@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.
- package/CHANGELOG.md +117 -2
- package/README.md +59 -218
- package/bin/cli.js +168 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/HOOK-INSTALL.md +15 -56
- package/docs/INSTALL.md +74 -4
- package/docs/PORTING.md +166 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +372 -0
- package/docs/reproduce.md +33 -3
- package/driver/CLAUDE.md +150 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +673 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +179 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +254 -0
- package/driver/wave_loop.py +1408 -0
- package/driver/wave_scheduler.py +890 -0
- package/hooks/pre-push-policy.sh +131 -33
- package/mcp/server.mjs +320 -4
- package/monitor/collect-signals.mjs +69 -0
- package/package.json +22 -14
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +4 -2
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/projections.py +63 -0
- package/state_store/read_api.py +156 -0
- package/state_store/store.py +185 -73
- package/state_store/write_api.py +462 -0
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +166 -43
- package/tools/cost_econ.py +480 -0
- package/tools/cost_projection.py +559 -0
- package/tools/crossos_drift.py +394 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +188 -26
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +523 -0
- package/tools/portability_check.py +206 -0
- package/tools/proposals.mjs +47 -2
- package/tools/reconcile.py +7 -4
- package/tools/reproduce.js +405 -0
- package/tools/secret_scan.py +207 -65
- package/tools/self_stats.py +20 -0
- package/tools/stall_check.py +247 -16
- package/tools/stateapi_lint.py +325 -0
- package/tools/test_battery.py +173 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_cost_panel.py +345 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +779 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +340 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +81 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +383 -55
- package/ui/quality_scorecard.py +232 -0
- package/ui/sse.py +3 -3
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +383 -0
- package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
- package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- 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
|
|
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
|
|