@matt82198/aesop 0.1.0-beta.3 → 0.1.0-beta.4

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 (108) hide show
  1. package/CHANGELOG.md +37 -94
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +89 -264
  4. package/aesop.config.example.json +27 -26
  5. package/bin/CLAUDE.md +39 -0
  6. package/bin/cli.js +368 -24
  7. package/daemons/CLAUDE.md +33 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +90 -10
  10. package/dash/CLAUDE.md +32 -0
  11. package/dash/dash-extra.mjs +194 -21
  12. package/dash/watchdog-gui.sh +23 -6
  13. package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
  14. package/docs/CHECKPOINTING.md +100 -0
  15. package/docs/DISPATCH-MODEL.md +11 -7
  16. package/docs/FORENSICS.md +204 -0
  17. package/docs/GOVERNANCE.md +4 -2
  18. package/docs/HOOK-INSTALL.md +307 -0
  19. package/docs/HOW-THE-LOOP-WORKS.md +175 -0
  20. package/docs/MEMORY-TEMPLATE.md +61 -0
  21. package/docs/README.md +44 -0
  22. package/docs/RELIABILITY.md +61 -0
  23. package/docs/RESTORE.md +397 -0
  24. package/docs/SCRIPTS-POLICY.md +97 -0
  25. package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
  26. package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
  27. package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
  28. package/docs/spikes/tiered-cognition/README.md +29 -0
  29. package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  30. package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  31. package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  32. package/hooks/CLAUDE.md +38 -0
  33. package/hooks/claude/force-model-policy.mjs +159 -0
  34. package/hooks/pre-push-policy.sh +753 -0
  35. package/monitor/.signal-state.json +3 -0
  36. package/monitor/ACTIONS.log +1 -0
  37. package/monitor/BRIEF.md +24 -0
  38. package/monitor/CHARTER.md +29 -8
  39. package/monitor/CLAUDE.md +40 -0
  40. package/monitor/SIGNALS.json +54 -0
  41. package/monitor/collect-signals.mjs +529 -62
  42. package/package.json +12 -3
  43. package/skills/CLAUDE.md +3 -0
  44. package/skills/power/SKILL.md +161 -0
  45. package/tools/CLAUDE.md +191 -0
  46. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  52. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  53. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  54. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  55. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  56. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  57. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  58. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  59. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  60. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  61. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  62. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  63. package/tools/agent-forensics.sh +186 -0
  64. package/tools/buildlog.py +182 -0
  65. package/tools/ci_merge_wait.py +204 -0
  66. package/tools/ensure_state.py +94 -0
  67. package/tools/eod_sweep.py +239 -0
  68. package/tools/fleet_ledger.py +272 -0
  69. package/tools/heartbeat.py +131 -0
  70. package/tools/inbox_drain.py +249 -0
  71. package/tools/launch_tui.py +51 -3
  72. package/tools/lock.mjs +211 -0
  73. package/tools/metrics_gate.py +205 -0
  74. package/tools/orchestrator_status.py +99 -0
  75. package/tools/power_selftest.py +386 -0
  76. package/tools/prepublish_scan.py +84 -0
  77. package/tools/proposals.mjs +248 -0
  78. package/tools/reconstitute.sh +467 -0
  79. package/tools/rotate_logs.py +228 -0
  80. package/tools/scanner_selftest.py +303 -0
  81. package/tools/secret_scan.py +131 -31
  82. package/tools/stall_check.py +172 -0
  83. package/tools/verify_dash.py +694 -0
  84. package/tools/verify_submit_encoding.py +194 -0
  85. package/ui/CLAUDE.md +109 -0
  86. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  87. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  88. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  89. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  90. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  91. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  92. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  93. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  94. package/ui/agents.py +179 -0
  95. package/ui/api/__init__.py +84 -0
  96. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  97. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  98. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  99. package/ui/api/submit.py +58 -0
  100. package/ui/api/tracker.py +116 -0
  101. package/ui/collectors.py +586 -0
  102. package/ui/config.py +114 -0
  103. package/ui/csrf.py +140 -0
  104. package/ui/handler.py +426 -0
  105. package/ui/render.py +29 -0
  106. package/ui/serve.py +68 -796
  107. package/ui/sse.py +168 -0
  108. package/ui/templates/dashboard.html +1202 -0
@@ -0,0 +1,239 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ eod_sweep.py — End-of-day safety check for repository health.
4
+
5
+ Verifies git repositories are safe (no data loss risk):
6
+ - Working tree clean/dirty
7
+ - Branch pushed (ahead-count 0)
8
+ - Untracked files not in .gitignore
9
+
10
+ Output contract:
11
+ Line 1: EOD-SWEEP: SAFE or EOD-SWEEP: AT-RISK — <n> findings
12
+ Lines 2+: One finding per line (if any)
13
+ Exit code 0 only when SAFE.
14
+
15
+ Usage: eod_sweep.py [--repos PATHS] [--readonly-repos PATHS] [--fix-push]
16
+
17
+ --repos: Colon-separated paths to scan (default: empty; use env var or flag to specify)
18
+ --readonly-repos: Colon-separated paths that should NOT be auto-pushed
19
+ --fix-push: Auto-push unpushed commits in repos where safe
20
+ """
21
+
22
+ import json
23
+ import subprocess
24
+ import sys
25
+ from pathlib import Path
26
+ from datetime import datetime
27
+ import time
28
+
29
+
30
+ class Finding:
31
+ """A single finding with repo + message."""
32
+ def __init__(self, repo, msg):
33
+ self.repo = repo
34
+ self.msg = msg
35
+
36
+ def __str__(self):
37
+ return f"{self.repo.name}: {self.msg}"
38
+
39
+
40
+ def get_git_status(repo_path):
41
+ """Return (is_clean, dirty_files_list) for a repo."""
42
+ try:
43
+ output = subprocess.run(
44
+ ['git', '-C', str(repo_path), 'status', '--porcelain'],
45
+ capture_output=True, text=True, timeout=5
46
+ ).stdout.strip()
47
+
48
+ if not output:
49
+ return (True, [])
50
+ else:
51
+ dirty = [line for line in output.split('\n') if line]
52
+ return (False, dirty)
53
+ except Exception as e:
54
+ return (None, str(e))
55
+
56
+
57
+ def get_ahead_count(repo_path):
58
+ """Return count of commits ahead of origin/HEAD (or None on error)."""
59
+ try:
60
+ # 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'],
70
+ capture_output=True, text=True, timeout=5
71
+ ).stdout.strip()
72
+
73
+ try:
74
+ return int(output) if output else 0
75
+ except:
76
+ return None
77
+ except Exception:
78
+ return None
79
+
80
+
81
+ def check_untracked_files(repo_path):
82
+ """Return list of untracked files not in .gitignore."""
83
+ try:
84
+ output = subprocess.run(
85
+ ['git', '-C', str(repo_path), 'ls-files', '--others', '--exclude-standard'],
86
+ capture_output=True, text=True, timeout=5
87
+ ).stdout.strip()
88
+
89
+ if output:
90
+ return output.split('\n')
91
+ return []
92
+ except Exception:
93
+ return None
94
+
95
+
96
+ def check_repo(repo_path):
97
+ """Check a single repo; return list of Finding objects or None if repo doesn't exist."""
98
+ if not repo_path.exists():
99
+ return None
100
+
101
+ if not (repo_path / '.git').exists():
102
+ return None
103
+
104
+ findings = []
105
+
106
+ # Check 1: Working tree clean
107
+ is_clean, dirty = get_git_status(repo_path)
108
+ if is_clean is None:
109
+ findings.append(Finding(repo_path, f"git status check failed: {dirty}"))
110
+ elif not is_clean:
111
+ findings.append(Finding(repo_path, f"dirty working tree: {len(dirty)} files"))
112
+
113
+ # Check 2: Branch pushed
114
+ ahead = get_ahead_count(repo_path)
115
+ if ahead is None:
116
+ findings.append(Finding(repo_path, "ahead-count check failed"))
117
+ elif ahead > 0:
118
+ findings.append(Finding(repo_path, f"ahead of origin: {ahead} commits unpushed"))
119
+
120
+ # Check 3: Untracked files
121
+ untracked = check_untracked_files(repo_path)
122
+ if untracked is None:
123
+ findings.append(Finding(repo_path, "untracked file check failed"))
124
+ elif untracked:
125
+ findings.append(Finding(repo_path, f"untracked files: {len(untracked)} items"))
126
+
127
+ return findings
128
+
129
+
130
+ def push_repo(repo_path):
131
+ """Push commits for a repo (return True if successful)."""
132
+ try:
133
+ result = subprocess.run(
134
+ ['git', '-C', str(repo_path), 'push'],
135
+ capture_output=True, text=True, timeout=30
136
+ )
137
+ return result.returncode == 0
138
+ except Exception:
139
+ return False
140
+
141
+
142
+ def run_secret_scan(repo_path):
143
+ """Run secret_scan.py on staged files (return True if no secrets found)."""
144
+ try:
145
+ script_path = Path(__file__).parent / 'secret_scan.py'
146
+ result = subprocess.run(
147
+ [sys.executable, str(script_path), '--staged'],
148
+ cwd=str(repo_path),
149
+ capture_output=True, text=True, timeout=30
150
+ )
151
+ return result.returncode == 0
152
+ except Exception:
153
+ return False
154
+
155
+
156
+ def main():
157
+ import argparse
158
+ parser = argparse.ArgumentParser(description=__doc__)
159
+ parser.add_argument(
160
+ '--repos',
161
+ default='',
162
+ help='Colon-separated paths to scan (default: empty)'
163
+ )
164
+ parser.add_argument(
165
+ '--readonly-repos',
166
+ default='',
167
+ help='Colon-separated paths that should NOT be auto-pushed'
168
+ )
169
+ parser.add_argument(
170
+ '--fix-push',
171
+ action='store_true',
172
+ help='Auto-push unpushed commits'
173
+ )
174
+ args = parser.parse_args()
175
+
176
+ # Parse repos
177
+ repos_to_check = []
178
+ if args.repos:
179
+ repos_to_check = [Path(p) for p in args.repos.split(':') if p]
180
+
181
+ # Parse readonly repos
182
+ readonly_repos = set()
183
+ if args.readonly_repos:
184
+ readonly_repos = {Path(p) for p in args.readonly_repos.split(':') if p}
185
+
186
+ findings = []
187
+
188
+ # Scan all repos
189
+ for repo_path in repos_to_check:
190
+ repo_findings = check_repo(repo_path)
191
+ if repo_findings is not None:
192
+ findings.extend(repo_findings)
193
+
194
+ # Determine verdict
195
+ if not findings:
196
+ verdict = "SAFE"
197
+ verdict_line = "EOD-SWEEP: SAFE"
198
+ exit_code = 0
199
+ else:
200
+ verdict = f"AT-RISK — {len(findings)} findings"
201
+ verdict_line = f"EOD-SWEEP: AT-RISK — {len(findings)} findings"
202
+ exit_code = 1
203
+
204
+ # Handle --fix-push if requested and conditions are met
205
+ if args.fix_push and findings:
206
+ # Filter for ahead-only findings that we can push
207
+ ahead_findings = [f for f in findings if 'unpushed' in f.msg and f.repo not in readonly_repos]
208
+ if ahead_findings:
209
+ for finding in ahead_findings:
210
+ repo_path = finding.repo
211
+ if repo_path and repo_path not in readonly_repos:
212
+ # Run secret scan first
213
+ if run_secret_scan(repo_path):
214
+ if push_repo(repo_path):
215
+ print(f"Pushed: {repo_path.name}")
216
+ findings.remove(finding)
217
+ else:
218
+ print(f"Push failed: {repo_path.name}")
219
+ else:
220
+ print(f"Secret scan blocked: {repo_path.name}")
221
+
222
+ # Re-evaluate verdict
223
+ if not findings:
224
+ verdict = "SAFE"
225
+ verdict_line = "EOD-SWEEP: SAFE"
226
+ exit_code = 0
227
+ else:
228
+ verdict_line = f"EOD-SWEEP: AT-RISK — {len(findings)} findings"
229
+
230
+ # Print output
231
+ print(verdict_line)
232
+ for finding in findings:
233
+ print(f" {finding}")
234
+
235
+ sys.exit(exit_code)
236
+
237
+
238
+ if __name__ == '__main__':
239
+ main()
@@ -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,131 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Heartbeat registry for fleet-wide liveness monitoring.
4
+
5
+ Usage:
6
+ heartbeat.py beat <name> [<status>] [--state-dir DIR] [--brain]
7
+ heartbeat.py check [--max-age 300] [--state-dir DIR]
8
+
9
+ Modes:
10
+ beat: Write epoch + optional status word to .heartbeats/<name>
11
+ --state-dir: Heartbeats directory (default: AESOP_STATE_ROOT/heartbeats or ./state/heartbeats)
12
+ --brain: Write to ~/.claude/.heartbeats/ (overrides --state-dir)
13
+ check: List all .heartbeats/* files and report "<name> ALIVE|STALE age:<n>s"
14
+ Exit 0 if all alive, 1 if any stale. Always prints output.
15
+ """
16
+
17
+ import sys
18
+ import os
19
+ import time
20
+ from pathlib import Path
21
+ import argparse
22
+
23
+
24
+ def get_state_dir():
25
+ """Resolve state directory from env var or current working directory."""
26
+ if os.environ.get("AESOP_STATE_ROOT"):
27
+ return Path(os.environ["AESOP_STATE_ROOT"])
28
+ # Default to ./state (relative to cwd)
29
+ return Path.cwd() / "state"
30
+
31
+
32
+ def beat(name, status=None, state_dir=None, brain=False):
33
+ """Write epoch (+ optional status) to .heartbeats/<name>."""
34
+ if brain:
35
+ target_dir = Path.home() / ".claude" / ".heartbeats"
36
+ location = "brain"
37
+ else:
38
+ if state_dir is None:
39
+ state_dir = get_state_dir()
40
+ target_dir = state_dir / "heartbeats"
41
+ location = "state"
42
+
43
+ target_dir.mkdir(parents=True, exist_ok=True)
44
+ hb_file = target_dir / name
45
+ now_epoch = str(int(time.time()))
46
+ content = now_epoch + "\n"
47
+ if status:
48
+ content += status + "\n"
49
+ hb_file.write_text(content)
50
+ print(f"beat: {name} written to {location}")
51
+
52
+
53
+ def check(max_age=300, state_dir=None):
54
+ """Check all heartbeat files and report ALIVE/STALE."""
55
+ now = time.time()
56
+ all_alive = True
57
+ results = []
58
+
59
+ if state_dir is None:
60
+ state_dir = get_state_dir()
61
+
62
+ # Collect state dir registry files
63
+ state_hb_dir = state_dir / "heartbeats"
64
+ if state_hb_dir.exists():
65
+ for hb_file in sorted(state_hb_dir.iterdir()):
66
+ if hb_file.is_file():
67
+ age = _get_age(hb_file, now)
68
+ status = "ALIVE" if age <= max_age else "STALE"
69
+ if status == "STALE":
70
+ all_alive = False
71
+ results.append((f"state/{hb_file.name}", status, age))
72
+
73
+ # Collect brain registry files
74
+ brain_hb_dir = Path.home() / ".claude" / ".heartbeats"
75
+ if brain_hb_dir.exists():
76
+ for hb_file in sorted(brain_hb_dir.iterdir()):
77
+ if hb_file.is_file():
78
+ age = _get_age(hb_file, now)
79
+ status = "ALIVE" if age <= max_age else "STALE"
80
+ if status == "STALE":
81
+ all_alive = False
82
+ results.append((f"brain/{hb_file.name}", status, age))
83
+
84
+ # Print results
85
+ for name, status, age in results:
86
+ print(f"{name} {status} age:{age}s")
87
+
88
+ sys.exit(0 if all_alive else 1)
89
+
90
+
91
+ def _get_age(hb_file, now):
92
+ """Extract epoch from first line and return age in seconds."""
93
+ try:
94
+ content = hb_file.read_text().strip().split('\n')
95
+ epoch_str = content[0].strip()
96
+ epoch = int(epoch_str)
97
+ age = int(now - epoch)
98
+ return max(0, age)
99
+ except (ValueError, IndexError):
100
+ return 99999
101
+
102
+
103
+ def main():
104
+ parser = argparse.ArgumentParser(description=__doc__)
105
+ subparsers = parser.add_subparsers(dest="cmd")
106
+
107
+ beat_parser = subparsers.add_parser("beat", help="Write heartbeat")
108
+ beat_parser.add_argument("name", help="Heartbeat name")
109
+ beat_parser.add_argument("status", nargs="?", default=None, help="Optional status word")
110
+ beat_parser.add_argument("--state-dir", default=None, help="State directory (default: AESOP_STATE_ROOT or ./state)")
111
+ beat_parser.add_argument("--brain", action="store_true", help="Write to brain .heartbeats/ (overrides --state-dir)")
112
+
113
+ check_parser = subparsers.add_parser("check", help="Check heartbeat status")
114
+ check_parser.add_argument("--max-age", type=int, default=300, help="Max age in seconds (default 300)")
115
+ check_parser.add_argument("--state-dir", default=None, help="State directory (default: AESOP_STATE_ROOT or ./state)")
116
+
117
+ args = parser.parse_args()
118
+
119
+ if args.cmd == "beat":
120
+ state_dir = Path(args.state_dir) if args.state_dir else None
121
+ beat(args.name, args.status, state_dir=state_dir, brain=args.brain)
122
+ elif args.cmd == "check":
123
+ state_dir = Path(args.state_dir) if args.state_dir else None
124
+ check(args.max_age, state_dir=state_dir)
125
+ else:
126
+ parser.print_help()
127
+ sys.exit(1)
128
+
129
+
130
+ if __name__ == "__main__":
131
+ main()