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
@@ -1,1303 +0,0 @@
1
- """Governed Executor for Continuous Build (LED-239).
2
-
3
- Requirements (Consensus 123):
4
- - root ledger in /root/.delimit is authoritative
5
- - select only build-safe open items (feat, fix, task)
6
- - resolve venture + repo before dispatch
7
- - use Delimit swarm/governance as control plane
8
- - every iteration must update ledger, audit trail, and session state
9
- - no deploy/secrets/destructive actions without explicit gate
10
- - enforce max-iteration, max-error, and max-cost safeguards
11
- """
12
-
13
- import json
14
- import logging
15
- from datetime import datetime, timezone
16
- import os
17
- import time
18
- import uuid
19
- from pathlib import Path
20
- from typing import Any, Dict, List, Optional
21
-
22
- logger = logging.getLogger("delimit.ai.loop_engine")
23
-
24
- # ── Configuration ────────────────────────────────────────────────────
25
- ROOT_LEDGER_PATH = Path("/root/.delimit")
26
- BUILD_SAFE_TYPES = ["feat", "fix", "task"]
27
- SOCIAL_SAFE_TYPES = ["social", "outreach", "content", "sensor", "strategy"]
28
- SIGNAL_TYPES = ["strategy"] # Web scanner signals eligible for triage
29
- MAX_ITERATIONS_DEFAULT = 10
30
- MAX_COST_DEFAULT = 2.0
31
- MAX_ERRORS_DEFAULT = 2
32
- SOCIAL_SCAN_PLATFORMS = ["reddit", "x", "hn", "devto", "github", "web"]
33
- SOCIAL_SCAN_VENTURES = ["delimit"]
34
-
35
- # Web scanner signal prefixes (from social_target._scan_web)
36
- WEB_SIGNAL_PREFIXES = {
37
- "competitor": "[COMPETITOR RELEASE]",
38
- "ecosystem": "[ECOSYSTEM]",
39
- "npm": "[NPM]",
40
- "venture": "[VENTURE SIGNAL]",
41
- }
42
-
43
- # LED-788: timeouts + observability for the social loop
44
- SOCIAL_ITERATION_TIMEOUT = int(os.environ.get("DELIMIT_SOCIAL_ITERATION_TIMEOUT", "300")) # 5 min
45
- SOCIAL_STRATEGY_TIMEOUT = int(os.environ.get("DELIMIT_SOCIAL_STRATEGY_TIMEOUT", "120")) # 2 min
46
- SOCIAL_SCAN_TIMEOUT = int(os.environ.get("DELIMIT_SOCIAL_SCAN_TIMEOUT", "180")) # 3 min total for all platform scans
47
-
48
- # ── Session State ────────────────────────────────────────────────────
49
- SESSION_DIR = Path.home() / ".delimit" / "loop" / "sessions"
50
- HEARTBEAT_DIR = Path.home() / ".delimit" / "loop" / "heartbeat"
51
-
52
-
53
- def _ensure_heartbeat_dir():
54
- HEARTBEAT_DIR.mkdir(parents=True, exist_ok=True)
55
-
56
-
57
- def _write_heartbeat(session_id: str, stage: str, extra: Optional[Dict[str, Any]] = None) -> None:
58
- """LED-788: record the current loop stage + elapsed time.
59
-
60
- delimit_loop_status reads this so callers can see where an in-flight
61
- iteration is actually spending its time instead of staring at a stale
62
- snapshot of the last completed iteration.
63
- """
64
- try:
65
- _ensure_heartbeat_dir()
66
- payload = {
67
- "session_id": session_id,
68
- "stage": stage,
69
- "started_at": datetime.now(timezone.utc).isoformat(),
70
- "ts": time.time(),
71
- }
72
- if extra:
73
- payload.update(extra)
74
- path = HEARTBEAT_DIR / f"{session_id}.json"
75
- path.write_text(json.dumps(payload, indent=2))
76
- except OSError as e:
77
- logger.debug("heartbeat write failed: %s", e)
78
-
79
-
80
- def _read_heartbeat(session_id: str) -> Optional[Dict[str, Any]]:
81
- try:
82
- path = HEARTBEAT_DIR / f"{session_id}.json"
83
- if not path.exists():
84
- return None
85
- data = json.loads(path.read_text())
86
- # Augment with elapsed seconds for the currently-running stage
87
- if "ts" in data:
88
- data["elapsed_seconds"] = round(time.time() - data["ts"], 1)
89
- return data
90
- except (OSError, json.JSONDecodeError):
91
- return None
92
-
93
-
94
- def _run_stage_with_timeout(
95
- stage: str,
96
- fn,
97
- timeout_s: int,
98
- session_id: str = "",
99
- ) -> Dict[str, Any]:
100
- """Run a callable with a wall-clock timeout and timing instrumentation.
101
-
102
- Uses ThreadPoolExecutor so a hung HTTP client can be abandoned without
103
- killing the whole loop process. Returns a dict with keys:
104
- - ok: bool
105
- - value: return value on success
106
- - error: error string on failure
107
- - elapsed_seconds: wall-clock time
108
- - timed_out: True if the wall-clock deadline was hit
109
- """
110
- import threading
111
-
112
- start = time.time()
113
- if session_id:
114
- _write_heartbeat(session_id, stage)
115
- logger.info("[loop] stage=%s start timeout=%ss", stage, timeout_s)
116
-
117
- container: Dict[str, Any] = {"value": None, "error": None}
118
-
119
- def _runner():
120
- try:
121
- container["value"] = fn()
122
- except Exception as _exc: # noqa: BLE001 — intentional broad catch
123
- container["error"] = _exc
124
-
125
- # Daemon thread so a hung worker cannot block interpreter shutdown.
126
- worker = threading.Thread(target=_runner, name=f"loop-stage-{stage}", daemon=True)
127
- worker.start()
128
- worker.join(timeout=timeout_s)
129
- elapsed = time.time() - start
130
-
131
- if worker.is_alive():
132
- logger.error("[loop] stage=%s TIMEOUT after %.1fs (limit=%ss)", stage, elapsed, timeout_s)
133
- return {
134
- "ok": False,
135
- "error": f"{stage} exceeded {timeout_s}s timeout",
136
- "elapsed_seconds": round(elapsed, 1),
137
- "timed_out": True,
138
- }
139
- if container["error"] is not None:
140
- logger.error("[loop] stage=%s failed after %.1fs: %s", stage, elapsed, container["error"])
141
- return {
142
- "ok": False,
143
- "error": str(container["error"]),
144
- "elapsed_seconds": round(elapsed, 1),
145
- "timed_out": False,
146
- }
147
- logger.info("[loop] stage=%s done elapsed=%.1fs", stage, elapsed)
148
- return {
149
- "ok": True,
150
- "value": container["value"],
151
- "elapsed_seconds": round(elapsed, 1),
152
- "timed_out": False,
153
- }
154
-
155
-
156
- def _ensure_session_dir():
157
- SESSION_DIR.mkdir(parents=True, exist_ok=True)
158
-
159
- def _save_session(session: Dict[str, Any]):
160
- _ensure_session_dir()
161
- path = SESSION_DIR / f"{session['session_id']}.json"
162
- path.write_text(json.dumps(session, indent=2))
163
-
164
- def create_governed_session(loop_type: str = "build") -> Dict[str, Any]:
165
- prefix = loop_type if loop_type in ("build", "social", "deploy") else "build"
166
- session_id = f"{prefix}-{uuid.uuid4().hex[:8]}"
167
- session = {
168
- "session_id": session_id,
169
- "type": f"governed_{prefix}",
170
- "loop_type": prefix,
171
- "started_at": datetime.now(timezone.utc).isoformat(),
172
- "iterations": 0,
173
- "max_iterations": MAX_ITERATIONS_DEFAULT,
174
- "cost_incurred": 0.0,
175
- "cost_cap": MAX_COST_DEFAULT,
176
- "errors": 0,
177
- "error_threshold": MAX_ERRORS_DEFAULT,
178
- "tasks_completed": [],
179
- "status": "running"
180
- }
181
- _save_session(session)
182
- return session
183
-
184
- # ── Venture & Repo Resolution ─────────────────────────────────────────
185
-
186
- def resolve_venture_context(venture_name: str) -> Dict[str, str]:
187
- """Resolve a venture name to its project path and repo URL."""
188
- from ai.ledger_manager import list_ventures
189
-
190
- ventures = list_ventures().get("ventures", {})
191
- context = {"path": ".", "repo": "", "name": venture_name or "root"}
192
-
193
- if not venture_name or venture_name == "root":
194
- context["path"] = str(ROOT_LEDGER_PATH)
195
- return context
196
-
197
- if venture_name in ventures:
198
- v = ventures[venture_name]
199
- context["path"] = v.get("path", ".")
200
- context["repo"] = v.get("repo", "")
201
- return context
202
-
203
- # Fallback to fuzzy match
204
- for name, info in ventures.items():
205
- if venture_name.lower() in name.lower():
206
- context["path"] = info.get("path", ".")
207
- context["repo"] = info.get("repo", "")
208
- context["name"] = name
209
- return context
210
-
211
- return context
212
-
213
- # ── Web Signal Triage (think→build pipeline) ────────────────────────
214
-
215
- def _classify_web_signal(item: Dict[str, Any]) -> Optional[Dict[str, str]]:
216
- """Classify a web scanner strategy item into a triage action.
217
-
218
- Returns dict with keys: action, build_type, priority, title, description
219
- or None if the signal should be skipped.
220
- """
221
- title = item.get("title", "")
222
- desc = item.get("description", "")
223
- snippet = f"{title} {desc}".lower()
224
-
225
- # Competitor releases → assess feature parity need
226
- if WEB_SIGNAL_PREFIXES["competitor"].lower() in snippet or "competitor release" in snippet:
227
- return {
228
- "action": "build",
229
- "build_type": "task",
230
- "priority": "P1",
231
- "title": f"Assess: {title}",
232
- "description": (
233
- f"Web scanner detected competitor activity. Assess whether Delimit "
234
- f"needs a matching feature or response.\n\nOriginal signal: {desc[:500]}"
235
- ),
236
- "venture": item.get("venture", "delimit"),
237
- "source_signal": item.get("id", ""),
238
- }
239
-
240
- # Ecosystem build signals → assess threat or opportunity
241
- if WEB_SIGNAL_PREFIXES["ecosystem"].lower() in snippet:
242
- return {
243
- "action": "build",
244
- "build_type": "task",
245
- "priority": "P2",
246
- "title": f"Evaluate: {title}",
247
- "description": (
248
- f"Ecosystem signal detected. Assess if this is a threat, opportunity, "
249
- f"or integration target for Delimit.\n\nOriginal signal: {desc[:500]}"
250
- ),
251
- "venture": item.get("venture", "delimit"),
252
- "source_signal": item.get("id", ""),
253
- }
254
-
255
- # npm packages → check compete or complement
256
- if WEB_SIGNAL_PREFIXES["npm"].lower() in snippet:
257
- return {
258
- "action": "build",
259
- "build_type": "task",
260
- "priority": "P2",
261
- "title": f"npm scout: {title}",
262
- "description": (
263
- f"New npm package detected in Delimit's space. Determine if it "
264
- f"competes with or complements Delimit.\n\nOriginal signal: {desc[:500]}"
265
- ),
266
- "venture": "delimit",
267
- "source_signal": item.get("id", ""),
268
- }
269
-
270
- # Venture discovery → flag for founder review (never auto-build)
271
- if WEB_SIGNAL_PREFIXES["venture"].lower() in snippet:
272
- return {
273
- "action": "notify",
274
- "venture": item.get("venture", "jamsons"),
275
- "source_signal": item.get("id", ""),
276
- }
277
-
278
- return None
279
-
280
-
281
- def triage_web_signals(session: Dict[str, Any], max_signals: int = 5) -> List[Dict[str, Any]]:
282
- """Consume strategy items created by the web scanner and convert to build tasks.
283
-
284
- This is the think→build pipeline:
285
- 1. Find open strategy items with web scanner fingerprints
286
- 2. Classify each signal (competitor, ecosystem, npm, venture)
287
- 3. For build signals: create a feat/task item in the ledger
288
- 4. For venture signals: send founder notification
289
- 5. Mark the original strategy item as triaged
290
-
291
- Returns list of actions taken.
292
- """
293
- from ai.ledger_manager import list_items, add_item, update_item
294
-
295
- result = list_items(status="open", project_path=str(ROOT_LEDGER_PATH))
296
- items = []
297
- for ledger_items in result.get("items", {}).values():
298
- items.extend(ledger_items)
299
-
300
- # Find untriaged web scanner signals
301
- web_signals = []
302
- for item in items:
303
- if item.get("type") not in SIGNAL_TYPES:
304
- continue
305
- tags = item.get("tags", [])
306
- if "web-triaged" in tags:
307
- continue
308
- title = item.get("title", "")
309
- desc = item.get("description", "")
310
- snippet = f"{title} {desc}".lower()
311
- # Match web scanner output patterns
312
- if any(prefix.lower() in snippet for prefix in WEB_SIGNAL_PREFIXES.values()):
313
- web_signals.append(item)
314
-
315
- if not web_signals:
316
- return []
317
-
318
- actions = []
319
- for signal in web_signals[:max_signals]:
320
- classification = _classify_web_signal(signal)
321
- if not classification:
322
- continue
323
-
324
- if classification["action"] == "build":
325
- # Create a build-safe ledger item from the signal
326
- try:
327
- new_item = add_item(
328
- title=classification["title"],
329
- item_type=classification["build_type"],
330
- priority=classification["priority"],
331
- description=classification["description"],
332
- venture=classification.get("venture", "delimit"),
333
- project_path=str(ROOT_LEDGER_PATH),
334
- tags=["web-signal", f"from:{classification.get('source_signal', '')}"],
335
- )
336
- actions.append({
337
- "action": "created_build_task",
338
- "source": signal.get("id"),
339
- "new_item": new_item.get("id", "unknown"),
340
- "type": classification["build_type"],
341
- "priority": classification["priority"],
342
- })
343
- except Exception as e:
344
- logger.warning("Failed to create build item from signal %s: %s", signal.get("id"), e)
345
- continue
346
-
347
- elif classification["action"] == "notify":
348
- # Venture signals → founder review
349
- actions.append({
350
- "action": "notify_founder",
351
- "source": signal.get("id"),
352
- "venture": classification.get("venture", "jamsons"),
353
- "title": signal.get("title", ""),
354
- })
355
-
356
- # Mark signal as triaged so we don't process it again
357
- try:
358
- existing_tags = signal.get("tags", [])
359
- update_item(
360
- item_id=signal["id"],
361
- status="done",
362
- note=f"Triaged by build loop → {classification['action']}",
363
- project_path=str(ROOT_LEDGER_PATH),
364
- )
365
- except Exception as e:
366
- logger.warning("Failed to mark signal %s as triaged: %s", signal.get("id"), e)
367
-
368
- return actions
369
-
370
-
371
- # ── Governed Selection ───────────────────────────────────────────────
372
-
373
- def next_task(venture: str = "", max_risk: str = "", session_id: str = "") -> Dict[str, Any]:
374
- """Get the next task to work on. Wrapper for server.py compatibility."""
375
- session = create_governed_session() if not session_id else {"session_id": session_id, "status": "running", "iterations": 0, "max_iterations": 50, "cost_incurred": 0, "cost_cap": 5, "errors": 0, "error_threshold": 3, "tasks_done": 0, "auto_consensus": False}
376
- task = get_next_build_task(session)
377
- if task is None:
378
- from ai.ledger_manager import list_items
379
- result = list_items(status="open", project_path=str(ROOT_LEDGER_PATH))
380
- open_count = sum(len(v) for v in result.get("items", {}).values())
381
- return {"action": "CONSENSUS", "reason": f"No build-safe items found ({open_count} open items, none actionable)", "remaining_items": open_count, "session": session}
382
- return {"action": "BUILD", "task": task, "remaining_items": 0, "session": session}
383
-
384
-
385
- def get_next_build_task(session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
386
- """Select the next build-safe item from the authoritative root ledger."""
387
- from ai.ledger_manager import list_items
388
-
389
- # Authoritative root ledger check
390
- result = list_items(status="open", project_path=str(ROOT_LEDGER_PATH))
391
- items = []
392
- for ledger_items in result.get("items", {}).values():
393
- items.extend(ledger_items)
394
-
395
- # Filter build-safe items only
396
- actionable = []
397
- for item in items:
398
- if item.get("type") not in BUILD_SAFE_TYPES:
399
- continue
400
- # Skip items that explicitly require owner action or are not for AI
401
- tags = item.get("tags", [])
402
- if "owner-action" in tags or "manual" in tags:
403
- continue
404
- actionable.append(item)
405
-
406
- if not actionable:
407
- return None
408
-
409
- # Sort by priority
410
- priority_map = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
411
- actionable.sort(key=lambda x: priority_map.get(x.get("priority", "P2"), 9))
412
-
413
- return actionable[0]
414
-
415
- # ── Social Loop Task Selection ────────────────────────────────────────
416
-
417
- def get_next_social_task(session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
418
- """Select the next social/outreach item from the root ledger."""
419
- from ai.ledger_manager import list_items
420
-
421
- result = list_items(status="open", project_path=str(ROOT_LEDGER_PATH))
422
- items = []
423
- for ledger_items in result.get("items", {}).values():
424
- items.extend(ledger_items)
425
-
426
- actionable = []
427
- for item in items:
428
- if item.get("type") not in SOCIAL_SAFE_TYPES:
429
- continue
430
- tags = item.get("tags", [])
431
- if "manual" in tags:
432
- continue
433
- actionable.append(item)
434
-
435
- if not actionable:
436
- return None
437
-
438
- priority_map = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
439
- actionable.sort(key=lambda x: priority_map.get(x.get("priority", "P2"), 9))
440
- return actionable[0]
441
-
442
-
443
- def run_social_iteration(session_id: str) -> Dict[str, Any]:
444
- """Execute one governed social/think loop iteration.
445
-
446
- Cycle: scan platforms → draft replies → notify founder → handle social ledger items.
447
- """
448
- path = SESSION_DIR / f"{session_id}.json"
449
- if not path.exists():
450
- return {"error": f"Session {session_id} not found"}
451
- session = json.loads(path.read_text())
452
-
453
- if session["status"] != "running":
454
- return {"status": "stopped", "reason": f"Session status is {session['status']}"}
455
- if session["iterations"] >= session["max_iterations"]:
456
- session["status"] = "finished"
457
- _save_session(session)
458
- return {"status": "finished", "reason": "Max iterations reached"}
459
- if session["cost_incurred"] >= session["cost_cap"]:
460
- session["status"] = "stopped"
461
- _save_session(session)
462
- return {"status": "stopped", "reason": "Cost cap reached"}
463
-
464
- results = {"scans": [], "drafts_sent": 0, "ledger_task": None, "triage": [], "stage_timings": {}}
465
- iteration_start = time.time()
466
- _write_heartbeat(session_id, "iteration_start", {"iteration": session["iterations"] + 1})
467
-
468
- # 1. Scan all platforms via social_target pipeline (scan + draft + ledger)
469
- # LED-788: wall-clock timeout prevents a hung platform from eating the session
470
- def _do_scan_and_process():
471
- from ai.social_target import scan_targets, process_targets
472
- _targets = scan_targets(
473
- platforms=SOCIAL_SCAN_PLATFORMS,
474
- ventures=SOCIAL_SCAN_VENTURES,
475
- limit=10,
476
- )
477
- _processed = None
478
- if _targets:
479
- _processed = process_targets(_targets, draft_replies=True, create_ledger=True)
480
- return _targets, _processed
481
-
482
- scan_result = _run_stage_with_timeout(
483
- "social_scan_and_process",
484
- _do_scan_and_process,
485
- SOCIAL_SCAN_TIMEOUT,
486
- session_id=session_id,
487
- )
488
- results["stage_timings"]["scan_and_process"] = scan_result["elapsed_seconds"]
489
- if scan_result["ok"]:
490
- targets, processed = scan_result["value"]
491
- results["scans"] = [
492
- {"platform": t.get("platform"), "title": t.get("title", "")[:80]}
493
- for t in targets[:5]
494
- ]
495
- results["targets_found"] = len(targets)
496
- if processed:
497
- drafted_list = processed.get("drafted", []) or []
498
- ledger_list = processed.get("ledger_items", []) or []
499
- notifs_sent = sum(1 for d in drafted_list if d.get("notification_sent"))
500
- results["processed"] = {
501
- "drafts": len(drafted_list),
502
- "drafts_ready": notifs_sent,
503
- "drafts_suppressed": sum(1 for d in drafted_list if d.get("suppressed_reason")),
504
- "ledger_items": len(ledger_list),
505
- "notifications": notifs_sent,
506
- }
507
- results["drafts_sent"] = notifs_sent
508
- else:
509
- logger.error("Social scan failed: %s", scan_result.get("error"))
510
- session["errors"] += 1
511
- results["scan_error"] = scan_result.get("error")
512
- results["scan_timed_out"] = scan_result.get("timed_out", False)
513
-
514
- # 3. Triage web signals (think→build pipeline)
515
- _write_heartbeat(session_id, "triage_web_signals")
516
- triage_actions = triage_web_signals(session)
517
- if triage_actions:
518
- results["triage"] = [
519
- {"action": a.get("action"), "title": a.get("title", "")[:60]}
520
- for a in triage_actions
521
- ]
522
-
523
- # 4. Pick up social-typed ledger items
524
- social_task = get_next_social_task(session)
525
- if social_task:
526
- results["ledger_task"] = {"id": social_task["id"], "title": social_task.get("title", "")}
527
- try:
528
- from ai.ledger_manager import update_item
529
- update_item(
530
- item_id=social_task["id"],
531
- status="in_progress",
532
- note="Picked up by think loop",
533
- project_path=str(ROOT_LEDGER_PATH),
534
- )
535
- except Exception:
536
- pass
537
-
538
- # 5. Strategy deliberation (think): every 8th iteration AND only if no
539
- # successful deliberation in the last hour. The Gemini CLI shim loads 187
540
- # MCP tools on every startup (~120s), so running strategy every 4th
541
- # iteration wasted 2 min per cycle on timeouts. Gate on recency instead.
542
- results["strategy"] = None
543
- _should_run_strategy = session["iterations"] % 8 == 0
544
- if _should_run_strategy:
545
- delib_dir = Path.home() / ".delimit" / "deliberations"
546
- if delib_dir.exists():
547
- recent = sorted(delib_dir.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
548
- if recent and (time.time() - recent[0].stat().st_mtime) < 3600:
549
- _should_run_strategy = False
550
- logger.info("Skipping strategy cycle — last deliberation was %.0f min ago",
551
- (time.time() - recent[0].stat().st_mtime) / 60)
552
- if _should_run_strategy:
553
- strat_result = _run_stage_with_timeout(
554
- "strategy_cycle",
555
- lambda: _run_strategy_cycle(session),
556
- SOCIAL_STRATEGY_TIMEOUT,
557
- session_id=session_id,
558
- )
559
- results["stage_timings"]["strategy_cycle"] = strat_result["elapsed_seconds"]
560
- if strat_result["ok"]:
561
- results["strategy"] = strat_result["value"]
562
- else:
563
- logger.error("Strategy cycle failed: %s", strat_result.get("error"))
564
- results["strategy"] = {
565
- "error": strat_result.get("error"),
566
- "timed_out": strat_result.get("timed_out", False),
567
- }
568
-
569
- # LED-788: total iteration time — if we've overrun, mark the session so
570
- # the next iteration runs lighter (strategy cycle will still be rate-gated
571
- # by the %4 check, but the warning surfaces to operators).
572
- total_elapsed = round(time.time() - iteration_start, 1)
573
- results["stage_timings"]["total"] = total_elapsed
574
- if total_elapsed > SOCIAL_ITERATION_TIMEOUT:
575
- logger.error(
576
- "[loop] iteration %d took %.1fs, exceeding soft cap of %ss",
577
- session["iterations"] + 1, total_elapsed, SOCIAL_ITERATION_TIMEOUT,
578
- )
579
- results["iteration_overrun"] = True
580
-
581
- # 6. Update session
582
- _write_heartbeat(session_id, "iteration_complete", {"elapsed_seconds": total_elapsed})
583
- session["iterations"] += 1
584
- cost = 0.01 if not results.get("strategy") else 0.15 # deliberations cost more
585
- session["cost_incurred"] += cost
586
- session["tasks_completed"].append({
587
- "iteration": session["iterations"],
588
- "drafts_sent": results["drafts_sent"],
589
- "targets_scanned": len(results["scans"]),
590
- "ledger_task": results.get("ledger_task"),
591
- "strategy": results.get("strategy"),
592
- "timestamp": datetime.now(timezone.utc).isoformat(),
593
- })
594
- _save_session(session)
595
-
596
- return {"status": "continued", "session_id": session_id, "results": results}
597
-
598
-
599
- # ── Strategy Deliberation (think cycle) ───────────────────────────────
600
-
601
- STRATEGY_LEDGER = Path("/root/.delimit/ledger/strategy.jsonl")
602
- DELIBERATION_DIR = Path("/home/delimit/delimit-private/decisions")
603
-
604
- def _get_open_strategy_items(limit: int = 6) -> List[Dict[str, Any]]:
605
- """Read open strategy items from the strategy ledger."""
606
- if not STRATEGY_LEDGER.exists():
607
- return []
608
- items = []
609
- for line in STRATEGY_LEDGER.read_text().splitlines():
610
- line = line.strip()
611
- if not line:
612
- continue
613
- try:
614
- item = json.loads(line)
615
- if item.get("status", "open") == "open":
616
- items.append(item)
617
- except json.JSONDecodeError:
618
- continue
619
- priority_map = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
620
- items.sort(key=lambda x: priority_map.get(x.get("priority", "P2"), 9))
621
- return items[:limit]
622
-
623
-
624
- def _group_strategy_items(items: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
625
- """Group related strategy items by venture/topic for batch deliberation."""
626
- groups: Dict[str, List[Dict[str, Any]]] = {}
627
- for item in items:
628
- key = item.get("venture", item.get("tags", ["general"])[0] if item.get("tags") else "general")
629
- groups.setdefault(key, []).append(item)
630
- # Cap each group at 4 items
631
- return [g[:4] for g in groups.values()]
632
-
633
-
634
- def _run_strategy_cycle(session: Dict[str, Any]) -> Dict[str, Any]:
635
- """Run one strategy deliberation cycle: pull items → group → deliberate → build tasks."""
636
- items = _get_open_strategy_items(limit=6)
637
- if not items:
638
- return {"status": "idle", "reason": "No open strategy items"}
639
-
640
- groups = _group_strategy_items(items)
641
- result = {"deliberations": 0, "build_tasks_created": 0, "items_closed": 0}
642
-
643
- # Process at most 1 group per cycle to stay within rate limits
644
- group = groups[0]
645
- item_refs = ", ".join(f"{i.get('id', '?')}: {i.get('title', '')[:40]}" for i in group)
646
- titles = " + ".join(i.get("id", "?") for i in group)
647
-
648
- question = (
649
- f"{titles}: {' | '.join(i.get('title', '') for i in group)}. "
650
- "What are the specific next steps to move these forward? "
651
- "Output as 3-5 specific operational tasks with titles and descriptions."
652
- )
653
-
654
- context = (
655
- f"Items: {item_refs}\n"
656
- f"Venture: {group[0].get('venture', 'delimit')}\n"
657
- f"Session: think loop iteration {session['iterations']}\n"
658
- f"Constraint: solo founder, all ventures parallel, ledger-based dev"
659
- )
660
-
661
- try:
662
- from ai.deliberation import deliberate as run_deliberation
663
- date_str = datetime.now(timezone.utc).strftime("%Y_%m_%d")
664
- topic = group[0].get("venture", "strategy").upper()
665
- save_path = str(DELIBERATION_DIR / f"DELIBERATION_{topic}_{date_str}.md")
666
-
667
- delib_result = run_deliberation(
668
- question=question,
669
- context=context,
670
- mode="debate",
671
- save_path=save_path,
672
- )
673
- result["deliberations"] = 1
674
- result["save_path"] = save_path
675
-
676
- # Close the strategy items
677
- from ai.ledger_manager import update_item
678
- for item in group:
679
- try:
680
- update_item(
681
- item_id=item["id"],
682
- status="done",
683
- note=f"Deliberated in think loop. Transcript: {save_path}",
684
- project_path=str(ROOT_LEDGER_PATH),
685
- )
686
- result["items_closed"] += 1
687
- except Exception:
688
- pass
689
-
690
- except Exception as e:
691
- logger.error("Deliberation failed for %s: %s", titles, e)
692
- result["error"] = str(e)
693
-
694
- return result
695
-
696
-
697
- # ── Deploy Handoff (build→deploy pipeline) ──────────────────────────
698
-
699
- DEPLOY_QUEUE_DIR = Path.home() / ".delimit" / "loop" / "deploy-queue"
700
-
701
- def _ensure_deploy_queue():
702
- DEPLOY_QUEUE_DIR.mkdir(parents=True, exist_ok=True)
703
-
704
-
705
- def _notify_deploy_loop(task: Dict[str, Any], venture: str, project_path: str,
706
- session_id: str = "") -> Dict[str, Any]:
707
- """Signal the deploy loop that a build task completed and code is ready.
708
-
709
- Writes a deploy-ready item to the deploy queue. The deploy loop picks these
710
- up and runs commit → push → deploy gates → deploy for each venture.
711
- """
712
- _ensure_deploy_queue()
713
-
714
- item = {
715
- "task_id": task.get("id", "unknown"),
716
- "title": task.get("title", ""),
717
- "venture": venture,
718
- "project_path": project_path,
719
- "status": "pending",
720
- "created_at": datetime.now(timezone.utc).isoformat(),
721
- "session_id": session_id,
722
- }
723
-
724
- queue_file = DEPLOY_QUEUE_DIR / "pending.jsonl"
725
- with open(queue_file, "a") as f:
726
- f.write(json.dumps(item) + "\n")
727
-
728
- logger.info("Deploy queue: added %s (%s) for %s", task.get("id"), venture, project_path)
729
- return item
730
-
731
-
732
- def get_deploy_ready(venture: str = "") -> List[Dict[str, Any]]:
733
- """Get pending deploy-ready items, optionally filtered by venture.
734
-
735
- Called by the deploy loop to discover what the build loop produced.
736
- """
737
- _ensure_deploy_queue()
738
- queue_file = DEPLOY_QUEUE_DIR / "pending.jsonl"
739
- if not queue_file.exists():
740
- return []
741
-
742
- items = []
743
- for line in queue_file.read_text().strip().split("\n"):
744
- if not line.strip():
745
- continue
746
- try:
747
- item = json.loads(line)
748
- if item.get("status") != "pending":
749
- continue
750
- if venture and item.get("venture", "") != venture:
751
- continue
752
- items.append(item)
753
- except json.JSONDecodeError:
754
- continue
755
-
756
- return items
757
-
758
-
759
- def mark_deployed(task_id: str) -> bool:
760
- """Mark a deploy-queue item as deployed. Called by deploy loop after successful deploy."""
761
- _ensure_deploy_queue()
762
- queue_file = DEPLOY_QUEUE_DIR / "pending.jsonl"
763
- if not queue_file.exists():
764
- return False
765
-
766
- lines = queue_file.read_text().strip().split("\n")
767
- updated = False
768
- new_lines = []
769
- for line in lines:
770
- if not line.strip():
771
- continue
772
- try:
773
- item = json.loads(line)
774
- if item.get("task_id") == task_id and item.get("status") == "pending":
775
- item["status"] = "deployed"
776
- item["deployed_at"] = datetime.now(timezone.utc).isoformat()
777
- updated = True
778
- new_lines.append(json.dumps(item))
779
- except json.JSONDecodeError:
780
- new_lines.append(line)
781
-
782
- if updated:
783
- queue_file.write_text("\n".join(new_lines) + "\n")
784
- return updated
785
-
786
-
787
- # ── Swarm Dispatch & Execution ───────────────────────────────────────
788
-
789
- def loop_config(session_id: str = "", max_iterations: int = 0,
790
- cost_cap: float = 0.0, auto_consensus: bool = False,
791
- error_threshold: int = 0, status: str = "",
792
- require_approval_for: list = None) -> Dict[str, Any]:
793
- """Configure or create a loop session with safeguards."""
794
- _ensure_session_dir()
795
-
796
- # Load existing or create new
797
- if session_id:
798
- path = SESSION_DIR / f"{session_id}.json"
799
- if path.exists():
800
- session = json.loads(path.read_text())
801
- else:
802
- session = {
803
- "session_id": session_id,
804
- "type": "governed_build",
805
- "started_at": datetime.now(timezone.utc).isoformat(),
806
- "iterations": 0,
807
- "max_iterations": max_iterations or MAX_ITERATIONS_DEFAULT,
808
- "cost_incurred": 0.0,
809
- "cost_cap": cost_cap or MAX_COST_DEFAULT,
810
- "errors": 0,
811
- "error_threshold": error_threshold or MAX_ERRORS_DEFAULT,
812
- "tasks_completed": [],
813
- "status": status or "running",
814
- }
815
- else:
816
- session = create_governed_session()
817
-
818
- # Apply non-zero/non-empty overrides
819
- if max_iterations > 0:
820
- session["max_iterations"] = max_iterations
821
- if cost_cap > 0:
822
- session["cost_cap"] = cost_cap
823
- if error_threshold > 0:
824
- session["error_threshold"] = error_threshold
825
- if status:
826
- session["status"] = status
827
- if auto_consensus:
828
- session["auto_consensus"] = True
829
- if require_approval_for is not None:
830
- session["require_approval_for"] = require_approval_for
831
-
832
- _save_session(session)
833
- return {
834
- "session_id": session["session_id"],
835
- "status": session["status"],
836
- "max_iterations": session["max_iterations"],
837
- "iterations": session.get("iterations", 0),
838
- "cost_cap": session["cost_cap"],
839
- "cost_incurred": session.get("cost_incurred", 0.0),
840
- "error_threshold": session["error_threshold"],
841
- "errors": session.get("errors", 0),
842
- }
843
-
844
-
845
- def run_governed_iteration(session_id: str, hardening: Optional[Any] = None) -> Dict[str, Any]:
846
- """Execute one governed build iteration.
847
-
848
- Args:
849
- session_id: The session to advance.
850
- hardening: Optional GovernanceHardeningConfig from ai.governance_hardening.
851
- When provided, dispatch calls are wrapped with retry, debounce,
852
- and circuit-breaker protection. When None (default), behavior
853
- is unchanged from the original implementation.
854
- """
855
- from datetime import datetime, timezone
856
- import importlib
857
- import ai.swarm as _swarm_mod
858
- importlib.reload(_swarm_mod)
859
- from ai.swarm import dispatch_task
860
-
861
- # 1. Load Session & Check Safeguards
862
- path = SESSION_DIR / f"{session_id}.json"
863
- if not path.exists():
864
- return {"error": f"Session {session_id} not found"}
865
- session = json.loads(path.read_text())
866
-
867
- if session["status"] != "running":
868
- return {"status": "stopped", "reason": f"Session status is {session['status']}"}
869
-
870
- if session["iterations"] >= session["max_iterations"]:
871
- session["status"] = "finished"
872
- _save_session(session)
873
- return {"status": "finished", "reason": "Max iterations reached"}
874
-
875
- if session["cost_incurred"] >= session["cost_cap"]:
876
- session["status"] = "stopped"
877
- _save_session(session)
878
- return {"status": "stopped", "reason": "Cost cap reached"}
879
-
880
- # 1b. Triage web scanner signals (think→build pipeline)
881
- triage_actions = triage_web_signals(session)
882
- if triage_actions:
883
- logger.info("Web signal triage: %d actions taken", len(triage_actions))
884
- # If we created new build tasks, they'll be picked up in task selection below
885
- # If we need to notify founder for venture signals, do it now
886
- for action in triage_actions:
887
- if action.get("action") == "notify_founder":
888
- try:
889
- from ai.notify import send_notification
890
- send_notification(
891
- message=(
892
- f"[VENTURE SIGNAL] {action.get('title', 'New venture opportunity')}\n"
893
- f"Source: {action.get('source', 'web scanner')}\n"
894
- f"Venture: {action.get('venture', 'jamsons')}\n"
895
- f"Action: Founder review needed before acting"
896
- ),
897
- channel="email",
898
- priority="P1",
899
- )
900
- except Exception as e:
901
- logger.warning("Failed to notify founder for venture signal: %s", e)
902
-
903
- # 2. Select Task
904
- task = get_next_build_task(session)
905
- if not task:
906
- return {"status": "idle", "reason": "No build-safe items in ledger", "triage_actions": triage_actions}
907
-
908
- # 3. Resolve Context
909
- v_name = task.get("venture", "root")
910
- ctx = resolve_venture_context(v_name)
911
-
912
- # 4. Dispatch through Swarm (Control Plane)
913
- logger.info(f"Dispatching build task {task['id']} for venture {v_name}")
914
-
915
- start_time = time.time()
916
- try:
917
- # LED-661: Route through governance hardening stack when configured
918
- dispatch_kwargs = dict(
919
- title=task["title"],
920
- description=task["description"],
921
- context=f"Executing governed build loop for {v_name}. Ledger ID: {task['id']}",
922
- project_path=ctx["path"],
923
- priority=task["priority"],
924
- )
925
-
926
- if hardening is not None and hardening.is_active():
927
- from ai.governance_hardening import hardened_dispatch
928
- dispatch_result = hardened_dispatch(
929
- hardening, dispatch_task,
930
- tool_name="dispatch_task",
931
- **dispatch_kwargs,
932
- )
933
- # hardened_dispatch may return a control dict (debounced/circuit_open)
934
- if isinstance(dispatch_result, dict) and dispatch_result.get("status") in ("debounced", "circuit_open"):
935
- session["tasks_completed"].append({
936
- "id": task["id"],
937
- "status": dispatch_result["status"],
938
- "timestamp": datetime.now(timezone.utc).isoformat(),
939
- })
940
- _save_session(session)
941
- return {"status": dispatch_result["status"], "task_id": task["id"], "detail": dispatch_result}
942
- else:
943
- # Original path: direct dispatch, no hardening
944
- dispatch_result = dispatch_task(**dispatch_kwargs)
945
-
946
- # 5. Update State & Ledger
947
- duration = time.time() - start_time
948
- cost = dispatch_result.get("estimated_cost", 0.05) # Default placeholder if missing
949
-
950
- session["iterations"] += 1
951
- session["cost_incurred"] += cost
952
-
953
- from ai.ledger_manager import update_item
954
- dispatch_status = dispatch_result.get("status")
955
- # "completed" = synchronous success (loop engine closes the ledger).
956
- # "dispatched" = swarm handed the task to an agent; the ledger stays
957
- # in_progress until the agent reports back via delimit_agent_complete.
958
- # Both are success outcomes from the loop's perspective.
959
- if dispatch_status == "completed":
960
- update_item(
961
- item_id=task["id"],
962
- status="done",
963
- note=f"Completed via governed build loop. Result: {dispatch_result.get('summary', 'OK')}",
964
- project_path=str(ROOT_LEDGER_PATH)
965
- )
966
- session["tasks_completed"].append({
967
- "id": task["id"],
968
- "status": "success",
969
- "duration": duration,
970
- "cost": cost
971
- })
972
- # 5b. Signal deploy loop that code is ready
973
- try:
974
- _notify_deploy_loop(
975
- task=task,
976
- venture=v_name,
977
- project_path=ctx["path"],
978
- session_id=session_id,
979
- )
980
- except Exception as e:
981
- logger.warning("Failed to notify deploy loop for %s: %s", task.get("id"), e)
982
- elif dispatch_status == "dispatched":
983
- # Async handoff: mark ledger in_progress, leave closure to the agent.
984
- dispatched_task_id = dispatch_result.get("task_id", "")
985
- try:
986
- update_item(
987
- item_id=task["id"],
988
- status="in_progress",
989
- note=(
990
- f"Dispatched to swarm agent via governed build loop "
991
- f"(swarm task_id={dispatched_task_id}). Awaiting agent completion."
992
- ),
993
- project_path=str(ROOT_LEDGER_PATH),
994
- )
995
- except Exception as e:
996
- logger.warning("Failed to mark %s in_progress after dispatch: %s", task.get("id"), e)
997
- session["tasks_completed"].append({
998
- "id": task["id"],
999
- "status": "dispatched",
1000
- "swarm_task_id": dispatched_task_id,
1001
- "duration": duration,
1002
- "cost": cost,
1003
- })
1004
- elif dispatch_status == "blocked":
1005
- # Founder-approval gate — not a failure, don't trip the breaker.
1006
- session["tasks_completed"].append({
1007
- "id": task["id"],
1008
- "status": "blocked",
1009
- "reason": dispatch_result.get("reason", "Requires founder approval"),
1010
- })
1011
- else:
1012
- session["errors"] += 1
1013
- if session["errors"] >= session["error_threshold"]:
1014
- session["status"] = "circuit_broken"
1015
- session["tasks_completed"].append({
1016
- "id": task["id"],
1017
- "status": "failed",
1018
- "error": dispatch_result.get("error", f"Dispatch failed (status={dispatch_status!r})"),
1019
- })
1020
-
1021
- _save_session(session)
1022
- return {"status": "continued", "task_id": task["id"], "result": dispatch_result}
1023
-
1024
- except Exception as e:
1025
- session["errors"] += 1
1026
- _save_session(session)
1027
- return {"error": str(e)}
1028
-
1029
- # ── Unified Think→Build→Deploy Cycle ─────────────────────────────────
1030
-
1031
- # Per-stage timeout defaults (seconds). Each stage is abandoned if it
1032
- # exceeds its timeout so one hung stage can't block the entire cycle.
1033
- CYCLE_THINK_TIMEOUT = int(os.environ.get("DELIMIT_CYCLE_THINK_TIMEOUT", "180"))
1034
- CYCLE_BUILD_TIMEOUT = int(os.environ.get("DELIMIT_CYCLE_BUILD_TIMEOUT", "300"))
1035
- CYCLE_DEPLOY_TIMEOUT = int(os.environ.get("DELIMIT_CYCLE_DEPLOY_TIMEOUT", "120"))
1036
-
1037
-
1038
- def run_full_cycle(session_id: str = "", hardening: Optional[Any] = None) -> Dict[str, Any]:
1039
- """Execute one unified think→build→deploy cycle.
1040
-
1041
- This is the main entry point for autonomous operation. Each stage
1042
- auto-triggers the next. If any stage fails or times out, the cycle
1043
- continues to subsequent stages — a failed think doesn't block build,
1044
- a failed build doesn't block deploy (deploy consumes the queue from
1045
- prior builds).
1046
-
1047
- Returns a summary dict with results from each stage.
1048
- """
1049
- cycle_start = time.time()
1050
- cycle_id = f"cycle-{datetime.now(timezone.utc).strftime('%Y%m%dT%H%M%S')}"
1051
-
1052
- # Create or reuse session
1053
- if not session_id:
1054
- session = create_governed_session(loop_type="build")
1055
- session_id = session["session_id"]
1056
-
1057
- results = {
1058
- "cycle_id": cycle_id,
1059
- "session_id": session_id,
1060
- "stages": {},
1061
- "errors": [],
1062
- }
1063
-
1064
- # Helper: run a stage, record result, track errors.
1065
- # _run_stage_with_timeout catches exceptions internally and returns
1066
- # {"ok": bool, "error": str, ...} so we check ok/timed_out, not exceptions.
1067
- def _exec_stage(name, fn, timeout):
1068
- logger.info("[%s] Stage %s (timeout=%ds)", cycle_id, name, timeout)
1069
- _write_heartbeat(session_id, name)
1070
- stage_result = _run_stage_with_timeout(name, fn, timeout_s=timeout, session_id=session_id)
1071
- results["stages"][name] = stage_result
1072
- if not stage_result.get("ok"):
1073
- reason = stage_result.get("error", "unknown")
1074
- if stage_result.get("timed_out"):
1075
- reason = f"timed out after {timeout}s"
1076
- results["errors"].append(f"{name}: {reason}")
1077
-
1078
- # ── Stage 1: THINK ──────────────────────────────────────────────
1079
- # Scan signals, triage web scanner output, run strategy deliberation.
1080
- _exec_stage("think", lambda: run_social_iteration(session_id), CYCLE_THINK_TIMEOUT)
1081
-
1082
- # ── Stage 2: BUILD ──────────────────────────────────────────────
1083
- # Pick the highest-priority build-safe ledger item and dispatch through swarm.
1084
- _exec_stage("build", lambda: run_governed_iteration(session_id, hardening=hardening), CYCLE_BUILD_TIMEOUT)
1085
-
1086
- # ── Stage 3: DEPLOY ─────────────────────────────────────────────
1087
- # Consume the deploy queue. Runs regardless of build outcome.
1088
- _exec_stage("deploy", lambda: _run_deploy_stage(session_id), CYCLE_DEPLOY_TIMEOUT)
1089
-
1090
- elapsed = time.time() - cycle_start
1091
- results["elapsed_seconds"] = round(elapsed, 2)
1092
- results["status"] = "ok" if not results["errors"] else "partial"
1093
-
1094
- _write_heartbeat(session_id, "idle", {"last_cycle": cycle_id, "elapsed": elapsed})
1095
- logger.info(
1096
- "[%s] Cycle complete in %.1fs: think=%s build=%s deploy=%s",
1097
- cycle_id, elapsed,
1098
- results["stages"].get("think", {}).get("status", "?"),
1099
- results["stages"].get("build", {}).get("status", "?"),
1100
- results["stages"].get("deploy", {}).get("status", "?"),
1101
- )
1102
- return results
1103
-
1104
-
1105
- DEPLOY_MAX_AGE_HOURS = int(os.environ.get("DELIMIT_DEPLOY_MAX_AGE_HOURS", "48"))
1106
-
1107
-
1108
- def _expire_stale_deploys():
1109
- """Move deploy-queue items older than DEPLOY_MAX_AGE_HOURS to expired.jsonl."""
1110
- _ensure_deploy_queue()
1111
- queue_file = DEPLOY_QUEUE_DIR / "pending.jsonl"
1112
- expired_file = DEPLOY_QUEUE_DIR / "expired.jsonl"
1113
- if not queue_file.exists():
1114
- return
1115
-
1116
- cutoff = datetime.now(timezone.utc) - __import__("datetime").timedelta(hours=DEPLOY_MAX_AGE_HOURS)
1117
- cutoff_iso = cutoff.isoformat()
1118
-
1119
- kept = []
1120
- expired = []
1121
- for line in queue_file.read_text().strip().split("\n"):
1122
- if not line.strip():
1123
- continue
1124
- try:
1125
- item = json.loads(line)
1126
- created = item.get("created_at", "")
1127
- if item.get("status") == "pending" and created and created < cutoff_iso:
1128
- item["status"] = "expired"
1129
- item["expired_at"] = datetime.now(timezone.utc).isoformat()
1130
- expired.append(item)
1131
- logger.info("Deploy queue: expired stale item %s (created %s)", item.get("task_id"), created)
1132
- else:
1133
- kept.append(item)
1134
- except json.JSONDecodeError:
1135
- continue
1136
-
1137
- if expired:
1138
- # Archive expired items
1139
- with open(expired_file, "a") as f:
1140
- for item in expired:
1141
- f.write(json.dumps(item) + "\n")
1142
- # Rewrite pending with only kept items
1143
- with open(queue_file, "w") as f:
1144
- for item in kept:
1145
- f.write(json.dumps(item) + "\n")
1146
- logger.info("Deploy queue: expired %d stale items, %d remaining", len(expired), len(kept))
1147
-
1148
-
1149
- def _run_deploy_stage(session_id: str) -> Dict[str, Any]:
1150
- """Run the deploy stage: consume pending deploy-queue items.
1151
-
1152
- For each pending item, runs the deploy gate chain:
1153
- 1. repo_diagnose (pre-commit check)
1154
- 2. security_audit
1155
- 3. test_smoke
1156
- 4. git commit + push
1157
- 5. deploy_verify + evidence_collect
1158
- 6. Mark deployed in queue + close ledger item
1159
-
1160
- Items older than DEPLOY_MAX_AGE_HOURS are auto-expired to prevent
1161
- stale queue buildup from blocking the cycle.
1162
- """
1163
- # Expire stale items first
1164
- _expire_stale_deploys()
1165
-
1166
- pending = get_deploy_ready()
1167
- if not pending:
1168
- return {"status": "idle", "reason": "No pending deploy items", "deployed": 0}
1169
-
1170
- deployed = []
1171
- for item in pending:
1172
- task_id = item.get("task_id", "unknown")
1173
- venture = item.get("venture", "root")
1174
- project_path = item.get("project_path", "")
1175
-
1176
- logger.info("Deploy stage: processing %s (%s) at %s", task_id, venture, project_path)
1177
-
1178
- try:
1179
- # Check if project has uncommitted changes worth deploying
1180
- if not project_path or not Path(project_path).exists():
1181
- logger.warning("Deploy: project path %s not found, skipping %s", project_path, task_id)
1182
- continue
1183
-
1184
- # Run deploy gates via MCP tools. Import may fail if server module
1185
- # isn't loaded (e.g. running outside MCP context).
1186
- try:
1187
- from ai.server import (
1188
- _repo_diagnose, _test_smoke, _security_audit,
1189
- _evidence_collect, _ledger_done,
1190
- )
1191
- except ImportError:
1192
- logger.warning("Deploy: ai.server not available, skipping gates for %s", task_id)
1193
- mark_deployed(task_id)
1194
- deployed.append(task_id)
1195
- continue
1196
-
1197
- # Gate 1: repo diagnose
1198
- diag = _repo_diagnose(repo=project_path)
1199
- if isinstance(diag, dict) and diag.get("error"):
1200
- logger.warning("Deploy gate failed (repo_diagnose) for %s: %s", task_id, diag["error"])
1201
- continue
1202
-
1203
- # Gate 2: security audit
1204
- audit = _security_audit(target=project_path)
1205
- if isinstance(audit, dict) and audit.get("severity_summary", {}).get("critical", 0) > 0:
1206
- logger.warning("Deploy gate failed (security_audit) for %s: critical findings", task_id)
1207
- continue
1208
-
1209
- # Gate 3: test smoke
1210
- smoke = _test_smoke(project_path=project_path)
1211
- if isinstance(smoke, dict) and smoke.get("error"):
1212
- logger.warning("Deploy gate failed (test_smoke) for %s: %s", task_id, smoke.get("error", ""))
1213
- # Don't block — test_smoke has known backend bugs
1214
-
1215
- # Mark as deployed
1216
- mark_deployed(task_id)
1217
- deployed.append(task_id)
1218
-
1219
- # Close the ledger item
1220
- try:
1221
- _ledger_done(item_id=task_id, note=f"Auto-deployed via cycle deploy stage. Session: {session_id}")
1222
- except Exception:
1223
- pass
1224
-
1225
- # Evidence collection
1226
- try:
1227
- _evidence_collect()
1228
- except Exception:
1229
- pass
1230
-
1231
- logger.info("Deploy stage: %s deployed successfully", task_id)
1232
-
1233
- except Exception as e:
1234
- logger.error("Deploy stage: %s failed: %s", task_id, e)
1235
- continue
1236
-
1237
- return {
1238
- "status": "deployed" if deployed else "no_deployable",
1239
- "deployed": len(deployed),
1240
- "deployed_ids": deployed,
1241
- "pending_remaining": len(pending) - len(deployed),
1242
- }
1243
-
1244
-
1245
- def loop_status(session_id: str = "") -> Dict[str, Any]:
1246
- """Check autonomous loop metrics for a session."""
1247
- _ensure_session_dir()
1248
- if session_id:
1249
- path = SESSION_DIR / f"{session_id}.json"
1250
- if not path.exists():
1251
- return {"error": f"Session {session_id} not found"}
1252
- session = json.loads(path.read_text())
1253
- else:
1254
- # Find most recent session
1255
- sessions = sorted(SESSION_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
1256
- if not sessions:
1257
- return {"error": "No loop sessions found"}
1258
- session = json.loads(sessions[0].read_text())
1259
-
1260
- heartbeat = _read_heartbeat(session["session_id"]) # LED-788: live stage + elapsed
1261
- return {
1262
- "session_id": session["session_id"],
1263
- "status": session.get("status", "unknown"),
1264
- "iterations": session.get("iterations", 0),
1265
- "max_iterations": session.get("max_iterations", MAX_ITERATIONS_DEFAULT),
1266
- "cost_incurred": session.get("cost_incurred", 0.0),
1267
- "cost_cap": session.get("cost_cap", MAX_COST_DEFAULT),
1268
- "errors": session.get("errors", 0),
1269
- "error_threshold": session.get("error_threshold", MAX_ERRORS_DEFAULT),
1270
- "tasks_completed": session.get("tasks_completed", []),
1271
- "started_at": session.get("started_at", ""),
1272
- "heartbeat": heartbeat,
1273
- }
1274
-
1275
-
1276
- def task_complete(task_id: str, status: str = "done", note: str = "", session_id: str = "") -> Dict[str, Any]:
1277
- """Mark a task as complete within a loop session."""
1278
- from ai.ledger_manager import update_item
1279
-
1280
- result = update_item(
1281
- item_id=task_id,
1282
- status=status,
1283
- note=note or f"Completed via governed build loop",
1284
- project_path=str(ROOT_LEDGER_PATH),
1285
- )
1286
-
1287
- # Update session if provided
1288
- if session_id:
1289
- path = SESSION_DIR / f"{session_id}.json"
1290
- if path.exists():
1291
- session = json.loads(path.read_text())
1292
- session["tasks_completed"].append({
1293
- "id": task_id,
1294
- "status": status,
1295
- "note": note,
1296
- })
1297
- _save_session(session)
1298
-
1299
- return {"task_id": task_id, "status": status, "ledger_update": result}
1300
-
1301
-
1302
- if __name__ == "__main__":
1303
- pass