@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,202 @@
1
+ """Browser-level proof that /submit's inbox-file bootstrap writes valid UTF-8
2
+ (ui/serve.py, handle_submit / INBOX_FILE header write).
3
+
4
+ Bug: when state/ui-inbox.md doesn't exist yet, the header used to be written
5
+ with INBOX_FILE.write_text(...) with no encoding=. On Windows that falls back
6
+ to the locale-preferred encoding (cp1252), so the em-dash U+2014 in the header
7
+ is encoded as the single byte 0x97. Every subsequent append opens the file
8
+ with encoding='utf-8'. The result is a file that is not valid UTF-8 as a
9
+ whole: any reader that does ui-inbox.md.read_text(encoding="utf-8") (the
10
+ normal, correct thing to do — matching the encoding the append path already
11
+ declares) raises UnicodeDecodeError: 'utf-8' codec can't decode byte 0x97.
12
+ That breaks the project's "orchestrator reads state/ui-inbox.md each turn /
13
+ on /power" pipeline on Windows dev machines.
14
+
15
+ This drives the REAL /submit flow end-to-end in a real headless Chromium:
16
+ type into #inbox-input, click #inbox-button, with the real CSRF token the
17
+ page injects — against a FRESH fixture (no pre-existing ui-inbox.md, so the
18
+ header-bootstrap path is exercised) — then asserts:
19
+ (a) state/ui-inbox.md decodes cleanly via .read_text(encoding="utf-8")
20
+ with NO exception
21
+ (b) it contains the submitted marker text
22
+
23
+ Run: python tools/verify_submit_encoding.py (exit 0 = proven, 1 = failed)
24
+ python tools/verify_submit_encoding.py --allow-skip (exit 0 = proven or skipped, 1 = failed)
25
+
26
+ Fails with exit 1 if playwright/chromium is unavailable (unless --allow-skip is passed).
27
+ Use --allow-skip for environments that legitimately can't run a browser.
28
+ """
29
+ import argparse
30
+ import os
31
+ import shutil
32
+ import socket
33
+ import subprocess
34
+ import sys
35
+ import tempfile
36
+ import time
37
+ from pathlib import Path
38
+
39
+ REPO = Path(__file__).resolve().parent.parent
40
+ SERVE = REPO / "ui" / "serve.py"
41
+
42
+ MARKER = "SUBMIT-ENCODING-FIXTURE-MARKER: rebuild the flux capacitor"
43
+
44
+
45
+ def free_port():
46
+ s = socket.socket()
47
+ s.bind(("127.0.0.1", 0))
48
+ port = s.getsockname()[1]
49
+ s.close()
50
+ return port
51
+
52
+
53
+ def build_fixture(root: Path):
54
+ """Fresh fixture root — deliberately does NOT create state/ui-inbox.md,
55
+ so /submit exercises the header-bootstrap (write_text) path, not just
56
+ the append path."""
57
+ (root / "state").mkdir(exist_ok=True)
58
+ (root / "transcripts").mkdir(exist_ok=True)
59
+ (root / "dash").mkdir(exist_ok=True)
60
+
61
+ # Copy ui/web/dist from the real repo so the server can serve the built React app
62
+ repo = Path(__file__).resolve().parent.parent
63
+ real_dist = repo / "ui" / "web" / "dist"
64
+ if real_dist.is_dir():
65
+ fixture_dist = root / "ui" / "web" / "dist"
66
+ shutil.copytree(real_dist, fixture_dist)
67
+
68
+ (root / "AUDIT-BACKLOG.md").write_text(
69
+ "# Audit backlog — verify_submit_encoding fixture\n\n## Landing log\n- fixture\n",
70
+ encoding="utf-8",
71
+ )
72
+ # Minimal fake detector so the collector thread has nothing real to shell out to.
73
+ (root / "dash" / "dash-extra.mjs").write_text(
74
+ "console.log(JSON.stringify([]));\n", encoding="utf-8"
75
+ )
76
+
77
+
78
+ def main():
79
+ parser = argparse.ArgumentParser(
80
+ description="Browser-level proof for /submit UTF-8 encoding"
81
+ )
82
+ parser.add_argument(
83
+ "--allow-skip",
84
+ action="store_true",
85
+ help="Allow skipping if playwright/chromium is unavailable (exit 0 instead of 1)"
86
+ )
87
+ args = parser.parse_args()
88
+
89
+ try:
90
+ from playwright.sync_api import sync_playwright
91
+ except ImportError:
92
+ msg = "playwright missing — run `python -m playwright install chromium`, or pass --allow-skip"
93
+ if args.allow_skip:
94
+ print(f"SKIP: {msg}")
95
+ return 0
96
+ else:
97
+ print(f"FAIL: {msg}")
98
+ return 1
99
+
100
+ root = Path(tempfile.mkdtemp(prefix="aesop-verify-submit-"))
101
+ state_root = root / "state"
102
+
103
+ # HARD GUARD: refuse to run if state_root looks like the real repo state dir
104
+ # (e.g., ~/aesop/state or an absolute path ending with /aesop/state)
105
+ real_state = Path.home() / "aesop" / "state"
106
+ if state_root.resolve() == real_state.resolve():
107
+ print("FAIL: state dir resolved to real repo state (~aesop/state), refusing to run")
108
+ return 1
109
+
110
+ inbox_file = state_root / "ui-inbox.md"
111
+ port = free_port()
112
+ env = dict(
113
+ os.environ,
114
+ AESOP_ROOT=str(root),
115
+ AESOP_STATE_ROOT=str(state_root),
116
+ AESOP_TRANSCRIPTS_ROOT=str(root / "transcripts"),
117
+ AESOP_UI_COLLECT_INTERVAL="0.3",
118
+ PORT=str(port),
119
+ )
120
+ build_fixture(root)
121
+ assert not inbox_file.exists(), "fixture must start WITHOUT ui-inbox.md"
122
+
123
+ server = subprocess.Popen(
124
+ [sys.executable, str(SERVE)], env=env,
125
+ stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL,
126
+ )
127
+ failures = []
128
+ try:
129
+ for _ in range(50):
130
+ try:
131
+ socket.create_connection(("127.0.0.1", port), timeout=0.2).close()
132
+ break
133
+ except OSError:
134
+ time.sleep(0.2)
135
+ else:
136
+ print("FAIL: server never came up")
137
+ return 1
138
+
139
+ with sync_playwright() as pw:
140
+ try:
141
+ browser = pw.chromium.launch(headless=True)
142
+ except Exception as e:
143
+ msg = f"chromium unavailable ({e}); run: python -m playwright install chromium"
144
+ if args.allow_skip:
145
+ print(f"SKIP: {msg}")
146
+ return 0
147
+ else:
148
+ print(f"FAIL: {msg}")
149
+ return 1
150
+ page = browser.new_page()
151
+ page.goto(f"http://127.0.0.1:{port}/", wait_until="domcontentloaded")
152
+
153
+ # Drive the real /submit flow: inbox-input -> inbox-submit, real
154
+ # CSRF token (window.__AESOP_CSRF_TOKEN__ injected server-side).
155
+ page.wait_for_selector("[data-testid='inbox-input']", timeout=8000)
156
+ page.fill("[data-testid='inbox-input']", MARKER)
157
+ page.click("[data-testid='inbox-submit']")
158
+
159
+ # Wait for the inbox file to land on disk (async POST).
160
+ for _ in range(50):
161
+ if inbox_file.exists():
162
+ break
163
+ time.sleep(0.2)
164
+ else:
165
+ failures.append("state/ui-inbox.md was never created by /submit")
166
+
167
+ browser.close()
168
+ finally:
169
+ server.terminate()
170
+ try:
171
+ server.wait(timeout=5)
172
+ except subprocess.TimeoutExpired:
173
+ server.kill()
174
+
175
+ if not failures:
176
+ raw = inbox_file.read_bytes()
177
+ try:
178
+ text = inbox_file.read_text(encoding="utf-8")
179
+ except UnicodeDecodeError as e:
180
+ bad_byte = raw[e.start:e.end].hex()
181
+ failures.append(
182
+ f"(a) state/ui-inbox.md is not valid UTF-8: {e} "
183
+ f"(offending byte(s): 0x{bad_byte}) — raw bytes: {raw!r}"
184
+ )
185
+ else:
186
+ if MARKER not in text:
187
+ failures.append(f"(b) submitted marker text not found in inbox file: {text!r}")
188
+
189
+ shutil.rmtree(root, ignore_errors=True)
190
+
191
+ if failures:
192
+ print("FAIL:")
193
+ for f in failures:
194
+ print(" -", f)
195
+ return 1
196
+ print("PROVEN: (a) state/ui-inbox.md decodes cleanly as UTF-8 "
197
+ "(b) submitted marker text present")
198
+ return 0
199
+
200
+
201
+ if __name__ == "__main__":
202
+ sys.exit(main())
package/ui/CLAUDE.md ADDED
@@ -0,0 +1,125 @@
1
+ # ui/ — Web dashboard
2
+
3
+ **Purpose**: Stdlib-only local observability dashboard. Serves a dark-theme HTML dashboard on a configurable port (realtime via Server-Sent Events), enabling real-time fleet monitoring without external dependencies.
4
+
5
+ ## Files (wave-14 U9 cutover: new React+Vite app structure)
6
+
7
+ **Backend (Python, unchanged from wave-9 split)**:
8
+ - **serve.py** — Thin (~65-line) entry point + composition layer. Wires the sibling modules, calls `config.reload()` / `csrf.init()` / `sse.reset_state()`, and re-exports their symbols so `serve.X` keeps resolving for the test suite (which loads serve.py by path) and for `python ui/serve.py`.
9
+ - **config.py** — Path / env / `aesop.config.json` resolution. `reload()` recomputes all path globals from the current environment. **Load-bearing rule: every other module reads `config.X` at call time (`import config`), never `from config import <path>` — a frozen import would go stale after reload() (breaks test-fixture isolation).** Added in wave-14: `WEB_DIST` path pointing to `ui/web/dist/`.
10
+ - **csrf.py** — Session-token generation (atomic O_EXCL 0600) + `validate_csrf_request()`; `init()` sets `SESSION_TOKEN`.
11
+ - **collectors.py** — Read-only data collectors (heartbeats, repos, events, alerts, messages, backlog parse), tracker CRUD, and SSE section snapshots (incl. `read`-style tracker/orchestrator-status snapshots + inbox drain).
12
+ - **agents.py** — Agent transcript reading (`get_fleet_agents`, `extract_agent_dispatch_prompt`) and path-traversal-safe agent-id handling (`_AGENT_ID_FORBIDDEN`).
13
+ - **sse.py** — SSE client registry, bounded broadcast, hash-gated `_maybe_emit`, and the background `collector_loop`. `reset_state()` restores per-import collector isolation (the `sse` module is cached across test re-imports). Extended in wave-14: emits "cost" as a 6th SSE section.
14
+ - **render.py** — Renders `ui/web/dist/index.html` (wave-14 U9 cutover: no fallback), substituting the CSRF token via a unique sentinel (the sentinel persists through Vite's build). Requires `template_path` parameter; legacy fallback removed.
15
+ - **handler.py** — `DashboardHandler` (HTTP routing + all GET/POST endpoints incl. /api/tracker) and `run_server()`. Wave-14 additions: static serving (`GET /assets/*` from `ui/web/dist/assets/`), `/api/state` consolidated snapshot, `/api/session` for Vite dev server, `/api/cost` scorecard. Reads `config.X` / `csrf.SESSION_TOKEN` at call time.
16
+ - **cost.py** — Parser for `state/ledger/OUTCOMES-LEDGER.md` (markdown table); returns per-model and per-day aggregates, verdict scorecards, and optional dollar estimates (only if `aesop.config.json` supplies `pricing` map).
17
+
18
+ ## State Store Integration (Wave-15)
19
+
20
+ The tracker uses a **dual-path mutation model** backed by an event-sourced SQLite WAL store:
21
+
22
+ - **Write path**: Mutations append events to `tracker_events.db` (WAL mode) via the StateAPI layer (`state_store/api.py`). Each mutation (create/update/delete) produces an immutable event in the event log.
23
+ - **Read path**: `tracker.json` is re-rendered as an export (projection) from the event log on every read. This export is committed to git for checkpoint durability.
24
+ - **Location**: `state/tracker_events.db` (SQLite WAL, never committed). `tracker.json` (rendered export, committed).
25
+ - **Render-failure recovery**: If projection rendering fails, the UI falls back to the last-known good `tracker.json`; no data loss. Stale renders are repaired on the next successful mutation (idempotent re-render).
26
+
27
+ This design provides:
28
+ - **Durable event audit trail** (append-only events in WAL)
29
+ - **Git-friendly checkpoints** (rendered tracker.json snapshots)
30
+ - **Atomic mutations** (events append, projection re-renders)
31
+ - **Graceful degradation** (fallback to last-known-good on render failure)
32
+
33
+ **Frontend (React 18 + Vite + TypeScript, wave-14 U1–U8)**:
34
+ - **ui/web/** — Complete React application (TypeScript, Vite, TypeScript strict mode).
35
+ - **src/main.tsx** — Vite entry point; renders `<App />` to `#root`.
36
+ - **src/App.tsx** — App shell: header + hash-routed view slots (/#/, /#/work, /#/activity, /#/cost).
37
+ - **src/styles/tokens.css** + **src/styles/global.css** — Design tokens (light/dark color palettes, spacing, typography) + base resets.
38
+ - **src/views/** — Page-level components (Overview, Work, Activity, Cost) with SSE bindings.
39
+ - **src/components/** — Reusable UI components (HealthHeader, AgentsPanel, TrackerBoard, Timeline, CostChart, etc.).
40
+ - **src/lib/api.ts** — Typed fetch helpers + CSRF header injection + `/api/session` fallback for dev server.
41
+ - **src/lib/useSSE.ts** — EventSource hook with reconnect logic, per-section state, connection status.
42
+ - **src/lib/types.ts** — TypeScript types for all API payloads (contract with backend).
43
+ - **src/lib/sanitizeUrl.ts** — XSS-safe URL parsing (PR links inert on bad schemes).
44
+ - **vite.config.ts** — Vite config with API proxy for `/data`, `/api`, `/events`, `/agent`, `/submit` to :8770.
45
+ - **dist/** — Built static files (committed to git; served by Python handler). Filenames are content-hashed by Vite.
46
+ - **README.md** — User guide and configuration reference (updated for wave-14).
47
+
48
+ ## Configuration & Path Precedence
49
+
50
+ Configuration is resolved in this order (first match wins):
51
+
52
+ 1. **Environment variables** (highest priority):
53
+ - `PORT` — HTTP server port (default: 8770)
54
+ - `AESOP_ROOT` — aesop installation root (default: `$HOME/aesop`)
55
+ - `AESOP_STATE_ROOT` — state directory (overrides config state_root)
56
+ - `AESOP_TRANSCRIPTS_ROOT` — Claude transcript directory (overrides config transcripts_root)
57
+ - `AESOP_UI_COLLECT_INTERVAL` — collector thread poll cadence in seconds (default: 1.0)
58
+
59
+ 2. **Config file** (`aesop.config.json`):
60
+ - `state_root` — path to state/ directory
61
+ - `transcripts_root` — path to Claude transcript directory
62
+
63
+ 3. **Built-in defaults** (lowest priority):
64
+ - `AESOP_ROOT/state` for state directory
65
+ - `~/.claude/projects` for transcripts
66
+
67
+ ## CSRF & Session Protection
68
+
69
+ **Token model**:
70
+ - Per-session CSRF token generated at startup, persisted to `state/.ui-session-token` (mode 0600, readable only by owner)
71
+ - Token is 43-character URL-safe base64 string (256 bits)
72
+ - Persistent across server restarts (read from file if exists, generate fresh if not)
73
+ - Used to validate mutations via /submit endpoint
74
+
75
+ **Validation on /submit POST**:
76
+ 1. **Origin/Referer check**: if present, must be local (http://127.0.0.1:<port>, http://localhost:<port>, or http://[::1]:<port>)
77
+ 2. **X-Aesop-Token header check**: must match SESSION_TOKEN exactly
78
+ - Both checks fail-closed (missing header = rejection)
79
+ - GET /events (SSE, read-only) requires no token
80
+
81
+ **Local CLI access**:
82
+ - CLI tools read token from `state/.ui-session-token` (0600) to submit to /submit endpoint
83
+ - Legitimate browser clients: token injected into HTML template, sent by JavaScript
84
+
85
+ ## API Endpoints (wave-14 wave additions)
86
+
87
+ **Read-only (no CSRF required)**:
88
+ - `GET /` — Renders `ui/web/dist/index.html` with CSRF token substituted (hard 500 if dist missing).
89
+ - `GET /assets/*` — Static files from `ui/web/dist/assets/` (content-hashed, immutable cache headers, path-traversal-safe).
90
+ - `GET /api/state` — Consolidated first-paint snapshot: `{data, backlog, agents, tracker, status, cost}` in one round trip (reuses latest snapshots).
91
+ - `GET /api/session` — Returns `{token}` for Vite dev server; Origin-checked fail-closed (only local origins).
92
+ - `GET /api/cost` — Cost/scorecard summary from `state/ledger/OUTCOMES-LEDGER.md` (per-model, per-day, verdicts, optional pricing estimates).
93
+ - `GET /events` — Server-Sent Events stream (read-only, no CSRF).
94
+
95
+ **Mutations (CSRF-gated)**:
96
+ - `POST /submit` — Append to inbox.
97
+ - `POST /api/tracker`, `POST /api/tracker/<id>` — Tracker CRUD.
98
+
99
+ ## Server-Sent Events (SSE) Model
100
+
101
+ **Realtime streaming via GET /events**:
102
+ - ThreadingHTTPServer required (SSE holds one connection per client)
103
+ - Streams JSON frames: `event: <section>` / `data: <json>`
104
+ - Keepalive comment-line (`: keepalive`) sent every ~15s to prevent timeout
105
+ - Read-only stream; no mutations
106
+
107
+ **Sections emitted** (only on content change, in order):
108
+ 1. **data** — heartbeat status, daemon state, log tail
109
+ 2. **backlog** — AUDIT-BACKLOG.md parsed into tier buckets (P0/P1/P2/Needs decision)
110
+ 3. **agents** — fleet agent activity (from Claude transcripts directory)
111
+ 4. **tracker** — tracker items (CRUD mutations reflected in realtime)
112
+ 5. **status** — orchestrator phase + activity
113
+ 6. **cost** — cost/scorecard summary (wave-14 addition)
114
+
115
+ ## Background Collector Thread
116
+
117
+ Daemon thread polling heartbeats/logs via mtime/fingerprint gates; re-derives SSE sections only on input change, broadcasts only when content-hash differs (avoids expensive operations on every tick). Started idempotently on first HTTP request via `start_collector_thread()`, runs with single-instance guard `_collector_lock`, wakes every `COLLECTOR_INTERVAL` (default 1.0s, `AESOP_UI_COLLECT_INTERVAL`).
118
+
119
+ ## Invariants & Gotchas
120
+
121
+ - **Stdlib only**: No external dependencies (requests, flask, etc.). Uses only `http.server`, `json`, `subprocess`, `threading`.
122
+ - **ThreadingHTTPServer required**: SSE model requires one thread per client connection. Standard HTTPServer (processes) cannot hold SSE connections open.
123
+ - **Collector fail-open**: If collector thread crashes, server continues serving; realtime updates stop but dashboard remains accessible.
124
+ - **Token file permissions**: On Unix-like systems, token file is chmod 0600 (user-only). Windows ignores mode bits but respects file permissions via ACLs.
125
+ - **Paths git-ignored**: `state/.ui-session-token` is ephemeral (regenerated if missing), never committed.
package/ui/agents.py ADDED
@@ -0,0 +1,183 @@
1
+ #!/usr/bin/env python3
2
+ """Aesop UI — agent transcript reading + path-traversal-safe id handling (wave-9 split)."""
3
+ import json
4
+ import re
5
+ import subprocess
6
+ import sys
7
+ from pathlib import Path
8
+
9
+ import config
10
+
11
+
12
+ def get_fleet_agents():
13
+ """Detect running subagents by calling dash-extra.mjs --json.
14
+
15
+ dash-extra.mjs truncates agent ids to 13 characters for display. With enough
16
+ concurrently-active agents, two distinct agents can share the same 13-char
17
+ prefix and collide onto the same id. The dashboard keys DOM rows (and the
18
+ click-to-expand lookup) by this id, so a collision silently merges two
19
+ different agents into one row and can show mismatched detail on click. Since
20
+ dash-extra.mjs is out of scope here, disambiguate post-hoc: keep the original
21
+ (display-friendly) id as a prefix, but suffix it to guarantee uniqueness.
22
+ """
23
+ agents = []
24
+ try:
25
+ # Call the working detector (dash-extra.mjs) with --json flag
26
+ dash_extra_path = config.AESOP_ROOT / "dash" / "dash-extra.mjs"
27
+ if not dash_extra_path.exists():
28
+ return agents
29
+ result = subprocess.run(
30
+ ["node", str(dash_extra_path), "--json"],
31
+ capture_output=True,
32
+ text=True,
33
+ timeout=5
34
+ )
35
+ if result.returncode == 0 and result.stdout:
36
+ agents = json.loads(result.stdout.strip())
37
+ except (subprocess.TimeoutExpired, json.JSONDecodeError, FileNotFoundError):
38
+ pass
39
+ except Exception:
40
+ pass
41
+
42
+ seen = {}
43
+ for a in agents:
44
+ if not isinstance(a, dict):
45
+ continue
46
+ aid = a.get("id", "")
47
+ if aid in seen:
48
+ seen[aid] += 1
49
+ a["id"] = f"{aid}-{seen[aid]}"
50
+ else:
51
+ seen[aid] = 1
52
+ return agents
53
+
54
+ _AGENT_ID_FORBIDDEN = re.compile(r'\.\.|[/\\*?\[\]]')
55
+
56
+ def extract_agent_dispatch_prompt(agent_id):
57
+ """
58
+ Extract dispatch prompt and metadata from agent jsonl transcript.
59
+ Returns dict with prompt, dispatcher, model, activity times, and message count.
60
+ Robust: missing/invalid file -> {error: "..."}
61
+ Security: rejects agent_id containing path-traversal or glob-metacharacter
62
+ sequences before building any glob pattern, and refuses to return a match
63
+ that resolves outside config.TRANSCRIPTS_ROOT (defense in depth). Error results
64
+ carry "invalid": True when the input itself was rejected, so callers can
65
+ map that to an HTTP 400 rather than a plain 404.
66
+
67
+ CRITICAL: Use prefix-matching via glob, not exact match. The dashboard (dash-extra.mjs)
68
+ scans for agent-*.jsonl files and emits a 13-char truncated ID; files on disk carry
69
+ full IDs (e.g., agent-a77b995bcdb953e9c1234567.jsonl). This function must search for
70
+ the same file format that the dashboard actually finds.
71
+ """
72
+ try:
73
+ if not agent_id or _AGENT_ID_FORBIDDEN.search(agent_id):
74
+ return {"error": "invalid agent id", "invalid": True}
75
+
76
+ # Prefix-match: search in config.TRANSCRIPTS_ROOT for files matching agent-agent_id*.jsonl
77
+ # The dashboard (dash-extra.mjs) scans for agent-*.jsonl files and emits
78
+ # a 13-char truncated ID; we must match against the same .jsonl files.
79
+ if not config.TRANSCRIPTS_ROOT.exists():
80
+ return {"error": f"transcripts root not found at {config.TRANSCRIPTS_ROOT}"}
81
+
82
+ # Glob for matching files (prefix-match handles truncated IDs)
83
+ matches = sorted(
84
+ config.TRANSCRIPTS_ROOT.glob(f"**/agent-{agent_id}*.jsonl"),
85
+ key=lambda p: p.stat().st_mtime, reverse=True,
86
+ )
87
+ if not matches:
88
+ return {"error": f"transcript not found for {agent_id}"}
89
+ output_file = matches[0]
90
+
91
+ # Containment check: the resolved match must stay inside config.TRANSCRIPTS_ROOT.
92
+ # Belt-and-suspenders alongside the input rejection above.
93
+ try:
94
+ is_contained = output_file.resolve().is_relative_to(config.TRANSCRIPTS_ROOT.resolve())
95
+ except AttributeError:
96
+ # Path.is_relative_to requires Python 3.9+; fall back for older runtimes.
97
+ try:
98
+ output_file.resolve().relative_to(config.TRANSCRIPTS_ROOT.resolve())
99
+ is_contained = True
100
+ except ValueError:
101
+ is_contained = False
102
+ if not is_contained:
103
+ return {"error": "resolved path outside transcripts root", "invalid": True}
104
+
105
+ dispatch_prompt = None
106
+ message_count = 0
107
+ model = None
108
+ parent_uuid = None
109
+ first_seen = None
110
+ last_activity = None
111
+
112
+ # Parse NDJSON (one JSON per line)
113
+ with open(output_file, 'r', encoding='utf-8', errors='ignore') as f:
114
+ lines = f.readlines()
115
+ message_count = len(lines)
116
+
117
+ # Get file mtime for activity time
118
+ stat = output_file.stat()
119
+ first_seen = int(stat.st_mtime)
120
+ last_activity = int(stat.st_mtime)
121
+
122
+ # First line should be type="user" with the dispatch prompt
123
+ if lines:
124
+ try:
125
+ first_line = json.loads(lines[0])
126
+ if first_line.get('type') == 'user':
127
+ msg = first_line.get('message', {})
128
+ dispatch_prompt = msg.get('content', '')
129
+ parent_uuid = first_line.get('parentUuid')
130
+ except (json.JSONDecodeError, KeyError):
131
+ pass
132
+
133
+ # Scan for model info in assistant messages
134
+ for line in lines[1:20]: # Check first ~20 lines
135
+ try:
136
+ obj = json.loads(line)
137
+ if obj.get('type') == 'assistant' and not model:
138
+ if 'model' in obj:
139
+ model = obj.get('model')
140
+ except (json.JSONDecodeError, KeyError):
141
+ pass
142
+
143
+ if not dispatch_prompt:
144
+ return {"error": f"no dispatch prompt found"}
145
+
146
+ # Infer dispatcher: if parentUuid is null, it's main thread; otherwise parent agent
147
+ dispatcher = "main thread" if parent_uuid is None else "parent agent"
148
+
149
+ return {
150
+ "id": agent_id,
151
+ "dispatch_prompt": dispatch_prompt,
152
+ "dispatcher": dispatcher,
153
+ "model": model or "unknown",
154
+ "message_count": message_count,
155
+ "first_seen": first_seen,
156
+ "last_activity": last_activity,
157
+ }
158
+ except Exception as e:
159
+ return {"error": str(e)}
160
+
161
+ def _transcripts_fingerprint():
162
+ """Cheap fs-stat-only fingerprint of the transcripts tree.
163
+
164
+ Used to decide whether it's worth re-invoking `node dash-extra.mjs` (which is
165
+ comparatively expensive: process spawn + re-parsing every agent transcript).
166
+ Only file count + max mtime — no file content is read.
167
+ """
168
+ try:
169
+ if not config.TRANSCRIPTS_ROOT.exists():
170
+ return (0, 0.0)
171
+ count = 0
172
+ latest = 0.0
173
+ for p in config.TRANSCRIPTS_ROOT.glob("**/agent-*.jsonl"):
174
+ try:
175
+ mtime = p.stat().st_mtime
176
+ except OSError:
177
+ continue
178
+ count += 1
179
+ if mtime > latest:
180
+ latest = mtime
181
+ return (count, latest)
182
+ except Exception:
183
+ return (0, 0.0)
@@ -0,0 +1,84 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aesop UI API — shared mutation-request validation (wave-10 P0 split from handler.py).
4
+
5
+ Every mutating endpoint (/submit, POST /api/tracker, POST /api/tracker/<id>)
6
+ repeats the same two gates before touching domain logic: CSRF validation and
7
+ a Content-Length bound check, followed by a JSON body parse. This module
8
+ centralizes those two gates + the parse so they are directly unit-testable
9
+ without an HTTP server (see tests/test_api_tracker.py).
10
+
11
+ SAFETY NOTE (load-bearing): callers MUST check Content-Length is within
12
+ [1, MAX_BODY_BYTES] *before* calling self.rfile.read() on the live socket --
13
+ reading an attacker-declared length without a cap first is exactly the DoS
14
+ hole this gate exists to close. validate_mutation() re-derives the same
15
+ bound from headers (cheap, no I/O) so its error response matches what the
16
+ caller already decided; callers should gate their own read using
17
+ MAX_BODY_BYTES from this module so the two stay in lockstep. This module
18
+ never touches the socket itself -- it only ever receives bytes the caller
19
+ already obtained.
20
+
21
+ Sibling modules (ui/api/tracker.py, ui/api/submit.py) that read config paths
22
+ must do so live via `import config` at call time, never `from config import
23
+ <path>` -- a frozen import goes stale after config.reload() (breaks
24
+ test-fixture isolation). See ui/CLAUDE.md.
25
+ """
26
+ import json
27
+
28
+ from csrf import validate_csrf_request
29
+
30
+
31
+ class NotFoundError(Exception):
32
+ """Raised when a tracker item is not found."""
33
+ pass
34
+
35
+
36
+ # Mutating endpoints cap request bodies at 10KB. Kept as a shared constant so
37
+ # handler.py's pre-read bound check and this module's own re-check never drift.
38
+ MAX_BODY_BYTES = 10000
39
+
40
+
41
+ def validate_mutation(headers, body_bytes, content_length_error="Invalid Content-Length"):
42
+ """Run the CSRF + Content-Length gates shared by every mutating endpoint,
43
+ then JSON-decode the body.
44
+
45
+ Args:
46
+ headers: dict-like HTTP headers (case-insensitive), as accepted by
47
+ csrf.validate_csrf_request.
48
+ body_bytes: raw request body bytes already read by the caller. The
49
+ caller must have bounded how many bytes it read using the same
50
+ Content-Length header (see SAFETY NOTE above) -- this function
51
+ performs no socket I/O itself.
52
+ content_length_error: exact error message to use on an out-of-range
53
+ Content-Length, so callers can preserve endpoint-specific wording
54
+ (e.g. /submit's "Invalid Content-Length (must be 1-10000 bytes)"
55
+ vs /api/tracker's "Invalid Content-Length").
56
+
57
+ Returns:
58
+ (True, parsed) where parsed is the decoded JSON value (typically a
59
+ dict) on success.
60
+ (False, (status_code, error_dict)) on a CSRF or Content-Length gate
61
+ failure -- callers should write this directly as the JSON error
62
+ response.
63
+
64
+ Note: a malformed JSON body raises json.JSONDecodeError rather than being
65
+ caught here, so callers can keep their historical per-endpoint handling
66
+ (some endpoints report 400 "Invalid JSON", others let the parse failure
67
+ fall through to a generic 500) -- see ui/api/tracker.py and
68
+ ui/api/submit.py callers in ui/handler.py.
69
+ """
70
+ is_valid, reason = validate_csrf_request(headers)
71
+ if not is_valid:
72
+ return False, (403, {"error": "CSRF protection: " + reason})
73
+
74
+ try:
75
+ content_length = int(headers.get('Content-Length', 0) or 0)
76
+ except (TypeError, ValueError):
77
+ content_length = 0
78
+
79
+ if content_length <= 0 or content_length > MAX_BODY_BYTES:
80
+ return False, (400, {"error": content_length_error})
81
+
82
+ body_str = (body_bytes or b'').decode('utf-8', errors='ignore')
83
+ parsed = json.loads(body_str)
84
+ return True, parsed