@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.
- package/CHANGELOG.md +46 -5
- package/README.md +57 -1
- package/aesop.config.example.json +8 -1
- package/bin/CLAUDE.md +30 -2
- package/bin/cli.js +286 -73
- package/daemons/CLAUDE.md +4 -1
- package/daemons/run-watchdog.sh +5 -2
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +23 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/collect-signals.mjs +85 -0
- package/package.json +4 -1
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +30 -149
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/ci_merge_wait.py +121 -8
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/secret_scan.py +8 -1
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_submit_encoding.py +12 -4
- package/ui/CLAUDE.md +57 -41
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +9 -5
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/submit.py +44 -5
- package/ui/collectors.py +184 -35
- package/ui/config.py +9 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +7 -3
- package/ui/handler.py +254 -4
- package/ui/render.py +26 -6
- package/ui/sse.py +16 -1
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
package/tools/ci_merge_wait.py
CHANGED
|
@@ -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(
|
|
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,
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
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()
|
package/tools/secret_scan.py
CHANGED
|
@@ -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
|