delimit-cli 3.15.12 → 3.15.14

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
@@ -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
@@ -1,7 +1,2 @@
1
- """key_resolver — 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
+ # key_resolver — Pro module (stubbed in npm package)
2
+ # Full implementation available on delimit.ai server
@@ -37,7 +37,7 @@ INBOX_ROUTING_FILE = Path.home() / ".delimit" / "inbox_routing.jsonl"
37
37
  IMAP_HOST = "mail.spacemail.com"
38
38
  IMAP_PORT = 993
39
39
  IMAP_USER = "pro@delimit.ai"
40
- FORWARD_TO = "configured-email@example.com"
40
+ FORWARD_TO = "owner@example.com"
41
41
 
42
42
  # Domains/senders whose emails require owner action
43
43
  OWNER_ACTION_DOMAINS = {
@@ -61,7 +61,7 @@ OWNER_ACTION_DOMAINS = {
61
61
  }
62
62
 
63
63
  OWNER_ACTION_SENDERS = {
64
- "configured-email@example.com",
64
+ "owner@example.com",
65
65
  }
66
66
 
67
67
  # Subject patterns that indicate owner-action (compiled once)
@@ -223,7 +223,7 @@ def send_email(
223
223
 
224
224
  Args:
225
225
  to: Recipient email address. Falls back to DELIMIT_SMTP_TO or
226
- configured-email@example.com.
226
+ owner@example.com.
227
227
  subject: Email subject line.
228
228
  body: Email body text (preferred). Falls back to 'message' for
229
229
  backward compatibility.
@@ -258,7 +258,7 @@ def send_email(
258
258
  smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
259
259
  smtp_from = os.environ.get("DELIMIT_SMTP_FROM", "")
260
260
 
261
- smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "configured-email@example.com")
261
+ smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "owner@example.com")
262
262
 
263
263
  if not all([smtp_host, smtp_from, smtp_to]):
264
264
  record = {
@@ -1,7 +1,235 @@
1
- """secrets_brokerPro feature. Runs server-side.
1
+ """Secrets broker JIT credential access with audit (STR-049).
2
2
 
3
- This module requires the Delimit MCP server to be running.
4
- Configure via: npx delimit-cli setup
3
+ Agents request credentials through this broker instead of accessing API keys
4
+ directly. The broker validates scope, issues time-limited access, and logs
5
+ every request in the audit trail.
5
6
  """
7
+ import json
8
+ import os
9
+ import base64
10
+ from pathlib import Path
11
+ from datetime import datetime, timezone
12
+ from typing import Dict, List, Optional
6
13
 
7
- # Stub full implementation runs server-side
14
+ SECRETS_DIR = Path.home() / ".delimit" / "secrets"
15
+
16
+
17
+ def store_secret(
18
+ name: str,
19
+ value: str,
20
+ scope: str = "all",
21
+ description: str = "",
22
+ created_by: str = "",
23
+ ) -> Dict:
24
+ """Store a secret locally.
25
+
26
+ Args:
27
+ name: Unique identifier for the secret.
28
+ value: The secret value (will be base64-encoded at rest).
29
+ scope: Comma-separated list of tools/agents allowed access, or 'all'.
30
+ description: Human-readable description.
31
+ created_by: Identity of the creator.
32
+
33
+ Returns:
34
+ Confirmation dict with the stored secret name.
35
+ """
36
+ if not name or not name.strip():
37
+ return {"error": "Secret name is required"}
38
+ if not value:
39
+ return {"error": "Secret value is required"}
40
+
41
+ # Sanitise name for filesystem safety
42
+ safe_name = name.strip().replace("/", "_").replace("\\", "_")
43
+
44
+ SECRETS_DIR.mkdir(parents=True, exist_ok=True)
45
+ encoded = base64.b64encode(value.encode()).decode()
46
+ secret = {
47
+ "name": safe_name,
48
+ "encrypted_value": encoded,
49
+ "scope": scope,
50
+ "description": description,
51
+ "created_by": created_by,
52
+ "created_at": datetime.now(timezone.utc).isoformat(),
53
+ "last_accessed_at": None,
54
+ "access_count": 0,
55
+ "revoked": False,
56
+ }
57
+ (SECRETS_DIR / f"{safe_name}.json").write_text(json.dumps(secret, indent=2))
58
+ return {"stored": safe_name}
59
+
60
+
61
+ def get_secret(
62
+ name: str,
63
+ agent_type: str = "",
64
+ tool: str = "",
65
+ ) -> Dict:
66
+ """Request access to a secret. Returns value if authorised.
67
+
68
+ Args:
69
+ name: The secret name to retrieve.
70
+ agent_type: Identity of the requesting agent (e.g. 'claude', 'codex').
71
+ tool: Which MCP tool is requesting access.
72
+
73
+ Returns:
74
+ Dict with 'value' and 'granted': True on success, or 'error' and
75
+ 'granted': False on failure.
76
+ """
77
+ safe_name = name.strip().replace("/", "_").replace("\\", "_")
78
+ path = SECRETS_DIR / f"{safe_name}.json"
79
+ if not path.exists():
80
+ _log_access(safe_name, agent_type, tool, granted=False, reason="not_found")
81
+ return {"error": f"Secret '{safe_name}' not found", "granted": False}
82
+
83
+ secret = json.loads(path.read_text())
84
+
85
+ if secret.get("revoked"):
86
+ _log_access(safe_name, agent_type, tool, granted=False, reason="revoked")
87
+ return {"error": f"Secret '{safe_name}' has been revoked", "granted": False}
88
+
89
+ # Scope check — 'all' allows any requester, otherwise match tool or agent_type
90
+ scope = secret.get("scope", "all")
91
+ if scope != "all":
92
+ allowed = {s.strip().lower() for s in scope.split(",")}
93
+ requester_ids = {agent_type.lower(), tool.lower()} - {""}
94
+ if requester_ids and not requester_ids & allowed:
95
+ _log_access(
96
+ safe_name, agent_type, tool,
97
+ granted=False,
98
+ reason=f"scope_denied: required={scope}, got agent_type={agent_type}, tool={tool}",
99
+ )
100
+ return {
101
+ "error": f"Access denied: scope '{scope}' does not include '{agent_type or tool}'",
102
+ "granted": False,
103
+ }
104
+
105
+ # Log successful access
106
+ _log_access(safe_name, agent_type, tool, granted=True, reason="")
107
+
108
+ # Update access metadata
109
+ secret["access_count"] = secret.get("access_count", 0) + 1
110
+ secret["last_accessed_at"] = datetime.now(timezone.utc).isoformat()
111
+ path.write_text(json.dumps(secret, indent=2))
112
+
113
+ value = base64.b64decode(secret["encrypted_value"]).decode()
114
+ return {"value": value, "granted": True, "name": safe_name}
115
+
116
+
117
+ def list_secrets() -> List[Dict]:
118
+ """List all secrets (metadata only, never values).
119
+
120
+ Returns:
121
+ List of dicts with name, scope, description, access_count, etc.
122
+ """
123
+ if not SECRETS_DIR.exists():
124
+ return []
125
+ secrets = []
126
+ for f in sorted(SECRETS_DIR.glob("*.json")):
127
+ try:
128
+ s = json.loads(f.read_text())
129
+ secrets.append({
130
+ "name": s["name"],
131
+ "scope": s.get("scope", "all"),
132
+ "description": s.get("description", ""),
133
+ "created_by": s.get("created_by", ""),
134
+ "access_count": s.get("access_count", 0),
135
+ "last_accessed_at": s.get("last_accessed_at"),
136
+ "revoked": s.get("revoked", False),
137
+ "created_at": s.get("created_at", ""),
138
+ })
139
+ except (json.JSONDecodeError, KeyError):
140
+ pass
141
+ return secrets
142
+
143
+
144
+ def revoke_secret(name: str) -> Dict:
145
+ """Revoke a secret, preventing future access.
146
+
147
+ Args:
148
+ name: The secret name to revoke.
149
+
150
+ Returns:
151
+ Confirmation dict or error.
152
+ """
153
+ safe_name = name.strip().replace("/", "_").replace("\\", "_")
154
+ path = SECRETS_DIR / f"{safe_name}.json"
155
+ if not path.exists():
156
+ return {"error": f"Secret '{safe_name}' not found"}
157
+ secret = json.loads(path.read_text())
158
+ secret["revoked"] = True
159
+ secret["revoked_at"] = datetime.now(timezone.utc).isoformat()
160
+ path.write_text(json.dumps(secret, indent=2))
161
+ _log_access(safe_name, "", "", granted=True, reason="revoked_by_user")
162
+ return {"revoked": safe_name}
163
+
164
+
165
+ def get_access_log(name: Optional[str] = None) -> List[Dict]:
166
+ """Return access log entries, optionally filtered by secret name.
167
+
168
+ Args:
169
+ name: If provided, only return entries for this secret.
170
+
171
+ Returns:
172
+ List of access log entries (newest first).
173
+ """
174
+ log_path = SECRETS_DIR / "access_log" / "log.jsonl"
175
+ if not log_path.exists():
176
+ return []
177
+ entries = []
178
+ for line in log_path.read_text().strip().split("\n"):
179
+ if not line.strip():
180
+ continue
181
+ try:
182
+ entry = json.loads(line)
183
+ if name and entry.get("secret_name") != name:
184
+ continue
185
+ entries.append(entry)
186
+ except json.JSONDecodeError:
187
+ pass
188
+ # Newest first
189
+ entries.reverse()
190
+ return entries
191
+
192
+
193
+ def delete_secret(name: str) -> Dict:
194
+ """Permanently delete a secret file.
195
+
196
+ Args:
197
+ name: The secret name to delete.
198
+
199
+ Returns:
200
+ Confirmation dict or error.
201
+ """
202
+ safe_name = name.strip().replace("/", "_").replace("\\", "_")
203
+ path = SECRETS_DIR / f"{safe_name}.json"
204
+ if not path.exists():
205
+ return {"error": f"Secret '{safe_name}' not found"}
206
+ path.unlink()
207
+ _log_access(safe_name, "", "", granted=True, reason="deleted_by_user")
208
+ return {"deleted": safe_name}
209
+
210
+
211
+ # ---------------------------------------------------------------------------
212
+ # Internal helpers
213
+ # ---------------------------------------------------------------------------
214
+
215
+
216
+ def _log_access(
217
+ secret_name: str,
218
+ agent_type: str,
219
+ tool: str,
220
+ granted: bool,
221
+ reason: str,
222
+ ) -> None:
223
+ """Append an entry to the JSONL access log."""
224
+ log_dir = SECRETS_DIR / "access_log"
225
+ log_dir.mkdir(parents=True, exist_ok=True)
226
+ entry = {
227
+ "secret_name": secret_name,
228
+ "agent_type": agent_type,
229
+ "tool": tool,
230
+ "ts": datetime.now(timezone.utc).isoformat(),
231
+ "granted": granted,
232
+ "reason": reason,
233
+ }
234
+ with open(log_dir / "log.jsonl", "a") as f:
235
+ f.write(json.dumps(entry) + "\n")
@@ -3697,6 +3697,14 @@ def delimit_swarm(action: str = "status", venture: str = "",
3697
3697
  return _with_next_steps("swarm", _safe_call(
3698
3698
  check_docs_freshness, project_path=repo_path or ".",
3699
3699
  ))
3700
+ if action == "create_tool":
3701
+ from ai.swarm import create_tool
3702
+ return _with_next_steps("swarm", _safe_call(
3703
+ create_tool, name=target_path, code=repo_path, venture=venture, agent_id=agent_id,
3704
+ ))
3705
+ if action == "list_tools":
3706
+ from ai.swarm import list_custom_tools
3707
+ return _with_next_steps("swarm", _safe_call(list_custom_tools, venture=venture))
3700
3708
  return _with_next_steps("swarm", _safe_call(get_swarm_status))
3701
3709
 
3702
3710
 
@@ -5692,14 +5700,7 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5692
5700
  Every post provides value — tips, insights, governance wisdom.
5693
5701
  Max 2 posts per day to stay authentic.
5694
5702
 
5695
- IMPORTANT — Platform tone rules (these are DIFFERENT per platform):
5696
- - Twitter: confident technical brand. Direct, professional, ALWAYS POSITIVE.
5697
- Celebrate wins and progress. Never complain or air gaps publicly.
5698
- No em dashes or en dashes. Include install commands when relevant.
5699
- - Reddit: proud builder posting as u/delimitdev. Casual, typed-on-phone energy.
5700
- ALWAYS POSITIVE. Mention Delimit ONLY when genuinely helpful.
5701
- NO bullet points/lists/bold/em dashes. 2-3 sentences max.
5702
- - LinkedIn: professional hook + insight + CTA
5703
+ Platform tone and posting guidelines are loaded from user config at runtime.
5703
5704
 
5704
5705
  Args:
5705
5706
  text: Tweet text. Leave empty to auto-generate.
@@ -5736,7 +5737,7 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5736
5737
  from ai.social import store_draft_message_id
5737
5738
 
5738
5739
  # Build contextual email body so the founder knows exactly what to do
5739
- _acct = account or ("delimitdev" if platform == "reddit" else "delimit_ai")
5740
+ _acct = account or os.environ.get("DELIMIT_SOCIAL_REDDIT_ACCOUNT", "delimit") if platform == "reddit" else account or os.environ.get("DELIMIT_SOCIAL_TWITTER_ACCOUNT", "delimit_ai")
5740
5741
  _lines = []
5741
5742
 
5742
5743
  if platform == "reddit":
@@ -6513,7 +6514,7 @@ def delimit_notify(channel: str = "webhook", message: str = "",
6513
6514
  to: Recipient email address (email only). Overrides default DELIMIT_SMTP_TO.
6514
6515
  Send to any address — leave empty for default (owner@example.com).
6515
6516
  from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
6516
- (e.g. 'pro@example.com', 'admin@example.com'). Email only.
6517
+ (e.g. 'pro@delimit.ai', 'admin@wire.report'). Email only.
6517
6518
  """
6518
6519
  from ai.notify import send_notification
6519
6520
  return _with_next_steps("notify", _safe_call(
@@ -1,7 +1,2 @@
1
- """supabase_sync — 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
+ # supabase_sync — Pro module (stubbed in npm package)
2
+ # Full implementation available on delimit.ai server
@@ -590,3 +590,109 @@ def check_docs_freshness(
590
590
  "stale": stale,
591
591
  "message": f"{len(findings)} doc issue(s) found" if findings else "Documentation is up to date",
592
592
  }
593
+
594
+
595
+ # ═══════════════════════════════════════════════════════════════════════
596
+ # LED-279: Self-Extending Swarm — Founder Mode
597
+ # Agents can create new MCP tools when authorized
598
+ # ═══════════════════════════════════════════════════════════════════════
599
+
600
+ TOOLS_DIR = Path.home() / ".delimit" / "swarm" / "custom_tools"
601
+
602
+
603
+ def create_tool(
604
+ name: str,
605
+ code: str,
606
+ venture: str,
607
+ agent_id: str = "",
608
+ description: str = "",
609
+ ) -> Dict[str, Any]:
610
+ """Create a new MCP tool (founder mode only).
611
+
612
+ Writes a Python module that can be loaded by the MCP server.
613
+ Requires reviewer approval before activation.
614
+ """
615
+ if not name or not code:
616
+ return {"error": "name and code are required"}
617
+
618
+ # Verify agent has creation authority
619
+ registry = _load_registry()
620
+ agent = registry["agents"].get(agent_id, {})
621
+ role = agent.get("role", "")
622
+ if role not in ("architect", "senior_dev"):
623
+ return {
624
+ "error": f"Role '{role}' cannot create tools. Only architect and senior_dev have creation authority.",
625
+ "agent_id": agent_id,
626
+ }
627
+
628
+ # Verify venture namespace
629
+ if agent.get("venture", "") != venture:
630
+ return {"error": f"Agent '{agent_id}' cannot create tools for venture '{venture}'"}
631
+
632
+ # Security scan — check for dangerous patterns
633
+ dangerous = [
634
+ "subprocess.call", "os.system", "exec(", "eval(",
635
+ "import socket", "import http.server",
636
+ "__import__", "compile(",
637
+ ]
638
+ for pattern in dangerous:
639
+ if pattern in code:
640
+ return {
641
+ "error": f"Security violation: '{pattern}' is not allowed in custom tools",
642
+ "blocked_pattern": pattern,
643
+ }
644
+
645
+ # Write tool module
646
+ TOOLS_DIR.mkdir(parents=True, exist_ok=True)
647
+ venture_dir = TOOLS_DIR / venture
648
+ venture_dir.mkdir(parents=True, exist_ok=True)
649
+
650
+ safe_name = name.lower().replace("-", "_").replace(" ", "_")
651
+ tool_path = venture_dir / f"{safe_name}.py"
652
+ tool_path.write_text(code)
653
+
654
+ # Log creation
655
+ _log({
656
+ "action": "tool_created",
657
+ "tool_name": safe_name,
658
+ "venture": venture,
659
+ "agent_id": agent_id,
660
+ "path": str(tool_path),
661
+ "lines": len(code.split("\n")),
662
+ "status": "pending_review",
663
+ })
664
+
665
+ return {
666
+ "status": "created",
667
+ "tool_name": safe_name,
668
+ "path": str(tool_path),
669
+ "venture": venture,
670
+ "created_by": agent_id,
671
+ "lines": len(code.split("\n")),
672
+ "next_step": "Reviewer agent must approve before tool is activated",
673
+ "message": f"Tool '{safe_name}' created for {venture}. Pending reviewer approval.",
674
+ }
675
+
676
+
677
+ def list_custom_tools(venture: str = "") -> Dict[str, Any]:
678
+ """List custom tools created by agents."""
679
+ TOOLS_DIR.mkdir(parents=True, exist_ok=True)
680
+ tools = []
681
+
682
+ search_dirs = [TOOLS_DIR / venture] if venture else list(TOOLS_DIR.iterdir())
683
+ for d in search_dirs:
684
+ if d.is_dir():
685
+ for f in sorted(d.glob("*.py")):
686
+ tools.append({
687
+ "name": f.stem,
688
+ "venture": d.name,
689
+ "path": str(f),
690
+ "lines": len(f.read_text().split("\n")),
691
+ })
692
+
693
+ return {
694
+ "status": "ok",
695
+ "tools": tools,
696
+ "total": len(tools),
697
+ "venture_filter": venture or "all",
698
+ }
@@ -14,7 +14,35 @@ Reference: Consensus 118/119/120 — Tool Segmentation Architecture.
14
14
 
15
15
  from typing import Dict, Literal
16
16
 
17
- Tier = Literal["public", "ops_pack", "internal", "experimental"]
17
+ Tier = Literal["core", "public", "ops_pack", "internal", "experimental"]
18
+
19
+ # ─────────────────────────────────────────────────────────────────────
20
+ # CORE WORKFLOWS — the 5 outcomes first users should see
21
+ #
22
+ # 1. Govern: catch breaking changes before deploy
23
+ # 2. Remember: persistent memory across models and sessions
24
+ # 3. Handoff: switch models without losing context
25
+ # 4. Lint: validate API specs for drift
26
+ # 5. Track: ledger for tasks that survive context resets
27
+ #
28
+ # Per consensus (STR-040): sell 5 workflows, not 162 tools
29
+ # ─────────────────────────────────────────────────────────────────────
30
+
31
+ CORE_TOOLS = {
32
+ # Govern
33
+ "delimit_lint", "delimit_scan", "delimit_gov_health", "delimit_drift_check",
34
+ # Remember
35
+ "delimit_memory_store", "delimit_memory_search", "delimit_memory_recent",
36
+ # Handoff
37
+ "delimit_session_handoff", "delimit_session_history",
38
+ # Lint + Diff
39
+ "delimit_diff", "delimit_semver", "delimit_explain",
40
+ # Track
41
+ "delimit_ledger_context", "delimit_ledger_add", "delimit_ledger_done",
42
+ "delimit_ledger_list", "delimit_ledger_update",
43
+ # Setup
44
+ "delimit_init", "delimit_scan", "delimit_version", "delimit_help",
45
+ }
18
46
 
19
47
  # ─────────────────────────────────────────────────────────────────────
20
48
  # TOOL_TIERS: canonical tier assignment for every registered tool.
@@ -31,12 +59,12 @@ Tier = Literal["public", "ops_pack", "internal", "experimental"]
31
59
  # ─────────────────────────────────────────────────────────────────────
32
60
 
33
61
  TOOL_TIERS: Dict[str, Tier] = {
34
- # === Govern domain (all public) ===
35
- "delimit_lint": "public",
36
- "delimit_diff": "public",
62
+ # === Govern domain ===
63
+ "delimit_lint": "core",
64
+ "delimit_diff": "core",
37
65
  "delimit_policy": "public",
38
- "delimit_semver": "public",
39
- "delimit_explain": "public",
66
+ "delimit_semver": "core",
67
+ "delimit_explain": "core",
40
68
  "delimit_zero_spec": "public",
41
69
  "delimit_init": "public",
42
70
  "delimit_gov_health": "public",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "delimit-cli",
3
3
  "mcpName": "io.github.delimit-ai/delimit-mcp-server",
4
- "version": "3.15.12",
4
+ "version": "3.15.14",
5
5
  "description": "Unify Claude Code, Codex, Cursor, and Gemini CLI with persistent context, governance, and multi-model debate.",
6
6
  "main": "index.js",
7
7
  "files": [
@@ -14,6 +14,12 @@
14
14
  "!gateway/ai/founding_users.py",
15
15
  "!gateway/ai/inbox_daemon.py",
16
16
  "!gateway/ai/deliberation.py",
17
+ "!gateway/ai/reddit_scanner.py",
18
+ "!gateway/ai/github_scanner.py",
19
+ "!gateway/ai/cross_model_audit.py",
20
+ "!gateway/ai/session_phoenix.py",
21
+ "!gateway/ai/handoff_receipts.py",
22
+ "!gateway/ai/toolcard_cache.py",
17
23
  "scripts/",
18
24
  "server.json",
19
25
  "README.md",
@@ -25,7 +25,7 @@ fi
25
25
 
26
26
  # 2. Blocklist terms
27
27
  echo -n " Blocklist... "
28
- BLOCKLIST="jamsonsholdings|Bladabah|Domainvested26|Delimit26|home/jamsons|infracore|crypttrx|\.wr_env"
28
+ BLOCKLIST="jamsonsholdings|Bladabah|Domainvested26|Delimit26|home/jamsons|infracore|crypttrx|\.wr_env|delimitdev|typed-on-phone|em dash.*ai tell|PAIN_CATEGORIES|VENTURE_CONFIG|VENTURE_SUBREDDITS|karma_building"
29
29
  if grep -rEi "$BLOCKLIST" "$TMPDIR/package/" --include="*.py" --include="*.js" --include="*.json" 2>/dev/null; then
30
30
  echo "❌ BLOCKED TERMS FOUND"
31
31
  FAIL=1
@@ -44,7 +44,7 @@ fi
44
44
 
45
45
  # 4. Proprietary files that shouldn't ship
46
46
  echo -n " Proprietary files... "
47
- PROPRIETARY="social_target\.py|social\.py|founding_users\.py|inbox_daemon\.py|deliberation\.py"
47
+ PROPRIETARY="social_target\.py|social\.py|founding_users\.py|inbox_daemon\.py|deliberation\.py|reddit_scanner\.py|github_scanner\.py|cross_model_audit\.py|session_phoenix\.py|handoff_receipts\.py|toolcard_cache\.py"
48
48
  if find "$TMPDIR/package/" -name "*.py" | grep -Ei "$PROPRIETARY" 2>/dev/null; then
49
49
  echo "❌ PROPRIETARY FILES IN PACKAGE"
50
50
  FAIL=1