@matt82198/aesop 0.1.0-beta.4 → 0.1.0-rc.1

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 (134) hide show
  1. package/CHANGELOG.md +187 -5
  2. package/LICENSE +66 -21
  3. package/README.md +143 -23
  4. package/aesop.config.example.json +14 -1
  5. package/bin/CLAUDE.md +42 -5
  6. package/bin/cli.js +479 -95
  7. package/daemons/CLAUDE.md +5 -2
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +211 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/RELEASING.md +159 -0
  19. package/docs/archive/README.md +3 -0
  20. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  21. package/docs/autonomous-swe.md +149 -0
  22. package/docs/case-study-portfolio.md +61 -0
  23. package/docs/reproduce.md +121 -0
  24. package/docs/self-stats-data.json +11 -0
  25. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  26. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  27. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  28. package/hooks/CLAUDE.md +51 -0
  29. package/hooks/install-waveguard.sh +68 -0
  30. package/hooks/pre-commit-waveguard.sh +26 -0
  31. package/hooks/pre-push-policy.sh +253 -15
  32. package/mcp/CLAUDE.md +213 -0
  33. package/mcp/package.json +26 -0
  34. package/mcp/server.mjs +543 -0
  35. package/monitor/CHARTER.md +76 -110
  36. package/monitor/CLAUDE.md +3 -3
  37. package/monitor/collect-signals.mjs +218 -20
  38. package/package.json +17 -6
  39. package/scan/CLAUDE.md +30 -0
  40. package/scan/fleet-scan.example.mjs +292 -0
  41. package/skills/CLAUDE.md +1 -0
  42. package/skills/healthcheck/SKILL.md +44 -0
  43. package/state_store/CLAUDE.md +39 -0
  44. package/state_store/__init__.py +24 -0
  45. package/state_store/api.py +35 -0
  46. package/state_store/export.py +19 -0
  47. package/state_store/ingest.py +41 -0
  48. package/state_store/projections.py +126 -0
  49. package/state_store/store.py +197 -0
  50. package/tools/CLAUDE.md +37 -149
  51. package/tools/alert_bridge.py +453 -0
  52. package/tools/bench_runner.py +438 -0
  53. package/tools/buildlog.py +4 -7
  54. package/tools/ci_merge_wait.py +308 -14
  55. package/tools/common.py +62 -0
  56. package/tools/cost_ceiling.py +191 -0
  57. package/tools/dash.js +102 -0
  58. package/tools/doctor.js +220 -0
  59. package/tools/fleet_ledger.py +189 -17
  60. package/tools/fleet_prompt_extractor.py +134 -0
  61. package/tools/halt.py +172 -0
  62. package/tools/healthcheck.py +299 -0
  63. package/tools/heartbeat.py +4 -7
  64. package/tools/metrics_gate.py +8 -2
  65. package/tools/orchestrator_status.py +4 -9
  66. package/tools/reconcile.py +277 -0
  67. package/tools/rotate_logs.py +152 -62
  68. package/tools/scanner_selftest.py +28 -3
  69. package/tools/secret_scan.py +367 -91
  70. package/tools/self_stats.py +797 -0
  71. package/tools/session_usage_summary.py +198 -0
  72. package/tools/status.js +187 -0
  73. package/tools/svg_to_png.mjs +50 -0
  74. package/tools/transcript_replay.py +236 -0
  75. package/tools/transcript_timeline.py +184 -0
  76. package/tools/verify_agent_inspector.py +289 -0
  77. package/tools/verify_dash.py +361 -542
  78. package/tools/verify_prboard.py +344 -0
  79. package/tools/verify_submit_encoding.py +12 -4
  80. package/tools/watch.js +49 -0
  81. package/ui/CLAUDE.md +59 -41
  82. package/ui/agents.py +337 -34
  83. package/ui/api/submit.py +44 -5
  84. package/ui/api/tracker.py +15 -7
  85. package/ui/collectors.py +294 -52
  86. package/ui/config.py +31 -2
  87. package/ui/cost.py +355 -0
  88. package/ui/csrf.py +9 -4
  89. package/ui/handler.py +450 -12
  90. package/ui/render.py +26 -6
  91. package/ui/sse.py +114 -13
  92. package/ui/wave_prs.py +230 -0
  93. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  94. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  95. package/ui/web/dist/index.html +14 -0
  96. package/monitor/.signal-state.json +0 -3
  97. package/monitor/ACTIONS.log +0 -1
  98. package/monitor/BRIEF.md +0 -24
  99. package/monitor/SIGNALS.json +0 -54
  100. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  104. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  105. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  106. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  107. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  108. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  109. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  110. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  111. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  112. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  113. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  114. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  115. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  116. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  117. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  118. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  119. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  120. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  121. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  122. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  123. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  124. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  125. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  126. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  127. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  128. package/ui/templates/dashboard.html +0 -1202
  129. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  130. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  131. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  132. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  133. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  134. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -0,0 +1,797 @@
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
+ python self_stats.py --regenerate [--repo PATH] [--data-file PATH] [--stats-file PATH]
11
+ python self_stats.py --update-readme [--repo PATH] [--stats-file PATH] [--readme PATH]
12
+ python self_stats.py --check [--repo PATH] [--stats-file PATH] [--readme PATH]
13
+
14
+ Output modes:
15
+ default - Human-readable table
16
+ --markdown - README block with <!-- SELF-STATS:START/END --> markers (markdown verification comments)
17
+ --json - Machine-readable JSON object
18
+
19
+ Special modes:
20
+ --regenerate - Regenerate stats.json from live git state
21
+ --update-readme - Update README.md between <!-- STATS:START/END --> markers with stats from stats.json
22
+ --check - Exit non-zero if README's marked block doesn't match current stats.json (drift gate)
23
+
24
+ All hard metrics (percentages, multipliers, dollar amounts) in markdown output include
25
+ <!-- metrics-verified: <source> --> markers for the metrics_gate.py CI gate.
26
+ """
27
+
28
+ import argparse
29
+ import json
30
+ import os
31
+ import re
32
+ import subprocess
33
+ import sys
34
+ from datetime import datetime, timezone
35
+ from pathlib import Path
36
+ from typing import Optional, Dict, Any
37
+
38
+
39
+ class GitStats:
40
+ """Compute statistics from git repository."""
41
+
42
+ def __init__(self, repo_root: str = "."):
43
+ """Initialize with repo root path."""
44
+ self.repo_root = Path(repo_root)
45
+ self._merged_prs = None
46
+ self._total_commits = None
47
+ self._project_age_days = None
48
+ self._wave_count = None
49
+ self._insertions_deletions = None
50
+ self._files_tracked = None
51
+ self._distinct_coauthors = None
52
+ self._lines_of_code = None
53
+
54
+ def _run_git(self, *args, check=True) -> str:
55
+ """Run git command in repo, return stdout."""
56
+ try:
57
+ result = subprocess.run(
58
+ ["git"] + list(args),
59
+ cwd=str(self.repo_root),
60
+ capture_output=True,
61
+ text=True,
62
+ encoding="utf-8",
63
+ errors="replace",
64
+ check=check,
65
+ )
66
+ return (result.stdout or "").strip()
67
+ except FileNotFoundError:
68
+ return ""
69
+
70
+ @property
71
+ def merged_prs(self) -> int:
72
+ """Count merge commits with 'Merge pull request #' in message."""
73
+ if self._merged_prs is not None:
74
+ return self._merged_prs
75
+
76
+ try:
77
+ # Get all commit messages
78
+ output = self._run_git("log", "--format=%B", check=False)
79
+ if not output:
80
+ self._merged_prs = 0
81
+ return 0
82
+
83
+ # Count lines matching "Merge pull request #"
84
+ count = output.count("Merge pull request #")
85
+ self._merged_prs = count
86
+ return count
87
+ except Exception:
88
+ self._merged_prs = 0
89
+ return 0
90
+
91
+ @property
92
+ def total_commits(self) -> int:
93
+ """Total commit count."""
94
+ if self._total_commits is not None:
95
+ return self._total_commits
96
+
97
+ try:
98
+ output = self._run_git("rev-list", "--count", "HEAD", check=False)
99
+ count = int(output) if output else 0
100
+ self._total_commits = count
101
+ return count
102
+ except (ValueError, Exception):
103
+ self._total_commits = 0
104
+ return 0
105
+
106
+ @property
107
+ def project_age_days(self) -> Optional[int]:
108
+ """Project age in days (first commit to now)."""
109
+ if self._project_age_days is not None:
110
+ return self._project_age_days
111
+
112
+ try:
113
+ # Get timestamp of the earliest commit (root) — --reverse lists
114
+ # oldest first, so the first line is the project's birth.
115
+ output = self._run_git(
116
+ "log", "--reverse", "--format=%cI", check=False
117
+ )
118
+ if not output:
119
+ self._project_age_days = None
120
+ return None
121
+
122
+ first_commit_iso = output.split("\n", 1)[0].strip()
123
+ if not first_commit_iso:
124
+ self._project_age_days = None
125
+ return None
126
+
127
+ # Parse ISO format timestamp
128
+ first_commit_dt = datetime.fromisoformat(first_commit_iso.replace("Z", "+00:00"))
129
+ now_dt = datetime.now(timezone.utc)
130
+ age_days = (now_dt - first_commit_dt).days
131
+
132
+ self._project_age_days = age_days
133
+ return age_days
134
+ except Exception:
135
+ self._project_age_days = None
136
+ return None
137
+
138
+ @property
139
+ def wave_count(self) -> int:
140
+ """Count of distinct waves (parse wave labels or release tags)."""
141
+ if self._wave_count is not None:
142
+ return self._wave_count
143
+
144
+ try:
145
+ # First try parsing wave labels from merge commit messages
146
+ output = self._run_git("log", "--format=%B", check=False)
147
+ if output:
148
+ # Count lines like "wave-N" (case insensitive)
149
+ waves = set()
150
+ for match in re.finditer(r"wave[_-]?(\d+)", output, re.IGNORECASE):
151
+ waves.add(int(match.group(1)))
152
+ if waves:
153
+ self._wave_count = len(waves)
154
+ return len(waves)
155
+
156
+ # Fallback: count release tags (v*)
157
+ tags = self._run_git("tag", "-l", "v*", check=False)
158
+ tag_count = len([t for t in tags.split("\n") if t.strip()])
159
+ self._wave_count = tag_count
160
+ return tag_count
161
+ except Exception:
162
+ self._wave_count = 0
163
+ return 0
164
+
165
+ @property
166
+ def insertions_deletions(self) -> int:
167
+ """Total insertions + deletions across all commits."""
168
+ if self._insertions_deletions is not None:
169
+ return self._insertions_deletions
170
+
171
+ try:
172
+ output = self._run_git(
173
+ "log", "--numstat", "--format=%H", check=False
174
+ )
175
+ if not output:
176
+ self._insertions_deletions = 0
177
+ return 0
178
+
179
+ total = 0
180
+ for line in output.split("\n"):
181
+ parts = line.split("\t")
182
+ if len(parts) >= 2:
183
+ try:
184
+ # Skip lines that are commit hashes or other non-numstat data
185
+ insertions = int(parts[0])
186
+ deletions = int(parts[1])
187
+ total += insertions + deletions
188
+ except ValueError:
189
+ continue
190
+
191
+ self._insertions_deletions = total
192
+ return total
193
+ except Exception:
194
+ self._insertions_deletions = 0
195
+ return 0
196
+
197
+ @property
198
+ def files_tracked(self) -> int:
199
+ """Count of tracked files."""
200
+ if self._files_tracked is not None:
201
+ return self._files_tracked
202
+
203
+ try:
204
+ output = self._run_git("ls-files", check=False)
205
+ count = len([f for f in output.split("\n") if f.strip()])
206
+ self._files_tracked = count
207
+ return count
208
+ except Exception:
209
+ self._files_tracked = 0
210
+ return 0
211
+
212
+ @property
213
+ def distinct_coauthors(self) -> int:
214
+ """Count of distinct authors including co-authors."""
215
+ if self._distinct_coauthors is not None:
216
+ return self._distinct_coauthors
217
+
218
+ try:
219
+ # Get all authors
220
+ output = self._run_git("log", "--format=%an", check=False)
221
+ authors = set()
222
+ if output:
223
+ for author in output.split("\n"):
224
+ if author.strip():
225
+ authors.add(author.strip())
226
+
227
+ # Get all co-authors from commit messages
228
+ commit_msg = self._run_git("log", "--format=%B", check=False)
229
+ if commit_msg:
230
+ for match in re.finditer(r"Co-Authored-By:\s*(.+?)(?:\n|$)", commit_msg):
231
+ coauthor = match.group(1).strip()
232
+ if coauthor:
233
+ authors.add(coauthor)
234
+
235
+ count = len(authors)
236
+ self._distinct_coauthors = count
237
+ return count
238
+ except Exception:
239
+ self._distinct_coauthors = 0
240
+ return 0
241
+
242
+ @property
243
+ def lines_of_code(self) -> int:
244
+ """Count total lines in tracked files."""
245
+ if self._lines_of_code is not None:
246
+ return self._lines_of_code
247
+
248
+ try:
249
+ # Get list of tracked files
250
+ output = self._run_git("ls-files", check=False)
251
+ if not output:
252
+ self._lines_of_code = 0
253
+ return 0
254
+
255
+ files = [f.strip() for f in output.split("\n") if f.strip()]
256
+ total_lines = 0
257
+
258
+ for file_path in files:
259
+ try:
260
+ file_full_path = self.repo_root / file_path
261
+ if file_full_path.is_file():
262
+ with open(file_full_path, 'r', encoding='utf-8', errors='ignore') as f:
263
+ total_lines += sum(1 for _ in f)
264
+ except Exception:
265
+ # Skip files we can't read
266
+ continue
267
+
268
+ self._lines_of_code = total_lines
269
+ return total_lines
270
+ except Exception:
271
+ self._lines_of_code = 0
272
+ return 0
273
+
274
+
275
+ class SessionTelemetry:
276
+ """Load session telemetry from JSON file."""
277
+
278
+ def __init__(self, data_file: str = "docs/self-stats-data.json"):
279
+ """Initialize with data file path."""
280
+ self.data_file = Path(data_file)
281
+ self._data = None
282
+ self._load()
283
+
284
+ def _load(self):
285
+ """Load JSON data, silently ignore missing/invalid files."""
286
+ if not self.data_file.exists():
287
+ self._data = {}
288
+ return
289
+
290
+ try:
291
+ with open(self.data_file) as f:
292
+ self._data = json.load(f)
293
+ except (json.JSONDecodeError, IOError):
294
+ self._data = {}
295
+
296
+ def _get(self, key: str) -> Optional[Any]:
297
+ """Get field, return None if missing or null."""
298
+ if not self._data:
299
+ return None
300
+ value = self._data.get(key)
301
+ return value if value is not None else None
302
+
303
+ @property
304
+ def total_sessions(self) -> Optional[int]:
305
+ return self._get("total_sessions")
306
+
307
+ @property
308
+ def total_turns(self) -> Optional[int]:
309
+ return self._get("total_turns")
310
+
311
+ @property
312
+ def total_user_prompts(self) -> Optional[int]:
313
+ return self._get("total_user_prompts")
314
+
315
+ @property
316
+ def max_tokens_single_turn(self) -> Optional[int]:
317
+ return self._get("max_tokens_single_turn")
318
+
319
+ @property
320
+ def cumulative_agent_runs(self) -> Optional[int]:
321
+ return self._get("cumulative_agent_runs")
322
+
323
+ @property
324
+ def cumulative_tokens(self) -> Optional[int]:
325
+ return self._get("cumulative_tokens")
326
+
327
+ @property
328
+ def total_coding_hours(self) -> Optional[float]:
329
+ return self._get("total_coding_hours")
330
+
331
+
332
+ class StatsCounter:
333
+ """Combine git and telemetry stats, format for output."""
334
+
335
+ def __init__(self, repo_root: str = ".", data_file: str = None):
336
+ """Initialize with repo root and optional data file."""
337
+ self.git = GitStats(repo_root=repo_root)
338
+ if data_file is None:
339
+ # Infer from repo root
340
+ data_file = str(Path(repo_root) / "docs" / "self-stats-data.json")
341
+ self.telemetry = SessionTelemetry(data_file=data_file)
342
+
343
+ def table(self) -> str:
344
+ """Human-readable table format."""
345
+ lines = []
346
+ lines.append("")
347
+ lines.append("=" * 50)
348
+ lines.append("Aesop Self-Building Stats")
349
+ lines.append("=" * 50)
350
+ lines.append("")
351
+
352
+ # Git-derived stats
353
+ lines.append("Repository Metrics:")
354
+ if self.git.merged_prs > 0:
355
+ lines.append(f" Merged PRs: {self.git.merged_prs}")
356
+ if self.git.total_commits > 0:
357
+ lines.append(f" Total Commits: {self.git.total_commits}")
358
+ if self.git.project_age_days is not None and self.git.project_age_days >= 0:
359
+ lines.append(f" Project Age (days): {self.git.project_age_days}")
360
+ if self.git.wave_count > 0:
361
+ lines.append(f" Wave Count: {self.git.wave_count}")
362
+ if self.git.insertions_deletions > 0:
363
+ lines.append(f" Insertions+Deletions: {self.git.insertions_deletions}")
364
+ if self.git.files_tracked > 0:
365
+ lines.append(f" Files Tracked: {self.git.files_tracked}")
366
+ if self.git.distinct_coauthors > 0:
367
+ lines.append(f" Distinct Co-authors: {self.git.distinct_coauthors}")
368
+ if self.git.lines_of_code > 0:
369
+ lines.append(f" Lines of Code: {self.git.lines_of_code}")
370
+
371
+ # Session telemetry (only if present)
372
+ if any([
373
+ self.telemetry.total_sessions,
374
+ self.telemetry.total_turns,
375
+ self.telemetry.cumulative_tokens,
376
+ ]):
377
+ lines.append("")
378
+ lines.append("Session Telemetry:")
379
+ if self.telemetry.total_sessions is not None:
380
+ lines.append(f" Total Sessions: {self.telemetry.total_sessions}")
381
+ if self.telemetry.total_turns is not None:
382
+ lines.append(f" Total Turns: {self.telemetry.total_turns}")
383
+ if self.telemetry.total_user_prompts is not None:
384
+ lines.append(f" User Prompts: {self.telemetry.total_user_prompts}")
385
+ if self.telemetry.cumulative_agent_runs is not None:
386
+ lines.append(f" Agent Runs: {self.telemetry.cumulative_agent_runs}")
387
+ if self.telemetry.cumulative_tokens is not None:
388
+ lines.append(f" Total Tokens: {self.telemetry.cumulative_tokens}")
389
+ if self.telemetry.max_tokens_single_turn is not None:
390
+ lines.append(f" Max Tokens/Turn: {self.telemetry.max_tokens_single_turn}")
391
+ if self.telemetry.total_coding_hours is not None:
392
+ lines.append(f" Coding Hours: {self.telemetry.total_coding_hours}")
393
+
394
+ lines.append("")
395
+ lines.append("=" * 50)
396
+ lines.append("")
397
+
398
+ return "\n".join(lines)
399
+
400
+ def markdown(self) -> str:
401
+ """Markdown output with verification markers for hard metrics."""
402
+ lines = []
403
+ lines.append("<!-- SELF-STATS:START -->")
404
+ lines.append("")
405
+ lines.append("## Aesop builds itself")
406
+ lines.append("")
407
+ lines.append(
408
+ "Aesop is built entirely by its own `/buildsystem` wave cycle—running parallel Haiku fleets "
409
+ "across ranked backlog items, verifying merges, auditing orchestration health. "
410
+ "These stats are the receipts: all numbers computed LIVE from git, verified by anyone who clones."
411
+ )
412
+ lines.append("")
413
+
414
+ # Build stat rows
415
+ rows = []
416
+
417
+ if self.git.merged_prs > 0:
418
+ rows.append(
419
+ f"| Merged PRs | {self.git.merged_prs} <!-- metrics-verified: self_stats.py (git log) --> |"
420
+ )
421
+ if self.git.total_commits > 0:
422
+ rows.append(
423
+ f"| Total Commits | {self.git.total_commits} <!-- metrics-verified: self_stats.py (git log) --> |"
424
+ )
425
+ if self.git.project_age_days is not None and self.git.project_age_days >= 0:
426
+ rows.append(
427
+ f"| Project Age | {self.git.project_age_days} days <!-- metrics-verified: self_stats.py (git log) --> |"
428
+ )
429
+ if self.git.wave_count > 0:
430
+ rows.append(
431
+ f"| Waves | {self.git.wave_count} <!-- metrics-verified: self_stats.py (git log) --> |"
432
+ )
433
+ if self.git.insertions_deletions > 0:
434
+ rows.append(
435
+ f"| Insertions + Deletions | {self.git.insertions_deletions:,} <!-- metrics-verified: self_stats.py (git log) --> |"
436
+ )
437
+ if self.git.files_tracked > 0:
438
+ rows.append(
439
+ f"| Files Tracked | {self.git.files_tracked} <!-- metrics-verified: self_stats.py (git log) --> |"
440
+ )
441
+ if self.git.distinct_coauthors > 0:
442
+ rows.append(
443
+ f"| Distinct Co-authors | {self.git.distinct_coauthors} <!-- metrics-verified: self_stats.py (git log) --> |"
444
+ )
445
+
446
+ # Session telemetry
447
+ if self.telemetry.total_sessions is not None:
448
+ rows.append(
449
+ f"| Sessions | {self.telemetry.total_sessions} <!-- metrics-verified: docs/self-stats-data.json --> |"
450
+ )
451
+ if self.telemetry.total_turns is not None:
452
+ rows.append(
453
+ f"| Total Turns | {self.telemetry.total_turns} <!-- metrics-verified: docs/self-stats-data.json --> |"
454
+ )
455
+ if self.telemetry.cumulative_tokens is not None:
456
+ rows.append(
457
+ f"| Cumulative Tokens | {self.telemetry.cumulative_tokens:,} <!-- metrics-verified: docs/self-stats-data.json --> |"
458
+ )
459
+ if self.telemetry.total_coding_hours is not None:
460
+ rows.append(
461
+ f"| Coding Hours | {self.telemetry.total_coding_hours} <!-- metrics-verified: docs/self-stats-data.json --> |"
462
+ )
463
+
464
+ # Only add table if we have rows
465
+ if rows:
466
+ lines.append("| Metric | Value |")
467
+ lines.append("| --- | --- |")
468
+ lines.extend(rows)
469
+ lines.append("")
470
+
471
+ lines.append("<!-- SELF-STATS:END -->")
472
+ lines.append("")
473
+
474
+ return "\n".join(lines)
475
+
476
+ def json(self) -> str:
477
+ """Machine-readable JSON output."""
478
+ data = {
479
+ "git": {
480
+ "merged_prs": self.git.merged_prs,
481
+ "total_commits": self.git.total_commits,
482
+ "project_age_days": self.git.project_age_days,
483
+ "wave_count": self.git.wave_count,
484
+ "insertions_deletions": self.git.insertions_deletions,
485
+ "files_tracked": self.git.files_tracked,
486
+ "distinct_coauthors": self.git.distinct_coauthors,
487
+ },
488
+ "telemetry": {
489
+ "total_sessions": self.telemetry.total_sessions,
490
+ "total_turns": self.telemetry.total_turns,
491
+ "total_user_prompts": self.telemetry.total_user_prompts,
492
+ "max_tokens_single_turn": self.telemetry.max_tokens_single_turn,
493
+ "cumulative_agent_runs": self.telemetry.cumulative_agent_runs,
494
+ "cumulative_tokens": self.telemetry.cumulative_tokens,
495
+ "total_coding_hours": self.telemetry.total_coding_hours,
496
+ },
497
+ }
498
+ return json.dumps(data, indent=2)
499
+
500
+ def to_dict_with_metadata(self) -> Dict[str, Any]:
501
+ """Export stats as dict with metadata (for stats.json)."""
502
+ data = json.loads(self.json())
503
+ data["generated_at"] = datetime.now(timezone.utc).isoformat()
504
+ data["loc"] = self.git.lines_of_code
505
+ return data
506
+
507
+ def save_stats(self, output_file: str = "stats.json") -> None:
508
+ """Regenerate stats.json from live git state."""
509
+ output_path = Path(output_file)
510
+ data = self.to_dict_with_metadata()
511
+
512
+ with open(output_path, 'w', encoding='utf-8') as f:
513
+ json.dump(data, f, indent=2)
514
+
515
+ def load_stats(self, stats_file: str = "stats.json") -> Optional[Dict[str, Any]]:
516
+ """Load previously saved stats from stats.json."""
517
+ stats_path = Path(stats_file)
518
+ if not stats_path.exists():
519
+ return None
520
+
521
+ try:
522
+ with open(stats_path, 'r', encoding='utf-8') as f:
523
+ return json.load(f)
524
+ except (json.JSONDecodeError, IOError):
525
+ return None
526
+
527
+ def markdown_from_dict(self, stats_dict: Dict[str, Any]) -> str:
528
+ """Generate markdown block from a stats dictionary (e.g., from stats.json)."""
529
+ lines = []
530
+ lines.append("<!-- STATS:START -->")
531
+ lines.append("")
532
+ lines.append("## Aesop builds itself")
533
+ lines.append("")
534
+ lines.append(
535
+ "Aesop is built entirely by its own `/buildsystem` wave cycle—running parallel Haiku fleets "
536
+ "across ranked backlog items, verifying merges, auditing orchestration health. "
537
+ "These stats are the receipts: all numbers computed LIVE from git, verified by anyone who clones."
538
+ )
539
+ lines.append("")
540
+
541
+ # Extract git stats from dict
542
+ git_stats = stats_dict.get("git", {})
543
+ rows = []
544
+
545
+ if git_stats.get("merged_prs", 0) > 0:
546
+ rows.append(
547
+ f"| Merged PRs | {git_stats['merged_prs']} <!-- metrics-verified: self_stats.py (git log) --> |"
548
+ )
549
+ if git_stats.get("total_commits", 0) > 0:
550
+ rows.append(
551
+ f"| Total Commits | {git_stats['total_commits']} <!-- metrics-verified: self_stats.py (git log) --> |"
552
+ )
553
+ if git_stats.get("project_age_days") is not None and git_stats.get("project_age_days", 0) >= 0:
554
+ rows.append(
555
+ f"| Project Age | {git_stats['project_age_days']} days <!-- metrics-verified: self_stats.py (git log) --> |"
556
+ )
557
+ if git_stats.get("wave_count", 0) > 0:
558
+ rows.append(
559
+ f"| Waves | {git_stats['wave_count']} <!-- metrics-verified: self_stats.py (git log) --> |"
560
+ )
561
+ if git_stats.get("insertions_deletions", 0) > 0:
562
+ rows.append(
563
+ f"| Insertions + Deletions | {git_stats['insertions_deletions']:,} <!-- metrics-verified: self_stats.py (git log) --> |"
564
+ )
565
+ if git_stats.get("files_tracked", 0) > 0:
566
+ rows.append(
567
+ f"| Files Tracked | {git_stats['files_tracked']} <!-- metrics-verified: self_stats.py (git log) --> |"
568
+ )
569
+ if git_stats.get("distinct_coauthors", 0) > 0:
570
+ rows.append(
571
+ f"| Distinct Co-authors | {git_stats['distinct_coauthors']} <!-- metrics-verified: self_stats.py (git log) --> |"
572
+ )
573
+
574
+ # Session telemetry
575
+ telemetry = stats_dict.get("telemetry", {})
576
+ if telemetry.get("total_sessions") is not None:
577
+ rows.append(
578
+ f"| Sessions | {telemetry['total_sessions']} <!-- metrics-verified: docs/self-stats-data.json --> |"
579
+ )
580
+ if telemetry.get("total_turns") is not None:
581
+ rows.append(
582
+ f"| Total Turns | {telemetry['total_turns']} <!-- metrics-verified: docs/self-stats-data.json --> |"
583
+ )
584
+ if telemetry.get("cumulative_tokens") is not None:
585
+ rows.append(
586
+ f"| Cumulative Tokens | {telemetry['cumulative_tokens']:,} <!-- metrics-verified: docs/self-stats-data.json --> |"
587
+ )
588
+ if telemetry.get("total_coding_hours") is not None:
589
+ rows.append(
590
+ f"| Coding Hours | {telemetry['total_coding_hours']} <!-- metrics-verified: docs/self-stats-data.json --> |"
591
+ )
592
+
593
+ # Only add table if we have rows
594
+ if rows:
595
+ lines.append("| Metric | Value |")
596
+ lines.append("| --- | --- |")
597
+ lines.extend(rows)
598
+ lines.append("")
599
+
600
+ lines.append("<!-- STATS:END -->")
601
+ lines.append("")
602
+
603
+ return "\n".join(lines)
604
+
605
+ def update_readme(self, readme_path: str = "README.md", stats_file: str = "stats.json") -> bool:
606
+ """Update README.md between <!-- STATS:START --> and <!-- STATS:END --> markers.
607
+
608
+ Returns True if updated, False if markers not found (gracefully no-op).
609
+ """
610
+ # Load stats from file
611
+ stats_dict = self.load_stats(stats_file)
612
+ if stats_dict is None:
613
+ # stats.json doesn't exist, regenerate it first
614
+ self.save_stats(stats_file)
615
+ stats_dict = self.load_stats(stats_file)
616
+
617
+ readme_file = Path(readme_path)
618
+ if not readme_file.exists():
619
+ return False
620
+
621
+ with open(readme_file, 'r', encoding='utf-8', errors='replace') as f:
622
+ content = f.read()
623
+
624
+ # Look for markers
625
+ start_marker = "<!-- STATS:START -->"
626
+ end_marker = "<!-- STATS:END -->"
627
+
628
+ if start_marker not in content or end_marker not in content:
629
+ # Markers not found, gracefully no-op
630
+ return False
631
+
632
+ # Generate new markdown block
633
+ new_block = self.markdown_from_dict(stats_dict)
634
+
635
+ # Replace the block
636
+ start_idx = content.find(start_marker)
637
+ end_idx = content.find(end_marker) + len(end_marker)
638
+
639
+ new_content = content[:start_idx] + new_block + content[end_idx:]
640
+
641
+ with open(readme_file, 'w', encoding='utf-8') as f:
642
+ f.write(new_content)
643
+
644
+ return True
645
+
646
+ def check_readme(self, readme_path: str = "README.md", stats_file: str = "stats.json") -> bool:
647
+ """Check if README's marked block matches current stats.json.
648
+
649
+ Returns True if they match, False if they don't (or markers not found).
650
+ Exit code usage: sys.exit(0) if True, sys.exit(1) if False.
651
+ """
652
+ # Load stats from file
653
+ stats_dict = self.load_stats(stats_file)
654
+ if stats_dict is None:
655
+ # stats.json doesn't exist, regenerate it
656
+ self.save_stats(stats_file)
657
+ stats_dict = self.load_stats(stats_file)
658
+ if stats_dict is None:
659
+ return False
660
+
661
+ readme_file = Path(readme_path)
662
+ if not readme_file.exists():
663
+ return False
664
+
665
+ with open(readme_file, 'r', encoding='utf-8', errors='replace') as f:
666
+ content = f.read()
667
+
668
+ # Look for markers
669
+ start_marker = "<!-- STATS:START -->"
670
+ end_marker = "<!-- STATS:END -->"
671
+
672
+ if start_marker not in content or end_marker not in content:
673
+ # Markers not found, treat as no-op (return True since nothing to check)
674
+ return True
675
+
676
+ # Extract current block
677
+ start_idx = content.find(start_marker)
678
+ end_idx = content.find(end_marker) + len(end_marker)
679
+ current_block = content[start_idx:end_idx]
680
+
681
+ # Generate expected block
682
+ expected_block = self.markdown_from_dict(stats_dict)
683
+
684
+ # Compare
685
+ return current_block.strip() == expected_block.strip()
686
+
687
+
688
+ def main():
689
+ """CLI entry point."""
690
+ parser = argparse.ArgumentParser(
691
+ description=__doc__,
692
+ formatter_class=argparse.RawDescriptionHelpFormatter,
693
+ )
694
+ parser.add_argument(
695
+ "--repo",
696
+ default=".",
697
+ help="Repository root (default: current directory)"
698
+ )
699
+ parser.add_argument(
700
+ "--data-file",
701
+ help="Path to docs/self-stats-data.json (auto-detected if not specified)"
702
+ )
703
+ parser.add_argument(
704
+ "--stats-file",
705
+ default="stats.json",
706
+ help="Path to stats.json (default: stats.json in repo root)"
707
+ )
708
+ parser.add_argument(
709
+ "--readme",
710
+ default="README.md",
711
+ help="Path to README.md (default: README.md in repo root)"
712
+ )
713
+
714
+ mode_group = parser.add_mutually_exclusive_group()
715
+ mode_group.add_argument(
716
+ "--markdown",
717
+ action="store_true",
718
+ help="Output markdown block with START/END markers"
719
+ )
720
+ mode_group.add_argument(
721
+ "--json",
722
+ action="store_true",
723
+ help="Output machine-readable JSON"
724
+ )
725
+ mode_group.add_argument(
726
+ "--regenerate",
727
+ action="store_true",
728
+ help="Regenerate stats.json from live git state"
729
+ )
730
+ mode_group.add_argument(
731
+ "--update-readme",
732
+ action="store_true",
733
+ help="Update README.md between <!-- STATS:START/END --> markers with stats from stats.json"
734
+ )
735
+ mode_group.add_argument(
736
+ "--check",
737
+ action="store_true",
738
+ help="Check if README's marked block matches current stats.json (exit 0=match, 1=mismatch)"
739
+ )
740
+
741
+ args = parser.parse_args()
742
+
743
+ counter = StatsCounter(repo_root=args.repo, data_file=args.data_file)
744
+
745
+ # Use UTF-8 for output to handle emojis
746
+ import io
747
+ if hasattr(sys.stdout, 'buffer'):
748
+ out = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
749
+ else:
750
+ out = sys.stdout
751
+
752
+ if args.regenerate:
753
+ # Resolve paths relative to repo root
754
+ stats_file = Path(args.repo) / args.stats_file if not Path(args.stats_file).is_absolute() else args.stats_file
755
+ counter.save_stats(str(stats_file))
756
+ out.write(f"Regenerated {stats_file}\n")
757
+ out.flush()
758
+ return 0
759
+
760
+ if args.update_readme:
761
+ # Resolve paths relative to repo root
762
+ readme_path = Path(args.repo) / args.readme if not Path(args.readme).is_absolute() else args.readme
763
+ stats_file = Path(args.repo) / args.stats_file if not Path(args.stats_file).is_absolute() else args.stats_file
764
+
765
+ if counter.update_readme(str(readme_path), str(stats_file)):
766
+ out.write(f"Updated {readme_path}\n")
767
+ else:
768
+ out.write(f"No markers found in {readme_path} or markers not recognized (gracefully skipped)\n")
769
+ out.flush()
770
+ return 0
771
+
772
+ if args.check:
773
+ # Resolve paths relative to repo root
774
+ readme_path = Path(args.repo) / args.readme if not Path(args.readme).is_absolute() else args.readme
775
+ stats_file = Path(args.repo) / args.stats_file if not Path(args.stats_file).is_absolute() else args.stats_file
776
+
777
+ if counter.check_readme(str(readme_path), str(stats_file)):
778
+ out.write(f"OK: {readme_path} matches {stats_file}\n")
779
+ out.flush()
780
+ sys.exit(0)
781
+ else:
782
+ out.write(f"DRIFT: {readme_path} does not match {stats_file}\n")
783
+ out.flush()
784
+ sys.exit(1)
785
+
786
+ if args.markdown:
787
+ out.write(counter.markdown())
788
+ elif args.json:
789
+ out.write(counter.json())
790
+ else:
791
+ out.write(counter.table())
792
+ out.flush()
793
+ return 0
794
+
795
+
796
+ if __name__ == "__main__":
797
+ sys.exit(main() or 0)