@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.
- package/CHANGELOG.md +158 -5
- package/LICENSE +66 -21
- package/README.md +97 -33
- package/aesop.config.example.json +6 -0
- package/bin/CLAUDE.md +12 -3
- package/bin/cli.js +211 -40
- package/daemons/CLAUDE.md +1 -1
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +208 -62
- package/dash/dash-extra.mjs +73 -4
- package/dash/watchdog-gui.sh +41 -35
- package/docs/ARCHITECTURE.md +296 -0
- package/docs/CONCEPTS.md +254 -0
- package/docs/CONFIGURE.md +256 -0
- package/docs/FIRST-WAVE.md +276 -0
- package/docs/INSTALL.md +224 -0
- package/docs/README.md +176 -27
- package/docs/autonomous-swe.md +149 -0
- package/docs/reproduce.md +121 -0
- package/hooks/CLAUDE.md +28 -0
- package/hooks/install-waveguard.sh +68 -0
- package/hooks/pre-commit-waveguard.sh +26 -0
- package/hooks/pre-push-policy.sh +253 -15
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +133 -20
- package/package.json +15 -7
- package/scan/CLAUDE.md +30 -0
- package/skills/CLAUDE.md +1 -0
- package/state_store/ingest.py +18 -1
- package/state_store/projections.py +78 -4
- package/state_store/store.py +102 -7
- package/tools/CLAUDE.md +25 -18
- package/tools/alert_bridge.py +14 -10
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +215 -34
- package/tools/common.py +62 -0
- package/tools/cost_ceiling.py +191 -0
- package/tools/dash.js +102 -0
- package/tools/doctor.js +220 -0
- package/tools/fleet_ledger.py +189 -17
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +24 -21
- package/tools/heartbeat.py +4 -7
- package/tools/metrics_gate.py +8 -2
- package/tools/orchestrator_status.py +4 -9
- package/tools/reconcile.py +277 -0
- package/tools/rotate_logs.py +152 -62
- package/tools/scanner_selftest.py +28 -3
- package/tools/secret_scan.py +359 -90
- package/tools/self_stats.py +292 -4
- package/tools/status.js +187 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_prboard.py +344 -0
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +2 -0
- package/ui/agents.py +332 -33
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +125 -32
- package/ui/config.py +22 -2
- package/ui/cost.py +98 -3
- package/ui/csrf.py +2 -1
- package/ui/handler.py +209 -21
- package/ui/sse.py +102 -16
- package/ui/wave_prs.py +230 -0
- package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
- package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/monitor/.signal-state.json +0 -3
- package/monitor/ACTIONS.log +0 -1
- package/monitor/BRIEF.md +0 -24
- package/monitor/SIGNALS.json +0 -54
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/web/.gitattributes +0 -13
- package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
- package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
- package/ui/web/index.html +0 -13
- package/ui/web/package-lock.json +0 -2225
- package/ui/web/package.json +0 -26
- package/ui/web/src/App.test.tsx +0 -74
- package/ui/web/src/App.tsx +0 -142
- package/ui/web/src/CONTRIBUTING-UI.md +0 -49
- package/ui/web/src/components/AgentRow.css +0 -187
- package/ui/web/src/components/AgentRow.test.tsx +0 -209
- package/ui/web/src/components/AgentRow.tsx +0 -207
- package/ui/web/src/components/AgentsPanel.css +0 -108
- package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
- package/ui/web/src/components/AgentsPanel.tsx +0 -58
- package/ui/web/src/components/AlertsPanel.css +0 -88
- package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
- package/ui/web/src/components/AlertsPanel.tsx +0 -67
- package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
- package/ui/web/src/components/BacklogPanel.tsx +0 -122
- package/ui/web/src/components/CostChart.css +0 -110
- package/ui/web/src/components/CostChart.test.tsx +0 -144
- package/ui/web/src/components/CostChart.tsx +0 -152
- package/ui/web/src/components/CostTable.css +0 -93
- package/ui/web/src/components/CostTable.test.tsx +0 -165
- package/ui/web/src/components/CostTable.tsx +0 -94
- package/ui/web/src/components/EventsFeed.css +0 -68
- package/ui/web/src/components/EventsFeed.test.tsx +0 -36
- package/ui/web/src/components/EventsFeed.tsx +0 -31
- package/ui/web/src/components/HealthHeader.css +0 -137
- package/ui/web/src/components/HealthHeader.test.tsx +0 -278
- package/ui/web/src/components/HealthHeader.tsx +0 -281
- package/ui/web/src/components/InboxForm.css +0 -135
- package/ui/web/src/components/InboxForm.test.tsx +0 -208
- package/ui/web/src/components/InboxForm.tsx +0 -116
- package/ui/web/src/components/MessagesTail.module.css +0 -144
- package/ui/web/src/components/MessagesTail.test.tsx +0 -176
- package/ui/web/src/components/MessagesTail.tsx +0 -94
- package/ui/web/src/components/ReposPanel.css +0 -90
- package/ui/web/src/components/ReposPanel.test.tsx +0 -45
- package/ui/web/src/components/ReposPanel.tsx +0 -67
- package/ui/web/src/components/Scorecard.css +0 -106
- package/ui/web/src/components/Scorecard.test.tsx +0 -117
- package/ui/web/src/components/Scorecard.tsx +0 -85
- package/ui/web/src/components/Timeline.module.css +0 -151
- package/ui/web/src/components/Timeline.test.tsx +0 -215
- package/ui/web/src/components/Timeline.tsx +0 -99
- package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
- package/ui/web/src/components/TrackerBoard.tsx +0 -107
- package/ui/web/src/components/TrackerCard.test.tsx +0 -180
- package/ui/web/src/components/TrackerCard.tsx +0 -160
- package/ui/web/src/components/TrackerForm.test.tsx +0 -189
- package/ui/web/src/components/TrackerForm.tsx +0 -144
- package/ui/web/src/lib/api.ts +0 -218
- package/ui/web/src/lib/format.test.ts +0 -89
- package/ui/web/src/lib/format.ts +0 -103
- package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
- package/ui/web/src/lib/sanitizeUrl.ts +0 -38
- package/ui/web/src/lib/types.ts +0 -230
- package/ui/web/src/lib/useHashRoute.test.ts +0 -60
- package/ui/web/src/lib/useHashRoute.ts +0 -23
- package/ui/web/src/lib/useSSE.ts +0 -175
- package/ui/web/src/main.tsx +0 -10
- package/ui/web/src/styles/global.css +0 -179
- package/ui/web/src/styles/theme.css +0 -184
- package/ui/web/src/styles/work.css +0 -572
- package/ui/web/src/test/fixtures.ts +0 -385
- package/ui/web/src/test/setup.ts +0 -49
- package/ui/web/src/views/Activity.module.css +0 -43
- package/ui/web/src/views/Activity.test.tsx +0 -89
- package/ui/web/src/views/Activity.tsx +0 -31
- package/ui/web/src/views/Cost.css +0 -87
- package/ui/web/src/views/Cost.test.tsx +0 -142
- package/ui/web/src/views/Cost.tsx +0 -54
- package/ui/web/src/views/Overview.css +0 -51
- package/ui/web/src/views/Overview.test.tsx +0 -76
- package/ui/web/src/views/Overview.tsx +0 -46
- package/ui/web/src/views/Work.test.tsx +0 -82
- package/ui/web/src/views/Work.tsx +0 -79
- package/ui/web/src/vite-env.d.ts +0 -10
- package/ui/web/tsconfig.json +0 -22
- package/ui/web/vite.config.ts +0 -25
- package/ui/web/vitest.config.ts +0 -12
package/tools/self_stats.py
CHANGED
|
@@ -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)
|
package/tools/status.js
ADDED
|
@@ -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
|
+
})();
|