@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
@@ -0,0 +1,289 @@
1
+ """Browser-level proof for the Agent Inspector drawer (ui/web/dist/ + /api/agent).
2
+
3
+ Drives the BUILT React app served by `python ui/serve.py` and exercises the
4
+ whole vertical slice of the inspector in a real Chromium via Playwright:
5
+ route + AgentsPanel mount, the real get_fleet_agents() → agents list (fed by a
6
+ stubbed dash-extra.mjs), and the real GET /api/agent?id= endpoint reading a
7
+ stubbed transcript. Everything is stubbed via a temp AESOP_ROOT so the proof is
8
+ deterministic and never touches the operator's real fleet.
9
+
10
+ One populated phase asserts:
11
+ (a) console clean (no errors / no XSS side effects)
12
+ (b) an agent row's "Inspect" button opens the drawer (role=dialog)
13
+ (c) the transcript TAIL renders (distinctive stub line is present)
14
+ (d) status is shown as TEXT ("running"), not color alone
15
+ (e) an XSS payload in the transcript is rendered as escaped TEXT — no <script>
16
+ /<img> element is injected and no onerror side effect fires
17
+ (f) focus moves INTO the dialog on open (activeElement inside it)
18
+ (g) Escape closes the drawer AND focus returns to the Inspect trigger
19
+
20
+ Run: python tools/verify_agent_inspector.py (exit 0 = proven, 1 = failed)
21
+ python tools/verify_agent_inspector.py --allow-skip (exit 0 = proven or skipped)
22
+
23
+ Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip).
24
+ """
25
+ import argparse
26
+ import json
27
+ import os
28
+ import shutil
29
+ import socket
30
+ import subprocess
31
+ import sys
32
+ import tempfile
33
+ import time
34
+ from pathlib import Path
35
+
36
+ REPO = Path(__file__).resolve().parent.parent
37
+ SERVE = REPO / "ui" / "serve.py"
38
+
39
+ # Generous, CI-safe waits. A cold ubuntu runner (cold chromium, cold `node`
40
+ # spawn for dash-extra.mjs, shared CPU) is much slower than a warm dev box, so
41
+ # every server/selector wait below is sized for the slow path, env-overridable.
42
+ SERVER_BOOT_TRIES = int(os.environ.get("AESOP_VERIFY_BOOT_TRIES", "150")) # *0.2s = 30s
43
+ SERVER_BOOT_SLEEP = 0.2
44
+ SEL_TIMEOUT_MS = int(os.environ.get("AESOP_VERIFY_SEL_TIMEOUT_MS", "30000"))
45
+
46
+ # The stub agent id (also the transcript filename stem). Full id — the frontend
47
+ # fetches /api/agent?id=<this> and the backend prefix-globs agent-<id>*.jsonl.
48
+ AGENT_ID = "inspectme12345abc"
49
+
50
+ # A distinctive tail line the proof asserts on, plus an XSS payload that must be
51
+ # rendered as inert text (never injected as real DOM).
52
+ TAIL_MARKER = "DISTINCTIVE_TAIL_LINE_42"
53
+ XSS_PAYLOAD = "<img src=x onerror=\"window.__xss_fired=true\"><script>window.__xss_fired=true</script>"
54
+
55
+ # dash-extra.mjs stub: emits one agent so get_fleet_agents() lists a row.
56
+ DASH_EXTRA_STUB = (
57
+ "console.log(JSON.stringify(["
58
+ + json.dumps({
59
+ "id": AGENT_ID,
60
+ "project": "aesop",
61
+ "status": "running",
62
+ "age_s": 12,
63
+ "hint": "wave-31 agent inspector drawer",
64
+ "startedAt": "2026-07-17T14:02:11.000Z",
65
+ "lastActivity": "2026-07-17T14:31:47.000Z",
66
+ "runtimeSeconds": 1776,
67
+ "tokensUsed": 48213,
68
+ "taskLabel": "Build the Agent Inspector drawer for aesop.",
69
+ })
70
+ + "]));\n"
71
+ )
72
+
73
+
74
+ def _real_console_errors(console_errors, failed_urls):
75
+ """Drop favicon/urlless-resource noise; surface real broken assets."""
76
+ non_favicon = [u for u in failed_urls if "favicon" not in u.lower()]
77
+ real = []
78
+ for e in console_errors:
79
+ low = e.lower()
80
+ if "favicon" in low:
81
+ continue
82
+ if "failed to load resource" in low and not non_favicon:
83
+ continue
84
+ real.append(e)
85
+ real.extend(f"failed resource: {u}" for u in non_favicon)
86
+ return real
87
+
88
+
89
+ def free_port():
90
+ s = socket.socket()
91
+ s.bind(("127.0.0.1", 0))
92
+ port = s.getsockname()[1]
93
+ s.close()
94
+ return port
95
+
96
+
97
+ def copy_dist(root: Path):
98
+ real_dist = REPO / "ui" / "web" / "dist"
99
+ if real_dist.is_dir():
100
+ shutil.copytree(real_dist, root / "ui" / "web" / "dist")
101
+
102
+
103
+ def build_root():
104
+ """Fresh temp root with dist + a dash-extra stub + a stub transcript."""
105
+ root = Path(tempfile.mkdtemp(prefix="aesop-verify-inspector-"))
106
+ (root / "state").mkdir(exist_ok=True)
107
+ transcripts = root / "transcripts"
108
+ transcripts.mkdir(exist_ok=True)
109
+ (root / "dash").mkdir(exist_ok=True)
110
+ copy_dist(root)
111
+ (root / "dash" / "dash-extra.mjs").write_text(DASH_EXTRA_STUB, encoding="utf-8")
112
+
113
+ lines = [
114
+ json.dumps({"type": "user", "parentUuid": None,
115
+ "message": {"content": "Build the Agent Inspector drawer for aesop."}}),
116
+ json.dumps({"type": "assistant", "model": "claude-haiku-4-5",
117
+ "message": {"content": [{"type": "text", "text": "Reading the plan first."}]}}),
118
+ json.dumps({"type": "assistant",
119
+ "message": {"content": f"{TAIL_MARKER} — progress update"}}),
120
+ json.dumps({"type": "assistant",
121
+ "message": {"content": XSS_PAYLOAD}}),
122
+ ]
123
+ (transcripts / f"agent-{AGENT_ID}.jsonl").write_text(
124
+ "\n".join(lines) + "\n", encoding="utf-8")
125
+ return root
126
+
127
+
128
+ def start_server(root: Path, port: int):
129
+ state_root = root / "state"
130
+ real_state = Path.home() / "aesop" / "state"
131
+ if state_root.resolve() == real_state.resolve():
132
+ raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
133
+ env = dict(os.environ,
134
+ AESOP_ROOT=str(root),
135
+ AESOP_STATE_ROOT=str(state_root),
136
+ AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
137
+ AESOP_UI_COLLECT_INTERVAL="0.3",
138
+ PORT=str(port))
139
+ server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
140
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
141
+ for _ in range(SERVER_BOOT_TRIES):
142
+ try:
143
+ socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
144
+ return server
145
+ except OSError:
146
+ time.sleep(SERVER_BOOT_SLEEP)
147
+ server.kill()
148
+ raise RuntimeError("server never came up")
149
+
150
+
151
+ def stop_server(server):
152
+ server.terminate()
153
+ try:
154
+ server.wait(timeout=5)
155
+ except subprocess.TimeoutExpired:
156
+ server.kill()
157
+
158
+
159
+ def run_populated(pw, failures):
160
+ root = build_root()
161
+ port = free_port()
162
+ server = start_server(root, port)
163
+ browser = pw.chromium.launch(headless=True)
164
+ page = browser.new_page()
165
+ console_errors, failed_urls = [], []
166
+ page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
167
+ page.on("pageerror", lambda e: console_errors.append(str(e)))
168
+ page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
169
+ try:
170
+ page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
171
+ try:
172
+ page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
173
+ page.evaluate("location.hash = '#/'")
174
+ # Agents panel is on Overview; the Inspect trigger appears per row.
175
+ page.wait_for_selector("[data-testid='agent-inspect-open']", timeout=SEL_TIMEOUT_MS)
176
+ except Exception as e:
177
+ failures.append(f"(b) agent row / inspect trigger never mounted: {e}")
178
+ return
179
+
180
+ # (b) open the drawer
181
+ try:
182
+ page.click("[data-testid='agent-inspect-open']")
183
+ page.wait_for_selector("[data-testid='agent-inspector']", timeout=SEL_TIMEOUT_MS)
184
+ dialog = page.locator("[data-testid='agent-inspector']")
185
+ assert dialog.get_attribute("role") == "dialog", "drawer is not role=dialog"
186
+ assert dialog.get_attribute("aria-modal") == "true", "drawer not aria-modal"
187
+ except Exception as e:
188
+ failures.append(f"(b) drawer did not open as a modal dialog: {e}")
189
+ return
190
+
191
+ # (c) transcript tail rendered (wait for the fetched detail)
192
+ try:
193
+ page.wait_for_selector("[data-testid='agent-inspector-transcript']", timeout=SEL_TIMEOUT_MS)
194
+ tail_text = page.inner_text("[data-testid='agent-inspector-transcript']")
195
+ assert TAIL_MARKER in tail_text, f"tail marker missing; got: {tail_text[:200]!r}"
196
+ except Exception as e:
197
+ failures.append(f"(c) transcript tail not rendered: {e}")
198
+
199
+ # (d) status shown as TEXT (not color alone)
200
+ try:
201
+ status_text = page.inner_text("[data-testid='agent-inspector-status']")
202
+ assert "running" in status_text.lower(), f"status text missing: {status_text!r}"
203
+ except Exception as e:
204
+ failures.append(f"(d) status not shown as text: {e}")
205
+
206
+ # (e) XSS payload is inert: no injected element, no side effect, text present
207
+ try:
208
+ xss_fired = page.evaluate("window.__xss_fired === true")
209
+ assert not xss_fired, "XSS onerror/script side effect fired"
210
+ injected = page.evaluate(
211
+ "document.querySelectorAll('[data-testid=\"agent-inspector-transcript\"] img,"
212
+ " [data-testid=\"agent-inspector-transcript\"] script').length")
213
+ assert injected == 0, f"{injected} raw HTML nodes injected from transcript"
214
+ body_has_text = page.evaluate(
215
+ "document.querySelector('[data-testid=\"agent-inspector-transcript\"]')"
216
+ ".textContent.includes('onerror')")
217
+ assert body_has_text, "XSS payload not rendered as visible text"
218
+ except Exception as e:
219
+ failures.append(f"(e) XSS not neutralised: {e}")
220
+
221
+ # (f) focus moved INTO the dialog on open
222
+ try:
223
+ focus_inside = page.evaluate(
224
+ "(() => { const d = document.querySelector('[data-testid=\"agent-inspector\"]');"
225
+ " return !!d && d.contains(document.activeElement); })()")
226
+ assert focus_inside, "focus did not move into the dialog"
227
+ except Exception as e:
228
+ failures.append(f"(f) focus not trapped into dialog: {e}")
229
+
230
+ # (g) Escape closes AND focus returns to the Inspect trigger
231
+ try:
232
+ page.keyboard.press("Escape")
233
+ page.wait_for_selector("[data-testid='agent-inspector']", state="detached", timeout=10000)
234
+ focus_on_trigger = page.evaluate(
235
+ "document.activeElement && document.activeElement.getAttribute('data-testid')"
236
+ " === 'agent-inspect-open'")
237
+ assert focus_on_trigger, "focus did not return to the Inspect trigger after close"
238
+ except Exception as e:
239
+ failures.append(f"(g) Escape-close / focus-restore failed: {e}")
240
+
241
+ # (a) console clean
242
+ time.sleep(0.4)
243
+ real = _real_console_errors(console_errors, failed_urls)
244
+ if real:
245
+ failures.append(f"(a) console errors: {real[:3]}")
246
+ finally:
247
+ browser.close()
248
+ stop_server(server)
249
+ shutil.rmtree(root, ignore_errors=True)
250
+
251
+
252
+ def main():
253
+ parser = argparse.ArgumentParser(description="Browser-level proof for the Agent Inspector drawer")
254
+ parser.add_argument("--allow-skip", action="store_true",
255
+ help="Allow skipping if playwright/chromium is unavailable")
256
+ args = parser.parse_args()
257
+
258
+ try:
259
+ from playwright.sync_api import sync_playwright
260
+ except ImportError:
261
+ msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
262
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
263
+ return 0 if args.allow_skip else 1
264
+
265
+ failures = []
266
+ with sync_playwright() as pw:
267
+ try:
268
+ pw.chromium.launch(headless=True).close()
269
+ except Exception as e:
270
+ msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
271
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
272
+ return 0 if args.allow_skip else 1
273
+ run_populated(pw, failures)
274
+
275
+ if failures:
276
+ print("FAIL:")
277
+ for f in failures:
278
+ print(" -", f)
279
+ return 1
280
+
281
+ print("PROVEN: (a) console clean (b) Inspect opens a modal dialog "
282
+ "(c) transcript tail renders (d) status shown as text "
283
+ "(e) XSS payload rendered inert (no injected node/side effect) "
284
+ "(f) focus trapped into dialog (g) Escape closes + focus restored")
285
+ return 0
286
+
287
+
288
+ if __name__ == "__main__":
289
+ sys.exit(main())
@@ -0,0 +1,344 @@
1
+ """Browser-level proof for the Wave PR Board view (ui/web/dist/ + /api/wave/prs).
2
+
3
+ Drives the BUILT React app served by `python ui/serve.py` and exercises the
4
+ full stack of the PR board — route wiring, component mount, the real
5
+ GET /api/wave/prs endpoint (which runs `gh`), and rendering — in a real
6
+ Chromium via Playwright. `gh` is stubbed through the AESOP_GH_BIN seam so the
7
+ proof is deterministic regardless of the box's real GitHub state.
8
+
9
+ Three phases, each a fresh server boot with its own fake gh:
10
+
11
+ Populated: fake gh emits fixture PRs (passing/failing/pending/draft) →
12
+ (a) console clean
13
+ (b) #/prs route renders the view + table with one row per PR
14
+ (c) CI status is color-INDEPENDENT: the words Passing/Failing/Pending are
15
+ present as text (not conveyed by color alone)
16
+ (d) top-blocker text ("CI failing") is shown
17
+ (e) a PR title is a real, keyboard-focusable <a> to the PR url
18
+ (f) no javascript: href leaks into the DOM
19
+
20
+ Empty (gh ok, zero PRs): fake gh emits [] →
21
+ (g) the "No open PRs" empty state renders, no table, clean console
22
+
23
+ Unavailable (gh un-authenticated): fake gh exits non-zero with an auth error →
24
+ (h) the "GitHub CLI unavailable" callout renders with the backend reason,
25
+ clean console
26
+
27
+ Run: python tools/verify_prboard.py (exit 0 = proven, 1 = failed)
28
+ python tools/verify_prboard.py --allow-skip (exit 0 = proven or skipped)
29
+
30
+ Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip).
31
+ """
32
+ import argparse
33
+ import os
34
+ import shutil
35
+ import socket
36
+ import stat
37
+ import subprocess
38
+ import sys
39
+ import tempfile
40
+ import time
41
+ from pathlib import Path
42
+
43
+ REPO = Path(__file__).resolve().parent.parent
44
+ SERVE = REPO / "ui" / "serve.py"
45
+
46
+ # Generous, CI-safe waits. A cold ubuntu runner (cold chromium, cold python
47
+ # import, shared CPU) is much slower than a warm dev box, so every server/
48
+ # selector wait below is sized for the slow path, overridable via env.
49
+ SERVER_BOOT_TRIES = int(os.environ.get("AESOP_VERIFY_BOOT_TRIES", "150")) # *0.2s = 30s
50
+ SERVER_BOOT_SLEEP = 0.2
51
+ SEL_TIMEOUT_MS = int(os.environ.get("AESOP_VERIFY_SEL_TIMEOUT_MS", "30000"))
52
+
53
+ # Fixture PRs the fake `gh pr list` emits in the populated phase. Covers every
54
+ # CI rollup state + a draft so the board's whole status vocabulary is exercised.
55
+ FIXTURE_PR_JSON = """import json
56
+ print(json.dumps([
57
+ {"number": 501, "title": "feat: PR board passing case", "headRefName": "feat/wave30-a",
58
+ "mergeable": "MERGEABLE", "isDraft": False, "url": "https://github.com/example/aesop/pull/501",
59
+ "createdAt": "2026-07-17T08:00:00Z", "reviewDecision": "REVIEW_REQUIRED",
60
+ "statusCheckRollup": [{"conclusion": "SUCCESS"}, {"conclusion": "SUCCESS"}]},
61
+ {"number": 502, "title": "fix: PR board failing case", "headRefName": "feat/wave30-b",
62
+ "mergeable": "CONFLICTING", "isDraft": False, "url": "https://github.com/example/aesop/pull/502",
63
+ "createdAt": "2026-07-16T18:00:00Z", "reviewDecision": "",
64
+ "statusCheckRollup": [{"conclusion": "SUCCESS"}, {"conclusion": "FAILURE"}]},
65
+ {"number": 503, "title": "wip: PR board pending draft", "headRefName": "feat/wave30-c",
66
+ "mergeable": "MERGEABLE", "isDraft": True, "url": "https://github.com/example/aesop/pull/503",
67
+ "createdAt": "2026-07-17T07:30:00Z", "reviewDecision": "",
68
+ "statusCheckRollup": [{"status": "IN_PROGRESS"}]}
69
+ ]))
70
+ """
71
+
72
+ FIXTURE_EMPTY_JSON = "print('[]')\n"
73
+
74
+ FIXTURE_AUTH_FAIL = """import sys
75
+ sys.stderr.write("gh: To get started with GitHub CLI, please run: gh auth login\\n")
76
+ sys.exit(1)
77
+ """
78
+
79
+
80
+ def _real_console_errors(console_errors, failed_urls):
81
+ """Drop favicon/urlless-resource noise; surface real broken assets."""
82
+ non_favicon = [u for u in failed_urls if "favicon" not in u.lower()]
83
+ real = []
84
+ for e in console_errors:
85
+ low = e.lower()
86
+ if "favicon" in low:
87
+ continue
88
+ if "failed to load resource" in low and not non_favicon:
89
+ continue
90
+ real.append(e)
91
+ real.extend(f"failed resource: {u}" for u in non_favicon)
92
+ return real
93
+
94
+
95
+ def free_port():
96
+ s = socket.socket()
97
+ s.bind(("127.0.0.1", 0))
98
+ port = s.getsockname()[1]
99
+ s.close()
100
+ return port
101
+
102
+
103
+ def copy_dist(root: Path):
104
+ real_dist = REPO / "ui" / "web" / "dist"
105
+ if real_dist.is_dir():
106
+ shutil.copytree(real_dist, root / "ui" / "web" / "dist")
107
+
108
+
109
+ def make_fake_gh(bin_dir: Path, script_body: str) -> Path:
110
+ """Write a fake `gh` executable and return its path — Linux-CI robust.
111
+
112
+ wave_prs.py invokes AESOP_GH_BIN directly as ``argv[0]`` via subprocess, so
113
+ the stub must be a file the *current* OS can exec on its own:
114
+
115
+ * Windows: a full-path ``gh.cmd`` batch that shells out to a python stub
116
+ (a bare extension-less script is not runnable by subprocess on Windows).
117
+ * POSIX (ubuntu CI): a python script whose first line is a ``#!`` shebang
118
+ pointing at this very interpreter, marked executable (chmod +x). The
119
+ previous ``gh.cmd`` was NOT executable on Linux — the kernel refused it
120
+ with EACCES, ``gh pr list`` failed, /api/wave/prs degraded to empty, and
121
+ the PR-table selector timed out. That was the CI failure this fixes.
122
+
123
+ Either way the stub ignores its args and emits the fixture, which is all
124
+ ``gh pr list`` needs for the proof.
125
+ """
126
+ bin_dir.mkdir(parents=True, exist_ok=True)
127
+ if os.name == "nt":
128
+ stub = bin_dir / "gh_stub.py"
129
+ stub.write_text(script_body, encoding="utf-8")
130
+ gh_cmd = bin_dir / "gh.cmd"
131
+ gh_cmd.write_text('@python "%~dp0gh_stub.py" %*\n', encoding="utf-8")
132
+ return gh_cmd
133
+ # POSIX: shebang + executable bit so the ubuntu runner can exec it directly.
134
+ gh = bin_dir / "gh"
135
+ gh.write_text(f"#!{sys.executable}\n{script_body}", encoding="utf-8")
136
+ gh.chmod(gh.stat().st_mode | stat.S_IXUSR | stat.S_IXGRP | stat.S_IXOTH)
137
+ return gh
138
+
139
+
140
+ def build_root(gh_script: str):
141
+ """Fresh temp root with dist + a fake gh; returns (root, gh_path)."""
142
+ root = Path(tempfile.mkdtemp(prefix="aesop-verify-prboard-"))
143
+ (root / "state").mkdir(exist_ok=True)
144
+ (root / "transcripts").mkdir(exist_ok=True)
145
+ (root / "dash").mkdir(exist_ok=True)
146
+ copy_dist(root)
147
+ (root / "dash" / "dash-extra.mjs").write_text(
148
+ "console.log(JSON.stringify([]));\n", encoding="utf-8")
149
+ gh_path = make_fake_gh(root / "fakebin", gh_script)
150
+ return root, gh_path
151
+
152
+
153
+ def start_server(root: Path, port: int, gh_path: Path):
154
+ state_root = root / "state"
155
+ real_state = Path.home() / "aesop" / "state"
156
+ if state_root.resolve() == real_state.resolve():
157
+ raise RuntimeError("state dir resolved to real repo state (~aesop/state)")
158
+ env = dict(os.environ,
159
+ AESOP_ROOT=str(root),
160
+ AESOP_STATE_ROOT=str(state_root),
161
+ AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
162
+ AESOP_UI_COLLECT_INTERVAL="0.3",
163
+ AESOP_GH_BIN=str(gh_path),
164
+ PORT=str(port))
165
+ server = subprocess.Popen([sys.executable, str(SERVE)], env=env,
166
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
167
+ for _ in range(SERVER_BOOT_TRIES):
168
+ try:
169
+ socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
170
+ return server
171
+ except OSError:
172
+ time.sleep(SERVER_BOOT_SLEEP)
173
+ server.kill()
174
+ raise RuntimeError("server never came up")
175
+
176
+
177
+ def stop_server(server):
178
+ server.terminate()
179
+ try:
180
+ server.wait(timeout=5)
181
+ except subprocess.TimeoutExpired:
182
+ server.kill()
183
+
184
+
185
+ def _boot(pw, gh_script):
186
+ """Boot a fresh server+page for one phase. Returns (server, root, browser,
187
+ page, console_errors, failed_urls). Caller must goto + assert + teardown."""
188
+ root, gh_path = build_root(gh_script)
189
+ port = free_port()
190
+ server = start_server(root, port, gh_path)
191
+ browser = pw.chromium.launch(headless=True)
192
+ page = browser.new_page()
193
+ console_errors, failed_urls = [], []
194
+ page.on("console", lambda m: console_errors.append(m.text) if m.type == "error" else None)
195
+ page.on("pageerror", lambda e: console_errors.append(str(e)))
196
+ page.on("response", lambda r: failed_urls.append(r.url) if r.status >= 400 else None)
197
+ page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
198
+ return server, root, browser, page, console_errors, failed_urls, port
199
+
200
+
201
+ def run_populated(pw, failures):
202
+ server, root, browser, page, console_errors, failed_urls, _ = _boot(pw, FIXTURE_PR_JSON)
203
+ try:
204
+ try:
205
+ page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
206
+ page.evaluate("location.hash = '#/prs'")
207
+ page.wait_for_selector("[data-testid='view-prboard']", timeout=SEL_TIMEOUT_MS)
208
+ except Exception as e:
209
+ failures.append(f"(b) PR board view never mounted: {e}")
210
+ return
211
+ # (b) table + one row per PR
212
+ try:
213
+ page.wait_for_selector("[data-testid='prboard-table']", timeout=SEL_TIMEOUT_MS)
214
+ rows = page.locator("[data-testid='prboard-row']").count()
215
+ assert rows == 3, f"expected 3 rows, got {rows}"
216
+ except Exception as e:
217
+ failures.append(f"(b) PR board table/rows wrong: {e}")
218
+ # (c) color-independent CI text labels present
219
+ try:
220
+ body = page.inner_text("[data-testid='prboard-table']")
221
+ for word in ("Passing", "Failing", "Pending"):
222
+ assert word in body, f"CI label '{word}' not shown as text"
223
+ except Exception as e:
224
+ failures.append(f"(c) CI status not color-independent: {e}")
225
+ # (d) top blocker text
226
+ try:
227
+ body = page.inner_text("[data-testid='prboard-table']")
228
+ assert "CI failing" in body, "top blocker 'CI failing' not shown"
229
+ except Exception as e:
230
+ failures.append(f"(d) blocker not shown: {e}")
231
+ # (e) PR title is a real, keyboard-focusable link to the url
232
+ try:
233
+ link = page.get_by_role("link", name="feat: PR board passing case")
234
+ assert link.get_attribute("href") == "https://github.com/example/aesop/pull/501"
235
+ link.focus()
236
+ focused_tag = page.evaluate("document.activeElement && document.activeElement.tagName")
237
+ assert focused_tag == "A", f"PR link not keyboard-focusable (active={focused_tag})"
238
+ except Exception as e:
239
+ failures.append(f"(e) PR title link/focus failed: {e}")
240
+ # (f) no javascript: href leaked
241
+ try:
242
+ bad = page.evaluate(
243
+ "Array.from(document.querySelectorAll('a[href]'))"
244
+ ".filter(a => a.href.toLowerCase().startsWith('javascript:')).length")
245
+ assert bad == 0, f"{bad} javascript: anchors present"
246
+ except Exception as e:
247
+ failures.append(f"(f) javascript: href leaked: {e}")
248
+ # (a) console clean
249
+ time.sleep(0.4)
250
+ real = _real_console_errors(console_errors, failed_urls)
251
+ if real:
252
+ failures.append(f"(a) populated console errors: {real[:3]}")
253
+ finally:
254
+ browser.close()
255
+ stop_server(server)
256
+ shutil.rmtree(root, ignore_errors=True)
257
+
258
+
259
+ def run_empty(pw, failures):
260
+ server, root, browser, page, console_errors, failed_urls, _ = _boot(pw, FIXTURE_EMPTY_JSON)
261
+ try:
262
+ try:
263
+ page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
264
+ page.evaluate("location.hash = '#/prs'")
265
+ page.wait_for_selector("[data-testid='prboard-empty']", timeout=SEL_TIMEOUT_MS)
266
+ text = page.inner_text("[data-testid='prboard-empty']")
267
+ assert "No open PRs" in text or "feature branch" in text, \
268
+ f"empty state text unexpected: {text!r}"
269
+ assert page.locator("[data-testid='prboard-table']").count() == 0, \
270
+ "table should not render in the empty state"
271
+ except Exception as e:
272
+ failures.append(f"(g) empty state failed: {e}")
273
+ time.sleep(0.4)
274
+ real = _real_console_errors(console_errors, failed_urls)
275
+ if real:
276
+ failures.append(f"(g) empty console errors: {real[:3]}")
277
+ finally:
278
+ browser.close()
279
+ stop_server(server)
280
+ shutil.rmtree(root, ignore_errors=True)
281
+
282
+
283
+ def run_unavailable(pw, failures):
284
+ server, root, browser, page, console_errors, failed_urls, _ = _boot(pw, FIXTURE_AUTH_FAIL)
285
+ try:
286
+ try:
287
+ page.wait_for_selector("[data-testid='health-header']", timeout=SEL_TIMEOUT_MS)
288
+ page.evaluate("location.hash = '#/prs'")
289
+ page.wait_for_selector("[data-testid='prboard-empty']", timeout=SEL_TIMEOUT_MS)
290
+ text = page.inner_text("[data-testid='prboard-empty']")
291
+ assert "GitHub CLI" in text, f"gh-unavailable callout missing: {text!r}"
292
+ assert "authenticated" in text.lower(), f"backend reason not surfaced: {text!r}"
293
+ except Exception as e:
294
+ failures.append(f"(h) gh-unavailable state failed: {e}")
295
+ time.sleep(0.4)
296
+ real = _real_console_errors(console_errors, failed_urls)
297
+ if real:
298
+ failures.append(f"(h) unavailable console errors: {real[:3]}")
299
+ finally:
300
+ browser.close()
301
+ stop_server(server)
302
+ shutil.rmtree(root, ignore_errors=True)
303
+
304
+
305
+ def main():
306
+ parser = argparse.ArgumentParser(description="Browser-level proof for the Wave PR Board")
307
+ parser.add_argument("--allow-skip", action="store_true",
308
+ help="Allow skipping if playwright/chromium is unavailable")
309
+ args = parser.parse_args()
310
+
311
+ try:
312
+ from playwright.sync_api import sync_playwright
313
+ except ImportError:
314
+ msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
315
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
316
+ return 0 if args.allow_skip else 1
317
+
318
+ failures = []
319
+ with sync_playwright() as pw:
320
+ try:
321
+ pw.chromium.launch(headless=True).close()
322
+ except Exception as e:
323
+ msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
324
+ print(f"SKIP: {msg}" if args.allow_skip else f"FAIL: {msg}")
325
+ return 0 if args.allow_skip else 1
326
+ run_populated(pw, failures)
327
+ run_empty(pw, failures)
328
+ run_unavailable(pw, failures)
329
+
330
+ if failures:
331
+ print("FAIL:")
332
+ for f in failures:
333
+ print(" -", f)
334
+ return 1
335
+
336
+ print("PROVEN: (a) console clean (b) #/prs table renders row-per-PR "
337
+ "(c) CI status color-independent text (d) top blocker shown "
338
+ "(e) PR title keyboard-focusable link (f) no javascript: href "
339
+ "(g) empty state (no PRs) (h) gh-unavailable callout with reason")
340
+ return 0
341
+
342
+
343
+ if __name__ == "__main__":
344
+ sys.exit(main())
package/tools/watch.js ADDED
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Aesop watch — Launch the watchdog daemon
5
+ *
6
+ * Spawns bash daemons/run-watchdog.sh with inherited stdio (foreground mode).
7
+ * The daemon will run continuously and inherit the parent's stdin/stdout/stderr.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { spawn } = require('child_process');
13
+
14
+ const CURRENT_DIR = process.cwd();
15
+
16
+ (async function main() {
17
+ try {
18
+ // Verify we're in an aesop repo
19
+ const watchdogScript = path.join(CURRENT_DIR, 'daemons', 'run-watchdog.sh');
20
+ if (!fs.existsSync(watchdogScript)) {
21
+ console.error(`Error: watchdog script not found at ${watchdogScript}`);
22
+ console.error('Are you running this from the aesop root directory?');
23
+ process.exitCode = 1;
24
+ return;
25
+ }
26
+
27
+ console.log('Starting aesop watchdog daemon...\n');
28
+
29
+ // Spawn the watchdog script in foreground (inherit stdio)
30
+ const proc = spawn('bash', [watchdogScript], {
31
+ cwd: CURRENT_DIR,
32
+ stdio: 'inherit',
33
+ shell: true
34
+ });
35
+
36
+ // Exit with the watchdog process's exit code
37
+ proc.on('exit', (code) => {
38
+ process.exitCode = code || 0;
39
+ });
40
+
41
+ proc.on('error', (err) => {
42
+ console.error(`Error spawning watchdog: ${err.message}`);
43
+ process.exitCode = 1;
44
+ });
45
+ } catch (err) {
46
+ console.error(`Error launching watchdog: ${err.message}`);
47
+ process.exitCode = 1;
48
+ }
49
+ })();