@matt82198/aesop 0.1.0-beta.2 → 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.
- package/CHANGELOG.md +37 -94
- package/CLAUDE-TEMPLATE.md +115 -0
- package/README.md +89 -264
- package/aesop.config.example.json +27 -26
- package/bin/CLAUDE.md +39 -0
- package/bin/cli.js +368 -24
- package/daemons/CLAUDE.md +33 -0
- package/daemons/backup-fleet.sh +92 -21
- package/daemons/run-watchdog.sh +90 -10
- package/dash/CLAUDE.md +32 -0
- package/dash/dash-extra.mjs +194 -21
- package/dash/watchdog-gui.sh +23 -6
- package/docs/BEHAVIORAL-PR-REVIEW.md +86 -0
- package/docs/CHECKPOINTING.md +100 -0
- package/docs/DISPATCH-MODEL.md +11 -7
- package/docs/FORENSICS.md +204 -0
- package/docs/GOVERNANCE.md +4 -2
- package/docs/HOOK-INSTALL.md +307 -0
- package/docs/HOW-THE-LOOP-WORKS.md +175 -0
- package/docs/MEMORY-TEMPLATE.md +61 -0
- package/docs/README.md +44 -0
- package/docs/RELIABILITY.md +61 -0
- package/docs/RESTORE.md +397 -0
- package/docs/SCRIPTS-POLICY.md +97 -0
- package/docs/spikes/tiered-cognition/ACTIVATION.md +125 -0
- package/docs/spikes/tiered-cognition/DESIGN.md +287 -0
- package/docs/spikes/tiered-cognition/FINDINGS.md +113 -0
- package/docs/spikes/tiered-cognition/README.md +29 -0
- package/docs/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
- package/docs/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
- package/docs/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
- package/hooks/CLAUDE.md +38 -0
- package/hooks/claude/force-model-policy.mjs +159 -0
- package/hooks/pre-push-policy.sh +753 -0
- package/monitor/.signal-state.json +3 -0
- package/monitor/ACTIONS.log +1 -0
- package/monitor/BRIEF.md +24 -0
- package/monitor/CHARTER.md +29 -8
- package/monitor/CLAUDE.md +40 -0
- package/monitor/SIGNALS.json +54 -0
- package/monitor/collect-signals.mjs +529 -62
- package/package.json +12 -3
- package/skills/CLAUDE.md +3 -0
- package/skills/power/SKILL.md +161 -0
- package/tools/CLAUDE.md +191 -0
- package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
- package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
- package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
- package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
- package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
- package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
- package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
- package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/agent-forensics.sh +186 -0
- package/tools/buildlog.py +182 -0
- package/tools/ci_merge_wait.py +204 -0
- package/tools/ensure_state.py +94 -0
- package/tools/eod_sweep.py +239 -0
- package/tools/fleet_ledger.py +272 -0
- package/tools/heartbeat.py +131 -0
- package/tools/inbox_drain.py +249 -0
- package/tools/launch_tui.py +51 -3
- package/tools/lock.mjs +211 -0
- package/tools/metrics_gate.py +205 -0
- package/tools/orchestrator_status.py +99 -0
- package/tools/power_selftest.py +386 -0
- package/tools/prepublish_scan.py +84 -0
- package/tools/proposals.mjs +248 -0
- package/tools/reconstitute.sh +467 -0
- package/tools/rotate_logs.py +228 -0
- package/tools/scanner_selftest.py +303 -0
- package/tools/secret_scan.py +131 -31
- package/tools/stall_check.py +172 -0
- package/tools/verify_dash.py +694 -0
- package/tools/verify_submit_encoding.py +194 -0
- package/ui/CLAUDE.md +109 -0
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +179 -0
- package/ui/api/__init__.py +84 -0
- package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
- package/ui/api/submit.py +58 -0
- package/ui/api/tracker.py +116 -0
- package/ui/collectors.py +586 -0
- package/ui/config.py +114 -0
- package/ui/csrf.py +140 -0
- package/ui/handler.py +426 -0
- package/ui/render.py +29 -0
- package/ui/serve.py +68 -796
- package/ui/sse.py +168 -0
- 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()
|