delimit-cli 4.1.43 → 4.1.44

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,408 +1,279 @@
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"
21
-
22
- # Actions the AI model must never auto-execute without human approval
23
- DEFAULT_REQUIRE_APPROVAL = ["deploy", "social_post", "outreach", "publish"]
24
-
25
- VALID_STATUSES = {"running", "paused", "stopped", "circuit_broken"}
26
-
27
-
28
- def _ensure_dir():
29
- """Create the loop sessions directory if it doesn't exist."""
30
- SESSIONS_DIR.mkdir(parents=True, exist_ok=True)
22
+ logger = logging.getLogger("delimit.ai.loop_engine")
31
23
 
24
+ # ── Configuration ────────────────────────────────────────────────────
25
+ ROOT_LEDGER_PATH = Path("/root/.delimit")
26
+ BUILD_SAFE_TYPES = ["feat", "fix", "task"]
27
+ MAX_ITERATIONS_DEFAULT = 10
28
+ MAX_COST_DEFAULT = 2.0
29
+ MAX_ERRORS_DEFAULT = 2
32
30
 
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"
36
-
37
-
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
43
- try:
44
- return json.loads(path.read_text())
45
- except (json.JSONDecodeError, OSError):
46
- return None
31
+ # ── Session State ────────────────────────────────────────────────────
32
+ SESSION_DIR = Path.home() / ".delimit" / "loop" / "sessions"
47
33
 
34
+ def _ensure_session_dir():
35
+ SESSION_DIR.mkdir(parents=True, exist_ok=True)
48
36
 
49
37
  def _save_session(session: Dict[str, Any]):
50
- """Persist session state to disk."""
51
- _ensure_dir()
52
- path = _session_path(session["session_id"])
38
+ _ensure_session_dir()
39
+ path = SESSION_DIR / f"{session['session_id']}.json"
53
40
  path.write_text(json.dumps(session, indent=2))
54
41
 
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]
42
+ def create_governed_session() -> Dict[str, Any]:
43
+ session_id = f"build-{uuid.uuid4().hex[:8]}"
60
44
  session = {
61
45
  "session_id": session_id,
62
- "started_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
46
+ "type": "governed_build",
47
+ "started_at": datetime.now(timezone.utc).isoformat(),
63
48
  "iterations": 0,
64
- "max_iterations": 50,
49
+ "max_iterations": MAX_ITERATIONS_DEFAULT,
65
50
  "cost_incurred": 0.0,
66
- "cost_cap": 5.0,
51
+ "cost_cap": MAX_COST_DEFAULT,
67
52
  "errors": 0,
68
- "error_threshold": 3,
53
+ "error_threshold": MAX_ERRORS_DEFAULT,
69
54
  "tasks_completed": [],
70
- "auto_consensus": False,
71
- "require_approval_for": list(DEFAULT_REQUIRE_APPROVAL),
72
- "status": "running",
55
+ "status": "running"
73
56
  }
74
57
  _save_session(session)
75
58
  return session
76
59
 
77
-
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":
90
- return {
91
- "action": "STOP",
92
- "reason": "Loop is paused. Call loop_config to resume.",
93
- "safeguard": "paused",
94
- }
95
-
96
- if session.get("status") == "stopped":
97
- return {
98
- "action": "STOP",
99
- "reason": "Loop has been stopped.",
100
- "safeguard": "stopped",
101
- }
102
-
103
- if session.get("status") == "circuit_broken":
104
- return {
105
- "action": "STOP",
106
- "reason": f"Circuit breaker tripped after {session['errors']} errors.",
107
- "safeguard": "circuit_breaker",
108
- }
109
-
110
- if session["iterations"] >= session["max_iterations"]:
111
- return {
112
- "action": "STOP",
113
- "reason": f"Reached max iterations ({session['max_iterations']}).",
114
- "safeguard": "max_iterations",
115
- }
116
-
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
- }
123
-
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
-
133
- return None
134
-
135
-
136
- def _get_open_items(venture: str = "", project_path: str = ".") -> List[Dict[str, Any]]:
137
- """Query the ledger for open items, sorted by priority."""
60
+ # ── Venture & Repo Resolution ─────────────────────────────────────────
61
+
62
+ def resolve_venture_context(venture_name: str) -> Dict[str, str]:
63
+ """Resolve a venture name to its project path and repo URL."""
64
+ from ai.ledger_manager import list_ventures
65
+
66
+ ventures = list_ventures().get("ventures", {})
67
+ context = {"path": ".", "repo": "", "name": venture_name or "root"}
68
+
69
+ if not venture_name or venture_name == "root":
70
+ context["path"] = str(ROOT_LEDGER_PATH)
71
+ return context
72
+
73
+ if venture_name in ventures:
74
+ v = ventures[venture_name]
75
+ context["path"] = v.get("path", ".")
76
+ context["repo"] = v.get("repo", "")
77
+ return context
78
+
79
+ # Fallback to fuzzy match
80
+ for name, info in ventures.items():
81
+ if venture_name.lower() in name.lower():
82
+ context["path"] = info.get("path", ".")
83
+ context["repo"] = info.get("repo", "")
84
+ context["name"] = name
85
+ return context
86
+
87
+ return context
88
+
89
+ # ── Governed Selection ───────────────────────────────────────────────
90
+
91
+ def next_task(venture: str = "", max_risk: str = "", session_id: str = "") -> Dict[str, Any]:
92
+ """Get the next task to work on. Wrapper for server.py compatibility."""
93
+ 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}
94
+ task = get_next_build_task(session)
95
+ if task is None:
96
+ from ai.ledger_manager import list_items
97
+ result = list_items(status="open", project_path=str(ROOT_LEDGER_PATH))
98
+ open_count = sum(len(v) for v in result.get("items", {}).values())
99
+ return {"action": "CONSENSUS", "reason": f"No build-safe items found ({open_count} open items, none actionable)", "remaining_items": open_count, "session": session}
100
+ return {"action": "BUILD", "task": task, "remaining_items": 0, "session": session}
101
+
102
+
103
+ def get_next_build_task(session: Dict[str, Any]) -> Optional[Dict[str, Any]]:
104
+ """Select the next build-safe item from the authoritative root ledger."""
138
105
  from ai.ledger_manager import list_items
139
- result = list_items(status="open", project_path=project_path)
106
+
107
+ # Authoritative root ledger check
108
+ result = list_items(status="open", project_path=str(ROOT_LEDGER_PATH))
140
109
  items = []
141
110
  for ledger_items in result.get("items", {}).values():
142
111
  items.extend(ledger_items)
143
-
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
148
-
149
-
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.
152
-
153
- Owner-only items are those with source='owner' or tags containing 'owner-action'.
154
- """
155
- filtered = []
112
+
113
+ # Filter build-safe items only
114
+ actionable = []
156
115
  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:
116
+ if item.get("type") not in BUILD_SAFE_TYPES:
160
117
  continue
161
- if item.get("source") == "owner":
118
+ # Skip items that explicitly require owner action or are not for AI
119
+ tags = item.get("tags", [])
120
+ if "owner-action" in tags or "manual" in tags:
162
121
  continue
163
-
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
169
-
170
- filtered.append(item)
171
- return filtered
172
-
173
-
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 "."
193
-
194
-
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)
199
-
200
-
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.
207
-
208
- Returns:
209
- Dict with action: BUILD (with task), CONSENSUS (generate new items), or STOP.
210
- """
211
- session = _get_or_create_session(session_id)
212
-
213
- # Check safeguards
214
- stop = _check_safeguards(session)
215
- if stop:
216
- stop["session"] = _session_summary(session)
217
- return stop
218
-
219
- # Resolve venture path
220
- project_path = _resolve_project_path(venture)
221
-
222
- # Get open items
223
- items = _get_open_items(venture=venture, project_path=project_path)
224
- actionable = _filter_actionable(items, max_risk=max_risk)
225
-
122
+ actionable.append(item)
123
+
226
124
  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
-
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."
259
-
260
- return result
261
-
262
-
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.
125
+ return None
126
+
127
+ # Sort by priority
128
+ priority_map = {"P0": 0, "P1": 1, "P2": 2, "P3": 3}
129
+ actionable.sort(key=lambda x: priority_map.get(x.get("priority", "P2"), 9))
130
+
131
+ return actionable[0]
272
132
 
273
- Records completion, updates session metrics, returns the next task.
274
- """
275
- session = _get_or_create_session(session_id)
133
+ # ── Swarm Dispatch & Execution ───────────────────────────────────────
276
134
 
277
- # Update metrics
278
- session["iterations"] += 1
279
- session["cost_incurred"] += cost_incurred
135
+ def loop_config(session_id: str = "", max_iterations: int = 0,
136
+ cost_cap: float = 0.0, auto_consensus: bool = False,
137
+ error_threshold: int = 0, status: str = "",
138
+ require_approval_for: list = None) -> Dict[str, Any]:
139
+ """Configure or create a loop session with safeguards."""
140
+ _ensure_session_dir()
280
141
 
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
- })
142
+ # Load existing or create new
143
+ if session_id:
144
+ path = SESSION_DIR / f"{session_id}.json"
145
+ if path.exists():
146
+ session = json.loads(path.read_text())
147
+ else:
148
+ session = {
149
+ "session_id": session_id,
150
+ "type": "governed_build",
151
+ "started_at": datetime.now(timezone.utc).isoformat(),
152
+ "iterations": 0,
153
+ "max_iterations": max_iterations or MAX_ITERATIONS_DEFAULT,
154
+ "cost_incurred": 0.0,
155
+ "cost_cap": cost_cap or MAX_COST_DEFAULT,
156
+ "errors": 0,
157
+ "error_threshold": error_threshold or MAX_ERRORS_DEFAULT,
158
+ "tasks_completed": [],
159
+ "status": status or "running",
160
+ }
290
161
  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
- })
298
-
299
- _save_session(session)
300
-
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
309
-
310
- # Return the next task
311
- return next_task(venture=venture, session_id=session["session_id"])
312
-
313
-
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
-
323
- session = _load_session(session_id)
324
- if not session:
325
- return {"error": f"Session {session_id} not found."}
162
+ session = create_governed_session()
326
163
 
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
- },
336
- }
337
-
338
-
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)
350
-
351
- changes = {}
164
+ # Apply non-zero/non-empty overrides
352
165
  if max_iterations > 0:
353
166
  session["max_iterations"] = max_iterations
354
- changes["max_iterations"] = max_iterations
355
167
  if cost_cap > 0:
356
168
  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
169
  if error_threshold > 0:
362
170
  session["error_threshold"] = error_threshold
363
- changes["error_threshold"] = error_threshold
364
- if status and status in VALID_STATUSES:
171
+ if status:
365
172
  session["status"] = status
366
- changes["status"] = status
173
+ if auto_consensus:
174
+ session["auto_consensus"] = True
367
175
  if require_approval_for is not None:
368
176
  session["require_approval_for"] = require_approval_for
369
- changes["require_approval_for"] = require_approval_for
370
177
 
371
178
  _save_session(session)
372
-
373
- return {
374
- "session_id": session["session_id"],
375
- "changes": changes,
376
- "current_config": _session_summary(session),
377
- }
378
-
379
-
380
- def _session_summary(session: Dict[str, Any]) -> Dict[str, Any]:
381
- """Return a concise session summary for inclusion in responses."""
382
179
  return {
383
180
  "session_id": session["session_id"],
384
181
  "status": session["status"],
385
- "iterations": session["iterations"],
386
182
  "max_iterations": session["max_iterations"],
387
- "cost_incurred": round(session["cost_incurred"], 4),
183
+ "iterations": session.get("iterations", 0),
388
184
  "cost_cap": session["cost_cap"],
389
- "errors": session["errors"],
185
+ "cost_incurred": session.get("cost_incurred", 0.0),
390
186
  "error_threshold": session["error_threshold"],
391
- "tasks_done": len(session.get("tasks_completed", [])),
392
- "auto_consensus": session.get("auto_consensus", False),
187
+ "errors": session.get("errors", 0),
393
188
  }
394
189
 
395
190
 
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
191
+ def run_governed_iteration(session_id: str) -> Dict[str, Any]:
192
+ """Execute one governed build iteration."""
193
+ from datetime import datetime, timezone
194
+ from ai.swarm import dispatch_task
195
+
196
+ # 1. Load Session & Check Safeguards
197
+ path = SESSION_DIR / f"{session_id}.json"
198
+ if not path.exists():
199
+ return {"error": f"Session {session_id} not found"}
200
+ session = json.loads(path.read_text())
201
+
202
+ if session["status"] != "running":
203
+ return {"status": "stopped", "reason": f"Session status is {session['status']}"}
204
+
205
+ if session["iterations"] >= session["max_iterations"]:
206
+ session["status"] = "finished"
207
+ _save_session(session)
208
+ return {"status": "finished", "reason": "Max iterations reached"}
209
+
210
+ if session["cost_incurred"] >= session["cost_cap"]:
211
+ session["status"] = "stopped"
212
+ _save_session(session)
213
+ return {"status": "stopped", "reason": "Cost cap reached"}
214
+
215
+ # 2. Select Task
216
+ task = get_next_build_task(session)
217
+ if not task:
218
+ return {"status": "idle", "reason": "No build-safe items in ledger"}
219
+
220
+ # 3. Resolve Context
221
+ v_name = task.get("venture", "root")
222
+ ctx = resolve_venture_context(v_name)
223
+
224
+ # 4. Dispatch through Swarm (Control Plane)
225
+ logger.info(f"Dispatching build task {task['id']} for venture {v_name}")
226
+
227
+ start_time = time.time()
228
+ try:
229
+ # Note: Swarm dispatch is the central point of governance
230
+ dispatch_result = dispatch_task(
231
+ title=task["title"],
232
+ description=task["description"],
233
+ context=f"Executing governed build loop for {v_name}. Ledger ID: {task['id']}",
234
+ project_path=ctx["path"],
235
+ priority=task["priority"]
236
+ )
237
+
238
+ # 5. Update State & Ledger
239
+ duration = time.time() - start_time
240
+ cost = dispatch_result.get("estimated_cost", 0.05) # Default placeholder if missing
241
+
242
+ session["iterations"] += 1
243
+ session["cost_incurred"] += cost
244
+
245
+ from ai.ledger_manager import update_item
246
+ if dispatch_result.get("status") == "completed":
247
+ update_item(
248
+ item_id=task["id"],
249
+ status="done",
250
+ note=f"Completed via governed build loop. Result: {dispatch_result.get('summary', 'OK')}",
251
+ project_path=str(ROOT_LEDGER_PATH)
252
+ )
253
+ session["tasks_completed"].append({
254
+ "id": task["id"],
255
+ "status": "success",
256
+ "duration": duration,
257
+ "cost": cost
258
+ })
259
+ else:
260
+ session["errors"] += 1
261
+ if session["errors"] >= session["error_threshold"]:
262
+ session["status"] = "circuit_broken"
263
+ session["tasks_completed"].append({
264
+ "id": task["id"],
265
+ "status": "failed",
266
+ "error": dispatch_result.get("error", "Dispatch failed")
267
+ })
268
+
269
+ _save_session(session)
270
+ return {"status": "continued", "task_id": task["id"], "result": dispatch_result}
271
+
272
+ except Exception as e:
273
+ session["errors"] += 1
274
+ _save_session(session)
275
+ return {"error": str(e)}
276
+
277
+ if __name__ == "__main__":
278
+ # Test pass if run directly
279
+ pass