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.
- package/CHANGELOG.md +26 -0
- package/README.md +34 -3
- package/bin/delimit-cli.js +150 -2
- package/bin/delimit-setup.js +22 -7
- package/gateway/ai/agent_dispatch.py +79 -0
- package/gateway/ai/daily_digest.py +386 -0
- package/gateway/ai/ledger_manager.py +32 -0
- package/gateway/ai/license_core.py +2 -0
- package/gateway/ai/notify.py +17 -11
- package/gateway/ai/reddit_proxy.py +28 -9
- package/gateway/ai/sensing/__init__.py +35 -0
- package/gateway/ai/sensing/schema.py +107 -0
- package/gateway/ai/sensing/signal_store.py +348 -0
- package/gateway/ai/server.py +419 -6
- package/gateway/ai/supabase_sync.py +308 -0
- package/gateway/ai/work_order.py +216 -0
- package/gateway/ai/workers/__init__.py +32 -0
- package/gateway/ai/workers/base.py +154 -0
- package/gateway/ai/workers/executor.py +861 -0
- package/gateway/ai/workers/outreach_drafter.py +161 -0
- package/gateway/ai/workers/pr_drafter.py +148 -0
- package/lib/ai-sbom-engine.js +154 -0
- package/lib/trust-page-engine.js +179 -0
- package/lib/wrap-engine.js +431 -0
- package/package.json +14 -1
- package/adapters/codex-security.js +0 -64
- package/adapters/codex-skill.js +0 -78
- package/adapters/cursor-rules.js +0 -73
- package/gateway/ai/continuity.py +0 -462
- package/gateway/ai/inbox_daemon_runner.py +0 -217
- package/gateway/ai/loop_engine.py +0 -1303
- package/gateway/ai/social_cache.py +0 -341
- package/gateway/ai/social_daemon.py +0 -483
- package/gateway/ai/tweet_corpus_schema.sql +0 -76
- package/scripts/crosspost_devto.py +0 -304
- package/scripts/demo-v420-clean.sh +0 -267
- package/scripts/demo-v420-deliberation.sh +0 -217
- package/scripts/demo-v420.sh +0 -55
- 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
|
+
]
|