delimit-cli 3.13.3 → 3.14.0

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.
@@ -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.0"
432
+ VERSION = "3.2.1"
44
433
 
45
- # LED-044: Hide STUB and PASS-THROUGH tools from MCP unless opted in.
46
- # Set DELIMIT_SHOW_EXPERIMENTAL=1 to expose all tools (internal development).
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 _experimental_tool():
51
- """Decorator that only registers the function as an MCP tool if SHOW_EXPERIMENTAL is set.
52
- When disabled, the function still exists but is not exposed via MCP."""
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 SHOW_EXPERIMENTAL:
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. Check Pro license gate (blocks if not authorized)
273
- 2. Check result against rules (thresholds, policies)
274
- 3. Auto-create ledger items for failures/warnings
275
- 4. Route back to delimit_ledger_context (the loop continues)
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
- return _with_next_steps("lint", _safe_call(run_lint, old_spec=old_spec, new_spec=new_spec, policy_file=policy_file))
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 events.jsonl ledger file.
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 policies_file.exists() and ledger_dir.exists() and events_file.exists():
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(operation: str, target: str, parameters: Optional[Dict[str, Any]] = None, require_approval: bool = True) -> Dict[str, Any]:
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
- @mcp.tool()
555
- def delimit_gov_health(repo: str = ".") -> Dict[str, Any]:
556
- """Check governance system health.
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
- repo: Repository path to check.
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
- from backends.governance_bridge import health
562
- return _with_next_steps("gov_health", _safe_call(health, repo=repo))
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(action: str, context: Optional[Dict[str, Any]] = None, repo: str = ".") -> Dict[str, Any]:
593
- """Evaluate if governance is required for an action (requires governancegate) (Pro).
594
-
595
- Args:
596
- action: The action to evaluate.
597
- context: Additional context.
598
- repo: Repository path.
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 (requires governancegate) (Pro).
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 (requires governancegate) (Pro).
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 (requires governancegate) (Pro).
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(content: str, tags: Optional[List[str]] = None, context: Optional[str] = None) -> Dict[str, Any]:
678
- """Store a memory entry for future retrieval (Pro).
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
- from ai.license import require_premium
686
- gate = require_premium("memory_store")
687
- if gate:
688
- return gate
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 (Pro).
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
- from ai.license import require_premium
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
- @mcp.tool()
755
- def delimit_deploy_plan(app: str, env: str, git_ref: Optional[str] = None) -> Dict[str, Any]:
756
- """Plan deployment with build steps (Pro).
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
- app: Application name.
760
- env: Target environment (staging/production).
761
- git_ref: Git reference (branch, tag, or SHA).
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
- @mcp.tool()
772
- def delimit_deploy_build(app: str, git_ref: Optional[str] = None) -> Dict[str, Any]:
773
- """Build Docker images with SHA tags (Pro).
774
-
775
- Args:
776
- app: Application name.
777
- git_ref: Git reference.
778
- """
779
- from ai.license import require_premium
780
- gate = require_premium("deploy_build")
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
- @mcp.tool()
788
- def delimit_deploy_publish(app: str, git_ref: Optional[str] = None) -> Dict[str, Any]:
789
- """Publish images to registry (Pro).
1920
+ if plan_result.get("error"):
1921
+ plan_result["chain"] = chain
1922
+ return _with_next_steps("deploy_plan", plan_result)
790
1923
 
791
- Args:
792
- app: Application name.
793
- git_ref: Git reference.
794
- """
795
- from ai.license import require_premium
796
- gate = require_premium("deploy_publish")
797
- if gate:
798
- return gate
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
- Args:
808
- app: Application name.
809
- env: Target environment.
810
- git_ref: Git reference.
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
- from ai.license import require_premium
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 delimit_deploy_rollback(app: str, env: str, to_sha: Optional[str] = None) -> Dict[str, Any]:
822
- """Rollback to previous SHA (Pro).
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
- Args:
825
- app: Application name.
826
- env: Target environment.
827
- to_sha: SHA to rollback to.
828
- """
829
- from ai.license import require_premium
830
- gate = require_premium("deploy_rollback")
831
- if gate:
832
- return gate
833
- from backends.deploy_bridge import rollback
834
- return _with_next_steps("deploy_rollback", _safe_call(rollback, app=app, env=env, to_sha=to_sha))
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 delimit_deploy_status(app: str, env: str) -> Dict[str, Any]:
839
- """Get deployment status (Pro).
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
- Args:
842
- app: Application name.
843
- env: Target environment.
844
- """
845
- from ai.license import require_premium
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(name: str, schema: Optional[Dict[str, Any]] = None, description: Optional[str] = None) -> Dict[str, Any]:
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(data: Dict[str, Any], provenance: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
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(dataset_id: Optional[str] = None, query: str = "", parameters: Optional[Dict[str, Any]] = None) -> Dict[str, Any]:
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
- @mcp.tool()
914
- def delimit_generate_template(template_type: str, name: str, framework: str = "nextjs", features: Optional[List[str]] = None) -> Dict[str, Any]:
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
- @mcp.tool()
928
- def delimit_generate_scaffold(project_type: str, name: str, packages: Optional[List[str]] = None) -> Dict[str, Any]:
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
- return _with_next_steps("security_audit", _safe_call(security_audit, target=target))
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
- @mcp.tool()
1075
- def delimit_release_plan(environment: str = "production", version: str = "", repository: str = ".", services: Optional[List[str]] = None) -> Dict[str, Any]:
1076
- """ (Pro).
1077
- Generate a release plan from git history.
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
- Reads git log since last tag, counts commits and changed files,
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
- environment: Target environment (staging/production).
1085
- version: Release version (auto-detected if empty).
1086
- repository: Repository path (default: current directory).
1087
- services: Optional service list.
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
- from ai.license import require_premium
1090
- gate = require_premium("release_plan")
1091
- if gate:
1092
- return gate
1093
- from backends.tools_infra import release_plan
1094
- return _with_next_steps("release_plan", _safe_call(release_plan, environment=environment, version=version, repository=repository, services=services))
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
- Args:
1102
- environment: Target environment.
1103
- version: Release version.
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
- return _safe_call(release_validate, environment=environment, version=version)
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
- @mcp.tool()
1110
- def delimit_release_status(environment: str = "production") -> Dict[str, Any]:
1111
- """ (Pro).
1112
- Check release/deploy status from file-based tracker and git state.
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
- Shows latest deploy plan, current git tag, how many commits HEAD
1115
- is ahead of the tag, and recent deploy history.
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
- Args:
1118
- environment: Target environment (staging/production).
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
- from ai.license import require_premium
1121
- gate = require_premium("release_status")
1122
- if gate:
1123
- return gate
1124
- from backends.tools_infra import release_status
1125
- return _with_next_steps("release_status", _safe_call(release_status, environment=environment))
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
- # ─── DataSteward (Governance Primitive) ────────────────────────────────
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
- @mcp.tool()
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
- @mcp.tool()
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
- @mcp.tool()
1245
- def delimit_obs_metrics(query: str = "system", time_range: str = "1h", source: Optional[str] = None) -> Dict[str, Any]:
1246
- """ (Pro).
1247
- Query live system metrics (CPU, memory, disk I/O, network).
1248
-
1249
- Query types: cpu, memory, disk, io, network, system (default), all.
1250
- Reads directly from /proc for real-time data.
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
- Optional: Set PROMETHEUS_URL for remote metrics.
2963
+ Actions: metrics, logs, alerts, status.
1253
2964
 
1254
2965
  Args:
1255
- query: Metrics query type (cpu|memory|disk|io|network|system|all).
1256
- time_range: Time range (e.g. "1h", "24h", "7d").
1257
- source: Optional metrics source (prometheus, local).
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
- from ai.license import require_premium
1260
- gate = require_premium("obs_metrics")
1261
- if gate:
1262
- return gate
1263
- from backends.tools_infra import obs_metrics
1264
- return _with_next_steps("obs_metrics", _safe_call(obs_metrics, query=query, time_range=time_range, source=source))
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 delimit_obs_logs(query: str, time_range: str = "1h", source: Optional[str] = None) -> Dict[str, Any]:
1269
- """ (Pro).
1270
- Search system and application logs.
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
- Args:
1278
- query: Log search query string.
1279
- time_range: Time range (5m, 15m, 1h, 6h, 24h, 7d).
1280
- source: Log source path or integration name (journalctl, elasticsearch).
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
- System health check: disk space, memory, running services, uptime.
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
- @mcp.tool()
1323
- def delimit_design_extract_tokens(figma_file_key: Optional[str] = None, token_types: Optional[List[str]] = None, project_path: Optional[str] = None) -> Dict[str, Any]:
1324
- """Extract design tokens from project CSS/SCSS/Tailwind config (or Figma if FIGMA_TOKEN set).
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 FIGMA_TOKEN env var is set).
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
- @mcp.tool()
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
- @mcp.tool()
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
- @mcp.tool()
1363
- def delimit_design_validate_responsive(project_path: str, check_types: Optional[List[str]] = None) -> Dict[str, Any]:
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
- @mcp.tool()
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
- @mcp.tool()
1391
- def delimit_story_generate(component_path: str, story_name: Optional[str] = None, variants: Optional[List[str]] = None) -> Dict[str, Any]:
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
- @mcp.tool()
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 with Playwright and compare to baseline.
3150
+ """Run visual regression test -- screenshot and compare to baseline.
1406
3151
 
1407
- Falls back to guidance if Playwright is not installed.
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
- @_experimental_tool() # HIDDEN: requires Storybook installed (LED-044)
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 (requires Storybook installed).
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
- @mcp.tool()
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
- @mcp.tool()
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, all tiers, and tool count."""
1662
- tiers = {
1663
- "tier1_core": ["lint", "diff", "policy", "ledger", "impact", "semver", "explain", "zero_spec", "init"],
1664
- "tier2_platform": [
1665
- "os.plan", "os.status", "os.gates",
1666
- "gov.health", "gov.status", "gov.policy", "gov.evaluate", "gov.new_task", "gov.run", "gov.verify",
1667
- "memory.search", "memory.store", "memory.recent",
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-unified",
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 77 tools. Here are the most useful ones to start with:",
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" / "events.jsonl"
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
- Deploy a site — git commit, push, Vercel build, and deploy in one step.
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
- Publish an npm package bump version, publish to registry, verify.
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
- return "."
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
- return add_item(title=title, ledger=ledger, type=item_type, priority=priority,
1987
- description=description, source=source, project_path=project)
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
- return update_item(item_id=item_id, status="done", note=note, project_path=project)
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
- return list_items(ledger=ledger, status=status or None, priority=priority or None, limit=limit, project_path=project)
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
- return get_context(project_path=project)
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
- Audit or sync all public surfaces for consistency.
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
  # ═══════════════════════════════════════════════════════════════════════