delimit-cli 3.13.3 → 3.14.1
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-cli.js +304 -1
- package/bin/delimit-setup.js +10 -4
- package/gateway/ai/inbox_daemon.py +623 -0
- package/gateway/ai/ledger_manager.py +88 -19
- package/gateway/ai/notify.py +975 -0
- package/gateway/ai/server.py +3570 -426
- package/gateway/ai/social.py +504 -0
- package/gateway/ai/tool_metadata.py +201 -0
- package/lib/cross-model-hooks.js +173 -43
- package/package.json +1 -1
- package/scripts/crosspost_devto.py +304 -0
package/gateway/ai/server.py
CHANGED
|
@@ -22,17 +22,406 @@ All tools follow the Adapter Boundary Contract v1.0:
|
|
|
22
22
|
import json
|
|
23
23
|
import logging
|
|
24
24
|
import os
|
|
25
|
+
import re
|
|
25
26
|
import shutil
|
|
26
27
|
import subprocess
|
|
27
28
|
import traceback
|
|
29
|
+
import uuid
|
|
28
30
|
from datetime import datetime, timezone
|
|
29
31
|
from pathlib import Path
|
|
30
|
-
from typing import Any, Dict, List, Optional
|
|
32
|
+
from typing import Any, Dict, List, Optional, Union
|
|
31
33
|
|
|
32
34
|
from fastmcp import FastMCP
|
|
33
35
|
|
|
34
36
|
logger = logging.getLogger("delimit.ai")
|
|
35
37
|
|
|
38
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
39
|
+
# STR-046: Agent Identity — session tracking for every tool call
|
|
40
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
41
|
+
|
|
42
|
+
_current_session_id = os.environ.get("DELIMIT_SESSION_ID", "")
|
|
43
|
+
|
|
44
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
45
|
+
# STR-053: Distributed Tracing — trace ID + span counter for every call
|
|
46
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
47
|
+
|
|
48
|
+
_trace_id = os.environ.get("DELIMIT_TRACE_ID", str(uuid.uuid4())[:8])
|
|
49
|
+
_span_counter = 0
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _next_span_id() -> str:
|
|
53
|
+
"""Generate a monotonically increasing span ID within the current trace."""
|
|
54
|
+
global _span_counter
|
|
55
|
+
_span_counter += 1
|
|
56
|
+
return f"{_trace_id}-{_span_counter:04d}"
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _detect_model() -> str:
|
|
60
|
+
"""Detect the AI model from environment variables.
|
|
61
|
+
|
|
62
|
+
Tries DELIMIT_MODEL first, then common env vars set by various clients,
|
|
63
|
+
and falls back to 'claude' since most users are on Claude Code.
|
|
64
|
+
"""
|
|
65
|
+
model = os.environ.get("DELIMIT_MODEL", "")
|
|
66
|
+
if model:
|
|
67
|
+
return model
|
|
68
|
+
for var in ("CLAUDE_MODEL", "ANTHROPIC_MODEL", "MCP_CLIENT"):
|
|
69
|
+
model = os.environ.get(var, "")
|
|
70
|
+
if model:
|
|
71
|
+
return model
|
|
72
|
+
return "claude"
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_session_info() -> Dict[str, str]:
|
|
76
|
+
"""Return identity envelope for the current agent session."""
|
|
77
|
+
return {
|
|
78
|
+
"session_id": _current_session_id,
|
|
79
|
+
"agent_type": _detect_model(),
|
|
80
|
+
"user_id": os.environ.get("DELIMIT_USER", ""),
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _sanitize_path(user_path: str, label: str = "path") -> Path:
|
|
85
|
+
"""Validate and resolve a user-supplied path.
|
|
86
|
+
|
|
87
|
+
Defense-in-depth against path traversal and injection via prompt manipulation.
|
|
88
|
+
Returns a resolved Path or raises ValueError.
|
|
89
|
+
"""
|
|
90
|
+
if not user_path or not isinstance(user_path, str):
|
|
91
|
+
raise ValueError(f"{label} must be a non-empty string")
|
|
92
|
+
|
|
93
|
+
# Block null bytes (classic injection vector)
|
|
94
|
+
if "\x00" in user_path:
|
|
95
|
+
raise ValueError(f"{label} contains null bytes")
|
|
96
|
+
|
|
97
|
+
# Block shell metacharacters that shouldn't appear in legitimate paths
|
|
98
|
+
dangerous_chars = {";", "|", "&", "`", "$", "(", ")", "{", "}", "<", ">", "\n", "\r"}
|
|
99
|
+
found = [c for c in dangerous_chars if c in user_path]
|
|
100
|
+
if found:
|
|
101
|
+
raise ValueError(f"{label} contains dangerous characters: {found}")
|
|
102
|
+
|
|
103
|
+
resolved = Path(user_path).resolve()
|
|
104
|
+
|
|
105
|
+
# Block /proc, /sys, /dev paths
|
|
106
|
+
blocked_prefixes = ("/proc/", "/sys/", "/dev/")
|
|
107
|
+
resolved_str = str(resolved)
|
|
108
|
+
for prefix in blocked_prefixes:
|
|
109
|
+
if resolved_str.startswith(prefix):
|
|
110
|
+
raise ValueError(f"{label} points to restricted path: {prefix}")
|
|
111
|
+
|
|
112
|
+
return resolved
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def _sanitize_subprocess_arg(value: str, label: str = "argument") -> str:
|
|
116
|
+
"""Sanitize a single subprocess argument against injection.
|
|
117
|
+
|
|
118
|
+
Blocks shell metacharacters that could exploit subprocess calls
|
|
119
|
+
even in list-form (e.g., via git or npm argument parsing).
|
|
120
|
+
"""
|
|
121
|
+
if not isinstance(value, str):
|
|
122
|
+
raise ValueError(f"{label} must be a string")
|
|
123
|
+
if "\x00" in value:
|
|
124
|
+
raise ValueError(f"{label} contains null bytes")
|
|
125
|
+
# Block arguments that start with - but look like option injection
|
|
126
|
+
# (e.g., "--exec=..." passed as a repo name)
|
|
127
|
+
if value.startswith("-") and "=" in value:
|
|
128
|
+
raise ValueError(f"{label} looks like option injection: {value[:50]}")
|
|
129
|
+
return value
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _detect_prompt_injection(args: dict, tool_name: str = "") -> Optional[str]:
|
|
133
|
+
"""LED-195: Detect prompt injection patterns in MCP tool arguments.
|
|
134
|
+
|
|
135
|
+
Scans all string arguments for known injection patterns.
|
|
136
|
+
Returns a warning string if detected, None if clean.
|
|
137
|
+
"""
|
|
138
|
+
INJECTION_PATTERNS = [
|
|
139
|
+
# System prompt overrides
|
|
140
|
+
(r"ignore\s+(all\s+)?previous\s+instructions", "system prompt override"),
|
|
141
|
+
(r"ignore\s+(all\s+)?above", "system prompt override"),
|
|
142
|
+
(r"disregard\s+(all\s+)?previous", "system prompt override"),
|
|
143
|
+
(r"forget\s+(all\s+)?previous", "system prompt override"),
|
|
144
|
+
(r"you\s+are\s+now\s+a", "role reassignment"),
|
|
145
|
+
(r"act\s+as\s+if\s+you", "role reassignment"),
|
|
146
|
+
(r"new\s+system\s+prompt", "system prompt injection"),
|
|
147
|
+
(r"<\s*system\s*>", "system tag injection"),
|
|
148
|
+
(r"\[SYSTEM\]", "system tag injection"),
|
|
149
|
+
# Data exfiltration
|
|
150
|
+
(r"send\s+(all|this|the)\s+(the\s+)?data\s+to", "data exfiltration"),
|
|
151
|
+
(r"curl\s+.*\|.*sh", "remote code execution"),
|
|
152
|
+
(r"wget\s+.*\|.*bash", "remote code execution"),
|
|
153
|
+
# Delimiter attacks
|
|
154
|
+
(r"={5,}", "delimiter manipulation"),
|
|
155
|
+
(r"-{5,}\s*end\s*of", "delimiter manipulation"),
|
|
156
|
+
]
|
|
157
|
+
|
|
158
|
+
import re as _re
|
|
159
|
+
for key, value in args.items():
|
|
160
|
+
if not isinstance(value, str) or len(value) < 15:
|
|
161
|
+
continue
|
|
162
|
+
value_lower = value.lower()
|
|
163
|
+
for pattern, category in INJECTION_PATTERNS:
|
|
164
|
+
if _re.search(pattern, value_lower):
|
|
165
|
+
_emit_event("security", {
|
|
166
|
+
"type": "prompt_injection_detected",
|
|
167
|
+
"tool": tool_name,
|
|
168
|
+
"argument": key,
|
|
169
|
+
"category": category,
|
|
170
|
+
"pattern": pattern,
|
|
171
|
+
})
|
|
172
|
+
return f"Prompt injection detected in '{key}': {category}"
|
|
173
|
+
return None
|
|
174
|
+
|
|
175
|
+
|
|
176
|
+
def _coerce_list_arg(
|
|
177
|
+
value: Optional[Union[str, List[str]]],
|
|
178
|
+
field_name: str,
|
|
179
|
+
) -> Optional[List[str]]:
|
|
180
|
+
"""Accept native lists, JSON list strings, or comma-delimited strings."""
|
|
181
|
+
if value is None:
|
|
182
|
+
return None
|
|
183
|
+
if isinstance(value, list):
|
|
184
|
+
return [str(item).strip() for item in value if str(item).strip()]
|
|
185
|
+
if isinstance(value, str):
|
|
186
|
+
text = value.strip()
|
|
187
|
+
if not text:
|
|
188
|
+
return []
|
|
189
|
+
if text.startswith("["):
|
|
190
|
+
try:
|
|
191
|
+
parsed = json.loads(text)
|
|
192
|
+
except json.JSONDecodeError as exc:
|
|
193
|
+
raise ValueError(f"{field_name} must be a list or JSON list string: {exc}") from exc
|
|
194
|
+
if not isinstance(parsed, list):
|
|
195
|
+
raise ValueError(f"{field_name} JSON string must decode to a list")
|
|
196
|
+
return [str(item).strip() for item in parsed if str(item).strip()]
|
|
197
|
+
return [item.strip() for item in text.split(",") if item.strip()]
|
|
198
|
+
raise ValueError(f"{field_name} must be a list or string")
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _coerce_dict_arg(
|
|
202
|
+
value: Optional[Union[str, Dict[str, Any]]],
|
|
203
|
+
field_name: str,
|
|
204
|
+
string_key: Optional[str] = None,
|
|
205
|
+
) -> Optional[Dict[str, Any]]:
|
|
206
|
+
"""Accept native dicts or JSON object strings, with optional string wrapping."""
|
|
207
|
+
if value is None:
|
|
208
|
+
return None
|
|
209
|
+
if isinstance(value, dict):
|
|
210
|
+
return value
|
|
211
|
+
if isinstance(value, str):
|
|
212
|
+
text = value.strip()
|
|
213
|
+
if not text:
|
|
214
|
+
return {}
|
|
215
|
+
if text.startswith("{"):
|
|
216
|
+
try:
|
|
217
|
+
parsed = json.loads(text)
|
|
218
|
+
except json.JSONDecodeError as exc:
|
|
219
|
+
raise ValueError(f"{field_name} must be a dict or JSON object string: {exc}") from exc
|
|
220
|
+
if not isinstance(parsed, dict):
|
|
221
|
+
raise ValueError(f"{field_name} JSON string must decode to an object")
|
|
222
|
+
return parsed
|
|
223
|
+
if string_key:
|
|
224
|
+
return {string_key: text}
|
|
225
|
+
raise ValueError(f"{field_name} must be a dict or JSON object string")
|
|
226
|
+
raise ValueError(f"{field_name} must be a dict or string")
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
230
|
+
# STR-040: Risk Classification for Approval Gates
|
|
231
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
232
|
+
|
|
233
|
+
HIGH_RISK_TOOLS = {
|
|
234
|
+
'deploy_publish', 'deploy_rollback', 'deploy_npm', 'deploy_site',
|
|
235
|
+
'security_scan', 'data_migrate', 'data_backup',
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
CRITICAL_RISK_TOOLS = {
|
|
239
|
+
'deploy_rollback', 'data_migrate',
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def _classify_risk(tool_name: str) -> str:
|
|
244
|
+
"""Classify tool risk level for approval gate decisions."""
|
|
245
|
+
clean = tool_name.replace('delimit_', '')
|
|
246
|
+
if clean in CRITICAL_RISK_TOOLS:
|
|
247
|
+
return 'critical'
|
|
248
|
+
if clean in HIGH_RISK_TOOLS:
|
|
249
|
+
return 'high'
|
|
250
|
+
return 'low'
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
254
|
+
# STR-052: Policy Kernel — Inline Enforcement
|
|
255
|
+
# Checks policy BEFORE/AFTER tool execution to block high-risk actions.
|
|
256
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def _check_policy_gate(tool_name: str, kwargs: dict) -> Optional[Dict]:
|
|
260
|
+
"""Check if policy allows this action. Returns error dict if blocked, None if allowed.
|
|
261
|
+
|
|
262
|
+
Modes:
|
|
263
|
+
- advisory: warn but never block (default for new users)
|
|
264
|
+
- guarded: block critical actions, warn on high-risk
|
|
265
|
+
- enforce: block critical + high-risk actions, require approval
|
|
266
|
+
"""
|
|
267
|
+
mode_file = Path.home() / ".delimit" / "enforcement_mode"
|
|
268
|
+
mode = "guarded" # Default: block critical, warn high-risk
|
|
269
|
+
if mode_file.exists():
|
|
270
|
+
try:
|
|
271
|
+
mode = mode_file.read_text().strip()
|
|
272
|
+
except Exception:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
if mode == "advisory":
|
|
276
|
+
return None # Never block in advisory mode
|
|
277
|
+
|
|
278
|
+
risk = _classify_risk(tool_name)
|
|
279
|
+
|
|
280
|
+
# Critical actions: always blocked in guarded/enforce mode
|
|
281
|
+
if risk == 'critical':
|
|
282
|
+
approval = _check_approval(tool_name)
|
|
283
|
+
if not approval:
|
|
284
|
+
_emit_policy_event(tool_name, "blocked", f"Critical action requires approval")
|
|
285
|
+
return {
|
|
286
|
+
"status": "blocked",
|
|
287
|
+
"reason": f"Critical action '{tool_name}' requires approval",
|
|
288
|
+
"risk_level": risk,
|
|
289
|
+
"action": "Request approval in the dashboard at app.delimit.ai",
|
|
290
|
+
"approval_url": "https://app.delimit.ai/dashboard",
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
# High-risk actions: blocked only in enforce mode, warned in guarded
|
|
294
|
+
if risk == 'high' and mode == 'enforce':
|
|
295
|
+
approval = _check_approval(tool_name)
|
|
296
|
+
if not approval:
|
|
297
|
+
_emit_policy_event(tool_name, "blocked", f"High-risk action requires approval (enforce mode)")
|
|
298
|
+
return {
|
|
299
|
+
"status": "blocked",
|
|
300
|
+
"reason": f"High-risk action '{tool_name}' requires approval in enforce mode",
|
|
301
|
+
"risk_level": risk,
|
|
302
|
+
"mode": mode,
|
|
303
|
+
"action": "Switch to guarded mode or request approval",
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
# LED-173: Deploy gating — block deploys when unresolved critical findings exist
|
|
307
|
+
DEPLOY_TOOLS = {"deploy_publish", "deploy_npm", "deploy_site", "deploy_build"}
|
|
308
|
+
clean = tool_name.replace("delimit_", "")
|
|
309
|
+
if clean in DEPLOY_TOOLS and mode != "advisory":
|
|
310
|
+
try:
|
|
311
|
+
from ai.ledger_manager import list_items
|
|
312
|
+
ledger_data = list_items(status="open")
|
|
313
|
+
open_items = ledger_data.get("items", [])
|
|
314
|
+
if isinstance(open_items, dict):
|
|
315
|
+
flat = []
|
|
316
|
+
for v in open_items.values():
|
|
317
|
+
if isinstance(v, list):
|
|
318
|
+
flat.extend(v)
|
|
319
|
+
open_items = flat
|
|
320
|
+
critical_findings = [
|
|
321
|
+
i for i in open_items
|
|
322
|
+
if isinstance(i, dict)
|
|
323
|
+
and i.get("priority") == "P0"
|
|
324
|
+
and i.get("source", "").startswith("security_ingest:")
|
|
325
|
+
]
|
|
326
|
+
if critical_findings:
|
|
327
|
+
titles = [f.get("title", "")[:60] for f in critical_findings[:3]]
|
|
328
|
+
_emit_policy_event(tool_name, "blocked", f"Deploy blocked: {len(critical_findings)} unresolved critical security finding(s)")
|
|
329
|
+
return {
|
|
330
|
+
"status": "blocked",
|
|
331
|
+
"reason": f"Deploy blocked: {len(critical_findings)} unresolved critical security finding(s) in ledger",
|
|
332
|
+
"findings": titles,
|
|
333
|
+
"action": "Resolve critical security findings first, or mark them as accepted risk",
|
|
334
|
+
"risk_level": "critical",
|
|
335
|
+
}
|
|
336
|
+
except Exception as e:
|
|
337
|
+
logger.debug("Deploy gate check failed: %s", e)
|
|
338
|
+
|
|
339
|
+
# Check venture-specific policies
|
|
340
|
+
venture = kwargs.get('venture', '') or kwargs.get('repo', '') or kwargs.get('target', '')
|
|
341
|
+
if isinstance(venture, dict):
|
|
342
|
+
venture = ''
|
|
343
|
+
policies = _load_venture_policies(str(venture))
|
|
344
|
+
for rule in policies:
|
|
345
|
+
if _rule_matches(rule, tool_name):
|
|
346
|
+
if rule.get('action') == 'forbid':
|
|
347
|
+
reason = rule.get('message', f"Policy '{rule.get('name', 'unnamed')}' blocks this action")
|
|
348
|
+
_emit_policy_event(tool_name, "blocked", reason)
|
|
349
|
+
return {
|
|
350
|
+
"status": "blocked",
|
|
351
|
+
"reason": reason,
|
|
352
|
+
"policy": rule.get('id', rule.get('name', '')),
|
|
353
|
+
"risk_level": risk,
|
|
354
|
+
}
|
|
355
|
+
return None
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def _check_approval(tool_name: str) -> bool:
|
|
359
|
+
"""Check if there's an active approval for this tool."""
|
|
360
|
+
approvals_dir = Path.home() / ".delimit" / "approvals"
|
|
361
|
+
if not approvals_dir.exists():
|
|
362
|
+
return False
|
|
363
|
+
for f in approvals_dir.glob("*.json"):
|
|
364
|
+
try:
|
|
365
|
+
a = json.loads(f.read_text())
|
|
366
|
+
if a.get('tool') == tool_name and a.get('status') == 'approved':
|
|
367
|
+
expires = datetime.fromisoformat(a.get('expires_at', ''))
|
|
368
|
+
if expires > datetime.now(timezone.utc):
|
|
369
|
+
return True
|
|
370
|
+
except Exception:
|
|
371
|
+
pass
|
|
372
|
+
return False
|
|
373
|
+
|
|
374
|
+
|
|
375
|
+
def _load_venture_policies(venture: str) -> list:
|
|
376
|
+
"""Load policies for a venture from ~/.delimit/policies.yml."""
|
|
377
|
+
policies_file = Path.home() / ".delimit" / "policies.yml"
|
|
378
|
+
if not policies_file.exists():
|
|
379
|
+
return []
|
|
380
|
+
try:
|
|
381
|
+
import yaml
|
|
382
|
+
data = yaml.safe_load(policies_file.read_text())
|
|
383
|
+
if not isinstance(data, dict):
|
|
384
|
+
return []
|
|
385
|
+
return data.get('rules', [])
|
|
386
|
+
except Exception:
|
|
387
|
+
return []
|
|
388
|
+
|
|
389
|
+
|
|
390
|
+
def _rule_matches(rule: dict, tool_name: str) -> bool:
|
|
391
|
+
"""Check if a policy rule matches a tool."""
|
|
392
|
+
path_pattern = rule.get('conditions', {}).get('tool_pattern', '')
|
|
393
|
+
if path_pattern:
|
|
394
|
+
try:
|
|
395
|
+
return bool(re.match(path_pattern, tool_name))
|
|
396
|
+
except re.error:
|
|
397
|
+
return False
|
|
398
|
+
change_types = rule.get('change_types', [])
|
|
399
|
+
if change_types and tool_name in ('delimit_lint', 'delimit_diff', 'lint', 'diff'):
|
|
400
|
+
return True
|
|
401
|
+
return False
|
|
402
|
+
|
|
403
|
+
|
|
404
|
+
def _emit_policy_event(tool_name: str, status: str, reason: str) -> None:
|
|
405
|
+
"""Write a policy enforcement event to the daily events log."""
|
|
406
|
+
try:
|
|
407
|
+
events_dir = Path.home() / ".delimit" / "events"
|
|
408
|
+
events_dir.mkdir(parents=True, exist_ok=True)
|
|
409
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
410
|
+
event = {
|
|
411
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
412
|
+
"type": "policy_blocked",
|
|
413
|
+
"tool": tool_name,
|
|
414
|
+
"status": status,
|
|
415
|
+
"reason": reason,
|
|
416
|
+
"model": _detect_model(),
|
|
417
|
+
"session_id": _current_session_id,
|
|
418
|
+
}
|
|
419
|
+
with open(events_dir / f"events-{today}.jsonl", "a") as f:
|
|
420
|
+
f.write(json.dumps(event) + "\n")
|
|
421
|
+
except Exception:
|
|
422
|
+
pass # Never let event tracking break tool execution
|
|
423
|
+
|
|
424
|
+
|
|
36
425
|
mcp = FastMCP("delimit")
|
|
37
426
|
mcp.description = (
|
|
38
427
|
"Delimit — One workspace for every AI coding assistant. "
|
|
@@ -40,26 +429,54 @@ mcp.description = (
|
|
|
40
429
|
"Use delimit_scan on new projects. Track all work via the ledger."
|
|
41
430
|
)
|
|
42
431
|
|
|
43
|
-
VERSION = "3.2.
|
|
432
|
+
VERSION = "3.2.1"
|
|
44
433
|
|
|
45
|
-
# LED-044
|
|
46
|
-
#
|
|
434
|
+
# LED-044 + Consensus 118/119/120: Tool visibility tiers.
|
|
435
|
+
# Tier cascade: SHOW_EXPERIMENTAL > SHOW_INTERNAL > SHOW_OPS > public (always visible).
|
|
436
|
+
# Set DELIMIT_SHOW_INTERNAL=1 to see all tiers (founder workflow).
|
|
47
437
|
SHOW_EXPERIMENTAL = os.environ.get("DELIMIT_SHOW_EXPERIMENTAL", "") == "1"
|
|
438
|
+
SHOW_OPS = os.environ.get("DELIMIT_SHOW_OPS", "") == "1"
|
|
439
|
+
SHOW_INTERNAL = os.environ.get("DELIMIT_SHOW_INTERNAL", "") == "1"
|
|
440
|
+
|
|
441
|
+
_TIER_ENABLED = {
|
|
442
|
+
"public": True,
|
|
443
|
+
"ops_pack": SHOW_OPS or SHOW_INTERNAL or SHOW_EXPERIMENTAL,
|
|
444
|
+
"internal": SHOW_INTERNAL or SHOW_EXPERIMENTAL,
|
|
445
|
+
"experimental": SHOW_EXPERIMENTAL,
|
|
446
|
+
}
|
|
48
447
|
|
|
49
448
|
|
|
50
|
-
def
|
|
51
|
-
"""
|
|
52
|
-
|
|
449
|
+
def _tier_tool(tier: str = "public"):
|
|
450
|
+
"""Register as MCP tool only when the tier is enabled.
|
|
451
|
+
Function remains importable as Python for chaining regardless."""
|
|
452
|
+
if tier not in _TIER_ENABLED:
|
|
453
|
+
raise ValueError(f"Unknown tool tier '{tier}'")
|
|
53
454
|
def decorator(fn):
|
|
54
|
-
if
|
|
455
|
+
if _TIER_ENABLED[tier]:
|
|
55
456
|
return mcp.tool()(fn)
|
|
56
457
|
return fn
|
|
57
458
|
return decorator
|
|
58
459
|
|
|
59
460
|
|
|
461
|
+
def _ops_pack_tool():
|
|
462
|
+
"""Convenience alias: register tool only when ops_pack tier is enabled."""
|
|
463
|
+
return _tier_tool("ops_pack")
|
|
464
|
+
|
|
465
|
+
|
|
466
|
+
def _internal_tool():
|
|
467
|
+
"""Convenience alias: register tool only when internal tier is enabled."""
|
|
468
|
+
return _tier_tool("internal")
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def _experimental_tool():
|
|
472
|
+
"""Backward-compatible alias: register tool only when experimental tier is enabled."""
|
|
473
|
+
return _tier_tool("experimental")
|
|
474
|
+
|
|
475
|
+
|
|
60
476
|
# Pro tools — single source of truth is license_core.py
|
|
61
477
|
# Import at module level; fallback to license.py shim if core unavailable
|
|
62
478
|
from ai.license import PRO_TOOLS
|
|
479
|
+
from ai.rate_limiter import limiter, create_cost_controls_response
|
|
63
480
|
|
|
64
481
|
# Free tools — everything NOT in PRO_TOOLS
|
|
65
482
|
# security_audit, security_scan, test_generate, test_smoke, activate, license_status
|
|
@@ -84,6 +501,79 @@ def _safe_call(fn, **kwargs) -> Dict[str, Any]:
|
|
|
84
501
|
return {"error": "backend_failure", "message": str(e)}
|
|
85
502
|
|
|
86
503
|
|
|
504
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
505
|
+
# CONSENSUS 120: Tool Chaining Infrastructure
|
|
506
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
507
|
+
|
|
508
|
+
_CHAIN_FAIL_STATUSES = {"blocked", "fail", "failed", "premium_required", "not_available"}
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
def _chain_is_error(result: Dict[str, Any]) -> bool:
|
|
512
|
+
"""Check if a chain step result indicates failure."""
|
|
513
|
+
if not isinstance(result, dict):
|
|
514
|
+
return False
|
|
515
|
+
if result.get("error"):
|
|
516
|
+
return True
|
|
517
|
+
status = str(result.get("status", "")).lower()
|
|
518
|
+
decision = str(result.get("decision", "")).lower()
|
|
519
|
+
return status in _CHAIN_FAIL_STATUSES or decision == "fail"
|
|
520
|
+
|
|
521
|
+
|
|
522
|
+
def _emit_chain_event(parent_tool: str, step: str, result: Dict[str, Any]) -> None:
|
|
523
|
+
"""Write a chain step event to the daily events log."""
|
|
524
|
+
try:
|
|
525
|
+
events_dir = Path.home() / ".delimit" / "events"
|
|
526
|
+
events_dir.mkdir(parents=True, exist_ok=True)
|
|
527
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
528
|
+
event = {
|
|
529
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
530
|
+
"type": "tool_chain_step",
|
|
531
|
+
"parent_tool": parent_tool,
|
|
532
|
+
"step": step,
|
|
533
|
+
"status": result.get("status", result.get("decision", "ok")),
|
|
534
|
+
"error": result.get("error", ""),
|
|
535
|
+
"trace_id": _trace_id,
|
|
536
|
+
"span_id": _next_span_id(),
|
|
537
|
+
"session_id": _current_session_id,
|
|
538
|
+
}
|
|
539
|
+
with open(events_dir / f"events-{today}.jsonl", "a") as f:
|
|
540
|
+
f.write(json.dumps(event) + "\n")
|
|
541
|
+
except Exception:
|
|
542
|
+
pass
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def _chain_call(parent_tool: str, step: str, fn, *, required: bool = True, **kwargs) -> Dict[str, Any]:
|
|
546
|
+
"""Call a backend function as part of a tool chain.
|
|
547
|
+
|
|
548
|
+
- Wraps _safe_call for error handling
|
|
549
|
+
- Emits chain-specific event (not through _with_next_steps)
|
|
550
|
+
- If required=True and step fails, sets _chain_halt=True in result
|
|
551
|
+
- If required=False, failure is logged but does not halt
|
|
552
|
+
"""
|
|
553
|
+
result = _safe_call(fn, **kwargs)
|
|
554
|
+
_emit_chain_event(parent_tool, step, result)
|
|
555
|
+
if required and _chain_is_error(result):
|
|
556
|
+
result["_chain_halt"] = True
|
|
557
|
+
return result
|
|
558
|
+
|
|
559
|
+
|
|
560
|
+
def _count_critical_findings(audit_result: Dict[str, Any]) -> int:
|
|
561
|
+
"""Extract critical finding count from security audit result."""
|
|
562
|
+
summary = audit_result.get("severity_summary")
|
|
563
|
+
if isinstance(summary, dict):
|
|
564
|
+
try:
|
|
565
|
+
return int(summary.get("critical", 0))
|
|
566
|
+
except (ValueError, TypeError):
|
|
567
|
+
pass
|
|
568
|
+
total = 0
|
|
569
|
+
for key in ("vulnerabilities", "anti_patterns", "secrets", "top_findings"):
|
|
570
|
+
items = audit_result.get(key, [])
|
|
571
|
+
if isinstance(items, list):
|
|
572
|
+
total += sum(1 for i in items
|
|
573
|
+
if isinstance(i, dict) and str(i.get("severity", "")).lower() == "critical")
|
|
574
|
+
return total
|
|
575
|
+
|
|
576
|
+
|
|
87
577
|
# ═══════════════════════════════════════════════════════════════════════
|
|
88
578
|
# CONSENSUS 096: Tool Cohesion — next_steps in every response
|
|
89
579
|
# ═══════════════════════════════════════════════════════════════════════
|
|
@@ -128,6 +618,59 @@ NEXT_STEPS_REGISTRY: Dict[str, List[Dict[str, Any]]] = {
|
|
|
128
618
|
{"tool": "delimit_gov_policy", "reason": "Review governance policy configuration", "suggested_args": {}, "is_premium": True},
|
|
129
619
|
],
|
|
130
620
|
"gov_policy": [],
|
|
621
|
+
"config_export": [
|
|
622
|
+
{"tool": "delimit_config_import", "reason": "Import this config into another project", "suggested_args": {}, "is_premium": False},
|
|
623
|
+
],
|
|
624
|
+
"config_import": [
|
|
625
|
+
{"tool": "delimit_gov_health", "reason": "Verify governance health after import", "suggested_args": {}, "is_premium": True},
|
|
626
|
+
{"tool": "delimit_lint", "reason": "Run lint with the imported policy", "suggested_args": {}, "is_premium": False},
|
|
627
|
+
],
|
|
628
|
+
"changelog": [
|
|
629
|
+
{"tool": "delimit_notify", "reason": "Notify stakeholders about the changelog", "suggested_args": {"event_type": "changelog_generated"}, "is_premium": True},
|
|
630
|
+
{"tool": "delimit_semver", "reason": "Determine the version bump for these changes", "suggested_args": {}, "is_premium": False},
|
|
631
|
+
],
|
|
632
|
+
"notify": [
|
|
633
|
+
{"tool": "delimit_changelog", "reason": "Generate a changelog to include in the notification", "suggested_args": {}, "is_premium": False},
|
|
634
|
+
{"tool": "delimit_notify_inbox", "reason": "Check inbound email inbox for owner-action items", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
635
|
+
],
|
|
636
|
+
"notify_inbox": [
|
|
637
|
+
{"tool": "delimit_notify_inbox", "reason": "Process inbox and forward owner-action emails", "suggested_args": {"action": "poll", "process": True}, "is_premium": True},
|
|
638
|
+
{"tool": "delimit_notify", "reason": "Send a notification about inbox status", "suggested_args": {"channel": "email"}, "is_premium": True},
|
|
639
|
+
],
|
|
640
|
+
# --- Agent Orchestration (Pro) ---
|
|
641
|
+
"agent_dispatch": [
|
|
642
|
+
{"tool": "delimit_agent_status", "reason": "Check the status of your dispatched task", "suggested_args": {}, "is_premium": True},
|
|
643
|
+
],
|
|
644
|
+
"agent_status": [
|
|
645
|
+
{"tool": "delimit_agent_complete", "reason": "Mark a task as complete when done", "suggested_args": {}, "is_premium": True},
|
|
646
|
+
{"tool": "delimit_agent_handoff", "reason": "Hand off a task to a different AI model", "suggested_args": {}, "is_premium": True},
|
|
647
|
+
],
|
|
648
|
+
"agent_complete": [
|
|
649
|
+
{"tool": "delimit_ledger_context", "reason": "Review overall ledger status after completing a task", "suggested_args": {}, "is_premium": False},
|
|
650
|
+
],
|
|
651
|
+
"agent_handoff": [
|
|
652
|
+
{"tool": "delimit_agent_status", "reason": "Verify the handoff was recorded", "suggested_args": {}, "is_premium": True},
|
|
653
|
+
],
|
|
654
|
+
# --- Autonomous Build Loop (Pro) ---
|
|
655
|
+
"next_task": [
|
|
656
|
+
{"tool": "delimit_task_complete", "reason": "Mark the task done when finished", "suggested_args": {}, "is_premium": True},
|
|
657
|
+
{"tool": "delimit_loop_status", "reason": "Check loop metrics", "suggested_args": {}, "is_premium": True},
|
|
658
|
+
],
|
|
659
|
+
"task_complete": [
|
|
660
|
+
{"tool": "delimit_next_task", "reason": "Continue to the next task (already returned)", "suggested_args": {}, "is_premium": True},
|
|
661
|
+
],
|
|
662
|
+
"loop_status": [
|
|
663
|
+
{"tool": "delimit_loop_config", "reason": "Adjust safeguards", "suggested_args": {}, "is_premium": True},
|
|
664
|
+
{"tool": "delimit_next_task", "reason": "Resume building", "suggested_args": {}, "is_premium": True},
|
|
665
|
+
],
|
|
666
|
+
"loop_config": [
|
|
667
|
+
{"tool": "delimit_next_task", "reason": "Start building with new config", "suggested_args": {}, "is_premium": True},
|
|
668
|
+
],
|
|
669
|
+
# --- Inbox Polling Daemon (Consensus 116) ---
|
|
670
|
+
"inbox_daemon": [
|
|
671
|
+
{"tool": "delimit_notify_inbox", "reason": "Check inbox status and routing history", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
672
|
+
{"tool": "delimit_inbox_daemon", "reason": "Control the daemon (start/stop/status)", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
673
|
+
],
|
|
131
674
|
"gov_evaluate": [],
|
|
132
675
|
"gov_new_task": [],
|
|
133
676
|
"gov_run": [],
|
|
@@ -256,6 +799,80 @@ NEXT_STEPS_REGISTRY: Dict[str, List[Dict[str, Any]]] = {
|
|
|
256
799
|
],
|
|
257
800
|
# --- Sensing ---
|
|
258
801
|
"sensor_github_issue": [],
|
|
802
|
+
# --- Context Filesystem (STR-048) ---
|
|
803
|
+
"context_init": [
|
|
804
|
+
{"tool": "delimit_context_write", "reason": "Write an artifact to the new context", "suggested_args": {}, "is_premium": False},
|
|
805
|
+
],
|
|
806
|
+
"context_write": [
|
|
807
|
+
{"tool": "delimit_context_list", "reason": "List all artifacts in this context", "suggested_args": {}, "is_premium": False},
|
|
808
|
+
{"tool": "delimit_context_snapshot", "reason": "Snapshot current state after writing", "suggested_args": {}, "is_premium": False},
|
|
809
|
+
],
|
|
810
|
+
"context_read": [
|
|
811
|
+
{"tool": "delimit_context_list", "reason": "List all artifacts in this context", "suggested_args": {}, "is_premium": False},
|
|
812
|
+
],
|
|
813
|
+
"context_list": [
|
|
814
|
+
{"tool": "delimit_context_read", "reason": "Read a specific artifact", "suggested_args": {}, "is_premium": False},
|
|
815
|
+
],
|
|
816
|
+
"context_snapshot": [
|
|
817
|
+
{"tool": "delimit_context_branch", "reason": "Create a branch for experimental changes", "suggested_args": {"action": "create"}, "is_premium": False},
|
|
818
|
+
],
|
|
819
|
+
"context_branch": [
|
|
820
|
+
{"tool": "delimit_context_snapshot", "reason": "Snapshot before branching or merging", "suggested_args": {}, "is_premium": False},
|
|
821
|
+
],
|
|
822
|
+
# --- Social ---
|
|
823
|
+
"social_post": [
|
|
824
|
+
{"tool": "delimit_social_history", "reason": "Review what was posted today", "suggested_args": {"limit": 5}, "is_premium": True},
|
|
825
|
+
{"tool": "delimit_social_approve", "reason": "Review and approve pending drafts", "suggested_args": {"action": "list"}, "is_premium": True},
|
|
826
|
+
],
|
|
827
|
+
"social_generate": [
|
|
828
|
+
{"tool": "delimit_social_post", "reason": "Post the generated content", "suggested_args": {}, "is_premium": True},
|
|
829
|
+
{"tool": "delimit_social_post", "reason": "Save as draft for review", "suggested_args": {"draft": True}, "is_premium": True},
|
|
830
|
+
],
|
|
831
|
+
"social_history": [
|
|
832
|
+
{"tool": "delimit_social_generate", "reason": "Generate a new post", "suggested_args": {}, "is_premium": True},
|
|
833
|
+
],
|
|
834
|
+
"social_approve": [
|
|
835
|
+
{"tool": "delimit_social_history", "reason": "Review post history after approval", "suggested_args": {"limit": 5}, "is_premium": True},
|
|
836
|
+
],
|
|
837
|
+
# --- Content Engine ---
|
|
838
|
+
"content_schedule": [
|
|
839
|
+
{"tool": "delimit_content_publish", "reason": "Publish next queued content", "suggested_args": {"content_type": "tweet"}, "is_premium": True},
|
|
840
|
+
{"tool": "delimit_content_queue", "reason": "Manage content queues", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
841
|
+
],
|
|
842
|
+
"content_publish": [
|
|
843
|
+
{"tool": "delimit_content_schedule", "reason": "Check content schedule", "suggested_args": {}, "is_premium": True},
|
|
844
|
+
],
|
|
845
|
+
"content_queue": [
|
|
846
|
+
{"tool": "delimit_content_publish", "reason": "Publish next content", "suggested_args": {"content_type": "tweet"}, "is_premium": True},
|
|
847
|
+
{"tool": "delimit_content_schedule", "reason": "View full schedule", "suggested_args": {}, "is_premium": True},
|
|
848
|
+
],
|
|
849
|
+
# --- Screen Recording ---
|
|
850
|
+
"screen_record": [
|
|
851
|
+
{"tool": "delimit_content_publish", "reason": "Publish the recorded video", "suggested_args": {"content_type": "video"}, "is_premium": True},
|
|
852
|
+
{"tool": "delimit_evidence_collect", "reason": "Attach recording as governance evidence", "suggested_args": {}, "is_premium": True},
|
|
853
|
+
],
|
|
854
|
+
# --- Consolidated (Consensus 082) ---
|
|
855
|
+
"deploy": [
|
|
856
|
+
{"tool": "delimit_deploy", "reason": "Check deployment status", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
857
|
+
],
|
|
858
|
+
"secret": [
|
|
859
|
+
{"tool": "delimit_secret", "reason": "List all secrets", "suggested_args": {"action": "list"}, "is_premium": False},
|
|
860
|
+
],
|
|
861
|
+
"gov": [
|
|
862
|
+
{"tool": "delimit_gov", "reason": "Check governance health", "suggested_args": {"action": "health"}, "is_premium": False},
|
|
863
|
+
],
|
|
864
|
+
"context": [
|
|
865
|
+
{"tool": "delimit_context", "reason": "List artifacts in context", "suggested_args": {"action": "list"}, "is_premium": False},
|
|
866
|
+
],
|
|
867
|
+
"obs": [
|
|
868
|
+
{"tool": "delimit_obs", "reason": "Check system health", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
869
|
+
],
|
|
870
|
+
"release": [
|
|
871
|
+
{"tool": "delimit_release", "reason": "Check release status", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
872
|
+
],
|
|
873
|
+
"agent": [
|
|
874
|
+
{"tool": "delimit_agent", "reason": "Check agent task status", "suggested_args": {"action": "status"}, "is_premium": True},
|
|
875
|
+
],
|
|
259
876
|
# --- Meta ---
|
|
260
877
|
"version": [],
|
|
261
878
|
"help": [],
|
|
@@ -265,15 +882,259 @@ NEXT_STEPS_REGISTRY: Dict[str, List[Dict[str, Any]]] = {
|
|
|
265
882
|
}
|
|
266
883
|
|
|
267
884
|
|
|
885
|
+
def _emit_event(tool_name: str, result: Dict[str, Any]) -> None:
|
|
886
|
+
"""Write a tool-call event to the daily events log for dashboard tracking.
|
|
887
|
+
|
|
888
|
+
STR-046: Includes agent session identity and risk classification.
|
|
889
|
+
STR-053: Includes trace_id and span_id for distributed tracing.
|
|
890
|
+
"""
|
|
891
|
+
try:
|
|
892
|
+
events_dir = Path.home() / ".delimit" / "events"
|
|
893
|
+
events_dir.mkdir(parents=True, exist_ok=True)
|
|
894
|
+
today = datetime.now(timezone.utc).strftime("%Y-%m-%d")
|
|
895
|
+
session_info = _get_session_info()
|
|
896
|
+
risk = _classify_risk(tool_name)
|
|
897
|
+
span_id = _next_span_id()
|
|
898
|
+
event = {
|
|
899
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
900
|
+
"type": "tool_call",
|
|
901
|
+
"tool": tool_name,
|
|
902
|
+
"model": _detect_model(),
|
|
903
|
+
"status": result.get("status", result.get("decision", "ok")),
|
|
904
|
+
"venture": result.get("venture", ""),
|
|
905
|
+
"session_id": session_info["session_id"],
|
|
906
|
+
"risk_level": risk,
|
|
907
|
+
"trace_id": _trace_id,
|
|
908
|
+
"span_id": span_id,
|
|
909
|
+
}
|
|
910
|
+
with open(events_dir / f"events-{today}.jsonl", "a") as f:
|
|
911
|
+
f.write(json.dumps(event) + "\n")
|
|
912
|
+
|
|
913
|
+
# Sync to Supabase for dashboard visibility
|
|
914
|
+
try:
|
|
915
|
+
from ai.supabase_sync import sync_event as _sync_event_to_cloud
|
|
916
|
+
_sync_event_to_cloud(event)
|
|
917
|
+
except Exception:
|
|
918
|
+
pass # Never let cloud sync break tool execution
|
|
919
|
+
|
|
920
|
+
# LED-183: Webhook notifications for governance events
|
|
921
|
+
_fire_webhook(event)
|
|
922
|
+
|
|
923
|
+
# STR-053: Write trace span for session replay
|
|
924
|
+
try:
|
|
925
|
+
from ai.tracing import start_span, end_span
|
|
926
|
+
span = start_span(_trace_id, tool_name, args={"tool": tool_name})
|
|
927
|
+
status = result.get("status", result.get("decision", "ok"))
|
|
928
|
+
summary = result.get("message", result.get("summary", ""))
|
|
929
|
+
if isinstance(summary, (list, dict)):
|
|
930
|
+
summary = json.dumps(summary)[:200]
|
|
931
|
+
end_span(_trace_id, span["span_id"], status=str(status), result_summary=str(summary)[:200])
|
|
932
|
+
except Exception:
|
|
933
|
+
pass # Tracing is best-effort
|
|
934
|
+
|
|
935
|
+
# STR-046: Write to agent_actions log for session drill-down
|
|
936
|
+
if session_info["session_id"]:
|
|
937
|
+
actions_dir = Path.home() / ".delimit" / "agent_actions"
|
|
938
|
+
actions_dir.mkdir(parents=True, exist_ok=True)
|
|
939
|
+
action = {
|
|
940
|
+
"ts": event["ts"],
|
|
941
|
+
"session_id": session_info["session_id"],
|
|
942
|
+
"tool": tool_name,
|
|
943
|
+
"result_status": event["status"],
|
|
944
|
+
"risk_level": risk,
|
|
945
|
+
"venture": event["venture"],
|
|
946
|
+
"trace_id": _trace_id,
|
|
947
|
+
"span_id": span_id,
|
|
948
|
+
}
|
|
949
|
+
with open(actions_dir / f"actions-{today}.jsonl", "a") as f:
|
|
950
|
+
f.write(json.dumps(action) + "\n")
|
|
951
|
+
except Exception:
|
|
952
|
+
pass # Never let event tracking break tool execution
|
|
953
|
+
|
|
954
|
+
|
|
955
|
+
def _fire_webhook(event: dict) -> None:
|
|
956
|
+
"""LED-183: Send governance events to configured webhooks (Slack, Discord, etc).
|
|
957
|
+
|
|
958
|
+
Webhooks are configured at ~/.delimit/webhooks.json:
|
|
959
|
+
[
|
|
960
|
+
{"url": "https://hooks.slack.com/services/...", "events": ["blocked", "critical"]},
|
|
961
|
+
{"url": "https://discord.com/api/webhooks/...", "events": ["all"]}
|
|
962
|
+
]
|
|
963
|
+
|
|
964
|
+
Only fires for significant events (blocked, critical, security warnings).
|
|
965
|
+
"""
|
|
966
|
+
try:
|
|
967
|
+
webhooks_file = Path.home() / ".delimit" / "webhooks.json"
|
|
968
|
+
if not webhooks_file.exists():
|
|
969
|
+
return
|
|
970
|
+
|
|
971
|
+
webhooks = json.loads(webhooks_file.read_text())
|
|
972
|
+
if not isinstance(webhooks, list) or not webhooks:
|
|
973
|
+
return
|
|
974
|
+
|
|
975
|
+
# Only fire for significant events
|
|
976
|
+
status = event.get("status", "")
|
|
977
|
+
risk = event.get("risk_level", "low")
|
|
978
|
+
is_significant = (
|
|
979
|
+
status in ("blocked", "policy_blocked", "error", "failed") or
|
|
980
|
+
risk in ("critical", "high") or
|
|
981
|
+
event.get("type") == "prompt_injection_detected"
|
|
982
|
+
)
|
|
983
|
+
if not is_significant:
|
|
984
|
+
return
|
|
985
|
+
|
|
986
|
+
# Format the notification
|
|
987
|
+
tool = event.get("tool", "unknown")
|
|
988
|
+
ts = event.get("ts", "")
|
|
989
|
+
venture = event.get("venture", "")
|
|
990
|
+
message = f"[Delimit] {status.upper()}: {tool}"
|
|
991
|
+
if venture:
|
|
992
|
+
message += f" ({venture})"
|
|
993
|
+
|
|
994
|
+
for hook in webhooks:
|
|
995
|
+
hook_url = hook.get("url", "")
|
|
996
|
+
hook_events = hook.get("events", ["all"])
|
|
997
|
+
if not hook_url:
|
|
998
|
+
continue
|
|
999
|
+
|
|
1000
|
+
# Check event filter
|
|
1001
|
+
if "all" not in hook_events and status not in hook_events and risk not in hook_events:
|
|
1002
|
+
continue
|
|
1003
|
+
|
|
1004
|
+
# Detect webhook type and format accordingly
|
|
1005
|
+
try:
|
|
1006
|
+
if "slack.com" in hook_url or "hooks.slack" in hook_url:
|
|
1007
|
+
payload = json.dumps({
|
|
1008
|
+
"text": message,
|
|
1009
|
+
"blocks": [{
|
|
1010
|
+
"type": "section",
|
|
1011
|
+
"text": {"type": "mrkdwn", "text": f"*{message}*\n`{tool}` | Risk: {risk} | {ts[:19]}"},
|
|
1012
|
+
}],
|
|
1013
|
+
}).encode()
|
|
1014
|
+
elif "discord.com" in hook_url:
|
|
1015
|
+
payload = json.dumps({
|
|
1016
|
+
"content": message,
|
|
1017
|
+
"embeds": [{
|
|
1018
|
+
"title": f"Governance: {status}",
|
|
1019
|
+
"description": f"Tool: `{tool}`\nRisk: {risk}\nVenture: {venture}",
|
|
1020
|
+
"color": 0xFF0000 if risk == "critical" else 0xFFAA00,
|
|
1021
|
+
}],
|
|
1022
|
+
}).encode()
|
|
1023
|
+
else:
|
|
1024
|
+
# Generic webhook
|
|
1025
|
+
payload = json.dumps({
|
|
1026
|
+
"event": event,
|
|
1027
|
+
"message": message,
|
|
1028
|
+
}).encode()
|
|
1029
|
+
|
|
1030
|
+
req = urllib.request.Request(
|
|
1031
|
+
hook_url,
|
|
1032
|
+
data=payload,
|
|
1033
|
+
headers={"Content-Type": "application/json"},
|
|
1034
|
+
method="POST",
|
|
1035
|
+
)
|
|
1036
|
+
urllib.request.urlopen(req, timeout=5)
|
|
1037
|
+
except Exception:
|
|
1038
|
+
pass # Never let webhook failures break governance
|
|
1039
|
+
except Exception:
|
|
1040
|
+
pass # Never let webhook config issues break tool execution
|
|
1041
|
+
|
|
1042
|
+
|
|
1043
|
+
def _detect_environment() -> Dict[str, Any]:
|
|
1044
|
+
"""Auto-detect available API keys, CLIs, and capabilities.
|
|
1045
|
+
|
|
1046
|
+
Used by delimit_init and delimit_version to show what's available
|
|
1047
|
+
without requiring the user to manually configure anything.
|
|
1048
|
+
"""
|
|
1049
|
+
detected_keys = {}
|
|
1050
|
+
detected_clis = {}
|
|
1051
|
+
|
|
1052
|
+
# Check common AI API keys
|
|
1053
|
+
key_checks = {
|
|
1054
|
+
"anthropic": ("ANTHROPIC_API_KEY",),
|
|
1055
|
+
"openai": ("OPENAI_API_KEY",),
|
|
1056
|
+
"xai": ("XAI_API_KEY",),
|
|
1057
|
+
"google": ("GOOGLE_APPLICATION_CREDENTIALS", "GOOGLE_AI_API_KEY"),
|
|
1058
|
+
"github": ("GITHUB_TOKEN", "GH_TOKEN"),
|
|
1059
|
+
}
|
|
1060
|
+
for service, env_vars in key_checks.items():
|
|
1061
|
+
for var in env_vars:
|
|
1062
|
+
if os.environ.get(var):
|
|
1063
|
+
detected_keys[service] = {"source": "env", "env_var": var}
|
|
1064
|
+
break
|
|
1065
|
+
|
|
1066
|
+
# Check secrets broker
|
|
1067
|
+
secrets_dir = Path.home() / ".delimit" / "secrets"
|
|
1068
|
+
if secrets_dir.exists():
|
|
1069
|
+
for secret_file in secrets_dir.glob("*.json"):
|
|
1070
|
+
name = secret_file.stem
|
|
1071
|
+
if name not in detected_keys:
|
|
1072
|
+
try:
|
|
1073
|
+
data = json.loads(secret_file.read_text())
|
|
1074
|
+
if any(data.get(f) for f in ("value", "api_key", "token", "key")):
|
|
1075
|
+
detected_keys[name] = {"source": "secrets_broker"}
|
|
1076
|
+
except Exception:
|
|
1077
|
+
pass
|
|
1078
|
+
|
|
1079
|
+
# Check AI CLIs
|
|
1080
|
+
cli_checks = {
|
|
1081
|
+
"claude": "Claude Code",
|
|
1082
|
+
"codex": "Codex CLI",
|
|
1083
|
+
"gemini": "Gemini CLI",
|
|
1084
|
+
"cursor": "Cursor",
|
|
1085
|
+
"aider": "Aider",
|
|
1086
|
+
}
|
|
1087
|
+
for cmd, label in cli_checks.items():
|
|
1088
|
+
path = shutil.which(cmd)
|
|
1089
|
+
if path:
|
|
1090
|
+
detected_clis[cmd] = {"label": label, "path": path}
|
|
1091
|
+
|
|
1092
|
+
# Check security tools
|
|
1093
|
+
security_tools = {}
|
|
1094
|
+
for tool in ("trivy", "semgrep", "bandit", "snyk"):
|
|
1095
|
+
path = shutil.which(tool)
|
|
1096
|
+
if path:
|
|
1097
|
+
security_tools[tool] = path
|
|
1098
|
+
|
|
1099
|
+
return {
|
|
1100
|
+
"api_keys": detected_keys,
|
|
1101
|
+
"clis": detected_clis,
|
|
1102
|
+
"security_tools": security_tools,
|
|
1103
|
+
"summary": {
|
|
1104
|
+
"keys_found": len(detected_keys),
|
|
1105
|
+
"clis_found": len(detected_clis),
|
|
1106
|
+
"security_tools_found": len(security_tools),
|
|
1107
|
+
},
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
|
|
268
1111
|
def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
269
1112
|
"""Route every tool result through governance. This IS the loop.
|
|
270
1113
|
|
|
271
1114
|
The governance loop:
|
|
272
|
-
1.
|
|
273
|
-
2.
|
|
274
|
-
3.
|
|
275
|
-
4.
|
|
1115
|
+
1. Emit event for dashboard tracking
|
|
1116
|
+
2. STR-052: Policy kernel gate (blocks high-risk actions without approval)
|
|
1117
|
+
3. Check Pro license gate (blocks if not authorized)
|
|
1118
|
+
4. Check result against rules (thresholds, policies)
|
|
1119
|
+
5. Auto-create ledger items for failures/warnings
|
|
1120
|
+
6. Route back to delimit_ledger_context (the loop continues)
|
|
276
1121
|
"""
|
|
1122
|
+
# Emit event for real-time dashboard
|
|
1123
|
+
_emit_event(tool_name, result)
|
|
1124
|
+
|
|
1125
|
+
# STR-052: Policy kernel inline enforcement
|
|
1126
|
+
policy_gate = _check_policy_gate(tool_name, result if isinstance(result, dict) else {})
|
|
1127
|
+
if policy_gate:
|
|
1128
|
+
policy_gate["original_result"] = result
|
|
1129
|
+
policy_gate["governance"] = {"action": "policy_blocked", "reason": policy_gate["reason"]}
|
|
1130
|
+
return policy_gate
|
|
1131
|
+
|
|
1132
|
+
# LED-195: Prompt injection detection on tool inputs
|
|
1133
|
+
if isinstance(result, dict):
|
|
1134
|
+
injection = _detect_prompt_injection(result, tool_name)
|
|
1135
|
+
if injection:
|
|
1136
|
+
result["_security_warning"] = injection
|
|
1137
|
+
|
|
277
1138
|
# Pro license gate — blocks execution for premium tools
|
|
278
1139
|
full_name = f"delimit_{tool_name}" if not tool_name.startswith("delimit_") else tool_name
|
|
279
1140
|
gate = _check_pro(full_name)
|
|
@@ -300,14 +1161,90 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
|
|
|
300
1161
|
def delimit_lint(old_spec: str, new_spec: str, policy_file: Optional[str] = None) -> Dict[str, Any]:
|
|
301
1162
|
"""Lint two OpenAPI specs for breaking changes and policy violations.
|
|
302
1163
|
Primary CI integration point. Combines diff + policy into pass/fail.
|
|
1164
|
+
Auto-chains: semver classification, governance evaluation on breaking changes.
|
|
303
1165
|
|
|
304
1166
|
Args:
|
|
305
1167
|
old_spec: Path to the old (baseline) OpenAPI spec file.
|
|
306
1168
|
new_spec: Path to the new (proposed) OpenAPI spec file.
|
|
307
1169
|
policy_file: Optional path to a .delimit/policies.yml file.
|
|
308
1170
|
"""
|
|
309
|
-
from backends.gateway_core import run_lint
|
|
310
|
-
|
|
1171
|
+
from backends.gateway_core import run_lint, run_semver
|
|
1172
|
+
|
|
1173
|
+
# Step 1: Core lint
|
|
1174
|
+
lint_result = _safe_call(run_lint, old_spec=old_spec, new_spec=new_spec, policy_file=policy_file)
|
|
1175
|
+
chain: Dict[str, Any] = {"id": "lint_chain", "steps": []}
|
|
1176
|
+
|
|
1177
|
+
if lint_result.get("error"):
|
|
1178
|
+
lint_result["chain"] = chain
|
|
1179
|
+
return _with_next_steps("lint", lint_result)
|
|
1180
|
+
|
|
1181
|
+
# Step 2: Auto-classify semver bump (non-blocking on failure)
|
|
1182
|
+
semver_result = _chain_call("lint", "semver", run_semver,
|
|
1183
|
+
required=False, old_spec=old_spec, new_spec=new_spec)
|
|
1184
|
+
chain["steps"].append({"step": "semver", "ok": not _chain_is_error(semver_result)})
|
|
1185
|
+
lint_result["semver"] = semver_result
|
|
1186
|
+
|
|
1187
|
+
if _chain_is_error(semver_result):
|
|
1188
|
+
chain["status"] = "semver_failed_nonfatal"
|
|
1189
|
+
lint_result["chain"] = chain
|
|
1190
|
+
return _with_next_steps("lint", lint_result)
|
|
1191
|
+
|
|
1192
|
+
bump = str(semver_result.get("bump", "")).upper()
|
|
1193
|
+
|
|
1194
|
+
if bump != "MAJOR":
|
|
1195
|
+
chain["status"] = f"complete_{bump.lower() or 'none'}"
|
|
1196
|
+
lint_result["chain"] = chain
|
|
1197
|
+
return _with_next_steps("lint", lint_result)
|
|
1198
|
+
|
|
1199
|
+
# Step 3: MAJOR bump detected -- evaluate governance
|
|
1200
|
+
# Note: _delimit_gov_impl has its own Pro gate. Free-tier gets lint+semver only.
|
|
1201
|
+
gov_result = _delimit_gov_impl(
|
|
1202
|
+
action="evaluate",
|
|
1203
|
+
eval_action="api_breaking_change",
|
|
1204
|
+
context={
|
|
1205
|
+
"tool": "delimit_lint",
|
|
1206
|
+
"old_spec": old_spec,
|
|
1207
|
+
"new_spec": new_spec,
|
|
1208
|
+
"semver_bump": bump,
|
|
1209
|
+
"breaking_changes": lint_result.get("breaking", []),
|
|
1210
|
+
},
|
|
1211
|
+
repo=".",
|
|
1212
|
+
)
|
|
1213
|
+
chain["steps"].append({"step": "gov_evaluate", "ok": not _chain_is_error(gov_result)})
|
|
1214
|
+
lint_result["gov_evaluate"] = gov_result
|
|
1215
|
+
|
|
1216
|
+
# If Pro gate blocked governance, return gracefully with lint+semver
|
|
1217
|
+
if gov_result.get("status") == "premium_required":
|
|
1218
|
+
chain["status"] = "governance_skipped_free_tier"
|
|
1219
|
+
lint_result["chain"] = chain
|
|
1220
|
+
return _with_next_steps("lint", lint_result)
|
|
1221
|
+
|
|
1222
|
+
# Step 4: If governance blocked, record in ledger (best-effort)
|
|
1223
|
+
gov_blocked = (
|
|
1224
|
+
str(gov_result.get("status", "")).lower() == "blocked"
|
|
1225
|
+
or gov_result.get("governance", {}).get("action") == "policy_blocked"
|
|
1226
|
+
)
|
|
1227
|
+
|
|
1228
|
+
if gov_blocked:
|
|
1229
|
+
from ai.ledger_manager import add_item
|
|
1230
|
+
ledger_result = _chain_call(
|
|
1231
|
+
"lint", "ledger_add", add_item,
|
|
1232
|
+
required=False,
|
|
1233
|
+
title=f"Governance blocked: MAJOR API change in {new_spec}",
|
|
1234
|
+
ledger="ops",
|
|
1235
|
+
item_type="fix",
|
|
1236
|
+
priority="P0",
|
|
1237
|
+
description="MAJOR semver bump detected. Governance blocked the change.",
|
|
1238
|
+
source="chain:lint:gov_blocked",
|
|
1239
|
+
)
|
|
1240
|
+
chain["steps"].append({"step": "ledger_add", "ok": not _chain_is_error(ledger_result)})
|
|
1241
|
+
lint_result["governance_blocked"] = True
|
|
1242
|
+
else:
|
|
1243
|
+
lint_result["governance_blocked"] = False
|
|
1244
|
+
|
|
1245
|
+
chain["status"] = "major_change_evaluated"
|
|
1246
|
+
lint_result["chain"] = chain
|
|
1247
|
+
return _with_next_steps("lint", lint_result)
|
|
311
1248
|
|
|
312
1249
|
|
|
313
1250
|
@mcp.tool()
|
|
@@ -339,7 +1276,7 @@ def delimit_ledger(ledger_path: str, api_name: Optional[str] = None, repository:
|
|
|
339
1276
|
"""Query the append-only contract ledger (hash-chained JSONL).
|
|
340
1277
|
|
|
341
1278
|
Args:
|
|
342
|
-
ledger_path: Path to the
|
|
1279
|
+
ledger_path: Path to the ledger JSONL file (e.g. .delimit/ledger/operations.jsonl).
|
|
343
1280
|
api_name: Filter events by API name.
|
|
344
1281
|
repository: Filter events by repository.
|
|
345
1282
|
validate_chain: Validate hash chain integrity.
|
|
@@ -445,14 +1382,24 @@ def delimit_init(
|
|
|
445
1382
|
policies_file = delimit_dir / "policies.yml"
|
|
446
1383
|
ledger_dir = delimit_dir / "ledger"
|
|
447
1384
|
events_file = ledger_dir / "events.jsonl"
|
|
1385
|
+
operations_file = ledger_dir / "operations.jsonl"
|
|
1386
|
+
strategy_file = ledger_dir / "strategy.jsonl"
|
|
448
1387
|
|
|
449
1388
|
# Idempotency check
|
|
450
|
-
if
|
|
1389
|
+
if (
|
|
1390
|
+
policies_file.exists()
|
|
1391
|
+
and ledger_dir.exists()
|
|
1392
|
+
and events_file.exists()
|
|
1393
|
+
and operations_file.exists()
|
|
1394
|
+
and strategy_file.exists()
|
|
1395
|
+
):
|
|
1396
|
+
environment = _detect_environment()
|
|
451
1397
|
return _with_next_steps("init", {
|
|
452
1398
|
"tool": "init",
|
|
453
1399
|
"status": "already_initialized",
|
|
454
1400
|
"project_path": str(root),
|
|
455
1401
|
"preset": preset,
|
|
1402
|
+
"environment": environment,
|
|
456
1403
|
"message": f"Project already initialized at {delimit_dir}. No files overwritten.",
|
|
457
1404
|
})
|
|
458
1405
|
|
|
@@ -484,17 +1431,29 @@ def delimit_init(
|
|
|
484
1431
|
ledger_dir.mkdir(parents=True, exist_ok=True)
|
|
485
1432
|
created.append(str(ledger_dir))
|
|
486
1433
|
|
|
487
|
-
# 4. Create empty events.jsonl
|
|
1434
|
+
# 4. Create empty events.jsonl for the contract ledger
|
|
488
1435
|
if not events_file.exists():
|
|
489
1436
|
events_file.touch()
|
|
490
1437
|
created.append(str(events_file))
|
|
491
1438
|
|
|
1439
|
+
# 5. Create project-local operation/strategy ledgers used by ledger_manager
|
|
1440
|
+
if not operations_file.exists():
|
|
1441
|
+
operations_file.touch()
|
|
1442
|
+
created.append(str(operations_file))
|
|
1443
|
+
if not strategy_file.exists():
|
|
1444
|
+
strategy_file.touch()
|
|
1445
|
+
created.append(str(strategy_file))
|
|
1446
|
+
|
|
1447
|
+
# Auto-detect available API keys and CLIs
|
|
1448
|
+
environment = _detect_environment()
|
|
1449
|
+
|
|
492
1450
|
return _with_next_steps("init", {
|
|
493
1451
|
"tool": "init",
|
|
494
1452
|
"status": "initialized",
|
|
495
1453
|
"project_path": str(root),
|
|
496
1454
|
"preset": preset,
|
|
497
1455
|
"created": created,
|
|
1456
|
+
"environment": environment,
|
|
498
1457
|
"message": f"Governance initialized with '{preset}' preset. {len(created)} items created.",
|
|
499
1458
|
})
|
|
500
1459
|
|
|
@@ -506,7 +1465,12 @@ def delimit_init(
|
|
|
506
1465
|
# ─── OS ─────────────────────────────────────────────────────────────────
|
|
507
1466
|
|
|
508
1467
|
@mcp.tool()
|
|
509
|
-
def delimit_os_plan(
|
|
1468
|
+
def delimit_os_plan(
|
|
1469
|
+
operation: str,
|
|
1470
|
+
target: str,
|
|
1471
|
+
parameters: Optional[Union[str, Dict[str, Any]]] = None,
|
|
1472
|
+
require_approval: bool = True,
|
|
1473
|
+
) -> Dict[str, Any]:
|
|
510
1474
|
"""Create a governed execution plan (Pro).
|
|
511
1475
|
|
|
512
1476
|
Args:
|
|
@@ -519,6 +1483,10 @@ def delimit_os_plan(operation: str, target: str, parameters: Optional[Dict[str,
|
|
|
519
1483
|
gate = require_premium("os_plan")
|
|
520
1484
|
if gate:
|
|
521
1485
|
return gate
|
|
1486
|
+
try:
|
|
1487
|
+
parameters = _coerce_dict_arg(parameters, "parameters")
|
|
1488
|
+
except ValueError as e:
|
|
1489
|
+
return _with_next_steps("os_plan", {"error": str(e)})
|
|
522
1490
|
from backends.os_bridge import create_plan
|
|
523
1491
|
return _with_next_steps("os_plan", _safe_call(create_plan, operation=operation, target=target, parameters=parameters, require_approval=require_approval))
|
|
524
1492
|
|
|
@@ -551,108 +1519,142 @@ def delimit_os_gates(plan_id: str) -> Dict[str, Any]:
|
|
|
551
1519
|
|
|
552
1520
|
# ─── Governance ─────────────────────────────────────────────────────────
|
|
553
1521
|
|
|
554
|
-
|
|
555
|
-
def
|
|
556
|
-
|
|
1522
|
+
# Consensus 082: Unified governance tool with action parameter
|
|
1523
|
+
def _delimit_gov_impl(
|
|
1524
|
+
action: str = "health",
|
|
1525
|
+
repo: str = ".",
|
|
1526
|
+
# evaluate params
|
|
1527
|
+
eval_action: str = "",
|
|
1528
|
+
context: Optional[Union[str, Dict[str, Any]]] = None,
|
|
1529
|
+
# new_task params
|
|
1530
|
+
title: str = "",
|
|
1531
|
+
scope: str = "",
|
|
1532
|
+
risk_level: str = "medium",
|
|
1533
|
+
# run/verify params
|
|
1534
|
+
task_id: str = "",
|
|
1535
|
+
) -> Dict[str, Any]:
|
|
1536
|
+
"""Manage governance (Pro for policy/evaluate/new_task/run/verify).
|
|
1537
|
+
|
|
1538
|
+
Actions: health, status, policy, evaluate, new_task, run, verify.
|
|
557
1539
|
|
|
558
1540
|
Args:
|
|
559
|
-
|
|
1541
|
+
action: Which governance operation to perform.
|
|
1542
|
+
repo: Repository path.
|
|
1543
|
+
eval_action: The action to evaluate (for action='evaluate').
|
|
1544
|
+
context: Additional context (for action='evaluate').
|
|
1545
|
+
title: Task title (for action='new_task').
|
|
1546
|
+
scope: Task scope (for action='new_task').
|
|
1547
|
+
risk_level: Risk level low/medium/high/critical (for action='new_task').
|
|
1548
|
+
task_id: Task ID (for action='run' or action='verify').
|
|
560
1549
|
"""
|
|
561
|
-
|
|
562
|
-
|
|
1550
|
+
action = action.lower().strip()
|
|
1551
|
+
valid_actions = ("health", "status", "policy", "evaluate", "new_task", "run", "verify")
|
|
1552
|
+
if action not in valid_actions:
|
|
1553
|
+
return {"error": f"Unknown action '{action}'. Valid: {', '.join(valid_actions)}"}
|
|
1554
|
+
|
|
1555
|
+
if action == "health":
|
|
1556
|
+
from backends.governance_bridge import health
|
|
1557
|
+
return _with_next_steps("gov_health", _safe_call(health, repo=repo))
|
|
1558
|
+
|
|
1559
|
+
if action == "status":
|
|
1560
|
+
from backends.governance_bridge import status
|
|
1561
|
+
return _with_next_steps("gov_status", _safe_call(status, repo=repo))
|
|
1562
|
+
|
|
1563
|
+
if action == "policy":
|
|
1564
|
+
from ai.license import require_premium
|
|
1565
|
+
gate = require_premium("gov_policy")
|
|
1566
|
+
if gate:
|
|
1567
|
+
return gate
|
|
1568
|
+
from backends.governance_bridge import policy
|
|
1569
|
+
return _with_next_steps("gov_policy", _safe_call(policy, repo=repo))
|
|
1570
|
+
|
|
1571
|
+
if action == "evaluate":
|
|
1572
|
+
from ai.license import require_premium
|
|
1573
|
+
gate = require_premium("gov_evaluate")
|
|
1574
|
+
if gate:
|
|
1575
|
+
return gate
|
|
1576
|
+
try:
|
|
1577
|
+
ctx = _coerce_dict_arg(context, "context", string_key="text")
|
|
1578
|
+
except ValueError as e:
|
|
1579
|
+
return _with_next_steps("gov_evaluate", {"error": str(e)})
|
|
1580
|
+
from backends.governance_bridge import evaluate_trigger
|
|
1581
|
+
return _with_next_steps("gov_evaluate", _safe_call(evaluate_trigger, action=eval_action, context=ctx, repo=repo))
|
|
1582
|
+
|
|
1583
|
+
if action == "new_task":
|
|
1584
|
+
from ai.license import require_premium
|
|
1585
|
+
gate = require_premium("gov_new_task")
|
|
1586
|
+
if gate:
|
|
1587
|
+
return gate
|
|
1588
|
+
from backends.governance_bridge import new_task
|
|
1589
|
+
return _with_next_steps("gov_new_task", _safe_call(new_task, title=title, scope=scope, risk_level=risk_level, repo=repo))
|
|
1590
|
+
|
|
1591
|
+
if action == "run":
|
|
1592
|
+
from ai.license import require_premium
|
|
1593
|
+
gate = require_premium("gov_run")
|
|
1594
|
+
if gate:
|
|
1595
|
+
return gate
|
|
1596
|
+
from backends.governance_bridge import run_task
|
|
1597
|
+
return _with_next_steps("gov_run", _safe_call(run_task, task_id=task_id, repo=repo))
|
|
1598
|
+
|
|
1599
|
+
if action == "verify":
|
|
1600
|
+
from ai.license import require_premium
|
|
1601
|
+
gate = require_premium("gov_verify")
|
|
1602
|
+
if gate:
|
|
1603
|
+
return gate
|
|
1604
|
+
from backends.governance_bridge import verify
|
|
1605
|
+
return _with_next_steps("gov_verify", _safe_call(verify, task_id=task_id, repo=repo))
|
|
1606
|
+
|
|
1607
|
+
return {"error": f"Unhandled action '{action}'"}
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
delimit_gov = mcp.tool()(_delimit_gov_impl)
|
|
1611
|
+
|
|
1612
|
+
# --- Thin wrappers (aliases) for backward compatibility ---
|
|
1613
|
+
|
|
1614
|
+
@mcp.tool()
|
|
1615
|
+
def delimit_gov_health(repo: str = ".") -> Dict[str, Any]:
|
|
1616
|
+
"""Check governance system health."""
|
|
1617
|
+
return _delimit_gov_impl(action="health", repo=repo)
|
|
563
1618
|
|
|
564
1619
|
|
|
565
1620
|
@mcp.tool()
|
|
566
1621
|
def delimit_gov_status(repo: str = ".") -> Dict[str, Any]:
|
|
567
|
-
"""Get current governance status for a repository.
|
|
568
|
-
|
|
569
|
-
Args:
|
|
570
|
-
repo: Repository path.
|
|
571
|
-
"""
|
|
572
|
-
from backends.governance_bridge import status
|
|
573
|
-
return _with_next_steps("gov_status", _safe_call(status, repo=repo))
|
|
1622
|
+
"""Get current governance status for a repository."""
|
|
1623
|
+
return _delimit_gov_impl(action="status", repo=repo)
|
|
574
1624
|
|
|
575
1625
|
|
|
576
1626
|
@mcp.tool()
|
|
577
1627
|
def delimit_gov_policy(repo: str = ".") -> Dict[str, Any]:
|
|
578
|
-
"""Get governance policy for a repository (Pro).
|
|
579
|
-
|
|
580
|
-
Args:
|
|
581
|
-
repo: Repository path.
|
|
582
|
-
"""
|
|
583
|
-
from ai.license import require_premium
|
|
584
|
-
gate = require_premium("gov_policy")
|
|
585
|
-
if gate:
|
|
586
|
-
return gate
|
|
587
|
-
from backends.governance_bridge import policy
|
|
588
|
-
return _with_next_steps("gov_policy", _safe_call(policy, repo=repo))
|
|
1628
|
+
"""Get governance policy for a repository (Pro)."""
|
|
1629
|
+
return _delimit_gov_impl(action="policy", repo=repo)
|
|
589
1630
|
|
|
590
1631
|
|
|
591
1632
|
@mcp.tool()
|
|
592
|
-
def delimit_gov_evaluate(
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
"""
|
|
600
|
-
from ai.license import require_premium
|
|
601
|
-
gate = require_premium("gov_evaluate")
|
|
602
|
-
if gate:
|
|
603
|
-
return gate
|
|
604
|
-
from backends.governance_bridge import evaluate_trigger
|
|
605
|
-
return _with_next_steps("gov_evaluate", _safe_call(evaluate_trigger, action=action, context=context, repo=repo))
|
|
1633
|
+
def delimit_gov_evaluate(
|
|
1634
|
+
action: str = "",
|
|
1635
|
+
context: Optional[Union[str, Dict[str, Any]]] = None,
|
|
1636
|
+
repo: str = ".",
|
|
1637
|
+
) -> Dict[str, Any]:
|
|
1638
|
+
"""Evaluate if governance is required for an action (Pro)."""
|
|
1639
|
+
return _delimit_gov_impl(action="evaluate", eval_action=action, context=context, repo=repo)
|
|
606
1640
|
|
|
607
1641
|
|
|
608
1642
|
@mcp.tool()
|
|
609
|
-
def delimit_gov_new_task(title: str, scope: str, risk_level: str = "medium", repo: str = ".") -> Dict[str, Any]:
|
|
610
|
-
"""Create a new governance task (
|
|
611
|
-
|
|
612
|
-
Args:
|
|
613
|
-
title: Task title.
|
|
614
|
-
scope: Task scope.
|
|
615
|
-
risk_level: Risk level (low/medium/high/critical).
|
|
616
|
-
repo: Repository path.
|
|
617
|
-
"""
|
|
618
|
-
from ai.license import require_premium
|
|
619
|
-
gate = require_premium("gov_new_task")
|
|
620
|
-
if gate:
|
|
621
|
-
return gate
|
|
622
|
-
from backends.governance_bridge import new_task
|
|
623
|
-
return _with_next_steps("gov_new_task", _safe_call(new_task, title=title, scope=scope, risk_level=risk_level, repo=repo))
|
|
1643
|
+
def delimit_gov_new_task(title: str = "", scope: str = "", risk_level: str = "medium", repo: str = ".") -> Dict[str, Any]:
|
|
1644
|
+
"""Create a new governance task (Pro)."""
|
|
1645
|
+
return _delimit_gov_impl(action="new_task", title=title, scope=scope, risk_level=risk_level, repo=repo)
|
|
624
1646
|
|
|
625
1647
|
|
|
626
1648
|
@mcp.tool()
|
|
627
|
-
def delimit_gov_run(task_id: str, repo: str = ".") -> Dict[str, Any]:
|
|
628
|
-
"""Run a governance task (
|
|
629
|
-
|
|
630
|
-
Args:
|
|
631
|
-
task_id: Task ID to run.
|
|
632
|
-
repo: Repository path.
|
|
633
|
-
"""
|
|
634
|
-
from ai.license import require_premium
|
|
635
|
-
gate = require_premium("gov_run")
|
|
636
|
-
if gate:
|
|
637
|
-
return gate
|
|
638
|
-
from backends.governance_bridge import run_task
|
|
639
|
-
return _with_next_steps("gov_run", _safe_call(run_task, task_id=task_id, repo=repo))
|
|
1649
|
+
def delimit_gov_run(task_id: str = "", repo: str = ".") -> Dict[str, Any]:
|
|
1650
|
+
"""Run a governance task (Pro)."""
|
|
1651
|
+
return _delimit_gov_impl(action="run", task_id=task_id, repo=repo)
|
|
640
1652
|
|
|
641
1653
|
|
|
642
1654
|
@mcp.tool()
|
|
643
|
-
def delimit_gov_verify(task_id: str, repo: str = ".") -> Dict[str, Any]:
|
|
644
|
-
"""Verify a governance task (
|
|
645
|
-
|
|
646
|
-
Args:
|
|
647
|
-
task_id: Task ID to verify.
|
|
648
|
-
repo: Repository path.
|
|
649
|
-
"""
|
|
650
|
-
from ai.license import require_premium
|
|
651
|
-
gate = require_premium("gov_verify")
|
|
652
|
-
if gate:
|
|
653
|
-
return gate
|
|
654
|
-
from backends.governance_bridge import verify
|
|
655
|
-
return _with_next_steps("gov_verify", _safe_call(verify, task_id=task_id, repo=repo))
|
|
1655
|
+
def delimit_gov_verify(task_id: str = "", repo: str = ".") -> Dict[str, Any]:
|
|
1656
|
+
"""Verify a governance task (Pro)."""
|
|
1657
|
+
return _delimit_gov_impl(action="verify", task_id=task_id, repo=repo)
|
|
656
1658
|
|
|
657
1659
|
|
|
658
1660
|
# ─── Memory ─────────────────────────────────────────────────────────────
|
|
@@ -674,33 +1676,40 @@ def delimit_memory_search(query: str, limit: int = 10) -> Dict[str, Any]:
|
|
|
674
1676
|
|
|
675
1677
|
|
|
676
1678
|
@mcp.tool()
|
|
677
|
-
def delimit_memory_store(
|
|
678
|
-
|
|
1679
|
+
def delimit_memory_store(
|
|
1680
|
+
content: str,
|
|
1681
|
+
tags: Optional[Union[str, List[str]]] = None,
|
|
1682
|
+
context: Optional[str] = None,
|
|
1683
|
+
) -> Dict[str, Any]:
|
|
1684
|
+
"""Store a memory entry for future retrieval.
|
|
1685
|
+
|
|
1686
|
+
Free: basic store and recent retrieval.
|
|
1687
|
+
Pro: structured search across all memories.
|
|
679
1688
|
|
|
680
1689
|
Args:
|
|
681
1690
|
content: The content to remember.
|
|
682
1691
|
tags: Optional categorization tags.
|
|
683
1692
|
context: Optional context about when/why this was stored.
|
|
684
1693
|
"""
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
1694
|
+
# LED-193: memory_store is now free (basic store)
|
|
1695
|
+
try:
|
|
1696
|
+
tags = _coerce_list_arg(tags, "tags")
|
|
1697
|
+
except ValueError as e:
|
|
1698
|
+
return _with_next_steps("memory_store", {"error": str(e)})
|
|
689
1699
|
from backends.memory_bridge import store
|
|
690
1700
|
return _with_next_steps("memory_store", _safe_call(store, content=content, tags=tags, context=context))
|
|
691
1701
|
|
|
692
1702
|
|
|
693
1703
|
@mcp.tool()
|
|
694
1704
|
def delimit_memory_recent(limit: int = 5) -> Dict[str, Any]:
|
|
695
|
-
"""Get recent work summary from memory
|
|
1705
|
+
"""Get recent work summary from memory.
|
|
1706
|
+
|
|
1707
|
+
Free: retrieve recent entries. Pro: structured search.
|
|
696
1708
|
|
|
697
1709
|
Args:
|
|
698
1710
|
limit: Number of recent entries to return.
|
|
699
1711
|
"""
|
|
700
|
-
|
|
701
|
-
gate = require_premium("memory_recent")
|
|
702
|
-
if gate:
|
|
703
|
-
return gate
|
|
1712
|
+
# LED-193: memory_recent is now free (basic retrieval)
|
|
704
1713
|
from backends.memory_bridge import get_recent
|
|
705
1714
|
return _with_next_steps("memory_recent", _safe_call(get_recent, limit=limit))
|
|
706
1715
|
|
|
@@ -751,109 +1760,233 @@ def delimit_vault_snapshot() -> Dict[str, Any]:
|
|
|
751
1760
|
|
|
752
1761
|
# ─── Deploy ─────────────────────────────────────────────────────────────
|
|
753
1762
|
|
|
754
|
-
|
|
755
|
-
def
|
|
756
|
-
|
|
1763
|
+
# Consensus 082: Unified deploy tool with action parameter
|
|
1764
|
+
def _delimit_deploy_impl(
|
|
1765
|
+
action: str = "status",
|
|
1766
|
+
app: str = "",
|
|
1767
|
+
env: str = "",
|
|
1768
|
+
git_ref: Optional[str] = None,
|
|
1769
|
+
to_sha: Optional[str] = None,
|
|
1770
|
+
# site params
|
|
1771
|
+
project_path: str = ".",
|
|
1772
|
+
message: str = "",
|
|
1773
|
+
# npm params
|
|
1774
|
+
bump: str = "patch",
|
|
1775
|
+
tag: str = "latest",
|
|
1776
|
+
dry_run: bool = False,
|
|
1777
|
+
) -> Dict[str, Any]:
|
|
1778
|
+
"""Manage deployments (Pro).
|
|
1779
|
+
|
|
1780
|
+
Actions: plan, build, npm, publish, site, status, verify, rollback.
|
|
757
1781
|
|
|
758
1782
|
Args:
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
1783
|
+
action: Which deploy operation to perform.
|
|
1784
|
+
app: Application name (for plan/build/publish/verify/rollback/status).
|
|
1785
|
+
env: Target environment staging/production (for plan/verify/rollback/status).
|
|
1786
|
+
git_ref: Git reference branch/tag/SHA (for plan/build/publish/verify).
|
|
1787
|
+
to_sha: SHA to rollback to (for action='rollback').
|
|
1788
|
+
project_path: Path to project (for action='site' or action='npm').
|
|
1789
|
+
message: Git commit message (for action='site').
|
|
1790
|
+
bump: Version bump patch/minor/major (for action='npm').
|
|
1791
|
+
tag: npm dist-tag (for action='npm').
|
|
1792
|
+
dry_run: Preview without publishing (for action='npm').
|
|
1793
|
+
"""
|
|
1794
|
+
action = action.lower().strip()
|
|
1795
|
+
valid_actions = ("plan", "build", "npm", "publish", "site", "status", "verify", "rollback")
|
|
1796
|
+
if action not in valid_actions:
|
|
1797
|
+
return {"error": f"Unknown action '{action}'. Valid: {', '.join(valid_actions)}"}
|
|
1798
|
+
|
|
1799
|
+
if action == "plan":
|
|
1800
|
+
# Delegate to the shared chain logic
|
|
1801
|
+
return _deploy_plan_chain(app=app, env=env, git_ref=git_ref)
|
|
1802
|
+
|
|
1803
|
+
if action == "build":
|
|
1804
|
+
from ai.license import require_premium
|
|
1805
|
+
gate = require_premium("deploy_build")
|
|
1806
|
+
if gate:
|
|
1807
|
+
return gate
|
|
1808
|
+
from backends.deploy_bridge import build
|
|
1809
|
+
return _with_next_steps("deploy_build", _safe_call(build, app=app, git_ref=git_ref))
|
|
1810
|
+
|
|
1811
|
+
if action == "publish":
|
|
1812
|
+
from ai.license import require_premium
|
|
1813
|
+
gate = require_premium("deploy_publish")
|
|
1814
|
+
if gate:
|
|
1815
|
+
return gate
|
|
1816
|
+
from backends.deploy_bridge import publish as deploy_publish_fn
|
|
1817
|
+
return _with_next_steps("deploy_publish", _safe_call(deploy_publish_fn, app=app, git_ref=git_ref))
|
|
1818
|
+
|
|
1819
|
+
if action == "verify":
|
|
1820
|
+
from ai.license import require_premium
|
|
1821
|
+
gate = require_premium("deploy_verify")
|
|
1822
|
+
if gate:
|
|
1823
|
+
return gate
|
|
1824
|
+
from backends.deploy_bridge import verify
|
|
1825
|
+
return _safe_call(verify, app=app, env=env, git_ref=git_ref)
|
|
1826
|
+
|
|
1827
|
+
if action == "rollback":
|
|
1828
|
+
from ai.license import require_premium
|
|
1829
|
+
gate = require_premium("deploy_rollback")
|
|
1830
|
+
if gate:
|
|
1831
|
+
return gate
|
|
1832
|
+
from backends.deploy_bridge import rollback
|
|
1833
|
+
return _with_next_steps("deploy_rollback", _safe_call(rollback, app=app, env=env, to_sha=to_sha))
|
|
1834
|
+
|
|
1835
|
+
if action == "status":
|
|
1836
|
+
from ai.license import require_premium
|
|
1837
|
+
gate = require_premium("deploy_status")
|
|
1838
|
+
if gate:
|
|
1839
|
+
return gate
|
|
1840
|
+
from backends.deploy_bridge import status
|
|
1841
|
+
return _with_next_steps("deploy_status", _safe_call(status, app=app, env=env))
|
|
1842
|
+
|
|
1843
|
+
if action == "site":
|
|
1844
|
+
try:
|
|
1845
|
+
_sanitize_path(project_path, "project_path")
|
|
1846
|
+
except ValueError as e:
|
|
1847
|
+
return _with_next_steps("deploy_site", {"error": str(e)})
|
|
1848
|
+
from ai.license import require_premium
|
|
1849
|
+
gate = require_premium("deploy_site")
|
|
1850
|
+
if gate:
|
|
1851
|
+
return gate
|
|
1852
|
+
from backends.tools_infra import deploy_site
|
|
1853
|
+
env_vars = {}
|
|
1854
|
+
if "delimit-ui" in project_path or "delimit-ui" in str(Path(project_path).resolve()):
|
|
1855
|
+
chatops_token = os.environ.get("CHATOPS_AUTH_TOKEN", "")
|
|
1856
|
+
env_vars = {
|
|
1857
|
+
"NEXT_PUBLIC_CHATOPS_URL": "https://chatops.delimit.ai",
|
|
1858
|
+
"NEXT_PUBLIC_CHATOPS_TOKEN": chatops_token,
|
|
1859
|
+
}
|
|
1860
|
+
return _with_next_steps("deploy_site", deploy_site(project_path, message, env_vars))
|
|
1861
|
+
|
|
1862
|
+
if action == "npm":
|
|
1863
|
+
from ai.license import require_premium
|
|
1864
|
+
gate = require_premium("deploy_npm")
|
|
1865
|
+
if gate:
|
|
1866
|
+
return gate
|
|
1867
|
+
from backends.tools_infra import deploy_npm
|
|
1868
|
+
return _with_next_steps("deploy_npm", deploy_npm(project_path, bump, tag, dry_run))
|
|
1869
|
+
|
|
1870
|
+
return {"error": f"Unhandled action '{action}'"}
|
|
1871
|
+
|
|
1872
|
+
|
|
1873
|
+
delimit_deploy = mcp.tool()(_delimit_deploy_impl)
|
|
1874
|
+
|
|
1875
|
+
# --- Thin wrappers (aliases) for backward compatibility ---
|
|
1876
|
+
|
|
1877
|
+
def _deploy_plan_chain(app: str = "", env: str = "", git_ref: Optional[str] = None) -> Dict[str, Any]:
|
|
1878
|
+
"""Shared deploy plan chain logic (Consensus 120).
|
|
1879
|
+
Called by both delimit_deploy_plan and _delimit_deploy_impl action=plan.
|
|
762
1880
|
"""
|
|
763
1881
|
from ai.license import require_premium
|
|
764
1882
|
gate = require_premium("deploy_plan")
|
|
765
1883
|
if gate:
|
|
766
1884
|
return gate
|
|
767
|
-
from backends.deploy_bridge import plan
|
|
768
|
-
return _with_next_steps("deploy_plan", _safe_call(plan, app=app, env=env, git_ref=git_ref))
|
|
769
1885
|
|
|
1886
|
+
from backends.tools_infra import security_audit
|
|
1887
|
+
from backends.deploy_bridge import plan as deploy_plan_fn
|
|
1888
|
+
|
|
1889
|
+
chain: Dict[str, Any] = {"id": "deploy_plan_chain", "steps": []}
|
|
1890
|
+
|
|
1891
|
+
# Step 1: Security audit preflight
|
|
1892
|
+
audit_target = app if app else "."
|
|
1893
|
+
audit_result = _chain_call("deploy_plan", "security_audit", security_audit,
|
|
1894
|
+
required=True, target=audit_target)
|
|
1895
|
+
chain["steps"].append({"step": "security_audit", "ok": not _chain_is_error(audit_result)})
|
|
1896
|
+
|
|
1897
|
+
if _chain_is_error(audit_result):
|
|
1898
|
+
return _with_next_steps("deploy_plan", {
|
|
1899
|
+
"status": "blocked",
|
|
1900
|
+
"reason": "Deploy plan halted: security audit failed (fail-closed)",
|
|
1901
|
+
"security_audit": audit_result,
|
|
1902
|
+
"chain": chain,
|
|
1903
|
+
})
|
|
770
1904
|
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
|
|
778
|
-
|
|
779
|
-
|
|
780
|
-
|
|
781
|
-
if gate:
|
|
782
|
-
return gate
|
|
783
|
-
from backends.deploy_bridge import build
|
|
784
|
-
return _with_next_steps("deploy_build", _safe_call(build, app=app, git_ref=git_ref))
|
|
1905
|
+
critical_count = _count_critical_findings(audit_result)
|
|
1906
|
+
if critical_count > 0:
|
|
1907
|
+
chain["status"] = "blocked_critical_findings"
|
|
1908
|
+
return _with_next_steps("deploy_plan", {
|
|
1909
|
+
"status": "blocked",
|
|
1910
|
+
"reason": f"Deploy plan blocked: {critical_count} critical security finding(s)",
|
|
1911
|
+
"security_audit": audit_result,
|
|
1912
|
+
"critical_findings": critical_count,
|
|
1913
|
+
"chain": chain,
|
|
1914
|
+
})
|
|
785
1915
|
|
|
1916
|
+
# Step 2: Generate deploy plan
|
|
1917
|
+
plan_result = _safe_call(deploy_plan_fn, app=app, env=env, git_ref=git_ref)
|
|
1918
|
+
chain["steps"].append({"step": "deploy_plan", "ok": not plan_result.get("error")})
|
|
786
1919
|
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
1920
|
+
if plan_result.get("error"):
|
|
1921
|
+
plan_result["chain"] = chain
|
|
1922
|
+
return _with_next_steps("deploy_plan", plan_result)
|
|
790
1923
|
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
from backends.deploy_bridge import publish
|
|
800
|
-
return _with_next_steps("deploy_publish", _safe_call(publish, app=app, git_ref=git_ref))
|
|
1924
|
+
# Step 3: Governance evaluation (best-effort)
|
|
1925
|
+
gov_result = _delimit_gov_impl(
|
|
1926
|
+
action="evaluate",
|
|
1927
|
+
eval_action="deploy_plan",
|
|
1928
|
+
context={"app": app, "env": env, "git_ref": git_ref or "", "critical_findings": 0},
|
|
1929
|
+
repo=".",
|
|
1930
|
+
)
|
|
1931
|
+
chain["steps"].append({"step": "gov_evaluate", "ok": not _chain_is_error(gov_result)})
|
|
801
1932
|
|
|
1933
|
+
plan_result["security_audit_summary"] = {
|
|
1934
|
+
"critical": critical_count,
|
|
1935
|
+
"total": audit_result.get("total_findings", 0),
|
|
1936
|
+
}
|
|
1937
|
+
plan_result["gov_evaluate"] = gov_result
|
|
1938
|
+
plan_result["chain"] = chain
|
|
1939
|
+
chain["status"] = "ok"
|
|
1940
|
+
return _with_next_steps("deploy_plan", plan_result)
|
|
802
1941
|
|
|
803
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
|
|
804
|
-
def delimit_deploy_verify(app: str, env: str, git_ref: Optional[str] = None) -> Dict[str, Any]:
|
|
805
|
-
"""Verify deployment health (experimental) (Pro).
|
|
806
1942
|
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
|
|
810
|
-
|
|
1943
|
+
@mcp.tool()
|
|
1944
|
+
def delimit_deploy_plan(app: str = "", env: str = "", git_ref: Optional[str] = None) -> Dict[str, Any]:
|
|
1945
|
+
"""Plan deployment with build steps (Pro).
|
|
1946
|
+
Auto-chains: security audit preflight, governance evaluation.
|
|
1947
|
+
Halts on critical security findings.
|
|
811
1948
|
"""
|
|
812
|
-
|
|
813
|
-
gate = require_premium("deploy_verify")
|
|
814
|
-
if gate:
|
|
815
|
-
return gate
|
|
816
|
-
from backends.deploy_bridge import verify
|
|
817
|
-
return _safe_call(verify, app=app, env=env, git_ref=git_ref)
|
|
1949
|
+
return _deploy_plan_chain(app=app, env=env, git_ref=git_ref)
|
|
818
1950
|
|
|
819
1951
|
|
|
820
1952
|
@mcp.tool()
|
|
821
|
-
def
|
|
822
|
-
"""
|
|
1953
|
+
def delimit_deploy_build(app: str = "", git_ref: Optional[str] = None) -> Dict[str, Any]:
|
|
1954
|
+
"""Build Docker images with SHA tags (Pro)."""
|
|
1955
|
+
return _delimit_deploy_impl(action="build", app=app, git_ref=git_ref)
|
|
823
1956
|
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
""
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
return
|
|
1957
|
+
|
|
1958
|
+
@mcp.tool()
|
|
1959
|
+
def delimit_deploy_publish(app: str = "", git_ref: Optional[str] = None) -> Dict[str, Any]:
|
|
1960
|
+
"""Publish images to registry (Pro)."""
|
|
1961
|
+
return _delimit_deploy_impl(action="publish", app=app, git_ref=git_ref)
|
|
1962
|
+
|
|
1963
|
+
|
|
1964
|
+
@_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
|
|
1965
|
+
def delimit_deploy_verify(app: str = "", env: str = "", git_ref: Optional[str] = None) -> Dict[str, Any]:
|
|
1966
|
+
"""Verify deployment health (experimental) (Pro)."""
|
|
1967
|
+
return _delimit_deploy_impl(action="verify", app=app, env=env, git_ref=git_ref)
|
|
835
1968
|
|
|
836
1969
|
|
|
837
1970
|
@mcp.tool()
|
|
838
|
-
def
|
|
839
|
-
"""
|
|
1971
|
+
def delimit_deploy_rollback(app: str = "", env: str = "", to_sha: Optional[str] = None) -> Dict[str, Any]:
|
|
1972
|
+
"""Rollback to previous SHA (Pro)."""
|
|
1973
|
+
return _delimit_deploy_impl(action="rollback", app=app, env=env, to_sha=to_sha)
|
|
840
1974
|
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
"""
|
|
845
|
-
|
|
846
|
-
gate = require_premium("deploy_status")
|
|
847
|
-
if gate:
|
|
848
|
-
return gate
|
|
849
|
-
from backends.deploy_bridge import status
|
|
850
|
-
return _with_next_steps("deploy_status", _safe_call(status, app=app, env=env))
|
|
1975
|
+
|
|
1976
|
+
@mcp.tool()
|
|
1977
|
+
def delimit_deploy_status(app: str = "", env: str = "") -> Dict[str, Any]:
|
|
1978
|
+
"""Get deployment status (Pro)."""
|
|
1979
|
+
return _delimit_deploy_impl(action="status", app=app, env=env)
|
|
851
1980
|
|
|
852
1981
|
|
|
853
1982
|
# ─── Intel ──────────────────────────────────────────────────────────────
|
|
854
1983
|
|
|
855
1984
|
@mcp.tool()
|
|
856
|
-
def delimit_intel_dataset_register(
|
|
1985
|
+
def delimit_intel_dataset_register(
|
|
1986
|
+
name: str,
|
|
1987
|
+
schema: Optional[Union[str, Dict[str, Any]]] = None,
|
|
1988
|
+
description: Optional[str] = None,
|
|
1989
|
+
) -> Dict[str, Any]:
|
|
857
1990
|
"""Register a new dataset in the file-based intel registry.
|
|
858
1991
|
|
|
859
1992
|
Args:
|
|
@@ -861,6 +1994,10 @@ def delimit_intel_dataset_register(name: str, schema: Optional[Dict[str, Any]] =
|
|
|
861
1994
|
schema: Optional JSON schema for the dataset.
|
|
862
1995
|
description: Human-readable description.
|
|
863
1996
|
"""
|
|
1997
|
+
try:
|
|
1998
|
+
schema = _coerce_dict_arg(schema, "schema")
|
|
1999
|
+
except ValueError as e:
|
|
2000
|
+
return _with_next_steps("intel_dataset_register", {"error": str(e)})
|
|
864
2001
|
from backends.tools_data import intel_dataset_register
|
|
865
2002
|
return _with_next_steps("intel_dataset_register", _safe_call(intel_dataset_register, name=name, schema=schema, description=description))
|
|
866
2003
|
|
|
@@ -884,19 +2021,31 @@ def delimit_intel_dataset_freeze(dataset_id: str) -> Dict[str, Any]:
|
|
|
884
2021
|
|
|
885
2022
|
|
|
886
2023
|
@mcp.tool()
|
|
887
|
-
def delimit_intel_snapshot_ingest(
|
|
2024
|
+
def delimit_intel_snapshot_ingest(
|
|
2025
|
+
data: Union[str, Dict[str, Any]],
|
|
2026
|
+
provenance: Optional[Union[str, Dict[str, Any]]] = None,
|
|
2027
|
+
) -> Dict[str, Any]:
|
|
888
2028
|
"""Store a research snapshot with provenance metadata in the local intel store.
|
|
889
2029
|
|
|
890
2030
|
Args:
|
|
891
2031
|
data: Snapshot data (any JSON-serializable dict).
|
|
892
2032
|
provenance: Optional provenance metadata (source, author, etc.).
|
|
893
2033
|
"""
|
|
2034
|
+
try:
|
|
2035
|
+
data = _coerce_dict_arg(data, "data")
|
|
2036
|
+
provenance = _coerce_dict_arg(provenance, "provenance", string_key="source")
|
|
2037
|
+
except ValueError as e:
|
|
2038
|
+
return _with_next_steps("intel_snapshot_ingest", {"error": str(e)})
|
|
894
2039
|
from backends.tools_data import intel_snapshot_ingest
|
|
895
2040
|
return _with_next_steps("intel_snapshot_ingest", _safe_call(intel_snapshot_ingest, data=data, provenance=provenance))
|
|
896
2041
|
|
|
897
2042
|
|
|
898
2043
|
@mcp.tool()
|
|
899
|
-
def delimit_intel_query(
|
|
2044
|
+
def delimit_intel_query(
|
|
2045
|
+
dataset_id: Optional[str] = None,
|
|
2046
|
+
query: str = "",
|
|
2047
|
+
parameters: Optional[Union[str, Dict[str, Any]]] = None,
|
|
2048
|
+
) -> Dict[str, Any]:
|
|
900
2049
|
"""Search saved intel snapshots by keyword, date, or dataset.
|
|
901
2050
|
|
|
902
2051
|
Args:
|
|
@@ -904,14 +2053,24 @@ def delimit_intel_query(dataset_id: Optional[str] = None, query: str = "", param
|
|
|
904
2053
|
query: Keyword search string.
|
|
905
2054
|
parameters: Optional params (date_from, date_to, limit).
|
|
906
2055
|
"""
|
|
2056
|
+
try:
|
|
2057
|
+
parameters = _coerce_dict_arg(parameters, "parameters")
|
|
2058
|
+
except ValueError as e:
|
|
2059
|
+
return _with_next_steps("intel_query", {"error": str(e)})
|
|
907
2060
|
from backends.tools_data import intel_query
|
|
908
2061
|
return _with_next_steps("intel_query", _safe_call(intel_query, dataset_id=dataset_id, query=query, parameters=parameters))
|
|
909
2062
|
|
|
910
2063
|
|
|
911
2064
|
# ─── Generate ───────────────────────────────────────────────────────────
|
|
912
2065
|
|
|
913
|
-
@
|
|
914
|
-
def delimit_generate_template(
|
|
2066
|
+
@_internal_tool()
|
|
2067
|
+
def delimit_generate_template(
|
|
2068
|
+
template_type: str,
|
|
2069
|
+
name: str,
|
|
2070
|
+
framework: str = "nextjs",
|
|
2071
|
+
features: Optional[Union[str, List[str]]] = None,
|
|
2072
|
+
target: str = ".",
|
|
2073
|
+
) -> Dict[str, Any]:
|
|
915
2074
|
"""Generate code template.
|
|
916
2075
|
|
|
917
2076
|
Args:
|
|
@@ -919,13 +2078,23 @@ def delimit_generate_template(template_type: str, name: str, framework: str = "n
|
|
|
919
2078
|
name: Name for the generated code.
|
|
920
2079
|
framework: Target framework.
|
|
921
2080
|
features: Optional feature flags.
|
|
2081
|
+
target: Directory to write the generated file into. Defaults to current directory.
|
|
922
2082
|
"""
|
|
2083
|
+
try:
|
|
2084
|
+
_sanitize_path(target, "target")
|
|
2085
|
+
features = _coerce_list_arg(features, "features")
|
|
2086
|
+
except ValueError as e:
|
|
2087
|
+
return _with_next_steps("generate_template", {"error": str(e)})
|
|
923
2088
|
from backends.generate_bridge import template
|
|
924
|
-
return _with_next_steps("generate_template", _safe_call(template, template_type=template_type, name=name, framework=framework, features=features))
|
|
2089
|
+
return _with_next_steps("generate_template", _safe_call(template, template_type=template_type, name=name, framework=framework, features=features, target=target))
|
|
925
2090
|
|
|
926
2091
|
|
|
927
|
-
@
|
|
928
|
-
def delimit_generate_scaffold(
|
|
2092
|
+
@_internal_tool()
|
|
2093
|
+
def delimit_generate_scaffold(
|
|
2094
|
+
project_type: str,
|
|
2095
|
+
name: str,
|
|
2096
|
+
packages: Optional[Union[str, List[str]]] = None,
|
|
2097
|
+
) -> Dict[str, Any]:
|
|
929
2098
|
"""Scaffold new project structure.
|
|
930
2099
|
|
|
931
2100
|
Args:
|
|
@@ -933,6 +2102,10 @@ def delimit_generate_scaffold(project_type: str, name: str, packages: Optional[L
|
|
|
933
2102
|
name: Project name.
|
|
934
2103
|
packages: Packages to include.
|
|
935
2104
|
"""
|
|
2105
|
+
try:
|
|
2106
|
+
packages = _coerce_list_arg(packages, "packages")
|
|
2107
|
+
except ValueError as e:
|
|
2108
|
+
return _with_next_steps("generate_scaffold", {"error": str(e)})
|
|
936
2109
|
from backends.generate_bridge import scaffold
|
|
937
2110
|
return _with_next_steps("generate_scaffold", _safe_call(scaffold, project_type=project_type, name=name, packages=packages))
|
|
938
2111
|
|
|
@@ -1012,9 +2185,375 @@ def delimit_security_scan(target: str = ".") -> Dict[str, Any]:
|
|
|
1012
2185
|
return _with_next_steps("security_scan", _safe_call(security_scan, target=target))
|
|
1013
2186
|
|
|
1014
2187
|
|
|
2188
|
+
@mcp.tool()
|
|
2189
|
+
def delimit_security_ingest(
|
|
2190
|
+
tool: str,
|
|
2191
|
+
results: str,
|
|
2192
|
+
repo: str = "",
|
|
2193
|
+
commit_sha: str = "",
|
|
2194
|
+
) -> Dict[str, Any]:
|
|
2195
|
+
"""Ingest security scan results from external tools (Pro).
|
|
2196
|
+
|
|
2197
|
+
Accepts JSON output from Trivy, Semgrep, npm audit, pip-audit, Snyk,
|
|
2198
|
+
or CodeQL. Normalizes findings into a canonical schema, tracks in the
|
|
2199
|
+
ledger, and enables deploy gating on unresolved criticals.
|
|
2200
|
+
|
|
2201
|
+
This is the orchestrator model — Delimit doesn't run the scanner,
|
|
2202
|
+
it adds intelligence on top of results you already have.
|
|
2203
|
+
|
|
2204
|
+
Args:
|
|
2205
|
+
tool: Scanner name (trivy, semgrep, npm-audit, pip-audit, snyk, codeql).
|
|
2206
|
+
results: JSON string of scan results, or path to a JSON results file.
|
|
2207
|
+
repo: Repository identifier (e.g. "my-org/my-repo"). Auto-detects if empty.
|
|
2208
|
+
commit_sha: Git commit SHA the scan was run against. Auto-detects if empty.
|
|
2209
|
+
"""
|
|
2210
|
+
from ai.license import require_premium
|
|
2211
|
+
gate = require_premium("security_ingest")
|
|
2212
|
+
if gate:
|
|
2213
|
+
return gate
|
|
2214
|
+
|
|
2215
|
+
import hashlib as _hashlib
|
|
2216
|
+
|
|
2217
|
+
SUPPORTED_TOOLS = ("trivy", "semgrep", "npm-audit", "pip-audit", "snyk", "codeql")
|
|
2218
|
+
tool_lower = tool.lower().replace(" ", "-").replace("_", "-")
|
|
2219
|
+
if tool_lower not in SUPPORTED_TOOLS:
|
|
2220
|
+
return _with_next_steps("security_ingest", {
|
|
2221
|
+
"error": f"Unsupported tool '{tool}'. Supported: {', '.join(SUPPORTED_TOOLS)}",
|
|
2222
|
+
})
|
|
2223
|
+
|
|
2224
|
+
# Parse results — accept JSON string or file path
|
|
2225
|
+
raw_data = None
|
|
2226
|
+
if results.strip().startswith(("{", "[")):
|
|
2227
|
+
try:
|
|
2228
|
+
raw_data = json.loads(results)
|
|
2229
|
+
except json.JSONDecodeError as e:
|
|
2230
|
+
return _with_next_steps("security_ingest", {"error": f"Invalid JSON: {e}"})
|
|
2231
|
+
else:
|
|
2232
|
+
results_path = Path(results.strip())
|
|
2233
|
+
if results_path.is_file():
|
|
2234
|
+
try:
|
|
2235
|
+
raw_data = json.loads(results_path.read_text())
|
|
2236
|
+
except Exception as e:
|
|
2237
|
+
return _with_next_steps("security_ingest", {"error": f"Failed to read {results}: {e}"})
|
|
2238
|
+
else:
|
|
2239
|
+
return _with_next_steps("security_ingest", {"error": f"Not valid JSON and file not found: {results}"})
|
|
2240
|
+
|
|
2241
|
+
# Auto-detect repo and commit
|
|
2242
|
+
if not repo:
|
|
2243
|
+
try:
|
|
2244
|
+
r = subprocess.run(["git", "remote", "get-url", "origin"], capture_output=True, text=True, timeout=5)
|
|
2245
|
+
if r.returncode == 0:
|
|
2246
|
+
url = r.stdout.strip()
|
|
2247
|
+
# Extract owner/repo from git URL
|
|
2248
|
+
for prefix in ["git@github.com:", "https://github.com/"]:
|
|
2249
|
+
if url.startswith(prefix):
|
|
2250
|
+
repo = url[len(prefix):].rstrip(".git")
|
|
2251
|
+
break
|
|
2252
|
+
except Exception:
|
|
2253
|
+
pass
|
|
2254
|
+
|
|
2255
|
+
if not commit_sha:
|
|
2256
|
+
try:
|
|
2257
|
+
r = subprocess.run(["git", "rev-parse", "HEAD"], capture_output=True, text=True, timeout=5)
|
|
2258
|
+
if r.returncode == 0:
|
|
2259
|
+
commit_sha = r.stdout.strip()[:12]
|
|
2260
|
+
except Exception:
|
|
2261
|
+
pass
|
|
2262
|
+
|
|
2263
|
+
# Normalize findings based on tool format
|
|
2264
|
+
findings = []
|
|
2265
|
+
|
|
2266
|
+
if tool_lower == "trivy":
|
|
2267
|
+
# Trivy JSON format: .Results[].Vulnerabilities[]
|
|
2268
|
+
for result_block in (raw_data if isinstance(raw_data, list) else raw_data.get("Results", [])):
|
|
2269
|
+
for vuln in result_block.get("Vulnerabilities", []):
|
|
2270
|
+
fingerprint = _hashlib.sha256(
|
|
2271
|
+
f"{vuln.get('VulnerabilityID', '')}:{vuln.get('PkgName', '')}:{vuln.get('InstalledVersion', '')}".encode()
|
|
2272
|
+
).hexdigest()[:16]
|
|
2273
|
+
findings.append({
|
|
2274
|
+
"id": fingerprint,
|
|
2275
|
+
"rule": vuln.get("VulnerabilityID", ""),
|
|
2276
|
+
"severity": vuln.get("Severity", "UNKNOWN").lower(),
|
|
2277
|
+
"package": vuln.get("PkgName", ""),
|
|
2278
|
+
"version": vuln.get("InstalledVersion", ""),
|
|
2279
|
+
"fixed_version": vuln.get("FixedVersion", ""),
|
|
2280
|
+
"title": vuln.get("Title", vuln.get("VulnerabilityID", "")),
|
|
2281
|
+
"description": vuln.get("Description", "")[:200],
|
|
2282
|
+
"source_tool": "trivy",
|
|
2283
|
+
})
|
|
2284
|
+
|
|
2285
|
+
elif tool_lower == "semgrep":
|
|
2286
|
+
# Semgrep JSON format: .results[]
|
|
2287
|
+
for r in raw_data.get("results", []):
|
|
2288
|
+
loc = r.get("path", "") + ":" + str(r.get("start", {}).get("line", ""))
|
|
2289
|
+
fingerprint = _hashlib.sha256(
|
|
2290
|
+
f"{r.get('check_id', '')}:{loc}".encode()
|
|
2291
|
+
).hexdigest()[:16]
|
|
2292
|
+
sev = r.get("extra", {}).get("severity", "WARNING").lower()
|
|
2293
|
+
findings.append({
|
|
2294
|
+
"id": fingerprint,
|
|
2295
|
+
"rule": r.get("check_id", ""),
|
|
2296
|
+
"severity": sev if sev in ("error", "critical", "high", "medium", "low", "warning") else "medium",
|
|
2297
|
+
"file": r.get("path", ""),
|
|
2298
|
+
"line": r.get("start", {}).get("line"),
|
|
2299
|
+
"title": r.get("extra", {}).get("message", r.get("check_id", "")),
|
|
2300
|
+
"description": r.get("extra", {}).get("message", "")[:200],
|
|
2301
|
+
"source_tool": "semgrep",
|
|
2302
|
+
})
|
|
2303
|
+
|
|
2304
|
+
elif tool_lower in ("npm-audit", "pip-audit"):
|
|
2305
|
+
# npm audit JSON: .vulnerabilities or .advisories
|
|
2306
|
+
vulns = raw_data.get("vulnerabilities", raw_data.get("advisories", raw_data))
|
|
2307
|
+
if isinstance(vulns, dict):
|
|
2308
|
+
for pkg_name, info in vulns.items():
|
|
2309
|
+
sev = info.get("severity", "moderate").lower()
|
|
2310
|
+
fingerprint = _hashlib.sha256(f"{pkg_name}:{sev}".encode()).hexdigest()[:16]
|
|
2311
|
+
findings.append({
|
|
2312
|
+
"id": fingerprint,
|
|
2313
|
+
"rule": info.get("via", [{}])[0].get("url", "") if isinstance(info.get("via"), list) else "",
|
|
2314
|
+
"severity": {"critical": "critical", "high": "high", "moderate": "medium", "low": "low"}.get(sev, "medium"),
|
|
2315
|
+
"package": pkg_name,
|
|
2316
|
+
"version": info.get("range", ""),
|
|
2317
|
+
"fixed_version": info.get("fixAvailable", {}).get("version", "") if isinstance(info.get("fixAvailable"), dict) else "",
|
|
2318
|
+
"title": f"{sev.capitalize()} vulnerability in {pkg_name}",
|
|
2319
|
+
"source_tool": tool_lower,
|
|
2320
|
+
})
|
|
2321
|
+
elif isinstance(vulns, list):
|
|
2322
|
+
# pip-audit format: list of {name, version, vulns: [{id, fix_versions}]}
|
|
2323
|
+
for pkg in vulns:
|
|
2324
|
+
for v in pkg.get("vulns", [pkg]):
|
|
2325
|
+
fingerprint = _hashlib.sha256(f"{pkg.get('name', '')}:{v.get('id', '')}".encode()).hexdigest()[:16]
|
|
2326
|
+
findings.append({
|
|
2327
|
+
"id": fingerprint,
|
|
2328
|
+
"rule": v.get("id", ""),
|
|
2329
|
+
"severity": "high",
|
|
2330
|
+
"package": pkg.get("name", ""),
|
|
2331
|
+
"version": pkg.get("version", ""),
|
|
2332
|
+
"fixed_version": ", ".join(v.get("fix_versions", [])),
|
|
2333
|
+
"title": f"Vulnerability {v.get('id', '')} in {pkg.get('name', '')}",
|
|
2334
|
+
"source_tool": tool_lower,
|
|
2335
|
+
})
|
|
2336
|
+
|
|
2337
|
+
else:
|
|
2338
|
+
# Generic: try to extract findings from common patterns
|
|
2339
|
+
if isinstance(raw_data, list):
|
|
2340
|
+
for item in raw_data[:100]:
|
|
2341
|
+
if isinstance(item, dict):
|
|
2342
|
+
findings.append({
|
|
2343
|
+
"id": _hashlib.sha256(json.dumps(item, sort_keys=True).encode()).hexdigest()[:16],
|
|
2344
|
+
"rule": item.get("rule", item.get("id", "")),
|
|
2345
|
+
"severity": item.get("severity", "medium").lower(),
|
|
2346
|
+
"title": item.get("title", item.get("message", str(item)[:100])),
|
|
2347
|
+
"source_tool": tool_lower,
|
|
2348
|
+
})
|
|
2349
|
+
|
|
2350
|
+
# Classify findings
|
|
2351
|
+
critical = [f for f in findings if f["severity"] in ("critical",)]
|
|
2352
|
+
high = [f for f in findings if f["severity"] in ("high", "error")]
|
|
2353
|
+
medium = [f for f in findings if f["severity"] in ("medium", "moderate", "warning")]
|
|
2354
|
+
low = [f for f in findings if f["severity"] in ("low", "info")]
|
|
2355
|
+
|
|
2356
|
+
# LED-172: Auto-track security findings in ledger with lifecycle
|
|
2357
|
+
ledger_created = []
|
|
2358
|
+
ledger_closed = []
|
|
2359
|
+
try:
|
|
2360
|
+
from ai.ledger_manager import add_item, update_item, list_items
|
|
2361
|
+
existing = list_items()
|
|
2362
|
+
all_items = existing.get("items", [])
|
|
2363
|
+
if isinstance(all_items, dict):
|
|
2364
|
+
flat = []
|
|
2365
|
+
for v in all_items.values():
|
|
2366
|
+
if isinstance(v, list):
|
|
2367
|
+
flat.extend(v)
|
|
2368
|
+
all_items = flat
|
|
2369
|
+
|
|
2370
|
+
# Find open security items from this scanner
|
|
2371
|
+
open_security = {
|
|
2372
|
+
i.get("title", ""): i
|
|
2373
|
+
for i in all_items
|
|
2374
|
+
if isinstance(i, dict)
|
|
2375
|
+
and i.get("status") == "open"
|
|
2376
|
+
and i.get("source", "").startswith(f"security_ingest:{tool_lower}")
|
|
2377
|
+
}
|
|
2378
|
+
|
|
2379
|
+
# Current finding titles
|
|
2380
|
+
current_titles = set()
|
|
2381
|
+
for finding in (critical + high)[:10]:
|
|
2382
|
+
title = f"Security: {finding['title'][:80]}"
|
|
2383
|
+
current_titles.add(title)
|
|
2384
|
+
if title not in open_security:
|
|
2385
|
+
entry = add_item(
|
|
2386
|
+
title=title,
|
|
2387
|
+
type="fix",
|
|
2388
|
+
priority="P0" if finding["severity"] == "critical" else "P1",
|
|
2389
|
+
description=f"Tool: {tool_lower}, Package: {finding.get('package', 'N/A')}, Fix: {finding.get('fixed_version', 'N/A')}",
|
|
2390
|
+
source=f"security_ingest:{tool_lower}",
|
|
2391
|
+
)
|
|
2392
|
+
item_id = entry.get("added", {}).get("id", "")
|
|
2393
|
+
if item_id:
|
|
2394
|
+
ledger_created.append(item_id)
|
|
2395
|
+
|
|
2396
|
+
# Auto-close findings that disappeared (resolved in new scan)
|
|
2397
|
+
for title, item in open_security.items():
|
|
2398
|
+
if title not in current_titles:
|
|
2399
|
+
item_id = item.get("id", "")
|
|
2400
|
+
if item_id:
|
|
2401
|
+
update_item(
|
|
2402
|
+
item_id=item_id,
|
|
2403
|
+
status="done",
|
|
2404
|
+
note=f"Auto-resolved: finding no longer present in {tool_lower} scan (commit {commit_sha[:8] if commit_sha else 'unknown'})",
|
|
2405
|
+
)
|
|
2406
|
+
ledger_closed.append(item_id)
|
|
2407
|
+
except Exception as e:
|
|
2408
|
+
logger.warning("Security ingest ledger lifecycle failed: %s", e)
|
|
2409
|
+
|
|
2410
|
+
return _with_next_steps("security_ingest", {
|
|
2411
|
+
"tool": "security_ingest",
|
|
2412
|
+
"scanner": tool_lower,
|
|
2413
|
+
"repo": repo,
|
|
2414
|
+
"commit": commit_sha,
|
|
2415
|
+
"findings": {
|
|
2416
|
+
"total": len(findings),
|
|
2417
|
+
"critical": len(critical),
|
|
2418
|
+
"high": len(high),
|
|
2419
|
+
"medium": len(medium),
|
|
2420
|
+
"low": len(low),
|
|
2421
|
+
},
|
|
2422
|
+
"top_findings": (critical + high + medium)[:10],
|
|
2423
|
+
"ledger_items_created": ledger_created,
|
|
2424
|
+
"ledger_items_resolved": ledger_closed,
|
|
2425
|
+
"message": f"Ingested {len(findings)} findings from {tool_lower}. {len(critical)} critical, {len(high)} high. {len(ledger_created)} new, {len(ledger_closed)} resolved.",
|
|
2426
|
+
})
|
|
2427
|
+
|
|
2428
|
+
|
|
2429
|
+
@mcp.tool()
|
|
2430
|
+
def delimit_security_deliberate(
|
|
2431
|
+
findings: str = "",
|
|
2432
|
+
repo: str = "",
|
|
2433
|
+
focus: str = "critical",
|
|
2434
|
+
) -> Dict[str, Any]:
|
|
2435
|
+
"""Multi-model triage of security findings (Pro).
|
|
2436
|
+
|
|
2437
|
+
Runs deliberation on ingested security findings to classify each as:
|
|
2438
|
+
real risk, false positive, accepted risk, or needs immediate action.
|
|
2439
|
+
|
|
2440
|
+
Can work on findings from the ledger (auto) or from a JSON string.
|
|
2441
|
+
|
|
2442
|
+
Args:
|
|
2443
|
+
findings: JSON string of findings to triage, or empty to pull from ledger.
|
|
2444
|
+
repo: Repository context for the triage.
|
|
2445
|
+
focus: Which findings to triage — "critical", "high", "all". Default: critical.
|
|
2446
|
+
"""
|
|
2447
|
+
from ai.license import require_premium
|
|
2448
|
+
gate = require_premium("security_deliberate")
|
|
2449
|
+
if gate:
|
|
2450
|
+
return gate
|
|
2451
|
+
|
|
2452
|
+
# Gather findings to triage
|
|
2453
|
+
items_to_triage = []
|
|
2454
|
+
|
|
2455
|
+
if findings and findings.strip().startswith(("[", "{")):
|
|
2456
|
+
try:
|
|
2457
|
+
items_to_triage = json.loads(findings)
|
|
2458
|
+
if isinstance(items_to_triage, dict):
|
|
2459
|
+
items_to_triage = [items_to_triage]
|
|
2460
|
+
except json.JSONDecodeError:
|
|
2461
|
+
return _with_next_steps("security_deliberate", {"error": "Invalid JSON in findings"})
|
|
2462
|
+
else:
|
|
2463
|
+
# Pull from ledger — find open security items
|
|
2464
|
+
try:
|
|
2465
|
+
from ai.ledger_manager import list_items
|
|
2466
|
+
ledger_data = list_items(status="open")
|
|
2467
|
+
all_items = ledger_data.get("items", [])
|
|
2468
|
+
if isinstance(all_items, dict):
|
|
2469
|
+
flat = []
|
|
2470
|
+
for v in all_items.values():
|
|
2471
|
+
if isinstance(v, list):
|
|
2472
|
+
flat.extend(v)
|
|
2473
|
+
all_items = flat
|
|
2474
|
+
|
|
2475
|
+
for item in all_items:
|
|
2476
|
+
if not isinstance(item, dict):
|
|
2477
|
+
continue
|
|
2478
|
+
source = item.get("source", "")
|
|
2479
|
+
if not source.startswith("security_ingest:"):
|
|
2480
|
+
continue
|
|
2481
|
+
priority = item.get("priority", "")
|
|
2482
|
+
if focus == "critical" and priority != "P0":
|
|
2483
|
+
continue
|
|
2484
|
+
if focus == "high" and priority not in ("P0", "P1"):
|
|
2485
|
+
continue
|
|
2486
|
+
items_to_triage.append({
|
|
2487
|
+
"id": item.get("id", ""),
|
|
2488
|
+
"title": item.get("title", ""),
|
|
2489
|
+
"priority": priority,
|
|
2490
|
+
"description": item.get("description", ""),
|
|
2491
|
+
"source": source,
|
|
2492
|
+
})
|
|
2493
|
+
except Exception as e:
|
|
2494
|
+
return _with_next_steps("security_deliberate", {"error": f"Failed to read ledger: {e}"})
|
|
2495
|
+
|
|
2496
|
+
if not items_to_triage:
|
|
2497
|
+
return _with_next_steps("security_deliberate", {
|
|
2498
|
+
"status": "clean",
|
|
2499
|
+
"message": f"No {focus}-level security findings to triage.",
|
|
2500
|
+
})
|
|
2501
|
+
|
|
2502
|
+
# Build deliberation prompt
|
|
2503
|
+
findings_text = ""
|
|
2504
|
+
for i, item in enumerate(items_to_triage[:5], 1): # Cap at 5 for deliberation
|
|
2505
|
+
findings_text += f"\n{i}. {item.get('title', 'Unknown')}"
|
|
2506
|
+
if item.get("description"):
|
|
2507
|
+
findings_text += f"\n Details: {item['description'][:150]}"
|
|
2508
|
+
if item.get("priority"):
|
|
2509
|
+
findings_text += f"\n Priority: {item['priority']}"
|
|
2510
|
+
|
|
2511
|
+
question = (
|
|
2512
|
+
f"Triage these {len(items_to_triage)} security findings. "
|
|
2513
|
+
f"For each, classify as: REAL RISK, FALSE POSITIVE, ACCEPTED RISK, or IMMEDIATE ACTION. "
|
|
2514
|
+
f"Give a confidence score (0-100) and one-sentence reasoning.\n"
|
|
2515
|
+
f"{findings_text}"
|
|
2516
|
+
)
|
|
2517
|
+
context = f"Repository: {repo or 'unknown'}. These findings came from automated security scanners."
|
|
2518
|
+
|
|
2519
|
+
# Run deliberation
|
|
2520
|
+
from ai.deliberation import deliberate
|
|
2521
|
+
result = deliberate(
|
|
2522
|
+
question=question,
|
|
2523
|
+
context=context,
|
|
2524
|
+
mode="dialogue",
|
|
2525
|
+
max_rounds=2,
|
|
2526
|
+
)
|
|
2527
|
+
|
|
2528
|
+
# Extract classifications from the deliberation
|
|
2529
|
+
classifications = []
|
|
2530
|
+
if result.get("rounds"):
|
|
2531
|
+
# Use the last round's consensus
|
|
2532
|
+
last_round = result["rounds"][-1]
|
|
2533
|
+
for model_id, response in last_round.get("responses", {}).items():
|
|
2534
|
+
if "error" not in response.lower():
|
|
2535
|
+
classifications.append({
|
|
2536
|
+
"model": model_id,
|
|
2537
|
+
"analysis": response[:500],
|
|
2538
|
+
})
|
|
2539
|
+
break # Use first valid response as representative
|
|
2540
|
+
|
|
2541
|
+
return _with_next_steps("security_deliberate", {
|
|
2542
|
+
"tool": "security_deliberate",
|
|
2543
|
+
"findings_triaged": len(items_to_triage),
|
|
2544
|
+
"focus": focus,
|
|
2545
|
+
"unanimous": result.get("unanimous", False),
|
|
2546
|
+
"rounds": len(result.get("rounds", [])),
|
|
2547
|
+
"classifications": classifications,
|
|
2548
|
+
"transcript_saved": result.get("saved_to", ""),
|
|
2549
|
+
"message": f"Triaged {len(items_to_triage)} {focus}-level findings via {len(result.get('models', []))}-model deliberation.",
|
|
2550
|
+
})
|
|
2551
|
+
|
|
2552
|
+
|
|
1015
2553
|
@mcp.tool()
|
|
1016
2554
|
def delimit_security_audit(target: str = ".") -> Dict[str, Any]:
|
|
1017
2555
|
"""Audit security: dependency vulnerabilities, anti-patterns, and secret detection.
|
|
2556
|
+
Auto-chains: evidence collection on all findings, governance task + notification on critical findings.
|
|
1018
2557
|
|
|
1019
2558
|
Scans for:
|
|
1020
2559
|
- Dependency vulnerabilities (pip-audit, npm audit)
|
|
@@ -1028,7 +2567,54 @@ def delimit_security_audit(target: str = ".") -> Dict[str, Any]:
|
|
|
1028
2567
|
target: Repository or file path to audit.
|
|
1029
2568
|
"""
|
|
1030
2569
|
from backends.tools_infra import security_audit
|
|
1031
|
-
|
|
2570
|
+
|
|
2571
|
+
chain: Dict[str, Any] = {"id": "security_audit_chain", "steps": []}
|
|
2572
|
+
|
|
2573
|
+
# Step 1: Core audit
|
|
2574
|
+
audit_result = _safe_call(security_audit, target=target)
|
|
2575
|
+
chain["steps"].append({"step": "security_audit", "ok": not audit_result.get("error")})
|
|
2576
|
+
|
|
2577
|
+
if audit_result.get("error"):
|
|
2578
|
+
audit_result["chain"] = chain
|
|
2579
|
+
return _with_next_steps("security_audit", audit_result)
|
|
2580
|
+
|
|
2581
|
+
# Step 2: Evidence collection (best-effort, all results)
|
|
2582
|
+
from backends.repo_bridge import evidence_collect
|
|
2583
|
+
evidence_result = _chain_call("security_audit", "evidence_collect",
|
|
2584
|
+
evidence_collect, required=False, target=target)
|
|
2585
|
+
chain["steps"].append({"step": "evidence_collect", "ok": not _chain_is_error(evidence_result)})
|
|
2586
|
+
audit_result["evidence"] = evidence_result
|
|
2587
|
+
|
|
2588
|
+
critical_count = _count_critical_findings(audit_result)
|
|
2589
|
+
|
|
2590
|
+
if critical_count == 0:
|
|
2591
|
+
chain["status"] = "clean"
|
|
2592
|
+
audit_result["chain"] = chain
|
|
2593
|
+
return _with_next_steps("security_audit", audit_result)
|
|
2594
|
+
|
|
2595
|
+
# Step 3: Critical findings -- create governance task
|
|
2596
|
+
gov_result = _delimit_gov_impl(
|
|
2597
|
+
action="new_task",
|
|
2598
|
+
title=f"Security: {critical_count} critical finding(s) in {target}",
|
|
2599
|
+
scope=target,
|
|
2600
|
+
risk_level="critical",
|
|
2601
|
+
repo=".",
|
|
2602
|
+
)
|
|
2603
|
+
chain["steps"].append({"step": "gov_new_task", "ok": not _chain_is_error(gov_result)})
|
|
2604
|
+
audit_result["gov_task"] = gov_result
|
|
2605
|
+
|
|
2606
|
+
# Step 4: Notify (best-effort)
|
|
2607
|
+
from ai.notify import send_notification
|
|
2608
|
+
notify_result = _chain_call("security_audit", "notify",
|
|
2609
|
+
send_notification, required=False,
|
|
2610
|
+
channel="webhook",
|
|
2611
|
+
event_type="security_critical",
|
|
2612
|
+
message=f"Critical: {critical_count} security finding(s) in {target}")
|
|
2613
|
+
chain["steps"].append({"step": "notify", "ok": not _chain_is_error(notify_result)})
|
|
2614
|
+
|
|
2615
|
+
chain["status"] = "critical_findings_actioned"
|
|
2616
|
+
audit_result["chain"] = chain
|
|
2617
|
+
return _with_next_steps("security_audit", audit_result)
|
|
1032
2618
|
|
|
1033
2619
|
|
|
1034
2620
|
# ─── Evidence ───────────────────────────────────────────────────────────
|
|
@@ -1071,83 +2657,161 @@ def delimit_evidence_verify(bundle_id: Optional[str] = None, bundle_path: Option
|
|
|
1071
2657
|
|
|
1072
2658
|
# ─── ReleasePilot (Governance Primitive) ────────────────────────────────
|
|
1073
2659
|
|
|
1074
|
-
|
|
1075
|
-
def
|
|
1076
|
-
""
|
|
1077
|
-
|
|
2660
|
+
# Consensus 082 Phase 2: Unified release tool with action parameter
|
|
2661
|
+
def _delimit_release_impl(
|
|
2662
|
+
action: str = "status",
|
|
2663
|
+
environment: str = "production",
|
|
2664
|
+
version: str = "",
|
|
2665
|
+
repository: str = ".",
|
|
2666
|
+
services: Optional[List[str]] = None,
|
|
2667
|
+
# rollback params
|
|
2668
|
+
to_version: str = "",
|
|
2669
|
+
# history params
|
|
2670
|
+
limit: int = 10,
|
|
2671
|
+
# sync params
|
|
2672
|
+
sync_action: str = "audit",
|
|
2673
|
+
) -> Dict[str, Any]:
|
|
2674
|
+
"""Manage releases: plan, validate, status, rollback, history, sync (Pro for plan/status/sync).
|
|
1078
2675
|
|
|
1079
|
-
|
|
1080
|
-
suggests a semver version, and generates a release checklist.
|
|
1081
|
-
Saves plan to ~/.delimit/deploys/ for tracking.
|
|
2676
|
+
Actions: plan, validate, status, rollback, history, sync.
|
|
1082
2677
|
|
|
1083
2678
|
Args:
|
|
1084
|
-
|
|
1085
|
-
|
|
1086
|
-
|
|
1087
|
-
|
|
2679
|
+
action: Which release operation to perform.
|
|
2680
|
+
environment: Target environment staging/production.
|
|
2681
|
+
version: Release version (auto-detected if empty, for plan/validate/rollback).
|
|
2682
|
+
repository: Repository path (for action='plan').
|
|
2683
|
+
services: Optional service list (for action='plan').
|
|
2684
|
+
to_version: Version to rollback to (for action='rollback').
|
|
2685
|
+
limit: Number of releases to return (for action='history').
|
|
2686
|
+
sync_action: Sub-action audit/config (for action='sync').
|
|
1088
2687
|
"""
|
|
1089
|
-
|
|
1090
|
-
|
|
1091
|
-
if
|
|
1092
|
-
return
|
|
1093
|
-
|
|
1094
|
-
|
|
2688
|
+
action = action.lower().strip()
|
|
2689
|
+
valid_actions = ("plan", "validate", "status", "rollback", "history", "sync")
|
|
2690
|
+
if action not in valid_actions:
|
|
2691
|
+
return {"error": f"Unknown action '{action}'. Valid: {', '.join(valid_actions)}"}
|
|
2692
|
+
|
|
2693
|
+
if action == "plan":
|
|
2694
|
+
from ai.license import require_premium
|
|
2695
|
+
gate = require_premium("release_plan")
|
|
2696
|
+
if gate:
|
|
2697
|
+
return gate
|
|
2698
|
+
from backends.tools_infra import release_plan
|
|
2699
|
+
return _with_next_steps("release_plan", _safe_call(release_plan, environment=environment, version=version, repository=repository, services=services))
|
|
2700
|
+
|
|
2701
|
+
if action == "validate":
|
|
2702
|
+
# Delegate to the shared chain logic
|
|
2703
|
+
return _release_validate_chain(environment=environment, version=version)
|
|
2704
|
+
|
|
2705
|
+
if action == "status":
|
|
2706
|
+
from ai.license import require_premium
|
|
2707
|
+
gate = require_premium("release_status")
|
|
2708
|
+
if gate:
|
|
2709
|
+
return gate
|
|
2710
|
+
from backends.tools_infra import release_status
|
|
2711
|
+
return _with_next_steps("release_status", _safe_call(release_status, environment=environment))
|
|
2712
|
+
|
|
2713
|
+
if action == "rollback":
|
|
2714
|
+
from backends.ops_bridge import release_rollback
|
|
2715
|
+
return _safe_call(release_rollback, environment=environment, version=version, to_version=to_version)
|
|
2716
|
+
|
|
2717
|
+
if action == "history":
|
|
2718
|
+
from backends.ops_bridge import release_history
|
|
2719
|
+
return _safe_call(release_history, environment=environment, limit=limit)
|
|
2720
|
+
|
|
2721
|
+
if action == "sync":
|
|
2722
|
+
from ai.license import require_premium
|
|
2723
|
+
gate = require_premium("release_sync")
|
|
2724
|
+
if gate:
|
|
2725
|
+
return gate
|
|
2726
|
+
from ai.release_sync import audit, get_release_config
|
|
2727
|
+
if sync_action == "config":
|
|
2728
|
+
return get_release_config()
|
|
2729
|
+
return _with_next_steps("release_sync", audit())
|
|
2730
|
+
|
|
2731
|
+
return {"error": f"Unhandled action '{action}'"}
|
|
2732
|
+
|
|
2733
|
+
|
|
2734
|
+
delimit_release = mcp.tool()(_delimit_release_impl)
|
|
2735
|
+
|
|
2736
|
+
# --- Thin wrappers (aliases) for backward compatibility ---
|
|
1095
2737
|
|
|
2738
|
+
@mcp.tool()
|
|
2739
|
+
def delimit_release_plan(environment: str = "production", version: str = "", repository: str = ".", services: Optional[List[str]] = None) -> Dict[str, Any]:
|
|
2740
|
+
"""Generate a release plan from git history (Pro)."""
|
|
2741
|
+
return _delimit_release_impl(action="plan", environment=environment, version=version, repository=repository, services=services)
|
|
1096
2742
|
|
|
1097
|
-
@_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
|
|
1098
|
-
def delimit_release_validate(environment: str, version: str) -> Dict[str, Any]:
|
|
1099
|
-
"""Validate release readiness (experimental).
|
|
1100
2743
|
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
|
|
2744
|
+
def _release_validate_chain(environment: str, version: str) -> Dict[str, Any]:
|
|
2745
|
+
"""Shared release validation chain logic (Consensus 120).
|
|
2746
|
+
Called by both delimit_release_validate and _delimit_release_impl action=validate.
|
|
1104
2747
|
"""
|
|
2748
|
+
chain: Dict[str, Any] = {"id": "release_validate_chain", "steps": []}
|
|
2749
|
+
|
|
2750
|
+
# Step 1: Core validation
|
|
1105
2751
|
from backends.ops_bridge import release_validate
|
|
1106
|
-
|
|
2752
|
+
validate_result = _safe_call(release_validate, environment=environment, version=version)
|
|
2753
|
+
chain["steps"].append({"step": "validate", "ok": not _chain_is_error(validate_result)})
|
|
1107
2754
|
|
|
2755
|
+
# On success, no chaining needed
|
|
2756
|
+
if not _chain_is_error(validate_result):
|
|
2757
|
+
chain["status"] = "passed"
|
|
2758
|
+
validate_result["chain"] = chain
|
|
2759
|
+
return _with_next_steps("release_validate", validate_result)
|
|
1108
2760
|
|
|
1109
|
-
|
|
1110
|
-
|
|
1111
|
-
"""
|
|
1112
|
-
|
|
2761
|
+
# Failure path: collect evidence, notify, record
|
|
2762
|
+
from backends.repo_bridge import evidence_collect
|
|
2763
|
+
evidence_result = _chain_call("release_validate", "evidence_collect",
|
|
2764
|
+
evidence_collect, required=False, target=".")
|
|
2765
|
+
chain["steps"].append({"step": "evidence_collect",
|
|
2766
|
+
"ok": not _chain_is_error(evidence_result)})
|
|
2767
|
+
|
|
2768
|
+
from ai.notify import send_notification
|
|
2769
|
+
notify_result = _chain_call("release_validate", "notify",
|
|
2770
|
+
send_notification, required=False,
|
|
2771
|
+
channel="webhook",
|
|
2772
|
+
event_type="release_validation_failed",
|
|
2773
|
+
message=f"Release {version} to {environment} failed validation")
|
|
2774
|
+
chain["steps"].append({"step": "notify", "ok": not _chain_is_error(notify_result)})
|
|
2775
|
+
|
|
2776
|
+
from ai.ledger_manager import add_item
|
|
2777
|
+
ledger_result = _chain_call("release_validate", "ledger_add",
|
|
2778
|
+
add_item, required=False,
|
|
2779
|
+
title=f"Release validation failed: {version} -> {environment}",
|
|
2780
|
+
ledger="ops", item_type="fix", priority="P1",
|
|
2781
|
+
description="Automated: release_validate chain detected failure",
|
|
2782
|
+
source="chain:release_validate:failed")
|
|
2783
|
+
chain["steps"].append({"step": "ledger_add", "ok": not _chain_is_error(ledger_result)})
|
|
1113
2784
|
|
|
1114
|
-
|
|
1115
|
-
|
|
2785
|
+
chain["status"] = "failed_with_evidence"
|
|
2786
|
+
validate_result["chain"] = chain
|
|
2787
|
+
validate_result["evidence"] = evidence_result
|
|
2788
|
+
return _with_next_steps("release_validate", validate_result)
|
|
1116
2789
|
|
|
1117
|
-
|
|
1118
|
-
|
|
2790
|
+
|
|
2791
|
+
@mcp.tool() # Promoted from experimental (Consensus 120: chaining makes it production-ready)
|
|
2792
|
+
def delimit_release_validate(environment: str, version: str) -> Dict[str, Any]:
|
|
2793
|
+
"""Validate release readiness.
|
|
2794
|
+
Auto-chains on failure: evidence collection, notification, ledger recording.
|
|
1119
2795
|
"""
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
2796
|
+
return _release_validate_chain(environment=environment, version=version)
|
|
2797
|
+
|
|
2798
|
+
|
|
2799
|
+
@mcp.tool()
|
|
2800
|
+
def delimit_release_status(environment: str = "production") -> Dict[str, Any]:
|
|
2801
|
+
"""Check release/deploy status (Pro)."""
|
|
2802
|
+
return _delimit_release_impl(action="status", environment=environment)
|
|
1126
2803
|
|
|
1127
2804
|
|
|
1128
2805
|
@_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
|
|
1129
2806
|
def delimit_release_rollback(environment: str, version: str, to_version: str) -> Dict[str, Any]:
|
|
1130
|
-
"""Rollback deployment to previous version (experimental).
|
|
1131
|
-
|
|
1132
|
-
Args:
|
|
1133
|
-
environment: Target environment.
|
|
1134
|
-
version: Current version.
|
|
1135
|
-
to_version: Version to rollback to.
|
|
1136
|
-
"""
|
|
1137
|
-
from backends.ops_bridge import release_rollback
|
|
1138
|
-
return _safe_call(release_rollback, environment=environment, version=version, to_version=to_version)
|
|
2807
|
+
"""Rollback deployment to previous version (experimental)."""
|
|
2808
|
+
return _delimit_release_impl(action="rollback", environment=environment, version=version, to_version=to_version)
|
|
1139
2809
|
|
|
1140
2810
|
|
|
1141
2811
|
@_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
|
|
1142
2812
|
def delimit_release_history(environment: str, limit: int = 10) -> Dict[str, Any]:
|
|
1143
|
-
"""Show release history (experimental).
|
|
1144
|
-
|
|
1145
|
-
Args:
|
|
1146
|
-
environment: Target environment.
|
|
1147
|
-
limit: Number of releases to return.
|
|
1148
|
-
"""
|
|
1149
|
-
from backends.ops_bridge import release_history
|
|
1150
|
-
return _safe_call(release_history, environment=environment, limit=limit)
|
|
2813
|
+
"""Show release history (experimental)."""
|
|
2814
|
+
return _delimit_release_impl(action="history", environment=environment, limit=limit)
|
|
1151
2815
|
|
|
1152
2816
|
|
|
1153
2817
|
# ─── CostGuard (Governance Primitive) ──────────────────────────────────
|
|
@@ -1204,9 +2868,50 @@ def delimit_cost_alert(action: str = "list", name: Optional[str] = None,
|
|
|
1204
2868
|
return _with_next_steps("cost_alert", _safe_call(cost_alert, action=action, name=name, threshold=threshold, alert_id=alert_id))
|
|
1205
2869
|
|
|
1206
2870
|
|
|
1207
|
-
# ───
|
|
2871
|
+
# ─── Rate Limiter / Cost Controls ──────────────────────────────────────
|
|
2872
|
+
#
|
|
2873
|
+
# Integration pattern for per-tool rate limiting:
|
|
2874
|
+
# To add rate-limit checking to any tool, insert at the top of the function:
|
|
2875
|
+
#
|
|
2876
|
+
# block = limiter.check("delimit_<tool_name>")
|
|
2877
|
+
# if block:
|
|
2878
|
+
# return block
|
|
2879
|
+
# # ... execute tool logic ...
|
|
2880
|
+
# limiter.record("delimit_<tool_name>")
|
|
2881
|
+
#
|
|
2882
|
+
# The limiter singleton is imported from ai.rate_limiter. The check/record
|
|
2883
|
+
# calls are intentionally opt-in per tool to keep this file's diff minimal.
|
|
2884
|
+
# High-cost tools (deliberation, deploy, social) are the best candidates.
|
|
1208
2885
|
|
|
1209
2886
|
@mcp.tool()
|
|
2887
|
+
def delimit_cost_controls(
|
|
2888
|
+
action: str = "status",
|
|
2889
|
+
tool_name: str = "",
|
|
2890
|
+
limit: Optional[int] = None,
|
|
2891
|
+
cost_cap: Optional[float] = None,
|
|
2892
|
+
) -> Dict[str, Any]:
|
|
2893
|
+
"""Manage MCP rate limits and session cost controls.
|
|
2894
|
+
|
|
2895
|
+
View current usage, check quota for a specific tool, adjust per-tool
|
|
2896
|
+
rate limits, set the session cost cap, or reset all tracking.
|
|
2897
|
+
|
|
2898
|
+
Args:
|
|
2899
|
+
action: One of 'status', 'quota', 'set', or 'reset'.
|
|
2900
|
+
tool_name: Tool name (required for 'quota' and 'set' with limit).
|
|
2901
|
+
limit: New hourly call limit for the tool (used with action='set').
|
|
2902
|
+
cost_cap: New session cost cap in USD (used with action='set').
|
|
2903
|
+
"""
|
|
2904
|
+
return create_cost_controls_response(
|
|
2905
|
+
action=action,
|
|
2906
|
+
tool_name=tool_name,
|
|
2907
|
+
limit=limit,
|
|
2908
|
+
cost_cap=cost_cap,
|
|
2909
|
+
)
|
|
2910
|
+
|
|
2911
|
+
|
|
2912
|
+
# ─── DataSteward (Governance Primitive) ────────────────────────────────
|
|
2913
|
+
|
|
2914
|
+
@_internal_tool()
|
|
1210
2915
|
def delimit_data_validate(target: str = ".") -> Dict[str, Any]:
|
|
1211
2916
|
"""Validate data files: JSON parse, CSV structure, SQLite integrity check.
|
|
1212
2917
|
|
|
@@ -1217,7 +2922,7 @@ def delimit_data_validate(target: str = ".") -> Dict[str, Any]:
|
|
|
1217
2922
|
return _with_next_steps("data_validate", _safe_call(data_validate, target=target))
|
|
1218
2923
|
|
|
1219
2924
|
|
|
1220
|
-
@
|
|
2925
|
+
@_internal_tool()
|
|
1221
2926
|
def delimit_data_migrate(target: str = ".") -> Dict[str, Any]:
|
|
1222
2927
|
"""Check for migration files (alembic, Django, Prisma, Knex) and report status.
|
|
1223
2928
|
|
|
@@ -1228,7 +2933,7 @@ def delimit_data_migrate(target: str = ".") -> Dict[str, Any]:
|
|
|
1228
2933
|
return _with_next_steps("data_migrate", _safe_call(data_migrate, target=target))
|
|
1229
2934
|
|
|
1230
2935
|
|
|
1231
|
-
@
|
|
2936
|
+
@_internal_tool()
|
|
1232
2937
|
def delimit_data_backup(target: str = ".") -> Dict[str, Any]:
|
|
1233
2938
|
"""Back up SQLite and JSON data files to ~/.delimit/backups/ with timestamp.
|
|
1234
2939
|
|
|
@@ -1241,98 +2946,123 @@ def delimit_data_backup(target: str = ".") -> Dict[str, Any]:
|
|
|
1241
2946
|
|
|
1242
2947
|
# ─── ObservabilityOps (Internal OS) ────────────────────────────────────
|
|
1243
2948
|
|
|
1244
|
-
|
|
1245
|
-
def
|
|
1246
|
-
""
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
|
|
1250
|
-
|
|
2949
|
+
# Consensus 082 Phase 2: Unified observability tool with action parameter
|
|
2950
|
+
def _delimit_obs_impl(
|
|
2951
|
+
action: str = "status",
|
|
2952
|
+
# metrics/logs params
|
|
2953
|
+
query: str = "system",
|
|
2954
|
+
time_range: str = "1h",
|
|
2955
|
+
source: Optional[str] = None,
|
|
2956
|
+
# alerts params
|
|
2957
|
+
alert_action: str = "list",
|
|
2958
|
+
alert_rule: Optional[Dict[str, Any]] = None,
|
|
2959
|
+
rule_id: Optional[str] = None,
|
|
2960
|
+
) -> Dict[str, Any]:
|
|
2961
|
+
"""Manage observability: metrics, logs, alerts, status (Pro for metrics/logs/status).
|
|
1251
2962
|
|
|
1252
|
-
|
|
2963
|
+
Actions: metrics, logs, alerts, status.
|
|
1253
2964
|
|
|
1254
2965
|
Args:
|
|
1255
|
-
|
|
1256
|
-
|
|
1257
|
-
|
|
2966
|
+
action: Which observability operation to perform.
|
|
2967
|
+
query: Metrics query type or log search string (for metrics/logs).
|
|
2968
|
+
time_range: Time range e.g. 1h, 24h, 7d (for metrics/logs).
|
|
2969
|
+
source: Optional data source (for metrics/logs).
|
|
2970
|
+
alert_action: Alert sub-action list/create/delete/update (for action='alerts').
|
|
2971
|
+
alert_rule: Alert rule definition (for alerts create/update).
|
|
2972
|
+
rule_id: Rule ID (for alerts delete/update).
|
|
1258
2973
|
"""
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
if
|
|
1262
|
-
return
|
|
1263
|
-
|
|
1264
|
-
|
|
1265
|
-
|
|
2974
|
+
action = action.lower().strip()
|
|
2975
|
+
valid_actions = ("metrics", "logs", "alerts", "status")
|
|
2976
|
+
if action not in valid_actions:
|
|
2977
|
+
return {"error": f"Unknown action '{action}'. Valid: {', '.join(valid_actions)}"}
|
|
2978
|
+
|
|
2979
|
+
if action == "metrics":
|
|
2980
|
+
from ai.license import require_premium
|
|
2981
|
+
gate = require_premium("obs_metrics")
|
|
2982
|
+
if gate:
|
|
2983
|
+
return gate
|
|
2984
|
+
from backends.tools_infra import obs_metrics
|
|
2985
|
+
return _with_next_steps("obs_metrics", _safe_call(obs_metrics, query=query, time_range=time_range, source=source))
|
|
2986
|
+
|
|
2987
|
+
if action == "logs":
|
|
2988
|
+
from ai.license import require_premium
|
|
2989
|
+
gate = require_premium("obs_logs")
|
|
2990
|
+
if gate:
|
|
2991
|
+
return gate
|
|
2992
|
+
from backends.tools_infra import obs_logs
|
|
2993
|
+
return _with_next_steps("obs_logs", _safe_call(obs_logs, query=query, time_range=time_range, source=source))
|
|
2994
|
+
|
|
2995
|
+
if action == "alerts":
|
|
2996
|
+
from backends.ops_bridge import obs_alerts
|
|
2997
|
+
return _safe_call(obs_alerts, action=alert_action, alert_rule=alert_rule, rule_id=rule_id)
|
|
2998
|
+
|
|
2999
|
+
if action == "status":
|
|
3000
|
+
from ai.license import require_premium
|
|
3001
|
+
gate = require_premium("obs_status")
|
|
3002
|
+
if gate:
|
|
3003
|
+
return gate
|
|
3004
|
+
from backends.tools_infra import obs_status
|
|
3005
|
+
return _with_next_steps("obs_status", _safe_call(obs_status))
|
|
3006
|
+
|
|
3007
|
+
return {"error": f"Unhandled action '{action}'"}
|
|
3008
|
+
|
|
3009
|
+
|
|
3010
|
+
delimit_obs = mcp.tool()(_delimit_obs_impl)
|
|
3011
|
+
|
|
3012
|
+
# --- Thin wrappers (aliases) for backward compatibility ---
|
|
1266
3013
|
|
|
1267
3014
|
@mcp.tool()
|
|
1268
|
-
def
|
|
1269
|
-
""" (Pro).
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
Searches journalctl, /var/log/*, and application log directories.
|
|
1273
|
-
Returns matching log lines with source attribution.
|
|
3015
|
+
def delimit_obs_metrics(query: str = "system", time_range: str = "1h", source: Optional[str] = None) -> Dict[str, Any]:
|
|
3016
|
+
"""Query live system metrics (Pro)."""
|
|
3017
|
+
return _delimit_obs_impl(action="metrics", query=query, time_range=time_range, source=source)
|
|
1274
3018
|
|
|
1275
|
-
Optional: Set ELASTICSEARCH_URL or LOKI_URL for centralized log search.
|
|
1276
3019
|
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
"""
|
|
1282
|
-
from ai.license import require_premium
|
|
1283
|
-
gate = require_premium("obs_logs")
|
|
1284
|
-
if gate:
|
|
1285
|
-
return gate
|
|
1286
|
-
from backends.tools_infra import obs_logs
|
|
1287
|
-
return _with_next_steps("obs_logs", _safe_call(obs_logs, query=query, time_range=time_range, source=source))
|
|
3020
|
+
@mcp.tool()
|
|
3021
|
+
def delimit_obs_logs(query: str, time_range: str = "1h", source: Optional[str] = None) -> Dict[str, Any]:
|
|
3022
|
+
"""Search system and application logs (Pro)."""
|
|
3023
|
+
return _delimit_obs_impl(action="logs", query=query, time_range=time_range, source=source)
|
|
1288
3024
|
|
|
1289
3025
|
|
|
1290
3026
|
@_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
|
|
1291
3027
|
def delimit_obs_alerts(action: str, alert_rule: Optional[Dict[str, Any]] = None, rule_id: Optional[str] = None) -> Dict[str, Any]:
|
|
1292
|
-
"""Manage alerting rules (experimental).
|
|
1293
|
-
|
|
1294
|
-
Args:
|
|
1295
|
-
action: Action (list/create/delete/update).
|
|
1296
|
-
alert_rule: Alert rule definition (for create/update).
|
|
1297
|
-
rule_id: Rule ID (for delete/update).
|
|
1298
|
-
"""
|
|
1299
|
-
from backends.ops_bridge import obs_alerts
|
|
1300
|
-
return _safe_call(obs_alerts, action=action, alert_rule=alert_rule, rule_id=rule_id)
|
|
3028
|
+
"""Manage alerting rules (experimental)."""
|
|
3029
|
+
return _delimit_obs_impl(action="alerts", alert_action=action, alert_rule=alert_rule, rule_id=rule_id)
|
|
1301
3030
|
|
|
1302
3031
|
|
|
1303
3032
|
@mcp.tool()
|
|
1304
3033
|
def delimit_obs_status() -> Dict[str, Any]:
|
|
1305
|
-
""" (Pro).
|
|
1306
|
-
|
|
1307
|
-
|
|
1308
|
-
Checks disk usage, memory, process count, load average, and probes
|
|
1309
|
-
common service ports (Node, PostgreSQL, Redis, Nginx, etc.).
|
|
1310
|
-
No external integration needed.
|
|
1311
|
-
"""
|
|
1312
|
-
from ai.license import require_premium
|
|
1313
|
-
gate = require_premium("obs_status")
|
|
1314
|
-
if gate:
|
|
1315
|
-
return gate
|
|
1316
|
-
from backends.tools_infra import obs_status
|
|
1317
|
-
return _with_next_steps("obs_status", _safe_call(obs_status))
|
|
3034
|
+
"""System health check (Pro)."""
|
|
3035
|
+
return _delimit_obs_impl(action="status")
|
|
1318
3036
|
|
|
1319
3037
|
|
|
1320
3038
|
# ─── DesignSystem (UI Tooling) ──────────────────────────────────────────
|
|
1321
3039
|
|
|
1322
|
-
@
|
|
1323
|
-
def delimit_design_extract_tokens(
|
|
1324
|
-
|
|
3040
|
+
@_internal_tool()
|
|
3041
|
+
def delimit_design_extract_tokens(
|
|
3042
|
+
figma_file_key: Optional[str] = None,
|
|
3043
|
+
token_types: Optional[Union[str, List[str]]] = None,
|
|
3044
|
+
project_path: Optional[str] = None,
|
|
3045
|
+
) -> Dict[str, Any]:
|
|
3046
|
+
"""Extract design tokens from project CSS/SCSS/Tailwind config.
|
|
3047
|
+
|
|
3048
|
+
Works without any API keys (scans local CSS/Tailwind). Figma integration
|
|
3049
|
+
auto-activates when a token is found in: FIGMA_TOKEN env var, or
|
|
3050
|
+
~/.delimit/secrets/figma.json, or via delimit_secret_store.
|
|
1325
3051
|
|
|
1326
3052
|
Args:
|
|
1327
|
-
figma_file_key: Optional Figma file key (uses Figma API if
|
|
3053
|
+
figma_file_key: Optional Figma file key (auto-uses Figma API if a token is available).
|
|
1328
3054
|
token_types: Token types to extract (colors, typography, spacing, breakpoints).
|
|
1329
3055
|
project_path: Project directory to scan. Defaults to cwd.
|
|
1330
3056
|
"""
|
|
3057
|
+
try:
|
|
3058
|
+
token_types = _coerce_list_arg(token_types, "token_types")
|
|
3059
|
+
except ValueError as e:
|
|
3060
|
+
return _with_next_steps("design_extract_tokens", {"error": str(e)})
|
|
1331
3061
|
from backends.ui_bridge import design_extract_tokens
|
|
1332
3062
|
return _with_next_steps("design_extract_tokens", _safe_call(design_extract_tokens, figma_file_key=figma_file_key, token_types=token_types, project_path=project_path))
|
|
1333
3063
|
|
|
1334
3064
|
|
|
1335
|
-
@
|
|
3065
|
+
@_internal_tool()
|
|
1336
3066
|
def delimit_design_generate_component(component_name: str, figma_node_id: Optional[str] = None, output_path: Optional[str] = None, project_path: Optional[str] = None) -> Dict[str, Any]:
|
|
1337
3067
|
"""Generate a React/Next.js component skeleton with props interface and Tailwind support.
|
|
1338
3068
|
|
|
@@ -1346,7 +3076,7 @@ def delimit_design_generate_component(component_name: str, figma_node_id: Option
|
|
|
1346
3076
|
return _with_next_steps("design_generate_component", _safe_call(design_generate_component, component_name=component_name, figma_node_id=figma_node_id, output_path=output_path, project_path=project_path))
|
|
1347
3077
|
|
|
1348
3078
|
|
|
1349
|
-
@
|
|
3079
|
+
@_internal_tool()
|
|
1350
3080
|
def delimit_design_generate_tailwind(figma_file_key: Optional[str] = None, output_path: Optional[str] = None, project_path: Optional[str] = None) -> Dict[str, Any]:
|
|
1351
3081
|
"""Read existing tailwind.config or generate one from detected CSS tokens.
|
|
1352
3082
|
|
|
@@ -1359,8 +3089,11 @@ def delimit_design_generate_tailwind(figma_file_key: Optional[str] = None, outpu
|
|
|
1359
3089
|
return _with_next_steps("design_generate_tailwind", _safe_call(design_generate_tailwind, figma_file_key=figma_file_key, output_path=output_path, project_path=project_path))
|
|
1360
3090
|
|
|
1361
3091
|
|
|
1362
|
-
@
|
|
1363
|
-
def delimit_design_validate_responsive(
|
|
3092
|
+
@_ops_pack_tool()
|
|
3093
|
+
def delimit_design_validate_responsive(
|
|
3094
|
+
project_path: str,
|
|
3095
|
+
check_types: Optional[Union[str, List[str]]] = None,
|
|
3096
|
+
) -> Dict[str, Any]:
|
|
1364
3097
|
"""Validate responsive design patterns via static CSS analysis.
|
|
1365
3098
|
|
|
1366
3099
|
Scans for media queries, viewport meta, mobile-first patterns, fixed widths.
|
|
@@ -1369,11 +3102,15 @@ def delimit_design_validate_responsive(project_path: str, check_types: Optional[
|
|
|
1369
3102
|
project_path: Project path to validate.
|
|
1370
3103
|
check_types: Check types (breakpoints, containers, fluid-type, etc.).
|
|
1371
3104
|
"""
|
|
3105
|
+
try:
|
|
3106
|
+
check_types = _coerce_list_arg(check_types, "check_types")
|
|
3107
|
+
except ValueError as e:
|
|
3108
|
+
return _with_next_steps("design_validate_responsive", {"error": str(e)})
|
|
1372
3109
|
from backends.ui_bridge import design_validate_responsive
|
|
1373
3110
|
return _with_next_steps("design_validate_responsive", _safe_call(design_validate_responsive, project_path=project_path, check_types=check_types))
|
|
1374
3111
|
|
|
1375
3112
|
|
|
1376
|
-
@
|
|
3113
|
+
@_internal_tool()
|
|
1377
3114
|
def delimit_design_component_library(project_path: str, output_format: str = "json") -> Dict[str, Any]:
|
|
1378
3115
|
"""Scan for React/Vue/Svelte components and generate a component catalog.
|
|
1379
3116
|
|
|
@@ -1387,8 +3124,12 @@ def delimit_design_component_library(project_path: str, output_format: str = "js
|
|
|
1387
3124
|
|
|
1388
3125
|
# ─── Story (Component Stories + Visual/A11y Testing) ────────────────────
|
|
1389
3126
|
|
|
1390
|
-
@
|
|
1391
|
-
def delimit_story_generate(
|
|
3127
|
+
@_internal_tool()
|
|
3128
|
+
def delimit_story_generate(
|
|
3129
|
+
component_path: str,
|
|
3130
|
+
story_name: Optional[str] = None,
|
|
3131
|
+
variants: Optional[Union[str, List[str]]] = None,
|
|
3132
|
+
) -> Dict[str, Any]:
|
|
1392
3133
|
"""Generate a .stories.tsx file for a component (no Storybook install required).
|
|
1393
3134
|
|
|
1394
3135
|
Args:
|
|
@@ -1396,15 +3137,21 @@ def delimit_story_generate(component_path: str, story_name: Optional[str] = None
|
|
|
1396
3137
|
story_name: Custom story name. Defaults to component name.
|
|
1397
3138
|
variants: Variants to generate. Defaults to [Default, WithChildren].
|
|
1398
3139
|
"""
|
|
3140
|
+
try:
|
|
3141
|
+
variants = _coerce_list_arg(variants, "variants")
|
|
3142
|
+
except ValueError as e:
|
|
3143
|
+
return _with_next_steps("story_generate", {"error": str(e)})
|
|
1399
3144
|
from backends.ui_bridge import story_generate
|
|
1400
3145
|
return _with_next_steps("story_generate", _safe_call(story_generate, component_path=component_path, story_name=story_name, variants=variants))
|
|
1401
3146
|
|
|
1402
3147
|
|
|
1403
|
-
@
|
|
3148
|
+
@_internal_tool()
|
|
1404
3149
|
def delimit_story_visual_test(url: str, project_path: Optional[str] = None, threshold: float = 0.05) -> Dict[str, Any]:
|
|
1405
|
-
"""Run visual regression test -- screenshot
|
|
3150
|
+
"""Run visual regression test -- screenshot and compare to baseline.
|
|
1406
3151
|
|
|
1407
|
-
|
|
3152
|
+
Works without external tools (returns guidance). Enhanced with:
|
|
3153
|
+
- Playwright (recommended): full visual regression with baseline comparison
|
|
3154
|
+
- Puppeteer (fallback): screenshot capture via npx
|
|
1408
3155
|
|
|
1409
3156
|
Args:
|
|
1410
3157
|
url: URL to screenshot.
|
|
@@ -1415,9 +3162,12 @@ def delimit_story_visual_test(url: str, project_path: Optional[str] = None, thre
|
|
|
1415
3162
|
return _with_next_steps("story_visual_test", _safe_call(story_visual_test, url=url, project_path=project_path, threshold=threshold))
|
|
1416
3163
|
|
|
1417
3164
|
|
|
1418
|
-
@
|
|
3165
|
+
@_internal_tool() # Was experimental (LED-044), promoted to internal (Consensus 120)
|
|
1419
3166
|
def delimit_story_build(project_path: str, output_dir: Optional[str] = None) -> Dict[str, Any]:
|
|
1420
|
-
"""Build Storybook static site
|
|
3167
|
+
"""Build Storybook static site.
|
|
3168
|
+
|
|
3169
|
+
Works without Storybook installed (returns setup guidance).
|
|
3170
|
+
If Storybook is configured in the project, runs the build automatically.
|
|
1421
3171
|
|
|
1422
3172
|
Args:
|
|
1423
3173
|
project_path: Project path.
|
|
@@ -1427,7 +3177,7 @@ def delimit_story_build(project_path: str, output_dir: Optional[str] = None) ->
|
|
|
1427
3177
|
return _safe_call(story_build, project_path=project_path, output_dir=output_dir)
|
|
1428
3178
|
|
|
1429
3179
|
|
|
1430
|
-
@
|
|
3180
|
+
@_internal_tool()
|
|
1431
3181
|
def delimit_story_accessibility(project_path: str, standards: str = "WCAG2AA") -> Dict[str, Any]:
|
|
1432
3182
|
"""Run WCAG accessibility checks by scanning HTML/JSX/TSX for common issues.
|
|
1433
3183
|
|
|
@@ -1492,7 +3242,7 @@ def delimit_test_smoke(project_path: str, test_suite: Optional[str] = None) -> D
|
|
|
1492
3242
|
|
|
1493
3243
|
# ─── Docs (Real implementations) ─────────────────────────────────────
|
|
1494
3244
|
|
|
1495
|
-
@
|
|
3245
|
+
@_ops_pack_tool()
|
|
1496
3246
|
def delimit_docs_generate(target: str = ".") -> Dict[str, Any]:
|
|
1497
3247
|
"""Generate API reference documentation for a project.
|
|
1498
3248
|
|
|
@@ -1627,6 +3377,8 @@ async def delimit_sensor_github_issue(
|
|
|
1627
3377
|
|
|
1628
3378
|
repo_key = repo.replace("/", "_")
|
|
1629
3379
|
return _with_next_steps("sensor_github_issue", {
|
|
3380
|
+
"repo": repo,
|
|
3381
|
+
"issue_number": str(issue_number),
|
|
1630
3382
|
"signal": {
|
|
1631
3383
|
"id": f"sensor:github_issue:{repo_key}:{issue_number}",
|
|
1632
3384
|
"venture": "delimit",
|
|
@@ -1656,47 +3408,32 @@ async def delimit_sensor_github_issue(
|
|
|
1656
3408
|
# ═══════════════════════════════════════════════════════════════════════
|
|
1657
3409
|
|
|
1658
3410
|
|
|
3411
|
+
def _count_registered_tools() -> int:
|
|
3412
|
+
"""Dynamically count tools registered with the MCP server."""
|
|
3413
|
+
try:
|
|
3414
|
+
return len(mcp._tool_manager._tools)
|
|
3415
|
+
except AttributeError:
|
|
3416
|
+
# FastMCP version without _tool_manager — count via module introspection
|
|
3417
|
+
import ai.server as _self
|
|
3418
|
+
return len([n for n in dir(_self) if n.startswith("delimit_")])
|
|
3419
|
+
|
|
3420
|
+
|
|
1659
3421
|
@mcp.tool()
|
|
1660
3422
|
def delimit_version() -> Dict[str, Any]:
|
|
1661
|
-
"""Return Delimit unified server version,
|
|
1662
|
-
|
|
1663
|
-
|
|
1664
|
-
|
|
1665
|
-
|
|
1666
|
-
|
|
1667
|
-
|
|
1668
|
-
"vault.search", "vault.health", "vault.snapshot",
|
|
1669
|
-
],
|
|
1670
|
-
"tier3_extended": [
|
|
1671
|
-
"deploy.plan", "deploy.build", "deploy.publish", "deploy.verify", "deploy.rollback", "deploy.status",
|
|
1672
|
-
"intel.dataset_register", "intel.dataset_list", "intel.dataset_freeze", "intel.snapshot_ingest", "intel.query",
|
|
1673
|
-
"generate.template", "generate.scaffold",
|
|
1674
|
-
"repo.diagnose", "repo.analyze", "repo.config_validate", "repo.config_audit",
|
|
1675
|
-
"security.scan", "security.audit",
|
|
1676
|
-
"evidence.collect", "evidence.verify",
|
|
1677
|
-
],
|
|
1678
|
-
"tier4_ops_ui": [
|
|
1679
|
-
"release.plan", "release.validate", "release.status", "release.rollback", "release.history",
|
|
1680
|
-
"cost.analyze", "cost.optimize", "cost.alert",
|
|
1681
|
-
"data.validate", "data.migrate", "data.backup",
|
|
1682
|
-
"obs.metrics", "obs.logs", "obs.alerts", "obs.status",
|
|
1683
|
-
"design.extract_tokens", "design.generate_component", "design.generate_tailwind", "design.validate_responsive", "design.component_library",
|
|
1684
|
-
"story.generate", "story.visual_test", "story.build", "story.accessibility",
|
|
1685
|
-
"test.generate", "test.coverage", "test.smoke",
|
|
1686
|
-
"docs.generate", "docs.validate",
|
|
1687
|
-
],
|
|
1688
|
-
"sensing": [
|
|
1689
|
-
"sensor.github_issue",
|
|
1690
|
-
],
|
|
1691
|
-
}
|
|
1692
|
-
total = sum(len(v) for v in tiers.values()) + 1 # +1 for version itself
|
|
3423
|
+
"""Return Delimit unified server version, tool count, and environment status.
|
|
3424
|
+
|
|
3425
|
+
Shows auto-detected API keys, CLIs, and security tools so users know
|
|
3426
|
+
what capabilities are available without manual configuration.
|
|
3427
|
+
"""
|
|
3428
|
+
total = _count_registered_tools()
|
|
3429
|
+
environment = _detect_environment()
|
|
1693
3430
|
return _with_next_steps("version", {
|
|
1694
3431
|
"version": VERSION,
|
|
1695
|
-
"server": "delimit-
|
|
3432
|
+
"server": "delimit-mcp",
|
|
1696
3433
|
"total_tools": total,
|
|
1697
|
-
"tiers": tiers,
|
|
1698
3434
|
"adapter_contract": "v1.0",
|
|
1699
3435
|
"authority": "delimit-gateway",
|
|
3436
|
+
"environment": environment,
|
|
1700
3437
|
})
|
|
1701
3438
|
|
|
1702
3439
|
|
|
@@ -1716,6 +3453,7 @@ TOOL_HELP = {
|
|
|
1716
3453
|
"repo_analyze": {"desc": "Full repo health report — code quality, security, dependencies", "example": "delimit_repo_analyze(target='.')", "params": "target (path)"},
|
|
1717
3454
|
"zero_spec": {"desc": "Extract OpenAPI spec from source code (FastAPI, Express, NestJS)", "example": "delimit_zero_spec(project_dir='.')", "params": "project_dir (path)"},
|
|
1718
3455
|
"sensor_github_issue": {"desc": "Monitor a GitHub issue for new comments", "example": "delimit_sensor_github_issue(repo='owner/repo', issue_number=123)", "params": "repo (owner/name), issue_number (int)"},
|
|
3456
|
+
"quickstart": {"desc": "60-second guided first-run experience", "example": "delimit_quickstart(project_path='.')", "params": "project_path (str, default '.')"},
|
|
1719
3457
|
}
|
|
1720
3458
|
|
|
1721
3459
|
|
|
@@ -1756,8 +3494,10 @@ def delimit_help(tool_name: str = "") -> Dict[str, Any]:
|
|
|
1756
3494
|
tool_name: Tool name (e.g. 'lint', 'gov_health'). Leave empty for overview.
|
|
1757
3495
|
"""
|
|
1758
3496
|
if not tool_name:
|
|
3497
|
+
total = _count_registered_tools()
|
|
1759
3498
|
return _with_next_steps("help", {
|
|
1760
|
-
"message": "Delimit has
|
|
3499
|
+
"message": f"Delimit has {total} tools. Here are the most useful ones to start with:",
|
|
3500
|
+
"total_tools": total,
|
|
1761
3501
|
"essential_tools": {k: v["desc"] for k, v in TOOL_HELP.items()},
|
|
1762
3502
|
"workflows": STANDARD_WORKFLOWS,
|
|
1763
3503
|
"tip": "Run delimit_help(tool_name='lint') for detailed help on a specific tool.",
|
|
@@ -1793,7 +3533,7 @@ def delimit_diagnose(project_path: str = ".") -> Dict[str, Any]:
|
|
|
1793
3533
|
p = Path(project_path).resolve()
|
|
1794
3534
|
delimit_dir = p / ".delimit"
|
|
1795
3535
|
policies = delimit_dir / "policies.yml"
|
|
1796
|
-
ledger = delimit_dir / "ledger" / "
|
|
3536
|
+
ledger = delimit_dir / "ledger" / "operations.jsonl"
|
|
1797
3537
|
|
|
1798
3538
|
checks["project_path"] = str(p)
|
|
1799
3539
|
checks["delimit_initialized"] = delimit_dir.is_dir()
|
|
@@ -1828,6 +3568,61 @@ def delimit_diagnose(project_path: str = ".") -> Dict[str, Any]:
|
|
|
1828
3568
|
checks["fastmcp"] = False
|
|
1829
3569
|
issues.append({"issue": "FastMCP not installed", "fix": "pip install fastmcp"})
|
|
1830
3570
|
|
|
3571
|
+
# LED-191: Config drift detection across AI assistants
|
|
3572
|
+
config_sync = {}
|
|
3573
|
+
home = Path.home()
|
|
3574
|
+
assistant_configs = {
|
|
3575
|
+
"claude_code": home / ".mcp.json",
|
|
3576
|
+
"codex_toml": home / ".codex" / "config.toml",
|
|
3577
|
+
"codex_json": home / ".codex" / "config.json",
|
|
3578
|
+
"cursor": home / ".cursor" / "mcp.json",
|
|
3579
|
+
"gemini": home / ".gemini" / "settings.json",
|
|
3580
|
+
}
|
|
3581
|
+
for name, config_path in assistant_configs.items():
|
|
3582
|
+
if not config_path.exists():
|
|
3583
|
+
config_sync[name] = "not_installed"
|
|
3584
|
+
continue
|
|
3585
|
+
try:
|
|
3586
|
+
content = config_path.read_text()
|
|
3587
|
+
if "delimit" in content.lower():
|
|
3588
|
+
config_sync[name] = "configured"
|
|
3589
|
+
else:
|
|
3590
|
+
config_sync[name] = "missing_delimit"
|
|
3591
|
+
issues.append({
|
|
3592
|
+
"issue": f"Delimit not configured in {name} ({config_path})",
|
|
3593
|
+
"fix": "Run: npx delimit-cli setup",
|
|
3594
|
+
})
|
|
3595
|
+
except Exception:
|
|
3596
|
+
config_sync[name] = "read_error"
|
|
3597
|
+
|
|
3598
|
+
configured_count = sum(1 for v in config_sync.values() if v == "configured")
|
|
3599
|
+
installed_count = sum(1 for v in config_sync.values() if v != "not_installed")
|
|
3600
|
+
checks["assistant_configs"] = config_sync
|
|
3601
|
+
checks["assistants_configured"] = f"{configured_count}/{installed_count}"
|
|
3602
|
+
|
|
3603
|
+
# LED-192: MCP server reputation check (basic — check for known risky patterns)
|
|
3604
|
+
mcp_warnings = []
|
|
3605
|
+
mcp_config_path = home / ".mcp.json"
|
|
3606
|
+
if mcp_config_path.exists():
|
|
3607
|
+
try:
|
|
3608
|
+
mcp_data = json.loads(mcp_config_path.read_text())
|
|
3609
|
+
for server_name, server_cfg in mcp_data.get("mcpServers", {}).items():
|
|
3610
|
+
cmd = server_cfg.get("command", "")
|
|
3611
|
+
args = server_cfg.get("args", [])
|
|
3612
|
+
# Check for risky patterns
|
|
3613
|
+
if "curl" in cmd or "wget" in cmd:
|
|
3614
|
+
mcp_warnings.append(f"{server_name}: command uses curl/wget (potential remote code execution)")
|
|
3615
|
+
if any("--no-sandbox" in str(a) for a in args):
|
|
3616
|
+
mcp_warnings.append(f"{server_name}: uses --no-sandbox flag")
|
|
3617
|
+
if server_cfg.get("env", {}).get("NODE_TLS_REJECT_UNAUTHORIZED") == "0":
|
|
3618
|
+
mcp_warnings.append(f"{server_name}: TLS verification disabled")
|
|
3619
|
+
except Exception:
|
|
3620
|
+
pass
|
|
3621
|
+
if mcp_warnings:
|
|
3622
|
+
checks["mcp_warnings"] = mcp_warnings
|
|
3623
|
+
for w in mcp_warnings:
|
|
3624
|
+
issues.append({"issue": f"MCP security: {w}", "fix": "Review server configuration"})
|
|
3625
|
+
|
|
1831
3626
|
# Summary
|
|
1832
3627
|
status = "healthy" if not issues else "issues_found"
|
|
1833
3628
|
result = {
|
|
@@ -1841,6 +3636,8 @@ def delimit_diagnose(project_path: str = ".") -> Dict[str, Any]:
|
|
|
1841
3636
|
diagnose_next = []
|
|
1842
3637
|
if not delimit_dir.is_dir():
|
|
1843
3638
|
diagnose_next.append({"tool": "delimit_init", "reason": "Initialize governance for this project", "suggested_args": {"preset": "default"}, "is_premium": False})
|
|
3639
|
+
if any(v == "missing_delimit" for v in config_sync.values()):
|
|
3640
|
+
diagnose_next.append({"tool": "delimit_quickstart", "reason": "Re-run setup to configure missing assistants", "is_premium": False})
|
|
1844
3641
|
result["next_steps"] = diagnose_next
|
|
1845
3642
|
return result
|
|
1846
3643
|
|
|
@@ -1878,30 +3675,8 @@ def delimit_deploy_site(
|
|
|
1878
3675
|
project_path: str = ".",
|
|
1879
3676
|
message: str = "",
|
|
1880
3677
|
) -> Dict[str, Any]:
|
|
1881
|
-
""" (Pro).
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
Handles the full chain: stages changes, commits, pushes to remote,
|
|
1885
|
-
builds with Vercel, deploys to production. No manual steps needed.
|
|
1886
|
-
|
|
1887
|
-
Args:
|
|
1888
|
-
project_path: Path to the site project (must have .vercel/ configured).
|
|
1889
|
-
message: Git commit message. Auto-generated if empty.
|
|
1890
|
-
"""
|
|
1891
|
-
from ai.license import require_premium
|
|
1892
|
-
gate = require_premium("deploy_site")
|
|
1893
|
-
if gate:
|
|
1894
|
-
return gate
|
|
1895
|
-
from backends.tools_infra import deploy_site
|
|
1896
|
-
env_vars = {}
|
|
1897
|
-
# Auto-detect Delimit UI env vars
|
|
1898
|
-
if "delimit-ui" in project_path or "delimit-ui" in str(Path(project_path).resolve()):
|
|
1899
|
-
chatops_token = os.environ.get("CHATOPS_AUTH_TOKEN", "")
|
|
1900
|
-
env_vars = {
|
|
1901
|
-
"NEXT_PUBLIC_CHATOPS_URL": "https://chatops.delimit.ai",
|
|
1902
|
-
"NEXT_PUBLIC_CHATOPS_TOKEN": chatops_token,
|
|
1903
|
-
}
|
|
1904
|
-
return _with_next_steps("deploy_site", deploy_site(project_path, message, env_vars))
|
|
3678
|
+
"""Deploy a site — git commit, push, Vercel build, deploy (Pro)."""
|
|
3679
|
+
return _delimit_deploy_impl(action="site", project_path=project_path, message=message)
|
|
1905
3680
|
|
|
1906
3681
|
|
|
1907
3682
|
@mcp.tool()
|
|
@@ -1911,24 +3686,8 @@ def delimit_deploy_npm(
|
|
|
1911
3686
|
tag: str = "latest",
|
|
1912
3687
|
dry_run: bool = False,
|
|
1913
3688
|
) -> Dict[str, Any]:
|
|
1914
|
-
""" (Pro).
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
Full chain: check auth, bump version, npm publish, verify on registry,
|
|
1918
|
-
git commit + push the version bump. Use dry_run=true to preview first.
|
|
1919
|
-
|
|
1920
|
-
Args:
|
|
1921
|
-
project_path: Path to the npm package (must have package.json).
|
|
1922
|
-
bump: Version bump type — "patch", "minor", or "major".
|
|
1923
|
-
tag: npm dist-tag (default "latest").
|
|
1924
|
-
dry_run: If true, preview without actually publishing.
|
|
1925
|
-
"""
|
|
1926
|
-
from ai.license import require_premium
|
|
1927
|
-
gate = require_premium("deploy_npm")
|
|
1928
|
-
if gate:
|
|
1929
|
-
return gate
|
|
1930
|
-
from backends.tools_infra import deploy_npm
|
|
1931
|
-
return _with_next_steps("deploy_npm", deploy_npm(project_path, bump, tag, dry_run))
|
|
3689
|
+
"""Publish an npm package (Pro)."""
|
|
3690
|
+
return _delimit_deploy_impl(action="npm", project_path=project_path, bump=bump, tag=tag, dry_run=dry_run)
|
|
1932
3691
|
|
|
1933
3692
|
|
|
1934
3693
|
# ═══════════════════════════════════════════════════════════════════════
|
|
@@ -1943,6 +3702,8 @@ def _resolve_venture(venture: str) -> str:
|
|
|
1943
3702
|
# If it's already a path
|
|
1944
3703
|
if venture.startswith("/") or venture.startswith("~"):
|
|
1945
3704
|
return str(Path(venture).expanduser())
|
|
3705
|
+
if venture.startswith(".") or "/" in venture:
|
|
3706
|
+
return str(Path(venture).resolve())
|
|
1946
3707
|
# Check registered ventures
|
|
1947
3708
|
from ai.ledger_manager import list_ventures
|
|
1948
3709
|
v = list_ventures()
|
|
@@ -1954,7 +3715,8 @@ def _resolve_venture(venture: str) -> str:
|
|
|
1954
3715
|
candidate = Path(root) / venture
|
|
1955
3716
|
if candidate.exists():
|
|
1956
3717
|
return str(candidate)
|
|
1957
|
-
|
|
3718
|
+
dedicated = Path.home() / ".delimit" / "ventures" / venture
|
|
3719
|
+
return str(dedicated)
|
|
1958
3720
|
|
|
1959
3721
|
|
|
1960
3722
|
@mcp.tool()
|
|
@@ -1966,6 +3728,10 @@ def delimit_ledger_add(
|
|
|
1966
3728
|
priority: str = "P1",
|
|
1967
3729
|
description: str = "",
|
|
1968
3730
|
source: str = "session",
|
|
3731
|
+
acceptance_criteria: Optional[Union[str, List[str]]] = None,
|
|
3732
|
+
context: str = "",
|
|
3733
|
+
tools_needed: Optional[Union[str, List[str]]] = None,
|
|
3734
|
+
estimated_complexity: str = "",
|
|
1969
3735
|
) -> Dict[str, Any]:
|
|
1970
3736
|
"""Add a new item to a project's ledger.
|
|
1971
3737
|
|
|
@@ -1980,11 +3746,26 @@ def delimit_ledger_add(
|
|
|
1980
3746
|
priority: P0 (urgent), P1 (important), P2 (nice to have).
|
|
1981
3747
|
description: Details.
|
|
1982
3748
|
source: Where this came from (session, consensus, focus-group, etc).
|
|
3749
|
+
acceptance_criteria: List of testable "done when" conditions (e.g. "tests pass", "coverage > 80%").
|
|
3750
|
+
context: Background info an AI agent needs to work on this item.
|
|
3751
|
+
tools_needed: Delimit tools needed (e.g. "delimit_lint", "delimit_test_coverage").
|
|
3752
|
+
estimated_complexity: small, medium, or large.
|
|
1983
3753
|
"""
|
|
3754
|
+
try:
|
|
3755
|
+
acceptance_criteria = _coerce_list_arg(acceptance_criteria, "acceptance_criteria")
|
|
3756
|
+
except ValueError:
|
|
3757
|
+
acceptance_criteria = None
|
|
3758
|
+
try:
|
|
3759
|
+
tools_needed = _coerce_list_arg(tools_needed, "tools_needed")
|
|
3760
|
+
except ValueError:
|
|
3761
|
+
tools_needed = None
|
|
1984
3762
|
from ai.ledger_manager import add_item
|
|
1985
3763
|
project = _resolve_venture(venture)
|
|
1986
|
-
|
|
1987
|
-
|
|
3764
|
+
result = add_item(title=title, ledger=ledger, type=item_type, priority=priority,
|
|
3765
|
+
description=description, source=source, project_path=project,
|
|
3766
|
+
acceptance_criteria=acceptance_criteria, context=context,
|
|
3767
|
+
tools_needed=tools_needed, estimated_complexity=estimated_complexity)
|
|
3768
|
+
return _with_next_steps("ledger_add", result)
|
|
1988
3769
|
|
|
1989
3770
|
|
|
1990
3771
|
@mcp.tool()
|
|
@@ -1998,7 +3779,8 @@ def delimit_ledger_done(item_id: str, note: str = "", venture: str = "") -> Dict
|
|
|
1998
3779
|
"""
|
|
1999
3780
|
from ai.ledger_manager import update_item
|
|
2000
3781
|
project = _resolve_venture(venture)
|
|
2001
|
-
|
|
3782
|
+
result = update_item(item_id=item_id, status="done", note=note, project_path=project)
|
|
3783
|
+
return _with_next_steps("ledger_done", result)
|
|
2002
3784
|
|
|
2003
3785
|
|
|
2004
3786
|
@mcp.tool()
|
|
@@ -2020,7 +3802,8 @@ def delimit_ledger_list(
|
|
|
2020
3802
|
"""
|
|
2021
3803
|
from ai.ledger_manager import list_items
|
|
2022
3804
|
project = _resolve_venture(venture)
|
|
2023
|
-
|
|
3805
|
+
result = list_items(ledger=ledger, status=status or None, priority=priority or None, limit=limit, project_path=project)
|
|
3806
|
+
return _with_next_steps("ledger_list", result)
|
|
2024
3807
|
|
|
2025
3808
|
|
|
2026
3809
|
@mcp.tool()
|
|
@@ -2035,7 +3818,8 @@ def delimit_ledger_context(venture: str = "") -> Dict[str, Any]:
|
|
|
2035
3818
|
"""
|
|
2036
3819
|
from ai.ledger_manager import get_context
|
|
2037
3820
|
project = _resolve_venture(venture) if venture else "."
|
|
2038
|
-
|
|
3821
|
+
result = get_context(project_path=project)
|
|
3822
|
+
return _with_next_steps("ledger_context", result)
|
|
2039
3823
|
|
|
2040
3824
|
|
|
2041
3825
|
@mcp.tool()
|
|
@@ -2241,7 +4025,7 @@ def delimit_deliberate(
|
|
|
2241
4025
|
except Exception as e:
|
|
2242
4026
|
logger.warning("Deliberation auto-ledger failed: %s", e)
|
|
2243
4027
|
|
|
2244
|
-
return summary
|
|
4028
|
+
return _with_next_steps("deliberate", summary)
|
|
2245
4029
|
|
|
2246
4030
|
|
|
2247
4031
|
def _extract_deliberation_actions(result: Dict, question: str) -> List[Dict[str, str]]:
|
|
@@ -2299,23 +4083,8 @@ def _extract_deliberation_actions(result: Dict, question: str) -> List[Dict[str,
|
|
|
2299
4083
|
|
|
2300
4084
|
@mcp.tool()
|
|
2301
4085
|
def delimit_release_sync(action: str = "audit") -> Dict[str, Any]:
|
|
2302
|
-
""" (Pro).
|
|
2303
|
-
|
|
2304
|
-
|
|
2305
|
-
Checks GitHub repos, npm, site meta tags, CLAUDE.md, and releases
|
|
2306
|
-
against a central config. Reports what's stale and what needs updating.
|
|
2307
|
-
|
|
2308
|
-
Args:
|
|
2309
|
-
action: "audit" to check all surfaces, "config" to view/edit the release config.
|
|
2310
|
-
"""
|
|
2311
|
-
from ai.license import require_premium
|
|
2312
|
-
gate = require_premium("release_sync")
|
|
2313
|
-
if gate:
|
|
2314
|
-
return gate
|
|
2315
|
-
from ai.release_sync import audit, get_release_config
|
|
2316
|
-
if action == "config":
|
|
2317
|
-
return get_release_config()
|
|
2318
|
-
return _with_next_steps("release_sync", audit())
|
|
4086
|
+
"""Audit or sync all public surfaces for consistency (Pro)."""
|
|
4087
|
+
return _delimit_release_impl(action="sync", sync_action=action)
|
|
2319
4088
|
|
|
2320
4089
|
|
|
2321
4090
|
@mcp.tool()
|
|
@@ -2447,6 +4216,1381 @@ def delimit_scan(project_path: str = ".") -> Dict[str, Any]:
|
|
|
2447
4216
|
})
|
|
2448
4217
|
|
|
2449
4218
|
|
|
4219
|
+
@mcp.tool()
|
|
4220
|
+
def delimit_quickstart(project_path: str = ".") -> Dict[str, Any]:
|
|
4221
|
+
"""60-second guided quickstart that proves Delimit value immediately.
|
|
4222
|
+
|
|
4223
|
+
Combines init + scan + environment detection into a single first-run
|
|
4224
|
+
experience. Run this right after installing Delimit.
|
|
4225
|
+
|
|
4226
|
+
Args:
|
|
4227
|
+
project_path: Path to the project to quickstart.
|
|
4228
|
+
"""
|
|
4229
|
+
steps_completed = []
|
|
4230
|
+
p = Path(project_path).resolve()
|
|
4231
|
+
|
|
4232
|
+
# Step 1: Auto-detect environment
|
|
4233
|
+
environment = _detect_environment()
|
|
4234
|
+
steps_completed.append({
|
|
4235
|
+
"step": 1,
|
|
4236
|
+
"name": "Environment Detection",
|
|
4237
|
+
"result": {
|
|
4238
|
+
"api_keys": len(environment["api_keys"]),
|
|
4239
|
+
"clis": list(environment["clis"].keys()),
|
|
4240
|
+
"security_tools": list(environment["security_tools"].keys()),
|
|
4241
|
+
},
|
|
4242
|
+
})
|
|
4243
|
+
|
|
4244
|
+
# Step 2: Initialize governance (idempotent)
|
|
4245
|
+
init_result = delimit_init.fn(project_path=str(p)) if hasattr(delimit_init, "fn") else delimit_init(project_path=str(p))
|
|
4246
|
+
steps_completed.append({
|
|
4247
|
+
"step": 2,
|
|
4248
|
+
"name": "Governance Init",
|
|
4249
|
+
"status": init_result.get("status", "unknown"),
|
|
4250
|
+
})
|
|
4251
|
+
|
|
4252
|
+
# Step 3: Scan project
|
|
4253
|
+
scan_result = delimit_scan.fn(project_path=str(p)) if hasattr(delimit_scan, "fn") else delimit_scan(project_path=str(p))
|
|
4254
|
+
steps_completed.append({
|
|
4255
|
+
"step": 3,
|
|
4256
|
+
"name": "Project Scan",
|
|
4257
|
+
"findings": len(scan_result.get("findings", [])),
|
|
4258
|
+
"suggestions": len(scan_result.get("suggestions", [])),
|
|
4259
|
+
})
|
|
4260
|
+
|
|
4261
|
+
# Step 4: Check governance health
|
|
4262
|
+
from ai.governance import govern
|
|
4263
|
+
gov_health = {"status": "healthy"}
|
|
4264
|
+
try:
|
|
4265
|
+
delimit_dir = p / ".delimit"
|
|
4266
|
+
gov_health["initialized"] = delimit_dir.is_dir()
|
|
4267
|
+
gov_health["policies"] = (delimit_dir / "policies.yml").is_file()
|
|
4268
|
+
gov_health["ledger"] = (delimit_dir / "ledger").is_dir()
|
|
4269
|
+
except Exception:
|
|
4270
|
+
pass
|
|
4271
|
+
steps_completed.append({
|
|
4272
|
+
"step": 4,
|
|
4273
|
+
"name": "Governance Health",
|
|
4274
|
+
"result": gov_health,
|
|
4275
|
+
})
|
|
4276
|
+
|
|
4277
|
+
# Step 5: Check deliberation readiness
|
|
4278
|
+
deliberation_ready = False
|
|
4279
|
+
enabled_models = []
|
|
4280
|
+
try:
|
|
4281
|
+
from ai.deliberation import get_models_config
|
|
4282
|
+
models = get_models_config()
|
|
4283
|
+
enabled_models = [v.get("name", k) for k, v in models.items() if v.get("enabled")]
|
|
4284
|
+
deliberation_ready = len(enabled_models) >= 2
|
|
4285
|
+
except Exception:
|
|
4286
|
+
pass
|
|
4287
|
+
steps_completed.append({
|
|
4288
|
+
"step": 5,
|
|
4289
|
+
"name": "Deliberation",
|
|
4290
|
+
"ready": deliberation_ready,
|
|
4291
|
+
"models": enabled_models,
|
|
4292
|
+
})
|
|
4293
|
+
|
|
4294
|
+
# Build suggested next actions based on findings
|
|
4295
|
+
next_actions = []
|
|
4296
|
+
if scan_result.get("findings"):
|
|
4297
|
+
for f in scan_result["findings"]:
|
|
4298
|
+
if f.get("type") == "openapi_specs":
|
|
4299
|
+
next_actions.append("Run `delimit_lint` on your OpenAPI spec to check for breaking changes")
|
|
4300
|
+
if f.get("type") == "security_concerns":
|
|
4301
|
+
next_actions.append("Run `delimit_security_scan` to audit for vulnerabilities")
|
|
4302
|
+
if f.get("type") == "tests_found":
|
|
4303
|
+
next_actions.append("Run `delimit_test_smoke` to verify tests pass")
|
|
4304
|
+
|
|
4305
|
+
if not deliberation_ready:
|
|
4306
|
+
next_actions.append("Add more AI models for multi-model deliberation: say 'configure delimit models'")
|
|
4307
|
+
|
|
4308
|
+
next_actions.append("Say 'add to ledger: [task]' to start tracking work across sessions")
|
|
4309
|
+
next_actions.append("Say 'deliberate [question]' to get AI consensus on a decision")
|
|
4310
|
+
|
|
4311
|
+
return _with_next_steps("quickstart", {
|
|
4312
|
+
"tool": "quickstart",
|
|
4313
|
+
"status": "complete",
|
|
4314
|
+
"project": str(p),
|
|
4315
|
+
"steps": steps_completed,
|
|
4316
|
+
"environment": environment,
|
|
4317
|
+
"scan_findings": scan_result.get("findings", []),
|
|
4318
|
+
"scan_suggestions": scan_result.get("suggestions", []),
|
|
4319
|
+
"next_actions": next_actions,
|
|
4320
|
+
"message": f"Quickstart complete! {len(steps_completed)} steps run. {len(next_actions)} suggested next actions.",
|
|
4321
|
+
})
|
|
4322
|
+
|
|
4323
|
+
|
|
4324
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4325
|
+
# STR-049: SECRETS BROKER — JIT credential access with audit
|
|
4326
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4327
|
+
|
|
4328
|
+
|
|
4329
|
+
# Consensus 082: Unified secret tool with action parameter
|
|
4330
|
+
def _delimit_secret_impl(
|
|
4331
|
+
action: str = "list",
|
|
4332
|
+
name: str = "",
|
|
4333
|
+
value: str = "",
|
|
4334
|
+
scope: str = "all",
|
|
4335
|
+
description: str = "",
|
|
4336
|
+
agent_type: str = "",
|
|
4337
|
+
tool: str = "",
|
|
4338
|
+
) -> Dict[str, Any]:
|
|
4339
|
+
"""Manage secrets broker.
|
|
4340
|
+
|
|
4341
|
+
Actions: store, get, list, revoke, access_log.
|
|
4342
|
+
|
|
4343
|
+
Args:
|
|
4344
|
+
action: Which secret operation to perform.
|
|
4345
|
+
name: Secret name (for store/get/revoke/access_log).
|
|
4346
|
+
value: Secret value (for action='store').
|
|
4347
|
+
scope: Comma-separated allowed agents/tools or 'all' (for action='store').
|
|
4348
|
+
description: Human-readable description (for action='store').
|
|
4349
|
+
agent_type: Requesting agent identity (for action='get').
|
|
4350
|
+
tool: Requesting tool name (for action='get').
|
|
4351
|
+
"""
|
|
4352
|
+
action = action.lower().strip()
|
|
4353
|
+
valid_actions = ("store", "get", "list", "revoke", "access_log")
|
|
4354
|
+
if action not in valid_actions:
|
|
4355
|
+
return {"error": f"Unknown action '{action}'. Valid: {', '.join(valid_actions)}"}
|
|
4356
|
+
|
|
4357
|
+
if action == "store":
|
|
4358
|
+
from ai.secrets_broker import store_secret
|
|
4359
|
+
return _safe_call(store_secret, name=name, value=value, scope=scope, description=description)
|
|
4360
|
+
|
|
4361
|
+
if action == "get":
|
|
4362
|
+
from ai.secrets_broker import get_secret
|
|
4363
|
+
return _safe_call(get_secret, name=name, agent_type=agent_type, tool=tool)
|
|
4364
|
+
|
|
4365
|
+
if action == "list":
|
|
4366
|
+
from ai.secrets_broker import list_secrets
|
|
4367
|
+
return _with_next_steps("secret_list", {"secrets": list_secrets()})
|
|
4368
|
+
|
|
4369
|
+
if action == "revoke":
|
|
4370
|
+
from ai.secrets_broker import revoke_secret
|
|
4371
|
+
return _safe_call(revoke_secret, name=name)
|
|
4372
|
+
|
|
4373
|
+
if action == "access_log":
|
|
4374
|
+
from ai.secrets_broker import get_access_log
|
|
4375
|
+
entries = get_access_log(name=name if name else None)
|
|
4376
|
+
return _with_next_steps("secret_access_log", {"log": entries, "count": len(entries)})
|
|
4377
|
+
|
|
4378
|
+
return {"error": f"Unhandled action '{action}'"}
|
|
4379
|
+
|
|
4380
|
+
|
|
4381
|
+
delimit_secret = mcp.tool()(_delimit_secret_impl)
|
|
4382
|
+
|
|
4383
|
+
# --- Thin wrappers (aliases) for backward compatibility ---
|
|
4384
|
+
|
|
4385
|
+
@mcp.tool()
|
|
4386
|
+
def delimit_secret_store(
|
|
4387
|
+
name: str = "",
|
|
4388
|
+
value: str = "",
|
|
4389
|
+
scope: str = "all",
|
|
4390
|
+
description: str = "",
|
|
4391
|
+
) -> Dict[str, Any]:
|
|
4392
|
+
"""Store a secret in the Delimit secrets broker."""
|
|
4393
|
+
return _delimit_secret_impl(action="store", name=name, value=value, scope=scope, description=description)
|
|
4394
|
+
|
|
4395
|
+
|
|
4396
|
+
@mcp.tool()
|
|
4397
|
+
def delimit_secret_get(
|
|
4398
|
+
name: str = "",
|
|
4399
|
+
agent_type: str = "",
|
|
4400
|
+
tool: str = "",
|
|
4401
|
+
) -> Dict[str, Any]:
|
|
4402
|
+
"""Request JIT access to a secret through the broker."""
|
|
4403
|
+
return _delimit_secret_impl(action="get", name=name, agent_type=agent_type, tool=tool)
|
|
4404
|
+
|
|
4405
|
+
|
|
4406
|
+
@mcp.tool()
|
|
4407
|
+
def delimit_secret_list() -> Dict[str, Any]:
|
|
4408
|
+
"""List all secrets in the broker (metadata only, never values)."""
|
|
4409
|
+
return _delimit_secret_impl(action="list")
|
|
4410
|
+
|
|
4411
|
+
|
|
4412
|
+
@mcp.tool()
|
|
4413
|
+
def delimit_secret_revoke(name: str = "") -> Dict[str, Any]:
|
|
4414
|
+
"""Revoke a secret, preventing any future access."""
|
|
4415
|
+
return _delimit_secret_impl(action="revoke", name=name)
|
|
4416
|
+
|
|
4417
|
+
|
|
4418
|
+
@mcp.tool()
|
|
4419
|
+
def delimit_secret_access_log(name: str = "") -> Dict[str, Any]:
|
|
4420
|
+
"""View the access audit log for secrets."""
|
|
4421
|
+
return _delimit_secret_impl(action="access_log", name=name)
|
|
4422
|
+
|
|
4423
|
+
|
|
4424
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4425
|
+
# STR-048: Context Filesystem — versioned namespace for agent state
|
|
4426
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4427
|
+
|
|
4428
|
+
# Consensus 082 Phase 2: Unified context tool with action parameter
|
|
4429
|
+
def _delimit_context_impl(
|
|
4430
|
+
action: str = "list",
|
|
4431
|
+
venture: str = "default",
|
|
4432
|
+
# write params
|
|
4433
|
+
name: str = "",
|
|
4434
|
+
content: str = "",
|
|
4435
|
+
artifact_type: str = "text",
|
|
4436
|
+
# snapshot params
|
|
4437
|
+
label: str = "",
|
|
4438
|
+
# branch params
|
|
4439
|
+
branch_action: str = "list",
|
|
4440
|
+
branch_name: str = "",
|
|
4441
|
+
) -> Dict[str, Any]:
|
|
4442
|
+
"""Manage context filesystem for cross-model continuity.
|
|
4443
|
+
|
|
4444
|
+
Actions: init, read, write, list, snapshot, branch.
|
|
4445
|
+
|
|
4446
|
+
Args:
|
|
4447
|
+
action: Which context operation to perform.
|
|
4448
|
+
venture: Venture/project namespace (default: "default").
|
|
4449
|
+
name: Artifact name (for read/write).
|
|
4450
|
+
content: Artifact content (for action='write').
|
|
4451
|
+
artifact_type: Type hint text/json/code/plan (for action='write').
|
|
4452
|
+
label: Snapshot label (for action='snapshot').
|
|
4453
|
+
branch_action: Branch sub-action create/merge/list (for action='branch').
|
|
4454
|
+
branch_name: Branch name (for action='branch' with create/merge).
|
|
4455
|
+
"""
|
|
4456
|
+
action = action.lower().strip()
|
|
4457
|
+
valid_actions = ("init", "read", "write", "list", "snapshot", "branch")
|
|
4458
|
+
if action not in valid_actions:
|
|
4459
|
+
return {"error": f"Unknown action '{action}'. Valid: {', '.join(valid_actions)}"}
|
|
4460
|
+
|
|
4461
|
+
if action == "init":
|
|
4462
|
+
from ai.context_fs import init_context
|
|
4463
|
+
return _with_next_steps("context_init", init_context(venture=venture))
|
|
4464
|
+
|
|
4465
|
+
if action == "write":
|
|
4466
|
+
from ai.context_fs import write_artifact
|
|
4467
|
+
return _with_next_steps("context_write", write_artifact(venture=venture, name=name, content=content, artifact_type=artifact_type))
|
|
4468
|
+
|
|
4469
|
+
if action == "read":
|
|
4470
|
+
from ai.context_fs import read_artifact
|
|
4471
|
+
return _with_next_steps("context_read", read_artifact(venture=venture, name=name))
|
|
4472
|
+
|
|
4473
|
+
if action == "list":
|
|
4474
|
+
from ai.context_fs import list_artifacts
|
|
4475
|
+
artifacts = list_artifacts(venture=venture)
|
|
4476
|
+
return _with_next_steps("context_list", {"venture": venture, "artifacts": artifacts, "count": len(artifacts)})
|
|
4477
|
+
|
|
4478
|
+
if action == "snapshot":
|
|
4479
|
+
from ai.context_fs import create_snapshot
|
|
4480
|
+
return _with_next_steps("context_snapshot", create_snapshot(venture=venture, label=label))
|
|
4481
|
+
|
|
4482
|
+
if action == "branch":
|
|
4483
|
+
from ai.context_fs import create_branch, merge_branch, list_branches
|
|
4484
|
+
ba = branch_action.lower().strip()
|
|
4485
|
+
if ba == "create":
|
|
4486
|
+
if not branch_name:
|
|
4487
|
+
return {"error": "branch_name is required for create"}
|
|
4488
|
+
return _with_next_steps("context_branch", create_branch(venture=venture, branch_name=branch_name))
|
|
4489
|
+
elif ba == "merge":
|
|
4490
|
+
if not branch_name:
|
|
4491
|
+
return {"error": "branch_name is required for merge"}
|
|
4492
|
+
return _with_next_steps("context_branch", merge_branch(venture=venture, branch_name=branch_name))
|
|
4493
|
+
elif ba == "list":
|
|
4494
|
+
branches = list_branches(venture=venture)
|
|
4495
|
+
return _with_next_steps("context_branch", {"venture": venture, "branches": branches, "count": len(branches)})
|
|
4496
|
+
else:
|
|
4497
|
+
return {"error": f"Unknown branch_action '{ba}'. Use create, merge, or list."}
|
|
4498
|
+
|
|
4499
|
+
return {"error": f"Unhandled action '{action}'"}
|
|
4500
|
+
|
|
4501
|
+
|
|
4502
|
+
delimit_context = mcp.tool()(_delimit_context_impl)
|
|
4503
|
+
|
|
4504
|
+
# --- Thin wrappers (aliases) for backward compatibility ---
|
|
4505
|
+
|
|
4506
|
+
@mcp.tool()
|
|
4507
|
+
def delimit_context_init(venture: str = "default") -> Dict[str, Any]:
|
|
4508
|
+
"""Initialize a context namespace for a venture."""
|
|
4509
|
+
return _delimit_context_impl(action="init", venture=venture)
|
|
4510
|
+
|
|
4511
|
+
|
|
4512
|
+
@mcp.tool()
|
|
4513
|
+
def delimit_context_write(venture: str, name: str, content: str, artifact_type: str = "text") -> Dict[str, Any]:
|
|
4514
|
+
"""Write an artifact to the context filesystem."""
|
|
4515
|
+
return _delimit_context_impl(action="write", venture=venture, name=name, content=content, artifact_type=artifact_type)
|
|
4516
|
+
|
|
4517
|
+
|
|
4518
|
+
@mcp.tool()
|
|
4519
|
+
def delimit_context_read(venture: str, name: str) -> Dict[str, Any]:
|
|
4520
|
+
"""Read an artifact from the context filesystem."""
|
|
4521
|
+
return _delimit_context_impl(action="read", venture=venture, name=name)
|
|
4522
|
+
|
|
4523
|
+
|
|
4524
|
+
@mcp.tool()
|
|
4525
|
+
def delimit_context_list(venture: str) -> Dict[str, Any]:
|
|
4526
|
+
"""List all artifacts in a venture's context."""
|
|
4527
|
+
return _delimit_context_impl(action="list", venture=venture)
|
|
4528
|
+
|
|
4529
|
+
|
|
4530
|
+
@mcp.tool()
|
|
4531
|
+
def delimit_context_snapshot(venture: str, label: str = "") -> Dict[str, Any]:
|
|
4532
|
+
"""Create a point-in-time snapshot of the entire context."""
|
|
4533
|
+
return _delimit_context_impl(action="snapshot", venture=venture, label=label)
|
|
4534
|
+
|
|
4535
|
+
|
|
4536
|
+
@mcp.tool()
|
|
4537
|
+
def delimit_context_branch(venture: str, action: str = "list", branch_name: str = "") -> Dict[str, Any]:
|
|
4538
|
+
"""Manage context branches -- create, merge, or list."""
|
|
4539
|
+
return _delimit_context_impl(action="branch", venture=venture, branch_action=action, branch_name=branch_name)
|
|
4540
|
+
|
|
4541
|
+
|
|
4542
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4543
|
+
# STR-050: DATA/ACTION PLANE — External systems as typed mounted resources
|
|
4544
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4545
|
+
|
|
4546
|
+
|
|
4547
|
+
@mcp.tool()
|
|
4548
|
+
def delimit_resource_list(
|
|
4549
|
+
driver: str = "github",
|
|
4550
|
+
resource: str = "",
|
|
4551
|
+
repo: str = "",
|
|
4552
|
+
org: str = "",
|
|
4553
|
+
state: str = "open",
|
|
4554
|
+
limit: int = 10,
|
|
4555
|
+
) -> Dict[str, Any]:
|
|
4556
|
+
"""List resources from a connected system (Pro).
|
|
4557
|
+
|
|
4558
|
+
Drivers: github. Resources: repos, pull_requests, issues, workflows.
|
|
4559
|
+
|
|
4560
|
+
Args:
|
|
4561
|
+
driver: The data plane driver to use (default: github).
|
|
4562
|
+
resource: The resource type to list (repos, pull_requests, issues, workflows).
|
|
4563
|
+
repo: Repository in owner/name format (required for workflows).
|
|
4564
|
+
org: Organization filter (for repos).
|
|
4565
|
+
state: State filter for PRs/issues (open, closed, all).
|
|
4566
|
+
limit: Maximum number of results.
|
|
4567
|
+
"""
|
|
4568
|
+
from ai.data_plane import get_driver
|
|
4569
|
+
|
|
4570
|
+
d = get_driver(driver)
|
|
4571
|
+
if not d:
|
|
4572
|
+
return {"error": f"Driver '{driver}' not found"}
|
|
4573
|
+
|
|
4574
|
+
method_map = {
|
|
4575
|
+
"repos": lambda: d.list_repos(org=org, limit=limit),
|
|
4576
|
+
"pull_requests": lambda: d.list_prs(repo=repo, state=state, limit=limit),
|
|
4577
|
+
"issues": lambda: d.list_issues(repo=repo, state=state, limit=limit),
|
|
4578
|
+
"workflows": lambda: d.list_runs(repo=repo, limit=limit),
|
|
4579
|
+
}
|
|
4580
|
+
fn = method_map.get(resource)
|
|
4581
|
+
if not fn:
|
|
4582
|
+
return {
|
|
4583
|
+
"error": f"Resource '{resource}' not found. Available: {list(method_map.keys())}"
|
|
4584
|
+
}
|
|
4585
|
+
return _with_next_steps(
|
|
4586
|
+
"resource_list",
|
|
4587
|
+
{"driver": driver, "resource": resource, "data": fn()},
|
|
4588
|
+
)
|
|
4589
|
+
|
|
4590
|
+
|
|
4591
|
+
@mcp.tool()
|
|
4592
|
+
def delimit_resource_get(
|
|
4593
|
+
driver: str = "github",
|
|
4594
|
+
resource: str = "",
|
|
4595
|
+
identifier: str = "",
|
|
4596
|
+
repo: str = "",
|
|
4597
|
+
) -> Dict[str, Any]:
|
|
4598
|
+
"""Get a specific resource from a connected system (Pro).
|
|
4599
|
+
|
|
4600
|
+
Args:
|
|
4601
|
+
driver: The data plane driver to use (default: github).
|
|
4602
|
+
resource: The resource type (repos, pull_requests, issues, workflows).
|
|
4603
|
+
identifier: The resource identifier (repo name, PR number, run ID).
|
|
4604
|
+
repo: Repository in owner/name format (for PRs, issues, workflow runs).
|
|
4605
|
+
"""
|
|
4606
|
+
from ai.data_plane import get_driver
|
|
4607
|
+
|
|
4608
|
+
d = get_driver(driver)
|
|
4609
|
+
if not d:
|
|
4610
|
+
return {"error": f"Driver '{driver}' not found"}
|
|
4611
|
+
|
|
4612
|
+
get_map = {
|
|
4613
|
+
"repos": lambda: d.get_repo(identifier),
|
|
4614
|
+
"pull_requests": lambda: d.get_pr(repo, int(identifier)) if identifier.isdigit() else {"error": "PR identifier must be a number"},
|
|
4615
|
+
"issues": lambda: d.get_issue(repo, int(identifier)) if identifier.isdigit() else {"error": "Issue identifier must be a number"},
|
|
4616
|
+
"workflows": lambda: d.get_run(repo, int(identifier)) if identifier.isdigit() else {"error": "Run identifier must be a number"},
|
|
4617
|
+
}
|
|
4618
|
+
fn = get_map.get(resource)
|
|
4619
|
+
if not fn:
|
|
4620
|
+
return {
|
|
4621
|
+
"error": f"Resource '{resource}' not found. Available: {list(get_map.keys())}"
|
|
4622
|
+
}
|
|
4623
|
+
return _with_next_steps("resource_get", fn())
|
|
4624
|
+
|
|
4625
|
+
|
|
4626
|
+
@mcp.tool()
|
|
4627
|
+
def delimit_resource_drivers() -> Dict[str, Any]:
|
|
4628
|
+
"""List available data plane drivers and their resource schemas."""
|
|
4629
|
+
from ai.data_plane import list_drivers
|
|
4630
|
+
|
|
4631
|
+
return _with_next_steps("resource_drivers", {"drivers": list_drivers()})
|
|
4632
|
+
|
|
4633
|
+
|
|
4634
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4635
|
+
# LED-188: ISSUE TRACKER CONTEXT SYNC
|
|
4636
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4637
|
+
|
|
4638
|
+
|
|
4639
|
+
@mcp.tool()
|
|
4640
|
+
def delimit_tracker_sync(
|
|
4641
|
+
repo: str = "",
|
|
4642
|
+
labels: str = "",
|
|
4643
|
+
limit: int = 10,
|
|
4644
|
+
) -> Dict[str, Any]:
|
|
4645
|
+
"""Pull open issues from GitHub into the Delimit ledger as context.
|
|
4646
|
+
|
|
4647
|
+
Read-only sync — enriches your ledger with external issue context.
|
|
4648
|
+
Does NOT write back to GitHub.
|
|
4649
|
+
|
|
4650
|
+
Args:
|
|
4651
|
+
repo: GitHub repository in owner/repo format. Auto-detects from git remote if empty.
|
|
4652
|
+
labels: Comma-separated label filter (e.g. "bug,priority:high").
|
|
4653
|
+
limit: Max issues to sync (default 10).
|
|
4654
|
+
"""
|
|
4655
|
+
import re as _re
|
|
4656
|
+
|
|
4657
|
+
# Auto-detect repo from git remote
|
|
4658
|
+
if not repo:
|
|
4659
|
+
try:
|
|
4660
|
+
r = subprocess.run(["git", "remote", "get-url", "origin"], capture_output=True, text=True, timeout=5)
|
|
4661
|
+
if r.returncode == 0:
|
|
4662
|
+
url = r.stdout.strip()
|
|
4663
|
+
for prefix in ["git@github.com:", "https://github.com/"]:
|
|
4664
|
+
if url.startswith(prefix):
|
|
4665
|
+
repo = url[len(prefix):].rstrip(".git")
|
|
4666
|
+
break
|
|
4667
|
+
except Exception:
|
|
4668
|
+
pass
|
|
4669
|
+
|
|
4670
|
+
if not repo or not _re.match(r'^[\w.-]+/[\w.-]+$', repo):
|
|
4671
|
+
return _with_next_steps("tracker_sync", {"error": f"Invalid or missing repo: {repo}. Use owner/repo format."})
|
|
4672
|
+
|
|
4673
|
+
# Fetch open issues via gh CLI
|
|
4674
|
+
try:
|
|
4675
|
+
label_filter = ""
|
|
4676
|
+
if labels:
|
|
4677
|
+
label_filter = f"&labels={labels}"
|
|
4678
|
+
cmd = ["gh", "api", f"repos/{repo}/issues?state=open&per_page={limit}{label_filter}",
|
|
4679
|
+
"--jq", '[.[] | {number, title, labels: [.labels[].name], assignee: .assignee.login, created_at, updated_at, html_url}]']
|
|
4680
|
+
result = subprocess.run(cmd, capture_output=True, text=True, timeout=30)
|
|
4681
|
+
if result.returncode != 0:
|
|
4682
|
+
return _with_next_steps("tracker_sync", {"error": f"gh api failed: {result.stderr.strip()[:200]}"})
|
|
4683
|
+
|
|
4684
|
+
issues = json.loads(result.stdout) if result.stdout.strip() else []
|
|
4685
|
+
except Exception as e:
|
|
4686
|
+
return _with_next_steps("tracker_sync", {"error": f"Failed to fetch issues: {e}"})
|
|
4687
|
+
|
|
4688
|
+
# Map to ledger-compatible format (read-only context, not actual ledger items)
|
|
4689
|
+
synced = []
|
|
4690
|
+
for issue in issues[:limit]:
|
|
4691
|
+
synced.append({
|
|
4692
|
+
"source": f"github:{repo}#{issue['number']}",
|
|
4693
|
+
"title": issue.get("title", ""),
|
|
4694
|
+
"labels": issue.get("labels", []),
|
|
4695
|
+
"assignee": issue.get("assignee"),
|
|
4696
|
+
"url": issue.get("html_url", ""),
|
|
4697
|
+
"updated_at": issue.get("updated_at", ""),
|
|
4698
|
+
})
|
|
4699
|
+
|
|
4700
|
+
return _with_next_steps("tracker_sync", {
|
|
4701
|
+
"tool": "tracker_sync",
|
|
4702
|
+
"repo": repo,
|
|
4703
|
+
"issues_synced": len(synced),
|
|
4704
|
+
"issues": synced,
|
|
4705
|
+
"message": f"Pulled {len(synced)} open issues from {repo}. Use these as context for governance decisions.",
|
|
4706
|
+
})
|
|
4707
|
+
|
|
4708
|
+
|
|
4709
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4710
|
+
# LED-183: WEBHOOK NOTIFICATIONS
|
|
4711
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4712
|
+
|
|
4713
|
+
|
|
4714
|
+
@mcp.tool()
|
|
4715
|
+
def delimit_webhook_manage(
|
|
4716
|
+
action: str = "list",
|
|
4717
|
+
url: str = "",
|
|
4718
|
+
events: str = "all",
|
|
4719
|
+
) -> Dict[str, Any]:
|
|
4720
|
+
"""Manage webhook notifications for governance events.
|
|
4721
|
+
|
|
4722
|
+
Get notified in Slack or Discord when governance blocks a deploy,
|
|
4723
|
+
detects a security finding, or a deliberation reaches consensus.
|
|
4724
|
+
|
|
4725
|
+
Actions:
|
|
4726
|
+
- "list": show configured webhooks
|
|
4727
|
+
- "add": add a webhook (set url + events filter)
|
|
4728
|
+
- "remove": remove a webhook by url
|
|
4729
|
+
- "test": send a test notification
|
|
4730
|
+
|
|
4731
|
+
Args:
|
|
4732
|
+
action: list, add, remove, or test.
|
|
4733
|
+
url: Webhook URL (Slack, Discord, or any HTTP endpoint).
|
|
4734
|
+
events: Comma-separated event filter: "all", "blocked", "critical", "security".
|
|
4735
|
+
"""
|
|
4736
|
+
webhooks_file = Path.home() / ".delimit" / "webhooks.json"
|
|
4737
|
+
|
|
4738
|
+
def _load():
|
|
4739
|
+
if webhooks_file.exists():
|
|
4740
|
+
try:
|
|
4741
|
+
return json.loads(webhooks_file.read_text())
|
|
4742
|
+
except Exception:
|
|
4743
|
+
pass
|
|
4744
|
+
return []
|
|
4745
|
+
|
|
4746
|
+
def _save(hooks):
|
|
4747
|
+
webhooks_file.parent.mkdir(parents=True, exist_ok=True)
|
|
4748
|
+
webhooks_file.write_text(json.dumps(hooks, indent=2))
|
|
4749
|
+
|
|
4750
|
+
if action == "list":
|
|
4751
|
+
hooks = _load()
|
|
4752
|
+
return _with_next_steps("webhook_manage", {
|
|
4753
|
+
"webhooks": [{"url": h["url"][:50] + "...", "events": h.get("events", ["all"])} for h in hooks],
|
|
4754
|
+
"count": len(hooks),
|
|
4755
|
+
})
|
|
4756
|
+
|
|
4757
|
+
if action == "add":
|
|
4758
|
+
if not url:
|
|
4759
|
+
return _with_next_steps("webhook_manage", {"error": "URL required. Provide a Slack or Discord webhook URL."})
|
|
4760
|
+
hooks = _load()
|
|
4761
|
+
event_list = [e.strip() for e in events.split(",") if e.strip()]
|
|
4762
|
+
hooks.append({"url": url, "events": event_list})
|
|
4763
|
+
_save(hooks)
|
|
4764
|
+
return _with_next_steps("webhook_manage", {
|
|
4765
|
+
"status": "added",
|
|
4766
|
+
"url": url[:50] + "...",
|
|
4767
|
+
"events": event_list,
|
|
4768
|
+
"total": len(hooks),
|
|
4769
|
+
})
|
|
4770
|
+
|
|
4771
|
+
if action == "remove":
|
|
4772
|
+
if not url:
|
|
4773
|
+
return _with_next_steps("webhook_manage", {"error": "URL required to remove."})
|
|
4774
|
+
hooks = _load()
|
|
4775
|
+
before = len(hooks)
|
|
4776
|
+
hooks = [h for h in hooks if h.get("url") != url]
|
|
4777
|
+
_save(hooks)
|
|
4778
|
+
return _with_next_steps("webhook_manage", {
|
|
4779
|
+
"status": "removed" if len(hooks) < before else "not_found",
|
|
4780
|
+
"remaining": len(hooks),
|
|
4781
|
+
})
|
|
4782
|
+
|
|
4783
|
+
if action == "test":
|
|
4784
|
+
hooks = _load()
|
|
4785
|
+
if not hooks:
|
|
4786
|
+
return _with_next_steps("webhook_manage", {"error": "No webhooks configured. Add one first."})
|
|
4787
|
+
test_event = {
|
|
4788
|
+
"ts": datetime.now(timezone.utc).isoformat(),
|
|
4789
|
+
"type": "test",
|
|
4790
|
+
"tool": "webhook_test",
|
|
4791
|
+
"status": "blocked",
|
|
4792
|
+
"risk_level": "critical",
|
|
4793
|
+
"venture": "test",
|
|
4794
|
+
}
|
|
4795
|
+
_fire_webhook(test_event)
|
|
4796
|
+
return _with_next_steps("webhook_manage", {
|
|
4797
|
+
"status": "test_sent",
|
|
4798
|
+
"webhooks_count": len(hooks),
|
|
4799
|
+
"message": "Test notification sent to all configured webhooks.",
|
|
4800
|
+
})
|
|
4801
|
+
|
|
4802
|
+
return _with_next_steps("webhook_manage", {"error": f"Unknown action '{action}'. Use: list, add, remove, test."})
|
|
4803
|
+
|
|
4804
|
+
|
|
4805
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4806
|
+
# SOCIAL MEDIA — Authentic engagement at scale (Pro)
|
|
4807
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4808
|
+
|
|
4809
|
+
|
|
4810
|
+
@_ops_pack_tool()
|
|
4811
|
+
def delimit_social_post(text: str = "", category: str = "", platform: str = "twitter",
|
|
4812
|
+
account: str = "", quote_tweet_id: str = "",
|
|
4813
|
+
reply_to_id: str = "", draft: bool = False) -> Dict[str, Any]:
|
|
4814
|
+
"""Post to social media (Pro).
|
|
4815
|
+
|
|
4816
|
+
Categories: tip, changelog, insight, engagement.
|
|
4817
|
+
Leave text empty to auto-generate from templates.
|
|
4818
|
+
Every post provides value — tips, insights, governance wisdom.
|
|
4819
|
+
Max 2 posts per day to stay authentic.
|
|
4820
|
+
|
|
4821
|
+
Args:
|
|
4822
|
+
text: Tweet text. Leave empty to auto-generate.
|
|
4823
|
+
category: Content category for auto-generation.
|
|
4824
|
+
platform: Social platform (twitter).
|
|
4825
|
+
account: Twitter handle (without @) to post from. Empty = default account.
|
|
4826
|
+
quote_tweet_id: Tweet ID to quote (creates a quote tweet).
|
|
4827
|
+
reply_to_id: Tweet ID to reply to (creates a reply).
|
|
4828
|
+
draft: If True, save as draft for approval instead of posting immediately.
|
|
4829
|
+
"""
|
|
4830
|
+
from ai.social import generate_post, post_tweet, should_post_today, save_draft
|
|
4831
|
+
|
|
4832
|
+
if not draft and not should_post_today():
|
|
4833
|
+
return {"status": "skipped", "reason": "Already posted twice today. Authenticity > volume."}
|
|
4834
|
+
|
|
4835
|
+
post = generate_post(category, text)
|
|
4836
|
+
|
|
4837
|
+
if platform == "twitter":
|
|
4838
|
+
if draft:
|
|
4839
|
+
entry = save_draft(
|
|
4840
|
+
post["text"], platform=platform, account=account,
|
|
4841
|
+
quote_tweet_id=quote_tweet_id, reply_to_id=reply_to_id,
|
|
4842
|
+
)
|
|
4843
|
+
# Send draft notification via email
|
|
4844
|
+
try:
|
|
4845
|
+
from ai.notify import send_email
|
|
4846
|
+
send_email(
|
|
4847
|
+
message=f"Social draft pending approval:\n\n{post['text']}\n\nDraft ID: {entry['draft_id']}\nPlatform: {platform}\nAccount: {account or 'default'}",
|
|
4848
|
+
subject=f"Social Draft: {entry['draft_id']}",
|
|
4849
|
+
event_type="social_draft",
|
|
4850
|
+
)
|
|
4851
|
+
except Exception as e:
|
|
4852
|
+
logger.warning("Failed to send draft notification email: %s", e)
|
|
4853
|
+
entry["category"] = post["category"]
|
|
4854
|
+
entry["mode"] = "draft"
|
|
4855
|
+
return _with_next_steps("social_post", entry)
|
|
4856
|
+
|
|
4857
|
+
result = post_tweet(post["text"], account=account,
|
|
4858
|
+
quote_tweet_id=quote_tweet_id, reply_to_id=reply_to_id)
|
|
4859
|
+
result["category"] = post["category"]
|
|
4860
|
+
return _with_next_steps("social_post", result)
|
|
4861
|
+
|
|
4862
|
+
return {"error": f"Platform '{platform}' not supported yet", "supported": ["twitter"]}
|
|
4863
|
+
|
|
4864
|
+
|
|
4865
|
+
@_ops_pack_tool()
|
|
4866
|
+
def delimit_social_generate(category: str = "tip") -> Dict[str, Any]:
|
|
4867
|
+
"""Generate a social media post without posting (Pro).
|
|
4868
|
+
|
|
4869
|
+
Categories: tip, changelog, insight, engagement.
|
|
4870
|
+
Returns the generated text for review before posting.
|
|
4871
|
+
"""
|
|
4872
|
+
from ai.social import generate_post
|
|
4873
|
+
|
|
4874
|
+
post = generate_post(category)
|
|
4875
|
+
return _with_next_steps("social_generate", post)
|
|
4876
|
+
|
|
4877
|
+
|
|
4878
|
+
@_internal_tool()
|
|
4879
|
+
def delimit_social_accounts() -> Dict[str, Any]:
|
|
4880
|
+
"""List configured social media accounts (Pro).
|
|
4881
|
+
|
|
4882
|
+
Shows all Twitter accounts with credentials configured.
|
|
4883
|
+
Each account has its own credentials file at ~/.delimit/secrets/twitter-<handle>.json.
|
|
4884
|
+
"""
|
|
4885
|
+
from ai.social import list_twitter_accounts
|
|
4886
|
+
|
|
4887
|
+
accounts = list_twitter_accounts()
|
|
4888
|
+
return _with_next_steps("social_accounts", {"accounts": accounts, "count": len(accounts)})
|
|
4889
|
+
|
|
4890
|
+
|
|
4891
|
+
@_internal_tool()
|
|
4892
|
+
def delimit_social_history(limit: int = 20) -> Dict[str, Any]:
|
|
4893
|
+
"""View recent social media post history (Pro)."""
|
|
4894
|
+
from ai.social import get_post_history
|
|
4895
|
+
|
|
4896
|
+
return _with_next_steps("social_history", {"posts": get_post_history(limit)})
|
|
4897
|
+
|
|
4898
|
+
|
|
4899
|
+
@_ops_pack_tool()
|
|
4900
|
+
def delimit_social_approve(action: str = "list", draft_id: str = "") -> Dict[str, Any]:
|
|
4901
|
+
"""Manage social media drafts: list, approve, or reject (Pro).
|
|
4902
|
+
|
|
4903
|
+
Use with delimit_social_post(draft=True) to queue content for review.
|
|
4904
|
+
|
|
4905
|
+
Args:
|
|
4906
|
+
action: 'list' to show pending drafts, 'approve' to post a draft,
|
|
4907
|
+
'reject' to discard a draft.
|
|
4908
|
+
draft_id: Required for approve/reject actions. The draft ID from save_draft().
|
|
4909
|
+
"""
|
|
4910
|
+
from ai.social import list_drafts, approve_draft, reject_draft
|
|
4911
|
+
|
|
4912
|
+
if action == "list":
|
|
4913
|
+
pending = list_drafts("pending")
|
|
4914
|
+
return _with_next_steps("social_approve", {
|
|
4915
|
+
"drafts": pending,
|
|
4916
|
+
"count": len(pending),
|
|
4917
|
+
})
|
|
4918
|
+
elif action == "approve":
|
|
4919
|
+
if not draft_id:
|
|
4920
|
+
return {"error": "draft_id is required for approve action"}
|
|
4921
|
+
result = approve_draft(draft_id)
|
|
4922
|
+
return _with_next_steps("social_approve", result)
|
|
4923
|
+
elif action == "reject":
|
|
4924
|
+
if not draft_id:
|
|
4925
|
+
return {"error": "draft_id is required for reject action"}
|
|
4926
|
+
result = reject_draft(draft_id)
|
|
4927
|
+
return _with_next_steps("social_approve", result)
|
|
4928
|
+
else:
|
|
4929
|
+
return {"error": f"Unknown action: {action}. Supported: list, approve, reject"}
|
|
4930
|
+
|
|
4931
|
+
|
|
4932
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4933
|
+
# CONTENT ENGINE — Autonomous video + tweet pipeline (Pro)
|
|
4934
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4935
|
+
|
|
4936
|
+
|
|
4937
|
+
@_internal_tool()
|
|
4938
|
+
def delimit_content_schedule() -> Dict[str, Any]:
|
|
4939
|
+
"""View the upcoming content schedule: queued tweets, pending videos, recent activity (Pro)."""
|
|
4940
|
+
from ai.content_engine import get_content_schedule
|
|
4941
|
+
|
|
4942
|
+
return _with_next_steps("content_schedule", get_content_schedule())
|
|
4943
|
+
|
|
4944
|
+
|
|
4945
|
+
@_internal_tool()
|
|
4946
|
+
def delimit_content_publish(content_type: str = "tweet") -> Dict[str, Any]:
|
|
4947
|
+
"""Manually trigger a content publish: tweet or youtube video (Pro).
|
|
4948
|
+
|
|
4949
|
+
Args:
|
|
4950
|
+
content_type: 'tweet' to post next queued tweet, 'youtube' to generate+upload next video.
|
|
4951
|
+
"""
|
|
4952
|
+
if content_type == "tweet":
|
|
4953
|
+
from ai.content_engine import post_next_tweet
|
|
4954
|
+
return _with_next_steps("content_publish", post_next_tweet())
|
|
4955
|
+
elif content_type == "youtube":
|
|
4956
|
+
from ai.content_engine import populate_video_queue, process_next_video
|
|
4957
|
+
populate_video_queue()
|
|
4958
|
+
return _with_next_steps("content_publish", process_next_video())
|
|
4959
|
+
else:
|
|
4960
|
+
return {"error": f"Unknown content_type: {content_type}", "supported": ["tweet", "youtube"]}
|
|
4961
|
+
|
|
4962
|
+
|
|
4963
|
+
@_internal_tool()
|
|
4964
|
+
def delimit_content_queue(action: str = "status", items: str = "") -> Dict[str, Any]:
|
|
4965
|
+
"""Manage the tweet and video content queues (Pro).
|
|
4966
|
+
|
|
4967
|
+
Args:
|
|
4968
|
+
action: 'status' to view queue, 'seed' to populate with defaults, 'add' to add custom tweets.
|
|
4969
|
+
items: For 'add' action -- newline-separated tweet texts to add to the queue.
|
|
4970
|
+
"""
|
|
4971
|
+
if action == "status":
|
|
4972
|
+
from ai.content_engine import get_tweet_queue_status, get_content_schedule
|
|
4973
|
+
return _with_next_steps("content_queue", {
|
|
4974
|
+
"tweets": get_tweet_queue_status(),
|
|
4975
|
+
"schedule": get_content_schedule(),
|
|
4976
|
+
})
|
|
4977
|
+
elif action == "seed":
|
|
4978
|
+
from ai.content_engine import seed_tweet_queue, populate_video_queue
|
|
4979
|
+
return _with_next_steps("content_queue", {
|
|
4980
|
+
"tweets": seed_tweet_queue(),
|
|
4981
|
+
"videos": populate_video_queue(),
|
|
4982
|
+
})
|
|
4983
|
+
elif action == "add":
|
|
4984
|
+
from ai.content_engine import add_tweets_to_queue
|
|
4985
|
+
tweet_list = [t.strip() for t in items.split("\n") if t.strip()]
|
|
4986
|
+
if not tweet_list:
|
|
4987
|
+
return {"error": "Provide tweet texts in 'items' parameter, separated by newlines"}
|
|
4988
|
+
return _with_next_steps("content_queue", add_tweets_to_queue(tweet_list))
|
|
4989
|
+
else:
|
|
4990
|
+
return {"error": f"Unknown action: {action}", "supported": ["status", "seed", "add"]}
|
|
4991
|
+
|
|
4992
|
+
|
|
4993
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4994
|
+
# AUTONOMOUS DAEMON
|
|
4995
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
4996
|
+
|
|
4997
|
+
|
|
4998
|
+
@mcp.tool()
|
|
4999
|
+
def delimit_daemon_status() -> Dict[str, Any]:
|
|
5000
|
+
"""Check autonomous daemon status — loops, items processed, recent actions."""
|
|
5001
|
+
from ai.daemon import get_daemon_status
|
|
5002
|
+
return _with_next_steps("daemon_status", get_daemon_status())
|
|
5003
|
+
|
|
5004
|
+
|
|
5005
|
+
@mcp.tool()
|
|
5006
|
+
def delimit_daemon_run(iterations: int = 1, dry_run: bool = True) -> Dict[str, Any]:
|
|
5007
|
+
"""Run the autonomous daemon for N iterations (Pro).
|
|
5008
|
+
|
|
5009
|
+
Args:
|
|
5010
|
+
iterations: Number of loop iterations (0 = infinite, default 1)
|
|
5011
|
+
dry_run: If true, log actions but don't execute (default true)
|
|
5012
|
+
"""
|
|
5013
|
+
from ai.daemon import run_loop
|
|
5014
|
+
return _with_next_steps("daemon_run", run_loop(
|
|
5015
|
+
max_iterations=iterations, interval_seconds=5, dry_run=dry_run,
|
|
5016
|
+
))
|
|
5017
|
+
|
|
5018
|
+
|
|
5019
|
+
@mcp.tool()
|
|
5020
|
+
def delimit_daemon_classify(item_id: str = "") -> Dict[str, Any]:
|
|
5021
|
+
"""Classify a ledger item's risk tier and suggested automation tool.
|
|
5022
|
+
|
|
5023
|
+
Args:
|
|
5024
|
+
item_id: Specific ledger item ID to classify. If empty, classifies the next automatable item.
|
|
5025
|
+
"""
|
|
5026
|
+
from ai.daemon import classify_item, get_next_automatable_item, get_open_ledger_items
|
|
5027
|
+
|
|
5028
|
+
if item_id:
|
|
5029
|
+
# Find specific item in open ledger items
|
|
5030
|
+
for item in get_open_ledger_items():
|
|
5031
|
+
if item.get("id") == item_id:
|
|
5032
|
+
risk, tool = classify_item(item)
|
|
5033
|
+
return _with_next_steps("daemon_classify", {
|
|
5034
|
+
"item_id": item_id,
|
|
5035
|
+
"risk": risk,
|
|
5036
|
+
"tool": tool,
|
|
5037
|
+
"title": item.get("title", ""),
|
|
5038
|
+
})
|
|
5039
|
+
return {"error": f"Item {item_id} not found"}
|
|
5040
|
+
else:
|
|
5041
|
+
item = get_next_automatable_item()
|
|
5042
|
+
if item:
|
|
5043
|
+
return _with_next_steps("daemon_classify", {
|
|
5044
|
+
"item_id": item.get("id"),
|
|
5045
|
+
"risk": item.get("_risk"),
|
|
5046
|
+
"tool": item.get("_suggested_tool"),
|
|
5047
|
+
"title": item.get("title", ""),
|
|
5048
|
+
})
|
|
5049
|
+
return {"status": "no_automatable_items"}
|
|
5050
|
+
|
|
5051
|
+
|
|
5052
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5053
|
+
# CONSENSUS 116: Inbox Polling Daemon
|
|
5054
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5055
|
+
|
|
5056
|
+
|
|
5057
|
+
@_internal_tool()
|
|
5058
|
+
def delimit_inbox_daemon(action: str = "status") -> Dict[str, Any]:
|
|
5059
|
+
"""Control the inbox polling daemon for email governance (Pro).
|
|
5060
|
+
|
|
5061
|
+
Polls pro@delimit.ai every 5 minutes, classifies emails, forwards
|
|
5062
|
+
owner-action items, and handles draft approval via email replies
|
|
5063
|
+
with a 10-minute cancel window.
|
|
5064
|
+
|
|
5065
|
+
Args:
|
|
5066
|
+
action: 'start' (begin polling), 'stop' (halt polling),
|
|
5067
|
+
'status' (show daemon state, last poll, failures).
|
|
5068
|
+
"""
|
|
5069
|
+
from ai.inbox_daemon import start_daemon, stop_daemon, get_daemon_status
|
|
5070
|
+
|
|
5071
|
+
if action == "start":
|
|
5072
|
+
return _with_next_steps("inbox_daemon", start_daemon())
|
|
5073
|
+
elif action == "stop":
|
|
5074
|
+
return _with_next_steps("inbox_daemon", stop_daemon())
|
|
5075
|
+
else:
|
|
5076
|
+
return _with_next_steps("inbox_daemon", get_daemon_status())
|
|
5077
|
+
|
|
5078
|
+
|
|
5079
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5080
|
+
# LED-187: Shareable Governance Config — export / import
|
|
5081
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5082
|
+
|
|
5083
|
+
|
|
5084
|
+
@mcp.tool()
|
|
5085
|
+
def delimit_config_export(project_path: str = ".") -> Dict[str, Any]:
|
|
5086
|
+
"""Export the current governance config as a shareable JSON bundle.
|
|
5087
|
+
|
|
5088
|
+
Reads delimit.yml (or .delimit/policies.yml) and the GitHub Action
|
|
5089
|
+
workflow from the project directory and packages them into a single
|
|
5090
|
+
portable config that can be shared with teammates or imported into
|
|
5091
|
+
other projects.
|
|
5092
|
+
|
|
5093
|
+
Args:
|
|
5094
|
+
project_path: Path to the project root (default: current directory).
|
|
5095
|
+
"""
|
|
5096
|
+
span = _next_span_id()
|
|
5097
|
+
try:
|
|
5098
|
+
root = _sanitize_path(project_path, "project_path")
|
|
5099
|
+
|
|
5100
|
+
bundle: Dict[str, Any] = {
|
|
5101
|
+
"delimit_config_version": 1,
|
|
5102
|
+
"created_at": datetime.now(timezone.utc).isoformat(),
|
|
5103
|
+
"project": root.name,
|
|
5104
|
+
"policies": None,
|
|
5105
|
+
"workflow": None,
|
|
5106
|
+
}
|
|
5107
|
+
|
|
5108
|
+
# Find policy file
|
|
5109
|
+
candidates = [
|
|
5110
|
+
root / "delimit.yml",
|
|
5111
|
+
root / ".delimit.yml",
|
|
5112
|
+
root / ".delimit" / "policies.yml",
|
|
5113
|
+
]
|
|
5114
|
+
for p in candidates:
|
|
5115
|
+
if p.is_file():
|
|
5116
|
+
bundle["policies"] = {
|
|
5117
|
+
"path": str(p.relative_to(root)),
|
|
5118
|
+
"content": p.read_text(encoding="utf-8"),
|
|
5119
|
+
}
|
|
5120
|
+
break
|
|
5121
|
+
|
|
5122
|
+
if not bundle["policies"]:
|
|
5123
|
+
return _with_next_steps("config_export", {
|
|
5124
|
+
"error": "no_config",
|
|
5125
|
+
"message": f"No governance config found in {root}. Run delimit_init first.",
|
|
5126
|
+
"span_id": span,
|
|
5127
|
+
})
|
|
5128
|
+
|
|
5129
|
+
# GitHub Action workflow
|
|
5130
|
+
wf = root / ".github" / "workflows" / "api-governance.yml"
|
|
5131
|
+
if wf.is_file():
|
|
5132
|
+
bundle["workflow"] = {
|
|
5133
|
+
"path": ".github/workflows/api-governance.yml",
|
|
5134
|
+
"content": wf.read_text(encoding="utf-8"),
|
|
5135
|
+
}
|
|
5136
|
+
|
|
5137
|
+
import base64
|
|
5138
|
+
encoded = base64.b64encode(json.dumps(bundle).encode()).decode()
|
|
5139
|
+
share_url = f"https://delimit.ai/import?config={encoded}"
|
|
5140
|
+
|
|
5141
|
+
return _with_next_steps("config_export", {
|
|
5142
|
+
"config": bundle,
|
|
5143
|
+
"share_url": share_url,
|
|
5144
|
+
"span_id": span,
|
|
5145
|
+
})
|
|
5146
|
+
except Exception as e:
|
|
5147
|
+
logger.error("config_export error: %s\n%s", e, traceback.format_exc())
|
|
5148
|
+
return {"error": "config_export_failed", "message": str(e), "span_id": span}
|
|
5149
|
+
|
|
5150
|
+
|
|
5151
|
+
@mcp.tool()
|
|
5152
|
+
def delimit_config_import(
|
|
5153
|
+
config_json: str,
|
|
5154
|
+
project_path: str = ".",
|
|
5155
|
+
write_workflow: bool = False,
|
|
5156
|
+
) -> Dict[str, Any]:
|
|
5157
|
+
"""Import a governance config from a JSON bundle into a project.
|
|
5158
|
+
|
|
5159
|
+
Writes the policy file (and optionally the GitHub Action workflow)
|
|
5160
|
+
from a previously exported config bundle.
|
|
5161
|
+
|
|
5162
|
+
Args:
|
|
5163
|
+
config_json: The JSON config bundle string (from delimit_config_export).
|
|
5164
|
+
project_path: Path to the target project root (default: current directory).
|
|
5165
|
+
write_workflow: Also write the GitHub Action workflow file if present in the bundle.
|
|
5166
|
+
"""
|
|
5167
|
+
span = _next_span_id()
|
|
5168
|
+
try:
|
|
5169
|
+
root = _sanitize_path(project_path, "project_path")
|
|
5170
|
+
bundle = json.loads(config_json)
|
|
5171
|
+
|
|
5172
|
+
if not isinstance(bundle, dict) or not bundle.get("policies"):
|
|
5173
|
+
return {"error": "invalid_bundle", "message": "Config bundle missing policies section.", "span_id": span}
|
|
5174
|
+
|
|
5175
|
+
policies = bundle["policies"]
|
|
5176
|
+
policy_path = root / (policies.get("path") or "delimit.yml")
|
|
5177
|
+
policy_path.parent.mkdir(parents=True, exist_ok=True)
|
|
5178
|
+
|
|
5179
|
+
written = []
|
|
5180
|
+
policy_path.write_text(policies["content"], encoding="utf-8")
|
|
5181
|
+
written.append(str(policy_path))
|
|
5182
|
+
|
|
5183
|
+
if write_workflow and bundle.get("workflow"):
|
|
5184
|
+
wf = bundle["workflow"]
|
|
5185
|
+
wf_path = root / (wf.get("path") or ".github/workflows/api-governance.yml")
|
|
5186
|
+
wf_path.parent.mkdir(parents=True, exist_ok=True)
|
|
5187
|
+
wf_path.write_text(wf["content"], encoding="utf-8")
|
|
5188
|
+
written.append(str(wf_path))
|
|
5189
|
+
|
|
5190
|
+
return _with_next_steps("config_import", {
|
|
5191
|
+
"imported_from": bundle.get("project", "unknown"),
|
|
5192
|
+
"files_written": written,
|
|
5193
|
+
"span_id": span,
|
|
5194
|
+
})
|
|
5195
|
+
except json.JSONDecodeError as e:
|
|
5196
|
+
return {"error": "invalid_json", "message": f"Could not parse config JSON: {e}", "span_id": span}
|
|
5197
|
+
except Exception as e:
|
|
5198
|
+
logger.error("config_import error: %s\n%s", e, traceback.format_exc())
|
|
5199
|
+
return {"error": "config_import_failed", "message": str(e), "span_id": span}
|
|
5200
|
+
|
|
5201
|
+
|
|
5202
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5203
|
+
# SCREEN RECORDING (LED-203)
|
|
5204
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5205
|
+
|
|
5206
|
+
|
|
5207
|
+
@_ops_pack_tool()
|
|
5208
|
+
def delimit_screen_record(mode: str = "browser", url: str = "", name: str = "recording",
|
|
5209
|
+
duration: int = 30, script: str = "") -> Dict[str, Any]:
|
|
5210
|
+
"""Record a screen capture (Pro).
|
|
5211
|
+
|
|
5212
|
+
Two modes:
|
|
5213
|
+
- browser: Records a Chromium browser session visiting a URL (1080p MP4)
|
|
5214
|
+
- terminal: Records a terminal session running a script (GIF + MP4)
|
|
5215
|
+
|
|
5216
|
+
Args:
|
|
5217
|
+
mode: "browser" or "terminal"
|
|
5218
|
+
url: URL to visit (browser mode only)
|
|
5219
|
+
name: Output filename (without extension)
|
|
5220
|
+
duration: Recording duration in seconds (max 120)
|
|
5221
|
+
script: Shell script to run (terminal mode only). If empty, records idle terminal.
|
|
5222
|
+
"""
|
|
5223
|
+
span = _next_span_id()
|
|
5224
|
+
from ai.license import require_premium
|
|
5225
|
+
gate = require_premium("screen_record")
|
|
5226
|
+
if gate:
|
|
5227
|
+
gate["span_id"] = span
|
|
5228
|
+
return gate
|
|
5229
|
+
|
|
5230
|
+
# Validate mode
|
|
5231
|
+
if mode not in ("browser", "terminal"):
|
|
5232
|
+
return {
|
|
5233
|
+
"error": "invalid_mode",
|
|
5234
|
+
"message": f"Mode must be 'browser' or 'terminal', got '{mode}'",
|
|
5235
|
+
"span_id": span,
|
|
5236
|
+
}
|
|
5237
|
+
|
|
5238
|
+
# Cap duration
|
|
5239
|
+
duration = min(max(1, duration), 120)
|
|
5240
|
+
|
|
5241
|
+
from ai.screen_record import record_browser, record_terminal
|
|
5242
|
+
if mode == "browser":
|
|
5243
|
+
result = record_browser(url=url, name=name, duration=duration)
|
|
5244
|
+
else:
|
|
5245
|
+
result = record_terminal(name=name, duration=duration, script=script)
|
|
5246
|
+
|
|
5247
|
+
result["span_id"] = span
|
|
5248
|
+
return _with_next_steps("screen_record", result)
|
|
5249
|
+
|
|
5250
|
+
|
|
5251
|
+
@_ops_pack_tool()
|
|
5252
|
+
def delimit_screenshot(url: str, name: str = "screenshot") -> Dict[str, Any]:
|
|
5253
|
+
"""Take a screenshot of a URL using headless Chromium (Pro).
|
|
5254
|
+
|
|
5255
|
+
Useful for audit evidence, visual regression, and documentation.
|
|
5256
|
+
|
|
5257
|
+
Args:
|
|
5258
|
+
url: URL to screenshot.
|
|
5259
|
+
name: Output filename (without extension).
|
|
5260
|
+
"""
|
|
5261
|
+
span = _next_span_id()
|
|
5262
|
+
from ai.license import require_premium
|
|
5263
|
+
gate = require_premium("screenshot")
|
|
5264
|
+
if gate:
|
|
5265
|
+
gate["span_id"] = span
|
|
5266
|
+
return gate
|
|
5267
|
+
|
|
5268
|
+
from ai.screen_record import take_screenshot
|
|
5269
|
+
result = take_screenshot(url=url, name=name)
|
|
5270
|
+
result["span_id"] = span
|
|
5271
|
+
return _with_next_steps("screen_record", result)
|
|
5272
|
+
|
|
5273
|
+
|
|
5274
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5275
|
+
# CONSENSUS 082: Changelog + Notify tools
|
|
5276
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5277
|
+
|
|
5278
|
+
|
|
5279
|
+
@mcp.tool()
|
|
5280
|
+
def delimit_changelog(old_spec: str = "", new_spec: str = "", format: str = "markdown",
|
|
5281
|
+
version: str = "") -> Dict[str, Any]:
|
|
5282
|
+
"""Generate a changelog from API spec changes.
|
|
5283
|
+
|
|
5284
|
+
Compares two OpenAPI specs and produces a human-readable changelog.
|
|
5285
|
+
Formats: markdown, json, keepachangelog, github-release.
|
|
5286
|
+
|
|
5287
|
+
Args:
|
|
5288
|
+
old_spec: Path to old OpenAPI spec (or content).
|
|
5289
|
+
new_spec: Path to new OpenAPI spec (or content).
|
|
5290
|
+
format: Output format (markdown, json, keepachangelog, github-release).
|
|
5291
|
+
version: Version label for the changelog entry.
|
|
5292
|
+
"""
|
|
5293
|
+
if not old_spec or not new_spec:
|
|
5294
|
+
return _with_next_steps("changelog", {
|
|
5295
|
+
"error": "Both old_spec and new_spec are required.",
|
|
5296
|
+
"usage": "Provide paths to two OpenAPI spec files to generate a changelog.",
|
|
5297
|
+
})
|
|
5298
|
+
from backends.gateway_core import run_changelog
|
|
5299
|
+
return _with_next_steps("changelog", _safe_call(
|
|
5300
|
+
run_changelog, old_spec=old_spec, new_spec=new_spec, fmt=format, version=version
|
|
5301
|
+
))
|
|
5302
|
+
|
|
5303
|
+
|
|
5304
|
+
@_ops_pack_tool()
|
|
5305
|
+
def delimit_notify(channel: str = "webhook", message: str = "",
|
|
5306
|
+
webhook_url: str = "", subject: str = "",
|
|
5307
|
+
event_type: str = "", to: str = "",
|
|
5308
|
+
from_account: str = "") -> Dict[str, Any]:
|
|
5309
|
+
"""Send a notification (Pro).
|
|
5310
|
+
|
|
5311
|
+
Channels: webhook (JSON POST), slack (webhook URL), email (SMTP).
|
|
5312
|
+
Use for: governance alerts, deployment notifications, breaking change warnings.
|
|
5313
|
+
|
|
5314
|
+
Args:
|
|
5315
|
+
channel: webhook, slack, or email.
|
|
5316
|
+
message: Notification body.
|
|
5317
|
+
webhook_url: URL for webhook/slack channels.
|
|
5318
|
+
subject: Subject line (email only).
|
|
5319
|
+
event_type: Event category for filtering.
|
|
5320
|
+
to: Recipient email address (email only). Overrides default DELIMIT_SMTP_TO.
|
|
5321
|
+
Send to any address — leave empty for default (jamsonsholdings@gmail.com).
|
|
5322
|
+
from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
|
|
5323
|
+
(e.g. 'pro@delimit.ai', 'admin@wire.report'). Email only.
|
|
5324
|
+
"""
|
|
5325
|
+
from ai.notify import send_notification
|
|
5326
|
+
return _with_next_steps("notify", _safe_call(
|
|
5327
|
+
send_notification,
|
|
5328
|
+
channel=channel,
|
|
5329
|
+
message=message,
|
|
5330
|
+
webhook_url=webhook_url,
|
|
5331
|
+
subject=subject,
|
|
5332
|
+
event_type=event_type,
|
|
5333
|
+
to=to,
|
|
5334
|
+
from_account=from_account,
|
|
5335
|
+
))
|
|
5336
|
+
|
|
5337
|
+
|
|
5338
|
+
@_internal_tool()
|
|
5339
|
+
def delimit_notify_inbox(action: str = "status", limit: int = 10,
|
|
5340
|
+
process: bool = False) -> Dict[str, Any]:
|
|
5341
|
+
"""Check inbound email inbox, classify, and route (Pro).
|
|
5342
|
+
|
|
5343
|
+
Polls pro@delimit.ai via IMAP. Classifies emails as owner-action
|
|
5344
|
+
(forwards to jamsonsholdings@gmail.com) or non-owner (stays in inbox).
|
|
5345
|
+
|
|
5346
|
+
Args:
|
|
5347
|
+
action: 'status' (show inbox state), 'poll' (classify and optionally forward),
|
|
5348
|
+
'history' (show routing log).
|
|
5349
|
+
limit: Number of messages to check (default 10).
|
|
5350
|
+
process: If True with action='poll', actually forward owner-action emails.
|
|
5351
|
+
If False, dry-run only (classify without forwarding).
|
|
5352
|
+
"""
|
|
5353
|
+
from ai.notify import poll_inbox, get_inbox_status
|
|
5354
|
+
|
|
5355
|
+
if action == "poll":
|
|
5356
|
+
return _with_next_steps("notify_inbox", _safe_call(
|
|
5357
|
+
poll_inbox, limit=limit, process=process,
|
|
5358
|
+
))
|
|
5359
|
+
elif action == "history":
|
|
5360
|
+
from ai.notify import INBOX_ROUTING_FILE
|
|
5361
|
+
import json as _json
|
|
5362
|
+
history = []
|
|
5363
|
+
try:
|
|
5364
|
+
if INBOX_ROUTING_FILE.exists():
|
|
5365
|
+
with open(INBOX_ROUTING_FILE, "r") as f:
|
|
5366
|
+
lines = f.readlines()
|
|
5367
|
+
for line in lines[-limit:]:
|
|
5368
|
+
try:
|
|
5369
|
+
history.append(_json.loads(line.strip()))
|
|
5370
|
+
except _json.JSONDecodeError:
|
|
5371
|
+
continue
|
|
5372
|
+
except OSError:
|
|
5373
|
+
pass
|
|
5374
|
+
return {"routing_history": history, "count": len(history)}
|
|
5375
|
+
else: # status
|
|
5376
|
+
return _with_next_steps("notify_inbox", _safe_call(
|
|
5377
|
+
get_inbox_status, limit=limit,
|
|
5378
|
+
))
|
|
5379
|
+
|
|
5380
|
+
|
|
5381
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5382
|
+
# TIER 5: AGENT ORCHESTRATION — Multi-agent dispatch, tracking, handoff
|
|
5383
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5384
|
+
|
|
5385
|
+
|
|
5386
|
+
# Consensus 082 Phase 2: Unified agent tool with action parameter
|
|
5387
|
+
def _delimit_agent_impl(
|
|
5388
|
+
action: str = "status",
|
|
5389
|
+
# dispatch params
|
|
5390
|
+
title: str = "",
|
|
5391
|
+
description: str = "",
|
|
5392
|
+
assignee: str = "any",
|
|
5393
|
+
priority: str = "P1",
|
|
5394
|
+
tools_needed: str = "",
|
|
5395
|
+
constraints: str = "",
|
|
5396
|
+
context: str = "",
|
|
5397
|
+
# status/complete/handoff params
|
|
5398
|
+
task_id: str = "",
|
|
5399
|
+
# complete params
|
|
5400
|
+
result: str = "",
|
|
5401
|
+
files_changed: str = "",
|
|
5402
|
+
# handoff params
|
|
5403
|
+
to_model: str = "",
|
|
5404
|
+
) -> Dict[str, Any]:
|
|
5405
|
+
"""Manage agent tasks: dispatch, status, complete, handoff (Pro).
|
|
5406
|
+
|
|
5407
|
+
Actions: dispatch, status, complete, handoff.
|
|
5408
|
+
|
|
5409
|
+
Args:
|
|
5410
|
+
action: Which agent operation to perform.
|
|
5411
|
+
title: Task title (for action='dispatch').
|
|
5412
|
+
description: Task description (for action='dispatch').
|
|
5413
|
+
assignee: Target model claude/codex/gemini/any (for action='dispatch').
|
|
5414
|
+
priority: P0/P1/P2 (for action='dispatch').
|
|
5415
|
+
tools_needed: Comma-separated tools list (for action='dispatch').
|
|
5416
|
+
constraints: Comma-separated constraints (for action='dispatch').
|
|
5417
|
+
context: Background info (for dispatch) or handoff context (for handoff).
|
|
5418
|
+
task_id: Task ID e.g. AGT-A1B2C3D4 (for status/complete/handoff).
|
|
5419
|
+
result: Summary of what was done (for action='complete').
|
|
5420
|
+
files_changed: Comma-separated files modified (for action='complete').
|
|
5421
|
+
to_model: Target model for handoff (for action='handoff').
|
|
5422
|
+
"""
|
|
5423
|
+
action = action.lower().strip()
|
|
5424
|
+
valid_actions = ("dispatch", "status", "complete", "handoff")
|
|
5425
|
+
if action not in valid_actions:
|
|
5426
|
+
return {"error": f"Unknown action '{action}'. Valid: {', '.join(valid_actions)}"}
|
|
5427
|
+
|
|
5428
|
+
if action == "dispatch":
|
|
5429
|
+
from ai.agent_dispatch import dispatch_task
|
|
5430
|
+
tools_list = _coerce_list_arg(tools_needed, "tools_needed")
|
|
5431
|
+
constraints_list = _coerce_list_arg(constraints, "constraints")
|
|
5432
|
+
return _with_next_steps("agent_dispatch", _safe_call(
|
|
5433
|
+
dispatch_task,
|
|
5434
|
+
title=title,
|
|
5435
|
+
description=description,
|
|
5436
|
+
assignee=assignee,
|
|
5437
|
+
priority=priority,
|
|
5438
|
+
tools_needed=tools_list,
|
|
5439
|
+
constraints=constraints_list,
|
|
5440
|
+
context=context,
|
|
5441
|
+
))
|
|
5442
|
+
|
|
5443
|
+
if action == "status":
|
|
5444
|
+
from ai.agent_dispatch import get_agent_status
|
|
5445
|
+
return _with_next_steps("agent_status", _safe_call(
|
|
5446
|
+
get_agent_status, task_id=task_id,
|
|
5447
|
+
))
|
|
5448
|
+
|
|
5449
|
+
if action == "complete":
|
|
5450
|
+
from ai.agent_dispatch import complete_task
|
|
5451
|
+
files_list = _coerce_list_arg(files_changed, "files_changed")
|
|
5452
|
+
return _with_next_steps("agent_complete", _safe_call(
|
|
5453
|
+
complete_task,
|
|
5454
|
+
task_id=task_id,
|
|
5455
|
+
result=result,
|
|
5456
|
+
files_changed=files_list,
|
|
5457
|
+
))
|
|
5458
|
+
|
|
5459
|
+
if action == "handoff":
|
|
5460
|
+
from ai.agent_dispatch import handoff_task
|
|
5461
|
+
return _with_next_steps("agent_handoff", _safe_call(
|
|
5462
|
+
handoff_task,
|
|
5463
|
+
task_id=task_id,
|
|
5464
|
+
to_model=to_model,
|
|
5465
|
+
context=context,
|
|
5466
|
+
))
|
|
5467
|
+
|
|
5468
|
+
return {"error": f"Unhandled action '{action}'"}
|
|
5469
|
+
|
|
5470
|
+
|
|
5471
|
+
delimit_agent = mcp.tool()(_delimit_agent_impl)
|
|
5472
|
+
|
|
5473
|
+
# --- Thin wrappers (aliases) for backward compatibility ---
|
|
5474
|
+
|
|
5475
|
+
@mcp.tool()
|
|
5476
|
+
def delimit_agent_dispatch(title: str, description: str = "", assignee: str = "any",
|
|
5477
|
+
priority: str = "P1", tools_needed: str = "",
|
|
5478
|
+
constraints: str = "", context: str = "") -> Dict[str, Any]:
|
|
5479
|
+
"""Dispatch an engineering task to an AI agent (Pro)."""
|
|
5480
|
+
return _delimit_agent_impl(action="dispatch", title=title, description=description,
|
|
5481
|
+
assignee=assignee, priority=priority, tools_needed=tools_needed,
|
|
5482
|
+
constraints=constraints, context=context)
|
|
5483
|
+
|
|
5484
|
+
|
|
5485
|
+
@mcp.tool()
|
|
5486
|
+
def delimit_agent_status(task_id: str = "") -> Dict[str, Any]:
|
|
5487
|
+
"""Check status of dispatched agent tasks (Pro)."""
|
|
5488
|
+
return _delimit_agent_impl(action="status", task_id=task_id)
|
|
5489
|
+
|
|
5490
|
+
|
|
5491
|
+
@mcp.tool()
|
|
5492
|
+
def delimit_agent_complete(task_id: str, result: str = "",
|
|
5493
|
+
files_changed: str = "") -> Dict[str, Any]:
|
|
5494
|
+
"""Mark an agent task as complete (Pro)."""
|
|
5495
|
+
return _delimit_agent_impl(action="complete", task_id=task_id, result=result, files_changed=files_changed)
|
|
5496
|
+
|
|
5497
|
+
|
|
5498
|
+
@mcp.tool()
|
|
5499
|
+
def delimit_agent_handoff(task_id: str, to_model: str,
|
|
5500
|
+
context: str = "") -> Dict[str, Any]:
|
|
5501
|
+
"""Hand off an agent task to a different AI model (Pro)."""
|
|
5502
|
+
return _delimit_agent_impl(action="handoff", task_id=task_id, to_model=to_model, context=context)
|
|
5503
|
+
|
|
5504
|
+
|
|
5505
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5506
|
+
# STR-026: AUTONOMOUS BUILD LOOP
|
|
5507
|
+
# ═══════════════════════════════════════════════════════════════════════
|
|
5508
|
+
|
|
5509
|
+
|
|
5510
|
+
@mcp.tool()
|
|
5511
|
+
def delimit_next_task(venture: str = "", max_risk: str = "", session_id: str = "") -> Dict[str, Any]:
|
|
5512
|
+
"""Get the next task to work on (Pro).
|
|
5513
|
+
|
|
5514
|
+
Returns the highest-priority open item with safeguard checks.
|
|
5515
|
+
Part of the autonomous build loop — call this to start or continue working.
|
|
5516
|
+
|
|
5517
|
+
Returns action: BUILD (with task), CONSENSUS (generate new items), or STOP (safeguard tripped).
|
|
5518
|
+
|
|
5519
|
+
Args:
|
|
5520
|
+
venture: Project name or path. Auto-detects if empty.
|
|
5521
|
+
max_risk: Filter tasks by max risk level (low, medium, high, critical).
|
|
5522
|
+
session_id: Resume an existing session. Creates a new one if empty.
|
|
5523
|
+
"""
|
|
5524
|
+
from ai.loop_engine import next_task
|
|
5525
|
+
result = _safe_call(next_task, venture=venture, max_risk=max_risk, session_id=session_id)
|
|
5526
|
+
return _with_next_steps("next_task", result)
|
|
5527
|
+
|
|
5528
|
+
|
|
5529
|
+
@mcp.tool()
|
|
5530
|
+
def delimit_task_complete(task_id: str, result: str = "", cost_incurred: float = 0.0,
|
|
5531
|
+
error: str = "", session_id: str = "", venture: str = "") -> Dict[str, Any]:
|
|
5532
|
+
"""Mark current task done and get the next one (Pro).
|
|
5533
|
+
|
|
5534
|
+
Records completion, updates session metrics, returns the next task.
|
|
5535
|
+
The loop continues until a STOP signal.
|
|
5536
|
+
|
|
5537
|
+
Args:
|
|
5538
|
+
task_id: The ledger item ID that was completed (e.g. LED-042).
|
|
5539
|
+
result: Summary of what was done.
|
|
5540
|
+
cost_incurred: Estimated cost of this iteration (dollars).
|
|
5541
|
+
error: If the task failed, describe the error.
|
|
5542
|
+
session_id: The loop session to update.
|
|
5543
|
+
venture: Project name or path.
|
|
5544
|
+
"""
|
|
5545
|
+
from ai.loop_engine import task_complete
|
|
5546
|
+
r = _safe_call(task_complete, task_id=task_id, result=result,
|
|
5547
|
+
cost_incurred=cost_incurred, error=error,
|
|
5548
|
+
session_id=session_id, venture=venture)
|
|
5549
|
+
return _with_next_steps("task_complete", r)
|
|
5550
|
+
|
|
5551
|
+
|
|
5552
|
+
@mcp.tool()
|
|
5553
|
+
def delimit_loop_status(session_id: str = "") -> Dict[str, Any]:
|
|
5554
|
+
"""Check autonomous loop metrics (Pro).
|
|
5555
|
+
|
|
5556
|
+
Shows: iterations, cost, errors, tasks completed, safeguard status.
|
|
5557
|
+
|
|
5558
|
+
Args:
|
|
5559
|
+
session_id: The session to check. Uses most recent if empty.
|
|
5560
|
+
"""
|
|
5561
|
+
from ai.loop_engine import loop_status
|
|
5562
|
+
return _with_next_steps("loop_status", _safe_call(loop_status, session_id=session_id))
|
|
5563
|
+
|
|
5564
|
+
|
|
5565
|
+
@mcp.tool()
|
|
5566
|
+
def delimit_loop_config(session_id: str = "", max_iterations: int = 0,
|
|
5567
|
+
cost_cap: float = 0.0, auto_consensus: bool = False,
|
|
5568
|
+
error_threshold: int = 0, status: str = "",
|
|
5569
|
+
require_approval_for: str = "") -> Dict[str, Any]:
|
|
5570
|
+
"""Configure the autonomous build loop safeguards (Pro).
|
|
5571
|
+
|
|
5572
|
+
Set limits before starting a loop session. Only non-zero/non-empty values are applied.
|
|
5573
|
+
|
|
5574
|
+
Args:
|
|
5575
|
+
session_id: Session to configure. Creates new if empty.
|
|
5576
|
+
max_iterations: Max tasks before stopping (default 50).
|
|
5577
|
+
cost_cap: Max cost in dollars before stopping (default 5.0).
|
|
5578
|
+
auto_consensus: If True, suggest consensus when ledger is empty.
|
|
5579
|
+
error_threshold: Consecutive errors before circuit breaker trips (default 3).
|
|
5580
|
+
status: Set loop status: running, paused, stopped.
|
|
5581
|
+
require_approval_for: Comma-separated list of action types requiring human approval.
|
|
5582
|
+
"""
|
|
5583
|
+
from ai.loop_engine import loop_config
|
|
5584
|
+
approval_list = None
|
|
5585
|
+
if require_approval_for:
|
|
5586
|
+
approval_list = [s.strip() for s in require_approval_for.split(",") if s.strip()]
|
|
5587
|
+
r = _safe_call(loop_config, session_id=session_id, max_iterations=max_iterations,
|
|
5588
|
+
cost_cap=cost_cap, auto_consensus=auto_consensus,
|
|
5589
|
+
error_threshold=error_threshold, status=status,
|
|
5590
|
+
require_approval_for=approval_list)
|
|
5591
|
+
return _with_next_steps("loop_config", r)
|
|
5592
|
+
|
|
5593
|
+
|
|
2450
5594
|
# ═══════════════════════════════════════════════════════════════════════
|
|
2451
5595
|
# ENTRY POINT
|
|
2452
5596
|
# ═══════════════════════════════════════════════════════════════════════
|