delimit-cli 4.1.53 → 4.3.0

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 (39) hide show
  1. package/CHANGELOG.md +26 -0
  2. package/README.md +34 -3
  3. package/bin/delimit-cli.js +150 -2
  4. package/bin/delimit-setup.js +22 -7
  5. package/gateway/ai/agent_dispatch.py +79 -0
  6. package/gateway/ai/daily_digest.py +386 -0
  7. package/gateway/ai/ledger_manager.py +32 -0
  8. package/gateway/ai/license_core.py +2 -0
  9. package/gateway/ai/notify.py +17 -11
  10. package/gateway/ai/reddit_proxy.py +28 -9
  11. package/gateway/ai/sensing/__init__.py +35 -0
  12. package/gateway/ai/sensing/schema.py +107 -0
  13. package/gateway/ai/sensing/signal_store.py +348 -0
  14. package/gateway/ai/server.py +419 -6
  15. package/gateway/ai/supabase_sync.py +308 -0
  16. package/gateway/ai/work_order.py +216 -0
  17. package/gateway/ai/workers/__init__.py +32 -0
  18. package/gateway/ai/workers/base.py +154 -0
  19. package/gateway/ai/workers/executor.py +861 -0
  20. package/gateway/ai/workers/outreach_drafter.py +161 -0
  21. package/gateway/ai/workers/pr_drafter.py +148 -0
  22. package/lib/ai-sbom-engine.js +154 -0
  23. package/lib/trust-page-engine.js +179 -0
  24. package/lib/wrap-engine.js +431 -0
  25. package/package.json +14 -1
  26. package/adapters/codex-security.js +0 -64
  27. package/adapters/codex-skill.js +0 -78
  28. package/adapters/cursor-rules.js +0 -73
  29. package/gateway/ai/continuity.py +0 -462
  30. package/gateway/ai/inbox_daemon_runner.py +0 -217
  31. package/gateway/ai/loop_engine.py +0 -1303
  32. package/gateway/ai/social_cache.py +0 -341
  33. package/gateway/ai/social_daemon.py +0 -483
  34. package/gateway/ai/tweet_corpus_schema.sql +0 -76
  35. package/scripts/crosspost_devto.py +0 -304
  36. package/scripts/demo-v420-clean.sh +0 -267
  37. package/scripts/demo-v420-deliberation.sh +0 -217
  38. package/scripts/demo-v420.sh +0 -55
  39. package/scripts/sync-gateway.sh +0 -112
@@ -29,6 +29,33 @@ if not SUPABASE_URL:
29
29
  pass
30
30
 
31
31
 
32
+ _VENTURE_CANONICAL = {
33
+ "delimit": "delimit",
34
+ "domainvested": "domainvested",
35
+ "domain_vested": "domainvested",
36
+ "dv": "domainvested",
37
+ "wirereport": "wirereport",
38
+ "wire_report": "wirereport",
39
+ "wire.report": "wirereport",
40
+ "wr": "wirereport",
41
+ "livetube": "livetube",
42
+ "livetubeai": "livetube",
43
+ "livetube.ai": "livetube",
44
+ "lt": "livetube",
45
+ }
46
+
47
+
48
+ def _normalize_venture(value) -> str:
49
+ """LED-1008: map freeform venture strings into the canonical 4-member
50
+ vocabulary the Inbox + Ventures surfaces expect. Blank stays blank
51
+ (unattributed). Unknown values pass through lowercased so we notice
52
+ them in the dashboard instead of silently losing them."""
53
+ if not value:
54
+ return ""
55
+ key = str(value).strip().lower()
56
+ return _VENTURE_CANONICAL.get(key, key)
57
+
58
+
32
59
  def _get_client():
33
60
  """Lazy-init Supabase client. Returns the SDK client, 'http' for fallback, or None."""
34
61
  global _client, _init_attempted
@@ -188,3 +215,284 @@ def sync_ledger_update(item_id: str, status: str, note: str = ""):
188
215
  client.table("ledger_items").update(update).eq("id", item_id).execute()
189
216
  except Exception as e:
190
217
  logger.debug(f"Ledger update sync failed: {e}")
218
+
219
+
220
+ def sync_work_order(wo: dict):
221
+ """Sync a work order to Supabase for dashboard inbox.
222
+
223
+ Gateway-local work order (from ai.work_order.create_work_order) maps to
224
+ the Supabase work_orders table (migration 020).
225
+ """
226
+ try:
227
+ client = _get_client()
228
+ if client is None:
229
+ return
230
+ row = {
231
+ "id": wo.get("id", ""),
232
+ "title": wo.get("title", ""),
233
+ "goal": wo.get("goal", ""),
234
+ "context": wo.get("context", ""),
235
+ "steps": wo.get("steps", []),
236
+ "acceptance_criteria": wo.get("acceptance_criteria", []),
237
+ "ledger_item_id": wo.get("ledger_item_id", ""),
238
+ "priority": wo.get("priority", "P1"),
239
+ "tools_needed": wo.get("tools_needed", []),
240
+ "estimated_minutes": wo.get("estimated_minutes", 15),
241
+ "worker_type": wo.get("worker_type", ""),
242
+ "status": wo.get("status", "pending"),
243
+ "preview": wo.get("preview", "")[:2000],
244
+ "artifact_path": wo.get("filepath", ""),
245
+ "executable_actions": wo.get("executable_actions", []),
246
+ "execution_status": wo.get("execution_status", ""),
247
+ "execution_log": wo.get("execution_log", []),
248
+ "executed_at": wo.get("executed_at"),
249
+ "executed_by": wo.get("executed_by", ""),
250
+ "venture": _normalize_venture(wo.get("venture", "")),
251
+ }
252
+ if not row["id"] or not row["title"]:
253
+ return
254
+ if client == "http":
255
+ _http_post(
256
+ "work_orders",
257
+ row,
258
+ headers_extra={
259
+ "Prefer": "resolution=merge-duplicates,return=minimal",
260
+ },
261
+ )
262
+ else:
263
+ client.table("work_orders").upsert(row).execute()
264
+
265
+ # LED-977 (rescoped): push notification on new pending WO. Safe to
266
+ # call every time sync runs; notify_new_work_order dedupes via the
267
+ # sent-marker file so only the initial pending insert pushes.
268
+ try:
269
+ notify_new_work_order(row)
270
+ except Exception as exc:
271
+ logger.debug("notify_new_work_order hook failed: %s", exc)
272
+ except Exception as e:
273
+ logger.debug(f"Work order sync failed: {e}")
274
+
275
+
276
+ def sync_deliberation(delib: dict):
277
+ """Sync a deliberation transcript summary to Supabase."""
278
+ try:
279
+ client = _get_client()
280
+ if client is None:
281
+ return
282
+ row = {
283
+ "id": delib.get("id") or delib.get("transcript_saved", "").split("/")[-1].replace(".json", ""),
284
+ "question": delib.get("question", "")[:2000],
285
+ "context": delib.get("context", "")[:2000],
286
+ "scope": delib.get("scope", ""),
287
+ "models_participated": delib.get("models_participated", []),
288
+ "rounds": delib.get("rounds", 0),
289
+ "status": delib.get("status", "unknown"),
290
+ "final_verdict": delib.get("final_verdict", "")[:4000],
291
+ "transcript_path": delib.get("transcript_saved", ""),
292
+ "ledger_items_created": delib.get("ledger_items_created", []),
293
+ "venture": _normalize_venture(delib.get("venture", "")),
294
+ }
295
+ if not row["id"] or not row["question"]:
296
+ return
297
+ if client == "http":
298
+ _http_post(
299
+ "deliberations",
300
+ row,
301
+ headers_extra={
302
+ "Prefer": "resolution=merge-duplicates,return=minimal",
303
+ },
304
+ )
305
+ else:
306
+ client.table("deliberations").upsert(row).execute()
307
+ except Exception as e:
308
+ logger.debug(f"Deliberation sync failed: {e}")
309
+
310
+
311
+ # ---------------------------------------------------------------------------
312
+ # LED-977 (rescoped): push notifications on new work-order approvals.
313
+ # Uses ntfy.sh — free, open-source, zero account/API-key management. User
314
+ # installs the ntfy mobile app (iOS/Android), subscribes to their private
315
+ # topic URL, and taps the push to deep-link into the dashboard inbox.
316
+ # ---------------------------------------------------------------------------
317
+
318
+ NTFY_ENV_KEY = "DELIMIT_NTFY_TOPIC"
319
+ NTFY_BASE_URL = os.environ.get("DELIMIT_NTFY_BASE_URL", "https://ntfy.sh")
320
+ NTFY_CLICK_URL = os.environ.get(
321
+ "DELIMIT_NTFY_CLICK_URL",
322
+ "https://app.delimit.ai/dashboard/inbox",
323
+ )
324
+ # Cache so we only send one push per WO — a WO gets upserted every time its
325
+ # status changes (approved, executed, etc), but the push is only meaningful
326
+ # on the initial pending insert.
327
+ _NTFY_SENT_FILE = Path.home() / ".delimit" / "notifications" / "ntfy_sent.jsonl"
328
+
329
+
330
+ def _ntfy_already_sent(wo_id: str) -> bool:
331
+ """Check if we've already pushed a notification for this WO."""
332
+ if not _NTFY_SENT_FILE.exists():
333
+ return False
334
+ try:
335
+ with _NTFY_SENT_FILE.open() as fh:
336
+ for line in fh:
337
+ try:
338
+ if json.loads(line).get("wo_id") == wo_id:
339
+ return True
340
+ except Exception:
341
+ continue
342
+ except Exception:
343
+ pass
344
+ return False
345
+
346
+
347
+ def _ntfy_record_sent(wo_id: str, title: str) -> None:
348
+ """Append a sent marker so the next sync for the same WO is a no-op."""
349
+ try:
350
+ _NTFY_SENT_FILE.parent.mkdir(parents=True, exist_ok=True)
351
+ with _NTFY_SENT_FILE.open("a") as fh:
352
+ fh.write(json.dumps({
353
+ "wo_id": wo_id,
354
+ "title": title[:80],
355
+ "ts": os.environ.get("_NTFY_TS_OVERRIDE") or __import__("time").strftime("%Y-%m-%dT%H:%M:%SZ"),
356
+ }) + "\n")
357
+ except Exception as exc:
358
+ logger.debug("ntfy sent-marker write failed: %s", exc)
359
+
360
+
361
+ def notify_new_work_order(wo: dict) -> None:
362
+ """Fire a push when a brand-new pending work order lands.
363
+
364
+ Fire-and-forget. Silent no-op when DELIMIT_NTFY_TOPIC isn't set so this
365
+ is opt-in — the service keeps working without it.
366
+ """
367
+ topic = os.environ.get(NTFY_ENV_KEY, "").strip()
368
+ if not topic:
369
+ return
370
+ if wo.get("status") != "pending":
371
+ return
372
+ wo_id = wo.get("id", "")
373
+ if not wo_id or _ntfy_already_sent(wo_id):
374
+ return
375
+
376
+ title = wo.get("title") or "New work order"
377
+ priority = str(wo.get("priority") or "P2").upper()
378
+ worker = wo.get("worker_type") or "worker"
379
+ body_lines = [wo.get("goal", "")[:200]]
380
+ if wo.get("ledger_item_id"):
381
+ body_lines.append(f"Source: {wo['ledger_item_id']}")
382
+ body_lines.append(f"Drafted by: {worker}")
383
+ body = "\n".join(l for l in body_lines if l).strip()
384
+
385
+ try:
386
+ import urllib.request
387
+ url = f"{NTFY_BASE_URL.rstrip('/')}/{topic}"
388
+ req = urllib.request.Request(
389
+ url,
390
+ data=body.encode("utf-8"),
391
+ headers={
392
+ "Title": f"[Delimit {priority}] {title}"[:180],
393
+ "Tags": "memo" if priority == "P2" else "warning",
394
+ "Click": NTFY_CLICK_URL,
395
+ "Priority": {"P0": "5", "P1": "4", "P2": "3"}.get(priority, "3"),
396
+ },
397
+ method="POST",
398
+ )
399
+ with urllib.request.urlopen(req, timeout=5) as resp:
400
+ if 200 <= resp.status < 300:
401
+ _ntfy_record_sent(wo_id, title)
402
+ logger.info("ntfy push sent wo=%s", wo_id)
403
+ else:
404
+ logger.warning("ntfy push unexpected status: %s", resp.status)
405
+ except Exception as exc:
406
+ logger.warning("ntfy push failed for %s: %s", wo_id, exc)
407
+
408
+
409
+ def sync_social_draft(draft: dict):
410
+ """Sync a social draft row to Supabase + optional ntfy push.
411
+
412
+ `draft` is the same shape save_draft() writes to social_drafts.jsonl.
413
+ Fire-and-forget like the other sync helpers — an outage here never
414
+ blocks the drafting worker.
415
+ """
416
+ try:
417
+ client = _get_client()
418
+ if client is None:
419
+ return
420
+ row = {
421
+ "draft_id": draft.get("draft_id", ""),
422
+ "platform": draft.get("platform", ""),
423
+ "account": draft.get("account", ""),
424
+ "text": draft.get("text", ""),
425
+ "thread_url": draft.get("thread_url", ""),
426
+ "context": draft.get("context", ""),
427
+ "source_fingerprint": draft.get("source_fingerprint", ""),
428
+ "quality": draft.get("quality", "review"),
429
+ "status": draft.get("status", "pending"),
430
+ "quote_tweet_id": draft.get("quote_tweet_id", ""),
431
+ "reply_to_id": draft.get("reply_to_id", ""),
432
+ "conversion_target": draft.get("conversion_target", ""),
433
+ "notification_message_id": draft.get("notification_message_id", ""),
434
+ "timestamp": draft.get("timestamp"),
435
+ "venture": _normalize_venture(draft.get("venture", "")),
436
+ }
437
+ if not row["draft_id"] or not row["text"]:
438
+ return
439
+ if client == "http":
440
+ _http_post(
441
+ "social_drafts",
442
+ row,
443
+ headers_extra={
444
+ "Prefer": "resolution=merge-duplicates,return=minimal",
445
+ },
446
+ )
447
+ else:
448
+ client.table("social_drafts").upsert(row).execute()
449
+
450
+ # ntfy on NEW pending drafts only, dedupe via the WO sent-marker file
451
+ # (reused — scoped by draft_id vs wo_id so no collision)
452
+ if row["status"] == "pending":
453
+ try:
454
+ _push_draft_notification(row)
455
+ except Exception as exc:
456
+ logger.debug("draft ntfy failed: %s", exc)
457
+ except Exception as e:
458
+ logger.debug(f"Social draft sync failed: {e}")
459
+
460
+
461
+ def _push_draft_notification(row: dict) -> None:
462
+ """Fire a medium-priority ntfy when a new pending draft lands."""
463
+ import time as _time
464
+ topic = os.environ.get(NTFY_ENV_KEY, "").strip()
465
+ if not topic:
466
+ return
467
+ draft_id = row.get("draft_id", "")
468
+ if not draft_id or _ntfy_already_sent(draft_id):
469
+ return
470
+ platform = (row.get("platform") or "?").upper()
471
+ quality = row.get("quality") or "?"
472
+ title = f"[Delimit DRAFT {platform}/{quality}]"
473
+ body_preview = (row.get("text") or "").replace("\n", " ")[:220]
474
+ body_lines = [
475
+ f"Thread: {row.get('thread_url', '?')}",
476
+ f"Account: {row.get('account', '?')}",
477
+ "",
478
+ body_preview,
479
+ ]
480
+ try:
481
+ import urllib.request as _ur
482
+ req = _ur.Request(
483
+ f"{NTFY_BASE_URL.rstrip('/')}/{topic}",
484
+ data="\n".join(body_lines).encode(),
485
+ headers={
486
+ "Title": title[:180],
487
+ "Tags": "memo" if quality == "ready" else "warning",
488
+ "Click": "https://app.delimit.ai/dashboard/inbox",
489
+ "Priority": "4" if quality == "ready" else "3",
490
+ },
491
+ method="POST",
492
+ )
493
+ with _ur.urlopen(req, timeout=5) as resp:
494
+ if 200 <= resp.status < 300:
495
+ _ntfy_record_sent(draft_id, row.get("text", "")[:80])
496
+ logger.info("draft ntfy sent draft_id=%s", draft_id)
497
+ except Exception as exc:
498
+ logger.warning("draft ntfy push failed for %s: %s", draft_id, exc)
@@ -0,0 +1,216 @@
1
+ """Work-order generator (STR-177 Week 3-4).
2
+
3
+ Converts deliberation TASK outputs into structured markdown files the
4
+ founder can copy-paste into an interactive Claude Code session. Bridges
5
+ the gap between "strategy decided this" and "founder executes this."
6
+
7
+ Work orders live at ~/.delimit/work-orders/WO-YYYY-MM-DD-NNN.md and
8
+ are surfaced via the daily digest + delimit_digest MCP tool.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import json
14
+ import time
15
+ from datetime import datetime, timezone
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ WORK_ORDERS_DIR = Path.home() / ".delimit" / "work-orders"
20
+
21
+
22
+ def _ensure_dir():
23
+ WORK_ORDERS_DIR.mkdir(parents=True, exist_ok=True)
24
+
25
+
26
+ def _next_id() -> str:
27
+ _ensure_dir()
28
+ today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
29
+ existing = sorted(WORK_ORDERS_DIR.glob(f"WO-{today}-*.md"))
30
+ n = len(existing) + 1
31
+ return f"WO-{today}-{n:03d}"
32
+
33
+
34
+ def create_work_order(
35
+ title: str,
36
+ goal: str,
37
+ context: str = "",
38
+ steps: Optional[List[str]] = None,
39
+ acceptance_criteria: Optional[List[str]] = None,
40
+ ledger_item_id: str = "",
41
+ deliberation_ref: str = "",
42
+ priority: str = "P1",
43
+ estimated_minutes: int = 0,
44
+ tools_needed: Optional[List[str]] = None,
45
+ worker_type: str = "",
46
+ executable_actions: Optional[List[Dict[str, Any]]] = None,
47
+ ) -> Dict[str, Any]:
48
+ """Create a work-order markdown file.
49
+
50
+ Returns dict with the work-order ID, file path, and a preview.
51
+ """
52
+ _ensure_dir()
53
+ wo_id = _next_id()
54
+ now = datetime.now(timezone.utc).isoformat()
55
+
56
+ lines = [
57
+ f"# {wo_id}: {title}",
58
+ "",
59
+ f"**Priority**: {priority}",
60
+ f"**Created**: {now[:19]}Z",
61
+ ]
62
+ if estimated_minutes:
63
+ lines.append(f"**Estimated**: ~{estimated_minutes} min")
64
+ if ledger_item_id:
65
+ lines.append(f"**Ledger**: {ledger_item_id}")
66
+ if deliberation_ref:
67
+ lines.append(f"**Deliberation**: {deliberation_ref}")
68
+ lines.append("")
69
+
70
+ lines.extend([
71
+ "## Goal",
72
+ "",
73
+ goal,
74
+ "",
75
+ ])
76
+
77
+ if context:
78
+ lines.extend([
79
+ "## Context",
80
+ "",
81
+ context,
82
+ "",
83
+ ])
84
+
85
+ if steps:
86
+ lines.extend([
87
+ "## Steps",
88
+ "",
89
+ ])
90
+ for i, step in enumerate(steps, 1):
91
+ if step.startswith("```") or step.startswith(" "):
92
+ lines.append(step)
93
+ else:
94
+ lines.append(f"{i}. {step}")
95
+ lines.append("")
96
+
97
+ if tools_needed:
98
+ lines.extend([
99
+ "## Tools needed",
100
+ "",
101
+ ])
102
+ for tool in tools_needed:
103
+ lines.append(f"- `{tool}`")
104
+ lines.append("")
105
+
106
+ if acceptance_criteria:
107
+ lines.extend([
108
+ "## Acceptance criteria",
109
+ "",
110
+ ])
111
+ for ac in acceptance_criteria:
112
+ lines.append(f"- [ ] {ac}")
113
+ lines.append("")
114
+
115
+ lines.extend([
116
+ "## How to execute",
117
+ "",
118
+ "Copy this work order into a Claude Code session:",
119
+ "",
120
+ "```",
121
+ f"Execute work order {wo_id}: {title}",
122
+ f"Goal: {goal}",
123
+ "```",
124
+ "",
125
+ "Or run specific steps manually from the Steps section above.",
126
+ "",
127
+ "---",
128
+ f"Generated by Delimit work-order system at {now}",
129
+ ])
130
+
131
+ content = "\n".join(lines)
132
+ filepath = WORK_ORDERS_DIR / f"{wo_id}.md"
133
+ filepath.write_text(content)
134
+
135
+ # Validate executable_actions against the executor whitelist so a worker
136
+ # can't smuggle an unsupported action through. A drafting worker that
137
+ # emits a bad action should fail loud at draft time, not later.
138
+ executable_actions = executable_actions or []
139
+ if executable_actions:
140
+ try:
141
+ from ai.workers.executor import validate_actions
142
+ errors = validate_actions(executable_actions)
143
+ if errors:
144
+ raise ValueError(
145
+ "executable_actions failed validation: " + "; ".join(errors)
146
+ )
147
+ except ImportError:
148
+ # Executor module not available (e.g. in a stripped bundle); drop
149
+ # the actions rather than shipping unvalidated data.
150
+ executable_actions = []
151
+
152
+ # Also write a JSON sidecar for machine consumption
153
+ meta = {
154
+ "id": wo_id,
155
+ "title": title,
156
+ "goal": goal,
157
+ "context": context,
158
+ "steps": steps or [],
159
+ "acceptance_criteria": acceptance_criteria or [],
160
+ "tools_needed": tools_needed or [],
161
+ "estimated_minutes": estimated_minutes or 15,
162
+ "priority": priority,
163
+ "ledger_item_id": ledger_item_id,
164
+ "deliberation_ref": deliberation_ref,
165
+ "worker_type": worker_type,
166
+ "executable_actions": executable_actions,
167
+ "created_at": now,
168
+ "status": "pending",
169
+ "filepath": str(filepath),
170
+ "preview": content[:2000],
171
+ }
172
+ (WORK_ORDERS_DIR / f"{wo_id}.json").write_text(json.dumps(meta, indent=2))
173
+
174
+ # Fire-and-forget sync to Supabase so the dashboard approval inbox
175
+ # can surface this work order to Pro users.
176
+ try:
177
+ from ai.supabase_sync import sync_work_order
178
+ sync_work_order(meta)
179
+ except Exception:
180
+ pass
181
+
182
+ return {
183
+ "id": wo_id,
184
+ "filepath": str(filepath),
185
+ "preview": content[:500],
186
+ }
187
+
188
+
189
+ def list_work_orders(status: str = "pending", limit: int = 10) -> List[Dict[str, Any]]:
190
+ """List work orders filtered by status."""
191
+ _ensure_dir()
192
+ results = []
193
+ for jf in sorted(WORK_ORDERS_DIR.glob("WO-*.json"), reverse=True):
194
+ try:
195
+ meta = json.loads(jf.read_text())
196
+ if status == "all" or meta.get("status") == status:
197
+ results.append(meta)
198
+ if len(results) >= limit:
199
+ break
200
+ except Exception:
201
+ continue
202
+ return results
203
+
204
+
205
+ def complete_work_order(wo_id: str, note: str = "") -> Dict[str, Any]:
206
+ """Mark a work order as completed."""
207
+ jf = WORK_ORDERS_DIR / f"{wo_id}.json"
208
+ if not jf.exists():
209
+ return {"error": f"work order {wo_id} not found"}
210
+ meta = json.loads(jf.read_text())
211
+ meta["status"] = "completed"
212
+ meta["completed_at"] = datetime.now(timezone.utc).isoformat()
213
+ if note:
214
+ meta["completion_note"] = note
215
+ jf.write_text(json.dumps(meta, indent=2))
216
+ return meta
@@ -0,0 +1,32 @@
1
+ """Worker pool v1 — read-only bounded agents (LED-975, LED-976).
2
+
3
+ Workers are specialized and sandboxed per the swarm charter:
4
+ - Each worker has a bounded capability surface
5
+ - Cannot escalate without re-deliberation
6
+ - Cannot write files, commit, push, or modify state
7
+ - Output is always an artifact (work order) for founder approval
8
+
9
+ The daemon dispatches work to workers. Workers produce artifacts.
10
+ The founder approves artifacts from their interactive session or
11
+ mobile PWA. Approved artifacts get executed.
12
+
13
+ This is ledger-based, not time-based: workers pull from the ledger,
14
+ produce work orders, and the ledger tracks completion.
15
+ """
16
+
17
+ from ai.workers.base import Worker, WorkerResult
18
+ from ai.workers.pr_drafter import PRDrafterWorker
19
+ from ai.workers.outreach_drafter import OutreachDrafterWorker
20
+
21
+ WORKER_REGISTRY = {
22
+ "pr_drafter": PRDrafterWorker,
23
+ "outreach_drafter": OutreachDrafterWorker,
24
+ }
25
+
26
+ __all__ = [
27
+ "Worker",
28
+ "WorkerResult",
29
+ "PRDrafterWorker",
30
+ "OutreachDrafterWorker",
31
+ "WORKER_REGISTRY",
32
+ ]