@matt82198/aesop 0.1.0 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +89 -2
- package/README.md +63 -12
- package/bin/cli.js +164 -41
- package/daemons/run-watchdog.sh +16 -4
- package/daemons/selfheal.sh +231 -0
- package/docs/ANY-REPO.md +427 -0
- package/docs/CONTRIBUTING.md +72 -0
- package/docs/DEMO.md +334 -0
- package/docs/INSTALL.md +70 -1
- package/docs/QUICKSTART.md +80 -0
- package/docs/README.md +33 -3
- package/docs/TEAM-STATE.md +540 -0
- package/driver/CLAUDE.md +148 -0
- package/driver/README.md +383 -0
- package/driver/aesop.config.example.json +80 -0
- package/driver/agent_driver.py +355 -0
- package/driver/backend_config.py +253 -0
- package/driver/claude_code_driver.py +198 -0
- package/driver/codex_driver.py +626 -0
- package/driver/openai_compatible_driver.py +249 -0
- package/driver/openai_transport.py +146 -0
- package/driver/verification_policy.py +75 -0
- package/driver/wave_bridge.py +246 -0
- package/driver/wave_loop.py +1041 -0
- package/hooks/pre-push-policy.sh +109 -28
- package/mcp/server.mjs +320 -4
- package/package.json +23 -15
- package/skills/CLAUDE.md +132 -2
- package/skills/buildsystem/SKILL.md +330 -0
- package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
- package/skills/fleet/SKILL.md +113 -0
- package/skills/power/SKILL.md +246 -131
- package/state_store/__init__.py +2 -1
- package/state_store/api.py +19 -4
- package/state_store/coordination.py +209 -0
- package/state_store/identity.py +51 -0
- package/state_store/store.py +185 -73
- package/templates/wave-presets/data.json +65 -0
- package/templates/wave-presets/library.json +65 -0
- package/templates/wave-presets/saas.json +64 -0
- package/tools/audit_report.py +388 -0
- package/tools/bench_runner.py +100 -3
- package/tools/ci_merge_wait.py +256 -35
- package/tools/ci_workflow_lint.py +430 -0
- package/tools/claudemd_drift.py +394 -0
- package/tools/claudemd_lint.py +359 -0
- package/tools/common.py +39 -3
- package/tools/cost_ceiling.py +63 -31
- package/tools/cost_econ.py +480 -0
- package/tools/defect_escape.py +252 -0
- package/tools/doctor.js +1 -1
- package/tools/eod_sweep.py +58 -0
- package/tools/fleet.js +260 -0
- package/tools/fleet_ledger.py +209 -7
- package/tools/git_identity_check.py +315 -0
- package/tools/health-score.js +40 -0
- package/tools/health_score.py +361 -0
- package/tools/metrics_gate.py +13 -4
- package/tools/mutation_test.py +401 -0
- package/tools/portability_check.py +206 -0
- package/tools/reconcile.py +7 -4
- package/tools/secret_scan.py +137 -50
- package/tools/self_stats.py +20 -0
- package/tools/transcript_digest.py +380 -0
- package/tools/verify_activity_filter.py +437 -0
- package/tools/verify_agent_inspector.py +2 -0
- package/tools/verify_dash.py +2 -0
- package/tools/verify_dispatch_panel.py +301 -0
- package/tools/verify_failure_drilldown.py +188 -0
- package/tools/verify_prboard.py +2 -0
- package/tools/verify_scorecards.py +281 -0
- package/tools/verify_submit_encoding.py +2 -0
- package/tools/verify_ui_trio.py +409 -0
- package/tools/verify_wave_telemetry.py +268 -0
- package/tools/wave_backlog_analyzer.py +490 -0
- package/tools/wave_ledger_hook.py +150 -0
- package/tools/wave_preflight.py +512 -0
- package/tools/wave_resume.py +215 -0
- package/tools/wave_templates.py +215 -0
- package/ui/agents.py +68 -14
- package/ui/collectors.py +0 -55
- package/ui/config.py +7 -2
- package/ui/cost.py +231 -12
- package/ui/handler.py +183 -0
- package/ui/quality_scorecard.py +232 -0
- package/ui/wave_audit_tail.py +213 -0
- package/ui/wave_dispatch.py +280 -0
- package/ui/wave_failure.py +288 -0
- package/ui/wave_gantt.py +152 -0
- package/ui/wave_reasoning_tail.py +176 -0
- package/ui/wave_telemetry.py +377 -0
- package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
- package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
- package/ui/web/dist/index.html +2 -2
- package/bin/CLAUDE.md +0 -76
- package/daemons/CLAUDE.md +0 -36
- package/dash/CLAUDE.md +0 -32
- package/docs/archive/README.md +0 -3
- package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
- package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
- package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
- package/docs/archive/spikes/tiered-cognition/README.md +0 -27
- package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
- package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
- package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
- package/hooks/CLAUDE.md +0 -89
- package/mcp/CLAUDE.md +0 -213
- package/monitor/CLAUDE.md +0 -40
- package/scan/CLAUDE.md +0 -30
- package/state_store/CLAUDE.md +0 -39
- package/tools/CLAUDE.md +0 -79
- package/ui/CLAUDE.md +0 -127
- package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
- package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Defect escape telemetry — Haiku code quality measurement.
|
|
4
|
+
|
|
5
|
+
Turns 'is Haiku producing good code' from assertion into a measured number.
|
|
6
|
+
|
|
7
|
+
Given --repo <path> --since <ISO date>, computes:
|
|
8
|
+
(a) fix-forward rate: count commits matching /fix-forward|hotfix|fix(ci)|repair/i
|
|
9
|
+
vs total feature commits in the window
|
|
10
|
+
(b) first-try-estimate: per-merge, whether a 'fix-forward' commit followed
|
|
11
|
+
the feature commit before the merge (proxy for not-first-try-green)
|
|
12
|
+
|
|
13
|
+
Output: JSON with {window, feature_commits, fixforward_commits, fixforward_rate, first_try_estimate}
|
|
14
|
+
|
|
15
|
+
Deterministic, stdlib+subprocess(git) only, no network, Windows-safe.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
import argparse
|
|
19
|
+
import json
|
|
20
|
+
import subprocess
|
|
21
|
+
import sys
|
|
22
|
+
from datetime import datetime
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def run_git(args, repo_path, check=True):
|
|
27
|
+
"""
|
|
28
|
+
Run git command in repo, return stdout.
|
|
29
|
+
|
|
30
|
+
Args:
|
|
31
|
+
args: list of git command args (e.g., ["log", "--oneline"])
|
|
32
|
+
repo_path: path to git repo
|
|
33
|
+
check: raise on non-zero exit if True
|
|
34
|
+
|
|
35
|
+
Returns:
|
|
36
|
+
stdout as string
|
|
37
|
+
"""
|
|
38
|
+
try:
|
|
39
|
+
result = subprocess.run(
|
|
40
|
+
["git", "-C", str(repo_path)] + args,
|
|
41
|
+
capture_output=True,
|
|
42
|
+
text=True, encoding="utf-8", errors="replace",
|
|
43
|
+
check=check,
|
|
44
|
+
)
|
|
45
|
+
return result.stdout
|
|
46
|
+
except subprocess.CalledProcessError as e:
|
|
47
|
+
if check:
|
|
48
|
+
raise RuntimeError(
|
|
49
|
+
f"git command failed: {' '.join(args)}\n"
|
|
50
|
+
f"stderr: {e.stderr}"
|
|
51
|
+
)
|
|
52
|
+
return ""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def get_commits_since(repo_path, since_date):
|
|
56
|
+
"""
|
|
57
|
+
Get all commits since a date using git log.
|
|
58
|
+
|
|
59
|
+
Args:
|
|
60
|
+
repo_path: path to git repo
|
|
61
|
+
since_date: ISO format date string (e.g., "2026-07-01")
|
|
62
|
+
|
|
63
|
+
Returns:
|
|
64
|
+
list of commit hashes
|
|
65
|
+
"""
|
|
66
|
+
output = run_git(
|
|
67
|
+
[
|
|
68
|
+
"log",
|
|
69
|
+
"--all",
|
|
70
|
+
f"--since={since_date}",
|
|
71
|
+
"--format=%H",
|
|
72
|
+
],
|
|
73
|
+
repo_path,
|
|
74
|
+
)
|
|
75
|
+
commits = [c.strip() for c in output.strip().split("\n") if c.strip()]
|
|
76
|
+
return commits
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def get_commit_subject(repo_path, commit_hash):
|
|
80
|
+
"""Get commit subject line."""
|
|
81
|
+
output = run_git(
|
|
82
|
+
["log", "-1", "--format=%s", commit_hash],
|
|
83
|
+
repo_path,
|
|
84
|
+
)
|
|
85
|
+
return output.strip()
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def is_fixforward_commit(subject):
|
|
89
|
+
"""
|
|
90
|
+
Check if commit subject matches fix-forward pattern.
|
|
91
|
+
|
|
92
|
+
Matches: fix-forward, hotfix, fix(ci), repair (case-insensitive)
|
|
93
|
+
"""
|
|
94
|
+
import re
|
|
95
|
+
|
|
96
|
+
patterns = [
|
|
97
|
+
r"fix-forward",
|
|
98
|
+
r"hotfix",
|
|
99
|
+
r"fix\s*\(\s*ci\s*\)",
|
|
100
|
+
r"repair",
|
|
101
|
+
]
|
|
102
|
+
combined = "|".join(f"({p})" for p in patterns)
|
|
103
|
+
return bool(re.search(combined, subject, re.IGNORECASE))
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def get_commit_parents(repo_path, commit_hash):
|
|
107
|
+
"""Get parent commit hashes for a commit."""
|
|
108
|
+
output = run_git(
|
|
109
|
+
["log", "-1", "--format=%P", commit_hash],
|
|
110
|
+
repo_path,
|
|
111
|
+
)
|
|
112
|
+
parents = output.strip().split() if output.strip() else []
|
|
113
|
+
return parents
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def compute_first_try_estimate(repo_path, commits_window):
|
|
117
|
+
"""
|
|
118
|
+
Compute first-try-estimate: fraction of fix-forward commits that
|
|
119
|
+
followed a feature commit before the merge (proxy for not-first-try-green).
|
|
120
|
+
|
|
121
|
+
Simplified approach: for commits in the window, check if a fix-forward
|
|
122
|
+
commit appears after its preceding feature commit (naive linear scan).
|
|
123
|
+
|
|
124
|
+
Args:
|
|
125
|
+
repo_path: path to git repo
|
|
126
|
+
commits_window: list of commit hashes in window (reverse chronological)
|
|
127
|
+
|
|
128
|
+
Returns:
|
|
129
|
+
float 0.0-1.0 or None if no opportunities in window
|
|
130
|
+
"""
|
|
131
|
+
if not commits_window or len(commits_window) < 2:
|
|
132
|
+
return None
|
|
133
|
+
|
|
134
|
+
# Build subject map for all commits in window
|
|
135
|
+
commit_subjects = {}
|
|
136
|
+
for commit in commits_window:
|
|
137
|
+
try:
|
|
138
|
+
subject = get_commit_subject(repo_path, commit)
|
|
139
|
+
commit_subjects[commit] = subject
|
|
140
|
+
except Exception:
|
|
141
|
+
pass
|
|
142
|
+
|
|
143
|
+
# Linear scan: count instances where a fix-forward follows a feature
|
|
144
|
+
# (commits come in reverse chronological, so "follows" means appears later in list)
|
|
145
|
+
feature_commits = [c for c in commits_window if c in commit_subjects
|
|
146
|
+
and not is_fixforward_commit(commit_subjects[c])]
|
|
147
|
+
|
|
148
|
+
if not feature_commits:
|
|
149
|
+
return None
|
|
150
|
+
|
|
151
|
+
followed_count = 0
|
|
152
|
+
for i, feature_commit in enumerate(feature_commits):
|
|
153
|
+
# Check if any fix-forward appears in next few commits
|
|
154
|
+
# (simple heuristic: next 3 commits after this feature)
|
|
155
|
+
feature_idx = commits_window.index(feature_commit)
|
|
156
|
+
for j in range(feature_idx + 1, min(feature_idx + 4, len(commits_window))):
|
|
157
|
+
next_commit = commits_window[j]
|
|
158
|
+
if next_commit in commit_subjects:
|
|
159
|
+
if is_fixforward_commit(commit_subjects[next_commit]):
|
|
160
|
+
followed_count += 1
|
|
161
|
+
break
|
|
162
|
+
|
|
163
|
+
return followed_count / len(feature_commits) if feature_commits else None
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def main():
|
|
167
|
+
"""Main entry point."""
|
|
168
|
+
parser = argparse.ArgumentParser(
|
|
169
|
+
description="Defect escape telemetry — Haiku code quality measurement"
|
|
170
|
+
)
|
|
171
|
+
parser.add_argument("--repo", required=True, help="Path to git repository")
|
|
172
|
+
parser.add_argument(
|
|
173
|
+
"--since", required=True, help="ISO format date (e.g., 2026-07-01)"
|
|
174
|
+
)
|
|
175
|
+
parser.add_argument(
|
|
176
|
+
"--json", action="store_true", help="Output JSON (default: human-readable)"
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
args = parser.parse_args()
|
|
180
|
+
|
|
181
|
+
repo_path = Path(args.repo)
|
|
182
|
+
if not repo_path.exists():
|
|
183
|
+
print(f"Error: repo not found: {repo_path}", file=sys.stderr)
|
|
184
|
+
sys.exit(1)
|
|
185
|
+
|
|
186
|
+
# Validate date format
|
|
187
|
+
try:
|
|
188
|
+
datetime.fromisoformat(args.since)
|
|
189
|
+
except ValueError:
|
|
190
|
+
print(f"Error: invalid date format (use ISO): {args.since}", file=sys.stderr)
|
|
191
|
+
sys.exit(1)
|
|
192
|
+
|
|
193
|
+
# Get all commits in window
|
|
194
|
+
try:
|
|
195
|
+
all_commits = get_commits_since(repo_path, args.since)
|
|
196
|
+
except RuntimeError as e:
|
|
197
|
+
print(f"Error: {e}", file=sys.stderr)
|
|
198
|
+
sys.exit(1)
|
|
199
|
+
|
|
200
|
+
# Count feature vs fix-forward
|
|
201
|
+
feature_commits = []
|
|
202
|
+
fixforward_commits = []
|
|
203
|
+
|
|
204
|
+
for commit in all_commits:
|
|
205
|
+
subject = get_commit_subject(repo_path, commit)
|
|
206
|
+
if is_fixforward_commit(subject):
|
|
207
|
+
fixforward_commits.append(commit)
|
|
208
|
+
else:
|
|
209
|
+
feature_commits.append(commit)
|
|
210
|
+
|
|
211
|
+
# Compute rates
|
|
212
|
+
total_feature = len(feature_commits)
|
|
213
|
+
total_fixforward = len(fixforward_commits)
|
|
214
|
+
fixforward_rate = (
|
|
215
|
+
total_fixforward / total_feature if total_feature > 0 else 0.0
|
|
216
|
+
)
|
|
217
|
+
|
|
218
|
+
# Compute first-try estimate
|
|
219
|
+
first_try_estimate = compute_first_try_estimate(repo_path, all_commits)
|
|
220
|
+
|
|
221
|
+
result = {
|
|
222
|
+
"window": {
|
|
223
|
+
"since": args.since,
|
|
224
|
+
"total_commits": len(all_commits),
|
|
225
|
+
},
|
|
226
|
+
"feature_commits": total_feature,
|
|
227
|
+
"fixforward_commits": total_fixforward,
|
|
228
|
+
"fixforward_rate": round(fixforward_rate, 4),
|
|
229
|
+
"first_try_estimate": (
|
|
230
|
+
round(first_try_estimate, 4) if first_try_estimate is not None else None
|
|
231
|
+
),
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
if args.json:
|
|
235
|
+
print(json.dumps(result, indent=2))
|
|
236
|
+
else:
|
|
237
|
+
print(f"Defect Escape Telemetry Report")
|
|
238
|
+
print(f" Window: since {args.since}")
|
|
239
|
+
print(f" Total commits: {len(all_commits)}")
|
|
240
|
+
print(f" Feature commits: {total_feature}")
|
|
241
|
+
print(f" Fix-forward commits: {total_fixforward}")
|
|
242
|
+
print(f" Fix-forward rate: {fixforward_rate:.2%}")
|
|
243
|
+
if first_try_estimate is not None:
|
|
244
|
+
print(f" First-try estimate: {first_try_estimate:.2%}")
|
|
245
|
+
else:
|
|
246
|
+
print(f" First-try estimate: N/A (no merges in window)")
|
|
247
|
+
|
|
248
|
+
return 0
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
if __name__ == "__main__":
|
|
252
|
+
sys.exit(main())
|
package/tools/doctor.js
CHANGED
|
@@ -126,7 +126,7 @@ function checkPort8770() {
|
|
|
126
126
|
if (!resolved) {
|
|
127
127
|
resolved = true;
|
|
128
128
|
cleanup();
|
|
129
|
-
resolve({ passed: false, hint: 'Port 8770 is in
|
|
129
|
+
resolve({ passed: false, hint: 'Port 8770 in use — is another fleet running? Update dashboard.port in aesop.config.json' });
|
|
130
130
|
}
|
|
131
131
|
});
|
|
132
132
|
|
package/tools/eod_sweep.py
CHANGED
|
@@ -11,21 +11,31 @@ Output contract:
|
|
|
11
11
|
Line 1: EOD-SWEEP: SAFE or EOD-SWEEP: AT-RISK — <n> findings
|
|
12
12
|
Lines 2+: One finding per line (if any)
|
|
13
13
|
Exit code 0 only when SAFE.
|
|
14
|
+
Verdict also appended to BUILDLOG.md (if --buildlog provided or AESOP_STATE_ROOT set).
|
|
14
15
|
|
|
15
16
|
Usage: eod_sweep.py [--repos PATHS] [--readonly-repos PATHS] [--fix-push]
|
|
17
|
+
[--buildlog PATH] [--timestamp YYYY-MM-DD HH:MM]
|
|
16
18
|
|
|
17
19
|
--repos: Colon-separated paths to scan (default: empty; use env var or flag to specify)
|
|
18
20
|
--readonly-repos: Colon-separated paths that should NOT be auto-pushed
|
|
19
21
|
--fix-push: Auto-push unpushed commits in repos where safe
|
|
22
|
+
--buildlog: Path to BUILDLOG.md (default: AESOP_STATE_ROOT/BUILDLOG.md or ./state/BUILDLOG.md)
|
|
23
|
+
--timestamp: Timestamp for BUILDLOG entry (format: YYYY-MM-DD HH:MM; omit to exclude timestamp)
|
|
20
24
|
"""
|
|
21
25
|
|
|
22
26
|
import json
|
|
23
27
|
import subprocess
|
|
24
28
|
import sys
|
|
29
|
+
import os
|
|
25
30
|
from pathlib import Path
|
|
26
31
|
from datetime import datetime
|
|
27
32
|
import time
|
|
28
33
|
|
|
34
|
+
try:
|
|
35
|
+
from common import get_state_dir
|
|
36
|
+
except ImportError:
|
|
37
|
+
from tools.common import get_state_dir
|
|
38
|
+
|
|
29
39
|
|
|
30
40
|
class Finding:
|
|
31
41
|
"""A single finding with repo + message."""
|
|
@@ -153,6 +163,32 @@ def run_secret_scan(repo_path):
|
|
|
153
163
|
return False
|
|
154
164
|
|
|
155
165
|
|
|
166
|
+
def append_to_buildlog(buildlog_path, verdict_line, timestamp_str=None):
|
|
167
|
+
"""Append verdict to BUILDLOG.md (append-only).
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
buildlog_path: Path to BUILDLOG.md file.
|
|
171
|
+
verdict_line: The verdict line to append (e.g., "EOD-SWEEP: SAFE").
|
|
172
|
+
timestamp_str: Optional timestamp string (format: YYYY-MM-DD HH:MM).
|
|
173
|
+
If None, timestamp is omitted from the entry.
|
|
174
|
+
"""
|
|
175
|
+
buildlog_path.parent.mkdir(parents=True, exist_ok=True)
|
|
176
|
+
|
|
177
|
+
# Create header if file doesn't exist
|
|
178
|
+
if not buildlog_path.exists():
|
|
179
|
+
buildlog_path.write_text("# Build Log (append-only)\n")
|
|
180
|
+
|
|
181
|
+
# Build entry line with optional timestamp
|
|
182
|
+
if timestamp_str:
|
|
183
|
+
entry_line = f"### [{timestamp_str}] {verdict_line}"
|
|
184
|
+
else:
|
|
185
|
+
entry_line = f"### {verdict_line}"
|
|
186
|
+
|
|
187
|
+
# Append to BUILDLOG
|
|
188
|
+
with open(buildlog_path, "a", encoding="utf-8") as f:
|
|
189
|
+
f.write(entry_line + "\n")
|
|
190
|
+
|
|
191
|
+
|
|
156
192
|
def main():
|
|
157
193
|
import argparse
|
|
158
194
|
parser = argparse.ArgumentParser(description=__doc__)
|
|
@@ -171,6 +207,16 @@ def main():
|
|
|
171
207
|
action='store_true',
|
|
172
208
|
help='Auto-push unpushed commits'
|
|
173
209
|
)
|
|
210
|
+
parser.add_argument(
|
|
211
|
+
'--buildlog',
|
|
212
|
+
default=None,
|
|
213
|
+
help='Path to BUILDLOG.md (default: AESOP_STATE_ROOT/BUILDLOG.md or ./state/BUILDLOG.md)'
|
|
214
|
+
)
|
|
215
|
+
parser.add_argument(
|
|
216
|
+
'--timestamp',
|
|
217
|
+
default=None,
|
|
218
|
+
help='Timestamp for BUILDLOG entry (format: YYYY-MM-DD HH:MM; omit to exclude timestamp)'
|
|
219
|
+
)
|
|
174
220
|
args = parser.parse_args()
|
|
175
221
|
|
|
176
222
|
# Parse repos
|
|
@@ -232,6 +278,18 @@ def main():
|
|
|
232
278
|
for finding in findings:
|
|
233
279
|
print(f" {finding}")
|
|
234
280
|
|
|
281
|
+
# Append to BUILDLOG if path is available
|
|
282
|
+
buildlog_path = None
|
|
283
|
+
if args.buildlog:
|
|
284
|
+
buildlog_path = Path(args.buildlog)
|
|
285
|
+
else:
|
|
286
|
+
# Try to derive from AESOP_STATE_ROOT or default to ./state
|
|
287
|
+
state_dir = get_state_dir()
|
|
288
|
+
buildlog_path = state_dir / "BUILDLOG.md"
|
|
289
|
+
|
|
290
|
+
if buildlog_path:
|
|
291
|
+
append_to_buildlog(buildlog_path, verdict_line, args.timestamp)
|
|
292
|
+
|
|
235
293
|
sys.exit(exit_code)
|
|
236
294
|
|
|
237
295
|
|
package/tools/fleet.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Aesop fleet — One-shot fleet snapshot
|
|
5
|
+
*
|
|
6
|
+
* Prints a comprehensive fleet status snapshot:
|
|
7
|
+
* - Agents (from dash-extra.mjs --json passthrough)
|
|
8
|
+
* - Heartbeat ages (watchdog + monitor, seconds, with STALE status if >threshold)
|
|
9
|
+
* - Tracker lane counts (from state/tracker.json)
|
|
10
|
+
* - Orchestrator status (from state/orchestrator-status.json)
|
|
11
|
+
*
|
|
12
|
+
* Graceful degradation: any missing state file produces explicit 'unavailable: <why>'
|
|
13
|
+
* rather than crashing or leaving blank output. Never crashes; always exits cleanly.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
const fs = require('fs');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const os = require('os');
|
|
19
|
+
const { spawn } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const CURRENT_DIR = process.cwd();
|
|
22
|
+
const WATCHDOG_STALE_THRESHOLD = 300; // seconds
|
|
23
|
+
const MONITOR_STALE_THRESHOLD = 3600; // seconds
|
|
24
|
+
|
|
25
|
+
// ============================================================================
|
|
26
|
+
// Utility Functions
|
|
27
|
+
// ============================================================================
|
|
28
|
+
|
|
29
|
+
function readHeartbeat(filePath) {
|
|
30
|
+
try {
|
|
31
|
+
if (!fs.existsSync(filePath)) {
|
|
32
|
+
return null;
|
|
33
|
+
}
|
|
34
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
35
|
+
const timestamp = parseInt(content, 10);
|
|
36
|
+
if (isNaN(timestamp)) {
|
|
37
|
+
return null;
|
|
38
|
+
}
|
|
39
|
+
return timestamp;
|
|
40
|
+
} catch (e) {
|
|
41
|
+
return null;
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function getHeartbeatStatus(timestamp, threshold) {
|
|
46
|
+
if (timestamp === null) {
|
|
47
|
+
return { age: null, status: 'MISSING' };
|
|
48
|
+
}
|
|
49
|
+
const now = Math.floor(Date.now() / 1000);
|
|
50
|
+
const age = now - timestamp;
|
|
51
|
+
const isStale = age > threshold;
|
|
52
|
+
const status = isStale ? 'STALE' : 'OK';
|
|
53
|
+
return { age, status };
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadJSON(filePath) {
|
|
57
|
+
try {
|
|
58
|
+
if (!fs.existsSync(filePath)) {
|
|
59
|
+
return null;
|
|
60
|
+
}
|
|
61
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
62
|
+
if (!content) {
|
|
63
|
+
return null;
|
|
64
|
+
}
|
|
65
|
+
return JSON.parse(content);
|
|
66
|
+
} catch (e) {
|
|
67
|
+
return null;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Invoke dash-extra.mjs --json to get active agents
|
|
73
|
+
* Returns { agents: [...] } or { unavailable: "reason" }
|
|
74
|
+
*/
|
|
75
|
+
function getFleetAgents(aesopRoot) {
|
|
76
|
+
return new Promise((resolve) => {
|
|
77
|
+
const dashExtraPath = path.join(aesopRoot, 'dash', 'dash-extra.mjs');
|
|
78
|
+
|
|
79
|
+
if (!fs.existsSync(dashExtraPath)) {
|
|
80
|
+
resolve({ unavailable: 'dash-extra.mjs not found' });
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
try {
|
|
85
|
+
const proc = spawn('node', [dashExtraPath, '--json'], {
|
|
86
|
+
cwd: aesopRoot,
|
|
87
|
+
env: {
|
|
88
|
+
...process.env,
|
|
89
|
+
AESOP_ROOT: aesopRoot
|
|
90
|
+
},
|
|
91
|
+
timeout: 5000,
|
|
92
|
+
stdio: ['ignore', 'pipe', 'pipe']
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
let stdout = '';
|
|
96
|
+
let stderr = '';
|
|
97
|
+
let resolved = false;
|
|
98
|
+
|
|
99
|
+
const cleanup = () => {
|
|
100
|
+
if (!resolved) {
|
|
101
|
+
resolved = true;
|
|
102
|
+
if (proc && !proc.killed) {
|
|
103
|
+
try {
|
|
104
|
+
proc.kill();
|
|
105
|
+
} catch (e) {
|
|
106
|
+
// ignore
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
};
|
|
111
|
+
|
|
112
|
+
const timeoutHandle = setTimeout(() => {
|
|
113
|
+
cleanup();
|
|
114
|
+
resolve({ unavailable: 'dash-extra.mjs timeout' });
|
|
115
|
+
}, 6000);
|
|
116
|
+
|
|
117
|
+
proc.stdout.on('data', (data) => {
|
|
118
|
+
stdout += data.toString();
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
proc.stderr.on('data', (data) => {
|
|
122
|
+
stderr += data.toString();
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
proc.on('close', (code) => {
|
|
126
|
+
clearTimeout(timeoutHandle);
|
|
127
|
+
if (!resolved) {
|
|
128
|
+
resolved = true;
|
|
129
|
+
if (code === 0 && stdout.trim()) {
|
|
130
|
+
try {
|
|
131
|
+
const agents = JSON.parse(stdout);
|
|
132
|
+
resolve({
|
|
133
|
+
agents: Array.isArray(agents) ? agents : [],
|
|
134
|
+
count: Array.isArray(agents) ? agents.length : 0
|
|
135
|
+
});
|
|
136
|
+
} catch (e) {
|
|
137
|
+
resolve({ unavailable: 'failed to parse dash-extra output' });
|
|
138
|
+
}
|
|
139
|
+
} else {
|
|
140
|
+
resolve({ unavailable: 'dash-extra.mjs failed' });
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
proc.on('error', (err) => {
|
|
146
|
+
clearTimeout(timeoutHandle);
|
|
147
|
+
cleanup();
|
|
148
|
+
if (!resolved) {
|
|
149
|
+
resolved = true;
|
|
150
|
+
resolve({ unavailable: 'dash-extra.mjs spawn error' });
|
|
151
|
+
}
|
|
152
|
+
});
|
|
153
|
+
} catch (e) {
|
|
154
|
+
resolve({ unavailable: 'exception spawning dash-extra.mjs' });
|
|
155
|
+
}
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function getTrackerLaneCounts(trackerPath) {
|
|
160
|
+
const tracker = loadJSON(trackerPath);
|
|
161
|
+
if (!tracker) {
|
|
162
|
+
return { unavailable: 'tracker.json not found or malformed' };
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
const items = tracker.items || [];
|
|
166
|
+
const byLane = {};
|
|
167
|
+
|
|
168
|
+
for (const item of items) {
|
|
169
|
+
const lane = item.lane || 'unknown';
|
|
170
|
+
byLane[lane] = (byLane[lane] || 0) + 1;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
total_items: items.length,
|
|
175
|
+
by_lane: byLane
|
|
176
|
+
};
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
function getOrchestratorStatus(orchStatusPath) {
|
|
180
|
+
const status = loadJSON(orchStatusPath);
|
|
181
|
+
if (!status) {
|
|
182
|
+
return { unavailable: 'orchestrator-status.json not found or malformed' };
|
|
183
|
+
}
|
|
184
|
+
return status;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
// ============================================================================
|
|
188
|
+
// Main
|
|
189
|
+
// ============================================================================
|
|
190
|
+
|
|
191
|
+
(async function main() {
|
|
192
|
+
try {
|
|
193
|
+
// Determine AESOP_ROOT
|
|
194
|
+
const aesopRoot = process.env.AESOP_ROOT || CURRENT_DIR;
|
|
195
|
+
|
|
196
|
+
const stateDir = path.join(aesopRoot, 'state');
|
|
197
|
+
const watchdogHeartbeatPath = path.join(stateDir, '.watchdog-heartbeat');
|
|
198
|
+
const monitorHeartbeatPath = path.join(stateDir, '.monitor-heartbeat');
|
|
199
|
+
const trackerPath = path.join(stateDir, 'tracker.json');
|
|
200
|
+
const orchStatusPath = path.join(stateDir, 'orchestrator-status.json');
|
|
201
|
+
|
|
202
|
+
// Gather all data concurrently
|
|
203
|
+
const [watchdogTs, monitorTs, agentsData, trackerData, orchStatusData] = await Promise.all([
|
|
204
|
+
Promise.resolve(readHeartbeat(watchdogHeartbeatPath)),
|
|
205
|
+
Promise.resolve(readHeartbeat(monitorHeartbeatPath)),
|
|
206
|
+
getFleetAgents(aesopRoot),
|
|
207
|
+
Promise.resolve(getTrackerLaneCounts(trackerPath)),
|
|
208
|
+
Promise.resolve(getOrchestratorStatus(orchStatusPath))
|
|
209
|
+
]);
|
|
210
|
+
|
|
211
|
+
// Build result object
|
|
212
|
+
const result = {
|
|
213
|
+
timestamp: new Date().toISOString(),
|
|
214
|
+
aesop_root: aesopRoot,
|
|
215
|
+
heartbeats: {
|
|
216
|
+
watchdog: null,
|
|
217
|
+
monitor: null
|
|
218
|
+
},
|
|
219
|
+
agents: agentsData,
|
|
220
|
+
tracker: trackerData,
|
|
221
|
+
orchestrator: orchStatusData
|
|
222
|
+
};
|
|
223
|
+
|
|
224
|
+
// Process watchdog heartbeat
|
|
225
|
+
const watchdogStatus = getHeartbeatStatus(watchdogTs, WATCHDOG_STALE_THRESHOLD);
|
|
226
|
+
if (watchdogStatus.age !== null) {
|
|
227
|
+
result.heartbeats.watchdog = {
|
|
228
|
+
age_seconds: watchdogStatus.age,
|
|
229
|
+
status: watchdogStatus.status,
|
|
230
|
+
threshold_seconds: WATCHDOG_STALE_THRESHOLD
|
|
231
|
+
};
|
|
232
|
+
} else {
|
|
233
|
+
result.heartbeats.watchdog = {
|
|
234
|
+
unavailable: watchdogStatus.status
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
// Process monitor heartbeat
|
|
239
|
+
const monitorStatus = getHeartbeatStatus(monitorTs, MONITOR_STALE_THRESHOLD);
|
|
240
|
+
if (monitorStatus.age !== null) {
|
|
241
|
+
result.heartbeats.monitor = {
|
|
242
|
+
age_seconds: monitorStatus.age,
|
|
243
|
+
status: monitorStatus.status,
|
|
244
|
+
threshold_seconds: MONITOR_STALE_THRESHOLD
|
|
245
|
+
};
|
|
246
|
+
} else {
|
|
247
|
+
result.heartbeats.monitor = {
|
|
248
|
+
unavailable: monitorStatus.status
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// Output JSON
|
|
253
|
+
console.log(JSON.stringify(result, null, 2));
|
|
254
|
+
|
|
255
|
+
process.exitCode = 0;
|
|
256
|
+
} catch (err) {
|
|
257
|
+
console.error(`Error gathering fleet snapshot: ${err.message}`);
|
|
258
|
+
process.exitCode = 1;
|
|
259
|
+
}
|
|
260
|
+
})();
|