@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.
- package/README.md +50 -27
- package/extensions/task-board.ts +10 -3
- package/package.json +1 -1
- package/scripts/enforcement/herdr_async_dispatch_pi.js +90 -18
- package/scripts/enforcement/task_board_core_pi.js +1446 -51
|
@@ -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
|
-
|
|
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
|
-
|
|
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, {
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
// no board
|
|
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
|
-
//
|
|
1006
|
-
//
|
|
1007
|
-
//
|
|
1008
|
-
const
|
|
1009
|
-
if (
|
|
1010
|
-
|
|
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(
|
|
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(
|
|
1080
|
-
//
|
|
1081
|
-
//
|
|
1082
|
-
|
|
1083
|
-
|
|
1084
|
-
//
|
|
1085
|
-
//
|
|
1086
|
-
//
|
|
1087
|
-
if (
|
|
1088
|
-
|
|
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:
|
|
2353
|
+
boardPath: activeBoardPath,
|
|
1144
2354
|
input: writerInput,
|
|
1145
2355
|
authority: input?.authority,
|
|
1146
2356
|
registries: {},
|
|
1147
2357
|
surface: "tasks",
|
|
1148
|
-
// No requireExistingBoard
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
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
|
-
|
|
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
|
}
|