@matt82198/aesop 0.1.0-beta.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -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/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -7,12 +7,20 @@ from docs/self-stats-data.json. All hard metrics in output carry verification ma
7
7
 
8
8
  Usage:
9
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]
10
13
 
11
14
  Output modes:
12
15
  default - Human-readable table
13
16
  --markdown - README block with <!-- SELF-STATS:START/END --> markers (markdown verification comments)
14
17
  --json - Machine-readable JSON object
15
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
+
16
24
  All hard metrics (percentages, multipliers, dollar amounts) in markdown output include
17
25
  <!-- metrics-verified: <source> --> markers for the metrics_gate.py CI gate.
18
26
  """
@@ -20,6 +28,7 @@ All hard metrics (percentages, multipliers, dollar amounts) in markdown output i
20
28
  import argparse
21
29
  import json
22
30
  import os
31
+ import re
23
32
  import subprocess
24
33
  import sys
25
34
  from datetime import datetime, timezone
@@ -40,6 +49,7 @@ class GitStats:
40
49
  self._insertions_deletions = None
41
50
  self._files_tracked = None
42
51
  self._distinct_coauthors = None
52
+ self._lines_of_code = None
43
53
 
44
54
  def _run_git(self, *args, check=True) -> str:
45
55
  """Run git command in repo, return stdout."""
@@ -136,7 +146,6 @@ class GitStats:
136
146
  output = self._run_git("log", "--format=%B", check=False)
137
147
  if output:
138
148
  # Count lines like "wave-N" (case insensitive)
139
- import re
140
149
  waves = set()
141
150
  for match in re.finditer(r"wave[_-]?(\d+)", output, re.IGNORECASE):
142
151
  waves.add(int(match.group(1)))
@@ -218,7 +227,6 @@ class GitStats:
218
227
  # Get all co-authors from commit messages
219
228
  commit_msg = self._run_git("log", "--format=%B", check=False)
220
229
  if commit_msg:
221
- import re
222
230
  for match in re.finditer(r"Co-Authored-By:\s*(.+?)(?:\n|$)", commit_msg):
223
231
  coauthor = match.group(1).strip()
224
232
  if coauthor:
@@ -231,6 +239,38 @@ class GitStats:
231
239
  self._distinct_coauthors = 0
232
240
  return 0
233
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
+
234
274
 
235
275
  class SessionTelemetry:
236
276
  """Load session telemetry from JSON file."""
@@ -325,6 +365,8 @@ class StatsCounter:
325
365
  lines.append(f" Files Tracked: {self.git.files_tracked}")
326
366
  if self.git.distinct_coauthors > 0:
327
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}")
328
370
 
329
371
  # Session telemetry (only if present)
330
372
  if any([
@@ -455,6 +497,193 @@ class StatsCounter:
455
497
  }
456
498
  return json.dumps(data, indent=2)
457
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
+
458
687
 
459
688
  def main():
460
689
  """CLI entry point."""
@@ -471,6 +700,16 @@ def main():
471
700
  "--data-file",
472
701
  help="Path to docs/self-stats-data.json (auto-detected if not specified)"
473
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
+ )
474
713
 
475
714
  mode_group = parser.add_mutually_exclusive_group()
476
715
  mode_group.add_argument(
@@ -483,6 +722,21 @@ def main():
483
722
  action="store_true",
484
723
  help="Output machine-readable JSON"
485
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
+ )
486
740
 
487
741
  args = parser.parse_args()
488
742
 
@@ -490,12 +744,45 @@ def main():
490
744
 
491
745
  # Use UTF-8 for output to handle emojis
492
746
  import io
493
- import sys
494
747
  if hasattr(sys.stdout, 'buffer'):
495
748
  out = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8', errors='replace')
496
749
  else:
497
750
  out = sys.stdout
498
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
+
499
786
  if args.markdown:
500
787
  out.write(counter.markdown())
501
788
  elif args.json:
@@ -503,7 +790,8 @@ def main():
503
790
  else:
504
791
  out.write(counter.table())
505
792
  out.flush()
793
+ return 0
506
794
 
507
795
 
508
796
  if __name__ == "__main__":
509
- main()
797
+ sys.exit(main() or 0)
@@ -0,0 +1,187 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Aesop status — One-shot fleet status snapshot
5
+ *
6
+ * Prints:
7
+ * - Watchdog heartbeat age (seconds) + STALE if >300s
8
+ * - Monitor heartbeat age (seconds) + STALE if >300s
9
+ * - Dashboard port reachability (default 8770)
10
+ * - Git branch and working tree state (clean/dirty)
11
+ *
12
+ * Exits immediately after printing status.
13
+ */
14
+
15
+ const fs = require('fs');
16
+ const path = require('path');
17
+ const net = require('net');
18
+ const { execSync } = require('child_process');
19
+
20
+ const CURRENT_DIR = process.cwd();
21
+ const HEARTBEAT_STALE_THRESHOLD = 300; // seconds
22
+
23
+ // ANSI color helpers
24
+ const COLORS = {
25
+ GREEN: '\x1b[32m',
26
+ RED: '\x1b[31m',
27
+ YELLOW: '\x1b[33m',
28
+ RESET: '\x1b[0m',
29
+ BOLD: '\x1b[1m'
30
+ };
31
+
32
+ function readHeartbeat(filePath) {
33
+ try {
34
+ if (!fs.existsSync(filePath)) {
35
+ return null;
36
+ }
37
+ const content = fs.readFileSync(filePath, 'utf8').trim();
38
+ const timestamp = parseInt(content, 10);
39
+ if (isNaN(timestamp)) {
40
+ return null;
41
+ }
42
+ return timestamp;
43
+ } catch (e) {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ function getHeartbeatStatus(timestamp) {
49
+ if (timestamp === null) {
50
+ return { age: null, status: 'MISSING', color: COLORS.RED };
51
+ }
52
+ const now = Math.floor(Date.now() / 1000);
53
+ const age = now - timestamp;
54
+ const isStale = age > HEARTBEAT_STALE_THRESHOLD;
55
+ const status = isStale ? 'STALE' : 'OK';
56
+ const color = isStale ? COLORS.YELLOW : COLORS.GREEN;
57
+ return { age, status, color };
58
+ }
59
+
60
+ function checkPortReachable(port) {
61
+ return new Promise((resolve) => {
62
+ let resolved = false;
63
+ const sock = net.createConnection(
64
+ { port, host: '127.0.0.1', timeout: 500 }
65
+ );
66
+
67
+ const cleanup = () => {
68
+ try {
69
+ sock.destroy();
70
+ } catch (e) {
71
+ // Ignore cleanup errors
72
+ }
73
+ };
74
+
75
+ sock.on('connect', () => {
76
+ if (!resolved) {
77
+ resolved = true;
78
+ cleanup();
79
+ resolve({ reachable: true, color: COLORS.GREEN, status: 'OK' });
80
+ }
81
+ });
82
+
83
+ sock.on('error', () => {
84
+ if (!resolved) {
85
+ resolved = true;
86
+ cleanup();
87
+ resolve({ reachable: false, color: COLORS.RED, status: 'NOT REACHABLE' });
88
+ }
89
+ });
90
+
91
+ sock.on('timeout', () => {
92
+ if (!resolved) {
93
+ resolved = true;
94
+ cleanup();
95
+ resolve({ reachable: false, color: COLORS.YELLOW, status: 'TIMEOUT' });
96
+ }
97
+ });
98
+
99
+ // Fallback timeout
100
+ setTimeout(() => {
101
+ if (!resolved) {
102
+ resolved = true;
103
+ cleanup();
104
+ resolve({ reachable: false, color: COLORS.YELLOW, status: 'TIMEOUT' });
105
+ }
106
+ }, 2000);
107
+ });
108
+ }
109
+
110
+ function getGitStatus() {
111
+ try {
112
+ // Get current branch
113
+ const branch = execSync('git rev-parse --abbrev-ref HEAD', {
114
+ cwd: CURRENT_DIR,
115
+ encoding: 'utf8',
116
+ stdio: 'pipe'
117
+ }).trim();
118
+
119
+ // Check if working tree is clean
120
+ const status = execSync('git status --porcelain', {
121
+ cwd: CURRENT_DIR,
122
+ encoding: 'utf8',
123
+ stdio: 'pipe'
124
+ });
125
+
126
+ const isClean = status.length === 0;
127
+ const state = isClean ? 'clean' : 'dirty';
128
+ const color = isClean ? COLORS.GREEN : COLORS.YELLOW;
129
+
130
+ return { branch, state, color };
131
+ } catch (e) {
132
+ return { branch: 'unknown', state: 'unknown', color: COLORS.RED };
133
+ }
134
+ }
135
+
136
+ function formatStatusLine(label, value, color = COLORS.RESET) {
137
+ const paddedLabel = label.padEnd(30);
138
+ return ` ${paddedLabel} ${color}${value}${COLORS.RESET}`;
139
+ }
140
+
141
+ (async function main() {
142
+ try {
143
+ console.log(`\n${COLORS.BOLD}Aesop Fleet Status${COLORS.RESET}\n`);
144
+
145
+ // Watchdog heartbeat
146
+ const watchdogPath = path.join(CURRENT_DIR, 'state', '.watchdog-heartbeat');
147
+ const watchdogTimestamp = readHeartbeat(watchdogPath);
148
+ const watchdogStatus = getHeartbeatStatus(watchdogTimestamp);
149
+ const watchdogLine = watchdogStatus.age !== null
150
+ ? `${watchdogStatus.age}s (${watchdogStatus.status})`
151
+ : watchdogStatus.status;
152
+ console.log(
153
+ formatStatusLine('Watchdog heartbeat', watchdogLine, watchdogStatus.color)
154
+ );
155
+
156
+ // Monitor heartbeat
157
+ const monitorPath = path.join(CURRENT_DIR, 'state', 'monitor', '.monitor-heartbeat');
158
+ const monitorTimestamp = readHeartbeat(monitorPath);
159
+ const monitorStatus = getHeartbeatStatus(monitorTimestamp);
160
+ const monitorLine = monitorStatus.age !== null
161
+ ? `${monitorStatus.age}s (${monitorStatus.status})`
162
+ : monitorStatus.status;
163
+ console.log(
164
+ formatStatusLine('Monitor heartbeat', monitorLine, monitorStatus.color)
165
+ );
166
+
167
+ // Dashboard port reachability
168
+ const portResult = await checkPortReachable(8770);
169
+ const dashboardLine = `localhost:8770 (${portResult.status})`;
170
+ console.log(
171
+ formatStatusLine('Dashboard port', dashboardLine, portResult.color)
172
+ );
173
+
174
+ // Git status
175
+ const gitStatus = getGitStatus();
176
+ const gitBranchLine = `${gitStatus.branch} (${gitStatus.state})`;
177
+ console.log(
178
+ formatStatusLine('Git branch', gitBranchLine, gitStatus.color)
179
+ );
180
+
181
+ console.log(`\n`);
182
+ process.exitCode = 0;
183
+ } catch (err) {
184
+ console.error(`Error gathering status: ${err.message}`);
185
+ process.exitCode = 1;
186
+ }
187
+ })();