delimit-cli 3.15.0 → 3.15.2

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.
@@ -0,0 +1,187 @@
1
+ """Prompt Playbook — versioned, reusable prompt templates (STR-048).
2
+
3
+ Save prompts as named commands that work across any AI assistant.
4
+ Share them with your team. Version them per model.
5
+
6
+ Storage: ~/.delimit/playbooks/
7
+ Format: YAML files with name, prompt, variables, model hints.
8
+
9
+ Focus group origin: "Prompt management is a total disaster.
10
+ Slack channels, Notion docs, personal text files."
11
+ """
12
+
13
+ import json
14
+ import time
15
+ import re
16
+ from pathlib import Path
17
+ from typing import Any, Dict, List, Optional
18
+
19
+ PLAYBOOKS_DIR = Path.home() / ".delimit" / "playbooks"
20
+
21
+
22
+ def _ensure_dir():
23
+ PLAYBOOKS_DIR.mkdir(parents=True, exist_ok=True)
24
+
25
+
26
+ def _playbook_path(name: str) -> Path:
27
+ safe = re.sub(r'[^a-zA-Z0-9_-]', '_', name.lower().strip())
28
+ return PLAYBOOKS_DIR / f"{safe}.json"
29
+
30
+
31
+ def save_playbook(
32
+ name: str,
33
+ prompt: str,
34
+ description: str = "",
35
+ variables: Optional[List[str]] = None,
36
+ model_hint: str = "",
37
+ tags: Optional[List[str]] = None,
38
+ ) -> Dict[str, Any]:
39
+ """Save a named prompt template.
40
+
41
+ Variables use {{var_name}} syntax in the prompt text.
42
+ Example: "Generate tests for {{file_path}} using {{framework}}"
43
+ """
44
+ if not name or not name.strip():
45
+ return {"error": "name is required"}
46
+ if not prompt or not prompt.strip():
47
+ return {"error": "prompt is required"}
48
+
49
+ name = name.strip()
50
+ _ensure_dir()
51
+
52
+ # Auto-detect variables from {{var}} patterns
53
+ detected_vars = re.findall(r'\{\{(\w+)\}\}', prompt)
54
+ all_vars = list(set((variables or []) + detected_vars))
55
+
56
+ playbook = {
57
+ "name": name,
58
+ "prompt": prompt,
59
+ "description": description or f"Playbook: {name}",
60
+ "variables": all_vars,
61
+ "model_hint": model_hint,
62
+ "tags": tags or [],
63
+ "version": 1,
64
+ "created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
65
+ "updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
66
+ }
67
+
68
+ pb_path = _playbook_path(name)
69
+
70
+ # If exists, increment version
71
+ if pb_path.exists():
72
+ try:
73
+ existing = json.loads(pb_path.read_text())
74
+ playbook["version"] = existing.get("version", 0) + 1
75
+ playbook["created_at"] = existing.get("created_at", playbook["created_at"])
76
+ except (json.JSONDecodeError, OSError):
77
+ pass
78
+
79
+ pb_path.write_text(json.dumps(playbook, indent=2))
80
+
81
+ return {
82
+ "status": "saved",
83
+ "name": name,
84
+ "version": playbook["version"],
85
+ "variables": all_vars,
86
+ "path": str(pb_path),
87
+ "message": f"Playbook '{name}' saved (v{playbook['version']})",
88
+ }
89
+
90
+
91
+ def run_playbook(
92
+ name: str,
93
+ variables: Optional[Dict[str, str]] = None,
94
+ ) -> Dict[str, Any]:
95
+ """Load and render a named playbook with variables filled in.
96
+
97
+ Returns the rendered prompt ready to send to an AI model.
98
+ """
99
+ if not name or not name.strip():
100
+ return {"error": "name is required"}
101
+
102
+ pb_path = _playbook_path(name.strip())
103
+ if not pb_path.exists():
104
+ # Try fuzzy match
105
+ matches = list(PLAYBOOKS_DIR.glob("*.json"))
106
+ suggestions = []
107
+ for m in matches:
108
+ try:
109
+ pb = json.loads(m.read_text())
110
+ suggestions.append(pb["name"])
111
+ except:
112
+ pass
113
+ return {
114
+ "error": f"Playbook '{name}' not found",
115
+ "available": suggestions[:10],
116
+ }
117
+
118
+ try:
119
+ playbook = json.loads(pb_path.read_text())
120
+ except (json.JSONDecodeError, OSError) as e:
121
+ return {"error": f"Failed to read playbook: {e}"}
122
+
123
+ prompt = playbook["prompt"]
124
+ vars_used = variables or {}
125
+
126
+ # Fill in variables
127
+ missing = []
128
+ for var in playbook.get("variables", []):
129
+ if var in vars_used:
130
+ prompt = prompt.replace(f"{{{{{var}}}}}", vars_used[var])
131
+ else:
132
+ missing.append(var)
133
+
134
+ return {
135
+ "status": "ready",
136
+ "name": playbook["name"],
137
+ "version": playbook["version"],
138
+ "rendered_prompt": prompt,
139
+ "model_hint": playbook.get("model_hint", ""),
140
+ "missing_variables": missing,
141
+ "message": f"Playbook '{name}' ready" + (f" (missing: {', '.join(missing)})" if missing else ""),
142
+ }
143
+
144
+
145
+ def list_playbooks(tag: str = "") -> Dict[str, Any]:
146
+ """List all saved playbooks, optionally filtered by tag."""
147
+ _ensure_dir()
148
+ playbooks = []
149
+
150
+ for pb_file in sorted(PLAYBOOKS_DIR.glob("*.json")):
151
+ try:
152
+ pb = json.loads(pb_file.read_text())
153
+ if tag and tag not in pb.get("tags", []):
154
+ continue
155
+ playbooks.append({
156
+ "name": pb["name"],
157
+ "description": pb.get("description", ""),
158
+ "version": pb.get("version", 1),
159
+ "variables": pb.get("variables", []),
160
+ "model_hint": pb.get("model_hint", ""),
161
+ "tags": pb.get("tags", []),
162
+ })
163
+ except (json.JSONDecodeError, OSError):
164
+ pass
165
+
166
+ return {
167
+ "status": "ok",
168
+ "playbooks": playbooks,
169
+ "total": len(playbooks),
170
+ }
171
+
172
+
173
+ def delete_playbook(name: str) -> Dict[str, Any]:
174
+ """Delete a named playbook."""
175
+ if not name:
176
+ return {"error": "name is required"}
177
+
178
+ pb_path = _playbook_path(name.strip())
179
+ if not pb_path.exists():
180
+ return {"error": f"Playbook '{name}' not found"}
181
+
182
+ pb_path.unlink()
183
+ return {
184
+ "status": "deleted",
185
+ "name": name,
186
+ "message": f"Playbook '{name}' deleted",
187
+ }
@@ -19,6 +19,30 @@ All tools follow the Adapter Boundary Contract v1.0:
19
19
  - Stateless between calls
20
20
  """
21
21
 
22
+ # ── Founder Voice Doctrine ──────────────────────────────────────────────
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
+ #
26
+ # Core: serious builder/operator, not a marketer. Credibility over persuasion.
27
+ # Truth over excitement. Concrete mechanisms, not vague benefits.
28
+ # No hype words (revolutionary, seamless, unlock, supercharge, game-changing).
29
+ # Sound earned. Respect the reader. Acknowledge tradeoffs.
30
+ # "Measured conviction" not "sales energy."
31
+ # "Serious founder/architect/controller" not "visionary hype founder."
32
+ #
33
+ # Quality bar: Does this sound like a real operator with consequences
34
+ # attached to decisions? Would this still read well a year from now?
35
+ # ────────────────────────────────────────────────────────────────────────
36
+
37
+ FOUNDER_VOICE_HYPE_WORDS = {
38
+ "revolutionary", "game-changing", "world-class", "cutting-edge",
39
+ "best-in-class", "seamless", "unlock", "supercharge", "next-generation",
40
+ "magical", "delightful", "effortless", "frictionless", "transformative",
41
+ "paradigm shift", "visionary", "category-defining", "industry-leading",
42
+ "innovative", "reimagine", "future of", "changing the game",
43
+ "empowering teams", "built for everyone",
44
+ }
45
+
22
46
  import json
23
47
  import logging
24
48
  import os
@@ -899,7 +923,10 @@ NEXT_STEPS_REGISTRY: Dict[str, List[Dict[str, Any]]] = {
899
923
  "version": [],
900
924
  "help": [],
901
925
  "diagnose": [],
902
- "activate": [],
926
+ "activate": [
927
+ {"tool": "delimit_init", "reason": "Initialize governance if not set up", "suggested_args": {"preset": "default"}, "is_premium": False},
928
+ {"tool": "delimit_diagnose", "reason": "Deep-dive into any failing checks", "suggested_args": {}, "is_premium": False},
929
+ ],
903
930
  "license_status": [],
904
931
  }
905
932
 
@@ -1131,6 +1158,7 @@ def _detect_environment() -> Dict[str, Any]:
1131
1158
 
1132
1159
 
1133
1160
  _inbox_daemon_autostarted = False
1161
+ _toolcard_cache_autoregistered = False
1134
1162
 
1135
1163
 
1136
1164
  def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
@@ -1138,6 +1166,7 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
1138
1166
 
1139
1167
  The governance loop:
1140
1168
  1. Auto-start inbox daemon on first tool call (model-agnostic)
1169
+ 1b. Auto-register tool schemas with toolcard cache (LED-219)
1141
1170
  2. Emit event for dashboard tracking
1142
1171
  3. STR-052: Policy kernel gate (blocks high-risk actions without approval)
1143
1172
  4. Check Pro license gate (blocks if not authorized)
@@ -1156,6 +1185,44 @@ def _with_next_steps(tool_name: str, result: Dict[str, Any]) -> Dict[str, Any]:
1156
1185
  except Exception as e:
1157
1186
  logger.warning("Inbox daemon auto-start failed: %s", e)
1158
1187
 
1188
+ # LED-219: Auto-register tool schemas with toolcard cache on first call
1189
+ global _toolcard_cache_autoregistered
1190
+ if not _toolcard_cache_autoregistered:
1191
+ _toolcard_cache_autoregistered = True
1192
+ try:
1193
+ from ai.toolcard_cache import get_cache
1194
+ _tc = get_cache()
1195
+ # Build schema list from mcp's registered tools
1196
+ _tool_schemas = []
1197
+ for _tname, _tfn in getattr(mcp, '_tool_manager', {}).items() if hasattr(mcp, '_tool_manager') else []:
1198
+ _tool_schemas.append({"name": _tname})
1199
+ if not _tool_schemas:
1200
+ # Fallback: just record the current tool call
1201
+ _tc.record_call(tool_name)
1202
+ logger.info("Toolcard cache auto-registered on first tool call")
1203
+ except Exception as e:
1204
+ logger.warning("Toolcard cache auto-register failed: %s", e)
1205
+
1206
+ # LED-219: Track every tool call for session analytics
1207
+ try:
1208
+ from ai.toolcard_cache import get_cache as _get_tc
1209
+ _get_tc().record_call(tool_name)
1210
+ except Exception:
1211
+ pass
1212
+
1213
+ # Voice doctrine check — flag hype words in outgoing text
1214
+ if isinstance(result, dict):
1215
+ _text_fields = [result.get("text", ""), result.get("message", ""),
1216
+ result.get("explanation", ""), result.get("changelog", ""),
1217
+ result.get("content", "")]
1218
+ _all_text = " ".join(str(f) for f in _text_fields if f).lower()
1219
+ _found_hype = [w for w in FOUNDER_VOICE_HYPE_WORDS if w in _all_text]
1220
+ if _found_hype:
1221
+ result.setdefault("voice_warnings", []).append(
1222
+ f"VOICE DOCTRINE: Hype words detected: {', '.join(_found_hype)}. "
1223
+ f"Rewrite with concrete mechanisms, not vague benefits."
1224
+ )
1225
+
1159
1226
  # Emit event for real-time dashboard
1160
1227
  _emit_event(tool_name, result)
1161
1228
 
@@ -3532,33 +3599,92 @@ TOOL_HELP = {
3532
3599
 
3533
3600
  STANDARD_WORKFLOWS = [
3534
3601
  {
3535
- "name": "Project Onboarding",
3536
- "description": "Set up governance for a new project",
3537
- "steps": ["delimit_init", "delimit_gov_health", "delimit_lint", "delimit_test_coverage", "delimit_security_scan"],
3602
+ "name": "Resume Work",
3603
+ "pain": "You switched models or sessions and lost all context",
3604
+ "fix": "Pick up exactly where you left off",
3605
+ "steps": ["delimit_session_history", "delimit_ledger_context", "delimit_memory_search"],
3538
3606
  },
3539
3607
  {
3540
- "name": "Pre-Commit Check",
3541
- "description": "Validate changes before committing",
3542
- "steps": ["delimit_lint", "delimit_test_coverage", "delimit_semver"],
3608
+ "name": "Catch Breaking Changes",
3609
+ "pain": "Your AI agent deployed a breaking API change and nobody caught it",
3610
+ "fix": "Detect and block breaking changes before merge",
3611
+ "steps": ["delimit_lint", "delimit_diff", "delimit_semver"],
3543
3612
  },
3544
3613
  {
3545
- "name": "Security Audit",
3546
- "description": "Full security scan with evidence collection",
3547
- "steps": ["delimit_security_scan", "delimit_evidence_collect", "delimit_evidence_verify"],
3614
+ "name": "Remember Across Models",
3615
+ "pain": "Every new session starts from zero — your agent forgot everything",
3616
+ "fix": "Store and recall context across any AI assistant",
3617
+ "steps": ["delimit_memory_store", "delimit_memory_search", "delimit_session_handoff"],
3548
3618
  },
3549
3619
  {
3550
- "name": "API Change Review",
3551
- "description": "Review and document an API change",
3552
- "steps": ["delimit_diff", "delimit_semver", "delimit_explain", "delimit_lint"],
3620
+ "name": "Track What Needs Doing",
3621
+ "pain": "Tasks get lost when context windows fill up",
3622
+ "fix": "Persistent ledger that survives session resets",
3623
+ "steps": ["delimit_ledger_add", "delimit_ledger_context", "delimit_ledger_done"],
3553
3624
  },
3554
3625
  {
3555
- "name": "Deploy Pipeline",
3556
- "description": "Build, publish, and verify a deployment",
3557
- "steps": ["delimit_deploy_build", "delimit_deploy_publish", "delimit_deploy_verify"],
3626
+ "name": "Watch for Drift",
3627
+ "pain": "Your API spec changed without governance review",
3628
+ "fix": "Continuous monitoring with alerts on drift",
3629
+ "steps": ["delimit_drift_check", "delimit_scan", "delimit_gov_health"],
3558
3630
  },
3559
3631
  ]
3560
3632
 
3561
3633
 
3634
+ @mcp.tool()
3635
+ def delimit_playbook(action: str = "list", name: str = "", prompt: str = "",
3636
+ description: str = "", variables: str = "",
3637
+ model_hint: str = "", tags: str = "") -> Dict[str, Any]:
3638
+ """Manage reusable prompt templates — save, run, list, delete.
3639
+
3640
+ Save your best prompts as named commands. Use {{variables}} for dynamic parts.
3641
+ Works across all AI assistants through the shared MCP workspace.
3642
+
3643
+ Examples:
3644
+ Save: delimit_playbook(action="save", name="test-gen", prompt="Generate Jest tests for {{file}}")
3645
+ Run: delimit_playbook(action="run", name="test-gen", variables="file=src/auth.ts")
3646
+ List: delimit_playbook(action="list")
3647
+
3648
+ Args:
3649
+ action: "save", "run", "list", or "delete".
3650
+ name: Playbook name (required for save/run/delete).
3651
+ prompt: Prompt template with {{variable}} placeholders (save only).
3652
+ description: Short description of what this playbook does.
3653
+ variables: For run: comma-separated key=value pairs. For save: comma-separated variable names.
3654
+ model_hint: Suggested model (e.g. "claude-opus" for complex tasks).
3655
+ tags: Comma-separated tags for organization.
3656
+ """
3657
+ from ai.playbook import save_playbook, run_playbook, list_playbooks, delete_playbook
3658
+
3659
+ action = action.lower().strip()
3660
+
3661
+ if action == "save":
3662
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
3663
+ var_list = [v.strip() for v in variables.split(",") if v.strip()] if variables else None
3664
+ return _with_next_steps("playbook", _safe_call(
3665
+ save_playbook, name=name, prompt=prompt, description=description,
3666
+ variables=var_list, model_hint=model_hint, tags=tag_list,
3667
+ ))
3668
+
3669
+ if action == "run":
3670
+ var_dict = {}
3671
+ if variables:
3672
+ for pair in variables.split(","):
3673
+ if "=" in pair:
3674
+ k, v = pair.split("=", 1)
3675
+ var_dict[k.strip()] = v.strip()
3676
+ return _with_next_steps("playbook", _safe_call(
3677
+ run_playbook, name=name, variables=var_dict,
3678
+ ))
3679
+
3680
+ if action == "delete":
3681
+ return _with_next_steps("playbook", _safe_call(delete_playbook, name=name))
3682
+
3683
+ # Default: list
3684
+ tag_filter = tags.strip() if tags else ""
3685
+ return _with_next_steps("playbook", _safe_call(list_playbooks, tag=tag_filter))
3686
+
3687
+
3562
3688
  @mcp.tool()
3563
3689
  def delimit_help(tool_name: str = "") -> Dict[str, Any]:
3564
3690
  """Get help for a Delimit tool — what it does, parameters, and examples.
@@ -3569,12 +3695,13 @@ def delimit_help(tool_name: str = "") -> Dict[str, Any]:
3569
3695
  if not tool_name:
3570
3696
  total = _count_registered_tools()
3571
3697
  return _with_next_steps("help", {
3572
- "message": f"Delimit has {total} tools. Here are the most useful ones to start with:",
3698
+ "message": "What problem are you solving?",
3699
+ "workflows": [
3700
+ {"name": w["name"], "pain": w["pain"], "start_with": w["steps"][0]}
3701
+ for w in STANDARD_WORKFLOWS
3702
+ ],
3703
+ "tip": "Tell me what you're trying to do — I'll suggest the right workflow.",
3573
3704
  "total_tools": total,
3574
- "essential_tools": {k: v["desc"] for k, v in TOOL_HELP.items()},
3575
- "workflows": STANDARD_WORKFLOWS,
3576
- "tip": "Run delimit_help(tool_name='lint') for detailed help on a specific tool.",
3577
- "all_tools": "Run delimit_version() for the complete list.",
3578
3705
  })
3579
3706
 
3580
3707
  # Normalize name
@@ -3721,14 +3848,22 @@ def delimit_diagnose(project_path: str = ".") -> Dict[str, Any]:
3721
3848
 
3722
3849
 
3723
3850
  @mcp.tool()
3724
- def delimit_activate(license_key: str) -> Dict[str, Any]:
3725
- """Activate a Delimit Pro license key.
3851
+ def delimit_activate(license_key: str = "", project_path: str = ".", auto_permissions: bool = True) -> Dict[str, Any]:
3852
+ """Activate Delimit and run a readiness checklist.
3853
+
3854
+ Performs a comprehensive activation check: license validation, MCP server
3855
+ status, governance init, test smoke, permission auto-config, and premium
3856
+ feature availability. Skipped checks (premium on free tier, no test
3857
+ framework) do NOT count against the score.
3726
3858
 
3727
3859
  Args:
3728
- license_key: The license key to activate (e.g. DELIMIT-XXXX-XXXX-XXXX).
3860
+ license_key: Optional license key to activate Pro (e.g. DELIMIT-XXXX-XXXX-XXXX). Leave empty to check free-tier readiness.
3861
+ project_path: Project directory to check.
3862
+ auto_permissions: Auto-configure AI assistant permissions for Delimit tools (default True).
3729
3863
  """
3730
- from ai.license import activate_license
3731
- return _with_next_steps("activate", activate_license(license_key))
3864
+ from ai.activate_helpers import build_checklist
3865
+ result = build_checklist(license_key=license_key, project_path=project_path, auto_permissions=auto_permissions)
3866
+ return _with_next_steps("activate", result)
3732
3867
 
3733
3868
 
3734
3869
  @mcp.tool()
@@ -4089,6 +4224,85 @@ def delimit_ventures() -> Dict[str, Any]:
4089
4224
  return list_ventures()
4090
4225
 
4091
4226
 
4227
+ # ═══════════════════════════════════════════════════════════════════════
4228
+ # SESSION PHOENIX — Cross-Model Resurrection (LED-218)
4229
+ # ═══════════════════════════════════════════════════════════════════════
4230
+
4231
+
4232
+ @mcp.tool()
4233
+ def delimit_soul_capture(
4234
+ active_task: str = "",
4235
+ decisions: str = "",
4236
+ key_context: str = "",
4237
+ blockers: str = "",
4238
+ next_steps: str = "",
4239
+ task_status: str = "in_progress",
4240
+ tokens_used: int = 0,
4241
+ context_fullness: float = 0.0,
4242
+ ) -> Dict[str, Any]:
4243
+ """Capture current session state as a 'soul' for cross-model resurrection (Pro).
4244
+
4245
+ Save what you're working on so the next session (in any model) picks up
4246
+ where you left off. Auto-detects git state and files changed.
4247
+
4248
+ Args:
4249
+ active_task: What you're currently working on (one line).
4250
+ decisions: Comma-separated key decisions made this session.
4251
+ key_context: Comma-separated important context for next session.
4252
+ blockers: Comma-separated blockers.
4253
+ next_steps: Comma-separated next steps.
4254
+ task_status: in_progress, blocked, or almost_done.
4255
+ tokens_used: Estimated tokens consumed this session.
4256
+ context_fullness: 0.0-1.0 how full the context window is.
4257
+ """
4258
+ from ai.session_phoenix import capture_soul as _capture
4259
+
4260
+ def _split(val: str) -> List[str]:
4261
+ if not val or not val.strip():
4262
+ return []
4263
+ return [s.strip() for s in val.split(",") if s.strip()]
4264
+
4265
+ soul = _capture(
4266
+ active_task=active_task,
4267
+ decisions=_split(decisions),
4268
+ key_context=_split(key_context),
4269
+ blockers=_split(blockers),
4270
+ next_steps=_split(next_steps),
4271
+ source_model=_detect_model(),
4272
+ task_status=task_status,
4273
+ tokens_used=tokens_used,
4274
+ context_fullness=context_fullness,
4275
+ )
4276
+
4277
+ from dataclasses import asdict
4278
+ return _with_next_steps("soul_capture", {
4279
+ "status": "captured",
4280
+ "soul_id": soul.soul_id,
4281
+ "project": soul.project_path,
4282
+ "active_task": soul.active_task,
4283
+ "files_modified": len(soul.files_modified),
4284
+ "files_created": len(soul.files_created),
4285
+ "uncommitted_changes": soul.uncommitted_changes,
4286
+ "message": f"Soul {soul.soul_id} captured. Run delimit_revive in any model to restore.",
4287
+ })
4288
+
4289
+
4290
+ @mcp.tool()
4291
+ def delimit_revive(project_path: str = "", soul_id: str = "") -> Dict[str, Any]:
4292
+ """Revive the last session's state in any model (Pro).
4293
+
4294
+ Run this at the start of a new session to pick up where you left off.
4295
+ Works across Claude Code, Codex, Gemini CLI, and Cursor.
4296
+
4297
+ Args:
4298
+ project_path: Project to revive. Auto-detects from cwd.
4299
+ soul_id: Specific soul to revive. Empty = latest.
4300
+ """
4301
+ from ai.session_phoenix import revive as _revive
4302
+ result = _revive(project_path=project_path, soul_id=soul_id)
4303
+ return _with_next_steps("revive", result)
4304
+
4305
+
4092
4306
  # ═══════════════════════════════════════════════════════════════════════
4093
4307
  # DELIBERATION (Multi-Round Consensus)
4094
4308
  # ═══════════════════════════════════════════════════════════════════════
@@ -4352,6 +4566,59 @@ def _extract_deliberation_actions(result: Dict, question: str) -> List[Dict[str,
4352
4566
  return actions[:10]
4353
4567
 
4354
4568
 
4569
+ @mcp.tool()
4570
+ def delimit_audit(
4571
+ target: str = "",
4572
+ target_type: str = "file",
4573
+ lenses: str = "",
4574
+ ) -> Dict[str, Any]:
4575
+ """Cross-model code audit -- 3 models, 3 lenses, synthesized findings (Pro).
4576
+
4577
+ Run security, correctness, and governance reviews through different AI models
4578
+ simultaneously. Agreements are high-confidence. Disagreements surface tradeoffs.
4579
+
4580
+ "Trust through triangulation."
4581
+
4582
+ Args:
4583
+ target: File path, git diff output, or code snippet to audit.
4584
+ target_type: "file" (reads file), "diff" (git diff text), "snippet" (inline code).
4585
+ lenses: Comma-separated lenses to apply (security, correctness, governance). Default: all.
4586
+ """
4587
+ from ai.license import require_premium
4588
+ gate = require_premium("audit")
4589
+ if gate:
4590
+ return gate
4591
+
4592
+ if not target.strip():
4593
+ return {"status": "error", "error": "No target provided. Pass a file path, diff, or code snippet."}
4594
+
4595
+ from ai.cross_model_audit import audit as run_audit
4596
+
4597
+ lens_list = [l.strip() for l in lenses.split(",") if l.strip()] if lenses else None
4598
+
4599
+ result = run_audit(
4600
+ target=target,
4601
+ target_type=target_type,
4602
+ lenses=lens_list,
4603
+ )
4604
+
4605
+ if result.get("status") == "error":
4606
+ return result
4607
+
4608
+ synthesis = result.get("synthesis", {})
4609
+ return _with_next_steps("audit", {
4610
+ "status": "ok",
4611
+ "formatted_output": result.get("formatted", ""),
4612
+ "agreements": len(synthesis.get("agreements", [])),
4613
+ "unique_findings": len(synthesis.get("unique_findings", [])),
4614
+ "disagreements": len(synthesis.get("disagreements", [])),
4615
+ "summary": synthesis.get("summary", ""),
4616
+ "saved_to": result.get("saved_to", ""),
4617
+ "elapsed_seconds": result.get("elapsed_seconds", 0),
4618
+ "note": "Review the formatted output above. Agreements are high-confidence. Unique findings may be blind-spot catches.",
4619
+ })
4620
+
4621
+
4355
4622
  @mcp.tool()
4356
4623
  def delimit_release_sync(action: str = "audit") -> Dict[str, Any]:
4357
4624
  """Audit or sync all public surfaces for consistency (Pro)."""
@@ -5477,6 +5744,66 @@ def delimit_social_target_config(
5477
5744
  return {"error": f"Unknown action: {action}. Supported: status, detect, update, add_subreddits"}
5478
5745
 
5479
5746
 
5747
+ @mcp.tool()
5748
+ def delimit_reddit_scan(sort: str = "hot", limit: int = 10) -> Dict[str, Any]:
5749
+ """Bulk scan 25+ subreddits for outreach targets (Pro).
5750
+
5751
+ Scans via residential proxy. Rate limited to 1 req/2sec.
5752
+ Returns ranked, categorized targets ready for comment drafting.
5753
+
5754
+ IMPORTANT -- TOOL CHAINING RULE:
5755
+ After scanning, the AI MUST process high_priority targets:
5756
+ 1. Draft replies via delimit_social_post or delimit_notify
5757
+ 2. Create ledger items for strategic opportunities via delimit_ledger_add
5758
+ Never just return targets and stop. Always chain to the next action.
5759
+
5760
+ Args:
5761
+ sort: Reddit sort order (hot, new, top).
5762
+ limit: Posts per subreddit (default 10, max 25).
5763
+ """
5764
+ from ai.reddit_scanner import scan_all
5765
+
5766
+ capped_limit = min(max(1, limit), 25)
5767
+ if sort not in ("hot", "new", "top", "rising"):
5768
+ sort = "hot"
5769
+
5770
+ result = scan_all(limit_per_sub=capped_limit, sort=sort)
5771
+ return _with_next_steps("social_target", result)
5772
+
5773
+
5774
+ @_internal_tool()
5775
+ @mcp.tool()
5776
+ def delimit_github_scan(
5777
+ cadence: str = "pulse",
5778
+ limit: int = 20,
5779
+ ) -> Dict[str, Any]:
5780
+ """Scan GitHub for adoption leads, competitive intel, and repo health (Pro).
5781
+
5782
+ Three cadences:
5783
+ pulse: Own repo health (stars, forks, issues, traffic). Fast, run often.
5784
+ hunter: Competitor users, adoption leads, pain threads. Medium, run hourly.
5785
+ deep: Full ecosystem intel. Slow, run daily.
5786
+
5787
+ IMPORTANT -- TOOL CHAINING RULE:
5788
+ After scanning, the AI MUST process high-score findings:
5789
+ 1. Auto-ledger items (score >= 75 competitor users) via delimit_ledger_add
5790
+ 2. Pain threads with existing_feature relevance via delimit_notify
5791
+ Never just return findings and stop. Always chain to the next action.
5792
+
5793
+ Args:
5794
+ cadence: pulse, hunter, or deep.
5795
+ limit: Max results per search query (default 20, max 30).
5796
+ """
5797
+ from ai.github_scanner import scan
5798
+
5799
+ if cadence not in ("pulse", "hunter", "deep"):
5800
+ cadence = "pulse"
5801
+ capped_limit = min(max(1, limit), 30)
5802
+
5803
+ result = scan(cadence=cadence, limit=capped_limit)
5804
+ return _with_next_steps("github_scan", result)
5805
+
5806
+
5480
5807
  # ═══════════════════════════════════════════════════════════════════════
5481
5808
  # CONTENT ENGINE — Autonomous video + tweet pipeline (Pro)
5482
5809
  # ═══════════════════════════════════════════════════════════════════════
@@ -5886,9 +6213,9 @@ def delimit_notify(channel: str = "webhook", message: str = "",
5886
6213
  subject: Subject line (email only). Use [ACTION], [INFO], [ALERT] prefix.
5887
6214
  event_type: Event category for filtering.
5888
6215
  to: Recipient email address (email only). Overrides default DELIMIT_SMTP_TO.
5889
- Send to any address — leave empty for default (configured-email@example.com).
6216
+ Send to any address — leave empty for default (owner@example.com).
5890
6217
  from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
5891
- (e.g. 'pro@delimit.ai', '<configured-email>'). Email only.
6218
+ (e.g. 'pro@example.com', 'admin@example.com'). Email only.
5892
6219
  """
5893
6220
  from ai.notify import send_notification
5894
6221
  return _with_next_steps("notify", _safe_call(
@@ -5994,7 +6321,7 @@ def delimit_notify_inbox(action: str = "status", limit: int = 10,
5994
6321
  """Check inbound email inbox, classify, and route (Pro).
5995
6322
 
5996
6323
  Polls pro@delimit.ai via IMAP. Classifies emails as owner-action
5997
- (forwards to configured-email@example.com) or non-owner (stays in inbox).
6324
+ (forwards to owner@example.com) or non-owner (stays in inbox).
5998
6325
 
5999
6326
  Args:
6000
6327
  action: 'status' (show inbox state), 'poll' (classify and optionally forward),
@@ -6344,6 +6671,253 @@ def delimit_loop_config(session_id: str = "", max_iterations: int = 0,
6344
6671
  return _with_next_steps("loop_config", r)
6345
6672
 
6346
6673
 
6674
+ # ═══════════════════════════════════════════════════════════════════════
6675
+ # LED-219: Toolcard Delta Cache — reduce MCP tool schema token waste
6676
+ # ═══════════════════════════════════════════════════════════════════════
6677
+
6678
+
6679
+ @mcp.tool()
6680
+ def delimit_toolcard_cache(
6681
+ action: str = "status",
6682
+ tool_schemas: Optional[str] = None,
6683
+ tool_names: Optional[str] = None,
6684
+ ) -> Dict[str, Any]:
6685
+ """Manage the tool schema cache to reduce token waste (Pro).
6686
+
6687
+ MCP servers dump full tool definitions every session. This cache
6688
+ stores schemas and sends only diffs, cutting context bloat.
6689
+
6690
+ Actions:
6691
+ status: Show cache stats (tools cached, hit rate, token savings)
6692
+ register: Register tool schemas (JSON array string). Auto-runs on first call.
6693
+ delta: Get only changed/new tool names since last session (comma-separated names)
6694
+ clear: Clear the cache (forces full schema send next session)
6695
+ estimate: Estimate token savings for a tool set (JSON array string)
6696
+ flush: Write session stats to disk log
6697
+
6698
+ Args:
6699
+ action: One of: status, register, delta, clear, estimate, flush
6700
+ tool_schemas: JSON array of tool schema objects (for register/estimate)
6701
+ tool_names: Comma-separated tool names (for delta)
6702
+ """
6703
+ from ai.license import require_premium
6704
+ gate = require_premium("toolcard_cache")
6705
+ if gate:
6706
+ return gate
6707
+
6708
+ from ai.toolcard_cache import get_cache
6709
+ cache = get_cache()
6710
+
6711
+ if action == "status":
6712
+ r = cache.get_stats()
6713
+ elif action == "register":
6714
+ if not tool_schemas:
6715
+ return _with_next_steps("toolcard_cache", {
6716
+ "error": "missing_param",
6717
+ "message": "register action requires tool_schemas (JSON array of tool schema objects)"
6718
+ })
6719
+ try:
6720
+ schemas = json.loads(tool_schemas)
6721
+ except json.JSONDecodeError as e:
6722
+ return _with_next_steps("toolcard_cache", {
6723
+ "error": "invalid_json", "message": str(e)
6724
+ })
6725
+ r = cache.register_tools(schemas)
6726
+ elif action == "delta":
6727
+ names = [n.strip() for n in (tool_names or "").split(",") if n.strip()]
6728
+ r = cache.get_delta(names)
6729
+ elif action == "clear":
6730
+ r = cache.clear()
6731
+ elif action == "estimate":
6732
+ if not tool_schemas:
6733
+ return _with_next_steps("toolcard_cache", {
6734
+ "error": "missing_param",
6735
+ "message": "estimate action requires tool_schemas (JSON array of tool schema objects)"
6736
+ })
6737
+ try:
6738
+ schemas = json.loads(tool_schemas)
6739
+ except json.JSONDecodeError as e:
6740
+ return _with_next_steps("toolcard_cache", {
6741
+ "error": "invalid_json", "message": str(e)
6742
+ })
6743
+ r = cache.estimate_savings(schemas)
6744
+ elif action == "flush":
6745
+ r = cache.flush_session()
6746
+ else:
6747
+ r = {"error": "unknown_action", "message": f"Unknown action: {action}. Use: status, register, delta, clear, estimate, flush"}
6748
+
6749
+ return _with_next_steps("toolcard_cache", r)
6750
+
6751
+
6752
+ # ═══════════════════════════════════════════════════════════════════════
6753
+ # HANDOFF RECEIPTS — Agent-to-Agent Structured Handoffs (LED-220)
6754
+ # ═══════════════════════════════════════════════════════════════════════
6755
+
6756
+
6757
+ @mcp.tool()
6758
+ def delimit_handoff_create(
6759
+ task_description: str = "",
6760
+ completed: str = "",
6761
+ not_completed: str = "",
6762
+ assumptions: str = "",
6763
+ blockers: str = "",
6764
+ files_modified: str = "",
6765
+ in_scope: str = "",
6766
+ out_of_scope: str = "",
6767
+ next_action: str = "",
6768
+ priority: str = "P1",
6769
+ to_model: str = "any",
6770
+ ) -> Dict[str, Any]:
6771
+ """Create a handoff receipt when transitioning between agents/sessions (Pro).
6772
+
6773
+ Documents what was done, what wasn't, and what the next agent should do.
6774
+ The receiving agent should acknowledge before starting work.
6775
+
6776
+ Args:
6777
+ task_description: What the task was (one line).
6778
+ completed: Comma-separated list of completed items.
6779
+ not_completed: Comma-separated list of items not completed (with reasons).
6780
+ assumptions: Comma-separated assumptions made during work.
6781
+ blockers: Comma-separated blockers encountered.
6782
+ files_modified: JSON list of {path, change_type, summary} dicts, or empty for auto-detect.
6783
+ in_scope: Comma-separated items that were in scope.
6784
+ out_of_scope: Comma-separated items explicitly excluded.
6785
+ next_action: What the receiving agent should do first.
6786
+ priority: P0/P1/P2.
6787
+ to_model: Target model (or "any").
6788
+ """
6789
+ from ai.handoff_receipts import create_receipt as _create, format_receipt
6790
+
6791
+ def _split(val: str) -> List[str]:
6792
+ if not val or not val.strip():
6793
+ return []
6794
+ return [s.strip() for s in val.split(",") if s.strip()]
6795
+
6796
+ # Parse files_modified as JSON if provided
6797
+ parsed_files = None
6798
+ if files_modified and files_modified.strip():
6799
+ try:
6800
+ parsed_files = json.loads(files_modified)
6801
+ if not isinstance(parsed_files, list):
6802
+ parsed_files = None
6803
+ except json.JSONDecodeError:
6804
+ parsed_files = None
6805
+
6806
+ receipt = _create(
6807
+ task_description=task_description,
6808
+ completed=_split(completed),
6809
+ not_completed=_split(not_completed),
6810
+ assumptions=_split(assumptions),
6811
+ blockers=_split(blockers),
6812
+ files_modified=parsed_files,
6813
+ in_scope=_split(in_scope),
6814
+ out_of_scope=_split(out_of_scope),
6815
+ next_action=next_action,
6816
+ priority=priority,
6817
+ from_model=_detect_model(),
6818
+ to_model=to_model,
6819
+ )
6820
+
6821
+ formatted = format_receipt(receipt)
6822
+ return _with_next_steps("handoff_create", {
6823
+ "status": "created",
6824
+ "receipt_id": receipt.receipt_id,
6825
+ "project": receipt.project_path,
6826
+ "task_description": receipt.task_description,
6827
+ "completed_count": len(receipt.completed),
6828
+ "not_completed_count": len(receipt.not_completed),
6829
+ "files_count": len(receipt.files_modified),
6830
+ "next_action": receipt.next_action,
6831
+ "priority": receipt.priority,
6832
+ "formatted": formatted,
6833
+ "message": f"Handoff receipt {receipt.receipt_id} created. Receiving agent should run delimit_handoff_acknowledge(receipt_id=\"{receipt.receipt_id}\").",
6834
+ })
6835
+
6836
+
6837
+ @mcp.tool()
6838
+ def delimit_handoff_acknowledge(
6839
+ receipt_id: str = "",
6840
+ notes: str = "",
6841
+ ) -> Dict[str, Any]:
6842
+ """Acknowledge a handoff receipt before starting work (Pro).
6843
+
6844
+ Run this at the start of a session if there are pending handoff receipts.
6845
+
6846
+ Args:
6847
+ receipt_id: The receipt ID to acknowledge.
6848
+ notes: Optional notes from the receiving agent.
6849
+ """
6850
+ from ai.handoff_receipts import acknowledge_receipt as _ack
6851
+
6852
+ if not receipt_id or not receipt_id.strip():
6853
+ return _with_next_steps("handoff_acknowledge", {
6854
+ "status": "error",
6855
+ "message": "receipt_id is required.",
6856
+ })
6857
+
6858
+ result = _ack(
6859
+ receipt_id=receipt_id.strip(),
6860
+ model=_detect_model(),
6861
+ notes=notes,
6862
+ )
6863
+ return _with_next_steps("handoff_acknowledge", result)
6864
+
6865
+
6866
+ @mcp.tool()
6867
+ def delimit_handoff_list(
6868
+ status: str = "pending",
6869
+ ) -> Dict[str, Any]:
6870
+ """List handoff receipts (Pro).
6871
+
6872
+ Args:
6873
+ status: "pending" (unacknowledged), "acknowledged", or "all".
6874
+ """
6875
+ from ai.handoff_receipts import get_receipts, format_receipt
6876
+ from dataclasses import asdict
6877
+
6878
+ if status not in ("pending", "acknowledged", "all"):
6879
+ status = "pending"
6880
+
6881
+ receipts = get_receipts(status=status)
6882
+
6883
+ if not receipts:
6884
+ return _with_next_steps("handoff_list", {
6885
+ "status": "empty",
6886
+ "filter": status,
6887
+ "count": 0,
6888
+ "message": f"No {status} handoff receipts found.",
6889
+ })
6890
+
6891
+ formatted_list = []
6892
+ for r in receipts:
6893
+ formatted_list.append({
6894
+ "receipt_id": r.receipt_id,
6895
+ "created_at": r.created_at,
6896
+ "task_description": r.task_description,
6897
+ "from_model": r.from_model,
6898
+ "to_model": r.to_model,
6899
+ "priority": r.priority,
6900
+ "acknowledged": r.acknowledged,
6901
+ "completed_count": len(r.completed),
6902
+ "not_completed_count": len(r.not_completed),
6903
+ "next_action": r.next_action,
6904
+ })
6905
+
6906
+ # Format the first pending receipt in full for immediate context
6907
+ display = ""
6908
+ if status == "pending" and receipts:
6909
+ display = format_receipt(receipts[0])
6910
+
6911
+ return _with_next_steps("handoff_list", {
6912
+ "status": "ok",
6913
+ "filter": status,
6914
+ "count": len(receipts),
6915
+ "receipts": formatted_list,
6916
+ "display": display,
6917
+ "message": f"{len(receipts)} {status} receipt(s) found.",
6918
+ })
6919
+
6920
+
6347
6921
  # ═══════════════════════════════════════════════════════════════════════
6348
6922
  # ENTRY POINT
6349
6923
  # ═══════════════════════════════════════════════════════════════════════
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.0",
4
+ "version": "3.15.2",
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": [