@evoclock/pi-agentic-driver 0.8.4 → 0.9.0

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
@@ -1,4 +1,4 @@
1
- # pi-agentic-driver v0.8.0
1
+ # pi-agentic-driver v0.9.0
2
2
 
3
3
  <p align="center">
4
4
  <img src="assets/Yamagane-origami.png" alt="pi-agentic-driver, Yamagane origami mark" width="140"/>
@@ -7,7 +7,7 @@
7
7
  <p align="center">
8
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
9
9
  <a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
10
- <img src="https://img.shields.io/badge/version-0.8.0-blue?style=flat" alt="Version 0.8.0"/>
10
+ <img src="https://img.shields.io/badge/version-0.9.0-blue?style=flat" alt="Version 0.9.0"/>
11
11
  <img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
12
12
  <img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
13
13
  <img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
@@ -51,6 +51,8 @@ proofs for agentic workflows.
51
51
  | `agentic_worker_dispatch` | Runs controlled worker journeys and observes worker liveness. | shipped |
52
52
  | `agentic_kanban_board` | Shows the workspace task board: lanes, flags, priorities, dependencies, and which cards can run. | shipped |
53
53
  | `agentic_kanban_board_write` | Adds cards to the board through the trusted writer, which records who authorized the work. | shipped |
54
+ | `agentic_kanban_board_update` | Moves, closes, flags, edits, or removes cards on the board, always recording who authorized the change. | shipped |
55
+ | `agentic_kanban_board_dispatch` | Claims an eligible board card for automated contained work and creates its assignment envelope. | shipped |
54
56
 
55
57
  **Status: active development and testing.** Each extension ships only after
56
58
  it passes fixture-based acceptance, native tests, live-session checks, and
@@ -190,30 +192,51 @@ silently, and return results as untrusted evidence.
190
192
 
191
193
 
192
194
  <details>
193
- <summary><strong>task board, planned work you can see</strong> <em>(released, 0.8.0)</em></summary>
195
+ <summary><strong>task board, planned and automated work you can see</strong> <em>(released, 0.9.0)</em></summary>
194
196
 
195
- Keep planned work on a Kanban board. You read and edit the board in
196
- Obsidian or in the Vogelkop Task Board pane (our upcoming Scientific and
197
- Research Workbench). Agents read the same board and add cards to it.
197
+ Keep planned work in canonical `TASKS.md`. You can read it directly or use
198
+ the generated `board.md` view in Obsidian. Agents use the same canonical
199
+ record through natural-language board tools.
198
200
 
199
- The board appears only when a `board.md` or `TASKS.md` file exists in the
200
- workspace. With no board file, the tools do not appear and nothing changes.
201
+ You can tell an agent to add, edit, move, block, propose, close, or remove a
202
+ card. The trusted writer assigns its ID, checks its fields, records your
203
+ instruction, and protects its dispatch provenance.
201
204
 
202
- When an agent adds a card, a trusted writer does the bookkeeping: it
203
- assigns the card ID, computes the integrity hash, checks the card, and
204
- records who authorized the work. The record comes from you: your
205
- instruction, or your approval of an agent's proposal. A card without that
206
- record cannot be dispatched. A hand-edited card cannot fake the record, and
207
- a tampered card refuses to run.
205
+ Agents cannot invent additional work or complete a card on their own. An
206
+ agent report is evidence for your review. Only your clear instruction or
207
+ direct board action completes the card.
208
208
 
209
- Only you complete a card. Move it to done in your board UI, or tell an
210
- agent to close it. An agent report that says the work is finished is
211
- evidence for your review. It is never the completion itself.
209
+ For automated work, you set an explicit policy for the board. It defines:
212
210
 
213
- The card format is shared. The same board renders in Obsidian, with
214
- optional Tasks-plugin emoji, and in the Vogelkop Task Board pane. The
215
- machine-readable fields are the single source of truth, so there is no
216
- second copy to keep in sync.
211
+ - allowed roles and repositories;
212
+ - contained placement;
213
+ - the maximum concurrent work;
214
+ - the policy expiry; and
215
+ - the risk ceiling.
216
+
217
+ Without a valid policy, automated dispatch is refused.
218
+
219
+ `agentic_kanban_board_dispatch` claims the highest-priority eligible card and
220
+ creates a single-attempt assignment envelope. The envelope binds the card,
221
+ repository, starting revision, assigned branch, allowed paths, stopping
222
+ point, expiry, and interaction profile.
223
+
224
+ Blocked, altered, expired, reused, or unauthorized assignments fail closed.
225
+ The journey validates the authenticated envelope before each consequential
226
+ step. It runs on the assigned branch and consumes the envelope when the
227
+ attempt ends.
228
+
229
+ Consuming an envelope releases the claim. It does not mark the card done.
230
+
231
+ Claims and envelopes are kept in authenticated state beside `TASKS.md`. The
232
+ derived `board.md` view shows active claims but never becomes an authority
233
+ source.
234
+
235
+ Coming next:
236
+
237
+ - an optional watcher that checks for dispatchable work;
238
+ - Vogelkop support for the shared card fields; and
239
+ - our own Obsidian plugin for canonical `TASKS.md`.
217
240
 
218
241
  </details>
219
242
 
@@ -388,10 +411,10 @@ end" or "don't wait for me" and the journey runs in autonomous mode. Say
388
411
  "do these two things" and it runs in the normal mode, pausing for you
389
412
  between steps. The mode is recorded in the report either way.
390
413
 
391
- The journeys run work you dispatch in natural language: work that is real
392
- and authorized but not yet formally placed on a kanban board. A task board
393
- integration is planned, and once it ships, planned journeys will execute
394
- board-managed work with the same containment and safety guarantees.
414
+ Autonomous journeys can run work you dispatch directly or work claimed from
415
+ a task board. Board-managed journeys use the card's authenticated assignment
416
+ envelope and the board's automation policy. Direct journeys continue to work
417
+ without a board.
395
418
 
396
419
  ### The cast
397
420
 
@@ -520,7 +543,7 @@ pi install npm:@evoclock/pi-agentic-driver
520
543
  Or from Git at a pinned tag:
521
544
 
522
545
  ```sh
523
- pi install git:github.com/evoclock/pi-agentic-driver@v0.8.0
546
+ pi install git:github.com/evoclock/pi-agentic-driver@v0.9.0
524
547
  ```
525
548
 
526
549
  Released extensions load standalone; neither requires the other.
@@ -534,7 +557,7 @@ extensions you want with the object form in your Pi settings:
534
557
  {
535
558
  "packages": [
536
559
  {
537
- "source": "npm:@evoclock/pi-agentic-driver@0.8.0",
560
+ "source": "npm:@evoclock/pi-agentic-driver@0.9.0",
538
561
  "extensions": [
539
562
  "extensions/aidr.ts",
540
563
  "extensions/code-phage.js"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.8.4",
3
+ "version": "0.9.0",
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",
@@ -16,6 +16,7 @@ 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
20
 
20
21
  export const WORKER_DISPATCH_TOOL = "agentic_worker_dispatch";
21
22
  export const WORKER_DISPATCH_SCHEMA = "agentic-driver.worker-dispatch.v1";
@@ -246,24 +247,53 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
246
247
  const communicationOptions = options.communication ?? options;
247
248
  const replacementRole = options.replacementRole ?? role;
248
249
  const replacementModel = options.replacementModel ?? options.model ?? params.model;
249
- const finish = (status) => ({
250
- schema: WORKER_DISPATCH_SCHEMA,
251
- ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
252
- action: "dispatch",
253
- mode,
254
- autonomy,
255
- ...(journey.cast ? { cast: journey.cast } : {}),
256
- role,
257
- status,
258
- steps: journey.steps,
259
- stepCount: journey.steps.filter((step) => step.status === "done").length,
260
- code: journey.code,
261
- report: journeyReceipt(journey),
262
- reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
263
- handoff: journey.handoff,
264
- nonAuthorizing: true,
265
- persisted: false,
266
- });
250
+ const board = options.board && typeof options.board === "object"
251
+ && typeof options.board.boardPath === "string" && options.board.envelope
252
+ ? options.board
253
+ : null;
254
+ let boardAttemptStarted = false;
255
+ let envelopeConsumed = false;
256
+ const finish = async (requestedStatus) => {
257
+ let status = requestedStatus;
258
+ if (board && boardAttemptStarted && !envelopeConsumed) {
259
+ const reason = ["completed", "exhausted", "cancelled", "failed", "waiting-approval", "role-blocked", "worker-unresponsive"].includes(status)
260
+ ? status
261
+ : status.startsWith("worker-unresponsive") ? "worker-unresponsive" : "failed";
262
+ let consumed;
263
+ try {
264
+ consumed = consumeEnvelope({ boardPath: board.boardPath, envelopeId: board.envelope.envelopeId, reason });
265
+ } catch (error) {
266
+ consumed = { ok: false, code: error?.code, reason: String(error?.message || error) };
267
+ }
268
+ if (consumed?.ok !== true) {
269
+ status = "failed";
270
+ journey.status = "failed";
271
+ journey.code = consumed?.code || "envelope-consume-failed";
272
+ journey.steps.push({ step: journey.steps.length + 1, taskId: null, status: "failed", error: consumed?.reason || "envelope consumption failed closed" });
273
+ } else {
274
+ envelopeConsumed = true;
275
+ }
276
+ }
277
+ journey.status = status;
278
+ return {
279
+ schema: WORKER_DISPATCH_SCHEMA,
280
+ ok: status === "completed" || status === "exhausted" || status === "waiting-approval",
281
+ action: "dispatch",
282
+ mode,
283
+ autonomy,
284
+ ...(journey.cast ? { cast: journey.cast } : {}),
285
+ role,
286
+ status,
287
+ steps: journey.steps,
288
+ stepCount: journey.steps.filter((step) => step.status === "done").length,
289
+ code: journey.code,
290
+ report: journeyReceipt(journey),
291
+ reportMarkers: { open: "[WORKER_JOURNEY_REPORT_BEGIN]", close: "[WORKER_JOURNEY_REPORT_END]" },
292
+ handoff: journey.handoff,
293
+ nonAuthorizing: true,
294
+ persisted: false,
295
+ };
296
+ };
267
297
 
268
298
  // Explicit unstuck path: with native confirmation, spin up a replacement
269
299
  // worker for the same trusted repository/role through the existing
@@ -375,6 +405,35 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
375
405
  }
376
406
  if (signal?.aborted) return finish("cancelled");
377
407
 
408
+ // Board-planned journey: authenticate the persisted envelope, prepare its
409
+ // assigned branch exactly once, then validate again. Every consequential
410
+ // prompt below repeats validation so mid-journey drift fails closed.
411
+ const checkBoardEnvelope = () => board
412
+ ? validateEnvelopeForExecution({ boardPath: board.boardPath, envelope: board.envelope })
413
+ : { ok: true };
414
+ if (board) {
415
+ let guard;
416
+ try {
417
+ const prepared = prepareEnvelopeForExecution({ boardPath: board.boardPath, envelope: board.envelope });
418
+ if (!prepared.ok) guard = prepared;
419
+ else {
420
+ boardAttemptStarted = true;
421
+ guard = checkBoardEnvelope();
422
+ }
423
+ } catch (error) {
424
+ journey.status = "failed";
425
+ journey.code = error?.code || "envelope-invalid";
426
+ journey.steps.push({ step: 0, taskId: null, status: "failed", error: String(error?.message || error).slice(0, 256) });
427
+ return finish("failed");
428
+ }
429
+ if (!guard.ok) {
430
+ journey.status = "failed";
431
+ journey.code = guard.code || "envelope-invalid";
432
+ journey.steps.push({ step: 0, taskId: null, status: "failed", error: guard.reason });
433
+ return finish("failed");
434
+ }
435
+ }
436
+
378
437
  for (let stepIndex = 1; stepIndex <= maxSteps; stepIndex += 1) {
379
438
  if (signal?.aborted) { journey.status = "cancelled"; return finish("cancelled"); }
380
439
 
@@ -452,6 +511,19 @@ export async function runWorkerJourney(params, context, options = {}, signal) {
452
511
  }
453
512
  }
454
513
 
514
+ // Revalidate immediately before the consequential prompt. This catches
515
+ // expiry, card/claim changes, and repository/branch/revision drift that
516
+ // occurred during pulse, queue observation, or confirmation.
517
+ if (board) {
518
+ const guard = checkBoardEnvelope();
519
+ if (!guard.ok) {
520
+ journey.status = "failed";
521
+ journey.code = guard.code || "envelope-invalid";
522
+ journey.steps.push({ step: stepIndex, taskId: task.id, status: "failed", error: guard.reason });
523
+ return finish("failed");
524
+ }
525
+ }
526
+
455
527
  // One prompt exchange. Any failure is terminal for the journey; there is
456
528
  // no invisible retry or resend.
457
529
  const exchange = await executeHerdrCommunication(
@@ -13,6 +13,7 @@
13
13
 
14
14
  import { createHash, createHmac, randomBytes } from "node:crypto";
15
15
  import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, unlinkSync, chmodSync, statSync as fsStatSync } from "node:fs";
16
+ import { execFileSync } from "node:child_process";
16
17
  import { dirname, join } from "node:path";
17
18
 
18
19
  // ---------------------------------------------------------------------------
@@ -486,6 +487,10 @@ export function serializeObsidianCard(card) {
486
487
  const checkbox = card.flags?.includes("cancelled") ? "[-]" : card.done ? "[x]" : "[ ]";
487
488
  const parts = [checkbox, card.title];
488
489
  parts.push(fieldText("id", card.cardId));
490
+ // §3.6: a live claim is published through the derived projection as the
491
+ // card being active. Presentation only — the claim record in dispatcher
492
+ // state is canonical for run authority, never this field.
493
+ if (card.activeClaim) parts.push(fieldText("active", card.activeClaim));
489
494
  if (card.hash) parts.push(fieldText("hash", card.hash));
490
495
  if (card.priority) parts.push(fieldText("priority", card.priority));
491
496
  for (const flag of card.flags ?? []) parts.push(fieldText("flag", flag));
@@ -582,11 +587,19 @@ export function projectionPath(boardPath) {
582
587
  // Recompute the projection from the canonical cards. Best-effort relative to
583
588
  // the authoritative write: a projection failure is reported but never rolls
584
589
  // back or invalidates the canonical persist.
585
- export function writeProjection(boardPath, cards) {
590
+ export function writeProjection(boardPath, cards, claims = []) {
586
591
  const path = projectionPath(boardPath);
587
592
  try {
593
+ const claimedIds = new Set((Array.isArray(claims) ? claims : [])
594
+ .map((claim) => claim?.cardId)
595
+ .filter((id) => typeof id === "string"));
596
+ const annotated = cards.map((card) => {
597
+ if (!claimedIds.has(card.cardId)) return card;
598
+ const claim = (Array.isArray(claims) ? claims : []).find((entry) => entry?.cardId === card.cardId);
599
+ return { ...card, activeClaim: typeof claim?.role === "string" ? claim.role : "claimed" };
600
+ });
588
601
  const frontmatter = "---\nkanban-plugin: board\n---\n\n";
589
- const body = serializeBoard(cards, { surface: "obsidian" })
602
+ const body = serializeBoard(annotated, { surface: "obsidian" })
590
603
  .replace(/^## backlog$/m, "## Backlog")
591
604
  .replace(/^## in-progress$/m, "## In Progress")
592
605
  .replace(/^## review$/m, "## Review")
@@ -632,13 +645,26 @@ function readWriterState(statePath) {
632
645
  try {
633
646
  const state = JSON.parse(readFileSync(statePath, "utf8"));
634
647
  const value = Number(state?.highWaterMark);
648
+ const generation = Number(state?.claimsGeneration);
635
649
  return {
636
650
  highWaterMark: Number.isInteger(value) && value >= 0 ? value : 0,
637
651
  secret: typeof state?.secret === "string" && state.secret !== "" ? state.secret : null,
638
652
  issuedCardIds: Array.isArray(state?.issuedCardIds) ? state.issuedCardIds.filter((id) => typeof id === "string") : [],
653
+ // Monotonic claims-generation anchor (deletion/rollback guard): the
654
+ // generation and digest of the LAST claims state this writer issued.
655
+ // A missing claims file is "fresh" only while no generation was ever
656
+ // issued; a lower generation or digest mismatch is replay/deletion.
657
+ claimsGeneration: Number.isInteger(generation) && generation >= 0 ? generation : 0,
658
+ claimsDigest: typeof state?.claimsDigest === "string" && state.claimsDigest !== "" ? state.claimsDigest : null,
659
+ // A complete next claims state is staged here before either authority
660
+ // file changes. Recovery always rolls it forward; it never guesses
661
+ // whether an older claims file is legitimate.
662
+ pendingClaimsState: state?.pendingClaimsState && typeof state.pendingClaimsState === "object"
663
+ ? state.pendingClaimsState
664
+ : null,
639
665
  };
640
666
  } catch {
641
- return { highWaterMark: 0, secret: null, issuedCardIds: [] };
667
+ return { highWaterMark: 0, secret: null, issuedCardIds: [], claimsGeneration: 0, claimsDigest: null, pendingClaimsState: null };
642
668
  }
643
669
  }
644
670
 
@@ -1254,6 +1280,912 @@ export function isDispatchable(card, boardIndex) {
1254
1280
  return { dispatchable: failed.length === 0, failedConditions: failed };
1255
1281
  }
1256
1282
 
1283
+ // ---------------------------------------------------------------------------
1284
+ // Dispatcher state (§3.6, §4): claims and assignment envelopes live OUTSIDE
1285
+ // the Markdown, beside the board, under the same writer lock as card writes.
1286
+ // Claim creation, envelope creation, and active-state publication happen in
1287
+ // one atomic operation. The claims file is canonical for run authority; the
1288
+ // Markdown stays canonical for task semantics.
1289
+ // ---------------------------------------------------------------------------
1290
+
1291
+ export const CLAIMS_SCHEMA = "agentic-driver.board-claims.v2";
1292
+ export const ENVELOPE_SCHEMA = "agentic-driver.assignment-envelope.v1";
1293
+ export const AUTOMATION_POLICY_SCHEMA = "agentic-driver.automation-policy.v1";
1294
+ export const PLACEMENTS = Object.freeze(["container", "host"]);
1295
+ export const RISK_LEVELS = Object.freeze(["low", "medium", "high"]);
1296
+ export const DEFAULT_ENVELOPE_EXPIRY_HOURS = 12;
1297
+
1298
+ // F1: the claims file is AUTHENTICATED exactly like the writer state — an
1299
+ // HMAC-SHA256 over its content, keyed by the writer-state secret, verified on
1300
+ // every read. Malformed, tampered, or HMAC-failing claims files fail closed:
1301
+ // dispatch is REFUSED, never treated as an empty claims list.
1302
+
1303
+ export function claimsPath(boardPath) {
1304
+ return `${boardPath}.claims.json`;
1305
+ }
1306
+
1307
+ export function automationPolicyPath(boardPath) {
1308
+ return `${boardPath}.automation-policy.json`;
1309
+ }
1310
+
1311
+ function readJsonFile(path) {
1312
+ try {
1313
+ return JSON.parse(readFileSync(path, "utf8"));
1314
+ } catch {
1315
+ return null;
1316
+ }
1317
+ }
1318
+
1319
+ function writeJsonFileAtomic(path, value) {
1320
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
1321
+ writeFileSync(tmpPath, JSON.stringify(value, null, 2) + "\n", "utf8");
1322
+ chmodSync(tmpPath, 0o600);
1323
+ renameSync(tmpPath, path);
1324
+ }
1325
+
1326
+ // HMAC over the canonical JSON form of the claims content, keyed by the
1327
+ // writer-state secret (F1). Covers claims, consumed-claim records, and the
1328
+ // transaction record — every authority-bearing field of the file.
1329
+ function claimsFileHmac(value, secret) {
1330
+ return createHmac("sha256", secret)
1331
+ .update(canonicalJsonString({
1332
+ schema: value.schema,
1333
+ generation: value.generation,
1334
+ transaction: value.transaction ?? null,
1335
+ claims: value.claims ?? [],
1336
+ consumedClaims: value.consumedClaims ?? [],
1337
+ }), "utf8")
1338
+ .digest("hex");
1339
+ }
1340
+
1341
+ // Item 6: CLOSED, strictly validated shapes. Every record trusted by the
1342
+ // dispatcher has an exact key set and typed/pattern-checked values — unknown
1343
+ // or missing keys fail closed before anything is trusted.
1344
+ const ISO_TS_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z$/;
1345
+
1346
+ function exactKeys(value, keys) {
1347
+ const actual = Object.keys(value ?? {}).sort();
1348
+ const expected = [...keys].sort();
1349
+ return actual.length === expected.length && actual.every((key, i) => key === expected[i]);
1350
+ }
1351
+
1352
+ const ENVELOPE_FIELDS = Object.freeze([
1353
+ "schema", "envelopeId", "cardId", "cardHash", "repository", "startingRevision",
1354
+ "baseRevision", "branch", "allowedPaths", "unchangedPaths", "capabilities",
1355
+ "stoppingPoint", "acceptance", "placement", "interactionProfile", "risk",
1356
+ "riskCeiling", "mode", "createdAt", "expiry",
1357
+ ]);
1358
+
1359
+ export function wellFormedEnvelope(env) {
1360
+ if (env === null || typeof env !== "object" || Array.isArray(env) || !exactKeys(env, ENVELOPE_FIELDS)) return false;
1361
+ if (env.schema !== ENVELOPE_SCHEMA) return false;
1362
+ if (typeof env.envelopeId !== "string" || !/^[0-9a-f]{32}$/.test(env.envelopeId)) return false;
1363
+ if (typeof env.cardId !== "string" || !CARD_ID_RE.test(env.cardId)) return false;
1364
+ if (env.cardHash !== null && !/^[0-9a-f]{64}$/.test(env.cardHash)) return false;
1365
+ if (typeof env.repository !== "string" || env.repository === "") return false;
1366
+ if (env.startingRevision !== null && !COMMIT_SHA_RE.test(env.startingRevision)) return false;
1367
+ if (env.baseRevision !== null && !COMMIT_SHA_RE.test(env.baseRevision)) return false;
1368
+ if (typeof env.branch !== "string" || !/^board\//.test(env.branch)) return false;
1369
+ if (!Array.isArray(env.allowedPaths) || !env.allowedPaths.every((p) => SAFE_PATH_RE.test(p))) return false;
1370
+ if (!Array.isArray(env.unchangedPaths) || !env.unchangedPaths.every((p) => SAFE_PATH_RE.test(p))) return false;
1371
+ if (!Array.isArray(env.capabilities) || !env.capabilities.every((c) => CAPABILITY_NAME_RE.test(c))) return false;
1372
+ if (env.stoppingPoint !== null && typeof env.stoppingPoint !== "string") return false;
1373
+ if (env.acceptance === null || typeof env.acceptance !== "object" || !exactKeys(env.acceptance, ["specHash", "dodHash"])) return false;
1374
+ if (env.acceptance.specHash !== null && !/^[0-9a-f]{64}$/.test(env.acceptance.specHash)) return false;
1375
+ if (env.acceptance.dodHash !== null && !/^[0-9a-f]{64}$/.test(env.acceptance.dodHash)) return false;
1376
+ if (!PLACEMENTS.includes(env.placement)) return false;
1377
+ if (!PLACEMENTS.includes(env.interactionProfile)) return false;
1378
+ if (!RISK_LEVELS.includes(env.risk)) return false;
1379
+ if (env.riskCeiling !== null && !RISK_LEVELS.includes(env.riskCeiling)) return false;
1380
+ if (env.mode !== "automated") return false;
1381
+ if (!ISO_TS_RE.test(env.createdAt) || !ISO_TS_RE.test(env.expiry)) return false;
1382
+ return true;
1383
+ }
1384
+
1385
+ const CLAIM_FIELDS = Object.freeze(["cardId", "claimedAt", "role", "envelopeId", "envelope"]);
1386
+ const CONSUMED_FIELDS = Object.freeze(["cardId", "envelopeId", "consumedAt", "reason"]);
1387
+ const CONSUMED_REASONS = Object.freeze([
1388
+ "expired", "reclaimed", "completed", "exhausted", "cancelled", "failed",
1389
+ "waiting-approval", "role-blocked", "worker-unresponsive",
1390
+ ]);
1391
+ const TRANSACTION_FIELDS = Object.freeze(["op", "cardId", "envelopeId", "at", "phase"]);
1392
+ const CLAIMS_STATE_FIELDS = Object.freeze(["schema", "generation", "transaction", "claims", "consumedClaims", "hmac"]);
1393
+
1394
+ function wellFormedClaim(claim) {
1395
+ return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CLAIM_FIELDS)
1396
+ && CARD_ID_RE.test(claim.cardId)
1397
+ && ISO_TS_RE.test(claim.claimedAt)
1398
+ && ROLE_NAME_RE.test(claim.role)
1399
+ && /^[0-9a-f]{32}$/.test(claim.envelopeId)
1400
+ && wellFormedEnvelope(claim.envelope)
1401
+ && claim.envelope.envelopeId === claim.envelopeId
1402
+ && claim.envelope.cardId === claim.cardId;
1403
+ }
1404
+
1405
+ function wellFormedConsumedClaim(claim) {
1406
+ return claim !== null && typeof claim === "object" && !Array.isArray(claim) && exactKeys(claim, CONSUMED_FIELDS)
1407
+ && CARD_ID_RE.test(claim.cardId)
1408
+ && /^[0-9a-f]{32}$/.test(claim.envelopeId)
1409
+ && ISO_TS_RE.test(claim.consumedAt)
1410
+ && CONSUMED_REASONS.includes(claim.reason);
1411
+ }
1412
+
1413
+ function wellFormedTransaction(tx) {
1414
+ return tx === null
1415
+ || (typeof tx === "object" && !Array.isArray(tx) && exactKeys(tx, TRANSACTION_FIELDS)
1416
+ && tx.op === "claim" && CARD_ID_RE.test(tx.cardId)
1417
+ && /^[0-9a-f]{32}$/.test(tx.envelopeId) && ISO_TS_RE.test(tx.at)
1418
+ && tx.phase === "claims-written");
1419
+ }
1420
+
1421
+ // Read and VERIFY the claims file. Missing file → fresh ONLY while the
1422
+ // authenticated writer state proves no claims generation was ever issued;
1423
+ // once a generation is anchored, deletion fails closed. A generation older
1424
+ // than the anchored one, or a digest that does not match the anchored latest
1425
+ // digest, is replay of older correctly signed state — rejected. Malformed
1426
+ // JSON, wrong shape, or an HMAC that does not verify → {ok: false, reason};
1427
+ // the caller MUST fail closed (refuse dispatch).
1428
+ export function readClaimsState(boardPath) {
1429
+ const path = claimsPath(boardPath);
1430
+ const writerState = readWriterState(writerStatePath(boardPath));
1431
+ if (writerState.pendingClaimsState !== null) {
1432
+ return { ok: false, recoverable: true, reason: "a staged claims-anchor transaction requires recovery under the writer lock (fails closed)" };
1433
+ }
1434
+ if (!existsSync(path)) {
1435
+ if (writerState.claimsDigest !== null || writerState.claimsGeneration > 0) {
1436
+ return { ok: false, reason: "the claims file is missing but the writer state anchors issued claims state — deletion is rejected (fails closed)" };
1437
+ }
1438
+ return { ok: true, missing: true, state: { generation: 0, transaction: null, claims: [], consumedClaims: [] } };
1439
+ }
1440
+ const value = readJsonFile(path);
1441
+ if (value === null || typeof value !== "object" || Array.isArray(value) || !exactKeys(value, CLAIMS_STATE_FIELDS)) {
1442
+ return { ok: false, reason: "the claims file is malformed or has an unknown shape (fails closed)" };
1443
+ }
1444
+ if (!Array.isArray(value.claims) || !Array.isArray(value.consumedClaims)
1445
+ || !Number.isInteger(value.generation) || value.generation < 0
1446
+ || !wellFormedTransaction(value.transaction)
1447
+ || !value.claims.every(wellFormedClaim)
1448
+ || !value.consumedClaims.every(wellFormedConsumedClaim)) {
1449
+ return { ok: false, reason: "the claims file contains a malformed record (fails closed)" };
1450
+ }
1451
+ // F2 integrity: an ACTIVE claim may never reference a consumed envelope —
1452
+ // a consumed envelope cannot be reused; only a new envelopeId is valid.
1453
+ const consumedIds = new Set(value.consumedClaims.map((entry) => entry.envelopeId));
1454
+ if (value.claims.some((claim) => consumedIds.has(claim.envelopeId))) {
1455
+ return { ok: false, reason: "an active claim references a consumed envelope — the claims file is inconsistent (fails closed)" };
1456
+ }
1457
+ if (writerState.secret === null) {
1458
+ return { ok: false, reason: "writer state file has no secret to verify the claims file (fails closed)" };
1459
+ }
1460
+ if (typeof value.hmac !== "string" || value.hmac !== claimsFileHmac(value, writerState.secret)) {
1461
+ return { ok: false, reason: "the claims file HMAC does not verify — tampered or forged (fails closed)" };
1462
+ }
1463
+ // Deletion/rollback guard: the generation must be exactly the anchored
1464
+ // latest (older = replay even if correctly signed; newer = not ours).
1465
+ if (value.generation !== writerState.claimsGeneration) {
1466
+ return { ok: false, reason: `claims generation ${value.generation} does not match the anchored generation ${writerState.claimsGeneration} — deletion or replay is rejected (fails closed)` };
1467
+ }
1468
+ if (writerState.claimsDigest !== claimsAnchorDigest(value, writerState.secret)) {
1469
+ return { ok: false, reason: "the claims content does not match the writer-state anchor — rollback is rejected (fails closed)" };
1470
+ }
1471
+ return {
1472
+ ok: true,
1473
+ state: {
1474
+ generation: value.generation,
1475
+ transaction: value.transaction,
1476
+ claims: value.claims,
1477
+ consumedClaims: value.consumedClaims,
1478
+ },
1479
+ };
1480
+ }
1481
+
1482
+ // Convenience reader. Throws a coded error on corruption so a caller cannot
1483
+ // silently treat tampered state as an empty claims list (Sol minor 3).
1484
+ export function readClaims(boardPath) {
1485
+ const result = readClaimsState(boardPath);
1486
+ if (!result.ok) {
1487
+ throw Object.assign(new Error(result.reason), { code: "claims-corrupt" });
1488
+ }
1489
+ return result.state.claims;
1490
+ }
1491
+
1492
+ function claimsAnchorDigest(value, secret) {
1493
+ return createHmac("sha256", secret)
1494
+ .update(canonicalJsonString({ generation: value.generation, hmac: value.hmac }), "utf8")
1495
+ .digest("hex");
1496
+ }
1497
+
1498
+ // Recover the two-file claims/anchor commit. The complete authenticated next
1499
+ // claims state is staged in writer state first, so either crash window rolls
1500
+ // forward deterministically rather than accepting an older file.
1501
+ function recoverClaimsAnchorLocked(boardPath) {
1502
+ const statePath = writerStatePath(boardPath);
1503
+ const writerState = readWriterState(statePath);
1504
+ const pending = writerState.pendingClaimsState;
1505
+ if (pending === null) return { ok: true, recovered: false };
1506
+ if (writerState.secret === null || !pending || typeof pending !== "object"
1507
+ || !exactKeys(pending, CLAIMS_STATE_FIELDS)
1508
+ || pending.hmac !== claimsFileHmac(pending, writerState.secret)) {
1509
+ return { ok: false, reason: "the staged claims-anchor transaction is malformed or unauthenticated (fails closed)" };
1510
+ }
1511
+ writeJsonFileAtomic(claimsPath(boardPath), pending);
1512
+ writeWriterState(statePath, {
1513
+ ...writerState,
1514
+ claimsGeneration: pending.generation,
1515
+ claimsDigest: claimsAnchorDigest(pending, writerState.secret),
1516
+ pendingClaimsState: null,
1517
+ });
1518
+ return { ok: true, recovered: true };
1519
+ }
1520
+
1521
+ function writeClaims(boardPath, claims, { consumedClaims = null, transaction = null } = {}) {
1522
+ const statePath = writerStatePath(boardPath);
1523
+ let writerState = readWriterState(statePath);
1524
+ if (writerState.secret === null) {
1525
+ throw Object.assign(new Error("writer state file has no secret (fails closed)"), { code: "writer-state-unavailable" });
1526
+ }
1527
+ const recovery = recoverClaimsAnchorLocked(boardPath);
1528
+ if (!recovery.ok) throw Object.assign(new Error(recovery.reason), { code: "claims-anchor-recovery-failed" });
1529
+ writerState = readWriterState(statePath);
1530
+ const previous = readClaimsState(boardPath);
1531
+ if (!previous.ok) throw Object.assign(new Error(previous.reason), { code: "claims-corrupt" });
1532
+ const value = {
1533
+ schema: CLAIMS_SCHEMA,
1534
+ generation: previous.state.generation + 1,
1535
+ transaction,
1536
+ claims,
1537
+ consumedClaims: consumedClaims ?? previous.state.consumedClaims,
1538
+ };
1539
+ value.hmac = claimsFileHmac(value, writerState.secret);
1540
+ // Prepare → claims → commit. Because prepare contains the complete signed
1541
+ // next value, recovery can safely roll forward after either later write.
1542
+ writeWriterState(statePath, { ...writerState, pendingClaimsState: value });
1543
+ writeJsonFileAtomic(claimsPath(boardPath), value);
1544
+ writeWriterState(statePath, {
1545
+ ...writerState,
1546
+ claimsGeneration: value.generation,
1547
+ claimsDigest: claimsAnchorDigest(value, writerState.secret),
1548
+ pendingClaimsState: null,
1549
+ });
1550
+ }
1551
+
1552
+ // The automation policy (§3.2, §4): an explicit, revocable record the USER
1553
+ // sets. No policy file = no overnight dispatch, ever (fails closed). Shape:
1554
+ // { roles: [...], placement: "container" | "host", maxConcurrent: N,
1555
+ // expiry: ISO yyyy-mm-dd (or full ISO timestamp), envelopeExpiryHours? }.
1556
+ export function readAutomationPolicy(boardPath, { configPath = null } = {}) {
1557
+ const path = configPath ?? automationPolicyPath(boardPath);
1558
+ const value = readJsonFile(path);
1559
+ if (value === null || typeof value !== "object") return null;
1560
+ return value;
1561
+ }
1562
+
1563
+ // Validate the policy shape and currency. The shape is CLOSED (F5): unknown
1564
+ // fields fail closed with a clear reason. Roles must match ROLE_NAME_RE.
1565
+ // The policy is bound to the board file it applies to (F4) and carries a risk
1566
+ // ceiling (F4). Returns {ok, policy, reason}.
1567
+ const POLICY_FIELDS = Object.freeze([
1568
+ "roles", "placement", "maxConcurrent", "expiry", "envelopeExpiryHours",
1569
+ "board", "riskCeiling", "allowPerCardRiskOverride", "acceptedRepositories",
1570
+ ]);
1571
+
1572
+ export function checkAutomationPolicy(policy, { now = null, boardPath = null } = {}) {
1573
+ const at = now ?? new Date().toISOString();
1574
+ if (policy === null || policy === undefined) {
1575
+ return { ok: false, reason: "no automation policy is set — automated dispatch is refused (fails closed)" };
1576
+ }
1577
+ if (typeof policy !== "object" || Array.isArray(policy)) {
1578
+ return { ok: false, reason: "the automation policy is malformed (fails closed)" };
1579
+ }
1580
+ for (const key of Object.keys(policy)) {
1581
+ if (!POLICY_FIELDS.includes(key)) {
1582
+ return { ok: false, reason: `the automation policy has an unknown field "${key}" — the policy shape is closed (fails closed)` };
1583
+ }
1584
+ }
1585
+ if (!Array.isArray(policy.roles) || policy.roles.length === 0
1586
+ || !policy.roles.every((role) => typeof role === "string" && ROLE_NAME_RE.test(role))) {
1587
+ return { ok: false, reason: `the automation policy declares no valid roles (every role must match ${ROLE_NAME_RE.source}) (fails closed)` };
1588
+ }
1589
+ if (!PLACEMENTS.includes(policy.placement)) {
1590
+ return { ok: false, reason: `the automation policy placement must be one of ${PLACEMENTS.join(", ")} (fails closed)` };
1591
+ }
1592
+ const maxConcurrent = Number(policy.maxConcurrent);
1593
+ if (!Number.isInteger(maxConcurrent) || maxConcurrent < 1) {
1594
+ return { ok: false, reason: "the automation policy maxConcurrent must be a positive integer (fails closed)" };
1595
+ }
1596
+ const expiry = typeof policy.expiry === "string" ? policy.expiry : null;
1597
+ if (expiry === null || Number.isNaN(Date.parse(expiry))) {
1598
+ return { ok: false, reason: "the automation policy has no valid expiry (fails closed)" };
1599
+ }
1600
+ if (Date.parse(expiry) <= Date.parse(at)) {
1601
+ return { ok: false, reason: "the automation policy has expired — new dispatches are refused until it is renewed" };
1602
+ }
1603
+ if (policy.envelopeExpiryHours !== undefined
1604
+ && (!Number.isFinite(Number(policy.envelopeExpiryHours)) || Number(policy.envelopeExpiryHours) <= 0)) {
1605
+ return { ok: false, reason: "the automation policy envelopeExpiryHours must be a positive number (fails closed)" };
1606
+ }
1607
+ if (typeof policy.board !== "string" || policy.board === "") {
1608
+ return { ok: false, reason: "the automation policy does not name the board file it applies to (fails closed)" };
1609
+ }
1610
+ if (boardPath !== null && policy.board !== boardPath) {
1611
+ return { ok: false, reason: `the automation policy is bound to board "${policy.board}", not this board (fails closed)` };
1612
+ }
1613
+ if (typeof policy.riskCeiling !== "string" || !RISK_LEVELS.includes(policy.riskCeiling)) {
1614
+ return { ok: false, reason: `the automation policy riskCeiling must be one of ${RISK_LEVELS.join(", ")} (fails closed)` };
1615
+ }
1616
+ if (policy.allowPerCardRiskOverride !== undefined && typeof policy.allowPerCardRiskOverride !== "boolean") {
1617
+ return { ok: false, reason: "the automation policy allowPerCardRiskOverride must be a boolean (fails closed)" };
1618
+ }
1619
+ // Repository/base policy (item 4): a CLOSED list of accepted repositories.
1620
+ // Dispatch may only run in a repository on this list; arbitrary repository
1621
+ // overrides outside it are rejected.
1622
+ if (!Array.isArray(policy.acceptedRepositories) || policy.acceptedRepositories.length === 0
1623
+ || !policy.acceptedRepositories.every((repo) => typeof repo === "string" && repo !== "" && !repo.includes(".."))) {
1624
+ return { ok: false, reason: "the automation policy acceptedRepositories must be a non-empty closed list of repository paths (fails closed)" };
1625
+ }
1626
+ return { ok: true, policy, reason: null };
1627
+ }
1628
+
1629
+ // Item 4: a repository override is honored only when it is on the policy's
1630
+ // closed acceptedRepositories list (exact match).
1631
+ export function repositoryAccepted(policy, repository) {
1632
+ return Array.isArray(policy?.acceptedRepositories) && policy.acceptedRepositories.includes(repository);
1633
+ }
1634
+
1635
+ // §3.2 assignment envelope: created ONCE per assignment, immutable (deep-
1636
+ // frozen, F2), and single-attempt. Retry, drift, or expiry require a NEW
1637
+ // envelope — never a mutation of this one. F4: the starting revision comes
1638
+ // from the CARD'S repository (resolved from the workspace the board lives
1639
+ // in), the card's base revision is bound in when present (branch chaining),
1640
+ // and the risk classification comes from the policy (per-card override only
1641
+ // when the policy allows).
1642
+ export function createEnvelope({ card, policy, now = null, repository = null, startingRevision = null }) {
1643
+ const at = now ?? new Date().toISOString();
1644
+ const envelopeId = randomBytes(16).toString("hex");
1645
+ const expiryHours = Number.isFinite(policy?.envelopeExpiryHours) && policy.envelopeExpiryHours > 0
1646
+ ? policy.envelopeExpiryHours
1647
+ : DEFAULT_ENVELOPE_EXPIRY_HOURS;
1648
+ let expiry = new Date(Date.parse(at) + expiryHours * 3_600_000).toISOString();
1649
+ // The envelope can never outlive the policy that authorized it.
1650
+ if (policy?.expiry && Date.parse(policy.expiry) < Date.parse(expiry)) expiry = policy.expiry;
1651
+ const repo = repository ?? (policy?.board ? dirname(policy.board) : process.cwd());
1652
+ const overrideAllowed = policy?.allowPerCardRiskOverride === true;
1653
+ const cardRisk = typeof card?.risk === "string" && RISK_LEVELS.includes(card.risk) ? card.risk : null;
1654
+ const risk = overrideAllowed && cardRisk !== null ? cardRisk : policy?.riskCeiling ?? "low";
1655
+ // Item 4: work STARTS from the declared base revision where present — the
1656
+ // envelope's starting revision IS the base (branch chaining), not merely a
1657
+ // copied field; execution validation enforces HEAD === startingRevision.
1658
+ const startRev = startingRevision ?? card.base ?? gitHead(repo);
1659
+ return deepFreeze({
1660
+ schema: ENVELOPE_SCHEMA,
1661
+ envelopeId,
1662
+ cardId: card.cardId,
1663
+ cardHash: card.hash ?? null,
1664
+ repository: repo,
1665
+ startingRevision: startRev,
1666
+ baseRevision: card.base ?? null,
1667
+ branch: `board/${card.cardId}-${envelopeId.slice(0, 8)}`,
1668
+ allowedPaths: Object.freeze([...(card.scope ?? [])]),
1669
+ unchangedPaths: Object.freeze([...(card.unchangedPaths ?? [])]),
1670
+ capabilities: Object.freeze([...(card.capabilities ?? [])]),
1671
+ stoppingPoint: card.stoppingPoint ?? null,
1672
+ acceptance: Object.freeze({ specHash: card.specHash ?? null, dodHash: card.dodHash ?? null }),
1673
+ placement: policy?.placement ?? null,
1674
+ interactionProfile: policy?.placement ?? null,
1675
+ risk,
1676
+ riskCeiling: policy?.riskCeiling ?? null,
1677
+ mode: "automated",
1678
+ createdAt: at,
1679
+ expiry,
1680
+ });
1681
+ }
1682
+
1683
+ function deepFreeze(value) {
1684
+ if (value !== null && typeof value === "object") {
1685
+ for (const key of Object.keys(value)) deepFreeze(value[key]);
1686
+ Object.freeze(value);
1687
+ }
1688
+ return value;
1689
+ }
1690
+
1691
+ // Read-only git observation for the envelope's starting revision. Git
1692
+ // OPERATIONS belong to the git extension (§0.9); reading HEAD is not one.
1693
+ export function gitHead(cwd = process.cwd()) {
1694
+ try {
1695
+ return execFileSync("git", ["rev-parse", "HEAD"], { cwd, encoding: "utf8" }).trim() || null;
1696
+ } catch {
1697
+ return null;
1698
+ }
1699
+ }
1700
+
1701
+ export function gitBranch(cwd = process.cwd()) {
1702
+ try {
1703
+ return execFileSync("git", ["branch", "--show-current"], { cwd, encoding: "utf8" }).trim() || null;
1704
+ } catch {
1705
+ return null;
1706
+ }
1707
+ }
1708
+
1709
+ // §3.3 + §4 dispatch eligibility, evaluated under the writer lock: the card
1710
+ // must be dispatchable per the pure predicate (which already enforces the
1711
+ // blocked-by gate, hash validity, and — with statePath set — writer
1712
+ // provenance), not already claimed, and its cardId must be in the writer's
1713
+ // issued-IDs ledger.
1714
+ export function dispatchEligibility({ card, boardIndex, boardPath, activeClaims }) {
1715
+ const claimed = new Set((activeClaims ?? []).map((claim) => claim.cardId));
1716
+ if (claimed.has(card.cardId)) {
1717
+ return { eligible: false, reason: `card ${card.cardId} is already claimed` };
1718
+ }
1719
+ const withState = { ...card, statePath: writerStatePath(boardPath) };
1720
+ const result = isDispatchable(withState, boardIndex);
1721
+ if (!result.dispatchable) {
1722
+ return { eligible: false, reason: result.failedConditions.join("; ") };
1723
+ }
1724
+ return { eligible: true, reason: null };
1725
+ }
1726
+
1727
+ const PRIORITY_ORDER = Object.freeze({ P0: 0, P1: 1, P2: 2, P3: 3 });
1728
+
1729
+ // Select the highest-priority dispatchable, unclaimed, provenance-verified
1730
+ // card. Optional cardId restricts selection to that card.
1731
+ export function selectDispatchableCard({ cards, boardPath, activeClaims, cardId = null }) {
1732
+ const index = new Map(cards.map((card) => [card.cardId, card]));
1733
+ const candidates = cards
1734
+ .filter((card) => cardId === null || card.cardId === cardId)
1735
+ .map((card) => ({ card, eligibility: dispatchEligibility({ card, boardIndex: index, boardPath, activeClaims }) }))
1736
+ .filter((entry) => entry.eligibility.eligible)
1737
+ .sort((a, b) =>
1738
+ (PRIORITY_ORDER[a.card.priority] ?? 99) - (PRIORITY_ORDER[b.card.priority] ?? 99)
1739
+ || String(a.card.cardId).localeCompare(String(b.card.cardId)));
1740
+ return candidates[0] ?? null;
1741
+ }
1742
+
1743
+ // §3.6 + §4: THE atomic claim. Under the writer lock, in one operation:
1744
+ // eligibility check → envelope creation → claims-file persist → projection
1745
+ // republication with the card shown active. A crash leaves either the old or
1746
+ // the new complete state (both writes are atomic renames); two concurrent
1747
+ // claims can never both win because the entire read-decide-write sequence
1748
+ // holds the lock. Expired claims are released first, inside the same lock.
1749
+ export function claimCard({ boardPath, cardId = null, role, policy = null, configPath = null, now = null, repository = null, startingRevision = null }) {
1750
+ if (typeof boardPath !== "string" || boardPath === "") {
1751
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
1752
+ }
1753
+ if (typeof role !== "string" || role === "") {
1754
+ throw Object.assign(new Error("role is required"), { code: "role-required" });
1755
+ }
1756
+ // Contended locks (two pulses racing across processes) retry briefly and
1757
+ // then fail as a structured contention error — never a crash. The claim
1758
+ // itself stays atomic: whoever takes the lock first wins the card.
1759
+ const { sleepSync } = { sleepSync: (ms) => Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms) };
1760
+ for (let attempt = 0; ; attempt += 1) {
1761
+ try {
1762
+ return withWriterLock(boardPath, () =>
1763
+ claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }));
1764
+ } catch (error) {
1765
+ if (error?.code === "writer-lock-held" && attempt < 20) {
1766
+ sleepSync(25);
1767
+ continue;
1768
+ }
1769
+ if (error?.code === "writer-lock-held") {
1770
+ return { ok: false, code: "lock-contention", reason: "the board writer lock stayed contended; the claim did not land (no card was double-claimed)" };
1771
+ }
1772
+ throw error;
1773
+ }
1774
+ }
1775
+ }
1776
+
1777
+ function claimCardLocked({ boardPath, cardId, role, policy, configPath, now, repository, startingRevision }) {
1778
+ const at = now ?? new Date().toISOString();
1779
+ if (!existsSync(boardPath)) {
1780
+ return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
1781
+ }
1782
+ if (!ROLE_NAME_RE.test(role)) {
1783
+ return { ok: false, code: "role-invalid", reason: `role "${role}" does not match ${ROLE_NAME_RE.source} (fails closed)` };
1784
+ }
1785
+ const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
1786
+ if (!anchorRecovery.ok) {
1787
+ return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
1788
+ }
1789
+ // F1: read AND verify the claims file. Corruption fails closed.
1790
+ const claimsRead = readClaimsState(boardPath);
1791
+ if (!claimsRead.ok) {
1792
+ return { ok: false, code: "claims-corrupt", reason: claimsRead.reason };
1793
+ }
1794
+ // F3: reconcile an interrupted transaction before anything else. A crash
1795
+ // between the claims-file write and the projection republish leaves a
1796
+ // transaction record; roll it forward (republish the projection) so a live
1797
+ // claim never exists without its active-state publication, and an envelope
1798
+ // never exists without its claim (the envelope is written inside the same
1799
+ // claims record, so claims-file presence IS claim+envelope presence).
1800
+ // If reconciliation itself fails, REFUSE the mutation — never clear or
1801
+ // bypass the pending transaction (item 3).
1802
+ const reconciliation = reconcileTransactionLocked({ boardPath, state: claimsRead.state });
1803
+ if (reconciliation !== null && reconciliation.failed) {
1804
+ return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason,
1805
+ errors: [reconciliation.reason] };
1806
+ }
1807
+ // Automation policy first (§4): no policy = no overnight dispatch.
1808
+ const resolvedPolicy = policy ?? readAutomationPolicy(boardPath, { configPath });
1809
+ const policyCheck = checkAutomationPolicy(resolvedPolicy, { now: at, boardPath });
1810
+ if (!policyCheck.ok) {
1811
+ return { ok: false, code: "policy-refused", reason: policyCheck.reason };
1812
+ }
1813
+ if (!policyCheck.policy.roles.includes(role)) {
1814
+ return { ok: false, code: "policy-role-refused", reason: `role "${role}" is not declared in the automation policy (fails closed)` };
1815
+ }
1816
+ // §3.4 mode table: automated placement is container/microVM, always.
1817
+ if (policyCheck.policy.placement !== "container") {
1818
+ return { ok: false, code: "policy-placement-refused", reason: "automated board dispatch requires container placement per the automation policy (§3.4)" };
1819
+ }
1820
+ // F4: the starting revision comes from the card's repository — the
1821
+ // workspace the board lives in, not process.cwd(). A caller-supplied
1822
+ // repository override is honored ONLY when it is on the policy's closed
1823
+ // acceptedRepositories list (item 4).
1824
+ const defaultRepo = dirname(boardPath);
1825
+ const repo = repository !== undefined && repository !== null
1826
+ ? (repositoryAccepted(policyCheck.policy, repository) ? repository : null)
1827
+ : defaultRepo;
1828
+ if (repo === null) {
1829
+ return { ok: false, code: "policy-repository-refused", reason: `repository "${repository}" is not on the automation policy's acceptedRepositories list (fails closed)` };
1830
+ }
1831
+ if (!repositoryAccepted(policyCheck.policy, repo)) {
1832
+ return { ok: false, code: "policy-repository-refused", reason: `repository "${repo}" is not on the automation policy's acceptedRepositories list (fails closed)` };
1833
+ }
1834
+ // Expired envelopes release their claims automatically on check (§4.6).
1835
+ const activeClaims = releaseExpiredClaimsLocked({ boardPath, at, state: claimsRead.state });
1836
+ const concurrency = Number(policyCheck.policy.maxConcurrent);
1837
+ if (activeClaims.length >= concurrency) {
1838
+ return { ok: false, code: "policy-concurrency-refused", reason: `the automation policy allows at most ${concurrency} concurrent claim(s); ${activeClaims.length} are active` };
1839
+ }
1840
+ const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), {});
1841
+ if (!validatedBoard.ok) {
1842
+ return { ok: false, code: "board-invalid", errors: validatedBoard.errors };
1843
+ }
1844
+ // F2: an envelope whose attempt was consumed (expired, reclaimed, or
1845
+ // completed) can never be reused — the consumed-attempt marker is
1846
+ // HMAC-covered in the claims file, and readClaimsState refuses any active
1847
+ // claim referencing a consumed envelopeId. A retry mints a NEW envelope.
1848
+ const consumedIds = new Set(claimsRead.state.consumedClaims.map((entry) => entry.envelopeId));
1849
+ if (cardId !== null && claimsRead.state.claims.some((claim) => claim.cardId === cardId && consumedIds.has(claim.envelopeId))) {
1850
+ return { ok: false, code: "envelope-consumed", reason: `card ${cardId} has a consumed envelope attempt; a retry requires a new dispatch and a new envelope` };
1851
+ }
1852
+ const selected = selectDispatchableCard({ cards: validatedBoard.cards, boardPath, activeClaims, cardId });
1853
+ if (selected === null) {
1854
+ return { ok: false, code: "no-dispatchable-card", reason: cardId
1855
+ ? `card ${cardId} is not dispatchable, is already claimed, or lacks writer provenance`
1856
+ : "no dispatchable unclaimed card is available" };
1857
+ }
1858
+ const card = selected.card;
1859
+ const envelope = createEnvelope({ card, policy: policyCheck.policy, now: at, repository: repo, startingRevision });
1860
+ const claim = {
1861
+ cardId: card.cardId,
1862
+ claimedAt: at,
1863
+ role,
1864
+ envelopeId: envelope.envelopeId,
1865
+ envelope,
1866
+ };
1867
+ const nextClaims = [...activeClaims, claim];
1868
+ // F3: the transaction record goes into the claims file BEFORE the
1869
+ // projection rename. Both writes are atomic renames under the lock, so a
1870
+ // crash leaves either the old or the new complete state, and the record
1871
+ // drives roll-forward recovery on the next operation.
1872
+ const transaction = { op: "claim", cardId: card.cardId, envelopeId: envelope.envelopeId, at, phase: "claims-written" };
1873
+ writeClaims(boardPath, nextClaims, { transaction });
1874
+ // Active-state publication: the projection is recomputed with the claim
1875
+ // visible, in the same locked operation (§3.6).
1876
+ const projection = writeProjection(boardPath, validatedBoard.cards, nextClaims);
1877
+ // Item 3: a projection failure returns ok:false as a STRUCTURED
1878
+ // RECOVERABLE failure — the claim and envelope are committed in the
1879
+ // authenticated claims file with the transaction retained; the next
1880
+ // operation rolls the projection forward. Never silently ok:true.
1881
+ if (!projection.written) {
1882
+ return {
1883
+ ok: false,
1884
+ claimed: true,
1885
+ code: "claim-recoverable",
1886
+ recoverable: true,
1887
+ reason: `projection publication failed: ${projection.error ?? "unknown"} (claim committed; transaction retained for roll-forward)`,
1888
+ cardId: card.cardId,
1889
+ role,
1890
+ claim,
1891
+ envelope,
1892
+ projection,
1893
+ };
1894
+ }
1895
+ // F3: finalize — clear the transaction record now that the projection is
1896
+ // published.
1897
+ finalizeTransactionLocked({ boardPath, claims: nextClaims, projection });
1898
+ return {
1899
+ ok: true,
1900
+ claimed: true,
1901
+ cardId: card.cardId,
1902
+ role,
1903
+ claim,
1904
+ envelope,
1905
+ projection,
1906
+ };
1907
+ }
1908
+
1909
+ // F3: roll an interrupted claim transaction forward — the claims (and their
1910
+ // envelopes) are already committed in the authenticated claims file, so only
1911
+ // the projection publication can be stale; republish it. Returns
1912
+ // {failed: true, reason} when roll-forward fails; callers must REFUSE the
1913
+ // mutation and never clear the pending transaction (item 3).
1914
+ function reconcileTransactionLocked({ boardPath, state }) {
1915
+ const transaction = state.transaction;
1916
+ if (transaction === null || transaction.phase !== "claims-written") return null;
1917
+ if (!existsSync(boardPath)) {
1918
+ return { failed: true, reason: "a pending claim transaction exists but the board file is unavailable — recovery failed (fails closed)" };
1919
+ }
1920
+ const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
1921
+ if (!validated.ok) {
1922
+ return { failed: true, reason: "a pending claim transaction exists but the board is invalid — recovery failed (fails closed)" };
1923
+ }
1924
+ const projection = writeProjection(boardPath, validated.cards, state.claims);
1925
+ if (!projection.written) {
1926
+ return { failed: true, reason: `projection republication failed during recovery: ${projection.error ?? "unknown"} (transaction retained)` };
1927
+ }
1928
+ writeClaims(boardPath, state.claims, { transaction: null });
1929
+ return { failed: false };
1930
+ }
1931
+
1932
+ // F3: clear the transaction record once the projection is published.
1933
+ function finalizeTransactionLocked({ boardPath, claims, projection }) {
1934
+ if (!projection?.written) {
1935
+ // Leave the transaction record in place so the next operation reconciles.
1936
+ return `projection publication failed: ${projection?.error ?? "unknown"} (transaction record retained for recovery)`;
1937
+ }
1938
+ writeClaims(boardPath, claims, { transaction: null });
1939
+ return null;
1940
+ }
1941
+
1942
+ // F2/Item 2: whether an envelope attempt has been consumed (expired,
1943
+ // reclaimed, or completed) — the journey layer checks this before executing
1944
+ // an envelope.
1945
+ export function isEnvelopeConsumed(boardPath, envelopeId) {
1946
+ const read = readClaimsState(boardPath);
1947
+ if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
1948
+ return read.state.consumedClaims.some((entry) => entry.envelopeId === envelopeId);
1949
+ }
1950
+
1951
+ // Prepare the one assigned branch before execution. Authentication happens
1952
+ // first against the persisted claim; only a missing assigned branch may be
1953
+ // created, and only from the envelope's exact starting revision. Existing
1954
+ // branch mismatch is drift, not an implicit checkout.
1955
+ export function prepareEnvelopeForExecution({ boardPath, envelope }) {
1956
+ if (typeof boardPath !== "string" || boardPath === "" || !wellFormedEnvelope(envelope)) {
1957
+ return { ok: false, code: "envelope-invalid", reason: "a valid board path and envelope are required" };
1958
+ }
1959
+ try {
1960
+ return withWriterLock(boardPath, () => {
1961
+ const recovery = recoverClaimsAnchorLocked(boardPath);
1962
+ if (!recovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", reason: recovery.reason };
1963
+ const guard = validateEnvelopeForExecutionLocked({ boardPath, envelope, requireBranch: false });
1964
+ if (!guard.ok) return guard;
1965
+ const current = gitBranch(envelope.repository);
1966
+ if (current === envelope.branch) return { ok: true, prepared: false, envelope: guard.envelope };
1967
+ if (gitHead(envelope.repository) !== envelope.startingRevision) {
1968
+ return { ok: false, code: "revision-drift", reason: "the repository is not at the envelope starting revision (fails closed)" };
1969
+ }
1970
+ try {
1971
+ execFileSync("git", ["show-ref", "--verify", "--quiet", `refs/heads/${envelope.branch}`], { cwd: envelope.repository });
1972
+ return { ok: false, code: "branch-drift", reason: `assigned branch ${envelope.branch} already exists but is not checked out (fails closed)` };
1973
+ } catch (error) {
1974
+ if (error?.status !== 1) return { ok: false, code: "branch-unreadable", reason: "the assigned branch state could not be verified (fails closed)" };
1975
+ }
1976
+ execFileSync("git", ["switch", "-c", envelope.branch, envelope.startingRevision], { cwd: envelope.repository, stdio: "ignore" });
1977
+ return { ok: true, prepared: true, envelope: guard.envelope };
1978
+ });
1979
+ } catch (error) {
1980
+ return { ok: false, code: error?.code || "branch-prepare-failed", reason: String(error?.message || error).slice(0, 512) };
1981
+ }
1982
+ }
1983
+
1984
+ // Item 2: the authoritative execution-boundary validation. Before an
1985
+ // envelope is executed or resumed, the journey layer MUST call this: it
1986
+ // validates authenticated consumption, expiry, card-hash drift, repository
1987
+ // drift, and HEAD/base/branch drift against the CURRENT board and repository.
1988
+ // Any drift fails closed — retry requires a new envelope.
1989
+ export function validateEnvelopeForExecution({ boardPath, envelope, now = null }) {
1990
+ if (typeof boardPath !== "string" || boardPath === "") {
1991
+ return { ok: false, code: "board-path-required", reason: "boardPath is required" };
1992
+ }
1993
+ try {
1994
+ return withWriterLock(boardPath, () => validateEnvelopeForExecutionLocked({ boardPath, envelope, now }));
1995
+ } catch (error) {
1996
+ return { ok: false, code: error?.code || "envelope-validation-failed", reason: String(error?.message || error).slice(0, 512) };
1997
+ }
1998
+ }
1999
+
2000
+ function validateEnvelopeForExecutionLocked({ boardPath, envelope, now = null, requireBranch = true }) {
2001
+ const at = now ?? new Date().toISOString();
2002
+ if (!wellFormedEnvelope(envelope)) {
2003
+ return { ok: false, code: "envelope-invalid", reason: "the envelope is malformed or has an unknown shape (fails closed)" };
2004
+ }
2005
+ const recovery = recoverClaimsAnchorLocked(boardPath);
2006
+ if (!recovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", reason: recovery.reason };
2007
+ const read = readClaimsState(boardPath);
2008
+ if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
2009
+ const claim = read.state.claims.find((entry) => entry.envelopeId === envelope.envelopeId);
2010
+ if (!claim) {
2011
+ const consumed = read.state.consumedClaims.some((entry) => entry.envelopeId === envelope.envelopeId);
2012
+ return { ok: false, code: "envelope-not-active",
2013
+ reason: consumed
2014
+ ? "the envelope attempt was consumed — a retry requires a new envelope (single-attempt lifecycle)"
2015
+ : "the envelope has no active claim on the board (fails closed)" };
2016
+ }
2017
+ // The caller does not get to supply a well-formed variant. Execution uses
2018
+ // exactly the envelope authenticated inside the active claims state.
2019
+ if (canonicalJsonString(envelope) !== canonicalJsonString(claim.envelope)) {
2020
+ return { ok: false, code: "envelope-authentication-failed", reason: "the supplied envelope does not exactly match the authenticated active-claim envelope (fails closed)" };
2021
+ }
2022
+ // Single-attempt lifecycle: expiry.
2023
+ if (Date.parse(envelope.expiry) <= Date.parse(at)) {
2024
+ return { ok: false, code: "envelope-expired", reason: `the envelope expired at ${envelope.expiry} (single-attempt lifecycle)` };
2025
+ }
2026
+ // Card hash drift: the board's current card must still hash to the
2027
+ // envelope's binding.
2028
+ if (!existsSync(boardPath)) {
2029
+ return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)" };
2030
+ }
2031
+ const board = validateBoard(readFileSync(boardPath, "utf8"), {});
2032
+ if (!board.ok) return { ok: false, code: "board-invalid", reason: "the board is invalid (fails closed)" };
2033
+ const card = board.cards.find((entry) => entry.cardId === envelope.cardId);
2034
+ if (!card) {
2035
+ return { ok: false, code: "card-not-found", reason: `card ${envelope.cardId} no longer exists on the board (drift — new envelope required)` };
2036
+ }
2037
+ if ((card.hash ?? computeCardHash(card)) !== envelope.cardHash) {
2038
+ return { ok: false, code: "card-hash-drift", reason: "the card hash drifted from the envelope binding — semantic edits require a new envelope (fails closed)" };
2039
+ }
2040
+ // Repository drift.
2041
+ if (envelope.repository !== dirname(boardPath)) {
2042
+ return { ok: false, code: "repository-drift", reason: `the envelope is bound to repository "${envelope.repository}", not this board's repository (fails closed)` };
2043
+ }
2044
+ // HEAD/base/branch drift: work must start from the declared starting
2045
+ // revision (the card's base revision where present, per item 4) — the
2046
+ // repository's current HEAD must equal it.
2047
+ if (envelope.startingRevision !== null) {
2048
+ const head = gitHead(envelope.repository);
2049
+ if (head === null) {
2050
+ return { ok: false, code: "head-unreadable", reason: "the envelope's repository HEAD could not be read (fails closed)" };
2051
+ }
2052
+ if (head !== envelope.startingRevision) {
2053
+ return { ok: false, code: "revision-drift",
2054
+ reason: `repository HEAD ${head} does not match the envelope's starting revision ${envelope.startingRevision} (drift — new envelope required)` };
2055
+ }
2056
+ }
2057
+ const branch = gitBranch(envelope.repository);
2058
+ if (requireBranch && branch !== envelope.branch) {
2059
+ return { ok: false, code: "branch-drift",
2060
+ reason: `repository branch ${branch ?? "(detached)"} does not match the assigned branch ${envelope.branch} (fails closed)` };
2061
+ }
2062
+ return { ok: true, claim, envelope: claim.envelope, reason: null };
2063
+ }
2064
+
2065
+ // Item 2: completion consumption — when the work reaches its stopping point,
2066
+ // the envelope attempt is consumed ("completed") and its claim released.
2067
+ // Never marks the card done (completion is human-only, §3.1).
2068
+ export function consumeEnvelope({ boardPath, envelopeId, reason = "completed" } = {}) {
2069
+ if (typeof boardPath !== "string" || boardPath === "") {
2070
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
2071
+ }
2072
+ if (!CONSUMED_REASONS.includes(reason)) {
2073
+ throw Object.assign(new Error(`reason must be one of ${CONSUMED_REASONS.join(", ")}`), { code: "invalid-input" });
2074
+ }
2075
+ return withWriterLock(boardPath, () => {
2076
+ const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
2077
+ if (!anchorRecovery.ok) return { ok: false, code: "claims-anchor-recovery-failed", recoverable: true, reason: anchorRecovery.reason };
2078
+ const read = readClaimsState(boardPath);
2079
+ if (!read.ok) return { ok: false, code: "claims-corrupt", reason: read.reason };
2080
+ const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
2081
+ if (reconciliation?.failed) {
2082
+ return { ok: false, code: "recovery-failed", recoverable: true, reason: reconciliation.reason };
2083
+ }
2084
+ const claim = read.state.claims.find((entry) => entry.envelopeId === envelopeId);
2085
+ if (!claim) {
2086
+ return { ok: false, code: "envelope-not-active", reason: "the envelope has no active claim to consume" };
2087
+ }
2088
+ const kept = read.state.claims.filter((entry) => entry.envelopeId !== envelopeId);
2089
+ const consumedClaims = [...read.state.consumedClaims, {
2090
+ cardId: claim.cardId,
2091
+ envelopeId,
2092
+ consumedAt: new Date().toISOString(),
2093
+ reason,
2094
+ }];
2095
+ writeClaims(boardPath, kept, { consumedClaims });
2096
+ if (existsSync(boardPath)) {
2097
+ const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
2098
+ if (validated.ok) writeProjection(boardPath, validated.cards, kept);
2099
+ }
2100
+ return { ok: true, consumed: true, envelopeId, reason, claims: kept };
2101
+ });
2102
+ }
2103
+
2104
+ // Release every claim whose envelope expiry has passed. Returns the surviving
2105
+ // active claims and republishes the projection. Safe to call anytime.
2106
+ export function releaseExpiredClaims({ boardPath, now = null } = {}) {
2107
+ if (typeof boardPath !== "string" || boardPath === "") {
2108
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
2109
+ }
2110
+ return withWriterLock(boardPath, () => {
2111
+ const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
2112
+ if (!anchorRecovery.ok) throw Object.assign(new Error(anchorRecovery.reason), { code: "claims-anchor-recovery-failed" });
2113
+ const read = readClaimsState(boardPath);
2114
+ if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
2115
+ // Item 3: every claims mutation reconciles first; refuse on failure.
2116
+ const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
2117
+ if (reconciliation?.failed) {
2118
+ throw Object.assign(new Error(reconciliation.reason), { code: "recovery-failed" });
2119
+ }
2120
+ return { released: releaseExpiredClaimsLocked({ boardPath, at: now ?? new Date().toISOString(), state: read.state }) };
2121
+ }).released;
2122
+ }
2123
+
2124
+ function releaseExpiredClaimsLocked({ boardPath, at, state = null }) {
2125
+ const existing = (state ?? readClaimsState(boardPath).state ?? { claims: [] }).claims;
2126
+ const active = existing.filter((claim) => {
2127
+ const expiry = claim?.envelope?.expiry;
2128
+ return typeof expiry === "string" && Date.parse(expiry) > Date.parse(at);
2129
+ });
2130
+ if (active.length !== existing.length) {
2131
+ // F2: a released envelope's attempt is consumed — it can never be
2132
+ // reused; a retry mints a new envelope.
2133
+ const consumedClaims = [...(state?.consumedClaims ?? readClaimsState(boardPath).state?.consumedClaims ?? []),
2134
+ ...existing.filter((claim) => !active.includes(claim)).map((claim) => ({
2135
+ cardId: claim.cardId,
2136
+ envelopeId: claim.envelopeId,
2137
+ consumedAt: at,
2138
+ reason: "expired",
2139
+ }))];
2140
+ writeClaims(boardPath, active, { consumedClaims });
2141
+ if (existsSync(boardPath)) {
2142
+ const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
2143
+ if (validated.ok) writeProjection(boardPath, validated.cards, active);
2144
+ }
2145
+ }
2146
+ return active;
2147
+ }
2148
+
2149
+ // Explicit reclaim: drop the claim for one card (or all claims with cardId
2150
+ // null). Releases the active-state publication. Never marks anything done.
2151
+ export function reclaimClaim({ boardPath, cardId = null, envelopeId = null } = {}) {
2152
+ if (typeof boardPath !== "string" || boardPath === "") {
2153
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
2154
+ }
2155
+ return withWriterLock(boardPath, () => {
2156
+ const anchorRecovery = recoverClaimsAnchorLocked(boardPath);
2157
+ if (!anchorRecovery.ok) throw Object.assign(new Error(anchorRecovery.reason), { code: "claims-anchor-recovery-failed" });
2158
+ const read = readClaimsState(boardPath);
2159
+ if (!read.ok) throw Object.assign(new Error(read.reason), { code: "claims-corrupt" });
2160
+ // Item 3: reconcile first; refuse the mutation when recovery fails.
2161
+ const reconciliation = reconcileTransactionLocked({ boardPath, state: read.state });
2162
+ if (reconciliation?.failed) {
2163
+ throw Object.assign(new Error(reconciliation.reason), { code: "recovery-failed" });
2164
+ }
2165
+ const existing = read.state.claims;
2166
+ const kept = existing.filter((claim) =>
2167
+ (cardId !== null ? claim.cardId !== cardId : true)
2168
+ && (envelopeId !== null ? claim.envelopeId !== envelopeId : true));
2169
+ if (kept.length === existing.length) {
2170
+ return { ok: false, code: "claim-not-found", reason: "no matching claim to reclaim" };
2171
+ }
2172
+ // F2: a reclaimed envelope's attempt is consumed too.
2173
+ const consumedClaims = [...read.state.consumedClaims,
2174
+ ...existing.filter((claim) => !kept.includes(claim)).map((claim) => ({
2175
+ cardId: claim.cardId,
2176
+ envelopeId: claim.envelopeId,
2177
+ consumedAt: new Date().toISOString(),
2178
+ reason: "reclaimed",
2179
+ }))];
2180
+ writeClaims(boardPath, kept, { consumedClaims });
2181
+ if (existsSync(boardPath)) {
2182
+ const validated = validateBoard(readFileSync(boardPath, "utf8"), {});
2183
+ if (validated.ok) writeProjection(boardPath, validated.cards, kept);
2184
+ }
2185
+ return { ok: true, reclaimed: existing.length - kept.length, claims: kept };
2186
+ });
2187
+ }
2188
+
1257
2189
  // ---------------------------------------------------------------------------
1258
2190
  // Provider observation (§5 reversibility): everything registers behind the
1259
2191
  // observation that a board file exists. No board file, no behavior change and
@@ -1595,5 +2527,63 @@ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPat
1595
2527
  });
1596
2528
  registered.push("agentic_kanban_board_update");
1597
2529
  }
2530
+ // The dispatch tool (§4): claims the highest-priority dispatchable,
2531
+ // unclaimed, provenance-verified card for a role under the user's
2532
+ // automation policy, creates the assignment envelope, and returns the
2533
+ // binding. The policy is the human decision; no policy = refusal. This
2534
+ // tool wires claim + envelope only — journey execution integration is a
2535
+ // follow-up.
2536
+ if (typeof pi?.registerTool === "function") {
2537
+ pi.registerTool({
2538
+ name: "agentic_kanban_board_dispatch",
2539
+ label: "Kanban Board Dispatch",
2540
+ description:
2541
+ "Claim the highest-priority dispatchable, unclaimed task-board card for a role and create its assignment envelope. Governed by the user's automation policy (roles, placement, maxConcurrent, expiry): no policy = refused; a role not in the policy = refused. Atomic: two concurrent claims can never claim the same card. Expired envelopes release automatically. Returns {cardId, envelope, branch, scope, stoppingPoint}.",
2542
+ parameters: {
2543
+ type: "object",
2544
+ additionalProperties: false,
2545
+ properties: {
2546
+ role: { type: "string", description: "The role claiming the card; must be declared in the automation policy." },
2547
+ cardId: { type: "string", description: "Optional: claim this specific card instead of the highest-priority dispatchable one." },
2548
+ },
2549
+ required: ["role"],
2550
+ },
2551
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
2552
+ const activeBoardPath = boardPathFor(ctx);
2553
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
2554
+ return unavailableResult();
2555
+ }
2556
+ if (typeof input?.role !== "string" || input.role === "") {
2557
+ const value = { ok: false, code: "invalid-input", reason: "role is required", errors: ["role is required"] };
2558
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2559
+ }
2560
+ let result;
2561
+ try {
2562
+ result = claimCard({ boardPath: activeBoardPath, role: input.role, cardId: input?.cardId ?? null });
2563
+ } catch (error) {
2564
+ const code = typeof error?.code === "string" && error.code !== "claims-corrupt" ? error.code : error.code;
2565
+ const value = { ok: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };
2566
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2567
+ }
2568
+ let value;
2569
+ if (result.ok) {
2570
+ value = {
2571
+ ok: true,
2572
+ claimed: true,
2573
+ cardId: result.cardId,
2574
+ envelope: result.envelope,
2575
+ branch: result.envelope.branch,
2576
+ scope: result.envelope.allowedPaths,
2577
+ stoppingPoint: result.envelope.stoppingPoint,
2578
+ role: result.role,
2579
+ };
2580
+ } else {
2581
+ value = { ok: false, code: result.code, reason: result.reason ?? (result.errors ?? []).join("; "), errors: result.errors ?? [] };
2582
+ }
2583
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2584
+ },
2585
+ });
2586
+ registered.push("agentic_kanban_board_dispatch");
2587
+ }
1598
2588
  return { registered, observation: { ...observation, boardPath: observation.boardPath } };
1599
2589
  }