@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.
- package/CHANGELOG.md +46 -5
- package/README.md +57 -1
- package/aesop.config.example.json +8 -1
- package/bin/CLAUDE.md +30 -2
- package/bin/cli.js +286 -73
- package/daemons/CLAUDE.md +4 -1
- package/daemons/run-watchdog.sh +5 -2
- package/docs/RELEASING.md +159 -0
- package/docs/archive/README.md +3 -0
- package/docs/{spikes → archive/spikes}/tiered-cognition/README.md +1 -3
- package/docs/case-study-portfolio.md +61 -0
- package/docs/self-stats-data.json +11 -0
- package/docs/templates/FLEET-OPS-ANALYSIS.example.md +27 -0
- package/docs/templates/FLEET-OPS-RECOMMENDATIONS.example.md +24 -0
- package/docs/templates/PROPOSALS-LOG.example.md +64 -0
- package/hooks/CLAUDE.md +23 -0
- package/mcp/CLAUDE.md +213 -0
- package/mcp/package.json +26 -0
- package/mcp/server.mjs +543 -0
- package/monitor/CHARTER.md +76 -110
- package/monitor/collect-signals.mjs +85 -0
- package/package.json +4 -1
- package/scan/fleet-scan.example.mjs +292 -0
- package/skills/healthcheck/SKILL.md +44 -0
- package/state_store/CLAUDE.md +39 -0
- package/state_store/__init__.py +24 -0
- package/state_store/__pycache__/__init__.cpython-314.pyc +0 -0
- package/state_store/__pycache__/api.cpython-314.pyc +0 -0
- package/state_store/__pycache__/export.cpython-314.pyc +0 -0
- package/state_store/__pycache__/ingest.cpython-314.pyc +0 -0
- package/state_store/__pycache__/projections.cpython-314.pyc +0 -0
- package/state_store/__pycache__/store.cpython-314.pyc +0 -0
- package/state_store/api.py +35 -0
- package/state_store/export.py +19 -0
- package/state_store/ingest.py +24 -0
- package/state_store/projections.py +52 -0
- package/state_store/store.py +102 -0
- package/tools/CLAUDE.md +30 -149
- package/tools/__pycache__/alert_bridge.cpython-314.pyc +0 -0
- package/tools/__pycache__/ci_merge_wait.cpython-314.pyc +0 -0
- package/tools/__pycache__/fleet_prompt_extractor.cpython-314.pyc +0 -0
- package/tools/__pycache__/healthcheck.cpython-314.pyc +0 -0
- package/tools/__pycache__/metrics_gate.cpython-314.pyc +0 -0
- package/tools/__pycache__/secret_scan.cpython-314.pyc +0 -0
- package/tools/__pycache__/self_stats.cpython-314.pyc +0 -0
- package/tools/__pycache__/session_usage_summary.cpython-314.pyc +0 -0
- package/tools/__pycache__/stall_check.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_replay.cpython-314.pyc +0 -0
- package/tools/__pycache__/transcript_timeline.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_dash.cpython-314.pyc +0 -0
- package/tools/__pycache__/verify_submit_encoding.cpython-314.pyc +0 -0
- package/tools/alert_bridge.py +449 -0
- package/tools/ci_merge_wait.py +121 -8
- package/tools/fleet_prompt_extractor.py +134 -0
- package/tools/healthcheck.py +296 -0
- package/tools/secret_scan.py +8 -1
- package/tools/self_stats.py +509 -0
- package/tools/session_usage_summary.py +198 -0
- package/tools/svg_to_png.mjs +50 -0
- package/tools/transcript_replay.py +236 -0
- package/tools/transcript_timeline.py +184 -0
- package/tools/verify_dash.py +361 -542
- package/tools/verify_submit_encoding.py +12 -4
- package/ui/CLAUDE.md +57 -41
- package/ui/__pycache__/agents.cpython-314.pyc +0 -0
- package/ui/__pycache__/collectors.cpython-314.pyc +0 -0
- package/ui/__pycache__/config.cpython-314.pyc +0 -0
- package/ui/__pycache__/cost.cpython-314.pyc +0 -0
- package/ui/__pycache__/csrf.cpython-314.pyc +0 -0
- package/ui/__pycache__/handler.cpython-314.pyc +0 -0
- package/ui/__pycache__/render.cpython-314.pyc +0 -0
- package/ui/__pycache__/serve.cpython-314.pyc +0 -0
- package/ui/__pycache__/sse.cpython-314.pyc +0 -0
- package/ui/agents.py +9 -5
- package/ui/api/__pycache__/submit.cpython-314.pyc +0 -0
- package/ui/api/submit.py +44 -5
- package/ui/collectors.py +184 -35
- package/ui/config.py +9 -0
- package/ui/cost.py +260 -0
- package/ui/csrf.py +7 -3
- package/ui/handler.py +254 -4
- package/ui/render.py +26 -6
- package/ui/sse.py +16 -1
- package/ui/web/.gitattributes +13 -0
- package/ui/web/dist/assets/index-2LZDQirC.js +9 -0
- package/ui/web/dist/assets/index-D4M1qyOv.css +1 -0
- package/ui/web/dist/index.html +14 -0
- package/ui/web/index.html +13 -0
- package/ui/web/package-lock.json +2225 -0
- package/ui/web/package.json +26 -0
- package/ui/web/src/App.test.tsx +74 -0
- package/ui/web/src/App.tsx +142 -0
- package/ui/web/src/CONTRIBUTING-UI.md +49 -0
- package/ui/web/src/components/AgentRow.css +187 -0
- package/ui/web/src/components/AgentRow.test.tsx +209 -0
- package/ui/web/src/components/AgentRow.tsx +207 -0
- package/ui/web/src/components/AgentsPanel.css +108 -0
- package/ui/web/src/components/AgentsPanel.test.tsx +41 -0
- package/ui/web/src/components/AgentsPanel.tsx +58 -0
- package/ui/web/src/components/AlertsPanel.css +88 -0
- package/ui/web/src/components/AlertsPanel.test.tsx +51 -0
- package/ui/web/src/components/AlertsPanel.tsx +67 -0
- package/ui/web/src/components/BacklogPanel.test.tsx +126 -0
- package/ui/web/src/components/BacklogPanel.tsx +122 -0
- package/ui/web/src/components/CostChart.css +110 -0
- package/ui/web/src/components/CostChart.test.tsx +144 -0
- package/ui/web/src/components/CostChart.tsx +152 -0
- package/ui/web/src/components/CostTable.css +93 -0
- package/ui/web/src/components/CostTable.test.tsx +165 -0
- package/ui/web/src/components/CostTable.tsx +94 -0
- package/ui/web/src/components/EventsFeed.css +68 -0
- package/ui/web/src/components/EventsFeed.test.tsx +36 -0
- package/ui/web/src/components/EventsFeed.tsx +31 -0
- package/ui/web/src/components/HealthHeader.css +137 -0
- package/ui/web/src/components/HealthHeader.test.tsx +278 -0
- package/ui/web/src/components/HealthHeader.tsx +281 -0
- package/ui/web/src/components/InboxForm.css +135 -0
- package/ui/web/src/components/InboxForm.test.tsx +208 -0
- package/ui/web/src/components/InboxForm.tsx +116 -0
- package/ui/web/src/components/MessagesTail.module.css +144 -0
- package/ui/web/src/components/MessagesTail.test.tsx +176 -0
- package/ui/web/src/components/MessagesTail.tsx +94 -0
- package/ui/web/src/components/ReposPanel.css +90 -0
- package/ui/web/src/components/ReposPanel.test.tsx +45 -0
- package/ui/web/src/components/ReposPanel.tsx +67 -0
- package/ui/web/src/components/Scorecard.css +106 -0
- package/ui/web/src/components/Scorecard.test.tsx +117 -0
- package/ui/web/src/components/Scorecard.tsx +85 -0
- package/ui/web/src/components/Timeline.module.css +151 -0
- package/ui/web/src/components/Timeline.test.tsx +215 -0
- package/ui/web/src/components/Timeline.tsx +99 -0
- package/ui/web/src/components/TrackerBoard.test.tsx +121 -0
- package/ui/web/src/components/TrackerBoard.tsx +107 -0
- package/ui/web/src/components/TrackerCard.test.tsx +180 -0
- package/ui/web/src/components/TrackerCard.tsx +160 -0
- package/ui/web/src/components/TrackerForm.test.tsx +189 -0
- package/ui/web/src/components/TrackerForm.tsx +144 -0
- package/ui/web/src/lib/api.ts +218 -0
- package/ui/web/src/lib/format.test.ts +89 -0
- package/ui/web/src/lib/format.ts +103 -0
- package/ui/web/src/lib/sanitizeUrl.test.ts +84 -0
- package/ui/web/src/lib/sanitizeUrl.ts +38 -0
- package/ui/web/src/lib/types.ts +230 -0
- package/ui/web/src/lib/useHashRoute.test.ts +60 -0
- package/ui/web/src/lib/useHashRoute.ts +23 -0
- package/ui/web/src/lib/useSSE.ts +175 -0
- package/ui/web/src/main.tsx +10 -0
- package/ui/web/src/styles/global.css +179 -0
- package/ui/web/src/styles/theme.css +184 -0
- package/ui/web/src/styles/work.css +572 -0
- package/ui/web/src/test/fixtures.ts +385 -0
- package/ui/web/src/test/setup.ts +49 -0
- package/ui/web/src/views/Activity.module.css +43 -0
- package/ui/web/src/views/Activity.test.tsx +89 -0
- package/ui/web/src/views/Activity.tsx +31 -0
- package/ui/web/src/views/Cost.css +87 -0
- package/ui/web/src/views/Cost.test.tsx +142 -0
- package/ui/web/src/views/Cost.tsx +54 -0
- package/ui/web/src/views/Overview.css +51 -0
- package/ui/web/src/views/Overview.test.tsx +76 -0
- package/ui/web/src/views/Overview.tsx +46 -0
- package/ui/web/src/views/Work.test.tsx +82 -0
- package/ui/web/src/views/Work.tsx +79 -0
- package/ui/web/src/vite-env.d.ts +10 -0
- package/ui/web/tsconfig.json +22 -0
- package/ui/web/vite.config.ts +25 -0
- package/ui/web/vitest.config.ts +12 -0
- package/ui/templates/dashboard.html +0 -1202
- /package/docs/{spikes → archive/spikes}/tiered-cognition/ACTIVATION.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/DESIGN.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/FINDINGS.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/aesop-cognition.example.md +0 -0
- /package/docs/{spikes → archive/spikes}/tiered-cognition/force-model-policy.merged.mjs +0 -0
- /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
|
-
|
|
8
|
-
- `
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
- `
|
|
12
|
-
- `
|
|
13
|
-
- `
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
- `
|
|
17
|
-
- `
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
- `
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
- `
|
|
24
|
-
- `
|
|
25
|
-
- `
|
|
26
|
-
- `
|
|
27
|
-
- `
|
|
28
|
-
- `
|
|
29
|
-
- `
|
|
30
|
-
- `
|
|
31
|
-
- `
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
- `
|
|
35
|
-
- `
|
|
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
|
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -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())
|