delimit-cli 3.14.15 → 3.14.17

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.
@@ -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();
@@ -839,8 +839,62 @@ exit 127
839
839
  }
840
840
  log('');
841
841
 
842
- // Step 11: Done
843
- step(11, 'Done!');
842
+ // Step 11: Social target scanning config
843
+ step(11, 'Configuring social target scanner...');
844
+
845
+ const socialConfigPath = path.join(DELIMIT_HOME, 'social_target_config.json');
846
+ const socialDefaultConfig = {
847
+ platforms: {
848
+ x: { enabled: true, provider: 'twttr241' },
849
+ reddit: { enabled: true, provider: 'proxy' },
850
+ github: { enabled: true, provider: 'gh_cli' },
851
+ hn: { enabled: true, provider: 'algolia' },
852
+ devto: { enabled: true, provider: 'public_api' },
853
+ namepros: { enabled: false, provider: 'manual' },
854
+ },
855
+ subreddits: {},
856
+ github_queries: {},
857
+ scan_limit: 10,
858
+ min_engagement: { score: 1, comments: 2 },
859
+ };
860
+
861
+ if (!fs.existsSync(socialConfigPath)) {
862
+ fs.mkdirSync(path.dirname(socialConfigPath), { recursive: true });
863
+ fs.writeFileSync(socialConfigPath, JSON.stringify(socialDefaultConfig, null, 2) + '\n');
864
+ log(` ${green('\u2713')} Created ${dim(socialConfigPath)}`);
865
+ } else {
866
+ log(` ${dim(' Config already exists:')} ${socialConfigPath}`);
867
+ }
868
+
869
+ // Auto-detect available platforms
870
+ const detectedPlatforms = {};
871
+ // HN and Dev.to are always available (public APIs)
872
+ detectedPlatforms['hn'] = 'available (public API)';
873
+ detectedPlatforms['devto'] = 'available (public API)';
874
+ // GitHub: check gh CLI
875
+ try {
876
+ execSync('gh auth status', { stdio: 'pipe', timeout: 10000 });
877
+ detectedPlatforms['github'] = 'available (gh authenticated)';
878
+ } catch {
879
+ detectedPlatforms['github'] = 'unavailable (gh not authenticated)';
880
+ }
881
+ // X: check for RapidAPI key in env
882
+ if (process.env.RAPIDAPI_KEY) {
883
+ detectedPlatforms['x'] = 'available (RapidAPI key in env)';
884
+ } else if (process.env.XAI_API_KEY) {
885
+ detectedPlatforms['x'] = 'available (xAI key in env, fallback)';
886
+ } else {
887
+ detectedPlatforms['x'] = 'unavailable (no API key)';
888
+ }
889
+
890
+ for (const [plat, status] of Object.entries(detectedPlatforms)) {
891
+ const icon = status.startsWith('available') ? green('\u2713') : yellow('\u2717');
892
+ log(` ${icon} ${plat}: ${dim(status)}`);
893
+ }
894
+ log('');
895
+
896
+ // Step 12: Done
897
+ step(12, 'Done!');
844
898
  log('');
845
899
  log(` ${green('Delimit is installed.')} Your AI now has persistent memory and governance.`);
846
900
  log('');
@@ -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
+ }