@evoclock/pi-agentic-driver 0.9.0 → 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 +94 -29
  4. package/extensions/attended-authority-guard.ts +1 -1
  5. package/extensions/code-phage.js +1 -1
  6. package/extensions/herdr-communication.ts +9 -2
  7. package/extensions/herdr-dispatch.ts +1 -1
  8. package/extensions/herdr-lifecycle.ts +5 -4
  9. package/extensions/linux-microvm.ts +1 -1
  10. package/extensions/pulse.ts +105 -0
  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 +17 -3
  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 +66 -4
  25. package/scripts/enforcement/herdr_communication_pi.js +55 -19
  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 +292 -0
  30. package/scripts/enforcement/pulse_scheduler_pi.js +1083 -0
  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 +597 -48
  38. package/skills/pulse/SKILL.md +39 -0
@@ -0,0 +1,1083 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-only
3
+
4
+ // Pulse scheduler (PULSE_DESIGN_v3 §2.3, §3.3, §6, §7, §8, §12): session-scoped
5
+ // scheduling composition. Owns the manual `check` action (pure observation),
6
+ // the interactive `run` action with its immutable single-use batch context and
7
+ // one native confirmation, the trusted semantic policy operations
8
+ // (enable/disable/configure), and the optional fixed-interval timer.
9
+ //
10
+ // It never claims, spawns, or mutates policy itself: atomic claims go through
11
+ // the existing dispatcher (claimCard), worker creation through the guarded
12
+ // Herdr lifecycle seam injected as `spawnWorker`, and policy writes through
13
+ // one confirmed semantic operation on the existing board-bound policy file.
14
+ // The timer never sends prompts to a model to make scheduling decisions.
15
+
16
+ import { existsSync, readFileSync, writeFileSync, renameSync, unlinkSync, statSync } from "node:fs";
17
+ import { basename, dirname, join, resolve } from "node:path";
18
+ import { randomUUID } from "node:crypto";
19
+ import {
20
+ validateBoard, readAutomationPolicy, checkAutomationPolicy, checkPulsePolicy,
21
+ readClaims, claimCard, claimCardForConfirmedBatch, registerConfirmedBatchForClaims,
22
+ automationPolicyPath, PLACEMENTS,
23
+ } from "./task_board_core_pi.js";
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";
27
+ import { isNativeTuiContext } from "./native_tui_context.js";
28
+
29
+ // ---------------------------------------------------------------------------
30
+ // Board resolution and observation
31
+ // ---------------------------------------------------------------------------
32
+
33
+ // Resolve the canonical board for the calling workspace. Mirrors the
34
+ // task-board extension's resolver: canonical TASKS.md first, derived
35
+ // board.md never authoritative.
36
+ export function resolveBoardPath(cwd) {
37
+ if (typeof cwd !== "string" || cwd === "") return null;
38
+ for (const name of ["TASKS.md", "board.md"]) {
39
+ const candidate = join(cwd, name);
40
+ if (existsSync(candidate)) return candidate;
41
+ }
42
+ return join(cwd, "TASKS.md");
43
+ }
44
+
45
+ // Observe model availability for the policy's declared routes. The adapter
46
+ // resolves exact configured models through the Pi model registry and scoped
47
+ // model list; labels and self-reports never prove availability. Unknown or
48
+ // unresolved models are `unknown` and unusable (fails closed).
49
+ export function observeModelAvailability({ pulsePolicy = null, modelRegistry = null, scopedModels = null } = {}) {
50
+ const observations = {};
51
+ const declared = new Set();
52
+ if (pulsePolicy && typeof pulsePolicy.routing === "object") {
53
+ for (const entry of Object.values(pulsePolicy.routing)) {
54
+ for (const route of [...(entry?.preferred ?? []), ...(entry?.fallback ?? [])]) {
55
+ if (typeof route?.model !== "string") continue;
56
+ declared.add(route.model);
57
+ observations[route.model] = { status: "unknown" };
58
+ }
59
+ }
60
+ }
61
+ const scopedList = Array.isArray(scopedModels) ? scopedModels : [];
62
+ // scopedModels entries are {model, thinkingLevel?}; an empty list means no
63
+ // scoping is configured, so every available model is usable (§4.1).
64
+ const scopedIds = new Set(scopedList.map((entry) => typeof entry === "string" ? entry : entry?.model)
65
+ .filter((id) => typeof id === "string"));
66
+ const noScoping = scopedList.length === 0;
67
+ for (const model of declared) {
68
+ if (!noScoping && !scopedIds.has(model)) {
69
+ observations[model] = { status: "unknown" };
70
+ continue;
71
+ }
72
+ try {
73
+ // Resolve through the registry: an exact model id is resolvable either
74
+ // as a loaded model (get) or through its provider auth (getProviderAuth
75
+ // resolves the current API key without requiring a loaded model).
76
+ // Labels and self-reports never prove availability (§4.1).
77
+ const api = modelRegistry?.get?.(model) ?? null;
78
+ if (!api && typeof modelRegistry?.getProviderAuth === "function") {
79
+ const provider = model.split("/")[0];
80
+ let auth = null;
81
+ try { auth = modelRegistry.getProviderAuth(provider); } catch { auth = null; }
82
+ observations[model] = { status: auth ? "available" : "unknown" };
83
+ continue;
84
+ }
85
+ if (!api) {
86
+ observations[model] = { status: "unknown" };
87
+ continue;
88
+ }
89
+ // Authentication is checked through the registry, not model self-report.
90
+ const auth = typeof modelRegistry?.isAuthenticated === "function"
91
+ ? modelRegistry.isAuthenticated(model)
92
+ : true;
93
+ observations[model] = { status: auth ? "available" : "unauthenticated" };
94
+ } catch {
95
+ observations[model] = { status: "unavailable" };
96
+ }
97
+ }
98
+ return { observations, declaredModels: [...declared] };
99
+ }
100
+
101
+ // One pure board scan for the manual check. Returns a structured result; it
102
+ // never claims and never mutates anything.
103
+ export function pulseCheck({ boardPath, modelRegistry = null, scopedModels = null, observedAt = null, activeSessions = [], providerLimit = null }) {
104
+ const base = observeBoardForPulse({ boardPath });
105
+ if (!base.ok) return base;
106
+ const { validated, pulsePolicy, effectivePolicy } = base;
107
+ const activeClaims = readClaims(boardPath);
108
+ const { observations, declaredModels } = observeModelAvailability({ pulsePolicy, modelRegistry, scopedModels });
109
+ const { scan, diagnosticHash } = scanBoard({
110
+ cards: validated.cards,
111
+ boardPath,
112
+ boardRevision: null,
113
+ policyRevision: null,
114
+ activeClaims,
115
+ pulsePolicy,
116
+ modelObservations: observations,
117
+ declaredModels,
118
+ observedAt,
119
+ placement: pulsePolicy ? effectivePolicy.placement : null,
120
+ activeSessions, providerLimit,
121
+ });
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 };
146
+ }
147
+
148
+ // Shared trusted resolution: board parse/validation + policy read/validate.
149
+ function observeBoardForPulse({ boardPath }) {
150
+ if (typeof boardPath !== "string" || boardPath === "" || !existsSync(boardPath)) {
151
+ return { ok: false, code: "board-unavailable", reason: "no board file found for this workspace (board-unavailable)" };
152
+ }
153
+ let validated;
154
+ try {
155
+ validated = validateBoard(readFileSync(boardPath, "utf8"), {});
156
+ } catch (error) {
157
+ return { ok: false, code: "board-invalid", reason: String(error?.message || error).slice(0, 512) };
158
+ }
159
+ if (!validated.ok) {
160
+ return { ok: false, code: "board-invalid", reason: (validated.errors ?? []).join("; ").slice(0, 512) };
161
+ }
162
+ const policy = readAutomationPolicy(boardPath);
163
+ // A missing or expired policy means Pulse is OFF, not an error: check is a
164
+ // pure observation and must still report the board (§8: zero scheduling
165
+ // effects). A malformed pulse object still fails closed.
166
+ const policyCheck = checkAutomationPolicy(policy, { boardPath });
167
+ if (!policyCheck.ok && policy !== null && policy?.pulse !== undefined) {
168
+ return { ok: false, code: "policy-invalid", reason: policyCheck.reason };
169
+ }
170
+ const effectivePolicy = policyCheck.ok ? policyCheck.policy : null;
171
+ const pulsePolicy = effectivePolicy?.pulse ?? null;
172
+ return { ok: true, validated, effectivePolicy, pulsePolicy };
173
+ }
174
+
175
+ // ---------------------------------------------------------------------------
176
+ // Immutable single-use batch context (§6.2)
177
+ // ---------------------------------------------------------------------------
178
+
179
+ export const PULSE_BATCH_SCHEMA = "agentic-driver.pulse-batch.v1";
180
+ const BATCH_ENTRY_STATES = Object.freeze(["unused", "reserved", "consumed"]);
181
+ const BATCH_INTERACTIVE_TTL_MS = 5 * 60 * 1000;
182
+
183
+ // Mint one immutable, single-use batch context from a fresh scan. The entry
184
+ // set covers the FULL currently eligible candidate list (every READY_FOR_NEXT
185
+ // card), not only initially free slots; refill consumes it lazily. It is
186
+ // extension-memory state, never model input or durable authority.
187
+ export function mintBatchContext({ scan, cards, policy, instruction, now = null, batchId = null, routeDecisions = null }) {
188
+ if (scan?.schema !== "agentic-driver.board-pulse.v1") {
189
+ throw new Error("mintBatchContext requires a pulse scan");
190
+ }
191
+ if (typeof instruction !== "string" || instruction.trim() === "") {
192
+ throw new Error("batch context requires the direct human instruction");
193
+ }
194
+ const at = now ?? new Date().toISOString();
195
+ const issuedMs = Date.parse(at);
196
+ // The public scan shape is title-keyed (§5): internal card identity comes
197
+ // from the validated cards list, matched by title. Duplicate titles fail
198
+ // closed rather than binding the wrong card.
199
+ const byTitle = new Map();
200
+ for (const card of cards) {
201
+ if (byTitle.has(card.title)) byTitle.set(card.title, null);
202
+ else byTitle.set(card.title, card);
203
+ }
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
+ );
212
+ const entries = [];
213
+ for (const cardScan of scan.cards) {
214
+ if (cardScan.result !== "READY_FOR_NEXT") continue;
215
+ const card = byTitle.get(cardScan.title);
216
+ if (!card) continue; // duplicate or missing title fails closed
217
+ const proposal = proposalByTitle.get(cardScan.title);
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;
224
+ const repositories = Array.isArray(card.repositories) ? card.repositories.filter((r) => typeof r === "string" && r !== "") : [];
225
+ const repository = repositories.length === 1 ? repositories[0]
226
+ : repositories.length === 0 && Array.isArray(policy?.acceptedRepositories) && policy.acceptedRepositories.length === 1
227
+ ? policy.acceptedRepositories[0]
228
+ : null;
229
+ if (!repository) continue; // ambiguous repository fails closed
230
+ entries.push({
231
+ cardId: card.cardId,
232
+ cardHash: card.hash ?? null,
233
+ title: card.title,
234
+ role: cardScan.role,
235
+ model,
236
+ repository,
237
+ placement: proposal?.placement ?? policy?.placement ?? null,
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,
251
+ });
252
+ }
253
+ // Interactive expiry is the earlier of policy expiry or five minutes.
254
+ const policyExpiryMs = policy?.expiry ? Date.parse(policy.expiry) : NaN;
255
+ const expiresMs = Number.isFinite(policyExpiryMs)
256
+ ? Math.min(policyExpiryMs, issuedMs + BATCH_INTERACTIVE_TTL_MS)
257
+ : issuedMs + BATCH_INTERACTIVE_TTL_MS;
258
+ return {
259
+ schema: PULSE_BATCH_SCHEMA,
260
+ batchId: batchId ?? randomUUID(),
261
+ instruction,
262
+ boardPath: scan.board,
263
+ policyRevision: null,
264
+ issuedAt: at,
265
+ expiresAt: new Date(expiresMs).toISOString(),
266
+ maxConcurrency: Number.isInteger(policy?.maxConcurrent) ? policy.maxConcurrent : null,
267
+ entries,
268
+ };
269
+ }
270
+
271
+ function batchEntry(ctx, cardId) {
272
+ return (ctx?.entries ?? []).find((entry) => entry.cardId === cardId) ?? null;
273
+ }
274
+
275
+ export function batchExpired(ctx, now = null) {
276
+ const at = now ?? new Date().toISOString();
277
+ return !ctx || typeof ctx.expiresAt !== "string" || Date.parse(ctx.expiresAt) <= Date.parse(at);
278
+ }
279
+
280
+ // Atomic entry transitions in extension memory. Reuse, expiry, or drift
281
+ // fails closed; a reserved entry returns to unused only when the lifecycle
282
+ // result proves no start occurred.
283
+ export function reserveBatchEntry(ctx, cardId, { now = null } = {}) {
284
+ const entry = batchEntry(ctx, cardId);
285
+ if (!entry) return { ok: false, reason: "card is not in the confirmed batch; a fresh preview and confirmation is required" };
286
+ if (batchExpired(ctx, now)) return { ok: false, reason: "the batch confirmation context has expired; a fresh preview and confirmation is required" };
287
+ if (entry.state !== "unused") return { ok: false, reason: `batch entry is ${entry.state}, not unused; it cannot be reserved again` };
288
+ entry.state = "reserved";
289
+ return { ok: true, entry };
290
+ }
291
+
292
+ export function consumeBatchEntry(ctx, cardId) {
293
+ const entry = batchEntry(ctx, cardId);
294
+ if (!entry) return { ok: false, reason: "card is not in the confirmed batch" };
295
+ if (entry.state !== "reserved") return { ok: false, reason: `batch entry is ${entry.state}, not reserved; it cannot be consumed` };
296
+ entry.state = "consumed";
297
+ return { ok: true, entry };
298
+ }
299
+
300
+ export function releaseBatchEntry(ctx, cardId) {
301
+ const entry = batchEntry(ctx, cardId);
302
+ if (!entry) return { ok: false, reason: "card is not in the confirmed batch" };
303
+ if (entry.state !== "reserved") return { ok: false, reason: `batch entry is ${entry.state}, not reserved; it cannot be released` };
304
+ entry.state = "unused";
305
+ return { ok: true, entry };
306
+ }
307
+
308
+ // ---------------------------------------------------------------------------
309
+ // Batch execution (claims + guarded spawns)
310
+ // ---------------------------------------------------------------------------
311
+
312
+ // Containment gate (pre-claim, fail-closed): a container/microvm assignment
313
+ // is NEVER started as an ordinary host Herdr worker. There is no verified
314
+ // containment execution seam that supports assignment workers today, so
315
+ // container/microvm dispatch is denied with a precise result BEFORE any claim
316
+ // is created — no claim means nothing to reconcile and no capacity is leaked,
317
+ // and no envelope attempt is consumed (a retry needs a fresh dispatch once a
318
+ // real containment seam exists). Automated host dispatch stays explicitly
319
+ // high-risk and is never inferred: it is denied unless the caller proves an
320
+ // interactive, natively confirmed run (allowHostDispatch).
321
+ const CONTAINMENT_SEAM_UNAVAILABLE =
322
+ "containment-seam-unavailable: no verified containment execution seam supports assignment workers; container/microvm assignments are never started as host workers (fails closed)";
323
+ const AUTOMATED_HOST_DENIED =
324
+ "automated host dispatch is explicitly high-risk and requires explicit authority; it is never inferred (fails closed)";
325
+
326
+ // Execute the confirmed batch: reserve → atomic claim → guarded spawn →
327
+ // consume after verified start. A claim race removes the card from the landed
328
+ // batch and refreshes capacity; an ambiguous spawn leaves the entry reserved
329
+ // and triggers reconciliation, never reuse or another start.
330
+ export async function executeBatch({ batch, boardPath, spawnWorker = null, context = null, now = null, allowHostDispatch = false, routerStore = null }) {
331
+ const landed = [];
332
+ const capacityTaken = new Set();
333
+ let terminalReason = "no-eligible-card";
334
+ for (const entry of batch.entries) {
335
+ if (batch.maxConcurrency !== null && capacityTaken.size >= batch.maxConcurrency) {
336
+ terminalReason = "capacity-full";
337
+ break;
338
+ }
339
+ if (entry.placement !== "host") {
340
+ // Pre-claim denial: nothing was claimed, so no claim cleanup or
341
+ // reconciliation is needed and capacity is not leaked.
342
+ landed.push({ title: entry.title, role: entry.role, model: entry.model, placement: entry.placement, status: "denied", reason: CONTAINMENT_SEAM_UNAVAILABLE });
343
+ terminalReason = "containment-seam-unavailable";
344
+ continue;
345
+ }
346
+ if (!allowHostDispatch) {
347
+ landed.push({ title: entry.title, role: entry.role, model: entry.model, placement: entry.placement, status: "denied", reason: AUTOMATED_HOST_DENIED });
348
+ terminalReason = "host-dispatch-denied";
349
+ continue;
350
+ }
351
+ const reserved = reserveBatchEntry(batch, entry.cardId, { now });
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 });
359
+ // Host entries claim ONLY through the trusted confirmed-batch path; the
360
+ // batch was registered after the one native confirmation, and each entry
361
+ // is single-use. Contained entries use the ordinary contained-only claim.
362
+ const claim = entry.placement === "host"
363
+ ? claimCardForConfirmedBatch({ boardPath, cardId: entry.cardId, role: entry.role, policy: null, now, confirmedBatch: batch })
364
+ : claimCard({ boardPath, cardId: entry.cardId, role: entry.role, policy: null });
365
+ if (!claim.ok) {
366
+ if (routerStore && entry.reservationId) releaseReservation(routerStore, { reservationId: entry.reservationId, now: now ?? new Date().toISOString() });
367
+ releaseBatchEntry(batch, entry.cardId);
368
+ landed.push({
369
+ title: entry.title, role: entry.role, model: entry.model, placement: entry.placement,
370
+ status: ["lock-contention", "no-dispatchable-card", "already-claimed"].includes(claim.code) ? "race-lost" : "denied",
371
+ ...(claim.reason ? { reason: claim.reason } : {}),
372
+ });
373
+ continue;
374
+ }
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
+ }
384
+ if (typeof spawnWorker !== "function") {
385
+ // No guarded spawn seam in this context: the claim landed and the entry
386
+ // is consumed as a claimed-but-not-started assignment; never spawn
387
+ // outside the guarded seam.
388
+ consumeBatchEntry(batch, entry.cardId);
389
+ landed.push({ title: entry.title, role: entry.role, model: entry.model, placement: entry.placement, status: "claimed" });
390
+ terminalReason = "spawn-seam-unavailable";
391
+ continue;
392
+ }
393
+ let spawned;
394
+ try {
395
+ spawned = await spawnWorker({
396
+ role: entry.role,
397
+ repository: entry.repository,
398
+ model: claim.envelope.model,
399
+ provider: claim.envelope.provider,
400
+ seatId: claim.envelope.seatId,
401
+ endpointRef: entry.endpointRef,
402
+ envelope: claim.envelope,
403
+ placement: entry.placement,
404
+ context, signal: null,
405
+ });
406
+ } catch (error) {
407
+ spawned = { ok: false, reason: String(error?.message || error).slice(0, 256) };
408
+ }
409
+ if (spawned?.ok === true) {
410
+ consumeBatchEntry(batch, entry.cardId);
411
+ landed.push({ title: entry.title, role: entry.role, model: entry.model, placement: entry.placement, status: "started" });
412
+ } else {
413
+ // Delivery-unknown, partial start, or ambiguous read-back: the entry
414
+ // stays reserved and triggers reconciliation — never reuse or another
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 });
418
+ landed.push({
419
+ title: entry.title, role: entry.role, model: entry.model, placement: entry.placement,
420
+ status: "denied",
421
+ reason: spawned?.reason ?? spawned?.code ?? "guarded spawn did not verify",
422
+ });
423
+ terminalReason = "spawn-unverified";
424
+ }
425
+ }
426
+ return { landed, terminalReason };
427
+ }
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
+
442
+ // ---------------------------------------------------------------------------
443
+ // Interactive run (§6.2)
444
+ // ---------------------------------------------------------------------------
445
+
446
+ // One interactive capacity-filling batch under current policy: trusted board
447
+ // resolution, one scan, one semantic preview, one native confirmation, mint
448
+ // the immutable batch context, atomic claims, parallel guarded spawns.
449
+ export async function pulseRun({ boardPath, instruction, context = null, spawnWorker = null, now = null }) {
450
+ if (typeof instruction !== "string" || instruction.trim() === "") {
451
+ return { ok: false, code: "instruction-required", reason: "run requires the direct current-turn human instruction (fails closed)", scan: null, landedAssignments: [], batchTerminalReason: "instruction-required" };
452
+ }
453
+ const base = observeBoardForPulse({ boardPath });
454
+ if (!base.ok) {
455
+ return { ok: false, code: base.code, reason: base.reason, scan: null, landedAssignments: [], batchTerminalReason: base.code };
456
+ }
457
+ const { validated, effectivePolicy, pulsePolicy } = base;
458
+ if (!pulsePolicy || pulsePolicy.enabled !== true) {
459
+ return { ok: false, code: "pulse-disabled", reason: "Pulse is not enabled for this board (fails closed)", scan: null, landedAssignments: [], batchTerminalReason: "pulse-disabled" };
460
+ }
461
+ const check = pulseCheck({ boardPath, modelRegistry: context?.modelRegistry ?? null, scopedModels: context?.scopedModels ?? null, observedAt: now,
462
+ activeSessions: context?.activeSessions ?? [], providerLimit: context?.providerLimit ?? null });
463
+ if (!check.ok) {
464
+ return { ok: false, code: check.code, reason: check.reason, scan: null, landedAssignments: [], batchTerminalReason: check.code };
465
+ }
466
+ const { scan } = check;
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 });
470
+ // One semantic batch preview and one native confirmation for the exact
471
+ // batch (§6.2 step 3-4). Headless contexts fail closed.
472
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
473
+ return { ok: false, code: "native-confirmation-required", reason: "interactive Pulse run requires the native TUI for the one batch confirmation (fails closed)", scan, landedAssignments: [], batchTerminalReason: "no-confirmation" };
474
+ }
475
+ const previewLines = batch.entries.map((e) => `- ${e.title} → ${e.role} / ${e.model} (${e.placement})`);
476
+ let confirmed;
477
+ try {
478
+ confirmed = await context.ui.confirm(
479
+ "Run Pulse batch",
480
+ [
481
+ `Board: ${batch.boardPath}`,
482
+ `Up to ${batch.maxConcurrency ?? "?"} parallel assignments from this confirmation.`,
483
+ previewLines.length ? previewLines.join("\n") : "(no eligible cards right now)",
484
+ "One confirmation for this exact batch; cards outside it require a fresh preview.",
485
+ ].join("\n"),
486
+ );
487
+ } catch (error) {
488
+ return { ok: false, code: "confirmation-failed", reason: String(error?.message || error).slice(0, 256), scan, landedAssignments: [], batchTerminalReason: "no-confirmation" };
489
+ }
490
+ if (confirmed !== true) {
491
+ return { ok: true, cancelled: true, scan, landedAssignments: [], batchTerminalReason: "user-cancelled", batchId: batch.batchId };
492
+ }
493
+ // Interactive host spawning is permitted here only because this path has
494
+ // already required the native TUI and one explicit batch confirmation. The
495
+ // batch context is registered for the trusted in-memory confirmed-batch
496
+ // claim path; each host entry is claimable exactly once from it.
497
+ registerConfirmedBatchForClaims(batch);
498
+ const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context, now, allowHostDispatch: true, routerStore: context?.routerStore ?? null });
499
+ return { ok: true, cancelled: false, scan, landedAssignments: landed, batchTerminalReason: terminalReason, batchId: batch.batchId };
500
+ }
501
+
502
+ // ---------------------------------------------------------------------------
503
+ // Automated tick (§7): deterministic fill without prompts, no model-driven
504
+ // polling. Only runs when the policy enables Pulse in automated mode.
505
+ // ---------------------------------------------------------------------------
506
+
507
+ export async function pulseTick({ boardPath, spawnWorker = null, context = null, now = null, routerRuntimeFor = null }) {
508
+ const base = observeBoardForPulse({ boardPath });
509
+ if (!base.ok) return { ok: false, code: base.code, reason: base.reason, landedAssignments: [] };
510
+ const { effectivePolicy, pulsePolicy } = base;
511
+ if (!pulsePolicy || pulsePolicy.enabled !== true) return { ok: true, skipped: true, reason: "pulse-disabled", landedAssignments: [] };
512
+ if (pulsePolicy.mode !== "automated") return { ok: true, skipped: true, reason: "interactive-mode-tick-is-a-no-op", landedAssignments: [] };
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 });
528
+ if (!check.ok) return { ok: false, code: check.code, reason: check.reason, landedAssignments: [] };
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 });
532
+ // Automated ticks never infer host dispatch: allowHostDispatch stays false.
533
+ const { landed, terminalReason } = await executeBatch({ batch, boardPath, spawnWorker, context: tickContext, now, allowHostDispatch: false, routerStore: tickContext?.routerStore ?? null });
534
+ return { ok: true, skipped: false, scan: check.scan, landedAssignments: landed, batchTerminalReason: terminalReason };
535
+ }
536
+
537
+ // ---------------------------------------------------------------------------
538
+ // Guarded worker-creation seam (§2.5)
539
+ // ---------------------------------------------------------------------------
540
+
541
+ // The one Pulse spawn seam. Placement-aware and fail-closed: a host placement
542
+ // goes through the existing guarded Herdr lifecycle boundary (native
543
+ // confirmation, trusted repository, installed model roll, fixed argv,
544
+ // shell:false); any container/microvm placement is denied WITHOUT touching
545
+ // the lifecycle — Pulse never represents a contained assignment as contained
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
+
574
+ export function pulseWorkerSpawnSeam({ executeHerdrSpawnWorker } = {}) {
575
+ if (typeof executeHerdrSpawnWorker !== "function") {
576
+ throw new Error("pulseWorkerSpawnSeam requires the guarded herdr-lifecycle executeHerdrSpawnWorker");
577
+ }
578
+ return async ({ role, repository, model, placement, context, signal, envelope = null, provider = null, seatId = null, endpointRef = null }) => {
579
+ if (placement !== "host") {
580
+ return { ok: false, code: "containment-seam-unavailable", reason: CONTAINMENT_SEAM_UNAVAILABLE };
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;
587
+ // Board policy stores canonical repository paths, while the guarded
588
+ // lifecycle accepts only registry names. Convert only a canonical sibling
589
+ // of the coordinator repository; the lifecycle then revalidates the name,
590
+ // registry membership, real path, and Git root before spawning.
591
+ const coordinator = context?.cwd;
592
+ const repositoryName = typeof repository === "string" ? basename(repository) : "";
593
+ if (typeof coordinator !== "string" || coordinator === ""
594
+ || typeof repository !== "string" || repository === ""
595
+ || resolve(repository) !== resolve(coordinator, "..", repositoryName)) {
596
+ return { ok: false, code: "repository-mismatch", reason: "the assignment repository is not a canonical sibling of the coordinator repository" };
597
+ }
598
+ return executeHerdrSpawnWorker({ placement: "tab", role, model, repository: repositoryName }, context, {}, signal);
599
+ };
600
+ }
601
+
602
+ // ---------------------------------------------------------------------------
603
+ // Trusted semantic policy operations (§3.3)
604
+ // ---------------------------------------------------------------------------
605
+
606
+ const PULSE_DEFAULTS = Object.freeze({
607
+ enabled: false,
608
+ mode: "interactive",
609
+ intervalSeconds: 300,
610
+ fillOnStart: false,
611
+ routing: {},
612
+ stallTimeoutSeconds: 600,
613
+ unattendedHostRiskAccepted: false,
614
+ });
615
+
616
+ const CHANGE_KINDS = Object.freeze([
617
+ "mode", "intervalSeconds", "fillOnStart", "globalMaxConcurrent",
618
+ "placement", "roleRoute", "stallTimeoutSeconds", "unattendedHostRiskAccepted",
619
+ ]);
620
+
621
+ function writePolicyAtomic(path, policy) {
622
+ const tmp = `${path}.tmp-${randomUUID()}`;
623
+ writeFileSync(tmp, `${JSON.stringify(policy, null, 2)}\n`, "utf8");
624
+ try {
625
+ renameSync(tmp, path);
626
+ } catch (error) {
627
+ try { unlinkSync(tmp); } catch { /* best effort */ }
628
+ throw error;
629
+ }
630
+ }
631
+
632
+ function resolveRouteModels(pulse, { scopedModels = null } = {}) {
633
+ const models = new Set();
634
+ for (const entry of Object.values(pulse?.routing ?? {})) {
635
+ for (const route of [...(entry?.preferred ?? []), ...(entry?.fallback ?? [])]) {
636
+ if (typeof route?.model === "string") models.add(route.model);
637
+ }
638
+ }
639
+ if (scopedModels === null) return { ok: true, models: [...models] };
640
+ const scopedList = Array.isArray(scopedModels) ? scopedModels : [];
641
+ // Empty scoped list means no scoping configured: every model is usable.
642
+ if (scopedList.length === 0) return { ok: true, models: [...models] };
643
+ const scopedIds = new Set(scopedList.map((entry) => typeof entry === "string" ? entry : entry?.model).filter((id) => typeof id === "string"));
644
+ for (const model of models) {
645
+ if (!scopedIds.has(model)) return { ok: false, reason: `model "${model}" is not in the session's scoped model list (fails closed)` };
646
+ }
647
+ return { ok: true, models: [...models] };
648
+ }
649
+
650
+ // One trusted semantic policy mutation. `instruction` is the direct user
651
+ // request; the operation derives everything else from trusted context,
652
+ // previews the semantic result once natively, and writes atomically after
653
+ // confirmation. Host-risk acceptance always requires native confirmation and
654
+ // fails closed headlessly (§3.3).
655
+ export async function pulsePolicyOperation({ action, instruction, changes = null, boardPath, context = null, scopedModels = null, now = null }) {
656
+ if (!["enable", "disable", "configure"].includes(action)) {
657
+ return { ok: false, code: "action-invalid", reason: `unknown policy action "${action}"`, persisted: false };
658
+ }
659
+ if (typeof instruction !== "string" || instruction.trim() === "") {
660
+ return { ok: false, code: "instruction-required", reason: "the policy operation requires the direct current-turn human instruction (fails closed)", persisted: false };
661
+ }
662
+ if (typeof boardPath !== "string" || boardPath === "" || !existsSync(boardPath)) {
663
+ return { ok: false, code: "board-unavailable", reason: "no board file found for this workspace (board-unavailable)", persisted: false };
664
+ }
665
+ const policyPath = automationPolicyPath(boardPath);
666
+ if (!existsSync(policyPath)) {
667
+ return { ok: false, code: "policy-absent", reason: "no automation policy exists for this board; create one before configuring Pulse (fails closed)", persisted: false };
668
+ }
669
+ let policy;
670
+ try {
671
+ policy = JSON.parse(readFileSync(policyPath, "utf8"));
672
+ } catch (error) {
673
+ return { ok: false, code: "policy-invalid", reason: `the automation policy is unreadable: ${String(error?.message || error).slice(0, 256)}`, persisted: false };
674
+ }
675
+ const baseCheck = checkAutomationPolicy(policy, { boardPath });
676
+ if (!baseCheck.ok) {
677
+ return { ok: false, code: "policy-invalid", reason: baseCheck.reason, persisted: false };
678
+ }
679
+ let pulse = policy.pulse !== undefined
680
+ ? JSON.parse(JSON.stringify(policy.pulse))
681
+ : { ...PULSE_DEFAULTS, routing: {} };
682
+
683
+ if (action === "enable") {
684
+ if (policy.pulse === undefined) {
685
+ return { ok: false, code: "pulse-unconfigured", reason: "no Pulse configuration exists for this board; configure routes first, then enable (fails closed)", persisted: false };
686
+ }
687
+ pulse.enabled = true;
688
+ } else if (action === "disable") {
689
+ if (policy.pulse === undefined) {
690
+ return { ok: false, code: "pulse-unconfigured", reason: "Pulse is not configured for this board; nothing to disable", persisted: false };
691
+ }
692
+ pulse.enabled = false;
693
+ } else {
694
+ if (!Array.isArray(changes) || changes.length === 0) {
695
+ return { ok: false, code: "changes-required", reason: "configure requires at least one closed change", persisted: false };
696
+ }
697
+ for (const change of changes) {
698
+ if (change === null || typeof change !== "object" || Array.isArray(change)) {
699
+ return { ok: false, code: "change-invalid", reason: "each change must be an object with a type (fails closed)", persisted: false };
700
+ }
701
+ const kind = change.type;
702
+ if (!CHANGE_KINDS.includes(kind)) {
703
+ return { ok: false, code: "change-invalid", reason: `unknown change type "${kind}" (fails closed)`, persisted: false };
704
+ }
705
+ if (kind === "mode") {
706
+ if (!["interactive", "automated"].includes(change.value)) {
707
+ return { ok: false, code: "change-invalid", reason: "mode must be interactive or automated (fails closed)", persisted: false };
708
+ }
709
+ pulse.mode = change.value;
710
+ } else if (kind === "intervalSeconds") {
711
+ if (!Number.isInteger(change.value) || change.value < 10 || change.value > 86400) {
712
+ return { ok: false, code: "change-invalid", reason: "intervalSeconds must be an integer from 10 through 86400 (fails closed)", persisted: false };
713
+ }
714
+ pulse.intervalSeconds = change.value;
715
+ } else if (kind === "fillOnStart") {
716
+ if (typeof change.value !== "boolean") {
717
+ return { ok: false, code: "change-invalid", reason: "fillOnStart must be a boolean (fails closed)", persisted: false };
718
+ }
719
+ pulse.fillOnStart = change.value;
720
+ } else if (kind === "globalMaxConcurrent") {
721
+ if (!Number.isInteger(change.value) || change.value < 1 || change.value > 32) {
722
+ return { ok: false, code: "change-invalid", reason: "globalMaxConcurrent must be an integer from 1 through 32 (fails closed)", persisted: false };
723
+ }
724
+ policy = { ...policy, maxConcurrent: change.value };
725
+ } else if (kind === "placement") {
726
+ if (!PLACEMENTS.includes(change.value)) {
727
+ return { ok: false, code: "change-invalid", reason: `placement must be one of ${PLACEMENTS.join(", ")} (fails closed)`, persisted: false };
728
+ }
729
+ policy = { ...policy, placement: change.value };
730
+ } else if (kind === "stallTimeoutSeconds") {
731
+ if (!Number.isInteger(change.value) || change.value < 30 || change.value > 86400) {
732
+ return { ok: false, code: "change-invalid", reason: "stallTimeoutSeconds must be an integer from 30 through 86400 (fails closed)", persisted: false };
733
+ }
734
+ pulse.stallTimeoutSeconds = change.value;
735
+ } else if (kind === "unattendedHostRiskAccepted") {
736
+ if (change.value !== false) {
737
+ // Host-risk acceptance always requires native interactive
738
+ // confirmation; it cannot be created headlessly or from vague
739
+ // language (§3.3).
740
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
741
+ return { ok: false, code: "host-risk-headless-denied", reason: "unattended-host risk acceptance requires the native interactive TUI (fails closed; it cannot be recorded headlessly)", persisted: false };
742
+ }
743
+ let accepted;
744
+ try {
745
+ accepted = await context.ui.confirm(
746
+ "Accept unattended host risk",
747
+ [
748
+ "This enables automated Pulse dispatch directly on the host for THIS board policy only:",
749
+ `Board: ${boardPath}`,
750
+ `Repositories: ${(policy.acceptedRepositories ?? []).join(", ")}`,
751
+ `Roles/models: ${Object.keys(pulse.routing ?? {}).join(", ") || "(none configured)"}`,
752
+ `Policy expiry: ${policy.expiry}`,
753
+ "The acceptance is bound to this policy's board, repositories, roles, models, expiry, risk ceiling, and global concurrency. It is not global and cannot be inferred.",
754
+ ].join("\n"),
755
+ );
756
+ } catch (error) {
757
+ return { ok: false, code: "host-risk-confirmation-failed", reason: String(error?.message || error).slice(0, 256), persisted: false };
758
+ }
759
+ if (accepted !== true) {
760
+ return { ok: false, code: "host-risk-not-accepted", reason: "unattended-host risk was not explicitly accepted (fails closed)", persisted: false };
761
+ }
762
+ }
763
+ pulse.unattendedHostRiskAccepted = change.value === true;
764
+ } else if (kind === "roleRoute") {
765
+ const role = change.role;
766
+ if (typeof role !== "string" || !policy.roles.includes(role)) {
767
+ return { ok: false, code: "change-invalid", reason: `roleRoute role "${role}" is not declared in the policy roles (fails closed)`, persisted: false };
768
+ }
769
+ const normalizeRoutes = (list, label) => {
770
+ if (!Array.isArray(list)) return { ok: false, reason: `${label} must be a list of routes (fails closed)` };
771
+ const routes = [];
772
+ for (const route of list) {
773
+ if (route === null || typeof route !== "object" || typeof route.model !== "string"
774
+ || !Number.isInteger(route.maxConcurrent) || route.maxConcurrent < 1 || route.maxConcurrent > 32) {
775
+ return { ok: false, reason: `${label} entries must be {model, maxConcurrent} with capacity 1..32 (fails closed)` };
776
+ }
777
+ routes.push({ model: route.model, maxConcurrent: route.maxConcurrent });
778
+ }
779
+ return { ok: true, routes };
780
+ };
781
+ const preferred = normalizeRoutes(change.preferred, "preferred");
782
+ if (!preferred.ok) return { ok: false, code: "change-invalid", reason: preferred.reason, persisted: false };
783
+ if (preferred.routes.length === 0) {
784
+ return { ok: false, code: "change-invalid", reason: "preferred must list at least one route (fails closed)", persisted: false };
785
+ }
786
+ const fallback = normalizeRoutes(change.fallback ?? [], "fallback");
787
+ if (!fallback.ok) return { ok: false, code: "change-invalid", reason: fallback.reason, persisted: false };
788
+ if (!Number.isInteger(change.maxConcurrent) || change.maxConcurrent < 1 || change.maxConcurrent > 32) {
789
+ return { ok: false, code: "change-invalid", reason: "roleRoute maxConcurrent must be an integer from 1 through 32 (fails closed)", persisted: false };
790
+ }
791
+ pulse.routing = { ...(pulse.routing ?? {}), [role]: { preferred: preferred.routes, fallback: fallback.routes, maxConcurrent: change.maxConcurrent } };
792
+ }
793
+ }
794
+ pulse.enabled = pulse.enabled ?? false;
795
+ }
796
+
797
+ // Revalidate the resulting pulse object in place against the declared roles.
798
+ const pulseCheck = checkPulsePolicy(pulse, { roles: policy.roles });
799
+ if (!pulseCheck.ok) {
800
+ return { ok: false, code: "policy-invalid", reason: pulseCheck.reason, persisted: false };
801
+ }
802
+ const modelResolution = resolveRouteModels(pulse, { scopedModels });
803
+ if (!modelResolution.ok) {
804
+ return { ok: false, code: "model-unresolved", reason: modelResolution.reason, persisted: false };
805
+ }
806
+ const nextPolicy = { ...policy, pulse };
807
+ const after = checkAutomationPolicy(nextPolicy, { boardPath });
808
+ if (!after.ok) {
809
+ return { ok: false, code: "policy-invalid", reason: after.reason, persisted: false };
810
+ }
811
+
812
+ // One semantic preview in native UI; write atomically after confirmation.
813
+ if (!isNativeTuiContext(context) || typeof context?.ui?.confirm !== "function") {
814
+ return { ok: false, code: "native-confirmation-required", reason: "policy changes require the native TUI for the semantic preview (fails closed)", persisted: false };
815
+ }
816
+ const summary = [
817
+ `Action: ${action} Pulse for board ${boardPath}`,
818
+ `After: enabled=${pulse.enabled} mode=${pulse.mode} interval=${pulse.intervalSeconds}s fillOnStart=${pulse.fillOnStart}`,
819
+ `Routing: ${Object.entries(pulse.routing).map(([role, entry]) => `${role}→${[...entry.preferred, ...entry.fallback].map((r) => r.model).join("|")}`).join("; ") || "(none)"}`,
820
+ `Placement: ${nextPolicy.placement}; globalMaxConcurrent: ${nextPolicy.maxConcurrent}`,
821
+ `Unattended-host risk accepted: ${pulse.unattendedHostRiskAccepted}`,
822
+ ].join("\n");
823
+ let confirmed;
824
+ try {
825
+ confirmed = await context.ui.confirm("Pulse policy change", summary);
826
+ } catch (error) {
827
+ return { ok: false, code: "confirmation-failed", reason: String(error?.message || error).slice(0, 256), persisted: false };
828
+ }
829
+ if (confirmed !== true) {
830
+ return { ok: false, code: "confirmation-denied", reason: "the semantic preview was not confirmed; nothing was written", persisted: false };
831
+ }
832
+ try {
833
+ writePolicyAtomic(policyPath, nextPolicy);
834
+ } catch (error) {
835
+ return { ok: false, code: "policy-write-failed", reason: String(error?.message || error).slice(0, 256), persisted: false };
836
+ }
837
+ return {
838
+ ok: true,
839
+ persisted: true,
840
+ policy: {
841
+ enabled: pulse.enabled,
842
+ mode: pulse.mode,
843
+ intervalSeconds: pulse.intervalSeconds,
844
+ fillOnStart: pulse.fillOnStart,
845
+ routing: pulse.routing,
846
+ stallTimeoutSeconds: pulse.stallTimeoutSeconds,
847
+ unattendedHostRiskAccepted: pulse.unattendedHostRiskAccepted,
848
+ placement: nextPolicy.placement,
849
+ maxConcurrent: nextPolicy.maxConcurrent,
850
+ expiry: nextPolicy.expiry,
851
+ board: nextPolicy.board,
852
+ },
853
+ };
854
+ }
855
+
856
+ // ---------------------------------------------------------------------------
857
+ // Timer lifecycle (§8)
858
+ // ---------------------------------------------------------------------------
859
+
860
+ // One session-scoped fixed-interval timer. Ticks never overlap (single-flight
861
+ // with skip, not queueing); a long tick delays the next tick rather than
862
+ // accumulating work. Missed ticks are never replayed. Injected schedule
863
+ // functions keep the lifecycle deterministically testable.
864
+ export function createPulseTimer({ intervalSeconds, fillOnStart = false, tick, scheduleFn = null, clearFn = null, now = null } = {}) {
865
+ const setTimeoutFn = scheduleFn ?? setTimeout;
866
+ const clearTimeoutFn = clearFn ?? clearTimeout;
867
+ let handle = null;
868
+ let running = false;
869
+ let stats = { started: 0, ticks: 0, skippedOverlaps: 0, errors: 0, stopped: false };
870
+
871
+ const runTick = async () => {
872
+ if (running) {
873
+ stats.skippedOverlaps += 1;
874
+ scheduleNext();
875
+ return;
876
+ }
877
+ running = true;
878
+ stats.ticks += 1;
879
+ try {
880
+ await tick();
881
+ } catch {
882
+ // One compact failure observation; the next ordinary tick is scheduled
883
+ // unless the tick itself reports policy invalid/revoked via stop=true.
884
+ stats.errors += 1;
885
+ } finally {
886
+ running = false;
887
+ scheduleNext();
888
+ }
889
+ };
890
+
891
+ const scheduleNext = () => {
892
+ if (stats.stopped || handle !== null) return;
893
+ handle = setTimeoutFn(() => {
894
+ handle = null;
895
+ // Returned promise is tracked for deterministic tests via runTick.
896
+ runTick();
897
+ }, intervalSeconds * 1000);
898
+ };
899
+
900
+ return {
901
+ // Awaitable single tick execution (test/observation seam; production
902
+ // callers use start/clear only).
903
+ async runTickNow() {
904
+ // Cancel any pending queued tick first: runTickNow takes over that
905
+ // occurrence so it cannot double-fire, and the settled tick then
906
+ // schedules exactly one ordinary next tick.
907
+ if (handle !== null) {
908
+ clearTimeoutFn(handle);
909
+ handle = null;
910
+ }
911
+ await runTick();
912
+ },
913
+ start() {
914
+ if (stats.stopped || handle !== null) return { ok: false, reason: "timer already started or stopped" };
915
+ stats.started += 1;
916
+ if (fillOnStart) {
917
+ // One scan after startup initialization; this is not catch-up. It
918
+ // queues one tick rather than running synchronously.
919
+ handle = setTimeoutFn(() => {
920
+ handle = null;
921
+ void runTick();
922
+ }, 0);
923
+ } else {
924
+ scheduleNext();
925
+ }
926
+ return { ok: true };
927
+ },
928
+ clear() {
929
+ stats.stopped = true;
930
+ if (handle !== null) {
931
+ clearTimeoutFn(handle);
932
+ handle = null;
933
+ }
934
+ return { ok: true };
935
+ },
936
+ stats() {
937
+ return { ...stats };
938
+ },
939
+ };
940
+ }
941
+
942
+ // ---------------------------------------------------------------------------
943
+ // Tool registration (§12.2)
944
+ // ---------------------------------------------------------------------------
945
+
946
+ export function registerPulseTools(pi, { resolveBoardPath: resolveBoardPathFn = null, spawnWorker = null, routerRuntimeFor = null } = {}) {
947
+ const boardPathFor = (ctx) => {
948
+ const cwd = typeof ctx === "string" ? ctx : ctx?.cwd;
949
+ if (typeof resolveBoardPathFn === "function") return resolveBoardPathFn(ctx);
950
+ return resolveBoardPath(cwd);
951
+ };
952
+ const registered = [];
953
+ if (typeof pi?.registerTool !== "function") return { registered };
954
+ pi.registerTool({
955
+ name: "agentic_kanban_pulse",
956
+ label: "Kanban Pulse",
957
+ description:
958
+ "Board Pulse: capacity scheduling for the validated task board. check = pure observation (no claims). run = interactive capacity-filling batch under current policy with one native confirmation. enable/disable/configure = trusted semantic policy mutation with native preview. Pulse is off unless the board policy enables it. This is not worker liveness observation (Herdr pulse).",
959
+ parameters: {
960
+ type: "object",
961
+ additionalProperties: false,
962
+ properties: {
963
+ action: { type: "string", enum: ["check", "run", "enable", "disable", "configure"] },
964
+ instruction: { type: "string", description: "The direct current-turn human request, copied by the coordinator. Required for run/enable/disable/configure; empty or agent-invented instructions fail closed." },
965
+ changes: {
966
+ type: "array",
967
+ description: "configure only: closed semantic change list (mode, intervalSeconds, fillOnStart, globalMaxConcurrent, placement, roleRoute, stallTimeoutSeconds, unattendedHostRiskAccepted).",
968
+ items: {
969
+ type: "object",
970
+ additionalProperties: false,
971
+ properties: {
972
+ type: { type: "string", enum: ["mode", "intervalSeconds", "fillOnStart", "globalMaxConcurrent", "placement", "roleRoute", "stallTimeoutSeconds", "unattendedHostRiskAccepted"] },
973
+ value: {},
974
+ role: { type: "string" },
975
+ preferred: { type: "array", items: { type: "object", additionalProperties: false, properties: { model: { type: "string" }, maxConcurrent: { type: "integer" } }, required: ["model", "maxConcurrent"] } },
976
+ fallback: { type: "array", items: { type: "object", additionalProperties: false, properties: { model: { type: "string" }, maxConcurrent: { type: "integer" } }, required: ["model", "maxConcurrent"] } },
977
+ maxConcurrent: { type: "integer" },
978
+ },
979
+ required: ["type"],
980
+ },
981
+ },
982
+ },
983
+ required: ["action"],
984
+ },
985
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
986
+ const fail = (code, reason) => {
987
+ const value = { ok: false, code, reason, errors: [reason], nonAuthorizing: true, persisted: false };
988
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
989
+ };
990
+ const action = input?.action;
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
+ }
1003
+ if (action === "check") {
1004
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
1005
+ return fail("board-unavailable", "no board file found for this workspace (board-unavailable)");
1006
+ }
1007
+ const result = pulseCheck({
1008
+ boardPath: activeBoardPath,
1009
+ modelRegistry: runtimeContext?.modelRegistry ?? null,
1010
+ scopedModels: runtimeContext?.scopedModels ?? null,
1011
+ observedAt: new Date().toISOString(),
1012
+ activeSessions: runtimeContext?.activeSessions ?? [],
1013
+ providerLimit: runtimeContext?.providerLimit ?? null,
1014
+ });
1015
+ if (!result.ok) return fail(result.code, result.reason);
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 };
1033
+ return {
1034
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
1035
+ details: { ...value, diagnosticHash: result.diagnosticHash },
1036
+ };
1037
+ }
1038
+ if (action === "run") {
1039
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
1040
+ return fail("board-unavailable", "no board file found for this workspace (board-unavailable)");
1041
+ }
1042
+ if (runtimeError !== null) return fail("router-unavailable", runtimeError);
1043
+ const result = await pulseRun({
1044
+ boardPath: activeBoardPath,
1045
+ instruction: input?.instruction,
1046
+ context: runtimeContext,
1047
+ spawnWorker,
1048
+ });
1049
+ if (!result.ok) return fail(result.code, result.reason);
1050
+ const value = {
1051
+ ok: true, nonAuthorizing: true, persisted: false,
1052
+ cancelled: result.cancelled === true,
1053
+ scan: result.scan,
1054
+ landedAssignments: result.landedAssignments,
1055
+ batchTerminalReason: result.batchTerminalReason,
1056
+ };
1057
+ return {
1058
+ content: [{ type: "text", text: JSON.stringify(value, null, 2) }],
1059
+ details: { ...value, batchId: result.batchId ?? null },
1060
+ };
1061
+ }
1062
+ if (["enable", "disable", "configure"].includes(action)) {
1063
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
1064
+ return fail("board-unavailable", "no board file found for this workspace (board-unavailable)");
1065
+ }
1066
+ const result = await pulsePolicyOperation({
1067
+ action,
1068
+ instruction: input?.instruction,
1069
+ changes: input?.changes ?? null,
1070
+ boardPath: activeBoardPath,
1071
+ context: ctx,
1072
+ scopedModels: ctx?.scopedModels ?? null,
1073
+ });
1074
+ if (!result.ok) return fail(result.code, result.reason);
1075
+ const value = { ok: true, nonAuthorizing: false, persisted: result.persisted, policy: result.policy };
1076
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1077
+ }
1078
+ return fail("action-invalid", `unknown action "${action}"`);
1079
+ },
1080
+ });
1081
+ registered.push("agentic_kanban_pulse");
1082
+ return { registered };
1083
+ }