@evoclock/pi-agentic-driver 0.8.2 → 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.
package/extensions/task-board.ts
CHANGED
|
@@ -9,18 +9,25 @@
|
|
|
9
9
|
import { existsSync } from "node:fs";
|
|
10
10
|
import { join } from "node:path";
|
|
11
11
|
|
|
12
|
-
|
|
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
|
|
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, {
|
|
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
|
+
"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
|
-
|
|
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
|
-
|
|
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, {
|
|
992
|
-
//
|
|
993
|
-
//
|
|
994
|
-
//
|
|
995
|
-
//
|
|
996
|
-
// no board
|
|
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
|
-
//
|
|
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 };
|
|
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(
|
|
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(
|
|
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 };
|
|
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:
|
|
1421
|
+
boardPath: activeBoardPath,
|
|
1144
1422
|
input: writerInput,
|
|
1145
1423
|
authority: input?.authority,
|
|
1146
1424
|
registries: {},
|
|
1147
1425
|
surface: "tasks",
|
|
1148
|
-
// No requireExistingBoard
|
|
1149
|
-
//
|
|
1150
|
-
//
|
|
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
|
-
|
|
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
|
}
|