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.
- package/bin/delimit-setup.js +19 -2
- package/gateway/ai/backends/deploy_bridge.py +56 -2
- package/gateway/ai/backends/gateway_core.py +212 -1
- package/gateway/ai/backends/generate_bridge.py +84 -13
- package/gateway/ai/backends/governance_bridge.py +63 -16
- package/gateway/ai/backends/memory_bridge.py +77 -76
- package/gateway/ai/backends/ops_bridge.py +76 -6
- package/gateway/ai/backends/os_bridge.py +23 -3
- package/gateway/ai/backends/repo_bridge.py +156 -17
- package/gateway/ai/backends/tools_design.py +116 -9
- package/gateway/ai/backends/tools_infra.py +200 -72
- package/gateway/ai/backends/tools_real.py +8 -0
- package/gateway/ai/backends/ui_bridge.py +115 -5
- package/gateway/ai/backends/vault_bridge.py +69 -114
- package/gateway/ai/content_engine.py +1276 -0
- package/gateway/ai/context_fs.py +193 -0
- package/gateway/ai/daemon.py +500 -0
- package/gateway/ai/data_plane.py +291 -0
- package/gateway/ai/deliberation.py +1033 -6
- package/gateway/ai/events.py +39 -0
- package/gateway/ai/founding_users.py +162 -0
- package/gateway/ai/governance.py +698 -4
- package/gateway/ai/inbox_daemon.py +78 -17
- package/gateway/ai/integrations/__init__.py +1 -0
- package/gateway/ai/integrations/opensage_wrapper.py +288 -0
- package/gateway/ai/key_resolver.py +95 -0
- package/gateway/ai/ledger_manager.py +289 -1
- package/gateway/ai/license.py +62 -4
- package/gateway/ai/license_core.py +208 -7
- package/gateway/ai/local_server.py +215 -0
- package/gateway/ai/loop_engine.py +408 -0
- package/gateway/ai/mcp_bridge.py +178 -0
- package/gateway/ai/release_sync.py +2 -2
- package/gateway/ai/screen_record.py +374 -0
- package/gateway/ai/secrets_broker.py +235 -0
- package/gateway/ai/social.py +189 -27
- package/gateway/ai/social_target.py +1635 -0
- package/gateway/ai/supabase_sync.py +190 -0
- package/gateway/ai/tracing.py +195 -0
- package/gateway/core/contract_ledger.py +1 -1
- package/gateway/core/dependency_graph.py +1 -1
- package/gateway/core/dependency_manifest.py +1 -1
- package/gateway/core/diff_engine_v2.py +272 -78
- package/gateway/core/event_backbone.py +2 -2
- package/gateway/core/event_schema.py +1 -1
- package/gateway/core/impact_analyzer.py +1 -1
- package/gateway/core/policy_engine.py +4 -0
- 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
|
+
}
|