delimit-cli 4.0.3 → 4.0.4

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.
@@ -19,9 +19,8 @@ All tools follow the Adapter Boundary Contract v1.0:
19
19
  - Stateless between calls
20
20
  """
21
21
 
22
- # ── Founder Voice Doctrine ──────────────────────────────────────────────
22
+ # ── Output Quality Rules ──────────────────────────────────────────────
23
23
  # Applies to ALL outward-facing text generated by any tool in this server.
24
- # Full doctrine: /home/delimit/delimit-private/style/FOUNDER_VOICE_DOCTRINE.md
25
24
  #
26
25
  # Core: serious builder/operator, not a marketer. Credibility over persuasion.
27
26
  # Truth over excitement. Concrete mechanisms, not vague benefits.
@@ -49,6 +48,7 @@ import os
49
48
  import re
50
49
  import shutil
51
50
  import subprocess
51
+ import threading
52
52
  import traceback
53
53
  import uuid
54
54
  from datetime import datetime, timezone
@@ -257,12 +257,57 @@ def _coerce_dict_arg(
257
257
  HIGH_RISK_TOOLS = {
258
258
  'deploy_publish', 'deploy_rollback', 'deploy_npm', 'deploy_site',
259
259
  'security_scan', 'data_migrate', 'data_backup',
260
+ # Social/outreach — drafts are fine, but posting/approving requires gate
261
+ 'social_approve', 'content_publish',
262
+ # Agent dispatch — spawns autonomous work, must be gated
263
+ 'agent_dispatch', 'daemon_run',
264
+ # Deliberation — burns model quota
265
+ 'deliberate',
260
266
  }
261
267
 
262
268
  CRITICAL_RISK_TOOLS = {
263
269
  'deploy_rollback', 'data_migrate',
264
270
  }
265
271
 
272
+ # Rate limits per tool per hour — prevents runaway loops
273
+ _TOOL_RATE_LIMITS = {
274
+ 'social_post': 10,
275
+ 'social_target': 20,
276
+ 'social_approve': 10,
277
+ 'notify': 15,
278
+ 'deliberate': 5,
279
+ 'agent_dispatch': 5,
280
+ 'ledger_add': 30,
281
+ }
282
+
283
+ _tool_call_counts: Dict[str, list] = {}
284
+ _tool_rate_lock = threading.Lock()
285
+
286
+
287
+ def _check_rate_limit(tool_name: str) -> Optional[Dict]:
288
+ """Enforce per-tool rate limits. Returns error dict if over limit, None if allowed."""
289
+ clean = tool_name.replace('delimit_', '')
290
+ limit = _TOOL_RATE_LIMITS.get(clean)
291
+ if not limit:
292
+ return None
293
+
294
+ import time as _rl_time
295
+ now = _rl_time.time()
296
+ with _tool_rate_lock:
297
+ calls = _tool_call_counts.setdefault(clean, [])
298
+ # Prune calls older than 1 hour
299
+ calls[:] = [t for t in calls if now - t < 3600]
300
+ if len(calls) >= limit:
301
+ return {
302
+ "status": "rate_limited",
303
+ "reason": f"Tool '{tool_name}' called {len(calls)} times in the last hour (limit: {limit}). "
304
+ f"This prevents runaway loops. Wait or check your scan configuration.",
305
+ "calls_this_hour": len(calls),
306
+ "limit": limit,
307
+ }
308
+ calls.append(now)
309
+ return None
310
+
266
311
 
267
312
  def _classify_risk(tool_name: str) -> str:
268
313
  """Classify tool risk level for approval gate decisions."""
@@ -1160,6 +1205,54 @@ def _detect_environment() -> Dict[str, Any]:
1160
1205
  _inbox_daemon_autostarted = False
1161
1206
  _toolcard_cache_autoregistered = False
1162
1207
 
1208
+ # MCP response size cap — prevents Node.js heap OOM on all clients (Gemini CLI, Cursor, etc.)
1209
+ # FastMCP serializes responses to JSON over stdio; large payloads crash Node's default 1.5GB heap.
1210
+ # Cap is set high enough that all normal tool responses (deliberation, audit, ledger) pass through
1211
+ # untouched. Only pathological cases (e.g. 910-item scan dumps) get trimmed.
1212
+ _MCP_RESPONSE_SIZE_LIMIT = 200_000 # 200KB hard ceiling
1213
+
1214
+ # Fields within list items that are safe to truncate — display text, not structured data
1215
+ _ITEM_TEXT_FIELDS = {"content_snippet", "body", "text", "rationale", "full_text", "description", "summary"}
1216
+ _ITEM_TEXT_MAX = 300 # chars per field within a list item
1217
+
1218
+ def _cap_response(result: Dict[str, Any]) -> Dict[str, Any]:
1219
+ """Truncate response payload to _MCP_RESPONSE_SIZE_LIMIT bytes.
1220
+
1221
+ Strategy (least destructive first):
1222
+ 1. Trim known text-only fields within list items (content_snippet, body, etc.)
1223
+ 2. If still over limit, truncate lists to first 20 items
1224
+ 3. If still over limit, add a note — structural data is never silently dropped
1225
+ """
1226
+ import json as _json, copy as _copy
1227
+ if len(_json.dumps(result)) <= _MCP_RESPONSE_SIZE_LIMIT:
1228
+ return result
1229
+ r = _copy.deepcopy(result)
1230
+
1231
+ # Pass 1: trim display-text fields inside list items (safe — these are human-readable snippets)
1232
+ for k, v in r.items():
1233
+ if isinstance(v, list):
1234
+ for item in v:
1235
+ if isinstance(item, dict):
1236
+ for field in _ITEM_TEXT_FIELDS:
1237
+ if field in item and isinstance(item[field], str) and len(item[field]) > _ITEM_TEXT_MAX:
1238
+ item[field] = item[field][:_ITEM_TEXT_MAX] + "…"
1239
+ if len(_json.dumps(r)) <= _MCP_RESPONSE_SIZE_LIMIT:
1240
+ return r
1241
+
1242
+ # Pass 2: truncate lists to first 20 items
1243
+ truncated_keys = [k for k, v in r.items() if isinstance(v, list) and len(v) > 20]
1244
+ for k in truncated_keys:
1245
+ total = len(r[k])
1246
+ r[k] = r[k][:20]
1247
+ r.setdefault("_pagination", {})[k] = {"returned": 20, "total": total, "note": "Use limit= to page"}
1248
+ if len(_json.dumps(r)) <= _MCP_RESPONSE_SIZE_LIMIT:
1249
+ return r
1250
+
1251
+ # Pass 3: last resort — note that response is large but return it anyway
1252
+ # Better to let the client decide than silently drop structured data
1253
+ r["_size_warning"] = f"Response exceeds {_MCP_RESPONSE_SIZE_LIMIT // 1000}KB. Use limit= or action='list' to reduce payload."
1254
+ return r
1255
+
1163
1256
 
1164
1257
  def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
1165
1258
  """Route every tool result through governance. This IS the loop.
@@ -1223,6 +1316,12 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
1223
1316
  f"Rewrite with concrete mechanisms, not vague benefits."
1224
1317
  )
1225
1318
 
1319
+ # Rate limit check — prevents runaway loops from any model
1320
+ rate_gate = _check_rate_limit(tool_name)
1321
+ if rate_gate:
1322
+ _emit_event(tool_name, rate_gate)
1323
+ return _cap_response(rate_gate)
1324
+
1226
1325
  # Emit event for real-time dashboard
1227
1326
  _emit_event(tool_name, result)
1228
1327
 
@@ -1231,7 +1330,7 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
1231
1330
  if policy_gate:
1232
1331
  policy_gate["original_result"] = result
1233
1332
  policy_gate["governance"] = {"action": "policy_blocked", "reason": policy_gate["reason"]}
1234
- return policy_gate
1333
+ return _cap_response(policy_gate)
1235
1334
 
1236
1335
  # LED-195: Prompt injection detection on tool inputs
1237
1336
  if isinstance(result, dict):
@@ -1248,12 +1347,12 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
1248
1347
  # Route through governance loop
1249
1348
  try:
1250
1349
  from ai.governance import govern
1251
- return govern(tool_name, result)
1350
+ return _cap_response(govern(tool_name, result))
1252
1351
  except Exception:
1253
1352
  # Fallback: just add next_steps from registry
1254
1353
  steps = NEXT_STEPS_REGISTRY.get(tool_name, [])
1255
1354
  result["next_steps"] = steps
1256
- return result
1355
+ return _cap_response(result)
1257
1356
 
1258
1357
 
1259
1358
  # ═══════════════════════════════════════════════════════════════════════
@@ -2077,7 +2176,6 @@ def delimit_deploy_publish(app: str = "", git_ref: Optional[str] = None) -> Dict
2077
2176
  return _delimit_deploy_impl(action="publish", app=app, git_ref=git_ref)
2078
2177
 
2079
2178
 
2080
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
2081
2179
  @mcp.tool()
2082
2180
  def delimit_deploy_verify(app: str = "", env: str = "", git_ref: Optional[str] = None) -> Dict[str, Any]:
2083
2181
  """Verify deployment health (experimental) (Pro)."""
@@ -2180,7 +2278,6 @@ def delimit_intel_query(
2180
2278
 
2181
2279
  # ─── Generate ───────────────────────────────────────────────────────────
2182
2280
 
2183
- @_internal_tool()
2184
2281
  @mcp.tool()
2185
2282
  def delimit_generate_template(
2186
2283
  template_type: str,
@@ -2207,7 +2304,6 @@ def delimit_generate_template(
2207
2304
  return _with_next_steps("generate_template", _safe_call(template, template_type=template_type, name=name, framework=framework, features=features, target=target))
2208
2305
 
2209
2306
 
2210
- @_internal_tool()
2211
2307
  @mcp.tool()
2212
2308
  def delimit_generate_scaffold(
2213
2309
  project_type: str,
@@ -2231,7 +2327,6 @@ def delimit_generate_scaffold(
2231
2327
 
2232
2328
  # ─── Repo (RepoDoctor + ConfigSentry) ──────────────────────────────────
2233
2329
 
2234
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
2235
2330
  @mcp.tool()
2236
2331
  def delimit_repo_diagnose(target: str = ".") -> Dict[str, Any]:
2237
2332
  """Diagnose repository health issues (experimental) (Pro).
@@ -2247,7 +2342,6 @@ def delimit_repo_diagnose(target: str = ".") -> Dict[str, Any]:
2247
2342
  return _safe_call(diagnose, target=target)
2248
2343
 
2249
2344
 
2250
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
2251
2345
  @mcp.tool()
2252
2346
  def delimit_repo_analyze(target: str = ".") -> Dict[str, Any]:
2253
2347
  """Analyze repository structure and quality (experimental) (Pro).
@@ -2263,7 +2357,6 @@ def delimit_repo_analyze(target: str = ".") -> Dict[str, Any]:
2263
2357
  return _safe_call(analyze, target=target)
2264
2358
 
2265
2359
 
2266
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
2267
2360
  @mcp.tool()
2268
2361
  def delimit_repo_config_validate(target: str = ".") -> Dict[str, Any]:
2269
2362
  """Validate configuration files (experimental) (Pro).
@@ -2279,7 +2372,6 @@ def delimit_repo_config_validate(target: str = ".") -> Dict[str, Any]:
2279
2372
  return _safe_call(config_validate, target=target)
2280
2373
 
2281
2374
 
2282
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
2283
2375
  @mcp.tool()
2284
2376
  def delimit_repo_config_audit(target: str = ".") -> Dict[str, Any]:
2285
2377
  """Audit configuration compliance (experimental) (Pro).
@@ -2673,6 +2765,55 @@ def delimit_security_deliberate(
2673
2765
  })
2674
2766
 
2675
2767
 
2768
+ @mcp.tool()
2769
+ def delimit_siem(action: str = "status", integration: str = "",
2770
+ settings: str = "", enabled: str = "",
2771
+ event: str = "") -> Dict[str, Any]:
2772
+ """Manage SIEM streaming — forward audit events to Splunk, Datadog, EventBridge, or webhooks.
2773
+
2774
+ Actions:
2775
+ status: Show all SIEM integrations and delivery stats
2776
+ configure: Update integration settings (pass integration name + settings JSON)
2777
+ test: Send a test event to all enabled integrations
2778
+ forward: Forward a specific event (used internally by audit trail)
2779
+
2780
+ Args:
2781
+ action: status, configure, test, or forward
2782
+ integration: splunk, datadog, eventbridge, or webhook (for configure)
2783
+ settings: JSON string of settings to update (for configure)
2784
+ enabled: "true" or "false" to enable/disable (for configure)
2785
+ event: JSON string of event to forward (for forward/test)
2786
+ """
2787
+ from ai.siem_streaming import configure, get_status, forward_event
2788
+
2789
+ if action == "status":
2790
+ return _with_next_steps("siem", get_status())
2791
+ if action == "configure":
2792
+ parsed_settings = {}
2793
+ if settings:
2794
+ try:
2795
+ parsed_settings = json.loads(settings)
2796
+ except json.JSONDecodeError:
2797
+ return _with_next_steps("siem", {"error": "Invalid JSON in settings"})
2798
+ is_enabled = None
2799
+ if enabled:
2800
+ is_enabled = enabled.lower() in ("true", "1", "yes")
2801
+ return _with_next_steps("siem", configure(
2802
+ integration=integration,
2803
+ settings=parsed_settings if parsed_settings else None,
2804
+ enabled=is_enabled,
2805
+ ))
2806
+ if action in ("test", "forward"):
2807
+ test_event = {"type": "test", "timestamp": time.time(), "source": "siem_test"}
2808
+ if event:
2809
+ try:
2810
+ test_event = json.loads(event)
2811
+ except json.JSONDecodeError:
2812
+ pass
2813
+ return _with_next_steps("siem", forward_event(test_event))
2814
+ return _with_next_steps("siem", {"error": f"Unknown action: {action}"})
2815
+
2816
+
2676
2817
  @mcp.tool()
2677
2818
  def delimit_security_audit(target: str = ".") -> Dict[str, Any]:
2678
2819
  """Audit security: dependency vulnerabilities, anti-patterns, and secret detection.
@@ -2925,14 +3066,12 @@ def delimit_release_status(environment: str = "production") -> Dict[str, Any]:
2925
3066
  return _delimit_release_impl(action="status", environment=environment)
2926
3067
 
2927
3068
 
2928
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
2929
3069
  @mcp.tool()
2930
3070
  def delimit_release_rollback(environment: str, version: str, to_version: str) -> Dict[str, Any]:
2931
3071
  """Rollback deployment to previous version (experimental)."""
2932
3072
  return _delimit_release_impl(action="rollback", environment=environment, version=version, to_version=to_version)
2933
3073
 
2934
3074
 
2935
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
2936
3075
  @mcp.tool()
2937
3076
  def delimit_release_history(environment: str, limit: int = 10) -> Dict[str, Any]:
2938
3077
  """Show release history (experimental)."""
@@ -3036,7 +3175,6 @@ def delimit_cost_controls(
3036
3175
 
3037
3176
  # ─── DataSteward (Governance Primitive) ────────────────────────────────
3038
3177
 
3039
- @_internal_tool()
3040
3178
  @mcp.tool()
3041
3179
  def delimit_data_validate(target: str = ".") -> Dict[str, Any]:
3042
3180
  """Validate data files: JSON parse, CSV structure, SQLite integrity check.
@@ -3048,7 +3186,6 @@ def delimit_data_validate(target: str = ".") -> Dict[str, Any]:
3048
3186
  return _with_next_steps("data_validate", _safe_call(data_validate, target=target))
3049
3187
 
3050
3188
 
3051
- @_internal_tool()
3052
3189
  @mcp.tool()
3053
3190
  def delimit_data_migrate(target: str = ".") -> Dict[str, Any]:
3054
3191
  """Check for migration files (alembic, Django, Prisma, Knex) and report status.
@@ -3060,7 +3197,6 @@ def delimit_data_migrate(target: str = ".") -> Dict[str, Any]:
3060
3197
  return _with_next_steps("data_migrate", _safe_call(data_migrate, target=target))
3061
3198
 
3062
3199
 
3063
- @_internal_tool()
3064
3200
  @mcp.tool()
3065
3201
  def delimit_data_backup(target: str = ".") -> Dict[str, Any]:
3066
3202
  """Back up SQLite and JSON data files to ~/.delimit/backups/ with timestamp.
@@ -3151,7 +3287,6 @@ def delimit_obs_logs(query: str, time_range: str = "1h", source: Optional[str] =
3151
3287
  return _delimit_obs_impl(action="logs", query=query, time_range=time_range, source=source)
3152
3288
 
3153
3289
 
3154
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
3155
3290
  @mcp.tool()
3156
3291
  def delimit_obs_alerts(action: str, alert_rule: Optional[Dict[str, Any]] = None, rule_id: Optional[str] = None) -> Dict[str, Any]:
3157
3292
  """Manage alerting rules (experimental)."""
@@ -3166,7 +3301,6 @@ def delimit_obs_status() -> Dict[str, Any]:
3166
3301
 
3167
3302
  # ─── DesignSystem (UI Tooling) ──────────────────────────────────────────
3168
3303
 
3169
- @_internal_tool()
3170
3304
  @mcp.tool()
3171
3305
  def delimit_design_extract_tokens(
3172
3306
  figma_file_key: Optional[str] = None,
@@ -3192,7 +3326,6 @@ def delimit_design_extract_tokens(
3192
3326
  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))
3193
3327
 
3194
3328
 
3195
- @_internal_tool()
3196
3329
  @mcp.tool()
3197
3330
  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]:
3198
3331
  """Generate a React/Next.js component skeleton with props interface and Tailwind support.
@@ -3207,7 +3340,6 @@ def delimit_design_generate_component(component_name: str, figma_node_id: Option
3207
3340
  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))
3208
3341
 
3209
3342
 
3210
- @_internal_tool()
3211
3343
  @mcp.tool()
3212
3344
  def delimit_design_generate_tailwind(figma_file_key: Optional[str] = None, output_path: Optional[str] = None, project_path: Optional[str] = None) -> Dict[str, Any]:
3213
3345
  """Read existing tailwind.config or generate one from detected CSS tokens.
@@ -3221,7 +3353,6 @@ def delimit_design_generate_tailwind(figma_file_key: Optional[str] = None, outpu
3221
3353
  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))
3222
3354
 
3223
3355
 
3224
- @_ops_pack_tool()
3225
3356
  @mcp.tool()
3226
3357
  def delimit_design_validate_responsive(
3227
3358
  project_path: str,
@@ -3243,7 +3374,6 @@ def delimit_design_validate_responsive(
3243
3374
  return _with_next_steps("design_validate_responsive", _safe_call(design_validate_responsive, project_path=project_path, check_types=check_types))
3244
3375
 
3245
3376
 
3246
- @_internal_tool()
3247
3377
  @mcp.tool()
3248
3378
  def delimit_design_component_library(project_path: str, output_format: str = "json") -> Dict[str, Any]:
3249
3379
  """Scan for React/Vue/Svelte components and generate a component catalog.
@@ -3258,7 +3388,6 @@ def delimit_design_component_library(project_path: str, output_format: str = "js
3258
3388
 
3259
3389
  # ─── Story (Component Stories + Visual/A11y Testing) ────────────────────
3260
3390
 
3261
- @_internal_tool()
3262
3391
  @mcp.tool()
3263
3392
  def delimit_story_generate(
3264
3393
  component_path: str,
@@ -3280,7 +3409,6 @@ def delimit_story_generate(
3280
3409
  return _with_next_steps("story_generate", _safe_call(story_generate, component_path=component_path, story_name=story_name, variants=variants))
3281
3410
 
3282
3411
 
3283
- @_internal_tool()
3284
3412
  @mcp.tool()
3285
3413
  def delimit_story_visual_test(url: str, project_path: Optional[str] = None, threshold: float = 0.05) -> Dict[str, Any]:
3286
3414
  """Run visual regression test -- screenshot and compare to baseline.
@@ -3298,7 +3426,6 @@ def delimit_story_visual_test(url: str, project_path: Optional[str] = None, thre
3298
3426
  return _with_next_steps("story_visual_test", _safe_call(story_visual_test, url=url, project_path=project_path, threshold=threshold))
3299
3427
 
3300
3428
 
3301
- @_internal_tool() # Was experimental (LED-044), promoted to internal (Consensus 120)
3302
3429
  @mcp.tool()
3303
3430
  def delimit_story_build(project_path: str, output_dir: Optional[str] = None) -> Dict[str, Any]:
3304
3431
  """Build Storybook static site.
@@ -3314,7 +3441,6 @@ def delimit_story_build(project_path: str, output_dir: Optional[str] = None) ->
3314
3441
  return _safe_call(story_build, project_path=project_path, output_dir=output_dir)
3315
3442
 
3316
3443
 
3317
- @_internal_tool()
3318
3444
  @mcp.tool()
3319
3445
  def delimit_story_accessibility(project_path: str, standards: str = "WCAG2AA") -> Dict[str, Any]:
3320
3446
  """Run WCAG accessibility checks by scanning HTML/JSX/TSX for common issues.
@@ -3347,7 +3473,6 @@ def delimit_test_generate(project_path: str, source_files: Optional[List[str]] =
3347
3473
  return _with_next_steps("test_generate", _safe_call(test_generate, project_path=project_path, source_files=source_files, framework=framework))
3348
3474
 
3349
3475
 
3350
- @_experimental_tool() # HIDDEN: stub/pass-through (LED-044)
3351
3476
  @mcp.tool()
3352
3477
  def delimit_test_coverage(project_path: str, threshold: int = 80) -> Dict[str, Any]:
3353
3478
  """Analyze test coverage (experimental) (Pro).
@@ -3381,7 +3506,6 @@ def delimit_test_smoke(project_path: str, test_suite: Optional[str] = None) -> D
3381
3506
 
3382
3507
  # ─── Docs (Real implementations) ─────────────────────────────────────
3383
3508
 
3384
- @_ops_pack_tool()
3385
3509
  @mcp.tool()
3386
3510
  def delimit_docs_generate(target: str = ".") -> Dict[str, Any]:
3387
3511
  """Generate API reference documentation for a project.
@@ -3438,7 +3562,7 @@ async def delimit_sensor_github_issue(
3438
3562
  with new comments, issue state, and severity classification.
3439
3563
 
3440
3564
  Args:
3441
- repo: GitHub repository in owner/repo format (e.g. "activepieces/activepieces").
3565
+ repo: GitHub repository in owner/repo format (e.g. "owner/repo").
3442
3566
  issue_number: The issue number to monitor.
3443
3567
  since_comment_id: Last seen comment ID. Pass 0 to get all comments.
3444
3568
  """
@@ -3650,14 +3774,20 @@ def delimit_swarm(action: str = "status", venture: str = "",
3650
3774
  approve: Check approval tier for an action
3651
3775
  guide: Get usage documentation
3652
3776
  rules: Get escalation rules
3777
+ create_tool: Create a custom MCP tool (architect/senior_dev only)
3778
+ list_tools: List custom tools per venture
3779
+ reload: Hot-reload MCP server to pick up updated modules
3780
+ create_agent: Provision a new specialist agent role (architect only)
3781
+ approve_agent: Activate a pending custom agent (founder only)
3782
+ list_agents: List all agents (built-in + custom)
3653
3783
 
3654
3784
  Args:
3655
- action: "status", "register", "venture", "agent", "check", "approve", "guide", or "rules".
3656
- venture: Venture name (for register/venture).
3657
- agent_id: Agent ID like "delimit-architect-01" (for agent/check).
3658
- repo_path: Repo path for venture registration.
3785
+ action: See actions above.
3786
+ venture: Venture name (for register/venture/create_agent).
3787
+ agent_id: Agent ID (for agent/check/create_agent/approve_agent).
3788
+ repo_path: Repo path, description, or reason depending on action.
3659
3789
  deploy_target: Deploy target for venture registration.
3660
- target_path: File path to check access for (check action).
3790
+ target_path: File path, tool name, or role name depending on action.
3661
3791
  access_action: Action name — for check: "read"/"write"/"deploy". For approve: "deploy_production"/"deploy_staging"/"social_post" etc.
3662
3792
  """
3663
3793
  from ai.swarm import (register_venture, get_venture, get_agent,
@@ -3705,6 +3835,34 @@ def delimit_swarm(action: str = "status", venture: str = "",
3705
3835
  if action == "list_tools":
3706
3836
  from ai.swarm import list_custom_tools
3707
3837
  return _with_next_steps("swarm", _safe_call(list_custom_tools, venture=venture))
3838
+ if action == "reload":
3839
+ from ai.swarm import hot_reload
3840
+ return _with_next_steps("swarm", _safe_call(hot_reload, reason=repo_path or "manual"))
3841
+ if action == "create_agent":
3842
+ from ai.swarm import create_agent
3843
+ return _with_next_steps("swarm", _safe_call(
3844
+ create_agent,
3845
+ venture=venture,
3846
+ role_name=target_path,
3847
+ description=repo_path or "",
3848
+ default_model=agent_id or "claude-opus-4.6",
3849
+ creator_agent_id=agent_id,
3850
+ ))
3851
+ if action == "approve_agent":
3852
+ from ai.swarm import approve_agent
3853
+ return _with_next_steps("swarm", _safe_call(approve_agent, agent_id=agent_id))
3854
+ if action == "list_agents":
3855
+ from ai.swarm import list_agents
3856
+ return _with_next_steps("swarm", _safe_call(list_agents, venture=venture))
3857
+ if action == "preflight":
3858
+ from ai.swarm import preflight_check
3859
+ return _with_next_steps("swarm", _safe_call(
3860
+ preflight_check,
3861
+ action=access_action or "general",
3862
+ venture=venture,
3863
+ path=target_path or repo_path or "",
3864
+ agent_id=agent_id,
3865
+ ))
3708
3866
  return _with_next_steps("swarm", _safe_call(get_swarm_status))
3709
3867
 
3710
3868
 
@@ -5681,7 +5839,6 @@ def delimit_webhook_manage(
5681
5839
  # ═══════════════════════════════════════════════════════════════════════
5682
5840
 
5683
5841
 
5684
- @_ops_pack_tool()
5685
5842
  @mcp.tool()
5686
5843
  def delimit_social_post(text: str = "", category: str = "", platform: str = "twitter",
5687
5844
  account: str = "", quote_tweet_id: str = "",
@@ -5703,9 +5860,9 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5703
5860
  IMPORTANT — Platform tone rules (these are DIFFERENT per platform):
5704
5861
  - Twitter: confident technical brand. Direct, professional, ALWAYS POSITIVE.
5705
5862
  Celebrate wins and progress. Never complain or air gaps publicly.
5706
- No em dashes or en dashes. Include install commands when relevant.
5707
- - Reddit: proud builder posting as u/delimitdev. Casual, typed-on-phone energy.
5708
- ALWAYS POSITIVE. Mention Delimit ONLY when genuinely helpful.
5863
+ No em dashes or en dashes. Default to insight-first with no CTA unless source-grounded.
5864
+ - Reddit: helpful builder voice. Grounded, concise, never salesy.
5865
+ Default to no Delimit mention unless directly necessary and source-grounded.
5709
5866
  NO bullet points/lists/bold/em dashes. 2-3 sentences max.
5710
5867
  - LinkedIn: professional hook + insight + CTA
5711
5868
 
@@ -5731,6 +5888,36 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5731
5888
  if platform not in ("twitter", "reddit"):
5732
5889
  return {"error": f"Platform '{platform}' not supported yet", "supported": ["twitter", "reddit"]}
5733
5890
 
5891
+ # ── Draft quality gate — reject template stubs and platform mismatches ──
5892
+ _draft_text = text or post.get("text", "")
5893
+ _stub_patterns = [
5894
+ "[DRAFT - needs human writing]",
5895
+ "[DRAFT -",
5896
+ "Engagement opportunity for",
5897
+ "needs human writing",
5898
+ ]
5899
+ if any(pat in _draft_text for pat in _stub_patterns):
5900
+ return {
5901
+ "error": "Draft rejected: template stub detected. Write an actual reply, not a placeholder.",
5902
+ "rejected_text": _draft_text[:200],
5903
+ "hint": "Read the thread, understand the conversation, then write a genuine 2-3 sentence reply. "
5904
+ "Helpful first, no product pitch, no invented personal story, no generic pep talk.",
5905
+ }
5906
+
5907
+ # Reject drafts shorter than 50 chars (likely not a real reply)
5908
+ if len(_draft_text.strip()) < 50:
5909
+ return {
5910
+ "error": "Draft rejected: too short. Write a substantive reply (50+ characters).",
5911
+ "text_length": len(_draft_text.strip()),
5912
+ }
5913
+
5914
+ # Warn if context field contains a Reddit URL but platform is set to twitter
5915
+ if platform == "twitter" and "reddit.com" in (context or ""):
5916
+ return {
5917
+ "error": "Platform mismatch: context references Reddit but platform is 'twitter'. Set platform='reddit'.",
5918
+ "hint": "When drafting from a Reddit scan target, always set platform='reddit'.",
5919
+ }
5920
+
5734
5921
  draft = True # Always draft, never auto-post
5735
5922
  entry = save_draft(
5736
5923
  post["text"], platform=platform, account=account,
@@ -5748,18 +5935,26 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5748
5935
  _lines = []
5749
5936
 
5750
5937
  if platform == "reddit":
5751
- _lines.append(f"POST FROM: u/{_acct} on REDDIT")
5938
+ _lines.append("WHERE: Reddit")
5752
5939
  else:
5753
- _lines.append(f"POST FROM: @{_acct} on TWITTER")
5940
+ _lines.append("WHERE: X")
5941
+ _where_link = ""
5942
+ if platform == "reddit":
5943
+ _where_link = entry.get("thread_url", "") or reply_to_id
5944
+ elif reply_to_id:
5945
+ _where_link = f"https://x.com/i/status/{reply_to_id}"
5946
+ elif quote_tweet_id:
5947
+ _where_link = f"https://x.com/i/status/{quote_tweet_id}"
5948
+ if _where_link:
5949
+ _lines.append(f"LINK: {_where_link}")
5754
5950
  _lines.append("")
5755
5951
 
5756
- # WHY should this be posted?
5757
5952
  _context = entry.get("context", "")
5758
5953
  if _context:
5759
5954
  _lines.append(f"WHY: {_context}")
5760
5955
  _lines.append("")
5761
5956
 
5762
- # What type of post is this?
5957
+ _lines.append("WHAT:")
5763
5958
  if platform == "reddit":
5764
5959
  _thread_url = entry.get("thread_url", "")
5765
5960
  # Fallback: extract reddit URL from context field
@@ -5770,9 +5965,8 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5770
5965
  if _url_match:
5771
5966
  _thread_url = _url_match.group(0)
5772
5967
  if reply_to_id or _thread_url:
5773
- _url = _thread_url or reply_to_id
5774
- _lines.append(f"ACTION: Reply in thread {_url}")
5775
- _lines.append("Open the thread, find the comment to reply to, paste the text below.")
5968
+ _lines.append(f"Platform: REDDIT as u/{_acct}")
5969
+ _lines.append("Owner action: Open the thread and reply using the draft below.")
5776
5970
  else:
5777
5971
  # New Reddit post — extract title from first line of text
5778
5972
  _post_text = post["text"]
@@ -5783,19 +5977,17 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5783
5977
  else:
5784
5978
  _reddit_title = _post_text[:100].strip()
5785
5979
  _reddit_body = _post_text
5786
- _lines.append("ACTION: New Reddit post")
5787
- _lines.append("Navigate to the subreddit, create a new post.")
5788
- _lines.append("")
5789
- _lines.append("--- TITLE (paste in title field) ---")
5980
+ _lines.append(f"Platform: REDDIT as u/{_acct}")
5981
+ _lines.append("Owner action: Navigate to the subreddit and create a new post.")
5790
5982
  _lines.append("")
5983
+ _lines.append("Post Title")
5984
+ _lines.append("Tap and hold inside this block to copy.")
5791
5985
  _lines.append(_reddit_title)
5792
5986
  _lines.append("")
5793
- _lines.append("--- BODY (paste in body field) ---")
5794
- _lines.append("")
5987
+ _lines.append("Post Body")
5988
+ _lines.append("Tap and hold inside this block to copy.")
5795
5989
  _lines.append(_reddit_body)
5796
5990
  _lines.append("")
5797
- _lines.append("--- END ---")
5798
- _lines.append("")
5799
5991
  _lines.append(f"Draft ID: {entry['draft_id']}")
5800
5992
  if entry.get("tone_warnings"):
5801
5993
  _lines.append("")
@@ -5817,22 +6009,20 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5817
6009
  entry["mode"] = "draft"
5818
6010
  return _with_next_steps("social_post", entry)
5819
6011
  elif reply_to_id:
5820
- _lines.append(f"ACTION: Reply to https://x.com/i/status/{reply_to_id}")
5821
- _lines.append("Open the link above, click Reply, paste the text below.")
6012
+ _lines.append(f"Platform: X as @{_acct}")
6013
+ _lines.append("Owner action: Open the link above, click Reply, paste the draft below.")
5822
6014
  elif quote_tweet_id:
5823
- _lines.append(f"ACTION: Quote tweet https://x.com/i/status/{quote_tweet_id}")
5824
- _lines.append("Open the link above, click Repost > Quote, paste the text below.")
6015
+ _lines.append(f"Platform: X as @{_acct}")
6016
+ _lines.append("Owner action: Open the link above, click Repost > Quote, paste the draft below.")
5825
6017
  else:
5826
- _lines.append("ACTION: New standalone tweet")
5827
- _lines.append("Open X, compose a new tweet, paste the text below.")
6018
+ _lines.append(f"Platform: X as @{_acct}")
6019
+ _lines.append("Owner action: Open X, compose a new post, paste the draft below.")
5828
6020
 
5829
6021
  _lines.append("")
5830
- _lines.append("--- COPY BELOW THIS LINE ---")
5831
- _lines.append("")
6022
+ _lines.append("Manual Post Text")
6023
+ _lines.append("Tap and hold inside this block to copy.")
5832
6024
  _lines.append(post["text"])
5833
6025
  _lines.append("")
5834
- _lines.append("--- END ---")
5835
- _lines.append("")
5836
6026
  _lines.append(f"Draft ID: {entry['draft_id']}")
5837
6027
  if entry.get("tone_warnings"):
5838
6028
  _lines.append("")
@@ -5868,7 +6058,6 @@ def delimit_social_post(text: str = "", category: str = "", platform: str = "twi
5868
6058
  return _with_next_steps("social_post", entry)
5869
6059
 
5870
6060
 
5871
- @_ops_pack_tool()
5872
6061
  @mcp.tool()
5873
6062
  def delimit_social_generate(category: str = "tip") -> Dict[str, Any]:
5874
6063
  """Generate a social media post without posting (Pro).
@@ -5882,7 +6071,6 @@ def delimit_social_generate(category: str = "tip") -> Dict[str, Any]:
5882
6071
  return _with_next_steps("social_generate", post)
5883
6072
 
5884
6073
 
5885
- @_internal_tool()
5886
6074
  @mcp.tool()
5887
6075
  def delimit_social_accounts() -> Dict[str, Any]:
5888
6076
  """List configured social media accounts (Pro).
@@ -5896,7 +6084,6 @@ def delimit_social_accounts() -> Dict[str, Any]:
5896
6084
  return _with_next_steps("social_accounts", {"accounts": accounts, "count": len(accounts)})
5897
6085
 
5898
6086
 
5899
- @_internal_tool()
5900
6087
  @mcp.tool()
5901
6088
  def delimit_social_history(limit: int = 20, platform: str = "",
5902
6089
  user: str = "", subreddit: str = "") -> Dict[str, Any]:
@@ -5918,7 +6105,6 @@ def delimit_social_history(limit: int = 20, platform: str = "",
5918
6105
  return _with_next_steps("social_history", {"posts": posts})
5919
6106
 
5920
6107
 
5921
- @_ops_pack_tool()
5922
6108
  @mcp.tool()
5923
6109
  def delimit_social_approve(action: str = "list", draft_id: str = "") -> Dict[str, Any]:
5924
6110
  """Manage social media drafts: list, approve, or reject (Pro).
@@ -6077,7 +6263,6 @@ def delimit_reddit_scan(sort: str = "hot", limit: int = 10) -> Dict[str, Any]:
6077
6263
  return _with_next_steps("social_target", result)
6078
6264
 
6079
6265
 
6080
- @_internal_tool()
6081
6266
  @mcp.tool()
6082
6267
  def delimit_github_scan(
6083
6268
  cadence: str = "pulse",
@@ -6087,12 +6272,12 @@ def delimit_github_scan(
6087
6272
 
6088
6273
  Three cadences:
6089
6274
  pulse: Own repo health (stars, forks, issues, traffic). Fast, run often.
6090
- hunter: Competitor users, adoption leads, pain threads. Medium, run hourly.
6275
+ hunter: Repository signals, engagement threads. Medium, run hourly.
6091
6276
  deep: Full ecosystem intel. Slow, run daily.
6092
6277
 
6093
6278
  IMPORTANT -- TOOL CHAINING RULE:
6094
6279
  After scanning, the AI MUST process high-score findings:
6095
- 1. Auto-ledger items (score >= 75 competitor users) via delimit_ledger_add
6280
+ 1. Auto-ledger high-score findings via delimit_ledger_add
6096
6281
  2. Pain threads with existing_feature relevance via delimit_notify
6097
6282
  Never just return findings and stop. Always chain to the next action.
6098
6283
 
@@ -6115,7 +6300,6 @@ def delimit_github_scan(
6115
6300
  # ═══════════════════════════════════════════════════════════════════════
6116
6301
 
6117
6302
 
6118
- @_internal_tool()
6119
6303
  @mcp.tool()
6120
6304
  def delimit_content_schedule() -> Dict[str, Any]:
6121
6305
  """View the upcoming content schedule: queued tweets, pending videos, recent activity (Pro)."""
@@ -6124,7 +6308,6 @@ def delimit_content_schedule() -> Dict[str, Any]:
6124
6308
  return _with_next_steps("content_schedule", get_content_schedule())
6125
6309
 
6126
6310
 
6127
- @_internal_tool()
6128
6311
  @mcp.tool()
6129
6312
  def delimit_content_publish(content_type: str = "tweet") -> Dict[str, Any]:
6130
6313
  """Manually trigger a content publish: tweet or youtube video (Pro).
@@ -6143,7 +6326,6 @@ def delimit_content_publish(content_type: str = "tweet") -> Dict[str, Any]:
6143
6326
  return {"error": f"Unknown content_type: {content_type}", "supported": ["tweet", "youtube"]}
6144
6327
 
6145
6328
 
6146
- @_internal_tool()
6147
6329
  @mcp.tool()
6148
6330
  def delimit_content_queue(action: str = "status", items: str = "") -> Dict[str, Any]:
6149
6331
  """Manage the tweet and video content queues (Pro).
@@ -6199,6 +6381,31 @@ def delimit_daemon_run(iterations: int = 1, dry_run: bool = True) -> Dict[str, A
6199
6381
  max_iterations=iterations, interval_seconds=5, dry_run=dry_run,
6200
6382
  ))
6201
6383
 
6384
+ @mcp.tool()
6385
+ def delimit_build_loop(action: str = "run", session_id: str = "") -> Dict[str, Any]:
6386
+ """Execute the governed continuous build loop (LED-239).
6387
+
6388
+ Requirements:
6389
+ - root ledger in /root/.delimit is authoritative
6390
+ - select only build-safe open items
6391
+ - resolve venture + repo before dispatch
6392
+ - use Delimit swarm/governance as control plane
6393
+ - enforce max-iteration, max-error, and max-cost safeguards
6394
+
6395
+ Args:
6396
+ action: 'init' to start a session, 'run' to execute one iteration.
6397
+ session_id: Optional session ID to continue.
6398
+ """
6399
+ from ai.loop_engine import create_governed_session, run_governed_iteration
6400
+
6401
+ if action == "init":
6402
+ return _with_next_steps("build_loop", create_governed_session())
6403
+ else:
6404
+ if not session_id:
6405
+ # Try to pick up existing or create new
6406
+ session_id = create_governed_session()["session_id"]
6407
+ return _with_next_steps("build_loop", run_governed_iteration(session_id))
6408
+
6202
6409
 
6203
6410
  @mcp.tool()
6204
6411
  def delimit_daemon_classify(item_id: str = "") -> Dict[str, Any]:
@@ -6238,7 +6445,6 @@ def delimit_daemon_classify(item_id: str = "") -> Dict[str, Any]:
6238
6445
  # ═══════════════════════════════════════════════════════════════════════
6239
6446
 
6240
6447
 
6241
- @_internal_tool()
6242
6448
  @mcp.tool()
6243
6449
  def delimit_inbox_daemon(action: str = "status") -> Dict[str, Any]:
6244
6450
  """Control the inbox polling daemon for email governance (Pro).
@@ -6261,6 +6467,26 @@ def delimit_inbox_daemon(action: str = "status") -> Dict[str, Any]:
6261
6467
  return _with_next_steps("inbox_daemon", get_daemon_status())
6262
6468
 
6263
6469
 
6470
+ @mcp.tool()
6471
+ def delimit_social_daemon(action: str = "status") -> Dict[str, Any]:
6472
+ """Control the social sensing daemon for Delimit (Pro).
6473
+
6474
+ Runs social discovery scans every 15 minutes, deduplicates findings,
6475
+ and emits HTML draft emails. Also monitors Reddit replies.
6476
+
6477
+ Args:
6478
+ action: 'start' (begin scanning), 'stop' (halt scanning),
6479
+ 'status' (show daemon state, last scan, targets found).
6480
+ """
6481
+ from ai.social_daemon import start_daemon, stop_daemon, get_daemon_status
6482
+
6483
+ if action == "start":
6484
+ return _with_next_steps("social_daemon", start_daemon())
6485
+ elif action == "stop":
6486
+ return _with_next_steps("social_daemon", stop_daemon())
6487
+ else:
6488
+ return _with_next_steps("social_daemon", get_daemon_status())
6489
+
6264
6490
  # ═══════════════════════════════════════════════════════════════════════
6265
6491
  # LED-187: Shareable Governance Config — export / import
6266
6492
  # ═══════════════════════════════════════════════════════════════════════
@@ -6389,7 +6615,6 @@ def delimit_config_import(
6389
6615
  # ═══════════════════════════════════════════════════════════════════════
6390
6616
 
6391
6617
 
6392
- @_ops_pack_tool()
6393
6618
  @mcp.tool()
6394
6619
  def delimit_screen_record(mode: str = "browser", url: str = "", name: str = "recording",
6395
6620
  duration: int = 30, script: str = "") -> Dict[str, Any]:
@@ -6434,7 +6659,6 @@ def delimit_screen_record(mode: str = "browser", url: str = "", name: str = "rec
6434
6659
  return _with_next_steps("screen_record", result)
6435
6660
 
6436
6661
 
6437
- @_ops_pack_tool()
6438
6662
  @mcp.tool()
6439
6663
  def delimit_screenshot(url: str, name: str = "screenshot") -> Dict[str, Any]:
6440
6664
  """Take a screenshot of a URL using headless Chromium (Pro).
@@ -6488,7 +6712,6 @@ def delimit_changelog(old_spec: str = "", new_spec: str = "", format: str = "mar
6488
6712
  ))
6489
6713
 
6490
6714
 
6491
- @_ops_pack_tool()
6492
6715
  @mcp.tool()
6493
6716
  def delimit_notify(channel: str = "webhook", message: str = "",
6494
6717
  webhook_url: str = "", subject: str = "",
@@ -6519,9 +6742,9 @@ def delimit_notify(channel: str = "webhook", message: str = "",
6519
6742
  subject: Subject line (email only). Use [ACTION], [INFO], [ALERT] prefix.
6520
6743
  event_type: Event category for filtering.
6521
6744
  to: Recipient email address (email only). Overrides default DELIMIT_SMTP_TO.
6522
- Send to any address — leave empty for default (owner@example.com).
6745
+ Send to any address — leave empty for default.
6523
6746
  from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
6524
- (e.g. 'pro@delimit.ai', 'admin@wire.report'). Email only.
6747
+ (e.g. 'notifications@example.com'). Email only.
6525
6748
  """
6526
6749
  from ai.notify import send_notification
6527
6750
  return _with_next_steps("notify", _safe_call(
@@ -6620,7 +6843,6 @@ def delimit_notify_routing(
6620
6843
  })
6621
6844
 
6622
6845
 
6623
- @_internal_tool()
6624
6846
  @mcp.tool()
6625
6847
  def delimit_notify_inbox(action: str = "status", limit: int = 10,
6626
6848
  process: bool = True) -> Dict[str, Any]:
@@ -6912,6 +7134,27 @@ def delimit_next_task(venture: str = "", max_risk: str = "", session_id: str = "
6912
7134
  return _with_next_steps("next_task", result)
6913
7135
 
6914
7136
 
7137
+ @mcp.tool()
7138
+ def delimit_ledger_propose(venture: str = "", focus: str = "",
7139
+ max_items: int = 5) -> Dict[str, Any]:
7140
+ """Propose new ledger items based on signals, completed work, and gaps.
7141
+
7142
+ Analyzes repo state, sensing signals, and completed work to suggest
7143
+ 3-5 new items with rationale. Run at end of build loops or when
7144
+ the queue is empty to keep momentum.
7145
+
7146
+ Works across all AI models via MCP.
7147
+
7148
+ Args:
7149
+ venture: Focus on a specific venture (auto-detects if empty).
7150
+ focus: Optional area filter — "outreach", "engineering", "security", etc.
7151
+ max_items: Maximum proposals to generate (default 5).
7152
+ """
7153
+ from ai.ledger_propose import propose_items
7154
+ result = _safe_call(propose_items, venture=venture, focus=focus, max_items=max_items)
7155
+ return _with_next_steps("ledger_propose", result)
7156
+
7157
+
6915
7158
  @mcp.tool()
6916
7159
  def delimit_task_complete(task_id: str, result: str = "", cost_incurred: float = 0.0,
6917
7160
  error: str = "", session_id: str = "", venture: str = "") -> Dict[str, Any]:
@@ -7242,3 +7485,22 @@ def main():
7242
7485
  """Entry point for `delimit-mcp` console script."""
7243
7486
  import asyncio
7244
7487
  asyncio.run(run_mcp_server(mcp))
7488
+
7489
+ @mcp.tool()
7490
+ def delimit_reddit_fetch_thread(thread_id: str) -> Dict[str, Any]:
7491
+ """Surgically fetch a single Reddit thread by ID (e.g. 'OSKJVH7f35')."""
7492
+ from ai.reddit_scanner import fetch_thread, score_and_classify
7493
+
7494
+ # Strip URL parts if user passed a full link
7495
+ if "comments/" in thread_id:
7496
+ parts = thread_id.split("comments/")[1].split("/")
7497
+ thread_id = parts[0]
7498
+ elif thread_id.startswith("http"):
7499
+ thread_id = thread_id.split("/")[-1].split("?")[0]
7500
+
7501
+ thread = fetch_thread(thread_id)
7502
+ if not thread:
7503
+ return {"error": f"Could not find thread with ID {thread_id}"}
7504
+
7505
+ scored = score_and_classify([thread])
7506
+ return {"thread": scored[0] if scored else thread}