@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
@@ -1,21 +1,27 @@
1
1
  #!/usr/bin/env python3
2
2
  """
3
- ci_merge_wait.py CI-gated merge helper: wait for PR checks to conclude, then merge.
3
+ ci_merge_wait.py - CI-gated merge helper: wait for PR checks to conclude, then merge.
4
4
 
5
5
  Polls gh pr view until all status checks conclude (SUCCESS/FAILURE), then merges ONLY
6
6
  if all checks are SUCCESS. The gh pr merge call is STRUCTURALLY UNREACHABLE unless the
7
- status is SUCCESS this is the whole point (prevents merge-on-CI-failure edge cases).
7
+ status is SUCCESS - this is the whole point (prevents merge-on-CI-failure edge cases).
8
+
9
+ Fail-closed semantics: empty rollup -> PENDING by default; use --allow-no-checks to treat
10
+ empty rollups as SUCCESS. Use --expect-checks to require specific named checks to be present
11
+ and successful before merging.
8
12
 
9
13
  Usage:
10
14
  python ci_merge_wait.py <PR-number> [--timeout SECONDS] [--poll SECONDS] [--merge-method merge|squash|rebase]
11
- [--dry-run] [--self-test]
15
+ [--dry-run] [--allow-no-checks] [--expect-checks NAME1,NAME2,...] [--self-test]
12
16
 
13
17
  Options:
14
- --timeout SECONDS Max seconds to wait for CI to conclude (default: 3600)
15
- --poll SECONDS Poll interval in seconds (default: 10)
16
- --merge-method METHOD Merge strategy: merge, squash, rebase (default: merge)
17
- --dry-run Skip actual merge, just verify CI status and report what would happen
18
- --self-test Run offline self-test of polling/decision logic (no network, no PR required)
18
+ --timeout SECONDS Max seconds to wait for CI (default: 3600)
19
+ --poll SECONDS Poll interval in seconds (default: 10)
20
+ --merge-method METHOD Merge strategy: merge, squash, rebase (default: merge)
21
+ --dry-run Skip actual merge, just verify CI status and report what would happen
22
+ --allow-no-checks Allow merge when no CI checks present (repos without CI)
23
+ --expect-checks NAME1,NAME2 Comma-separated list of checks that MUST be present and successful
24
+ --self-test Run offline self-test of polling/decision logic (no network, no PR required)
19
25
 
20
26
  Exit codes:
21
27
  0 = PR merged successfully, dry-run verified, or self-test passed
@@ -73,7 +79,7 @@ def get_pr_status(pr_number):
73
79
  return data
74
80
 
75
81
 
76
- def check_ci_status(status_rollup):
82
+ def check_ci_status(status_rollup, allow_no_checks=False, expected_checks=None):
77
83
  """
78
84
  Analyze status check rollup from gh pr view --json statusCheckRollup.
79
85
  Handles both CheckRun (status + conclusion) and StatusContext (state) entries.
@@ -95,15 +101,35 @@ def check_ci_status(status_rollup):
95
101
 
96
102
  GitHub semantics: NEUTRAL and SKIPPED conclusions/states are non-blocking and do not prevent merge.
97
103
 
104
+ Empty rollup (fail-closed):
105
+ - If allow_no_checks=True: ("success", None)
106
+ - If allow_no_checks=False: ("pending", None) [fail-closed default]
107
+
108
+ Expected checks (if provided):
109
+ - ALL named checks must be present in rollup
110
+ - ALL named checks must be in SUCCESS state
111
+ - If any expected check is missing: ("pending", None)
112
+ - If any expected check fails: ("failure", check_name)
113
+
114
+ Args:
115
+ status_rollup: list of check dicts from gh pr view
116
+ allow_no_checks: if True, empty rollup → success; if False (default), empty rollup → pending
117
+ expected_checks: set of check names that MUST be present and successful
118
+
98
119
  Returns: ("pending", None), ("success", None), or ("failure", check_name)
99
120
  """
121
+ # Fail-closed: empty rollup defaults to PENDING unless explicitly allowed
100
122
  if not status_rollup:
101
- # No checks (unusual but treat as success)
102
- return ("success", None)
123
+ if allow_no_checks:
124
+ return ("success", None)
125
+ else:
126
+ # Empty rollup: fail-closed to PENDING (window where checks vanished/haven't registered yet)
127
+ return ("pending", None)
103
128
 
104
129
  # Collect statuses
105
130
  pending_checks = []
106
131
  failed_checks = []
132
+ found_checks = {} # Map of check name → status
107
133
 
108
134
  for check in status_rollup:
109
135
  check_name = check.get("name", "unknown")
@@ -117,10 +143,16 @@ def check_ci_status(status_rollup):
117
143
 
118
144
  if status == "COMPLETED":
119
145
  # Check conclusion for failure indicators
120
- if conclusion and conclusion.upper() in ("FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"):
146
+ if conclusion and conclusion.upper() in ("FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE", "STALE"):
121
147
  check_status = "failure"
148
+ elif not conclusion or conclusion == "":
149
+ # COMPLETED with null/empty conclusion = fail-closed to PENDING (API anomaly)
150
+ check_status = "pending"
151
+ elif conclusion.upper() in ("NEUTRAL", "SKIPPED"):
152
+ # Non-blocking advisory or skipped checks
153
+ check_status = "success"
122
154
  else:
123
- # COMPLETED with no/empty conclusion or other conclusion = success
155
+ # Other conclusion values (unknown) = success (GitHub default)
124
156
  check_status = "success"
125
157
  elif status in ("QUEUED", "IN_PROGRESS"):
126
158
  check_status = "pending"
@@ -148,13 +180,44 @@ def check_ci_status(status_rollup):
148
180
  # Unrecognized shape (no status or state field) - fail-closed
149
181
  check_status = "pending"
150
182
 
183
+ # Record this check's status
184
+ found_checks[check_name] = check_status
185
+
151
186
  # Collect check status
152
187
  if check_status == "failure":
153
188
  failed_checks.append(check_name)
154
189
  elif check_status == "pending":
155
190
  pending_checks.append(check_name)
156
191
 
157
- # Determine overall status
192
+ # If expected checks provided, verify all are present and successful
193
+ if expected_checks:
194
+ for expected_name in expected_checks:
195
+ if expected_name not in found_checks:
196
+ # Expected check not found (missing in the window)
197
+ return ("pending", None)
198
+ if found_checks[expected_name] == "failure":
199
+ # Expected check failed
200
+ return ("failure", expected_name)
201
+ if found_checks[expected_name] == "pending":
202
+ # Expected check still pending
203
+ return ("pending", None)
204
+
205
+ # All expected checks passed - warn about any non-expected check failures but don't block
206
+ # Non-expected checks are informational/flaky third-party checks; they should not gate merge
207
+ non_expected_failures = []
208
+ for check_name, check_status in found_checks.items():
209
+ if check_name not in expected_checks and check_status == "failure":
210
+ non_expected_failures.append(check_name)
211
+
212
+ if non_expected_failures:
213
+ # Log the failures loudly but don't block merge
214
+ print(f"WARNING: Non-expected check(s) failed (informational, does not block merge): {', '.join(non_expected_failures)}")
215
+
216
+ # All expected checks passed - merge is allowed even if non-expected checks are failing
217
+ # (pending non-expected checks are also acceptable)
218
+ return ("success", None)
219
+
220
+ # Determine overall status (when expected_checks is not specified)
158
221
  if failed_checks:
159
222
  return ("failure", failed_checks[0])
160
223
  elif pending_checks:
@@ -191,17 +254,17 @@ def run_self_test():
191
254
  """
192
255
  print("Running self-test with real GitHub API payloads...")
193
256
 
194
- # Test 1: CheckRun success case (COMPLETED with no conclusion)
257
+ # Test 1: CheckRun success case (COMPLETED with explicit success conclusion)
195
258
  checkrun_success = [
196
- {"name": "test-unit", "status": "COMPLETED", "conclusion": None},
197
- {"name": "test-integration", "status": "COMPLETED", "conclusion": ""},
198
- {"name": "lint", "status": "COMPLETED", "conclusion": None},
259
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": "SUCCESS"},
260
+ {"name": "test-integration", "status": "COMPLETED", "conclusion": "SUCCESS"},
261
+ {"name": "lint", "status": "COMPLETED", "conclusion": None}, # null conclusion = fail-closed to PENDING
199
262
  ]
200
263
  ci_status, failed_check = check_ci_status(checkrun_success)
201
- if ci_status != "success":
202
- print(f"FAIL: Expected 'success' for CheckRun COMPLETED, got '{ci_status}'")
264
+ if ci_status != "pending":
265
+ print(f"FAIL: Expected 'pending' for COMPLETED with null conclusion (fail-closed), got '{ci_status}'")
203
266
  return False
204
- print("[OK] CheckRun success case (COMPLETED + no conclusion)")
267
+ print("[OK] CheckRun with null conclusion fails-closed to PENDING")
205
268
 
206
269
  # Test 2: CheckRun in-progress case (should be pending)
207
270
  checkrun_pending = [
@@ -257,7 +320,7 @@ def run_self_test():
257
320
 
258
321
  # Test 7: Mixed CheckRun and StatusContext (all success)
259
322
  mixed_success = [
260
- {"name": "test-unit", "status": "COMPLETED", "conclusion": None},
323
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": "SUCCESS"},
261
324
  {"name": "travis-ci", "state": "success"},
262
325
  ]
263
326
  ci_status, _ = check_ci_status(mixed_success)
@@ -268,7 +331,7 @@ def run_self_test():
268
331
 
269
332
  # Test 8: Mixed payloads with pending (should block merge)
270
333
  mixed_pending = [
271
- {"name": "test-unit", "status": "COMPLETED", "conclusion": None},
334
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": "SUCCESS"},
272
335
  {"name": "lint", "status": "QUEUED", "conclusion": None},
273
336
  ]
274
337
  ci_status, _ = check_ci_status(mixed_pending)
@@ -277,14 +340,21 @@ def run_self_test():
277
340
  return False
278
341
  print("[OK] Mixed payloads with QUEUED (pending)")
279
342
 
280
- # Test 9: No checks (treat as success)
343
+ # Test 9: Empty checks array (fail-closed: should be PENDING by default)
281
344
  ci_status, _ = check_ci_status([])
345
+ if ci_status != "pending":
346
+ print(f"FAIL: Expected 'pending' for empty rollup (fail-closed), got '{ci_status}'")
347
+ return False
348
+ print("[OK] Empty rollup case: treated as PENDING (fail-closed)")
349
+
350
+ # Test 10: Empty checks array with --allow-no-checks flag (should be SUCCESS)
351
+ ci_status, _ = check_ci_status([], allow_no_checks=True)
282
352
  if ci_status != "success":
283
- print(f"FAIL: Expected 'success' for no checks, got '{ci_status}'")
353
+ print(f"FAIL: Expected 'success' for empty rollup with allow_no_checks=True, got '{ci_status}'")
284
354
  return False
285
- print("[OK] No-checks case: treated as success")
355
+ print("[OK] Empty rollup with allow_no_checks=True: treated as SUCCESS")
286
356
 
287
- # Test 10: Unrecognized shape (fail-closed: should not succeed)
357
+ # Test 11: Unrecognized shape (fail-closed: should not succeed)
288
358
  unrecognized = [
289
359
  {"name": "mystery-check"},
290
360
  ]
@@ -294,7 +364,7 @@ def run_self_test():
294
364
  return False
295
365
  print("[OK] Unrecognized check shape (fail-closed)")
296
366
 
297
- # Test 11: CheckRun cancelled (counts as failure)
367
+ # Test 12: CheckRun cancelled (counts as failure)
298
368
  cancelled_check = [
299
369
  {"name": "test-unit", "status": "COMPLETED", "conclusion": "CANCELLED"},
300
370
  ]
@@ -304,7 +374,7 @@ def run_self_test():
304
374
  return False
305
375
  print("[OK] CheckRun CANCELLED (failure)")
306
376
 
307
- # Test 12: CheckRun neutral conclusion (non-blocking advisory, should be success)
377
+ # Test 13: CheckRun neutral conclusion (non-blocking advisory, should be success)
308
378
  neutral_check = [
309
379
  {"name": "advisory-lint", "status": "COMPLETED", "conclusion": "NEUTRAL"},
310
380
  ]
@@ -314,7 +384,7 @@ def run_self_test():
314
384
  return False
315
385
  print("[OK] CheckRun NEUTRAL conclusion (success, non-blocking)")
316
386
 
317
- # Test 13: CheckRun skipped conclusion (non-blocking, should be success)
387
+ # Test 14: CheckRun skipped conclusion (non-blocking, should be success)
318
388
  skipped_check = [
319
389
  {"name": "optional-test", "status": "COMPLETED", "conclusion": "SKIPPED"},
320
390
  ]
@@ -324,7 +394,7 @@ def run_self_test():
324
394
  return False
325
395
  print("[OK] CheckRun SKIPPED conclusion (success, non-blocking)")
326
396
 
327
- # Test 14: StatusContext neutral state (non-blocking advisory, should be success)
397
+ # Test 15: StatusContext neutral state (non-blocking advisory, should be success)
328
398
  neutral_state = [
329
399
  {"name": "advisory-check", "state": "neutral"},
330
400
  ]
@@ -334,7 +404,7 @@ def run_self_test():
334
404
  return False
335
405
  print("[OK] StatusContext neutral state (success, non-blocking)")
336
406
 
337
- # Test 15: StatusContext skipped state (non-blocking, should be success)
407
+ # Test 16: StatusContext skipped state (non-blocking, should be success)
338
408
  skipped_state = [
339
409
  {"name": "optional-check", "state": "skipped"},
340
410
  ]
@@ -344,7 +414,7 @@ def run_self_test():
344
414
  return False
345
415
  print("[OK] StatusContext skipped state (success, non-blocking)")
346
416
 
347
- # Test 16: Fabricated unknown state (fail-closed to pending)
417
+ # Test 17: Fabricated unknown state (fail-closed to pending)
348
418
  unknown_state = [
349
419
  {"name": "mystery-state", "state": "fabricated_unknown_state"},
350
420
  ]
@@ -354,6 +424,133 @@ def run_self_test():
354
424
  return False
355
425
  print("[OK] Fabricated unknown state (pending, fail-closed)")
356
426
 
427
+ # Test 18: Expected checks - all present and successful
428
+ rollup_with_expected = [
429
+ {"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
430
+ {"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
431
+ {"name": "lint", "status": "COMPLETED", "conclusion": "SUCCESS"},
432
+ ]
433
+ ci_status, _ = check_ci_status(
434
+ rollup_with_expected,
435
+ expected_checks={"unit-tests", "integration-tests"}
436
+ )
437
+ if ci_status != "success":
438
+ print(f"FAIL: Expected 'success' with expected checks present, got '{ci_status}'")
439
+ return False
440
+ print("[OK] Expected checks all present and successful")
441
+
442
+ # Test 19: Expected checks - one missing (should be PENDING)
443
+ rollup_missing_expected = [
444
+ {"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
445
+ {"name": "lint", "status": "COMPLETED", "conclusion": "SUCCESS"},
446
+ ]
447
+ ci_status, _ = check_ci_status(
448
+ rollup_missing_expected,
449
+ expected_checks={"unit-tests", "integration-tests"}
450
+ )
451
+ if ci_status != "pending":
452
+ print(f"FAIL: Expected 'pending' with missing expected check, got '{ci_status}'")
453
+ return False
454
+ print("[OK] Missing expected check (window transition): returns PENDING")
455
+
456
+ # Test 20: Expected checks - one failed (should be FAILURE)
457
+ rollup_failed_expected = [
458
+ {"name": "unit-tests", "status": "COMPLETED", "conclusion": "FAILURE"},
459
+ {"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
460
+ ]
461
+ ci_status, failed_check = check_ci_status(
462
+ rollup_failed_expected,
463
+ expected_checks={"unit-tests", "integration-tests"}
464
+ )
465
+ if ci_status != "failure" or failed_check != "unit-tests":
466
+ print(f"FAIL: Expected 'failure' with expected check failed, got '{ci_status}' / '{failed_check}'")
467
+ return False
468
+ print("[OK] Expected check failed: returns FAILURE")
469
+
470
+ # Test 20b: Expected checks all pass, but non-expected check FAILED (P2 audit bug fix)
471
+ # FIXED: When --expect-checks is given, non-expected failures should NOT block merge.
472
+ # Non-expected checks are informational/flaky third-party checks; only expected checks gate merge.
473
+ # The failure is logged loudly as a warning, but merge is allowed.
474
+ rollup_expected_pass_noncxpected_fail = [
475
+ {"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
476
+ {"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
477
+ {"name": "lint", "status": "COMPLETED", "conclusion": "FAILURE"},
478
+ ]
479
+ ci_status, failed_check = check_ci_status(
480
+ rollup_expected_pass_noncxpected_fail,
481
+ expected_checks={"unit-tests", "integration-tests"}
482
+ )
483
+ if ci_status != "success":
484
+ print(f"FAIL: Expected 'success' with non-expected check failed (audit P2 fix), got '{ci_status}'")
485
+ return False
486
+ print("[OK] Non-expected check failed while expected pass: returns SUCCESS with warning (P2 audit fix)")
487
+
488
+ # Test 20c: Expected checks all pass, non-expected pending is OK (does not block)
489
+ rollup_expected_pass_noncexpected_pending = [
490
+ {"name": "unit-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
491
+ {"name": "integration-tests", "status": "COMPLETED", "conclusion": "SUCCESS"},
492
+ {"name": "optional-scan", "status": "IN_PROGRESS", "conclusion": None},
493
+ ]
494
+ ci_status, _ = check_ci_status(
495
+ rollup_expected_pass_noncexpected_pending,
496
+ expected_checks={"unit-tests", "integration-tests"}
497
+ )
498
+ if ci_status != "success":
499
+ print(f"FAIL: Expected 'success' when all expected pass + non-expected pending, got '{ci_status}'")
500
+ return False
501
+ print("[OK] Expected all pass, non-expected pending: returns SUCCESS (pending non-expected is OK)")
502
+
503
+ # Test 22: CheckRun STALE conclusion (invalidated check, counts as failure)
504
+ stale_check = [
505
+ {"name": "ci-run", "status": "COMPLETED", "conclusion": "STALE"},
506
+ ]
507
+ ci_status, _ = check_ci_status(stale_check)
508
+ if ci_status != "failure":
509
+ print(f"FAIL: Expected 'failure' for STALE conclusion, got '{ci_status}'")
510
+ return False
511
+ print("[OK] CheckRun STALE conclusion (failure, invalidated by force-push)")
512
+
513
+ # Test 23: CheckRun COMPLETED with null conclusion (API anomaly, fail-closed)
514
+ null_conclusion_check = [
515
+ {"name": "test-suite", "status": "COMPLETED", "conclusion": None},
516
+ ]
517
+ ci_status, _ = check_ci_status(null_conclusion_check)
518
+ if ci_status != "pending":
519
+ print(f"FAIL: Expected 'pending' for COMPLETED + null conclusion (fail-closed), got '{ci_status}'")
520
+ return False
521
+ print("[OK] CheckRun COMPLETED + null conclusion fails-closed to PENDING")
522
+
523
+ # Test 21: Superseded-run window simulation (old checks vanish, new pending appear)
524
+ # First: old run had checks that completed successfully
525
+ old_run_checks = [
526
+ {"name": "build", "status": "COMPLETED", "conclusion": "SUCCESS"},
527
+ {"name": "test", "status": "COMPLETED", "conclusion": "SUCCESS"},
528
+ ]
529
+ ci_status, _ = check_ci_status(old_run_checks)
530
+ if ci_status != "success":
531
+ print(f"FAIL: Old run checks should be success, got '{ci_status}'")
532
+ return False
533
+
534
+ # Second: transition window where old checks vanished and new run hasn't registered yet
535
+ # Empty rollup should return PENDING (fail-closed)
536
+ empty_window = []
537
+ ci_status, _ = check_ci_status(empty_window)
538
+ if ci_status != "pending":
539
+ print(f"FAIL: Transition window (empty rollup) should be PENDING (fail-closed), got '{ci_status}'")
540
+ return False
541
+
542
+ # Third: new run appears with pending checks
543
+ new_run_checks = [
544
+ {"name": "build", "status": "IN_PROGRESS", "conclusion": None},
545
+ {"name": "test", "status": "QUEUED", "conclusion": None},
546
+ ]
547
+ ci_status, _ = check_ci_status(new_run_checks)
548
+ if ci_status != "pending":
549
+ print(f"FAIL: New run pending checks should be PENDING, got '{ci_status}'")
550
+ return False
551
+
552
+ print("[OK] Superseded-run window simulation: transitions correctly")
553
+
357
554
  print("\nAll self-tests passed!")
358
555
  return True
359
556
 
@@ -393,6 +590,17 @@ def main():
393
590
  action="store_true",
394
591
  help="Skip actual merge, just verify CI status and report what would happen"
395
592
  )
593
+ parser.add_argument(
594
+ "--allow-no-checks",
595
+ action="store_true",
596
+ help="Allow merge when no CI checks present (repos without CI)"
597
+ )
598
+ parser.add_argument(
599
+ "--expect-checks",
600
+ type=str,
601
+ default=None,
602
+ help="Comma-separated list of checks that MUST be present and successful"
603
+ )
396
604
  parser.add_argument(
397
605
  "--self-test",
398
606
  action="store_true",
@@ -422,6 +630,11 @@ def main():
422
630
  print("ERROR: timeout and poll must be positive")
423
631
  sys.exit(1)
424
632
 
633
+ # Parse expected checks
634
+ expected_checks = None
635
+ if args.expect_checks:
636
+ expected_checks = set(c.strip() for c in args.expect_checks.split(",") if c.strip())
637
+
425
638
  # Fetch initial PR status
426
639
  print(f"Checking PR #{args.pr_number} status...")
427
640
  status = get_pr_status(args.pr_number)
@@ -452,7 +665,11 @@ def main():
452
665
  sys.exit(1)
453
666
 
454
667
  status_rollup = status.get("statusCheckRollup", [])
455
- ci_status, failed_check = check_ci_status(status_rollup)
668
+ ci_status, failed_check = check_ci_status(
669
+ status_rollup,
670
+ allow_no_checks=args.allow_no_checks,
671
+ expected_checks=expected_checks
672
+ )
456
673
 
457
674
  if ci_status == "failure":
458
675
  print(f"CI FAILED: {failed_check}")
@@ -466,7 +683,11 @@ def main():
466
683
  sys.exit(1)
467
684
 
468
685
  final_rollup = final_check.get("statusCheckRollup", [])
469
- final_ci_status, final_failed = check_ci_status(final_rollup)
686
+ final_ci_status, final_failed = check_ci_status(
687
+ final_rollup,
688
+ allow_no_checks=args.allow_no_checks,
689
+ expected_checks=expected_checks
690
+ )
470
691
 
471
692
  if final_ci_status != "success":
472
693
  print(f"CI STATUS CHANGED: {final_failed}")