@evoclock/pi-agentic-driver 0.8.3 → 0.8.4

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.
@@ -9,18 +9,25 @@
9
9
  import { existsSync } from "node:fs";
10
10
  import { join } from "node:path";
11
11
 
12
- const BOARD_FILENAMES = ["board.md", "TASKS.md"];
12
+ // The canonical file comes first: the projection (board.md) is a derived
13
+ // view and must never be the board the tools operate on.
14
+ const BOARD_FILENAMES = ["TASKS.md", "board.md"];
13
15
 
16
+ // Returns the board path for the workspace: an existing board file if one is
17
+ // present, otherwise the canonical TASKS.md candidate (the write tool
18
+ // bootstraps a fresh board there). Null only when the workspace is unknown.
14
19
  export function resolveBoardPath(cwd) {
15
20
  if (typeof cwd !== "string" || cwd === "") return null;
16
21
  for (const name of BOARD_FILENAMES) {
17
22
  const candidate = join(cwd, name);
18
23
  if (existsSync(candidate)) return candidate;
19
24
  }
20
- return null;
25
+ return join(cwd, "TASKS.md");
21
26
  }
22
27
 
23
28
  export default async function taskBoardPi(pi) {
24
29
  const module = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
25
- return module.registerKanbanBoardTools(pi, { resolveBoardPath });
30
+ return module.registerKanbanBoardTools(pi, {
31
+ resolveBoardPath: (ctx) => resolveBoardPath(typeof ctx === "string" ? ctx : ctx?.cwd || process.cwd()),
32
+ });
26
33
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.8.3",
3
+ "version": "0.8.4",
4
4
  "description": "Guardrail extensions for Agentic Driver: advisory review, bounded Herdr communication, and guarded worker lifecycle.",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",
@@ -248,7 +248,11 @@ function parseCardLine(line, surface, checkboxState = null) {
248
248
 
249
249
  function laneFromHeading(heading) {
250
250
  const name = heading.replace(/^##\s*/, "").trim().toLowerCase();
251
- return LANES.includes(name) ? name : null;
251
+ if (LANES.includes(name)) return name;
252
+ // Obsidian display lane names (the derived projection's headings) map onto
253
+ // the canonical closed lanes — one semantic model, two surfaces (§1).
254
+ const display = { backlog: "backlog", "in progress": "in-progress", review: "review", done: "done" };
255
+ return display[name] ?? null;
252
256
  }
253
257
 
254
258
  export function parseBoard(markdown, { surface = "auto" } = {}) {
@@ -553,6 +557,49 @@ export function serializeBoard(cards, { surface }) {
553
557
  // supply identifiers or hashes.
554
558
  // ---------------------------------------------------------------------------
555
559
 
560
+ // ---------------------------------------------------------------------------
561
+ // The Obsidian projection (§2 derived projection). After every successful
562
+ // write/update/delete to the canonical TASKS.md board, a sibling projection
563
+ // file is RECOMPUTED from the canonical Markdown — never incrementally
564
+ // patched, never read back as authority. The read tool (agentic_kanban_board)
565
+ // reads only the canonical board file; if the canonical board is deleted the
566
+ // projection is stale-by-design and is ignored by every reader. The
567
+ // projection exists purely so the Obsidian Kanban plugin can render the same
568
+ // semantic model (§1: one semantic model, two surfaces).
569
+ //
570
+ // Projection path: a sibling "board.md" next to the canonical board. When the
571
+ // canonical board is itself named board.md (test/dev setups), the projection
572
+ // is "board.projection.md" so the canonical file is never overwritten by its
573
+ // own view.
574
+ // ---------------------------------------------------------------------------
575
+
576
+ export function projectionPath(boardPath) {
577
+ const file = boardPath.split("/").pop();
578
+ const name = file === "board.md" ? "board.projection.md" : "board.md";
579
+ return join(dirname(boardPath), name);
580
+ }
581
+
582
+ // Recompute the projection from the canonical cards. Best-effort relative to
583
+ // the authoritative write: a projection failure is reported but never rolls
584
+ // back or invalidates the canonical persist.
585
+ export function writeProjection(boardPath, cards) {
586
+ const path = projectionPath(boardPath);
587
+ try {
588
+ const frontmatter = "---\nkanban-plugin: board\n---\n\n";
589
+ const body = serializeBoard(cards, { surface: "obsidian" })
590
+ .replace(/^## backlog$/m, "## Backlog")
591
+ .replace(/^## in-progress$/m, "## In Progress")
592
+ .replace(/^## review$/m, "## Review")
593
+ .replace(/^## done$/m, "## Done");
594
+ const tmpPath = `${path}.tmp-${process.pid}-${Date.now()}`;
595
+ writeFileSync(tmpPath, frontmatter + body, "utf8");
596
+ renameSync(tmpPath, path);
597
+ return { written: true, path, error: null };
598
+ } catch (error) {
599
+ return { written: false, path, error: String(error?.message || error).slice(0, 512) };
600
+ }
601
+ }
602
+
556
603
  export function allocateCardId(cards, { prefix = "T" } = {}) {
557
604
  let max = 0;
558
605
  for (const card of cards) {
@@ -893,7 +940,237 @@ function writeCardLocked({ boardPath, input, authority, registries, surface, now
893
940
  state.highWaterMark = nextNumber;
894
941
  if (!state.issuedCardIds.includes(cardId)) state.issuedCardIds.push(cardId);
895
942
  writeWriterState(statePath, state);
896
- return { ok: true, cardId, card: Object.freeze({ ...card }), persisted: true, ...(now ? { now } : {}) };
943
+ // §2 derived projection: recomputed from the just-persisted canonical board
944
+ // on every mutation; a view only, never authority, stale-by-design if the
945
+ // canonical board is removed.
946
+ const projection = writeProjection(boardPath, [...existingCards, card]);
947
+ return { ok: true, cardId, card: Object.freeze({ ...card }), persisted: true, projection, ...(now ? { now } : {}) };
948
+ }
949
+
950
+ // ---------------------------------------------------------------------------
951
+ // Card update and delete (§3.5): every board operation a user could express
952
+ // goes through the trusted writer with a REQUIRED authority record, recorded
953
+ // with the same HMAC + provenance discipline as creation. The card hash is
954
+ // recomputed after any change; spec/DoD text updates recompute their hashes;
955
+ // completion (done=true) requires human authority — an instruction or an
956
+ // approved proposal — never an agent report alone.
957
+ // ---------------------------------------------------------------------------
958
+
959
+ const UPDATABLE_LIST_FIELDS = [
960
+ ["scopePaths", "scope"],
961
+ ["capabilities", "capabilities"],
962
+ ["dependencies", "dependencies"],
963
+ ["tags", "tags"],
964
+ ];
965
+
966
+ // Apply a changes subset to a parsed card. Returns a plain updated card (hash
967
+ // not yet recomputed) or an error descriptor.
968
+ function applyCardChanges(card, changes) {
969
+ const updated = { ...card, flags: [...(card.flags ?? [])] };
970
+ const changed = [];
971
+ const c = changes ?? {};
972
+
973
+ if (c.lane !== undefined) {
974
+ if (!LANES.includes(c.lane)) {
975
+ return { error: { code: "invalid-lane", errors: [`lane "${c.lane}" is not one of ${LANES.join(", ")}`] } };
976
+ }
977
+ updated.lane = c.lane;
978
+ changed.push("lane");
979
+ }
980
+ if (c.done !== undefined) {
981
+ updated.done = Boolean(c.done);
982
+ // Moving to done sets the done checkbox AND the lane (§1 lifecycle).
983
+ if (c.done) updated.lane = "done";
984
+ changed.push("done");
985
+ }
986
+ if (c.flags !== undefined) {
987
+ // Accept {add: [], remove: []} or a full replacement array.
988
+ if (Array.isArray(c.flags)) {
989
+ updated.flags = [...c.flags];
990
+ } else if (c.flags && typeof c.flags === "object") {
991
+ const set = new Set(updated.flags);
992
+ for (const flag of c.flags.add ?? []) set.add(flag);
993
+ for (const flag of c.flags.remove ?? []) set.delete(flag);
994
+ updated.flags = [...set];
995
+ } else {
996
+ return { error: { code: "invalid-flags", errors: ["flags must be an array or {add, remove}"] } };
997
+ }
998
+ changed.push("flags");
999
+ }
1000
+ if (c.title !== undefined) {
1001
+ if (typeof c.title !== "string" || c.title.trim() === "") {
1002
+ return { error: { code: "invalid-title", errors: ["title must be a non-empty string"] } };
1003
+ }
1004
+ updated.title = sanitizeFreeText(c.title);
1005
+ changed.push("title");
1006
+ }
1007
+ if (c.description !== undefined) {
1008
+ updated.description = sanitizeFreeText(c.description);
1009
+ changed.push("description");
1010
+ }
1011
+ if (c.priority !== undefined) {
1012
+ if (c.priority !== null && !PRIORITIES.includes(c.priority)) {
1013
+ return { error: { code: "invalid-priority", errors: [`priority "${c.priority}" is not one of ${PRIORITIES.join(", ")}`] } };
1014
+ }
1015
+ updated.priority = c.priority;
1016
+ changed.push("priority");
1017
+ }
1018
+ for (const [textField, hashField] of [["specification", "specHash", "specText"], ["definitionOfDone", "dodHash", "dodText"]]) {
1019
+ if (c[textField] !== undefined) {
1020
+ const text = c[textField] === null ? null : nfc(String(c[textField]));
1021
+ if (text !== null && text.trim() === "") {
1022
+ return { error: { code: textField === "specification" ? "empty-specification" : "empty-definition-of-done", errors: [`${textField} text must be non-empty`] } };
1023
+ }
1024
+ updated[textField === "specification" ? "specText" : "dodText"] = text;
1025
+ updated[hashField] = text !== null ? computeSpecHash(text) : null;
1026
+ changed.push(textField);
1027
+ }
1028
+ }
1029
+ if (c.stoppingPoint !== undefined) {
1030
+ updated.stoppingPoint = c.stoppingPoint === null ? null : sanitizeFreeText(c.stoppingPoint);
1031
+ changed.push("stoppingPoint");
1032
+ }
1033
+ for (const [inputKey, cardKey] of UPDATABLE_LIST_FIELDS) {
1034
+ if (c[inputKey] !== undefined) {
1035
+ if (!Array.isArray(c[inputKey])) {
1036
+ return { error: { code: `invalid-${inputKey}`, errors: [`${inputKey} must be an array (full replacement list)`] } };
1037
+ }
1038
+ updated[cardKey] = [...c[inputKey]];
1039
+ changed.push(inputKey);
1040
+ }
1041
+ }
1042
+ if (c.base !== undefined) {
1043
+ updated.base = c.base;
1044
+ changed.push("base");
1045
+ }
1046
+ if (c.dueDate !== undefined) {
1047
+ updated.due = c.dueDate;
1048
+ changed.push("dueDate");
1049
+ }
1050
+ if (c.role !== undefined) {
1051
+ updated.role = c.role;
1052
+ changed.push("role");
1053
+ }
1054
+ return { updated, changed };
1055
+ }
1056
+
1057
+ // Update an existing card through the trusted writer. `changes` is any subset
1058
+ // of: lane, done, flags, title, description, priority, specification,
1059
+ // definitionOfDone, stoppingPoint, scopePaths, capabilities, dependencies
1060
+ // (full replacement list), base, dueDate, role, tags. The authority record is
1061
+ // REQUIRED and re-recorded (HMAC bound to the recomputed card hash).
1062
+ export function updateCard({ boardPath, cardId, changes, authority, registries = {}, surface = "tasks", now = null }) {
1063
+ if (typeof boardPath !== "string" || boardPath === "") {
1064
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
1065
+ }
1066
+ if (typeof cardId !== "string" || cardId === "") {
1067
+ throw Object.assign(new Error("cardId is required"), { code: "card-id-required" });
1068
+ }
1069
+ return withWriterLock(boardPath, () => updateCardLocked({ boardPath, cardId, changes, authority, registries, surface, now }));
1070
+ }
1071
+
1072
+ function updateCardLocked({ boardPath, cardId, changes, authority, registries, surface, now }) {
1073
+ if (!existsSync(boardPath)) {
1074
+ 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 };
1075
+ }
1076
+ const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), registries);
1077
+ if (!validatedBoard.ok) {
1078
+ return { ok: false, code: "board-invalid", errors: validatedBoard.errors, persisted: false };
1079
+ }
1080
+ const index = validatedBoard.cards.findIndex((card) => card.cardId === cardId);
1081
+ if (index === -1) {
1082
+ return { ok: false, code: "card-not-found", errors: [`cardId "${cardId}" does not exist on the board`], persisted: false };
1083
+ }
1084
+ // Completion is human-only (§3.1): marking a card done requires the
1085
+ // authority source to be an instruction or an approved report proposal.
1086
+ // An agent report alone is never completion.
1087
+ if (changes?.done === true) {
1088
+ const source = authority?.source;
1089
+ if (source !== "instruction" && source !== "report-proposal") {
1090
+ return {
1091
+ ok: false,
1092
+ code: "completion-authority-required",
1093
+ errors: ["marking a card done requires human authority: an instruction or an approved report proposal; an agent report alone is never completion"],
1094
+ persisted: false,
1095
+ cardId,
1096
+ };
1097
+ }
1098
+ }
1099
+ // The authority record is REQUIRED for every mutation and is validated
1100
+ // exactly as at creation (recordAuthoritySource throws on malformation).
1101
+ const record = recordAuthoritySource(authority);
1102
+ const { updated, changed, error } = applyCardChanges(validatedBoard.cards[index], changes);
1103
+ if (error) return { ok: false, code: error.code, errors: error.errors, persisted: false, cardId };
1104
+ if (changed.length === 0) {
1105
+ return { ok: false, code: "no-changes", errors: ["changes must contain at least one updatable field"], persisted: false, cardId };
1106
+ }
1107
+ const validation = validateCard(updated, registries);
1108
+ if (!validation.ok) {
1109
+ return { ok: false, code: "validation-failed", errors: validation.errors, persisted: false, cardId };
1110
+ }
1111
+ // The card hash is recomputed after ANY change (§1 hash binding).
1112
+ updated.hash = computeCardHash(updated);
1113
+ // F1: the re-recorded authority HMAC binds to the NEW card hash.
1114
+ updated.authoritySource = record;
1115
+ const statePath = writerStatePath(boardPath);
1116
+ const state = readWriterState(statePath);
1117
+ if (state.secret === null) {
1118
+ return { ok: false, code: "writer-state-unavailable", errors: ["writer state file has no secret (fails closed)"], persisted: false, cardId };
1119
+ }
1120
+ updated.authorityWriterHmac = authorityRecordHmac(record, state.secret, updated.hash);
1121
+ const cards = validatedBoard.cards.map((card, i) => (i === index ? updated : card));
1122
+ const serialized = serializeBoard(cards, { surface });
1123
+ const roundTrip = validateBoard(serialized, registries);
1124
+ if (!roundTrip.ok) {
1125
+ return { ok: false, code: "serialization-invalid", errors: roundTrip.errors, persisted: false, cardId };
1126
+ }
1127
+ const tmpPath = `${boardPath}.tmp-${process.pid}-${Date.now()}`;
1128
+ writeFileSync(tmpPath, serialized, "utf8");
1129
+ renameSync(tmpPath, boardPath);
1130
+ // Derived projection recomputed from the canonical board after the mutation.
1131
+ const projection = writeProjection(boardPath, cards);
1132
+ return { ok: true, cardId, card: Object.freeze({ ...updated }), changedFields: changed, persisted: true, projection, ...(now ? { now } : {}) };
1133
+ }
1134
+
1135
+ // Remove a card from the board through the trusted writer. The issued-ID
1136
+ // ledger KEEPS the ID forever — a deleted cardId is never reused. Deletion is
1137
+ // a consequential action and requires the same REQUIRED authority record.
1138
+ export function deleteCard({ boardPath, cardId, authority, registries = {}, surface = "tasks", now = null }) {
1139
+ if (typeof boardPath !== "string" || boardPath === "") {
1140
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
1141
+ }
1142
+ if (typeof cardId !== "string" || cardId === "") {
1143
+ throw Object.assign(new Error("cardId is required"), { code: "card-id-required" });
1144
+ }
1145
+ return withWriterLock(boardPath, () => deleteCardLocked({ boardPath, cardId, authority, registries, surface, now }));
1146
+ }
1147
+
1148
+ function deleteCardLocked({ boardPath, cardId, authority, registries, surface, now }) {
1149
+ if (!existsSync(boardPath)) {
1150
+ 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 };
1151
+ }
1152
+ const validatedBoard = validateBoard(readFileSync(boardPath, "utf8"), registries);
1153
+ if (!validatedBoard.ok) {
1154
+ return { ok: false, code: "board-invalid", errors: validatedBoard.errors, persisted: false };
1155
+ }
1156
+ if (!validatedBoard.cards.some((card) => card.cardId === cardId)) {
1157
+ return { ok: false, code: "card-not-found", errors: [`cardId "${cardId}" does not exist on the board`], persisted: false };
1158
+ }
1159
+ // Authority is REQUIRED for deletion too; validate exactly as at creation.
1160
+ recordAuthoritySource(authority);
1161
+ const cards = validatedBoard.cards.filter((card) => card.cardId !== cardId);
1162
+ const serialized = cards.length > 0 ? serializeBoard(cards, { surface }) : "";
1163
+ const roundTrip = validateBoard(serialized, registries);
1164
+ if (!roundTrip.ok) {
1165
+ // A dangling dependency on the deleted card fails closed.
1166
+ return { ok: false, code: "dependency-referenced", errors: roundTrip.errors, persisted: false, cardId };
1167
+ }
1168
+ const tmpPath = `${boardPath}.tmp-${process.pid}-${Date.now()}`;
1169
+ writeFileSync(tmpPath, serialized, "utf8");
1170
+ renameSync(tmpPath, boardPath);
1171
+ // The issued-ID ledger keeps the deleted ID forever — never reused.
1172
+ const projection = writeProjection(boardPath, cards);
1173
+ return { ok: true, cardId, removed: true, persisted: true, projection, ...(now ? { now } : {}) };
897
1174
  }
898
1175
 
899
1176
  // ---------------------------------------------------------------------------
@@ -988,13 +1265,33 @@ export function observeBoardProvider({ boardPath }) {
988
1265
  return { present, boardPath: present ? boardPath : null };
989
1266
  }
990
1267
 
991
- export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {}) {
992
- // Tools register unconditionally; the board is resolved per call from the
993
- // calling session's working directory (Pi extensions have no ctx at
994
- // registration time). A workspace with no board file gets a structured
995
- // board-unavailable result per call, so the surface stays reversible (§5):
996
- // no board, nothing happens.
1268
+ export function registerKanbanBoardTools(pi, { boardPath = null, resolveBoardPath = null } = {}) {
1269
+ // Board resolution happens per tool call, not at registration: Pi
1270
+ // extensions receive only the ExtensionAPI at registration (ctx is
1271
+ // per-tool-call), so a static boardPath observed at startup is wrong for
1272
+ // multi-workspace sessions and undefined cwd breaks resolution entirely.
1273
+ // Registration is unconditional; the surface is gated per call — no board
1274
+ // for the calling workspace yields a structured board-unavailable result
1275
+ // (reversibility preserved: no board file, nothing happens).
1276
+ const boardPathFor = (ctx) => {
1277
+ if (typeof resolveBoardPath === "function") return resolveBoardPath(ctx);
1278
+ return typeof boardPath === "string" && boardPath !== "" ? boardPath : null;
1279
+ };
1280
+ const observation = observeBoardProvider({ boardPath: boardPathFor(undefined) ?? undefined });
997
1281
  const registered = [];
1282
+ const unavailableValue = (extra = {}) => ({
1283
+ ok: false,
1284
+ persisted: false,
1285
+ boardUnavailable: true,
1286
+ code: "board-unavailable",
1287
+ reason: "no board file found for this workspace (board-unavailable)",
1288
+ errors: ["no board file found for this workspace (board-unavailable)"],
1289
+ ...extra,
1290
+ });
1291
+ const unavailableResult = (extra = {}) => {
1292
+ const value = unavailableValue(extra);
1293
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1294
+ };
998
1295
  if (typeof pi?.registerTool === "function") {
999
1296
  pi.registerTool({
1000
1297
  name: "agentic_kanban_board",
@@ -1002,24 +1299,16 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1002
1299
  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
1300
  parameters: { type: "object", additionalProperties: false, properties: {} },
1004
1301
  async execute(_toolCallId, _params, _signal, _onUpdate, ctx) {
1005
- // The board is resolved per call from the calling session's working
1006
- // directory. No board in this workspace: structured board-unavailable,
1007
- // nothing happens.
1008
- const resolvedBoardPath = (typeof resolveBoardPath === "function" ? resolveBoardPath(ctx?.cwd) : null) ?? boardPath ?? null;
1009
- if (resolvedBoardPath === null || !existsSync(resolvedBoardPath)) {
1010
- const value = {
1011
- ok: false,
1012
- nonAuthorizing: true,
1013
- persisted: false,
1014
- boardUnavailable: true,
1015
- cards: [],
1016
- errors: ["no board.md or TASKS.md in this workspace (board-unavailable)"],
1017
- };
1018
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1302
+ // Per-call board resolution + F7(b) re-observation. No board for the
1303
+ // calling workspace, or the board removed after registration:
1304
+ // observed board-unavailable, never a stale board, never a throw.
1305
+ const activeBoardPath = boardPathFor(ctx);
1306
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
1307
+ return unavailableResult({ nonAuthorizing: true, cards: [] });
1019
1308
  }
1020
1309
  let value;
1021
1310
  try {
1022
- const markdown = readFileSync(resolvedBoardPath, "utf8");
1311
+ const markdown = readFileSync(activeBoardPath, "utf8");
1023
1312
  const validated = validateBoard(markdown);
1024
1313
  value = validated.ok
1025
1314
  ? { ok: true, nonAuthorizing: true, persisted: false, cards: validated.cards, errors: [] }
@@ -1076,27 +1365,16 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1076
1365
  },
1077
1366
  required: ["title", "specification", "definitionOfDone", "stoppingPoint", "scopePaths", "authority"],
1078
1367
  },
1079
- async execute(_toolContext, input, _signal, _onUpdate, ctx) {
1080
- // Resolve the board per call from the calling session's working
1081
- // directory. No board here: structured board-unavailable, no write.
1082
- let resolvedBoardPath = (typeof resolveBoardPath === "function" ? resolveBoardPath(ctx?.cwd) : null) ?? boardPath ?? null;
1083
- // The write tool bootstraps: a MISSING board file is fine (the writer
1084
- // creates it fresh under the lock). When the resolver finds no board,
1085
- // fall back to the canonical board.md in the workspace so the writer
1086
- // can create it.
1087
- if (resolvedBoardPath === null && typeof ctx?.cwd === "string" && ctx.cwd !== "") {
1088
- resolvedBoardPath = join(ctx.cwd, "TASKS.md");
1089
- }
1090
- if (resolvedBoardPath === null) {
1091
- const value = {
1092
- ok: false,
1093
- persisted: false,
1094
- boardUnavailable: true,
1095
- code: "board-unavailable",
1096
- reason: "no board.md or TASKS.md in this workspace (board-unavailable)",
1097
- errors: ["no board.md or TASKS.md in this workspace (board-unavailable)"],
1098
- };
1099
- return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1368
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
1369
+ // Per-call board resolution + F7(b) re-observation. No board for the
1370
+ // calling workspace, or the board removed after registration:
1371
+ // structured board-unavailable instead of writing to a stale path.
1372
+ const activeBoardPath = boardPathFor(ctx);
1373
+ // The write tool bootstraps: a missing board file is fine (the writer
1374
+ // creates it fresh under the lock). Only an unresolvable workspace is
1375
+ // refused here.
1376
+ if (!activeBoardPath) {
1377
+ return unavailableResult();
1100
1378
  }
1101
1379
  // The writer allocates the cardId and computes all hashes; the tool
1102
1380
  // forwards only content and the authority record. Input is normalized
@@ -1140,15 +1418,14 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1140
1418
  let result;
1141
1419
  try {
1142
1420
  result = writeCard({
1143
- boardPath: resolvedBoardPath,
1421
+ boardPath: activeBoardPath,
1144
1422
  input: writerInput,
1145
1423
  authority: input?.authority,
1146
1424
  registries: {},
1147
1425
  surface: "tasks",
1148
- // No requireExistingBoard here: the write tool bootstraps a
1149
- // fresh board when none exists (§3.4/§5). Recreation is safe —
1150
- // the writer builds fresh content only, and every card still
1151
- // requires a genuine authority record.
1426
+ // No requireExistingBoard: the write tool bootstraps a fresh
1427
+ // board when none exists. Recreation is safe — fresh content only,
1428
+ // and every card still requires a genuine authority record.
1152
1429
  });
1153
1430
  } catch (error) {
1154
1431
  const code = typeof error?.code === "string" ? error.code : "writer-error";
@@ -1190,5 +1467,133 @@ export function registerKanbanBoardTools(pi, { resolveBoardPath, boardPath } = {
1190
1467
  });
1191
1468
  registered.push("agentic_kanban_board_write");
1192
1469
  }
1193
- return { registered };
1470
+ // The update/delete tool (§3.5): card updates and removal go through the
1471
+ // trusted writer only, with the same REQUIRED genuine authority record as
1472
+ // creation. operation "update" applies a changes subset to an existing
1473
+ // card (lane move, done, flags, field updates, dependency replacement);
1474
+ // operation "delete" removes the card (the issued-ID ledger keeps the ID
1475
+ // forever). Completion (done=true) is enforced by the writer: only an
1476
+ // instruction or an approved report proposal completes a card — an agent
1477
+ // report alone is never completion.
1478
+ if (typeof pi?.registerTool === "function") {
1479
+ pi.registerTool({
1480
+ name: "agentic_kanban_board_update",
1481
+ label: "Kanban Board Update",
1482
+ description:
1483
+ "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.",
1484
+ parameters: {
1485
+ type: "object",
1486
+ additionalProperties: false,
1487
+ properties: {
1488
+ operation: { type: "string", enum: ["update", "delete"], description: "update (apply changes to a card) or delete (remove the card; its cardId is never reused)." },
1489
+ cardId: { type: "string", description: "The existing cardId to update or delete." },
1490
+ lane: { type: "string", enum: [...LANES], description: "update: move the card to this lane." },
1491
+ 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." },
1492
+ flags: {
1493
+ type: "object",
1494
+ description: "update: add/remove flags, e.g. {add: ['blocked']} or {remove: ['blocked']}.",
1495
+ additionalProperties: false,
1496
+ properties: {
1497
+ add: { type: "array", items: { type: "string", enum: [...FLAGS] } },
1498
+ remove: { type: "array", items: { type: "string", enum: [...FLAGS] } },
1499
+ },
1500
+ },
1501
+ title: { type: "string", description: "update: new title." },
1502
+ description: { type: "string", description: "update: new description." },
1503
+ priority: { type: "string", enum: [...PRIORITIES], description: "update: new priority (P0-P3)." },
1504
+ specification: { type: "string", description: "update: new specification text (hash recomputed by the writer)." },
1505
+ definitionOfDone: { type: "string", description: "update: new definition-of-done text (hash recomputed by the writer)." },
1506
+ stoppingPoint: { type: "string", description: "update: new stopping point." },
1507
+ scopePaths: { type: "array", items: { type: "string" }, description: "update: full replacement scope-path list." },
1508
+ capabilities: { type: "array", items: { type: "string" }, description: "update: full replacement capability list." },
1509
+ dependencies: { type: "array", items: { type: "string" }, description: "update: full replacement ordered blocked-by cardId list (add/remove by supplying the new complete list)." },
1510
+ tags: { type: "array", items: { type: "string" }, description: "update: full replacement tag list." },
1511
+ base: { type: "string", description: "update: exact base revision (full 40-hex Git commit SHA)." },
1512
+ dueDate: { type: "string", description: "update: due date, ISO yyyy-mm-dd." },
1513
+ role: { type: "string", description: "update: assigned role label." },
1514
+ authority: {
1515
+ type: "object",
1516
+ description: "REQUIRED authority record (§3.1/§3.5): { source: 'instruction' | 'report-proposal', sessionOrReportId, quotedInstruction }. Quote the user's actual instruction verbatim; never invent one.",
1517
+ additionalProperties: false,
1518
+ properties: {
1519
+ source: { type: "string", enum: ["instruction", "report-proposal"] },
1520
+ sessionOrReportId: { type: "string" },
1521
+ quotedInstruction: { type: "string", description: "The user's actual words. Required; a digest alone is not accepted through this tool." },
1522
+ },
1523
+ required: ["source", "sessionOrReportId", "quotedInstruction"],
1524
+ },
1525
+ },
1526
+ required: ["operation", "cardId", "authority"],
1527
+ },
1528
+ async execute(_toolCallId, input, _signal, _onUpdate, ctx) {
1529
+ const activeBoardPath = boardPathFor(ctx);
1530
+ if (!activeBoardPath || !existsSync(activeBoardPath)) {
1531
+ return unavailableResult();
1532
+ }
1533
+ const operation = input?.operation;
1534
+ if (operation !== "update" && operation !== "delete") {
1535
+ const value = { ok: false, persisted: false, code: "invalid-input", reason: "operation must be \"update\" or \"delete\"", errors: ["operation must be \"update\" or \"delete\""] };
1536
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1537
+ }
1538
+ if (typeof input?.cardId !== "string" || input.cardId === "") {
1539
+ const value = { ok: false, persisted: false, code: "invalid-input", reason: "cardId is required", errors: ["cardId is required"] };
1540
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1541
+ }
1542
+ // Map the flat tool input onto the writer's changes subset. Absent
1543
+ // fields are left untouched; list fields are full replacement lists.
1544
+ const changes = {};
1545
+ for (const key of ["lane", "done", "flags", "title", "description", "priority", "specification", "definitionOfDone", "stoppingPoint", "scopePaths", "capabilities", "dependencies", "tags", "base", "dueDate", "role"]) {
1546
+ if (input?.[key] !== undefined) changes[key] = input[key];
1547
+ }
1548
+ let result;
1549
+ try {
1550
+ result = operation === "update"
1551
+ ? updateCard({ boardPath: activeBoardPath, cardId: input.cardId, changes, authority: input?.authority, registries: {}, surface: "tasks" })
1552
+ : deleteCard({ boardPath: activeBoardPath, cardId: input.cardId, authority: input?.authority, registries: {}, surface: "tasks" });
1553
+ } catch (error) {
1554
+ const code = typeof error?.code === "string" ? error.code : "writer-error";
1555
+ const value = { ok: false, persisted: false, code, reason: String(error?.message || error).slice(0, 512), errors: [String(error?.message || error).slice(0, 512)] };
1556
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1557
+ }
1558
+ let value;
1559
+ if (result.ok) {
1560
+ value = operation === "update"
1561
+ ? {
1562
+ ok: true,
1563
+ persisted: true,
1564
+ operation,
1565
+ cardId: result.card.cardId,
1566
+ lane: result.card.lane,
1567
+ done: Boolean(result.card.done),
1568
+ flags: [...(result.card.flags ?? [])],
1569
+ changedFields: result.changedFields,
1570
+ hashPresent: Boolean(result.card.hash),
1571
+ authorityWriterHmacPresent: Boolean(result.card.authorityWriterHmac),
1572
+ authoritySource: { ...result.card.authoritySource },
1573
+ projection: result.projection,
1574
+ }
1575
+ : {
1576
+ ok: true,
1577
+ persisted: true,
1578
+ operation,
1579
+ cardId: result.cardId,
1580
+ removed: true,
1581
+ projection: result.projection,
1582
+ };
1583
+ } else {
1584
+ value = {
1585
+ ok: false,
1586
+ persisted: false,
1587
+ code: result.code,
1588
+ reason: (result.reason ?? (result.errors ?? []).join("; ")).slice(0, 512),
1589
+ errors: result.errors ?? [],
1590
+ ...(result.cardId ? { cardId: result.cardId } : {}),
1591
+ };
1592
+ }
1593
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1594
+ },
1595
+ });
1596
+ registered.push("agentic_kanban_board_update");
1597
+ }
1598
+ return { registered, observation: { ...observation, boardPath: observation.boardPath } };
1194
1599
  }