@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
@@ -8,19 +8,23 @@ status is SUCCESS — this is the whole point (prevents merge-on-CI-failure edge
8
8
 
9
9
  Usage:
10
10
  python ci_merge_wait.py <PR-number> [--timeout SECONDS] [--poll SECONDS] [--merge-method merge|squash|rebase]
11
+ [--dry-run] [--self-test]
11
12
 
12
13
  Options:
13
14
  --timeout SECONDS Max seconds to wait for CI to conclude (default: 3600)
14
15
  --poll SECONDS Poll interval in seconds (default: 10)
15
16
  --merge-method METHOD Merge strategy: merge, squash, rebase (default: merge)
17
+ --dry-run Skip actual merge, just verify CI status and report what would happen
18
+ --self-test Run offline self-test of polling/decision logic (no network, no PR required)
16
19
 
17
20
  Exit codes:
18
- 0 = PR merged successfully
21
+ 0 = PR merged successfully, dry-run verified, or self-test passed
22
+ 1 = General error or self-test failed
19
23
  2 = CI checks failed (do NOT merge, prints which check failed)
20
24
  3 = Timeout waiting for CI to conclude
21
25
  4 = PR not mergeable or has merge conflicts
22
26
 
23
- Requires: gh CLI available on PATH. Gracefully exits with error if gh is missing.
27
+ Requires: gh CLI available on PATH (unless --self-test). Gracefully exits with error if gh is missing.
24
28
  """
25
29
 
26
30
  import argparse
@@ -98,12 +102,17 @@ def check_ci_status(status_rollup):
98
102
  return ("success", None)
99
103
 
100
104
 
101
- def merge_pr(pr_number, merge_method):
105
+ def merge_pr(pr_number, merge_method, dry_run=False):
102
106
  """
103
107
  Merge the PR using gh pr merge.
104
108
  This call is STRUCTURALLY UNREACHABLE unless CI is SUCCESS.
109
+ If dry_run is True, report what would be done without actually merging.
105
110
  Returns True on success, False on error.
106
111
  """
112
+ if dry_run:
113
+ print(f"[DRY-RUN] Would merge PR #{pr_number} with --{merge_method}")
114
+ return True
115
+
107
116
  result = subprocess.run(
108
117
  ["gh", "pr", "merge", str(pr_number), f"--{merge_method}"],
109
118
  capture_output=True,
@@ -113,12 +122,73 @@ def merge_pr(pr_number, merge_method):
113
122
  return result.returncode == 0
114
123
 
115
124
 
125
+ def run_self_test():
126
+ """
127
+ Run self-test with mocked CI status checks.
128
+ No network calls; verifies the merge guard logic.
129
+ Returns True if all tests pass, False otherwise.
130
+ """
131
+ print("Running self-test with mocked CI statuses...")
132
+
133
+ # Mock status rollup: all checks SUCCESS
134
+ success_rollup = [
135
+ {"name": "test-unit", "status": "SUCCESS"},
136
+ {"name": "test-integration", "status": "SUCCESS"},
137
+ {"name": "lint", "status": "SUCCESS"},
138
+ ]
139
+
140
+ # Test 1: success case
141
+ ci_status, failed_check = check_ci_status(success_rollup)
142
+ if ci_status != "success":
143
+ print(f"FAIL: Expected 'success', got '{ci_status}'")
144
+ return False
145
+ print("[OK] success case: merge guard permits merge")
146
+
147
+ # Test 2: pending case
148
+ pending_rollup = [
149
+ {"name": "test-unit", "status": "PENDING"},
150
+ {"name": "test-integration", "status": "SUCCESS"},
151
+ ]
152
+ ci_status, failed_check = check_ci_status(pending_rollup)
153
+ if ci_status != "pending":
154
+ print(f"FAIL: Expected 'pending', got '{ci_status}'")
155
+ return False
156
+ print("[OK] pending case: merge guard blocks merge (structurally unreachable)")
157
+
158
+ # Test 3: failure case
159
+ failure_rollup = [
160
+ {"name": "test-unit", "status": "FAILURE"},
161
+ {"name": "test-integration", "status": "SUCCESS"},
162
+ ]
163
+ ci_status, failed_check = check_ci_status(failure_rollup)
164
+ if ci_status != "failure" or failed_check != "test-unit":
165
+ print(f"FAIL: Expected 'failure' with 'test-unit', got '{ci_status}' / '{failed_check}'")
166
+ return False
167
+ print("[OK] failure case: merge guard blocks merge (structurally unreachable)")
168
+
169
+ # Test 4: no checks (treat as success)
170
+ ci_status, _ = check_ci_status([])
171
+ if ci_status != "success":
172
+ print(f"FAIL: Expected 'success' for no checks, got '{ci_status}'")
173
+ return False
174
+ print("[OK] no-checks case: treated as success")
175
+
176
+ print("\nAll self-tests passed!")
177
+ return True
178
+
179
+
116
180
  def main():
117
181
  parser = argparse.ArgumentParser(
118
182
  description=__doc__,
119
183
  formatter_class=argparse.RawDescriptionHelpFormatter
120
184
  )
121
- parser.add_argument("pr_number", type=int, help="GitHub PR number")
185
+ parser.add_argument(
186
+ "pr_number",
187
+ type=int,
188
+ nargs="?",
189
+ default=None,
190
+ help="GitHub PR number (required unless using --self-test)"
191
+ )
122
192
  parser.add_argument(
123
193
  "--timeout",
124
194
  type=int,
@@ -137,9 +207,31 @@ def main():
137
207
  default="merge",
138
208
  help="Merge strategy (default: merge)"
139
209
  )
210
+ parser.add_argument(
211
+ "--dry-run",
212
+ action="store_true",
213
+ help="Skip actual merge, just verify CI status and report what would happen"
214
+ )
215
+ parser.add_argument(
216
+ "--self-test",
217
+ action="store_true",
218
+ help="Run offline self-test of polling/decision logic (no network)"
219
+ )
140
220
 
141
221
  args = parser.parse_args()
142
222
 
223
+ # Handle self-test mode
224
+ if args.self_test:
225
+ if run_self_test():
226
+ sys.exit(0)
227
+ else:
228
+ sys.exit(1)
229
+
230
+ # Validate PR is provided for non-self-test mode
231
+ if args.pr_number is None:
232
+ print("ERROR: PR number is required (unless using --self-test)")
233
+ sys.exit(1)
234
+
143
235
  # Validate inputs
144
236
  if args.pr_number <= 0:
145
237
  print("ERROR: PR number must be positive")
@@ -185,11 +277,32 @@ def main():
185
277
  print(f"CI FAILED: {failed_check}")
186
278
  sys.exit(2)
187
279
  elif ci_status == "success":
188
- # SUCCESS: CI is green, proceed to merge
280
+ # SUCCESS: CI is green, re-check immediately before proceeding
281
+ print(f"CI GREEN: All checks passed. Re-checking status before merge...")
282
+ final_check = get_pr_status(args.pr_number)
283
+ if final_check is None:
284
+ print("ERROR: final status check failed")
285
+ sys.exit(1)
286
+
287
+ final_rollup = final_check.get("statusCheckRollup", [])
288
+ final_ci_status, final_failed = check_ci_status(final_rollup)
289
+
290
+ if final_ci_status != "success":
291
+ print(f"CI STATUS CHANGED: {final_failed}")
292
+ sys.exit(2)
293
+
294
+ # SUCCESS: CI is still green, proceed to merge
189
295
  # This merge call is STRUCTURALLY UNREACHABLE unless ci_status == "success"
190
- print(f"CI GREEN: All checks passed. Merging PR #{args.pr_number}...")
191
- if merge_pr(args.pr_number, args.merge_method):
192
- print(f"MERGED: PR #{args.pr_number} merged successfully")
296
+ if args.dry_run:
297
+ print(f"[DRY-RUN] PR #{args.pr_number} CI is green, would merge...")
298
+ else:
299
+ print(f"CI CONFIRMED GREEN. Merging PR #{args.pr_number}...")
300
+
301
+ if merge_pr(args.pr_number, args.merge_method, dry_run=args.dry_run):
302
+ if args.dry_run:
303
+ print(f"[DRY-RUN] PR #{args.pr_number} merge command would succeed")
304
+ else:
305
+ print(f"MERGED: PR #{args.pr_number} merged successfully")
193
306
  sys.exit(0)
194
307
  else:
195
308
  print("ERROR: merge failed (PR state changed?)")
@@ -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()
@@ -101,12 +101,19 @@ def is_binary_file(filepath):
101
101
 
102
102
 
103
103
  def should_skip_file(filepath):
104
- """Check if file should be skipped entirely (.git/, __pycache__, .pyc, .pyo)."""
104
+ """Check if file should be skipped entirely (.git/, node_modules/, __pycache__, .pyc, .pyo)."""
105
105
  # Skip .git directories — match a path COMPONENT, not a substring
106
106
  # (".git" in str(path) also matched ".gitignore" and skipped scanning it).
107
107
  if ".git" in filepath.parts:
108
108
  return True
109
109
 
110
+ # Skip node_modules — third-party deps, always git-ignored, never our code.
111
+ # CI installs them via `npm ci` for the dashboard build, so a whole-tree
112
+ # scan would otherwise walk thousands of package files (README example
113
+ # connection strings, files literally named token.js → false positives).
114
+ if "node_modules" in filepath.parts:
115
+ return True
116
+
110
117
  # Skip __pycache__ directories
111
118
  if "__pycache__" in filepath.parts:
112
119
  return True