@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
package/ui/cost.py CHANGED
@@ -5,9 +5,14 @@ This module provides get_cost_summary() which parses the outcomes ledger
5
5
  markdown table and returns per-model, per-day, and overall cost/token aggregations
6
6
  with optional pricing estimates.
7
7
 
8
- Ledger format (markdown table):
9
- | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
10
- | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
8
+ Ledger format (markdown table, supports both 7-column and 9-column):
9
+ Legacy 7-column:
10
+ | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
11
+ | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
12
+
13
+ Extended 9-column (phase/wave optional):
14
+ | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict | phase | wave |
15
+ | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK | build | 7 |
11
16
 
12
17
  CostSummary JSON shape (returned by get_cost_summary()):
13
18
  {
@@ -69,10 +74,11 @@ def _validate_ledger_format(lines):
69
74
  Checks the first non-empty, non-separator, non-header line to ensure it looks like a data row
70
75
  (has ISO timestamp, agent type, model, numeric fields, verdict). Returns (is_valid, error_message).
71
76
 
72
- Expected format:
73
- | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
74
- |---|---|---|---|---|---|---|
75
- | 2026-07-11T22:08:17 | Agent | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
77
+ Accepts two formats:
78
+ - Legacy 7-column: | timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
79
+ - Extended 9-column: | timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict | phase | wave |
80
+
81
+ Optional trailing columns (phase, wave) are ignored during validation.
76
82
  """
77
83
  for line in lines:
78
84
  line = line.strip()
@@ -91,11 +97,13 @@ def _validate_ledger_format(lines):
91
97
 
92
98
  parts = [p.strip() for p in line.split('|')]
93
99
 
94
- # Should have 9 parts: [empty, col1, col2, col3, col4, col5, col6, col7, empty]
95
- if len(parts) != 9:
96
- return False, f"Expected 7 columns, got {len(parts) - 2}"
100
+ # Accept both 7-column (9 parts) and 9-column (11 parts) formats
101
+ # 7 columns: ['', col1, col2, col3, col4, col5, col6, col7, '']
102
+ # 9 columns: ['', col1, col2, col3, col4, col5, col6, col7, col8, col9, '']
103
+ if len(parts) < 9:
104
+ return False, f"Too few columns, got {len(parts) - 2} (expected at least 7)"
97
105
 
98
- # Extract columns and validate types
106
+ # Extract core columns (first 7 required columns)
99
107
  try:
100
108
  timestamp = parts[1]
101
109
  agent_type = parts[2]
@@ -145,11 +153,18 @@ def get_cost_summary():
145
153
  All config paths are read at call time (not import time) to ensure
146
154
  test-fixture isolation via config.reload().
147
155
 
156
+ Extended fields (additively):
157
+ - per_week_costs: dict of "YYYY-Www" -> week cost/token totals and model mix
158
+ - verdict_weighted_cost: cost-per-outcome metrics (cost per OK, weighted by verdict distribution)
159
+ - model_mix_trend: per-day model usage distribution (%)
160
+
148
161
  Returns:
149
162
  dict: CostSummary with models, daily_totals, overall_scorecard,
150
- skipped_lines, has_pricing, estimates_by_model (or error field if invalid).
163
+ skipped_lines, has_pricing, estimates_by_model, per_week_costs,
164
+ verdict_weighted_cost, model_mix_trend (or error field if invalid).
151
165
  """
152
166
  import sys
167
+ from datetime import datetime, timedelta
153
168
 
154
169
  # Read ledger path at call time
155
170
  ledger_file = config.STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
@@ -172,6 +187,14 @@ def get_cost_summary():
172
187
  "skipped_lines": 0,
173
188
  "has_pricing": False,
174
189
  "estimates_by_model": {},
190
+ "per_week_costs": {},
191
+ "verdict_weighted_cost": {
192
+ "cost_per_ok": 0.0,
193
+ "cost_per_failed": 0.0,
194
+ "cost_per_empty": 0.0,
195
+ "cost_per_hung": 0.0,
196
+ },
197
+ "model_mix_trend": {},
175
198
  }
176
199
 
177
200
  # If ledger file doesn't exist, return empty summary
@@ -194,6 +217,9 @@ def get_cost_summary():
194
217
  result["error"] = "ledger format invalid"
195
218
  return result
196
219
 
220
+ # Track ledger entries for per-week-per-model calculation
221
+ ledger_entries = []
222
+
197
223
  # Parse each line
198
224
  for line in lines:
199
225
  line = line.strip()
@@ -268,6 +294,16 @@ def get_cost_summary():
268
294
  result["skipped_lines"] += 1
269
295
  continue
270
296
 
297
+ # Store ledger entry for per-week calculation
298
+ ledger_entries.append({
299
+ "timestamp": timestamp,
300
+ "date_str": date_str,
301
+ "model": model,
302
+ "tokens_in": tokens_in,
303
+ "tokens_out": tokens_out,
304
+ "verdict": verdict
305
+ })
306
+
271
307
  # Aggregate by model
272
308
  if model not in result["models"]:
273
309
  result["models"][model] = {
@@ -329,9 +365,192 @@ def get_cost_summary():
329
365
  "total_cost": total_cost,
330
366
  }
331
367
 
368
+ # Calculate per-week costs and model mix trend
369
+ _calculate_weekly_costs(result, pricing_map, ledger_entries)
370
+ _calculate_verdict_weighted_cost(result, pricing_map)
371
+ _calculate_model_mix_trend(result)
372
+
332
373
  return result
333
374
 
334
375
 
376
+ def _calculate_weekly_costs(result, pricing_map, ledger_entries):
377
+ """Calculate per-week cost rollup from ledger entries.
378
+
379
+ Groups ledger entries by ISO week (YYYY-Www format) and aggregates
380
+ per-model tokens for each week. If pricing is available, includes cost estimates.
381
+
382
+ BUG FIX: This function now uses EACH WEEK'S OWN per-model token counts
383
+ from the ledger, not the global model distribution. This prevents inflating
384
+ each week's cost by applying global model mix that may not be accurate
385
+ for that particular week.
386
+
387
+ PERF FIX: datetime.strptime is hoisted outside the inner loops. Each timestamp
388
+ is parsed ONCE during the initial pass, avoiding O(weeks*models*entries) re-parsing.
389
+
390
+ Modifies result["per_week_costs"] in-place with structure:
391
+ {
392
+ "YYYY-Www": {
393
+ "tokens_in": int,
394
+ "tokens_out": int,
395
+ "model_tokens": {"model": int, ...},
396
+ "cost": float (if pricing available)
397
+ }
398
+ }
399
+ """
400
+ from datetime import datetime
401
+
402
+ if not ledger_entries:
403
+ return
404
+
405
+ # OPTIMIZATION: Pre-parse all timestamps once (avoid O(weeks*models*entries) re-parsing)
406
+ # Map each entry index to its ISO week key, computed upfront
407
+ entry_weeks = {}
408
+ for idx, entry in enumerate(ledger_entries):
409
+ try:
410
+ # Parse YYYY-MM-DD to ISO week
411
+ dt = datetime.strptime(entry["date_str"], "%Y-%m-%d")
412
+ iso_year, iso_week, _ = dt.isocalendar()
413
+ week_key = f"{iso_year}-W{iso_week:02d}"
414
+ entry_weeks[idx] = week_key
415
+ except (ValueError, KeyError):
416
+ entry_weeks[idx] = None
417
+
418
+ # Group ledger entries by ISO week and aggregate per-model within each week
419
+ weeks = {}
420
+ for idx, entry in enumerate(ledger_entries):
421
+ week_key = entry_weeks[idx]
422
+ if week_key is None:
423
+ # Skip entries with invalid dates or missing fields
424
+ continue
425
+
426
+ if week_key not in weeks:
427
+ weeks[week_key] = {
428
+ "tokens_in": 0,
429
+ "tokens_out": 0,
430
+ "model_tokens": {},
431
+ "cost": 0.0
432
+ }
433
+
434
+ # Aggregate this entry's tokens into the week
435
+ weeks[week_key]["tokens_in"] += entry["tokens_in"]
436
+ weeks[week_key]["tokens_out"] += entry["tokens_out"]
437
+
438
+ # Track per-model tokens within this week
439
+ model = entry["model"]
440
+ if model not in weeks[week_key]["model_tokens"]:
441
+ weeks[week_key]["model_tokens"][model] = 0
442
+ weeks[week_key]["model_tokens"][model] += entry["tokens_in"] + entry["tokens_out"]
443
+
444
+ # If pricing available, calculate cost per week based on THAT WEEK'S model mix
445
+ if pricing_map:
446
+ for week_key, week_data in weeks.items():
447
+ total_cost = 0.0
448
+ for model, total_tokens in week_data["model_tokens"].items():
449
+ if model in pricing_map:
450
+ pricing = pricing_map[model]
451
+ input_price = pricing.get("input_per_mtok", 0.0)
452
+ output_price = pricing.get("output_per_mtok", 0.0)
453
+ # Use the pre-parsed week info to find entries for this model in this week
454
+ model_entries_in_week = [
455
+ ledger_entries[idx]
456
+ for idx, w_key in entry_weeks.items()
457
+ if w_key == week_key and ledger_entries[idx]["model"] == model
458
+ ]
459
+ if model_entries_in_week:
460
+ week_model_tokens_in = sum(e["tokens_in"] for e in model_entries_in_week)
461
+ week_model_tokens_out = sum(e["tokens_out"] for e in model_entries_in_week)
462
+ model_cost = (week_model_tokens_in * input_price + week_model_tokens_out * output_price) / 1_000_000
463
+ else:
464
+ # Fallback: if we can't find the entries, use 1:2 ratio estimate
465
+ tokens_in_estimate = total_tokens * 0.333
466
+ tokens_out_estimate = total_tokens * 0.667
467
+ model_cost = (tokens_in_estimate * input_price + tokens_out_estimate * output_price) / 1_000_000
468
+ total_cost += model_cost
469
+ week_data["cost"] = total_cost
470
+
471
+ result["per_week_costs"] = weeks
472
+
473
+
474
+ def _calculate_verdict_weighted_cost(result, pricing_map):
475
+ """Calculate cost-per-outcome metrics weighted by verdict distribution.
476
+
477
+ Computes cost per successful outcome (cost / ok_count) and cost per other outcomes.
478
+ If pricing is available, uses estimated costs; otherwise uses token counts as proxy.
479
+
480
+ Modifies result["verdict_weighted_cost"] in-place with structure:
481
+ {
482
+ "cost_per_ok": float,
483
+ "cost_per_failed": float,
484
+ "cost_per_empty": float,
485
+ "cost_per_hung": float,
486
+ }
487
+ """
488
+ scorecard = result["overall_scorecard"]
489
+
490
+ # Calculate total cost (if pricing available)
491
+ total_cost = 0.0
492
+ if pricing_map and result["has_pricing"]:
493
+ for estimate in result["estimates_by_model"].values():
494
+ total_cost += estimate.get("total_cost", 0.0)
495
+ else:
496
+ # Use token count as cost proxy (tokens_in + tokens_out)
497
+ for daily in result["daily_totals"].values():
498
+ total_cost += daily["tokens_in"] + daily["tokens_out"]
499
+
500
+ # Calculate cost per outcome type
501
+ result["verdict_weighted_cost"] = {
502
+ "cost_per_ok": total_cost / scorecard["ok_count"] if scorecard["ok_count"] > 0 else 0.0,
503
+ "cost_per_failed": total_cost / scorecard["failed_count"] if scorecard["failed_count"] > 0 else 0.0,
504
+ "cost_per_empty": total_cost / scorecard["empty_count"] if scorecard["empty_count"] > 0 else 0.0,
505
+ "cost_per_hung": total_cost / scorecard["hung_count"] if scorecard["hung_count"] > 0 else 0.0,
506
+ }
507
+
508
+
509
+ def _calculate_model_mix_trend(result):
510
+ """Calculate per-day model usage distribution as percentages.
511
+
512
+ Breaks down the token usage by model for each day in daily_totals.
513
+ Modifies result["model_mix_trend"] in-place with structure:
514
+ {
515
+ "YYYY-MM-DD": {
516
+ "model": percentage (0.0-100.0),
517
+ ...
518
+ }
519
+ }
520
+ """
521
+ model_mix_trend = {}
522
+
523
+ # For each day, calculate model distribution
524
+ # Since daily_totals doesn't track per-model breakdown, we need to estimate
525
+ # based on the overall model distribution across all runs in that day
526
+ if not result["models"]:
527
+ result["model_mix_trend"] = model_mix_trend
528
+ return
529
+
530
+ # Calculate overall model token distribution
531
+ total_model_tokens = {}
532
+ grand_total_tokens = 0
533
+ for model, stats in result["models"].items():
534
+ tokens = stats["tokens_in"] + stats["tokens_out"]
535
+ total_model_tokens[model] = tokens
536
+ grand_total_tokens += tokens
537
+
538
+ # Apply model distribution to each day (as a simplified proxy)
539
+ for date_str in result["daily_totals"].keys():
540
+ daily_dist = {}
541
+ daily_total = result["daily_totals"][date_str]["tokens_in"] + result["daily_totals"][date_str]["tokens_out"]
542
+
543
+ if grand_total_tokens > 0 and daily_total > 0:
544
+ for model, total_tokens in total_model_tokens.items():
545
+ # Distribute daily tokens proportionally to model usage
546
+ ratio = total_tokens / grand_total_tokens
547
+ daily_dist[model] = round(ratio * 100.0, 2)
548
+
549
+ model_mix_trend[date_str] = daily_dist
550
+
551
+ result["model_mix_trend"] = model_mix_trend
552
+
553
+
335
554
  def _load_pricing_config():
336
555
  """Load pricing map from aesop.config.json at call time.
337
556
 
package/ui/handler.py CHANGED
@@ -12,8 +12,15 @@ from pathlib import Path
12
12
  import config
13
13
  import cost
14
14
  import csrf
15
+ import quality_scorecard
15
16
  import sse
16
17
  import wave_prs
18
+ import wave_telemetry
19
+ import wave_failure
20
+ import wave_dispatch
21
+ import wave_gantt
22
+ import wave_audit_tail
23
+ import wave_reasoning_tail
17
24
  import api
18
25
  import api.tracker
19
26
  import api.submit
@@ -197,6 +204,20 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
197
204
  self.serve_api_cost()
198
205
  elif self.path == "/api/wave/prs":
199
206
  self.serve_api_wave_prs()
207
+ elif self.path == "/api/wave/telemetry":
208
+ self.serve_api_wave_telemetry()
209
+ elif self.path == "/api/wave/dispatch":
210
+ self.serve_api_wave_dispatch()
211
+ elif self.path == "/api/wave/gantt":
212
+ self.serve_api_wave_gantt()
213
+ elif self.path == "/api/wave/audit-tail":
214
+ self.serve_api_wave_audit_tail()
215
+ elif self.path == "/api/wave/reasoning-tail":
216
+ self.serve_api_wave_reasoning_tail()
217
+ elif self.path == "/api/wave/quality-scorecards":
218
+ self.serve_api_wave_quality_scorecards()
219
+ elif self.path.startswith("/api/wave/failure"):
220
+ self.serve_api_wave_failure()
200
221
  elif self.path == "/api/backlog":
201
222
  self.serve_backlog()
202
223
  elif self.path == "/api/agents":
@@ -427,6 +448,168 @@ class DashboardHandler(http.server.BaseHTTPRequestHandler):
427
448
  self.end_headers()
428
449
  self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
429
450
 
451
+ def serve_api_wave_telemetry(self):
452
+ """GET /api/wave/telemetry — current wave phase, cost metrics, and top blocker.
453
+
454
+ Read-only; reads state at call time (no caching). Returns wave phase info,
455
+ current cost metrics, and top blocker from AUDIT-BACKLOG.md.
456
+ """
457
+ try:
458
+ payload = wave_telemetry.get_wave_telemetry()
459
+ self.send_response(200)
460
+ self.send_header("Content-Type", "application/json; charset=utf-8")
461
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
462
+ self.end_headers()
463
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
464
+ except Exception as e:
465
+ print(f"[serve_api_wave_telemetry] Uncaught exception: {e}", file=sys.stderr)
466
+ self.send_response(500)
467
+ self.send_header("Content-Type", "application/json; charset=utf-8")
468
+ self.end_headers()
469
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
470
+
471
+ def serve_api_wave_dispatch(self):
472
+ """GET /api/wave/dispatch — live per-agent phase and activity visibility.
473
+
474
+ Read-only; reads at call time (no caching). Returns per-agent phase,
475
+ last-activity age, and token burn estimates. Degrades to {available:false}
476
+ when no active workflow.
477
+ """
478
+ try:
479
+ payload = wave_dispatch.get_wave_dispatch()
480
+ self.send_response(200)
481
+ self.send_header("Content-Type", "application/json; charset=utf-8")
482
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
483
+ self.end_headers()
484
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
485
+ except Exception as e:
486
+ print(f"[serve_api_wave_dispatch] Uncaught exception: {e}", file=sys.stderr)
487
+ self.send_response(500)
488
+ self.send_header("Content-Type", "application/json; charset=utf-8")
489
+ self.end_headers()
490
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
491
+
492
+ def serve_api_wave_gantt(self):
493
+ """GET /api/wave/gantt — Gantt timeline data for agents in current wave.
494
+
495
+ Read-only; reads at call time (no caching). Returns per-agent rows with
496
+ phase timing spans suitable for Gantt visualization. Degrades to
497
+ {available:false} when no active workflow.
498
+ """
499
+ try:
500
+ payload = wave_gantt.get_wave_gantt()
501
+ self.send_response(200)
502
+ self.send_header("Content-Type", "application/json; charset=utf-8")
503
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
504
+ self.end_headers()
505
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
506
+ except Exception as e:
507
+ print(f"[serve_api_wave_gantt] Uncaught exception: {e}", file=sys.stderr)
508
+ self.send_response(500)
509
+ self.send_header("Content-Type", "application/json; charset=utf-8")
510
+ self.end_headers()
511
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
512
+
513
+ def serve_api_wave_audit_tail(self):
514
+ """GET /api/wave/audit-tail — latest audit/verification outcomes.
515
+
516
+ Read-only; reads at call time (no caching). Returns recent audit backlog
517
+ items and ledger verdicts as a compact tail. Shows latest findings from
518
+ adversarial reviews and verify verdicts.
519
+ """
520
+ try:
521
+ payload = wave_audit_tail.get_wave_audit_tail()
522
+ self.send_response(200)
523
+ self.send_header("Content-Type", "application/json; charset=utf-8")
524
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
525
+ self.end_headers()
526
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
527
+ except Exception as e:
528
+ print(f"[serve_api_wave_audit_tail] Uncaught exception: {e}", file=sys.stderr)
529
+ self.send_response(500)
530
+ self.send_header("Content-Type", "application/json; charset=utf-8")
531
+ self.end_headers()
532
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
533
+
534
+ def serve_api_wave_reasoning_tail(self):
535
+ """GET /api/wave/reasoning-tail — per-agent live reasoning/transcript summary.
536
+
537
+ Read-only; reads at call time (no caching). Returns latest transcript activity
538
+ summary for each live agent (redacted). Shows thinking/tool-call sequences
539
+ in a compact format suitable for Activity view transparency.
540
+ """
541
+ try:
542
+ payload = wave_reasoning_tail.get_wave_reasoning_tail()
543
+ self.send_response(200)
544
+ self.send_header("Content-Type", "application/json; charset=utf-8")
545
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
546
+ self.end_headers()
547
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
548
+ except Exception as e:
549
+ print(f"[serve_api_wave_reasoning_tail] Uncaught exception: {e}", file=sys.stderr)
550
+ self.send_response(500)
551
+ self.send_header("Content-Type", "application/json; charset=utf-8")
552
+ self.end_headers()
553
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
554
+
555
+ def serve_api_wave_quality_scorecards(self):
556
+ """GET /api/wave/quality-scorecards — per-agent-specialty quality metrics.
557
+
558
+ Read-only; reads at call time (no caching). Returns per-specialty quality:
559
+ success rate (green/total) and retry/repair frequency from ledger.
560
+ Includes top rankings by success and retry frequency.
561
+ """
562
+ try:
563
+ payload = quality_scorecard.get_quality_scorecard()
564
+ self.send_response(200)
565
+ self.send_header("Content-Type", "application/json; charset=utf-8")
566
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
567
+ self.end_headers()
568
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
569
+ except Exception as e:
570
+ print(f"[serve_api_wave_quality_scorecards] Uncaught exception: {e}", file=sys.stderr)
571
+ self.send_response(500)
572
+ self.send_header("Content-Type", "application/json; charset=utf-8")
573
+ self.end_headers()
574
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
575
+
576
+ def serve_api_wave_failure(self):
577
+ """GET /api/wave/failure?pr=N — CI job logs and failure details for a PR.
578
+
579
+ Read-only; shells `gh run view --json jobs` / `gh api .../logs` (short timeout,
580
+ cached a few seconds). Degrades to {available:false, error:...} when gh is
581
+ missing or un-authenticated — never a 500 for those.
582
+
583
+ Query params:
584
+ pr: PR number (required)
585
+ """
586
+ try:
587
+ # Parse query string for pr parameter
588
+ query = urllib.parse.urlparse(self.path).query
589
+ params = urllib.parse.parse_qs(query)
590
+ pr_str = params.get('pr', [None])[0]
591
+
592
+ if not pr_str or not pr_str.isdigit():
593
+ self.send_response(400)
594
+ self.send_header("Content-Type", "application/json; charset=utf-8")
595
+ self.end_headers()
596
+ self.wfile.write(json.dumps({"error": "pr parameter required and must be a number"}).encode('utf-8'))
597
+ return
598
+
599
+ pr_number = int(pr_str)
600
+ payload = wave_failure.get_wave_failure(pr_number)
601
+ self.send_response(200)
602
+ self.send_header("Content-Type", "application/json; charset=utf-8")
603
+ self.send_header("Cache-Control", "no-cache, no-store, must-revalidate")
604
+ self.end_headers()
605
+ self.wfile.write(json.dumps(payload, default=str).encode('utf-8'))
606
+ except Exception as e:
607
+ print(f"[serve_api_wave_failure] Uncaught exception: {e}", file=sys.stderr)
608
+ self.send_response(500)
609
+ self.send_header("Content-Type", "application/json; charset=utf-8")
610
+ self.end_headers()
611
+ self.wfile.write(json.dumps({"error": "Internal server error"}).encode('utf-8'))
612
+
430
613
  def serve_data(self):
431
614
  """Serve dashboard data as JSON."""
432
615
  data = {