@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -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/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -0,0 +1,232 @@
1
+ #!/usr/bin/env python3
2
+ """Quality scorecard collector — per-agent-specialty success rates and retry frequencies.
3
+
4
+ This module provides get_quality_scorecard() which parses the outcomes ledger
5
+ and returns per-agent-specialty quality metrics: success rate (green/total)
6
+ and retry/repair frequency, derived from ledger agent_type + verdict columns.
7
+
8
+ Ledger format (same as cost.py):
9
+ | ISO timestamp | agent_type | model | duration | tokens_in | tokens_out | verdict |
10
+ | 2026-07-11T22:08:17 | haiku | claude-haiku-4-5-20251001 | 0 | 8 | 186 | OK |
11
+
12
+ QualityScorecard JSON shape (returned by get_quality_scorecard()):
13
+ {
14
+ "specialties": {
15
+ "agent-type": {
16
+ "total_runs": int,
17
+ "success_count": int,
18
+ "failed_count": int,
19
+ "empty_count": int,
20
+ "hung_count": int,
21
+ "success_rate": float (0.0-1.0),
22
+ "repair_count": int (consecutive failures followed by success),
23
+ "retry_frequency": float (repairs / total_runs)
24
+ },
25
+ ...
26
+ },
27
+ "top_by_success": [
28
+ {"agent_type": str, "success_rate": float, "total_runs": int},
29
+ ...
30
+ ],
31
+ "top_by_retry": [
32
+ {"agent_type": str, "retry_frequency": float, "total_runs": int},
33
+ ...
34
+ ],
35
+ "skipped_lines": int
36
+ }
37
+
38
+ Key behavior:
39
+ - Missing ledger file: returns empty summary with documented shape.
40
+ - Malformed lines: skipped and counted in skipped_lines field.
41
+ - Config read at CALL time (not import time) for test isolation.
42
+ - UTF-8 explicit encoding for all file operations.
43
+ - No external dependencies (pure stdlib).
44
+ - Retry frequency calculated by detecting "failure -> success" transitions (repair cycles).
45
+ - Rankings sorted by metric descending (highest success rate / highest retry frequency first).
46
+ """
47
+ import json
48
+ from pathlib import Path
49
+
50
+ import config
51
+
52
+
53
+ def get_quality_scorecard():
54
+ """Parse the outcomes ledger and return per-agent-specialty quality metrics.
55
+
56
+ Reads from the ledger path exposed by config (config.STATE_DIR/ledger/OUTCOMES-LEDGER.md).
57
+ Returns an empty summary with documented shape if ledger is missing or empty.
58
+ Malformed lines are skipped and counted in skipped_lines.
59
+
60
+ Extracts agent_type from the ledger (haiku, sonnet, opus, orchestrator, etc.) and
61
+ aggregates success rates and repair (retry) frequencies.
62
+
63
+ Repair cycles detected by counting consecutive failures followed by a success
64
+ in the same agent_type's history.
65
+
66
+ Returns:
67
+ dict: QualityScorecard with specialties, top_by_success, top_by_retry,
68
+ and skipped_lines (or error field if invalid).
69
+ """
70
+ import sys
71
+
72
+ # Read ledger path at call time
73
+ ledger_file = config.STATE_DIR / "ledger" / "OUTCOMES-LEDGER.md"
74
+
75
+ # Initialize result structure
76
+ result = {
77
+ "specialties": {},
78
+ "top_by_success": [],
79
+ "top_by_retry": [],
80
+ "skipped_lines": 0,
81
+ }
82
+
83
+ # If ledger file doesn't exist, return empty summary
84
+ if not ledger_file.exists():
85
+ return result
86
+
87
+ # Read and parse ledger with explicit UTF-8 encoding
88
+ try:
89
+ content = ledger_file.read_text(encoding='utf-8')
90
+ except Exception:
91
+ # Graceful: if read fails, return empty summary
92
+ return result
93
+
94
+ lines = content.strip().split('\n')
95
+
96
+ # Parse each line and collect per-agent-type history
97
+ agent_verdicts = {} # agent_type -> [verdict1, verdict2, ...]
98
+
99
+ for line in lines:
100
+ line = line.strip()
101
+
102
+ # Skip empty lines
103
+ if not line:
104
+ continue
105
+
106
+ # Skip header separator lines (all dashes, pipes, and spaces)
107
+ if all(c in '|- ' for c in line):
108
+ continue
109
+
110
+ # Parse pipe-delimited row
111
+ if not line.startswith('|') or not line.endswith('|'):
112
+ result["skipped_lines"] += 1
113
+ continue
114
+
115
+ # Split by pipe and strip whitespace
116
+ parts = [p.strip() for p in line.split('|')]
117
+
118
+ if len(parts) < 9: # Need at least 9 parts (empty + 7 columns + empty)
119
+ result["skipped_lines"] += 1
120
+ continue
121
+
122
+ # Extract columns (skip leading/trailing empty)
123
+ try:
124
+ timestamp = parts[1] # ISO timestamp
125
+ agent_type = parts[2] # "haiku", "sonnet", "orchestrator", etc.
126
+ model = parts[3]
127
+ duration_str = parts[4]
128
+ tokens_in_str = parts[5]
129
+ tokens_out_str = parts[6]
130
+ verdict = parts[7]
131
+ except IndexError:
132
+ result["skipped_lines"] += 1
133
+ continue
134
+
135
+ # Skip header line
136
+ if 'timestamp' in timestamp.lower() or 'iso' in timestamp.lower():
137
+ continue
138
+
139
+ # Validate verdict before processing
140
+ if verdict not in ("OK", "FAILED", "EMPTY", "HUNG"):
141
+ result["skipped_lines"] += 1
142
+ continue
143
+
144
+ # Parse and validate numeric fields
145
+ try:
146
+ tokens_in = int(tokens_in_str)
147
+ tokens_out = int(tokens_out_str)
148
+ except ValueError:
149
+ result["skipped_lines"] += 1
150
+ continue
151
+
152
+ # Initialize agent_type entry if not seen
153
+ if agent_type not in result["specialties"]:
154
+ result["specialties"][agent_type] = {
155
+ "total_runs": 0,
156
+ "success_count": 0,
157
+ "failed_count": 0,
158
+ "empty_count": 0,
159
+ "hung_count": 0,
160
+ "success_rate": 0.0,
161
+ "repair_count": 0,
162
+ "retry_frequency": 0.0,
163
+ }
164
+ agent_verdicts[agent_type] = []
165
+
166
+ # Increment verdict counters
167
+ result["specialties"][agent_type]["total_runs"] += 1
168
+ if verdict == "OK":
169
+ result["specialties"][agent_type]["success_count"] += 1
170
+ elif verdict == "FAILED":
171
+ result["specialties"][agent_type]["failed_count"] += 1
172
+ elif verdict == "EMPTY":
173
+ result["specialties"][agent_type]["empty_count"] += 1
174
+ elif verdict == "HUNG":
175
+ result["specialties"][agent_type]["hung_count"] += 1
176
+
177
+ # Collect verdict history for repair detection
178
+ agent_verdicts[agent_type].append(verdict)
179
+
180
+ # Calculate success rates and detect repair cycles
181
+ for agent_type, stats in result["specialties"].items():
182
+ total = stats["total_runs"]
183
+ if total > 0:
184
+ stats["success_rate"] = stats["success_count"] / total
185
+ stats["retry_frequency"] = stats["repair_count"] / total
186
+
187
+ # Detect repair cycles (consecutive failures followed by success)
188
+ verdicts = agent_verdicts.get(agent_type, [])
189
+ repair_count = 0
190
+ in_failure_sequence = False
191
+
192
+ for verdict in verdicts:
193
+ if verdict in ("FAILED", "EMPTY", "HUNG"):
194
+ in_failure_sequence = True
195
+ elif verdict == "OK" and in_failure_sequence:
196
+ # Success after failures = repair cycle
197
+ repair_count += 1
198
+ in_failure_sequence = False
199
+
200
+ stats["repair_count"] = repair_count
201
+ if total > 0:
202
+ stats["retry_frequency"] = repair_count / total
203
+
204
+ # Generate top-by-success ranking (highest success rate first)
205
+ success_ranking = sorted(
206
+ [
207
+ {
208
+ "agent_type": agent_type,
209
+ "success_rate": stats["success_rate"],
210
+ "total_runs": stats["total_runs"],
211
+ }
212
+ for agent_type, stats in result["specialties"].items()
213
+ ],
214
+ key=lambda x: (-x["success_rate"], -x["total_runs"]), # Sort desc by success_rate, then runs
215
+ )
216
+ result["top_by_success"] = success_ranking
217
+
218
+ # Generate top-by-retry ranking (highest retry/repair frequency first)
219
+ retry_ranking = sorted(
220
+ [
221
+ {
222
+ "agent_type": agent_type,
223
+ "retry_frequency": stats["retry_frequency"],
224
+ "total_runs": stats["total_runs"],
225
+ }
226
+ for agent_type, stats in result["specialties"].items()
227
+ ],
228
+ key=lambda x: (-x["retry_frequency"], -x["total_runs"]), # Sort desc by retry_frequency, then runs
229
+ )
230
+ result["top_by_retry"] = retry_ranking
231
+
232
+ return result
package/ui/sse.py CHANGED
@@ -165,14 +165,14 @@ def collector_loop(stop_event):
165
165
  # Wave-19: Gate data section on mtimes+sizes to avoid expensive file reads every tick.
166
166
  # Only regenerate the snapshot if one of the underlying log files changed.
167
167
  try:
168
- backup_log_stat = config.BACKUP_LOG.stat() if config.BACKUP_LOG.exists() else None
168
+ backup_log_stat = config.BACKUP_LOG.stat() if config.BACKUP_LOG and config.BACKUP_LOG.exists() else None
169
169
  backup_log_mtime = backup_log_stat.st_mtime if backup_log_stat else None
170
170
  backup_log_size = backup_log_stat.st_size if backup_log_stat else None
171
171
  except OSError:
172
172
  backup_log_mtime = None
173
173
  backup_log_size = None
174
174
  try:
175
- alerts_log_stat = config.ALERTS_LOG.stat() if config.ALERTS_LOG.exists() else None
175
+ alerts_log_stat = config.ALERTS_LOG.stat() if config.ALERTS_LOG and config.ALERTS_LOG.exists() else None
176
176
  alerts_log_mtime = alerts_log_stat.st_mtime if alerts_log_stat else None
177
177
  alerts_log_size = alerts_log_stat.st_size if alerts_log_stat else None
178
178
  except OSError:
@@ -236,7 +236,7 @@ def collector_loop(stop_event):
236
236
 
237
237
  # Emit cost section (mtime+size-gated on the outcomes ledger)
238
238
  try:
239
- cost_stat = config.LEDGER_FILE.stat() if config.LEDGER_FILE.exists() else None
239
+ cost_stat = config.LEDGER_FILE.stat() if config.LEDGER_FILE and config.LEDGER_FILE.exists() else None
240
240
  cost_mtime = cost_stat.st_mtime if cost_stat else None
241
241
  cost_size = cost_stat.st_size if cost_stat else None
242
242
  except OSError:
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Wave audit tail — newest audit/verification outcomes (adversarial findings, verdicts).
4
+
5
+ Reads audit backlog and ledger to surface latest verification outcomes.
6
+ Compact live tail panel showing recent audit events and findings.
7
+
8
+ Returns audit tail data for the Activity view.
9
+ """
10
+ import json
11
+ import re
12
+ from datetime import datetime, timezone
13
+ from pathlib import Path
14
+ from typing import Dict, List, Optional
15
+
16
+ import config
17
+ import cost
18
+
19
+
20
+ def _parse_audit_backlog_recent() -> List[Dict]:
21
+ """Extract recent items from AUDIT-BACKLOG.md.
22
+
23
+ Returns the most recent 5-10 P0/P1 items (inflight, todo, or done).
24
+
25
+ Returns:
26
+ list: [{"status": "✅|🔵|⬜", "tier": "P0|P1", "tag": "[tag]", "title": str, ...}, ...]
27
+ """
28
+ try:
29
+ backlog_file = config.AUDIT_BACKLOG_FILE
30
+ if not backlog_file.exists():
31
+ return []
32
+
33
+ content = backlog_file.read_text(encoding='utf-8')
34
+ lines = content.split('\n')
35
+
36
+ recent_items = []
37
+ current_tier = None
38
+
39
+ for line in lines:
40
+ line_stripped = line.strip()
41
+
42
+ # Detect tier headers
43
+ if re.match(r'^##\s+(P0|P1|P2)', line_stripped):
44
+ match = re.match(r'^##\s+(P0|P1|P2)', line_stripped)
45
+ current_tier = match.group(1) if match else None
46
+ continue
47
+
48
+ if current_tier and line_stripped.startswith('- '):
49
+ # Extract status emoji, tag, and title
50
+ # Pattern: - 🔵 **[tag] Title**
51
+ match = re.search(r'(✅|🔵|⬜|⏸)\s+\*\*\[([^\]]+)\]\s+(.+?)\*\*', line_stripped)
52
+ if match:
53
+ status_emoji = match.group(1)
54
+ tag = match.group(2)
55
+ title = match.group(3)
56
+
57
+ recent_items.append({
58
+ "status": status_emoji,
59
+ "tier": current_tier,
60
+ "tag": tag,
61
+ "title": title,
62
+ "timestamp": None, # AUDIT-BACKLOG doesn't have timestamps
63
+ })
64
+
65
+ # Return most recent (last) items
66
+ return recent_items[-10:] if recent_items else []
67
+ except Exception as e:
68
+ print(f"[wave_audit_tail] Error parsing audit backlog: {e}")
69
+ return []
70
+
71
+
72
+ def _parse_ledger_recent_verdicts() -> List[Dict]:
73
+ """Extract recent verdict outcomes from OUTCOMES-LEDGER.md.
74
+
75
+ Looks for recent lines showing agent results: OK/FAILED/EMPTY/HUNG.
76
+
77
+ Ledger format (9-column, optionally 7-column legacy):
78
+ | ISO ts | agent_type | model | duration_sec | tokens_in | tokens_out | verdict | phase | wave |
79
+
80
+ Verdict whitelist: only OK, FAILED, EMPTY, HUNG are valid.
81
+ Forged/invalid verdicts are skipped.
82
+
83
+ Returns:
84
+ list: [{"agent": str, "verdict": "OK|FAILED|EMPTY|HUNG", "timestamp": iso, ...}, ...]
85
+ """
86
+ # Whitelist of valid verdicts
87
+ VALID_VERDICTS = {"OK", "FAILED", "EMPTY", "HUNG"}
88
+
89
+ try:
90
+ ledger_file = config.LEDGER_FILE
91
+ if not ledger_file.exists():
92
+ return []
93
+
94
+ content = ledger_file.read_text(encoding='utf-8')
95
+ lines = content.split('\n')
96
+
97
+ recent_verdicts = []
98
+
99
+ # Parse ledger table rows (supports 7-column and 9-column formats)
100
+ for line in reversed(lines[-50:]): # Check last 50 lines
101
+ line_stripped = line.strip()
102
+ # Look for table rows with verdict info
103
+ # Pattern: | timestamp | agent_type | model | ... | verdict | ... |
104
+ if '|' not in line_stripped:
105
+ continue
106
+
107
+ parts = [p.strip() for p in line_stripped.split('|')]
108
+
109
+ # Accept both 7-column (9 parts) and 9-column (11 parts) formats
110
+ # 7 columns: ['', col1, col2, col3, col4, col5, col6, col7, ''] -> parts[7] = verdict
111
+ # 9 columns: ['', col1, col2, col3, col4, col5, col6, col7, col8, col9, ''] -> parts[7] = verdict
112
+ if len(parts) < 9:
113
+ continue
114
+
115
+ # Try to extract timestamp (parts[1]), agent_type (parts[2]), and verdict (parts[7])
116
+ try:
117
+ timestamp_str = parts[1]
118
+ agent_str = parts[2]
119
+ # IMPORTANT: verdict is at parts[7], not parts[3] (which is model)
120
+ verdict_str = parts[7].upper()
121
+
122
+ # Validate verdict against whitelist
123
+ if verdict_str not in VALID_VERDICTS:
124
+ # Skip forged/invalid verdicts
125
+ continue
126
+
127
+ # Verify timestamp looks like ISO format
128
+ if 'T' not in timestamp_str and '-' not in timestamp_str[:10]:
129
+ continue
130
+
131
+ recent_verdicts.append({
132
+ "timestamp": timestamp_str,
133
+ "agent": agent_str.split('-')[-1][:13], # Short agent ID
134
+ "verdict": verdict_str,
135
+ })
136
+ except (IndexError, ValueError):
137
+ pass
138
+
139
+ # Return most recent (first in reversed list)
140
+ return recent_verdicts[-10:] if recent_verdicts else []
141
+ except Exception as e:
142
+ print(f"[wave_audit_tail] Error parsing ledger: {e}")
143
+ return []
144
+
145
+
146
+ def get_wave_audit_tail() -> Dict:
147
+ """Get audit tail data for the current wave.
148
+
149
+ Returns latest audit/verification outcomes as a compact tail.
150
+
151
+ Returns:
152
+ dict: {
153
+ "available": bool,
154
+ "audit_items": [
155
+ {
156
+ "type": "audit_backlog|verdict",
157
+ "timestamp": "2026-07-21T12:34:56Z" or null,
158
+ "status": "✅|🔵|⬜|...",
159
+ "tier": "P0|P1|P2",
160
+ "tag": "[sec]|[ui]|...",
161
+ "title": str,
162
+ "verdict": "OK|FAILED|...",
163
+ "agent": str,
164
+ },
165
+ ...
166
+ ],
167
+ "at": "2026-07-21T12:34:56Z"
168
+ }
169
+ """
170
+ try:
171
+ audit_items = []
172
+
173
+ # Get recent backlog items
174
+ backlog_items = _parse_audit_backlog_recent()
175
+ for item in backlog_items:
176
+ audit_items.append({
177
+ "type": "audit_backlog",
178
+ **item,
179
+ })
180
+
181
+ # Get recent verdict outcomes
182
+ verdict_items = _parse_ledger_recent_verdicts()
183
+ for item in verdict_items:
184
+ audit_items.append({
185
+ "type": "verdict",
186
+ **item,
187
+ })
188
+
189
+ # Sort by timestamp (nulls last) and limit to 15 most recent
190
+ def sort_key(item):
191
+ ts = item.get("timestamp")
192
+ if ts is None:
193
+ return ""
194
+ return ts
195
+
196
+ audit_items.sort(key=sort_key, reverse=True)
197
+ audit_items = audit_items[:15]
198
+
199
+ return {
200
+ "available": bool(audit_items),
201
+ "audit_items": audit_items,
202
+ "at": datetime.now(timezone.utc).isoformat() + "Z",
203
+ }
204
+ except Exception as e:
205
+ print(f"[wave_audit_tail] Uncaught error: {e}")
206
+ import traceback
207
+ traceback.print_exc()
208
+ return {
209
+ "available": False,
210
+ "error": str(e),
211
+ "audit_items": [],
212
+ "at": datetime.now(timezone.utc).isoformat() + "Z"
213
+ }