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

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 (234) hide show
  1. package/CHANGELOG.md +79 -95
  2. package/CLAUDE-TEMPLATE.md +115 -0
  3. package/README.md +126 -245
  4. package/aesop.config.example.json +34 -26
  5. package/bin/CLAUDE.md +67 -0
  6. package/bin/cli.js +593 -36
  7. package/daemons/CLAUDE.md +36 -0
  8. package/daemons/backup-fleet.sh +92 -21
  9. package/daemons/run-watchdog.sh +95 -12
  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/RELEASING.md +159 -0
  23. package/docs/RELIABILITY.md +61 -0
  24. package/docs/RESTORE.md +397 -0
  25. package/docs/SCRIPTS-POLICY.md +97 -0
  26. package/docs/archive/README.md +3 -0
  27. package/docs/archive/spikes/tiered-cognition/ACTIVATION.md +125 -0
  28. package/docs/archive/spikes/tiered-cognition/DESIGN.md +287 -0
  29. package/docs/archive/spikes/tiered-cognition/FINDINGS.md +113 -0
  30. package/docs/archive/spikes/tiered-cognition/README.md +27 -0
  31. package/docs/archive/spikes/tiered-cognition/aesop-cognition.example.md +32 -0
  32. package/docs/archive/spikes/tiered-cognition/force-model-policy.merged.mjs +673 -0
  33. package/docs/archive/spikes/tiered-cognition/strip-tools-hook.mjs +434 -0
  34. package/docs/case-study-portfolio.md +61 -0
  35. package/docs/self-stats-data.json +11 -0
  36. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  37. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  38. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  39. package/hooks/CLAUDE.md +61 -0
  40. package/hooks/claude/force-model-policy.mjs +159 -0
  41. package/hooks/pre-push-policy.sh +753 -0
  42. package/mcp/CLAUDE.md +213 -0
  43. package/mcp/package.json +26 -0
  44. package/mcp/server.mjs +543 -0
  45. package/monitor/.signal-state.json +3 -0
  46. package/monitor/ACTIONS.log +1 -0
  47. package/monitor/BRIEF.md +24 -0
  48. package/monitor/CHARTER.md +75 -88
  49. package/monitor/CLAUDE.md +40 -0
  50. package/monitor/SIGNALS.json +54 -0
  51. package/monitor/collect-signals.mjs +613 -61
  52. package/package.json +15 -3
  53. package/scan/fleet-scan.example.mjs +292 -0
  54. package/skills/CLAUDE.md +3 -0
  55. package/skills/healthcheck/SKILL.md +44 -0
  56. package/skills/power/SKILL.md +161 -0
  57. package/state_store/CLAUDE.md +39 -0
  58. package/state_store/__init__.py +24 -0
  59. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  60. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  61. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  62. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  63. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  64. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  65. package/state_store/api.py +35 -0
  66. package/state_store/export.py +19 -0
  67. package/state_store/ingest.py +24 -0
  68. package/state_store/projections.py +52 -0
  69. package/state_store/store.py +102 -0
  70. package/tools/CLAUDE.md +72 -0
  71. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  72. package/tools/__pycache__/buildlog.cpython-314.pyc +0 -0
  73. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  74. package/tools/__pycache__/ensure_state.cpython-314.pyc +0 -0
  75. package/tools/__pycache__/eod_sweep.cpython-314.pyc +0 -0
  76. package/tools/__pycache__/fleet_ledger.cpython-314.pyc +0 -0
  77. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  78. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  79. package/tools/__pycache__/heartbeat.cpython-314.pyc +0 -0
  80. package/tools/__pycache__/inbox_drain.cpython-314.pyc +0 -0
  81. package/tools/__pycache__/launch_tui.cpython-314.pyc +0 -0
  82. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  83. package/tools/__pycache__/orchestrator_status.cpython-314.pyc +0 -0
  84. package/tools/__pycache__/power_selftest.cpython-314.pyc +0 -0
  85. package/tools/__pycache__/prepublish_scan.cpython-314.pyc +0 -0
  86. package/tools/__pycache__/rotate_logs.cpython-314.pyc +0 -0
  87. package/tools/__pycache__/scanner_selftest.cpython-314.pyc +0 -0
  88. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  89. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  90. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  91. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  92. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  93. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  94. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  95. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  96. package/tools/agent-forensics.sh +186 -0
  97. package/tools/alert_bridge.py +449 -0
  98. package/tools/buildlog.py +182 -0
  99. package/tools/ci_merge_wait.py +317 -0
  100. package/tools/ensure_state.py +94 -0
  101. package/tools/eod_sweep.py +239 -0
  102. package/tools/fleet_ledger.py +272 -0
  103. package/tools/fleet_prompt_extractor.py +134 -0
  104. package/tools/healthcheck.py +296 -0
  105. package/tools/heartbeat.py +131 -0
  106. package/tools/inbox_drain.py +249 -0
  107. package/tools/launch_tui.py +51 -3
  108. package/tools/lock.mjs +211 -0
  109. package/tools/metrics_gate.py +205 -0
  110. package/tools/orchestrator_status.py +99 -0
  111. package/tools/power_selftest.py +386 -0
  112. package/tools/prepublish_scan.py +84 -0
  113. package/tools/proposals.mjs +248 -0
  114. package/tools/reconstitute.sh +467 -0
  115. package/tools/rotate_logs.py +228 -0
  116. package/tools/scanner_selftest.py +303 -0
  117. package/tools/secret_scan.py +138 -31
  118. package/tools/self_stats.py +509 -0
  119. package/tools/session_usage_summary.py +198 -0
  120. package/tools/stall_check.py +172 -0
  121. package/tools/svg_to_png.mjs +50 -0
  122. package/tools/transcript_replay.py +236 -0
  123. package/tools/transcript_timeline.py +184 -0
  124. package/tools/verify_dash.py +513 -0
  125. package/tools/verify_submit_encoding.py +202 -0
  126. package/ui/CLAUDE.md +125 -0
  127. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  128. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  129. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  130. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  131. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  132. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  133. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  134. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  135. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  136. package/ui/agents.py +183 -0
  137. package/ui/api/__init__.py +84 -0
  138. package/ui/api/__pycache__/__init__.cpython-314.pyc +0 -0
  139. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  140. package/ui/api/__pycache__/tracker.cpython-314.pyc +0 -0
  141. package/ui/api/submit.py +97 -0
  142. package/ui/api/tracker.py +116 -0
  143. package/ui/collectors.py +735 -0
  144. package/ui/config.py +123 -0
  145. package/ui/cost.py +260 -0
  146. package/ui/csrf.py +144 -0
  147. package/ui/handler.py +676 -0
  148. package/ui/render.py +49 -0
  149. package/ui/serve.py +68 -796
  150. package/ui/sse.py +183 -0
  151. package/ui/web/.gitattributes +13 -0
  152. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  153. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  154. package/ui/web/dist/index.html +14 -0
  155. package/ui/web/index.html +13 -0
  156. package/ui/web/package-lock.json +2225 -0
  157. package/ui/web/package.json +26 -0
  158. package/ui/web/src/App.test.tsx +74 -0
  159. package/ui/web/src/App.tsx +142 -0
  160. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  161. package/ui/web/src/components/AgentRow.css +187 -0
  162. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  163. package/ui/web/src/components/AgentRow.tsx +207 -0
  164. package/ui/web/src/components/AgentsPanel.css +108 -0
  165. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  166. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  167. package/ui/web/src/components/AlertsPanel.css +88 -0
  168. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  169. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  170. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  171. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  172. package/ui/web/src/components/CostChart.css +110 -0
  173. package/ui/web/src/components/CostChart.test.tsx +144 -0
  174. package/ui/web/src/components/CostChart.tsx +152 -0
  175. package/ui/web/src/components/CostTable.css +93 -0
  176. package/ui/web/src/components/CostTable.test.tsx +165 -0
  177. package/ui/web/src/components/CostTable.tsx +94 -0
  178. package/ui/web/src/components/EventsFeed.css +68 -0
  179. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  180. package/ui/web/src/components/EventsFeed.tsx +31 -0
  181. package/ui/web/src/components/HealthHeader.css +137 -0
  182. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  183. package/ui/web/src/components/HealthHeader.tsx +281 -0
  184. package/ui/web/src/components/InboxForm.css +135 -0
  185. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  186. package/ui/web/src/components/InboxForm.tsx +116 -0
  187. package/ui/web/src/components/MessagesTail.module.css +144 -0
  188. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  189. package/ui/web/src/components/MessagesTail.tsx +94 -0
  190. package/ui/web/src/components/ReposPanel.css +90 -0
  191. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  192. package/ui/web/src/components/ReposPanel.tsx +67 -0
  193. package/ui/web/src/components/Scorecard.css +106 -0
  194. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  195. package/ui/web/src/components/Scorecard.tsx +85 -0
  196. package/ui/web/src/components/Timeline.module.css +151 -0
  197. package/ui/web/src/components/Timeline.test.tsx +215 -0
  198. package/ui/web/src/components/Timeline.tsx +99 -0
  199. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  200. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  201. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  202. package/ui/web/src/components/TrackerCard.tsx +160 -0
  203. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  204. package/ui/web/src/components/TrackerForm.tsx +144 -0
  205. package/ui/web/src/lib/api.ts +218 -0
  206. package/ui/web/src/lib/format.test.ts +89 -0
  207. package/ui/web/src/lib/format.ts +103 -0
  208. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  209. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  210. package/ui/web/src/lib/types.ts +230 -0
  211. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  212. package/ui/web/src/lib/useHashRoute.ts +23 -0
  213. package/ui/web/src/lib/useSSE.ts +175 -0
  214. package/ui/web/src/main.tsx +10 -0
  215. package/ui/web/src/styles/global.css +179 -0
  216. package/ui/web/src/styles/theme.css +184 -0
  217. package/ui/web/src/styles/work.css +572 -0
  218. package/ui/web/src/test/fixtures.ts +385 -0
  219. package/ui/web/src/test/setup.ts +49 -0
  220. package/ui/web/src/views/Activity.module.css +43 -0
  221. package/ui/web/src/views/Activity.test.tsx +89 -0
  222. package/ui/web/src/views/Activity.tsx +31 -0
  223. package/ui/web/src/views/Cost.css +87 -0
  224. package/ui/web/src/views/Cost.test.tsx +142 -0
  225. package/ui/web/src/views/Cost.tsx +54 -0
  226. package/ui/web/src/views/Overview.css +51 -0
  227. package/ui/web/src/views/Overview.test.tsx +76 -0
  228. package/ui/web/src/views/Overview.tsx +46 -0
  229. package/ui/web/src/views/Work.test.tsx +82 -0
  230. package/ui/web/src/views/Work.tsx +79 -0
  231. package/ui/web/src/vite-env.d.ts +10 -0
  232. package/ui/web/tsconfig.json +22 -0
  233. package/ui/web/vite.config.ts +25 -0
  234. package/ui/web/vitest.config.ts +12 -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()