@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.5

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -0
@@ -0,0 +1,513 @@
1
+ """Browser-level proof for the wave-14 React dashboard (ui/web/dist/).
2
+
3
+ Drives the BUILT app served by `python ui/serve.py` against fixture fleet state,
4
+ asserting the contract via data-testid hooks only (never CSS internals):
5
+
6
+ Populated-state phase:
7
+ (a) console clean of errors across the whole run
8
+ (b) app serves React dist (not legacy template)
9
+ (c) health-header testid present and rendered
10
+ (d) view testids present (overview on first paint)
11
+ (e) inbox form testids (submit flow)
12
+ (f) cost view renders table/chart/scorecard from the fixture ledger
13
+ (g) CSS stylesheet loaded (reduced-motion media query proven by vitest)
14
+ (h) a11y live regions present (role=status or aria-live)
15
+ (j) SSE live update WITHOUT reload (tracker.json mutation appears in DOM)
16
+ (k) tracker round-trip through the real form: create -> proposed lane, claim/done move lanes
17
+ (l) hostile javascript: pr_link is inert in the real DOM (no javascript: href)
18
+ (m) keyboard-only agent-row expand; expansion SURVIVES an SSE agents update
19
+ (n) computed contrast of health-header label >= 4.5:1 in BOTH themes (toggle exercised)
20
+ (o) orchestrator-status phase=audit -> audit badge appears in a live region
21
+
22
+ Empty-state phase (separate boot, empty tracker/agents/alerts/backlog):
23
+ (i) all four views render their empty states with a clean console
24
+
25
+ Run: python tools/verify_dash.py (exit 0 = proven, 1 = failed)
26
+ python tools/verify_dash.py --allow-skip (exit 0 = proven or skipped, 1 = failed)
27
+
28
+ Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip is passed).
29
+ """
30
+ import argparse
31
+ import json
32
+ import os
33
+ import shutil
34
+ import socket
35
+ import subprocess
36
+ import sys
37
+ import tempfile
38
+ import time
39
+ from pathlib import Path
40
+
41
+ REPO = Path(__file__).resolve().parent.parent
42
+ SERVE = REPO / "ui" / "serve.py"
43
+
44
+
45
+ def _real_console_errors(console_errors, failed_urls):
46
+ """Drop noise from the captured console errors.
47
+
48
+ Playwright reports a failed sub-resource as a generic
49
+ "Failed to load resource: ... 404" console line that carries NO url, so
50
+ string-matching it is fragile. We instead correlate with the actual
51
+ failed-response urls: a generic resource-load line is ignorable when the
52
+ only failed responses were favicon (which the server now answers 204, so
53
+ this is belt-and-suspenders). Non-favicon failed responses are surfaced
54
+ explicitly so a real broken asset still fails the proof.
55
+ """
56
+ non_favicon = [u for u in failed_urls if "favicon" not in u.lower()]
57
+ real = []
58
+ for e in console_errors:
59
+ low = e.lower()
60
+ if "favicon" in low:
61
+ continue
62
+ if "failed to load resource" in low and not non_favicon:
63
+ continue # only favicon failed → ignore the urlless generic line
64
+ real.append(e)
65
+ real.extend(f"failed resource: {u}" for u in non_favicon)
66
+ return real
67
+
68
+ AGENT_FULL_ID = "verifyagent0123456789ab"
69
+ PROMPT_MARKER = "FIXTURE-PROMPT-MARKER: rebuild the flux capacitor\n" + "\n".join(
70
+ f"line {i}: recalibrate subsystem {i} and verify each tolerance band carefully"
71
+ for i in range(60))
72
+
73
+ FIXTURE_BACKLOG = """# Audit backlog — wave-14 verify_dash fixture
74
+
75
+ **Status legend:** ⬜ unclaimed · 🔵 dispatched · ✅ merged · ⏸ user call
76
+
77
+ ## P0 — correctness / security
78
+
79
+ - ✅ **[sec] Dashboard rewrite (U1 foundation).** completed.
80
+ - 🔵 **[ui] React component library (U4-U7).** in progress.
81
+
82
+ ## P1 — observability
83
+
84
+ - ⬜ **[cost] Cost analytics per model.** todo.
85
+
86
+ ## Landing log
87
+ - fixture
88
+ """
89
+
90
+ FIXTURE_LEDGER = """| timestamp | agent_type | model | duration_seconds | tokens_in | tokens_out | verdict |
91
+ | --- | --- | --- | --- | --- | --- | --- |
92
+ | 2026-07-13T14:00:00Z | orchestrator | claude-opus-4-20250805 | 120 | 50000 | 12000 | OK |
93
+ | 2026-07-13T14:02:30Z | haiku | claude-haiku-4-5-20251001 | 45 | 12000 | 3500 | OK |
94
+ | 2026-07-13T14:05:15Z | haiku | claude-haiku-4-5-20251001 | 50 | 14000 | 4200 | OK |
95
+ | 2026-07-13T14:08:00Z | sonnet | claude-sonnet-4-5-20250929 | 85 | 28000 | 8100 | OK |
96
+ | 2026-07-13T14:12:20Z | haiku | claude-haiku-4-5-20251001 | 40 | 11000 | 3200 | FAILED |
97
+ """
98
+
99
+ XSS_ITEM = {
100
+ "id": "fixturexss01",
101
+ "title": "fixture xss probe",
102
+ "priority": "P2",
103
+ "status": "todo",
104
+ "lane": "proposed",
105
+ "source": "verify-dash",
106
+ "tags": ["fixture"],
107
+ "notes": "hostile pr_link must render inert",
108
+ "pr_link": "javascript:alert(1)",
109
+ "created_at": "2026-07-14T00:00:00Z",
110
+ "completed_at": None,
111
+ }
112
+
113
+
114
+ def free_port():
115
+ s = socket.socket()
116
+ s.bind(("127.0.0.1", 0))
117
+ port = s.getsockname()[1]
118
+ s.close()
119
+ return port
120
+
121
+
122
+ def _rel_luminance(rgb):
123
+ def chan(c):
124
+ c = c / 255.0
125
+ return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
126
+ r, g, b = (chan(v) for v in rgb)
127
+ return 0.2126 * r + 0.7152 * g + 0.0722 * b
128
+
129
+
130
+ def contrast_ratio(rgb1, rgb2):
131
+ l1, l2 = _rel_luminance(rgb1), _rel_luminance(rgb2)
132
+ hi, lo = max(l1, l2), min(l1, l2)
133
+ return (hi + 0.05) / (lo + 0.05)
134
+
135
+
136
+ def parse_rgb(css_color):
137
+ """Parse 'rgb(r, g, b)' / 'rgba(r, g, b, a)' into an (r, g, b) tuple."""
138
+ inner = css_color[css_color.index("(") + 1: css_color.rindex(")")]
139
+ parts = [p.strip() for p in inner.split(",")]
140
+ return tuple(int(float(p)) for p in parts[:3])
141
+
142
+
143
+ def copy_dist(root: Path):
144
+ real_dist = REPO / "ui" / "web" / "dist"
145
+ if real_dist.is_dir():
146
+ shutil.copytree(real_dist, root / "ui" / "web" / "dist")
147
+
148
+
149
+ def build_fixture(root: Path, hint: str):
150
+ """Populated fixture: agent + tracker (with XSS probe) + alerts + ledger + backlog."""
151
+ (root / "state").mkdir(exist_ok=True)
152
+ (root / "transcripts").mkdir(exist_ok=True)
153
+ (root / "dash").mkdir(exist_ok=True)
154
+ copy_dist(root)
155
+
156
+ (root / "AUDIT-BACKLOG.md").write_text(FIXTURE_BACKLOG, encoding="utf-8")
157
+
158
+ (root / "state" / "ledger").mkdir(parents=True, exist_ok=True)
159
+ (root / "state" / "ledger" / "OUTCOMES-LEDGER.md").write_text(
160
+ FIXTURE_LEDGER, encoding="utf-8")
161
+
162
+ (root / "state" / "SECURITY-ALERTS.log").write_text(
163
+ "2026-07-13T14:32:01Z | HIGH | fixture alert high\n"
164
+ "2026-07-13T14:30:05Z | MED | fixture alert med\n", encoding="utf-8")
165
+
166
+ (root / "state" / "tracker.json").write_text(
167
+ json.dumps({"version": 1, "items": [XSS_ITEM]}), encoding="utf-8")
168
+
169
+ # Fake detector: reads hint.txt so live agent updates are deterministic.
170
+ (root / "hint.txt").write_text(hint, encoding="utf-8")
171
+ fake = (
172
+ "import { readFileSync } from 'node:fs';\n"
173
+ "const hint = readFileSync(new URL('../hint.txt', import.meta.url), 'utf8').trim();\n"
174
+ "console.log(JSON.stringify([{id:'" + AGENT_FULL_ID[:13] + "',"
175
+ "status:'running',age_s:4,hint:hint,taskLabel:hint}]));\n"
176
+ )
177
+ (root / "dash" / "dash-extra.mjs").write_text(fake, encoding="utf-8")
178
+ transcript = root / "transcripts" / f"agent-{AGENT_FULL_ID}.jsonl"
179
+ lines = [
180
+ json.dumps({"type": "user", "parentUuid": None,
181
+ "message": {"content": PROMPT_MARKER}}),
182
+ json.dumps({"type": "assistant", "model": "claude-haiku-4-5",
183
+ "message": {"content": "working"}}),
184
+ ]
185
+ transcript.write_text("\n".join(lines) + "\n", encoding="utf-8")
186
+ (root / "transcripts" / "agent-seed.jsonl").write_text("{}\n", encoding="utf-8")
187
+
188
+
189
+ def build_empty_fixture(root: Path):
190
+ """Empty fixture: no tracker items, no agents, no alerts, no backlog, no ledger."""
191
+ (root / "state").mkdir(exist_ok=True)
192
+ (root / "transcripts").mkdir(exist_ok=True)
193
+ (root / "dash").mkdir(exist_ok=True)
194
+ copy_dist(root)
195
+ (root / "dash" / "dash-extra.mjs").write_text(
196
+ "console.log(JSON.stringify([]));\n", encoding="utf-8")
197
+
198
+
199
+ def start_server(root: Path, port: int):
200
+ state_root = root / "state"
201
+ real_state = Path.home() / "aesop" / "state"
202
+ if state_root.resolve() == real_state.resolve():
203
+ raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
204
+ env = dict(os.environ,
205
+ AESOP_ROOT=str(root),
206
+ AESOP_STATE_ROOT=str(state_root),
207
+ AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
208
+ AESOP_UI_COLLECT_INTERVAL="0.3",
209
+ PORT=str(port))
210
+ server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
211
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
212
+ for _ in range(50):
213
+ try:
214
+ socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
215
+ return server
216
+ except OSError:
217
+ time.sleep(0.2)
218
+ server.kill()
219
+ raise RuntimeError("server never came up")
220
+
221
+
222
+ def stop_server(server):
223
+ server.terminate()
224
+ try:
225
+ server.wait(timeout=5)
226
+ except subprocess.TimeoutExpired:
227
+ server.kill()
228
+
229
+
230
+ def run_empty_phase(pw, failures):
231
+ """(i) all four views render empty states with a clean console."""
232
+ root = Path(tempfile.mkdtemp(prefix="aesop-verify-w14-empty-"))
233
+ port = free_port()
234
+ console_errors = []
235
+ failed_urls = []
236
+ build_empty_fixture(root)
237
+ try:
238
+ server = start_server(root, port)
239
+ except RuntimeError as e:
240
+ failures.append(f"(i) empty-state server failed: {e}")
241
+ shutil.rmtree(root, ignore_errors=True)
242
+ return
243
+ try:
244
+ browser = pw.chromium.launch(headless=True)
245
+ page = browser.new_page()
246
+ page.on("console", lambda m: console_errors.append(m.text)
247
+ if m.type == "error" else None)
248
+ page.on("pageerror", lambda e: console_errors.append(str(e)))
249
+ page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
250
+ page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
251
+ try:
252
+ page.wait_for_selector("[data-testid='health-header']", timeout=15000)
253
+ except Exception as e:
254
+ failures.append(f"(i) empty-state app never mounted: {e}")
255
+ views = [("#/", "view-overview"), ("#/work", "view-work"),
256
+ ("#/activity", "view-activity"), ("#/cost", "view-cost")]
257
+ for hash_, testid in views:
258
+ page.evaluate(f"location.hash = '{hash_}'")
259
+ try:
260
+ page.wait_for_selector(f"[data-testid='{testid}']", timeout=12000)
261
+ except Exception as e:
262
+ failures.append(f"(i) empty-state view {testid} did not render: {e}")
263
+ time.sleep(0.5)
264
+ real_errors = _real_console_errors(console_errors, failed_urls)
265
+ if real_errors:
266
+ failures.append(f"(i) empty-state console errors: {real_errors[:3]}")
267
+ browser.close()
268
+ finally:
269
+ stop_server(server)
270
+ shutil.rmtree(root, ignore_errors=True)
271
+
272
+
273
+ def main():
274
+ parser = argparse.ArgumentParser(
275
+ description="Browser-level proof for the wave-14 React dashboard (ui/web/dist/)")
276
+ parser.add_argument("--allow-skip", action="store_true",
277
+ help="Allow skipping if playwright/chromium is unavailable")
278
+ args = parser.parse_args()
279
+
280
+ try:
281
+ from playwright.sync_api import sync_playwright
282
+ except ImportError:
283
+ msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
284
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
285
+ return 0 if args.allow_skip else 1
286
+
287
+ root = Path(tempfile.mkdtemp(prefix="aesop-verify-wave14-dash-"))
288
+ port = free_port()
289
+ console_errors = []
290
+ failed_urls = []
291
+ failures = []
292
+ build_fixture(root, hint="fixture hint alpha")
293
+ try:
294
+ server = start_server(root, port)
295
+ except RuntimeError as e:
296
+ print(f"FAIL: {e}")
297
+ shutil.rmtree(root, ignore_errors=True)
298
+ return 1
299
+
300
+ try:
301
+ with sync_playwright() as pw:
302
+ try:
303
+ browser = pw.chromium.launch(headless=True)
304
+ except Exception as e:
305
+ msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
306
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
307
+ return 0 if args.allow_skip else 1
308
+
309
+ page = browser.new_page()
310
+ page.on("console", lambda m: console_errors.append(m.text)
311
+ if m.type == "error" else None)
312
+ page.on("pageerror", lambda e: console_errors.append(str(e)))
313
+ page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
314
+ page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
315
+
316
+ # (b) app serves React dist, not the legacy template
317
+ try:
318
+ page_html = page.content()
319
+ assert "data-testid" in page_html, "React app should use data-testid hooks"
320
+ assert "audit-banner" not in page_html, "should not be serving old template"
321
+ except Exception as e:
322
+ failures.append(f"(b) app does not serve React dist: {e}")
323
+
324
+ # (c) health header rendered
325
+ try:
326
+ page.wait_for_selector("[data-testid='health-header']", timeout=5000)
327
+ except Exception as e:
328
+ failures.append(f"(c) health-header testid not found: {e}")
329
+
330
+ # (d) overview view on first paint
331
+ try:
332
+ page.wait_for_selector("[data-testid='view-overview']", timeout=5000)
333
+ except Exception as e:
334
+ failures.append(f"(d) view testids not found: {e}")
335
+
336
+ # (e) inbox form present
337
+ try:
338
+ page.wait_for_selector("[data-testid='inbox-input']", timeout=5000)
339
+ page.wait_for_selector("[data-testid='inbox-submit']", timeout=5000)
340
+ except Exception as e:
341
+ failures.append(f"(e) inbox form testids not found: {e}")
342
+
343
+ # (m) keyboard-only agent expand + survival across an SSE agents update
344
+ try:
345
+ page.wait_for_selector("[data-testid='agent-row']", timeout=8000)
346
+ # keyboard-only: focus the row's real <button> and press Enter
347
+ expand_btn = page.locator("[data-testid='agent-row'] button").first
348
+ expand_btn.focus()
349
+ page.keyboard.press("Enter")
350
+ page.wait_for_selector("[data-testid='agent-row-detail']", timeout=8000)
351
+ # trigger a live agents update: change hint + touch the fingerprint seed
352
+ (root / "hint.txt").write_text("fixture hint beta", encoding="utf-8")
353
+ seed = root / "transcripts" / "agent-seed.jsonl"
354
+ seed.write_text('{"touch": %d}\n' % time.time_ns(), encoding="utf-8")
355
+ page.wait_for_function(
356
+ "document.body.innerText.includes('fixture hint beta')", timeout=10000)
357
+ assert page.query_selector("[data-testid='agent-row-detail']") is not None, \
358
+ "expansion did not survive the SSE agents update"
359
+ except Exception as e:
360
+ failures.append(f"(m) keyboard expand / expansion-survival failed: {e}")
361
+
362
+ # (j) SSE live update WITHOUT reload: mutate tracker.json directly
363
+ try:
364
+ page.evaluate("location.hash = '#/work'")
365
+ page.wait_for_selector("[data-testid='view-work']", timeout=5000)
366
+ tracker_file = root / "state" / "tracker.json"
367
+ data = json.loads(tracker_file.read_text(encoding="utf-8"))
368
+ data["items"].append(dict(XSS_ITEM, id="fixturesse001",
369
+ title="SSE-LIVE-MARKER item",
370
+ pr_link=None))
371
+ tracker_file.write_text(json.dumps(data), encoding="utf-8")
372
+ page.wait_for_function(
373
+ "document.body.innerText.includes('SSE-LIVE-MARKER')", timeout=10000)
374
+ except Exception as e:
375
+ failures.append(f"(j) SSE live tracker update failed: {e}")
376
+
377
+ # (l) hostile javascript: pr_link inert in the real DOM
378
+ try:
379
+ bad = page.evaluate(
380
+ "Array.from(document.querySelectorAll('a[href]'))"
381
+ ".filter(a => a.href.toLowerCase().startsWith('javascript:')).length")
382
+ assert bad == 0, f"{bad} anchor(s) carry a javascript: href"
383
+ except Exception as e:
384
+ failures.append(f"(l) XSS pr_link not inert: {e}")
385
+
386
+ # (k) tracker round-trip through the real form (CSRF path)
387
+ try:
388
+ # the add form sits behind a "+ Add Item" toggle
389
+ if page.locator("[data-testid='tracker-form-title']").count() == 0:
390
+ page.get_by_role("button", name="+ Add Item").click()
391
+ page.fill("[data-testid='tracker-form-title']", "ROUNDTRIP-MARKER item")
392
+ page.click("[data-testid='tracker-form-submit']")
393
+ page.wait_for_function(
394
+ "document.body.innerText.includes('ROUNDTRIP-MARKER')", timeout=10000)
395
+ # locate the card, then drive its lane actions by accessible name
396
+ card = page.locator("[data-testid='tracker-card']",
397
+ has_text="ROUNDTRIP-MARKER").first
398
+ moved = 0
399
+ for action in ("Claim", "Done"):
400
+ btn = card.get_by_role("button", name=action)
401
+ if btn.count() == 0:
402
+ # expand the card first if actions are inside the detail area
403
+ card.click()
404
+ btn = card.get_by_role("button", name=action)
405
+ if btn.count() > 0:
406
+ btn.first.click()
407
+ time.sleep(1.0)
408
+ moved += 1
409
+ assert moved == 2, f"only {moved}/2 lane actions (Claim/Done) were operable"
410
+ except Exception as e:
411
+ failures.append(f"(k) tracker round-trip failed: {e}")
412
+
413
+ # (f) cost view renders table/chart/scorecard from the fixture ledger
414
+ try:
415
+ page.evaluate("location.hash = '#/cost'")
416
+ page.wait_for_selector("[data-testid='cost-table']", timeout=8000)
417
+ page.wait_for_selector("[data-testid='cost-chart']", timeout=5000)
418
+ page.wait_for_selector("[data-testid='scorecard']", timeout=5000)
419
+ assert "haiku" in page.inner_text("[data-testid='cost-table']").lower(), \
420
+ "fixture ledger models not rendered in cost table"
421
+ except Exception as e:
422
+ failures.append(f"(f) cost view did not render fixture ledger: {e}")
423
+
424
+ # (o) orchestrator-status phase=audit -> audit badge in a live region
425
+ try:
426
+ status_file = root / "state" / "orchestrator-status.json"
427
+ status_file.write_text(json.dumps({
428
+ "id": "main", "role": "orchestrator",
429
+ "activity": "running audit", "phase": "audit"}), encoding="utf-8")
430
+ page.wait_for_function(
431
+ "(() => { const el = document.querySelector(\"[data-testid='health-orchestrator']\");"
432
+ " return el && /audit/i.test(el.textContent); })()", timeout=10000)
433
+ announced = page.evaluate(
434
+ "(() => { const el = document.querySelector(\"[data-testid='health-orchestrator']\");"
435
+ " const near = el.closest('[role=status],[aria-live]') || el.querySelector('[role=status],[aria-live]');"
436
+ " return !!near || el.getAttribute('role') === 'status' || el.hasAttribute('aria-live'); })()")
437
+ assert announced, "audit badge is not in/near a live region"
438
+ except Exception as e:
439
+ failures.append(f"(o) audit-phase badge failed: {e}")
440
+
441
+ # (n) computed contrast of a health-header label in BOTH themes
442
+ try:
443
+ page.evaluate("location.hash = '#/'")
444
+ page.wait_for_selector("[data-testid='health-watchdog']", timeout=5000)
445
+ ratios = {}
446
+ for theme_pass in ("first", "second"):
447
+ styles = page.evaluate(
448
+ "(() => { const el = document.querySelector(\"[data-testid='health-watchdog']\");"
449
+ " const cs = getComputedStyle(el);"
450
+ " let bg = 'rgba(0, 0, 0, 0)'; let node = el;"
451
+ " while (node) { const c = getComputedStyle(node).backgroundColor;"
452
+ " if (c && !c.includes('0, 0, 0, 0')) { bg = c; break; } node = node.parentElement; }"
453
+ " if (bg.includes('0, 0, 0, 0')) bg = getComputedStyle(document.body).backgroundColor;"
454
+ " return { fg: cs.color, bg: bg,"
455
+ " theme: document.documentElement.getAttribute('data-theme') || 'default' }; })()")
456
+ ratio = contrast_ratio(parse_rgb(styles["fg"]), parse_rgb(styles["bg"]))
457
+ ratios[styles["theme"] + "/" + theme_pass] = round(ratio, 2)
458
+ assert ratio >= 4.5, \
459
+ f"health label contrast {ratio:.2f}:1 in theme '{styles['theme']}' (< 4.5)"
460
+ if theme_pass == "first":
461
+ page.click("[data-testid='theme-toggle']")
462
+ time.sleep(0.5)
463
+ print(f" contrast ratios: {ratios}")
464
+ except Exception as e:
465
+ failures.append(f"(n) theme contrast check failed: {e}")
466
+
467
+ # (g) CSS stylesheet loaded
468
+ try:
469
+ assert page.query_selector("link[rel='stylesheet']") is not None, \
470
+ "page should have CSS stylesheet links"
471
+ except Exception as e:
472
+ failures.append(f"(g) CSS stylesheet check failed: {e}")
473
+
474
+ # (h) live regions present
475
+ try:
476
+ live_regions = page.evaluate(
477
+ "document.querySelectorAll('[role=\"status\"], [aria-live]').length")
478
+ assert live_regions > 0, "no live regions on page"
479
+ except Exception as e:
480
+ failures.append(f"(h) live regions check failed: {e}")
481
+
482
+ # (a) console clean across the whole populated run
483
+ time.sleep(0.5)
484
+ real_errors = _real_console_errors(console_errors, failed_urls)
485
+ if real_errors:
486
+ failures.append(f"(a) console errors: {real_errors[:3]}")
487
+
488
+ browser.close()
489
+
490
+ # (i) empty-state phase — separate boot, separate console
491
+ run_empty_phase(pw, failures)
492
+
493
+ finally:
494
+ stop_server(server)
495
+ shutil.rmtree(root, ignore_errors=True)
496
+
497
+ if failures:
498
+ print("FAIL:")
499
+ for f in failures:
500
+ print(" -", f)
501
+ return 1
502
+
503
+ print("PROVEN: (a) console clean (b) React dist served (c) health-header "
504
+ "(d) view testids (e) inbox form (f) cost view renders fixture ledger "
505
+ "(g) stylesheet (h) live regions (i) empty-state pass all views "
506
+ "(j) SSE tracker update w/o reload (k) tracker form round-trip incl. lane actions "
507
+ "(l) javascript: pr_link inert (m) keyboard expand survives SSE update "
508
+ "(n) AA contrast both themes (o) audit badge announced")
509
+ return 0
510
+
511
+
512
+ if __name__ == "__main__":
513
+ sys.exit(main())