delimit-cli 3.15.1 → 3.15.3

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.
@@ -37,7 +37,37 @@ 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 = os.environ.get("DELIMIT_FORWARD_TO", "")
40
+ def _resolve_forward_to():
41
+ """Resolve forward email from env or secrets broker."""
42
+ # 1. Environment variable (highest priority)
43
+ val = os.environ.get("DELIMIT_FORWARD_TO", "")
44
+ if val:
45
+ return val
46
+ # 2. DELIMIT_SMTP_TO env var
47
+ val = os.environ.get("DELIMIT_SMTP_TO", "")
48
+ if val:
49
+ return val
50
+ # 3. Read from secrets broker config
51
+ try:
52
+ import json as _json
53
+ from pathlib import Path as _Path
54
+ # Check smtp-all.json for configured accounts
55
+ smtp_all = _Path.home() / ".delimit" / "secrets" / "smtp-all.json"
56
+ if smtp_all.exists():
57
+ data = _json.loads(smtp_all.read_text())
58
+ # The forward target is typically stored separately
59
+ # Check for a dedicated forward-to secret
60
+ fwd_file = _Path.home() / ".delimit" / "secrets" / "forward-to.json"
61
+ if fwd_file.exists():
62
+ fwd_data = _json.loads(fwd_file.read_text())
63
+ val = fwd_data.get("value", fwd_data.get("email", ""))
64
+ if val:
65
+ return val
66
+ except Exception:
67
+ pass
68
+ return ""
69
+
70
+ FORWARD_TO = _resolve_forward_to()
41
71
 
42
72
  # Domains/senders whose emails require owner action
43
73
  OWNER_ACTION_DOMAINS = {
@@ -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
+ }
@@ -0,0 +1,173 @@
1
+ """delimit.yml — committable project configuration (STR-049).
2
+
3
+ A single YAML file that teams commit to their repo. Defines:
4
+ - Context directories (what the AI should know about)
5
+ - Preferred models per task type
6
+ - Policy preset
7
+ - Playbook references
8
+ - Governance mode (advisory/guarded/enforce)
9
+
10
+ Focus group: "My AI setup on my laptop should match my teammate's."
11
+ """
12
+
13
+ import json
14
+ import time
15
+ from pathlib import Path
16
+ from typing import Any, Dict, Optional
17
+
18
+ try:
19
+ import yaml as _yaml
20
+ except ImportError:
21
+ _yaml = None
22
+
23
+
24
+ DEFAULT_CONFIG = {
25
+ "version": 1,
26
+ "governance": {
27
+ "mode": "advisory",
28
+ "preset": "default",
29
+ },
30
+ "context": {
31
+ "include": ["src/", "lib/", "app/"],
32
+ "exclude": ["node_modules/", ".git/", "dist/", "__pycache__/"],
33
+ },
34
+ "models": {
35
+ "default": "auto",
36
+ "tasks": {
37
+ "refactoring": "claude-opus",
38
+ "testing": "claude-sonnet",
39
+ "documentation": "gemini-flash",
40
+ "debugging": "auto",
41
+ },
42
+ },
43
+ "playbooks": [],
44
+ "team": {
45
+ "shared_memory": True,
46
+ "shared_ledger": True,
47
+ "require_approval_for": ["deploy", "publish"],
48
+ },
49
+ }
50
+
51
+ CONFIG_FILENAMES = ["delimit.yml", "delimit.yaml", ".delimit.yml", ".delimit.yaml"]
52
+
53
+
54
+ def find_project_config(project_path: str = ".") -> Optional[Path]:
55
+ """Find delimit.yml in the project directory or parents."""
56
+ p = Path(project_path).resolve()
57
+ for _ in range(10): # Max 10 parent dirs
58
+ for name in CONFIG_FILENAMES:
59
+ candidate = p / name
60
+ if candidate.exists():
61
+ return candidate
62
+ parent = p.parent
63
+ if parent == p:
64
+ break
65
+ p = parent
66
+ return None
67
+
68
+
69
+ def load_project_config(project_path: str = ".") -> Dict[str, Any]:
70
+ """Load project config from delimit.yml or return defaults."""
71
+ config_file = find_project_config(project_path)
72
+
73
+ if not config_file:
74
+ return {
75
+ "status": "no_config",
76
+ "config": DEFAULT_CONFIG,
77
+ "source": "defaults",
78
+ "message": "No delimit.yml found. Using defaults. Run delimit_project_init to create one.",
79
+ }
80
+
81
+ try:
82
+ content = config_file.read_text()
83
+ if _yaml:
84
+ config = _yaml.safe_load(content)
85
+ else:
86
+ config = json.loads(content)
87
+
88
+ # Merge with defaults for missing keys
89
+ merged = {**DEFAULT_CONFIG}
90
+ if isinstance(config, dict):
91
+ for key in config:
92
+ if key in merged and isinstance(merged[key], dict) and isinstance(config[key], dict):
93
+ merged[key] = {**merged[key], **config[key]}
94
+ else:
95
+ merged[key] = config[key]
96
+
97
+ return {
98
+ "status": "loaded",
99
+ "config": merged,
100
+ "source": str(config_file),
101
+ "message": f"Loaded from {config_file.name}",
102
+ }
103
+ except Exception as e:
104
+ return {
105
+ "status": "error",
106
+ "config": DEFAULT_CONFIG,
107
+ "source": str(config_file),
108
+ "error": str(e),
109
+ "message": f"Error loading {config_file.name}: {e}. Using defaults.",
110
+ }
111
+
112
+
113
+ def init_project_config(
114
+ project_path: str = ".",
115
+ mode: str = "advisory",
116
+ preset: str = "default",
117
+ ) -> Dict[str, Any]:
118
+ """Create a delimit.yml in the project root."""
119
+ p = Path(project_path).resolve()
120
+
121
+ # Check if already exists
122
+ existing = find_project_config(project_path)
123
+ if existing:
124
+ return {
125
+ "status": "exists",
126
+ "path": str(existing),
127
+ "message": f"Config already exists at {existing}",
128
+ }
129
+
130
+ config = dict(DEFAULT_CONFIG)
131
+ config["governance"]["mode"] = mode
132
+ config["governance"]["preset"] = preset
133
+
134
+ # Auto-detect context dirs
135
+ detected_dirs = []
136
+ for d in ["src", "lib", "app", "api", "server", "client", "packages"]:
137
+ if (p / d).exists():
138
+ detected_dirs.append(f"{d}/")
139
+ if detected_dirs:
140
+ config["context"]["include"] = detected_dirs
141
+
142
+ config_path = p / "delimit.yml"
143
+
144
+ if _yaml:
145
+ content = _yaml.dump(config, default_flow_style=False, sort_keys=False)
146
+ else:
147
+ content = json.dumps(config, indent=2)
148
+
149
+ config_path.write_text(content)
150
+
151
+ return {
152
+ "status": "created",
153
+ "path": str(config_path),
154
+ "config": config,
155
+ "message": f"Created delimit.yml with {mode} mode, {preset} preset",
156
+ }
157
+
158
+
159
+ def get_model_for_task(task_type: str, project_path: str = ".") -> Dict[str, Any]:
160
+ """Get the recommended model for a specific task type."""
161
+ result = load_project_config(project_path)
162
+ config = result.get("config", DEFAULT_CONFIG)
163
+
164
+ models = config.get("models", {})
165
+ tasks = models.get("tasks", {})
166
+
167
+ model = tasks.get(task_type, models.get("default", "auto"))
168
+
169
+ return {
170
+ "task": task_type,
171
+ "model": model,
172
+ "source": result.get("source", "defaults"),
173
+ }
@@ -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,128 @@ 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_project_config(action: str = "load", project_path: str = ".",
3636
+ mode: str = "advisory", preset: str = "default",
3637
+ task_type: str = "") -> Dict[str, Any]:
3638
+ """Manage delimit.yml project configuration.
3639
+
3640
+ A committable YAML file that defines AI governance for your repo.
3641
+ Your teammates get the same AI setup when they clone.
3642
+
3643
+ Actions:
3644
+ load: Read current project config (or defaults if no delimit.yml)
3645
+ init: Create a delimit.yml in your project root
3646
+ model: Get recommended model for a task type
3647
+
3648
+ Args:
3649
+ action: "load", "init", or "model".
3650
+ project_path: Project root directory.
3651
+ mode: Governance mode for init (advisory/guarded/enforce).
3652
+ preset: Policy preset for init (strict/default/relaxed).
3653
+ task_type: Task type for model lookup (refactoring/testing/docs/debugging).
3654
+ """
3655
+ from ai.project_config import load_project_config, init_project_config, get_model_for_task
3656
+
3657
+ if action == "init":
3658
+ return _with_next_steps("project_config", _safe_call(
3659
+ init_project_config, project_path=project_path, mode=mode, preset=preset,
3660
+ ))
3661
+ if action == "model":
3662
+ return _with_next_steps("project_config", _safe_call(
3663
+ get_model_for_task, task_type=task_type, project_path=project_path,
3664
+ ))
3665
+ return _with_next_steps("project_config", _safe_call(
3666
+ load_project_config, project_path=project_path,
3667
+ ))
3668
+
3669
+
3670
+ @mcp.tool()
3671
+ def delimit_playbook(action: str = "list", name: str = "", prompt: str = "",
3672
+ description: str = "", variables: str = "",
3673
+ model_hint: str = "", tags: str = "") -> Dict[str, Any]:
3674
+ """Manage reusable prompt templates — save, run, list, delete.
3675
+
3676
+ Save your best prompts as named commands. Use {{variables}} for dynamic parts.
3677
+ Works across all AI assistants through the shared MCP workspace.
3678
+
3679
+ Examples:
3680
+ Save: delimit_playbook(action="save", name="test-gen", prompt="Generate Jest tests for {{file}}")
3681
+ Run: delimit_playbook(action="run", name="test-gen", variables="file=src/auth.ts")
3682
+ List: delimit_playbook(action="list")
3683
+
3684
+ Args:
3685
+ action: "save", "run", "list", or "delete".
3686
+ name: Playbook name (required for save/run/delete).
3687
+ prompt: Prompt template with {{variable}} placeholders (save only).
3688
+ description: Short description of what this playbook does.
3689
+ variables: For run: comma-separated key=value pairs. For save: comma-separated variable names.
3690
+ model_hint: Suggested model (e.g. "claude-opus" for complex tasks).
3691
+ tags: Comma-separated tags for organization.
3692
+ """
3693
+ from ai.playbook import save_playbook, run_playbook, list_playbooks, delete_playbook
3694
+
3695
+ action = action.lower().strip()
3696
+
3697
+ if action == "save":
3698
+ tag_list = [t.strip() for t in tags.split(",") if t.strip()] if tags else None
3699
+ var_list = [v.strip() for v in variables.split(",") if v.strip()] if variables else None
3700
+ return _with_next_steps("playbook", _safe_call(
3701
+ save_playbook, name=name, prompt=prompt, description=description,
3702
+ variables=var_list, model_hint=model_hint, tags=tag_list,
3703
+ ))
3704
+
3705
+ if action == "run":
3706
+ var_dict = {}
3707
+ if variables:
3708
+ for pair in variables.split(","):
3709
+ if "=" in pair:
3710
+ k, v = pair.split("=", 1)
3711
+ var_dict[k.strip()] = v.strip()
3712
+ return _with_next_steps("playbook", _safe_call(
3713
+ run_playbook, name=name, variables=var_dict,
3714
+ ))
3715
+
3716
+ if action == "delete":
3717
+ return _with_next_steps("playbook", _safe_call(delete_playbook, name=name))
3718
+
3719
+ # Default: list
3720
+ tag_filter = tags.strip() if tags else ""
3721
+ return _with_next_steps("playbook", _safe_call(list_playbooks, tag=tag_filter))
3722
+
3723
+
3562
3724
  @mcp.tool()
3563
3725
  def delimit_help(tool_name: str = "") -> Dict[str, Any]:
3564
3726
  """Get help for a Delimit tool — what it does, parameters, and examples.
@@ -3569,12 +3731,13 @@ def delimit_help(tool_name: str = "") -> Dict[str, Any]:
3569
3731
  if not tool_name:
3570
3732
  total = _count_registered_tools()
3571
3733
  return _with_next_steps("help", {
3572
- "message": f"Delimit has {total} tools. Here are the most useful ones to start with:",
3734
+ "message": "What problem are you solving?",
3735
+ "workflows": [
3736
+ {"name": w["name"], "pain": w["pain"], "start_with": w["steps"][0]}
3737
+ for w in STANDARD_WORKFLOWS
3738
+ ],
3739
+ "tip": "Tell me what you're trying to do — I'll suggest the right workflow.",
3573
3740
  "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
3741
  })
3579
3742
 
3580
3743
  # Normalize name
@@ -3721,14 +3884,22 @@ def delimit_diagnose(project_path: str = ".") -> Dict[str, Any]:
3721
3884
 
3722
3885
 
3723
3886
  @mcp.tool()
3724
- def delimit_activate(license_key: str) -> Dict[str, Any]:
3725
- """Activate a Delimit Pro license key.
3887
+ def delimit_activate(license_key: str = "", project_path: str = ".", auto_permissions: bool = True) -> Dict[str, Any]:
3888
+ """Activate Delimit and run a readiness checklist.
3889
+
3890
+ Performs a comprehensive activation check: license validation, MCP server
3891
+ status, governance init, test smoke, permission auto-config, and premium
3892
+ feature availability. Skipped checks (premium on free tier, no test
3893
+ framework) do NOT count against the score.
3726
3894
 
3727
3895
  Args:
3728
- license_key: The license key to activate (e.g. DELIMIT-XXXX-XXXX-XXXX).
3896
+ license_key: Optional license key to activate Pro (e.g. DELIMIT-XXXX-XXXX-XXXX). Leave empty to check free-tier readiness.
3897
+ project_path: Project directory to check.
3898
+ auto_permissions: Auto-configure AI assistant permissions for Delimit tools (default True).
3729
3899
  """
3730
- from ai.license import activate_license
3731
- return _with_next_steps("activate", activate_license(license_key))
3900
+ from ai.activate_helpers import build_checklist
3901
+ result = build_checklist(license_key=license_key, project_path=project_path, auto_permissions=auto_permissions)
3902
+ return _with_next_steps("activate", result)
3732
3903
 
3733
3904
 
3734
3905
  @mcp.tool()
@@ -4089,6 +4260,85 @@ def delimit_ventures() -> Dict[str, Any]:
4089
4260
  return list_ventures()
4090
4261
 
4091
4262
 
4263
+ # ═══════════════════════════════════════════════════════════════════════
4264
+ # SESSION PHOENIX — Cross-Model Resurrection (LED-218)
4265
+ # ═══════════════════════════════════════════════════════════════════════
4266
+
4267
+
4268
+ @mcp.tool()
4269
+ def delimit_soul_capture(
4270
+ active_task: str = "",
4271
+ decisions: str = "",
4272
+ key_context: str = "",
4273
+ blockers: str = "",
4274
+ next_steps: str = "",
4275
+ task_status: str = "in_progress",
4276
+ tokens_used: int = 0,
4277
+ context_fullness: float = 0.0,
4278
+ ) -> Dict[str, Any]:
4279
+ """Capture current session state as a 'soul' for cross-model resurrection (Pro).
4280
+
4281
+ Save what you're working on so the next session (in any model) picks up
4282
+ where you left off. Auto-detects git state and files changed.
4283
+
4284
+ Args:
4285
+ active_task: What you're currently working on (one line).
4286
+ decisions: Comma-separated key decisions made this session.
4287
+ key_context: Comma-separated important context for next session.
4288
+ blockers: Comma-separated blockers.
4289
+ next_steps: Comma-separated next steps.
4290
+ task_status: in_progress, blocked, or almost_done.
4291
+ tokens_used: Estimated tokens consumed this session.
4292
+ context_fullness: 0.0-1.0 how full the context window is.
4293
+ """
4294
+ from ai.session_phoenix import capture_soul as _capture
4295
+
4296
+ def _split(val: str) -> List[str]:
4297
+ if not val or not val.strip():
4298
+ return []
4299
+ return [s.strip() for s in val.split(",") if s.strip()]
4300
+
4301
+ soul = _capture(
4302
+ active_task=active_task,
4303
+ decisions=_split(decisions),
4304
+ key_context=_split(key_context),
4305
+ blockers=_split(blockers),
4306
+ next_steps=_split(next_steps),
4307
+ source_model=_detect_model(),
4308
+ task_status=task_status,
4309
+ tokens_used=tokens_used,
4310
+ context_fullness=context_fullness,
4311
+ )
4312
+
4313
+ from dataclasses import asdict
4314
+ return _with_next_steps("soul_capture", {
4315
+ "status": "captured",
4316
+ "soul_id": soul.soul_id,
4317
+ "project": soul.project_path,
4318
+ "active_task": soul.active_task,
4319
+ "files_modified": len(soul.files_modified),
4320
+ "files_created": len(soul.files_created),
4321
+ "uncommitted_changes": soul.uncommitted_changes,
4322
+ "message": f"Soul {soul.soul_id} captured. Run delimit_revive in any model to restore.",
4323
+ })
4324
+
4325
+
4326
+ @mcp.tool()
4327
+ def delimit_revive(project_path: str = "", soul_id: str = "") -> Dict[str, Any]:
4328
+ """Revive the last session's state in any model (Pro).
4329
+
4330
+ Run this at the start of a new session to pick up where you left off.
4331
+ Works across Claude Code, Codex, Gemini CLI, and Cursor.
4332
+
4333
+ Args:
4334
+ project_path: Project to revive. Auto-detects from cwd.
4335
+ soul_id: Specific soul to revive. Empty = latest.
4336
+ """
4337
+ from ai.session_phoenix import revive as _revive
4338
+ result = _revive(project_path=project_path, soul_id=soul_id)
4339
+ return _with_next_steps("revive", result)
4340
+
4341
+
4092
4342
  # ═══════════════════════════════════════════════════════════════════════
4093
4343
  # DELIBERATION (Multi-Round Consensus)
4094
4344
  # ═══════════════════════════════════════════════════════════════════════
@@ -4352,6 +4602,59 @@ def _extract_deliberation_actions(result: Dict, question: str) -> List[Dict[str,
4352
4602
  return actions[:10]
4353
4603
 
4354
4604
 
4605
+ @mcp.tool()
4606
+ def delimit_audit(
4607
+ target: str = "",
4608
+ target_type: str = "file",
4609
+ lenses: str = "",
4610
+ ) -> Dict[str, Any]:
4611
+ """Cross-model code audit -- 3 models, 3 lenses, synthesized findings (Pro).
4612
+
4613
+ Run security, correctness, and governance reviews through different AI models
4614
+ simultaneously. Agreements are high-confidence. Disagreements surface tradeoffs.
4615
+
4616
+ "Trust through triangulation."
4617
+
4618
+ Args:
4619
+ target: File path, git diff output, or code snippet to audit.
4620
+ target_type: "file" (reads file), "diff" (git diff text), "snippet" (inline code).
4621
+ lenses: Comma-separated lenses to apply (security, correctness, governance). Default: all.
4622
+ """
4623
+ from ai.license import require_premium
4624
+ gate = require_premium("audit")
4625
+ if gate:
4626
+ return gate
4627
+
4628
+ if not target.strip():
4629
+ return {"status": "error", "error": "No target provided. Pass a file path, diff, or code snippet."}
4630
+
4631
+ from ai.cross_model_audit import audit as run_audit
4632
+
4633
+ lens_list = [l.strip() for l in lenses.split(",") if l.strip()] if lenses else None
4634
+
4635
+ result = run_audit(
4636
+ target=target,
4637
+ target_type=target_type,
4638
+ lenses=lens_list,
4639
+ )
4640
+
4641
+ if result.get("status") == "error":
4642
+ return result
4643
+
4644
+ synthesis = result.get("synthesis", {})
4645
+ return _with_next_steps("audit", {
4646
+ "status": "ok",
4647
+ "formatted_output": result.get("formatted", ""),
4648
+ "agreements": len(synthesis.get("agreements", [])),
4649
+ "unique_findings": len(synthesis.get("unique_findings", [])),
4650
+ "disagreements": len(synthesis.get("disagreements", [])),
4651
+ "summary": synthesis.get("summary", ""),
4652
+ "saved_to": result.get("saved_to", ""),
4653
+ "elapsed_seconds": result.get("elapsed_seconds", 0),
4654
+ "note": "Review the formatted output above. Agreements are high-confidence. Unique findings may be blind-spot catches.",
4655
+ })
4656
+
4657
+
4355
4658
  @mcp.tool()
4356
4659
  def delimit_release_sync(action: str = "audit") -> Dict[str, Any]:
4357
4660
  """Audit or sync all public surfaces for consistency (Pro)."""
@@ -5477,6 +5780,66 @@ def delimit_social_target_config(
5477
5780
  return {"error": f"Unknown action: {action}. Supported: status, detect, update, add_subreddits"}
5478
5781
 
5479
5782
 
5783
+ @mcp.tool()
5784
+ def delimit_reddit_scan(sort: str = "hot", limit: int = 10) -> Dict[str, Any]:
5785
+ """Bulk scan 25+ subreddits for outreach targets (Pro).
5786
+
5787
+ Scans via residential proxy. Rate limited to 1 req/2sec.
5788
+ Returns ranked, categorized targets ready for comment drafting.
5789
+
5790
+ IMPORTANT -- TOOL CHAINING RULE:
5791
+ After scanning, the AI MUST process high_priority targets:
5792
+ 1. Draft replies via delimit_social_post or delimit_notify
5793
+ 2. Create ledger items for strategic opportunities via delimit_ledger_add
5794
+ Never just return targets and stop. Always chain to the next action.
5795
+
5796
+ Args:
5797
+ sort: Reddit sort order (hot, new, top).
5798
+ limit: Posts per subreddit (default 10, max 25).
5799
+ """
5800
+ from ai.reddit_scanner import scan_all
5801
+
5802
+ capped_limit = min(max(1, limit), 25)
5803
+ if sort not in ("hot", "new", "top", "rising"):
5804
+ sort = "hot"
5805
+
5806
+ result = scan_all(limit_per_sub=capped_limit, sort=sort)
5807
+ return _with_next_steps("social_target", result)
5808
+
5809
+
5810
+ @_internal_tool()
5811
+ @mcp.tool()
5812
+ def delimit_github_scan(
5813
+ cadence: str = "pulse",
5814
+ limit: int = 20,
5815
+ ) -> Dict[str, Any]:
5816
+ """Scan GitHub for adoption leads, competitive intel, and repo health (Pro).
5817
+
5818
+ Three cadences:
5819
+ pulse: Own repo health (stars, forks, issues, traffic). Fast, run often.
5820
+ hunter: Competitor users, adoption leads, pain threads. Medium, run hourly.
5821
+ deep: Full ecosystem intel. Slow, run daily.
5822
+
5823
+ IMPORTANT -- TOOL CHAINING RULE:
5824
+ After scanning, the AI MUST process high-score findings:
5825
+ 1. Auto-ledger items (score >= 75 competitor users) via delimit_ledger_add
5826
+ 2. Pain threads with existing_feature relevance via delimit_notify
5827
+ Never just return findings and stop. Always chain to the next action.
5828
+
5829
+ Args:
5830
+ cadence: pulse, hunter, or deep.
5831
+ limit: Max results per search query (default 20, max 30).
5832
+ """
5833
+ from ai.github_scanner import scan
5834
+
5835
+ if cadence not in ("pulse", "hunter", "deep"):
5836
+ cadence = "pulse"
5837
+ capped_limit = min(max(1, limit), 30)
5838
+
5839
+ result = scan(cadence=cadence, limit=capped_limit)
5840
+ return _with_next_steps("github_scan", result)
5841
+
5842
+
5480
5843
  # ═══════════════════════════════════════════════════════════════════════
5481
5844
  # CONTENT ENGINE — Autonomous video + tweet pipeline (Pro)
5482
5845
  # ═══════════════════════════════════════════════════════════════════════
@@ -5886,9 +6249,9 @@ def delimit_notify(channel: str = "webhook", message: str = "",
5886
6249
  subject: Subject line (email only). Use [ACTION], [INFO], [ALERT] prefix.
5887
6250
  event_type: Event category for filtering.
5888
6251
  to: Recipient email address (email only). Overrides default DELIMIT_SMTP_TO.
5889
- Send to any address — leave empty for default (configured-email@example.com).
6252
+ Send to any address — leave empty for default (owner@example.com).
5890
6253
  from_account: Sender account key from ~/.delimit/secrets/smtp-all.json
5891
- (e.g. 'pro@delimit.ai', '<configured-email>'). Email only.
6254
+ (e.g. 'pro@example.com', 'admin@example.com'). Email only.
5892
6255
  """
5893
6256
  from ai.notify import send_notification
5894
6257
  return _with_next_steps("notify", _safe_call(
@@ -5994,7 +6357,7 @@ def delimit_notify_inbox(action: str = "status", limit: int = 10,
5994
6357
  """Check inbound email inbox, classify, and route (Pro).
5995
6358
 
5996
6359
  Polls pro@delimit.ai via IMAP. Classifies emails as owner-action
5997
- (forwards to configured-email@example.com) or non-owner (stays in inbox).
6360
+ (forwards to owner@example.com) or non-owner (stays in inbox).
5998
6361
 
5999
6362
  Args:
6000
6363
  action: 'status' (show inbox state), 'poll' (classify and optionally forward),
@@ -6344,6 +6707,253 @@ def delimit_loop_config(session_id: str = "", max_iterations: int = 0,
6344
6707
  return _with_next_steps("loop_config", r)
6345
6708
 
6346
6709
 
6710
+ # ═══════════════════════════════════════════════════════════════════════
6711
+ # LED-219: Toolcard Delta Cache — reduce MCP tool schema token waste
6712
+ # ═══════════════════════════════════════════════════════════════════════
6713
+
6714
+
6715
+ @mcp.tool()
6716
+ def delimit_toolcard_cache(
6717
+ action: str = "status",
6718
+ tool_schemas: Optional[str] = None,
6719
+ tool_names: Optional[str] = None,
6720
+ ) -> Dict[str, Any]:
6721
+ """Manage the tool schema cache to reduce token waste (Pro).
6722
+
6723
+ MCP servers dump full tool definitions every session. This cache
6724
+ stores schemas and sends only diffs, cutting context bloat.
6725
+
6726
+ Actions:
6727
+ status: Show cache stats (tools cached, hit rate, token savings)
6728
+ register: Register tool schemas (JSON array string). Auto-runs on first call.
6729
+ delta: Get only changed/new tool names since last session (comma-separated names)
6730
+ clear: Clear the cache (forces full schema send next session)
6731
+ estimate: Estimate token savings for a tool set (JSON array string)
6732
+ flush: Write session stats to disk log
6733
+
6734
+ Args:
6735
+ action: One of: status, register, delta, clear, estimate, flush
6736
+ tool_schemas: JSON array of tool schema objects (for register/estimate)
6737
+ tool_names: Comma-separated tool names (for delta)
6738
+ """
6739
+ from ai.license import require_premium
6740
+ gate = require_premium("toolcard_cache")
6741
+ if gate:
6742
+ return gate
6743
+
6744
+ from ai.toolcard_cache import get_cache
6745
+ cache = get_cache()
6746
+
6747
+ if action == "status":
6748
+ r = cache.get_stats()
6749
+ elif action == "register":
6750
+ if not tool_schemas:
6751
+ return _with_next_steps("toolcard_cache", {
6752
+ "error": "missing_param",
6753
+ "message": "register action requires tool_schemas (JSON array of tool schema objects)"
6754
+ })
6755
+ try:
6756
+ schemas = json.loads(tool_schemas)
6757
+ except json.JSONDecodeError as e:
6758
+ return _with_next_steps("toolcard_cache", {
6759
+ "error": "invalid_json", "message": str(e)
6760
+ })
6761
+ r = cache.register_tools(schemas)
6762
+ elif action == "delta":
6763
+ names = [n.strip() for n in (tool_names or "").split(",") if n.strip()]
6764
+ r = cache.get_delta(names)
6765
+ elif action == "clear":
6766
+ r = cache.clear()
6767
+ elif action == "estimate":
6768
+ if not tool_schemas:
6769
+ return _with_next_steps("toolcard_cache", {
6770
+ "error": "missing_param",
6771
+ "message": "estimate action requires tool_schemas (JSON array of tool schema objects)"
6772
+ })
6773
+ try:
6774
+ schemas = json.loads(tool_schemas)
6775
+ except json.JSONDecodeError as e:
6776
+ return _with_next_steps("toolcard_cache", {
6777
+ "error": "invalid_json", "message": str(e)
6778
+ })
6779
+ r = cache.estimate_savings(schemas)
6780
+ elif action == "flush":
6781
+ r = cache.flush_session()
6782
+ else:
6783
+ r = {"error": "unknown_action", "message": f"Unknown action: {action}. Use: status, register, delta, clear, estimate, flush"}
6784
+
6785
+ return _with_next_steps("toolcard_cache", r)
6786
+
6787
+
6788
+ # ═══════════════════════════════════════════════════════════════════════
6789
+ # HANDOFF RECEIPTS — Agent-to-Agent Structured Handoffs (LED-220)
6790
+ # ═══════════════════════════════════════════════════════════════════════
6791
+
6792
+
6793
+ @mcp.tool()
6794
+ def delimit_handoff_create(
6795
+ task_description: str = "",
6796
+ completed: str = "",
6797
+ not_completed: str = "",
6798
+ assumptions: str = "",
6799
+ blockers: str = "",
6800
+ files_modified: str = "",
6801
+ in_scope: str = "",
6802
+ out_of_scope: str = "",
6803
+ next_action: str = "",
6804
+ priority: str = "P1",
6805
+ to_model: str = "any",
6806
+ ) -> Dict[str, Any]:
6807
+ """Create a handoff receipt when transitioning between agents/sessions (Pro).
6808
+
6809
+ Documents what was done, what wasn't, and what the next agent should do.
6810
+ The receiving agent should acknowledge before starting work.
6811
+
6812
+ Args:
6813
+ task_description: What the task was (one line).
6814
+ completed: Comma-separated list of completed items.
6815
+ not_completed: Comma-separated list of items not completed (with reasons).
6816
+ assumptions: Comma-separated assumptions made during work.
6817
+ blockers: Comma-separated blockers encountered.
6818
+ files_modified: JSON list of {path, change_type, summary} dicts, or empty for auto-detect.
6819
+ in_scope: Comma-separated items that were in scope.
6820
+ out_of_scope: Comma-separated items explicitly excluded.
6821
+ next_action: What the receiving agent should do first.
6822
+ priority: P0/P1/P2.
6823
+ to_model: Target model (or "any").
6824
+ """
6825
+ from ai.handoff_receipts import create_receipt as _create, format_receipt
6826
+
6827
+ def _split(val: str) -> List[str]:
6828
+ if not val or not val.strip():
6829
+ return []
6830
+ return [s.strip() for s in val.split(",") if s.strip()]
6831
+
6832
+ # Parse files_modified as JSON if provided
6833
+ parsed_files = None
6834
+ if files_modified and files_modified.strip():
6835
+ try:
6836
+ parsed_files = json.loads(files_modified)
6837
+ if not isinstance(parsed_files, list):
6838
+ parsed_files = None
6839
+ except json.JSONDecodeError:
6840
+ parsed_files = None
6841
+
6842
+ receipt = _create(
6843
+ task_description=task_description,
6844
+ completed=_split(completed),
6845
+ not_completed=_split(not_completed),
6846
+ assumptions=_split(assumptions),
6847
+ blockers=_split(blockers),
6848
+ files_modified=parsed_files,
6849
+ in_scope=_split(in_scope),
6850
+ out_of_scope=_split(out_of_scope),
6851
+ next_action=next_action,
6852
+ priority=priority,
6853
+ from_model=_detect_model(),
6854
+ to_model=to_model,
6855
+ )
6856
+
6857
+ formatted = format_receipt(receipt)
6858
+ return _with_next_steps("handoff_create", {
6859
+ "status": "created",
6860
+ "receipt_id": receipt.receipt_id,
6861
+ "project": receipt.project_path,
6862
+ "task_description": receipt.task_description,
6863
+ "completed_count": len(receipt.completed),
6864
+ "not_completed_count": len(receipt.not_completed),
6865
+ "files_count": len(receipt.files_modified),
6866
+ "next_action": receipt.next_action,
6867
+ "priority": receipt.priority,
6868
+ "formatted": formatted,
6869
+ "message": f"Handoff receipt {receipt.receipt_id} created. Receiving agent should run delimit_handoff_acknowledge(receipt_id=\"{receipt.receipt_id}\").",
6870
+ })
6871
+
6872
+
6873
+ @mcp.tool()
6874
+ def delimit_handoff_acknowledge(
6875
+ receipt_id: str = "",
6876
+ notes: str = "",
6877
+ ) -> Dict[str, Any]:
6878
+ """Acknowledge a handoff receipt before starting work (Pro).
6879
+
6880
+ Run this at the start of a session if there are pending handoff receipts.
6881
+
6882
+ Args:
6883
+ receipt_id: The receipt ID to acknowledge.
6884
+ notes: Optional notes from the receiving agent.
6885
+ """
6886
+ from ai.handoff_receipts import acknowledge_receipt as _ack
6887
+
6888
+ if not receipt_id or not receipt_id.strip():
6889
+ return _with_next_steps("handoff_acknowledge", {
6890
+ "status": "error",
6891
+ "message": "receipt_id is required.",
6892
+ })
6893
+
6894
+ result = _ack(
6895
+ receipt_id=receipt_id.strip(),
6896
+ model=_detect_model(),
6897
+ notes=notes,
6898
+ )
6899
+ return _with_next_steps("handoff_acknowledge", result)
6900
+
6901
+
6902
+ @mcp.tool()
6903
+ def delimit_handoff_list(
6904
+ status: str = "pending",
6905
+ ) -> Dict[str, Any]:
6906
+ """List handoff receipts (Pro).
6907
+
6908
+ Args:
6909
+ status: "pending" (unacknowledged), "acknowledged", or "all".
6910
+ """
6911
+ from ai.handoff_receipts import get_receipts, format_receipt
6912
+ from dataclasses import asdict
6913
+
6914
+ if status not in ("pending", "acknowledged", "all"):
6915
+ status = "pending"
6916
+
6917
+ receipts = get_receipts(status=status)
6918
+
6919
+ if not receipts:
6920
+ return _with_next_steps("handoff_list", {
6921
+ "status": "empty",
6922
+ "filter": status,
6923
+ "count": 0,
6924
+ "message": f"No {status} handoff receipts found.",
6925
+ })
6926
+
6927
+ formatted_list = []
6928
+ for r in receipts:
6929
+ formatted_list.append({
6930
+ "receipt_id": r.receipt_id,
6931
+ "created_at": r.created_at,
6932
+ "task_description": r.task_description,
6933
+ "from_model": r.from_model,
6934
+ "to_model": r.to_model,
6935
+ "priority": r.priority,
6936
+ "acknowledged": r.acknowledged,
6937
+ "completed_count": len(r.completed),
6938
+ "not_completed_count": len(r.not_completed),
6939
+ "next_action": r.next_action,
6940
+ })
6941
+
6942
+ # Format the first pending receipt in full for immediate context
6943
+ display = ""
6944
+ if status == "pending" and receipts:
6945
+ display = format_receipt(receipts[0])
6946
+
6947
+ return _with_next_steps("handoff_list", {
6948
+ "status": "ok",
6949
+ "filter": status,
6950
+ "count": len(receipts),
6951
+ "receipts": formatted_list,
6952
+ "display": display,
6953
+ "message": f"{len(receipts)} {status} receipt(s) found.",
6954
+ })
6955
+
6956
+
6347
6957
  # ═══════════════════════════════════════════════════════════════════════
6348
6958
  # ENTRY POINT
6349
6959
  # ═══════════════════════════════════════════════════════════════════════
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.1",
4
+ "version": "3.15.3",
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": [