@matt82198/aesop 0.1.0 → 0.3.1

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 (130) hide show
  1. package/CHANGELOG.md +117 -2
  2. package/README.md +59 -218
  3. package/bin/cli.js +168 -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/HOOK-INSTALL.md +15 -56
  10. package/docs/INSTALL.md +74 -4
  11. package/docs/PORTING.md +166 -0
  12. package/docs/README.md +33 -3
  13. package/docs/TEAM-STATE.md +372 -0
  14. package/docs/reproduce.md +33 -3
  15. package/driver/CLAUDE.md +150 -0
  16. package/driver/README.md +383 -0
  17. package/driver/aesop.config.example.json +80 -0
  18. package/driver/agent_driver.py +355 -0
  19. package/driver/backend_config.py +253 -0
  20. package/driver/claude_code_driver.py +198 -0
  21. package/driver/codex_driver.py +673 -0
  22. package/driver/openai_compatible_driver.py +249 -0
  23. package/driver/openai_transport.py +179 -0
  24. package/driver/verification_policy.py +75 -0
  25. package/driver/wave_bridge.py +254 -0
  26. package/driver/wave_loop.py +1408 -0
  27. package/driver/wave_scheduler.py +890 -0
  28. package/hooks/pre-push-policy.sh +131 -33
  29. package/mcp/server.mjs +320 -4
  30. package/monitor/collect-signals.mjs +69 -0
  31. package/package.json +22 -14
  32. package/skills/CLAUDE.md +132 -2
  33. package/skills/buildsystem/SKILL.md +330 -0
  34. package/skills/buildsystem/wave-flat-dispatch.template.mjs +658 -0
  35. package/skills/fleet/SKILL.md +113 -0
  36. package/skills/power/SKILL.md +246 -131
  37. package/state_store/__init__.py +4 -2
  38. package/state_store/api.py +19 -4
  39. package/state_store/coordination.py +209 -0
  40. package/state_store/identity.py +51 -0
  41. package/state_store/projections.py +63 -0
  42. package/state_store/read_api.py +156 -0
  43. package/state_store/store.py +185 -73
  44. package/state_store/write_api.py +462 -0
  45. package/templates/wave-presets/data.json +65 -0
  46. package/templates/wave-presets/library.json +65 -0
  47. package/templates/wave-presets/saas.json +64 -0
  48. package/tools/audit_report.py +388 -0
  49. package/tools/bench_runner.py +100 -3
  50. package/tools/ci_merge_wait.py +256 -35
  51. package/tools/ci_workflow_lint.py +430 -0
  52. package/tools/claudemd_drift.py +394 -0
  53. package/tools/claudemd_lint.py +359 -0
  54. package/tools/common.py +39 -3
  55. package/tools/cost_ceiling.py +166 -43
  56. package/tools/cost_econ.py +480 -0
  57. package/tools/cost_projection.py +559 -0
  58. package/tools/crossos_drift.py +394 -0
  59. package/tools/defect_escape.py +252 -0
  60. package/tools/doctor.js +1 -1
  61. package/tools/eod_sweep.py +188 -26
  62. package/tools/fleet.js +260 -0
  63. package/tools/fleet_ledger.py +209 -7
  64. package/tools/git_identity_check.py +315 -0
  65. package/tools/health-score.js +40 -0
  66. package/tools/health_score.py +361 -0
  67. package/tools/metrics_gate.py +13 -4
  68. package/tools/mutation_test.py +523 -0
  69. package/tools/portability_check.py +206 -0
  70. package/tools/proposals.mjs +47 -2
  71. package/tools/reconcile.py +7 -4
  72. package/tools/reproduce.js +405 -0
  73. package/tools/secret_scan.py +207 -65
  74. package/tools/self_stats.py +20 -0
  75. package/tools/stall_check.py +247 -16
  76. package/tools/stateapi_lint.py +325 -0
  77. package/tools/test_battery.py +173 -0
  78. package/tools/transcript_digest.py +380 -0
  79. package/tools/verify_activity_filter.py +437 -0
  80. package/tools/verify_agent_inspector.py +2 -0
  81. package/tools/verify_cost_panel.py +345 -0
  82. package/tools/verify_dash.py +2 -0
  83. package/tools/verify_dispatch_panel.py +301 -0
  84. package/tools/verify_failure_drilldown.py +188 -0
  85. package/tools/verify_prboard.py +2 -0
  86. package/tools/verify_scorecards.py +281 -0
  87. package/tools/verify_submit_encoding.py +2 -0
  88. package/tools/verify_ui_trio.py +409 -0
  89. package/tools/verify_wave_telemetry.py +268 -0
  90. package/tools/wave_backlog_analyzer.py +490 -0
  91. package/tools/wave_ledger_hook.py +150 -0
  92. package/tools/wave_preflight.py +779 -0
  93. package/tools/wave_resume.py +215 -0
  94. package/tools/wave_templates.py +340 -0
  95. package/ui/agents.py +68 -14
  96. package/ui/collectors.py +81 -55
  97. package/ui/config.py +7 -2
  98. package/ui/cost.py +231 -12
  99. package/ui/handler.py +383 -55
  100. package/ui/quality_scorecard.py +232 -0
  101. package/ui/sse.py +3 -3
  102. package/ui/wave_audit_tail.py +213 -0
  103. package/ui/wave_dispatch.py +280 -0
  104. package/ui/wave_failure.py +288 -0
  105. package/ui/wave_gantt.py +152 -0
  106. package/ui/wave_reasoning_tail.py +176 -0
  107. package/ui/wave_telemetry.py +383 -0
  108. package/ui/web/dist/assets/index-CNQxaiOW.css +1 -0
  109. package/ui/web/dist/assets/index-CP68RIh3.js +9 -0
  110. package/ui/web/dist/index.html +2 -2
  111. package/bin/CLAUDE.md +0 -76
  112. package/daemons/CLAUDE.md +0 -36
  113. package/dash/CLAUDE.md +0 -32
  114. package/docs/archive/README.md +0 -3
  115. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +0 -125
  116. package/docs/archive/spikes/tiered-cognition/DESIGN.md +0 -287
  117. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +0 -113
  118. package/docs/archive/spikes/tiered-cognition/README.md +0 -27
  119. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +0 -32
  120. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +0 -673
  121. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +0 -434
  122. package/hooks/CLAUDE.md +0 -89
  123. package/mcp/CLAUDE.md +0 -213
  124. package/monitor/CLAUDE.md +0 -40
  125. package/scan/CLAUDE.md +0 -30
  126. package/state_store/CLAUDE.md +0 -39
  127. package/tools/CLAUDE.md +0 -79
  128. package/ui/CLAUDE.md +0 -127
  129. package/ui/web/dist/assets/index-0qQYnvMC.js +0 -9
  130. package/ui/web/dist/assets/index-BdIlFieV.css +0 -1
@@ -11,21 +11,32 @@ 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
27
+ import os
23
28
  import subprocess
24
29
  import sys
30
+ import os
25
31
  from pathlib import Path
26
32
  from datetime import datetime
27
33
  import time
28
34
 
35
+ try:
36
+ from common import get_state_dir
37
+ except ImportError:
38
+ from tools.common import get_state_dir
39
+
29
40
 
30
41
  class Finding:
31
42
  """A single finding with repo + message."""
@@ -38,13 +49,27 @@ class Finding:
38
49
 
39
50
 
40
51
  def get_git_status(repo_path):
41
- """Return (is_clean, dirty_files_list) for a repo."""
52
+ """Return (is_clean, dirty_files_list) for a repo.
53
+
54
+ Returns:
55
+ (True, []): Repo is clean
56
+ (False, list): Repo is dirty with file list
57
+ (None, error_msg): Git command failed (FAIL-CLOSED: treat as AT-RISK)
58
+ """
42
59
  try:
43
- output = subprocess.run(
44
- ['git', '-C', str(repo_path), 'status', '--porcelain'],
60
+ # Resolve path to normalize 8.3 short names on Windows
61
+ resolved_path = Path(repo_path).resolve()
62
+ result = subprocess.run(
63
+ ['git', '-C', str(resolved_path), 'status', '--porcelain'],
45
64
  capture_output=True, text=True, timeout=5
46
- ).stdout.strip()
65
+ )
66
+
67
+ # FAIL-CLOSED: Check return code before processing output
68
+ if result.returncode != 0:
69
+ error_msg = result.stderr.strip() if result.stderr.strip() else f"exit code {result.returncode}"
70
+ return (None, f"git status check failed: {error_msg}")
47
71
 
72
+ output = result.stdout.strip()
48
73
  if not output:
49
74
  return (True, [])
50
75
  else:
@@ -55,21 +80,56 @@ def get_git_status(repo_path):
55
80
 
56
81
 
57
82
  def get_ahead_count(repo_path):
58
- """Return count of commits ahead of origin/HEAD (or None on error)."""
83
+ """Return count of commits ahead of origin/HEAD (or None on error).
84
+
85
+ Returns:
86
+ int >= 0: Number of commits ahead (0 = all pushed or no remote)
87
+ None: Git command failed unexpectedly (FAIL-CLOSED: treat as AT-RISK)
88
+ """
59
89
  try:
90
+ # Resolve path to normalize 8.3 short names on Windows
91
+ resolved_path = Path(repo_path).resolve()
60
92
  # First check if there's a tracking branch
61
- try:
62
- output = subprocess.run(
63
- ['git', '-C', str(repo_path), 'rev-list', '--left-only', '--count', 'HEAD...@{u}'],
64
- capture_output=True, text=True, timeout=5
65
- ).stdout.strip()
66
- except:
67
- # Fallback to origin/HEAD if no upstream
68
- output = subprocess.run(
69
- ['git', '-C', str(repo_path), 'rev-list', '--left-only', '--count', 'HEAD...origin/HEAD'],
93
+ result = subprocess.run(
94
+ ['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...@{u}'],
95
+ capture_output=True, text=True, timeout=5
96
+ )
97
+
98
+ # If upstream tracking exists, use that result
99
+ if result.returncode == 0:
100
+ output = result.stdout.strip()
101
+ try:
102
+ return int(output) if output else 0
103
+ except:
104
+ return None
105
+
106
+ # If no upstream tracking, try origin/HEAD (fallback for single-branch or no-remote repos)
107
+ # Expected error for local-only repos: "fatal: no upstream configured"
108
+ stderr_lower = result.stderr.lower()
109
+ if 'no upstream configured' in stderr_lower:
110
+ # Local-only repo (no tracking branch), try origin/HEAD as fallback
111
+ result = subprocess.run(
112
+ ['git', '-C', str(resolved_path), 'rev-list', '--left-only', '--count', 'HEAD...origin/HEAD'],
70
113
  capture_output=True, text=True, timeout=5
71
- ).stdout.strip()
114
+ )
115
+
116
+ # Check return code; if fails, determine if it's an expected "no remote" error or a real error
117
+ if result.returncode != 0:
118
+ stderr_lower = result.stderr.lower()
119
+ # Expected errors for local-only repos (no remote configured)
120
+ expected_errors = [
121
+ 'no upstream configured',
122
+ 'ambiguous argument',
123
+ 'unknown revision',
124
+ 'not a valid object name',
125
+ 'no such ref'
126
+ ]
127
+ if any(err in stderr_lower for err in expected_errors):
128
+ return 0 # No remote/tracking, treat as "all pushed"
129
+ else:
130
+ return None # Real error, fail-closed
72
131
 
132
+ output = result.stdout.strip()
73
133
  try:
74
134
  return int(output) if output else 0
75
135
  except:
@@ -79,13 +139,26 @@ def get_ahead_count(repo_path):
79
139
 
80
140
 
81
141
  def check_untracked_files(repo_path):
82
- """Return list of untracked files not in .gitignore."""
142
+ """Return list of untracked files not in .gitignore.
143
+
144
+ Returns:
145
+ []: No untracked files
146
+ [list]: Untracked files found
147
+ None: Git command failed (FAIL-CLOSED: treat as AT-RISK)
148
+ """
83
149
  try:
84
- output = subprocess.run(
85
- ['git', '-C', str(repo_path), 'ls-files', '--others', '--exclude-standard'],
150
+ # Resolve path to normalize 8.3 short names on Windows
151
+ resolved_path = Path(repo_path).resolve()
152
+ result = subprocess.run(
153
+ ['git', '-C', str(resolved_path), 'ls-files', '--others', '--exclude-standard'],
86
154
  capture_output=True, text=True, timeout=5
87
- ).stdout.strip()
155
+ )
156
+
157
+ # FAIL-CLOSED: Check return code before processing output
158
+ if result.returncode != 0:
159
+ return None
88
160
 
161
+ output = result.stdout.strip()
89
162
  if output:
90
163
  return output.split('\n')
91
164
  return []
@@ -94,12 +167,18 @@ def check_untracked_files(repo_path):
94
167
 
95
168
 
96
169
  def check_repo(repo_path):
97
- """Check a single repo; return list of Finding objects or None if repo doesn't exist."""
170
+ """Check a single repo; returns a list of Finding objects.
171
+
172
+ FAIL-CLOSED (runner incident): a repo the caller ASKED to check that does
173
+ not exist or is not a git repo is an AT-RISK finding, never a silent skip
174
+ — the silent-None path made the sweep report a vacuous SAFE (exit 0) when
175
+ fixture/real repos failed to initialize.
176
+ """
98
177
  if not repo_path.exists():
99
- return None
178
+ return [Finding(repo_path, "repo path does not exist")]
100
179
 
101
180
  if not (repo_path / '.git').exists():
102
- return None
181
+ return [Finding(repo_path, "not a git repository (.git missing)")]
103
182
 
104
183
  findings = []
105
184
 
@@ -130,8 +209,10 @@ def check_repo(repo_path):
130
209
  def push_repo(repo_path):
131
210
  """Push commits for a repo (return True if successful)."""
132
211
  try:
212
+ # Resolve path to normalize 8.3 short names on Windows
213
+ resolved_path = Path(repo_path).resolve()
133
214
  result = subprocess.run(
134
- ['git', '-C', str(repo_path), 'push'],
215
+ ['git', '-C', str(resolved_path), 'push'],
135
216
  capture_output=True, text=True, timeout=30
136
217
  )
137
218
  return result.returncode == 0
@@ -153,13 +234,64 @@ def run_secret_scan(repo_path):
153
234
  return False
154
235
 
155
236
 
237
+ def append_to_buildlog(buildlog_path, verdict_line, timestamp_str=None):
238
+ """Append verdict to BUILDLOG.md (append-only).
239
+
240
+ Args:
241
+ buildlog_path: Path to BUILDLOG.md file.
242
+ verdict_line: The verdict line to append (e.g., "EOD-SWEEP: SAFE").
243
+ timestamp_str: Optional timestamp string (format: YYYY-MM-DD HH:MM).
244
+ If None, timestamp is omitted from the entry.
245
+
246
+ Raises:
247
+ OSError: If file operations fail (permission denied, locked file, etc.).
248
+ """
249
+ try:
250
+ buildlog_path.parent.mkdir(parents=True, exist_ok=True)
251
+ except OSError as e:
252
+ # Parent directory creation failed; re-raise with context
253
+ raise OSError(f"Failed to create parent directory for BUILDLOG: {e}") from e
254
+
255
+ # Create header if file doesn't exist
256
+ try:
257
+ if not buildlog_path.exists():
258
+ buildlog_path.write_text("# Build Log (append-only)\n")
259
+ except OSError as e:
260
+ # File creation failed; re-raise with context
261
+ raise OSError(f"Failed to create BUILDLOG file: {e}") from e
262
+
263
+ # Build entry line with optional timestamp
264
+ if timestamp_str:
265
+ entry_line = f"### [{timestamp_str}] {verdict_line}"
266
+ else:
267
+ entry_line = f"### {verdict_line}"
268
+
269
+ # Append to BUILDLOG with retry logic for Windows file locking
270
+ max_retries = 3
271
+ retry_delay = 0.1 # seconds
272
+ last_error = None
273
+
274
+ for attempt in range(max_retries):
275
+ try:
276
+ with open(buildlog_path, "a", encoding="utf-8") as f:
277
+ f.write(entry_line + "\n")
278
+ return
279
+ except OSError as e:
280
+ last_error = e
281
+ if attempt < max_retries - 1:
282
+ time.sleep(retry_delay)
283
+
284
+ # All retries failed
285
+ raise OSError(f"Failed to append to BUILDLOG after {max_retries} attempts: {last_error}") from last_error
286
+
287
+
156
288
  def main():
157
289
  import argparse
158
290
  parser = argparse.ArgumentParser(description=__doc__)
159
291
  parser.add_argument(
160
292
  '--repos',
161
293
  default='',
162
- help='Colon-separated paths to scan (default: empty)'
294
+ help="os.pathsep-separated paths (; on Windows, : on POSIX)"
163
295
  )
164
296
  parser.add_argument(
165
297
  '--readonly-repos',
@@ -171,17 +303,31 @@ def main():
171
303
  action='store_true',
172
304
  help='Auto-push unpushed commits'
173
305
  )
306
+ parser.add_argument(
307
+ '--buildlog',
308
+ default=None,
309
+ help='Path to BUILDLOG.md (default: AESOP_STATE_ROOT/BUILDLOG.md or ./state/BUILDLOG.md)'
310
+ )
311
+ parser.add_argument(
312
+ '--timestamp',
313
+ default=None,
314
+ help='Timestamp for BUILDLOG entry (format: YYYY-MM-DD HH:MM; omit to exclude timestamp)'
315
+ )
174
316
  args = parser.parse_args()
175
317
 
176
318
  # Parse repos
319
+ # Split on os.pathsep (';' on Windows, ':' on POSIX): a ':' delimiter
320
+ # eats Windows drive letters (the runner's vacuous-SAFE incident: tmp on
321
+ # C:, checkout on D:, drive-relative remainder resolved to nothing and
322
+ # the old silent-skip reported SAFE with zero repos scanned).
177
323
  repos_to_check = []
178
324
  if args.repos:
179
- repos_to_check = [Path(p) for p in args.repos.split(':') if p]
325
+ repos_to_check = [Path(p) for p in args.repos.split(os.pathsep) if p]
180
326
 
181
327
  # Parse readonly repos
182
328
  readonly_repos = set()
183
329
  if args.readonly_repos:
184
- readonly_repos = {Path(p) for p in args.readonly_repos.split(':') if p}
330
+ readonly_repos = {Path(p) for p in args.readonly_repos.split(os.pathsep) if p}
185
331
 
186
332
  findings = []
187
333
 
@@ -232,6 +378,22 @@ def main():
232
378
  for finding in findings:
233
379
  print(f" {finding}")
234
380
 
381
+ # Append to BUILDLOG if path is available
382
+ buildlog_path = None
383
+ if args.buildlog:
384
+ buildlog_path = Path(args.buildlog)
385
+ else:
386
+ # Try to derive from AESOP_STATE_ROOT or default to ./state
387
+ state_dir = get_state_dir()
388
+ buildlog_path = state_dir / "BUILDLOG.md"
389
+
390
+ if buildlog_path:
391
+ try:
392
+ append_to_buildlog(buildlog_path, verdict_line, args.timestamp)
393
+ except OSError as e:
394
+ # Log error but don't fail the verdict — exit code depends on findings, not BUILDLOG
395
+ print(f"WARNING: Failed to append to BUILDLOG: {e}", file=sys.stderr)
396
+
235
397
  sys.exit(exit_code)
236
398
 
237
399
 
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
+ })();