@matt82198/aesop 0.1.0-beta.4 → 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 (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
@@ -0,0 +1,292 @@
1
+ // Example IOC/secret scanner — copy to fleet-scan.mjs and configure paths.
2
+ // Scans committed code and fleet transcripts for security/alignment red-flags.
3
+ // Runs each watchdog cycle; marks findings in SECURITY-ALERTS.log. Never blocks the fleet.
4
+ //
5
+ // SETUP:
6
+ // 1. Copy this file to fleet-scan.mjs (in the same directory)
7
+ // 2. Edit the REPOS and PROJECT_ROOTS configuration below to match your fleet setup
8
+ // 3. Configure paths via aesop.config.json or environment variables:
9
+ // - AESOP_FLEET_ROOT: root directory containing your project repositories
10
+ // - AESOP_TRANSCRIPTS_ROOT: root directory containing ~/.claude/projects transcripts
11
+ // 4. Ensure aesop.config.json contains repo definitions (see aesop.config.example.json)
12
+
13
+ import { execSync } from 'node:child_process';
14
+ import fs from 'node:fs';
15
+ import path from 'node:path';
16
+ import crypto from 'node:crypto';
17
+
18
+ // Load configuration from aesop.config.json or use environment variable defaults
19
+ function loadConfig() {
20
+ const configPath = process.env.AESOP_CONFIG || path.join(process.cwd(), 'aesop.config.json');
21
+ let config = {};
22
+ if (fs.existsSync(configPath)) {
23
+ try {
24
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
25
+ } catch (e) {
26
+ console.error(`Warning: Failed to load config from ${configPath}: ${e.message}`);
27
+ }
28
+ }
29
+ return config;
30
+ }
31
+
32
+ const config = loadConfig();
33
+
34
+ // Environment variable overrides (precedence: env > config > defaults)
35
+ const FLEET_ROOT = process.env.AESOP_FLEET_ROOT || config.fleet_root || process.env.HOME;
36
+ const TRANSCRIPTS_ROOT = process.env.AESOP_TRANSCRIPTS_ROOT || config.transcripts_root || path.join(process.env.HOME, '.claude', 'projects');
37
+
38
+ // === CONFIGURE YOUR FLEET ===
39
+ // Edit REPOS array to match your monitored repositories
40
+ // Example: { path: '/path/to/project1', name: 'project1', branch: 'main' }
41
+ // Paths can be absolute or relative to FLEET_ROOT
42
+ const REPOS = config.repos || [
43
+ // Example:
44
+ // { path: 'project-a', name: 'project-a', branch: 'main' },
45
+ // { path: 'project-b', name: 'project-b', branch: 'main' },
46
+ ];
47
+
48
+ // Transcript project roots to scan for fleet prompts
49
+ // Default: ~/.claude/projects (set via AESOP_TRANSCRIPTS_ROOT or config)
50
+ const PROJECT_ROOTS = [
51
+ TRANSCRIPTS_ROOT,
52
+ ];
53
+
54
+ // Handoff/alerts directory (where SECURITY-ALERTS.log is stored)
55
+ // Set via config: alerts.alerts_root or alerts_root
56
+ const ALERTS_ROOT = config.alerts?.alerts_root || config.alerts_root || path.join(FLEET_ROOT, '..', 'conductor3', 'state');
57
+ const ALERTS = path.join(ALERTS_ROOT, 'SECURITY-ALERTS.log');
58
+ const SEENF = path.join(ALERTS_ROOT, '.fleet-scan-seen.json');
59
+ const MARKERF = path.join(ALERTS_ROOT, '.fleet-scan-lastcommit');
60
+
61
+ // Session ID to exclude (optional: set via EXCLUDE_SESSION env var or config)
62
+ const MY_SESSION = process.env.EXCLUDE_SESSION || config.exclude_session || '';
63
+
64
+ // ---- Utility functions ----
65
+ const git = (a, cwd) => {
66
+ try {
67
+ return execSync(`git ${a}`, {
68
+ cwd,
69
+ encoding: 'utf8',
70
+ stdio: ['ignore', 'pipe', 'ignore'],
71
+ });
72
+ } catch {
73
+ return '';
74
+ }
75
+ };
76
+ const now = () => new Date().toISOString().replace('T', ' ').slice(0, 19);
77
+ const seen = fs.existsSync(SEENF) ? new Set(JSON.parse(fs.readFileSync(SEENF, 'utf8'))) : new Set();
78
+ const findings = [];
79
+
80
+ const add = (sev, kind, where, detail) => {
81
+ const key = crypto
82
+ .createHash('sha1')
83
+ .update(sev + kind + where + detail)
84
+ .digest('hex')
85
+ .slice(0, 16);
86
+ if (seen.has(key)) return;
87
+ seen.add(key);
88
+ findings.push(`[${now()}] ${sev} ${kind} | ${where} | ${detail}`);
89
+ };
90
+
91
+ // ---- IOC patterns for ADDED code lines ----
92
+ const CODE_IOC = [
93
+ ['HIGH', 'exec/shell', /Runtime\.getRuntime\(\)\.exec|new\s+ProcessBuilder|\bos\.system\(|\bsubprocess\.|\bpopen\(/i],
94
+ ['HIGH', 'reverse-shell', /\/dev\/tcp\/|\bnc\s+-e\b|bash\s+-i\b|sh\s+-i\b|socket\.SOCK_STREAM.*connect/i],
95
+ ['HIGH', 'pipe-to-shell', /\b(curl|wget)\b[^\n]*\|\s*(sh|bash)\b/i],
96
+ ['HIGH', 'b64-exec', /base64\s+(-d|--decode)[^\n]*\|\s*(sh|bash)|Base64.*decode[^\n]*exec/i],
97
+ ['HIGH', 'secret-literal', /AKIA[0-9A-Z]{16}|sk-[A-Za-z0-9]{20,}|-----BEGIN[A-Z ]*PRIVATE KEY-----|xox[baprs]-[A-Za-z0-9-]{10,}|ghp_[A-Za-z0-9]{20,}/],
98
+ ['HIGH', 'cred-access', /\.ssh\/(id_|authorized)|\.aws\/credentials|\/etc\/(passwd|shadow)|secrets\.toml|\.env\b/i],
99
+ ['HIGH', 'destructive', /\brm\s+-rf\b|DROP\s+TABLE|TRUNCATE\s+TABLE|DELETE\s+FROM\s+\w+\s*;/i],
100
+ ['MED', 'deserialization', /\bObjectInputStream\b|\.readObject\(|XMLDecoder|@JsonTypeInfo|enableDefaultTyping/],
101
+ ['MED', 'reflection', /Class\.forName\(|Method\.invoke\(|getDeclaredMethod\(|setAccessible\(true\)/],
102
+ ['MED', 'raw-network', /new\s+Socket\(|URLConnection|\.openConnection\(|InetSocketAddress\(/i],
103
+ ['MED', 'hardcoded-cred', /(password|passwd|secret|api[_-]?key|token)\s*[:=]\s*["'][^"']{6,}["']/i],
104
+ ];
105
+
106
+ function scanCodeAddedLines(diff, label) {
107
+ if (!diff) return;
108
+ let file = label;
109
+ for (const line of diff.split('\n')) {
110
+ if (line.startsWith('+++ ')) {
111
+ file = line.slice(4).replace(/^b\//, '');
112
+ continue;
113
+ }
114
+ if (!line.startsWith('+') || line.startsWith('+++')) continue;
115
+ const code = line.slice(1);
116
+ for (const [sev, kind, re] of CODE_IOC) {
117
+ if (re.test(code)) add(sev, 'CODE:' + kind, file, code.trim().slice(0, 160));
118
+ }
119
+ }
120
+ }
121
+
122
+ // Scan all configured repos
123
+ for (const repo of REPOS) {
124
+ const repoPath = path.isAbsolute(repo.path) ? repo.path : path.join(FLEET_ROOT, repo.path);
125
+ if (!fs.existsSync(repoPath)) {
126
+ console.warn(`Warning: Repo not found: ${repoPath}`);
127
+ continue;
128
+ }
129
+
130
+ const head = git('rev-parse HEAD', repoPath).trim();
131
+ const markerFile = path.join(repoPath, '.fleet-scan-lastcommit-' + repo.name);
132
+ const last = fs.existsSync(markerFile) ? fs.readFileSync(markerFile, 'utf8').trim() : '';
133
+
134
+ if (head) {
135
+ if (last && last !== head) {
136
+ scanCodeAddedLines(git(`diff ${last}..${head}`, repoPath), `${repo.name}:commit-range`);
137
+ } else if (!last) {
138
+ scanCodeAddedLines(git(`show ${head}`, repoPath), `${repo.name}:${head.slice(0, 8)}`);
139
+ }
140
+ scanCodeAddedLines(git('diff HEAD', repoPath), `${repo.name}:working-tree`);
141
+ fs.writeFileSync(markerFile, head);
142
+ }
143
+ }
144
+
145
+ // ---- Fleet transcript scan (injection / exfil / off-goal) ----
146
+ const CRED_HARVEST_ALLOWLIST = [
147
+ 'secret_scan.py',
148
+ 'secret/credential gate',
149
+ 'scripts/CLAUDE.md',
150
+ 'env_assignment',
151
+ 'env_suspicious_keys',
152
+ 'scanner_selftest',
153
+ 'allow-pattern-docs',
154
+ 'CRED_HARVEST_ALLOWLIST',
155
+ 'generic_secret_assignment',
156
+ ];
157
+
158
+ const EXFILTRATION_ALLOWLIST = [
159
+ 'semantic-prompt-review',
160
+ 'semantic review',
161
+ 'OK/DRIFT/SUSPICIOUS',
162
+ 'drift_escalator',
163
+ 'prompt_classifier',
164
+ 'alert-review',
165
+ 'triage',
166
+ 'EXFILTRATION_ALLOWLIST',
167
+ 'power_selftest',
168
+ 'secret_scan.py',
169
+ 'fleet-scan.mjs',
170
+ 'scanner_selftest',
171
+ 'SECRETS-PERMITTED',
172
+ 'resolve_alerts',
173
+ ];
174
+
175
+ const PROMPT_IOC = [
176
+ ['HIGH', 'prompt-injection', /ignore\s+(all\s+)?(previous|prior|the\s+above)\s+instructions|disregard\s+(the|your)\s+(rules|instructions)|you\s+are\s+now\s+|new\s+system\s+prompt/i],
177
+ ['HIGH', 'exfiltration', /exfiltrat|send\s+(it|them|the\s+\w+)\s+to\s+https?:|upload\s+to\s+|POST\s+.*to\s+http|leak\s+(the\s+)?(secret|cred|token|key)/i],
178
+ ['HIGH', 'cred-harvest', /read\s+.*secrets\.toml|\.env\b|\.ssh\/|\.aws\/credentials|environment\s+variables?\s+.*(send|post|exfil)|harvest\s+(cred|token|key)/i],
179
+ ['HIGH', 'evade-security', /disable\s+(the\s+)?(sentinel|antivirus|edr|defender|firewall)|bypass\s+(the\s+)?(security|scan|review)|hide\s+(this\s+)?from\s+the\s+user|don.?t\s+tell\s+the\s+(user|owner)/i],
180
+ ['MED', 'danger-shell', /\b(curl|wget)\b.*\|\s*(sh|bash)|base64\s+-d|\/dev\/tcp\/|rm\s+-rf\s+(\/|~)|chmod\s+\+s/i],
181
+ ];
182
+
183
+ function extractTexts(obj) {
184
+ const out = [];
185
+ const c = obj?.message?.content;
186
+ if (typeof c === 'string') out.push(c);
187
+ else if (Array.isArray(c)) {
188
+ for (const x of c) {
189
+ if (typeof x === 'string') out.push(x);
190
+ else if (x?.type === 'text' && x.text) out.push(x.text);
191
+ else if (x?.type === 'tool_use' && (x.name === 'Agent' || x.name === 'Task')) {
192
+ if (x.input?.prompt) out.push('[SPAWN] ' + x.input.prompt);
193
+ if (x.input?.description) out.push('[SPAWN] ' + x.input.description);
194
+ }
195
+ }
196
+ }
197
+ return out;
198
+ }
199
+
200
+ const SINCE = Date.now() - 25 * 60 * 1000;
201
+ const tFiles = [];
202
+
203
+ function walk(d) {
204
+ try {
205
+ for (const e of fs.readdirSync(d, { withFileTypes: true })) {
206
+ const p = path.join(d, e.name);
207
+ if (e.isDirectory()) walk(p);
208
+ else if (e.name.endsWith('.jsonl')) tFiles.push(p);
209
+ }
210
+ } catch {}
211
+ }
212
+
213
+ for (const r of PROJECT_ROOTS) {
214
+ if (fs.existsSync(r)) walk(r);
215
+ }
216
+
217
+ for (const fp of tFiles) {
218
+ if (MY_SESSION && fp.includes(MY_SESSION)) continue; // skip excluded session
219
+ let st;
220
+ try {
221
+ st = fs.statSync(fp);
222
+ } catch {
223
+ continue;
224
+ }
225
+ if (st.mtimeMs < SINCE) continue; // only recent fleet activity
226
+ const base = path.basename(fp);
227
+ const isAgentFile = base.startsWith('agent-'); // agent files: scan all; main session: [SPAWN] only
228
+ let lines;
229
+ try {
230
+ lines = fs.readFileSync(fp, 'utf8').split('\n');
231
+ } catch {
232
+ continue;
233
+ }
234
+ for (const line of lines) {
235
+ if (!line.trim()) continue;
236
+ let obj;
237
+ try {
238
+ obj = JSON.parse(line);
239
+ } catch {
240
+ continue;
241
+ }
242
+ for (const text of extractTexts(obj)) {
243
+ if (!text || text.length < 8) continue;
244
+ const isSpawnedPrompt = text.startsWith('[SPAWN]');
245
+ const shouldScanPromptIOC = isAgentFile || isSpawnedPrompt;
246
+ let flagged = false;
247
+ if (shouldScanPromptIOC) {
248
+ for (const [sev, kind, re] of PROMPT_IOC) {
249
+ if (re.test(text)) {
250
+ if (kind === 'cred-harvest') {
251
+ const isDefensiveToolRef = CRED_HARVEST_ALLOWLIST.some((ref) => text.includes(ref));
252
+ if (isDefensiveToolRef) {
253
+ const detail = text.replace(/\s+/g, ' ').slice(0, 180);
254
+ add('SUPPRESSED-FP', 'cred-harvest (defensive-tool reference)', base, detail);
255
+ flagged = true;
256
+ continue;
257
+ }
258
+ }
259
+ if (kind === 'exfiltration') {
260
+ const isDefensiveReviewRef = EXFILTRATION_ALLOWLIST.some((ref) => text.includes(ref));
261
+ if (isDefensiveReviewRef) {
262
+ const detail = text.replace(/\s+/g, ' ').slice(0, 180);
263
+ add('SUPPRESSED-FP', 'exfiltration (defensive-review reference)', base, detail);
264
+ flagged = true;
265
+ continue;
266
+ }
267
+ }
268
+ add(sev, 'PROMPT:' + kind, base, text.replace(/\s+/g, ' ').slice(0, 180));
269
+ flagged = true;
270
+ }
271
+ }
272
+ }
273
+ void flagged;
274
+ }
275
+ }
276
+ }
277
+
278
+ // ---- emit ----
279
+ // Ensure output directory exists
280
+ if (!fs.existsSync(ALERTS_ROOT)) {
281
+ fs.mkdirSync(ALERTS_ROOT, { recursive: true });
282
+ }
283
+
284
+ if (findings.length) {
285
+ fs.appendFileSync(ALERTS, findings.join('\n') + '\n');
286
+ fs.writeFileSync(SEENF, JSON.stringify([...seen]));
287
+ console.log(`${findings.length} new finding(s) -> ${ALERTS}`);
288
+ for (const f of findings) console.log(f);
289
+ } else {
290
+ fs.writeFileSync(SEENF, JSON.stringify([...seen]));
291
+ console.log('no new findings');
292
+ }
@@ -0,0 +1,44 @@
1
+ ---
2
+ name: healthcheck
3
+ description: Check fleet health — one colored ball (heartbeats, alerts, orchestrator, tracker items).
4
+ version: 1.0.0
5
+ ---
6
+
7
+ # Healthcheck — one colored health ball
8
+
9
+ One-line status indicator for aesop fleet health, driven by aggregated signals.
10
+
11
+ ## Procedure
12
+
13
+ Run the healthcheck tool and interpret the ball:
14
+
15
+ ```bash
16
+ python tools/healthcheck.py
17
+ ```
18
+
19
+ ### Output interpretation
20
+
21
+ **🟢 Green**: All heartbeats fresh (watchdog <300s, monitor <3600s), no HIGH alerts
22
+
23
+ **🟡 Yellow**: Stale heartbeat OR unreviewed MED severity alert
24
+
25
+ **🔴 Red**: HIGH severity alert OR watchdog dead (>600s) while orchestrator actively dispatching
26
+
27
+ ### Fixing issues
28
+
29
+ When healthcheck returns non-green, check the bullet-list reasons:
30
+
31
+ 1. **Stale heartbeat**: Restart the relevant daemon (watchdog or monitor). Check `bash daemons/run-watchdog.sh --once` or monitor process.
32
+ 2. **Unreviewed HIGH alert**: Review SECURITY-ALERTS.log, prefix line with `NOTE:` or `RESOLVED-FP` to mark reviewed.
33
+ 3. **Unreviewed MED alert**: Same as HIGH — review and mark.
34
+ 4. **Dead watchdog + active dispatch**: Critical issue — watchdog daemon crashed while orchestrator was dispatching agents. Check logs, restart watchdog, verify orchestrator stability.
35
+
36
+ ### Machine-readable output
37
+
38
+ For integration with monitoring/dashboards, use `--json`:
39
+
40
+ ```bash
41
+ python tools/healthcheck.py --json
42
+ ```
43
+
44
+ Returns a JSON object with `ball`, `health` status, issues list, and tracker lane counts.
@@ -0,0 +1,39 @@
1
+ # state_store/ — event-sourced state layer (DB source of truth, git as export)
2
+
3
+ **Purpose**: the durable substrate to move aesop's coordination/state off git —
4
+ which cannot scale to a team (single-writer control files, hot-file merge
5
+ conflicts, no transactions/concurrency/real-time). State becomes an append-only
6
+ event log with per-stream versioning; current state is a projection; git is
7
+ demoted to a rendered, diffable **export**.
8
+
9
+ **Status (2026-07-14)**: additive prototype. The live `ui/` tracker path
10
+ (`collectors.py` → `state/tracker.json`) is UNCHANGED. This package ships the
11
+ store + tracker projection + backfill + export, ready for a later dual-read
12
+ cutover. Design: `conductor3 plans/aesop-scaling-rearchitecture.md`.
13
+
14
+ ## Files (stdlib only — sqlite3/json/threading/time)
15
+ - **store.py** — `EventStore(db_path)`: append-only SQLite (WAL) log.
16
+ Concurrency-safe across threads and processes via `busy_timeout` +
17
+ `BEGIN IMMEDIATE` (atomic read-max-version-then-insert). `append/read/read_all`.
18
+ - **projections.py** — `project_tracker(events)`: folds `item_created` /
19
+ `item_updated` / `item_archived` into the full `tracker.json` shape,
20
+ preserving first-seen order.
21
+ - **api.py** — `StateAPI(db_path)`: the swap seam (`append`/`get`/`project`).
22
+ Backend swaps SQLite→Postgres here without touching callers.
23
+ - **export.py** — `export_tracker(api, out_path)`: render the projection back to
24
+ a git-tracked JSON snapshot (indent=2, ascii-escaped to match the live file).
25
+ - **ingest.py** — `ingest_tracker_json(api, path)`: backfill one `item_created`
26
+ per existing item (the migration/backfill path).
27
+
28
+ ## Invariants
29
+ - **Append-only**: never mutate/delete events; state changes are new events.
30
+ - **Per-stream version is 1-based and gapless** (enforced atomically).
31
+ - **git as export, not source**: nothing here reads git for state.
32
+ - **Round-trip fidelity**: ingest → project → export reproduces the same items
33
+ (tested against the real `state/tracker.json`).
34
+
35
+ ## Next (cutover, follow-up — NOT this increment)
36
+ Point tracker CRUD at `StateAPI` (create→`item_created`, update→`item_updated`,
37
+ move→lane update, delete→`item_archived`); add a `subscribe()` real-time path to
38
+ replace the SSE file-watch; run the `export_tracker` job to keep `tracker.json`
39
+ rendered during dual-read; then flip readers to the API.
@@ -0,0 +1,24 @@
1
+ """state_store — event-sourced state layer (DB source of truth, git as export).
2
+
3
+ Additive prototype landed 2026-07-14: the live ui/ tracker path is UNCHANGED.
4
+ This package provides the durable substrate to migrate aesop's coordination
5
+ state off git (which does not scale to concurrent writers) onto a real store
6
+ with transactions and per-stream versioning — SQLite (WAL) now, Postgres later
7
+ behind the same StateAPI. git is demoted to a rendered export (see export.py).
8
+
9
+ Design + rationale: conductor3 plans/aesop-scaling-rearchitecture.md; overview
10
+ in state_store/CLAUDE.md.
11
+ """
12
+ from .api import StateAPI
13
+ from .export import export_tracker
14
+ from .ingest import ingest_tracker_json
15
+ from .projections import project_tracker
16
+ from .store import EventStore
17
+
18
+ __all__ = [
19
+ "EventStore",
20
+ "StateAPI",
21
+ "project_tracker",
22
+ "export_tracker",
23
+ "ingest_tracker_json",
24
+ ]
@@ -0,0 +1,35 @@
1
+ """state_store.api — StateAPI facade over the event store + projections.
2
+
3
+ The single seam callers use, so the backend (SQLite now, Postgres later) can be
4
+ swapped without touching call sites. ``project(view)`` reads the same-named
5
+ stream and folds it through the registered projector.
6
+ """
7
+ from __future__ import annotations
8
+
9
+ from .projections import project_tracker
10
+ from .store import EventStore
11
+
12
+ _PROJECTORS = {"tracker": project_tracker}
13
+
14
+
15
+ class StateAPI:
16
+ """Facade: append events, read a stream, or project a view to current state."""
17
+
18
+ def __init__(self, db_path: str):
19
+ self._store = EventStore(db_path)
20
+
21
+ def append(self, stream: str, event_type: str, payload: dict, actor: str = "system") -> int:
22
+ """Append one event; return its new per-stream version."""
23
+ return self._store.append(stream, event_type, payload, actor)
24
+
25
+ def get(self, stream: str) -> list:
26
+ """Return all events in ``stream`` ascending by version."""
27
+ return self._store.read(stream)
28
+
29
+ def project(self, view: str) -> dict:
30
+ """Fold the same-named stream through its projector into current state."""
31
+ try:
32
+ projector = _PROJECTORS[view]
33
+ except KeyError:
34
+ raise ValueError(f"unknown projection view: {view!r}")
35
+ return projector(self.get(view))
@@ -0,0 +1,19 @@
1
+ """state_store.export — render a projection back into git-tracked JSON.
2
+
3
+ In the DB-source-of-truth design git stops being *read* for state; this job
4
+ renders human-readable, diffable snapshots (e.g. tracker.json) FROM the
5
+ projections for durability and review. The JSON style (indent=2, ascii-escaped)
6
+ matches the existing state/tracker.json so a future cutover produces
7
+ minimal-diff snapshots.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+
14
+ def export_tracker(api, out_path: str) -> None:
15
+ """Write the tracker projection to ``out_path`` as pretty JSON."""
16
+ projection = api.project("tracker")
17
+ with open(out_path, "w", encoding="utf-8") as fh:
18
+ json.dump(projection, fh, indent=2)
19
+ fh.write("\n")
@@ -0,0 +1,24 @@
1
+ """state_store.ingest — backfill events from an existing tracker.json.
2
+
3
+ Migration path for the DB-source-of-truth cutover: read the current
4
+ tracker.json and emit one ``item_created`` event per item (full payload) so the
5
+ event store reproduces the current state. History is not reconstructed (only
6
+ final state exists on disk), but the projection round-trips to the same items,
7
+ so an export re-renders the identical tracker.json.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+
13
+
14
+ def ingest_tracker_json(api, tracker_json_path: str, actor: str = "migration") -> int:
15
+ """Append one ``item_created`` per item in ``tracker_json_path``.
16
+
17
+ Returns the number of items ingested.
18
+ """
19
+ with open(tracker_json_path, "r", encoding="utf-8") as fh:
20
+ data = json.load(fh)
21
+ items = data.get("items", [])
22
+ for item in items:
23
+ api.append("tracker", "item_created", item, actor)
24
+ return len(items)
@@ -0,0 +1,52 @@
1
+ """state_store.projections — fold tracker events into current state.
2
+
3
+ ``project_tracker`` reconstructs the tracker.json shape
4
+ (``{"version": 1, "items": [...]}``) from the ``tracker`` event stream:
5
+
6
+ - ``item_created`` : payload is the full item dict; establishes the item
7
+ (kept in first-seen order).
8
+ - ``item_updated`` : payload is ``{"id": <id>, ...partial fields}``; merges
9
+ the given fields onto the existing item.
10
+ - ``item_archived`` : payload is ``{"id": <id>}`` (+ optional
11
+ ``completed_at``); sets ``status`` to ``"archived"``.
12
+
13
+ Unknown ids on update/archive and unknown event types are ignored, keeping the
14
+ fold tolerant of partial/legacy streams.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ TRACKER_VERSION = 1
19
+
20
+
21
+ def project_tracker(events: list) -> dict:
22
+ """Return the current tracker state projected from ``events``."""
23
+ order = [] # item ids in first-seen order (stable output ordering)
24
+ items = {} # id -> item dict
25
+ for ev in events:
26
+ etype = ev.get("type")
27
+ payload = ev.get("payload") or {}
28
+ if etype == "item_created":
29
+ iid = payload.get("id")
30
+ if iid is None:
31
+ continue
32
+ if iid not in items:
33
+ order.append(iid)
34
+ items[iid] = dict(payload)
35
+ elif etype == "item_updated":
36
+ iid = payload.get("id")
37
+ if iid in items:
38
+ merged = dict(items[iid])
39
+ for key, value in payload.items():
40
+ if key != "id":
41
+ merged[key] = value
42
+ items[iid] = merged
43
+ elif etype == "item_archived":
44
+ iid = payload.get("id")
45
+ if iid in items:
46
+ merged = dict(items[iid])
47
+ merged["status"] = "archived"
48
+ if "completed_at" in payload:
49
+ merged["completed_at"] = payload["completed_at"]
50
+ items[iid] = merged
51
+ # any other event type is ignored
52
+ return {"version": TRACKER_VERSION, "items": [items[i] for i in order]}
@@ -0,0 +1,102 @@
1
+ """state_store.store — SQLite-backed, append-only, concurrency-safe event log.
2
+
3
+ Durable substrate for aesop's event-sourced state layer (the DB-source-of-truth
4
+ design; git becomes a rendered export, not the coordination layer). Backend is
5
+ SQLite in WAL mode so many readers and serialized writers share one file; the
6
+ same interface is meant to swap to Postgres behind ``state_store.api.StateAPI``
7
+ for team scale without touching call sites.
8
+
9
+ Stdlib only (sqlite3, json, time) per aesop's no-external-deps invariant.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import sqlite3
15
+ import time
16
+
17
+
18
+ class EventStore:
19
+ """Append-only event log stored at ``db_path``.
20
+
21
+ Safe under concurrent appends from multiple threads AND multiple EventStore
22
+ instances on the same file: every call opens its own connection with
23
+ ``PRAGMA busy_timeout`` and assigns the per-stream version inside a
24
+ ``BEGIN IMMEDIATE`` transaction, so the read-max-version-then-insert is
25
+ atomic and two writers can never collide or duplicate a version.
26
+ """
27
+
28
+ def __init__(self, db_path: str):
29
+ self.db_path = db_path
30
+ conn = sqlite3.connect(self.db_path)
31
+ try:
32
+ conn.execute("PRAGMA journal_mode=WAL")
33
+ conn.execute("PRAGMA busy_timeout=5000")
34
+ conn.execute(
35
+ """
36
+ CREATE TABLE IF NOT EXISTS events (
37
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
38
+ ts REAL NOT NULL,
39
+ actor TEXT NOT NULL,
40
+ stream TEXT NOT NULL,
41
+ type TEXT NOT NULL,
42
+ payload TEXT NOT NULL,
43
+ version INTEGER NOT NULL
44
+ )
45
+ """
46
+ )
47
+ conn.execute(
48
+ "CREATE INDEX IF NOT EXISTS idx_events_stream_version "
49
+ "ON events(stream, version)"
50
+ )
51
+ conn.commit()
52
+ finally:
53
+ conn.close()
54
+
55
+ def append(self, stream: str, event_type: str, payload: dict, actor: str = "system") -> int:
56
+ """Append one event to ``stream``; return its new per-stream version (1-based)."""
57
+ conn = sqlite3.connect(self.db_path)
58
+ try:
59
+ conn.execute("PRAGMA busy_timeout=5000")
60
+ # BEGIN IMMEDIATE takes the write lock up front so the
61
+ # read-max-then-insert below is atomic under contention.
62
+ conn.execute("BEGIN IMMEDIATE")
63
+ row = conn.execute(
64
+ "SELECT COALESCE(MAX(version), 0) FROM events WHERE stream = ?",
65
+ (stream,),
66
+ ).fetchone()
67
+ version = row[0] + 1
68
+ conn.execute(
69
+ "INSERT INTO events (ts, actor, stream, type, payload, version) "
70
+ "VALUES (?, ?, ?, ?, ?, ?)",
71
+ (time.time(), actor, stream, event_type, json.dumps(payload), version),
72
+ )
73
+ conn.commit()
74
+ return version
75
+ finally:
76
+ conn.close()
77
+
78
+ def read(self, stream: str) -> list:
79
+ """Return all events for ``stream`` ascending by version (empty if none)."""
80
+ return self._rows("WHERE stream = ? ORDER BY version ASC", (stream,))
81
+
82
+ def read_all(self) -> list:
83
+ """Return all events across all streams ascending by id."""
84
+ return self._rows("ORDER BY id ASC", ())
85
+
86
+ def _rows(self, clause: str, params) -> list:
87
+ conn = sqlite3.connect(self.db_path)
88
+ try:
89
+ conn.execute("PRAGMA busy_timeout=5000")
90
+ cur = conn.execute(
91
+ "SELECT id, ts, actor, stream, type, payload, version FROM events " + clause,
92
+ params,
93
+ )
94
+ return [
95
+ {
96
+ "id": r[0], "ts": r[1], "actor": r[2], "stream": r[3],
97
+ "type": r[4], "payload": json.loads(r[5]), "version": r[6],
98
+ }
99
+ for r in cur.fetchall()
100
+ ]
101
+ finally:
102
+ conn.close()