@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,272 @@
1
+ #!/usr/bin/env python3
2
+ r"""
3
+ Fleet Outcome Ledger — append-only audit trail for dispatched agents.
4
+
5
+ Subcommands:
6
+ append <timestamp> <agent_type> <model> <duration_sec> <tokens_in> <tokens_out> [verdict]
7
+ Manually append one ledger line (verdict = OK|FAILED|EMPTY|HUNG, default OK)
8
+ harvest
9
+ Scan session tasks directories for agent outcomes and append missing entries.
10
+ (Tracks state in ledger directory for resume capability)
11
+ rotate
12
+ Archive ledger lines exceeding ~200 lines to dated archive, keep recent tail in live ledger
13
+
14
+ Ledger format (markdown table):
15
+ | ISO ts | agent_type | model | duration_sec | tokens_in | tokens_out | verdict |
16
+
17
+ Environment:
18
+ AESOP_STATE_ROOT: Path to state directory (default: ./state relative to cwd)
19
+ AESOP_TEMP_ROOT: Path to temp directory for tasks scanning (optional)
20
+ """
21
+
22
+ import sys
23
+ import json
24
+ import os
25
+ from pathlib import Path
26
+ from datetime import datetime, timezone
27
+ import re
28
+ from collections import defaultdict
29
+
30
+
31
+ def get_state_dir():
32
+ """Resolve state directory from env var or current working directory."""
33
+ if os.environ.get("AESOP_STATE_ROOT"):
34
+ return Path(os.environ["AESOP_STATE_ROOT"])
35
+ return Path.cwd() / "state"
36
+
37
+
38
+ def get_ledger_paths():
39
+ """Get ledger file and sidecar state file paths."""
40
+ state_dir = get_state_dir()
41
+ ledger_dir = state_dir / "ledger"
42
+ ledger_file = ledger_dir / "OUTCOMES-LEDGER.md"
43
+ harvest_state_file = ledger_dir / ".fleet-ledger-harvest.json"
44
+ return ledger_file, harvest_state_file, ledger_dir
45
+
46
+
47
+ def ensure_ledger_header():
48
+ """Ensure ledger file exists with markdown table header."""
49
+ ledger_file, _, ledger_dir = get_ledger_paths()
50
+ if not ledger_file.exists():
51
+ ledger_dir.mkdir(parents=True, exist_ok=True)
52
+ header = '| ISO ts | agent_type | model | duration_sec | tokens_in | tokens_out | verdict |\n'
53
+ header += '|--------|------------|-------|--------------|-----------|------------|--------|\n'
54
+ ledger_file.write_text(header, encoding='utf-8')
55
+
56
+
57
+ def append_ledger_line(iso_ts, agent_type, model, duration_sec, tokens_in, tokens_out, verdict='OK'):
58
+ """Append one line to the ledger."""
59
+ ensure_ledger_header()
60
+ ledger_file, _, _ = get_ledger_paths()
61
+
62
+ # Sanitize fields: no pipes, truncate to reasonable length
63
+ agent_type = str(agent_type or '-').replace('|', '').strip()[:30]
64
+ model = str(model or '-').replace('|', '').strip()[:30]
65
+ verdict = str(verdict or 'OK').replace('|', '').strip()[:10]
66
+
67
+ try:
68
+ dur = int(duration_sec) if duration_sec else 0
69
+ except (ValueError, TypeError):
70
+ dur = 0
71
+
72
+ try:
73
+ ti = int(tokens_in) if tokens_in else 0
74
+ except (ValueError, TypeError):
75
+ ti = 0
76
+
77
+ try:
78
+ to = int(tokens_out) if tokens_out else 0
79
+ except (ValueError, TypeError):
80
+ to = 0
81
+
82
+ line = f'| {iso_ts} | {agent_type} | {model} | {dur} | {ti} | {to} | {verdict} |\n'
83
+ with open(ledger_file, 'a', encoding='utf-8') as f:
84
+ f.write(line)
85
+
86
+
87
+ def load_harvest_state():
88
+ """Load last-harvest state (set of already-seen agent IDs)."""
89
+ _, harvest_state_file, _ = get_ledger_paths()
90
+ if harvest_state_file.exists():
91
+ try:
92
+ data = json.loads(harvest_state_file.read_text(encoding='utf-8'))
93
+ return set(data.get('seen_agents', [])), data.get('last_harvest_ts', None)
94
+ except (json.JSONDecodeError, IOError):
95
+ pass
96
+ return set(), None
97
+
98
+
99
+ def save_harvest_state(seen_agents, last_harvest_ts=None):
100
+ """Save last-harvest state."""
101
+ _, harvest_state_file, ledger_dir = get_ledger_paths()
102
+ ledger_dir.mkdir(parents=True, exist_ok=True)
103
+ state = {
104
+ 'seen_agents': sorted(list(seen_agents)),
105
+ 'last_harvest_ts': last_harvest_ts or datetime.now(timezone.utc).isoformat()
106
+ }
107
+ harvest_state_file.write_text(json.dumps(state, indent=2), encoding='utf-8')
108
+
109
+
110
+ def harvest():
111
+ """Scan task output files and append missing agent outcomes."""
112
+ ensure_ledger_header()
113
+ seen_agents, _ = load_harvest_state()
114
+ new_seen = set(seen_agents)
115
+ harvested_count = 0
116
+
117
+ # Determine temp root for tasks scanning
118
+ if os.environ.get("AESOP_TEMP_ROOT"):
119
+ temp_root = Path(os.environ["AESOP_TEMP_ROOT"])
120
+ else:
121
+ # Default to system temp + /claude
122
+ import tempfile
123
+ temp_root = Path(tempfile.gettempdir()) / "claude"
124
+
125
+ # Find all .output files in tasks directories
126
+ for output_file in temp_root.rglob('tasks/*.output'):
127
+ if not output_file.is_file():
128
+ continue
129
+
130
+ try:
131
+ content = output_file.read_text(encoding='utf-8', errors='ignore')
132
+ except (IOError, OSError):
133
+ continue
134
+
135
+ # Parse JSONL: each line is a JSON object
136
+ for line_idx, line in enumerate(content.split('\n')):
137
+ if not line.strip():
138
+ continue
139
+
140
+ try:
141
+ obj = json.loads(line)
142
+ except json.JSONDecodeError:
143
+ continue
144
+
145
+ # Look for agent spawn events (type='assistant' with Agent/Task tool_use)
146
+ # or completion events (type='assistant' with usage data)
147
+ agent_id = obj.get('agentId') or obj.get('uuid')
148
+ if not agent_id:
149
+ continue
150
+
151
+ # Skip if already seen
152
+ if agent_id in seen_agents:
153
+ continue
154
+
155
+ # Try to extract completion info
156
+ msg = obj.get('message', {})
157
+ ts = obj.get('timestamp', '')
158
+ msg_type = msg.get('type', '')
159
+
160
+ # Look for assistant message with usage (indicates completion)
161
+ if msg_type == 'message' and msg.get('role') == 'assistant':
162
+ usage = msg.get('usage', {})
163
+ if usage:
164
+ # Found a completion event
165
+ agent_type = 'Agent' # placeholder; ideally track subagent_type from spawn
166
+ model = msg.get('model', '-')
167
+
168
+ # Try to extract duration from timestamps or usage
169
+ input_tokens = usage.get('input_tokens', 0)
170
+ output_tokens = usage.get('output_tokens', 0)
171
+
172
+ # Verdict: assume OK if no stop_reason error
173
+ stop_reason = msg.get('stop_reason')
174
+ verdict = 'OK'
175
+ if stop_reason in ('error', 'end_turn'):
176
+ verdict = 'OK' # normal completion
177
+ elif not stop_reason:
178
+ verdict = 'OK'
179
+
180
+ duration_sec = 0 # TODO: parse from timestamps if available
181
+
182
+ # Append to ledger
183
+ if ts:
184
+ append_ledger_line(ts[:19], agent_type, model, duration_sec,
185
+ input_tokens, output_tokens, verdict)
186
+ new_seen.add(agent_id)
187
+ harvested_count += 1
188
+
189
+ # Save state after processing each file to avoid re-processing
190
+ save_harvest_state(new_seen)
191
+
192
+ ledger_file, _, _ = get_ledger_paths()
193
+ print(f'Harvested {harvested_count} new agent outcomes to {ledger_file}')
194
+ return harvested_count
195
+
196
+
197
+ def rotate():
198
+ """Archive old ledger lines if ledger exceeds ~200 lines."""
199
+ ensure_ledger_header()
200
+ ledger_file, _, ledger_dir = get_ledger_paths()
201
+
202
+ try:
203
+ lines = ledger_file.read_text(encoding='utf-8').split('\n')
204
+ except (IOError, OSError):
205
+ print('Error reading ledger')
206
+ return
207
+
208
+ # Count non-header lines
209
+ data_lines = [l for l in lines if l.strip() and not l.startswith('|---|')]
210
+ header_lines = [l for l in lines if l.startswith('|') and '---|' in l]
211
+
212
+ if len(data_lines) <= 202: # Leave some headroom
213
+ print(f'Ledger has {len(data_lines)} lines; no rotation needed (threshold: 200)')
214
+ return
215
+
216
+ # Create archive with date stamp
217
+ archive_dir = ledger_dir / "archives"
218
+ archive_dir.mkdir(parents=True, exist_ok=True)
219
+ archive_name = datetime.now(timezone.utc).strftime('FLEET-LEDGER-%Y%m%d-%H%M%S.md')
220
+ archive_file = archive_dir / archive_name
221
+
222
+ # Write header + oldest 50% of data lines to archive
223
+ archive_count = len(data_lines) // 2
224
+ archive_lines = header_lines + data_lines[:archive_count]
225
+ archive_file.write_text('\n'.join(archive_lines) + '\n', encoding='utf-8')
226
+
227
+ # Keep header + newest lines in live ledger
228
+ new_ledger = header_lines + data_lines[archive_count:]
229
+ ledger_file.write_text('\n'.join(new_ledger) + '\n', encoding='utf-8')
230
+
231
+ print(f'Rotated {archive_count} lines to {archive_file}')
232
+ print(f'Live ledger now has {len(new_ledger) - len(header_lines)} data lines')
233
+
234
+
235
+ def main():
236
+ if len(sys.argv) < 2:
237
+ print(__doc__)
238
+ sys.exit(1)
239
+
240
+ cmd = sys.argv[1].lower()
241
+
242
+ if cmd == 'append':
243
+ # append <ts> <agent_type> <model> <dur> <tokens_in> <tokens_out> [verdict]
244
+ if len(sys.argv) < 7:
245
+ print('Usage: fleet_ledger.py append <ts> <agent_type> <model> <dur_sec> <tokens_in> <tokens_out> [verdict]')
246
+ sys.exit(1)
247
+
248
+ ts = sys.argv[2]
249
+ agent_type = sys.argv[3]
250
+ model = sys.argv[4]
251
+ dur = sys.argv[5]
252
+ ti = sys.argv[6]
253
+ to = sys.argv[7] if len(sys.argv) > 7 else '0'
254
+ verdict = sys.argv[8] if len(sys.argv) > 8 else 'OK'
255
+
256
+ append_ledger_line(ts, agent_type, model, dur, ti, to, verdict)
257
+ print(f'Appended: {ts} {agent_type} {model} {dur}s {ti}->{to} [{verdict}]')
258
+
259
+ elif cmd == 'harvest':
260
+ harvest()
261
+
262
+ elif cmd == 'rotate':
263
+ rotate()
264
+
265
+ else:
266
+ print(f'Unknown command: {cmd}')
267
+ print(__doc__)
268
+ sys.exit(1)
269
+
270
+
271
+ if __name__ == '__main__':
272
+ main()
@@ -0,0 +1,134 @@
1
+ """Extract NEW fleet spawn prompts (Agent/Task) for review cycle with deduplication."""
2
+ import argparse
3
+ import hashlib
4
+ import json
5
+ import os
6
+ import pathlib
7
+ import sys
8
+ from datetime import datetime, timedelta
9
+
10
+
11
+ def walk_jsonl(directory):
12
+ """Recursively find all .jsonl files in a directory."""
13
+ result = []
14
+ for root, dirs, files in os.walk(directory):
15
+ for f in files:
16
+ if f.endswith(".jsonl"):
17
+ result.append(os.path.join(root, f))
18
+ return result
19
+
20
+
21
+ def main():
22
+ parser = argparse.ArgumentParser(
23
+ description="Extract new fleet spawn prompts (Agent/Task) for review, with dedup by SHA1."
24
+ )
25
+ parser.add_argument(
26
+ "roots", nargs="+", help="Root directories containing .jsonl transcripts to scan"
27
+ )
28
+ parser.add_argument(
29
+ "-s", "--seen-file", required=True, help="Path to seen-set JSON file for dedup (will be created/updated)"
30
+ )
31
+ parser.add_argument(
32
+ "-m", "--minutes", type=int, default=30, help="Look back this many minutes (default 30)"
33
+ )
34
+ parser.add_argument(
35
+ "-x", "--exclude-session", help="Exclude transcripts from this session ID substring"
36
+ )
37
+ parser.add_argument(
38
+ "--max", type=int, default=20, help="Cap prompts per review cycle (default 20)"
39
+ )
40
+
41
+ args = parser.parse_args()
42
+
43
+ # Load seen set
44
+ seen_set = set()
45
+ if os.path.exists(args.seen_file):
46
+ try:
47
+ with open(args.seen_file, "r", encoding="utf-8") as f:
48
+ seen_set = set(json.load(f))
49
+ except Exception:
50
+ pass
51
+
52
+ # Gather .jsonl files
53
+ files = []
54
+ for root in args.roots:
55
+ if os.path.exists(root):
56
+ files.extend(walk_jsonl(root))
57
+
58
+ # Time filter
59
+ since_ms = (datetime.now() - timedelta(minutes=args.minutes)).timestamp() * 1000
60
+
61
+ out = []
62
+ for fp in files:
63
+ # Session exclusion
64
+ if args.exclude_session and args.exclude_session in fp:
65
+ continue
66
+
67
+ try:
68
+ st = os.stat(fp)
69
+ if st.st_mtime_ns / 1e6 < since_ms:
70
+ continue
71
+ except Exception:
72
+ continue
73
+
74
+ try:
75
+ with open(fp, "r", encoding="utf-8") as f:
76
+ lines = f.read().split("\n")
77
+ except Exception:
78
+ continue
79
+
80
+ base = os.path.basename(fp)
81
+ for line in lines:
82
+ if not line.strip() or ('"Agent"' not in line and '"Task"' not in line):
83
+ continue
84
+ try:
85
+ obj = json.loads(line)
86
+ except json.JSONDecodeError:
87
+ continue
88
+
89
+ content = obj.get("message", {}).get("content")
90
+ if not isinstance(content, list):
91
+ continue
92
+
93
+ for c in content:
94
+ if c.get("type") != "tool_use" or c.get("name") not in ["Agent", "Task"]:
95
+ continue
96
+
97
+ prompt = (c.get("input", {}).get("prompt") or c.get("input", {}).get("description") or "").strip()
98
+ if len(prompt) < 8:
99
+ continue
100
+
101
+ key = hashlib.sha1(prompt.encode()).hexdigest()[:16]
102
+ if key in seen_set:
103
+ continue
104
+
105
+ seen_set.add(key)
106
+ out.append(
107
+ {
108
+ "key": key,
109
+ "src": base,
110
+ "label": (c.get("input", {}).get("description") or "")[:80],
111
+ "prompt": prompt[:700],
112
+ }
113
+ )
114
+
115
+ if len(out) >= args.max:
116
+ break
117
+
118
+ if len(out) >= args.max:
119
+ break
120
+
121
+ if len(out) >= args.max:
122
+ break
123
+
124
+ # Write updated seen set
125
+ pathlib.Path(args.seen_file).parent.mkdir(parents=True, exist_ok=True)
126
+ with open(args.seen_file, "w", encoding="utf-8") as f:
127
+ json.dump(sorted(list(seen_set)), f)
128
+
129
+ # Output to stdout as JSON
130
+ sys.stdout.write(json.dumps(out))
131
+
132
+
133
+ if __name__ == "__main__":
134
+ main()
@@ -0,0 +1,296 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Healthcheck tool — Aggregates fleet health signals.
4
+
5
+ Checks:
6
+ - Heartbeat ages (watchdog, monitor) from config-driven state paths
7
+ - Tracker open-item counts by lane (state/tracker.json)
8
+ - Security-alert count and severity (state/SECURITY-ALERTS.log, if present)
9
+ - Orchestrator status age and phase (state/orchestrator-status.json)
10
+
11
+ Output: One line `HEALTH: 🟢|🟡|🔴 <reason>` + compact bullet list of non-green contributors.
12
+
13
+ Green = all heartbeats fresh + no HIGH alerts
14
+ Yellow = stale heartbeat OR unreviewed MED alert
15
+ Red = HIGH alert OR watchdog dead while agents running
16
+
17
+ Config read at CALL time. Graceful on missing files (missing = reported, not crash).
18
+ Encoding: UTF-8 always. --json mode outputs machine-readable format.
19
+ """
20
+ import json
21
+ import os
22
+ import sys
23
+ from datetime import datetime, timezone
24
+ from pathlib import Path
25
+ from time import time
26
+
27
+ # Import UI config for state path resolution
28
+ UI_DIR = Path(__file__).parent.parent / "ui"
29
+ if str(UI_DIR) not in sys.path:
30
+ sys.path.insert(0, str(UI_DIR))
31
+
32
+ try:
33
+ import config
34
+ except ImportError:
35
+ print("ERROR: Unable to import config module from ui/", file=sys.stderr)
36
+ sys.exit(2)
37
+
38
+
39
+ def check_health(json_mode=False):
40
+ """
41
+ Aggregate health signals and return health ball + reason.
42
+
43
+ Returns:
44
+ str: Either human-readable "HEALTH: 🟢|🟡|🔴 <reason>\n- bullet list"
45
+ or JSON string (if json_mode=True)
46
+ """
47
+ # Reload config at call time to pick up env vars
48
+ config.reload()
49
+
50
+ issues = [] # List of (severity, message) tuples
51
+
52
+ # 1. Check heartbeats
53
+ watchdog_status = _check_heartbeat("watchdog", config.WATCHDOG_HEARTBEAT, threshold=300)
54
+ if watchdog_status:
55
+ issues.append(watchdog_status)
56
+
57
+ monitor_status = _check_heartbeat("monitor", config.MONITOR_HEARTBEAT, threshold=3600)
58
+ if monitor_status:
59
+ issues.append(monitor_status)
60
+
61
+ # 2. Check security alerts
62
+ alert_status = _check_alerts(config.ALERTS_LOG)
63
+ if alert_status:
64
+ issues.append(alert_status)
65
+
66
+ # 3. Check orchestrator status
67
+ orch_status = _check_orchestrator_status(config.ORCH_STATUS_FILE)
68
+ if orch_status:
69
+ issues.append(orch_status)
70
+
71
+ # 4. Check tracker items (informational, not severity-driving)
72
+ tracker_info = _check_tracker(config.TRACKER_FILE)
73
+
74
+ # Determine ball color based on issue severities
75
+ ball = "🟢" # Default green
76
+ for severity, msg in issues:
77
+ if severity == "RED":
78
+ ball = "🔴"
79
+ break
80
+ elif severity == "YELLOW" and ball != "🔴":
81
+ ball = "🟡"
82
+
83
+ # Build output
84
+ if json_mode:
85
+ return _format_json(ball, issues, tracker_info)
86
+ else:
87
+ return _format_human(ball, issues, tracker_info)
88
+
89
+
90
+ def _check_heartbeat(name, heartbeat_file, threshold=300):
91
+ """
92
+ Check heartbeat freshness.
93
+
94
+ Returns:
95
+ tuple (severity, message) or None if all OK
96
+ """
97
+ if not heartbeat_file.exists():
98
+ return ("YELLOW", f"no {name} heartbeat file")
99
+
100
+ try:
101
+ content = heartbeat_file.read_text(encoding="utf-8").strip()
102
+ if not content:
103
+ return ("YELLOW", f"{name} heartbeat empty")
104
+
105
+ try:
106
+ timestamp = int(content)
107
+ except ValueError:
108
+ return ("YELLOW", f"{name} heartbeat unparseable")
109
+
110
+ age_seconds = int(time()) - timestamp
111
+ if age_seconds >= threshold * 2:
112
+ # Watchdog dead while orchestrator might be active
113
+ if name == "watchdog" and age_seconds >= 600:
114
+ return ("RED", f"watchdog dead ({age_seconds}s)")
115
+ return ("YELLOW", f"{name} stale ({age_seconds}s > {threshold}s threshold)")
116
+ elif age_seconds > threshold:
117
+ return ("YELLOW", f"{name} stale ({age_seconds}s)")
118
+
119
+ return None
120
+ except Exception as e:
121
+ return ("YELLOW", f"{name} heartbeat read error: {e}")
122
+
123
+
124
+ def _check_alerts(alerts_file):
125
+ """
126
+ Check security alerts for HIGH severity.
127
+
128
+ Returns:
129
+ tuple (severity, message) or None if all OK
130
+ """
131
+ if not alerts_file.exists():
132
+ return None
133
+
134
+ try:
135
+ content = alerts_file.read_text(encoding="utf-8").strip()
136
+ if not content:
137
+ return None
138
+
139
+ lines = content.split("\n")
140
+ unreviewed = [
141
+ line.strip() for line in lines
142
+ if line.strip()
143
+ and "NOTE:" not in line
144
+ and "RESOLVED-FP" not in line
145
+ ]
146
+
147
+ if not unreviewed:
148
+ return None
149
+
150
+ # Check for HIGH severity
151
+ high_count = sum(1 for line in unreviewed if "[HIGH]" in line)
152
+ if high_count > 0:
153
+ return ("RED", f"{high_count} HIGH severity alerts")
154
+
155
+ # Check for MED severity
156
+ med_count = sum(1 for line in unreviewed if "[MED]" in line)
157
+ if med_count > 0:
158
+ return ("YELLOW", f"{med_count} unreviewed MED alerts")
159
+
160
+ if unreviewed:
161
+ return ("YELLOW", f"{len(unreviewed)} unreviewed alerts")
162
+
163
+ return None
164
+ except Exception as e:
165
+ return ("YELLOW", f"alert check error: {e}")
166
+
167
+
168
+ def _check_orchestrator_status(status_file):
169
+ """
170
+ Check orchestrator status age (informational; reports stale status).
171
+
172
+ Returns:
173
+ tuple (severity, message) or None if all OK
174
+ """
175
+ if not status_file.exists():
176
+ return None
177
+
178
+ try:
179
+ content = status_file.read_text(encoding="utf-8").strip()
180
+ if not content:
181
+ return None
182
+
183
+ data = json.loads(content)
184
+ if not isinstance(data, dict):
185
+ return None
186
+
187
+ # Check updated_at age
188
+ updated_at_str = data.get("updated_at", "")
189
+ if not updated_at_str:
190
+ return None
191
+
192
+ updated_at_str = updated_at_str.rstrip("Z")
193
+ try:
194
+ updated_at = datetime.fromisoformat(updated_at_str)
195
+ age_seconds = int((datetime.now(timezone.utc).replace(tzinfo=None) - updated_at).total_seconds())
196
+
197
+ # >1800s (30min) is stale for orchestrator
198
+ if age_seconds > 1800:
199
+ return ("YELLOW", f"orchestrator status stale ({age_seconds}s)")
200
+ except Exception:
201
+ pass
202
+
203
+ return None
204
+ except Exception as e:
205
+ return ("YELLOW", f"orchestrator status check error: {e}")
206
+
207
+
208
+ def _check_tracker(tracker_file):
209
+ """
210
+ Aggregate tracker item counts by lane (informational only).
211
+
212
+ Returns:
213
+ dict or None with lane counts
214
+ """
215
+ if not tracker_file.exists():
216
+ return None
217
+
218
+ try:
219
+ content = tracker_file.read_text(encoding="utf-8").strip()
220
+ if not content:
221
+ return None
222
+
223
+ data = json.loads(content)
224
+ if not isinstance(data, dict):
225
+ return None
226
+
227
+ items = data.get("items", [])
228
+ if not items:
229
+ return None
230
+
231
+ # Count by lane
232
+ lanes = {}
233
+ for item in items:
234
+ lane = item.get("lane", "unknown")
235
+ if lane not in lanes:
236
+ lanes[lane] = 0
237
+ lanes[lane] += 1
238
+
239
+ return lanes
240
+ except Exception:
241
+ return None
242
+
243
+
244
+ def _format_human(ball, issues, tracker_info):
245
+ """Format human-readable output."""
246
+ reason = "OK"
247
+ if issues:
248
+ reasons = [msg for severity, msg in issues]
249
+ reason = "; ".join(reasons)
250
+
251
+ lines = [f"HEALTH: {ball} {reason}"]
252
+
253
+ if tracker_info:
254
+ lane_str = ", ".join(f"{lane}: {count}" for lane, count in sorted(tracker_info.items()))
255
+ lines.append(f" Tracker: {lane_str}")
256
+
257
+ return "\n".join(lines)
258
+
259
+
260
+ def _format_json(ball, issues, tracker_info):
261
+ """Format JSON output."""
262
+ result = {
263
+ "ball": ball,
264
+ "health": "OK" if ball == "🟢" else ("DEGRADED" if ball == "🟡" else "CRITICAL"),
265
+ "issues": [{"severity": severity, "message": msg} for severity, msg in issues],
266
+ "tracker": tracker_info or {},
267
+ }
268
+ return json.dumps(result, indent=2)
269
+
270
+
271
+ def main():
272
+ """CLI entry point."""
273
+ import argparse
274
+
275
+ parser = argparse.ArgumentParser(
276
+ description="Aesop healthcheck — aggregate fleet health signals"
277
+ )
278
+ parser.add_argument(
279
+ "--json",
280
+ action="store_true",
281
+ help="Output as JSON"
282
+ )
283
+
284
+ args = parser.parse_args()
285
+
286
+ try:
287
+ output = check_health(json_mode=args.json)
288
+ print(output)
289
+ sys.exit(0)
290
+ except Exception as e:
291
+ print(f"ERROR: {e}", file=sys.stderr)
292
+ sys.exit(1)
293
+
294
+
295
+ if __name__ == "__main__":
296
+ main()