delimit-cli 3.14.16 → 3.14.18
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/bin/delimit-cli.js +3 -0
- package/gateway/ai/agent_dispatch.py +458 -0
- package/gateway/ai/agent_policy.py +230 -0
- package/gateway/ai/drift_monitor.py +246 -0
- package/gateway/ai/server.py +791 -38
- package/lib/cross-model-hooks.js +104 -5
- package/package.json +1 -1
package/bin/delimit-cli.js
CHANGED
|
@@ -760,6 +760,9 @@ const hookCmd = program
|
|
|
760
760
|
case 'pre-commit':
|
|
761
761
|
await crossModelHooks.hookPreCommit();
|
|
762
762
|
break;
|
|
763
|
+
case 'deploy-gate':
|
|
764
|
+
await crossModelHooks.hookDeployGate();
|
|
765
|
+
break;
|
|
763
766
|
default:
|
|
764
767
|
// Legacy: fall back to agent-based hook evaluation
|
|
765
768
|
await ensureAgent();
|
|
@@ -0,0 +1,458 @@
|
|
|
1
|
+
"""Multi-agent orchestration — dispatch, track, and govern engineering tasks.
|
|
2
|
+
|
|
3
|
+
Agents are tracked via a local task store. Each dispatched task gets a unique
|
|
4
|
+
ID with status tracking. The dispatcher doesn't spawn actual AI processes
|
|
5
|
+
(that's the host AI's job) — it provides the governance layer:
|
|
6
|
+
assignment, tracking, policy enforcement, and handoff protocol.
|
|
7
|
+
|
|
8
|
+
Storage: ~/.delimit/agents/tasks.json
|
|
9
|
+
Audit trail: ~/.delimit/agents/audit.jsonl
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
import json
|
|
13
|
+
import time
|
|
14
|
+
import uuid
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from typing import Any, Dict, List, Optional
|
|
17
|
+
|
|
18
|
+
AGENTS_DIR = Path.home() / ".delimit" / "agents"
|
|
19
|
+
TASKS_FILE = AGENTS_DIR / "tasks.json"
|
|
20
|
+
AUDIT_FILE = AGENTS_DIR / "audit.jsonl"
|
|
21
|
+
|
|
22
|
+
VALID_PRIORITIES = {"P0", "P1", "P2"}
|
|
23
|
+
VALID_ASSIGNEES = {"claude", "codex", "gemini", "any"}
|
|
24
|
+
VALID_STATUSES = {"dispatched", "in_progress", "done", "handed_off", "failed"}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def _ensure_dir():
|
|
28
|
+
"""Create the agents directory if it doesn't exist."""
|
|
29
|
+
AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _load_tasks() -> Dict[str, Any]:
|
|
33
|
+
"""Load all tasks from the tasks file."""
|
|
34
|
+
if not TASKS_FILE.exists():
|
|
35
|
+
return {}
|
|
36
|
+
try:
|
|
37
|
+
return json.loads(TASKS_FILE.read_text())
|
|
38
|
+
except (json.JSONDecodeError, OSError):
|
|
39
|
+
return {}
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _save_tasks(tasks: Dict[str, Any]):
|
|
43
|
+
"""Write all tasks back to the tasks file."""
|
|
44
|
+
_ensure_dir()
|
|
45
|
+
TASKS_FILE.write_text(json.dumps(tasks, indent=2))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _append_audit(entry: Dict[str, Any]):
|
|
49
|
+
"""Append an entry to the audit trail."""
|
|
50
|
+
_ensure_dir()
|
|
51
|
+
entry["ts"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
52
|
+
with open(AUDIT_FILE, "a") as f:
|
|
53
|
+
f.write(json.dumps(entry) + "\n")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def dispatch_task(
|
|
57
|
+
title: str,
|
|
58
|
+
description: str = "",
|
|
59
|
+
assignee: str = "any",
|
|
60
|
+
priority: str = "P1",
|
|
61
|
+
tools_needed: Optional[List[str]] = None,
|
|
62
|
+
constraints: Optional[List[str]] = None,
|
|
63
|
+
context: str = "",
|
|
64
|
+
) -> Dict[str, Any]:
|
|
65
|
+
"""Create a tracked agent task.
|
|
66
|
+
|
|
67
|
+
Returns:
|
|
68
|
+
Dict with task_id, task details, and a structured prompt for the host AI.
|
|
69
|
+
"""
|
|
70
|
+
if not title or not title.strip():
|
|
71
|
+
return {"error": "title is required"}
|
|
72
|
+
|
|
73
|
+
assignee = assignee.lower().strip() if assignee else "any"
|
|
74
|
+
if assignee not in VALID_ASSIGNEES:
|
|
75
|
+
return {"error": f"assignee must be one of: {', '.join(sorted(VALID_ASSIGNEES))}"}
|
|
76
|
+
|
|
77
|
+
priority = priority.upper().strip() if priority else "P1"
|
|
78
|
+
if priority not in VALID_PRIORITIES:
|
|
79
|
+
return {"error": f"priority must be one of: {', '.join(sorted(VALID_PRIORITIES))}"}
|
|
80
|
+
|
|
81
|
+
task_id = f"AGT-{uuid.uuid4().hex[:8].upper()}"
|
|
82
|
+
|
|
83
|
+
task = {
|
|
84
|
+
"id": task_id,
|
|
85
|
+
"title": title.strip(),
|
|
86
|
+
"description": description.strip(),
|
|
87
|
+
"assignee": assignee,
|
|
88
|
+
"priority": priority,
|
|
89
|
+
"tools_needed": tools_needed or [],
|
|
90
|
+
"constraints": constraints or [],
|
|
91
|
+
"context": context.strip(),
|
|
92
|
+
"status": "dispatched",
|
|
93
|
+
"created_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
94
|
+
"updated_at": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
95
|
+
"files_changed": [],
|
|
96
|
+
"result": "",
|
|
97
|
+
"handoffs": [],
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
tasks = _load_tasks()
|
|
101
|
+
tasks[task_id] = task
|
|
102
|
+
_save_tasks(tasks)
|
|
103
|
+
|
|
104
|
+
_append_audit({
|
|
105
|
+
"action": "dispatch",
|
|
106
|
+
"task_id": task_id,
|
|
107
|
+
"title": title.strip(),
|
|
108
|
+
"assignee": assignee,
|
|
109
|
+
"priority": priority,
|
|
110
|
+
})
|
|
111
|
+
|
|
112
|
+
# Build a structured prompt that the host AI can pass to a subagent
|
|
113
|
+
prompt = _build_agent_prompt(task)
|
|
114
|
+
|
|
115
|
+
return {
|
|
116
|
+
"status": "dispatched",
|
|
117
|
+
"task_id": task_id,
|
|
118
|
+
"task": task,
|
|
119
|
+
"agent_prompt": prompt,
|
|
120
|
+
"message": f"Task {task_id} dispatched to {assignee} ({priority})",
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _build_agent_prompt(task: Dict[str, Any]) -> str:
|
|
125
|
+
"""Build a structured prompt for a subagent to execute the task."""
|
|
126
|
+
lines = [
|
|
127
|
+
f"## Agent Task: {task['id']}",
|
|
128
|
+
f"**Title:** {task['title']}",
|
|
129
|
+
]
|
|
130
|
+
if task.get("description"):
|
|
131
|
+
lines.append(f"**Description:** {task['description']}")
|
|
132
|
+
lines.append(f"**Priority:** {task['priority']}")
|
|
133
|
+
lines.append(f"**Assignee:** {task['assignee']}")
|
|
134
|
+
|
|
135
|
+
if task.get("context"):
|
|
136
|
+
lines.append(f"\n**Context:**\n{task['context']}")
|
|
137
|
+
|
|
138
|
+
if task.get("tools_needed"):
|
|
139
|
+
lines.append(f"\n**Tools needed:** {', '.join(task['tools_needed'])}")
|
|
140
|
+
|
|
141
|
+
if task.get("constraints"):
|
|
142
|
+
lines.append(f"\n**Constraints:**")
|
|
143
|
+
for c in task["constraints"]:
|
|
144
|
+
lines.append(f"- {c}")
|
|
145
|
+
|
|
146
|
+
lines.append(f"\n**When done:** Call `delimit_agent_complete` with task_id='{task['id']}' and your result.")
|
|
147
|
+
|
|
148
|
+
return "\n".join(lines)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def get_agent_status(task_id: str = "") -> Dict[str, Any]:
|
|
152
|
+
"""Get the status of a specific task, or list all active tasks."""
|
|
153
|
+
tasks = _load_tasks()
|
|
154
|
+
|
|
155
|
+
if not task_id or not task_id.strip():
|
|
156
|
+
return list_active_agents()
|
|
157
|
+
|
|
158
|
+
task_id = task_id.strip().upper()
|
|
159
|
+
if task_id not in tasks:
|
|
160
|
+
return {"error": f"Task {task_id} not found"}
|
|
161
|
+
|
|
162
|
+
return {
|
|
163
|
+
"status": "ok",
|
|
164
|
+
"task": tasks[task_id],
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def list_active_agents() -> Dict[str, Any]:
|
|
169
|
+
"""Return all tasks that are not done or failed."""
|
|
170
|
+
tasks = _load_tasks()
|
|
171
|
+
active = {
|
|
172
|
+
tid: t for tid, t in tasks.items()
|
|
173
|
+
if t.get("status") in ("dispatched", "in_progress", "handed_off")
|
|
174
|
+
}
|
|
175
|
+
completed = {
|
|
176
|
+
tid: t for tid, t in tasks.items()
|
|
177
|
+
if t.get("status") in ("done", "failed")
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
return {
|
|
181
|
+
"status": "ok",
|
|
182
|
+
"active_count": len(active),
|
|
183
|
+
"completed_count": len(completed),
|
|
184
|
+
"active_tasks": list(active.values()),
|
|
185
|
+
"summary": [
|
|
186
|
+
{"id": t["id"], "title": t["title"], "status": t["status"],
|
|
187
|
+
"assignee": t["assignee"], "priority": t["priority"]}
|
|
188
|
+
for t in active.values()
|
|
189
|
+
],
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def complete_task(
|
|
194
|
+
task_id: str,
|
|
195
|
+
result: str = "",
|
|
196
|
+
files_changed: Optional[List[str]] = None,
|
|
197
|
+
) -> Dict[str, Any]:
|
|
198
|
+
"""Mark a dispatched task as done."""
|
|
199
|
+
if not task_id or not task_id.strip():
|
|
200
|
+
return {"error": "task_id is required"}
|
|
201
|
+
|
|
202
|
+
task_id = task_id.strip().upper()
|
|
203
|
+
tasks = _load_tasks()
|
|
204
|
+
|
|
205
|
+
if task_id not in tasks:
|
|
206
|
+
return {"error": f"Task {task_id} not found"}
|
|
207
|
+
|
|
208
|
+
task = tasks[task_id]
|
|
209
|
+
if task["status"] == "done":
|
|
210
|
+
return {"error": f"Task {task_id} is already marked done"}
|
|
211
|
+
|
|
212
|
+
task["status"] = "done"
|
|
213
|
+
task["result"] = result.strip()
|
|
214
|
+
task["files_changed"] = files_changed or []
|
|
215
|
+
task["completed_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
216
|
+
task["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
217
|
+
|
|
218
|
+
tasks[task_id] = task
|
|
219
|
+
_save_tasks(tasks)
|
|
220
|
+
|
|
221
|
+
_append_audit({
|
|
222
|
+
"action": "complete",
|
|
223
|
+
"task_id": task_id,
|
|
224
|
+
"result": result.strip()[:200],
|
|
225
|
+
"files_changed": files_changed or [],
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
return {
|
|
229
|
+
"status": "completed",
|
|
230
|
+
"task_id": task_id,
|
|
231
|
+
"task": task,
|
|
232
|
+
"message": f"Task {task_id} marked as done",
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def handoff_task(
|
|
237
|
+
task_id: str,
|
|
238
|
+
to_model: str,
|
|
239
|
+
context: str = "",
|
|
240
|
+
) -> Dict[str, Any]:
|
|
241
|
+
"""Transfer a task from one AI model to another."""
|
|
242
|
+
if not task_id or not task_id.strip():
|
|
243
|
+
return {"error": "task_id is required"}
|
|
244
|
+
if not to_model or not to_model.strip():
|
|
245
|
+
return {"error": "to_model is required"}
|
|
246
|
+
|
|
247
|
+
task_id = task_id.strip().upper()
|
|
248
|
+
to_model = to_model.lower().strip()
|
|
249
|
+
|
|
250
|
+
if to_model not in VALID_ASSIGNEES - {"any"}:
|
|
251
|
+
return {"error": f"to_model must be one of: {', '.join(sorted(VALID_ASSIGNEES - {'any'}))}"}
|
|
252
|
+
|
|
253
|
+
tasks = _load_tasks()
|
|
254
|
+
if task_id not in tasks:
|
|
255
|
+
return {"error": f"Task {task_id} not found"}
|
|
256
|
+
|
|
257
|
+
task = tasks[task_id]
|
|
258
|
+
if task["status"] == "done":
|
|
259
|
+
return {"error": f"Task {task_id} is already done, cannot hand off"}
|
|
260
|
+
|
|
261
|
+
from_model = task["assignee"]
|
|
262
|
+
task["handoffs"].append({
|
|
263
|
+
"from": from_model,
|
|
264
|
+
"to": to_model,
|
|
265
|
+
"context": context.strip(),
|
|
266
|
+
"ts": time.strftime("%Y-%m-%dT%H:%M:%SZ"),
|
|
267
|
+
})
|
|
268
|
+
task["assignee"] = to_model
|
|
269
|
+
task["status"] = "handed_off"
|
|
270
|
+
task["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
271
|
+
|
|
272
|
+
# Rebuild the prompt with handoff context
|
|
273
|
+
if context.strip():
|
|
274
|
+
task["context"] = (task.get("context", "") + "\n\n---\n**Handoff context from " +
|
|
275
|
+
from_model + ":**\n" + context.strip()).strip()
|
|
276
|
+
|
|
277
|
+
tasks[task_id] = task
|
|
278
|
+
_save_tasks(tasks)
|
|
279
|
+
|
|
280
|
+
_append_audit({
|
|
281
|
+
"action": "handoff",
|
|
282
|
+
"task_id": task_id,
|
|
283
|
+
"from": from_model,
|
|
284
|
+
"to": to_model,
|
|
285
|
+
"context_snippet": context.strip()[:200],
|
|
286
|
+
})
|
|
287
|
+
|
|
288
|
+
prompt = _build_agent_prompt(task)
|
|
289
|
+
|
|
290
|
+
return {
|
|
291
|
+
"status": "handed_off",
|
|
292
|
+
"task_id": task_id,
|
|
293
|
+
"from_model": from_model,
|
|
294
|
+
"to_model": to_model,
|
|
295
|
+
"task": task,
|
|
296
|
+
"agent_prompt": prompt,
|
|
297
|
+
"message": f"Task {task_id} handed off from {from_model} to {to_model}",
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def enforce_constraints(task_id: str, action: str) -> Dict[str, Any]:
|
|
302
|
+
"""Check if an action is allowed given the task's constraints.
|
|
303
|
+
|
|
304
|
+
Returns:
|
|
305
|
+
Dict with 'allowed' (bool) and 'reason' if denied.
|
|
306
|
+
"""
|
|
307
|
+
if not task_id or not task_id.strip():
|
|
308
|
+
return {"allowed": True, "reason": "No task_id provided, no constraints to check"}
|
|
309
|
+
|
|
310
|
+
task_id = task_id.strip().upper()
|
|
311
|
+
tasks = _load_tasks()
|
|
312
|
+
|
|
313
|
+
if task_id not in tasks:
|
|
314
|
+
return {"allowed": True, "reason": f"Task {task_id} not found, defaulting to allow"}
|
|
315
|
+
|
|
316
|
+
task = tasks[task_id]
|
|
317
|
+
constraints = task.get("constraints", [])
|
|
318
|
+
|
|
319
|
+
if not constraints:
|
|
320
|
+
return {"allowed": True, "reason": "No constraints on this task"}
|
|
321
|
+
|
|
322
|
+
action_lower = action.lower().strip() if action else ""
|
|
323
|
+
|
|
324
|
+
# Check each constraint against the action
|
|
325
|
+
for constraint in constraints:
|
|
326
|
+
c = constraint.lower().strip()
|
|
327
|
+
|
|
328
|
+
if c == "read-only":
|
|
329
|
+
write_keywords = ["write", "edit", "create", "delete", "modify", "deploy", "push", "commit"]
|
|
330
|
+
if any(kw in action_lower for kw in write_keywords):
|
|
331
|
+
return {
|
|
332
|
+
"allowed": False,
|
|
333
|
+
"reason": f"Constraint 'read-only' blocks action: {action}",
|
|
334
|
+
"constraint": constraint,
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
elif c == "no-deploy" or c == "no-deploys":
|
|
338
|
+
deploy_keywords = ["deploy", "publish", "release", "rollback"]
|
|
339
|
+
if any(kw in action_lower for kw in deploy_keywords):
|
|
340
|
+
return {
|
|
341
|
+
"allowed": False,
|
|
342
|
+
"reason": f"Constraint '{constraint}' blocks action: {action}",
|
|
343
|
+
"constraint": constraint,
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
elif c == "must-lint" or c == "must_lint":
|
|
347
|
+
# This is an affirmative constraint — doesn't block, just flags
|
|
348
|
+
pass
|
|
349
|
+
|
|
350
|
+
elif c.startswith("no-"):
|
|
351
|
+
# Generic "no-X" constraint
|
|
352
|
+
blocked = c[3:]
|
|
353
|
+
if blocked in action_lower:
|
|
354
|
+
return {
|
|
355
|
+
"allowed": False,
|
|
356
|
+
"reason": f"Constraint '{constraint}' blocks action: {action}",
|
|
357
|
+
"constraint": constraint,
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {"allowed": True, "reason": "All constraints passed"}
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def link_ledger_item(task_id: str, ledger_item_id: str) -> Dict[str, Any]:
|
|
364
|
+
"""Link a dispatched agent task to a ledger item (LED-xxx or STR-xxx).
|
|
365
|
+
|
|
366
|
+
This creates a bidirectional relationship so the dashboard can show
|
|
367
|
+
which agent is working on which ledger item.
|
|
368
|
+
"""
|
|
369
|
+
if not task_id or not task_id.strip():
|
|
370
|
+
return {"error": "task_id is required"}
|
|
371
|
+
if not ledger_item_id or not ledger_item_id.strip():
|
|
372
|
+
return {"error": "ledger_item_id is required"}
|
|
373
|
+
|
|
374
|
+
task_id = task_id.strip().upper()
|
|
375
|
+
ledger_item_id = ledger_item_id.strip().upper()
|
|
376
|
+
|
|
377
|
+
tasks = _load_tasks()
|
|
378
|
+
if task_id not in tasks:
|
|
379
|
+
return {"error": f"Task {task_id} not found"}
|
|
380
|
+
|
|
381
|
+
task = tasks[task_id]
|
|
382
|
+
linked = task.get("linked_ledger_items", [])
|
|
383
|
+
if ledger_item_id not in linked:
|
|
384
|
+
linked.append(ledger_item_id)
|
|
385
|
+
task["linked_ledger_items"] = linked
|
|
386
|
+
task["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
387
|
+
|
|
388
|
+
tasks[task_id] = task
|
|
389
|
+
_save_tasks(tasks)
|
|
390
|
+
|
|
391
|
+
_append_audit({
|
|
392
|
+
"action": "link_ledger",
|
|
393
|
+
"task_id": task_id,
|
|
394
|
+
"ledger_item_id": ledger_item_id,
|
|
395
|
+
})
|
|
396
|
+
|
|
397
|
+
return {
|
|
398
|
+
"status": "linked",
|
|
399
|
+
"task_id": task_id,
|
|
400
|
+
"ledger_item_id": ledger_item_id,
|
|
401
|
+
"all_linked": linked,
|
|
402
|
+
"message": f"Task {task_id} linked to {ledger_item_id}",
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def get_agent_dashboard() -> Dict[str, Any]:
|
|
407
|
+
"""Return a full dashboard view of all agent activity.
|
|
408
|
+
|
|
409
|
+
Groups tasks by status and assignee, includes audit trail summary,
|
|
410
|
+
and handoff history.
|
|
411
|
+
"""
|
|
412
|
+
tasks = _load_tasks()
|
|
413
|
+
|
|
414
|
+
by_assignee: Dict[str, List[Dict]] = {}
|
|
415
|
+
by_status: Dict[str, int] = {}
|
|
416
|
+
handoff_count = 0
|
|
417
|
+
|
|
418
|
+
for t in tasks.values():
|
|
419
|
+
assignee = t.get("assignee", "unknown")
|
|
420
|
+
status = t.get("status", "unknown")
|
|
421
|
+
by_assignee.setdefault(assignee, []).append(t)
|
|
422
|
+
by_status[status] = by_status.get(status, 0) + 1
|
|
423
|
+
handoff_count += len(t.get("handoffs", []))
|
|
424
|
+
|
|
425
|
+
# Recent audit entries
|
|
426
|
+
recent_audit: List[Dict] = []
|
|
427
|
+
if AUDIT_FILE.exists():
|
|
428
|
+
try:
|
|
429
|
+
lines = AUDIT_FILE.read_text().strip().split("\n")
|
|
430
|
+
for line in lines[-20:]:
|
|
431
|
+
try:
|
|
432
|
+
recent_audit.append(json.loads(line))
|
|
433
|
+
except json.JSONDecodeError:
|
|
434
|
+
pass
|
|
435
|
+
except OSError:
|
|
436
|
+
pass
|
|
437
|
+
|
|
438
|
+
return {
|
|
439
|
+
"status": "ok",
|
|
440
|
+
"total_tasks": len(tasks),
|
|
441
|
+
"by_status": by_status,
|
|
442
|
+
"by_assignee": {
|
|
443
|
+
model: {
|
|
444
|
+
"total": len(model_tasks),
|
|
445
|
+
"active": sum(1 for t in model_tasks if t["status"] in ("dispatched", "in_progress", "handed_off")),
|
|
446
|
+
"done": sum(1 for t in model_tasks if t["status"] == "done"),
|
|
447
|
+
"tasks": [
|
|
448
|
+
{"id": t["id"], "title": t["title"], "status": t["status"],
|
|
449
|
+
"priority": t.get("priority", "P1"),
|
|
450
|
+
"linked_ledger": t.get("linked_ledger_items", [])}
|
|
451
|
+
for t in model_tasks
|
|
452
|
+
],
|
|
453
|
+
}
|
|
454
|
+
for model, model_tasks in by_assignee.items()
|
|
455
|
+
},
|
|
456
|
+
"handoff_count": handoff_count,
|
|
457
|
+
"recent_audit": recent_audit,
|
|
458
|
+
}
|
|
@@ -0,0 +1,230 @@
|
|
|
1
|
+
"""Per-model policy scoping — agent-level governance.
|
|
2
|
+
|
|
3
|
+
Allows setting different permissions per AI model:
|
|
4
|
+
- Which tools each model can call
|
|
5
|
+
- Read-only vs read-write access to ledger/memory
|
|
6
|
+
- Deploy permissions per model
|
|
7
|
+
- Custom constraints per agent identity
|
|
8
|
+
|
|
9
|
+
Storage: ~/.delimit/agents/policies.json
|
|
10
|
+
|
|
11
|
+
Feedback origin: Accurate_Mistake_398 on r/ClaudeAI (2026-03-28)
|
|
12
|
+
identified that governance was session-level, not agent-level.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
import json
|
|
16
|
+
import time
|
|
17
|
+
from pathlib import Path
|
|
18
|
+
from typing import Any, Dict, List, Optional
|
|
19
|
+
|
|
20
|
+
AGENTS_DIR = Path.home() / ".delimit" / "agents"
|
|
21
|
+
POLICIES_FILE = AGENTS_DIR / "policies.json"
|
|
22
|
+
|
|
23
|
+
# Default permissions — what each model gets if no policy is set
|
|
24
|
+
DEFAULT_PERMISSIONS = {
|
|
25
|
+
"ledger": "read-write",
|
|
26
|
+
"memory": "read-write",
|
|
27
|
+
"deploy": False,
|
|
28
|
+
"lint": True,
|
|
29
|
+
"deliberate": True,
|
|
30
|
+
"security_audit": True,
|
|
31
|
+
"evidence": "read-write",
|
|
32
|
+
"secrets": False,
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
VALID_MODELS = {"claude", "codex", "gemini", "cursor", "any"}
|
|
36
|
+
VALID_ACCESS = {"read-only", "read-write", "none"}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _ensure_dir():
|
|
40
|
+
AGENTS_DIR.mkdir(parents=True, exist_ok=True)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _load_policies() -> Dict[str, Any]:
|
|
44
|
+
if not POLICIES_FILE.exists():
|
|
45
|
+
return {}
|
|
46
|
+
try:
|
|
47
|
+
return json.loads(POLICIES_FILE.read_text())
|
|
48
|
+
except (json.JSONDecodeError, OSError):
|
|
49
|
+
return {}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _save_policies(policies: Dict[str, Any]):
|
|
53
|
+
_ensure_dir()
|
|
54
|
+
POLICIES_FILE.write_text(json.dumps(policies, indent=2))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def set_agent_policy(
|
|
58
|
+
model: str,
|
|
59
|
+
ledger: str = "",
|
|
60
|
+
memory: str = "",
|
|
61
|
+
deploy: Optional[bool] = None,
|
|
62
|
+
lint: Optional[bool] = None,
|
|
63
|
+
deliberate: Optional[bool] = None,
|
|
64
|
+
security_audit: Optional[bool] = None,
|
|
65
|
+
evidence: str = "",
|
|
66
|
+
secrets: Optional[bool] = None,
|
|
67
|
+
custom_constraints: Optional[List[str]] = None,
|
|
68
|
+
) -> Dict[str, Any]:
|
|
69
|
+
"""Set permissions for a specific AI model.
|
|
70
|
+
|
|
71
|
+
Example: set_agent_policy("codex", ledger="read-only", deploy=False)
|
|
72
|
+
means Codex can read the ledger but not write, and cannot deploy.
|
|
73
|
+
"""
|
|
74
|
+
model = model.lower().strip()
|
|
75
|
+
if model not in VALID_MODELS:
|
|
76
|
+
return {"error": f"model must be one of: {', '.join(sorted(VALID_MODELS))}"}
|
|
77
|
+
|
|
78
|
+
policies = _load_policies()
|
|
79
|
+
existing = policies.get(model, dict(DEFAULT_PERMISSIONS))
|
|
80
|
+
|
|
81
|
+
if ledger and ledger in VALID_ACCESS:
|
|
82
|
+
existing["ledger"] = ledger
|
|
83
|
+
if memory and memory in VALID_ACCESS:
|
|
84
|
+
existing["memory"] = memory
|
|
85
|
+
if evidence and evidence in VALID_ACCESS:
|
|
86
|
+
existing["evidence"] = evidence
|
|
87
|
+
if deploy is not None:
|
|
88
|
+
existing["deploy"] = deploy
|
|
89
|
+
if lint is not None:
|
|
90
|
+
existing["lint"] = lint
|
|
91
|
+
if deliberate is not None:
|
|
92
|
+
existing["deliberate"] = deliberate
|
|
93
|
+
if security_audit is not None:
|
|
94
|
+
existing["security_audit"] = security_audit
|
|
95
|
+
if secrets is not None:
|
|
96
|
+
existing["secrets"] = secrets
|
|
97
|
+
if custom_constraints is not None:
|
|
98
|
+
existing["custom_constraints"] = custom_constraints
|
|
99
|
+
|
|
100
|
+
existing["updated_at"] = time.strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
101
|
+
policies[model] = existing
|
|
102
|
+
_save_policies(policies)
|
|
103
|
+
|
|
104
|
+
return {
|
|
105
|
+
"status": "updated",
|
|
106
|
+
"model": model,
|
|
107
|
+
"policy": existing,
|
|
108
|
+
"message": f"Policy updated for {model}",
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def get_agent_policy(model: str = "") -> Dict[str, Any]:
|
|
113
|
+
"""Get permissions for a specific model, or all models."""
|
|
114
|
+
policies = _load_policies()
|
|
115
|
+
|
|
116
|
+
if not model or not model.strip():
|
|
117
|
+
# Return all policies with defaults filled in
|
|
118
|
+
all_policies = {}
|
|
119
|
+
for m in VALID_MODELS - {"any"}:
|
|
120
|
+
all_policies[m] = policies.get(m, dict(DEFAULT_PERMISSIONS))
|
|
121
|
+
return {
|
|
122
|
+
"status": "ok",
|
|
123
|
+
"policies": all_policies,
|
|
124
|
+
"default": DEFAULT_PERMISSIONS,
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
model = model.lower().strip()
|
|
128
|
+
if model not in VALID_MODELS:
|
|
129
|
+
return {"error": f"model must be one of: {', '.join(sorted(VALID_MODELS))}"}
|
|
130
|
+
|
|
131
|
+
policy = policies.get(model, dict(DEFAULT_PERMISSIONS))
|
|
132
|
+
return {
|
|
133
|
+
"status": "ok",
|
|
134
|
+
"model": model,
|
|
135
|
+
"policy": policy,
|
|
136
|
+
"is_default": model not in policies,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def check_agent_permission(
|
|
141
|
+
model: str,
|
|
142
|
+
action: str,
|
|
143
|
+
resource: str = "",
|
|
144
|
+
) -> Dict[str, Any]:
|
|
145
|
+
"""Check if a model is allowed to perform an action.
|
|
146
|
+
|
|
147
|
+
Actions: ledger_write, ledger_read, memory_write, memory_read,
|
|
148
|
+
deploy, lint, deliberate, security_audit, evidence_write,
|
|
149
|
+
evidence_read, secrets_read, secrets_write.
|
|
150
|
+
|
|
151
|
+
Returns: {"allowed": bool, "reason": str}
|
|
152
|
+
"""
|
|
153
|
+
model = model.lower().strip() if model else "any"
|
|
154
|
+
policies = _load_policies()
|
|
155
|
+
policy = policies.get(model, dict(DEFAULT_PERMISSIONS))
|
|
156
|
+
|
|
157
|
+
action = action.lower().strip()
|
|
158
|
+
|
|
159
|
+
# Parse action into category + operation
|
|
160
|
+
if "_" in action:
|
|
161
|
+
parts = action.split("_", 1)
|
|
162
|
+
category = parts[0]
|
|
163
|
+
operation = parts[1] if len(parts) > 1 else "read"
|
|
164
|
+
else:
|
|
165
|
+
category = action
|
|
166
|
+
operation = "read"
|
|
167
|
+
|
|
168
|
+
# Check access-level permissions (ledger, memory, evidence)
|
|
169
|
+
if category in ("ledger", "memory", "evidence"):
|
|
170
|
+
access = policy.get(category, "read-write")
|
|
171
|
+
if access == "none":
|
|
172
|
+
return {
|
|
173
|
+
"allowed": False,
|
|
174
|
+
"model": model,
|
|
175
|
+
"action": action,
|
|
176
|
+
"reason": f"{model} has no access to {category}",
|
|
177
|
+
}
|
|
178
|
+
if operation == "write" and access == "read-only":
|
|
179
|
+
return {
|
|
180
|
+
"allowed": False,
|
|
181
|
+
"model": model,
|
|
182
|
+
"action": action,
|
|
183
|
+
"reason": f"{model} has read-only access to {category}",
|
|
184
|
+
}
|
|
185
|
+
return {"allowed": True, "model": model, "action": action, "reason": "permitted"}
|
|
186
|
+
|
|
187
|
+
# Check boolean permissions (deploy, lint, etc.)
|
|
188
|
+
if category in ("deploy", "lint", "deliberate", "security_audit", "secrets"):
|
|
189
|
+
key = category.replace("security_", "security_")
|
|
190
|
+
allowed = policy.get(key, DEFAULT_PERMISSIONS.get(key, True))
|
|
191
|
+
if not allowed:
|
|
192
|
+
return {
|
|
193
|
+
"allowed": False,
|
|
194
|
+
"model": model,
|
|
195
|
+
"action": action,
|
|
196
|
+
"reason": f"{model} is not permitted to {category}",
|
|
197
|
+
}
|
|
198
|
+
return {"allowed": True, "model": model, "action": action, "reason": "permitted"}
|
|
199
|
+
|
|
200
|
+
# Check custom constraints
|
|
201
|
+
constraints = policy.get("custom_constraints", [])
|
|
202
|
+
for c in constraints:
|
|
203
|
+
c_lower = c.lower().strip()
|
|
204
|
+
if c_lower.startswith("no-") and c_lower[3:] in action:
|
|
205
|
+
return {
|
|
206
|
+
"allowed": False,
|
|
207
|
+
"model": model,
|
|
208
|
+
"action": action,
|
|
209
|
+
"reason": f"Custom constraint: {c}",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {"allowed": True, "model": model, "action": action, "reason": "no restrictions"}
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def remove_agent_policy(model: str) -> Dict[str, Any]:
|
|
216
|
+
"""Remove custom policy for a model, reverting to defaults."""
|
|
217
|
+
model = model.lower().strip()
|
|
218
|
+
policies = _load_policies()
|
|
219
|
+
|
|
220
|
+
if model not in policies:
|
|
221
|
+
return {"status": "ok", "message": f"No custom policy for {model} (already using defaults)"}
|
|
222
|
+
|
|
223
|
+
del policies[model]
|
|
224
|
+
_save_policies(policies)
|
|
225
|
+
|
|
226
|
+
return {
|
|
227
|
+
"status": "removed",
|
|
228
|
+
"model": model,
|
|
229
|
+
"message": f"Custom policy removed for {model}. Now using defaults.",
|
|
230
|
+
}
|