delimit-cli 4.1.43 → 4.1.47

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 (57) hide show
  1. package/CHANGELOG.md +33 -0
  2. package/README.md +46 -5
  3. package/bin/delimit-cli.js +1987 -337
  4. package/bin/delimit-setup.js +108 -66
  5. package/gateway/ai/activate_helpers.py +253 -7
  6. package/gateway/ai/agent_dispatch.py +34 -2
  7. package/gateway/ai/backends/deploy_bridge.py +167 -12
  8. package/gateway/ai/backends/gateway_core.py +236 -13
  9. package/gateway/ai/backends/repo_bridge.py +80 -16
  10. package/gateway/ai/backends/tools_infra.py +49 -32
  11. package/gateway/ai/checksums.sha256 +6 -0
  12. package/gateway/ai/content_engine.py +1276 -2
  13. package/gateway/ai/continuity.py +462 -0
  14. package/gateway/ai/deliberation.pyi +53 -0
  15. package/gateway/ai/github_scanner.py +1 -1
  16. package/gateway/ai/governance.py +58 -0
  17. package/gateway/ai/governance.pyi +32 -0
  18. package/gateway/ai/governance_hardening.py +569 -0
  19. package/gateway/ai/inbox_daemon_runner.py +217 -0
  20. package/gateway/ai/key_resolver.py +95 -2
  21. package/gateway/ai/ledger_manager.py +53 -3
  22. package/gateway/ai/license.py +104 -3
  23. package/gateway/ai/license_core.py +177 -36
  24. package/gateway/ai/license_core.pyi +50 -0
  25. package/gateway/ai/loop_engine.py +929 -294
  26. package/gateway/ai/notify.py +1786 -2
  27. package/gateway/ai/reddit_scanner.py +190 -1
  28. package/gateway/ai/screen_record.py +1 -1
  29. package/gateway/ai/secrets_broker.py +5 -1
  30. package/gateway/ai/server.py +254 -19
  31. package/gateway/ai/social_cache.py +341 -0
  32. package/gateway/ai/social_daemon.py +41 -10
  33. package/gateway/ai/supabase_sync.py +190 -2
  34. package/gateway/ai/swarm.py +86 -0
  35. package/gateway/ai/swarm_infra.py +656 -0
  36. package/gateway/ai/tui.py +594 -36
  37. package/gateway/ai/tweet_corpus_schema.sql +76 -0
  38. package/gateway/core/diff_engine_v2.py +6 -2
  39. package/gateway/core/generator_drift.py +242 -0
  40. package/gateway/core/json_schema_diff.py +375 -0
  41. package/gateway/core/openapi_version.py +124 -0
  42. package/gateway/core/spec_detector.py +47 -7
  43. package/gateway/core/spec_health.py +5 -2
  44. package/gateway/core/zero_spec/express_extractor.py +2 -2
  45. package/gateway/core/zero_spec/nestjs_extractor.py +40 -9
  46. package/gateway/requirements.txt +3 -6
  47. package/lib/cross-model-hooks.js +4 -12
  48. package/package.json +11 -3
  49. package/scripts/demo-v420-clean.sh +267 -0
  50. package/scripts/demo-v420-deliberation.sh +217 -0
  51. package/scripts/demo-v420.sh +55 -0
  52. package/scripts/postinstall.js +4 -3
  53. package/scripts/publish-ci-guard.sh +30 -0
  54. package/scripts/record-and-upload.sh +132 -0
  55. package/scripts/release.sh +126 -0
  56. package/scripts/sync-gateway.sh +112 -0
  57. package/scripts/youtube-upload.py +141 -0
@@ -1,408 +1,1043 @@
1
- """Autonomous build loop engine governed, throttled, cross-model.
2
-
3
- Provides the core loop primitives that any AI model can use via MCP:
4
- - next_task: get the next prioritized item with safeguard checks
5
- - task_complete: record completion, check if loop should continue
6
- - loop_status: current session metrics
7
- - loop_config: configure safeguards
8
-
9
- Session state persisted at ~/.delimit/loop/sessions/<session_id>.json
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
10
11
  """
11
12
 
12
13
  import json
14
+ import logging
15
+ from datetime import datetime, timezone
13
16
  import os
14
17
  import time
15
18
  import uuid
16
19
  from pathlib import Path
17
20
  from typing import Any, Dict, List, Optional
18
21
 
19
- LOOP_DIR = Path.home() / ".delimit" / "loop"
20
- SESSIONS_DIR = LOOP_DIR / "sessions"
22
+ logger = logging.getLogger("delimit.ai.loop_engine")
21
23
 
22
- # Actions the AI model must never auto-execute without human approval
23
- DEFAULT_REQUIRE_APPROVAL = ["deploy", "social_post", "outreach", "publish"]
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"]
24
34
 
25
- VALID_STATUSES = {"running", "paused", "stopped", "circuit_broken"}
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
+ }
26
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
27
47
 
28
- def _ensure_dir():
29
- """Create the loop sessions directory if it doesn't exist."""
30
- SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
48
+ # ── Session State ────────────────────────────────────────────────────
49
+ SESSION_DIR = Path.home() / ".delimit" / "loop" / "sessions"
50
+ HEARTBEAT_DIR = Path.home() / ".delimit" / "loop" / "heartbeat"
31
51
 
32
52
 
33
- def _session_path(session_id: str) -> Path:
34
- """Return the filesystem path for a session state file."""
35
- return SESSIONS_DIR / f"{session_id}.json"
53
+ def _ensure_heartbeat_dir():
54
+ HEARTBEAT_DIR.mkdir(parents=True, exist_ok=True)
36
55
 
37
56
 
38
- def _load_session(session_id: str) -> Optional[Dict[str, Any]]:
39
- """Load session state from disk. Returns None if not found."""
40
- path = _session_path(session_id)
41
- if not path.exists():
42
- return None
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
+ """
43
64
  try:
44
- return json.loads(path.read_text())
45
- except (json.JSONDecodeError, OSError):
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):
46
91
  return None
47
92
 
48
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
+
49
159
  def _save_session(session: Dict[str, Any]):
50
- """Persist session state to disk."""
51
- _ensure_dir()
52
- path = _session_path(session["session_id"])
160
+ _ensure_session_dir()
161
+ path = SESSION_DIR / f"{session['session_id']}.json"
53
162
  path.write_text(json.dumps(session, indent=2))
54
163
 
55
-
56
- def _create_session(session_id: str = "") -> Dict[str, Any]:
57
- """Create a new loop session with default safeguards."""
58
- if not session_id:
59
- session_id = str(uuid.uuid4())[:12]
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]}"
60
167
  session = {
61
168
  "session_id": session_id,
62
- "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
169
+ "type": f"governed_{prefix}",
170
+ "loop_type": prefix,
171
+ "started_at": datetime.now(timezone.utc).isoformat(),
63
172
  "iterations": 0,
64
- "max_iterations": 50,
173
+ "max_iterations": MAX_ITERATIONS_DEFAULT,
65
174
  "cost_incurred": 0.0,
66
- "cost_cap": 5.0,
175
+ "cost_cap": MAX_COST_DEFAULT,
67
176
  "errors": 0,
68
- "error_threshold": 3,
177
+ "error_threshold": MAX_ERRORS_DEFAULT,
69
178
  "tasks_completed": [],
70
- "auto_consensus": False,
71
- "require_approval_for": list(DEFAULT_REQUIRE_APPROVAL),
72
- "status": "running",
179
+ "status": "running"
73
180
  }
74
181
  _save_session(session)
75
182
  return session
76
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()
77
224
 
78
- def _get_or_create_session(session_id: str = "") -> Dict[str, Any]:
79
- """Load an existing session or create a new one."""
80
- if session_id:
81
- existing = _load_session(session_id)
82
- if existing:
83
- return existing
84
- return _create_session(session_id)
85
-
86
-
87
- def _check_safeguards(session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
88
- """Check all safeguards. Returns a STOP action if any are tripped, else None."""
89
- if session.get("status") == "paused":
225
+ # Competitor releases assess feature parity need
226
+ if WEB_SIGNAL_PREFIXES["competitor"].lower() in snippet or "competitor release" in snippet:
90
227
  return {
91
- "action": "STOP",
92
- "reason": "Loop is paused. Call loop_config to resume.",
93
- "safeguard": "paused",
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", ""),
94
238
  }
95
239
 
96
- if session.get("status") == "stopped":
240
+ # Ecosystem build signals → assess threat or opportunity
241
+ if WEB_SIGNAL_PREFIXES["ecosystem"].lower() in snippet:
97
242
  return {
98
- "action": "STOP",
99
- "reason": "Loop has been stopped.",
100
- "safeguard": "stopped",
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", ""),
101
253
  }
102
254
 
103
- if session.get("status") == "circuit_broken":
255
+ # npm packages → check compete or complement
256
+ if WEB_SIGNAL_PREFIXES["npm"].lower() in snippet:
104
257
  return {
105
- "action": "STOP",
106
- "reason": f"Circuit breaker tripped after {session['errors']} errors.",
107
- "safeguard": "circuit_breaker",
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", ""),
108
268
  }
109
269
 
110
- if session["iterations"] >= session["max_iterations"]:
270
+ # Venture discovery → flag for founder review (never auto-build)
271
+ if WEB_SIGNAL_PREFIXES["venture"].lower() in snippet:
111
272
  return {
112
- "action": "STOP",
113
- "reason": f"Reached max iterations ({session['max_iterations']}).",
114
- "safeguard": "max_iterations",
273
+ "action": "notify",
274
+ "venture": item.get("venture", "jamsons"),
275
+ "source_signal": item.get("id", ""),
115
276
  }
116
277
 
117
- if session["cost_incurred"] >= session["cost_cap"]:
118
- return {
119
- "action": "STOP",
120
- "reason": f"Cost cap reached (${session['cost_incurred']:.2f} >= ${session['cost_cap']:.2f}).",
121
- "safeguard": "cost_cap",
122
- }
278
+ return None
123
279
 
124
- if session["errors"] >= session["error_threshold"]:
125
- session["status"] = "circuit_broken"
126
- _save_session(session)
127
- return {
128
- "action": "STOP",
129
- "reason": f"Circuit breaker: {session['errors']} errors hit threshold ({session['error_threshold']}).",
130
- "safeguard": "circuit_breaker",
131
- }
132
280
 
133
- return None
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.
134
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
135
290
 
136
- def _get_open_items(venture: str = "", project_path: str = ".") -> List[Dict[str, Any]]:
137
- """Query the ledger for open items, sorted by priority."""
138
- from ai.ledger_manager import list_items
139
- result = list_items(status="open", project_path=project_path)
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))
140
296
  items = []
141
297
  for ledger_items in result.get("items", {}).values():
142
298
  items.extend(ledger_items)
143
299
 
144
- # Sort by priority
145
- priority_order = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
146
- items.sort(key=lambda x: priority_order.get(x.get("priority", "P2"), 9))
147
- return items
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
148
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
149
346
 
150
- def _filter_actionable(items: List[Dict[str, Any]], max_risk: str = "") -> List[Dict[str, Any]]:
151
- """Filter out owner-only items and apply risk filtering.
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
+ })
152
355
 
153
- Owner-only items are those with source='owner' or tags containing 'owner-action'.
154
- """
155
- filtered = []
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 = []
156
397
  for item in items:
157
- # Skip owner-only items
158
- tags = item.get("tags", [])
159
- if "owner-action" in tags or "owner-only" in tags:
398
+ if item.get("type") not in BUILD_SAFE_TYPES:
160
399
  continue
161
- if item.get("source") == "owner":
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:
162
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]
163
414
 
164
- # Risk filtering
165
- if max_risk:
166
- item_risk = item.get("risk", "")
167
- if item_risk and _risk_level(item_risk) > _risk_level(max_risk):
168
- continue
415
+ # ── Social Loop Task Selection ────────────────────────────────────────
169
416
 
170
- filtered.append(item)
171
- return filtered
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
172
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)
173
425
 
174
- def _resolve_project_path(venture: str) -> str:
175
- """Resolve a venture name or path to a project directory path."""
176
- if not venture:
177
- return "."
178
- # Direct path — use as-is
179
- if venture.startswith("/") or venture.startswith("~"):
180
- return str(Path(venture).expanduser())
181
- if venture.startswith(".") or os.sep in venture:
182
- return str(Path(venture).resolve())
183
- # Try registered ventures
184
- try:
185
- from ai.ledger_manager import list_ventures
186
- ventures = list_ventures()
187
- for name, info in ventures.get("ventures", {}).items():
188
- if name == venture or venture in name:
189
- return info.get("path", ".")
190
- except Exception:
191
- pass
192
- return "."
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)
193
434
 
435
+ if not actionable:
436
+ return None
194
437
 
195
- def _risk_level(risk: str) -> int:
196
- """Convert risk string to numeric level for comparison."""
197
- levels = {"low": 1, "medium": 2, "high": 3, "critical": 4}
198
- return levels.get(risk.lower(), 2)
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]
199
441
 
200
442
 
201
- def next_task(
202
- venture: str = "",
203
- max_risk: str = "",
204
- session_id: str = "",
205
- ) -> Dict[str, Any]:
206
- """Get the next task to work on with safeguard checks.
443
+ def run_social_iteration(session_id: str) -> Dict[str, Any]:
444
+ """Execute one governed social/think loop iteration.
207
445
 
208
- Returns:
209
- Dict with action: BUILD (with task), CONSENSUS (generate new items), or STOP.
446
+ Cycle: scan platforms → draft replies → notify founder → handle social ledger items.
210
447
  """
211
- session = _get_or_create_session(session_id)
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())
212
452
 
213
- # Check safeguards
214
- stop = _check_safeguards(session)
215
- if stop:
216
- stop["session"] = _session_summary(session)
217
- return stop
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 4th iteration to avoid rate limits
539
+ # LED-788: strategy cycle wraps delimit_deliberate which easily hangs on
540
+ # a single slow model — wall-clock cap so it can't eat the whole iteration.
541
+ results["strategy"] = None
542
+ if session["iterations"] % 4 == 0:
543
+ strat_result = _run_stage_with_timeout(
544
+ "strategy_cycle",
545
+ lambda: _run_strategy_cycle(session),
546
+ SOCIAL_STRATEGY_TIMEOUT,
547
+ session_id=session_id,
548
+ )
549
+ results["stage_timings"]["strategy_cycle"] = strat_result["elapsed_seconds"]
550
+ if strat_result["ok"]:
551
+ results["strategy"] = strat_result["value"]
552
+ else:
553
+ logger.error("Strategy cycle failed: %s", strat_result.get("error"))
554
+ results["strategy"] = {
555
+ "error": strat_result.get("error"),
556
+ "timed_out": strat_result.get("timed_out", False),
557
+ }
218
558
 
219
- # Resolve venture path
220
- project_path = _resolve_project_path(venture)
559
+ # LED-788: total iteration time — if we've overrun, mark the session so
560
+ # the next iteration runs lighter (strategy cycle will still be rate-gated
561
+ # by the %4 check, but the warning surfaces to operators).
562
+ total_elapsed = round(time.time() - iteration_start, 1)
563
+ results["stage_timings"]["total"] = total_elapsed
564
+ if total_elapsed > SOCIAL_ITERATION_TIMEOUT:
565
+ logger.error(
566
+ "[loop] iteration %d took %.1fs, exceeding soft cap of %ss",
567
+ session["iterations"] + 1, total_elapsed, SOCIAL_ITERATION_TIMEOUT,
568
+ )
569
+ results["iteration_overrun"] = True
570
+
571
+ # 6. Update session
572
+ _write_heartbeat(session_id, "iteration_complete", {"elapsed_seconds": total_elapsed})
573
+ session["iterations"] += 1
574
+ cost = 0.01 if not results.get("strategy") else 0.15 # deliberations cost more
575
+ session["cost_incurred"] += cost
576
+ session["tasks_completed"].append({
577
+ "iteration": session["iterations"],
578
+ "drafts_sent": results["drafts_sent"],
579
+ "targets_scanned": len(results["scans"]),
580
+ "ledger_task": results.get("ledger_task"),
581
+ "strategy": results.get("strategy"),
582
+ "timestamp": datetime.now(timezone.utc).isoformat(),
583
+ })
584
+ _save_session(session)
221
585
 
222
- # Get open items
223
- items = _get_open_items(venture=venture, project_path=project_path)
224
- actionable = _filter_actionable(items, max_risk=max_risk)
586
+ return {"status": "continued", "session_id": session_id, "results": results}
225
587
 
226
- if not actionable:
227
- if session.get("auto_consensus"):
228
- return {
229
- "action": "CONSENSUS",
230
- "message": "No actionable items. Run consensus to generate new work.",
231
- "session": _session_summary(session),
232
- }
233
- return {
234
- "action": "STOP",
235
- "reason": "No actionable items in the ledger.",
236
- "safeguard": "empty_ledger",
237
- "session": _session_summary(session),
238
- }
239
588
 
240
- task = actionable[0]
241
-
242
- # Check if this task requires approval
243
- require_approval = session.get("require_approval_for", [])
244
- task_tags = task.get("tags", [])
245
- needs_approval = any(tag in require_approval for tag in task_tags)
246
- task_type = task.get("type", "")
247
- if task_type in require_approval:
248
- needs_approval = True
249
-
250
- result = {
251
- "action": "BUILD",
252
- "task": task,
253
- "remaining_items": len(actionable) - 1,
254
- "session": _session_summary(session),
255
- }
256
- if needs_approval:
257
- result["approval_required"] = True
258
- result["approval_reason"] = "Task type or tags match require_approval_for list."
589
+ # ── Strategy Deliberation (think cycle) ───────────────────────────────
259
590
 
260
- return result
591
+ STRATEGY_LEDGER = Path("/root/.delimit/ledger/strategy.jsonl")
592
+ DELIBERATION_DIR = Path("/home/delimit/delimit-private/decisions")
261
593
 
594
+ def _get_open_strategy_items(limit: int = 6) -> List[Dict[str, Any]]:
595
+ """Read open strategy items from the strategy ledger."""
596
+ if not STRATEGY_LEDGER.exists():
597
+ return []
598
+ items = []
599
+ for line in STRATEGY_LEDGER.read_text().splitlines():
600
+ line = line.strip()
601
+ if not line:
602
+ continue
603
+ try:
604
+ item = json.loads(line)
605
+ if item.get("status", "open") == "open":
606
+ items.append(item)
607
+ except json.JSONDecodeError:
608
+ continue
609
+ priority_map = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
610
+ items.sort(key=lambda x: priority_map.get(x.get("priority", "P2"), 9))
611
+ return items[:limit]
262
612
 
263
- def task_complete(
264
- task_id: str,
265
- result: str = "",
266
- cost_incurred: float = 0.0,
267
- error: str = "",
268
- session_id: str = "",
269
- venture: str = "",
270
- ) -> Dict[str, Any]:
271
- """Mark current task done and get the next one.
272
613
 
273
- Records completion, updates session metrics, returns the next task.
274
- """
275
- session = _get_or_create_session(session_id)
614
+ def _group_strategy_items(items: List[Dict[str, Any]]) -> List[List[Dict[str, Any]]]:
615
+ """Group related strategy items by venture/topic for batch deliberation."""
616
+ groups: Dict[str, List[Dict[str, Any]]] = {}
617
+ for item in items:
618
+ key = item.get("venture", item.get("tags", ["general"])[0] if item.get("tags") else "general")
619
+ groups.setdefault(key, []).append(item)
620
+ # Cap each group at 4 items
621
+ return [g[:4] for g in groups.values()]
622
+
623
+
624
+ def _run_strategy_cycle(session: Dict[str, Any]) -> Dict[str, Any]:
625
+ """Run one strategy deliberation cycle: pull items → group → deliberate → build tasks."""
626
+ items = _get_open_strategy_items(limit=6)
627
+ if not items:
628
+ return {"status": "idle", "reason": "No open strategy items"}
629
+
630
+ groups = _group_strategy_items(items)
631
+ result = {"deliberations": 0, "build_tasks_created": 0, "items_closed": 0}
632
+
633
+ # Process at most 1 group per cycle to stay within rate limits
634
+ group = groups[0]
635
+ item_refs = ", ".join(f"{i.get('id', '?')}: {i.get('title', '')[:40]}" for i in group)
636
+ titles = " + ".join(i.get("id", "?") for i in group)
637
+
638
+ question = (
639
+ f"{titles}: {' | '.join(i.get('title', '') for i in group)}. "
640
+ "What are the specific next steps to move these forward? "
641
+ "Output as 3-5 specific operational tasks with titles and descriptions."
642
+ )
643
+
644
+ context = (
645
+ f"Items: {item_refs}\n"
646
+ f"Venture: {group[0].get('venture', 'delimit')}\n"
647
+ f"Session: think loop iteration {session['iterations']}\n"
648
+ f"Constraint: solo founder, all ventures parallel, ledger-based dev"
649
+ )
276
650
 
277
- # Update metrics
278
- session["iterations"] += 1
279
- session["cost_incurred"] += cost_incurred
651
+ try:
652
+ from ai.deliberation import deliberate as run_deliberation
653
+ date_str = datetime.now(timezone.utc).strftime("%Y_%m_%d")
654
+ topic = group[0].get("venture", "strategy").upper()
655
+ save_path = str(DELIBERATION_DIR / f"DELIBERATION_{topic}_{date_str}.md")
656
+
657
+ delib_result = run_deliberation(
658
+ question=question,
659
+ context=context,
660
+ mode="debate",
661
+ save_path=save_path,
662
+ )
663
+ result["deliberations"] = 1
664
+ result["save_path"] = save_path
665
+
666
+ # Close the strategy items
667
+ from ai.ledger_manager import update_item
668
+ for item in group:
669
+ try:
670
+ update_item(
671
+ item_id=item["id"],
672
+ status="done",
673
+ note=f"Deliberated in think loop. Transcript: {save_path}",
674
+ project_path=str(ROOT_LEDGER_PATH),
675
+ )
676
+ result["items_closed"] += 1
677
+ except Exception:
678
+ pass
679
+
680
+ except Exception as e:
681
+ logger.error("Deliberation failed for %s: %s", titles, e)
682
+ result["error"] = str(e)
280
683
 
281
- if error:
282
- session["errors"] += 1
283
- session["tasks_completed"].append({
284
- "task_id": task_id,
285
- "status": "error",
286
- "error": error,
287
- "completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
288
- "cost": cost_incurred,
289
- })
290
- else:
291
- session["tasks_completed"].append({
292
- "task_id": task_id,
293
- "status": "done",
294
- "result": result[:500] if result else "",
295
- "completed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
296
- "cost": cost_incurred,
297
- })
684
+ return result
298
685
 
299
- _save_session(session)
300
686
 
301
- # Mark ledger item as done (best-effort)
302
- if not error:
303
- try:
304
- from ai.ledger_manager import update_item
305
- project_path = _resolve_project_path(venture)
306
- update_item(item_id=task_id, status="done", note=result[:200] if result else "Completed via build loop", project_path=project_path)
307
- except Exception:
308
- pass # Never let ledger sync break the loop
687
+ # ── Deploy Handoff (build→deploy pipeline) ──────────────────────────
309
688
 
310
- # Return the next task
311
- return next_task(venture=venture, session_id=session["session_id"])
689
+ DEPLOY_QUEUE_DIR = Path.home() / ".delimit" / "loop" / "deploy-queue"
312
690
 
691
+ def _ensure_deploy_queue():
692
+ DEPLOY_QUEUE_DIR.mkdir(parents=True, exist_ok=True)
313
693
 
314
- def loop_status(session_id: str = "") -> Dict[str, Any]:
315
- """Return current session metrics."""
316
- if not session_id:
317
- # Try to find the most recent session
318
- sessions = _list_sessions()
319
- if not sessions:
320
- return {"error": "No active loop sessions found."}
321
- session_id = sessions[0]["session_id"]
322
694
 
323
- session = _load_session(session_id)
324
- if not session:
325
- return {"error": f"Session {session_id} not found."}
695
+ def _notify_deploy_loop(task: Dict[str, Any], venture: str, project_path: str,
696
+ session_id: str = "") -> Dict[str, Any]:
697
+ """Signal the deploy loop that a build task completed and code is ready.
326
698
 
327
- return {
328
- "session": _session_summary(session),
329
- "tasks_completed": session.get("tasks_completed", []),
330
- "safeguards": {
331
- "max_iterations": session["max_iterations"],
332
- "cost_cap": session["cost_cap"],
333
- "error_threshold": session["error_threshold"],
334
- "require_approval_for": session.get("require_approval_for", []),
335
- },
699
+ Writes a deploy-ready item to the deploy queue. The deploy loop picks these
700
+ up and runs commit → push → deploy gates → deploy for each venture.
701
+ """
702
+ _ensure_deploy_queue()
703
+
704
+ item = {
705
+ "task_id": task.get("id", "unknown"),
706
+ "title": task.get("title", ""),
707
+ "venture": venture,
708
+ "project_path": project_path,
709
+ "status": "pending",
710
+ "created_at": datetime.now(timezone.utc).isoformat(),
711
+ "session_id": session_id,
336
712
  }
337
713
 
714
+ queue_file = DEPLOY_QUEUE_DIR / "pending.jsonl"
715
+ with open(queue_file, "a") as f:
716
+ f.write(json.dumps(item) + "\n")
338
717
 
339
- def loop_config(
340
- session_id: str = "",
341
- max_iterations: int = 0,
342
- cost_cap: float = 0.0,
343
- auto_consensus: Optional[bool] = None,
344
- error_threshold: int = 0,
345
- status: str = "",
346
- require_approval_for: Optional[List[str]] = None,
347
- ) -> Dict[str, Any]:
348
- """Update session configuration. Only provided values are changed."""
349
- session = _get_or_create_session(session_id)
718
+ logger.info("Deploy queue: added %s (%s) for %s", task.get("id"), venture, project_path)
719
+ return item
720
+
721
+
722
+ def get_deploy_ready(venture: str = "") -> List[Dict[str, Any]]:
723
+ """Get pending deploy-ready items, optionally filtered by venture.
724
+
725
+ Called by the deploy loop to discover what the build loop produced.
726
+ """
727
+ _ensure_deploy_queue()
728
+ queue_file = DEPLOY_QUEUE_DIR / "pending.jsonl"
729
+ if not queue_file.exists():
730
+ return []
731
+
732
+ items = []
733
+ for line in queue_file.read_text().strip().split("\n"):
734
+ if not line.strip():
735
+ continue
736
+ try:
737
+ item = json.loads(line)
738
+ if item.get("status") != "pending":
739
+ continue
740
+ if venture and item.get("venture", "") != venture:
741
+ continue
742
+ items.append(item)
743
+ except json.JSONDecodeError:
744
+ continue
745
+
746
+ return items
747
+
748
+
749
+ def mark_deployed(task_id: str) -> bool:
750
+ """Mark a deploy-queue item as deployed. Called by deploy loop after successful deploy."""
751
+ _ensure_deploy_queue()
752
+ queue_file = DEPLOY_QUEUE_DIR / "pending.jsonl"
753
+ if not queue_file.exists():
754
+ return False
350
755
 
351
- changes = {}
756
+ lines = queue_file.read_text().strip().split("\n")
757
+ updated = False
758
+ new_lines = []
759
+ for line in lines:
760
+ if not line.strip():
761
+ continue
762
+ try:
763
+ item = json.loads(line)
764
+ if item.get("task_id") == task_id and item.get("status") == "pending":
765
+ item["status"] = "deployed"
766
+ item["deployed_at"] = datetime.now(timezone.utc).isoformat()
767
+ updated = True
768
+ new_lines.append(json.dumps(item))
769
+ except json.JSONDecodeError:
770
+ new_lines.append(line)
771
+
772
+ if updated:
773
+ queue_file.write_text("\n".join(new_lines) + "\n")
774
+ return updated
775
+
776
+
777
+ # ── Swarm Dispatch & Execution ───────────────────────────────────────
778
+
779
+ def loop_config(session_id: str = "", max_iterations: int = 0,
780
+ cost_cap: float = 0.0, auto_consensus: bool = False,
781
+ error_threshold: int = 0, status: str = "",
782
+ require_approval_for: list = None) -> Dict[str, Any]:
783
+ """Configure or create a loop session with safeguards."""
784
+ _ensure_session_dir()
785
+
786
+ # Load existing or create new
787
+ if session_id:
788
+ path = SESSION_DIR / f"{session_id}.json"
789
+ if path.exists():
790
+ session = json.loads(path.read_text())
791
+ else:
792
+ session = {
793
+ "session_id": session_id,
794
+ "type": "governed_build",
795
+ "started_at": datetime.now(timezone.utc).isoformat(),
796
+ "iterations": 0,
797
+ "max_iterations": max_iterations or MAX_ITERATIONS_DEFAULT,
798
+ "cost_incurred": 0.0,
799
+ "cost_cap": cost_cap or MAX_COST_DEFAULT,
800
+ "errors": 0,
801
+ "error_threshold": error_threshold or MAX_ERRORS_DEFAULT,
802
+ "tasks_completed": [],
803
+ "status": status or "running",
804
+ }
805
+ else:
806
+ session = create_governed_session()
807
+
808
+ # Apply non-zero/non-empty overrides
352
809
  if max_iterations > 0:
353
810
  session["max_iterations"] = max_iterations
354
- changes["max_iterations"] = max_iterations
355
811
  if cost_cap > 0:
356
812
  session["cost_cap"] = cost_cap
357
- changes["cost_cap"] = cost_cap
358
- if auto_consensus is not None:
359
- session["auto_consensus"] = auto_consensus
360
- changes["auto_consensus"] = auto_consensus
361
813
  if error_threshold > 0:
362
814
  session["error_threshold"] = error_threshold
363
- changes["error_threshold"] = error_threshold
364
- if status and status in VALID_STATUSES:
815
+ if status:
365
816
  session["status"] = status
366
- changes["status"] = status
817
+ if auto_consensus:
818
+ session["auto_consensus"] = True
367
819
  if require_approval_for is not None:
368
820
  session["require_approval_for"] = require_approval_for
369
- changes["require_approval_for"] = require_approval_for
370
821
 
371
822
  _save_session(session)
372
-
373
823
  return {
374
824
  "session_id": session["session_id"],
375
- "changes": changes,
376
- "current_config": _session_summary(session),
825
+ "status": session["status"],
826
+ "max_iterations": session["max_iterations"],
827
+ "iterations": session.get("iterations", 0),
828
+ "cost_cap": session["cost_cap"],
829
+ "cost_incurred": session.get("cost_incurred", 0.0),
830
+ "error_threshold": session["error_threshold"],
831
+ "errors": session.get("errors", 0),
377
832
  }
378
833
 
379
834
 
380
- def _session_summary(session: Dict[str, Any]) -> Dict[str, Any]:
381
- """Return a concise session summary for inclusion in responses."""
835
+ def run_governed_iteration(session_id: str, hardening: Optional[Any] = None) -> Dict[str, Any]:
836
+ """Execute one governed build iteration.
837
+
838
+ Args:
839
+ session_id: The session to advance.
840
+ hardening: Optional GovernanceHardeningConfig from ai.governance_hardening.
841
+ When provided, dispatch calls are wrapped with retry, debounce,
842
+ and circuit-breaker protection. When None (default), behavior
843
+ is unchanged from the original implementation.
844
+ """
845
+ from datetime import datetime, timezone
846
+ import importlib
847
+ import ai.swarm as _swarm_mod
848
+ importlib.reload(_swarm_mod)
849
+ from ai.swarm import dispatch_task
850
+
851
+ # 1. Load Session & Check Safeguards
852
+ path = SESSION_DIR / f"{session_id}.json"
853
+ if not path.exists():
854
+ return {"error": f"Session {session_id} not found"}
855
+ session = json.loads(path.read_text())
856
+
857
+ if session["status"] != "running":
858
+ return {"status": "stopped", "reason": f"Session status is {session['status']}"}
859
+
860
+ if session["iterations"] >= session["max_iterations"]:
861
+ session["status"] = "finished"
862
+ _save_session(session)
863
+ return {"status": "finished", "reason": "Max iterations reached"}
864
+
865
+ if session["cost_incurred"] >= session["cost_cap"]:
866
+ session["status"] = "stopped"
867
+ _save_session(session)
868
+ return {"status": "stopped", "reason": "Cost cap reached"}
869
+
870
+ # 1b. Triage web scanner signals (think→build pipeline)
871
+ triage_actions = triage_web_signals(session)
872
+ if triage_actions:
873
+ logger.info("Web signal triage: %d actions taken", len(triage_actions))
874
+ # If we created new build tasks, they'll be picked up in task selection below
875
+ # If we need to notify founder for venture signals, do it now
876
+ for action in triage_actions:
877
+ if action.get("action") == "notify_founder":
878
+ try:
879
+ from ai.notify import send_notification
880
+ send_notification(
881
+ message=(
882
+ f"[VENTURE SIGNAL] {action.get('title', 'New venture opportunity')}\n"
883
+ f"Source: {action.get('source', 'web scanner')}\n"
884
+ f"Venture: {action.get('venture', 'jamsons')}\n"
885
+ f"Action: Founder review needed before acting"
886
+ ),
887
+ channel="email",
888
+ priority="P1",
889
+ )
890
+ except Exception as e:
891
+ logger.warning("Failed to notify founder for venture signal: %s", e)
892
+
893
+ # 2. Select Task
894
+ task = get_next_build_task(session)
895
+ if not task:
896
+ return {"status": "idle", "reason": "No build-safe items in ledger", "triage_actions": triage_actions}
897
+
898
+ # 3. Resolve Context
899
+ v_name = task.get("venture", "root")
900
+ ctx = resolve_venture_context(v_name)
901
+
902
+ # 4. Dispatch through Swarm (Control Plane)
903
+ logger.info(f"Dispatching build task {task['id']} for venture {v_name}")
904
+
905
+ start_time = time.time()
906
+ try:
907
+ # LED-661: Route through governance hardening stack when configured
908
+ dispatch_kwargs = dict(
909
+ title=task["title"],
910
+ description=task["description"],
911
+ context=f"Executing governed build loop for {v_name}. Ledger ID: {task['id']}",
912
+ project_path=ctx["path"],
913
+ priority=task["priority"],
914
+ )
915
+
916
+ if hardening is not None and hardening.is_active():
917
+ from ai.governance_hardening import hardened_dispatch
918
+ dispatch_result = hardened_dispatch(
919
+ hardening, dispatch_task,
920
+ tool_name="dispatch_task",
921
+ **dispatch_kwargs,
922
+ )
923
+ # hardened_dispatch may return a control dict (debounced/circuit_open)
924
+ if isinstance(dispatch_result, dict) and dispatch_result.get("status") in ("debounced", "circuit_open"):
925
+ session["tasks_completed"].append({
926
+ "id": task["id"],
927
+ "status": dispatch_result["status"],
928
+ "timestamp": datetime.now(timezone.utc).isoformat(),
929
+ })
930
+ _save_session(session)
931
+ return {"status": dispatch_result["status"], "task_id": task["id"], "detail": dispatch_result}
932
+ else:
933
+ # Original path: direct dispatch, no hardening
934
+ dispatch_result = dispatch_task(**dispatch_kwargs)
935
+
936
+ # 5. Update State & Ledger
937
+ duration = time.time() - start_time
938
+ cost = dispatch_result.get("estimated_cost", 0.05) # Default placeholder if missing
939
+
940
+ session["iterations"] += 1
941
+ session["cost_incurred"] += cost
942
+
943
+ from ai.ledger_manager import update_item
944
+ if dispatch_result.get("status") == "completed":
945
+ update_item(
946
+ item_id=task["id"],
947
+ status="done",
948
+ note=f"Completed via governed build loop. Result: {dispatch_result.get('summary', 'OK')}",
949
+ project_path=str(ROOT_LEDGER_PATH)
950
+ )
951
+ session["tasks_completed"].append({
952
+ "id": task["id"],
953
+ "status": "success",
954
+ "duration": duration,
955
+ "cost": cost
956
+ })
957
+ # 5b. Signal deploy loop that code is ready
958
+ try:
959
+ _notify_deploy_loop(
960
+ task=task,
961
+ venture=v_name,
962
+ project_path=ctx["path"],
963
+ session_id=session_id,
964
+ )
965
+ except Exception as e:
966
+ logger.warning("Failed to notify deploy loop for %s: %s", task.get("id"), e)
967
+ else:
968
+ session["errors"] += 1
969
+ if session["errors"] >= session["error_threshold"]:
970
+ session["status"] = "circuit_broken"
971
+ session["tasks_completed"].append({
972
+ "id": task["id"],
973
+ "status": "failed",
974
+ "error": dispatch_result.get("error", "Dispatch failed")
975
+ })
976
+
977
+ _save_session(session)
978
+ return {"status": "continued", "task_id": task["id"], "result": dispatch_result}
979
+
980
+ except Exception as e:
981
+ session["errors"] += 1
982
+ _save_session(session)
983
+ return {"error": str(e)}
984
+
985
+ def loop_status(session_id: str = "") -> Dict[str, Any]:
986
+ """Check autonomous loop metrics for a session."""
987
+ _ensure_session_dir()
988
+ if session_id:
989
+ path = SESSION_DIR / f"{session_id}.json"
990
+ if not path.exists():
991
+ return {"error": f"Session {session_id} not found"}
992
+ session = json.loads(path.read_text())
993
+ else:
994
+ # Find most recent session
995
+ sessions = sorted(SESSION_DIR.glob("*.json"), key=lambda p: p.stat().st_mtime, reverse=True)
996
+ if not sessions:
997
+ return {"error": "No loop sessions found"}
998
+ session = json.loads(sessions[0].read_text())
999
+
1000
+ heartbeat = _read_heartbeat(session["session_id"]) # LED-788: live stage + elapsed
382
1001
  return {
383
1002
  "session_id": session["session_id"],
384
- "status": session["status"],
385
- "iterations": session["iterations"],
386
- "max_iterations": session["max_iterations"],
387
- "cost_incurred": round(session["cost_incurred"], 4),
388
- "cost_cap": session["cost_cap"],
389
- "errors": session["errors"],
390
- "error_threshold": session["error_threshold"],
391
- "tasks_done": len(session.get("tasks_completed", [])),
392
- "auto_consensus": session.get("auto_consensus", False),
1003
+ "status": session.get("status", "unknown"),
1004
+ "iterations": session.get("iterations", 0),
1005
+ "max_iterations": session.get("max_iterations", MAX_ITERATIONS_DEFAULT),
1006
+ "cost_incurred": session.get("cost_incurred", 0.0),
1007
+ "cost_cap": session.get("cost_cap", MAX_COST_DEFAULT),
1008
+ "errors": session.get("errors", 0),
1009
+ "error_threshold": session.get("error_threshold", MAX_ERRORS_DEFAULT),
1010
+ "tasks_completed": session.get("tasks_completed", []),
1011
+ "started_at": session.get("started_at", ""),
1012
+ "heartbeat": heartbeat,
393
1013
  }
394
1014
 
395
1015
 
396
- def _list_sessions() -> List[Dict[str, Any]]:
397
- """List all sessions, most recent first."""
398
- if not SESSIONS_DIR.exists():
399
- return []
400
- sessions = []
401
- for f in SESSIONS_DIR.glob("*.json"):
402
- try:
403
- s = json.loads(f.read_text())
404
- sessions.append(s)
405
- except (json.JSONDecodeError, OSError):
406
- continue
407
- sessions.sort(key=lambda x: x.get("started_at", ""), reverse=True)
408
- return sessions
1016
+ def task_complete(task_id: str, status: str = "done", note: str = "", session_id: str = "") -> Dict[str, Any]:
1017
+ """Mark a task as complete within a loop session."""
1018
+ from ai.ledger_manager import update_item
1019
+
1020
+ result = update_item(
1021
+ item_id=task_id,
1022
+ status=status,
1023
+ note=note or f"Completed via governed build loop",
1024
+ project_path=str(ROOT_LEDGER_PATH),
1025
+ )
1026
+
1027
+ # Update session if provided
1028
+ if session_id:
1029
+ path = SESSION_DIR / f"{session_id}.json"
1030
+ if path.exists():
1031
+ session = json.loads(path.read_text())
1032
+ session["tasks_completed"].append({
1033
+ "id": task_id,
1034
+ "status": status,
1035
+ "note": note,
1036
+ })
1037
+ _save_session(session)
1038
+
1039
+ return {"task_id": task_id, "status": status, "ledger_update": result}
1040
+
1041
+
1042
+ if __name__ == "__main__":
1043
+ pass