@matt82198/aesop 0.1.0-beta.5 → 0.1.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 (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -75,7 +75,26 @@ def get_pr_status(pr_number):
75
75
 
76
76
  def check_ci_status(status_rollup):
77
77
  """
78
- Analyze status check rollup.
78
+ Analyze status check rollup from gh pr view --json statusCheckRollup.
79
+ Handles both CheckRun (status + conclusion) and StatusContext (state) entries.
80
+
81
+ CheckRun classification:
82
+ - status=COMPLETED + conclusion in (FAILURE, CANCELLED, TIMED_OUT, ACTION_REQUIRED, STARTUP_FAILURE) = FAILURE
83
+ - status=COMPLETED + conclusion in (NEUTRAL, SKIPPED) or None/empty = SUCCESS (non-blocking per GitHub)
84
+ - status in (QUEUED, IN_PROGRESS) = PENDING
85
+ - status not recognized = PENDING (fail-closed)
86
+
87
+ StatusContext classification:
88
+ - state='success' = SUCCESS
89
+ - state in ('failure', 'error') = FAILURE
90
+ - state='pending' = PENDING
91
+ - state in ('neutral', 'skipped') = SUCCESS (non-blocking advisory checks)
92
+ - state not recognized = PENDING (fail-closed)
93
+
94
+ Unrecognized check shapes (no status/state field) = PENDING (fail-closed).
95
+
96
+ GitHub semantics: NEUTRAL and SKIPPED conclusions/states are non-blocking and do not prevent merge.
97
+
79
98
  Returns: ("pending", None), ("success", None), or ("failure", check_name)
80
99
  """
81
100
  if not status_rollup:
@@ -87,11 +106,53 @@ def check_ci_status(status_rollup):
87
106
  failed_checks = []
88
107
 
89
108
  for check in status_rollup:
90
- status = check.get("status", "").upper()
91
- if status == "PENDING":
92
- pending_checks.append(check.get("name", "unknown"))
93
- elif status == "FAILURE":
94
- failed_checks.append(check.get("name", "unknown"))
109
+ check_name = check.get("name", "unknown")
110
+ check_status = None
111
+
112
+ # Determine check classification (CheckRun or StatusContext or unrecognized)
113
+ if "status" in check:
114
+ # CheckRun entry (has 'status' field)
115
+ status = check.get("status", "").upper()
116
+ conclusion = check.get("conclusion", "")
117
+
118
+ if status == "COMPLETED":
119
+ # Check conclusion for failure indicators
120
+ if conclusion and conclusion.upper() in ("FAILURE", "CANCELLED", "TIMED_OUT", "ACTION_REQUIRED", "STARTUP_FAILURE"):
121
+ check_status = "failure"
122
+ else:
123
+ # COMPLETED with no/empty conclusion or other conclusion = success
124
+ check_status = "success"
125
+ elif status in ("QUEUED", "IN_PROGRESS"):
126
+ check_status = "pending"
127
+ else:
128
+ # Unrecognized status value (fail-closed)
129
+ check_status = "pending"
130
+
131
+ elif "state" in check:
132
+ # StatusContext entry (has 'state' field)
133
+ state = check.get("state", "").lower()
134
+ if state == "success":
135
+ check_status = "success"
136
+ elif state in ("failure", "error"):
137
+ check_status = "failure"
138
+ elif state == "pending":
139
+ check_status = "pending"
140
+ elif state in ("neutral", "skipped"):
141
+ # Non-blocking advisory or skipped checks (GitHub semantics)
142
+ check_status = "success"
143
+ else:
144
+ # Unrecognized state value (fail-closed)
145
+ check_status = "pending"
146
+
147
+ else:
148
+ # Unrecognized shape (no status or state field) - fail-closed
149
+ check_status = "pending"
150
+
151
+ # Collect check status
152
+ if check_status == "failure":
153
+ failed_checks.append(check_name)
154
+ elif check_status == "pending":
155
+ pending_checks.append(check_name)
95
156
 
96
157
  # Determine overall status
97
158
  if failed_checks:
@@ -124,54 +185,174 @@ def merge_pr(pr_number, merge_method, dry_run=False):
124
185
 
125
186
  def run_self_test():
126
187
  """
127
- Run self-test with mocked CI status checks.
128
- No network calls; verifies the merge guard logic.
188
+ Run self-test with real GitHub API payload structures.
189
+ No network calls; verifies the merge guard logic with CheckRun and StatusContext payloads.
129
190
  Returns True if all tests pass, False otherwise.
130
191
  """
131
- print("Running self-test with mocked CI statuses...")
192
+ print("Running self-test with real GitHub API payloads...")
132
193
 
133
- # Mock status rollup: all checks SUCCESS
134
- success_rollup = [
135
- {"name": "test-unit", "status": "SUCCESS"},
136
- {"name": "test-integration", "status": "SUCCESS"},
137
- {"name": "lint", "status": "SUCCESS"},
194
+ # Test 1: CheckRun success case (COMPLETED with no conclusion)
195
+ checkrun_success = [
196
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": None},
197
+ {"name": "test-integration", "status": "COMPLETED", "conclusion": ""},
198
+ {"name": "lint", "status": "COMPLETED", "conclusion": None},
138
199
  ]
139
-
140
- # Test 1: success case
141
- ci_status, failed_check = check_ci_status(success_rollup)
200
+ ci_status, failed_check = check_ci_status(checkrun_success)
142
201
  if ci_status != "success":
143
- print(f"FAIL: Expected 'success', got '{ci_status}'")
202
+ print(f"FAIL: Expected 'success' for CheckRun COMPLETED, got '{ci_status}'")
144
203
  return False
145
- print("[OK] success case: merge guard permits merge")
204
+ print("[OK] CheckRun success case (COMPLETED + no conclusion)")
146
205
 
147
- # Test 2: pending case
148
- pending_rollup = [
149
- {"name": "test-unit", "status": "PENDING"},
150
- {"name": "test-integration", "status": "SUCCESS"},
206
+ # Test 2: CheckRun in-progress case (should be pending)
207
+ checkrun_pending = [
208
+ {"name": "test-unit", "status": "IN_PROGRESS", "conclusion": None},
209
+ {"name": "test-integration", "status": "COMPLETED", "conclusion": None},
151
210
  ]
152
- ci_status, failed_check = check_ci_status(pending_rollup)
211
+ ci_status, failed_check = check_ci_status(checkrun_pending)
153
212
  if ci_status != "pending":
154
- print(f"FAIL: Expected 'pending', got '{ci_status}'")
213
+ print(f"FAIL: Expected 'pending' for IN_PROGRESS, got '{ci_status}'")
155
214
  return False
156
- print("[OK] pending case: merge guard blocks merge (structurally unreachable)")
215
+ print("[OK] CheckRun pending case (IN_PROGRESS)")
157
216
 
158
- # Test 3: failure case
159
- failure_rollup = [
160
- {"name": "test-unit", "status": "FAILURE"},
161
- {"name": "test-integration", "status": "SUCCESS"},
217
+ # Test 3: CheckRun failure case (COMPLETED with FAILURE conclusion)
218
+ checkrun_failure = [
219
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": "FAILURE"},
220
+ {"name": "test-integration", "status": "COMPLETED", "conclusion": None},
162
221
  ]
163
- ci_status, failed_check = check_ci_status(failure_rollup)
222
+ ci_status, failed_check = check_ci_status(checkrun_failure)
164
223
  if ci_status != "failure" or failed_check != "test-unit":
165
224
  print(f"FAIL: Expected 'failure' with 'test-unit', got '{ci_status}' / '{failed_check}'")
166
225
  return False
167
- print("[OK] failure case: merge guard blocks merge (structurally unreachable)")
226
+ print("[OK] CheckRun failure case (COMPLETED + FAILURE)")
168
227
 
169
- # Test 4: no checks (treat as success)
228
+ # Test 4: StatusContext success case (state=success)
229
+ statuscontext_success = [
230
+ {"name": "continuous-integration/travis-ci/push", "state": "success"},
231
+ ]
232
+ ci_status, failed_check = check_ci_status(statuscontext_success)
233
+ if ci_status != "success":
234
+ print(f"FAIL: Expected 'success' for StatusContext state=success, got '{ci_status}'")
235
+ return False
236
+ print("[OK] StatusContext success case (state=success)")
237
+
238
+ # Test 5: StatusContext failure case (state=failure)
239
+ statuscontext_failure = [
240
+ {"name": "continuous-integration/travis-ci/push", "state": "failure"},
241
+ ]
242
+ ci_status, failed_check = check_ci_status(statuscontext_failure)
243
+ if ci_status != "failure" or failed_check != "continuous-integration/travis-ci/push":
244
+ print(f"FAIL: Expected 'failure' with StatusContext, got '{ci_status}' / '{failed_check}'")
245
+ return False
246
+ print("[OK] StatusContext failure case (state=failure)")
247
+
248
+ # Test 6: StatusContext pending case (state=pending)
249
+ statuscontext_pending = [
250
+ {"name": "continuous-integration/travis-ci/push", "state": "pending"},
251
+ ]
252
+ ci_status, failed_check = check_ci_status(statuscontext_pending)
253
+ if ci_status != "pending":
254
+ print(f"FAIL: Expected 'pending' for StatusContext state=pending, got '{ci_status}'")
255
+ return False
256
+ print("[OK] StatusContext pending case (state=pending)")
257
+
258
+ # Test 7: Mixed CheckRun and StatusContext (all success)
259
+ mixed_success = [
260
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": None},
261
+ {"name": "travis-ci", "state": "success"},
262
+ ]
263
+ ci_status, _ = check_ci_status(mixed_success)
264
+ if ci_status != "success":
265
+ print(f"FAIL: Expected 'success' for mixed payloads, got '{ci_status}'")
266
+ return False
267
+ print("[OK] Mixed CheckRun + StatusContext success case")
268
+
269
+ # Test 8: Mixed payloads with pending (should block merge)
270
+ mixed_pending = [
271
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": None},
272
+ {"name": "lint", "status": "QUEUED", "conclusion": None},
273
+ ]
274
+ ci_status, _ = check_ci_status(mixed_pending)
275
+ if ci_status != "pending":
276
+ print(f"FAIL: Expected 'pending' for QUEUED check, got '{ci_status}'")
277
+ return False
278
+ print("[OK] Mixed payloads with QUEUED (pending)")
279
+
280
+ # Test 9: No checks (treat as success)
170
281
  ci_status, _ = check_ci_status([])
171
282
  if ci_status != "success":
172
283
  print(f"FAIL: Expected 'success' for no checks, got '{ci_status}'")
173
284
  return False
174
- print("[OK] no-checks case: treated as success")
285
+ print("[OK] No-checks case: treated as success")
286
+
287
+ # Test 10: Unrecognized shape (fail-closed: should not succeed)
288
+ unrecognized = [
289
+ {"name": "mystery-check"},
290
+ ]
291
+ ci_status, _ = check_ci_status(unrecognized)
292
+ if ci_status == "success":
293
+ print(f"FAIL: Expected not 'success' for unrecognized shape, got '{ci_status}'")
294
+ return False
295
+ print("[OK] Unrecognized check shape (fail-closed)")
296
+
297
+ # Test 11: CheckRun cancelled (counts as failure)
298
+ cancelled_check = [
299
+ {"name": "test-unit", "status": "COMPLETED", "conclusion": "CANCELLED"},
300
+ ]
301
+ ci_status, _ = check_ci_status(cancelled_check)
302
+ if ci_status != "failure":
303
+ print(f"FAIL: Expected 'failure' for CANCELLED, got '{ci_status}'")
304
+ return False
305
+ print("[OK] CheckRun CANCELLED (failure)")
306
+
307
+ # Test 12: CheckRun neutral conclusion (non-blocking advisory, should be success)
308
+ neutral_check = [
309
+ {"name": "advisory-lint", "status": "COMPLETED", "conclusion": "NEUTRAL"},
310
+ ]
311
+ ci_status, _ = check_ci_status(neutral_check)
312
+ if ci_status != "success":
313
+ print(f"FAIL: Expected 'success' for NEUTRAL conclusion, got '{ci_status}'")
314
+ return False
315
+ print("[OK] CheckRun NEUTRAL conclusion (success, non-blocking)")
316
+
317
+ # Test 13: CheckRun skipped conclusion (non-blocking, should be success)
318
+ skipped_check = [
319
+ {"name": "optional-test", "status": "COMPLETED", "conclusion": "SKIPPED"},
320
+ ]
321
+ ci_status, _ = check_ci_status(skipped_check)
322
+ if ci_status != "success":
323
+ print(f"FAIL: Expected 'success' for SKIPPED conclusion, got '{ci_status}'")
324
+ return False
325
+ print("[OK] CheckRun SKIPPED conclusion (success, non-blocking)")
326
+
327
+ # Test 14: StatusContext neutral state (non-blocking advisory, should be success)
328
+ neutral_state = [
329
+ {"name": "advisory-check", "state": "neutral"},
330
+ ]
331
+ ci_status, _ = check_ci_status(neutral_state)
332
+ if ci_status != "success":
333
+ print(f"FAIL: Expected 'success' for StatusContext state=neutral, got '{ci_status}'")
334
+ return False
335
+ print("[OK] StatusContext neutral state (success, non-blocking)")
336
+
337
+ # Test 15: StatusContext skipped state (non-blocking, should be success)
338
+ skipped_state = [
339
+ {"name": "optional-check", "state": "skipped"},
340
+ ]
341
+ ci_status, _ = check_ci_status(skipped_state)
342
+ if ci_status != "success":
343
+ print(f"FAIL: Expected 'success' for StatusContext state=skipped, got '{ci_status}'")
344
+ return False
345
+ print("[OK] StatusContext skipped state (success, non-blocking)")
346
+
347
+ # Test 16: Fabricated unknown state (fail-closed to pending)
348
+ unknown_state = [
349
+ {"name": "mystery-state", "state": "fabricated_unknown_state"},
350
+ ]
351
+ ci_status, _ = check_ci_status(unknown_state)
352
+ if ci_status != "pending":
353
+ print(f"FAIL: Expected 'pending' for unknown state, got '{ci_status}'")
354
+ return False
355
+ print("[OK] Fabricated unknown state (pending, fail-closed)")
175
356
 
176
357
  print("\nAll self-tests passed!")
177
358
  return True
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Common utilities shared across tools.
4
+
5
+ Functions:
6
+ get_state_dir() -> Path
7
+ Resolve state directory from AESOP_STATE_ROOT env var or default to ./state
8
+
9
+ check_heartbeat_staleness(hb_file, threshold_s) -> (is_stale, age_s, info)
10
+ Check if a heartbeat file is stale and return staleness, age, and descriptive info
11
+ """
12
+
13
+ import os
14
+ import time
15
+ from pathlib import Path
16
+
17
+
18
+ def get_state_dir():
19
+ """Resolve state directory from env var or current working directory.
20
+
21
+ Returns:
22
+ Path: Directory path for state files. Either from AESOP_STATE_ROOT env var
23
+ or defaults to ./state relative to cwd.
24
+ """
25
+ if os.environ.get("AESOP_STATE_ROOT"):
26
+ return Path(os.environ["AESOP_STATE_ROOT"])
27
+ # Default to ./state (relative to cwd)
28
+ return Path.cwd() / "state"
29
+
30
+
31
+ def check_heartbeat_staleness(hb_file, threshold_s):
32
+ """Check if a heartbeat file is stale.
33
+
34
+ Args:
35
+ hb_file: Path to heartbeat file (contains epoch timestamp as first line)
36
+ threshold_s: Staleness threshold in seconds; age >= threshold is stale
37
+
38
+ Returns:
39
+ Tuple of (is_stale, age_s, info):
40
+ is_stale (bool): True if file missing, unreadable, or age >= threshold_s
41
+ age_s (int): Age in seconds (0 if file missing/unreadable)
42
+ info (str or None): Descriptive message if stale/missing, None if fresh
43
+ """
44
+ if not hb_file.exists():
45
+ return True, 0, "Heartbeat file missing"
46
+
47
+ try:
48
+ content = hb_file.read_text(encoding="utf-8").strip()
49
+ if not content:
50
+ return True, 0, "Heartbeat file empty"
51
+
52
+ timestamp = int(content)
53
+ except (ValueError, IOError):
54
+ return True, 0, "Heartbeat file unreadable"
55
+
56
+ age_seconds = int(time.time()) - timestamp
57
+ age_seconds = max(0, age_seconds) # Never negative
58
+
59
+ if age_seconds >= threshold_s:
60
+ return True, age_seconds, f"Heartbeat stale ({age_seconds}s >= {threshold_s}s)"
61
+
62
+ return False, age_seconds, None
@@ -0,0 +1,191 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Cost ceiling guard — trips tools/halt.py's kill switch when fleet spend
4
+ (tokens) meets or exceeds a configured ceiling.
5
+
6
+ Why: wave-26 critique — autonomy expanded to self-merging portfolio PRs on
7
+ green with no ceiling/cap/limit/abort anywhere in the harness. This is the cap.
8
+
9
+ Configuration (aesop.config.json):
10
+ "limits": {
11
+ "max_wave_tokens": null, # null = disabled (opt-in)
12
+ "max_daily_tokens": null
13
+ }
14
+
15
+ Spend source: an explicit --spent/spent= figure always wins. Otherwise spend
16
+ is read from the cost ledger (tools/fleet_ledger.py's OUTCOMES-LEDGER.md under
17
+ the resolved state dir): sum of tokens_in + tokens_out across all ledger rows.
18
+ Missing/unreadable ledger -> spend of 0 (never trips on a ledger that doesn't
19
+ exist yet).
20
+
21
+ API:
22
+ check(spent=None, period="wave", config=None, state_dir=None, trip=True) -> dict
23
+ Returns {"period", "ceiling", "spent", "exceeded", "tripped"}.
24
+ When exceeded and trip=True, calls tools/halt.py's halt() with a reason
25
+ describing the breach, and "tripped" is True. When ceiling is None
26
+ (unconfigured), exceeded is always False and nothing is ever tripped.
27
+
28
+ CLI:
29
+ python tools/cost_ceiling.py --check --spent N [--period wave|daily]
30
+ Exit 0 if not exceeded (or ceiling unconfigured), exit 1 if exceeded
31
+ (and thus tripped, unless already halted).
32
+ """
33
+
34
+ import argparse
35
+ import json
36
+ import sys
37
+ from pathlib import Path
38
+ from datetime import datetime, timezone
39
+
40
+ try:
41
+ import halt
42
+ import fleet_ledger
43
+ except ImportError:
44
+ from tools import halt
45
+ from tools import fleet_ledger
46
+
47
+
48
+ def load_config():
49
+ """Load aesop.config.json from current directory, return dict (or {} if absent/bad)."""
50
+ config_file = Path("aesop.config.json")
51
+ if not config_file.exists():
52
+ return {}
53
+ try:
54
+ with open(config_file, "r", encoding="utf-8") as f:
55
+ return json.load(f)
56
+ except Exception as e:
57
+ print(f"[cost_ceiling] Failed to load config: {e}", file=sys.stderr)
58
+ return {}
59
+
60
+
61
+ def get_ceiling(config, period):
62
+ """Extract the configured ceiling for `period` ('wave' or 'daily'), or None."""
63
+ limits = config.get("limits", {}) if isinstance(config, dict) else {}
64
+ if not isinstance(limits, dict):
65
+ return None
66
+ key = f"max_{period}_tokens"
67
+ value = limits.get(key)
68
+ if value is None:
69
+ return None
70
+ try:
71
+ return int(value)
72
+ except (TypeError, ValueError):
73
+ return None
74
+
75
+
76
+ def read_ledger_total_tokens(state_dir, period="wave"):
77
+ """Sum tokens_in + tokens_out from OUTCOMES-LEDGER.md.
78
+
79
+ Args:
80
+ state_dir: path to state directory
81
+ period: "wave" (all rows) or "daily" (today's rows only, filtered by UTC date)
82
+
83
+ Returns 0 if the ledger doesn't exist or is unreadable/empty.
84
+ Uses fleet_ledger.py's shared parser (single source of truth).
85
+ """
86
+ # Set the state root temporarily so fleet_ledger can find the ledger
87
+ import os
88
+ old_state_root = os.environ.get("AESOP_STATE_ROOT")
89
+ try:
90
+ os.environ["AESOP_STATE_ROOT"] = str(state_dir)
91
+ rows = fleet_ledger.parse_ledger_rows()
92
+ finally:
93
+ if old_state_root is not None:
94
+ os.environ["AESOP_STATE_ROOT"] = old_state_root
95
+ else:
96
+ os.environ.pop("AESOP_STATE_ROOT", None)
97
+
98
+ if not rows:
99
+ return 0
100
+
101
+ total = 0
102
+
103
+ if period == "daily":
104
+ # Filter to today's UTC date only
105
+ today_utc = datetime.now(timezone.utc).date()
106
+ for row in rows:
107
+ # Extract date from ISO timestamp (format: YYYY-MM-DDTHH:MM:SSZ or similar)
108
+ try:
109
+ iso_ts = row['iso_ts']
110
+ # Parse the date part (first 10 characters: YYYY-MM-DD)
111
+ row_date = datetime.fromisoformat(iso_ts.replace('Z', '+00:00')).date()
112
+ if row_date == today_utc:
113
+ total += row['tokens_in'] + row['tokens_out']
114
+ except (ValueError, IndexError, KeyError):
115
+ # Skip malformed timestamps
116
+ continue
117
+ else:
118
+ # period == "wave": sum all rows
119
+ for row in rows:
120
+ total += row['tokens_in'] + row['tokens_out']
121
+
122
+ return total
123
+
124
+
125
+ def check(spent=None, period="wave", config=None, state_dir=None, trip=True):
126
+ """Check spend against the configured ceiling for `period`.
127
+
128
+ Returns a dict: {"period", "ceiling", "spent", "exceeded", "tripped"}.
129
+ """
130
+ if config is None:
131
+ config = load_config()
132
+ if state_dir is None:
133
+ state_dir = halt.resolve_state_dir(config=config)
134
+ state_dir = Path(state_dir)
135
+
136
+ ceiling = get_ceiling(config, period)
137
+
138
+ if spent is None:
139
+ spent = read_ledger_total_tokens(state_dir, period=period)
140
+ spent = int(spent)
141
+
142
+ exceeded = ceiling is not None and spent >= ceiling
143
+ tripped = False
144
+
145
+ if exceeded and trip:
146
+ reason = (
147
+ f"cost ceiling exceeded: {period} spend {spent} tokens >= "
148
+ f"ceiling {ceiling} tokens"
149
+ )
150
+ halt.halt(reason, state_dir=state_dir)
151
+ tripped = True
152
+
153
+ return {
154
+ "period": period,
155
+ "ceiling": ceiling,
156
+ "spent": spent,
157
+ "exceeded": exceeded,
158
+ "tripped": tripped,
159
+ }
160
+
161
+
162
+ def main(argv=None):
163
+ parser = argparse.ArgumentParser(description="Cost ceiling guard for the fleet kill switch.")
164
+ parser.add_argument("--check", action="store_true", help="Run the ceiling check (required).")
165
+ parser.add_argument("--spent", type=int, default=None, help="Explicit spend figure in tokens; defaults to ledger total.")
166
+ parser.add_argument("--period", choices=("wave", "daily"), default="wave", help="Which ceiling to check against (default: wave).")
167
+ args = parser.parse_args(argv)
168
+
169
+ if not args.check:
170
+ parser.print_usage(sys.stderr)
171
+ return 2
172
+
173
+ result = check(spent=args.spent, period=args.period)
174
+
175
+ if result["ceiling"] is None:
176
+ print(f"[cost_ceiling] no {args.period} ceiling configured — skipping (spent={result['spent']})")
177
+ return 0
178
+
179
+ if result["exceeded"]:
180
+ print(
181
+ f"[cost_ceiling] EXCEEDED: {args.period} spend {result['spent']} >= "
182
+ f"ceiling {result['ceiling']} — HALT tripped"
183
+ )
184
+ return 1
185
+
186
+ print(f"[cost_ceiling] ok: {args.period} spend {result['spent']} < ceiling {result['ceiling']}")
187
+ return 0
188
+
189
+
190
+ if __name__ == "__main__":
191
+ sys.exit(main())
package/tools/dash.js ADDED
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Aesop dash — Launch the web dashboard
5
+ *
6
+ * Spawns python ui/serve.py with the configured port.
7
+ * Honors PORT environment variable, aesop.config.json dashboard.port, or defaults to 8770.
8
+ * Prints the dashboard URL before launching.
9
+ */
10
+
11
+ const fs = require('fs');
12
+ const path = require('path');
13
+ const { spawn, spawnSync } = require('child_process');
14
+
15
+ const CURRENT_DIR = process.cwd();
16
+
17
+ // Resolve Python interpreter portably (prefer python3, fallback to python)
18
+ function resolvePythonInterpreter() {
19
+ if (spawnSync('python3', ['--version'], { stdio: 'ignore' }).error === undefined) {
20
+ return 'python3';
21
+ }
22
+ if (spawnSync('python', ['--version'], { stdio: 'ignore' }).error === undefined) {
23
+ return 'python';
24
+ }
25
+ return null;
26
+ }
27
+
28
+ function loadConfig() {
29
+ const configPath = path.join(CURRENT_DIR, 'aesop.config.json');
30
+ try {
31
+ if (!fs.existsSync(configPath)) {
32
+ return null;
33
+ }
34
+ const content = fs.readFileSync(configPath, 'utf8');
35
+ return JSON.parse(content);
36
+ } catch (e) {
37
+ return null;
38
+ }
39
+ }
40
+
41
+ (async function main() {
42
+ try {
43
+ // Verify dashboard script exists
44
+ const dashScript = path.join(CURRENT_DIR, 'ui', 'serve.py');
45
+ if (!fs.existsSync(dashScript)) {
46
+ console.error(`Error: dashboard script not found at ${dashScript}`);
47
+ console.error('Are you running this from the aesop root directory?');
48
+ process.exitCode = 1;
49
+ return;
50
+ }
51
+
52
+ // Determine port: env var > config > default
53
+ let port = 8770;
54
+
55
+ if (process.env.PORT) {
56
+ const envPort = parseInt(process.env.PORT, 10);
57
+ if (!isNaN(envPort) && envPort > 0 && envPort < 65536) {
58
+ port = envPort;
59
+ }
60
+ } else {
61
+ const config = loadConfig();
62
+ if (config && config.dashboard && config.dashboard.port) {
63
+ const configPort = parseInt(config.dashboard.port, 10);
64
+ if (!isNaN(configPort) && configPort > 0 && configPort < 65536) {
65
+ port = configPort;
66
+ }
67
+ }
68
+ }
69
+
70
+ console.log(`Launching aesop dashboard on http://localhost:${port}\n`);
71
+
72
+ // Resolve Python interpreter
73
+ const pythonExe = resolvePythonInterpreter();
74
+ if (!pythonExe) {
75
+ console.error('Error: python3 or python not found on PATH');
76
+ console.error('Please install Python 3 to run the dashboard');
77
+ process.exitCode = 1;
78
+ return;
79
+ }
80
+
81
+ // Spawn the dashboard script with PORT env var set
82
+ const proc = spawn(pythonExe, [dashScript], {
83
+ cwd: CURRENT_DIR,
84
+ stdio: 'inherit',
85
+ env: { ...process.env, PORT: port.toString() },
86
+ shell: process.platform === 'win32'
87
+ });
88
+
89
+ // Exit with the dashboard process's exit code
90
+ proc.on('exit', (code) => {
91
+ process.exitCode = code || 0;
92
+ });
93
+
94
+ proc.on('error', (err) => {
95
+ console.error(`Error spawning dashboard: ${err.message}`);
96
+ process.exitCode = 1;
97
+ });
98
+ } catch (err) {
99
+ console.error(`Error launching dashboard: ${err.message}`);
100
+ process.exitCode = 1;
101
+ }
102
+ })();