delimit-cli 3.14.27 → 3.14.29

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 (48) hide show
  1. package/bin/delimit-setup.js +19 -2
  2. package/gateway/ai/backends/deploy_bridge.py +56 -2
  3. package/gateway/ai/backends/gateway_core.py +212 -1
  4. package/gateway/ai/backends/generate_bridge.py +84 -13
  5. package/gateway/ai/backends/governance_bridge.py +63 -16
  6. package/gateway/ai/backends/memory_bridge.py +77 -76
  7. package/gateway/ai/backends/ops_bridge.py +76 -6
  8. package/gateway/ai/backends/os_bridge.py +23 -3
  9. package/gateway/ai/backends/repo_bridge.py +156 -17
  10. package/gateway/ai/backends/tools_design.py +116 -9
  11. package/gateway/ai/backends/tools_infra.py +200 -72
  12. package/gateway/ai/backends/tools_real.py +8 -0
  13. package/gateway/ai/backends/ui_bridge.py +115 -5
  14. package/gateway/ai/backends/vault_bridge.py +69 -114
  15. package/gateway/ai/content_engine.py +1276 -0
  16. package/gateway/ai/context_fs.py +193 -0
  17. package/gateway/ai/daemon.py +500 -0
  18. package/gateway/ai/data_plane.py +291 -0
  19. package/gateway/ai/deliberation.py +1033 -6
  20. package/gateway/ai/events.py +39 -0
  21. package/gateway/ai/founding_users.py +162 -0
  22. package/gateway/ai/governance.py +698 -4
  23. package/gateway/ai/inbox_daemon.py +78 -17
  24. package/gateway/ai/integrations/__init__.py +1 -0
  25. package/gateway/ai/integrations/opensage_wrapper.py +288 -0
  26. package/gateway/ai/key_resolver.py +95 -0
  27. package/gateway/ai/ledger_manager.py +289 -1
  28. package/gateway/ai/license.py +62 -4
  29. package/gateway/ai/license_core.py +208 -7
  30. package/gateway/ai/local_server.py +215 -0
  31. package/gateway/ai/loop_engine.py +408 -0
  32. package/gateway/ai/mcp_bridge.py +178 -0
  33. package/gateway/ai/release_sync.py +2 -2
  34. package/gateway/ai/screen_record.py +374 -0
  35. package/gateway/ai/secrets_broker.py +235 -0
  36. package/gateway/ai/social.py +189 -27
  37. package/gateway/ai/social_target.py +1635 -0
  38. package/gateway/ai/supabase_sync.py +190 -0
  39. package/gateway/ai/tracing.py +195 -0
  40. package/gateway/core/contract_ledger.py +1 -1
  41. package/gateway/core/dependency_graph.py +1 -1
  42. package/gateway/core/dependency_manifest.py +1 -1
  43. package/gateway/core/diff_engine_v2.py +272 -78
  44. package/gateway/core/event_backbone.py +2 -2
  45. package/gateway/core/event_schema.py +1 -1
  46. package/gateway/core/impact_analyzer.py +1 -1
  47. package/gateway/core/policy_engine.py +4 -0
  48. package/package.json +1 -1
@@ -0,0 +1,193 @@
1
+ """Context Filesystem -- versioned namespace for agent state (STR-048).
2
+
3
+ All agent state lives here: memory, plans, artifacts, embeddings.
4
+ Supports branching (per-session forks) and merging (sync back to main).
5
+ This is what makes switching models seamless.
6
+ """
7
+ import json
8
+ import shutil
9
+ from pathlib import Path
10
+ from datetime import datetime, timezone
11
+
12
+ CONTEXT_ROOT = Path.home() / ".delimit" / "context"
13
+
14
+
15
+ def init_context(venture: str = "default") -> dict:
16
+ """Initialize a context namespace for a venture."""
17
+ ctx_dir = CONTEXT_ROOT / venture
18
+ ctx_dir.mkdir(parents=True, exist_ok=True)
19
+ for sub in ["memory", "plans", "artifacts", "snapshots", "branches"]:
20
+ (ctx_dir / sub).mkdir(exist_ok=True)
21
+
22
+ manifest = ctx_dir / "manifest.json"
23
+ if not manifest.exists():
24
+ manifest.write_text(json.dumps({
25
+ "venture": venture,
26
+ "created_at": datetime.now(timezone.utc).isoformat(),
27
+ "current_branch": "main",
28
+ "version": 1,
29
+ }))
30
+ return {"initialized": venture, "path": str(ctx_dir)}
31
+
32
+
33
+ def write_artifact(venture: str, name: str, content: str, artifact_type: str = "text") -> dict:
34
+ """Write an artifact to the context filesystem."""
35
+ ctx_dir = CONTEXT_ROOT / venture / "artifacts"
36
+ ctx_dir.mkdir(parents=True, exist_ok=True)
37
+
38
+ artifact = {
39
+ "name": name,
40
+ "type": artifact_type,
41
+ "content": content,
42
+ "created_at": datetime.now(timezone.utc).isoformat(),
43
+ "size": len(content),
44
+ }
45
+ (ctx_dir / f"{name}.json").write_text(json.dumps(artifact))
46
+
47
+ # Version tracking
48
+ _bump_version(venture)
49
+ return {"written": name, "venture": venture, "size": len(content)}
50
+
51
+
52
+ def read_artifact(venture: str, name: str) -> dict:
53
+ """Read an artifact from the context filesystem."""
54
+ path = CONTEXT_ROOT / venture / "artifacts" / f"{name}.json"
55
+ if not path.exists():
56
+ return {"error": f"Artifact '{name}' not found in {venture}"}
57
+ return json.loads(path.read_text())
58
+
59
+
60
+ def list_artifacts(venture: str) -> list:
61
+ """List all artifacts in a venture's context."""
62
+ ctx_dir = CONTEXT_ROOT / venture / "artifacts"
63
+ if not ctx_dir.exists():
64
+ return []
65
+ artifacts = []
66
+ for f in sorted(ctx_dir.glob("*.json")):
67
+ try:
68
+ a = json.loads(f.read_text())
69
+ artifacts.append({
70
+ "name": a["name"],
71
+ "type": a.get("type"),
72
+ "size": a.get("size", 0),
73
+ "created_at": a.get("created_at"),
74
+ })
75
+ except (json.JSONDecodeError, KeyError):
76
+ pass
77
+ return artifacts
78
+
79
+
80
+ def create_snapshot(venture: str, label: str = "") -> dict:
81
+ """Create a point-in-time snapshot of the entire context."""
82
+ ctx_dir = CONTEXT_ROOT / venture
83
+ snapshots_dir = ctx_dir / "snapshots"
84
+ snapshots_dir.mkdir(parents=True, exist_ok=True)
85
+
86
+ ts = datetime.now(timezone.utc).strftime("%Y%m%d_%H%M%S")
87
+ snap_name = f"{ts}_{label}" if label else ts
88
+ snap_dir = snapshots_dir / snap_name
89
+
90
+ # Copy artifacts and memory
91
+ for sub in ["artifacts", "memory"]:
92
+ src = ctx_dir / sub
93
+ if src.exists():
94
+ shutil.copytree(src, snap_dir / sub, dirs_exist_ok=True)
95
+
96
+ # Save manifest
97
+ manifest = {
98
+ "snapshot": snap_name,
99
+ "venture": venture,
100
+ "created_at": datetime.now(timezone.utc).isoformat(),
101
+ "label": label,
102
+ }
103
+ (snap_dir / "snapshot.json").write_text(json.dumps(manifest))
104
+
105
+ return {"snapshot": snap_name, "venture": venture}
106
+
107
+
108
+ def list_snapshots(venture: str) -> list:
109
+ """List all snapshots for a venture."""
110
+ snapshots_dir = CONTEXT_ROOT / venture / "snapshots"
111
+ if not snapshots_dir.exists():
112
+ return []
113
+ snaps = []
114
+ for d in sorted(snapshots_dir.iterdir(), reverse=True):
115
+ if d.is_dir():
116
+ meta_file = d / "snapshot.json"
117
+ if meta_file.exists():
118
+ snaps.append(json.loads(meta_file.read_text()))
119
+ else:
120
+ snaps.append({"snapshot": d.name, "venture": venture})
121
+ return snaps
122
+
123
+
124
+ def create_branch(venture: str, branch_name: str) -> dict:
125
+ """Create a branch (fork) of the current context."""
126
+ ctx_dir = CONTEXT_ROOT / venture
127
+ branch_dir = ctx_dir / "branches" / branch_name
128
+ if branch_dir.exists():
129
+ return {"error": f"Branch '{branch_name}' already exists"}
130
+
131
+ branch_dir.mkdir(parents=True)
132
+ for sub in ["artifacts", "memory"]:
133
+ src = ctx_dir / sub
134
+ if src.exists():
135
+ shutil.copytree(src, branch_dir / sub, dirs_exist_ok=True)
136
+
137
+ manifest = {
138
+ "branch": branch_name,
139
+ "venture": venture,
140
+ "created_at": datetime.now(timezone.utc).isoformat(),
141
+ "parent": "main",
142
+ }
143
+ (branch_dir / "branch.json").write_text(json.dumps(manifest))
144
+
145
+ return {"branch": branch_name, "venture": venture}
146
+
147
+
148
+ def list_branches(venture: str) -> list:
149
+ """List all branches for a venture."""
150
+ branches_dir = CONTEXT_ROOT / venture / "branches"
151
+ if not branches_dir.exists():
152
+ return []
153
+ branches = []
154
+ for d in sorted(branches_dir.iterdir()):
155
+ if d.is_dir():
156
+ meta_file = d / "branch.json"
157
+ if meta_file.exists():
158
+ branches.append(json.loads(meta_file.read_text()))
159
+ else:
160
+ branches.append({"branch": d.name, "venture": venture})
161
+ return branches
162
+
163
+
164
+ def merge_branch(venture: str, branch_name: str) -> dict:
165
+ """Merge a branch back into main context."""
166
+ branch_dir = CONTEXT_ROOT / venture / "branches" / branch_name
167
+ if not branch_dir.exists():
168
+ return {"error": f"Branch '{branch_name}' not found"}
169
+
170
+ ctx_dir = CONTEXT_ROOT / venture
171
+ merged_files = 0
172
+ for sub in ["artifacts", "memory"]:
173
+ src = branch_dir / sub
174
+ if src.exists():
175
+ dest = ctx_dir / sub
176
+ dest.mkdir(parents=True, exist_ok=True)
177
+ for f in src.glob("*"):
178
+ shutil.copy2(f, dest / f.name)
179
+ merged_files += 1
180
+
181
+ shutil.rmtree(branch_dir)
182
+ _bump_version(venture)
183
+ return {"merged": branch_name, "files": merged_files}
184
+
185
+
186
+ def _bump_version(venture: str):
187
+ """Increment the version counter in the venture manifest."""
188
+ manifest_path = CONTEXT_ROOT / venture / "manifest.json"
189
+ if manifest_path.exists():
190
+ m = json.loads(manifest_path.read_text())
191
+ m["version"] = m.get("version", 0) + 1
192
+ m["updated_at"] = datetime.now(timezone.utc).isoformat()
193
+ manifest_path.write_text(json.dumps(m))
@@ -0,0 +1,500 @@
1
+ """Delimit Autonomous Daemon — processes ledger items without human prompting.
2
+
3
+ The daemon continuously:
4
+ 1. Checks the ledger for open items
5
+ 2. Classifies each item as automatable vs needs-human
6
+ 3. Executes automatable items
7
+ 4. Updates results
8
+ 5. Escalates high-risk items via approval gates
9
+ 6. Loops
10
+
11
+ Risk tiers:
12
+ - LOW: auto-execute immediately (lint, diff, scan, test, docs)
13
+ - MEDIUM: execute and notify after (deploy to staging, security audit)
14
+ - HIGH: create approval request, wait for human (deploy to prod, data migration)
15
+ - CRITICAL: never auto-execute (delete data, change auth, billing changes)
16
+ """
17
+ import json
18
+ import time
19
+ import logging
20
+ import os
21
+ from pathlib import Path
22
+ from datetime import datetime, timezone, timedelta
23
+ from typing import Dict, List, Optional, Tuple
24
+
25
+ logger = logging.getLogger("delimit.daemon")
26
+
27
+ DAEMON_LOG = Path.home() / ".delimit" / "daemon" / "daemon.log.jsonl"
28
+ DAEMON_STATE = Path.home() / ".delimit" / "daemon" / "state.json"
29
+
30
+ # Risk classification for automated execution
31
+ AUTO_EXECUTE_TOOLS = {
32
+ # LOW risk — safe to run anytime
33
+ "lint", "diff", "scan", "diagnose", "doctor", "version", "help",
34
+ "ledger_context", "ledger_list", "gov_health", "gov_status",
35
+ "security_scan", "security_audit", "test_smoke", "test_generate",
36
+ "docs_generate", "docs_validate", "zero_spec", "explain", "semver",
37
+ "policy", "impact", "license_status", "content_schedule",
38
+ "social_generate", "resource_drivers", "context_list",
39
+ "secret_list", "obs_status", "obs_metrics",
40
+ }
41
+
42
+ NOTIFY_AFTER_TOOLS = {
43
+ # MEDIUM risk — execute then notify
44
+ "ledger_add", "ledger_done", "init", "social_post",
45
+ "content_publish", "context_write", "context_snapshot",
46
+ "gov_evaluate", "evidence_collect",
47
+ }
48
+
49
+ APPROVAL_REQUIRED_TOOLS = {
50
+ # HIGH risk — needs approval gate
51
+ "deploy_publish", "deploy_npm", "deploy_site", "deploy_rollback",
52
+ "secret_store", "secret_revoke", "data_migrate", "data_backup",
53
+ }
54
+
55
+ # Keywords indicating the item requires human action (forums, outreach, etc.)
56
+ HUMAN_REQUIRED_KEYWORDS = [
57
+ "recruit", "beta testers", "namepros", "contact", "email", "call",
58
+ "manual", "approve", "review", "decision", "meeting", "interview",
59
+ "negotiate", "sign", "contract", "payment", "purchase",
60
+ "sensor_github_issue",
61
+ ]
62
+
63
+ # Tools that should never be auto-called by the daemon (polling/sensor tools)
64
+ SKIP_TOOLS = {
65
+ "sensor_github_issue",
66
+ }
67
+
68
+ # Cooldown: don't re-call the same tool within this window
69
+ TOOL_COOLDOWN_SECONDS = 3600 # 1 hour
70
+
71
+ # Ledger item patterns that can be auto-processed
72
+ AUTO_PATTERNS = {
73
+ "lint": ["lint", "breaking change", "api check", "spec check"],
74
+ "scan": ["scan", "security", "audit", "vulnerability"],
75
+ "test": ["test", "coverage", "smoke"],
76
+ "docs": ["docs", "documentation", "readme"],
77
+ "governance": ["governance", "policy", "compliance"],
78
+ }
79
+
80
+
81
+ class DaemonState:
82
+ """Tracks daemon execution state across runs."""
83
+
84
+ def __init__(self, state_path: Optional[Path] = None):
85
+ self.state_path = state_path or DAEMON_STATE
86
+ self.state_path.parent.mkdir(parents=True, exist_ok=True)
87
+ self.state = self._load()
88
+
89
+ def _load(self) -> dict:
90
+ if self.state_path.exists():
91
+ try:
92
+ return json.loads(self.state_path.read_text())
93
+ except (json.JSONDecodeError, OSError):
94
+ pass
95
+ return {
96
+ "started_at": datetime.now(timezone.utc).isoformat(),
97
+ "loops": 0,
98
+ "items_processed": 0,
99
+ "items_skipped": 0,
100
+ "items_escalated": 0,
101
+ "last_loop_at": None,
102
+ "status": "idle",
103
+ "errors": 0,
104
+ "processed_ids": [],
105
+ }
106
+
107
+ def save(self):
108
+ self.state_path.write_text(json.dumps(self.state, indent=2))
109
+
110
+ def increment(self, key: str):
111
+ self.state[key] = self.state.get(key, 0) + 1
112
+ self.save()
113
+
114
+ def set(self, key: str, value):
115
+ self.state[key] = value
116
+ self.save()
117
+
118
+ def mark_processed(self, item_id: str):
119
+ """Record that an item has been attempted so it is skipped next loop."""
120
+ ids = set(self.state.get("processed_ids", []))
121
+ ids.add(item_id)
122
+ self.state["processed_ids"] = sorted(ids)
123
+ self.save()
124
+
125
+ def is_processed(self, item_id: str) -> bool:
126
+ return item_id in set(self.state.get("processed_ids", []))
127
+
128
+ def record_tool_call(self, tool_name: str):
129
+ """Record when a tool was last called for cooldown enforcement."""
130
+ cooldowns = self.state.get("tool_cooldowns", {})
131
+ cooldowns[tool_name] = datetime.now(timezone.utc).isoformat()
132
+ self.state["tool_cooldowns"] = cooldowns
133
+ self.save()
134
+
135
+ def is_tool_on_cooldown(self, tool_name: str,
136
+ cooldown_seconds: int = TOOL_COOLDOWN_SECONDS) -> bool:
137
+ """Return True if the tool was called within the cooldown window."""
138
+ cooldowns = self.state.get("tool_cooldowns", {})
139
+ last_call = cooldowns.get(tool_name)
140
+ if not last_call:
141
+ return False
142
+ try:
143
+ last_dt = datetime.fromisoformat(last_call)
144
+ if last_dt.tzinfo is None:
145
+ last_dt = last_dt.replace(tzinfo=timezone.utc)
146
+ return datetime.now(timezone.utc) - last_dt < timedelta(seconds=cooldown_seconds)
147
+ except (ValueError, TypeError):
148
+ return False
149
+
150
+
151
+ def log_action(action: str, item_id: str = "", detail: str = "",
152
+ risk: str = "low", log_path: Optional[Path] = None):
153
+ """Log daemon action to JSONL file."""
154
+ target = log_path or DAEMON_LOG
155
+ target.parent.mkdir(parents=True, exist_ok=True)
156
+ entry = {
157
+ "ts": datetime.now(timezone.utc).isoformat(),
158
+ "action": action,
159
+ "item_id": item_id,
160
+ "detail": detail[:500],
161
+ "risk": risk,
162
+ }
163
+ with open(target, "a") as f:
164
+ f.write(json.dumps(entry) + "\n")
165
+
166
+
167
+ def classify_item(item: dict) -> Tuple[str, str]:
168
+ """Classify a ledger item into risk tier and suggested tool.
169
+
170
+ Returns: (risk_tier, suggested_tool)
171
+ """
172
+ title = item.get("title", "").lower()
173
+ description = item.get("description", "").lower()
174
+ text = f"{title} {description}"
175
+
176
+ # Check for skip-tools — sensor/polling tools the daemon must never call
177
+ if any(kw in text for kw in SKIP_TOOLS):
178
+ return ("high", "human_required")
179
+
180
+ # Check for high-risk keywords
181
+ high_risk_keywords = [
182
+ "deploy", "publish", "production", "rollback",
183
+ "delete", "migrate", "billing", "auth",
184
+ ]
185
+ if any(kw in text for kw in high_risk_keywords):
186
+ return ("high", "deploy_publish")
187
+
188
+ # Check for human-required keywords — these override low-risk patterns
189
+ if any(kw in text for kw in HUMAN_REQUIRED_KEYWORDS):
190
+ return ("high", "human_required")
191
+
192
+ # Check for automatable patterns
193
+ for tool, keywords in AUTO_PATTERNS.items():
194
+ if any(kw in text for kw in keywords):
195
+ return ("low", tool)
196
+
197
+ # Default: medium risk, needs review
198
+ return ("medium", "unknown")
199
+
200
+
201
+ def get_open_ledger_items(ledger_dir: Optional[Path] = None) -> List[dict]:
202
+ """Read open items from ledger JSONL files.
203
+
204
+ Handles both simple entries and update entries that modify existing items.
205
+ """
206
+ ledger_dir = ledger_dir or (Path.home() / ".delimit" / "ledger")
207
+ if not ledger_dir.exists():
208
+ return []
209
+
210
+ items: Dict[str, dict] = {}
211
+ for fname in ["operations.jsonl", "strategy.jsonl"]:
212
+ fpath = ledger_dir / fname
213
+ if not fpath.exists():
214
+ continue
215
+ for line in fpath.read_text().splitlines():
216
+ line = line.strip()
217
+ if not line:
218
+ continue
219
+ try:
220
+ e = json.loads(line)
221
+ if not e.get("id"):
222
+ continue
223
+ if e.get("type") == "update" and e["id"] in items:
224
+ items[e["id"]].update(e)
225
+ elif e.get("type") != "update":
226
+ items[e["id"]] = e
227
+ except (json.JSONDecodeError, KeyError):
228
+ pass
229
+
230
+ # Filter to open items
231
+ return [i for i in items.values() if i.get("status") == "open"]
232
+
233
+
234
+ def get_next_automatable_item(
235
+ ledger_dir: Optional[Path] = None,
236
+ state: Optional[DaemonState] = None,
237
+ ) -> Optional[dict]:
238
+ """Get the next ledger item that can be auto-executed (low risk only).
239
+
240
+ If *state* is provided, items already in ``state.processed_ids`` are
241
+ skipped so the daemon does not re-process the same item every loop.
242
+ """
243
+ open_items = get_open_ledger_items(ledger_dir)
244
+
245
+ # Sort by priority
246
+ priority_order = {"P0": 0, "P1": 1, "P2": 2}
247
+ open_items.sort(key=lambda x: priority_order.get(x.get("priority", "P2"), 3))
248
+
249
+ # Find first automatable item that hasn't been processed already
250
+ for item in open_items:
251
+ item_id = item.get("id", "")
252
+ if state and state.is_processed(item_id):
253
+ continue
254
+ risk, tool = classify_item(item)
255
+ if risk == "low":
256
+ # Enforce tool cooldown — don't call the same tool repeatedly
257
+ if state and tool and state.is_tool_on_cooldown(tool):
258
+ continue
259
+ item["_risk"] = risk
260
+ item["_suggested_tool"] = tool
261
+ return item
262
+
263
+ return None
264
+
265
+
266
+ def process_item(item: dict, log_path: Optional[Path] = None) -> dict:
267
+ """Process a single ledger item by running the suggested tool.
268
+
269
+ For high/critical risk items, creates an escalation instead of executing.
270
+ For low risk items, actually runs the tool. Medium risk items run then notify.
271
+ """
272
+ item_id = item.get("id", "unknown")
273
+ risk = item.get("_risk", "medium")
274
+ tool = item.get("_suggested_tool", "unknown")
275
+
276
+ log_action("processing", item_id, f"risk={risk}, tool={tool}", risk,
277
+ log_path=log_path)
278
+
279
+ if risk in ("high", "critical"):
280
+ # Create approval request instead of executing
281
+ log_action("escalated", item_id, "Requires human approval", risk,
282
+ log_path=log_path)
283
+ return {
284
+ "status": "escalated",
285
+ "item_id": item_id,
286
+ "reason": "high-risk action requires human approval",
287
+ }
288
+
289
+ # ACTUALLY RUN THE TOOL
290
+ tool_map = {
291
+ "lint": _run_lint,
292
+ "scan": _run_scan,
293
+ "test": _run_test,
294
+ "governance": _run_governance,
295
+ "docs": _run_docs,
296
+ }
297
+
298
+ runner = tool_map.get(tool)
299
+ if runner:
300
+ try:
301
+ result = runner(item)
302
+ log_action("completed", item_id, json.dumps(result)[:200], risk,
303
+ log_path=log_path)
304
+ return {"status": "executed", "item_id": item_id, "result": result}
305
+ except Exception as e:
306
+ log_action("error", item_id, str(e)[:200], risk,
307
+ log_path=log_path)
308
+ return {"status": "error", "item_id": item_id, "error": str(e)}
309
+
310
+ # Fallback for tools without a runner
311
+ result = {
312
+ "status": "processed",
313
+ "item_id": item_id,
314
+ "tool": tool,
315
+ "risk": risk,
316
+ "action": f"No runner for {tool}: {item.get('title', '')[:100]}",
317
+ }
318
+ log_action("completed", item_id, json.dumps(result)[:200], risk,
319
+ log_path=log_path)
320
+ return result
321
+
322
+
323
+ def _run_lint(item: dict) -> dict:
324
+ """Run lint on any spec files mentioned in the item."""
325
+ import glob as globmod
326
+ try:
327
+ from ai.backends.gateway_core import run_lint
328
+ except ImportError:
329
+ return {"status": "import_error", "detail": "gateway_core not available"}
330
+ specs = (
331
+ globmod.glob("**/openapi.yaml", recursive=True)
332
+ + globmod.glob("**/openapi.yml", recursive=True)
333
+ + globmod.glob("**/openapi.json", recursive=True)
334
+ )
335
+ if specs:
336
+ return run_lint(specs[0], specs[0])
337
+ return {"status": "no_specs_found"}
338
+
339
+
340
+ def _run_scan(item: dict) -> dict:
341
+ """Run a lightweight project scan by discovering key files."""
342
+ scan_result = {"status": "scanned", "files": {}}
343
+ for pattern, label in [
344
+ ("**/openapi.yaml", "openapi_specs"),
345
+ ("**/openapi.yml", "openapi_specs"),
346
+ ("**/package.json", "node_projects"),
347
+ ("**/pyproject.toml", "python_projects"),
348
+ ("**/.delimit/policies.yml", "delimit_policies"),
349
+ ]:
350
+ import glob as globmod
351
+ found = globmod.glob(pattern, recursive=True)
352
+ if found:
353
+ scan_result["files"][label] = found[:10]
354
+ return scan_result
355
+
356
+
357
+ def _run_test(item: dict) -> dict:
358
+ """Run test discovery (not execution) to count available tests."""
359
+ import subprocess
360
+ try:
361
+ result = subprocess.run(
362
+ ["python3", "-m", "pytest", "--co", "-q"],
363
+ capture_output=True, text=True, timeout=30,
364
+ )
365
+ lines = [l for l in result.stdout.strip().splitlines() if l.strip()]
366
+ return {"tests_found": len(lines), "status": "discovered"}
367
+ except FileNotFoundError:
368
+ return {"status": "pytest_not_found"}
369
+ except subprocess.TimeoutExpired:
370
+ return {"status": "timeout"}
371
+ except Exception as e:
372
+ return {"status": "error", "detail": str(e)[:200]}
373
+
374
+
375
+ def _run_governance(item: dict) -> dict:
376
+ """Run a governance health check by inspecting project structure."""
377
+ result = {"status": "checked", "findings": []}
378
+ cwd = Path(".")
379
+ if (cwd / ".delimit" / "policies.yml").exists():
380
+ result["findings"].append("policies.yml found")
381
+ else:
382
+ result["findings"].append("no policies.yml — run delimit init")
383
+ if (cwd / "delimit.yml").exists():
384
+ result["findings"].append("delimit.yml found")
385
+ if any(cwd.glob("**/openapi.yaml")) or any(cwd.glob("**/openapi.yml")):
386
+ result["findings"].append("OpenAPI spec found")
387
+ else:
388
+ result["findings"].append("no OpenAPI spec detected")
389
+ return result
390
+
391
+
392
+ def _run_docs(item: dict) -> dict:
393
+ """Run docs validation by checking for common documentation files."""
394
+ result = {"status": "checked", "files_found": [], "missing": []}
395
+ cwd = Path(".")
396
+ for doc_file in ["README.md", "CHANGELOG.md", "CONTRIBUTING.md", "LICENSE"]:
397
+ if (cwd / doc_file).exists():
398
+ result["files_found"].append(doc_file)
399
+ else:
400
+ result["missing"].append(doc_file)
401
+ return result
402
+
403
+
404
+ def run_loop(max_iterations: int = 0, interval_seconds: int = 60,
405
+ dry_run: bool = True, state_path: Optional[Path] = None,
406
+ log_path: Optional[Path] = None,
407
+ ledger_dir: Optional[Path] = None) -> dict:
408
+ """Run the daemon loop.
409
+
410
+ Args:
411
+ max_iterations: 0 = infinite loop, >0 = stop after N iterations
412
+ interval_seconds: seconds between checks
413
+ dry_run: if True, log but don't execute
414
+ state_path: override state file location (for testing)
415
+ log_path: override log file location (for testing)
416
+ ledger_dir: override ledger directory (for testing)
417
+ """
418
+ state = DaemonState(state_path=state_path)
419
+ state.set("status", "running")
420
+ state.set("started_at", datetime.now(timezone.utc).isoformat())
421
+
422
+ iteration = 0
423
+ logger.info(f"Daemon started (dry_run={dry_run}, interval={interval_seconds}s)")
424
+ log_action("daemon_start", detail=f"dry_run={dry_run}", log_path=log_path)
425
+
426
+ try:
427
+ while True:
428
+ iteration += 1
429
+ state.increment("loops")
430
+ state.set("last_loop_at", datetime.now(timezone.utc).isoformat())
431
+
432
+ # Get next automatable item (skip already-processed ones)
433
+ item = get_next_automatable_item(
434
+ ledger_dir=ledger_dir, state=state,
435
+ )
436
+
437
+ if item:
438
+ item_id = item.get("id", "")
439
+ tool = item.get("_suggested_tool", "unknown")
440
+ if dry_run:
441
+ risk, tool = classify_item(item)
442
+ log_action(
443
+ "dry_run", item.get("id", ""),
444
+ f"Would process: {item.get('title', '')[:80]} "
445
+ f"(tool={tool}, risk={risk})",
446
+ log_path=log_path,
447
+ )
448
+ state.increment("items_skipped")
449
+ else:
450
+ result = process_item(item, log_path=log_path)
451
+ if result.get("status") == "escalated":
452
+ state.increment("items_escalated")
453
+ else:
454
+ state.increment("items_processed")
455
+ # Mark item as processed so it is not retried next loop
456
+ state.mark_processed(item_id)
457
+ # Record tool cooldown so it is not called again within the window
458
+ if tool and tool != "unknown":
459
+ state.record_tool_call(tool)
460
+ else:
461
+ log_action("idle", detail="No automatable items found",
462
+ log_path=log_path)
463
+
464
+ # Check iteration limit
465
+ if max_iterations > 0 and iteration >= max_iterations:
466
+ break
467
+
468
+ time.sleep(interval_seconds)
469
+
470
+ except KeyboardInterrupt:
471
+ logger.info("Daemon stopped by user")
472
+ finally:
473
+ state.set("status", "stopped")
474
+ log_action("daemon_stop", detail=f"iterations={iteration}",
475
+ log_path=log_path)
476
+
477
+ return state.state
478
+
479
+
480
+ def get_daemon_status(state_path: Optional[Path] = None,
481
+ log_path: Optional[Path] = None) -> dict:
482
+ """Get current daemon status including recent log entries."""
483
+ state = DaemonState(state_path=state_path)
484
+ target_log = log_path or DAEMON_LOG
485
+
486
+ # Read recent log entries
487
+ recent = []
488
+ if target_log.exists():
489
+ lines = target_log.read_text().splitlines()
490
+ for line in lines[-20:]:
491
+ try:
492
+ recent.append(json.loads(line))
493
+ except (json.JSONDecodeError, ValueError):
494
+ pass
495
+
496
+ return {
497
+ **state.state,
498
+ "recent_actions": recent[-10:],
499
+ "log_path": str(target_log),
500
+ }