@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,735 @@
1
+ #!/usr/bin/env python3
2
+ """Aesop UI — read-only data collectors + tracker CRUD + SSE section snapshots (wave-9 split)."""
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import re
7
+ import secrets
8
+ import sys
9
+ from datetime import datetime, timezone
10
+ from time import time
11
+
12
+ import config
13
+
14
+
15
+ def parse_audit_backlog():
16
+ """
17
+ Parse AUDIT-BACKLOG.md and return structured tier data.
18
+
19
+ Returns:
20
+ dict with 'tiers' list, each tier containing:
21
+ {
22
+ "tier": "P0" | "P1" | "P2" | "Needs decision",
23
+ "items": [
24
+ {"status": "✅"|"🔵"|"⬜"|"⏸", "tag": "[sec]", "title": "..."},
25
+ ...
26
+ ],
27
+ "done": int,
28
+ "inflight": int,
29
+ "todo": int,
30
+ "total": int
31
+ }
32
+ """
33
+ result = {"tiers": []}
34
+
35
+ try:
36
+ if not config.AUDIT_BACKLOG_FILE.exists():
37
+ return result
38
+
39
+ content = config.AUDIT_BACKLOG_FILE.read_text(encoding='utf-8')
40
+ except Exception as e:
41
+ print(f"[collectors] Failed to read audit backlog: {e}", file=sys.stderr)
42
+ return result
43
+
44
+ # Split into lines
45
+ lines = content.split('\n')
46
+
47
+ # Parse sections and items.
48
+ #
49
+ # NOTE: tier headers are matched by REGEX PREFIX (e.g. "## P0\b"), not by exact/startswith
50
+ # comparison against a fixed full title string. The backlog file's section titles evolve
51
+ # over time (suffixes like "(do first)" become "(wave 5, from five-lens re-audit)"), and a
52
+ # hardcoded full-string tier_map silently stops matching anything when that happens — the
53
+ # panel then renders "no backlog found" forever even though the file is full of live items.
54
+ # Regex-on-prefix survives any suffix/rename of the tier header.
55
+ current_tier = None
56
+ tier_patterns = [
57
+ (re.compile(r'^##\s*P0\b'), "P0"),
58
+ (re.compile(r'^##\s*P1\b'), "P1"),
59
+ (re.compile(r'^##\s*P2\b'), "P2"),
60
+ (re.compile(r'^##\s*Needs a user decision\b', re.IGNORECASE), "Needs decision"),
61
+ ]
62
+
63
+ # Stop parsing at these sections
64
+ stop_sections = ["## Landing log", "## Dispatch plan"]
65
+
66
+ tiers_data = {} # tier_name -> list of items
67
+
68
+ for line in lines:
69
+ line_stripped = line.strip()
70
+
71
+ # Check if we hit a stop section
72
+ if any(line_stripped.startswith(stop) for stop in stop_sections):
73
+ break
74
+
75
+ # Any level-2 header re-evaluates current_tier. This is deliberate: a header that
76
+ # doesn't match a known tier (e.g. "## Features (user-requested)") resets current_tier
77
+ # to None, so its items are NOT silently attributed to whatever tier came before it
78
+ # (bleed-through bug from sticky state).
79
+ if line_stripped.startswith("## "):
80
+ matched_tier = None
81
+ for pattern, tier_name in tier_patterns:
82
+ if pattern.match(line_stripped):
83
+ matched_tier = tier_name
84
+ break
85
+ current_tier = matched_tier
86
+ if current_tier and current_tier not in tiers_data:
87
+ tiers_data[current_tier] = []
88
+ continue
89
+
90
+ # Parse item line (starts with "- " and a status glyph)
91
+ if current_tier and line_stripped.startswith("- "):
92
+ # Status glyphs: ✅ 🔵 ⬜ ⏸
93
+ status = None
94
+ rest = line_stripped[2:].strip() # Remove "- "
95
+
96
+ if rest.startswith("✅"):
97
+ status = "✅"
98
+ rest = rest[1:].strip()
99
+ elif rest.startswith("🔵"):
100
+ status = "🔵"
101
+ rest = rest[1:].strip()
102
+ elif rest.startswith("⬜"):
103
+ status = "⬜"
104
+ rest = rest[1:].strip()
105
+ elif rest.startswith("⏸"):
106
+ status = "⏸"
107
+ rest = rest[1:].strip()
108
+
109
+ if status:
110
+ # Extract tag and title from "**[tag] Title...**"
111
+ # Pattern: **[something] rest**
112
+ if rest.startswith("**"):
113
+ # Find the closing **
114
+ match = re.match(r'\*\*\[([^\]]+)\]\s+(.+?)\*\*', rest)
115
+ if match:
116
+ tag = f"[{match.group(1)}]"
117
+ title = match.group(2)
118
+
119
+ tiers_data[current_tier].append({
120
+ "status": status,
121
+ "tag": tag,
122
+ "title": title
123
+ })
124
+
125
+ # Convert to result format with counts
126
+ tier_order = ["P0", "P1", "P2", "Needs decision"]
127
+ for tier_name in tier_order:
128
+ if tier_name in tiers_data:
129
+ items = tiers_data[tier_name]
130
+ done = sum(1 for item in items if item["status"] == "✅")
131
+ inflight = sum(1 for item in items if item["status"] == "🔵")
132
+ todo = sum(1 for item in items if item["status"] == "⬜")
133
+
134
+ result["tiers"].append({
135
+ "tier": tier_name,
136
+ "items": items,
137
+ "done": done,
138
+ "inflight": inflight,
139
+ "todo": todo,
140
+ "total": len(items)
141
+ })
142
+
143
+ return result
144
+
145
+ def get_heartbeat_status():
146
+ """Read daemon heartbeat age and status.
147
+
148
+ Buckets age to prevent every-tick hash change: age is reported in 3-second buckets
149
+ (e.g., 0-2s → 0, 3-5s → 3, 6-8s → 6, ...) so the heartbeat snapshot only changes
150
+ every ~3 seconds, not every 1 second. This preserves the change-hash gate effectiveness.
151
+ """
152
+ try:
153
+ if not config.WATCHDOG_HEARTBEAT.exists():
154
+ return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
155
+ content = config.WATCHDOG_HEARTBEAT.read_text(encoding='utf-8').strip()
156
+ if not content:
157
+ return {"alive": "UNKNOWN", "age": -1, "threshold": 300}
158
+ # Parse epoch value robustly; assume seconds (standard epoch format)
159
+ try:
160
+ timestamp = int(content)
161
+ except ValueError:
162
+ # Retry once in case of race during daemon write
163
+ try:
164
+ content = config.WATCHDOG_HEARTBEAT.read_text().strip()
165
+ timestamp = int(content)
166
+ except Exception as e:
167
+ print(f"[collectors] Failed to parse watchdog heartbeat: {e}", file=sys.stderr)
168
+ return {"alive": "unknown", "age": -1, "threshold": 300}
169
+ # Age in seconds: now_seconds - heartbeat_seconds
170
+ age_seconds = int(time()) - timestamp
171
+ # Bucket age to 3-second intervals to prevent hash churn
172
+ age_bucketed = (age_seconds // 3) * 3
173
+ alive = "ALIVE" if age_seconds < 300 else "STALE"
174
+ return {"alive": alive, "age": age_bucketed, "threshold": 300}
175
+ except Exception as e:
176
+ print(f"[collectors] Failed to get watchdog heartbeat: {e}", file=sys.stderr)
177
+ return {"alive": "unknown", "age": -1, "threshold": 300}
178
+
179
+ def get_monitor_heartbeat_status():
180
+ """Read orchestration monitor heartbeat age and status.
181
+
182
+ Buckets age to prevent every-tick hash change: age is reported in 3-second buckets
183
+ (e.g., 0-2s → 0, 3-5s → 3, 6-8s → 6, ...) so the monitor snapshot only changes
184
+ every ~3 seconds, not every 1 second. This preserves the change-hash gate effectiveness.
185
+ """
186
+ try:
187
+ # Check both possible paths: state/.monitor-heartbeat and monitor/.monitor-heartbeat
188
+ monitor_hb = config.MONITOR_HEARTBEAT
189
+ if not monitor_hb.exists():
190
+ # Try alternate path
191
+ alt_path = config.AESOP_ROOT / "monitor" / ".monitor-heartbeat"
192
+ if not alt_path.exists():
193
+ return {"alive": "not running", "age": -1, "threshold": 3600}
194
+ monitor_hb = alt_path
195
+
196
+ content = monitor_hb.read_text(encoding='utf-8').strip()
197
+ if not content:
198
+ return {"alive": "not running", "age": -1, "threshold": 3600}
199
+ # Parse epoch value robustly; assume seconds (standard epoch format)
200
+ try:
201
+ timestamp = int(content)
202
+ except ValueError:
203
+ # Retry once in case of race during monitor write
204
+ try:
205
+ content = monitor_hb.read_text().strip()
206
+ timestamp = int(content)
207
+ except Exception as e:
208
+ print(f"[collectors] Failed to parse monitor heartbeat: {e}", file=sys.stderr)
209
+ return {"alive": "unknown", "age": -1, "threshold": 3600}
210
+ # Age in seconds: now_seconds - heartbeat_seconds
211
+ age_seconds = int(time()) - timestamp
212
+ # Bucket age to 3-second intervals to prevent hash churn
213
+ age_bucketed = (age_seconds // 3) * 3
214
+ alive = "ALIVE" if age_seconds < 3600 else "STALE"
215
+ return {"alive": alive, "age": age_bucketed, "threshold": 3600}
216
+ except Exception as e:
217
+ print(f"[collectors] Failed to get monitor heartbeat: {e}", file=sys.stderr)
218
+ return {"alive": "unknown", "age": -1, "threshold": 3600}
219
+
220
+ def get_main_thread_messages():
221
+ """Read last ~12 messages from newest session JSONL."""
222
+ messages = []
223
+ try:
224
+ if not config.TRANSCRIPTS_ROOT.exists():
225
+ return messages
226
+ # Find newest .jsonl
227
+ jsonl_files = sorted(
228
+ config.TRANSCRIPTS_ROOT.glob("**/*.jsonl"),
229
+ key=lambda p: p.stat().st_mtime,
230
+ reverse=True
231
+ )
232
+ if not jsonl_files:
233
+ return messages
234
+
235
+ newest = jsonl_files[0]
236
+ with open(newest, 'r', encoding='utf-8', errors='ignore') as f:
237
+ lines = f.readlines()
238
+ # Get last 30 lines to extract ~12 message turns
239
+ for line in lines[-30:]:
240
+ try:
241
+ obj = json.loads(line)
242
+ role = obj.get("role", "unknown")
243
+ if role in ("user", "assistant"):
244
+ # Extract text content
245
+ content = obj.get("content", [])
246
+ text = ""
247
+ if isinstance(content, list):
248
+ for block in content:
249
+ if isinstance(block, dict) and "text" in block:
250
+ text = block["text"]
251
+ break
252
+ elif isinstance(content, str):
253
+ text = content
254
+
255
+ if text:
256
+ # Truncate to 200 chars and sanitize
257
+ preview = text[:200].replace("\n", " ").strip()
258
+ timestamp = obj.get("timestamp", "")
259
+ messages.append({
260
+ "role": role,
261
+ "text": preview,
262
+ "timestamp": timestamp
263
+ })
264
+ except (json.JSONDecodeError, KeyError):
265
+ pass
266
+ # Keep only last 12
267
+ messages = messages[-12:]
268
+ except Exception as e:
269
+ print(f"[collectors] Failed to read main thread messages: {e}", file=sys.stderr)
270
+ return messages
271
+
272
+ def get_repos_status():
273
+ """Read repos from .watchdog-repos.json."""
274
+ repos = []
275
+ try:
276
+ if not config.REPOS_JSON.exists():
277
+ return repos
278
+ data = json.loads(config.REPOS_JSON.read_text(encoding='utf-8'))
279
+ if isinstance(data, list):
280
+ repos = data[:10] # Limit to 10
281
+ elif isinstance(data, dict):
282
+ repos = [{"repo": k, "state": v} for k, v in data.items()][:10]
283
+ except Exception as e:
284
+ print(f"[collectors] Failed to read repos status: {e}", file=sys.stderr)
285
+ return repos
286
+
287
+ def get_recent_events():
288
+ """Read last 8 lines from FLEET-BACKUP.log."""
289
+ events = []
290
+ try:
291
+ if not config.BACKUP_LOG.exists():
292
+ return events
293
+ lines = config.BACKUP_LOG.read_text(encoding='utf-8').strip().split('\n')
294
+ events = [line.strip() for line in lines[-8:] if line.strip()]
295
+ except Exception as e:
296
+ print(f"[collectors] Failed to read recent events: {e}", file=sys.stderr)
297
+ return events
298
+
299
+ def get_alerts():
300
+ """Read SECURITY-ALERTS.log, skip NOTE:/RESOLVED-FP, count by severity."""
301
+ alerts = {"count": 0, "lines": []}
302
+ try:
303
+ if not config.ALERTS_LOG.exists():
304
+ return alerts
305
+ lines = config.ALERTS_LOG.read_text(encoding='utf-8').strip().split('\n')
306
+ unreviewed = [
307
+ line.strip() for line in lines
308
+ if line.strip()
309
+ and "NOTE:" not in line
310
+ and "RESOLVED-FP" not in line
311
+ ]
312
+ alerts["count"] = len(unreviewed)
313
+ alerts["lines"] = unreviewed[-5:] # Show last 5
314
+ except Exception as e:
315
+ print(f"[collectors] Failed to read alerts: {e}", file=sys.stderr)
316
+ return alerts
317
+
318
+ def load_tracker():
319
+ """Load tracker.json, return empty tracker if missing or corrupt.
320
+
321
+ Defect 2 fix: Check dirty flag. If render previously failed, the event log
322
+ and tracker.json may be out of sync. Re-render from projection to self-heal.
323
+ """
324
+ # Check dirty flag (indicates previous render failure)
325
+ dirty_file = config.STATE_DIR / ".tracker-render-dirty"
326
+ if dirty_file.exists():
327
+ try:
328
+ print("[tracker] Detected previous render failure; recovering...", file=sys.stderr)
329
+ api = _tracker_api()
330
+ # Force re-render from projection
331
+ try:
332
+ save_tracker(api.project("tracker"))
333
+ dirty_file.unlink()
334
+ print("[tracker] Recovery render completed", file=sys.stderr)
335
+ except Exception as e:
336
+ print(f"[tracker] Recovery render failed: {e}", file=sys.stderr)
337
+ except Exception as e:
338
+ print(f"[tracker] Failed to recover from dirty flag: {e}", file=sys.stderr)
339
+
340
+ if not config.TRACKER_FILE.exists():
341
+ return {"version": 1, "items": []}
342
+
343
+ try:
344
+ data = json.loads(config.TRACKER_FILE.read_text(encoding='utf-8'))
345
+ if not isinstance(data, dict) or "version" not in data:
346
+ raise ValueError("Invalid tracker schema")
347
+ return data
348
+ except Exception as e:
349
+ print(f"[tracker] Corrupt tracker.json: {e}", file=sys.stderr)
350
+ corrupt_path = config.TRACKER_FILE.with_suffix('.json.corrupt')
351
+ try:
352
+ if config.TRACKER_FILE.exists():
353
+ config.TRACKER_FILE.rename(corrupt_path)
354
+ except Exception as e:
355
+ print(f"[tracker] Failed to rename corrupt tracker: {e}", file=sys.stderr)
356
+ return {"version": 1, "items": []}
357
+
358
+ def save_tracker(tracker):
359
+ """Save tracker atomically using temp file + os.replace."""
360
+ config.STATE_DIR.mkdir(parents=True, exist_ok=True)
361
+ temp_file = config.TRACKER_FILE.with_suffix('.json.tmp')
362
+ try:
363
+ with open(temp_file, 'w', encoding='utf-8') as f:
364
+ json.dump(tracker, f, indent=2)
365
+ os.replace(str(temp_file), str(config.TRACKER_FILE))
366
+ except Exception as e:
367
+ print(f"[tracker] Error saving tracker: {e}", file=sys.stderr)
368
+ try:
369
+ temp_file.unlink()
370
+ except Exception as ue:
371
+ print(f"[tracker] Failed to unlink temp file: {ue}", file=sys.stderr)
372
+ raise
373
+
374
+ def migrate_tracker_from_backlog():
375
+ """One-time idempotent migration: AUDIT-BACKLOG.md -> tracker.json."""
376
+ if config.TRACKER_FILE.exists():
377
+ return load_tracker()
378
+
379
+ backlog_data = parse_audit_backlog()
380
+ if not backlog_data.get("tiers"):
381
+ return {"version": 1, "items": []}
382
+
383
+ items = []
384
+ for tier_data in backlog_data["tiers"]:
385
+ priority = tier_data["tier"]
386
+
387
+ for backlog_item in tier_data.get("items", []):
388
+ status_glyph = backlog_item["status"]
389
+
390
+ if status_glyph == "✅":
391
+ status, lane = "done", "done"
392
+ tags = []
393
+ elif status_glyph == "🔵":
394
+ status, lane = "in-progress", "in-progress"
395
+ tags = []
396
+ elif status_glyph == "⏸":
397
+ status, lane = "todo", "proposed"
398
+ tags = ["needs-decision"]
399
+ else:
400
+ status, lane = "todo", "ranked"
401
+ tags = []
402
+
403
+ title = backlog_item.get("title", "")
404
+ tag_prefix = backlog_item.get("tag", "")
405
+ if tag_prefix:
406
+ tag_value = tag_prefix.strip("[]")
407
+ if tag_value and tag_value not in tags:
408
+ tags.insert(0, tag_value)
409
+
410
+ item = {
411
+ "id": secrets.token_hex(6),
412
+ "title": title,
413
+ "priority": priority,
414
+ "status": status,
415
+ "lane": lane,
416
+ "source": "audit-backlog-migration",
417
+ "tags": tags,
418
+ "notes": None,
419
+ "pr_link": None,
420
+ "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
421
+ "completed_at": None
422
+ }
423
+ items.append(item)
424
+
425
+ tracker = {"version": 1, "items": items}
426
+ save_tracker(tracker)
427
+ return tracker
428
+
429
+ def get_tracker_items(status=None, priority=None):
430
+ """Retrieve tracker items with optional filters."""
431
+ tracker = load_tracker()
432
+ items = tracker.get("items", [])
433
+
434
+ if status:
435
+ items = [i for i in items if i.get("status") == status]
436
+ if priority:
437
+ items = [i for i in items if i.get("priority") == priority]
438
+
439
+ return items
440
+
441
+ # --- Event-sourced tracker backing (state_store) -----------------------------
442
+ # The event log is the write-authority + audit trail; tracker.json is kept as
443
+ # the rendered export that reads/SSE/UI consume. Each mutation appends an event
444
+ # and re-renders the file (atomic os.replace via save_tracker), replacing the
445
+ # old load->mutate->save read-modify-write that raced under concurrent writers.
446
+ # The live read path (load_tracker / get_tracker_items / SSE snapshot) is
447
+ # unchanged: it still reads tracker.json, which every write keeps current.
448
+
449
+ def _tracker_api():
450
+ """Return a StateAPI over state/tracker_events.db (lazy import; call-time paths)."""
451
+ try:
452
+ from state_store import StateAPI
453
+ except ImportError:
454
+ from pathlib import Path as _Path
455
+ root = str(_Path(__file__).resolve().parents[1])
456
+ if root not in sys.path:
457
+ sys.path.insert(0, root)
458
+ from state_store import StateAPI
459
+ config.STATE_DIR.mkdir(parents=True, exist_ok=True)
460
+ return StateAPI(str(config.STATE_DIR / "tracker_events.db"))
461
+
462
+
463
+ def _ensure_tracker_migrated(api):
464
+ """Backfill the event log from the existing tracker.json once (idempotent).
465
+
466
+ Defect 3 fix: Guard migration with a marker event (migration_started with
467
+ version=1). The first caller to append this marker wins and performs the
468
+ backfill. Subsequent callers see the marker and skip the backfill. This
469
+ prevents concurrent callers from polluting the audit log with duplicate
470
+ item_created events.
471
+ """
472
+ events = api.get("tracker")
473
+
474
+ # Check for migration marker (first event of type "migration_started")
475
+ has_migration_marker = any(e.get("type") == "migration_started" and
476
+ e.get("payload", {}).get("version") == 1
477
+ for e in events)
478
+ if has_migration_marker:
479
+ # Migration already completed (or in progress); skip backfill
480
+ return
481
+
482
+ # Append migration marker first to atomically claim the migration
483
+ try:
484
+ api.append("tracker", "migration_started", {"version": 1}, "system")
485
+ except Exception as e:
486
+ print(f"[tracker] Failed to append migration marker: {e}", file=sys.stderr)
487
+ return
488
+
489
+ # Now safe to backfill (other callers will see marker and skip)
490
+ if config.TRACKER_FILE.exists():
491
+ try:
492
+ data = json.loads(config.TRACKER_FILE.read_text(encoding='utf-8'))
493
+ except Exception:
494
+ data = {"items": []}
495
+ for item in data.get("items", []):
496
+ if isinstance(item, dict) and item.get("id"):
497
+ api.append("tracker", "item_created", item, "migration")
498
+
499
+
500
+ def _tracker_items_by_id(api):
501
+ return {it["id"]: it for it in api.project("tracker")["items"]}
502
+
503
+
504
+ def _render_tracker(api):
505
+ """Materialize the projection back to tracker.json (atomic).
506
+
507
+ Defect 2 fix: Wrap render in try/except. On failure, log loudly and mark
508
+ a dirty flag so the next read triggers a recovery re-render. This ensures
509
+ that if the event log has events but tracker.json is stale, the next read
510
+ self-heals by detecting the mismatch and re-rendering.
511
+ """
512
+ try:
513
+ save_tracker(api.project("tracker"))
514
+ except Exception as e:
515
+ print(f"[tracker] CRITICAL: Failed to render tracker after event append: {e}",
516
+ file=sys.stderr)
517
+ # Mark dirty flag for recovery on next read
518
+ dirty_file = config.STATE_DIR / ".tracker-render-dirty"
519
+ try:
520
+ dirty_file.write_text(str(time()), encoding='utf-8')
521
+ except Exception as e2:
522
+ print(f"[tracker] Failed to write dirty flag: {e2}", file=sys.stderr)
523
+ raise
524
+
525
+
526
+ def create_tracker_item(data):
527
+ """Create a new tracker item (event-sourced; tracker.json re-rendered)."""
528
+ api = _tracker_api()
529
+ _ensure_tracker_migrated(api)
530
+
531
+ item = {
532
+ "id": secrets.token_hex(6),
533
+ "title": data.get("title", ""),
534
+ "priority": data.get("priority", "P1"),
535
+ "status": data.get("status", "todo"),
536
+ "lane": data.get("lane", "proposed"),
537
+ "source": data.get("source", "manual"),
538
+ "tags": data.get("tags", []) if isinstance(data.get("tags"), list) else [],
539
+ "notes": data.get("notes"),
540
+ "pr_link": data.get("pr_link"),
541
+ "created_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
542
+ "completed_at": None
543
+ }
544
+
545
+ api.append("tracker", "item_created", item, item["source"])
546
+ _render_tracker(api)
547
+ return item
548
+
549
+ def update_tracker_item(item_id, update_data):
550
+ """Update a tracker item by id (event-sourced)."""
551
+ api = _tracker_api()
552
+ _ensure_tracker_migrated(api)
553
+
554
+ current = _tracker_items_by_id(api)
555
+ if item_id not in current:
556
+ raise Exception(f"404 Item not found: {item_id}")
557
+
558
+ patch = {"id": item_id}
559
+ for key in ["status", "lane", "priority", "notes", "pr_link", "tags"]:
560
+ if key in update_data:
561
+ patch[key] = update_data[key]
562
+
563
+ if update_data.get("status") == "done" and not current[item_id].get("completed_at"):
564
+ patch["completed_at"] = datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
565
+
566
+ api.append("tracker", "item_updated", patch, "api")
567
+ _render_tracker(api)
568
+ return _tracker_items_by_id(api)[item_id]
569
+
570
+ def delete_tracker_item(item_id):
571
+ """Soft-delete a tracker item (mark as archived; event-sourced)."""
572
+ api = _tracker_api()
573
+ _ensure_tracker_migrated(api)
574
+
575
+ current = _tracker_items_by_id(api)
576
+ if item_id not in current:
577
+ raise Exception(f"404 Item not found: {item_id}")
578
+
579
+ api.append("tracker", "item_archived", {"id": item_id}, "api")
580
+ _render_tracker(api)
581
+ return _tracker_items_by_id(api)[item_id]
582
+
583
+ def _snapshot_data():
584
+ """Everything the 'data' SSE section covers (header, repos, events, alerts, messages)."""
585
+ return {
586
+ "watchdog": get_heartbeat_status(),
587
+ "monitor": get_monitor_heartbeat_status(),
588
+ "repos": get_repos_status(),
589
+ "events": get_recent_events(),
590
+ "alerts": get_alerts(),
591
+ "messages": get_main_thread_messages(),
592
+ }
593
+
594
+ def _snapshot_tracker():
595
+ """Read tracker.json, return {items: [...]}."""
596
+ tracker_file = config.STATE_DIR / "tracker.json"
597
+ if not tracker_file.exists():
598
+ return {"items": []}
599
+ try:
600
+ data = json.loads(tracker_file.read_text(encoding='utf-8'))
601
+ if isinstance(data, dict) and "items" in data:
602
+ return {"items": data.get("items", [])}
603
+ return {"items": []}
604
+ except Exception as e:
605
+ print(f"[tracker] Snapshot error: {e}", file=sys.stderr)
606
+ return {"items": []}
607
+
608
+ def _snapshot_orchestrator_status():
609
+ """Read and normalize orchestrator-status.json."""
610
+ status_file = config.STATE_DIR / "orchestrator-status.json"
611
+ if not status_file.exists():
612
+ return {"orchestrators": []}
613
+ try:
614
+ data = json.loads(status_file.read_text(encoding='utf-8'))
615
+ if not isinstance(data, dict):
616
+ return {"orchestrators": []}
617
+ # Already normalized list shape
618
+ if "orchestrators" in data and isinstance(data["orchestrators"], list):
619
+ return data
620
+ # Wrap bare object as single entry
621
+ if "id" in data or "role" in data:
622
+ age_seconds = 0
623
+ stale = False
624
+ try:
625
+ updated_at_str = data.get("updated_at", "")
626
+ if updated_at_str:
627
+ updated_at_str = updated_at_str.rstrip('Z')
628
+ updated_at = datetime.fromisoformat(updated_at_str)
629
+ age_seconds = int((datetime.now(timezone.utc).replace(tzinfo=None) - updated_at).total_seconds())
630
+ stale = age_seconds > 1800
631
+ except Exception as e:
632
+ print(f"[collectors] Failed to parse orchestrator timestamp: {e}", file=sys.stderr)
633
+ entry = dict(data)
634
+ entry["age_seconds"] = age_seconds
635
+ entry["stale"] = stale
636
+ return {"orchestrators": [entry]}
637
+ return {"orchestrators": []}
638
+ except Exception as e:
639
+ print(f"[status] Snapshot error: {e}", file=sys.stderr)
640
+ return {"orchestrators": []}
641
+
642
+ def drain_tracker_inbox():
643
+ """Drain .tracker-inbox.jsonl, create items idempotently.
644
+
645
+ Defect 1 fix: Atomically rename inbox file to unique processing name FIRST
646
+ to ensure only one caller processes it under concurrent access. Strengthen
647
+ dedup to check both tracker.json AND api.project("tracker") so items
648
+ in the event store but not yet rendered are also excluded.
649
+ """
650
+ inbox_file = config.STATE_DIR / ".tracker-inbox.jsonl"
651
+ if not inbox_file.exists():
652
+ return []
653
+
654
+ created = []
655
+ try:
656
+ # Defect 1: Atomically rename inbox to unique processing name first.
657
+ # This ensures only one caller wins; others see no file.
658
+ processing_file = inbox_file.with_name(
659
+ f".tracker-inbox.jsonl.processing-{secrets.token_hex(8)}"
660
+ )
661
+ try:
662
+ os.replace(str(inbox_file), str(processing_file))
663
+ except FileNotFoundError:
664
+ # Another caller already renamed it; nothing to process
665
+ return []
666
+ except Exception as e:
667
+ print(f"[inbox] Failed to rename inbox: {e}", file=sys.stderr)
668
+ return []
669
+
670
+ content = processing_file.read_text(encoding='utf-8')
671
+ if not content.strip():
672
+ processing_file.unlink()
673
+ return []
674
+
675
+ lines = content.strip().splitlines()
676
+
677
+ # Defect 1: Build dedup hash set from both tracker.json AND event store projection.
678
+ # This catches items in the event store that haven't been rendered to tracker.json yet.
679
+ existing_hashes = set()
680
+
681
+ # Check rendered tracker.json
682
+ tracker_json = load_tracker()
683
+ for item in tracker_json.get("items", []):
684
+ source = item.get("source", "")
685
+ title = item.get("title", "")
686
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
687
+ existing_hashes.add(h)
688
+
689
+ # Also check event store projection (catches items in DB but not yet rendered)
690
+ try:
691
+ api = _tracker_api()
692
+ projected = api.project("tracker")
693
+ for item in projected.get("items", []):
694
+ source = item.get("source", "")
695
+ title = item.get("title", "")
696
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
697
+ existing_hashes.add(h)
698
+ except Exception as e:
699
+ print(f"[inbox] Failed to project tracker state: {e}", file=sys.stderr)
700
+ # Fall back to tracker.json only if projection fails
701
+ pass
702
+
703
+ rejects = []
704
+ for line in lines:
705
+ line = line.strip()
706
+ if not line:
707
+ continue
708
+ try:
709
+ entry = json.loads(line)
710
+ if not isinstance(entry, dict):
711
+ rejects.append(line)
712
+ continue
713
+
714
+ source = entry.get("source", "")
715
+ title = entry.get("title", "")
716
+ h = hashlib.sha256((source + ":" + title).encode()).hexdigest()
717
+
718
+ if h not in existing_hashes:
719
+ item = create_tracker_item(entry)
720
+ created.append(item)
721
+ existing_hashes.add(h)
722
+ except json.JSONDecodeError:
723
+ rejects.append(line)
724
+ except Exception as e:
725
+ rejects.append(line + " # " + str(e))
726
+
727
+ if rejects:
728
+ rejects_file = inbox_file.with_name(".tracker-inbox.rejects")
729
+ rejects_file.write_text("\n".join(rejects) + "\n", encoding='utf-8')
730
+
731
+ processing_file.unlink()
732
+ except Exception as e:
733
+ print(f"[inbox] Drain error: {e}", file=sys.stderr)
734
+
735
+ return created