0xray 3.5.0 → 3.5.1

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.
@@ -4,7 +4,7 @@
4
4
 
5
5
  Quick reference for the 0xRay AI orchestration framework (shipped to your project on `npm install 0xray`).
6
6
 
7
- **v3.5.0** — 7 MCP servers · 45 skills · 68 codex terms · 4 platform bridges
7
+ **v3.5.1** — 7 MCP servers · 45 skills · 68 codex terms · 4 platform bridges
8
8
 
9
9
  ## Postinstall (automatic)
10
10
 
package/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # xray Agents
2
2
 
3
- Quick reference for the xray AI orchestration framework (**v3.5.0**).
3
+ Quick reference for the xray AI orchestration framework (**v3.5.1**).
4
4
 
5
5
  **42 agents** · **45 skills** · **7 consumer MCP servers** · **68 codex terms** · **3,226 tests**
6
6
 
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # xray — MCP-Centric AI Governance OS
2
2
 
3
- **v3.5.0** — 42 agents · 45 skills · 7 MCP servers · 68 codex terms · 3,226 tests
3
+ **v3.5.1** — 42 agents · 45 skills · 7 MCP servers · 68 codex terms · 3,226 tests
4
4
 
5
5
  ## Your AI Power Suit
6
6
 
package/SKILLS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 0xray Skills
2
2
 
3
- **v3.5.0** — **45 skills** · invoked via `xray-skills` MCP · synced on postinstall
3
+ **v3.5.1** — **45 skills** · invoked via `xray-skills` MCP · synced on postinstall
4
4
 
5
5
  xray ships 45 knowledge skills (`src/skills/<name>/SKILL.md`) across the full software lifecycle. Lead-dev operating model lives in **`orchestrator`** skill + `multi_agent_orchestration.lead_dev_mode` in features.json. Skills load on demand via the **xray-skills** MCP server (`npx -y 0xray mcp skills`), agent `@mentions`, or platform skill directories after postinstall.
6
6
 
package/dist/AGENTS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # xray Agents
2
2
 
3
- Quick reference for the xray AI orchestration framework (**v3.5.0**).
3
+ Quick reference for the xray AI orchestration framework (**v3.5.1**).
4
4
 
5
5
  **42 agents** · **45 skills** · **7 consumer MCP servers** · **68 codex terms** · **3,226 tests**
6
6
 
package/dist/CHANGELOG.md CHANGED
@@ -4,6 +4,22 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Conventional Commits](https://www.conventionalcommits.org/).
6
6
 
7
+ ## [3.5.1] - 2026-06-19
8
+
9
+ ### ✨ Features
10
+ - **Multi-host delegation gates** — `src/nucleus/delegation-gate.ts` SSOT; `evaluatePreToolGate` / `evaluatePostToolSpawn` shared across Grok, Hermes, OpenCode
11
+ - **Hermes** — `pre_tool_call` returns `{"action":"block"}` via bridge `delegation-gate`; `post_tool_call` clears pending on `delegate_task`
12
+ - **OpenCode** — `tool.execute.before` / `after` enforce delegation gate + post-spawn clear
13
+
14
+ ### ✅ Tests
15
+ - `delegation-gate.test.ts`; `verify-delegation-gate-core.mjs`; `verify-hermes-delegation-gate.mjs`; shared `delegation-gate-fixture.mjs`
16
+ - Grok hook-utils deduped — re-exports nucleus SSOT via `delegation-gate-runtime.mjs`
17
+
18
+ ### 📋 Deferred
19
+ - **OpenClaw host veto** — no consumer tool-block API; observability-only until platform supports pre-tool deny
20
+
21
+ ---
22
+
7
23
  ## [3.5.0] - 2026-06-19
8
24
 
9
25
  ### ✨ Features
package/dist/README.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # xray — MCP-Centric AI Governance OS
2
2
 
3
- **v3.5.0** — 42 agents · 45 skills · 7 MCP servers · 68 codex terms · 3,226 tests
3
+ **v3.5.1** — 42 agents · 45 skills · 7 MCP servers · 68 codex terms · 3,226 tests
4
4
 
5
5
  ## Your AI Power Suit
6
6
 
package/dist/SKILLS.md CHANGED
@@ -1,6 +1,6 @@
1
1
  # 0xray Skills
2
2
 
3
- **v3.5.0** — **45 skills** · invoked via `xray-skills` MCP · synced on postinstall
3
+ **v3.5.1** — **45 skills** · invoked via `xray-skills` MCP · synced on postinstall
4
4
 
5
5
  xray ships 45 knowledge skills (`src/skills/<name>/SKILL.md`) across the full software lifecycle. Lead-dev operating model lives in **`orchestrator`** skill + `multi_agent_orchestration.lead_dev_mode` in features.json. Skills load on demand via the **xray-skills** MCP server (`npx -y 0xray mcp skills`), agent `@mentions`, or platform skill directories after postinstall.
6
6
 
@@ -6,6 +6,31 @@
6
6
 
7
7
  import fs from 'fs';
8
8
  import path from 'path';
9
+ import {
10
+ checkPendingDelegationGate,
11
+ checkSubagentGate,
12
+ satisfyDelegationsFromToolInput,
13
+ isSubagentTool,
14
+ isOrchestrateToolEvent,
15
+ isReadOnlyTool,
16
+ isShellTool,
17
+ getActivePendingDelegations,
18
+ validateSpawnMatchesTodo,
19
+ updatePlanTodoStatusInPlace,
20
+ } from '../../hooks/delegation-gate-runtime.mjs';
21
+
22
+ export {
23
+ checkPendingDelegationGate,
24
+ checkSubagentGate,
25
+ satisfyDelegationsFromToolInput,
26
+ isSubagentTool,
27
+ isOrchestrateToolEvent,
28
+ isReadOnlyTool,
29
+ isShellTool,
30
+ getActivePendingDelegations,
31
+ validateSpawnMatchesTodo,
32
+ updatePlanTodoStatusInPlace,
33
+ };
9
34
 
10
35
  export const LEAD_DEV_RULES = [
11
36
  'Phased plan + todos; assign best subagent; monitor output',
@@ -116,140 +141,6 @@ export function loadBlockingCodexTerms() {
116
141
  }
117
142
  }
118
143
 
119
- export function leadDevPlanPath(root = workspaceRoot()) {
120
- return path.join(root, '.xray', 'state', 'lead-dev-plan.json');
121
- }
122
-
123
- const DEFAULT_PLAN_TTL_MS = 4 * 60 * 60 * 1000;
124
-
125
- export function loadPersistedLeadDevPlan(root = workspaceRoot()) {
126
- const planPath = leadDevPlanPath(root);
127
- if (!fs.existsSync(planPath)) return null;
128
- try {
129
- return JSON.parse(fs.readFileSync(planPath, 'utf8'));
130
- } catch {
131
- return null;
132
- }
133
- }
134
-
135
- export function savePersistedLeadDevPlan(plan, root = workspaceRoot()) {
136
- const planPath = leadDevPlanPath(root);
137
- fs.mkdirSync(path.dirname(planPath), { recursive: true });
138
- fs.writeFileSync(planPath, JSON.stringify(plan, null, 2));
139
- return planPath;
140
- }
141
-
142
- export function getOutstandingPlanTodos(plan) {
143
- if (!plan?.phases) return [];
144
- return plan.phases.flatMap((p) => p.todos).filter(
145
- (t) => t.status === 'pending' || t.status === 'in_progress',
146
- );
147
- }
148
-
149
- export function getNextRequiredPlanTodo(plan) {
150
- if (!plan?.phases) return null;
151
- for (const phase of plan.phases) {
152
- const next = phase.todos.find(
153
- (t) => t.status === 'pending' || t.status === 'in_progress',
154
- );
155
- if (next) return next;
156
- }
157
- return null;
158
- }
159
-
160
- /** Todo-level persistence: plan stays valid while outstanding todos exist */
161
- export function hasValidLeadDevPlanForSpawn(root = workspaceRoot()) {
162
- const plan = loadPersistedLeadDevPlan(root);
163
- if (!plan?.active) return false;
164
- const outstanding = getOutstandingPlanTodos(plan);
165
- if (outstanding.length > 0) return true;
166
- const persistedAt = plan.persistedAt;
167
- if (!persistedAt) {
168
- try {
169
- const stat = fs.statSync(leadDevPlanPath(root));
170
- return Date.now() - stat.mtimeMs <= DEFAULT_PLAN_TTL_MS;
171
- } catch {
172
- return false;
173
- }
174
- }
175
- return Date.now() - new Date(persistedAt).getTime() <= DEFAULT_PLAN_TTL_MS;
176
- }
177
-
178
- export function hasFreshLeadDevPlan(maxAgeMs = DEFAULT_PLAN_TTL_MS) {
179
- return hasValidLeadDevPlanForSpawn();
180
- }
181
-
182
- function normalizeSpawnAgent(agent) {
183
- const key = String(agent || '').toLowerCase().trim();
184
- const aliases = {
185
- 'bug-triage-specialist': 'bug-triage',
186
- 'code-reviewer': 'code-review',
187
- };
188
- return aliases[key] ?? key;
189
- }
190
-
191
- function agentsAlign(expected, actual) {
192
- const e = normalizeSpawnAgent(expected);
193
- const a = normalizeSpawnAgent(actual);
194
- return e === a || e.includes(a) || a.includes(e);
195
- }
196
-
197
- export function validateSpawnMatchesTodo(toolInput, root = workspaceRoot(), expectedTodo = null) {
198
- const plan = loadPersistedLeadDevPlan(root);
199
- const nextTodo = expectedTodo ?? (plan ? getNextRequiredPlanTodo(plan) : null);
200
- if (!nextTodo) return { valid: true };
201
-
202
- const prompt = String(
203
- toolInput.prompt || toolInput.description || toolInput.task || '',
204
- ).toLowerCase();
205
- const subagent = String(toolInput.subagent_type || toolInput.agent || '');
206
- const explicitTodo = toolInput.planTodoId || toolInput.delegationId;
207
-
208
- if (explicitTodo && explicitTodo === nextTodo.id) {
209
- return { valid: true, expectedTodoId: nextTodo.id, hint: null };
210
- }
211
- if (prompt.includes(nextTodo.id.toLowerCase())) {
212
- return { valid: true, expectedTodoId: nextTodo.id, hint: null };
213
- }
214
- if (prompt.includes(nextTodo.task.toLowerCase().slice(0, 30))) {
215
- return { valid: true, expectedTodoId: nextTodo.id, hint: null };
216
- }
217
- if (subagent && agentsAlign(nextTodo.subagent, subagent) && prompt.length > 20) {
218
- return { valid: true, expectedTodoId: nextTodo.id, hint: null };
219
- }
220
-
221
- return {
222
- valid: false,
223
- expectedTodoId: nextTodo.id,
224
- reason: `Spawn must target plan todo ${nextTodo.id} before other work`,
225
- gate: 'spawn-todo-persistence',
226
- hint: {
227
- tool: 'Task',
228
- subagent_type: nextTodo.subagent,
229
- planTodoId: nextTodo.id,
230
- description: `Lead-dev todo ${nextTodo.id}: ${nextTodo.task}. Include plan todo id in Task prompt.`,
231
- },
232
- };
233
- }
234
-
235
- export function updatePlanTodoStatusInPlace(todoId, status, root = workspaceRoot()) {
236
- const plan = loadPersistedLeadDevPlan(root);
237
- if (!plan) return false;
238
- let updated = false;
239
- for (const phase of plan.phases) {
240
- for (const todo of phase.todos) {
241
- if (todo.id === todoId) {
242
- todo.status = status;
243
- updated = true;
244
- }
245
- }
246
- }
247
- if (updated) {
248
- fs.writeFileSync(leadDevPlanPath(root), JSON.stringify(plan, null, 2));
249
- }
250
- return updated;
251
- }
252
-
253
144
  export function extractToolContext(event) {
254
145
  const toolName = event.toolName || process.env.TOOL_NAME || 'unknown';
255
146
  const toolInput = event.toolInput ?? {};
@@ -290,41 +181,6 @@ export function checkSurfaceArea(paths, root = workspaceRoot()) {
290
181
  return null;
291
182
  }
292
183
 
293
- export function checkSubagentGate(toolName, features, root = workspaceRoot(), sessionId = null, toolInput = {}) {
294
- if (!features.lead_dev_mode) return null;
295
- if (!isSubagentTool(toolName)) return null;
296
-
297
- if (!hasValidLeadDevPlanForSpawn(root)) {
298
- return {
299
- reason:
300
- 'Codex 59/67: call xray-orchestrator analyze-complexity first — ' +
301
- 'writes .xray/state/lead-dev-plan.json required before spawn_subagent',
302
- gate: 'spawn-plan-missing',
303
- };
304
- }
305
-
306
- if (features.auto_chain_delegations === false) return null;
307
-
308
- const pending = getActivePendingDelegations(root, sessionId);
309
- const expectedTodo =
310
- pending[0]?.planTodoId && loadPersistedLeadDevPlan(root)
311
- ? loadPersistedLeadDevPlan(root).phases
312
- .flatMap((p) => p.todos)
313
- .find((t) => t.id === pending[0].planTodoId) ?? null
314
- : null;
315
-
316
- const validation = validateSpawnMatchesTodo(toolInput, root, expectedTodo);
317
- if (!validation.valid) {
318
- return {
319
- reason: validation.reason,
320
- gate: validation.gate,
321
- hint: validation.hint,
322
- };
323
- }
324
-
325
- return null;
326
- }
327
-
328
184
  export function checkFullTestSuite(cmd, features) {
329
185
  if (!features.per_suite_test_triage) return null;
330
186
  const cmdText = cmd.toLowerCase();
@@ -343,10 +199,6 @@ export function isWriteTool(toolName) {
343
199
  return WRITE_TOOLS.has(toolName) || /write|edit|replace/i.test(toolName);
344
200
  }
345
201
 
346
- export function isShellTool(toolName) {
347
- return /terminal|bash|shell|run_terminal/i.test(toolName);
348
- }
349
-
350
202
  export async function readStdinJson() {
351
203
  const chunks = [];
352
204
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -408,50 +260,6 @@ export function ensureSessionBoot(root = workspaceRoot(), source = '0xray/grok-b
408
260
  return writeSessionBoot(root, buildSessionBootPayload(root, source));
409
261
  }
410
262
 
411
- const READ_TOOLS = new Set([
412
- 'read_file',
413
- 'Read',
414
- 'grep',
415
- 'Grep',
416
- 'glob',
417
- 'Glob',
418
- 'list_dir',
419
- 'ListDir',
420
- 'web_search',
421
- 'WebSearch',
422
- 'codebase_search',
423
- 'SemanticSearch',
424
- ]);
425
-
426
- const ORCHESTRATOR_CONSULT_TOOLS = new Set([
427
- 'analyze-complexity',
428
- 'analyze_complexity',
429
- 'get-orchestration-status',
430
- 'get_orchestration_status',
431
- 'orchestrate-task',
432
- 'orchestrate_task',
433
- ]);
434
-
435
- export function pendingDelegationsPath(root = workspaceRoot()) {
436
- return path.join(root, '.xray', 'state', 'pending-delegations.json');
437
- }
438
-
439
- export function loadPendingDelegationsState(root = workspaceRoot()) {
440
- const filePath = pendingDelegationsPath(root);
441
- if (!fs.existsSync(filePath)) return null;
442
- try {
443
- return JSON.parse(fs.readFileSync(filePath, 'utf8'));
444
- } catch {
445
- return null;
446
- }
447
- }
448
-
449
- export function savePendingDelegationsState(state, root = workspaceRoot()) {
450
- const filePath = pendingDelegationsPath(root);
451
- fs.mkdirSync(path.dirname(filePath), { recursive: true });
452
- fs.writeFileSync(filePath, JSON.stringify(state, null, 2));
453
- }
454
-
455
263
  export function resolveSessionId(event) {
456
264
  return (
457
265
  event.sessionId ||
@@ -460,176 +268,15 @@ export function resolveSessionId(event) {
460
268
  );
461
269
  }
462
270
 
463
- export function isPendingStateActive(state, sessionId) {
464
- if (!state || !sessionId || state.sessionId !== sessionId) return false;
465
- const age = Date.now() - new Date(state.createdAt).getTime();
466
- if (age > (state.ttlMs ?? 4 * 60 * 60 * 1000)) return false;
467
- return state.delegations?.some((d) => d.status === 'pending');
468
- }
469
-
470
- export function getActivePendingDelegations(root, sessionId) {
471
- const state = loadPendingDelegationsState(root);
472
- if (!isPendingStateActive(state, sessionId)) return [];
473
- return state.delegations.filter((d) => d.status === 'pending');
474
- }
475
-
476
271
  export function clearPendingDelegationsForSessionChange(newSessionId, root = workspaceRoot()) {
477
- const state = loadPendingDelegationsState(root);
478
- if (!state) return false;
479
- if (newSessionId && state.sessionId === newSessionId) return false;
272
+ const filePath = path.join(root, '.xray', 'state', 'pending-delegations.json');
273
+ if (!fs.existsSync(filePath)) return false;
480
274
  try {
481
- fs.unlinkSync(pendingDelegationsPath(root));
275
+ const state = JSON.parse(fs.readFileSync(filePath, 'utf8'));
276
+ if (newSessionId && state.sessionId === newSessionId) return false;
277
+ fs.unlinkSync(filePath);
482
278
  return true;
483
279
  } catch {
484
280
  return false;
485
281
  }
486
- }
487
-
488
- function isFocusedTestCommand(cmd) {
489
- const cmdText = (cmd || '').toLowerCase();
490
- return (
491
- /\b(npm\s+(run\s+)?test|vitest\s+run|pnpm\s+test|yarn\s+test)\b/.test(cmdText) &&
492
- (/--\s+\S+\.test/.test(cmdText) || /vitest\s+run\s+\S+\.test/.test(cmdText))
493
- );
494
- }
495
-
496
- function isReadOnlyTool(toolName) {
497
- return READ_TOOLS.has(toolName) || /^(read|grep|glob|list)/i.test(toolName);
498
- }
499
-
500
- export function isSubagentTool(toolName) {
501
- return SUBAGENT_TOOLS.has(toolName) || /^task$/i.test(toolName) || toolName === 'spawn_subagent';
502
- }
503
-
504
- function extractMcpToolName(toolInput) {
505
- const candidates = [
506
- toolInput.toolName,
507
- toolInput.tool,
508
- toolInput.name,
509
- toolInput.mcpToolName,
510
- ];
511
- for (const c of candidates) {
512
- if (c) return String(c);
513
- }
514
- const server = String(toolInput.server || toolInput.mcpServer || '');
515
- const args = toolInput.arguments ?? toolInput.args ?? {};
516
- if (args.toolName) return String(args.toolName);
517
- if (server.includes('orchestrator') && args.name) return String(args.name);
518
- return '';
519
- }
520
-
521
- function isOrchestratorConsultMcp(toolName, toolInput) {
522
- if (!/mcp|CallMcpTool/i.test(toolName)) return false;
523
- const inner = extractMcpToolName(toolInput).toLowerCase();
524
- if (!inner) return false;
525
- for (const t of ORCHESTRATOR_CONSULT_TOOLS) {
526
- if (inner.includes(t) || inner.includes(t.replace(/-/g, '_'))) return true;
527
- }
528
- return false;
529
- }
530
-
531
- export function isOrchestrateToolEvent(toolName, toolInput = {}) {
532
- const inner = extractMcpToolName(toolInput).toLowerCase();
533
- if (/orchestrate[-_]?task/.test(inner)) return true;
534
- const blob = JSON.stringify(toolInput).toLowerCase();
535
- return /orchestrate[-_]?task/.test(blob);
536
- }
537
-
538
- export function satisfyDelegationsFromToolInput(toolInput, root = workspaceRoot()) {
539
- const state = loadPendingDelegationsState(root);
540
- if (!state) return { satisfied: [], clearedAll: false };
541
-
542
- const prompt = String(
543
- toolInput.prompt || toolInput.description || toolInput.task || '',
544
- ).toLowerCase();
545
- const subagent = String(toolInput.subagent_type || toolInput.agent || '').toLowerCase();
546
- const delegationId = toolInput.delegationId || toolInput.delegation_id;
547
-
548
- const pending = state.delegations.filter((d) => d.status === 'pending');
549
- if (pending.length === 0) return { satisfied: [], clearedAll: false };
550
-
551
- let matches = [];
552
- if (delegationId) {
553
- matches = pending.filter((d) => d.id === delegationId);
554
- } else {
555
- for (const d of pending) {
556
- if (d.planTodoId && prompt.includes(d.planTodoId.toLowerCase())) {
557
- matches.push(d);
558
- break;
559
- }
560
- if (prompt.includes(d.taskId.toLowerCase())) {
561
- matches.push(d);
562
- break;
563
- }
564
- if (subagent && d.agent.toLowerCase().includes(subagent)) {
565
- matches.push(d);
566
- break;
567
- }
568
- }
569
- }
570
-
571
- if (matches.length === 0 && pending.length === 1) {
572
- const only = pending[0];
573
- const hint = (only.spawnHint?.description || '').toLowerCase();
574
- if (prompt && hint && prompt.includes(hint.slice(0, 30))) {
575
- matches = [only];
576
- }
577
- }
578
-
579
- if (matches.length === 0) return { satisfied: [], clearedAll: false };
580
-
581
- const now = new Date().toISOString();
582
- const ids = new Set(matches.map((m) => m.id));
583
- for (const d of state.delegations) {
584
- if (ids.has(d.id) && d.status === 'pending') {
585
- d.status = 'satisfied';
586
- d.satisfiedAt = now;
587
- }
588
- }
589
- savePendingDelegationsState(state, root);
590
- for (const m of matches) {
591
- if (m.planTodoId) {
592
- updatePlanTodoStatusInPlace(m.planTodoId, 'in_progress', root);
593
- }
594
- }
595
- const clearedAll = !state.delegations.some((d) => d.status === 'pending');
596
- return { satisfied: matches, clearedAll };
597
- }
598
-
599
- /**
600
- * Surgical gate: block writes and unrelated work while pending delegations exist.
601
- * Allows read-only tools, orchestrator consult MCP, focused test shell, and Task/spawn.
602
- */
603
- export function checkPendingDelegationGate(toolName, toolInput, features, root, sessionId) {
604
- if (!features.lead_dev_mode || features.auto_chain_delegations === false) return null;
605
- if (!sessionId) return null;
606
-
607
- const pending = getActivePendingDelegations(root, sessionId);
608
- if (pending.length === 0) return null;
609
-
610
- if (isSubagentTool(toolName)) return null;
611
-
612
- if (isReadOnlyTool(toolName)) return null;
613
-
614
- if (isOrchestratorConsultMcp(toolName, toolInput)) return null;
615
-
616
- if (isShellTool(toolName)) {
617
- const cmd = String(toolInput.command || '');
618
- if (isFocusedTestCommand(cmd)) return null;
619
- }
620
-
621
- const primary = pending[0];
622
- return {
623
- reason: 'Pending implementation delegation — spawn host Task before other work',
624
- gate: 'auto-chain-pending',
625
- hint: primary.spawnHint ?? {
626
- tool: 'Task',
627
- subagent_type: primary.agent,
628
- description: primary.taskDescription,
629
- planTodoId: primary.planTodoId,
630
- delegationId: primary.id,
631
- },
632
- pendingCount: pending.length,
633
- delegationId: primary.id,
634
- };
635
282
  }
@@ -187,17 +187,52 @@ def _is_xray_mcp(tool_name: str) -> bool:
187
187
  return tool_name.startswith("mcp_xray_")
188
188
 
189
189
 
190
+ def _delegation_gate_block(tool_name: str, args: dict, task_id: str, **kwargs):
191
+ """3.5.1 multi-host parity — block via nucleus delegation-gate SSOT."""
192
+ session_id = (
193
+ kwargs.get("session_id")
194
+ or kwargs.get("sessionId")
195
+ or task_id
196
+ or ""
197
+ )
198
+ bridge_result = _call_bridge(
199
+ {
200
+ "command": "delegation-gate",
201
+ "phase": "pre",
202
+ "tool": tool_name,
203
+ "args": args or {},
204
+ "sessionId": session_id,
205
+ "host": "hermes",
206
+ },
207
+ timeout=10,
208
+ )
209
+ if bridge_result.get("allow") is False:
210
+ reason = bridge_result.get("reason", "Delegation gate blocked this tool")
211
+ gate = bridge_result.get("gate", "delegation-gate")
212
+ _log_to_file(
213
+ "activity.log",
214
+ f"[delegation-gate] deny tool={tool_name} gate={gate} reason={reason}",
215
+ )
216
+ return {"action": "block", "message": reason}
217
+ return None
218
+
219
+
190
220
  def _on_pre_tool_call(tool_name: str, args: dict, task_id: str, **kwargs):
191
221
  """Fires before ANY tool executes.
192
222
 
193
223
  Pipeline:
194
224
  1. Track stats
195
225
  2. Log tool-start event to disk
196
- 3. For code-producing tools: run quality gate + pre-processors via bridge
197
- 4. For non-code tools: nudge if Xray alternative exists
226
+ 3. Delegation gate (pending + spawn todo) — may block tool
227
+ 4. For code-producing tools: run quality gate + pre-processors via bridge
228
+ 5. For non-code tools: nudge if Xray alternative exists
198
229
  """
199
230
  _session_stats["total_tool_calls"] += 1
200
231
 
232
+ block = _delegation_gate_block(tool_name, args, task_id, **kwargs)
233
+ if block:
234
+ return block
235
+
201
236
  # Log start event
202
237
  _log_tool_event("start", tool_name, args)
203
238
 
@@ -329,9 +364,21 @@ def _on_post_tool_call(tool_name: str, args: dict, result, task_id: str, **kwarg
329
364
  # Record outcome for the inference feedback loop
330
365
  _record_tool_outcome(tool_name, args or {}, error is None)
331
366
 
332
- # delegate_task: validate all files the subagent changed
367
+ # delegate_task: clear pending delegations + validate subagent changes
333
368
  if tool_name == "delegate_task":
334
369
  tid = kwargs.get("task_id", "") or args.get("task_id", "") or task_id
370
+ session_id = kwargs.get("session_id") or kwargs.get("sessionId") or tid
371
+ _call_bridge(
372
+ {
373
+ "command": "delegation-gate",
374
+ "phase": "post",
375
+ "tool": tool_name,
376
+ "args": args or {},
377
+ "sessionId": session_id,
378
+ "host": "hermes",
379
+ },
380
+ timeout=10,
381
+ )
335
382
  if tid:
336
383
  _validate_subagent_changes(tid)
337
384
  return