@evoclock/pi-agentic-driver 0.9.1 → 0.9.3

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.
Files changed (67) hide show
  1. package/.agentic-driver/router.defaults.json +64 -0
  2. package/LICENSE +11 -8
  3. package/README.md +116 -32
  4. package/extensions/attended-authority-guard.ts +1 -1
  5. package/extensions/code-phage.js +1 -1
  6. package/extensions/herdr-communication.ts +1 -1
  7. package/extensions/herdr-dispatch.ts +1 -1
  8. package/extensions/herdr-lifecycle.ts +1 -1
  9. package/extensions/linux-microvm.ts +1 -1
  10. package/extensions/pulse.ts +33 -2
  11. package/extensions/task-board.ts +1 -1
  12. package/janus/README.draft.md +210 -0
  13. package/janus/README.md +196 -0
  14. package/janus/janus/audit.ts +213 -0
  15. package/janus/janus/budget.ts +101 -0
  16. package/janus/janus/budget_breaker.ts +105 -0
  17. package/janus/janus/config.ts +332 -0
  18. package/janus/janus/errors.ts +90 -0
  19. package/janus/janus/evaluate.ts +230 -0
  20. package/janus/janus/keychain.ts +119 -0
  21. package/janus/janus/providers.ts +544 -0
  22. package/janus/janus/schema.ts +344 -0
  23. package/janus/janus/server.ts +267 -0
  24. package/janus/janus/single_flight.ts +99 -0
  25. package/janus/janus/verdict.ts +192 -0
  26. package/janus/package-lock.json +616 -0
  27. package/janus/package.json +29 -0
  28. package/janus/tests/accounting.test.ts +229 -0
  29. package/janus/tests/allowlist.test.ts +97 -0
  30. package/janus/tests/config.test.ts +151 -0
  31. package/janus/tests/errors.test.ts +147 -0
  32. package/janus/tests/evaluate_pipeline.test.ts +358 -0
  33. package/janus/tests/helpers.ts +217 -0
  34. package/janus/tests/providers.test.ts +292 -0
  35. package/janus/tests/redaction_budget.test.ts +227 -0
  36. package/janus/tests/schema.test.ts +356 -0
  37. package/janus/tests/server.test.ts +288 -0
  38. package/janus/tests/single_flight.test.ts +84 -0
  39. package/janus/tsconfig.json +19 -0
  40. package/janus/user.janus.plist +51 -0
  41. package/lib/adapters/diff-scope.mjs +1 -1
  42. package/lib/adapters/evidence.mjs +1 -1
  43. package/lib/adapters/narrative.mjs +1 -1
  44. package/lib/adapters/review-feedback.mjs +1 -1
  45. package/lib/adapters/visualization.mjs +1 -1
  46. package/lib/code-phage-core.mjs +1 -1
  47. package/lib/python_ast_metrics.py +1 -1
  48. package/lib/typescript_ast_metrics.mjs +1 -1
  49. package/package.json +14 -3
  50. package/scripts/aidr_writing_review.js +1 -1
  51. package/scripts/enforcement/attended_authority_guard.js +1 -1
  52. package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
  53. package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
  54. package/scripts/enforcement/herdr_communication_pi.js +13 -8
  55. package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
  56. package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
  57. package/scripts/enforcement/native_tui_context.js +1 -1
  58. package/scripts/enforcement/pulse_core_pi.js +38 -14
  59. package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
  60. package/scripts/enforcement/router_engine_pi.js +483 -0
  61. package/scripts/enforcement/router_ranking_pi.js +146 -0
  62. package/scripts/enforcement/router_replacement_pi.js +98 -0
  63. package/scripts/enforcement/router_runtime_pi.js +299 -0
  64. package/scripts/enforcement/router_schemas_pi.js +654 -0
  65. package/scripts/enforcement/router_store_pi.js +438 -0
  66. package/scripts/enforcement/task_board_core_pi.js +255 -44
  67. package/skills/pulse/SKILL.md +1 -1
@@ -1,5 +1,5 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
- // SPDX-License-Identifier: AGPL-3.0-or-later
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
3
 
4
4
  // Pulse scheduler (PULSE_DESIGN_v3 §2.3, §3.3, §6, §7, §8, §12): session-scoped
5
5
  // scheduling composition. Owns the manual `check` action (pure observation),
@@ -22,6 +22,8 @@ import {
22
22
  automationPolicyPath, PLACEMENTS,
23
23
  } from "./task_board_core_pi.js";
24
24
  import { scanBoard } from "./pulse_core_pi.js";
25
+ import { routeDecision } from "./router_engine_pi.js";
26
+ import { createReservation, claimReservation, renewReservationLease, consumeReservation, releaseReservation, insertRouteDecision } from "./router_store_pi.js";
25
27
  import { isNativeTuiContext } from "./native_tui_context.js";
26
28
 
27
29
  // ---------------------------------------------------------------------------
@@ -98,7 +100,7 @@ export function observeModelAvailability({ pulsePolicy = null, modelRegistry = n
98
100
 
99
101
  // One pure board scan for the manual check. Returns a structured result; it
100
102
  // never claims and never mutates anything.
101
- export function pulseCheck({ boardPath, modelRegistry = null, scopedModels = null, observedAt = null }) {
103
+ export function pulseCheck({ boardPath, modelRegistry = null, scopedModels = null, observedAt = null, activeSessions = [], providerLimit = null }) {
102
104
  const base = observeBoardForPulse({ boardPath });
103
105
  if (!base.ok) return base;
104
106
  const { validated, pulsePolicy, effectivePolicy } = base;
@@ -115,8 +117,32 @@ export function pulseCheck({ boardPath, modelRegistry = null, scopedModels = nul
115
117
  declaredModels,
116
118
  observedAt,
117
119
  placement: pulsePolicy ? effectivePolicy.placement : null,
120
+ activeSessions, providerLimit,
118
121
  });
119
- return { ok: true, code: null, reason: null, scan, diagnosticHash, validated, effectivePolicy, pulsePolicy, activeClaims };
122
+ return { ok: true, code: null, reason: null, scan, diagnosticHash, validated, effectivePolicy, pulsePolicy, activeClaims, activeSessions, providerLimit };
123
+ }
124
+
125
+ export async function routePulseCheck(check, { routerConfig, quotaObservations = [], healthObservations = {}, consumptionSamples = [], existingReservations = [], modelInstalled = null, janusRank = null, now = null } = {}) {
126
+ if (!check?.ok || !routerConfig) return { ok: false, code: "router-unavailable", reason: "validated router configuration is required" };
127
+ const decisions = [];
128
+ const proposed = new Set((check.scan.proposedDispatches ?? []).map((p) => p.title));
129
+ for (const card of check.validated.cards) {
130
+ if (!proposed.has(card.title)) continue;
131
+ const role = check.scan.cards.find((c) => c.title === card.title)?.role ?? "implementer";
132
+ const routeModels = [...(check.pulsePolicy?.routing?.[role]?.preferred ?? []), ...(check.pulsePolicy?.routing?.[role]?.fallback ?? [])].map((r) => r.model);
133
+ const policyRouteSeatIds = routerConfig.seats.filter((s) => routeModels.includes(s.model)).map((s) => s.seatId);
134
+ const freeCapacityBySeat = Object.fromEntries(routerConfig.seats.map((s) => [s.seatId,
135
+ check.scan.capacity.find((c) => c.role === role && c.model === s.model)?.free ?? 0]));
136
+ const decision = await routeDecision({ card: { ...card, role }, config: routerConfig,
137
+ snapshotDigest: check.diagnosticHash, policyRouteSeatIds, healthObservations,
138
+ quotaObservations, consumptionSamples, existingReservations, activeSessions: check.activeSessions,
139
+ freeCapacityBySeat, modelInstalled, janusRank, now: now ?? new Date().toISOString() });
140
+ if (!decision.ok) {
141
+ return { ok: false, code: "route-unavailable", reason: decision.reason ?? `no eligible route for card ${card.cardId}` };
142
+ }
143
+ decisions.push({ ...decision, cardId: card.cardId });
144
+ }
145
+ return { ok: true, decisions };
120
146
  }
121
147
 
122
148
  // Shared trusted resolution: board parse/validation + policy read/validate.
@@ -158,7 +184,7 @@ const BATCH_INTERACTIVE_TTL_MS = 5 * 60 * 1000;
158
184
  // set covers the FULL currently eligible candidate list (every READY_FOR_NEXT
159
185
  // card), not only initially free slots; refill consumes it lazily. It is
160
186
  // extension-memory state, never model input or durable authority.
161
- export function mintBatchContext({ scan, cards, policy, instruction, now = null, batchId = null }) {
187
+ export function mintBatchContext({ scan, cards, policy, instruction, now = null, batchId = null, routeDecisions = null }) {
162
188
  if (scan?.schema !== "agentic-driver.board-pulse.v1") {
163
189
  throw new Error("mintBatchContext requires a pulse scan");
164
190
  }
@@ -176,14 +202,25 @@ export function mintBatchContext({ scan, cards, policy, instruction, now = null,
176
202
  else byTitle.set(card.title, card);
177
203
  }
178
204
  const proposalByTitle = new Map((scan.proposedDispatches ?? []).map((p) => [p.title, p]));
205
+ // Route decisions (router design §1/§8.1): keyed by cardId. The scheduler
206
+ // carries the decision's identity (seatId, provider, model, digest,
207
+ // reservationId) into the batch entry — it never recomputes a route.
208
+ const decisionByCardId = new Map(
209
+ (routeDecisions ?? []).filter((d) => d?.ok && typeof d?.record?.decisionId === "string")
210
+ .map((d) => [d.cardId, d]),
211
+ );
179
212
  const entries = [];
180
213
  for (const cardScan of scan.cards) {
181
214
  if (cardScan.result !== "READY_FOR_NEXT") continue;
182
215
  const card = byTitle.get(cardScan.title);
183
216
  if (!card) continue; // duplicate or missing title fails closed
184
217
  const proposal = proposalByTitle.get(cardScan.title);
185
- const model = proposal?.model ?? null;
186
- if (!model) continue; // no usable declared route observed this scan
218
+ const decision = decisionByCardId.get(card.cardId) ?? null;
219
+ if (!decision || typeof decision.digest !== "string" || typeof decision.model !== "string"
220
+ || typeof decision.provider !== "string" || typeof decision.selectedSeatId !== "string") {
221
+ throw new Error(`missing complete route decision for card ${card.cardId} (fails closed)`);
222
+ }
223
+ const model = decision.model;
187
224
  const repositories = Array.isArray(card.repositories) ? card.repositories.filter((r) => typeof r === "string" && r !== "") : [];
188
225
  const repository = repositories.length === 1 ? repositories[0]
189
226
  : repositories.length === 0 && Array.isArray(policy?.acceptedRepositories) && policy.acceptedRepositories.length === 1
@@ -199,6 +236,18 @@ export function mintBatchContext({ scan, cards, policy, instruction, now = null,
199
236
  repository,
200
237
  placement: proposal?.placement ?? policy?.placement ?? null,
201
238
  state: "unused",
239
+ seatId: decision.selectedSeatId,
240
+ accountId: decision.accountId,
241
+ provider: decision.provider,
242
+ endpointRef: decision.endpointRef,
243
+ effort: decision.effort,
244
+ containmentTier: decision.containmentTier,
245
+ phase: decision.record.phase,
246
+ routeDecisionId: decision.record.decisionId,
247
+ routeDecisionRecord: decision.record,
248
+ routeDecisionDigest: decision.digest,
249
+ reservationId: decision.reservationId,
250
+ reservationVectors: decision.reservationVectors,
202
251
  });
203
252
  }
204
253
  // Interactive expiry is the earlier of policy expiry or five minutes.
@@ -278,7 +327,7 @@ const AUTOMATED_HOST_DENIED =
278
327
  // consume after verified start. A claim race removes the card from the landed
279
328
  // batch and refreshes capacity; an ambiguous spawn leaves the entry reserved
280
329
  // and triggers reconciliation, never reuse or another start.
281
- async function executeBatch({ batch, boardPath, spawnWorker = null, context = null, now = null, allowHostDispatch = false }) {
330
+ export async function executeBatch({ batch, boardPath, spawnWorker = null, context = null, now = null, allowHostDispatch = false, routerStore = null }) {
282
331
  const landed = [];
283
332
  const capacityTaken = new Set();
284
333
  let terminalReason = "no-eligible-card";
@@ -300,7 +349,13 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
300
349
  continue;
301
350
  }
302
351
  const reserved = reserveBatchEntry(batch, entry.cardId, { now });
303
- if (!reserved.ok) continue; // consumed/expired earlier in this batch
352
+ if (!reserved.ok) continue;
353
+ if (entry.reservationId !== null) {
354
+ if (!routerStore) { releaseBatchEntry(batch, entry.cardId); throw new Error("router store is required for quota reservation"); }
355
+ createReservation(routerStore, { reservationId: entry.reservationId, decisionId: entry.routeDecisionId,
356
+ seatId: entry.seatId, accountId: entry.accountId, vectors: entry.reservationVectors, now: now ?? new Date().toISOString() });
357
+ }
358
+ if (routerStore) insertRouteDecision(routerStore, { record: entry.routeDecisionRecord, digest: entry.routeDecisionDigest });
304
359
  // Host entries claim ONLY through the trusted confirmed-batch path; the
305
360
  // batch was registered after the one native confirmation, and each entry
306
361
  // is single-use. Contained entries use the ordinary contained-only claim.
@@ -308,6 +363,7 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
308
363
  ? claimCardForConfirmedBatch({ boardPath, cardId: entry.cardId, role: entry.role, policy: null, now, confirmedBatch: batch })
309
364
  : claimCard({ boardPath, cardId: entry.cardId, role: entry.role, policy: null });
310
365
  if (!claim.ok) {
366
+ if (routerStore && entry.reservationId) releaseReservation(routerStore, { reservationId: entry.reservationId, now: now ?? new Date().toISOString() });
311
367
  releaseBatchEntry(batch, entry.cardId);
312
368
  landed.push({
313
369
  title: entry.title, role: entry.role, model: entry.model, placement: entry.placement,
@@ -317,6 +373,14 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
317
373
  continue;
318
374
  }
319
375
  capacityTaken.add(entry.cardId);
376
+ if (routerStore && entry.reservationId) {
377
+ const bound = claimReservation(routerStore, { reservationId: entry.reservationId,
378
+ claimId: claim.envelopeId, envelopeId: claim.envelopeId, now: now ?? new Date().toISOString() });
379
+ if (!bound.ok) throw new Error(bound.reason);
380
+ // L1: lease renewal runs through the production dispatch loop hook, not
381
+ // only from tests. The authenticated envelope is the reservation identity.
382
+ heartbeatBatchReservation({ routerStore, envelope: claim.envelope, now });
383
+ }
320
384
  if (typeof spawnWorker !== "function") {
321
385
  // No guarded spawn seam in this context: the claim landed and the entry
322
386
  // is consumed as a claimed-but-not-started assignment; never spawn
@@ -331,10 +395,13 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
331
395
  spawned = await spawnWorker({
332
396
  role: entry.role,
333
397
  repository: entry.repository,
334
- model: entry.model,
398
+ model: claim.envelope.model,
399
+ provider: claim.envelope.provider,
400
+ seatId: claim.envelope.seatId,
401
+ endpointRef: entry.endpointRef,
402
+ envelope: claim.envelope,
335
403
  placement: entry.placement,
336
- context,
337
- signal: null,
404
+ context, signal: null,
338
405
  });
339
406
  } catch (error) {
340
407
  spawned = { ok: false, reason: String(error?.message || error).slice(0, 256) };
@@ -345,7 +412,9 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
345
412
  } else {
346
413
  // Delivery-unknown, partial start, or ambiguous read-back: the entry
347
414
  // stays reserved and triggers reconciliation — never reuse or another
348
- // start (§6.2).
415
+ // start (§6.2). The reservation follows the claim through the terminal
416
+ // settlement hook (production caller for settleBatchReservation).
417
+ if (routerStore && entry.reservationId) settleBatchReservation({ routerStore, envelope: claim.envelope, outcome: "released", now });
349
418
  landed.push({
350
419
  title: entry.title, role: entry.role, model: entry.model, placement: entry.placement,
351
420
  status: "denied",
@@ -357,6 +426,19 @@ async function executeBatch({ batch, boardPath, spawnWorker = null, context = nu
357
426
  return { landed, terminalReason };
358
427
  }
359
428
 
429
+ // Dispatch-loop completion/heartbeat hooks. The authenticated envelope is
430
+ // the only accepted reservation identity; terminal transitions are idempotent.
431
+ export function heartbeatBatchReservation({ routerStore, envelope, now = null }) {
432
+ if (!routerStore || typeof envelope?.reservationId !== "string") return { ok: true, skipped: true };
433
+ return renewReservationLease(routerStore, { reservationId: envelope.reservationId, now: now ?? new Date().toISOString() });
434
+ }
435
+
436
+ export function settleBatchReservation({ routerStore, envelope, outcome, now = null }) {
437
+ if (!routerStore || typeof envelope?.reservationId !== "string") return { ok: true, skipped: true };
438
+ const input = { reservationId: envelope.reservationId, now: now ?? new Date().toISOString() };
439
+ return outcome === "completed" ? consumeReservation(routerStore, input) : releaseReservation(routerStore, input);
440
+ }
441
+
360
442
  // ---------------------------------------------------------------------------
361
443
  // Interactive run (§6.2)
362
444
  // ---------------------------------------------------------------------------
@@ -376,12 +458,15 @@ export async function pulseRun({ boardPath, instruction, context = null, spawnWo
376
458
  if (!pulsePolicy || pulsePolicy.enabled !== true) {
377
459
  return { ok: false, code: "pulse-disabled", reason: "Pulse is not enabled for this board (fails closed)", scan: null, landedAssignments: [], batchTerminalReason: "pulse-disabled" };
378
460
  }
379
- const check = pulseCheck({ boardPath, modelRegistry: context?.modelRegistry ?? null, scopedModels: context?.scopedModels ?? null, observedAt: now });
461
+ const check = pulseCheck({ boardPath, modelRegistry: context?.modelRegistry ?? null, scopedModels: context?.scopedModels ?? null, observedAt: now,
462
+ activeSessions: context?.activeSessions ?? [], providerLimit: context?.providerLimit ?? null });
380
463
  if (!check.ok) {
381
464
  return { ok: false, code: check.code, reason: check.reason, scan: null, landedAssignments: [], batchTerminalReason: check.code };
382
465
  }
383
466
  const { scan } = check;
384
- const batch = mintBatchContext({ scan, cards: validated.cards, policy: effectivePolicy, instruction, now });
467
+ const routed = await routePulseCheck(check, { ...(context?.routerSnapshot ?? {}), routerConfig: context?.routerConfig, now });
468
+ if (!routed.ok) return { ok: false, code: routed.code, reason: routed.reason, scan, landedAssignments: [], batchTerminalReason: routed.code };
469
+ const batch = mintBatchContext({ scan, cards: validated.cards, policy: effectivePolicy, instruction, now, routeDecisions: routed.decisions });
385
470
  // One semantic batch preview and one native confirmation for the exact
386
471
  // batch (§6.2 step 3-4). Headless contexts fail closed.
387
472
  if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
@@ -410,7 +495,7 @@ export async function pulseRun({ boardPath, instruction, context = null, spawnWo
410
495
  // batch context is registered for the trusted in-memory confirmed-batch
411
496
  // claim path; each host entry is claimable exactly once from it.
412
497
  registerConfirmedBatchForClaims(batch);
413
- const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context, now, allowHostDispatch: true });
498
+ const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context, now, allowHostDispatch: true, routerStore: context?.routerStore ?? null });
414
499
  return { ok: true, cancelled: false, scan, landedAssignments: landed, batchTerminalReason: terminalReason, batchId: batch.batchId };
415
500
  }
416
501
 
@@ -419,17 +504,33 @@ export async function pulseRun({ boardPath, instruction, context = null, spawnWo
419
504
  // polling. Only runs when the policy enables Pulse in automated mode.
420
505
  // ---------------------------------------------------------------------------
421
506
 
422
- export async function pulseTick({ boardPath, spawnWorker = null, context = null, now = null }) {
507
+ export async function pulseTick({ boardPath, spawnWorker = null, context = null, now = null, routerRuntimeFor = null }) {
423
508
  const base = observeBoardForPulse({ boardPath });
424
509
  if (!base.ok) return { ok: false, code: base.code, reason: base.reason, landedAssignments: [] };
425
510
  const { effectivePolicy, pulsePolicy } = base;
426
511
  if (!pulsePolicy || pulsePolicy.enabled !== true) return { ok: true, skipped: true, reason: "pulse-disabled", landedAssignments: [] };
427
512
  if (pulsePolicy.mode !== "automated") return { ok: true, skipped: true, reason: "interactive-mode-tick-is-a-no-op", landedAssignments: [] };
428
- const check = pulseCheck({ boardPath, modelRegistry: context?.modelRegistry ?? null, scopedModels: context?.scopedModels ?? null, observedAt: now });
513
+ // W3: an automated tick refreshes its capacity snapshot per tick. A factory
514
+ // supplied by the timer re-reads collector caches and health probes so
515
+ // read-time freshness and the prior-reservation term are never frozen at
516
+ // session setup. A factory failure fails the tick closed (no stale routing).
517
+ let tickContext = context;
518
+ if (typeof routerRuntimeFor === "function") {
519
+ try {
520
+ const refreshed = await routerRuntimeFor({ boardPath, now });
521
+ tickContext = { ...(context ?? {}), ...(refreshed ?? {}) };
522
+ } catch (error) {
523
+ return { ok: false, code: "router-unavailable", reason: String(error?.message || error).slice(0, 512), landedAssignments: [] };
524
+ }
525
+ }
526
+ const check = pulseCheck({ boardPath, modelRegistry: tickContext?.modelRegistry ?? null, scopedModels: tickContext?.scopedModels ?? null, observedAt: now,
527
+ activeSessions: tickContext?.activeSessions ?? [], providerLimit: tickContext?.providerLimit ?? null });
429
528
  if (!check.ok) return { ok: false, code: check.code, reason: check.reason, landedAssignments: [] };
430
- const batch = mintBatchContext({ scan: check.scan, cards: check.validated.cards, policy: effectivePolicy, instruction: "automated-tick", now });
529
+ const routed = await routePulseCheck(check, { ...(tickContext?.routerSnapshot ?? {}), routerConfig: tickContext?.routerConfig, now });
530
+ if (!routed.ok) return { ok: false, code: routed.code, reason: routed.reason, landedAssignments: [] };
531
+ const batch = mintBatchContext({ scan: check.scan, cards: check.validated.cards, policy: effectivePolicy, instruction: "automated-tick", now, routeDecisions: routed.decisions });
431
532
  // Automated ticks never infer host dispatch: allowHostDispatch stays false.
432
- const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context, now, allowHostDispatch: false });
533
+ const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context: tickContext, now, allowHostDispatch: false, routerStore: tickContext?.routerStore ?? null });
433
534
  return { ok: true, skipped: false, scan: check.scan, landedAssignments: landed, batchTerminalReason: terminalReason };
434
535
  }
435
536
 
@@ -443,14 +544,46 @@ export async function pulseTick({ boardPath, spawnWorker = null, context = null,
443
544
  // shell:false); any container/microvm placement is denied WITHOUT touching
444
545
  // the lifecycle — Pulse never represents a contained assignment as contained
445
546
  // while starting an ordinary host worker.
547
+ // §5.2 execution validation: the spawn step verifies the model/endpoint
548
+ // passed to Herdr exactly matches the authenticated envelope (model +
549
+ // provider + seat identity). A mismatch fails closed and is reported as a
550
+ // structured route-mismatch result (the caller logs the event).
551
+ export function validateSpawnMatchesEnvelope({ envelope, model, provider = null, seatId = null, endpointRef = null, routerConfig = null }) {
552
+ if (envelope === null || typeof envelope !== "object") {
553
+ return { ok: false, code: "route-mismatch", reason: "no authenticated envelope (fails closed)" };
554
+ }
555
+ if (typeof model !== "string" || model !== envelope.model) {
556
+ return { ok: false, code: "route-mismatch", reason: `spawn model "${model}" does not match the authenticated envelope model "${envelope.model}" (fails closed)` };
557
+ }
558
+ if (typeof provider !== "string" || provider !== envelope.provider) {
559
+ return { ok: false, code: "route-mismatch", reason: `spawn provider "${provider}" does not match the authenticated envelope provider "${envelope.provider}" (fails closed)` };
560
+ }
561
+ if (typeof seatId !== "string" || seatId !== envelope.seatId) {
562
+ return { ok: false, code: "route-mismatch", reason: `spawn seat "${seatId}" does not match the authenticated envelope seat "${envelope.seatId}" (fails closed)` };
563
+ }
564
+ if (routerConfig !== null) {
565
+ const seat = routerConfig?.seats?.find?.((s) => s.seatId === envelope.seatId);
566
+ if (!seat || seat.model !== envelope.model || seat.provider !== envelope.provider || seat.endpointRef !== endpointRef
567
+ || routerConfig?.endpoints?.[endpointRef]?.kind !== envelope.provider) {
568
+ return { ok: false, code: "route-mismatch", reason: "spawn endpoint does not resolve to the authenticated envelope route (fails closed)" };
569
+ }
570
+ }
571
+ return { ok: true, code: null, reason: null };
572
+ }
573
+
446
574
  export function pulseWorkerSpawnSeam({ executeHerdrSpawnWorker } = {}) {
447
575
  if (typeof executeHerdrSpawnWorker !== "function") {
448
576
  throw new Error("pulseWorkerSpawnSeam requires the guarded herdr-lifecycle executeHerdrSpawnWorker");
449
577
  }
450
- return async ({ role, repository, model, placement, context, signal }) => {
578
+ return async ({ role, repository, model, placement, context, signal, envelope = null, provider = null, seatId = null, endpointRef = null }) => {
451
579
  if (placement !== "host") {
452
580
  return { ok: false, code: "containment-seam-unavailable", reason: CONTAINMENT_SEAM_UNAVAILABLE };
453
581
  }
582
+ // §5.2 execution validation: the spawn must exactly match the
583
+ // authenticated envelope's route identity. A mismatch fails closed
584
+ // BEFORE any Herdr call is attempted.
585
+ const routeCheck = validateSpawnMatchesEnvelope({ envelope, model, provider, seatId, endpointRef, routerConfig: context?.routerConfig ?? null });
586
+ if (!routeCheck.ok) return routeCheck;
454
587
  // Board policy stores canonical repository paths, while the guarded
455
588
  // lifecycle accepts only registry names. Convert only a canonical sibling
456
589
  // of the coordinator repository; the lifecycle then revalidates the name,
@@ -810,7 +943,7 @@ export function createPulseTimer({ intervalSeconds, fillOnStart = false, tick, s
810
943
  // Tool registration (§12.2)
811
944
  // ---------------------------------------------------------------------------
812
945
 
813
- export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn = null, spawnWorker = null } = {}) {
946
+ export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn = null, spawnWorker = null, routerRuntimeFor = null } = {}) {
814
947
  const boardPathFor = (ctx) => {
815
948
  const cwd = typeof ctx === "string" ? ctx : ctx?.cwd;
816
949
  if (typeof resolveBoardPathFn === "function") return resolveBoardPathFn(ctx);
@@ -856,18 +989,47 @@ export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn =
856
989
  };
857
990
  const action = input?.action;
858
991
  const activeBoardPath = boardPathFor(ctx);
992
+ let runtimeContext = ctx;
993
+ let runtimeError = null;
994
+ if (["check", "run"].includes(action) && typeof routerRuntimeFor === "function" && activeBoardPath) {
995
+ try {
996
+ // W4: `check` is pure observation, so the runtime is assembled
997
+ // read-only (no store writes, no store creation) while `run` uses
998
+ // the write-mode runtime its claims need.
999
+ runtimeContext = { ...ctx, ...(await routerRuntimeFor(ctx, activeBoardPath, { readOnly: action === "check" })) };
1000
+ }
1001
+ catch (error) { runtimeError = String(error?.message || error).slice(0, 512); }
1002
+ }
859
1003
  if (action === "check") {
860
1004
  if (!activeBoardPath || !existsSync(activeBoardPath)) {
861
1005
  return fail("board-unavailable", "no board file found for this workspace (board-unavailable)");
862
1006
  }
863
1007
  const result = pulseCheck({
864
1008
  boardPath: activeBoardPath,
865
- modelRegistry: ctx?.modelRegistry ?? null,
866
- scopedModels: ctx?.scopedModels ?? null,
1009
+ modelRegistry: runtimeContext?.modelRegistry ?? null,
1010
+ scopedModels: runtimeContext?.scopedModels ?? null,
867
1011
  observedAt: new Date().toISOString(),
1012
+ activeSessions: runtimeContext?.activeSessions ?? [],
1013
+ providerLimit: runtimeContext?.providerLimit ?? null,
868
1014
  });
869
1015
  if (!result.ok) return fail(result.code, result.reason);
870
- const value = { ok: true, nonAuthorizing: true, persisted: false, scan: result.scan };
1016
+ // Routing is part of the observation: when the router is available
1017
+ // the check carries the real route decisions; when it is not, the
1018
+ // scan is still reported (pure observation, zero scheduling
1019
+ // effects) with a structured routing failure — dispatch paths
1020
+ // (run/tick) fail closed separately.
1021
+ let routing;
1022
+ if (runtimeError !== null) {
1023
+ routing = { ok: false, code: "router-unavailable", reason: runtimeError };
1024
+ } else {
1025
+ const routed = await routePulseCheck(result, { ...(runtimeContext?.routerSnapshot ?? {}),
1026
+ routerConfig: runtimeContext?.routerConfig, now: runtimeContext?.routerNow ?? new Date().toISOString() });
1027
+ routing = routed.ok
1028
+ ? { ok: true, routeDecisions: routed.decisions.map((decision) => ({ cardId: decision.cardId,
1029
+ seatId: decision.selectedSeatId, routeDecisionDigest: decision.digest })) }
1030
+ : { ok: false, code: routed.code, reason: routed.reason };
1031
+ }
1032
+ const value = { ok: true, nonAuthorizing: true, persisted: false, scan: result.scan, routing };
871
1033
  return {
872
1034
  content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
873
1035
  details: { ...value, diagnosticHash: result.diagnosticHash },
@@ -877,10 +1039,11 @@ export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn =
877
1039
  if (!activeBoardPath || !existsSync(activeBoardPath)) {
878
1040
  return fail("board-unavailable", "no board file found for this workspace (board-unavailable)");
879
1041
  }
1042
+ if (runtimeError !== null) return fail("router-unavailable", runtimeError);
880
1043
  const result = await pulseRun({
881
1044
  boardPath: activeBoardPath,
882
1045
  instruction: input?.instruction,
883
- context: ctx,
1046
+ context: runtimeContext,
884
1047
  spawnWorker,
885
1048
  });
886
1049
  if (!result.ok) return fail(result.code, result.reason);