@evoclock/pi-agentic-driver 0.8.3 → 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.
@@ -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
  // ---------------------------------------------------------------------------
@@ -248,7 +249,11 @@ function parseCardLine(line, surface, checkboxState = null) {
248
249
 
249
250
  function laneFromHeading(heading) {
250
251
  const name = heading.replace(/^##\s*/, "").trim().toLowerCase();
251
- return LANES.includes(name) ? name : null;
252
+ if (LANES.includes(name)) return name;
253
+ // Obsidian display lane names (the derived projection's headings) map onto
254
+ // the canonical closed lanes — one semantic model, two surfaces (§1).
255
+ const display = { backlog: "backlog", "in progress": "in-progress", review: "review", done: "done" };
256
+ return display[name] ?? null;
252
257
  }
253
258
 
254
259
  export function parseBoard(markdown, { surface = "auto" } = {}) {
@@ -482,6 +487,10 @@ export function serializeObsidianCard(card) {
482
487
  const checkbox = card.flags?.includes("cancelled") ? "[-]" : card.done ? "[x]" : "[ ]";
483
488
  const parts = [checkbox, card.title];
484
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));
485
494
  if (card.hash) parts.push(fieldText("hash", card.hash));
486
495
  if (card.priority) parts.push(fieldText("priority", card.priority));
487
496
  for (const flag of card.flags ?? []) parts.push(fieldText("flag", flag));
@@ -553,6 +562,57 @@ export function serializeBoard(cards, { surface }) {
553
562
  // supply identifiers or hashes.
554
563
  // ---------------------------------------------------------------------------
555
564
 
565
+ // ---------------------------------------------------------------------------
566
+ // The Obsidian projection (§2 derived projection). After every successful
567
+ // write/update/delete to the canonical TASKS.md board, a sibling projection
568
+ // file is RECOMPUTED from the canonical Markdown — never incrementally
569
+ // patched, never read back as authority. The read tool (agentic_kanban_board)
570
+ // reads only the canonical board file; if the canonical board is deleted the
571
+ // projection is stale-by-design and is ignored by every reader. The
572
+ // projection exists purely so the Obsidian Kanban plugin can render the same
573
+ // semantic model (§1: one semantic model, two surfaces).
574
+ //
575
+ // Projection path: a sibling "board.md" next to the canonical board. When the
576
+ // canonical board is itself named board.md (test/dev setups), the projection
577
+ // is "board.projection.md" so the canonical file is never overwritten by its
578
+ // own view.
579
+ // ---------------------------------------------------------------------------
580
+
581
+ export function projectionPath(boardPath) {
582
+ const file = boardPath.split("/").pop();
583
+ const name = file === "board.md" ? "board.projection.md" : "board.md";
584
+ return join(dirname(boardPath), name);
585
+ }
586
+
587
+ // Recompute the projection from the canonical cards. Best-effort relative to
588
+ // the authoritative write: a projection failure is reported but never rolls
589
+ // back or invalidates the canonical persist.
590
+ export function writeProjection(boardPath, cards, claims = []) {
591
+ const path = projectionPath(boardPath);
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
+ });
601
+ const frontmatter = "---\nkanban-plugin: board\n---\n\n";
602
+ const body = serializeBoard(annotated, { surface: "obsidian" })
603
+ .replace(/^## backlog$/m, "## Backlog")
604
+ .replace(/^## in-progress$/m, "## In Progress")
605
+ .replace(/^## review$/m, "## Review")
606
+ .replace(/^## done$/m, "## Done");
607
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
608
+ writeFileSync(tmpPath, frontmatter + body, "utf8");
609
+ renameSync(tmpPath, path);
610
+ return { written: true, path, error: null };
611
+ } catch (error) {
612
+ return { written: false, path, error: String(error?.message || error).slice(0, 512) };
613
+ }
614
+ }
615
+
556
616
  export function allocateCardId(cards, { prefix = "T" } = {}) {
557
617
  let max = 0;
558
618
  for (const card of cards) {
@@ -585,13 +645,26 @@ function readWriterState(statePath) {
585
645
  try {
586
646
  const state = JSON.parse(readFileSync(statePath, "utf8"));
587
647
  const value = Number(state?.highWaterMark);
648
+ const generation = Number(state?.claimsGeneration);
588
649
  return {
589
650
  highWaterMark: Number.isInteger(value) && value >= 0 ? value : 0,
590
651
  secret: typeof state?.secret === "string" && state.secret !== "" ? state.secret : null,
591
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,
592
665
  };
593
666
  } catch {
594
- return { highWaterMark: 0, secret: null, issuedCardIds: [] };
667
+ return { highWaterMark: 0, secret: null, issuedCardIds: [], claimsGeneration: 0, claimsDigest: null, pendingClaimsState: null };
595
668
  }
596
669
  }
597
670
 
@@ -893,7 +966,237 @@ function writeCardLocked({ boardPath, input, authority, registries, surface, now
893
966
  state.highWaterMark = nextNumber;
894
967
  if (!state.issuedCardIds.includes(cardId)) state.issuedCardIds.push(cardId);
895
968
  writeWriterState(statePath, state);
896
- return { ok: true, cardId, card: Object.freeze({ ...card }), persisted: true, ...(now ? { now } : {}) };
969
+ // §2 derived projection: recomputed from the just-persisted canonical board
970
+ // on every mutation; a view only, never authority, stale-by-design if the
971
+ // canonical board is removed.
972
+ const projection = writeProjection(boardPath, [...existingCards, card]);
973
+ return { ok: true, cardId, card: Object.freeze({ ...card }), persisted: true, projection, ...(now ? { now } : {}) };
974
+ }
975
+
976
+ // ---------------------------------------------------------------------------
977
+ // Card update and delete (§3.5): every board operation a user could express
978
+ // goes through the trusted writer with a REQUIRED authority record, recorded
979
+ // with the same HMAC + provenance discipline as creation. The card hash is
980
+ // recomputed after any change; spec/DoD text updates recompute their hashes;
981
+ // completion (done=true) requires human authority — an instruction or an
982
+ // approved proposal — never an agent report alone.
983
+ // ---------------------------------------------------------------------------
984
+
985
+ const UPDATABLE_LIST_FIELDS = [
986
+ ["scopePaths", "scope"],
987
+ ["capabilities", "capabilities"],
988
+ ["dependencies", "dependencies"],
989
+ ["tags", "tags"],
990
+ ];
991
+
992
+ // Apply a changes subset to a parsed card. Returns a plain updated card (hash
993
+ // not yet recomputed) or an error descriptor.
994
+ function applyCardChanges(card, changes) {
995
+ const updated = { ...card, flags: [...(card.flags ?? [])] };
996
+ const changed = [];
997
+ const c = changes ?? {};
998
+
999
+ if (c.lane !== undefined) {
1000
+ if (!LANES.includes(c.lane)) {
1001
+ return { error: { code: "invalid-lane", errors: [`lane "${c.lane}" is not one of ${LANES.join(", ")}`] } };
1002
+ }
1003
+ updated.lane = c.lane;
1004
+ changed.push("lane");
1005
+ }
1006
+ if (c.done !== undefined) {
1007
+ updated.done = Boolean(c.done);
1008
+ // Moving to done sets the done checkbox AND the lane (§1 lifecycle).
1009
+ if (c.done) updated.lane = "done";
1010
+ changed.push("done");
1011
+ }
1012
+ if (c.flags !== undefined) {
1013
+ // Accept {add: [], remove: []} or a full replacement array.
1014
+ if (Array.isArray(c.flags)) {
1015
+ updated.flags = [...c.flags];
1016
+ } else if (c.flags && typeof c.flags === "object") {
1017
+ const set = new Set(updated.flags);
1018
+ for (const flag of c.flags.add ?? []) set.add(flag);
1019
+ for (const flag of c.flags.remove ?? []) set.delete(flag);
1020
+ updated.flags = [...set];
1021
+ } else {
1022
+ return { error: { code: "invalid-flags", errors: ["flags must be an array or {add, remove}"] } };
1023
+ }
1024
+ changed.push("flags");
1025
+ }
1026
+ if (c.title !== undefined) {
1027
+ if (typeof c.title !== "string" || c.title.trim() === "") {
1028
+ return { error: { code: "invalid-title", errors: ["title must be a non-empty string"] } };
1029
+ }
1030
+ updated.title = sanitizeFreeText(c.title);
1031
+ changed.push("title");
1032
+ }
1033
+ if (c.description !== undefined) {
1034
+ updated.description = sanitizeFreeText(c.description);
1035
+ changed.push("description");
1036
+ }
1037
+ if (c.priority !== undefined) {
1038
+ if (c.priority !== null && !PRIORITIES.includes(c.priority)) {
1039
+ return { error: { code: "invalid-priority", errors: [`priority "${c.priority}" is not one of ${PRIORITIES.join(", ")}`] } };
1040
+ }
1041
+ updated.priority = c.priority;
1042
+ changed.push("priority");
1043
+ }
1044
+ for (const [textField, hashField] of [["specification", "specHash", "specText"], ["definitionOfDone", "dodHash", "dodText"]]) {
1045
+ if (c[textField] !== undefined) {
1046
+ const text = c[textField] === null ? null : nfc(String(c[textField]));
1047
+ if (text !== null && text.trim() === "") {
1048
+ return { error: { code: textField === "specification" ? "empty-specification" : "empty-definition-of-done", errors: [`${textField} text must be non-empty`] } };
1049
+ }
1050
+ updated[textField === "specification" ? "specText" : "dodText"] = text;
1051
+ updated[hashField] = text !== null ? computeSpecHash(text) : null;
1052
+ changed.push(textField);
1053
+ }
1054
+ }
1055
+ if (c.stoppingPoint !== undefined) {
1056
+ updated.stoppingPoint = c.stoppingPoint === null ? null : sanitizeFreeText(c.stoppingPoint);
1057
+ changed.push("stoppingPoint");
1058
+ }
1059
+ for (const [inputKey, cardKey] of UPDATABLE_LIST_FIELDS) {
1060
+ if (c[inputKey] !== undefined) {
1061
+ if (!Array.isArray(c[inputKey])) {
1062
+ return { error: { code: `invalid-${inputKey}`, errors: [`${inputKey} must be an array (full replacement list)`] } };
1063
+ }
1064
+ updated[cardKey] = [...c[inputKey]];
1065
+ changed.push(inputKey);
1066
+ }
1067
+ }
1068
+ if (c.base !== undefined) {
1069
+ updated.base = c.base;
1070
+ changed.push("base");
1071
+ }
1072
+ if (c.dueDate !== undefined) {
1073
+ updated.due = c.dueDate;
1074
+ changed.push("dueDate");
1075
+ }
1076
+ if (c.role !== undefined) {
1077
+ updated.role = c.role;
1078
+ changed.push("role");
1079
+ }
1080
+ return { updated, changed };
1081
+ }
1082
+
1083
+ // Update an existing card through the trusted writer. `changes` is any subset
1084
+ // of: lane, done, flags, title, description, priority, specification,
1085
+ // definitionOfDone, stoppingPoint, scopePaths, capabilities, dependencies
1086
+ // (full replacement list), base, dueDate, role, tags. The authority record is
1087
+ // REQUIRED and re-recorded (HMAC bound to the recomputed card hash).
1088
+ export function updateCard({ boardPath, cardId, changes, authority, registries = {}, surface = "tasks", now = null }) {
1089
+ if (typeof boardPath !== "string" || boardPath === "") {
1090
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
1091
+ }
1092
+ if (typeof cardId !== "string" || cardId === "") {
1093
+ throw Object.assign(new Error("cardId is required"), { code: "card-id-required" });
1094
+ }
1095
+ return withWriterLock(boardPath, () => updateCardLocked({ boardPath, cardId, changes, authority, registries, surface, now }));
1096
+ }
1097
+
1098
+ function updateCardLocked({ boardPath, cardId, changes, authority, registries, surface, now }) {
1099
+ if (!existsSync(boardPath)) {
1100
+ return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)", errors: ["board file is no longer present (board-unavailable)"], persisted: false };
1101
+ }
1102
+ const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), registries);
1103
+ if (!validatedBoard.ok) {
1104
+ return { ok: false, code: "board-invalid", errors: validatedBoard.errors, persisted: false };
1105
+ }
1106
+ const index = validatedBoard.cards.findIndex((card) => card.cardId === cardId);
1107
+ if (index === -1) {
1108
+ return { ok: false, code: "card-not-found", errors: [`cardId "${cardId}" does not exist on the board`], persisted: false };
1109
+ }
1110
+ // Completion is human-only (§3.1): marking a card done requires the
1111
+ // authority source to be an instruction or an approved report proposal.
1112
+ // An agent report alone is never completion.
1113
+ if (changes?.done === true) {
1114
+ const source = authority?.source;
1115
+ if (source !== "instruction" && source !== "report-proposal") {
1116
+ return {
1117
+ ok: false,
1118
+ code: "completion-authority-required",
1119
+ errors: ["marking a card done requires human authority: an instruction or an approved report proposal; an agent report alone is never completion"],
1120
+ persisted: false,
1121
+ cardId,
1122
+ };
1123
+ }
1124
+ }
1125
+ // The authority record is REQUIRED for every mutation and is validated
1126
+ // exactly as at creation (recordAuthoritySource throws on malformation).
1127
+ const record = recordAuthoritySource(authority);
1128
+ const { updated, changed, error } = applyCardChanges(validatedBoard.cards[index], changes);
1129
+ if (error) return { ok: false, code: error.code, errors: error.errors, persisted: false, cardId };
1130
+ if (changed.length === 0) {
1131
+ return { ok: false, code: "no-changes", errors: ["changes must contain at least one updatable field"], persisted: false, cardId };
1132
+ }
1133
+ const validation = validateCard(updated, registries);
1134
+ if (!validation.ok) {
1135
+ return { ok: false, code: "validation-failed", errors: validation.errors, persisted: false, cardId };
1136
+ }
1137
+ // The card hash is recomputed after ANY change (§1 hash binding).
1138
+ updated.hash = computeCardHash(updated);
1139
+ // F1: the re-recorded authority HMAC binds to the NEW card hash.
1140
+ updated.authoritySource = record;
1141
+ const statePath = writerStatePath(boardPath);
1142
+ const state = readWriterState(statePath);
1143
+ if (state.secret === null) {
1144
+ return { ok: false, code: "writer-state-unavailable", errors: ["writer state file has no secret (fails closed)"], persisted: false, cardId };
1145
+ }
1146
+ updated.authorityWriterHmac = authorityRecordHmac(record, state.secret, updated.hash);
1147
+ const cards = validatedBoard.cards.map((card, i) => (i === index ? updated : card));
1148
+ const serialized = serializeBoard(cards, { surface });
1149
+ const roundTrip = validateBoard(serialized, registries);
1150
+ if (!roundTrip.ok) {
1151
+ return { ok: false, code: "serialization-invalid", errors: roundTrip.errors, persisted: false, cardId };
1152
+ }
1153
+ const tmpPath = `${boardPath}.tmp-${process.pid}-${Date.now()}`;
1154
+ writeFileSync(tmpPath, serialized, "utf8");
1155
+ renameSync(tmpPath, boardPath);
1156
+ // Derived projection recomputed from the canonical board after the mutation.
1157
+ const projection = writeProjection(boardPath, cards);
1158
+ return { ok: true, cardId, card: Object.freeze({ ...updated }), changedFields: changed, persisted: true, projection, ...(now ? { now } : {}) };
1159
+ }
1160
+
1161
+ // Remove a card from the board through the trusted writer. The issued-ID
1162
+ // ledger KEEPS the ID forever — a deleted cardId is never reused. Deletion is
1163
+ // a consequential action and requires the same REQUIRED authority record.
1164
+ export function deleteCard({ boardPath, cardId, authority, registries = {}, surface = "tasks", now = null }) {
1165
+ if (typeof boardPath !== "string" || boardPath === "") {
1166
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
1167
+ }
1168
+ if (typeof cardId !== "string" || cardId === "") {
1169
+ throw Object.assign(new Error("cardId is required"), { code: "card-id-required" });
1170
+ }
1171
+ return withWriterLock(boardPath, () => deleteCardLocked({ boardPath, cardId, authority, registries, surface, now }));
1172
+ }
1173
+
1174
+ function deleteCardLocked({ boardPath, cardId, authority, registries, surface, now }) {
1175
+ if (!existsSync(boardPath)) {
1176
+ return { ok: false, code: "board-unavailable", reason: "board file is no longer present (board-unavailable)", errors: ["board file is no longer present (board-unavailable)"], persisted: false };
1177
+ }
1178
+ const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), registries);
1179
+ if (!validatedBoard.ok) {
1180
+ return { ok: false, code: "board-invalid", errors: validatedBoard.errors, persisted: false };
1181
+ }
1182
+ if (!validatedBoard.cards.some((card) => card.cardId === cardId)) {
1183
+ return { ok: false, code: "card-not-found", errors: [`cardId "${cardId}" does not exist on the board`], persisted: false };
1184
+ }
1185
+ // Authority is REQUIRED for deletion too; validate exactly as at creation.
1186
+ recordAuthoritySource(authority);
1187
+ const cards = validatedBoard.cards.filter((card) => card.cardId !== cardId);
1188
+ const serialized = cards.length > 0 ? serializeBoard(cards, { surface }) : "";
1189
+ const roundTrip = validateBoard(serialized, registries);
1190
+ if (!roundTrip.ok) {
1191
+ // A dangling dependency on the deleted card fails closed.
1192
+ return { ok: false, code: "dependency-referenced", errors: roundTrip.errors, persisted: false, cardId };
1193
+ }
1194
+ const tmpPath = `${boardPath}.tmp-${process.pid}-${Date.now()}`;
1195
+ writeFileSync(tmpPath, serialized, "utf8");
1196
+ renameSync(tmpPath, boardPath);
1197
+ // The issued-ID ledger keeps the deleted ID forever — never reused.
1198
+ const projection = writeProjection(boardPath, cards);
1199
+ return { ok: true, cardId, removed: true, persisted: true, projection, ...(now ? { now } : {}) };
897
1200
  }
898
1201
 
899
1202
  // ---------------------------------------------------------------------------
@@ -977,6 +1280,912 @@ export function isDispatchable(card, boardIndex) {
977
1280
  return { dispatchable: failed.length === 0, failedConditions: failed };
978
1281
  }
979
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
+
980
2189
  // ---------------------------------------------------------------------------
981
2190
  // Provider observation (§5 reversibility): everything registers behind the
982
2191
  // observation that a board file exists. No board file, no behavior change and
@@ -988,13 +2197,33 @@ export function observeBoardProvider({ boardPath }) {
988
2197
  return { present, boardPath: present ? boardPath : null };
989
2198
  }
990
2199
 
991
- export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {}) {
992
- // Tools register unconditionally; the board is resolved per call from the
993
- // calling session's working directory (Pi extensions have no ctx at
994
- // registration time). A workspace with no board file gets a structured
995
- // board-unavailable result per call, so the surface stays reversible (§5):
996
- // no board, nothing happens.
2200
+ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPath = null } = {}) {
2201
+ // Board resolution happens per tool call, not at registration: Pi
2202
+ // extensions receive only the ExtensionAPI at registration (ctx is
2203
+ // per-tool-call), so a static boardPath observed at startup is wrong for
2204
+ // multi-workspace sessions and undefined cwd breaks resolution entirely.
2205
+ // Registration is unconditional; the surface is gated per call — no board
2206
+ // for the calling workspace yields a structured board-unavailable result
2207
+ // (reversibility preserved: no board file, nothing happens).
2208
+ const boardPathFor = (ctx) => {
2209
+ if (typeof resolveBoardPath === "function") return resolveBoardPath(ctx);
2210
+ return typeof boardPath === "string" && boardPath !== "" ? boardPath : null;
2211
+ };
2212
+ const observation = observeBoardProvider({ boardPath: boardPathFor(undefined) ?? undefined });
997
2213
  const registered = [];
2214
+ const unavailableValue = (extra = {}) => ({
2215
+ ok: false,
2216
+ persisted: false,
2217
+ boardUnavailable: true,
2218
+ code: "board-unavailable",
2219
+ reason: "no board file found for this workspace (board-unavailable)",
2220
+ errors: ["no board file found for this workspace (board-unavailable)"],
2221
+ ...extra,
2222
+ });
2223
+ const unavailableResult = (extra = {}) => {
2224
+ const value = unavailableValue(extra);
2225
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2226
+ };
998
2227
  if (typeof pi?.registerTool === "function") {
999
2228
  pi.registerTool({
1000
2229
  name: "agentic_kanban_board",
@@ -1002,24 +2231,16 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1002
2231
  description: "Read-only view of the validated task board: lanes, flags, priorities, dependencies, and dispatchability. The board is additive and grants no authority; agents read it and act within card states.",
1003
2232
  parameters: { type: "object", additionalProperties: false, properties: {} },
1004
2233
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1005
- // The board is resolved per call from the calling session's working
1006
- // directory. No board in this workspace: structured board-unavailable,
1007
- // nothing happens.
1008
- const resolvedBoardPath = (typeof resolveBoardPath === "function" ? resolveBoardPath(ctx?.cwd) : null) ?? boardPath ?? null;
1009
- if (resolvedBoardPath === null || !existsSync(resolvedBoardPath)) {
1010
- const value = {
1011
- ok: false,
1012
- nonAuthorizing: true,
1013
- persisted: false,
1014
- boardUnavailable: true,
1015
- cards: [],
1016
- errors: ["no board.md or TASKS.md in this workspace (board-unavailable)"],
1017
- };
1018
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2234
+ // Per-call board resolution + F7(b) re-observation. No board for the
2235
+ // calling workspace, or the board removed after registration:
2236
+ // observed board-unavailable, never a stale board, never a throw.
2237
+ const activeBoardPath = boardPathFor(ctx);
2238
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
2239
+ return unavailableResult({ nonAuthorizing: true, cards: [] });
1019
2240
  }
1020
2241
  let value;
1021
2242
  try {
1022
- const markdown = readFileSync(resolvedBoardPath, "utf8");
2243
+ const markdown = readFileSync(activeBoardPath, "utf8");
1023
2244
  const validated = validateBoard(markdown);
1024
2245
  value = validated.ok
1025
2246
  ? { ok: true, nonAuthorizing: true, persisted: false, cards: validated.cards, errors: [] }
@@ -1076,27 +2297,16 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1076
2297
  },
1077
2298
  required: ["title", "specification", "definitionOfDone", "stoppingPoint", "scopePaths", "authority"],
1078
2299
  },
1079
- async execute(_toolContext, input, _signal, _onUpdate, ctx) {
1080
- // Resolve the board per call from the calling session's working
1081
- // directory. No board here: structured board-unavailable, no write.
1082
- let resolvedBoardPath = (typeof resolveBoardPath === "function" ? resolveBoardPath(ctx?.cwd) : null) ?? boardPath ?? null;
1083
- // The write tool bootstraps: a MISSING board file is fine (the writer
1084
- // creates it fresh under the lock). When the resolver finds no board,
1085
- // fall back to the canonical board.md in the workspace so the writer
1086
- // can create it.
1087
- if (resolvedBoardPath === null && typeof ctx?.cwd === "string" && ctx.cwd !== "") {
1088
- resolvedBoardPath = join(ctx.cwd, "TASKS.md");
1089
- }
1090
- if (resolvedBoardPath === null) {
1091
- const value = {
1092
- ok: false,
1093
- persisted: false,
1094
- boardUnavailable: true,
1095
- code: "board-unavailable",
1096
- reason: "no board.md or TASKS.md in this workspace (board-unavailable)",
1097
- errors: ["no board.md or TASKS.md in this workspace (board-unavailable)"],
1098
- };
1099
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2300
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
2301
+ // Per-call board resolution + F7(b) re-observation. No board for the
2302
+ // calling workspace, or the board removed after registration:
2303
+ // structured board-unavailable instead of writing to a stale path.
2304
+ const activeBoardPath = boardPathFor(ctx);
2305
+ // The write tool bootstraps: a missing board file is fine (the writer
2306
+ // creates it fresh under the lock). Only an unresolvable workspace is
2307
+ // refused here.
2308
+ if (!activeBoardPath) {
2309
+ return unavailableResult();
1100
2310
  }
1101
2311
  // The writer allocates the cardId and computes all hashes; the tool
1102
2312
  // forwards only content and the authority record. Input is normalized
@@ -1140,15 +2350,14 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1140
2350
  let result;
1141
2351
  try {
1142
2352
  result = writeCard({
1143
- boardPath: resolvedBoardPath,
2353
+ boardPath: activeBoardPath,
1144
2354
  input: writerInput,
1145
2355
  authority: input?.authority,
1146
2356
  registries: {},
1147
2357
  surface: "tasks",
1148
- // No requireExistingBoard here: the write tool bootstraps a
1149
- // fresh board when none exists (§3.4/§5). Recreation is safe —
1150
- // the writer builds fresh content only, and every card still
1151
- // requires a genuine authority record.
2358
+ // No requireExistingBoard: the write tool bootstraps a fresh
2359
+ // board when none exists. Recreation is safe — fresh content only,
2360
+ // and every card still requires a genuine authority record.
1152
2361
  });
1153
2362
  } catch (error) {
1154
2363
  const code = typeof error?.code === "string" ? error.code : "writer-error";
@@ -1190,5 +2399,191 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1190
2399
  });
1191
2400
  registered.push("agentic_kanban_board_write");
1192
2401
  }
1193
- return { registered };
2402
+ // The update/delete tool (§3.5): card updates and removal go through the
2403
+ // trusted writer only, with the same REQUIRED genuine authority record as
2404
+ // creation. operation "update" applies a changes subset to an existing
2405
+ // card (lane move, done, flags, field updates, dependency replacement);
2406
+ // operation "delete" removes the card (the issued-ID ledger keeps the ID
2407
+ // forever). Completion (done=true) is enforced by the writer: only an
2408
+ // instruction or an approved report proposal completes a card — an agent
2409
+ // report alone is never completion.
2410
+ if (typeof pi?.registerTool === "function") {
2411
+ pi.registerTool({
2412
+ name: "agentic_kanban_board_update",
2413
+ label: "Kanban Board Update",
2414
+ description:
2415
+ "Update or delete an existing task-board card through the trusted board writer. Governance: all writes go through the trusted, deterministic writer — never through model-authored Markdown. An authority record is REQUIRED and must be genuine: the user's actual instruction (or approved report proposal) quoted verbatim; never invent, paraphrase-as-quote, or fabricate one. Marking a card done requires human authority — an agent report alone is never completion. Do not supply hashes or identifiers other than the existing cardId.",
2416
+ parameters: {
2417
+ type: "object",
2418
+ additionalProperties: false,
2419
+ properties: {
2420
+ operation: { type: "string", enum: ["update", "delete"], description: "update (apply changes to a card) or delete (remove the card; its cardId is never reused)." },
2421
+ cardId: { type: "string", description: "The existing cardId to update or delete." },
2422
+ lane: { type: "string", enum: [...LANES], description: "update: move the card to this lane." },
2423
+ done: { type: "boolean", description: "update: mark done (true) or un-done (false). done=true sets the done checkbox and the done lane, and requires human authority." },
2424
+ flags: {
2425
+ type: "object",
2426
+ description: "update: add/remove flags, e.g. {add: ['blocked']} or {remove: ['blocked']}.",
2427
+ additionalProperties: false,
2428
+ properties: {
2429
+ add: { type: "array", items: { type: "string", enum: [...FLAGS] } },
2430
+ remove: { type: "array", items: { type: "string", enum: [...FLAGS] } },
2431
+ },
2432
+ },
2433
+ title: { type: "string", description: "update: new title." },
2434
+ description: { type: "string", description: "update: new description." },
2435
+ priority: { type: "string", enum: [...PRIORITIES], description: "update: new priority (P0-P3)." },
2436
+ specification: { type: "string", description: "update: new specification text (hash recomputed by the writer)." },
2437
+ definitionOfDone: { type: "string", description: "update: new definition-of-done text (hash recomputed by the writer)." },
2438
+ stoppingPoint: { type: "string", description: "update: new stopping point." },
2439
+ scopePaths: { type: "array", items: { type: "string" }, description: "update: full replacement scope-path list." },
2440
+ capabilities: { type: "array", items: { type: "string" }, description: "update: full replacement capability list." },
2441
+ dependencies: { type: "array", items: { type: "string" }, description: "update: full replacement ordered blocked-by cardId list (add/remove by supplying the new complete list)." },
2442
+ tags: { type: "array", items: { type: "string" }, description: "update: full replacement tag list." },
2443
+ base: { type: "string", description: "update: exact base revision (full 40-hex Git commit SHA)." },
2444
+ dueDate: { type: "string", description: "update: due date, ISO yyyy-mm-dd." },
2445
+ role: { type: "string", description: "update: assigned role label." },
2446
+ authority: {
2447
+ type: "object",
2448
+ description: "REQUIRED authority record (§3.1/§3.5): { source: 'instruction' | 'report-proposal', sessionOrReportId, quotedInstruction }. Quote the user's actual instruction verbatim; never invent one.",
2449
+ additionalProperties: false,
2450
+ properties: {
2451
+ source: { type: "string", enum: ["instruction", "report-proposal"] },
2452
+ sessionOrReportId: { type: "string" },
2453
+ quotedInstruction: { type: "string", description: "The user's actual words. Required; a digest alone is not accepted through this tool." },
2454
+ },
2455
+ required: ["source", "sessionOrReportId", "quotedInstruction"],
2456
+ },
2457
+ },
2458
+ required: ["operation", "cardId", "authority"],
2459
+ },
2460
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
2461
+ const activeBoardPath = boardPathFor(ctx);
2462
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
2463
+ return unavailableResult();
2464
+ }
2465
+ const operation = input?.operation;
2466
+ if (operation !== "update" && operation !== "delete") {
2467
+ const value = { ok: false, persisted: false, code: "invalid-input", reason: "operation must be \"update\" or \"delete\"", errors: ["operation must be \"update\" or \"delete\""] };
2468
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2469
+ }
2470
+ if (typeof input?.cardId !== "string" || input.cardId === "") {
2471
+ const value = { ok: false, persisted: false, code: "invalid-input", reason: "cardId is required", errors: ["cardId is required"] };
2472
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2473
+ }
2474
+ // Map the flat tool input onto the writer's changes subset. Absent
2475
+ // fields are left untouched; list fields are full replacement lists.
2476
+ const changes = {};
2477
+ for (const key of ["lane", "done", "flags", "title", "description", "priority", "specification", "definitionOfDone", "stoppingPoint", "scopePaths", "capabilities", "dependencies", "tags", "base", "dueDate", "role"]) {
2478
+ if (input?.[key] !== undefined) changes[key] = input[key];
2479
+ }
2480
+ let result;
2481
+ try {
2482
+ result = operation === "update"
2483
+ ? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries: {}, surface: "tasks" })
2484
+ : deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries: {}, surface: "tasks" });
2485
+ } catch (error) {
2486
+ const code = typeof error?.code === "string" ? error.code : "writer-error";
2487
+ const value = { ok: false, persisted: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };
2488
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2489
+ }
2490
+ let value;
2491
+ if (result.ok) {
2492
+ value = operation === "update"
2493
+ ? {
2494
+ ok: true,
2495
+ persisted: true,
2496
+ operation,
2497
+ cardId: result.card.cardId,
2498
+ lane: result.card.lane,
2499
+ done: Boolean(result.card.done),
2500
+ flags: [...(result.card.flags ?? [])],
2501
+ changedFields: result.changedFields,
2502
+ hashPresent: Boolean(result.card.hash),
2503
+ authorityWriterHmacPresent: Boolean(result.card.authorityWriterHmac),
2504
+ authoritySource: { ...result.card.authoritySource },
2505
+ projection: result.projection,
2506
+ }
2507
+ : {
2508
+ ok: true,
2509
+ persisted: true,
2510
+ operation,
2511
+ cardId: result.cardId,
2512
+ removed: true,
2513
+ projection: result.projection,
2514
+ };
2515
+ } else {
2516
+ value = {
2517
+ ok: false,
2518
+ persisted: false,
2519
+ code: result.code,
2520
+ reason: (result.reason ?? (result.errors ?? []).join("; ")).slice(0, 512),
2521
+ errors: result.errors ?? [],
2522
+ ...(result.cardId ? { cardId: result.cardId } : {}),
2523
+ };
2524
+ }
2525
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
2526
+ },
2527
+ });
2528
+ registered.push("agentic_kanban_board_update");
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
+ }
2588
+ return { registered, observation: { ...observation, boardPath: observation.boardPath } };
1194
2589
  }