@matt82198/aesop 0.1.0 → 0.3.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -0,0 +1,394 @@
1
+ #!/usr/bin/env python3
2
+ """Cross-OS drift measurement tool.
3
+
4
+ Quantifies Windows-vs-Linux CI outcome drift from GitHub Actions history via gh CLI.
5
+
6
+ For the last N completed ci.yml runs on main, collects per-job conclusions (ubuntu ci shards vs
7
+ windows job) and reports:
8
+ - Windows pass rate vs ubuntu pass rate
9
+ - Set of runs where they diverged (ubuntu green + windows red)
10
+ - Failing windows test names aggregated by frequency (from gh run view --job <id> --log-failed)
11
+
12
+ Usage:
13
+ python crossos_drift.py [--runs N=10] [--json]
14
+
15
+ Exit codes:
16
+ 0 - Success
17
+ 1 - Error (e.g., parsing failure, logic error)
18
+ 2 - Execution error (e.g., gh call failed)
19
+ 3 - Authentication error (gh is not authenticated)
20
+
21
+ Honesty:
22
+ - If windows job doesn't exist (pre-#317), counts as NOT-PRESENT in per-run analysis
23
+ - If gh is unauthenticated, exits 3 with a clear message
24
+ - Parsing bounds test names; full logs never dumped
25
+ """
26
+ import sys
27
+ import subprocess
28
+ import json
29
+ import argparse
30
+ from collections import defaultdict
31
+ from typing import Dict, List, Tuple, Optional
32
+
33
+ DEFAULT_RUNS = 10
34
+
35
+ # GitHub Actions workflow context
36
+ WORKFLOW_FILE = "ci.yml"
37
+ GH_REPO = None # Auto-detect current repo
38
+
39
+
40
+ class GhError(Exception):
41
+ """gh CLI error."""
42
+ pass
43
+
44
+
45
+ def gh_run_list(limit: int = 10, branch: str = "main") -> List[Dict]:
46
+ """Fetch the last N completed workflow runs for ci.yml on main.
47
+
48
+ Returns list of run objects with: databaseId, number, name, status, conclusion, createdAt.
49
+ Raises GhError if gh is not authenticated.
50
+ """
51
+ try:
52
+ cmd = [
53
+ "gh", "run", "list",
54
+ "--workflow", WORKFLOW_FILE,
55
+ "--branch", branch,
56
+ "--limit", str(limit),
57
+ "--json", "databaseId,number,name,status,conclusion,createdAt",
58
+ ]
59
+ result = subprocess.run(
60
+ cmd,
61
+ capture_output=True,
62
+ text=True,
63
+ timeout=30,
64
+ )
65
+
66
+ if result.returncode != 0:
67
+ stderr = result.stderr.lower()
68
+ if "authentication" in stderr or "not authenticated" in stderr:
69
+ raise GhError("gh is not authenticated")
70
+ raise GhError(f"gh run list failed: {result.stderr}")
71
+
72
+ return json.loads(result.stdout)
73
+ except subprocess.TimeoutExpired:
74
+ raise GhError("gh run list timed out")
75
+ except json.JSONDecodeError as e:
76
+ raise GhError(f"Failed to parse gh response: {e}")
77
+
78
+
79
+ def gh_jobs_for_run(run_id: str) -> List[Dict]:
80
+ """Fetch all jobs for a given run.
81
+
82
+ Returns list of job objects with: id, name, status, conclusion.
83
+ """
84
+ try:
85
+ cmd = [
86
+ "gh", "run", "view", str(run_id),
87
+ "--json", "jobs",
88
+ ]
89
+ result = subprocess.run(
90
+ cmd,
91
+ capture_output=True,
92
+ text=True,
93
+ timeout=30,
94
+ )
95
+
96
+ if result.returncode != 0:
97
+ raise GhError(f"gh run view failed: {result.stderr}")
98
+
99
+ data = json.loads(result.stdout)
100
+ return data.get("jobs", [])
101
+ except subprocess.TimeoutExpired:
102
+ raise GhError(f"gh run view timed out for run {run_id}")
103
+ except json.JSONDecodeError as e:
104
+ raise GhError(f"Failed to parse gh jobs response: {e}")
105
+
106
+
107
+ def gh_job_logs(job_id: str) -> str:
108
+ """Fetch logs for a job. Return stdout only (not stderr).
109
+
110
+ Returns the full log text as a string.
111
+ """
112
+ try:
113
+ cmd = [
114
+ "gh", "run", "view", "--job", str(job_id), "--log",
115
+ ]
116
+ result = subprocess.run(
117
+ cmd,
118
+ capture_output=True,
119
+ text=True,
120
+ timeout=60,
121
+ )
122
+
123
+ if result.returncode != 0:
124
+ # Log fetch failure is not fatal; continue with divergence analysis
125
+ return ""
126
+
127
+ return result.stdout
128
+ except subprocess.TimeoutExpired:
129
+ return ""
130
+
131
+
132
+ def parse_failing_tests(log_text: str) -> Dict[str, int]:
133
+ """Parse failing test names from job log output.
134
+
135
+ Searches for lines matching "FAIL:" or "not ok" patterns.
136
+ Returns dict of {test_name: frequency} sorted by frequency descending.
137
+ """
138
+ failures = defaultdict(int)
139
+
140
+ for line in log_text.split("\n"):
141
+ line = line.strip()
142
+ test_name = None
143
+
144
+ if line.startswith("FAIL:"):
145
+ test_name = line.replace("FAIL:", "", 1).strip()
146
+ elif line.startswith("not ok"):
147
+ # TAP format: "not ok N - test_name" or "not ok test_name"
148
+ test_name = line.replace("not ok", "", 1).strip()
149
+ # Remove test number prefix if present
150
+ if test_name and test_name[0].isdigit():
151
+ test_name = " ".join(test_name.split()[1:])
152
+
153
+ if test_name:
154
+ failures[test_name] += 1
155
+
156
+ return dict(sorted(failures.items(), key=lambda x: x[1], reverse=True))
157
+
158
+
159
+ def classify_job(job: Dict) -> Tuple[str, Optional[str]]:
160
+ """Classify a job as ubuntu/windows and return (category, specific_name).
161
+
162
+ Returns: ("ubuntu", "shard-X") | ("windows", None) | (None, None)
163
+ """
164
+ name = job.get("name", "").lower()
165
+
166
+ # Windows job: named "windows"
167
+ if name == "windows":
168
+ return ("windows", job.get("name"))
169
+
170
+ # Ubuntu jobs: named "ci (N)" or contain "ubuntu"/"linux"
171
+ if name.startswith("ci (") or "ubuntu" in name or "linux" in name:
172
+ return ("ubuntu", job.get("name"))
173
+
174
+ return (None, None)
175
+
176
+
177
+ def job_conclusion(job: Dict) -> str:
178
+ """Extract job conclusion as PASS or FAIL or PENDING.
179
+
180
+ Return "PASS" if status=COMPLETED and conclusion=SUCCESS.
181
+ Return "FAIL" if any failure-like conclusion.
182
+ Return "PENDING" if not yet complete.
183
+ """
184
+ status = job.get("status", "").upper()
185
+ conclusion = job.get("conclusion", "").upper()
186
+
187
+ if status != "COMPLETED":
188
+ return "PENDING"
189
+
190
+ if conclusion in ("SUCCESS", "NEUTRAL", "SKIPPED"):
191
+ return "PASS"
192
+ elif conclusion in ("FAILURE", "TIMED_OUT", "CANCELLED", "ACTION_REQUIRED", "STALE"):
193
+ return "FAIL"
194
+
195
+ return "PENDING"
196
+
197
+
198
+ def analyze_run(run_id: str) -> Tuple[Optional[str], Optional[str]]:
199
+ """Analyze a single run.
200
+
201
+ Returns (ubuntu_status, windows_status) where each is:
202
+ "PASS" | "FAIL" | "PENDING" | "NOT-PRESENT"
203
+ """
204
+ try:
205
+ jobs = gh_jobs_for_run(run_id)
206
+
207
+ ubuntu_results = []
208
+ windows_result = None
209
+
210
+ for job in jobs:
211
+ category, _ = classify_job(job)
212
+ conclusion = job_conclusion(job)
213
+
214
+ if category == "ubuntu":
215
+ ubuntu_results.append(conclusion)
216
+ elif category == "windows":
217
+ windows_result = conclusion
218
+
219
+ # Ubuntu: all shards must pass for "PASS"
220
+ ubuntu_status = "PASS" if ubuntu_results and all(r == "PASS" for r in ubuntu_results) else (
221
+ "FAIL" if ubuntu_results and any(r == "FAIL" for r in ubuntu_results) else "PENDING"
222
+ )
223
+
224
+ # Windows: not present if no windows job found
225
+ windows_status = windows_result if windows_result else "NOT-PRESENT"
226
+
227
+ return (ubuntu_status, windows_status)
228
+ except GhError:
229
+ # Run analysis failed; skip this run
230
+ return (None, None)
231
+
232
+
233
+ def analyze_windows_failures(run_id: str) -> Dict[str, int]:
234
+ """Fetch logs for windows job in this run and parse failing tests.
235
+
236
+ Returns dict of {test_name: frequency}.
237
+ """
238
+ try:
239
+ jobs = gh_jobs_for_run(run_id)
240
+
241
+ for job in jobs:
242
+ category, _ = classify_job(job)
243
+ if category == "windows":
244
+ job_id = job.get("id")
245
+ if job_id:
246
+ log_text = gh_job_logs(job_id)
247
+ if log_text:
248
+ return parse_failing_tests(log_text)
249
+ except GhError:
250
+ pass
251
+
252
+ return {}
253
+
254
+
255
+ def main():
256
+ """Main entry point."""
257
+ parser = argparse.ArgumentParser(
258
+ description="Measure Windows-vs-Linux CI outcome drift from GitHub Actions history.",
259
+ formatter_class=argparse.RawDescriptionHelpFormatter,
260
+ epilog="""\
261
+ Examples:
262
+ python crossos_drift.py
263
+ Analyze last 10 completed ci.yml runs on main (default).
264
+
265
+ python crossos_drift.py --runs 8
266
+ Analyze last 8 runs.
267
+
268
+ python crossos_drift.py --runs 10 --json
269
+ Output results as JSON.
270
+
271
+ Exit codes:
272
+ 0 - Success
273
+ 1 - Error (parsing, logic, etc.)
274
+ 2 - Execution error (gh call failed)
275
+ 3 - Authentication error (gh not authenticated)
276
+ """,
277
+ )
278
+
279
+ parser.add_argument(
280
+ "--runs",
281
+ type=int,
282
+ default=DEFAULT_RUNS,
283
+ help=f"Number of completed runs to analyze (default: {DEFAULT_RUNS})",
284
+ )
285
+ parser.add_argument(
286
+ "--json",
287
+ action="store_true",
288
+ help="Output results as JSON",
289
+ )
290
+
291
+ args = parser.parse_args()
292
+
293
+ if args.runs <= 0:
294
+ print("ERROR: --runs must be > 0", file=sys.stderr)
295
+ sys.exit(1)
296
+
297
+ # Fetch run list
298
+ try:
299
+ runs = gh_run_list(limit=args.runs)
300
+ except GhError as e:
301
+ if "not authenticated" in str(e).lower():
302
+ print(f"ERROR: {e}", file=sys.stderr)
303
+ sys.exit(3)
304
+ print(f"ERROR: {e}", file=sys.stderr)
305
+ sys.exit(2)
306
+
307
+ if not runs:
308
+ print("ERROR: No completed runs found", file=sys.stderr)
309
+ sys.exit(1)
310
+
311
+ # Analyze each run
312
+ ubuntu_results = []
313
+ windows_results = []
314
+ divergences = []
315
+ all_windows_failures = defaultdict(int)
316
+
317
+ for run in runs:
318
+ run_id = run.get("databaseId") or run.get("number")
319
+ ubuntu_status, windows_status = analyze_run(str(run_id))
320
+
321
+ if ubuntu_status is None or windows_status is None:
322
+ # Run analysis failed
323
+ continue
324
+
325
+ ubuntu_results.append(ubuntu_status)
326
+ windows_results.append(windows_status)
327
+
328
+ # Detect divergence: ubuntu PASS + windows FAIL (or NOT-PRESENT doesn't count as divergence)
329
+ if ubuntu_status == "PASS" and windows_status == "FAIL":
330
+ divergences.append(run_id)
331
+
332
+ # Collect windows failures
333
+ if windows_status == "FAIL":
334
+ failures = analyze_windows_failures(run_id)
335
+ for test_name, freq in failures.items():
336
+ all_windows_failures[test_name] += freq
337
+
338
+ # Calculate pass rates
339
+ ubuntu_completed = [r for r in ubuntu_results if r != "PENDING"]
340
+ windows_completed = [r for r in windows_results if r not in ("PENDING", "NOT-PRESENT")]
341
+
342
+ ubuntu_pass_rate = (sum(1 for r in ubuntu_completed if r == "PASS") / len(ubuntu_completed)) if ubuntu_completed else 0.0
343
+ windows_pass_rate = (sum(1 for r in windows_completed if r == "PASS") / len(windows_completed)) if windows_completed else 0.0
344
+
345
+ # Top windows failures (bound output)
346
+ top_failures = dict(sorted(all_windows_failures.items(), key=lambda x: x[1], reverse=True)[:20])
347
+
348
+ # Output
349
+ if args.json:
350
+ output = {
351
+ "ubuntu_pass_rate": round(ubuntu_pass_rate, 4),
352
+ "windows_pass_rate": round(windows_pass_rate, 4),
353
+ "total_runs_analyzed": len(ubuntu_results),
354
+ "ubuntu_total": len(ubuntu_results),
355
+ "windows_total": len(windows_results),
356
+ "windows_not_present": sum(1 for r in windows_results if r == "NOT-PRESENT"),
357
+ "divergences": divergences,
358
+ "top_windows_failures": top_failures,
359
+ }
360
+ print(json.dumps(output, indent=2))
361
+ else:
362
+ print("Cross-OS CI Drift Report")
363
+ print("=" * 60)
364
+ print(f"Runs analyzed: {len(ubuntu_results)}")
365
+ print()
366
+ print("Pass Rates:")
367
+ print(f" Ubuntu: {ubuntu_pass_rate*100:6.2f}% ({sum(1 for r in ubuntu_completed if r == 'PASS')}/{len(ubuntu_completed)} runs)")
368
+ print(f" Windows: {windows_pass_rate*100:6.2f}% ({sum(1 for r in windows_completed if r == 'PASS')}/{len(windows_completed)} runs)")
369
+ print()
370
+
371
+ windows_not_present = sum(1 for r in windows_results if r == "NOT-PRESENT")
372
+ if windows_not_present > 0:
373
+ print(f"Windows job not present in {windows_not_present} runs (pre-#317)")
374
+ print()
375
+
376
+ if divergences:
377
+ print(f"Divergences (ubuntu PASS + windows FAIL): {len(divergences)}")
378
+ for run_id in divergences[:10]: # Show first 10
379
+ print(f" {run_id}")
380
+ if len(divergences) > 10:
381
+ print(f" ... and {len(divergences) - 10} more")
382
+ print()
383
+
384
+ if top_failures:
385
+ print("Top Windows Failures:")
386
+ for test_name, freq in sorted(top_failures.items(), key=lambda x: x[1], reverse=True)[:10]:
387
+ print(f" {freq:2d}x {test_name}")
388
+ print()
389
+
390
+ sys.exit(0)
391
+
392
+
393
+ if __name__ == "__main__":
394
+ main()
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Defect escape telemetry — Haiku code quality measurement.
4
+
5
+ Turns 'is Haiku producing good code' from assertion into a measured number.
6
+
7
+ Given --repo <path> --since <ISO date>, computes:
8
+ (a) fix-forward rate: count commits matching /fix-forward|hotfix|fix(ci)|repair/i
9
+ vs total feature commits in the window
10
+ (b) first-try-estimate: per-merge, whether a 'fix-forward' commit followed
11
+ the feature commit before the merge (proxy for not-first-try-green)
12
+
13
+ Output: JSON with {window, feature_commits, fixforward_commits, fixforward_rate, first_try_estimate}
14
+
15
+ Deterministic, stdlib+subprocess(git) only, no network, Windows-safe.
16
+ """
17
+
18
+ import argparse
19
+ import json
20
+ import subprocess
21
+ import sys
22
+ from datetime import datetime
23
+ from pathlib import Path
24
+
25
+
26
+ def run_git(args, repo_path, check=True):
27
+ """
28
+ Run git command in repo, return stdout.
29
+
30
+ Args:
31
+ args: list of git command args (e.g., ["log", "--oneline"])
32
+ repo_path: path to git repo
33
+ check: raise on non-zero exit if True
34
+
35
+ Returns:
36
+ stdout as string
37
+ """
38
+ try:
39
+ result = subprocess.run(
40
+ ["git", "-C", str(repo_path)] + args,
41
+ capture_output=True,
42
+ text=True, encoding="utf-8", errors="replace",
43
+ check=check,
44
+ )
45
+ return result.stdout
46
+ except subprocess.CalledProcessError as e:
47
+ if check:
48
+ raise RuntimeError(
49
+ f"git command failed: {' '.join(args)}\n"
50
+ f"stderr: {e.stderr}"
51
+ )
52
+ return ""
53
+
54
+
55
+ def get_commits_since(repo_path, since_date):
56
+ """
57
+ Get all commits since a date using git log.
58
+
59
+ Args:
60
+ repo_path: path to git repo
61
+ since_date: ISO format date string (e.g., "2026-07-01")
62
+
63
+ Returns:
64
+ list of commit hashes
65
+ """
66
+ output = run_git(
67
+ [
68
+ "log",
69
+ "--all",
70
+ f"--since={since_date}",
71
+ "--format=%H",
72
+ ],
73
+ repo_path,
74
+ )
75
+ commits = [c.strip() for c in output.strip().split("\n") if c.strip()]
76
+ return commits
77
+
78
+
79
+ def get_commit_subject(repo_path, commit_hash):
80
+ """Get commit subject line."""
81
+ output = run_git(
82
+ ["log", "-1", "--format=%s", commit_hash],
83
+ repo_path,
84
+ )
85
+ return output.strip()
86
+
87
+
88
+ def is_fixforward_commit(subject):
89
+ """
90
+ Check if commit subject matches fix-forward pattern.
91
+
92
+ Matches: fix-forward, hotfix, fix(ci), repair (case-insensitive)
93
+ """
94
+ import re
95
+
96
+ patterns = [
97
+ r"fix-forward",
98
+ r"hotfix",
99
+ r"fix\s*\(\s*ci\s*\)",
100
+ r"repair",
101
+ ]
102
+ combined = "|".join(f"({p})" for p in patterns)
103
+ return bool(re.search(combined, subject, re.IGNORECASE))
104
+
105
+
106
+ def get_commit_parents(repo_path, commit_hash):
107
+ """Get parent commit hashes for a commit."""
108
+ output = run_git(
109
+ ["log", "-1", "--format=%P", commit_hash],
110
+ repo_path,
111
+ )
112
+ parents = output.strip().split() if output.strip() else []
113
+ return parents
114
+
115
+
116
+ def compute_first_try_estimate(repo_path, commits_window):
117
+ """
118
+ Compute first-try-estimate: fraction of fix-forward commits that
119
+ followed a feature commit before the merge (proxy for not-first-try-green).
120
+
121
+ Simplified approach: for commits in the window, check if a fix-forward
122
+ commit appears after its preceding feature commit (naive linear scan).
123
+
124
+ Args:
125
+ repo_path: path to git repo
126
+ commits_window: list of commit hashes in window (reverse chronological)
127
+
128
+ Returns:
129
+ float 0.0-1.0 or None if no opportunities in window
130
+ """
131
+ if not commits_window or len(commits_window) < 2:
132
+ return None
133
+
134
+ # Build subject map for all commits in window
135
+ commit_subjects = {}
136
+ for commit in commits_window:
137
+ try:
138
+ subject = get_commit_subject(repo_path, commit)
139
+ commit_subjects[commit] = subject
140
+ except Exception:
141
+ pass
142
+
143
+ # Linear scan: count instances where a fix-forward follows a feature
144
+ # (commits come in reverse chronological, so "follows" means appears later in list)
145
+ feature_commits = [c for c in commits_window if c in commit_subjects
146
+ and not is_fixforward_commit(commit_subjects[c])]
147
+
148
+ if not feature_commits:
149
+ return None
150
+
151
+ followed_count = 0
152
+ for i, feature_commit in enumerate(feature_commits):
153
+ # Check if any fix-forward appears in next few commits
154
+ # (simple heuristic: next 3 commits after this feature)
155
+ feature_idx = commits_window.index(feature_commit)
156
+ for j in range(feature_idx + 1, min(feature_idx + 4, len(commits_window))):
157
+ next_commit = commits_window[j]
158
+ if next_commit in commit_subjects:
159
+ if is_fixforward_commit(commit_subjects[next_commit]):
160
+ followed_count += 1
161
+ break
162
+
163
+ return followed_count / len(feature_commits) if feature_commits else None
164
+
165
+
166
+ def main():
167
+ """Main entry point."""
168
+ parser = argparse.ArgumentParser(
169
+ description="Defect escape telemetry — Haiku code quality measurement"
170
+ )
171
+ parser.add_argument("--repo", required=True, help="Path to git repository")
172
+ parser.add_argument(
173
+ "--since", required=True, help="ISO format date (e.g., 2026-07-01)"
174
+ )
175
+ parser.add_argument(
176
+ "--json", action="store_true", help="Output JSON (default: human-readable)"
177
+ )
178
+
179
+ args = parser.parse_args()
180
+
181
+ repo_path = Path(args.repo)
182
+ if not repo_path.exists():
183
+ print(f"Error: repo not found: {repo_path}", file=sys.stderr)
184
+ sys.exit(1)
185
+
186
+ # Validate date format
187
+ try:
188
+ datetime.fromisoformat(args.since)
189
+ except ValueError:
190
+ print(f"Error: invalid date format (use ISO): {args.since}", file=sys.stderr)
191
+ sys.exit(1)
192
+
193
+ # Get all commits in window
194
+ try:
195
+ all_commits = get_commits_since(repo_path, args.since)
196
+ except RuntimeError as e:
197
+ print(f"Error: {e}", file=sys.stderr)
198
+ sys.exit(1)
199
+
200
+ # Count feature vs fix-forward
201
+ feature_commits = []
202
+ fixforward_commits = []
203
+
204
+ for commit in all_commits:
205
+ subject = get_commit_subject(repo_path, commit)
206
+ if is_fixforward_commit(subject):
207
+ fixforward_commits.append(commit)
208
+ else:
209
+ feature_commits.append(commit)
210
+
211
+ # Compute rates
212
+ total_feature = len(feature_commits)
213
+ total_fixforward = len(fixforward_commits)
214
+ fixforward_rate = (
215
+ total_fixforward / total_feature if total_feature > 0 else 0.0
216
+ )
217
+
218
+ # Compute first-try estimate
219
+ first_try_estimate = compute_first_try_estimate(repo_path, all_commits)
220
+
221
+ result = {
222
+ "window": {
223
+ "since": args.since,
224
+ "total_commits": len(all_commits),
225
+ },
226
+ "feature_commits": total_feature,
227
+ "fixforward_commits": total_fixforward,
228
+ "fixforward_rate": round(fixforward_rate, 4),
229
+ "first_try_estimate": (
230
+ round(first_try_estimate, 4) if first_try_estimate is not None else None
231
+ ),
232
+ }
233
+
234
+ if args.json:
235
+ print(json.dumps(result, indent=2))
236
+ else:
237
+ print(f"Defect Escape Telemetry Report")
238
+ print(f" Window: since {args.since}")
239
+ print(f" Total commits: {len(all_commits)}")
240
+ print(f" Feature commits: {total_feature}")
241
+ print(f" Fix-forward commits: {total_fixforward}")
242
+ print(f" Fix-forward rate: {fixforward_rate:.2%}")
243
+ if first_try_estimate is not None:
244
+ print(f" First-try estimate: {first_try_estimate:.2%}")
245
+ else:
246
+ print(f" First-try estimate: N/A (no merges in window)")
247
+
248
+ return 0
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
package/tools/doctor.js CHANGED
@@ -126,7 +126,7 @@ function checkPort8770() {
126
126
  if (!resolved) {
127
127
  resolved = true;
128
128
  cleanup();
129
- resolve({ passed: false, hint: 'Port 8770 is in use' });
129
+ resolve({ passed: false, hint: 'Port 8770 in use — is another fleet running? Update dashboard.port in aesop.config.json' });
130
130
  }
131
131
  });
132
132