delimit-cli 3.15.11 → 3.15.13

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## [3.15.13] - 2026-03-29
4
+
5
+ ### Added
6
+ - **Self-extending swarm**: Architect and Senior Dev agents can create new MCP tools at runtime
7
+ - **Tool security scan**: Block dangerous patterns (subprocess, exec, eval, socket) in custom tools
8
+ - **8 new modules**: activate_helpers, cross_model_audit, github_scanner, handoff_receipts, reddit_scanner, session_phoenix, social_target, toolcard_cache
9
+ - **Reviewer approval gate**: Custom tools require reviewer sign-off before activation
10
+
11
+ ### Changed
12
+ - Swarm actions expanded: create_tool, list_tools now available via delimit_swarm
13
+ - Inbox daemon: enhanced email classification and approval routing
14
+ - Social pipeline: improved content generation and scheduling
15
+
3
16
  ## [3.15.9] - 2026-03-30
4
17
 
5
18
  ### Added
@@ -0,0 +1,210 @@
1
+ """LED-269 / LED-270: Activation checklist helpers.
2
+
3
+ Extracted so they can be tested independently of ai.server (which has
4
+ heavy MCP decorator dependencies).
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Dict, Any
11
+
12
+
13
+ def activate_auto_permissions(auto_permissions: bool) -> dict:
14
+ """LED-269: Detect AI assistant and auto-configure Delimit tool permissions.
15
+
16
+ Returns a checklist entry dict with item/status/detail.
17
+ """
18
+ home = Path.home()
19
+ assistant = None
20
+ config_path = None
21
+
22
+ # Detect which assistant is running
23
+ if os.environ.get("CLAUDE_CODE") or (home / ".claude").is_dir():
24
+ assistant = "claude_code"
25
+ config_path = home / ".claude" / "settings.json"
26
+ elif (home / ".codex" / "config.toml").exists():
27
+ assistant = "codex"
28
+ config_path = home / ".codex" / "config.toml"
29
+ elif (home / ".gemini" / "settings.json").exists():
30
+ assistant = "gemini"
31
+ config_path = home / ".gemini" / "settings.json"
32
+ else:
33
+ if os.environ.get("CODEX_CLI"):
34
+ assistant = "codex"
35
+ config_path = home / ".codex" / "config.toml"
36
+
37
+ if not assistant:
38
+ return {"item": "Permissions", "status": "Skip (no assistant)", "detail": "No AI assistant detected"}
39
+
40
+ if not auto_permissions:
41
+ return {"item": "Permissions", "status": "Skip (manual)", "detail": f"Detected {assistant} — auto-config disabled"}
42
+
43
+ try:
44
+ if assistant == "claude_code":
45
+ return configure_claude_code_permissions(config_path)
46
+ elif assistant == "codex":
47
+ return configure_codex_permissions(config_path)
48
+ elif assistant == "gemini":
49
+ return {"item": "Permissions", "status": "Skip (manual)", "detail": "Gemini CLI — configure permissions manually"}
50
+ except Exception as e:
51
+ return {"item": "Permissions", "status": "Fail", "detail": f"Auto-config failed: {e}"}
52
+
53
+ return {"item": "Permissions", "status": "Skip (manual)", "detail": f"Detected {assistant}"}
54
+
55
+
56
+ def configure_claude_code_permissions(config_path: Path) -> dict:
57
+ """Add mcp__delimit__* to Claude Code permissions.allow if not present."""
58
+ permission_pattern = "mcp__delimit__*"
59
+
60
+ if config_path.exists():
61
+ try:
62
+ data = json.loads(config_path.read_text())
63
+ except (json.JSONDecodeError, OSError):
64
+ data = {}
65
+ else:
66
+ data = {}
67
+
68
+ permissions = data.setdefault("permissions", {})
69
+ allow_list = permissions.setdefault("allow", [])
70
+
71
+ if any(permission_pattern in str(entry) for entry in allow_list):
72
+ return {"item": "Permissions", "status": "Pass", "detail": f"Claude Code: {permission_pattern} already in settings"}
73
+
74
+ allow_list.append(permission_pattern)
75
+ config_path.parent.mkdir(parents=True, exist_ok=True)
76
+ config_path.write_text(json.dumps(data, indent=2))
77
+ return {"item": "Permissions", "status": "Pass", "detail": f"Claude Code: added {permission_pattern} to {config_path}"}
78
+
79
+
80
+ def configure_codex_permissions(config_path: Path) -> dict:
81
+ """Set trust_level to trusted for Delimit in Codex config.toml."""
82
+ if config_path.exists():
83
+ content = config_path.read_text()
84
+ if "trust_level" in content and "trusted" in content:
85
+ return {"item": "Permissions", "status": "Pass", "detail": "Codex: already trusted"}
86
+ else:
87
+ content = ""
88
+
89
+ if "[delimit]" not in content:
90
+ addition = '\n[delimit]\ntrust_level = "trusted"\n'
91
+ config_path.parent.mkdir(parents=True, exist_ok=True)
92
+ with open(config_path, "a") as f:
93
+ f.write(addition)
94
+ return {"item": "Permissions", "status": "Pass", "detail": f"Codex: added trust_level=trusted to {config_path}"}
95
+ else:
96
+ return {"item": "Permissions", "status": "Pass", "detail": "Codex: delimit section exists"}
97
+
98
+
99
+ def build_checklist(
100
+ license_key: str,
101
+ project_path: str,
102
+ auto_permissions: bool,
103
+ ) -> Dict[str, Any]:
104
+ """Build the activation checklist. Core logic extracted from delimit_activate.
105
+
106
+ Returns the result dict (without next_steps wrapping).
107
+ """
108
+ from ai.license import activate_license, get_license, is_premium, require_premium
109
+
110
+ checklist: list = []
111
+ p = Path(project_path).resolve()
112
+
113
+ # --- Step 1: License activation (if key provided) ---
114
+ if license_key:
115
+ lic_result = activate_license(license_key)
116
+ if lic_result.get("status") == "activated":
117
+ checklist.append({"item": "License activation", "status": "Pass", "detail": f"Tier: {lic_result.get('tier', 'pro')}"})
118
+ else:
119
+ checklist.append({"item": "License activation", "status": "Fail", "detail": lic_result.get("error", "Unknown error")})
120
+ else:
121
+ lic = get_license()
122
+ tier = lic.get("tier", "free")
123
+ checklist.append({"item": "License status", "status": "Pass", "detail": f"Tier: {tier}"})
124
+
125
+ # --- Step 2: MCP server reachable ---
126
+ checklist.append({"item": "MCP server", "status": "Pass", "detail": "Server responding"})
127
+
128
+ # --- Step 3: Python dependencies ---
129
+ dep_ok = True
130
+ for pkg in ["yaml", "pydantic", "packaging", "fastmcp"]:
131
+ try:
132
+ __import__(pkg)
133
+ except ImportError:
134
+ dep_ok = False
135
+ checklist.append({"item": f"Dependency: {pkg}", "status": "Fail", "detail": f"pip install {pkg}"})
136
+ if dep_ok:
137
+ checklist.append({"item": "Dependencies", "status": "Pass", "detail": "All required packages installed"})
138
+
139
+ # --- Step 4: Governance initialized ---
140
+ delimit_dir = p / ".delimit"
141
+ policies = delimit_dir / "policies.yml"
142
+ if delimit_dir.is_dir() and policies.is_file():
143
+ checklist.append({"item": "Governance", "status": "Pass", "detail": f"Initialized at {delimit_dir}"})
144
+ elif delimit_dir.is_dir():
145
+ checklist.append({"item": "Governance", "status": "Fail", "detail": "Missing policies.yml — run delimit_init"})
146
+ else:
147
+ checklist.append({"item": "Governance", "status": "Fail", "detail": "Not initialized — run delimit_init"})
148
+
149
+ # --- Step 5: Test smoke (skip if no framework) ---
150
+ try:
151
+ from backends.tools_real import test_smoke as _test_smoke_fn
152
+ smoke = _test_smoke_fn(project_path=str(p))
153
+ if smoke.get("status") == "no_framework":
154
+ checklist.append({"item": "Test smoke", "status": "Skip (no tests)", "detail": "No test framework detected"})
155
+ elif smoke.get("error"):
156
+ checklist.append({"item": "Test smoke", "status": "Fail", "detail": smoke.get("error", "")})
157
+ else:
158
+ passed_count = smoke.get("passed", 0)
159
+ failed_count = smoke.get("failed", 0)
160
+ if failed_count == 0:
161
+ checklist.append({"item": "Test smoke", "status": "Pass", "detail": f"{passed_count} tests passed"})
162
+ else:
163
+ checklist.append({"item": "Test smoke", "status": "Fail", "detail": f"{passed_count} passed, {failed_count} failed"})
164
+ except Exception as e:
165
+ checklist.append({"item": "Test smoke", "status": "Skip (no tests)", "detail": f"Could not run: {e}"})
166
+
167
+ # --- Step 6: AI assistant detection + permission auto-config (LED-269) ---
168
+ perm_result = activate_auto_permissions(auto_permissions)
169
+ checklist.append(perm_result)
170
+
171
+ # --- Step 7: Premium feature checks (skip on free tier) ---
172
+ premium_checks = [
173
+ ("Deliberation (multi-model)", "delimit_deliberate"),
174
+ ("Security audit", "delimit_security_ingest"),
175
+ ("Deploy pipeline", "delimit_deploy_plan"),
176
+ ("Cost analysis", "delimit_cost_analyze"),
177
+ ("Release management", "delimit_release_plan"),
178
+ ("Agent orchestration", "delimit_agent_dispatch"),
179
+ ]
180
+ for label, tool_name in premium_checks:
181
+ gate = require_premium(tool_name)
182
+ if gate is None:
183
+ checklist.append({"item": label, "status": "Pass", "detail": "Pro feature unlocked"})
184
+ else:
185
+ checklist.append({"item": label, "status": "Skip (Pro)", "detail": "Requires Delimit Pro"})
186
+
187
+ # --- Score: only count applicable checks (exclude skips) ---
188
+ applicable = [c for c in checklist if not c["status"].startswith("Skip")]
189
+ passed_total = sum(1 for c in applicable if c["status"] == "Pass")
190
+ total = len(applicable)
191
+ score = f"{passed_total}/{total}"
192
+
193
+ result: Dict[str, Any] = {
194
+ "tool": "activate",
195
+ "status": "complete",
196
+ "score": score,
197
+ "passed": passed_total,
198
+ "total": total,
199
+ "skipped": len(checklist) - total,
200
+ "checklist": checklist,
201
+ "tier": get_license().get("tier", "free"),
202
+ "project": str(p),
203
+ }
204
+ if passed_total == total and total > 0:
205
+ result["message"] = f"All {total} checks passed. Delimit is fully operational."
206
+ elif passed_total < total:
207
+ failed_items = [c["item"] for c in applicable if c["status"] == "Fail"]
208
+ result["message"] = f"{passed_total}/{total} checks passed. Fix: {', '.join(failed_items)}"
209
+
210
+ return result
@@ -0,0 +1,141 @@
1
+ """Duplicate work detection — prevent two AI models from editing the same file (STR-051).
2
+
3
+ Tracks which model is working on which files. Alerts before collision.
4
+ Adjacent problem nobody else solves.
5
+
6
+ Storage: ~/.delimit/agents/file_locks.json
7
+ """
8
+
9
+ import json
10
+ import time
11
+ from pathlib import Path
12
+ from typing import Any, Dict, List, Optional
13
+
14
+ AGENTS_DIR = Path.home() / ".delimit" / "agents"
15
+ LOCKS_FILE = AGENTS_DIR / "file_locks.json"
16
+
17
+ # Lock expires after 30 minutes of inactivity
18
+ LOCK_TTL_SECONDS = 1800
19
+
20
+
21
+ def _ensure_dir():
22
+ AGENTS_DIR.mkdir(parents=True, exist_ok=True)
23
+
24
+
25
+ def _load_locks() -> Dict[str, Any]:
26
+ if not LOCKS_FILE.exists():
27
+ return {}
28
+ try:
29
+ return json.loads(LOCKS_FILE.read_text())
30
+ except (json.JSONDecodeError, OSError):
31
+ return {}
32
+
33
+
34
+ def _save_locks(locks: Dict[str, Any]):
35
+ _ensure_dir()
36
+ LOCKS_FILE.write_text(json.dumps(locks, indent=2))
37
+
38
+
39
+ def _cleanup_expired(locks: Dict[str, Any]) -> Dict[str, Any]:
40
+ now = time.time()
41
+ return {
42
+ path: lock for path, lock in locks.items()
43
+ if now - lock.get("ts", 0) < LOCK_TTL_SECONDS
44
+ }
45
+
46
+
47
+ def claim_file(
48
+ file_path: str,
49
+ model: str,
50
+ task_id: str = "",
51
+ ) -> Dict[str, Any]:
52
+ """Claim a file for editing. Returns collision info if another model holds it."""
53
+ if not file_path or not model:
54
+ return {"error": "file_path and model are required"}
55
+
56
+ file_path = str(Path(file_path).resolve())
57
+ model = model.lower().strip()
58
+
59
+ locks = _cleanup_expired(_load_locks())
60
+
61
+ existing = locks.get(file_path)
62
+ if existing and existing["model"] != model:
63
+ return {
64
+ "status": "collision",
65
+ "file": file_path,
66
+ "held_by": existing["model"],
67
+ "held_since": existing.get("claimed_at", "unknown"),
68
+ "task_id": existing.get("task_id", ""),
69
+ "your_model": model,
70
+ "message": f"COLLISION: {existing['model']} is already editing {Path(file_path).name}",
71
+ "recommendation": "Coordinate with the other model or wait for them to finish.",
72
+ }
73
+
74
+ locks[file_path] = {
75
+ "model": model,
76
+ "task_id": task_id,
77
+ "ts": time.time(),
78
+ "claimed_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
79
+ }
80
+ _save_locks(locks)
81
+
82
+ return {
83
+ "status": "claimed",
84
+ "file": file_path,
85
+ "model": model,
86
+ "message": f"{model} claimed {Path(file_path).name}",
87
+ }
88
+
89
+
90
+ def release_file(file_path: str, model: str = "") -> Dict[str, Any]:
91
+ """Release a file lock."""
92
+ file_path = str(Path(file_path).resolve())
93
+ locks = _load_locks()
94
+
95
+ if file_path in locks:
96
+ if model and locks[file_path]["model"] != model.lower():
97
+ return {"error": f"File held by {locks[file_path]['model']}, not {model}"}
98
+ del locks[file_path]
99
+ _save_locks(locks)
100
+ return {"status": "released", "file": file_path}
101
+
102
+ return {"status": "ok", "message": "File was not locked"}
103
+
104
+
105
+ def check_collisions(model: str = "") -> Dict[str, Any]:
106
+ """Check for active file locks and potential collisions."""
107
+ locks = _cleanup_expired(_load_locks())
108
+ _save_locks(locks)
109
+
110
+ active = []
111
+ by_model = {}
112
+ for path, lock in locks.items():
113
+ entry = {
114
+ "file": Path(path).name,
115
+ "full_path": path,
116
+ "model": lock["model"],
117
+ "claimed_at": lock.get("claimed_at", ""),
118
+ "task_id": lock.get("task_id", ""),
119
+ }
120
+ active.append(entry)
121
+ by_model.setdefault(lock["model"], []).append(entry)
122
+
123
+ # Detect overlapping directories (two models in same folder)
124
+ dir_models = {}
125
+ for path, lock in locks.items():
126
+ parent = str(Path(path).parent)
127
+ dir_models.setdefault(parent, set()).add(lock["model"])
128
+
129
+ hotspots = [
130
+ {"directory": d, "models": list(models), "risk": "high"}
131
+ for d, models in dir_models.items() if len(models) > 1
132
+ ]
133
+
134
+ return {
135
+ "status": "ok",
136
+ "active_locks": len(active),
137
+ "locks": active,
138
+ "by_model": {m: len(files) for m, files in by_model.items()},
139
+ "hotspots": hotspots,
140
+ "message": f"{len(active)} active lock(s), {len(hotspots)} hotspot(s)" if active else "No active locks",
141
+ }
@@ -1,7 +1,2 @@
1
- """content_engine — Pro feature. Runs server-side.
2
-
3
- This module requires the Delimit MCP server to be running.
4
- Configure via: npx delimit-cli setup
5
- """
6
-
7
- # Stub — full implementation runs server-side
1
+ # content_engine — Pro module (stubbed in npm package)
2
+ # Full implementation available on delimit.ai server