@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,449 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Aesop Alert Bridge — Slack/Discord webhook integration for fleet alerts.
4
+
5
+ Bridges SECURITY-ALERTS.log and watchdog heartbeat stalls to Slack/Discord webhooks.
6
+ Modes:
7
+ --scan Check SECURITY-ALERTS.log and heartbeat, send alerts (default)
8
+ --test-message Send a test ping to webhook
9
+ --dry-run Print masked payload instead of posting
10
+
11
+ Configuration: reads from aesop.config.json at runtime (not imported at module load).
12
+ alerts: {
13
+ webhook_url: null, # If null/absent: no-op exit 0 (feature opt-in)
14
+ provider: "slack"|"discord",
15
+ min_severity: "LOW"|"MEDIUM"|"HIGH"|"CRITICAL",
16
+ heartbeat_stall_s: 600 # Check heartbeat staleness; null = skip
17
+ }
18
+
19
+ Idempotency: cursor file (state/.alert-bridge-cursor) tracks last sent line.
20
+ Never logs or echoes webhook URL (masked to last 6 chars in output).
21
+
22
+ Stdlib-only (urllib, json, sys, os, pathlib, time).
23
+ """
24
+
25
+ import json
26
+ import os
27
+ import sys
28
+ import time
29
+ import urllib.request
30
+ import urllib.error
31
+ from pathlib import Path
32
+
33
+
34
+ # ==============================================================================
35
+ # Configuration Loading
36
+ # ==============================================================================
37
+
38
+
39
+ def load_config():
40
+ """Load aesop.config.json from current directory, return config dict."""
41
+ config_file = Path("aesop.config.json")
42
+ if not config_file.exists():
43
+ return {}
44
+ try:
45
+ with open(config_file, "r", encoding="utf-8") as f:
46
+ return json.load(f)
47
+ except Exception as e:
48
+ print(f"[alert_bridge] Failed to load config: {e}", file=sys.stderr)
49
+ return {}
50
+
51
+
52
+ def get_alerts_config(config):
53
+ """Extract alerts config from aesop.config.json."""
54
+ alerts = config.get("alerts", {})
55
+ if not isinstance(alerts, dict):
56
+ return {}
57
+ return alerts
58
+
59
+
60
+ def get_state_root(config):
61
+ """Get state directory from config or default to ./state."""
62
+ return Path(config.get("state_root", "./state"))
63
+
64
+
65
+ # ==============================================================================
66
+ # Severity Levels
67
+ # ==============================================================================
68
+
69
+
70
+ SEVERITY_ORDER = {"LOW": 0, "MEDIUM": 1, "HIGH": 2, "CRITICAL": 3}
71
+
72
+
73
+ def parse_severity(text):
74
+ """Extract severity from alert text (e.g., 'HIGH: ...' or '[2026-01-15] HIGH: ...') or return None."""
75
+ text = text.strip()
76
+ # Handle timestamps like "[2026-01-15 10:30]" at start
77
+ if text.startswith("["):
78
+ end_bracket = text.find("]")
79
+ if end_bracket >= 0:
80
+ text = text[end_bracket + 1:].strip()
81
+
82
+ # Now check for severity prefix
83
+ for severity in SEVERITY_ORDER.keys():
84
+ if text.startswith(severity + ":"):
85
+ return severity
86
+ return None
87
+
88
+
89
+ def should_send_alert(alert_text, min_severity):
90
+ """Check if alert meets minimum severity threshold."""
91
+ severity = parse_severity(alert_text)
92
+ if severity is None:
93
+ return False # Unknown severity format
94
+ min_level = SEVERITY_ORDER.get(min_severity, 0)
95
+ return SEVERITY_ORDER.get(severity, 0) >= min_level
96
+
97
+
98
+ # ==============================================================================
99
+ # Cursor Management (Idempotency)
100
+ # ==============================================================================
101
+
102
+
103
+ def get_cursor_path(state_root):
104
+ """Return cursor file path."""
105
+ return state_root / ".alert-bridge-cursor"
106
+
107
+
108
+ def read_cursor(state_root):
109
+ """Read last sent line number from cursor file (default 0)."""
110
+ cursor_file = get_cursor_path(state_root)
111
+ if not cursor_file.exists():
112
+ return 0
113
+ try:
114
+ return int(cursor_file.read_text(encoding="utf-8").strip())
115
+ except (ValueError, IOError):
116
+ return 0
117
+
118
+
119
+ def write_cursor(state_root, line_number):
120
+ """Write line number to cursor file."""
121
+ cursor_file = get_cursor_path(state_root)
122
+ state_root.mkdir(parents=True, exist_ok=True)
123
+ try:
124
+ cursor_file.write_text(str(line_number), encoding="utf-8")
125
+ except IOError as e:
126
+ print(f"[alert_bridge] Failed to write cursor: {e}", file=sys.stderr)
127
+
128
+
129
+ # ==============================================================================
130
+ # Alert Collection
131
+ # ==============================================================================
132
+
133
+
134
+ def collect_new_alerts(state_root, min_severity, since_line):
135
+ """
136
+ Read SECURITY-ALERTS.log, filter by severity, return new alerts since cursor.
137
+
138
+ Returns: (new_alerts: list[str], total_lines: int)
139
+ """
140
+ alerts_file = state_root / "SECURITY-ALERTS.log"
141
+ if not alerts_file.exists():
142
+ return [], 0
143
+
144
+ try:
145
+ content = alerts_file.read_text(encoding="utf-8")
146
+ except IOError:
147
+ return [], 0
148
+
149
+ lines = content.strip().split("\n") if content.strip() else []
150
+ new_alerts = []
151
+
152
+ for i, line in enumerate(lines, start=1):
153
+ # Skip if before cursor
154
+ if i <= since_line:
155
+ continue
156
+ line = line.strip()
157
+ if not line:
158
+ continue
159
+ # Skip marked entries
160
+ if "NOTE:" in line or "RESOLVED-FP" in line:
161
+ continue
162
+ # Check severity
163
+ if should_send_alert(line, min_severity):
164
+ new_alerts.append(line)
165
+
166
+ return new_alerts, len(lines)
167
+
168
+
169
+ # ==============================================================================
170
+ # Heartbeat Staleness Check
171
+ # ==============================================================================
172
+
173
+
174
+ def check_heartbeat_staleness(state_root, stall_threshold_s):
175
+ """
176
+ Check if watchdog heartbeat is stale.
177
+
178
+ Returns: (is_stale: bool, heartbeat_info: str or None)
179
+ """
180
+ if stall_threshold_s is None:
181
+ return False, None
182
+
183
+ hb_file = state_root / ".watchdog-heartbeat"
184
+ if not hb_file.exists():
185
+ return True, "Watchdog heartbeat missing"
186
+
187
+ try:
188
+ timestamp = int(hb_file.read_text(encoding="utf-8").strip())
189
+ except (ValueError, IOError):
190
+ return True, "Watchdog heartbeat unreadable"
191
+
192
+ age_seconds = int(time.time()) - timestamp
193
+ if age_seconds >= stall_threshold_s:
194
+ return True, f"Watchdog heartbeat stale ({age_seconds}s >= {stall_threshold_s}s)"
195
+
196
+ return False, None
197
+
198
+
199
+ # ==============================================================================
200
+ # Payload Formatting
201
+ # ==============================================================================
202
+
203
+
204
+ def format_slack_payload(alerts, heartbeat_info):
205
+ """Format Slack block-kit payload."""
206
+ blocks = []
207
+
208
+ # Header block
209
+ blocks.append(
210
+ {
211
+ "type": "header",
212
+ "text": {"type": "plain_text", "text": "🚨 Aesop Fleet Alert"},
213
+ }
214
+ )
215
+
216
+ # Section for alerts
217
+ if alerts:
218
+ alert_text = "\n".join([f"• {alert}" for alert in alerts[:10]])
219
+ blocks.append(
220
+ {
221
+ "type": "section",
222
+ "text": {"type": "mrkdwn", "text": alert_text},
223
+ }
224
+ )
225
+
226
+ # Section for heartbeat
227
+ if heartbeat_info:
228
+ blocks.append(
229
+ {
230
+ "type": "section",
231
+ "text": {
232
+ "type": "mrkdwn",
233
+ "text": f"⚠️ *Heartbeat*: {heartbeat_info}",
234
+ },
235
+ }
236
+ )
237
+
238
+ return {"blocks": blocks}
239
+
240
+
241
+ def format_discord_payload(alerts, heartbeat_info):
242
+ """Format Discord embed payload."""
243
+ embeds = []
244
+
245
+ description_parts = []
246
+ if alerts:
247
+ for alert in alerts[:10]:
248
+ description_parts.append(alert)
249
+ if heartbeat_info:
250
+ description_parts.append(f"⚠️ **Heartbeat**: {heartbeat_info}")
251
+
252
+ embed = {
253
+ "title": "🚨 Aesop Fleet Alert",
254
+ "description": "\n".join(description_parts) or "Alert triggered",
255
+ "color": 16711680, # Red
256
+ }
257
+
258
+ embeds.append(embed)
259
+ return {"embeds": embeds}
260
+
261
+
262
+ # ==============================================================================
263
+ # Webhook Sending
264
+ # ==============================================================================
265
+
266
+
267
+ def mask_webhook_url(webhook_url):
268
+ """Mask webhook URL to last 6 chars for logging."""
269
+ if not webhook_url or len(webhook_url) < 6:
270
+ return "***"
271
+ return "***" + webhook_url[-6:]
272
+
273
+
274
+ def send_webhook(webhook_url, payload):
275
+ """POST payload to webhook URL. Returns (success: bool, status_code: int or None)."""
276
+ if not webhook_url:
277
+ return False, None
278
+
279
+ try:
280
+ data = json.dumps(payload).encode("utf-8")
281
+ req = urllib.request.Request(
282
+ webhook_url,
283
+ data=data,
284
+ headers={"Content-Type": "application/json"},
285
+ method="POST",
286
+ )
287
+ response = urllib.request.urlopen(req, timeout=10)
288
+ status_code = response.getcode()
289
+ return status_code in (200, 204), status_code
290
+ except urllib.error.HTTPError as e:
291
+ return False, e.code
292
+ except Exception as e:
293
+ print(
294
+ f"[alert_bridge] Failed to send webhook: {e}",
295
+ file=sys.stderr,
296
+ )
297
+ return False, None
298
+
299
+
300
+ # ==============================================================================
301
+ # Main Modes
302
+ # ==============================================================================
303
+
304
+
305
+ def mode_scan(config):
306
+ """Scan SECURITY-ALERTS.log and heartbeat, send alerts if new."""
307
+ alerts_config = get_alerts_config(config)
308
+ state_root = get_state_root(config)
309
+
310
+ webhook_url = alerts_config.get("webhook_url")
311
+ provider = alerts_config.get("provider", "slack")
312
+ min_severity = alerts_config.get("min_severity", "HIGH")
313
+ heartbeat_stall_s = alerts_config.get("heartbeat_stall_s")
314
+
315
+ # No-op if webhook disabled
316
+ if not webhook_url:
317
+ return 0
318
+
319
+ # Read cursor
320
+ cursor = read_cursor(state_root)
321
+
322
+ # Collect new alerts
323
+ new_alerts, total_lines = collect_new_alerts(state_root, min_severity, cursor)
324
+
325
+ # Check heartbeat staleness
326
+ is_stale, heartbeat_info = check_heartbeat_staleness(state_root, heartbeat_stall_s)
327
+
328
+ # Nothing to send
329
+ if not new_alerts and not is_stale:
330
+ return 0
331
+
332
+ # Format payload
333
+ if provider == "discord":
334
+ payload = format_discord_payload(new_alerts, heartbeat_info)
335
+ else: # slack or default
336
+ payload = format_slack_payload(new_alerts, heartbeat_info)
337
+
338
+ # Send webhook
339
+ success, status_code = send_webhook(webhook_url, payload)
340
+ if success:
341
+ # Update cursor only on success
342
+ write_cursor(state_root, total_lines)
343
+ print(
344
+ f"[alert_bridge] Sent {len(new_alerts)} alert(s) to {mask_webhook_url(webhook_url)} (status: {status_code})"
345
+ )
346
+ return 0
347
+ else:
348
+ print(
349
+ f"[alert_bridge] Failed to send webhook (status: {status_code})",
350
+ file=sys.stderr,
351
+ )
352
+ return 1
353
+
354
+
355
+ def mode_test_message(config):
356
+ """Send a test ping to webhook."""
357
+ alerts_config = get_alerts_config(config)
358
+ webhook_url = alerts_config.get("webhook_url")
359
+ provider = alerts_config.get("provider", "slack")
360
+
361
+ if not webhook_url:
362
+ print("[alert_bridge] Webhook URL not configured (no-op)", file=sys.stderr)
363
+ return 0
364
+
365
+ # Format test message
366
+ if provider == "discord":
367
+ payload = {"embeds": [{"title": "✅ Alert Bridge Test", "description": "Connection OK"}]}
368
+ else: # slack or default
369
+ payload = {
370
+ "blocks": [
371
+ {"type": "header", "text": {"type": "plain_text", "text": "✅ Alert Bridge Test"}},
372
+ {
373
+ "type": "section",
374
+ "text": {"type": "mrkdwn", "text": "Connection OK"},
375
+ },
376
+ ]
377
+ }
378
+
379
+ success, status_code = send_webhook(webhook_url, payload)
380
+ if success:
381
+ print(f"[alert_bridge] Test message sent to {mask_webhook_url(webhook_url)} (status: {status_code})")
382
+ return 0
383
+ else:
384
+ print(
385
+ f"[alert_bridge] Test message failed (status: {status_code})",
386
+ file=sys.stderr,
387
+ )
388
+ return 1
389
+
390
+
391
+ def mode_dry_run(config):
392
+ """Print masked payload without sending."""
393
+ alerts_config = get_alerts_config(config)
394
+ state_root = get_state_root(config)
395
+
396
+ webhook_url = alerts_config.get("webhook_url")
397
+ provider = alerts_config.get("provider", "slack")
398
+ min_severity = alerts_config.get("min_severity", "HIGH")
399
+ heartbeat_stall_s = alerts_config.get("heartbeat_stall_s")
400
+
401
+ if not webhook_url:
402
+ print("[alert_bridge] Webhook URL not configured (no-op)", file=sys.stderr)
403
+ return 0
404
+
405
+ cursor = read_cursor(state_root)
406
+ new_alerts, _ = collect_new_alerts(state_root, min_severity, cursor)
407
+ is_stale, heartbeat_info = check_heartbeat_staleness(state_root, heartbeat_stall_s)
408
+
409
+ if not new_alerts and not is_stale:
410
+ print(f"[alert_bridge] No alerts to send (webhook: {mask_webhook_url(webhook_url)})")
411
+ return 0
412
+
413
+ # Format payload
414
+ if provider == "discord":
415
+ payload = format_discord_payload(new_alerts, heartbeat_info)
416
+ else: # slack or default
417
+ payload = format_slack_payload(new_alerts, heartbeat_info)
418
+
419
+ print(f"[alert_bridge] DRY-RUN payload (webhook: {mask_webhook_url(webhook_url)})")
420
+ print(json.dumps(payload, indent=2))
421
+ return 0
422
+
423
+
424
+ # ==============================================================================
425
+ # Entry Point
426
+ # ==============================================================================
427
+
428
+
429
+ def main(args=None):
430
+ """Main entry point."""
431
+ if args is None:
432
+ args = sys.argv[1:]
433
+
434
+ mode = "--scan" # default
435
+ if args:
436
+ mode = args[0]
437
+
438
+ config = load_config()
439
+
440
+ if mode == "--test-message":
441
+ return mode_test_message(config)
442
+ elif mode == "--dry-run":
443
+ return mode_dry_run(config)
444
+ else: # --scan or default
445
+ return mode_scan(config)
446
+
447
+
448
+ if __name__ == "__main__":
449
+ sys.exit(main())
@@ -0,0 +1,182 @@
1
+ #!/usr/bin/env python3
2
+ r"""
3
+ Append uniform BUILDLOG entries with optional git HEAD reference.
4
+
5
+ Usage:
6
+ python buildlog.py "<message>" [--state-dir DIR] [--head] [--repo-path PATH]
7
+
8
+ Purpose:
9
+ Ensures consistent BUILDLOG.md formatting across agents: one line per entry,
10
+ timestamped, with optional git HEAD reference. Prevents hand-formatting variance.
11
+
12
+ Args:
13
+ message: Entry text to append (positional, required).
14
+ --state-dir: Path to state directory for BUILDLOG.md (default: AESOP_STATE_ROOT or ./state).
15
+ --head: Include git HEAD (short-hash subject) from specified repo or current directory.
16
+ --repo-path: Path to git repo for HEAD extraction (default: current working directory).
17
+
18
+ Output:
19
+ Prints the exact line appended to stderr; exit 0 always.
20
+
21
+ Behavior:
22
+ - Appends ONE line formatted: ### [YYYY-MM-DD HH:MM] <message>
23
+ - With --head, appends: | HEAD: <short-hash> <subject>
24
+ - If repo not found or not a git repo, omits HEAD and notes "(no-repo)".
25
+ - Creates BUILDLOG.md with append-only header if missing.
26
+ - Never overwrites; always returns the line appended.
27
+ """
28
+
29
+ import argparse
30
+ import os
31
+ import subprocess
32
+ import sys
33
+ from datetime import datetime
34
+ from pathlib import Path
35
+
36
+
37
+ def get_state_dir():
38
+ """Resolve state directory from env var or current working directory."""
39
+ if os.environ.get("AESOP_STATE_ROOT"):
40
+ return Path(os.environ["AESOP_STATE_ROOT"])
41
+ # Default to ./state (relative to cwd)
42
+ return Path.cwd() / "state"
43
+
44
+
45
+ def get_git_head(repo_path):
46
+ """Get short hash and subject from git HEAD.
47
+
48
+ Args:
49
+ repo_path: Path to git repository.
50
+
51
+ Returns:
52
+ Tuple of (short_hash, subject) or (None, None) if not a git repo.
53
+ """
54
+ try:
55
+ if not Path(repo_path / ".git").exists():
56
+ return None, None
57
+
58
+ result = subprocess.run(
59
+ ["git", "log", "-1", "--pretty=%h %s"],
60
+ cwd=repo_path,
61
+ capture_output=True,
62
+ text=True,
63
+ timeout=5,
64
+ )
65
+ if result.returncode == 0 and result.stdout.strip():
66
+ parts = result.stdout.strip().split(None, 1)
67
+ if len(parts) == 2:
68
+ return parts[0], parts[1]
69
+ elif len(parts) == 1:
70
+ return parts[0], "(no subject)"
71
+ except Exception:
72
+ pass
73
+
74
+ return None, None
75
+
76
+
77
+ def build_entry_line(message, short_hash=None, subject=None):
78
+ """Format BUILDLOG entry line.
79
+
80
+ Args:
81
+ message: Entry message.
82
+ short_hash: Git short hash (optional).
83
+ subject: Git subject (optional).
84
+
85
+ Returns:
86
+ Formatted entry line.
87
+ """
88
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M")
89
+ line = f"### [{timestamp}] {message}"
90
+
91
+ if short_hash and subject:
92
+ line += f" | HEAD: {short_hash} {subject}"
93
+ elif short_hash:
94
+ line += f" | HEAD: {short_hash} (no subject)"
95
+
96
+ return line
97
+
98
+
99
+ def ensure_buildlog_header(buildlog_path):
100
+ """Create BUILDLOG.md with header if missing.
101
+
102
+ Args:
103
+ buildlog_path: Path to BUILDLOG.md file.
104
+ """
105
+ if buildlog_path.exists():
106
+ return
107
+
108
+ header = "# Build Log (append-only)\n"
109
+ buildlog_path.parent.mkdir(parents=True, exist_ok=True)
110
+ buildlog_path.write_text(header)
111
+
112
+
113
+ def append_entry(buildlog_path, line):
114
+ """Append line to BUILDLOG.md.
115
+
116
+ Args:
117
+ buildlog_path: Path to BUILDLOG.md file.
118
+ line: Entry line to append.
119
+ """
120
+ ensure_buildlog_header(buildlog_path)
121
+
122
+ with open(buildlog_path, "a", encoding="utf-8") as f:
123
+ f.write(line + "\n")
124
+
125
+
126
+ def main():
127
+ """Main entry point."""
128
+ parser = argparse.ArgumentParser(
129
+ description="Append uniform BUILDLOG entries with optional git HEAD reference.",
130
+ formatter_class=argparse.RawDescriptionHelpFormatter,
131
+ )
132
+ parser.add_argument("message", help="Entry message to append")
133
+ parser.add_argument(
134
+ "--state-dir",
135
+ default=None,
136
+ help="Path to state directory (default: AESOP_STATE_ROOT or ./state)",
137
+ )
138
+ parser.add_argument(
139
+ "--head",
140
+ action="store_true",
141
+ help="Include git HEAD (short-hash subject) reference",
142
+ )
143
+ parser.add_argument(
144
+ "--repo-path",
145
+ default=None,
146
+ help="Path to git repo for HEAD extraction (default: current working directory)",
147
+ )
148
+
149
+ args = parser.parse_args()
150
+
151
+ # Resolve state directory
152
+ if args.state_dir:
153
+ state_dir = Path(args.state_dir)
154
+ else:
155
+ state_dir = get_state_dir()
156
+
157
+ # Derive repo path and get git HEAD if requested
158
+ short_hash = None
159
+ subject = None
160
+ repo_note = ""
161
+
162
+ if args.head:
163
+ repo_path = Path(args.repo_path) if args.repo_path else Path.cwd()
164
+ short_hash, subject = get_git_head(repo_path)
165
+
166
+ if not short_hash:
167
+ repo_note = " (no-repo)"
168
+
169
+ # Build and format entry
170
+ line = build_entry_line(args.message, short_hash, subject)
171
+
172
+ # Append to BUILDLOG.md
173
+ buildlog_path = state_dir / "BUILDLOG.md"
174
+ append_entry(buildlog_path, line)
175
+
176
+ # Print the exact line appended (with repo note if --head requested and failed)
177
+ output_line = line + repo_note if repo_note else line
178
+ print(output_line, file=sys.stderr)
179
+
180
+
181
+ if __name__ == "__main__":
182
+ main()