@matt82198/aesop 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (114) hide show
  1. package/CHANGELOG.md +89 -2
  2. package/README.md +63 -12
  3. package/bin/cli.js +164 -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/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -0,0 +1,512 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Wave preflight validator — check repo readiness before starting a wave.
4
+
5
+ Validates:
6
+ 1. Current repo is on a feature branch (never main/master)
7
+ 2. Working tree clean
8
+ 3. STATE.md phase heading consistent with state/orchestrator-status.json phase
9
+ (warning-level: drift is reported but does not block the wave)
10
+ 4. No .HALT sentinel
11
+ 5. Heartbeats fresh (watchdog 200s) and orchestrator-status.json fresh (300s)
12
+ 6. state/tracker.json parses as JSON
13
+ 7. secret_scan importable
14
+
15
+ Exit codes:
16
+ 0 = ready (all checks pass, or only warnings like phase drift)
17
+ 1 = blocked (one or more critical checks failed)
18
+
19
+ Output:
20
+ --text (default): numbered list of checks with status and detail
21
+ --json: {ready: bool, checks: [{name, ok, detail}]}
22
+
23
+ Usage:
24
+ python tools/wave_preflight.py [--root REPO_ROOT] [--state-root STATE_ROOT] [--json]
25
+
26
+ Arguments:
27
+ --root REPO_ROOT: repository root directory (default: cwd)
28
+ --state-root STATE_ROOT: state directory (default: REPO_ROOT/state or ./state)
29
+ --json: output in JSON format (default: text)
30
+
31
+ Environment:
32
+ AESOP_STATE_ROOT: state dir (takes precedence over --state-root argument)
33
+ AESOP_ROOT: repo root (default: cwd or inferred from .git)
34
+ """
35
+
36
+ import json
37
+ import os
38
+ import re
39
+ import subprocess
40
+ import sys
41
+ import time
42
+ from pathlib import Path
43
+
44
+ try:
45
+ from common import get_state_dir, check_heartbeat_staleness
46
+ except ImportError:
47
+ from tools.common import get_state_dir, check_heartbeat_staleness
48
+
49
+ try:
50
+ import halt
51
+ except ImportError:
52
+ from tools import halt
53
+
54
+
55
+ def load_config(root_dir=None):
56
+ """Load aesop.config.json from root, return dict (or {} if absent/bad)."""
57
+ if root_dir is None:
58
+ root_dir = Path.cwd()
59
+ else:
60
+ root_dir = Path(root_dir)
61
+
62
+ config_file = root_dir / "aesop.config.json"
63
+ if not config_file.exists():
64
+ return {}
65
+ try:
66
+ with open(config_file, "r", encoding="utf-8") as f:
67
+ return json.load(f)
68
+ except Exception:
69
+ return {}
70
+
71
+
72
+ def resolve_state_dir(root_dir=None, config=None):
73
+ """Resolve state dir: AESOP_STATE_ROOT env > config state_root > ./state."""
74
+ if os.environ.get("AESOP_STATE_ROOT"):
75
+ return Path(os.environ["AESOP_STATE_ROOT"])
76
+
77
+ if root_dir is None:
78
+ root_dir = Path.cwd()
79
+ else:
80
+ root_dir = Path(root_dir)
81
+
82
+ if config is None:
83
+ config = load_config(root_dir)
84
+
85
+ state_root = config.get("state_root") if isinstance(config, dict) else None
86
+ if state_root:
87
+ p = Path(state_root).expanduser()
88
+ if not p.is_absolute():
89
+ p = root_dir / p
90
+ return p
91
+
92
+ return root_dir / "state"
93
+
94
+
95
+ def parse_state_md_phase(state_md_path):
96
+ """Extract phase from STATE.md heading: ## Phase: `<phase>` ...
97
+
98
+ Returns:
99
+ str or None: the phase name, or None if not found/unparseable.
100
+ """
101
+ if not state_md_path.exists():
102
+ return None
103
+
104
+ try:
105
+ content = state_md_path.read_text(encoding="utf-8")
106
+ # Match: ## Phase: `<phase>` ...
107
+ match = re.search(r'^##\s+Phase:\s+`([^`]+)`', content, re.MULTILINE)
108
+ if match:
109
+ return match.group(1)
110
+ except Exception:
111
+ pass
112
+
113
+ return None
114
+
115
+
116
+ def parse_orchestrator_status_phase(status_json_path):
117
+ """Extract phase from orchestrator-status.json.
118
+
119
+ Returns:
120
+ str or None: the phase field, or None if not found/unparseable.
121
+ """
122
+ if not status_json_path.exists():
123
+ return None
124
+
125
+ try:
126
+ data = json.loads(status_json_path.read_text(encoding="utf-8"))
127
+ return data.get("phase")
128
+ except Exception:
129
+ pass
130
+
131
+ return None
132
+
133
+
134
+ def check_orchestrator_status_freshness(status_json_path, threshold_s):
135
+ """Check if orchestrator-status.json is fresh based on updated_at timestamp.
136
+
137
+ Args:
138
+ status_json_path: Path to orchestrator-status.json
139
+ threshold_s: Staleness threshold in seconds
140
+
141
+ Returns:
142
+ Tuple of (is_stale, age_s, info):
143
+ is_stale (bool): True if file missing, unreadable, or age >= threshold_s
144
+ age_s (int): Age in seconds (0 if file missing/unreadable)
145
+ info (str or None): Descriptive message if stale/missing, None if fresh
146
+ """
147
+ if not status_json_path.exists():
148
+ return True, 0, "orchestrator-status.json file missing"
149
+
150
+ try:
151
+ data = json.loads(status_json_path.read_text(encoding="utf-8"))
152
+ updated_at = data.get("updated_at")
153
+ if not updated_at:
154
+ return True, 0, "orchestrator-status.json missing updated_at field"
155
+
156
+ # Parse ISO 8601 timestamp (handle both "Z" and "+00:00" timezone formats)
157
+ import datetime
158
+ # Normalize Z suffix to +00:00 for fromisoformat compatibility
159
+ normalized_ts = updated_at.replace("Z", "+00:00")
160
+ updated_dt = datetime.datetime.fromisoformat(normalized_ts)
161
+ timestamp = updated_dt.timestamp()
162
+
163
+ age_seconds = int(time.time()) - int(timestamp)
164
+
165
+ # Check for far-future timestamp (clock skew beyond tolerance)
166
+ # More than 120s in the future is treated as stale, not clamped-to-fresh
167
+ if age_seconds < -120:
168
+ return True, 0, "orchestrator-status timestamp in future (clock skew)"
169
+
170
+ # Clamp small negative ages to 0 (normal clock skew recovery)
171
+ age_seconds = max(0, age_seconds)
172
+
173
+ if age_seconds >= threshold_s:
174
+ return True, age_seconds, f"orchestrator-status stale ({age_seconds}s >= {threshold_s}s)"
175
+
176
+ return False, age_seconds, None
177
+ except Exception as e:
178
+ return True, 0, f"orchestrator-status.json unreadable: {e}"
179
+
180
+
181
+ def is_git_repo(root_dir):
182
+ """Check if root_dir is a git repository."""
183
+ git_dir = Path(root_dir) / ".git"
184
+ return git_dir.exists()
185
+
186
+
187
+ def get_current_branch(root_dir):
188
+ """Get current git branch name.
189
+
190
+ Returns:
191
+ str or None: branch name, or None if unable to determine (e.g., not a repo, detached HEAD).
192
+ """
193
+ try:
194
+ result = subprocess.run(
195
+ ["git", "rev-parse", "--abbrev-ref", "HEAD"],
196
+ cwd=root_dir,
197
+ capture_output=True,
198
+ text=True,
199
+ timeout=5,
200
+ )
201
+ if result.returncode == 0:
202
+ branch = result.stdout.strip()
203
+ if branch == "HEAD":
204
+ # Detached HEAD
205
+ return None
206
+ return branch
207
+ except Exception:
208
+ pass
209
+ return None
210
+
211
+
212
+ def is_working_tree_clean(root_dir):
213
+ """Check if git working tree is clean (ignores untracked files).
214
+
215
+ Returns:
216
+ (bool, str or None): (is_clean, detail_msg if not clean)
217
+ """
218
+ try:
219
+ result = subprocess.run(
220
+ ["git", "status", "--porcelain"],
221
+ cwd=root_dir,
222
+ capture_output=True,
223
+ text=True,
224
+ timeout=5,
225
+ )
226
+ if result.returncode == 0:
227
+ output = result.stdout.strip()
228
+ if not output:
229
+ return True, None
230
+ # Filter out untracked files (lines starting with ??)
231
+ # and only consider tracked file changes (M, A, D, etc.)
232
+ dirty_lines = [
233
+ line for line in output.split("\n")
234
+ if line and not line.startswith("??")
235
+ ]
236
+ if not dirty_lines:
237
+ return True, None
238
+ # List first few dirty files
239
+ lines = dirty_lines[:3]
240
+ detail = "uncommitted changes: " + "; ".join(lines)
241
+ if len(dirty_lines) > 3:
242
+ detail += f" (+{len(dirty_lines) - 3} more)"
243
+ return False, detail
244
+ except Exception:
245
+ pass
246
+ return False, "unable to check git status"
247
+
248
+
249
+ def can_import_secret_scan():
250
+ """Check if secret_scan can be imported.
251
+
252
+ Returns:
253
+ (bool, str or None): (importable, detail_msg if not)
254
+ """
255
+ try:
256
+ import secret_scan
257
+ return True, None
258
+ except ImportError as e:
259
+ return False, str(e)
260
+
261
+
262
+ def run_checks(root_dir=None, state_dir=None, config=None):
263
+ """Run all preflight checks.
264
+
265
+ Args:
266
+ root_dir: repo root (inferred from cwd if None)
267
+ state_dir: state dir (resolved if None)
268
+ config: aesop.config.json dict (loaded if None)
269
+
270
+ Returns:
271
+ dict: {
272
+ ready: bool (all checks pass/warn),
273
+ checks: [
274
+ {name: str, ok: bool, detail: str},
275
+ ...
276
+ ]
277
+ }
278
+ """
279
+ if root_dir is None:
280
+ root_dir = Path.cwd()
281
+ else:
282
+ root_dir = Path(root_dir)
283
+
284
+ if config is None:
285
+ config = load_config(root_dir)
286
+
287
+ if state_dir is None:
288
+ state_dir = resolve_state_dir(root_dir, config)
289
+ else:
290
+ state_dir = Path(state_dir)
291
+
292
+ checks = []
293
+
294
+ # Check 1: Git repo exists
295
+ is_repo = is_git_repo(root_dir)
296
+ checks.append({
297
+ "name": "Git repository",
298
+ "ok": is_repo,
299
+ "detail": "repo found" if is_repo else "not a git repo",
300
+ })
301
+
302
+ if not is_repo:
303
+ # Can't proceed without a repo
304
+ return {"ready": False, "checks": checks}
305
+
306
+ # Check 2: On a feature branch (not main/master)
307
+ branch = get_current_branch(root_dir)
308
+ if branch is None:
309
+ on_feature_branch = False
310
+ detail = "detached HEAD or unable to determine branch"
311
+ else:
312
+ on_feature_branch = branch not in ("main", "master")
313
+ detail = f"branch={branch}"
314
+
315
+ checks.append({
316
+ "name": "Feature branch (not main/master)",
317
+ "ok": on_feature_branch,
318
+ "detail": detail,
319
+ })
320
+
321
+ # Check 3: Working tree clean
322
+ clean, dirty_detail = is_working_tree_clean(root_dir)
323
+ checks.append({
324
+ "name": "Working tree clean",
325
+ "ok": clean,
326
+ "detail": dirty_detail or "no uncommitted changes",
327
+ })
328
+
329
+ # Check 4: No .HALT sentinel
330
+ is_halted = halt.is_halted(state_dir)
331
+ halt_detail = "not halted"
332
+ if is_halted:
333
+ halt_info = halt.get_halt_info(state_dir)
334
+ halt_detail = halt_info.get("reason", "halted") if halt_info else "halted"
335
+
336
+ checks.append({
337
+ "name": "No .HALT sentinel",
338
+ "ok": not is_halted,
339
+ "detail": halt_detail,
340
+ })
341
+
342
+ # Check 5: STATE.md phase vs orchestrator-status.json phase (warn-level)
343
+ # Genuine comparison: only warn if both phases exist and differ
344
+ state_md_path = root_dir / "STATE.md"
345
+ status_json_path = state_dir / "orchestrator-status.json"
346
+
347
+ state_phase = parse_state_md_phase(state_md_path)
348
+ status_phase = parse_orchestrator_status_phase(status_json_path)
349
+
350
+ # Determine phase drift: only if both are defined and differ
351
+ drift_detected = False
352
+ if state_phase is not None and status_phase is not None:
353
+ if state_phase != status_phase:
354
+ # Drift detected: both phases exist but differ
355
+ drift_detected = True
356
+ phase_detail = f"STATE.md={state_phase}, status.json={status_phase} [WARN: drift detected]"
357
+ else:
358
+ # Phases match
359
+ phase_detail = f"STATE.md={state_phase}, status.json={status_phase}"
360
+ else:
361
+ # One or both phases missing (not yet ready or not applicable)
362
+ phase_detail = f"STATE.md={state_phase}, status.json={status_phase}"
363
+
364
+ # Phase drift check: warning-level (drift is reported but does not block)
365
+ # The check always passes (phase_ok = True), but drift detail is visible in output
366
+ # This makes the check non-vacuous: drift is detected and reported, but doesn't block
367
+ phase_ok = True
368
+
369
+ checks.append({
370
+ "name": "STATE.md phase consistent with orchestrator-status.json (warning-level)",
371
+ "ok": phase_ok,
372
+ "detail": phase_detail,
373
+ })
374
+
375
+ # Check 6: Heartbeats and status freshness
376
+ # Check watchdog heartbeat (200s threshold) and orchestrator-status.json (300s)
377
+ heartbeat_details = []
378
+ all_heartbeats_ok = True
379
+
380
+ watchdog_hb = state_dir / ".watchdog-heartbeat"
381
+ is_stale, age, info = check_heartbeat_staleness(watchdog_hb, 200)
382
+ hb_name = "watchdog"
383
+ if is_stale:
384
+ all_heartbeats_ok = False
385
+ heartbeat_details.append(f"{hb_name}: {info} (age={age}s)")
386
+ else:
387
+ heartbeat_details.append(f"{hb_name}: fresh (age={age}s)")
388
+
389
+ # Check orchestrator-status.json updated_at field (300s threshold)
390
+ status_json_path = state_dir / "orchestrator-status.json"
391
+ is_stale, age, info = check_orchestrator_status_freshness(status_json_path, 300)
392
+ status_name = "orchestrator-status"
393
+ if is_stale:
394
+ all_heartbeats_ok = False
395
+ heartbeat_details.append(f"{status_name}: {info} (age={age}s)")
396
+ else:
397
+ heartbeat_details.append(f"{status_name}: fresh (age={age}s)")
398
+
399
+ checks.append({
400
+ "name": "Heartbeats and orchestrator status fresh",
401
+ "ok": all_heartbeats_ok,
402
+ "detail": "; ".join(heartbeat_details),
403
+ })
404
+
405
+ # Check 7: state/tracker.json parses as JSON
406
+ tracker_json_path = state_dir / "tracker.json"
407
+ tracker_ok = False
408
+ tracker_detail = "tracker.json not found"
409
+
410
+ if tracker_json_path.exists():
411
+ try:
412
+ json.loads(tracker_json_path.read_text(encoding="utf-8"))
413
+ tracker_ok = True
414
+ tracker_detail = "valid JSON"
415
+ except json.JSONDecodeError as e:
416
+ tracker_detail = f"invalid JSON: {e}"
417
+ except Exception as e:
418
+ tracker_detail = f"unreadable: {e}"
419
+
420
+ checks.append({
421
+ "name": "state/tracker.json parses as JSON",
422
+ "ok": tracker_ok,
423
+ "detail": tracker_detail,
424
+ })
425
+
426
+ # Check 8: secret_scan importable
427
+ can_import, import_detail = can_import_secret_scan()
428
+ checks.append({
429
+ "name": "secret_scan importable",
430
+ "ok": can_import,
431
+ "detail": import_detail or "importable",
432
+ })
433
+
434
+ # Determine overall readiness: pass if all checks ok (warnings don't fail)
435
+ # For now, all checks must pass (warnings are just flags in detail)
436
+ all_ok = all(c["ok"] for c in checks)
437
+
438
+ return {
439
+ "ready": all_ok,
440
+ "checks": checks,
441
+ }
442
+
443
+
444
+ def main(argv=None):
445
+ """CLI entry point."""
446
+ argv = sys.argv[1:] if argv is None else argv
447
+
448
+ root_dir = None
449
+ state_dir = None
450
+ output_format = "text"
451
+
452
+ # Parse arguments
453
+ i = 0
454
+ while i < len(argv):
455
+ arg = argv[i]
456
+ if arg == "--root":
457
+ i += 1
458
+ if i < len(argv):
459
+ root_dir = argv[i]
460
+ i += 1
461
+ elif arg.startswith("--root="):
462
+ root_dir = arg[len("--root="):]
463
+ i += 1
464
+ elif arg == "--state-root":
465
+ i += 1
466
+ if i < len(argv):
467
+ state_dir = argv[i]
468
+ i += 1
469
+ elif arg.startswith("--state-root="):
470
+ state_dir = arg[len("--state-root="):]
471
+ i += 1
472
+ elif arg == "--json":
473
+ output_format = "json"
474
+ i += 1
475
+ else:
476
+ print(f"Unknown argument: {arg}", file=sys.stderr)
477
+ return 2
478
+
479
+ if root_dir is None:
480
+ root_dir = Path.cwd()
481
+ else:
482
+ root_dir = Path(root_dir)
483
+
484
+ config = load_config(root_dir)
485
+ if state_dir is None:
486
+ state_dir = resolve_state_dir(root_dir, config)
487
+ else:
488
+ state_dir = Path(state_dir)
489
+
490
+ result = run_checks(root_dir, state_dir, config)
491
+
492
+ if output_format == "json":
493
+ print(json.dumps(result, indent=2))
494
+ else:
495
+ # Text format: numbered list
496
+ print("Wave preflight checks:")
497
+ for i, check in enumerate(result["checks"], 1):
498
+ status = "PASS" if check["ok"] else "FAIL"
499
+ print(f"{i}. {check['name']}: {status}")
500
+ if check["detail"]:
501
+ print(f" {check['detail']}")
502
+
503
+ if result["ready"]:
504
+ print("\nPASS: Ready for wave")
505
+ else:
506
+ print("\nFAIL: Not ready for wave (see failures above)")
507
+
508
+ return 0 if result["ready"] else 1
509
+
510
+
511
+ if __name__ == "__main__":
512
+ sys.exit(main())