@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.
- package/CHANGELOG.md +187 -5
- package/LICENSE +66 -21
- package/README.md +143 -23
- package/aesop.config.example.json +14 -1
- package/bin/CLAUDE.md +42 -5
- package/bin/cli.js +479 -95
- package/daemons/CLAUDE.md +5 -2
- package/daemons/backup-fleet.sh +209 -51
- package/daemons/run-watchdog.sh +211 -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/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/autonomous-swe.md +149 -0
- package/docs/case-study-portfolio.md +61 -0
- package/docs/reproduce.md +121 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +51 -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/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/CLAUDE.md +3 -3
- package/monitor/collect-signals.mjs +218 -20
- package/package.json +17 -6
- package/scan/CLAUDE.md +30 -0
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/CLAUDE.md +1 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +41 -0
- package/state_store/projections.py +126 -0
- package/state_store/store.py +197 -0
- package/tools/CLAUDE.md +37 -149
- package/tools/alert_bridge.py +453 -0
- package/tools/bench_runner.py +438 -0
- package/tools/buildlog.py +4 -7
- package/tools/ci_merge_wait.py +308 -14
- 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/fleet_prompt_extractor.py +134 -0
- package/tools/halt.py +172 -0
- package/tools/healthcheck.py +299 -0
- 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 +367 -91
- package/tools/self_stats.py +797 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/status.js +187 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_agent_inspector.py +289 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_prboard.py +344 -0
- package/tools/verify_submit_encoding.py +12 -4
- package/tools/watch.js +49 -0
- package/ui/CLAUDE.md +59 -41
- package/ui/agents.py +337 -34
- package/ui/api/submit.py +44 -5
- package/ui/api/tracker.py +15 -7
- package/ui/collectors.py +294 -52
- package/ui/config.py +31 -2
- package/ui/cost.py +355 -0
- package/ui/csrf.py +9 -4
- package/ui/handler.py +450 -12
- package/ui/render.py +26 -6
- package/ui/sse.py +114 -13
- 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 +14 -0
- 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/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__/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__/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__/stall_check.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__/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/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /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)
|