delimit-cli 4.0.0 → 4.0.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,409 @@
1
+ """
2
+ Handoff Receipts -- Structured receipts for agent-to-agent handoffs (LED-220).
3
+
4
+ When one agent/session hands off to another, a receipt captures:
5
+ - What was done and what wasn't
6
+ - Assumptions made and blockers encountered
7
+ - Files touched with change summaries
8
+ - Scope boundaries
9
+ - Next action required
10
+
11
+ The receiving agent must acknowledge the receipt before acting,
12
+ preventing the "undo what the last agent did" problem.
13
+
14
+ Architecture:
15
+ create_receipt() -> ~/.delimit/handoff_receipts/{project_hash}/{receipt_id}.json
16
+ acknowledge_receipt() -> marks receipt as acknowledged
17
+ get_pending_receipts()-> returns unacknowledged receipts
18
+ """
19
+
20
+ import hashlib
21
+ import json
22
+ import os
23
+ import subprocess
24
+ import uuid
25
+ from dataclasses import asdict, dataclass, field
26
+ from datetime import datetime, timezone
27
+ from pathlib import Path
28
+ from typing import Any, Dict, List, Optional
29
+
30
+ MAX_RECEIPTS_PER_PROJECT = 50
31
+ RECEIPTS_BASE_DIR = Path.home() / ".delimit" / "handoff_receipts"
32
+
33
+
34
+ @dataclass
35
+ class HandoffReceipt:
36
+ """Structured receipt for agent-to-agent handoffs."""
37
+
38
+ receipt_id: str = ""
39
+ created_at: str = ""
40
+ from_model: str = "unknown"
41
+ to_model: str = "any"
42
+ project_path: str = ""
43
+
44
+ # Work summary
45
+ task_description: str = ""
46
+ completed: List[str] = field(default_factory=list)
47
+ not_completed: List[str] = field(default_factory=list)
48
+
49
+ # Context transfer
50
+ assumptions: List[str] = field(default_factory=list)
51
+ blockers: List[str] = field(default_factory=list)
52
+
53
+ # File manifest: [{path, change_type, summary}]
54
+ files_modified: List[Dict[str, str]] = field(default_factory=list)
55
+
56
+ # Scope
57
+ in_scope: List[str] = field(default_factory=list)
58
+ out_of_scope: List[str] = field(default_factory=list)
59
+
60
+ # Next action
61
+ next_action: str = ""
62
+ priority: str = "P1"
63
+
64
+ # Acknowledgment
65
+ acknowledged: bool = False
66
+ acknowledged_at: str = ""
67
+ acknowledged_by: str = ""
68
+ acknowledge_notes: str = ""
69
+
70
+
71
+ def _project_hash(project_path: str) -> str:
72
+ """Stable hash for a project path, used as directory name."""
73
+ normalized = os.path.realpath(project_path)
74
+ return hashlib.sha256(normalized.encode()).hexdigest()[:12]
75
+
76
+
77
+ def _project_dir(project_path: str) -> Path:
78
+ """Return the receipt storage directory for a project."""
79
+ return RECEIPTS_BASE_DIR / _project_hash(project_path)
80
+
81
+
82
+ def _run_git(args: List[str], cwd: str = "") -> str:
83
+ """Run a git command and return stdout, or empty string on failure."""
84
+ try:
85
+ result = subprocess.run(
86
+ ["git"] + args,
87
+ capture_output=True,
88
+ text=True,
89
+ timeout=5,
90
+ cwd=cwd or None,
91
+ )
92
+ if result.returncode == 0:
93
+ return result.stdout.strip()
94
+ except (subprocess.TimeoutExpired, FileNotFoundError, OSError):
95
+ pass
96
+ return ""
97
+
98
+
99
+ def _auto_detect_files(project_path: str) -> List[Dict[str, str]]:
100
+ """Auto-detect modified files from git diff HEAD~1."""
101
+ cwd = project_path or os.getcwd()
102
+
103
+ # Get files changed in the last commit
104
+ diff_output = _run_git(["diff", "--name-status", "HEAD~1"], cwd=cwd)
105
+ if not diff_output:
106
+ # Fall back to uncommitted changes
107
+ diff_output = _run_git(["diff", "--name-status", "HEAD"], cwd=cwd)
108
+ if not diff_output:
109
+ return []
110
+
111
+ files = []
112
+ status_map = {
113
+ "A": "created",
114
+ "M": "modified",
115
+ "D": "deleted",
116
+ "R": "renamed",
117
+ "C": "copied",
118
+ }
119
+ for line in diff_output.splitlines():
120
+ parts = line.split("\t", 1)
121
+ if len(parts) >= 2:
122
+ status_code = parts[0].strip()[0] if parts[0].strip() else "M"
123
+ filepath = parts[1].strip()
124
+ change_type = status_map.get(status_code, "modified")
125
+ files.append({
126
+ "path": filepath,
127
+ "change_type": change_type,
128
+ "summary": "",
129
+ })
130
+ return files
131
+
132
+
133
+ def _index_path(project_path: str) -> Path:
134
+ """Return the index file path for a project."""
135
+ return _project_dir(project_path) / "index.json"
136
+
137
+
138
+ def _load_index(project_path: str) -> Dict[str, Any]:
139
+ """Load the receipt index for a project."""
140
+ idx_path = _index_path(project_path)
141
+ if idx_path.exists():
142
+ try:
143
+ return json.loads(idx_path.read_text())
144
+ except (json.JSONDecodeError, OSError):
145
+ pass
146
+ return {"receipts": []}
147
+
148
+
149
+ def _save_index(project_path: str, index: Dict[str, Any]) -> None:
150
+ """Save the receipt index for a project."""
151
+ proj_dir = _project_dir(project_path)
152
+ proj_dir.mkdir(parents=True, exist_ok=True)
153
+ _index_path(project_path).write_text(json.dumps(index, indent=2))
154
+
155
+
156
+ def create_receipt(
157
+ task_description: str,
158
+ completed: Optional[List[str]] = None,
159
+ not_completed: Optional[List[str]] = None,
160
+ assumptions: Optional[List[str]] = None,
161
+ blockers: Optional[List[str]] = None,
162
+ files_modified: Optional[List[Dict[str, str]]] = None,
163
+ in_scope: Optional[List[str]] = None,
164
+ out_of_scope: Optional[List[str]] = None,
165
+ next_action: str = "",
166
+ priority: str = "P1",
167
+ from_model: str = "unknown",
168
+ to_model: str = "any",
169
+ project_path: str = "",
170
+ ) -> HandoffReceipt:
171
+ """Create a handoff receipt and persist it to disk.
172
+
173
+ Auto-detects project_path from cwd and files_modified from git if not provided.
174
+ """
175
+ project_path = project_path or os.getcwd()
176
+
177
+ if files_modified is None:
178
+ files_modified = _auto_detect_files(project_path)
179
+
180
+ receipt = HandoffReceipt(
181
+ receipt_id=str(uuid.uuid4())[:8],
182
+ created_at=datetime.now(timezone.utc).isoformat(),
183
+ from_model=from_model,
184
+ to_model=to_model,
185
+ project_path=project_path,
186
+ task_description=task_description,
187
+ completed=completed or [],
188
+ not_completed=not_completed or [],
189
+ assumptions=assumptions or [],
190
+ blockers=blockers or [],
191
+ files_modified=files_modified,
192
+ in_scope=in_scope or [],
193
+ out_of_scope=out_of_scope or [],
194
+ next_action=next_action,
195
+ priority=priority,
196
+ )
197
+
198
+ _store_receipt(receipt)
199
+ return receipt
200
+
201
+
202
+ def _store_receipt(receipt: HandoffReceipt) -> Path:
203
+ """Persist a receipt to disk and update the index."""
204
+ proj_dir = _project_dir(receipt.project_path)
205
+ proj_dir.mkdir(parents=True, exist_ok=True)
206
+
207
+ filename = f"{receipt.receipt_id}.json"
208
+ filepath = proj_dir / filename
209
+ filepath.write_text(json.dumps(asdict(receipt), indent=2))
210
+
211
+ # Update index
212
+ index = _load_index(receipt.project_path)
213
+ index["receipts"].append({
214
+ "receipt_id": receipt.receipt_id,
215
+ "created_at": receipt.created_at,
216
+ "task_description": receipt.task_description,
217
+ "from_model": receipt.from_model,
218
+ "to_model": receipt.to_model,
219
+ "priority": receipt.priority,
220
+ "acknowledged": False,
221
+ })
222
+
223
+ # Prune old receipts
224
+ if len(index["receipts"]) > MAX_RECEIPTS_PER_PROJECT:
225
+ old_entries = index["receipts"][:-MAX_RECEIPTS_PER_PROJECT]
226
+ index["receipts"] = index["receipts"][-MAX_RECEIPTS_PER_PROJECT:]
227
+ for entry in old_entries:
228
+ old_file = proj_dir / f"{entry['receipt_id']}.json"
229
+ old_file.unlink(missing_ok=True)
230
+
231
+ _save_index(receipt.project_path, index)
232
+ return filepath
233
+
234
+
235
+ def _load_receipt(project_path: str, receipt_id: str) -> Optional[HandoffReceipt]:
236
+ """Load a receipt from disk by ID."""
237
+ filepath = _project_dir(project_path) / f"{receipt_id}.json"
238
+ if not filepath.exists():
239
+ return None
240
+ try:
241
+ data = json.loads(filepath.read_text())
242
+ return HandoffReceipt(**{
243
+ k: v for k, v in data.items()
244
+ if k in HandoffReceipt.__dataclass_fields__
245
+ })
246
+ except (json.JSONDecodeError, TypeError, KeyError, OSError):
247
+ return None
248
+
249
+
250
+ def acknowledge_receipt(
251
+ receipt_id: str,
252
+ model: str = "unknown",
253
+ notes: str = "",
254
+ project_path: str = "",
255
+ ) -> Dict[str, Any]:
256
+ """Mark a handoff receipt as acknowledged by the receiving agent.
257
+
258
+ Returns the updated receipt data or an error if not found.
259
+ """
260
+ project_path = project_path or os.getcwd()
261
+
262
+ receipt = _load_receipt(project_path, receipt_id)
263
+ if receipt is None:
264
+ return {
265
+ "status": "not_found",
266
+ "message": f"No receipt with ID '{receipt_id}' found.",
267
+ }
268
+
269
+ if receipt.acknowledged:
270
+ return {
271
+ "status": "already_acknowledged",
272
+ "message": f"Receipt {receipt_id} was already acknowledged by {receipt.acknowledged_by} at {receipt.acknowledged_at}.",
273
+ "receipt_id": receipt_id,
274
+ }
275
+
276
+ now = datetime.now(timezone.utc).isoformat()
277
+ receipt.acknowledged = True
278
+ receipt.acknowledged_at = now
279
+ receipt.acknowledged_by = model
280
+ receipt.acknowledge_notes = notes
281
+
282
+ # Update the receipt file
283
+ filepath = _project_dir(project_path) / f"{receipt_id}.json"
284
+ filepath.write_text(json.dumps(asdict(receipt), indent=2))
285
+
286
+ # Update the index
287
+ index = _load_index(project_path)
288
+ for entry in index["receipts"]:
289
+ if entry["receipt_id"] == receipt_id:
290
+ entry["acknowledged"] = True
291
+ break
292
+ _save_index(project_path, index)
293
+
294
+ return {
295
+ "status": "acknowledged",
296
+ "receipt_id": receipt_id,
297
+ "acknowledged_by": model,
298
+ "acknowledged_at": now,
299
+ "task_description": receipt.task_description,
300
+ "next_action": receipt.next_action,
301
+ "message": f"Receipt {receipt_id} acknowledged. Next action: {receipt.next_action or '(none specified)'}",
302
+ }
303
+
304
+
305
+ def get_pending_receipts(project_path: str = "") -> List[HandoffReceipt]:
306
+ """Get receipts that haven't been acknowledged yet."""
307
+ project_path = project_path or os.getcwd()
308
+ index = _load_index(project_path)
309
+
310
+ pending = []
311
+ for entry in index["receipts"]:
312
+ if not entry.get("acknowledged", False):
313
+ receipt = _load_receipt(project_path, entry["receipt_id"])
314
+ if receipt and not receipt.acknowledged:
315
+ pending.append(receipt)
316
+ return pending
317
+
318
+
319
+ def get_receipts(project_path: str = "", status: str = "pending") -> List[HandoffReceipt]:
320
+ """Get receipts filtered by status: pending, acknowledged, or all."""
321
+ project_path = project_path or os.getcwd()
322
+ index = _load_index(project_path)
323
+
324
+ results = []
325
+ for entry in index["receipts"]:
326
+ receipt = _load_receipt(project_path, entry["receipt_id"])
327
+ if receipt is None:
328
+ continue
329
+ if status == "all":
330
+ results.append(receipt)
331
+ elif status == "pending" and not receipt.acknowledged:
332
+ results.append(receipt)
333
+ elif status == "acknowledged" and receipt.acknowledged:
334
+ results.append(receipt)
335
+ return results
336
+
337
+
338
+ def format_receipt(receipt: HandoffReceipt) -> str:
339
+ """Format a receipt into a clean, readable text block."""
340
+ lines = []
341
+ lines.append("=== HANDOFF RECEIPT ===")
342
+ lines.append(f"ID: {receipt.receipt_id}")
343
+ lines.append(f"From: {receipt.from_model} | To: {receipt.to_model}")
344
+ lines.append(f"Task: {receipt.task_description}")
345
+ lines.append(f"Priority: {receipt.priority}")
346
+ lines.append(f"Created: {receipt.created_at}")
347
+ lines.append("")
348
+
349
+ if receipt.completed:
350
+ lines.append("COMPLETED:")
351
+ for item in receipt.completed:
352
+ lines.append(f" [x] {item}")
353
+ lines.append("")
354
+
355
+ if receipt.not_completed:
356
+ lines.append("NOT COMPLETED:")
357
+ for item in receipt.not_completed:
358
+ lines.append(f" [ ] {item}")
359
+ lines.append("")
360
+
361
+ if receipt.assumptions:
362
+ lines.append("ASSUMPTIONS:")
363
+ for item in receipt.assumptions:
364
+ lines.append(f" - {item}")
365
+ lines.append("")
366
+
367
+ if receipt.blockers:
368
+ lines.append("BLOCKERS:")
369
+ for item in receipt.blockers:
370
+ lines.append(f" ! {item}")
371
+ lines.append("")
372
+
373
+ if receipt.files_modified:
374
+ lines.append("FILES MODIFIED:")
375
+ for f in receipt.files_modified:
376
+ path = f.get("path", "")
377
+ change_type = f.get("change_type", "modified")
378
+ summary = f.get("summary", "")
379
+ suffix = f" -- {summary}" if summary else ""
380
+ lines.append(f" {path} ({change_type}){suffix}")
381
+ lines.append("")
382
+
383
+ if receipt.in_scope:
384
+ lines.append("IN SCOPE:")
385
+ for item in receipt.in_scope:
386
+ lines.append(f" + {item}")
387
+ lines.append("")
388
+
389
+ if receipt.out_of_scope:
390
+ lines.append("OUT OF SCOPE:")
391
+ for item in receipt.out_of_scope:
392
+ lines.append(f" - {item}")
393
+ lines.append("")
394
+
395
+ if receipt.next_action:
396
+ lines.append(f"NEXT ACTION: {receipt.next_action}")
397
+ lines.append("")
398
+
399
+ if receipt.acknowledged:
400
+ lines.append(f"ACKNOWLEDGED: by {receipt.acknowledged_by} at {receipt.acknowledged_at}")
401
+ if receipt.acknowledge_notes:
402
+ lines.append(f" Notes: {receipt.acknowledge_notes}")
403
+ lines.append("")
404
+ else:
405
+ lines.append(f'To acknowledge: delimit_handoff_acknowledge(receipt_id="{receipt.receipt_id}")')
406
+ lines.append("")
407
+
408
+ lines.append("=" * 24)
409
+ return "\n".join(lines)
@@ -5,7 +5,6 @@ This module is distributed as a native binary (.so/.pyd), not readable Python.
5
5
  """
6
6
  import hashlib
7
7
  import json
8
- import os
9
8
  import time
10
9
  from pathlib import Path
11
10
 
@@ -167,7 +166,7 @@ def activate(key: str) -> dict:
167
166
  def _revalidate(data: dict) -> dict:
168
167
  """Re-validate against Lemon Squeezy."""
169
168
  key = data.get("key", "")
170
- if not key or key.startswith(os.environ.get("DELIMIT_INTERNAL_KEY_PREFIX", "")):
169
+ if not key or key.startswith("JAMSONS"):
171
170
  return {"valid": True}
172
171
  try:
173
172
  import urllib.request
@@ -34,10 +34,10 @@ HISTORY_FILE = Path.home() / ".delimit" / "notifications.jsonl"
34
34
  INBOX_ROUTING_FILE = Path.home() / ".delimit" / "inbox_routing.jsonl"
35
35
 
36
36
  # ── Inbound email configuration ──────────────────────────────────────
37
- IMAP_HOST = os.environ.get("DELIMIT_IMAP_HOST", "")
38
- IMAP_PORT = int(os.environ.get("DELIMIT_IMAP_PORT", "993"))
39
- IMAP_USER = os.environ.get("DELIMIT_IMAP_USER", "")
40
- FORWARD_TO = os.environ.get("DELIMIT_FORWARD_TO", "")
37
+ IMAP_HOST = "mail.spacemail.com"
38
+ IMAP_PORT = 993
39
+ IMAP_USER = "pro@delimit.ai"
40
+ FORWARD_TO = "owner@example.com"
41
41
 
42
42
  # Domains/senders whose emails require owner action
43
43
  OWNER_ACTION_DOMAINS = {
@@ -60,9 +60,9 @@ OWNER_ACTION_DOMAINS = {
60
60
  "digitalocean.com",
61
61
  }
62
62
 
63
- OWNER_ACTION_SENDERS = set(
64
- filter(None, [os.environ.get("DELIMIT_OWNER_EMAIL", "")])
65
- )
63
+ OWNER_ACTION_SENDERS = {
64
+ "owner@example.com",
65
+ }
66
66
 
67
67
  # Subject patterns that indicate owner-action (compiled once)
68
68
  import re as _re
@@ -258,7 +258,7 @@ def send_email(
258
258
  smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
259
259
  smtp_from = os.environ.get("DELIMIT_SMTP_FROM", "")
260
260
 
261
- smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "")
261
+ smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "owner@example.com")
262
262
 
263
263
  if not all([smtp_host, smtp_from, smtp_to]):
264
264
  record = {