@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.
package/README.md CHANGED
@@ -232,9 +232,14 @@ Claims and envelopes are kept in authenticated state beside `TASKS.md`. The
232
232
  derived `board.md` view shows active claims but never becomes an authority
233
233
  source.
234
234
 
235
+ `agentic_kanban_pulse` checks the board for ready work and available capacity.
236
+ A check only reports which work is ready to run. It does not claim cards. Interactive host
237
+ work needs explicit policy and one native confirmation for the batch. Automated
238
+ work requires containment and never falls back to the host.
239
+
235
240
  Coming next:
236
241
 
237
- - an optional watcher that checks for dispatchable work;
242
+ - contained Pulse execution through Testudo;
238
243
  - Vogelkop support for the shared card fields; and
239
244
  - our own Obsidian plugin for canonical `TASKS.md`.
240
245
 
@@ -1,7 +1,14 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
+ import { statSync } from "node:fs";
5
+ import { fileURLToPath } from "node:url";
6
+
4
7
  export default async function herdrCommunicationPi(pi) {
5
- const module = await import(new URL("../scripts/enforcement/herdr_communication_pi.js", import.meta.url).href);
8
+ const moduleUrl = new URL("../scripts/enforcement/herdr_communication_pi.js", import.meta.url);
9
+ // Native ESM retains transitive modules across Pi's Jiti extension reload.
10
+ // Bind this import to the current file revision so /reload cannot reuse it.
11
+ moduleUrl.searchParams.set("mtime", String(statSync(fileURLToPath(moduleUrl)).mtimeMs));
12
+ const module = await import(moduleUrl.href);
6
13
  return module.registerHerdrCommunicationInterface(pi);
7
14
  }
@@ -1,7 +1,8 @@
1
1
  // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
2
  // SPDX-License-Identifier: AGPL-3.0-or-later
3
3
 
4
- export default async function herdrLifecyclePi(pi) {
5
- const module = await import(new URL("../scripts/enforcement/herdr_lifecycle_pi.js", import.meta.url).href);
6
- return module.registerHerdrLifecycleInterface(pi);
4
+ import { registerHerdrLifecycleInterface } from "../scripts/enforcement/herdr_lifecycle_pi.js";
5
+
6
+ export default function herdrLifecyclePi(pi) {
7
+ return registerHerdrLifecycleInterface(pi);
7
8
  }
@@ -0,0 +1,74 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // Board Pulse provider extension. Registers the deterministic
5
+ // agentic_kanban_pulse tool and owns the optional fixed-interval timer. The
6
+ // board is resolved per tool call from the calling session's working
7
+ // directory; a workspace with no board file gets a structured
8
+ // board-unavailable result. Board Pulse is capacity scheduling; it is
9
+ // distinct from Herdr worker liveness pulse.
10
+ //
11
+ // No resources start in the extension factory: the timer is created and
12
+ // started only on session_start and cleared idempotently on session_shutdown
13
+ // (PULSE_DESIGN_v3 §8). The worker spawn seam is the existing guarded
14
+ // herdr-lifecycle boundary — native confirmation, trusted repository,
15
+ // installed model roll, fixed argv, shell:false — never raw pane management.
16
+
17
+ import { executeHerdrSpawnWorker } from "../scripts/enforcement/herdr_lifecycle_pi.js";
18
+ import { resolveBoardPath, registerPulseTools, createPulseTimer, pulseTick, pulseWorkerSpawnSeam } from "../scripts/enforcement/pulse_scheduler_pi.js";
19
+
20
+ export default function pulsePi(pi) {
21
+
22
+ // Guarded worker-creation seam (§2.5): every Pulse spawn goes through
23
+ // pulseWorkerSpawnSeam, which is placement-aware and fails closed — host
24
+ // placements route to executeHerdrSpawnWorker (native confirmation,
25
+ // verification); container/microvm placements are denied before any host
26
+ // worker is ever started. No other creation path exists.
27
+ const spawnWorker = pulseWorkerSpawnSeam({ executeHerdrSpawnWorker });
28
+
29
+ const result = registerPulseTools(pi, {
30
+ resolveBoardPath: (ctx) => resolveBoardPath(typeof ctx === "string" ? ctx : ctx?.cwd || process.cwd()),
31
+ spawnWorker,
32
+ });
33
+
34
+ // Optional timer (§8): created only after session_start, only when the
35
+ // resolved workspace policy enables Pulse in automated mode with a valid
36
+ // interval. fillOnStart queues one tick after startup; it is not catch-up.
37
+ // Ticks never overlap and missed ticks are never replayed. Reload/new/
38
+ // resume/fork emit session_shutdown first, so exactly one fresh runtime
39
+ // exists at a time and no in-memory single-flight state is inherited.
40
+ let timer = null;
41
+ const clearTimer = () => {
42
+ if (timer) {
43
+ timer.clear();
44
+ timer = null;
45
+ }
46
+ };
47
+ const setupTimer = async (_event, ctx) => {
48
+ clearTimer();
49
+ try {
50
+ const boardPath = resolveBoardPath(ctx?.cwd || process.cwd());
51
+ if (!boardPath) return;
52
+ const { readAutomationPolicy, checkAutomationPolicy } = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
53
+ const policy = readAutomationPolicy(boardPath);
54
+ const check = checkAutomationPolicy(policy, { boardPath });
55
+ const pulse = check.ok ? check.policy.pulse : null;
56
+ if (!pulse || pulse.enabled !== true || pulse.mode !== "automated") return;
57
+ timer = createPulseTimer({
58
+ intervalSeconds: pulse.intervalSeconds,
59
+ fillOnStart: pulse.fillOnStart === true,
60
+ tick: () => pulseTick({ boardPath, spawnWorker, context: ctx }),
61
+ });
62
+ timer.start();
63
+ } catch {
64
+ // No-board/no-policy/disabled policy produce zero scheduling effects.
65
+ clearTimer();
66
+ }
67
+ };
68
+ if (typeof pi?.on === "function") {
69
+ pi.on("session_start", setupTimer);
70
+ pi.on("session_shutdown", clearTimer);
71
+ }
72
+
73
+ return result;
74
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.9.0",
3
+ "version": "0.9.1",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -40,6 +40,7 @@
40
40
  "extensions/herdr-communication.ts",
41
41
  "extensions/herdr-dispatch.ts",
42
42
  "extensions/task-board.ts",
43
+ "extensions/pulse.ts",
43
44
  "lib/adapters/diff-scope.mjs",
44
45
  "lib/adapters/evidence.mjs",
45
46
  "lib/adapters/narrative.mjs",
@@ -66,7 +67,10 @@
66
67
  "extensions/aidr.ts",
67
68
  "scripts/aidr_writing_review.js",
68
69
  "templates/AGENTS.md",
69
- "scripts/enforcement/task_board_core_pi.js"
70
+ "scripts/enforcement/task_board_core_pi.js",
71
+ "scripts/enforcement/pulse_core_pi.js",
72
+ "scripts/enforcement/pulse_scheduler_pi.js",
73
+ "skills/pulse/SKILL.md"
70
74
  ],
71
75
  "pi": {
72
76
  "extensions": [
@@ -16,7 +16,9 @@ import {
16
16
  HERDR_COMMUNICATION_ACTIONS,
17
17
  } from "./herdr_communication_pi.js";
18
18
  import { isNativeTuiContext } from "./native_tui_context.js";
19
- import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvelope } from "./task_board_core_pi.js";
19
+ import { prepareEnvelopeForExecution, validateEnvelopeForExecution, consumeEnvelope, gitHead } from "./task_board_core_pi.js";
20
+ import { sha256Hex } from "./task_board_core_pi.js";
21
+ import { execFileSync } from "node:child_process";
20
22
 
21
23
  export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
22
24
  export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
@@ -29,10 +31,17 @@ const MAX_JOURNEY_STEPS = 200;
29
31
  const MAX_REPORT_BYTES = 32 * 1024;
30
32
  const REGISTRATIONS = new WeakSet();
31
33
 
34
+ // The worker-side review-request marker. An implementation journey whose last
35
+ // complete report carries this marker terminates as review_requested (§6.3):
36
+ // it projects review readiness without approval. The board lane transition
37
+ // remains a human/writer decision; Pulse never infers it.
38
+ export const REVIEW_REQUEST_MARKER = "[REVIEW_REQUESTED]";
39
+
32
40
  // Terminal journey states. Only `cancelled` and `failed` are failures; every
33
41
  // other terminal state is an observed outcome, and no state is retried.
34
42
  export const WORKER_DISPATCH_TERMINAL_STATES = Object.freeze([
35
43
  "completed", "exhausted", "role-blocked", "cancelled", "failed", "waiting-approval", "worker-unresponsive",
44
+ "review_requested",
36
45
  ]);
37
46
 
38
47
  export const WORKER_DISPATCH_PARAMETERS = Object.freeze({
@@ -225,6 +234,46 @@ function journeyReceipt(journey) {
225
234
  return body;
226
235
  }
227
236
 
237
+ // §10.2 progress snapshot: a compact observation attached to the journey
238
+ // step/terminal evidence it belongs to. Not authority, not persisted
239
+ // separately, and not written on every timer tick — only at assignment start
240
+ // and meaningful checkpoints. Repository observations are read-only.
241
+ function progressSnapshot({ taskId = null, envelope = null, repository = null, workerStatus = null, lastReportAt = null }) {
242
+ const snapshot = {
243
+ schema: "agentic-driver.progress-snapshot.v1",
244
+ taskId,
245
+ at: new Date().toISOString(),
246
+ };
247
+ if (envelope) {
248
+ snapshot.envelopeId = envelope.envelopeId ?? null;
249
+ snapshot.cardId = envelope.cardId ?? null;
250
+ snapshot.branch = envelope.branch ?? null;
251
+ }
252
+ if (repository) {
253
+ snapshot.repository = repository;
254
+ snapshot.head = gitHead(repository);
255
+ const diff = scopedDiffHash(repository);
256
+ snapshot.scopedDiffHash = diff.hash;
257
+ snapshot.changedPaths = diff.paths;
258
+ }
259
+ if (workerStatus !== null) snapshot.workerStatus = workerStatus;
260
+ if (lastReportAt !== null) snapshot.lastCompleteReportAt = lastReportAt;
261
+ return snapshot;
262
+ }
263
+
264
+ // Hash of the repository's uncommitted diff (scoped work-in-progress signal).
265
+ // Returns {hash, paths}; a repository where git is unavailable yields nulls.
266
+ function scopedDiffHash(repository) {
267
+ try {
268
+ const paths = execFileSync("git", ["status", "--porcelain"], { cwd: repository, encoding: "utf8" })
269
+ .split("\n").map((line) => line.slice(3).trim()).filter(Boolean);
270
+ const diff = execFileSync("git", ["diff", "HEAD"], { cwd: repository, encoding: "utf8", maxBuffer: 64 * 1024 * 1024 });
271
+ return { hash: sha256Hex(diff), paths: paths.slice(0, 64) };
272
+ } catch {
273
+ return { hash: null, paths: [] };
274
+ }
275
+ }
276
+
228
277
  // One continuous journey. Each step: pulse (liveness + eligibility), observe
229
278
  // the next dispatchable item, one prompt exchange (no retry on any failure),
230
279
  // read the marked report, collate. Bounded by maxSteps, never wall-clock.
@@ -256,7 +305,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
256
305
  const finish = async (requestedStatus) => {
257
306
  let status = requestedStatus;
258
307
  if (board && boardAttemptStarted && !envelopeConsumed) {
259
- const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive"].includes(status)
308
+ const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive", "review_requested"].includes(status)
260
309
  ? status
261
310
  : status.startsWith("worker-unresponsive") ? "worker-unresponsive" : "failed";
262
311
  let consumed;
@@ -277,7 +326,7 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
277
326
  journey.status = status;
278
327
  return {
279
328
  schema: WORKER_DISPATCH_SCHEMA,
280
- ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
329
+ ok: status === "completed" || status === "exhausted" || status === "waiting-approval" || status === "review_requested",
281
330
  action: "dispatch",
282
331
  mode,
283
332
  autonomy,
@@ -590,8 +639,21 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
590
639
  status: "done",
591
640
  workerStatus: exchange.agentStatus,
592
641
  report: exchange.report,
642
+ // §10.2 progress snapshot at the meaningful checkpoint (step completion).
643
+ progress: progressSnapshot({
644
+ taskId: task.id,
645
+ envelope: board?.envelope ?? null,
646
+ repository: options.repository ?? null,
647
+ workerStatus: exchange.agentStatus ?? null,
648
+ lastReportAt: new Date().toISOString(),
649
+ }),
593
650
  });
594
651
  dispatched.add(task.id);
652
+ // §6.3 review routing: the last complete report requests review.
653
+ if (typeof exchange.report === "string" && exchange.report.includes(REVIEW_REQUEST_MARKER)) {
654
+ journey.status = "review_requested";
655
+ return finish("review_requested");
656
+ }
595
657
  }
596
658
 
597
659
  journey.status = "completed";
@@ -42,6 +42,10 @@ const MAX_IDENTITY_FIELD_BYTES = 4 * 1024;
42
42
  const MAX_MARKER_HORIZONTAL_WHITESPACE = 128;
43
43
  const MAX_PROMPT_CONTRACT_ECHO_BYTES = 4 * 1024;
44
44
  const MAX_READ_LINES = 400;
45
+ // A valid 32 KiB report can contain more than 400 short lines. A second,
46
+ // finite read may cover one line per byte plus framing without relaxing the
47
+ // existing 128 KiB process-output ceiling.
48
+ const MAX_REPORT_READ_LINES = MAX_REPORT_BYTES + 16;
45
49
  const MAX_WAIT_TIMEOUT_MS = 300_000;
46
50
  const MAX_IMPLEMENTER_PROMPT_TIMEOUT_MS = 120_000;
47
51
  const COMMAND_TIMEOUT_MS = 15_000;
@@ -397,6 +401,14 @@ function validateParams(params) {
397
401
  if (!isReviewerRole(params.role) && params.timeoutMs > MAX_IMPLEMENTER_PROMPT_TIMEOUT_MS) {
398
402
  throw communicationError("implementer_prompt_timeout_exceeded", "worker prompts are limited to one two-minute atomic step", "denied");
399
403
  }
404
+ // Prompt freshness: each explicit prompt carries its own task scope and
405
+ // contract framing built from THAT call's prompt text. A stale or reused
406
+ // prompt text never binds a later exchange — provenance requires the
407
+ // echoed contract to match the current prompt exactly (provenancedReport
408
+ // Segment), so a prior exchange's scope cannot silently cover this one.
409
+ if (params.prompt.includes(REPORT_CONTRACT_LINE)) {
410
+ throw communicationError("prompt_preformatted", "the prompt must carry task scope only; the report contract is added by the transport (fails closed)", "denied");
411
+ }
400
412
  }
401
413
  if (action === "wait") {
402
414
  if (!Number.isInteger(params.timeoutMs) || params.timeoutMs < 1 || params.timeoutMs > MAX_WAIT_TIMEOUT_MS) {
@@ -458,7 +470,7 @@ function fixedArgv(action, params) {
458
470
  case "read":
459
471
  return [
460
472
  "agent", "read", params.role,
461
- "--source", "recent-unwrapped", "--lines", String(MAX_READ_LINES),
473
+ "--source", "recent-unwrapped", "--lines", String(params.readLines ?? MAX_READ_LINES),
462
474
  "--format", "text",
463
475
  ];
464
476
  default:
@@ -468,11 +480,11 @@ function fixedArgv(action, params) {
468
480
 
469
481
  function promptWithReportRequirement(role, prompt) {
470
482
  const marker = reportMarkersForRole(role);
483
+ // Communication adds transport framing only. Task scope and role behaviour
484
+ // belong to the caller's current prompt; injecting either here would make a
485
+ // task-scoped instruction silently bind every later exchange for that role.
471
486
  const requirement = [
472
487
  "",
473
- ...(isReviewerRole(role) ? ["Remain strictly read-only; do not modify files, state, or Git."] : [
474
- "MANDATORY ATOMIC EXECUTION CONTRACT: execute one acceptance-checked step only; do not continue to a second file, test group, or follow-up; stop and report incomplete work as CHANGES_REQUIRED before the two-minute boundary.",
475
- ]),
476
488
  REPORT_CONTRACT_LINE,
477
489
  marker.open,
478
490
  marker.close,
@@ -1223,11 +1235,23 @@ export async function executeHerdrCommunication(params, context, options = {}, s
1223
1235
  if (waitedStatus === "blocked") {
1224
1236
  return errorResult(operation, communicationError("role_blocked", "the prompted role reached blocked state", "blocked"));
1225
1237
  }
1226
- const rawReport = await invokeHerdr("read", { action: "read", role }, context, options, signal);
1227
- const post = readText(rawReport);
1238
+ let post = readText(await invokeHerdr("read", { action: "read", role }, context, options, signal));
1228
1239
  const sentPrompt = promptWithReportRequirement(role, request.prompt);
1229
- const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
1230
- const report = extractReportFromSegment(post, role, boundary.end);
1240
+ let report;
1241
+ let reportReadCount = 1;
1242
+ try {
1243
+ const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
1244
+ report = extractReportFromSegment(post, role, boundary.end);
1245
+ } catch (error) {
1246
+ if (!(error instanceof HerdrCommunicationError)
1247
+ || !["report_scope_unavailable", "report_reversed"].includes(error.code)) throw error;
1248
+ // The ordinary terminal tail can begin inside a valid current report.
1249
+ // Expand once; never resend the prompt and never retry indefinitely.
1250
+ post = readText(await invokeHerdr("read", { action: "read", role, readLines: MAX_REPORT_READ_LINES }, context, options, signal));
1251
+ reportReadCount = 2;
1252
+ const boundary = provenancedReportSegment(pre, post, sentPrompt, role);
1253
+ report = extractReportFromSegment(post, role, boundary.end);
1254
+ }
1231
1255
  return successResult(operation, {
1232
1256
  status: "complete",
1233
1257
  role,
@@ -1237,7 +1261,7 @@ export async function executeHerdrCommunication(params, context, options = {}, s
1237
1261
  promptSent: true,
1238
1262
  invocationCount: 1,
1239
1263
  waitCount: 1,
1240
- readCount: 1,
1264
+ readCount: reportReadCount,
1241
1265
  report,
1242
1266
  reportMarkers: reportMarkersForRole(role),
1243
1267
  });
@@ -1250,8 +1274,15 @@ export async function executeHerdrCommunication(params, context, options = {}, s
1250
1274
  }
1251
1275
  return successResult(operation, { role, status, agentStatus: status, repository });
1252
1276
  }
1253
- const raw = await invokeHerdr(operation, request, context, options, signal);
1254
- const report = extractLatestHerdrReport(readText(raw), role);
1277
+ let raw = await invokeHerdr(operation, request, context, options, signal);
1278
+ let report;
1279
+ try {
1280
+ report = extractLatestHerdrReport(readText(raw), role);
1281
+ } catch (error) {
1282
+ if (!(error instanceof HerdrCommunicationError) || error.code !== "report_reversed") throw error;
1283
+ raw = await invokeHerdr("read", { action: "read", role, readLines: MAX_REPORT_READ_LINES }, context, options, signal);
1284
+ report = extractLatestHerdrReport(readText(raw), role);
1285
+ }
1255
1286
  return successResult(operation, {
1256
1287
  role,
1257
1288
  report,
@@ -0,0 +1,268 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // Pulse core (PULSE_DESIGN_v3 §2.2, §4, §5): a side-effect-free evaluation of
5
+ // one selected board. It consumes validated board cards, active claim
6
+ // observations, a validated automation policy (with its optional closed
7
+ // `pulse` object), and model-availability observations supplied by trusted
8
+ // adapters. It returns one Pulse result per card, composed free capacity per
9
+ // role/model route, and a deterministic scheduling proposal.
10
+ //
11
+ // It never reads files, claims cards, spawns workers, starts timers, or
12
+ // mutates policy. Eligibility grants no authority: the existing dispatcher
13
+ // owns atomic claim and envelope creation.
14
+
15
+ import {
16
+ canonicalJsonString, sha256Hex, isDispatchable,
17
+ } from "./task_board_core_pi.js";
18
+ export { canonicalJsonString, sha256Hex };
19
+
20
+ export const PULSE_SCAN_SCHEMA = "agentic-driver.board-pulse.v1";
21
+
22
+ // The five closed Pulse results (§5). Exactly one per evaluated card.
23
+ export const PULSE_RESULTS = Object.freeze([
24
+ "READY_FOR_NEXT", "BLOCKED", "STALE", "DENIED", "REVIEW_REQUIRED",
25
+ ]);
26
+
27
+ const PRIORITY_ORDER = Object.freeze({ P0: 0, P1: 1, P2: 2, P3: 3 });
28
+ const AVAILABILITY = Object.freeze([
29
+ "available", "full", "unauthenticated", "unavailable", "unknown",
30
+ ]);
31
+
32
+ function isPlainObject(value) {
33
+ return value !== null && typeof value === "object" && !Array.isArray(value);
34
+ }
35
+
36
+ // Close the availability observation for one model. Only declared
37
+ // preferred/fallback route models are considered; anything else is unknown
38
+ // and unavailable for routing (§3.1: fails closed).
39
+ export function modelAvailability({ model, observations = null, declaredModels = null }) {
40
+ if (declaredModels !== null && !declaredModels.includes(model)) return "unknown";
41
+ const observed = isPlainObject(observations) ? observations[model] : undefined;
42
+ if (observed === undefined) return "unknown";
43
+ if (!isPlainObject(observed)) return "unknown";
44
+ const status = observed.status;
45
+ if (status === "available" || status === "full"
46
+ || status === "unauthenticated" || status === "unavailable") return status;
47
+ return "unknown";
48
+ }
49
+
50
+ // True only when the model can take new work this tick. "full" means the
51
+ // provider reported a live limit reached; the route is full, not unusable.
52
+ export function modelUsable(availability) {
53
+ return availability === "available" || availability === "full";
54
+ }
55
+
56
+ // Composed free capacity for one role/model route (§4.2):
57
+ // free = min(configured route ceiling, role ceiling, policy-wide ceiling)
58
+ // - unique active work for role/model
59
+ // Active work is deduplicated by attempt identity across claims and verified
60
+ // sessions; a session bound to an active claim counts once.
61
+ export function freeCapacity({ role, model, pulsePolicy, activeClaims = [], activeSessions = [], providerLimit = null }) {
62
+ const routing = pulsePolicy?.routing ?? {};
63
+ const roleEntry = routing[role];
64
+ const routes = [...(roleEntry?.preferred ?? []), ...(roleEntry?.fallback ?? [])];
65
+ const route = routes.find((entry) => entry?.model === model) ?? null;
66
+ if (!route) return { free: 0, configured: 0, reason: `model "${model}" is not a declared route for role "${role}"` };
67
+
68
+ // Deduplicate active work by attempt identity (envelope id when present,
69
+ // else session id). A session matching an active claim counts once.
70
+ const claimIdentities = new Set(
71
+ (activeClaims ?? [])
72
+ .filter((claim) => claim?.role === role && claimModel(claim) === model)
73
+ .map((claim) => claim?.envelopeId ?? claim?.envelope?.envelopeId ?? `claim:${claim?.cardId}`)
74
+ .filter((id) => typeof id === "string"),
75
+ );
76
+ const boundSessionIds = new Set(
77
+ (activeClaims ?? []).map((claim) => claim?.sessionId).filter((id) => typeof id === "string"),
78
+ );
79
+ const orphanSessions = (activeSessions ?? []).filter((session) => {
80
+ if (session?.role !== role || session?.model !== model) return false;
81
+ if (typeof session?.sessionId !== "string") return false;
82
+ if (boundSessionIds.has(session.sessionId)) return false;
83
+ // An observed orphan session counts separately only after reconciliation
84
+ // cannot bind it to an active attempt; ambiguity consumes capacity (§4.2).
85
+ return session.reconciled === true || session.reconciled === undefined;
86
+ });
87
+ const active = claimIdentities.size + orphanSessions.length;
88
+
89
+ const routeCeiling = route.maxConcurrent;
90
+ const roleCeiling = roleEntry?.maxConcurrent ?? routeCeiling;
91
+ const policyCeiling = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
92
+ ? pulsePolicy.globalMaxConcurrent
93
+ : null;
94
+ const configured = Math.min(routeCeiling, roleCeiling, policyCeiling ?? routeCeiling);
95
+ const providerCap = Number.isInteger(providerLimit) && providerLimit >= 0 ? providerLimit : null;
96
+ const ceiling = providerCap === null ? configured : Math.min(configured, providerCap);
97
+ return { free: Math.max(0, ceiling - active), configured, reason: null };
98
+ }
99
+
100
+ // The model a claim is running under. Claims carry the exact model on their
101
+ // envelope when Pulse created them; legacy claims without one count against
102
+ // no specific model route — we attribute them to nothing rather than guess
103
+ // (no silent substitution).
104
+ function claimModel(claim) {
105
+ return typeof claim?.envelope?.model === "string" ? claim.envelope.model : null;
106
+ }
107
+
108
+ // Ordered candidate routes for a role: preferred first, then fallback in
109
+ // declaration order (§3.1). Fallback is considered only when preferred
110
+ // routes are unavailable or full.
111
+ export function routesForRole(role, pulsePolicy) {
112
+ const entry = pulsePolicy?.routing?.[role];
113
+ if (!entry) return [];
114
+ return [
115
+ ...entry.preferred.map((route) => ({ ...route, tier: "preferred" })),
116
+ ...entry.fallback.map((route) => ({ ...route, tier: "fallback" })),
117
+ ];
118
+ }
119
+
120
+ // Evaluate one card and return exactly one Pulse result plus a reason (§5).
121
+ // Inputs are already trusted: the card comes from parseBoard/validateBoard,
122
+ // the policy from checkAutomationPolicy. Capacity never changes a card
123
+ // result — it only changes `capacity` and `proposedDispatches`.
124
+ export function evaluateCard({ card, boardIndex, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, acceptedRepositories = null }) {
125
+ // Provenance verification is the caller adapter's responsibility: it
126
+ // supplies cards with `statePath` only when the trusted writer state is
127
+ // available. The core stays side-effect-free and file-free.
128
+ const failed = isDispatchable(card, boardIndex).failedConditions ?? [];
129
+ const claimed = (activeClaims ?? []).some((claim) => claim?.cardId === card.cardId);
130
+ if (claimed) failed.push("card is already claimed by an active authenticated attempt");
131
+ if (failed.length > 0) {
132
+ const text = failed.join("; ");
133
+ if (claimed) {
134
+ return { result: "BLOCKED", reason: "card is already claimed by an active authenticated attempt" };
135
+ }
136
+ if (/hash|provenance|authority|tampered/.test(text)) {
137
+ return { result: "STALE", reason: text };
138
+ }
139
+ if (/expired|revoked|prohibited|not declared|placement|risk/i.test(text)) {
140
+ return { result: "DENIED", reason: text };
141
+ }
142
+ if (/dependenc|lane|blocked|cancelled|flag/i.test(text)) {
143
+ return { result: "BLOCKED", reason: text };
144
+ }
145
+ return { result: "REVIEW_REQUIRED", reason: text };
146
+ }
147
+
148
+ // Policy gate (§5 DENIED): no policy, expired/revoked policy, or a role the
149
+ // policy does not declare and route.
150
+ if (!isPlainObject(pulsePolicy) || pulsePolicy.enabled !== true) {
151
+ return { result: "REVIEW_REQUIRED", reason: "Pulse is not enabled for this board" };
152
+ }
153
+ const role = typeof card.role === "string" && card.role !== "" ? card.role : "implementer";
154
+ const routes = routesForRole(role, pulsePolicy);
155
+ if (routes.length === 0) {
156
+ return { result: "DENIED", reason: `no Pulse route is declared for role "${role}" (fails closed)` };
157
+ }
158
+ // Repository gate (§9): the card must identify a repository the policy
159
+ // accepts. A card naming exactly one repository is unambiguous; otherwise
160
+ // a single-repository policy supplies the default. Ambiguity fails closed.
161
+ if (Array.isArray(acceptedRepositories) && acceptedRepositories.length > 0) {
162
+ const named = Array.isArray(card.repositories) ? card.repositories.filter((r) => typeof r === "string" && r !== "") : [];
163
+ const repository = named.length === 1 ? named[0]
164
+ : named.length === 0 && acceptedRepositories.length === 1 ? acceptedRepositories[0]
165
+ : null;
166
+ if (repository === null || !acceptedRepositories.includes(repository)) {
167
+ return { result: "DENIED", reason: "card repository is ambiguous or not in the policy acceptedRepositories (fails closed)" };
168
+ }
169
+ }
170
+ // Route availability: at least one declared route must be usable. Without
171
+ // an authorised fallback this is REVIEW_REQUIRED (§5), never a substitution.
172
+ const usable = routes.filter((route) => modelUsable(modelAvailability({
173
+ model: route.model, observations: modelObservations, declaredModels,
174
+ })));
175
+ if (usable.length === 0) {
176
+ return { result: "REVIEW_REQUIRED", reason: `no usable declared route for role "${role}" (preferred unavailable without authorised fallback)` };
177
+ }
178
+ return { result: "READY_FOR_NEXT", reason: null, role, routes: usable };
179
+ }
180
+
181
+ // One full board scan (§4.3, §5). Deterministic: cards in board P0→P3 then
182
+ // card-ID order; capacity in role-then-route declaration order; proposals in
183
+ // selection order. No cross-board priority comparison happens here.
184
+ export function scanBoard({ cards, boardPath, boardRevision = null, policyRevision = null, activeClaims = [], pulsePolicy = null, modelObservations = null, declaredModels = null, observedAt = null, placement = null }) {
185
+ const index = new Map(cards.map((card) => [card.cardId, card]));
186
+ const ordered = [...cards].sort((a, b) =>
187
+ (PRIORITY_ORDER[a.priority] ?? 99) - (PRIORITY_ORDER[b.priority] ?? 99)
188
+ || String(a.cardId).localeCompare(String(b.cardId)));
189
+
190
+ const cardResults = [];
191
+ const capacity = [];
192
+ const proposedDispatches = [];
193
+ const policyWideRemaining = Number.isInteger(pulsePolicy?.globalMaxConcurrent)
194
+ ? pulsePolicy.globalMaxConcurrent - (activeClaims ?? []).length
195
+ : null;
196
+
197
+ const roleOrder = pulsePolicy?.routing ? Object.keys(pulsePolicy.routing) : [];
198
+ for (const role of roleOrder) {
199
+ for (const route of routesForRole(role, pulsePolicy)) {
200
+ const availability = modelAvailability({ model: route.model, observations: modelObservations, declaredModels });
201
+ const free = freeCapacity({ role, model: route.model, pulsePolicy, activeClaims });
202
+ capacity.push({
203
+ role, model: route.model, configured: free.configured,
204
+ activeClaims: (activeClaims ?? []).filter((claim) => claim?.role === role && claimModel(claim) === route.model).length,
205
+ startingOrRunning: 0,
206
+ providerLimit: null,
207
+ free: availability === "available" || availability === "full" ? free.free : 0,
208
+ availability,
209
+ });
210
+ }
211
+ }
212
+
213
+ const remainingByRoute = new Map(capacity.map((entry) => [`${entry.role}\u0000${entry.model}`, entry.free]));
214
+ let globalRemaining = policyWideRemaining;
215
+ // Placement for proposals is supplied by the scheduler adapter (the pulse
216
+ // object does not carry it; the policy's placement field governs). In
217
+ // automated mode containment is the default when available (§3.2); the core
218
+ // never decides containment availability.
219
+ const scanPlacement = placement;
220
+
221
+ for (const card of ordered) {
222
+ const evaluation = evaluateCard({
223
+ card, boardIndex: index, boardPath, activeClaims, pulsePolicy,
224
+ modelObservations, declaredModels,
225
+ acceptedRepositories: pulsePolicy?.acceptedRepositories ?? null,
226
+ });
227
+ const entry = { title: card.title, role: evaluation.role ?? (typeof card.role === "string" && card.role !== "" ? card.role : "implementer"), result: evaluation.result, reason: evaluation.reason };
228
+ cardResults.push(entry);
229
+ if (evaluation.result !== "READY_FOR_NEXT") continue;
230
+
231
+ // Propose at most free capacity and policy-wide remaining capacity (§4.3
232
+ // step 5): preferred routes first, then declared fallback, in order.
233
+ let proposed = false;
234
+ for (const route of evaluation.routes) {
235
+ const key = `${entry.role}\u0000${route.model}`;
236
+ const remaining = remainingByRoute.get(key) ?? 0;
237
+ if (remaining <= 0) continue;
238
+ if (globalRemaining !== null && globalRemaining <= 0) break;
239
+ remainingByRoute.set(key, remaining - 1);
240
+ if (globalRemaining !== null) globalRemaining -= 1;
241
+ proposedDispatches.push({ title: card.title, role: entry.role, model: route.model, placement: scanPlacement });
242
+ proposed = true;
243
+ break;
244
+ }
245
+ if (!proposed && evaluation.routes.length > 0) {
246
+ // Ready but no free slot: the card result stays READY_FOR_NEXT (§5);
247
+ // capacity alone gates the proposal.
248
+ entry.reason = "ready; no free capacity on any declared route this tick";
249
+ }
250
+ }
251
+
252
+ const scan = {
253
+ schema: PULSE_SCAN_SCHEMA,
254
+ board: boardPath,
255
+ boardRevision,
256
+ policyRevision,
257
+ observedAt: observedAt ?? null,
258
+ cards: cardResults,
259
+ capacity,
260
+ proposedDispatches,
261
+ authorityCreated: false,
262
+ };
263
+ // Internal diagnostic hash only (§5): canonical input is the closed public
264
+ // result, serialized by the existing canonical JSON helper and SHA-256
265
+ // hashed. It grants no authority and is not part of the public shape.
266
+ const diagnosticHash = sha256Hex(canonicalJsonString(scan));
267
+ return { scan, diagnosticHash };
268
+ }