delimit-cli 3.15.11 → 3.15.13
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/gateway/ai/activate_helpers.py +210 -0
- package/gateway/ai/collision_detect.py +141 -0
- package/gateway/ai/content_engine.py +2 -7
- package/gateway/ai/cross_model_audit.py +600 -0
- package/gateway/ai/github_scanner.py +622 -0
- package/gateway/ai/handoff_receipts.py +409 -0
- package/gateway/ai/key_resolver.py +2 -7
- package/gateway/ai/multi_review.py +154 -0
- package/gateway/ai/notify.py +4 -4
- package/gateway/ai/pii_redact.py +149 -0
- package/gateway/ai/prompt_drift.py +207 -0
- package/gateway/ai/reddit_scanner.py +562 -0
- package/gateway/ai/secrets_broker.py +232 -4
- package/gateway/ai/server.py +9 -1
- package/gateway/ai/session_phoenix.py +371 -0
- package/gateway/ai/supabase_sync.py +2 -7
- package/gateway/ai/swarm.py +106 -0
- package/gateway/ai/tool_metadata.py +34 -6
- package/gateway/ai/toolcard_cache.py +327 -0
- package/package.json +1 -1
|
@@ -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)
|
|
@@ -1,7 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
This module requires the Delimit MCP server to be running.
|
|
4
|
-
Configure via: npx delimit-cli setup
|
|
5
|
-
"""
|
|
6
|
-
|
|
7
|
-
# Stub — full implementation runs server-side
|
|
1
|
+
# key_resolver — Pro module (stubbed in npm package)
|
|
2
|
+
# Full implementation available on delimit.ai server
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
"""Multi-model PR review — consolidated code review from multiple AI models (STR-053).
|
|
2
|
+
|
|
3
|
+
Takes a diff or file changes, sends them to multiple models for review,
|
|
4
|
+
and consolidates the feedback into a single structured report.
|
|
5
|
+
|
|
6
|
+
Focus group: "GitHub Action runs delimit review, posts consolidated PR
|
|
7
|
+
review combining feedback from multiple models. 10x over standard
|
|
8
|
+
Copilot review."
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import json
|
|
12
|
+
import time
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Dict, List, Optional
|
|
15
|
+
|
|
16
|
+
REVIEWS_DIR = Path.home() / ".delimit" / "reviews"
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _ensure_dir():
|
|
20
|
+
REVIEWS_DIR.mkdir(parents=True, exist_ok=True)
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def generate_review_prompt(diff: str, context: str = "") -> str:
|
|
24
|
+
"""Generate a code review prompt from a diff."""
|
|
25
|
+
return f"""Review this code change. For each issue found, provide:
|
|
26
|
+
- Line number or location
|
|
27
|
+
- Severity (critical/warning/suggestion)
|
|
28
|
+
- What's wrong and why
|
|
29
|
+
- How to fix it
|
|
30
|
+
|
|
31
|
+
Be concise. Only flag real issues, not style preferences.
|
|
32
|
+
|
|
33
|
+
{f"Context: {context}" if context else ""}
|
|
34
|
+
|
|
35
|
+
```diff
|
|
36
|
+
{diff[:8000]}
|
|
37
|
+
```"""
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def consolidate_reviews(reviews: List[Dict[str, Any]]) -> Dict[str, Any]:
|
|
41
|
+
"""Consolidate reviews from multiple models into one report.
|
|
42
|
+
|
|
43
|
+
Groups findings by file/line, identifies agreements and disagreements,
|
|
44
|
+
and ranks by severity.
|
|
45
|
+
"""
|
|
46
|
+
all_findings = []
|
|
47
|
+
model_summaries = []
|
|
48
|
+
|
|
49
|
+
for review in reviews:
|
|
50
|
+
model = review.get("model", "unknown")
|
|
51
|
+
content = review.get("content", "")
|
|
52
|
+
duration = review.get("duration_ms", 0)
|
|
53
|
+
|
|
54
|
+
model_summaries.append({
|
|
55
|
+
"model": model,
|
|
56
|
+
"response_length": len(content),
|
|
57
|
+
"duration_ms": duration,
|
|
58
|
+
})
|
|
59
|
+
|
|
60
|
+
# Each model's review content becomes a finding block
|
|
61
|
+
all_findings.append({
|
|
62
|
+
"model": model,
|
|
63
|
+
"review": content,
|
|
64
|
+
"duration_ms": duration,
|
|
65
|
+
})
|
|
66
|
+
|
|
67
|
+
# Build consolidated report
|
|
68
|
+
report = {
|
|
69
|
+
"models_used": [r.get("model") for r in reviews],
|
|
70
|
+
"total_models": len(reviews),
|
|
71
|
+
"reviews": all_findings,
|
|
72
|
+
"model_summaries": model_summaries,
|
|
73
|
+
"generated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return report
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def format_pr_comment(report: Dict[str, Any]) -> str:
|
|
80
|
+
"""Format the consolidated review as a GitHub PR comment."""
|
|
81
|
+
models = report.get("models_used", [])
|
|
82
|
+
reviews = report.get("reviews", [])
|
|
83
|
+
|
|
84
|
+
lines = []
|
|
85
|
+
lines.append("## Delimit Multi-Model Review")
|
|
86
|
+
lines.append("")
|
|
87
|
+
lines.append(f"Reviewed by: **{', '.join(models)}**")
|
|
88
|
+
lines.append("")
|
|
89
|
+
|
|
90
|
+
for review in reviews:
|
|
91
|
+
model = review.get("model", "unknown")
|
|
92
|
+
content = review.get("review", "")
|
|
93
|
+
duration = review.get("duration_ms", 0)
|
|
94
|
+
|
|
95
|
+
lines.append(f"### {model}")
|
|
96
|
+
if duration:
|
|
97
|
+
lines.append(f"*({duration}ms)*")
|
|
98
|
+
lines.append("")
|
|
99
|
+
lines.append(content)
|
|
100
|
+
lines.append("")
|
|
101
|
+
|
|
102
|
+
lines.append("---")
|
|
103
|
+
lines.append("Powered by [Delimit](https://delimit.ai) multi-model review")
|
|
104
|
+
|
|
105
|
+
return "\n".join(lines)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def save_review(
|
|
109
|
+
diff: str,
|
|
110
|
+
report: Dict[str, Any],
|
|
111
|
+
pr_url: str = "",
|
|
112
|
+
) -> Dict[str, Any]:
|
|
113
|
+
"""Save a review report to disk."""
|
|
114
|
+
_ensure_dir()
|
|
115
|
+
|
|
116
|
+
review_id = f"review-{int(time.time())}"
|
|
117
|
+
review_file = REVIEWS_DIR / f"{review_id}.json"
|
|
118
|
+
|
|
119
|
+
data = {
|
|
120
|
+
"id": review_id,
|
|
121
|
+
"diff_preview": diff[:500],
|
|
122
|
+
"report": report,
|
|
123
|
+
"pr_url": pr_url,
|
|
124
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
review_file.write_text(json.dumps(data, indent=2))
|
|
128
|
+
|
|
129
|
+
return {
|
|
130
|
+
"status": "saved",
|
|
131
|
+
"review_id": review_id,
|
|
132
|
+
"path": str(review_file),
|
|
133
|
+
"pr_comment": format_pr_comment(report),
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def list_reviews(limit: int = 10) -> Dict[str, Any]:
|
|
138
|
+
"""List recent reviews."""
|
|
139
|
+
_ensure_dir()
|
|
140
|
+
reviews = []
|
|
141
|
+
|
|
142
|
+
for f in sorted(REVIEWS_DIR.glob("review-*.json"), reverse=True)[:limit]:
|
|
143
|
+
try:
|
|
144
|
+
data = json.loads(f.read_text())
|
|
145
|
+
reviews.append({
|
|
146
|
+
"id": data["id"],
|
|
147
|
+
"models": data["report"].get("models_used", []),
|
|
148
|
+
"created_at": data.get("created_at", ""),
|
|
149
|
+
"pr_url": data.get("pr_url", ""),
|
|
150
|
+
})
|
|
151
|
+
except:
|
|
152
|
+
pass
|
|
153
|
+
|
|
154
|
+
return {"status": "ok", "reviews": reviews, "total": len(reviews)}
|
package/gateway/ai/notify.py
CHANGED
|
@@ -37,7 +37,7 @@ INBOX_ROUTING_FILE = Path.home() / ".delimit" / "inbox_routing.jsonl"
|
|
|
37
37
|
IMAP_HOST = "mail.spacemail.com"
|
|
38
38
|
IMAP_PORT = 993
|
|
39
39
|
IMAP_USER = "pro@delimit.ai"
|
|
40
|
-
FORWARD_TO = "
|
|
40
|
+
FORWARD_TO = "owner@example.com"
|
|
41
41
|
|
|
42
42
|
# Domains/senders whose emails require owner action
|
|
43
43
|
OWNER_ACTION_DOMAINS = {
|
|
@@ -61,7 +61,7 @@ OWNER_ACTION_DOMAINS = {
|
|
|
61
61
|
}
|
|
62
62
|
|
|
63
63
|
OWNER_ACTION_SENDERS = {
|
|
64
|
-
"
|
|
64
|
+
"owner@example.com",
|
|
65
65
|
}
|
|
66
66
|
|
|
67
67
|
# Subject patterns that indicate owner-action (compiled once)
|
|
@@ -223,7 +223,7 @@ def send_email(
|
|
|
223
223
|
|
|
224
224
|
Args:
|
|
225
225
|
to: Recipient email address. Falls back to DELIMIT_SMTP_TO or
|
|
226
|
-
|
|
226
|
+
owner@example.com.
|
|
227
227
|
subject: Email subject line.
|
|
228
228
|
body: Email body text (preferred). Falls back to 'message' for
|
|
229
229
|
backward compatibility.
|
|
@@ -258,7 +258,7 @@ def send_email(
|
|
|
258
258
|
smtp_pass = os.environ.get("DELIMIT_SMTP_PASS", "")
|
|
259
259
|
smtp_from = os.environ.get("DELIMIT_SMTP_FROM", "")
|
|
260
260
|
|
|
261
|
-
smtp_to = to or os.environ.get("DELIMIT_SMTP_TO", "
|
|
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 = {
|