@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.
Files changed (114) hide show
  1. package/CHANGELOG.md +89 -2
  2. package/README.md +63 -12
  3. package/bin/cli.js +164 -41
  4. package/daemons/run-watchdog.sh +16 -4
  5. package/daemons/selfheal.sh +231 -0
  6. package/docs/ANY-REPO.md +427 -0
  7. package/docs/CONTRIBUTING.md +72 -0
  8. package/docs/DEMO.md +334 -0
  9. package/docs/INSTALL.md +70 -1
  10. package/docs/QUICKSTART.md +80 -0
  11. package/docs/README.md +33 -3
  12. package/docs/TEAM-STATE.md +540 -0
  13. package/driver/CLAUDE.md +148 -0
  14. package/driver/README.md +383 -0
  15. package/driver/aesop.config.example.json +80 -0
  16. package/driver/agent_driver.py +355 -0
  17. package/driver/backend_config.py +253 -0
  18. package/driver/claude_code_driver.py +198 -0
  19. package/driver/codex_driver.py +626 -0
  20. package/driver/openai_compatible_driver.py +249 -0
  21. package/driver/openai_transport.py +146 -0
  22. package/driver/verification_policy.py +75 -0
  23. package/driver/wave_bridge.py +246 -0
  24. package/driver/wave_loop.py +1041 -0
  25. package/hooks/pre-push-policy.sh +109 -28
  26. package/mcp/server.mjs +320 -4
  27. package/package.json +23 -15
  28. package/skills/CLAUDE.md +132 -2
  29. package/skills/buildsystem/SKILL.md +330 -0
  30. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  31. package/skills/fleet/SKILL.md +113 -0
  32. package/skills/power/SKILL.md +246 -131
  33. package/state_store/__init__.py +2 -1
  34. package/state_store/api.py +19 -4
  35. package/state_store/coordination.py +209 -0
  36. package/state_store/identity.py +51 -0
  37. package/state_store/store.py +185 -73
  38. package/templates/wave-presets/data.json +65 -0
  39. package/templates/wave-presets/library.json +65 -0
  40. package/templates/wave-presets/saas.json +64 -0
  41. package/tools/audit_report.py +388 -0
  42. package/tools/bench_runner.py +100 -3
  43. package/tools/ci_merge_wait.py +256 -35
  44. package/tools/ci_workflow_lint.py +430 -0
  45. package/tools/claudemd_drift.py +394 -0
  46. package/tools/claudemd_lint.py +359 -0
  47. package/tools/common.py +39 -3
  48. package/tools/cost_ceiling.py +63 -31
  49. package/tools/cost_econ.py +480 -0
  50. package/tools/defect_escape.py +252 -0
  51. package/tools/doctor.js +1 -1
  52. package/tools/eod_sweep.py +58 -0
  53. package/tools/fleet.js +260 -0
  54. package/tools/fleet_ledger.py +209 -7
  55. package/tools/git_identity_check.py +315 -0
  56. package/tools/health-score.js +40 -0
  57. package/tools/health_score.py +361 -0
  58. package/tools/metrics_gate.py +13 -4
  59. package/tools/mutation_test.py +401 -0
  60. package/tools/portability_check.py +206 -0
  61. package/tools/reconcile.py +7 -4
  62. package/tools/secret_scan.py +137 -50
  63. package/tools/self_stats.py +20 -0
  64. package/tools/transcript_digest.py +380 -0
  65. package/tools/verify_activity_filter.py +437 -0
  66. package/tools/verify_agent_inspector.py +2 -0
  67. package/tools/verify_dash.py +2 -0
  68. package/tools/verify_dispatch_panel.py +301 -0
  69. package/tools/verify_failure_drilldown.py +188 -0
  70. package/tools/verify_prboard.py +2 -0
  71. package/tools/verify_scorecards.py +281 -0
  72. package/tools/verify_submit_encoding.py +2 -0
  73. package/tools/verify_ui_trio.py +409 -0
  74. package/tools/verify_wave_telemetry.py +268 -0
  75. package/tools/wave_backlog_analyzer.py +490 -0
  76. package/tools/wave_ledger_hook.py +150 -0
  77. package/tools/wave_preflight.py +512 -0
  78. package/tools/wave_resume.py +215 -0
  79. package/tools/wave_templates.py +215 -0
  80. package/ui/agents.py +68 -14
  81. package/ui/collectors.py +0 -55
  82. package/ui/config.py +7 -2
  83. package/ui/cost.py +231 -12
  84. package/ui/handler.py +183 -0
  85. package/ui/quality_scorecard.py +232 -0
  86. package/ui/wave_audit_tail.py +213 -0
  87. package/ui/wave_dispatch.py +280 -0
  88. package/ui/wave_failure.py +288 -0
  89. package/ui/wave_gantt.py +152 -0
  90. package/ui/wave_reasoning_tail.py +176 -0
  91. package/ui/wave_telemetry.py +377 -0
  92. package/ui/web/dist/assets/index-BGbgw2Nh.js +9 -0
  93. package/ui/web/dist/assets/index-DqZLgwNg.css +1 -0
  94. package/ui/web/dist/index.html +2 -2
  95. package/bin/CLAUDE.md +0 -76
  96. package/daemons/CLAUDE.md +0 -36
  97. package/dash/CLAUDE.md +0 -32
  98. package/docs/archive/README.md +0 -3
  99. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  100. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  101. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  102. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  103. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  104. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  105. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  106. package/hooks/CLAUDE.md +0 -89
  107. package/mcp/CLAUDE.md +0 -213
  108. package/monitor/CLAUDE.md +0 -40
  109. package/scan/CLAUDE.md +0 -30
  110. package/state_store/CLAUDE.md +0 -39
  111. package/tools/CLAUDE.md +0 -79
  112. package/ui/CLAUDE.md +0 -127
  113. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  114. 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 use' });
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
 
@@ -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
+ })();