@matt82198/aesop 0.2.0 → 0.3.2

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 (46) hide show
  1. package/CHANGELOG.md +39 -1
  2. package/README.md +60 -263
  3. package/bin/cli.js +7 -3
  4. package/daemons/install-tasks.ps1 +193 -0
  5. package/daemons/run-hidden.vbs +39 -0
  6. package/docs/HOOK-INSTALL.md +15 -56
  7. package/docs/INSTALL.md +48 -3
  8. package/docs/PORTING.md +166 -0
  9. package/docs/TEAM-STATE.md +16 -184
  10. package/docs/reproduce.md +33 -3
  11. package/driver/CLAUDE.md +50 -48
  12. package/driver/codex_driver.py +59 -12
  13. package/driver/openai_transport.py +33 -0
  14. package/driver/wave_bridge.py +9 -1
  15. package/driver/wave_loop.py +437 -70
  16. package/driver/wave_scheduler.py +890 -0
  17. package/hooks/pre-push-policy.sh +22 -5
  18. package/monitor/collect-signals.mjs +69 -0
  19. package/package.json +6 -4
  20. package/state_store/__init__.py +2 -1
  21. package/state_store/projections.py +63 -0
  22. package/state_store/read_api.py +156 -0
  23. package/state_store/write_api.py +462 -0
  24. package/tools/cost_ceiling.py +106 -15
  25. package/tools/cost_projection.py +559 -0
  26. package/tools/crossos_drift.py +394 -0
  27. package/tools/eod_sweep.py +137 -33
  28. package/tools/mutation_test.py +130 -8
  29. package/tools/proposals.mjs +47 -2
  30. package/tools/reproduce.js +405 -0
  31. package/tools/secret_scan.py +73 -18
  32. package/tools/stall_check.py +247 -16
  33. package/tools/stateapi_lint.py +325 -0
  34. package/tools/test_battery.py +173 -0
  35. package/tools/verify_cost_panel.py +345 -0
  36. package/tools/wave_preflight.py +296 -29
  37. package/tools/wave_templates.py +170 -45
  38. package/ui/collectors.py +81 -0
  39. package/ui/handler.py +230 -85
  40. package/ui/sse.py +3 -3
  41. package/ui/wave_telemetry.py +24 -18
  42. package/ui/web/dist/assets/{index-DqZLgwNg.css → index-CNQxaiOW.css} +1 -1
  43. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  44. package/ui/web/dist/index.html +2 -2
  45. package/docs/QUICKSTART.md +0 -80
  46. package/ui/web/dist/assets/index-BGbgw2Nh.js +0 -9
@@ -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()
@@ -24,6 +24,7 @@ Usage: eod_sweep.py [--repos PATHS] [--readonly-repos PATHS] [--fix-push]
24
24
  """
25
25
 
26
26
  import json
27
+ import os
27
28
  import subprocess
28
29
  import sys
29
30
  import os
@@ -48,13 +49,27 @@ class Finding:
48
49
 
49
50
 
50
51
  def get_git_status(repo_path):
51
- """Return (is_clean, dirty_files_list) for a repo."""
52
+ """Return (is_clean, dirty_files_list) for a repo.
53
+
54
+ Returns:
55
+ (True, []): Repo is clean
56
+ (False, list): Repo is dirty with file list
57
+ (None, error_msg): Git command failed (FAIL-CLOSED: treat as AT-RISK)
58
+ """
52
59
  try:
53
- output = subprocess.run(
54
- ['git', '-C', str(repo_path), 'status', '--porcelain'],
60
+ # Resolve path to normalize 8.3 short names on Windows
61
+ resolved_path = Path(repo_path).resolve()
62
+ result = subprocess.run(
63
+ ['git', '-C', str(resolved_path), 'status', '--porcelain'],
55
64
  capture_output=True, text=True, timeout=5
56
- ).stdout.strip()
65
+ )
57
66
 
67
+ # FAIL-CLOSED: Check return code before processing output
68
+ if result.returncode != 0:
69
+ error_msg = result.stderr.strip() if result.stderr.strip() else f"exit code {result.returncode}"
70
+ return (None, f"git status check failed: {error_msg}")
71
+
72
+ output = result.stdout.strip()
58
73
  if not output:
59
74
  return (True, [])
60
75
  else:
@@ -65,21 +80,56 @@ def get_git_status(repo_path):
65
80
 
66
81
 
67
82
  def get_ahead_count(repo_path):
68
- """Return count of commits ahead of origin/HEAD (or None on error)."""
83
+ """Return count of commits ahead of origin/HEAD (or None on error).
84
+
85
+ Returns:
86
+ int >= 0: Number of commits ahead (0 = all pushed or no remote)
87
+ None: Git command failed unexpectedly (FAIL-CLOSED: treat as AT-RISK)
88
+ """
69
89
  try:
90
+ # Resolve path to normalize 8.3 short names on Windows
91
+ resolved_path = Path(repo_path).resolve()
70
92
  # First check if there's a tracking branch
71
- try:
72
- output = subprocess.run(
73
- ['git', '-C', str(repo_path), 'rev-list', '--left-only', '--count', 'HEAD...@{u}'],
74
- capture_output=True, text=True, timeout=5
75
- ).stdout.strip()
76
- except:
77
- # Fallback to origin/HEAD if no upstream
78
- output = subprocess.run(
79
- ['git', '-C', str(repo_path), 'rev-list', '--left-only', '--count', 'HEAD...origin/HEAD'],
93
+ result = subprocess.run(
94
+ ['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...@{u}'],
95
+ capture_output=True, text=True, timeout=5
96
+ )
97
+
98
+ # If upstream tracking exists, use that result
99
+ if result.returncode == 0:
100
+ output = result.stdout.strip()
101
+ try:
102
+ return int(output) if output else 0
103
+ except:
104
+ return None
105
+
106
+ # If no upstream tracking, try origin/HEAD (fallback for single-branch or no-remote repos)
107
+ # Expected error for local-only repos: "fatal: no upstream configured"
108
+ stderr_lower = result.stderr.lower()
109
+ if 'no upstream configured' in stderr_lower:
110
+ # Local-only repo (no tracking branch), try origin/HEAD as fallback
111
+ result = subprocess.run(
112
+ ['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...origin/HEAD'],
80
113
  capture_output=True, text=True, timeout=5
81
- ).stdout.strip()
114
+ )
115
+
116
+ # Check return code; if fails, determine if it's an expected "no remote" error or a real error
117
+ if result.returncode != 0:
118
+ stderr_lower = result.stderr.lower()
119
+ # Expected errors for local-only repos (no remote configured)
120
+ expected_errors = [
121
+ 'no upstream configured',
122
+ 'ambiguous argument',
123
+ 'unknown revision',
124
+ 'not a valid object name',
125
+ 'no such ref'
126
+ ]
127
+ if any(err in stderr_lower for err in expected_errors):
128
+ return 0 # No remote/tracking, treat as "all pushed"
129
+ else:
130
+ return None # Real error, fail-closed
82
131
 
132
+ output = result.stdout.strip()
83
133
  try:
84
134
  return int(output) if output else 0
85
135
  except:
@@ -89,13 +139,26 @@ def get_ahead_count(repo_path):
89
139
 
90
140
 
91
141
  def check_untracked_files(repo_path):
92
- """Return list of untracked files not in .gitignore."""
142
+ """Return list of untracked files not in .gitignore.
143
+
144
+ Returns:
145
+ []: No untracked files
146
+ [list]: Untracked files found
147
+ None: Git command failed (FAIL-CLOSED: treat as AT-RISK)
148
+ """
93
149
  try:
94
- output = subprocess.run(
95
- ['git', '-C', str(repo_path), 'ls-files', '--others', '--exclude-standard'],
150
+ # Resolve path to normalize 8.3 short names on Windows
151
+ resolved_path = Path(repo_path).resolve()
152
+ result = subprocess.run(
153
+ ['git', '-C', str(resolved_path), 'ls-files', '--others', '--exclude-standard'],
96
154
  capture_output=True, text=True, timeout=5
97
- ).stdout.strip()
155
+ )
156
+
157
+ # FAIL-CLOSED: Check return code before processing output
158
+ if result.returncode != 0:
159
+ return None
98
160
 
161
+ output = result.stdout.strip()
99
162
  if output:
100
163
  return output.split('\n')
101
164
  return []
@@ -104,12 +167,18 @@ def check_untracked_files(repo_path):
104
167
 
105
168
 
106
169
  def check_repo(repo_path):
107
- """Check a single repo; return list of Finding objects or None if repo doesn't exist."""
170
+ """Check a single repo; returns a list of Finding objects.
171
+
172
+ FAIL-CLOSED (runner incident): a repo the caller ASKED to check that does
173
+ not exist or is not a git repo is an AT-RISK finding, never a silent skip
174
+ — the silent-None path made the sweep report a vacuous SAFE (exit 0) when
175
+ fixture/real repos failed to initialize.
176
+ """
108
177
  if not repo_path.exists():
109
- return None
178
+ return [Finding(repo_path, "repo path does not exist")]
110
179
 
111
180
  if not (repo_path / '.git').exists():
112
- return None
181
+ return [Finding(repo_path, "not a git repository (.git missing)")]
113
182
 
114
183
  findings = []
115
184
 
@@ -140,8 +209,10 @@ def check_repo(repo_path):
140
209
  def push_repo(repo_path):
141
210
  """Push commits for a repo (return True if successful)."""
142
211
  try:
212
+ # Resolve path to normalize 8.3 short names on Windows
213
+ resolved_path = Path(repo_path).resolve()
143
214
  result = subprocess.run(
144
- ['git', '-C', str(repo_path), 'push'],
215
+ ['git', '-C', str(resolved_path), 'push'],
145
216
  capture_output=True, text=True, timeout=30
146
217
  )
147
218
  return result.returncode == 0
@@ -171,12 +242,23 @@ def append_to_buildlog(buildlog_path, verdict_line, timestamp_str=None):
171
242
  verdict_line: The verdict line to append (e.g., "EOD-SWEEP: SAFE").
172
243
  timestamp_str: Optional timestamp string (format: YYYY-MM-DD HH:MM).
173
244
  If None, timestamp is omitted from the entry.
245
+
246
+ Raises:
247
+ OSError: If file operations fail (permission denied, locked file, etc.).
174
248
  """
175
- buildlog_path.parent.mkdir(parents=True, exist_ok=True)
249
+ try:
250
+ buildlog_path.parent.mkdir(parents=True, exist_ok=True)
251
+ except OSError as e:
252
+ # Parent directory creation failed; re-raise with context
253
+ raise OSError(f"Failed to create parent directory for BUILDLOG: {e}") from e
176
254
 
177
255
  # Create header if file doesn't exist
178
- if not buildlog_path.exists():
179
- buildlog_path.write_text("# Build Log (append-only)\n")
256
+ try:
257
+ if not buildlog_path.exists():
258
+ buildlog_path.write_text("# Build Log (append-only)\n")
259
+ except OSError as e:
260
+ # File creation failed; re-raise with context
261
+ raise OSError(f"Failed to create BUILDLOG file: {e}") from e
180
262
 
181
263
  # Build entry line with optional timestamp
182
264
  if timestamp_str:
@@ -184,9 +266,23 @@ def append_to_buildlog(buildlog_path, verdict_line, timestamp_str=None):
184
266
  else:
185
267
  entry_line = f"### {verdict_line}"
186
268
 
187
- # Append to BUILDLOG
188
- with open(buildlog_path, "a", encoding="utf-8") as f:
189
- f.write(entry_line + "\n")
269
+ # Append to BUILDLOG with retry logic for Windows file locking
270
+ max_retries = 3
271
+ retry_delay = 0.1 # seconds
272
+ last_error = None
273
+
274
+ for attempt in range(max_retries):
275
+ try:
276
+ with open(buildlog_path, "a", encoding="utf-8") as f:
277
+ f.write(entry_line + "\n")
278
+ return
279
+ except OSError as e:
280
+ last_error = e
281
+ if attempt < max_retries - 1:
282
+ time.sleep(retry_delay)
283
+
284
+ # All retries failed
285
+ raise OSError(f"Failed to append to BUILDLOG after {max_retries} attempts: {last_error}") from last_error
190
286
 
191
287
 
192
288
  def main():
@@ -195,7 +291,7 @@ def main():
195
291
  parser.add_argument(
196
292
  '--repos',
197
293
  default='',
198
- help='Colon-separated paths to scan (default: empty)'
294
+ help="os.pathsep-separated paths (; on Windows, : on POSIX)"
199
295
  )
200
296
  parser.add_argument(
201
297
  '--readonly-repos',
@@ -220,14 +316,18 @@ def main():
220
316
  args = parser.parse_args()
221
317
 
222
318
  # Parse repos
319
+ # Split on os.pathsep (';' on Windows, ':' on POSIX): a ':' delimiter
320
+ # eats Windows drive letters (the runner's vacuous-SAFE incident: tmp on
321
+ # C:, checkout on D:, drive-relative remainder resolved to nothing and
322
+ # the old silent-skip reported SAFE with zero repos scanned).
223
323
  repos_to_check = []
224
324
  if args.repos:
225
- repos_to_check = [Path(p) for p in args.repos.split(':') if p]
325
+ repos_to_check = [Path(p) for p in args.repos.split(os.pathsep) if p]
226
326
 
227
327
  # Parse readonly repos
228
328
  readonly_repos = set()
229
329
  if args.readonly_repos:
230
- readonly_repos = {Path(p) for p in args.readonly_repos.split(':') if p}
330
+ readonly_repos = {Path(p) for p in args.readonly_repos.split(os.pathsep) if p}
231
331
 
232
332
  findings = []
233
333
 
@@ -288,7 +388,11 @@ def main():
288
388
  buildlog_path = state_dir / "BUILDLOG.md"
289
389
 
290
390
  if buildlog_path:
291
- append_to_buildlog(buildlog_path, verdict_line, args.timestamp)
391
+ try:
392
+ append_to_buildlog(buildlog_path, verdict_line, args.timestamp)
393
+ except OSError as e:
394
+ # Log error but don't fail the verdict — exit code depends on findings, not BUILDLOG
395
+ print(f"WARNING: Failed to append to BUILDLOG: {e}", file=sys.stderr)
292
396
 
293
397
  sys.exit(exit_code)
294
398