@matt82198/aesop 0.1.0-beta.5 → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (198) hide show
  1. package/CHANGELOG.md +158 -5
  2. package/LICENSE +66 -21
  3. package/README.md +97 -33
  4. package/aesop.config.example.json +6 -0
  5. package/bin/CLAUDE.md +12 -3
  6. package/bin/cli.js +211 -40
  7. package/daemons/CLAUDE.md +1 -1
  8. package/daemons/backup-fleet.sh +209 -51
  9. package/daemons/run-watchdog.sh +208 -62
  10. package/dash/dash-extra.mjs +73 -4
  11. package/dash/watchdog-gui.sh +41 -35
  12. package/docs/ARCHITECTURE.md +296 -0
  13. package/docs/CONCEPTS.md +254 -0
  14. package/docs/CONFIGURE.md +256 -0
  15. package/docs/FIRST-WAVE.md +276 -0
  16. package/docs/INSTALL.md +224 -0
  17. package/docs/README.md +176 -27
  18. package/docs/autonomous-swe.md +149 -0
  19. package/docs/reproduce.md +121 -0
  20. package/hooks/CLAUDE.md +28 -0
  21. package/hooks/install-waveguard.sh +68 -0
  22. package/hooks/pre-commit-waveguard.sh +26 -0
  23. package/hooks/pre-push-policy.sh +253 -15
  24. package/monitor/CLAUDE.md +3 -3
  25. package/monitor/collect-signals.mjs +133 -20
  26. package/package.json +15 -7
  27. package/scan/CLAUDE.md +30 -0
  28. package/skills/CLAUDE.md +1 -0
  29. package/state_store/ingest.py +18 -1
  30. package/state_store/projections.py +78 -4
  31. package/state_store/store.py +102 -7
  32. package/tools/CLAUDE.md +25 -18
  33. package/tools/alert_bridge.py +14 -10
  34. package/tools/bench_runner.py +438 -0
  35. package/tools/buildlog.py +4 -7
  36. package/tools/ci_merge_wait.py +215 -34
  37. package/tools/common.py +62 -0
  38. package/tools/cost_ceiling.py +191 -0
  39. package/tools/dash.js +102 -0
  40. package/tools/doctor.js +220 -0
  41. package/tools/fleet_ledger.py +189 -17
  42. package/tools/halt.py +172 -0
  43. package/tools/healthcheck.py +24 -21
  44. package/tools/heartbeat.py +4 -7
  45. package/tools/metrics_gate.py +8 -2
  46. package/tools/orchestrator_status.py +4 -9
  47. package/tools/reconcile.py +277 -0
  48. package/tools/rotate_logs.py +152 -62
  49. package/tools/scanner_selftest.py +28 -3
  50. package/tools/secret_scan.py +359 -90
  51. package/tools/self_stats.py +292 -4
  52. package/tools/status.js +187 -0
  53. package/tools/verify_agent_inspector.py +289 -0
  54. package/tools/verify_prboard.py +344 -0
  55. package/tools/watch.js +49 -0
  56. package/ui/CLAUDE.md +2 -0
  57. package/ui/agents.py +332 -33
  58. package/ui/api/tracker.py +15 -7
  59. package/ui/collectors.py +125 -32
  60. package/ui/config.py +22 -2
  61. package/ui/cost.py +98 -3
  62. package/ui/csrf.py +2 -1
  63. package/ui/handler.py +209 -21
  64. package/ui/sse.py +102 -16
  65. package/ui/wave_prs.py +230 -0
  66. package/ui/web/dist/assets/index-0qQYnvMC.js +9 -0
  67. package/ui/web/dist/assets/index-BdIlFieV.css +1 -0
  68. package/ui/web/dist/index.html +2 -2
  69. package/monitor/.signal-state.json +0 -3
  70. package/monitor/ACTIONS.log +0 -1
  71. package/monitor/BRIEF.md +0 -24
  72. package/monitor/SIGNALS.json +0 -54
  73. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  74. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  75. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  76. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  77. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  78. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  96. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  97. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  98. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  99. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  100. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  101. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  102. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  103. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  104. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  105. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  106. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  107. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  108. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  109. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  110. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  111. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  112. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  113. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  114. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  115. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  116. package/ui/web/.gitattributes +0 -13
  117. package/ui/web/dist/assets/index-2LZDQirC.js +0 -9
  118. package/ui/web/dist/assets/index-D4M1qyOv.css +0 -1
  119. package/ui/web/index.html +0 -13
  120. package/ui/web/package-lock.json +0 -2225
  121. package/ui/web/package.json +0 -26
  122. package/ui/web/src/App.test.tsx +0 -74
  123. package/ui/web/src/App.tsx +0 -142
  124. package/ui/web/src/CONTRIBUTING-UI.md +0 -49
  125. package/ui/web/src/components/AgentRow.css +0 -187
  126. package/ui/web/src/components/AgentRow.test.tsx +0 -209
  127. package/ui/web/src/components/AgentRow.tsx +0 -207
  128. package/ui/web/src/components/AgentsPanel.css +0 -108
  129. package/ui/web/src/components/AgentsPanel.test.tsx +0 -41
  130. package/ui/web/src/components/AgentsPanel.tsx +0 -58
  131. package/ui/web/src/components/AlertsPanel.css +0 -88
  132. package/ui/web/src/components/AlertsPanel.test.tsx +0 -51
  133. package/ui/web/src/components/AlertsPanel.tsx +0 -67
  134. package/ui/web/src/components/BacklogPanel.test.tsx +0 -126
  135. package/ui/web/src/components/BacklogPanel.tsx +0 -122
  136. package/ui/web/src/components/CostChart.css +0 -110
  137. package/ui/web/src/components/CostChart.test.tsx +0 -144
  138. package/ui/web/src/components/CostChart.tsx +0 -152
  139. package/ui/web/src/components/CostTable.css +0 -93
  140. package/ui/web/src/components/CostTable.test.tsx +0 -165
  141. package/ui/web/src/components/CostTable.tsx +0 -94
  142. package/ui/web/src/components/EventsFeed.css +0 -68
  143. package/ui/web/src/components/EventsFeed.test.tsx +0 -36
  144. package/ui/web/src/components/EventsFeed.tsx +0 -31
  145. package/ui/web/src/components/HealthHeader.css +0 -137
  146. package/ui/web/src/components/HealthHeader.test.tsx +0 -278
  147. package/ui/web/src/components/HealthHeader.tsx +0 -281
  148. package/ui/web/src/components/InboxForm.css +0 -135
  149. package/ui/web/src/components/InboxForm.test.tsx +0 -208
  150. package/ui/web/src/components/InboxForm.tsx +0 -116
  151. package/ui/web/src/components/MessagesTail.module.css +0 -144
  152. package/ui/web/src/components/MessagesTail.test.tsx +0 -176
  153. package/ui/web/src/components/MessagesTail.tsx +0 -94
  154. package/ui/web/src/components/ReposPanel.css +0 -90
  155. package/ui/web/src/components/ReposPanel.test.tsx +0 -45
  156. package/ui/web/src/components/ReposPanel.tsx +0 -67
  157. package/ui/web/src/components/Scorecard.css +0 -106
  158. package/ui/web/src/components/Scorecard.test.tsx +0 -117
  159. package/ui/web/src/components/Scorecard.tsx +0 -85
  160. package/ui/web/src/components/Timeline.module.css +0 -151
  161. package/ui/web/src/components/Timeline.test.tsx +0 -215
  162. package/ui/web/src/components/Timeline.tsx +0 -99
  163. package/ui/web/src/components/TrackerBoard.test.tsx +0 -121
  164. package/ui/web/src/components/TrackerBoard.tsx +0 -107
  165. package/ui/web/src/components/TrackerCard.test.tsx +0 -180
  166. package/ui/web/src/components/TrackerCard.tsx +0 -160
  167. package/ui/web/src/components/TrackerForm.test.tsx +0 -189
  168. package/ui/web/src/components/TrackerForm.tsx +0 -144
  169. package/ui/web/src/lib/api.ts +0 -218
  170. package/ui/web/src/lib/format.test.ts +0 -89
  171. package/ui/web/src/lib/format.ts +0 -103
  172. package/ui/web/src/lib/sanitizeUrl.test.ts +0 -84
  173. package/ui/web/src/lib/sanitizeUrl.ts +0 -38
  174. package/ui/web/src/lib/types.ts +0 -230
  175. package/ui/web/src/lib/useHashRoute.test.ts +0 -60
  176. package/ui/web/src/lib/useHashRoute.ts +0 -23
  177. package/ui/web/src/lib/useSSE.ts +0 -175
  178. package/ui/web/src/main.tsx +0 -10
  179. package/ui/web/src/styles/global.css +0 -179
  180. package/ui/web/src/styles/theme.css +0 -184
  181. package/ui/web/src/styles/work.css +0 -572
  182. package/ui/web/src/test/fixtures.ts +0 -385
  183. package/ui/web/src/test/setup.ts +0 -49
  184. package/ui/web/src/views/Activity.module.css +0 -43
  185. package/ui/web/src/views/Activity.test.tsx +0 -89
  186. package/ui/web/src/views/Activity.tsx +0 -31
  187. package/ui/web/src/views/Cost.css +0 -87
  188. package/ui/web/src/views/Cost.test.tsx +0 -142
  189. package/ui/web/src/views/Cost.tsx +0 -54
  190. package/ui/web/src/views/Overview.css +0 -51
  191. package/ui/web/src/views/Overview.test.tsx +0 -76
  192. package/ui/web/src/views/Overview.tsx +0 -46
  193. package/ui/web/src/views/Work.test.tsx +0 -82
  194. package/ui/web/src/views/Work.tsx +0 -79
  195. package/ui/web/src/vite-env.d.ts +0 -10
  196. package/ui/web/tsconfig.json +0 -22
  197. package/ui/web/vite.config.ts +0 -25
  198. package/ui/web/vitest.config.ts +0 -12
@@ -0,0 +1,220 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Aesop doctor — preflight checklist for adopter onboarding
5
+ *
6
+ * Runs diagnostic checks and prints a readiness table.
7
+ * Exit code 0 = all checks passed; 1 = at least one failed.
8
+ */
9
+
10
+ const fs = require('fs');
11
+ const path = require('path');
12
+ const { spawnSync } = require('child_process');
13
+ const net = require('net');
14
+
15
+ const CURRENT_DIR = process.cwd();
16
+
17
+ // ANSI color helpers
18
+ const COLORS = {
19
+ GREEN: '\x1b[32m',
20
+ RED: '\x1b[31m',
21
+ RESET: '\x1b[0m',
22
+ BOLD: '\x1b[1m'
23
+ };
24
+
25
+ function colorPass() {
26
+ return `${COLORS.GREEN}✓ PASS${COLORS.RESET}`;
27
+ }
28
+
29
+ function colorFail() {
30
+ return `${COLORS.RED}✗ FAIL${COLORS.RESET}`;
31
+ }
32
+
33
+ // Check Node.js version >= 18
34
+ function checkNodeVersion() {
35
+ const version = parseInt(process.versions.node.split('.')[0], 10);
36
+ const passed = version >= 18;
37
+ const hint = passed ? '' : `Found Node.js v${process.versions.node}, need >=18`;
38
+ return { passed, hint };
39
+ }
40
+
41
+ // Check Python available (python3 or python)
42
+ function checkPython() {
43
+ // Try python3 first
44
+ const result3 = spawnSync('python3', ['--version'], { stdio: 'ignore', timeout: 5000 });
45
+ if (result3.error && result3.error.code === 'ENOENT') {
46
+ // python3 not found, try python fallback
47
+ const result = spawnSync('python', ['--version'], { stdio: 'ignore', timeout: 5000 });
48
+ if (result.error && result.error.code === 'ENOENT') {
49
+ // Neither python3 nor python found
50
+ return { passed: false, hint: 'python3 or python not found on PATH' };
51
+ }
52
+ if (result.status !== 0) {
53
+ // python exists but returned non-zero exit code
54
+ return { passed: false, hint: 'python found but returned non-zero exit code' };
55
+ }
56
+ return { passed: true, hint: '' };
57
+ }
58
+ if (result3.status !== 0) {
59
+ // python3 exists but returned non-zero exit code
60
+ return { passed: false, hint: 'python3 found but returned non-zero exit code' };
61
+ }
62
+ return { passed: true, hint: '' };
63
+ }
64
+
65
+ // Check git repo (.git directory exists)
66
+ function checkGitRepo() {
67
+ const gitDir = path.join(CURRENT_DIR, '.git');
68
+ const passed = fs.existsSync(gitDir);
69
+ const hint = passed ? '' : 'Not inside a git repository';
70
+ return { passed, hint };
71
+ }
72
+
73
+ // Check aesop.config.json exists and is valid JSON
74
+ function checkConfig() {
75
+ const configPath = path.join(CURRENT_DIR, 'aesop.config.json');
76
+ try {
77
+ if (!fs.existsSync(configPath)) {
78
+ return { passed: false, hint: 'aesop.config.json not found' };
79
+ }
80
+ const content = fs.readFileSync(configPath, 'utf8');
81
+ JSON.parse(content);
82
+ return { passed: true, hint: '' };
83
+ } catch (e) {
84
+ return { passed: false, hint: `Config parse error: ${e.message}` };
85
+ }
86
+ }
87
+
88
+ // Check required directories exist
89
+ function checkDirectories() {
90
+ const requiredDirs = ['daemons', 'dash', 'monitor', 'tools', 'ui'];
91
+ const missing = requiredDirs.filter(dir => {
92
+ const dirPath = path.join(CURRENT_DIR, dir);
93
+ return !fs.existsSync(dirPath) || !fs.statSync(dirPath).isDirectory();
94
+ });
95
+
96
+ if (missing.length === 0) {
97
+ return { passed: true, hint: '' };
98
+ } else {
99
+ return { passed: false, hint: `Missing: ${missing.join(', ')}` };
100
+ }
101
+ }
102
+
103
+ // Check git pre-push hook installed
104
+ function checkPrePushHook() {
105
+ const hookPath = path.join(CURRENT_DIR, '.git', 'hooks', 'pre-push');
106
+ const passed = fs.existsSync(hookPath);
107
+ const hint = passed ? '' : 'Pre-push hook not installed at .git/hooks/pre-push';
108
+ return { passed, hint };
109
+ }
110
+
111
+ // Check if port 8770 is free (using socket connection test)
112
+ function checkPort8770() {
113
+ return new Promise((resolve) => {
114
+ let resolved = false;
115
+ const sock = net.createConnection({ port: 8770, host: '127.0.0.1', timeout: 500 });
116
+
117
+ const cleanup = () => {
118
+ try {
119
+ sock.destroy();
120
+ } catch (e) {
121
+ // Ignore cleanup errors
122
+ }
123
+ };
124
+
125
+ sock.on('connect', () => {
126
+ if (!resolved) {
127
+ resolved = true;
128
+ cleanup();
129
+ resolve({ passed: false, hint: 'Port 8770 is in use' });
130
+ }
131
+ });
132
+
133
+ sock.on('error', () => {
134
+ // Connection failed, port is free
135
+ if (!resolved) {
136
+ resolved = true;
137
+ cleanup();
138
+ resolve({ passed: true, hint: '' });
139
+ }
140
+ });
141
+
142
+ sock.on('timeout', () => {
143
+ if (!resolved) {
144
+ resolved = true;
145
+ cleanup();
146
+ resolve({ passed: true, hint: '' });
147
+ }
148
+ });
149
+
150
+ // Fallback timeout to ensure we resolve within 2 seconds
151
+ setTimeout(() => {
152
+ if (!resolved) {
153
+ resolved = true;
154
+ cleanup();
155
+ resolve({ passed: true, hint: '' });
156
+ }
157
+ }, 2000);
158
+ });
159
+ }
160
+
161
+ // Format a row in the readiness table
162
+ function formatRow(label, status, hint) {
163
+ const statusStr = status ? colorPass() : colorFail();
164
+ const hintStr = hint ? ` — ${hint}` : '';
165
+ // Pad label to 35 chars for alignment
166
+ const paddedLabel = label.padEnd(35);
167
+ return ` ${paddedLabel} ${statusStr}${hintStr}`;
168
+ }
169
+
170
+ // Main execution
171
+ (async function main() {
172
+ try {
173
+ console.log(`\n${COLORS.BOLD}Aesop Readiness Check${COLORS.RESET}\n`);
174
+
175
+ const syncChecks = [
176
+ { label: 'Node.js version ≥18', fn: checkNodeVersion },
177
+ { label: 'Python (python3 or python)', fn: checkPython },
178
+ { label: 'Git repository', fn: checkGitRepo },
179
+ { label: 'aesop.config.json (valid JSON)', fn: checkConfig },
180
+ { label: 'Required directories (daemons, dash, monitor, tools, ui)', fn: checkDirectories },
181
+ { label: 'Git pre-push hook installed', fn: checkPrePushHook }
182
+ ];
183
+
184
+ const results = [];
185
+
186
+ // Run sync checks
187
+ for (const check of syncChecks) {
188
+ const result = check.fn();
189
+ results.push({ label: check.label, ...result });
190
+ console.log(formatRow(check.label, result.passed, result.hint));
191
+ }
192
+
193
+ // Run async port check
194
+ try {
195
+ const portResult = await checkPort8770();
196
+ results.push({ label: 'Port 8770 available', ...portResult });
197
+ console.log(formatRow('Port 8770 available', portResult.passed, portResult.hint));
198
+ } catch (e) {
199
+ results.push({ label: 'Port 8770 available', passed: false, hint: 'Port check failed' });
200
+ console.log(formatRow('Port 8770 available', false, 'Port check failed'));
201
+ }
202
+
203
+ const allPassed = results.every(r => r.passed);
204
+ const passCount = results.filter(r => r.passed).length;
205
+ const failCount = results.length - passCount;
206
+
207
+ console.log(`\n${COLORS.BOLD}Summary: ${passCount}/${results.length} checks passed${COLORS.RESET}`);
208
+
209
+ if (allPassed) {
210
+ console.log(`${COLORS.GREEN}✓ You are ready to run: bash daemons/run-watchdog.sh --once${COLORS.RESET}\n`);
211
+ process.exitCode = 0;
212
+ } else {
213
+ console.log(`${COLORS.RED}✗ Fix the ${failCount} failed check(s) above and try again${COLORS.RESET}\n`);
214
+ process.exitCode = 1;
215
+ }
216
+ } catch (err) {
217
+ console.error(`Error running doctor: ${err.message}`);
218
+ process.exit(1);
219
+ }
220
+ })();
@@ -3,16 +3,20 @@ r"""
3
3
  Fleet Outcome Ledger — append-only audit trail for dispatched agents.
4
4
 
5
5
  Subcommands:
6
- append <timestamp> <agent_type> <model> <duration_sec> <tokens_in> <tokens_out> [verdict]
6
+ append <timestamp> <agent_type> <model> <duration_sec> <tokens_in> <tokens_out> [verdict] [phase] [wave]
7
7
  Manually append one ledger line (verdict = OK|FAILED|EMPTY|HUNG, default OK)
8
+ phase = build|verify|repair|other (optional, default null)
9
+ wave = wave number as integer (optional, default null)
8
10
  harvest
9
11
  Scan session tasks directories for agent outcomes and append missing entries.
10
12
  (Tracks state in ledger directory for resume capability)
11
13
  rotate
12
14
  Archive ledger lines exceeding ~200 lines to dated archive, keep recent tail in live ledger
15
+ summary
16
+ Print total cost/tokens grouped by wave number and phase; supports --json for machine reading
13
17
 
14
18
  Ledger format (markdown table):
15
- | ISO ts | agent_type | model | duration_sec | tokens_in | tokens_out | verdict |
19
+ | ISO ts | agent_type | model | duration_sec | tokens_in | tokens_out | verdict | phase | wave |
16
20
 
17
21
  Environment:
18
22
  AESOP_STATE_ROOT: Path to state directory (default: ./state relative to cwd)
@@ -27,12 +31,10 @@ from datetime import datetime, timezone
27
31
  import re
28
32
  from collections import defaultdict
29
33
 
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"
34
+ try:
35
+ from common import get_state_dir
36
+ except ImportError:
37
+ from tools.common import get_state_dir
36
38
 
37
39
 
38
40
  def get_ledger_paths():
@@ -49,13 +51,25 @@ def ensure_ledger_header():
49
51
  ledger_file, _, ledger_dir = get_ledger_paths()
50
52
  if not ledger_file.exists():
51
53
  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
+ header = '| ISO ts | agent_type | model | duration_sec | tokens_in | tokens_out | verdict | phase | wave |\n'
55
+ header += '|--------|------------|-------|--------------|-----------|------------|--------|-------|------|\n'
54
56
  ledger_file.write_text(header, encoding='utf-8')
55
57
 
56
58
 
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
+ def append_ledger_line(iso_ts, agent_type, model, duration_sec, tokens_in, tokens_out, verdict='OK', phase=None, wave=None):
60
+ """Append one line to the ledger.
61
+
62
+ Args:
63
+ iso_ts: ISO 8601 timestamp
64
+ agent_type: agent type string
65
+ model: model identifier
66
+ duration_sec: duration in seconds
67
+ tokens_in: input tokens
68
+ tokens_out: output tokens
69
+ verdict: OK|FAILED|EMPTY|HUNG (default OK)
70
+ phase: build|verify|repair|other (optional, default None)
71
+ wave: wave number as int or None (optional, default None)
72
+ """
59
73
  ensure_ledger_header()
60
74
  ledger_file, _, _ = get_ledger_paths()
61
75
 
@@ -64,6 +78,20 @@ def append_ledger_line(iso_ts, agent_type, model, duration_sec, tokens_in, token
64
78
  model = str(model or '-').replace('|', '').strip()[:30]
65
79
  verdict = str(verdict or 'OK').replace('|', '').strip()[:10]
66
80
 
81
+ # Sanitize optional fields
82
+ if phase is not None:
83
+ phase = str(phase).replace('|', '').strip()[:15]
84
+ else:
85
+ phase = ''
86
+
87
+ if wave is not None:
88
+ try:
89
+ wave = str(int(wave))
90
+ except (ValueError, TypeError):
91
+ wave = ''
92
+ else:
93
+ wave = ''
94
+
67
95
  try:
68
96
  dur = int(duration_sec) if duration_sec else 0
69
97
  except (ValueError, TypeError):
@@ -79,7 +107,7 @@ def append_ledger_line(iso_ts, agent_type, model, duration_sec, tokens_in, token
79
107
  except (ValueError, TypeError):
80
108
  to = 0
81
109
 
82
- line = f'| {iso_ts} | {agent_type} | {model} | {dur} | {ti} | {to} | {verdict} |\n'
110
+ line = f'| {iso_ts} | {agent_type} | {model} | {dur} | {ti} | {to} | {verdict} | {phase} | {wave} |\n'
83
111
  with open(ledger_file, 'a', encoding='utf-8') as f:
84
112
  f.write(line)
85
113
 
@@ -232,6 +260,144 @@ def rotate():
232
260
  print(f'Live ledger now has {len(new_ledger) - len(header_lines)} data lines')
233
261
 
234
262
 
263
+ def parse_ledger_rows():
264
+ """Parse and return all ledger rows as structured data.
265
+
266
+ Returns:
267
+ list of dicts, each with keys: iso_ts, agent_type, model, duration_sec,
268
+ tokens_in, tokens_out, verdict, phase, wave (wave as int or None)
269
+
270
+ Returns empty list if ledger doesn't exist or is unreadable.
271
+ """
272
+ ensure_ledger_header()
273
+ ledger_file, _, _ = get_ledger_paths()
274
+
275
+ try:
276
+ lines = ledger_file.read_text(encoding='utf-8').split('\n')
277
+ except (IOError, OSError):
278
+ return []
279
+
280
+ rows = []
281
+ for line in lines:
282
+ # Skip empty, header, separator lines
283
+ if not line.strip() or '---|' in line or not line.startswith('|'):
284
+ continue
285
+
286
+ # Parse markdown table row
287
+ cells = [c.strip() for c in line.split('|')[1:-1]] # split by |, skip first/last empty
288
+ if len(cells) < 7:
289
+ continue
290
+
291
+ try:
292
+ # Original columns: ISO ts, agent_type, model, duration_sec, tokens_in, tokens_out, verdict
293
+ # New columns: phase, wave
294
+ iso_ts = cells[0]
295
+ agent_type = cells[1]
296
+ model = cells[2]
297
+ duration_sec = int(cells[3]) if cells[3] else 0
298
+ tokens_in = int(cells[4]) if cells[4] else 0
299
+ tokens_out = int(cells[5]) if cells[5] else 0
300
+ verdict = cells[6] if len(cells) > 6 else 'OK'
301
+ phase = cells[7].strip() if len(cells) > 7 and cells[7].strip() else None
302
+ wave = cells[8].strip() if len(cells) > 8 and cells[8].strip() else None
303
+
304
+ # Try to parse wave as int
305
+ wave_num = None
306
+ if wave:
307
+ try:
308
+ wave_num = int(wave)
309
+ except ValueError:
310
+ pass
311
+
312
+ rows.append({
313
+ 'iso_ts': iso_ts,
314
+ 'agent_type': agent_type,
315
+ 'model': model,
316
+ 'duration_sec': duration_sec,
317
+ 'tokens_in': tokens_in,
318
+ 'tokens_out': tokens_out,
319
+ 'verdict': verdict,
320
+ 'phase': phase,
321
+ 'wave': wave_num,
322
+ })
323
+ except (ValueError, IndexError):
324
+ # Skip malformed lines silently
325
+ continue
326
+
327
+ return rows
328
+
329
+
330
+ def summary(output_format='text'):
331
+ """Aggregate ledger entries by wave and phase; report total tokens/cost.
332
+
333
+ Args:
334
+ output_format: 'text' (default) or 'json'
335
+ """
336
+ # Use shared parser to get rows
337
+ rows = parse_ledger_rows()
338
+
339
+ # Parse ledger: skip header and separator lines, parse data lines
340
+ by_wave_phase = defaultdict(lambda: {'tokens_out': 0, 'tokens_in': 0, 'entries': 0, 'duration': 0})
341
+ by_wave = defaultdict(lambda: {'tokens_out': 0, 'tokens_in': 0, 'entries': 0, 'duration': 0})
342
+ totals = {'tokens_out': 0, 'tokens_in': 0, 'entries': 0, 'duration': 0}
343
+
344
+ for row in rows:
345
+ tokens_in = row['tokens_in']
346
+ tokens_out = row['tokens_out']
347
+ duration_sec = row['duration_sec']
348
+ wave_num = row['wave']
349
+ phase = row['phase']
350
+
351
+ # Accumulate by wave+phase
352
+ key = (wave_num, phase)
353
+ by_wave_phase[key]['tokens_out'] += tokens_out
354
+ by_wave_phase[key]['tokens_in'] += tokens_in
355
+ by_wave_phase[key]['entries'] += 1
356
+ by_wave_phase[key]['duration'] += duration_sec
357
+
358
+ # Accumulate by wave
359
+ by_wave[wave_num]['tokens_out'] += tokens_out
360
+ by_wave[wave_num]['tokens_in'] += tokens_in
361
+ by_wave[wave_num]['entries'] += 1
362
+ by_wave[wave_num]['duration'] += duration_sec
363
+
364
+ # Accumulate totals
365
+ totals['tokens_out'] += tokens_out
366
+ totals['tokens_in'] += tokens_in
367
+ totals['entries'] += 1
368
+ totals['duration'] += duration_sec
369
+
370
+ if output_format == 'json':
371
+ import json
372
+ result = {
373
+ 'by_wave_phase': {str(k): v for k, v in sorted(by_wave_phase.items())},
374
+ 'by_wave': {str(k): v for k, v in sorted(by_wave.items())},
375
+ 'totals': totals
376
+ }
377
+ print(json.dumps(result, indent=2))
378
+ else:
379
+ # Text format: human-readable table
380
+ print('\n=== Ledger Summary ===')
381
+ print(f'\nTotal: {totals["entries"]} entries | {totals["tokens_in"]} in | {totals["tokens_out"]} out | {totals["duration"]}s\n')
382
+
383
+ if by_wave_phase:
384
+ print('By Wave + Phase:')
385
+ print(' Wave | Phase | Entries | Tokens In | Tokens Out | Duration')
386
+ print(' -----|----------|---------|-----------|------------|----------')
387
+ for (wave_num, phase), stats in sorted(by_wave_phase.items()):
388
+ w_str = str(wave_num) if wave_num is not None else 'None'
389
+ p_str = phase if phase else '(no phase)'
390
+ print(f' {w_str:4} | {p_str:8} | {stats["entries"]:7} | {stats["tokens_in"]:9} | {stats["tokens_out"]:10} | {stats["duration"]:8}')
391
+
392
+ if by_wave:
393
+ print('\nBy Wave (Total):')
394
+ print(' Wave | Entries | Tokens In | Tokens Out | Duration')
395
+ print(' -----|---------|-----------|------------|----------')
396
+ for wave_num, stats in sorted(by_wave.items()):
397
+ w_str = str(wave_num) if wave_num is not None else 'None'
398
+ print(f' {w_str:4} | {stats["entries"]:7} | {stats["tokens_in"]:9} | {stats["tokens_out"]:10} | {stats["duration"]:8}')
399
+
400
+
235
401
  def main():
236
402
  if len(sys.argv) < 2:
237
403
  print(__doc__)
@@ -240,9 +406,9 @@ def main():
240
406
  cmd = sys.argv[1].lower()
241
407
 
242
408
  if cmd == 'append':
243
- # append <ts> <agent_type> <model> <dur> <tokens_in> <tokens_out> [verdict]
409
+ # append <ts> <agent_type> <model> <dur> <tokens_in> <tokens_out> [verdict] [phase] [wave]
244
410
  if len(sys.argv) < 7:
245
- print('Usage: fleet_ledger.py append <ts> <agent_type> <model> <dur_sec> <tokens_in> <tokens_out> [verdict]')
411
+ print('Usage: fleet_ledger.py append <ts> <agent_type> <model> <dur_sec> <tokens_in> <tokens_out> [verdict] [phase] [wave]')
246
412
  sys.exit(1)
247
413
 
248
414
  ts = sys.argv[2]
@@ -252,9 +418,11 @@ def main():
252
418
  ti = sys.argv[6]
253
419
  to = sys.argv[7] if len(sys.argv) > 7 else '0'
254
420
  verdict = sys.argv[8] if len(sys.argv) > 8 else 'OK'
421
+ phase = sys.argv[9] if len(sys.argv) > 9 else None
422
+ wave = sys.argv[10] if len(sys.argv) > 10 else None
255
423
 
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}]')
424
+ append_ledger_line(ts, agent_type, model, dur, ti, to, verdict, phase, wave)
425
+ print(f'Appended: {ts} {agent_type} {model} {dur}s {ti}->{to} [{verdict}] phase={phase} wave={wave}')
258
426
 
259
427
  elif cmd == 'harvest':
260
428
  harvest()
@@ -262,6 +430,10 @@ def main():
262
430
  elif cmd == 'rotate':
263
431
  rotate()
264
432
 
433
+ elif cmd == 'summary':
434
+ output_fmt = 'json' if '--json' in sys.argv else 'text'
435
+ summary(output_fmt)
436
+
265
437
  else:
266
438
  print(f'Unknown command: {cmd}')
267
439
  print(__doc__)
package/tools/halt.py ADDED
@@ -0,0 +1,172 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Fleet kill switch — a .HALT sentinel file that daemons/run-watchdog.sh (and any
4
+ other cardinal-rule-abiding loop) must check every cycle and refuse to do work
5
+ while it exists.
6
+
7
+ Why: wave-26 critique — autonomy expanded to self-merging portfolio PRs on green
8
+ with no ceiling/cap/limit/abort anywhere in the harness. This is the abort.
9
+
10
+ API:
11
+ halt(reason, state_dir=None) -> Path
12
+ Write the .HALT sentinel (reason + UTC timestamp, JSON) under the resolved
13
+ state dir. Creates the state dir if missing. Overwrites any existing sentinel
14
+ (last halt() call wins — reason is always the most recent one).
15
+
16
+ is_halted(state_dir=None) -> bool
17
+ True iff the sentinel file exists.
18
+
19
+ get_halt_info(state_dir=None) -> dict | None
20
+ Parsed sentinel contents ({"reason": ..., "timestamp": ...}), or None if not halted.
21
+
22
+ clear_halt(state_dir=None) -> bool
23
+ Remove the sentinel if present. Returns True if a sentinel was removed, False
24
+ if there was nothing to clear (idempotent no-op).
25
+
26
+ resolve_state_dir(config=None) -> Path
27
+ Precedence: AESOP_STATE_ROOT env var > aesop.config.json "state_root" > default
28
+ (tools/common.get_state_dir(): ./state relative to cwd). Mirrors the precedence
29
+ documented in aesop.config.example.json's top-level "description" field.
30
+
31
+ CLI:
32
+ python tools/halt.py set "<reason>" -> writes sentinel, exit 0
33
+ python tools/halt.py --status -> prints halted/not-halted; exit 1 if halted, 0 if not
34
+ python tools/halt.py --clear -> removes sentinel if present, exit 0
35
+
36
+ Sentinel location: <state_dir>/.HALT
37
+ """
38
+
39
+ import json
40
+ import os
41
+ import sys
42
+ from datetime import datetime, timezone
43
+ from pathlib import Path
44
+
45
+ try:
46
+ from common import get_state_dir
47
+ except ImportError:
48
+ from tools.common import get_state_dir
49
+
50
+ SENTINEL_NAME = ".HALT"
51
+
52
+
53
+ def load_config():
54
+ """Load aesop.config.json from current directory, return dict (or {} if absent/bad)."""
55
+ config_file = Path("aesop.config.json")
56
+ if not config_file.exists():
57
+ return {}
58
+ try:
59
+ with open(config_file, "r", encoding="utf-8") as f:
60
+ return json.load(f)
61
+ except Exception as e:
62
+ print(f"[halt] Failed to load config: {e}", file=sys.stderr)
63
+ return {}
64
+
65
+
66
+ def resolve_state_dir(config=None):
67
+ """Resolve the state directory: AESOP_STATE_ROOT env > config state_root > default.
68
+
69
+ A relative config state_root is resolved against AESOP_ROOT (if set) else cwd,
70
+ matching how daemons/run-watchdog.sh resolves $AESOP_ROOT/state.
71
+ """
72
+ if os.environ.get("AESOP_STATE_ROOT"):
73
+ return Path(os.environ["AESOP_STATE_ROOT"])
74
+
75
+ if config is None:
76
+ config = load_config()
77
+
78
+ state_root = config.get("state_root") if isinstance(config, dict) else None
79
+ if state_root:
80
+ p = Path(state_root).expanduser()
81
+ if not p.is_absolute():
82
+ root = Path(os.environ.get("AESOP_ROOT", Path.cwd()))
83
+ p = root / p
84
+ return p
85
+
86
+ return get_state_dir()
87
+
88
+
89
+ def _sentinel_path(state_dir=None):
90
+ if state_dir is None:
91
+ state_dir = resolve_state_dir()
92
+ return Path(state_dir) / SENTINEL_NAME
93
+
94
+
95
+ def halt(reason, state_dir=None):
96
+ """Write the .HALT sentinel. Returns the sentinel Path."""
97
+ if state_dir is None:
98
+ state_dir = resolve_state_dir()
99
+ state_dir = Path(state_dir)
100
+ state_dir.mkdir(parents=True, exist_ok=True)
101
+
102
+ sentinel = state_dir / SENTINEL_NAME
103
+ payload = {
104
+ "reason": str(reason),
105
+ "timestamp": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z"),
106
+ }
107
+ sentinel.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8")
108
+ return sentinel
109
+
110
+
111
+ def is_halted(state_dir=None):
112
+ """True iff the .HALT sentinel exists."""
113
+ return _sentinel_path(state_dir).exists()
114
+
115
+
116
+ def get_halt_info(state_dir=None):
117
+ """Return parsed sentinel dict, or None if not halted / unreadable."""
118
+ sentinel = _sentinel_path(state_dir)
119
+ if not sentinel.exists():
120
+ return None
121
+ try:
122
+ return json.loads(sentinel.read_text(encoding="utf-8"))
123
+ except (json.JSONDecodeError, OSError):
124
+ # Sentinel exists but is corrupt — still halted, just no parsed detail.
125
+ return {"reason": "(unreadable sentinel)", "timestamp": None}
126
+
127
+
128
+ def clear_halt(state_dir=None):
129
+ """Remove the sentinel if present. Returns True if removed, False if absent."""
130
+ sentinel = _sentinel_path(state_dir)
131
+ if sentinel.exists():
132
+ sentinel.unlink()
133
+ return True
134
+ return False
135
+
136
+
137
+ def main(argv=None):
138
+ argv = sys.argv[1:] if argv is None else argv
139
+
140
+ if not argv:
141
+ print('Usage: halt.py set "<reason>" | --status | --clear', file=sys.stderr)
142
+ return 2
143
+
144
+ if argv[0] == "set":
145
+ if len(argv) < 2 or not argv[1].strip():
146
+ print('Usage: halt.py set "<reason>"', file=sys.stderr)
147
+ return 2
148
+ reason = " ".join(argv[1:])
149
+ sentinel = halt(reason)
150
+ print(f"HALTED: {reason}")
151
+ print(f"sentinel: {sentinel}")
152
+ return 0
153
+
154
+ if argv[0] == "--status":
155
+ info = get_halt_info()
156
+ if info:
157
+ print(f"HALTED: {info.get('reason')} (since {info.get('timestamp')})")
158
+ return 1
159
+ print("not halted")
160
+ return 0
161
+
162
+ if argv[0] == "--clear":
163
+ cleared = clear_halt()
164
+ print("cleared" if cleared else "not halted (nothing to clear)")
165
+ return 0
166
+
167
+ print(f"Unknown argument: {argv[0]}", file=sys.stderr)
168
+ return 2
169
+
170
+
171
+ if __name__ == "__main__":
172
+ sys.exit(main())