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