delimit-cli 4.1.7 → 4.1.9

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.
@@ -1,240 +0,0 @@
1
- """Ledger Propose — AI-driven item generation from signals.
2
-
3
- Analyzes repo state, sensing signals, completed work, and venture priorities
4
- to propose 3-5 new ledger items with rationale. Runs at end of build loops
5
- when the queue is empty, or on-demand.
6
-
7
- Works across all AI models via MCP — no model-specific code.
8
- """
9
-
10
- import json
11
- import time
12
- from pathlib import Path
13
- from typing import Any, Dict, List, Optional
14
-
15
-
16
- SIGNALS_DIR = Path.home() / ".delimit" / "signals"
17
- PROPOSALS_DIR = Path.home() / ".delimit" / "proposals"
18
-
19
-
20
- def _gather_context(venture: str = "") -> Dict[str, Any]:
21
- """Collect context from multiple sources for proposal generation."""
22
- context = {
23
- "timestamp": time.time(),
24
- "venture": venture or "all",
25
- "sources": {},
26
- }
27
-
28
- # 1. Recent ledger completions (what just shipped)
29
- ledger_path = Path.home() / ".delimit" / "ledger.jsonl"
30
- if ledger_path.exists():
31
- recent_done = []
32
- for line in ledger_path.read_text().strip().split("\n")[-100:]:
33
- try:
34
- item = json.loads(line)
35
- if item.get("status") == "done":
36
- recent_done.append({
37
- "id": item.get("id", ""),
38
- "title": item.get("title", ""),
39
- "venture": item.get("venture", ""),
40
- })
41
- except json.JSONDecodeError:
42
- continue
43
- context["sources"]["completed_work"] = recent_done[-10:]
44
-
45
- # 2. Open items (what's already tracked)
46
- open_items = []
47
- if ledger_path.exists():
48
- for line in ledger_path.read_text().strip().split("\n"):
49
- try:
50
- item = json.loads(line)
51
- if item.get("status") == "open":
52
- open_items.append(item.get("title", ""))
53
- except json.JSONDecodeError:
54
- continue
55
- context["sources"]["open_items_count"] = len(open_items)
56
- context["sources"]["open_item_titles"] = open_items[:20]
57
-
58
- # 3. Sensing signals (GitHub issues, Reddit, migrations)
59
- signals_file = Path.home() / ".delimit" / "signals" / "recent.jsonl"
60
- if signals_file.exists():
61
- signals = []
62
- for line in signals_file.read_text().strip().split("\n")[-20:]:
63
- try:
64
- sig = json.loads(line)
65
- signals.append({
66
- "type": sig.get("type", ""),
67
- "title": sig.get("title", ""),
68
- "source": sig.get("source", ""),
69
- "relevance": sig.get("relevance", 0),
70
- })
71
- except json.JSONDecodeError:
72
- continue
73
- context["sources"]["signals"] = signals
74
-
75
- # 4. Git recent activity
76
- try:
77
- import subprocess
78
- result = subprocess.run(
79
- ["git", "log", "--oneline", "-10"],
80
- capture_output=True, text=True, timeout=5,
81
- )
82
- if result.returncode == 0:
83
- context["sources"]["recent_commits"] = result.stdout.strip().split("\n")
84
- except Exception:
85
- pass
86
-
87
- # 5. Swarm state
88
- swarm_registry = Path.home() / ".delimit" / "swarm" / "agent_registry.json"
89
- if swarm_registry.exists():
90
- try:
91
- reg = json.loads(swarm_registry.read_text())
92
- context["sources"]["swarm_agents"] = len(reg.get("agents", {}))
93
- except json.JSONDecodeError:
94
- pass
95
-
96
- return context
97
-
98
-
99
- def propose_items(
100
- venture: str = "",
101
- focus: str = "",
102
- max_items: int = 5,
103
- ) -> Dict[str, Any]:
104
- """Generate proposed ledger items based on current context.
105
-
106
- Analyzes completed work, open items, sensing signals, and repo state
107
- to suggest what to work on next. Returns structured proposals that
108
- can be added to the ledger with delimit_ledger_add.
109
-
110
- This is the AI's "what should I do next?" engine. Works with any model.
111
-
112
- Args:
113
- venture: Focus proposals on a specific venture.
114
- focus: Optional focus area (e.g., "outreach", "engineering", "security").
115
- max_items: Maximum number of proposals to generate.
116
- """
117
- context = _gather_context(venture)
118
-
119
- # Build proposal prompt from context
120
- proposals = []
121
- open_titles = set(context["sources"].get("open_item_titles", []))
122
-
123
- # Strategy 1: Follow-through on completed work
124
- for done in context["sources"].get("completed_work", []):
125
- title = done.get("title", "")
126
- if venture and done.get("venture", "") != venture:
127
- continue
128
- # Suggest follow-up actions
129
- if "deploy" in title.lower() or "publish" in title.lower():
130
- follow_up = f"Verify deployment: {title}"
131
- if follow_up not in open_titles:
132
- proposals.append({
133
- "title": follow_up,
134
- "rationale": f"Follow-through: '{title}' was shipped but may need verification",
135
- "priority": "P1",
136
- "type": "task",
137
- "source": "ledger_propose:follow_through",
138
- })
139
- if "outreach" in title.lower() or "issue" in title.lower():
140
- follow_up = f"Monitor engagement: {title}"
141
- if follow_up not in open_titles:
142
- proposals.append({
143
- "title": follow_up,
144
- "rationale": f"Outreach needs monitoring for responses and engagement",
145
- "priority": "P1",
146
- "type": "task",
147
- "source": "ledger_propose:follow_through",
148
- })
149
-
150
- # Strategy 2: Act on unprocessed signals
151
- for sig in context["sources"].get("signals", []):
152
- sig_title = sig.get("title", "")
153
- if sig_title and sig_title not in open_titles:
154
- proposals.append({
155
- "title": f"Evaluate signal: {sig_title[:80]}",
156
- "rationale": f"Unprocessed {sig.get('type', 'signal')} from {sig.get('source', 'unknown')}",
157
- "priority": "P1",
158
- "type": "strategy",
159
- "source": "ledger_propose:signal",
160
- })
161
-
162
- # Strategy 3: Detect gaps
163
- has_tests = any("test" in t.lower() for t in open_titles)
164
- has_docs = any("doc" in t.lower() or "readme" in t.lower() for t in open_titles)
165
- has_security = any("security" in t.lower() or "audit" in t.lower() for t in open_titles)
166
-
167
- if not has_tests and focus != "outreach":
168
- proposals.append({
169
- "title": f"Run test coverage analysis{f' for {venture}' if venture else ''}",
170
- "rationale": "No test-related items in queue — ensure coverage hasn't regressed",
171
- "priority": "P1",
172
- "type": "task",
173
- "source": "ledger_propose:gap_detection",
174
- })
175
-
176
- if not has_security and focus != "outreach":
177
- proposals.append({
178
- "title": f"Security audit{f' for {venture}' if venture else ''}",
179
- "rationale": "No security items in queue — periodic audits prevent surprises",
180
- "priority": "P2",
181
- "type": "task",
182
- "source": "ledger_propose:gap_detection",
183
- })
184
-
185
- if not has_docs:
186
- proposals.append({
187
- "title": f"Documentation freshness check{f' for {venture}' if venture else ''}",
188
- "rationale": "No doc items in queue — ensure README/CHANGELOG reflect current state",
189
- "priority": "P2",
190
- "type": "task",
191
- "source": "ledger_propose:gap_detection",
192
- })
193
-
194
- # Apply focus filter
195
- if focus:
196
- focus_lower = focus.lower()
197
- proposals = [p for p in proposals if focus_lower in p.get("title", "").lower()
198
- or focus_lower in p.get("rationale", "").lower()
199
- or focus_lower in p.get("type", "").lower()]
200
-
201
- # Deduplicate and limit
202
- seen = set()
203
- unique = []
204
- for p in proposals:
205
- key = p["title"][:50]
206
- if key not in seen:
207
- seen.add(key)
208
- unique.append(p)
209
- proposals = unique[:max_items]
210
-
211
- # Save proposals for audit trail
212
- PROPOSALS_DIR.mkdir(parents=True, exist_ok=True)
213
- proposal_file = PROPOSALS_DIR / f"proposal_{int(time.time())}.json"
214
- proposal_file.write_text(json.dumps({
215
- "proposals": proposals,
216
- "context_summary": {
217
- "completed_work": len(context["sources"].get("completed_work", [])),
218
- "open_items": context["sources"].get("open_items_count", 0),
219
- "signals": len(context["sources"].get("signals", [])),
220
- "swarm_agents": context["sources"].get("swarm_agents", 0),
221
- },
222
- "venture": venture,
223
- "focus": focus,
224
- "generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
225
- }, indent=2))
226
-
227
- return {
228
- "status": "ok",
229
- "proposals": proposals,
230
- "total": len(proposals),
231
- "venture": venture or "all",
232
- "focus": focus or "none",
233
- "context": {
234
- "completed_work_analyzed": len(context["sources"].get("completed_work", [])),
235
- "open_items": context["sources"].get("open_items_count", 0),
236
- "signals_analyzed": len(context["sources"].get("signals", [])),
237
- },
238
- "message": f"Generated {len(proposals)} proposal(s). "
239
- "Use delimit_ledger_add to add approved items.",
240
- }
@@ -1,106 +0,0 @@
1
- import json
2
- import logging
3
- import os
4
- import urllib.parse
5
- import urllib.request
6
- from pathlib import Path
7
- from typing import Any, Dict, List, Optional
8
-
9
- logger = logging.getLogger("delimit.ai.reddit_proxy")
10
-
11
- def _get_proxy_config() -> Dict[str, str]:
12
- """Load proxy config from private secrets or environment."""
13
- config = {"proxy_url": ""}
14
-
15
- # 1. Check environment variable
16
- env_url = os.environ.get("DELIMIT_REDDIT_PROXY")
17
- if env_url:
18
- config["proxy_url"] = env_url
19
- return config
20
-
21
- # 2. Check private secrets file
22
- secrets_path = Path.home() / ".delimit" / "secrets" / "reddit-proxy.json"
23
- if secrets_path.exists():
24
- try:
25
- secrets = json.loads(secrets_path.read_text())
26
- config["proxy_url"] = secrets.get("proxy_url", "")
27
- except Exception as e:
28
- logger.debug(f"Failed to load reddit-proxy secrets: {e}")
29
-
30
- return config
31
-
32
- def fetch_subreddit(subreddit: str, sort: str = "new", limit: int = 10) -> List[Dict[str, Any]]:
33
- """
34
- Fetch posts from a single subreddit with fallback chain.
35
- Returns standardized post dicts.
36
- """
37
- reddit_url = f"https://www.reddit.com/r/{subreddit}/{sort}.json?limit={limit}&raw_json=1"
38
-
39
- # 1. Try Local Proxy (Residential IP)
40
- proxy_cfg = _get_proxy_config()
41
- proxy_url = proxy_cfg.get("proxy_url")
42
- if proxy_url:
43
- try:
44
- fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
45
- req = urllib.request.Request(fetch_url, headers={"User-Agent": "Delimit/1.0"})
46
- with urllib.request.urlopen(req, timeout=10) as resp:
47
- body = json.loads(resp.read().decode())
48
- children = body.get("data", {}).get("children", [])
49
- return [c.get("data", {}) for c in children if c.get("data")]
50
- except Exception as e:
51
- logger.debug(f"Local proxy failed for r/{subreddit}: {e}")
52
-
53
- # 2. Fallback: PullPush API (Public Archive)
54
- try:
55
- pp_url = f"https://api.pullpush.io/reddit/search/submission/?subreddit={subreddit}&size={limit}&sort=desc"
56
- req = urllib.request.Request(pp_url, headers={"User-Agent": "Delimit/1.0"})
57
- with urllib.request.urlopen(req, timeout=10) as resp:
58
- body = json.loads(resp.read().decode())
59
- return body.get("data", [])
60
- except Exception as e:
61
- logger.debug(f"PullPush fallback failed for r/{subreddit}: {e}")
62
-
63
- # 3. Fallback: Direct (Often blocked on servers)
64
- try:
65
- req = urllib.request.Request(reddit_url, headers={"User-Agent": "Mozilla/5.0 (Delimit)"})
66
- with urllib.request.urlopen(req, timeout=5) as resp:
67
- body = json.loads(resp.read().decode())
68
- children = body.get("data", {}).get("children", [])
69
- return [c.get("data", {}) for c in children if c.get("data")]
70
- except Exception as e:
71
- logger.warning(f"Direct fetch failed for r/{subreddit}: {e}")
72
-
73
- return []
74
-
75
- def fetch_thread(thread_id: str) -> Optional[Dict[str, Any]]:
76
- """
77
- Fetch a single Reddit thread by ID with fallback chain.
78
- """
79
- reddit_url = f"https://www.reddit.com/comments/{thread_id}.json?raw_json=1"
80
-
81
- # 1. Try Local Proxy
82
- proxy_cfg = _get_proxy_config()
83
- proxy_url = proxy_cfg.get("proxy_url")
84
- if proxy_url:
85
- try:
86
- fetch_url = f"{proxy_url}?url={urllib.parse.quote(reddit_url, safe='')}"
87
- req = urllib.request.Request(fetch_url, headers={"User-Agent": "Delimit/1.0"})
88
- with urllib.request.urlopen(req, timeout=10) as resp:
89
- data = json.loads(resp.read().decode())
90
- if isinstance(data, list) and len(data) > 0:
91
- return data[0].get("data", {}).get("children", [{}])[0].get("data", {})
92
- except Exception as e:
93
- logger.debug(f"Local proxy failed for thread {thread_id}: {e}")
94
-
95
- # 2. Fallback: PullPush
96
- try:
97
- pp_url = f"https://api.pullpush.io/reddit/search/submission/?ids={thread_id}"
98
- req = urllib.request.Request(pp_url, headers={"User-Agent": "Delimit/1.0"})
99
- with urllib.request.urlopen(req, timeout=10) as resp:
100
- body = json.loads(resp.read().decode())
101
- data = body.get("data", [])
102
- return data[0] if data else None
103
- except Exception as e:
104
- logger.debug(f"PullPush fallback failed for thread {thread_id}: {e}")
105
-
106
- return None
@@ -1,290 +0,0 @@
1
- """SIEM Streaming — forward audit trail events to external security tools.
2
-
3
- LED-280: Enterprise CISOs need audit logs in their existing SIEM, not just our dashboard.
4
- Supports Splunk HEC, Datadog Logs API, and AWS EventBridge.
5
-
6
- Config stored at ~/.delimit/siem.json. Each integration can be enabled independently.
7
- Events are forwarded in real-time as they're written to the audit trail.
8
- """
9
-
10
- import json
11
- import logging
12
- import os
13
- import time
14
- from pathlib import Path
15
- from typing import Any, Dict, List, Optional
16
-
17
- logger = logging.getLogger("delimit.ai.siem_streaming")
18
-
19
- SIEM_CONFIG_PATH = Path.home() / ".delimit" / "siem.json"
20
- SIEM_LOG_PATH = Path.home() / ".delimit" / "siem_delivery.jsonl"
21
-
22
- DEFAULT_CONFIG = {
23
- "splunk": {
24
- "enabled": False,
25
- "hec_url": "",
26
- "hec_token": "",
27
- "index": "delimit",
28
- "source": "delimit-governance",
29
- "sourcetype": "_json",
30
- },
31
- "datadog": {
32
- "enabled": False,
33
- "api_key": "",
34
- "site": "datadoghq.com",
35
- "service": "delimit",
36
- "source": "delimit-governance",
37
- "tags": ["env:production"],
38
- },
39
- "eventbridge": {
40
- "enabled": False,
41
- "bus_name": "default",
42
- "region": "us-east-1",
43
- "source": "delimit.governance",
44
- "detail_type": "GovernanceEvent",
45
- },
46
- "webhook": {
47
- "enabled": False,
48
- "url": "",
49
- "headers": {},
50
- "method": "POST",
51
- },
52
- }
53
-
54
-
55
- def _load_config() -> Dict[str, Any]:
56
- if SIEM_CONFIG_PATH.exists():
57
- try:
58
- return json.loads(SIEM_CONFIG_PATH.read_text())
59
- except json.JSONDecodeError:
60
- pass
61
- return dict(DEFAULT_CONFIG)
62
-
63
-
64
- def _save_config(config: Dict[str, Any]) -> None:
65
- SIEM_CONFIG_PATH.parent.mkdir(parents=True, exist_ok=True)
66
- SIEM_CONFIG_PATH.write_text(json.dumps(config, indent=2))
67
-
68
-
69
- def _log_delivery(integration: str, event_id: str, status: str, error: str = "") -> None:
70
- try:
71
- SIEM_LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
72
- entry = {
73
- "timestamp": time.time(),
74
- "integration": integration,
75
- "event_id": event_id,
76
- "status": status,
77
- "error": error,
78
- }
79
- with open(SIEM_LOG_PATH, "a") as f:
80
- f.write(json.dumps(entry) + "\n")
81
- except Exception:
82
- pass
83
-
84
-
85
- def configure(
86
- integration: str,
87
- settings: Optional[Dict[str, Any]] = None,
88
- enabled: Optional[bool] = None,
89
- ) -> Dict[str, Any]:
90
- """Configure a SIEM integration.
91
-
92
- Args:
93
- integration: splunk, datadog, eventbridge, or webhook
94
- settings: Key-value pairs to update (e.g. {"hec_url": "...", "hec_token": "..."})
95
- enabled: Enable or disable the integration
96
- """
97
- if integration not in DEFAULT_CONFIG:
98
- return {"error": f"Unknown integration: {integration}. Choose: splunk, datadog, eventbridge, webhook"}
99
-
100
- config = _load_config()
101
- if integration not in config:
102
- config[integration] = dict(DEFAULT_CONFIG[integration])
103
-
104
- if settings:
105
- config[integration].update(settings)
106
- if enabled is not None:
107
- config[integration]["enabled"] = enabled
108
-
109
- _save_config(config)
110
-
111
- # Mask secrets in response
112
- safe = dict(config[integration])
113
- for key in ("hec_token", "api_key"):
114
- if key in safe and safe[key]:
115
- safe[key] = safe[key][:4] + "***" + safe[key][-4:]
116
-
117
- return {
118
- "status": "configured",
119
- "integration": integration,
120
- "config": safe,
121
- }
122
-
123
-
124
- def get_status() -> Dict[str, Any]:
125
- """Get status of all SIEM integrations."""
126
- config = _load_config()
127
- integrations = {}
128
-
129
- for name, settings in config.items():
130
- if not isinstance(settings, dict):
131
- continue
132
- enabled = settings.get("enabled", False)
133
- healthy = False
134
-
135
- if enabled:
136
- if name == "splunk":
137
- healthy = bool(settings.get("hec_url") and settings.get("hec_token"))
138
- elif name == "datadog":
139
- healthy = bool(settings.get("api_key"))
140
- elif name == "eventbridge":
141
- healthy = bool(settings.get("bus_name"))
142
- elif name == "webhook":
143
- healthy = bool(settings.get("url"))
144
-
145
- integrations[name] = {
146
- "enabled": enabled,
147
- "healthy": healthy if enabled else None,
148
- "status": "active" if (enabled and healthy) else "misconfigured" if enabled else "disabled",
149
- }
150
-
151
- # Delivery stats
152
- delivery_count = 0
153
- delivery_errors = 0
154
- if SIEM_LOG_PATH.exists():
155
- for line in SIEM_LOG_PATH.read_text().strip().split("\n")[-100:]:
156
- try:
157
- entry = json.loads(line)
158
- delivery_count += 1
159
- if entry.get("status") == "error":
160
- delivery_errors += 1
161
- except json.JSONDecodeError:
162
- continue
163
-
164
- return {
165
- "integrations": integrations,
166
- "active_count": sum(1 for i in integrations.values() if i["status"] == "active"),
167
- "total_deliveries": delivery_count,
168
- "delivery_errors": delivery_errors,
169
- }
170
-
171
-
172
- def forward_event(event: Dict[str, Any]) -> Dict[str, Any]:
173
- """Forward a governance event to all enabled SIEM integrations.
174
-
175
- Called automatically by the audit trail when events are recorded.
176
- Returns delivery status per integration.
177
- """
178
- config = _load_config()
179
- results = {}
180
- event_id = event.get("id", str(time.time()))
181
-
182
- for name, settings in config.items():
183
- if not isinstance(settings, dict) or not settings.get("enabled"):
184
- continue
185
-
186
- try:
187
- if name == "splunk":
188
- results[name] = _forward_splunk(event, settings, event_id)
189
- elif name == "datadog":
190
- results[name] = _forward_datadog(event, settings, event_id)
191
- elif name == "eventbridge":
192
- results[name] = _forward_eventbridge(event, settings, event_id)
193
- elif name == "webhook":
194
- results[name] = _forward_webhook(event, settings, event_id)
195
- except Exception as e:
196
- results[name] = {"status": "error", "error": str(e)}
197
- _log_delivery(name, event_id, "error", str(e))
198
-
199
- return {
200
- "event_id": event_id,
201
- "forwarded_to": list(results.keys()),
202
- "results": results,
203
- }
204
-
205
-
206
- def _forward_splunk(event: Dict, settings: Dict, event_id: str) -> Dict:
207
- """Forward to Splunk via HTTP Event Collector (HEC)."""
208
- import urllib.request
209
-
210
- payload = json.dumps({
211
- "event": event,
212
- "source": settings.get("source", "delimit-governance"),
213
- "sourcetype": settings.get("sourcetype", "_json"),
214
- "index": settings.get("index", "delimit"),
215
- }).encode()
216
-
217
- req = urllib.request.Request(
218
- settings["hec_url"],
219
- data=payload,
220
- headers={
221
- "Authorization": f"Splunk {settings['hec_token']}",
222
- "Content-Type": "application/json",
223
- },
224
- )
225
- resp = urllib.request.urlopen(req, timeout=10)
226
- _log_delivery("splunk", event_id, "ok")
227
- return {"status": "ok", "http_code": resp.status}
228
-
229
-
230
- def _forward_datadog(event: Dict, settings: Dict, event_id: str) -> Dict:
231
- """Forward to Datadog Logs API."""
232
- import urllib.request
233
-
234
- site = settings.get("site", "datadoghq.com")
235
- payload = json.dumps([{
236
- "ddsource": settings.get("source", "delimit-governance"),
237
- "ddtags": ",".join(settings.get("tags", [])),
238
- "service": settings.get("service", "delimit"),
239
- "message": json.dumps(event),
240
- }]).encode()
241
-
242
- req = urllib.request.Request(
243
- f"https://http-intake.logs.{site}/api/v2/logs",
244
- data=payload,
245
- headers={
246
- "DD-API-KEY": settings["api_key"],
247
- "Content-Type": "application/json",
248
- },
249
- )
250
- resp = urllib.request.urlopen(req, timeout=10)
251
- _log_delivery("datadog", event_id, "ok")
252
- return {"status": "ok", "http_code": resp.status}
253
-
254
-
255
- def _forward_eventbridge(event: Dict, settings: Dict, event_id: str) -> Dict:
256
- """Forward to AWS EventBridge."""
257
- try:
258
- import boto3
259
- client = boto3.client("events", region_name=settings.get("region", "us-east-1"))
260
- response = client.put_events(Entries=[{
261
- "Source": settings.get("source", "delimit.governance"),
262
- "DetailType": settings.get("detail_type", "GovernanceEvent"),
263
- "Detail": json.dumps(event),
264
- "EventBusName": settings.get("bus_name", "default"),
265
- }])
266
- failed = response.get("FailedEntryCount", 0)
267
- status = "ok" if failed == 0 else "partial"
268
- _log_delivery("eventbridge", event_id, status)
269
- return {"status": status, "failed_entries": failed}
270
- except ImportError:
271
- _log_delivery("eventbridge", event_id, "error", "boto3 not installed")
272
- return {"status": "error", "error": "boto3 not installed. Run: pip install boto3"}
273
-
274
-
275
- def _forward_webhook(event: Dict, settings: Dict, event_id: str) -> Dict:
276
- """Forward to a generic webhook URL."""
277
- import urllib.request
278
-
279
- headers = {"Content-Type": "application/json"}
280
- headers.update(settings.get("headers", {}))
281
-
282
- req = urllib.request.Request(
283
- settings["url"],
284
- data=json.dumps(event).encode(),
285
- headers=headers,
286
- method=settings.get("method", "POST"),
287
- )
288
- resp = urllib.request.urlopen(req, timeout=10)
289
- _log_delivery("webhook", event_id, "ok")
290
- return {"status": "ok", "http_code": resp.status}