@evoclock/pi-agentic-driver 0.9.1 → 0.9.2

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 (38) hide show
  1. package/.agentic-driver/router.defaults.json +64 -0
  2. package/LICENSE +11 -8
  3. package/README.md +92 -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/lib/adapters/diff-scope.mjs +1 -1
  13. package/lib/adapters/evidence.mjs +1 -1
  14. package/lib/adapters/narrative.mjs +1 -1
  15. package/lib/adapters/review-feedback.mjs +1 -1
  16. package/lib/adapters/visualization.mjs +1 -1
  17. package/lib/code-phage-core.mjs +1 -1
  18. package/lib/python_ast_metrics.py +1 -1
  19. package/lib/typescript_ast_metrics.mjs +1 -1
  20. package/package.json +12 -2
  21. package/scripts/aidr_writing_review.js +1 -1
  22. package/scripts/enforcement/attended_authority_guard.js +1 -1
  23. package/scripts/enforcement/guest_containment_taxonomy.v1.json +1 -1
  24. package/scripts/enforcement/herdr_async_dispatch_pi.js +1 -1
  25. package/scripts/enforcement/herdr_communication_pi.js +13 -8
  26. package/scripts/enforcement/herdr_lifecycle_pi.js +3 -3
  27. package/scripts/enforcement/linux_microvm_cutover_pi.js +1 -1
  28. package/scripts/enforcement/native_tui_context.js +1 -1
  29. package/scripts/enforcement/pulse_core_pi.js +38 -14
  30. package/scripts/enforcement/pulse_scheduler_pi.js +188 -25
  31. package/scripts/enforcement/router_engine_pi.js +483 -0
  32. package/scripts/enforcement/router_ranking_pi.js +146 -0
  33. package/scripts/enforcement/router_replacement_pi.js +98 -0
  34. package/scripts/enforcement/router_runtime_pi.js +299 -0
  35. package/scripts/enforcement/router_schemas_pi.js +604 -0
  36. package/scripts/enforcement/router_store_pi.js +438 -0
  37. package/scripts/enforcement/task_board_core_pi.js +255 -44
  38. 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
  import {
5
5
  accessSync,
@@ -14,7 +14,7 @@ import { resolve } from "node:path";
14
14
 
15
15
  export const HERDR_COMMUNICATION_TOOL = "agentic_herdr_communication";
16
16
  export const HERDR_COMMUNICATION_SCHEMA = "agentic-driver.herdr-communication.v1";
17
- export const HERDR_VERSION = "0.8.2";
17
+ export const HERDR_VERSION = "0.9.1";
18
18
  // One versioned policy: every schema-valid dynamic role is eligible except the
19
19
  // coordinator class (`coordinator` and `coordinator-*`).
20
20
  export const HERDR_ROLE_POLICY = Object.freeze({
@@ -25,12 +25,17 @@ export const HERDR_ROLE_PATTERN = "^[a-z][a-z0-9]*(?:-[a-z0-9]+)*$";
25
25
  const HERDR_ROLE_REGEXP = new RegExp(HERDR_ROLE_PATTERN);
26
26
  export const HERDR_COMMUNICATION_ACTIONS = Object.freeze(["list", "get", "prompt", "wait", "read"]);
27
27
 
28
- // The Homebrew link is the configured driver-node path observed for Herdr 0.8.2.
29
- // It is deliberately not resolved through PATH or HERDR_BIN_PATH. A package
30
- // upgrade changes the realpath and therefore fails closed until this pin is
31
- // reviewed. Linux callers have no configured production path in this package.
28
+ // The Homebrew link is the configured driver-node path. It is deliberately
29
+ // not resolved through PATH or HERDR_BIN_PATH. The realpath is validated as
30
+ // <Cellar root>/herdr/<any-version>/bin/herdr so a Homebrew upgrade does not
31
+ // break the trust seam: the executable must be the Homebrew-managed herdr
32
+ // binary (same path family), executable, and a regular file, but the version
33
+ // number is not pinned because pinning would break every package upgrade.
34
+ // The semantic version constant above records the version this integration was
35
+ // last validated against and is advisory only. Linux callers have no
36
+ // configured production path in this package.
32
37
  export const TRUSTED_HERDR_EXECUTABLE = "/opt/homebrew/bin/herdr";
33
- const TRUSTED_HERDR_REALPATH_FRAGMENT = `/Cellar/herdr/${HERDR_VERSION}/bin/herdr`;
38
+ const TRUSTED_HERDR_REALPATH_PATTERN = /\/Cellar\/herdr\/[0-9]+\.[0-9]+\.[0-9]+\/bin\/herdr$/;
34
39
  const WORKER_REPOSITORY_REGISTRY = "config/herdr-worker-repositories.v1.json";
35
40
  const WORKER_REPOSITORY_SCHEMA = "agentic-driver.herdr-worker-repositories.v1";
36
41
  const WORKER_REPOSITORY_FIELDS = new Set(["schema", "repositories"]);
@@ -427,7 +432,7 @@ function productionExecutable() {
427
432
  real = realpathSync(TRUSTED_HERDR_EXECUTABLE);
428
433
  const stat = statSync(real);
429
434
  accessSync(real, fsConstants.X_OK);
430
- if (!stat.isFile() || !real.endsWith(TRUSTED_HERDR_REALPATH_FRAGMENT)) throw new Error("version mismatch");
435
+ if (!stat.isFile() || !TRUSTED_HERDR_REALPATH_PATTERN.test(real)) throw new Error("trust mismatch");
431
436
  } catch {
432
437
  throw communicationError("trusted_executable_unavailable", `the configured Herdr ${HERDR_VERSION} executable was not observed`);
433
438
  }
@@ -1,8 +1,8 @@
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
  // Guarded spawn_worker composite capability (Tranche 07). One tool, one closed
5
- // request, one documented Herdr 0.8.2 layout/start sequence, bounded read-back,
5
+ // request, one documented Herdr layout/start sequence, bounded read-back,
6
6
  // and a bounded receipt. This is deliberately NOT a general Herdr management
7
7
  // surface: no raw management verbs, no terminal remote control, no model
8
8
  // allowlist constant. Pane placement uses pane split directly; tab placement
@@ -767,7 +767,7 @@ export async function executeHerdrSpawnWorker(params, context, options = {}, sig
767
767
  }
768
768
  canonicalRoot = reconfirmedRoot;
769
769
 
770
- // Herdr 0.8.2 owns new-shell readiness. Follow its documented topology:
770
+ // Herdr owns new-shell readiness. Follow its documented topology:
771
771
  // split directly for pane placement, or create a tab for tab placement.
772
772
  let coordinator = null;
773
773
  if (placement !== "tab") {
@@ -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
  import { createHash, randomBytes } from "node:crypto";
5
5
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
@@ -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
  // Public native-TUI context predicate shared by the shipped enforcement
5
5
  // interfaces. Restriction-gated actions stay fail-closed: anything that is
@@ -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 core (PULSE_DESIGN_v3 §2.2, §4, §5): a side-effect-free evaluation of
5
5
  // one selected board. It consumes validated board cards, active claim
@@ -48,9 +48,17 @@ export function modelAvailability({ model, observations = null, declaredModels =
48
48
  }
49
49
 
50
50
  // True only when the model can take new work this tick. "full" means the
51
- // provider reported a live limit reached; the route is full, not unusable.
52
- export function modelUsable(availability) {
53
- return availability === "available" || availability === "full";
51
+ // provider reported a live limit reached; the route is full, not unusable —
52
+ // UNLESS an explicit provider-limit observation says otherwise (router design
53
+ // §9 item 1): a bare "full" with no provider limit is treated as unusable
54
+ // because capacity cannot be verified, and capacity races must fail closed.
55
+ export function modelUsable(availability, { providerLimit = null, providerActive = null } = {}) {
56
+ if (availability === "available") return true;
57
+ if (availability === "full") {
58
+ return Number.isInteger(providerLimit) && Number.isInteger(providerActive)
59
+ && providerLimit > 0 && providerActive < providerLimit;
60
+ }
61
+ return false;
54
62
  }
55
63
 
56
64
  // Composed free capacity for one role/model route (§4.2):
@@ -88,8 +96,12 @@ export function freeCapacity({ role, model, pulsePolicy, activeClaims = [], acti
88
96
 
89
97
  const routeCeiling = route.maxConcurrent;
90
98
  const roleCeiling = roleEntry?.maxConcurrent ?? routeCeiling;
91
- const policyCeiling = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
92
- ? pulsePolicy.globalMaxConcurrent
99
+ // §9 item 2: the policy-wide ceiling is the automation policy's own
100
+ // maxConcurrent (the actual global ceiling). The legacy pulsePolicy
101
+ // .globalMaxConcurrent lookup is removed — that field never existed in the
102
+ // closed pulse shape.
103
+ const policyCeiling = Number.isInteger(pulsePolicy?.maxConcurrent)
104
+ ? pulsePolicy.maxConcurrent
93
105
  : null;
94
106
  const configured = Math.min(routeCeiling, roleCeiling, policyCeiling ?? routeCeiling);
95
107
  const providerCap = Number.isInteger(providerLimit) && providerLimit >= 0 ? providerLimit : null;
@@ -121,7 +133,7 @@ export function routesForRole(role, pulsePolicy) {
121
133
  // Inputs are already trusted: the card comes from parseBoard/validateBoard,
122
134
  // the policy from checkAutomationPolicy. Capacity never changes a card
123
135
  // result — it only changes `capacity` and `proposedDispatches`.
124
- export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, acceptedRepositories = null }) {
136
+ export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, acceptedRepositories = null, providerLimit = null, providerActive = null }) {
125
137
  // Provenance verification is the caller adapter's responsibility: it
126
138
  // supplies cards with `statePath` only when the trusted writer state is
127
139
  // available. The core stays side-effect-free and file-free.
@@ -169,9 +181,13 @@ export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy
169
181
  }
170
182
  // Route availability: at least one declared route must be usable. Without
171
183
  // an authorised fallback this is REVIEW_REQUIRED (§5), never a substitution.
184
+ // "full" counts as usable only with an explicit provider limit (§9 item 1).
172
185
  const usable = routes.filter((route) => modelUsable(modelAvailability({
173
186
  model: route.model, observations: modelObservations, declaredModels,
174
- })));
187
+ }), {
188
+ providerLimit,
189
+ providerActive: isPlainObject(providerActive) ? providerActive[route.model] : providerActive,
190
+ }));
175
191
  if (usable.length === 0) {
176
192
  return { result: "REVIEW_REQUIRED", reason: `no usable declared route for role "${role}" (preferred unavailable without authorised fallback)` };
177
193
  }
@@ -181,7 +197,7 @@ export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy
181
197
  // One full board scan (§4.3, §5). Deterministic: cards in board P0→P3 then
182
198
  // card-ID order; capacity in role-then-route declaration order; proposals in
183
199
  // selection order. No cross-board priority comparison happens here.
184
- export function scanBoard({ cards, boardPath, boardRevision = null, policyRevision = null, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, observedAt = null, placement = null }) {
200
+ export function scanBoard({ cards, boardPath, boardRevision = null, policyRevision = null, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, observedAt = null, placement = null, activeSessions = [], providerLimit = null }) {
185
201
  const index = new Map(cards.map((card) => [card.cardId, card]));
186
202
  const ordered = [...cards].sort((a, b) =>
187
203
  (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
@@ -190,21 +206,24 @@ export function scanBoard({ cards, boardPath, boardRevision = null, policyRevisi
190
206
  const cardResults = [];
191
207
  const capacity = [];
192
208
  const proposedDispatches = [];
193
- const policyWideRemaining = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
194
- ? pulsePolicy.globalMaxConcurrent - (activeClaims ?? []).length
209
+ // §9 item 2: the policy-wide ceiling is the automation policy's
210
+ // maxConcurrent. §9 item 3: the production path supplies activeSessions
211
+ // and providerLimit; the scan composes them into capacity.
212
+ const policyWideRemaining = Number.isInteger(pulsePolicy?.maxConcurrent)
213
+ ? pulsePolicy.maxConcurrent - (activeClaims ?? []).length
195
214
  : null;
196
215
 
197
216
  const roleOrder = pulsePolicy?.routing ? Object.keys(pulsePolicy.routing) : [];
198
217
  for (const role of roleOrder) {
199
218
  for (const route of routesForRole(role, pulsePolicy)) {
200
219
  const availability = modelAvailability({ model: route.model, observations: modelObservations, declaredModels });
201
- const free = freeCapacity({ role, model: route.model, pulsePolicy, activeClaims });
220
+ const free = freeCapacity({ role, model: route.model, pulsePolicy, activeClaims, activeSessions, providerLimit });
202
221
  capacity.push({
203
222
  role, model: route.model, configured: free.configured,
204
223
  activeClaims: (activeClaims ?? []).filter((claim) => claim?.role === role && claimModel(claim) === route.model).length,
205
224
  startingOrRunning: 0,
206
- providerLimit: null,
207
- free: availability === "available" || availability === "full" ? free.free : 0,
225
+ providerLimit: Number.isInteger(providerLimit) && providerLimit >= 0 ? providerLimit : null,
226
+ free: modelUsable(availability, { providerLimit, providerActive: (activeSessions ?? []).filter((s) => s?.model === route.model).length }) ? free.free : 0,
208
227
  availability,
209
228
  });
210
229
  }
@@ -223,6 +242,11 @@ export function scanBoard({ cards, boardPath, boardRevision = null, policyRevisi
223
242
  card, boardIndex: index, boardPath, activeClaims, pulsePolicy,
224
243
  modelObservations, declaredModels,
225
244
  acceptedRepositories: pulsePolicy?.acceptedRepositories ?? null,
245
+ providerLimit,
246
+ providerActive: Object.fromEntries(routesForRole(
247
+ typeof card.role === "string" && card.role !== "" ? card.role : "implementer",
248
+ pulsePolicy,
249
+ ).map((route) => [route.model, (activeSessions ?? []).filter((session) => session?.model === route.model).length])),
226
250
  });
227
251
  const entry = { title: card.title, role: evaluation.role ?? (typeof card.role === "string" && card.role !== "" ? card.role : "implementer"), result: evaluation.result, reason: evaluation.reason };
228
252
  cardResults.push(entry);
@@ -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);