@matt82198/aesop 0.1.0-beta.4 → 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 (174) hide show
  1. package/CHANGELOG.md +46 -5
  2. package/README.md +57 -1
  3. package/aesop.config.example.json +8 -1
  4. package/bin/CLAUDE.md +30 -2
  5. package/bin/cli.js +286 -73
  6. package/daemons/CLAUDE.md +4 -1
  7. package/daemons/run-watchdog.sh +5 -2
  8. package/docs/RELEASING.md +159 -0
  9. package/docs/archive/README.md +3 -0
  10. package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
  11. package/docs/case-study-portfolio.md +61 -0
  12. package/docs/self-stats-data.json +11 -0
  13. package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
  14. package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
  15. package/docs/templates/PROPOSALS-LOG.example.md +64 -0
  16. package/hooks/CLAUDE.md +23 -0
  17. package/mcp/CLAUDE.md +213 -0
  18. package/mcp/package.json +26 -0
  19. package/mcp/server.mjs +543 -0
  20. package/monitor/CHARTER.md +76 -110
  21. package/monitor/collect-signals.mjs +85 -0
  22. package/package.json +4 -1
  23. package/scan/fleet-scan.example.mjs +292 -0
  24. package/skills/healthcheck/SKILL.md +44 -0
  25. package/state_store/CLAUDE.md +39 -0
  26. package/state_store/__init__.py +24 -0
  27. package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
  28. package/state_store/__pycache__/api.cpython-314.pyc +0 -0
  29. package/state_store/__pycache__/export.cpython-314.pyc +0 -0
  30. package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
  31. package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
  32. package/state_store/__pycache__/store.cpython-314.pyc +0 -0
  33. package/state_store/api.py +35 -0
  34. package/state_store/export.py +19 -0
  35. package/state_store/ingest.py +24 -0
  36. package/state_store/projections.py +52 -0
  37. package/state_store/store.py +102 -0
  38. package/tools/CLAUDE.md +30 -149
  39. package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
  40. package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
  41. package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
  42. package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
  43. package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
  44. package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
  45. package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
  46. package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
  47. package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
  48. package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
  49. package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
  50. package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
  51. package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
  52. package/tools/alert_bridge.py +449 -0
  53. package/tools/ci_merge_wait.py +121 -8
  54. package/tools/fleet_prompt_extractor.py +134 -0
  55. package/tools/healthcheck.py +296 -0
  56. package/tools/secret_scan.py +8 -1
  57. package/tools/self_stats.py +509 -0
  58. package/tools/session_usage_summary.py +198 -0
  59. package/tools/svg_to_png.mjs +50 -0
  60. package/tools/transcript_replay.py +236 -0
  61. package/tools/transcript_timeline.py +184 -0
  62. package/tools/verify_dash.py +361 -542
  63. package/tools/verify_submit_encoding.py +12 -4
  64. package/ui/CLAUDE.md +57 -41
  65. package/ui/__pycache__/agents.cpython-314.pyc +0 -0
  66. package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
  67. package/ui/__pycache__/config.cpython-314.pyc +0 -0
  68. package/ui/__pycache__/cost.cpython-314.pyc +0 -0
  69. package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
  70. package/ui/__pycache__/handler.cpython-314.pyc +0 -0
  71. package/ui/__pycache__/render.cpython-314.pyc +0 -0
  72. package/ui/__pycache__/serve.cpython-314.pyc +0 -0
  73. package/ui/__pycache__/sse.cpython-314.pyc +0 -0
  74. package/ui/agents.py +9 -5
  75. package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
  76. package/ui/api/submit.py +44 -5
  77. package/ui/collectors.py +184 -35
  78. package/ui/config.py +9 -0
  79. package/ui/cost.py +260 -0
  80. package/ui/csrf.py +7 -3
  81. package/ui/handler.py +254 -4
  82. package/ui/render.py +26 -6
  83. package/ui/sse.py +16 -1
  84. package/ui/web/.gitattributes +13 -0
  85. package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
  86. package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
  87. package/ui/web/dist/index.html +14 -0
  88. package/ui/web/index.html +13 -0
  89. package/ui/web/package-lock.json +2225 -0
  90. package/ui/web/package.json +26 -0
  91. package/ui/web/src/App.test.tsx +74 -0
  92. package/ui/web/src/App.tsx +142 -0
  93. package/ui/web/src/CONTRIBUTING-UI.md +49 -0
  94. package/ui/web/src/components/AgentRow.css +187 -0
  95. package/ui/web/src/components/AgentRow.test.tsx +209 -0
  96. package/ui/web/src/components/AgentRow.tsx +207 -0
  97. package/ui/web/src/components/AgentsPanel.css +108 -0
  98. package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
  99. package/ui/web/src/components/AgentsPanel.tsx +58 -0
  100. package/ui/web/src/components/AlertsPanel.css +88 -0
  101. package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
  102. package/ui/web/src/components/AlertsPanel.tsx +67 -0
  103. package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
  104. package/ui/web/src/components/BacklogPanel.tsx +122 -0
  105. package/ui/web/src/components/CostChart.css +110 -0
  106. package/ui/web/src/components/CostChart.test.tsx +144 -0
  107. package/ui/web/src/components/CostChart.tsx +152 -0
  108. package/ui/web/src/components/CostTable.css +93 -0
  109. package/ui/web/src/components/CostTable.test.tsx +165 -0
  110. package/ui/web/src/components/CostTable.tsx +94 -0
  111. package/ui/web/src/components/EventsFeed.css +68 -0
  112. package/ui/web/src/components/EventsFeed.test.tsx +36 -0
  113. package/ui/web/src/components/EventsFeed.tsx +31 -0
  114. package/ui/web/src/components/HealthHeader.css +137 -0
  115. package/ui/web/src/components/HealthHeader.test.tsx +278 -0
  116. package/ui/web/src/components/HealthHeader.tsx +281 -0
  117. package/ui/web/src/components/InboxForm.css +135 -0
  118. package/ui/web/src/components/InboxForm.test.tsx +208 -0
  119. package/ui/web/src/components/InboxForm.tsx +116 -0
  120. package/ui/web/src/components/MessagesTail.module.css +144 -0
  121. package/ui/web/src/components/MessagesTail.test.tsx +176 -0
  122. package/ui/web/src/components/MessagesTail.tsx +94 -0
  123. package/ui/web/src/components/ReposPanel.css +90 -0
  124. package/ui/web/src/components/ReposPanel.test.tsx +45 -0
  125. package/ui/web/src/components/ReposPanel.tsx +67 -0
  126. package/ui/web/src/components/Scorecard.css +106 -0
  127. package/ui/web/src/components/Scorecard.test.tsx +117 -0
  128. package/ui/web/src/components/Scorecard.tsx +85 -0
  129. package/ui/web/src/components/Timeline.module.css +151 -0
  130. package/ui/web/src/components/Timeline.test.tsx +215 -0
  131. package/ui/web/src/components/Timeline.tsx +99 -0
  132. package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
  133. package/ui/web/src/components/TrackerBoard.tsx +107 -0
  134. package/ui/web/src/components/TrackerCard.test.tsx +180 -0
  135. package/ui/web/src/components/TrackerCard.tsx +160 -0
  136. package/ui/web/src/components/TrackerForm.test.tsx +189 -0
  137. package/ui/web/src/components/TrackerForm.tsx +144 -0
  138. package/ui/web/src/lib/api.ts +218 -0
  139. package/ui/web/src/lib/format.test.ts +89 -0
  140. package/ui/web/src/lib/format.ts +103 -0
  141. package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
  142. package/ui/web/src/lib/sanitizeUrl.ts +38 -0
  143. package/ui/web/src/lib/types.ts +230 -0
  144. package/ui/web/src/lib/useHashRoute.test.ts +60 -0
  145. package/ui/web/src/lib/useHashRoute.ts +23 -0
  146. package/ui/web/src/lib/useSSE.ts +175 -0
  147. package/ui/web/src/main.tsx +10 -0
  148. package/ui/web/src/styles/global.css +179 -0
  149. package/ui/web/src/styles/theme.css +184 -0
  150. package/ui/web/src/styles/work.css +572 -0
  151. package/ui/web/src/test/fixtures.ts +385 -0
  152. package/ui/web/src/test/setup.ts +49 -0
  153. package/ui/web/src/views/Activity.module.css +43 -0
  154. package/ui/web/src/views/Activity.test.tsx +89 -0
  155. package/ui/web/src/views/Activity.tsx +31 -0
  156. package/ui/web/src/views/Cost.css +87 -0
  157. package/ui/web/src/views/Cost.test.tsx +142 -0
  158. package/ui/web/src/views/Cost.tsx +54 -0
  159. package/ui/web/src/views/Overview.css +51 -0
  160. package/ui/web/src/views/Overview.test.tsx +76 -0
  161. package/ui/web/src/views/Overview.tsx +46 -0
  162. package/ui/web/src/views/Work.test.tsx +82 -0
  163. package/ui/web/src/views/Work.tsx +79 -0
  164. package/ui/web/src/vite-env.d.ts +10 -0
  165. package/ui/web/tsconfig.json +22 -0
  166. package/ui/web/vite.config.ts +25 -0
  167. package/ui/web/vitest.config.ts +12 -0
  168. package/ui/templates/dashboard.html +0 -1202
  169. /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
  170. /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
  171. /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
  172. /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
  173. /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
  174. /package/docs/{spikes → archive/spikes}/tiered-cognition/strip-tools-hook.mjs +0 -0
package/tools/CLAUDE.md CHANGED
@@ -4,40 +4,36 @@ Local-only Python (stdlib only, no external deps), bash (POSIX, CRLF-safe). Neve
4
4
 
5
5
  ## FILES
6
6
 
7
- **Locking & atomicity**:
8
- - `lock.mjs` — Fail-closed atomic lock acquisition (exponential backoff + stale-lock detection) for PROPOSALS.md and related single-writer operations
9
-
10
- **Secret scanning & compliance**:
11
- - `secret_scan.py` — Pre-push secret/credential detection gate; scans staged, history, or paths by regex + filename patterns
12
- - `scanner_selftest.py` — Regression harness for secret_scan.py; validates TP/FP vectors and self-scan cleanliness
13
- - `prepublish_scan.py` — Pre-publish gate running full git history + staged-changes scans; exit 0 only if CLEAR-TO-PUBLISH
14
-
15
- **Browser-level verification (CI gates)**:
16
- - `verify_dash.py` — Browser proof for realtime SSE dashboard; validates console errors, backlog rendering, live SSE updates (use `--allow-skip` in browserless environments)
17
- - `verify_submit_encoding.py` — Browser proof for /submit UTF-8 inbox bootstrap; validates encoding on Windows and real CSRF flow (use `--allow-skip` in browserless environments)
18
-
19
- **CI/merge operations**:
20
- - `ci_merge_wait.py` — CI-gated merge helper; polls gh pr view until checks conclude (SUCCESS/FAILURE), then merges ONLY if SUCCESS (structurally unreachable otherwise)
21
-
22
- **Orchestration infrastructure**:
23
- - `proposals.mjs` — Proposal lifecycle manager (list/accept/reject); uses fail-closed locking for atomic state updates
24
- - `power_selftest.py` — Health check harness for /power bootstrap; validates hooks, brain, heartbeats, decisions, and scanner
25
- - `buildlog.py` — Uniform BUILDLOG.md appender; ensures consistent timestamp + message + git HEAD ref formatting
26
- - `ensure_state.py` — Scaffold STATE.md and BUILDLOG.md templates in state directory if missing
27
- - `fleet_ledger.py` — Append-only ledger of agent runs, resource use, and verdicts; supports harvest (scan tasks) and rotate (archive)
28
- - `heartbeat.py` — Single-instance loop liveness registry; write beats to state/.heartbeats/<name>, check staleness across fleet
29
- - `stall_check.py` — Automated silent-hang detector; scans agent transcript mtimes to identify stalled agents, feeds watchdog monitoring
30
- - `inbox_drain.py` — Drain UI inbox submissions; tracks processed dashboard work items (queue while no session running)
31
- - `orchestrator_status.py` — Atomic writer for state/orchestrator-status.json (set/clear activity+phase); feeds the dashboard status panel
32
-
33
- **Repository operations**:
34
- - `reconstitute.sh` — Clone or fetch repos from config (tab/space-delimited); validates clone targets against fleet-root (physical paths, security)
35
- - `eod_sweep.py` — End-of-day safety check; scans repos for dirty trees, unpushed commits, untracked files; optional auto-push
36
-
37
- **Utilities**:
38
- - `agent-forensics.sh` — Incident forensics / behavior reconstruction; read-only git plumbing to snapshot or diff behavior-controlling files
39
- - `launch_tui.py` — Spawn bash TUI script in detached terminal; finds terminal (Git Bash → Windows Terminal), idempotent via pidfile
40
- - `rotate_logs.py` — Log rotation utility; archives oldest lines when file exceeds size/line thresholds; ensures no data loss
7
+ - `lock.mjs` — Fail-closed atomic lock acquisition (exponential backoff + stale-lock detection)
8
+ - `secret_scan.py` — Pre-push secret/credential detection gate
9
+ - `scanner_selftest.py` — Regression harness for secret_scan.py
10
+ - `prepublish_scan.py` Pre-publish full history + staged-changes scan gate
11
+ - `metrics_gate.py` — PR gate for hard numeric claims in markdown
12
+ - `self_stats.py` — Git-derived metrics counter + README block generator
13
+ - `verify_dash.py` — Browser proof for realtime SSE dashboard
14
+ - `verify_submit_encoding.py` — Browser proof for /submit UTF-8 inbox bootstrap
15
+ - `ci_merge_wait.py` CI-gated merge helper (polls gh pr view until SUCCESS)
16
+ - `alert_bridge.py` — Slack/Discord webhook bridge for SECURITY-ALERTS
17
+ - `proposals.mjs` — Proposal lifecycle manager (list/accept/reject)
18
+ - `power_selftest.py` — Health check harness for /power bootstrap
19
+ - `healthcheck.py` — Fleet health aggregator (heartbeat/alert/orchestrator status)
20
+ - `buildlog.py` — Uniform BUILDLOG.md appender
21
+ - `ensure_state.py` — Scaffold STATE.md and BUILDLOG.md templates
22
+ - `fleet_ledger.py` — Append-only cost ledger with harvest/rotate
23
+ - `heartbeat.py` — Single-instance loop liveness registry
24
+ - `stall_check.py` — Automated agent transcript stall detector
25
+ - `inbox_drain.py` — Drain UI inbox submissions
26
+ - `orchestrator_status.py` — Atomic orchestrator status updates
27
+ - `reconstitute.sh` — Clone/fetch repos from config with security validation
28
+ - `eod_sweep.py` — End-of-day safety check (dirty trees, unpushed commits)
29
+ - `agent-forensics.sh` — Incident forensics / behavior reconstruction
30
+ - `launch_tui.py` — Spawn bash TUI script in detached terminal
31
+ - `rotate_logs.py` — Log rotation utility with size/line thresholds
32
+ - `session_usage_summary.py` — Aggregate token usage across session transcripts
33
+ - `transcript_replay.py` — Replay post-commit edits from transcripts to recover work
34
+ - `transcript_timeline.py` — Extract Write/Edit/Read timeline from transcripts
35
+ - `fleet_prompt_extractor.py` — Extract and deduplicate Agent/Task spawn prompts
36
+ - `svg_to_png.mjs` — Rasterize SVG to PNG via @resvg/resvg-js (with lazy import error handling)
41
37
 
42
38
  ## secret_scan.py — Pre-push secret/credential detection gate
43
39
 
@@ -64,121 +60,6 @@ Read-only git plumbing; reconstructs agent behavior snapshot or diffs behavior-c
64
60
 
65
61
  Exit: 0=success, 1=error (never raw git traces). Requires: git, head, tail, wc, grep.
66
62
 
67
- ## launch_tui.py — Spawn bash TUI script in detached terminal
68
-
69
- Finds terminal (prefer Git Bash → Windows Terminal wt.exe), spawns script detached, idempotent via pidfile.
70
-
71
- - `python launch_tui.py --script <path> [--title <title>] [--pidfile <path>]`
72
-
73
- Exit: 0=success, 1=error. Output: exactly one line (`spawned (pid N)` or `already running (pid N)` or ERROR).
74
-
75
- ## power_selftest.py — Health check harness for /power bootstrap
76
-
77
- Validates hooks (settings.json), brain (git status), heartbeats (state/ beat files), decisions/inbox counts, and secret scanner.
78
-
79
- - `python power_selftest.py` — run all checks and print summary line + bullets for non-OK items
80
-
81
- Configuration via `aesop.config.json` or env vars: `BRAIN_ROOT`, `AESOP_STATE_ROOT`, `SCRIPTS_ROOT`.
82
- Gracefully degrades when targets don't exist (reports `n/a` instead of crashing).
83
-
84
- Exit: 0=OK/DEGRADED, 1=FAIL. Output: `POWER-SELFTEST: OK|DEGRADED|FAIL — <checks>` + optional bullets.
85
-
86
- ## inbox_drain.py — Drain UI inbox submissions
87
-
88
- Tracks processed dashboard submissions (work items queued while no session was running).
89
-
90
- - `python inbox_drain.py pending` — list unprocessed inbox items
91
- - `python inbox_drain.py mark <ISO-ts>` — mark one item processed
92
- - `python inbox_drain.py mark-all` — mark all pending items processed
93
-
94
- Configuration via `aesop.config.json` or env vars: `AESOP_INBOX_PATH`, `AESOP_INBOX_SEEN_PATH`, `AESOP_STATE_ROOT`.
95
- Gracefully handles missing inbox/seen files (no crash).
96
-
97
- Exit: 0 always. Output: `pending` lists items one per line; `mark`/`mark-all` prints summary to stderr.
98
-
99
- ## heartbeat.py — Single-instance loop liveness registry
100
-
101
- Fleet-wide heartbeat tracking for monitoring loop freshness. Write heartbeat "beat" to registry, check all beats for staleness.
102
-
103
- - `heartbeat.py beat <name> [status] [--state-dir DIR] [--brain]` — write epoch to state/.heartbeats/<name> (or ~/.claude/.heartbeats with --brain)
104
- - `heartbeat.py check [--max-age SEC] [--state-dir DIR]` — check all beats, exit 0 if all alive
105
-
106
- ## stall_check.py — Automated silent-hang detector for agent transcripts
107
-
108
- Scans agent transcript files (agent-*.jsonl) by mtime to detect stalled/silent-hanging agents.
109
-
110
- - `stall_check.py [--transcripts-root DIR] [--threshold-seconds SEC] [--json] [--exit-nonzero-on-stall]`
111
-
112
- Options:
113
- - `--transcripts-root DIR` — Root directory to scan; defaults to AESOP_TRANSCRIPTS_ROOT env var or ~/.claude/projects
114
- - `--threshold-seconds SEC` — Max age (seconds) for a "fresh" transcript (default: 600); transcripts older are flagged as stalled
115
- - `--json` — Output as JSON list of {agent_id, age_seconds, stalled, last_mtime} instead of human table
116
- - `--exit-nonzero-on-stall` — Exit 1 if any agent is stalled (for CI/monitor integration); default always exits 0
117
-
118
- Exit: 0 always (or 1 if --exit-nonzero-on-stall + stalls found). Output: human table or JSON; reports "no transcripts found" gracefully.
119
-
120
- ## buildlog.py — Uniform BUILDLOG entry appender
121
-
122
- Ensures consistent BUILDLOG.md formatting across orchestrated work: timestamp + message + optional git HEAD ref.
123
-
124
- - `buildlog.py "<message>" [--state-dir DIR] [--head] [--repo-path PATH]` — append entry to state/BUILDLOG.md
125
-
126
- ## ensure_state.py — Scaffold checkpointing directories
127
-
128
- Creates STATE.md and BUILDLOG.md templates in state directory if missing.
129
-
130
- - `ensure_state.py --state-dir DIR` — scaffold templates
131
-
132
- ## fleet_ledger.py — Outcome audit trail for dispatched agents
133
-
134
- Append-only ledger of agent runs, resource use, and verdicts. Supports harvest (scan temp tasks) and rotate (archive old lines).
135
-
136
- - `fleet_ledger.py append <ts> <agent_type> <model> <dur_sec> <tokens_in> <tokens_out> [verdict]`
137
- - `fleet_ledger.py harvest` — scan session tasks and append missing outcomes
138
- - `fleet_ledger.py rotate` — archive old lines when ledger exceeds ~200 lines
139
-
140
- ## scanner_selftest.py — Regression harness for secret_scan.py
141
-
142
- Validates scanner against TP/FP test vectors. Ensures self-scan is clean (no pragma reliance).
143
-
144
- - `python scanner_selftest.py [--temp-dir DIR]` — run full test suite, exit 0 only if all pass
145
-
146
- ## prepublish_scan.py — Pre-publish gate (history + staged)
147
-
148
- Runs full git history and staged-changes scans before public release. Both must pass.
149
-
150
- - `prepublish_scan.py [--repo PATH]` — scan full history + staged changes, exit 0 only if CLEAR-TO-PUBLISH
151
-
152
- ## eod_sweep.py — End-of-day repository safety check
153
-
154
- Scans listed repos for: dirty working tree, unpushed commits, untracked files. Optional auto-push on safe repos.
155
-
156
- - `eod_sweep.py [--repos PATHS] [--readonly-repos PATHS] [--fix-push]` — check repo health, optionally push
157
-
158
- ## orchestrator_status.py — Atomic orchestrator status updates (wave-8+)
159
-
160
- Manages orchestrator heartbeat and activity tracking for SSE status section and wave-9+ multi-orchestrator coordination.
161
-
162
- - `python orchestrator_status.py set --activity "dispatching wave-8" --phase audit [--id main --role orchestrator]` — atomically write status
163
- - `python orchestrator_status.py clear` — remove status file
164
-
165
- Writes `state/orchestrator-status.json` atomically (temp+replace). Forward-compatible with bare-object → list normalization in serve.py. Exit: 0=success, 1=error.
166
-
167
- ## ci_merge_wait.py — CI-gated merge helper
168
-
169
- Polls gh pr view until all status checks conclude (SUCCESS/FAILURE), then merges ONLY if all checks pass. The `gh pr merge` call is STRUCTURALLY UNREACHABLE unless CI status is SUCCESS — this prevents merge-on-CI-failure edge cases (e.g., wave-7 PR #80).
170
-
171
- - `python ci_merge_wait.py <PR-number> [--timeout SECONDS] [--poll SECONDS] [--merge-method merge|squash|rebase]`
172
-
173
- Exit codes:
174
- - 0 = PR merged successfully
175
- - 2 = CI checks failed (do NOT merge, prints which check failed)
176
- - 3 = Timeout waiting for CI to conclude
177
- - 4 = PR not mergeable or has merge conflicts
178
-
179
- Requires: `gh` CLI available on PATH. Gracefully exits with error if gh is missing.
180
-
181
- Implementation note: This is the reusable form of the orchestrator's merge-gating discipline (buildsystem Phase 1). Core invariant: the merge call is STRUCTURALLY UNREACHABLE on any status other than SUCCESS.
182
63
 
183
64
  ## Invariants
184
65
 
@@ -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())