@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.
- 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,205 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
NO-UNVERIFIED-METRICS gate: scan git diff for hard numeric claims in *.md files
|
|
4
|
+
that lack source verification.
|
|
5
|
+
|
|
6
|
+
A hard numeric claim is:
|
|
7
|
+
- Percentages (e.g., "42%")
|
|
8
|
+
- Multipliers (e.g., "3x", "3×")
|
|
9
|
+
- Dollar amounts (e.g., "$15000")
|
|
10
|
+
|
|
11
|
+
Exclusions (don't require verification):
|
|
12
|
+
- Version numbers (e.g., "v1.2.3", "2.0")
|
|
13
|
+
- Dates (e.g., "2024", "2025")
|
|
14
|
+
- Line numbers (e.g., "line 42", "line:123")
|
|
15
|
+
- Numbers in code/JSON contexts
|
|
16
|
+
|
|
17
|
+
A claim passes verification if:
|
|
18
|
+
- The same line or an adjacent line has: <!-- metrics-verified: <source> -->
|
|
19
|
+
- OR the claim itself looks like a version (e.g., "v1.2.3")
|
|
20
|
+
- OR the claim is a year/date (2000-2999)
|
|
21
|
+
- OR the claim is a line reference (line X, :X, @X patterns)
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
metrics_gate.py [origin/main...HEAD]
|
|
25
|
+
Scans ADDED lines in diff from origin/main...HEAD (default).
|
|
26
|
+
Exits 0 if all metrics are verified or excluded.
|
|
27
|
+
Exits 1 if unverified hard claims found (lists them).
|
|
28
|
+
|
|
29
|
+
Used in CI as a PR gate:
|
|
30
|
+
metrics_gate.py origin/main...HEAD
|
|
31
|
+
"""
|
|
32
|
+
|
|
33
|
+
import sys
|
|
34
|
+
import subprocess
|
|
35
|
+
import re
|
|
36
|
+
from pathlib import Path
|
|
37
|
+
from typing import List, Tuple, Optional
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class MetricsGate:
|
|
41
|
+
"""Scan git diffs for unverified hard numeric claims."""
|
|
42
|
+
|
|
43
|
+
# Regex patterns for hard numeric claims
|
|
44
|
+
PERCENTAGE_PATTERN = re.compile(r'\d+\s*%')
|
|
45
|
+
MULTIPLIER_PATTERN = re.compile(r'\d+\s*[x×]\s*(?:faster|slower|more|less|bigger|smaller|better|worse|improvement|increase|decrease|growth)')
|
|
46
|
+
DOLLAR_PATTERN = re.compile(r'\$\s*\d+')
|
|
47
|
+
|
|
48
|
+
# Exclusion patterns
|
|
49
|
+
VERSION_PATTERN = re.compile(r'\bv?\d+\.\d+(?:\.\d+)?(?:[.-](?:alpha|beta|rc|a|b)\d*)?\b')
|
|
50
|
+
DATE_PATTERN = re.compile(r'\b(19|20)\d{2}\b')
|
|
51
|
+
LINE_REFERENCE_PATTERN = re.compile(r'(?:line\s*|:|@)\s*\d+')
|
|
52
|
+
|
|
53
|
+
# Verification marker pattern
|
|
54
|
+
VERIFICATION_PATTERN = re.compile(r'<!--\s*metrics-verified:\s*[^-]+\s*-->')
|
|
55
|
+
|
|
56
|
+
def __init__(self, diff_range: str = "origin/main...HEAD"):
|
|
57
|
+
"""Initialize with a git diff range."""
|
|
58
|
+
self.diff_range = diff_range
|
|
59
|
+
self.repo_root = Path(__file__).resolve().parent.parent
|
|
60
|
+
|
|
61
|
+
def get_diff_lines(self) -> List[Tuple[str, str]]:
|
|
62
|
+
"""Get lines added in diff. Returns list of (file, line_content)."""
|
|
63
|
+
try:
|
|
64
|
+
# Use git diff to get added lines
|
|
65
|
+
result = subprocess.run(
|
|
66
|
+
["git", "diff", self.diff_range, "--unified=0"],
|
|
67
|
+
cwd=self.repo_root,
|
|
68
|
+
capture_output=True,
|
|
69
|
+
text=True,
|
|
70
|
+
check=False,
|
|
71
|
+
)
|
|
72
|
+
if result.returncode not in (0, 128): # 128 = invalid range
|
|
73
|
+
# Fall back to empty if range is invalid
|
|
74
|
+
return []
|
|
75
|
+
|
|
76
|
+
diff_output = result.stdout
|
|
77
|
+
added_lines = []
|
|
78
|
+
current_file = None
|
|
79
|
+
|
|
80
|
+
for line in diff_output.split('\n'):
|
|
81
|
+
# Track current file
|
|
82
|
+
if line.startswith('+++'):
|
|
83
|
+
current_file = line[6:] # Strip "+++ b/"
|
|
84
|
+
if current_file.startswith('b/'):
|
|
85
|
+
current_file = current_file[2:]
|
|
86
|
+
# Capture added lines (start with '+' but not '+++')
|
|
87
|
+
elif line.startswith('+') and not line.startswith('+++'):
|
|
88
|
+
content = line[1:] # Strip leading '+'
|
|
89
|
+
if current_file and current_file.endswith('.md'):
|
|
90
|
+
added_lines.append((current_file, content))
|
|
91
|
+
|
|
92
|
+
return added_lines
|
|
93
|
+
except Exception:
|
|
94
|
+
return []
|
|
95
|
+
|
|
96
|
+
def is_version_number(self, text: str) -> bool:
|
|
97
|
+
"""Check if text looks like a version number."""
|
|
98
|
+
return bool(self.VERSION_PATTERN.search(text))
|
|
99
|
+
|
|
100
|
+
def is_date(self, text: str) -> bool:
|
|
101
|
+
"""Check if text looks like a date (year)."""
|
|
102
|
+
return bool(self.DATE_PATTERN.search(text))
|
|
103
|
+
|
|
104
|
+
def is_line_reference(self, text: str) -> bool:
|
|
105
|
+
"""Check if text looks like a line reference."""
|
|
106
|
+
return bool(self.LINE_REFERENCE_PATTERN.search(text))
|
|
107
|
+
|
|
108
|
+
def is_excluded(self, text: str) -> bool:
|
|
109
|
+
"""Check if a claim is excluded from verification."""
|
|
110
|
+
return (
|
|
111
|
+
self.is_version_number(text)
|
|
112
|
+
or self.is_date(text)
|
|
113
|
+
or self.is_line_reference(text)
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
def find_hard_claims(self, text: str) -> List[str]:
|
|
117
|
+
"""Find hard numeric claims in text."""
|
|
118
|
+
claims = []
|
|
119
|
+
# Find percentages
|
|
120
|
+
claims.extend(self.PERCENTAGE_PATTERN.findall(text))
|
|
121
|
+
# Find multipliers
|
|
122
|
+
claims.extend(self.MULTIPLIER_PATTERN.findall(text))
|
|
123
|
+
# Find dollar amounts
|
|
124
|
+
claims.extend(self.DOLLAR_PATTERN.findall(text))
|
|
125
|
+
return claims
|
|
126
|
+
|
|
127
|
+
def is_verified(self, lines: List[Tuple[str, int, str]]) -> bool:
|
|
128
|
+
"""Check if a claim line has verification marker nearby.
|
|
129
|
+
|
|
130
|
+
lines: list of (file, line_num, content) for this and adjacent lines
|
|
131
|
+
"""
|
|
132
|
+
for _, _, content in lines:
|
|
133
|
+
if self.VERIFICATION_PATTERN.search(content):
|
|
134
|
+
return True
|
|
135
|
+
return False
|
|
136
|
+
|
|
137
|
+
def check(self) -> Tuple[int, List[str]]:
|
|
138
|
+
"""Check for unverified metrics. Returns (exit_code, list of failures)."""
|
|
139
|
+
diff_lines = self.get_diff_lines()
|
|
140
|
+
failures = []
|
|
141
|
+
|
|
142
|
+
# Group lines by file for context
|
|
143
|
+
lines_by_file = {}
|
|
144
|
+
for file, content in diff_lines:
|
|
145
|
+
if file not in lines_by_file:
|
|
146
|
+
lines_by_file[file] = []
|
|
147
|
+
lines_by_file[file].append(content)
|
|
148
|
+
|
|
149
|
+
# Check each added line
|
|
150
|
+
for file, contents in lines_by_file.items():
|
|
151
|
+
for i, content in enumerate(contents):
|
|
152
|
+
claims = self.find_hard_claims(content)
|
|
153
|
+
for claim in claims:
|
|
154
|
+
# Skip if it's excluded (version, date, line ref)
|
|
155
|
+
if self.is_excluded(claim):
|
|
156
|
+
continue
|
|
157
|
+
|
|
158
|
+
# Check if verified (current line or adjacent)
|
|
159
|
+
context = []
|
|
160
|
+
if i > 0:
|
|
161
|
+
context.append((file, i - 1, contents[i - 1]))
|
|
162
|
+
context.append((file, i, content))
|
|
163
|
+
if i + 1 < len(contents):
|
|
164
|
+
context.append((file, i + 1, contents[i + 1]))
|
|
165
|
+
|
|
166
|
+
if not self.is_verified(context):
|
|
167
|
+
failures.append(
|
|
168
|
+
f"{file}: unverified metric '{claim}' — add "
|
|
169
|
+
f"<!-- metrics-verified: <source> --> on same or adjacent line"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
exit_code = 1 if failures else 0
|
|
173
|
+
return exit_code, failures
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def main():
|
|
177
|
+
import argparse
|
|
178
|
+
|
|
179
|
+
parser = argparse.ArgumentParser(
|
|
180
|
+
description=__doc__,
|
|
181
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
182
|
+
)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"diff_range",
|
|
185
|
+
nargs="?",
|
|
186
|
+
default="origin/main...HEAD",
|
|
187
|
+
help="Git diff range to scan (default: origin/main...HEAD)",
|
|
188
|
+
)
|
|
189
|
+
args = parser.parse_args()
|
|
190
|
+
|
|
191
|
+
gate = MetricsGate(args.diff_range)
|
|
192
|
+
exit_code, failures = gate.check()
|
|
193
|
+
|
|
194
|
+
if failures:
|
|
195
|
+
print("NO-UNVERIFIED-METRICS GATE FAILED:", file=sys.stderr)
|
|
196
|
+
for failure in failures:
|
|
197
|
+
print(f" - {failure}", file=sys.stderr)
|
|
198
|
+
sys.exit(exit_code)
|
|
199
|
+
else:
|
|
200
|
+
print("NO-UNVERIFIED-METRICS: OK")
|
|
201
|
+
sys.exit(0)
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
if __name__ == "__main__":
|
|
205
|
+
main()
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Orchestrator status CLI tool - atomic status updates for the orchestration layer.
|
|
3
|
+
|
|
4
|
+
Usage:
|
|
5
|
+
python orchestrator_status.py set --activity "dispatching wave-8" --phase audit [--id main --role orchestrator]
|
|
6
|
+
python orchestrator_status.py clear
|
|
7
|
+
|
|
8
|
+
Writes state/orchestrator-status.json atomically (temp+replace) for wave-8/wave-9+ compatibility.
|
|
9
|
+
No external dependencies.
|
|
10
|
+
"""
|
|
11
|
+
import argparse
|
|
12
|
+
import json
|
|
13
|
+
import os
|
|
14
|
+
import sys
|
|
15
|
+
from datetime import datetime, timezone
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def get_state_dir():
|
|
20
|
+
"""Get state directory from env or fallback to AESOP_ROOT/state."""
|
|
21
|
+
state_root = os.getenv("AESOP_STATE_ROOT")
|
|
22
|
+
if state_root:
|
|
23
|
+
return Path(state_root)
|
|
24
|
+
|
|
25
|
+
aesop_root = os.getenv("AESOP_ROOT", str(Path.home() / "aesop"))
|
|
26
|
+
return Path(aesop_root) / "state"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def set_status(activity, phase, id=None, role=None):
|
|
30
|
+
"""Atomically write orchestrator status."""
|
|
31
|
+
state_dir = get_state_dir()
|
|
32
|
+
state_dir.mkdir(parents=True, exist_ok=True)
|
|
33
|
+
|
|
34
|
+
status_data = {
|
|
35
|
+
"id": id or "main",
|
|
36
|
+
"role": role or "orchestrator",
|
|
37
|
+
"parent_id": None,
|
|
38
|
+
"activity": activity,
|
|
39
|
+
"phase": phase,
|
|
40
|
+
"updated_at": datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
status_file = state_dir / "orchestrator-status.json"
|
|
44
|
+
temp_file = status_file.with_suffix(".json.tmp")
|
|
45
|
+
|
|
46
|
+
try:
|
|
47
|
+
with open(temp_file, "w", encoding="utf-8") as f:
|
|
48
|
+
json.dump(status_data, f, indent=2)
|
|
49
|
+
os.replace(str(temp_file), str(status_file))
|
|
50
|
+
print(f"[OK] Status updated: activity={activity}, phase={phase}")
|
|
51
|
+
except Exception as e:
|
|
52
|
+
print(f"[ERROR] Failed to write status: {e}", file=sys.stderr)
|
|
53
|
+
try:
|
|
54
|
+
temp_file.unlink()
|
|
55
|
+
except:
|
|
56
|
+
pass
|
|
57
|
+
sys.exit(1)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def clear_status():
|
|
61
|
+
"""Atomically remove orchestrator status file."""
|
|
62
|
+
state_dir = get_state_dir()
|
|
63
|
+
status_file = state_dir / "orchestrator-status.json"
|
|
64
|
+
|
|
65
|
+
try:
|
|
66
|
+
if status_file.exists():
|
|
67
|
+
status_file.unlink()
|
|
68
|
+
print("[OK] Status cleared")
|
|
69
|
+
except Exception as e:
|
|
70
|
+
print(f"[ERROR] Failed to clear status: {e}", file=sys.stderr)
|
|
71
|
+
sys.exit(1)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def main():
|
|
75
|
+
parser = argparse.ArgumentParser(
|
|
76
|
+
description="Atomic orchestrator status updates"
|
|
77
|
+
)
|
|
78
|
+
subparsers = parser.add_subparsers(dest="command", required=True)
|
|
79
|
+
|
|
80
|
+
# set command
|
|
81
|
+
set_parser = subparsers.add_parser("set", help="Set orchestrator status")
|
|
82
|
+
set_parser.add_argument("--activity", required=True, help="Activity description")
|
|
83
|
+
set_parser.add_argument("--phase", required=True, help="Phase (plan, dispatch, audit, etc)")
|
|
84
|
+
set_parser.add_argument("--id", default="main", help="Orchestrator ID (default: main)")
|
|
85
|
+
set_parser.add_argument("--role", default="orchestrator", help="Orchestrator role (default: orchestrator)")
|
|
86
|
+
|
|
87
|
+
# clear command
|
|
88
|
+
subparsers.add_parser("clear", help="Clear orchestrator status")
|
|
89
|
+
|
|
90
|
+
args = parser.parse_args()
|
|
91
|
+
|
|
92
|
+
if args.command == "set":
|
|
93
|
+
set_status(args.activity, args.phase, args.id, args.role)
|
|
94
|
+
elif args.command == "clear":
|
|
95
|
+
clear_status()
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
if __name__ == "__main__":
|
|
99
|
+
main()
|
|
@@ -0,0 +1,386 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
power_selftest.py — Health check harness for /power bootstrap.
|
|
4
|
+
Validates hooks, brain state, heartbeats, decisions, and scanner regression.
|
|
5
|
+
Exit 0 if OK/DEGRADED, 1 if FAIL. Prints one summary line + bullets for non-OK items.
|
|
6
|
+
|
|
7
|
+
EXPECTED OUTPUT — HEALTHY SYSTEM:
|
|
8
|
+
POWER-SELFTEST: OK — hooks:ok brain:ok beats:ok decisions:0 pending,0 inbox scanner:n/a
|
|
9
|
+
|
|
10
|
+
EXPECTED OUTPUT — UNHEALTHY SYSTEM:
|
|
11
|
+
POWER-SELFTEST: DEGRADED — hooks:ok brain:ok beats:stale decisions:2 pending scanner:8/9
|
|
12
|
+
- beats: watchdog:stale
|
|
13
|
+
- scanner: 8/9 tests passed
|
|
14
|
+
|
|
15
|
+
Exit codes: 0=OK/DEGRADED, 1=FAIL (FAIL is any hook/brain/scanner non-OK; stale beats=WARN not FAIL)
|
|
16
|
+
|
|
17
|
+
Configuration:
|
|
18
|
+
- Reads aesop.config.json for brain_root, state_root, scripts_root overrides.
|
|
19
|
+
- Env vars override config file: BRAIN_ROOT, AESOP_STATE_ROOT, SCRIPTS_ROOT.
|
|
20
|
+
- Falls back to defaults: brain_root=~/.claude, state_root=<aesop-root>/state.
|
|
21
|
+
- Gracefully degrades when targets don't exist (reports n/a instead of crashing).
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
import json
|
|
25
|
+
import subprocess
|
|
26
|
+
import sys
|
|
27
|
+
import io
|
|
28
|
+
from pathlib import Path
|
|
29
|
+
from datetime import datetime
|
|
30
|
+
from collections import namedtuple
|
|
31
|
+
|
|
32
|
+
# Force UTF-8 encoding on stdout to prevent UnicodeEncodeError on Windows
|
|
33
|
+
if sys.stdout.encoding != 'utf-8':
|
|
34
|
+
sys.stdout = io.TextIOWrapper(sys.stdout.buffer, encoding='utf-8')
|
|
35
|
+
|
|
36
|
+
# Named tuples for result tracking
|
|
37
|
+
Check = namedtuple('Check', ['name', 'status', 'details', 'is_fail'])
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def load_config():
|
|
41
|
+
"""Load aesop.config.json if present; return dict."""
|
|
42
|
+
try:
|
|
43
|
+
config_path = Path.cwd() / 'aesop.config.json'
|
|
44
|
+
if config_path.exists():
|
|
45
|
+
with open(config_path) as f:
|
|
46
|
+
return json.load(f)
|
|
47
|
+
except Exception:
|
|
48
|
+
pass
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def resolve_paths(config):
|
|
53
|
+
"""Resolve paths with precedence: env var > config > default."""
|
|
54
|
+
aesop_root = Path.cwd()
|
|
55
|
+
|
|
56
|
+
# brain_root: env BRAIN_ROOT > config brain_root > default ~/.claude
|
|
57
|
+
brain_root = Path(
|
|
58
|
+
os.environ.get('BRAIN_ROOT', config.get('brain_root', ''))
|
|
59
|
+
or str(Path.home() / '.claude')
|
|
60
|
+
).expanduser()
|
|
61
|
+
|
|
62
|
+
# state_root: env AESOP_STATE_ROOT > config state_root > default <aesop-root>/state
|
|
63
|
+
state_root = Path(
|
|
64
|
+
os.environ.get('AESOP_STATE_ROOT', config.get('state_root', ''))
|
|
65
|
+
or str(aesop_root / 'state')
|
|
66
|
+
).expanduser()
|
|
67
|
+
|
|
68
|
+
# scripts_root: env SCRIPTS_ROOT > config scripts_root > default <aesop-root>/tools
|
|
69
|
+
scripts_root = Path(
|
|
70
|
+
os.environ.get('SCRIPTS_ROOT', config.get('scripts_root', ''))
|
|
71
|
+
or str(aesop_root / 'tools')
|
|
72
|
+
).expanduser()
|
|
73
|
+
|
|
74
|
+
return {
|
|
75
|
+
'aesop_root': aesop_root,
|
|
76
|
+
'brain_root': brain_root,
|
|
77
|
+
'state_root': state_root,
|
|
78
|
+
'scripts_root': scripts_root,
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
import os
|
|
83
|
+
config = load_config()
|
|
84
|
+
paths = resolve_paths(config)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def check_hooks():
|
|
88
|
+
"""Check hooks configuration. Returns Check."""
|
|
89
|
+
try:
|
|
90
|
+
settings_path = paths['brain_root'] / 'settings.json'
|
|
91
|
+
if not settings_path.exists():
|
|
92
|
+
return Check('hooks', 'OK', '(n/a)', False)
|
|
93
|
+
|
|
94
|
+
with open(settings_path) as f:
|
|
95
|
+
settings = json.load(f)
|
|
96
|
+
|
|
97
|
+
hooks = settings.get('hooks', {})
|
|
98
|
+
pre_tool_entries = hooks.get('PreToolUse', [])
|
|
99
|
+
post_tool_entries = hooks.get('PostToolUse', [])
|
|
100
|
+
|
|
101
|
+
# Check for required matchers in PreToolUse and PostToolUse arrays
|
|
102
|
+
pre_matchers = set()
|
|
103
|
+
post_matchers = set()
|
|
104
|
+
all_commands = []
|
|
105
|
+
|
|
106
|
+
for entry in pre_tool_entries if isinstance(pre_tool_entries, list) else []:
|
|
107
|
+
if isinstance(entry, dict):
|
|
108
|
+
matcher = entry.get('matcher', '')
|
|
109
|
+
if matcher:
|
|
110
|
+
pre_matchers.update(matcher.split('|'))
|
|
111
|
+
hook_list = entry.get('hooks', [])
|
|
112
|
+
for hook in hook_list:
|
|
113
|
+
if isinstance(hook, dict):
|
|
114
|
+
cmd = hook.get('command', '')
|
|
115
|
+
if cmd:
|
|
116
|
+
all_commands.append(cmd)
|
|
117
|
+
|
|
118
|
+
for entry in post_tool_entries if isinstance(post_tool_entries, list) else []:
|
|
119
|
+
if isinstance(entry, dict):
|
|
120
|
+
matcher = entry.get('matcher', '')
|
|
121
|
+
if matcher:
|
|
122
|
+
post_matchers.update(matcher.split('|'))
|
|
123
|
+
hook_list = entry.get('hooks', [])
|
|
124
|
+
for hook in hook_list:
|
|
125
|
+
if isinstance(hook, dict):
|
|
126
|
+
cmd = hook.get('command', '')
|
|
127
|
+
if cmd:
|
|
128
|
+
all_commands.append(cmd)
|
|
129
|
+
|
|
130
|
+
# Check required matchers
|
|
131
|
+
required_pre = {'Agent', 'Task'}
|
|
132
|
+
required_post = {'Write', 'Edit', 'NotebookEdit'}
|
|
133
|
+
|
|
134
|
+
pre_ok = required_pre.issubset(pre_matchers)
|
|
135
|
+
post_ok = required_post.issubset(post_matchers)
|
|
136
|
+
|
|
137
|
+
missing_files = []
|
|
138
|
+
for cmd in all_commands:
|
|
139
|
+
parts = cmd.split()
|
|
140
|
+
if parts:
|
|
141
|
+
for part in parts:
|
|
142
|
+
if part.endswith(('.mjs', '.js', '.py', '.sh')):
|
|
143
|
+
if not Path(part).exists():
|
|
144
|
+
missing_files.append(part)
|
|
145
|
+
break
|
|
146
|
+
|
|
147
|
+
if not (pre_ok and post_ok):
|
|
148
|
+
missing = []
|
|
149
|
+
if not pre_ok:
|
|
150
|
+
missing.append(f"PreToolUse: {required_pre - pre_matchers}")
|
|
151
|
+
if not post_ok:
|
|
152
|
+
missing.append(f"PostToolUse: {required_post - post_matchers}")
|
|
153
|
+
return Check('hooks', 'FAIL', f'missing matchers: {"; ".join(missing)}', True)
|
|
154
|
+
elif missing_files:
|
|
155
|
+
return Check('hooks', 'FAIL', f'missing files: {missing_files}', True)
|
|
156
|
+
else:
|
|
157
|
+
return Check('hooks', 'OK', None, False)
|
|
158
|
+
except Exception as e:
|
|
159
|
+
return Check('hooks', 'OK', '(error reading)', False)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def check_brain():
|
|
163
|
+
"""Check brain (git) status. Returns Check."""
|
|
164
|
+
try:
|
|
165
|
+
brain_path = paths['brain_root']
|
|
166
|
+
if not (brain_path / '.git').exists():
|
|
167
|
+
return Check('brain', 'OK', '(no git repo)', False)
|
|
168
|
+
|
|
169
|
+
# Get status
|
|
170
|
+
status_output = subprocess.run(
|
|
171
|
+
['git', '-C', str(brain_path), 'status', '--porcelain'],
|
|
172
|
+
capture_output=True, text=True, timeout=5
|
|
173
|
+
).stdout.strip()
|
|
174
|
+
|
|
175
|
+
status_lines = [l for l in status_output.split('\n') if l]
|
|
176
|
+
|
|
177
|
+
# Check ahead of current branch's upstream
|
|
178
|
+
try:
|
|
179
|
+
current_branch = subprocess.run(
|
|
180
|
+
['git', '-C', str(brain_path), 'rev-parse', '--abbrev-ref', 'HEAD'],
|
|
181
|
+
capture_output=True, text=True, timeout=5
|
|
182
|
+
).stdout.strip()
|
|
183
|
+
except:
|
|
184
|
+
current_branch = 'HEAD'
|
|
185
|
+
|
|
186
|
+
ahead_output = subprocess.run(
|
|
187
|
+
['git', '-C', str(brain_path), 'rev-list', '--left-only', '--count', f'{current_branch}@{{u}}...{current_branch}'],
|
|
188
|
+
capture_output=True, text=True, timeout=5
|
|
189
|
+
).stdout.strip()
|
|
190
|
+
|
|
191
|
+
try:
|
|
192
|
+
ahead_count = int(ahead_output) if ahead_output else 0
|
|
193
|
+
except:
|
|
194
|
+
ahead_count = 0
|
|
195
|
+
|
|
196
|
+
if ahead_count > 0:
|
|
197
|
+
return Check('brain', 'FAIL', f'ahead:{ahead_count}', True)
|
|
198
|
+
elif status_lines:
|
|
199
|
+
return Check('brain', 'WARN', f'{len(status_lines)} uncommitted', False)
|
|
200
|
+
else:
|
|
201
|
+
return Check('brain', 'OK', None, False)
|
|
202
|
+
except Exception as e:
|
|
203
|
+
return Check('brain', 'OK', '(error checking)', False)
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def check_beats():
|
|
207
|
+
"""Check heartbeats. Returns Check."""
|
|
208
|
+
try:
|
|
209
|
+
heartbeat_results = []
|
|
210
|
+
|
|
211
|
+
# Watchdog heartbeat
|
|
212
|
+
try:
|
|
213
|
+
watchdog_beat = paths['state_root'] / '.watchdog-heartbeat'
|
|
214
|
+
if watchdog_beat.exists():
|
|
215
|
+
epoch_time = float(watchdog_beat.read_text().strip())
|
|
216
|
+
age = datetime.now().timestamp() - epoch_time
|
|
217
|
+
if age > 300:
|
|
218
|
+
heartbeat_results.append(('watchdog', 'stale', int(age)))
|
|
219
|
+
else:
|
|
220
|
+
heartbeat_results.append(('watchdog', 'ok', int(age)))
|
|
221
|
+
else:
|
|
222
|
+
heartbeat_results.append(('watchdog', 'missing', None))
|
|
223
|
+
except Exception as e:
|
|
224
|
+
heartbeat_results.append(('watchdog', 'n/a', None))
|
|
225
|
+
|
|
226
|
+
# Orchestration monitor heartbeat
|
|
227
|
+
try:
|
|
228
|
+
monitor_beat = paths['state_root'] / '.monitor-heartbeat'
|
|
229
|
+
if monitor_beat.exists():
|
|
230
|
+
epoch_time = float(monitor_beat.read_text().strip())
|
|
231
|
+
age = datetime.now().timestamp() - epoch_time
|
|
232
|
+
if age > 3600:
|
|
233
|
+
heartbeat_results.append(('monitor', 'stale', int(age)))
|
|
234
|
+
else:
|
|
235
|
+
heartbeat_results.append(('monitor', 'ok', int(age)))
|
|
236
|
+
else:
|
|
237
|
+
heartbeat_results.append(('monitor', 'missing', None))
|
|
238
|
+
except Exception as e:
|
|
239
|
+
heartbeat_results.append(('monitor', 'n/a', None))
|
|
240
|
+
|
|
241
|
+
# Determine beats status
|
|
242
|
+
beats_ok = all(status not in ('error', 'stale') for _, status, _ in heartbeat_results)
|
|
243
|
+
beats_stale = any(status == 'stale' for _, status, _ in heartbeat_results)
|
|
244
|
+
beats_all_na = all(status == 'n/a' for _, status, _ in heartbeat_results)
|
|
245
|
+
|
|
246
|
+
if beats_all_na:
|
|
247
|
+
return Check('beats', 'OK', '(n/a)', False)
|
|
248
|
+
elif not beats_ok:
|
|
249
|
+
details = '; '.join(f'{name}:{status}' for name, status, _ in heartbeat_results)
|
|
250
|
+
return Check('beats', 'FAIL', details, True)
|
|
251
|
+
elif beats_stale:
|
|
252
|
+
details = '; '.join(f'{name}:{status}' for name, status, _ in heartbeat_results)
|
|
253
|
+
return Check('beats', 'WARN', details, False)
|
|
254
|
+
else:
|
|
255
|
+
return Check('beats', 'OK', None, False)
|
|
256
|
+
except Exception as e:
|
|
257
|
+
return Check('beats', 'OK', '(n/a)', False)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def check_decisions():
|
|
261
|
+
"""Check decisions/inbox counts. Returns Check."""
|
|
262
|
+
try:
|
|
263
|
+
pending_count = 0
|
|
264
|
+
inbox_count = 0
|
|
265
|
+
|
|
266
|
+
try:
|
|
267
|
+
pending_path = paths['brain_root'] / 'plans' / 'PENDING-DECISIONS.md'
|
|
268
|
+
if pending_path.exists():
|
|
269
|
+
content = pending_path.read_text()
|
|
270
|
+
for line in content.split('\n'):
|
|
271
|
+
line = line.strip()
|
|
272
|
+
if line.startswith('- [ ]') or (line.startswith('-') and '[' not in line and line):
|
|
273
|
+
pending_count += 1
|
|
274
|
+
except Exception:
|
|
275
|
+
pass
|
|
276
|
+
|
|
277
|
+
try:
|
|
278
|
+
inbox_path = paths['state_root'] / 'ui-inbox.md'
|
|
279
|
+
if inbox_path.exists():
|
|
280
|
+
content = inbox_path.read_text()
|
|
281
|
+
for line in content.split('\n'):
|
|
282
|
+
if '- [' in line and ']' in line:
|
|
283
|
+
inbox_count += 1
|
|
284
|
+
except Exception:
|
|
285
|
+
pass
|
|
286
|
+
|
|
287
|
+
details = f'{pending_count} pending'
|
|
288
|
+
if inbox_count > 0:
|
|
289
|
+
details += f',{inbox_count} inbox'
|
|
290
|
+
|
|
291
|
+
return Check('decisions', 'OK', details, False)
|
|
292
|
+
except Exception as e:
|
|
293
|
+
return Check('decisions', 'OK', '0 pending', False)
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
def check_scanner():
|
|
297
|
+
"""Check secret scanner. Returns Check."""
|
|
298
|
+
try:
|
|
299
|
+
scanner_path = paths['scripts_root'] / 'secret_scan.py'
|
|
300
|
+
if not scanner_path.exists():
|
|
301
|
+
return Check('scanner', 'OK', 'n/a', False)
|
|
302
|
+
|
|
303
|
+
result = subprocess.run(
|
|
304
|
+
[sys.executable, str(scanner_path), '--staged'],
|
|
305
|
+
capture_output=True, text=True, timeout=30,
|
|
306
|
+
cwd=str(paths['aesop_root'])
|
|
307
|
+
)
|
|
308
|
+
|
|
309
|
+
# Scanner exit 0 = clean, 1 = findings, 2 = usage error
|
|
310
|
+
if result.returncode == 0:
|
|
311
|
+
return Check('scanner', 'OK', None, False)
|
|
312
|
+
elif result.returncode == 1:
|
|
313
|
+
# Findings detected
|
|
314
|
+
return Check('scanner', 'FAIL', 'findings detected', True)
|
|
315
|
+
else:
|
|
316
|
+
return Check('scanner', 'OK', 'n/a', False)
|
|
317
|
+
except Exception as e:
|
|
318
|
+
return Check('scanner', 'OK', 'n/a', False)
|
|
319
|
+
|
|
320
|
+
|
|
321
|
+
def run_checks():
|
|
322
|
+
"""Run all health checks and return results."""
|
|
323
|
+
results = []
|
|
324
|
+
|
|
325
|
+
for check_fn in [check_hooks, check_brain, check_beats, check_decisions, check_scanner]:
|
|
326
|
+
result = check_fn()
|
|
327
|
+
if result:
|
|
328
|
+
results.append(result)
|
|
329
|
+
|
|
330
|
+
return results
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def format_output(results):
|
|
334
|
+
"""Format results into the summary line and optional bullets."""
|
|
335
|
+
# Build status
|
|
336
|
+
has_fail = any(r.is_fail for r in results)
|
|
337
|
+
has_warn = any(r.status == 'WARN' for r in results)
|
|
338
|
+
|
|
339
|
+
if has_fail:
|
|
340
|
+
overall = 'FAIL'
|
|
341
|
+
exit_code = 1
|
|
342
|
+
elif has_warn:
|
|
343
|
+
overall = 'DEGRADED'
|
|
344
|
+
exit_code = 0
|
|
345
|
+
else:
|
|
346
|
+
overall = 'OK'
|
|
347
|
+
exit_code = 0
|
|
348
|
+
|
|
349
|
+
# Build detail strings for each check
|
|
350
|
+
check_details = []
|
|
351
|
+
for result in results:
|
|
352
|
+
if result.status in ('OK', 'WARN'):
|
|
353
|
+
if result.name in ('decisions', 'scanner'):
|
|
354
|
+
check_details.append(f'{result.name}:{result.details}')
|
|
355
|
+
else:
|
|
356
|
+
check_details.append(f'{result.name}:ok')
|
|
357
|
+
elif result.status in ('FAIL', 'ERROR'):
|
|
358
|
+
if result.details:
|
|
359
|
+
check_details.append(f'{result.name}:{result.details}')
|
|
360
|
+
else:
|
|
361
|
+
check_details.append(f'{result.name}:fail')
|
|
362
|
+
|
|
363
|
+
summary_line = f'POWER-SELFTEST: {overall} — {" ".join(check_details)}'
|
|
364
|
+
|
|
365
|
+
# Build bullet points for non-OK items
|
|
366
|
+
bullets = []
|
|
367
|
+
for result in results:
|
|
368
|
+
if result.status not in ('OK',):
|
|
369
|
+
msg = f'- {result.name}: {result.details}' if result.details else f'- {result.name}'
|
|
370
|
+
bullets.append(msg)
|
|
371
|
+
|
|
372
|
+
output_lines = [summary_line]
|
|
373
|
+
output_lines.extend(bullets)
|
|
374
|
+
|
|
375
|
+
return '\n'.join(output_lines), exit_code
|
|
376
|
+
|
|
377
|
+
|
|
378
|
+
def main():
|
|
379
|
+
results = run_checks()
|
|
380
|
+
output, exit_code = format_output(results)
|
|
381
|
+
print(output)
|
|
382
|
+
sys.exit(exit_code)
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
if __name__ == '__main__':
|
|
386
|
+
main()
|