@evoclock/pi-agentic-driver 0.9.0 → 0.9.1

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