@evoclock/pi-agentic-driver 0.7.0 → 0.8.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,4 +1,4 @@
1
- # pi-agentic-driver v0.7.0
1
+ # pi-agentic-driver v0.8.0
2
2
 
3
3
  <p align="center">
4
4
  <img src="assets/Yamagane-origami.png" alt="pi-agentic-driver, Yamagane origami mark" width="140"/>
@@ -7,7 +7,7 @@
7
7
  <p align="center">
8
8
  <a href="LICENSE"><img src="https://img.shields.io/badge/license-AGPL%20v3-blue?style=flat" alt="License: AGPL v3"/></a>
9
9
  <a href="https://www.npmjs.com/package/@evoclock/pi-agentic-driver"><img src="https://img.shields.io/npm/v/@evoclock/pi-agentic-driver?style=flat" alt="npm version"/></a>
10
- <img src="https://img.shields.io/badge/version-0.7.0-blue?style=flat" alt="Version 0.7.0"/>
10
+ <img src="https://img.shields.io/badge/version-0.8.0-blue?style=flat" alt="Version 0.8.0"/>
11
11
  <img src="https://img.shields.io/badge/status-active%20development%20%26%20testing-orange?style=flat" alt="Status"/>
12
12
  <img src="https://img.shields.io/badge/JavaScript-F7DF1E?style=flat&logo=javascript&logoColor=black" alt="JavaScript"/>
13
13
  <img src="https://img.shields.io/badge/TypeScript-3178C6?style=flat&logo=typescript&logoColor=white" alt="TypeScript"/>
@@ -49,6 +49,8 @@ proofs for agentic workflows.
49
49
  | `agentic_aidr` | A remedy for AI;DR. Reviews writing for clarity, simplicity, brevity, and humanity. | shipped |
50
50
  | `agentic_linux_microvm_cutover` | Runs one job in a throwaway QEMU/KVM virtual machine on a Linux host, with a severity-tiered killswitch that stops escape attempts. | user-enabled, native confirmation |
51
51
  | `agentic_worker_dispatch` | Runs controlled worker journeys and observes worker liveness. | shipped |
52
+ | `agentic_kanban_board` | Shows the workspace task board: lanes, flags, priorities, dependencies, and which cards can run. | shipped |
53
+ | `agentic_kanban_board_write` | Adds cards to the board through the trusted writer, which records who authorized the work. | shipped |
52
54
 
53
55
  **Status: active development and testing.** Each extension ships only after
54
56
  it passes fixture-based acceptance, native tests, live-session checks, and
@@ -187,6 +189,34 @@ silently, and return results as untrusted evidence.
187
189
  </details>
188
190
 
189
191
 
192
+ <details>
193
+ <summary><strong>task board, planned work you can see</strong> <em>(released, 0.8.0)</em></summary>
194
+
195
+ Keep planned work on a Kanban board. You read and edit the board in
196
+ Obsidian or in the Vogelkop Task Board pane (our upcoming Scientific and
197
+ Research Workbench). Agents read the same board and add cards to it.
198
+
199
+ The board appears only when a `board.md` or `TASKS.md` file exists in the
200
+ workspace. With no board file, the tools do not appear and nothing changes.
201
+
202
+ When an agent adds a card, a trusted writer does the bookkeeping: it
203
+ assigns the card ID, computes the integrity hash, checks the card, and
204
+ records who authorized the work. The record comes from you: your
205
+ instruction, or your approval of an agent's proposal. A card without that
206
+ record cannot be dispatched. A hand-edited card cannot fake the record, and
207
+ a tampered card refuses to run.
208
+
209
+ Only you complete a card. Move it to done in your board UI, or tell an
210
+ agent to close it. An agent report that says the work is finished is
211
+ evidence for your review. It is never the completion itself.
212
+
213
+ The card format is shared. The same board renders in Obsidian, with
214
+ optional Tasks-plugin emoji, and in the Vogelkop Task Board pane. The
215
+ machine-readable fields are the single source of truth, so there is no
216
+ second copy to keep in sync.
217
+
218
+ </details>
219
+
190
220
  - **project status and state review.** Read-only projections of workspace Git
191
221
  state, formal records, and task-state health.
192
222
  - **role-lane routing and warm sessions.** Separate lanes handle
@@ -490,7 +520,7 @@ pi install npm:@evoclock/pi-agentic-driver
490
520
  Or from Git at a pinned tag:
491
521
 
492
522
  ```sh
493
- pi install git:github.com/evoclock/pi-agentic-driver@v0.7.0
523
+ pi install git:github.com/evoclock/pi-agentic-driver@v0.8.0
494
524
  ```
495
525
 
496
526
  Released extensions load standalone; neither requires the other.
@@ -504,7 +534,7 @@ extensions you want with the object form in your Pi settings:
504
534
  {
505
535
  "packages": [
506
536
  {
507
- "source": "npm:@evoclock/pi-agentic-driver@0.7.0",
537
+ "source": "npm:@evoclock/pi-agentic-driver@0.8.0",
508
538
  "extensions": [
509
539
  "extensions/aidr.ts",
510
540
  "extensions/code-phage.js"
@@ -0,0 +1,26 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // BOARD-1 provider extension. Both tools register at startup; the board is
5
+ // resolved per tool call from the calling session's working directory. A
6
+ // workspace with no board file gets a structured board-unavailable result —
7
+ // nothing is created and nothing else changes.
8
+
9
+ import { existsSync } from "node:fs";
10
+ import { join } from "node:path";
11
+
12
+ const BOARD_FILENAMES = ["board.md", "TASKS.md"];
13
+
14
+ export function resolveBoardPath(cwd) {
15
+ if (typeof cwd !== "string" || cwd === "") return null;
16
+ for (const name of BOARD_FILENAMES) {
17
+ const candidate = join(cwd, name);
18
+ if (existsSync(candidate)) return candidate;
19
+ }
20
+ return null;
21
+ }
22
+
23
+ export default async function taskBoardPi(pi) {
24
+ const module = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
25
+ return module.registerKanbanBoardTools(pi, { resolveBoardPath });
26
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@evoclock/pi-agentic-driver",
3
- "version": "0.7.0",
3
+ "version": "0.8.1",
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",
@@ -39,6 +39,7 @@
39
39
  "extensions/code-phage.js",
40
40
  "extensions/herdr-communication.ts",
41
41
  "extensions/herdr-dispatch.ts",
42
+ "extensions/task-board.ts",
42
43
  "lib/adapters/diff-scope.mjs",
43
44
  "lib/adapters/evidence.mjs",
44
45
  "lib/adapters/narrative.mjs",
@@ -64,7 +65,8 @@
64
65
  "PROVENANCE.md",
65
66
  "extensions/aidr.ts",
66
67
  "scripts/aidr_writing_review.js",
67
- "templates/AGENTS.md"
68
+ "templates/AGENTS.md",
69
+ "scripts/enforcement/task_board_core_pi.js"
68
70
  ],
69
71
  "pi": {
70
72
  "extensions": [
@@ -40,6 +40,18 @@ const STE_PHRASAL_GUIDANCE = Object.freeze({
40
40
  "look at": "examine",
41
41
  "set up": "configure or install",
42
42
  });
43
+ // Project terminology rules (established in session guidance, encoded here so
44
+ // AI;DR enforces them advisorially):
45
+ // - "limited" for numeric limits; "controlled" or "authorised" for authority
46
+ // and behavior; "approved" or "authorised" for tasks.
47
+ // - "fundamental" or "crucial" instead of the metaphor "load-bearing".
48
+ // - Avoid "bounded" in prose entirely; use "limited" for numeric limits.
49
+ // (The boundedText identifier is a code name, not prose, and is flagged
50
+ // separately for a future rename.)
51
+ const TERMINOLOGY_GUIDANCE = Object.freeze({
52
+ bounded: "use limited for numeric limits; avoid bounded in prose",
53
+ "load-bearing": "use fundamental or crucial",
54
+ });
43
55
  const STE_MODAL_GUIDANCE = Object.freeze({
44
56
  should: "use must for a requirement, or state the recommendation directly",
45
57
  may: "use can for ability or must have permission language when needed",
@@ -154,6 +166,13 @@ function steFindings(prose, sentences, documentType) {
154
166
  message: "State requirement, ability, permission, or condition precisely; do not leave the modal meaning implicit.",
155
167
  examples: modalExamples,
156
168
  });
169
+ const terminologyExamples = steTermExamples(prose, TERMINOLOGY_GUIDANCE);
170
+ if (terminologyExamples.length) findings.push({
171
+ rule: "STE-T1",
172
+ kind: "ste-project-terminology",
173
+ message: "Use the project terminology: limited for numeric limits, controlled or authorised for authority and behavior, approved or authorised for tasks, fundamental or crucial instead of load-bearing, and avoid bounded in prose.",
174
+ examples: terminologyExamples,
175
+ });
157
176
  if (/\band\/or\b/i.test(prose)) findings.push({
158
177
  rule: "STE-C1",
159
178
  kind: "ste-conjunction",
@@ -0,0 +1,1184 @@
1
+ // SPDX-FileCopyrightText: 2026 Julen Gamboa <j.a.r.gamboa@gmail.com>
2
+ // SPDX-License-Identifier: AGPL-3.0-or-later
3
+
4
+ // BOARD-1 semantic model, closed validator, hash canonicalization, both
5
+ // surface parsers/serializers, the trusted board writer, and the pure
6
+ // dispatchability predicate. Implements evidence/BOARD1_DESIGN_v6.md §1-§3.
7
+ //
8
+ // Governance boundary: this module is deterministic and side-effect-free
9
+ // except for the trusted writer's atomic persist, which is only ever invoked
10
+ // with a recorded human authority source. Models never supply identifiers or
11
+ // hashes; presentation is excluded from the hash; every disagreement between
12
+ // surfaces fails closed.
13
+
14
+ import { createHash, createHmac, randomBytes } from "node:crypto";
15
+ import { existsSync, readFileSync, writeFileSync, renameSync, mkdirSync, openSync, closeSync, unlinkSync, chmodSync, statSync as fsStatSync } from "node:fs";
16
+ import { dirname, join } from "node:path";
17
+
18
+ // ---------------------------------------------------------------------------
19
+ // Closed vocabularies (§1)
20
+ // ---------------------------------------------------------------------------
21
+
22
+ export const LANES = Object.freeze(["backlog", "in-progress", "review", "done"]);
23
+ export const FLAGS = Object.freeze(["proposed", "blocked", "cancelled"]);
24
+ export const PRIORITIES = Object.freeze(["P0", "P1", "P2", "P3"]);
25
+ export const CARD_ID_PATTERN = "^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$";
26
+ export const CARD_ID_RE = new RegExp(CARD_ID_PATTERN);
27
+ export const COMMIT_SHA_RE = /^[0-9a-f]{40}$/;
28
+ export const ISO_DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
29
+ export const ROLE_NAME_RE = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/;
30
+ export const CAPABILITY_NAME_RE = /^[a-z][a-z0-9._-]{0,63}$/;
31
+ export const SAFE_PATH_RE = /^[A-Za-z0-9._/@-]+$/;
32
+
33
+ // Obsidian Tasks five-level priority → canonical P0-P3. The mapping is
34
+ // documented as lossy and one-directional (design §1): canonical → Obsidian
35
+ // picks one emoji and the reverse trip is never treated as faithful.
36
+ export const OBSIDIAN_PRIORITY_MAP = Object.freeze({
37
+ highest: "P0",
38
+ high: "P1",
39
+ medium: "P2",
40
+ low: "P3",
41
+ lowest: "P3",
42
+ });
43
+
44
+ const FIELD_RE = /\[([A-Za-z][A-Za-z0-9_-]*)::[ \t]([^\][]*)\]/g;
45
+
46
+ // Field-key aliases the parser accepts (F5). Duplicate detection and the
47
+ // semantic mapping both go through the canonical key, so semantic aliases
48
+ // ([specHash:: x] vs [spec-hash:: x]) collide as duplicates fail-closed.
49
+ export const FIELD_KEY_ALIASES = Object.freeze({
50
+ "stopping-point": "stopping",
51
+ "spec-hash": "specHash",
52
+ "dod-hash": "dodHash",
53
+ "spec-text": "specText",
54
+ "dod-text": "dodText",
55
+ });
56
+
57
+ export function canonicalFieldKey(key) {
58
+ return FIELD_KEY_ALIASES[key] ?? key;
59
+ }
60
+ const HTML_ID_MARKER_RE = /<!--\s*id:\s*([^>]*?)\s*-->/g;
61
+ // Tasks-plugin presentation emoji (optional; never required, never hashed).
62
+ const EMOJI_RE = /[\u{1F300}-\u{1FAFF}\u{2600}-\u{27BF}\u{FE0F}\u{2194}-\u{21AA}]/gu;
63
+ const EMOJI_DATE_RE = /📅\s*(\d{4}-\d{2}-\d{2})/u;
64
+
65
+ function nfc(value) {
66
+ return typeof value === "string" ? value.normalize("NFC") : value;
67
+ }
68
+
69
+ // ---------------------------------------------------------------------------
70
+ // Hash canonicalization (§1): SHA-256 over canonical JSON of the
71
+ // authority-bearing fields only. Keys recursively sorted; absent optional
72
+ // fields omitted (never null); strings NFC-normalized; presentation excluded.
73
+ // ---------------------------------------------------------------------------
74
+
75
+ export function canonicalJson(value) {
76
+ if (value === null || value === undefined) return undefined;
77
+ if (typeof value === "string") return nfc(value);
78
+ if (typeof value === "number" || typeof value === "boolean") return value;
79
+ if (Array.isArray(value)) return value.map((entry) => canonicalJson(entry));
80
+ if (typeof value === "object") {
81
+ const out = {};
82
+ for (const key of Object.keys(value).sort()) {
83
+ const normalized = canonicalJson(value[key]);
84
+ if (normalized !== undefined) out[key] = normalized;
85
+ }
86
+ return out;
87
+ }
88
+ return undefined;
89
+ }
90
+
91
+ export function canonicalJsonString(value) {
92
+ return JSON.stringify(canonicalJson(value));
93
+ }
94
+
95
+ export function sha256Hex(text) {
96
+ return createHash("sha256").update(text, "utf8").digest("hex");
97
+ }
98
+
99
+ // Free-text (titles, quoted instructions) is sanitized before it is ever
100
+ // serialized into a card line: [key:: value] field syntax, stray "]", and
101
+ // HTML-comment syntax are stripped so a hostile string cannot alter parsing
102
+ // (§6 gate 3). Sanitization is lossy by design — it fails closed.
103
+ export function sanitizeFreeText(text) {
104
+ return String(text ?? "").normalize("NFC")
105
+ .replace(/<!--[\s\S]*?-->/g, " ")
106
+ .replace(/<!--|-->/g, " ")
107
+ .replace(/\[[A-Za-z][A-Za-z0-9_-]*::[^\]]*\]?/g, " ")
108
+ .replace(/\]/g, ")")
109
+ .replace(/\s+/g, " ").trim();
110
+ }
111
+
112
+ // Spec/DoD text travels base64url-encoded inside a [key:: value] field: the
113
+ // encoding round-trips byte-for-byte (so the dispatch gate can recompute the
114
+ // hash) and cannot inject field or HTML-comment syntax.
115
+ export function encodeFieldText(text) {
116
+ return Buffer.from(String(text ?? ""), "utf8").toString("base64url");
117
+ }
118
+
119
+ // F2: only the canonical base64url encoding is accepted. The decoded bytes
120
+ // are re-encoded and compared to the original string exactly, so a permissive
121
+ // decoder cannot smuggle non-canonical input ('***' and friends decode to
122
+ // nothing usable and are rejected).
123
+ export function decodeFieldText(encoded) {
124
+ if (typeof encoded !== "string" || encoded === "") return null;
125
+ try {
126
+ const decoded = Buffer.from(encoded, "base64url").toString("utf8");
127
+ if (Buffer.from(decoded, "utf8").toString("base64url") !== encoded) return null;
128
+ return decoded;
129
+ } catch {
130
+ return null;
131
+ }
132
+ }
133
+
134
+ // The authority-bearing field set. The authority-source reference is NOT
135
+ // hash-bearing: it is recorded alongside the card, outside this payload.
136
+ // §1 scope = paths + capability classes + explicitly unchanged paths +
137
+ // repository identity (for user-level cards) — all hash-bearing.
138
+ const AUTHORITY_FIELDS = Object.freeze([
139
+ "cardId", "lane", "flags", "priority", "dependencies", "base",
140
+ "specHash", "dodHash", "stoppingPoint", "scope", "unchangedPaths",
141
+ "capabilities", "repositories",
142
+ ]);
143
+
144
+ export function hashPayload(card) {
145
+ const payload = {};
146
+ for (const field of AUTHORITY_FIELDS) {
147
+ const value = card[field];
148
+ if (value === undefined || value === null) continue;
149
+ if (Array.isArray(value) && value.length === 0) continue;
150
+ payload[field] = value;
151
+ }
152
+ return payload;
153
+ }
154
+
155
+ export function computeCardHash(card) {
156
+ return sha256Hex(canonicalJsonString(hashPayload(card)));
157
+ }
158
+
159
+ export function computeSpecHash(specText) {
160
+ return sha256Hex(nfc(String(specText)));
161
+ }
162
+
163
+ // ---------------------------------------------------------------------------
164
+ // Title stripping / normalization (§2): fields, emoji, and the ID marker are
165
+ // stripped from the displayed title consistently in both parsers.
166
+ // ---------------------------------------------------------------------------
167
+
168
+ export function stripTitle(rawTitle) {
169
+ let title = String(rawTitle ?? "");
170
+ title = title.replace(HTML_ID_MARKER_RE, " ");
171
+ title = title.replace(FIELD_RE, " ");
172
+ title = title.replace(EMOJI_DATE_RE, " ");
173
+ title = title.replace(EMOJI_RE, " ");
174
+ return title.replace(/\s+/g, " ").trim();
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // Parsers (§2). One tokenizer handles both surface shapes: Obsidian Kanban
179
+ // Markdown ([id:: ...] Dataview marker) and vogelkop TASKS.md
180
+ // (<!-- id: ... --> HTML-comment marker). Unknown ## headings are rejected;
181
+ // [-] cancelled checkboxes map to #cancelled; indented continuation lines are
182
+ // description text.
183
+ // ---------------------------------------------------------------------------
184
+
185
+ function parseCardLine(line, surface, checkboxState = null) {
186
+ const errors = [];
187
+ const fields = {};
188
+ const duplicateKeys = new Set();
189
+ const flags = [];
190
+ let idMarker = null;
191
+ let idField = null;
192
+
193
+ const idMarkerMatches = [...line.matchAll(HTML_ID_MARKER_RE)];
194
+ if (idMarkerMatches.length > 1) {
195
+ errors.push("multiple HTML id markers on one card line (injection rejected)");
196
+ } else if (idMarkerMatches.length === 1) {
197
+ idMarker = idMarkerMatches[0][1].trim();
198
+ }
199
+
200
+ for (const match of line.matchAll(FIELD_RE)) {
201
+ const key = canonicalFieldKey(match[1]);
202
+ const value = match[2].trim();
203
+ if (key === "id") {
204
+ if (idField !== null) errors.push("duplicate [id:: ...] field");
205
+ idField = value;
206
+ } else if (key === "flag") {
207
+ if (!FLAGS.includes(value)) {
208
+ errors.push(`unknown flag "${value}" (closed enum: ${FLAGS.join(", ")})`);
209
+ } else if (flags.includes(value)) {
210
+ // F5: a repeated flag is a duplicate error; distinct flags on one
211
+ // card are fine.
212
+ errors.push(`duplicate [flag:: ${value}] field on one card line (injection rejected)`);
213
+ } else {
214
+ flags.push(value);
215
+ }
216
+ } else {
217
+ // Duplicate authority-bearing fields are ambiguous (last-value-wins is
218
+ // an injection vector); reject fail-closed (M2). Keys are compared
219
+ // through the canonical alias map so semantic aliases collide too (F5).
220
+ if (Object.hasOwn(fields, key)) duplicateKeys.add(key);
221
+ fields[key] = value;
222
+ }
223
+ }
224
+ for (const key of duplicateKeys) {
225
+ errors.push(`duplicate [${key}:: ...] field on one card line (injection rejected)`);
226
+ }
227
+
228
+ // Mirror consistency (§1): if both surface markers are present they must
229
+ // encode the same canonical identity; disagreement fails closed.
230
+ if (idMarker !== null && idField !== null && idMarker !== idField) {
231
+ errors.push(`mirror-consistency failure: id marker "${idMarker}" != [id:: ${idField}]`);
232
+ }
233
+ const cardId = idMarker ?? idField;
234
+
235
+ if (checkboxState === "-") flags.push("cancelled");
236
+ const state = checkboxState;
237
+
238
+ const title = stripTitle(line.replace(/^[-*]\s+\[( |x|X|-)\]\s*/, ""));
239
+ if (/<!--|-->/.test(title)) {
240
+ errors.push("HTML-comment injection in card title rejected");
241
+ }
242
+
243
+ const emojiDate = line.match(EMOJI_DATE_RE);
244
+ if (emojiDate) fields.due = emojiDate[1];
245
+
246
+ return { cardId, title, fields, flags: [...new Set(flags)], state, surface, errors };
247
+ }
248
+
249
+ function laneFromHeading(heading) {
250
+ const name = heading.replace(/^##\s*/, "").trim().toLowerCase();
251
+ return LANES.includes(name) ? name : null;
252
+ }
253
+
254
+ export function parseBoard(markdown, { surface = "auto" } = {}) {
255
+ const errors = [];
256
+ const cards = [];
257
+ const lines = String(markdown ?? "").split(/\r?\n/);
258
+ let lane = null;
259
+ let current = null;
260
+
261
+ for (const line of lines) {
262
+ const heading = line.match(/^##\s+(.+)$/);
263
+ if (heading) {
264
+ const parsed = laneFromHeading(heading[1]);
265
+ if (parsed === null) {
266
+ errors.push(`unknown ## heading "${heading[1].trim()}" (lanes are closed)`);
267
+ lane = null;
268
+ } else {
269
+ lane = parsed;
270
+ }
271
+ current = null;
272
+ continue;
273
+ }
274
+ if (lane === null) continue;
275
+
276
+ const cardMatch = line.match(/^[-*]\s+\[( |x|X|-)\]\s*(.*)$/);
277
+ if (cardMatch) {
278
+ const parsed = parseCardLine(cardMatch[2], surface, cardMatch[1]);
279
+ for (const error of parsed.errors) errors.push(`${parsed.cardId ?? "(unidentified)"}: ${error}`);
280
+ current = {
281
+ cardId: parsed.cardId,
282
+ lane,
283
+ title: parsed.title,
284
+ fields: parsed.fields,
285
+ flags: parsed.flags,
286
+ state: parsed.state,
287
+ description: [],
288
+ done: parsed.state === "x" || parsed.state === "X",
289
+ };
290
+ cards.push(current);
291
+ continue;
292
+ }
293
+ // Indented continuation lines are description text (§2).
294
+ if (current && /^\s+\S/.test(line)) {
295
+ current.description.push(line.trim());
296
+ }
297
+ }
298
+
299
+ const resolved = cards.map((card) => semanticCard(card, errors));
300
+ return { ok: errors.length === 0, cards: resolved, errors };
301
+ }
302
+
303
+ function semanticCard(raw, errors) {
304
+ const f = raw.fields;
305
+ const card = {
306
+ cardId: raw.cardId ?? null,
307
+ lane: raw.lane,
308
+ title: raw.title,
309
+ flags: raw.flags,
310
+ priority: f.priority ?? null,
311
+ dependencies: f.blockedBy ? f.blockedBy.split(/[\s,]+/).filter(Boolean) : [],
312
+ base: f.base ?? null,
313
+ due: f.due ?? null,
314
+ role: f.role ?? null,
315
+ capabilities: f.capabilities ? f.capabilities.split(/[\s,]+/).filter(Boolean) : [],
316
+ // Field keys are already canonicalized by the parser (F5).
317
+ stoppingPoint: f.stopping ?? null,
318
+ specHash: f.specHash ?? null,
319
+ dodHash: f.dodHash ?? null,
320
+ specText: decodeFieldText(f.specText),
321
+ dodText: decodeFieldText(f.dodText),
322
+ scope: f.scope ? f.scope.split(/[\s,]+/).filter(Boolean) : [],
323
+ unchangedPaths: f.unchanged ? f.unchanged.split(/[\s,]+/).filter(Boolean) : [],
324
+ repositories: f.repos ? f.repos.split(/[\s,]+/).filter(Boolean) : [],
325
+ tags: f.tags ? f.tags.split(/[\s,]+/).filter(Boolean) : [],
326
+ provenance: f.provenance ?? null,
327
+ importedId: f.importedId ?? null,
328
+ hash: f.hash ?? null,
329
+ authoritySource: f.authority ? safeJsonParse(f.authority) : null,
330
+ authorityWriterHmac: f.authorityHmac ?? null,
331
+ fields: { ...f },
332
+ description: raw.description.join("\n"),
333
+ done: raw.done ?? false,
334
+ };
335
+ if (card.cardId === null) errors.push(`card without an id marker in lane ${raw.lane}`);
336
+ if (card.authoritySource !== null && !isValidAuthoritySource(card.authoritySource)) {
337
+ errors.push(`${card.cardId ?? "(unidentified)"}: malformed authority-source record (fails closed)`);
338
+ card.authoritySource = null;
339
+ }
340
+ return card;
341
+ }
342
+
343
+ // §3.5: an authority-source record is a reference — {source: "instruction" |
344
+ // "report-proposal", sessionOrReportId, quotedInstruction-or-digest}. The
345
+ // shape is closed (F1): exactly these three fields, and exactly one of
346
+ // quotedInstruction or digest. A malformed or absent record is never
347
+ // dispatchable.
348
+ export function isValidAuthoritySource(record) {
349
+ if (record === null || typeof record !== "object" || Array.isArray(record)) return false;
350
+ if (typeof record === "object" && "writerHmac" in record) return false;
351
+ const keys = Object.keys(record);
352
+ if (keys.length !== 3) return false;
353
+ for (const key of keys) {
354
+ if (key !== "source" && key !== "sessionOrReportId" && key !== "quotedInstruction" && key !== "digest") return false;
355
+ }
356
+ if (record.source !== "instruction" && record.source !== "report-proposal") return false;
357
+ if (typeof record.sessionOrReportId !== "string" || record.sessionOrReportId.trim() === "") return false;
358
+ const hasInstruction = typeof record.quotedInstruction === "string" && record.quotedInstruction.trim() !== "";
359
+ const hasDigest = typeof record.digest === "string" && /^[0-9a-f]{64}$/.test(record.digest);
360
+ // Exactly one of quotedInstruction or digest — never both, never neither.
361
+ return hasInstruction !== hasDigest;
362
+ }
363
+
364
+ function safeJsonParse(text) {
365
+ try {
366
+ return JSON.parse(text);
367
+ } catch {
368
+ return { malformed: String(text) };
369
+ }
370
+ }
371
+
372
+ // ---------------------------------------------------------------------------
373
+ // Closed validator (§1, §6 gate 2). Validate before persist; decline on
374
+ // violation; duplicate cardId is a validation error; decorative use of the
375
+ // canonical flag names in free-form tags is a validation error.
376
+ // ---------------------------------------------------------------------------
377
+
378
+ export function validateCard(card, context = {}) {
379
+ const errors = [];
380
+ const { knownCardIds = [], roles = [], capabilities = [], userLevel = false } = context;
381
+
382
+ if (typeof card.cardId !== "string" || !CARD_ID_RE.test(card.cardId)) {
383
+ errors.push(`cardId "${card.cardId}" does not match ${CARD_ID_PATTERN}`);
384
+ }
385
+ if (!LANES.includes(card.lane)) {
386
+ errors.push(`lane "${card.lane}" is not one of ${LANES.join(", ")}`);
387
+ }
388
+ for (const flag of card.flags ?? []) {
389
+ if (!FLAGS.includes(flag)) errors.push(`flag "${flag}" is not one of ${FLAGS.join(", ")}`);
390
+ }
391
+ // Decorative flag-name tags are a validation error (§1).
392
+ for (const tag of card.tags ?? []) {
393
+ if (FLAGS.includes(tag)) errors.push(`decorative use of canonical flag name "${tag}" as a tag is a validation error`);
394
+ }
395
+ if (card.priority !== null && card.priority !== undefined && !PRIORITIES.includes(card.priority)) {
396
+ errors.push(`priority "${card.priority}" is not one of ${PRIORITIES.join(", ")}`);
397
+ }
398
+ for (const dep of card.dependencies ?? []) {
399
+ if (!CARD_ID_RE.test(dep)) errors.push(`dependency "${dep}" is not a valid cardId`);
400
+ }
401
+ if (card.base !== null && card.base !== undefined && !COMMIT_SHA_RE.test(card.base)) {
402
+ errors.push(`base "${card.base}" is not a full 40-hex commit SHA`);
403
+ }
404
+ for (const field of ["due"]) {
405
+ const value = card[field];
406
+ if (value !== null && value !== undefined && !ISO_DATE_RE.test(value)) {
407
+ errors.push(`${field} "${value}" is not an ISO yyyy-mm-dd date`);
408
+ }
409
+ }
410
+ if (card.role !== null && card.role !== undefined && card.role !== "" && !roles.includes(card.role)) {
411
+ errors.push(`role "${card.role}" is not declared in the role registry`);
412
+ }
413
+ for (const capability of card.capabilities ?? []) {
414
+ if (!CAPABILITY_NAME_RE.test(capability)) {
415
+ errors.push(`capability "${capability}" is not a well-formed capability class name`);
416
+ } else if (!capabilities.includes(capability)) {
417
+ errors.push(`capability "${capability}" is not declared in the capability registry`);
418
+ }
419
+ }
420
+ for (const path of card.scope ?? []) {
421
+ if (!SAFE_PATH_RE.test(path) || path.includes("..")) {
422
+ errors.push(`scope path "${path}" is not a safe repository-relative path`);
423
+ }
424
+ }
425
+ for (const path of card.unchangedPaths ?? []) {
426
+ if (!SAFE_PATH_RE.test(path) || path.includes("..")) {
427
+ errors.push(`unchanged path "${path}" is not a safe repository-relative path`);
428
+ }
429
+ }
430
+ if (userLevel && (card.scope ?? []).length > 0 && (card.repositories ?? []).length === 0) {
431
+ errors.push("a user-level card with scope paths must name the repository (or repositories) they refer to");
432
+ }
433
+ if (card.importedId !== null && card.importedId !== undefined) {
434
+ if (String(card.importedId).includes("/")) {
435
+ if (card.cardId !== substituteImportedId(card.importedId)) {
436
+ errors.push(`an imported id containing "/" must map to the substituted cardId "${substituteImportedId(card.importedId)}"`);
437
+ }
438
+ if (!card.provenance) {
439
+ errors.push("an imported id containing \"/\" must retain the original in provenance");
440
+ }
441
+ }
442
+ }
443
+ return { ok: errors.length === 0, errors };
444
+ }
445
+
446
+ // Imported IDs containing `/` use a schema-safe `--` substitution, with the
447
+ // original retained in provenance (§1).
448
+ export function substituteImportedId(importedId) {
449
+ return String(importedId).replace(/\//g, "--");
450
+ }
451
+
452
+ export function validateBoard(markdown, context = {}) {
453
+ const parsed = parseBoard(markdown, { surface: context.surface ?? "auto" });
454
+ const errors = [...parsed.errors];
455
+ const seen = new Map();
456
+ for (const card of parsed.cards) {
457
+ if (card.cardId !== null) {
458
+ if (seen.has(card.cardId)) errors.push(`duplicate cardId "${card.cardId}" is a validation error`);
459
+ seen.set(card.cardId, card);
460
+ }
461
+ const result = validateCard(card, context);
462
+ for (const error of result.errors) errors.push(`${card.cardId ?? "(unidentified)"}: ${error}`);
463
+ for (const dep of card.dependencies ?? []) {
464
+ if (!seen.has(dep) && !parsed.cards.some((other) => other.cardId === dep)) {
465
+ errors.push(`${card.cardId}: dependency "${dep}" does not exist on the board`);
466
+ }
467
+ }
468
+ }
469
+ return { ok: errors.length === 0, cards: parsed.cards, errors };
470
+ }
471
+
472
+ // ---------------------------------------------------------------------------
473
+ // Serializers (§2). One canonical machine encoding: [key:: value] fields on
474
+ // the card line. Emoji are optional presentation; no reader requires them.
475
+ // ---------------------------------------------------------------------------
476
+
477
+ function fieldText(key, value) {
478
+ return `[${key}:: ${value}]`;
479
+ }
480
+
481
+ export function serializeObsidianCard(card) {
482
+ const checkbox = card.flags?.includes("cancelled") ? "[-]" : card.done ? "[x]" : "[ ]";
483
+ const parts = [checkbox, card.title];
484
+ parts.push(fieldText("id", card.cardId));
485
+ if (card.hash) parts.push(fieldText("hash", card.hash));
486
+ if (card.priority) parts.push(fieldText("priority", card.priority));
487
+ for (const flag of card.flags ?? []) parts.push(fieldText("flag", flag));
488
+ if ((card.dependencies ?? []).length > 0) parts.push(fieldText("blockedBy", card.dependencies.join(", ")));
489
+ if (card.base) parts.push(fieldText("base", card.base));
490
+ if (card.due) parts.push(`📅 ${card.due}`);
491
+ if (card.role) parts.push(fieldText("role", card.role));
492
+ if ((card.capabilities ?? []).length > 0) parts.push(fieldText("capabilities", card.capabilities.join(", ")));
493
+ if (card.stoppingPoint) parts.push(fieldText("stopping", card.stoppingPoint));
494
+ if (card.specHash) parts.push(fieldText("specHash", card.specHash));
495
+ if (card.dodHash) parts.push(fieldText("dodHash", card.dodHash));
496
+ if (card.specText !== null && card.specText !== undefined) parts.push(fieldText("specText", encodeFieldText(card.specText)));
497
+ if (card.dodText !== null && card.dodText !== undefined) parts.push(fieldText("dodText", encodeFieldText(card.dodText)));
498
+ if ((card.scope ?? []).length > 0) parts.push(fieldText("scope", card.scope.join(", ")));
499
+ if ((card.unchangedPaths ?? []).length > 0) parts.push(fieldText("unchanged", card.unchangedPaths.join(", ")));
500
+ if ((card.repositories ?? []).length > 0) parts.push(fieldText("repos", card.repositories.join(", ")));
501
+ if ((card.tags ?? []).length > 0) parts.push(fieldText("tags", card.tags.join(", ")));
502
+ if (card.provenance) parts.push(fieldText("provenance", card.provenance));
503
+ if (card.importedId) parts.push(fieldText("importedId", card.importedId));
504
+ if (card.authoritySource) parts.push(fieldText("authority", JSON.stringify(card.authoritySource)));
505
+ if (card.authorityWriterHmac) parts.push(fieldText("authorityHmac", card.authorityWriterHmac));
506
+ let out = `- ${parts.join(" ")}`;
507
+ if (card.description) out += `\n ${card.description.replace(/\n/g, "\n ")}`;
508
+ return out;
509
+ }
510
+
511
+ export function serializeTasksCard(card) {
512
+ const checkbox = card.flags?.includes("cancelled") ? "[-]" : card.done ? "[x]" : "[ ]";
513
+ const parts = [checkbox, card.title, `<!-- id: ${card.cardId} -->`];
514
+ if (card.hash) parts.push(fieldText("hash", card.hash));
515
+ if (card.priority) parts.push(fieldText("priority", card.priority));
516
+ for (const flag of card.flags ?? []) parts.push(fieldText("flag", flag));
517
+ if ((card.dependencies ?? []).length > 0) parts.push(fieldText("blockedBy", card.dependencies.join(", ")));
518
+ if (card.base) parts.push(fieldText("base", card.base));
519
+ if (card.due) parts.push(fieldText("due", card.due));
520
+ if (card.role) parts.push(fieldText("role", card.role));
521
+ if ((card.capabilities ?? []).length > 0) parts.push(fieldText("capabilities", card.capabilities.join(", ")));
522
+ if (card.stoppingPoint) parts.push(fieldText("stopping", card.stoppingPoint));
523
+ if (card.specHash) parts.push(fieldText("specHash", card.specHash));
524
+ if (card.dodHash) parts.push(fieldText("dodHash", card.dodHash));
525
+ if (card.specText !== null && card.specText !== undefined) parts.push(fieldText("specText", encodeFieldText(card.specText)));
526
+ if (card.dodText !== null && card.dodText !== undefined) parts.push(fieldText("dodText", encodeFieldText(card.dodText)));
527
+ if ((card.scope ?? []).length > 0) parts.push(fieldText("scope", card.scope.join(", ")));
528
+ if ((card.unchangedPaths ?? []).length > 0) parts.push(fieldText("unchanged", card.unchangedPaths.join(", ")));
529
+ if ((card.repositories ?? []).length > 0) parts.push(fieldText("repos", card.repositories.join(", ")));
530
+ if ((card.tags ?? []).length > 0) parts.push(fieldText("tags", card.tags.join(", ")));
531
+ if (card.provenance) parts.push(fieldText("provenance", card.provenance));
532
+ if (card.importedId) parts.push(fieldText("importedId", card.importedId));
533
+ if (card.authoritySource) parts.push(fieldText("authority", JSON.stringify(card.authoritySource)));
534
+ if (card.authorityWriterHmac) parts.push(fieldText("authorityHmac", card.authorityWriterHmac));
535
+ let out = `- ${parts.join(" ")}`;
536
+ if (card.description) out += `\n ${card.description.replace(/\n/g, "\n ")}`;
537
+ return out;
538
+ }
539
+
540
+ export function serializeBoard(cards, { surface }) {
541
+ const sections = [];
542
+ for (const lane of LANES) {
543
+ const laneCards = cards.filter((card) => card.lane === lane);
544
+ const body = laneCards.map((card) => surface === "obsidian" ? serializeObsidianCard(card) : serializeTasksCard(card));
545
+ sections.push(`## ${lane}${body.length > 0 ? `\n\n${body.join("\n")}` : ""}`);
546
+ }
547
+ return sections.join("\n\n") + "\n";
548
+ }
549
+
550
+ // ---------------------------------------------------------------------------
551
+ // Trusted board writer (§3.5). Allocates the cardId, canonicalises and hashes,
552
+ // validates, persists atomically, records the authority source. Models never
553
+ // supply identifiers or hashes.
554
+ // ---------------------------------------------------------------------------
555
+
556
+ export function allocateCardId(cards, { prefix = "T" } = {}) {
557
+ let max = 0;
558
+ for (const card of cards) {
559
+ const match = typeof card.cardId === "string" ? card.cardId.match(new RegExp(`^${prefix}-(\\d+)$`)) : null;
560
+ if (match) max = Math.max(max, Number(match[1]));
561
+ }
562
+ return `${prefix}-${String(max + 1).padStart(4, "0")}`;
563
+ }
564
+
565
+ // The high-water mark, the per-board HMAC secret, and the issued-cardId
566
+ // ledger are tracked durably in a writer state file next to the board
567
+ // (§3.5: IDs are minted by the writer and never reused).
568
+ //
569
+ // TRUST MODEL (F1, stated honestly): the user who owns the machine can edit
570
+ // both the board and this state file and can always forge a valid-looking
571
+ // authority record. That is accepted — the machine owner is trusted. The
572
+ // boundary this scheme enforces is against AGENT and other-figure edits:
573
+ // cards not written through the trusted writer cannot dispatch, because
574
+ // dispatch requires (a) the cardId to appear in the writer's issued-IDs
575
+ // ledger in the state file, and (b) the authority record's HMAC-SHA256,
576
+ // keyed by the state-file secret, to verify. A hand-edited card with a new
577
+ // cardId is not in the ledger; a hand-edited card reusing an issued cardId
578
+ // fails the HMAC or the hash comparison. An agent that edits only the board
579
+ // file cannot manufacture dispatch eligibility.
580
+ export function writerStatePath(boardPath) {
581
+ return `${boardPath}.writer-state.json`;
582
+ }
583
+
584
+ function readWriterState(statePath) {
585
+ try {
586
+ const state = JSON.parse(readFileSync(statePath, "utf8"));
587
+ const value = Number(state?.highWaterMark);
588
+ return {
589
+ highWaterMark: Number.isInteger(value) && value >= 0 ? value : 0,
590
+ secret: typeof state?.secret === "string" && state.secret !== "" ? state.secret : null,
591
+ issuedCardIds: Array.isArray(state?.issuedCardIds) ? state.issuedCardIds.filter((id) => typeof id === "string") : [],
592
+ };
593
+ } catch {
594
+ return { highWaterMark: 0, secret: null, issuedCardIds: [] };
595
+ }
596
+ }
597
+
598
+ // HMAC over the canonical JSON form of {authority record, card hash}, keyed
599
+ // by the per-board secret held in the writer state file (F1). Binding the
600
+ // card hash into the HMAC means a hand-edited card that reuses an issued
601
+ // cardId and copies the record fails: any hash-bearing edit changes the card
602
+ // hash and the HMAC no longer verifies. The digest is stored beside the
603
+ // record — the record itself keeps exactly its three closed fields.
604
+ export function authorityRecordHmac(record, secret, cardHash) {
605
+ return createHmac("sha256", secret)
606
+ .update(canonicalJsonString({ record, cardHash }), "utf8")
607
+ .digest("hex");
608
+ }
609
+
610
+ // Dispatch-time authority verification (F1): the record must be well-formed,
611
+ // carry a writerHmac that verifies against the state file's secret AND the
612
+ // card's recomputed hash, and the cardId must appear in the writer's
613
+ // issued-IDs ledger. A hand-edited card fails at least one of these.
614
+ export function verifyAuthorityProvenance({ authoritySource, cardId, cardHash, statePath }) {
615
+ const state = readWriterState(statePath);
616
+ // The writerHmac travels beside the record; validate the bare record.
617
+ const { writerHmac, ...bareRecord } = authoritySource ?? {};
618
+ if (!isValidAuthoritySource(bareRecord)) {
619
+ return { ok: false, reason: "no well-formed authority-source record (fails closed)" };
620
+ }
621
+ if (state.secret === null) {
622
+ return { ok: false, reason: "writer state file has no secret (fails closed)" };
623
+ }
624
+ const expected = authorityRecordHmac(bareRecord, state.secret, cardHash);
625
+ if (writerHmac !== expected) {
626
+ return { ok: false, reason: "authority-source HMAC does not verify against the writer state (fails closed)" };
627
+ }
628
+ if (!state.issuedCardIds.includes(cardId)) {
629
+ return { ok: false, reason: `cardId "${cardId}" was not issued by the trusted writer (fails closed)` };
630
+ }
631
+ return { ok: true, reason: null };
632
+ }
633
+
634
+ function writeWriterState(statePath, state) {
635
+ const tmpPath = `${statePath}.tmp-${process.pid}-${Date.now()}`;
636
+ writeFileSync(tmpPath, JSON.stringify(state, null, 2) + "\n", "utf8");
637
+ chmodSync(tmpPath, 0o600);
638
+ renameSync(tmpPath, statePath);
639
+ }
640
+
641
+ function nextCardNumber(cards, prefix, state) {
642
+ let max = state.highWaterMark;
643
+ const re = new RegExp(`^${prefix}-(\\d+)$`);
644
+ for (const card of cards) {
645
+ const match = typeof card.cardId === "string" ? card.cardId.match(re) : null;
646
+ if (match) max = Math.max(max, Number(match[1]));
647
+ }
648
+ return max + 1;
649
+ }
650
+
651
+ export function formatCardId(prefix, number) {
652
+ return `${prefix}-${String(number).padStart(4, "0")}`;
653
+ }
654
+
655
+ // Writer serialization (§6 gate 2): a lock file created exclusively next to
656
+ // the board. The lock content is a random owner token (F3): a stale lock is
657
+ // reclaimed only by compare-and-delete — the reclaimer reads the observed
658
+ // token, and unlinks only if the content still equals that token at unlink
659
+ // time. Each writer's finally unlinks only if the content still equals its
660
+ // own token, so a second writer can never unlink a live lock out from under
661
+ // the first, and the first can never unlink the second's.
662
+ const LOCK_TTL_MS = 30_000;
663
+
664
+ export function writerLockPath(boardPath) {
665
+ return `${boardPath}.lock`;
666
+ }
667
+
668
+ function readLockToken(lockPath) {
669
+ try {
670
+ return readFileSync(lockPath, "utf8");
671
+ } catch {
672
+ return null;
673
+ }
674
+ }
675
+
676
+ // Compare-and-delete: unlink only if the content still equals the expected
677
+ // token. Returns true when this caller removed the lock.
678
+ function unlinkIfToken(lockPath, expectedToken) {
679
+ const observed = readLockToken(lockPath);
680
+ if (observed === null || observed !== expectedToken) return false;
681
+ try {
682
+ unlinkSync(lockPath);
683
+ return true;
684
+ } catch {
685
+ return false;
686
+ }
687
+ }
688
+
689
+ export function withWriterLock(boardPath, fn) {
690
+ const lockPath = writerLockPath(boardPath);
691
+ mkdirSync(dirname(boardPath), { recursive: true });
692
+ for (;;) {
693
+ const token = randomBytes(16).toString("hex") + "\n";
694
+ let fd = null;
695
+ try {
696
+ fd = openSync(lockPath, "wx");
697
+ writeFileSync(lockPath, token, { flag: "r+" });
698
+ } catch (error) {
699
+ if (fd !== null) {
700
+ try { closeSync(fd); } catch {}
701
+ }
702
+ if (error?.code !== "EEXIST") throw error;
703
+ let age = null;
704
+ try {
705
+ age = Date.now() - Number(fsStatSync(lockPath).mtimeMs);
706
+ } catch {
707
+ age = null;
708
+ }
709
+ if (age === null || age > LOCK_TTL_MS) {
710
+ // F3: reclaim a stale lock by compare-and-delete against the token
711
+ // observed now. If another writer replaced it in the meantime, the
712
+ // token no longer matches and we retry without unlinking anything.
713
+ const observedToken = readLockToken(lockPath);
714
+ if (observedToken !== null && unlinkIfToken(lockPath, observedToken)) continue;
715
+ if (observedToken === null) continue; // vanished; retry the create
716
+ throw Object.assign(new Error("board writer lock is held by another writer"), { code: "writer-lock-held" });
717
+ }
718
+ throw Object.assign(new Error("board writer lock is held by another writer"), { code: "writer-lock-held" });
719
+ }
720
+ try {
721
+ return fn();
722
+ } finally {
723
+ try {
724
+ closeSync(fd);
725
+ } catch {}
726
+ // Only unlink if the lock still holds OUR token (F3).
727
+ unlinkIfToken(lockPath, token);
728
+ }
729
+ }
730
+ }
731
+
732
+ export function recordAuthoritySource({ source, sessionOrReportId, quotedInstruction, digest } = {}) {
733
+ if (source !== "instruction" && source !== "report-proposal") {
734
+ throw Object.assign(new Error(`authority source must be "instruction" or "report-proposal", got "${source}"`), {
735
+ code: "authority-source-invalid",
736
+ });
737
+ }
738
+ if (typeof sessionOrReportId !== "string" || sessionOrReportId.trim() === "") {
739
+ throw Object.assign(new Error("sessionOrReportId is required"), { code: "authority-source-invalid" });
740
+ }
741
+ if (typeof quotedInstruction === "string" && quotedInstruction.trim() !== "") {
742
+ return { source, sessionOrReportId, quotedInstruction };
743
+ } else if (typeof quotedInstruction === "string" && quotedInstruction.trim() === "") {
744
+ throw Object.assign(new Error("an authority source requires a quoted instruction or a digest of it"), {
745
+ code: "authority-source-invalid",
746
+ });
747
+ } else if (typeof digest === "string" && /^[0-9a-f]{64}$/.test(digest)) {
748
+ return { source, sessionOrReportId, digest };
749
+ } else {
750
+ throw Object.assign(new Error("an authority source requires a quoted instruction or a caller-supplied digest"), {
751
+ code: "authority-source-invalid",
752
+ });
753
+ }
754
+ }
755
+
756
+ // The board's declared ID prefix: the first cardId on the board, else "T".
757
+ // A model-supplied idPrefix is only honored when it matches the declared
758
+ // prefix, so a model cannot mint a foreign ID space (§3.5).
759
+ export function declaredBoardPrefix(cards) {
760
+ for (const card of cards) {
761
+ const match = typeof card.cardId === "string" ? card.cardId.match(/^([A-Za-z0-9][A-Za-z0-9._:-]{0,127})-(\d+)$/) : null;
762
+ if (match) return match[1];
763
+ }
764
+ return "T";
765
+ }
766
+
767
+ // Writes a card into a board file. Every step is deterministic; the write is
768
+ // atomic (temp file + rename) so it lands complete or not at all. Declines
769
+ // before persist on any validation violation. Writer operations are
770
+ // serialized with a lock file; IDs come from a durable high-water mark.
771
+ export function writeCard({ boardPath, input, authority, registries = {}, surface = "tasks", now = null, requireExistingBoard = false }) {
772
+ if (typeof boardPath !== "string" || boardPath === "") {
773
+ throw Object.assign(new Error("boardPath is required"), { code: "board-path-required" });
774
+ }
775
+ return withWriterLock(boardPath, () => writeCardLocked({ boardPath, input, authority, registries, surface, now, requireExistingBoard }));
776
+ }
777
+
778
+ function writeCardLocked({ boardPath, input, authority, registries, surface, now, requireExistingBoard }) {
779
+ // Authoritative board-presence check, made under the writer lock (TOCTOU
780
+ // fix): when requireExistingBoard is set — the tool path — a board deleted
781
+ // between the caller's outer observation and this locked write must fail
782
+ // closed as board-unavailable. Without this, the absent file would be
783
+ // treated as an empty board and silently recreated. The direct writer API
784
+ // retains fresh-board bootstrap (requireExistingBoard defaults to false);
785
+ // an empty file is a valid fresh board either way — only a missing file
786
+ // fails when the flag is set.
787
+ if (requireExistingBoard && !existsSync(boardPath)) {
788
+ return {
789
+ ok: false,
790
+ code: "board-unavailable",
791
+ reason: "board file is no longer present (board-unavailable)",
792
+ errors: ["board file is no longer present (board-unavailable)"],
793
+ persisted: false,
794
+ };
795
+ }
796
+ const markdown = existsSync(boardPath) ? readFileSync(boardPath, "utf8") : "";
797
+ // Existing boards are validated against the complete persisted
798
+ // representation, not merely parsed (§6 gate 2).
799
+ const validatedBoard = validateBoard(markdown, registries);
800
+ if (!validatedBoard.ok) {
801
+ return { ok: false, code: "board-invalid", errors: validatedBoard.errors, persisted: false };
802
+ }
803
+ const parsed = { cards: validatedBoard.cards };
804
+ const declaredPrefix = declaredBoardPrefix(parsed.cards);
805
+ const prefix = input.idPrefix ?? declaredPrefix;
806
+ if (!/^[A-Za-z0-9][A-Za-z0-9._:-]{0,63}$/.test(prefix) || prefix !== declaredPrefix) {
807
+ return {
808
+ ok: false,
809
+ code: "id-prefix-rejected",
810
+ errors: [`idPrefix "${prefix}" does not match the board's declared prefix "${declaredPrefix}"`],
811
+ persisted: false,
812
+ };
813
+ }
814
+ const statePath = writerStatePath(boardPath);
815
+ let state = readWriterState(statePath);
816
+ // F7(a): if the state file is missing but the board is non-empty, recover
817
+ // the high-water mark from the board and write the state file immediately
818
+ // under the lock. Deleting BOTH the board and the state file is out of
819
+ // scope — that is a fresh board.
820
+ if (state.secret === null && parsed.cards.length > 0) {
821
+ state = { highWaterMark: state.highWaterMark, secret: randomBytes(32).toString("hex"), issuedCardIds: [] };
822
+ writeWriterState(statePath, state);
823
+ } else if (state.secret === null) {
824
+ state = { highWaterMark: 0, secret: randomBytes(32).toString("hex"), issuedCardIds: [] };
825
+ }
826
+ const nextNumber = nextCardNumber(parsed.cards, prefix, state);
827
+ const cardId = formatCardId(prefix, nextNumber);
828
+ if (parsed.cards.some((card) => card.cardId === cardId)) {
829
+ return { ok: false, code: "duplicate-card-id", errors: [`cardId "${cardId}" already exists`], persisted: false };
830
+ }
831
+ const specText = input.spec !== undefined && input.spec !== null ? nfc(String(input.spec)) : null;
832
+ const dodText = input.definitionOfDone !== undefined && input.definitionOfDone !== null ? nfc(String(input.definitionOfDone)) : null;
833
+ // F2: spec/DoD text must be non-empty — an empty specification can never
834
+ // dispatch.
835
+ if (specText !== null && specText.trim() === "") {
836
+ return { ok: false, code: "empty-specification", errors: ["specification text must be non-empty"], persisted: false };
837
+ }
838
+ if (dodText !== null && dodText.trim() === "") {
839
+ return { ok: false, code: "empty-definition-of-done", errors: ["definition-of-done text must be non-empty"], persisted: false };
840
+ }
841
+ const card = {
842
+ cardId,
843
+ lane: input.lane ?? "backlog",
844
+ title: sanitizeFreeText(input.title),
845
+ flags: [...(input.flags ?? [])],
846
+ priority: input.priority ?? null,
847
+ dependencies: [...(input.dependencies ?? [])],
848
+ base: input.base ?? null,
849
+ due: input.due ?? null,
850
+ role: input.role ?? null,
851
+ capabilities: [...(input.capabilities ?? [])],
852
+ stoppingPoint: input.stoppingPoint !== null && input.stoppingPoint !== undefined
853
+ ? sanitizeFreeText(input.stoppingPoint)
854
+ : null,
855
+ specHash: specText !== null ? computeSpecHash(specText) : null,
856
+ dodHash: dodText !== null ? computeSpecHash(dodText) : null,
857
+ specText,
858
+ dodText,
859
+ scope: [...(input.scope ?? [])],
860
+ unchangedPaths: [...(input.unchangedPaths ?? [])],
861
+ repositories: [...(input.repositories ?? [])],
862
+ tags: [...(input.tags ?? [])],
863
+ provenance: input.provenance !== undefined && input.provenance !== null ? sanitizeFreeText(input.provenance) : null,
864
+ importedId: input.importedId ?? null,
865
+ authoritySource: recordAuthoritySource(authority),
866
+ description: sanitizeFreeText(input.description),
867
+ done: false,
868
+ };
869
+ const validation = validateCard(card, { ...registries, userLevel: input.userLevel ?? false });
870
+ if (!validation.ok) {
871
+ return { ok: false, code: "validation-failed", errors: validation.errors, persisted: false, cardId };
872
+ }
873
+ card.hash = computeCardHash(card);
874
+ card.hash = computeCardHash(card);
875
+ // F1: writer-authenticated provenance — HMAC over {record, cardHash} keyed
876
+ // by the state-file secret, stored beside the record.
877
+ card.authorityWriterHmac = authorityRecordHmac(card.authoritySource, state.secret, card.hash);
878
+ const existingCards = parsed.cards.map((existing) => ({ ...existing, hash: existing.hash ?? computeCardHash(existing) }));
879
+ const serialized = serializeBoard([...existingCards, card], { surface });
880
+ // The complete resulting board representation is validated before the
881
+ // atomic rename (§6 gate 2) — not just the in-memory new card.
882
+ const roundTrip = validateBoard(serialized, registries);
883
+ if (!roundTrip.ok) {
884
+ return { ok: false, code: "serialization-invalid", errors: roundTrip.errors, persisted: false, cardId };
885
+ }
886
+ mkdirSync(dirname(boardPath), { recursive: true });
887
+ const tmpPath = `${boardPath}.tmp-${process.pid}-${Date.now()}`;
888
+ writeFileSync(tmpPath, serialized, "utf8");
889
+ renameSync(tmpPath, boardPath);
890
+ // F1: the issued-IDs ledger is updated atomically with the card write,
891
+ // under the same lock. The HMAC over the authority record is computed
892
+ // against the state-file secret and stored beside the record.
893
+ state.highWaterMark = nextNumber;
894
+ if (!state.issuedCardIds.includes(cardId)) state.issuedCardIds.push(cardId);
895
+ writeWriterState(statePath, state);
896
+ return { ok: true, cardId, card: Object.freeze({ ...card }), persisted: true, ...(now ? { now } : {}) };
897
+ }
898
+
899
+ // ---------------------------------------------------------------------------
900
+ // Dispatchability (§3.3) — a pure function. The validator states which
901
+ // condition failed.
902
+ // ---------------------------------------------------------------------------
903
+
904
+ export function isDispatchable(card, boardIndex) {
905
+ const failed = [];
906
+ if (card.lane !== "backlog") failed.push(`lane is "${card.lane}", not "backlog"`);
907
+ if ((card.flags ?? []).includes("proposed")) failed.push("card carries the #proposed flag");
908
+ if ((card.flags ?? []).includes("blocked")) failed.push("card carries the #blocked flag");
909
+ if ((card.flags ?? []).includes("cancelled")) failed.push("card carries the #cancelled flag");
910
+ if (!card.specHash) failed.push("specification hash missing");
911
+ if (!card.dodHash) failed.push("definition-of-done hash missing");
912
+ // B1: a present, valid, matching card hash is REQUIRED — a missing hash
913
+ // never dispatches (fails closed).
914
+ if (!card.hash) {
915
+ failed.push("card hash missing (fails closed)");
916
+ } else if (card.hash !== computeCardHash(card)) {
917
+ failed.push("card hash is stale or tampered (fails closed)");
918
+ }
919
+ // B2: spec and DoD hashes are recomputed from the persisted text and
920
+ // compared; a mismatch or absent text fails closed.
921
+ if (card.specText === null || card.specText === undefined) {
922
+ failed.push("specification text missing (hash cannot be verified)");
923
+ } else if (String(card.specText).trim() === "") {
924
+ // F2: an empty specification never dispatches.
925
+ failed.push("specification text is empty (fails closed)");
926
+ } else if (computeSpecHash(card.specText) !== card.specHash) {
927
+ failed.push("specification hash does not match the persisted specification text (fails closed)");
928
+ }
929
+ if (card.dodText === null || card.dodText === undefined) {
930
+ failed.push("definition-of-done text missing (hash cannot be verified)");
931
+ } else if (String(card.dodText).trim() === "") {
932
+ // F2: an empty definition of done never dispatches.
933
+ failed.push("definition-of-done text is empty (fails closed)");
934
+ } else if (computeSpecHash(card.dodText) !== card.dodHash) {
935
+ failed.push("definition-of-done hash does not match the persisted text (fails closed)");
936
+ }
937
+ // B4: dispatch requires a well-formed authority-source record (§3.5).
938
+ // F1: when the writer state is available, the record must additionally
939
+ // verify against it — the HMAC over {record, cardHash} must match the
940
+ // state-file secret, and the cardId must be in the writer's issued-IDs
941
+ // ledger. Cards not written through the trusted writer cannot dispatch.
942
+ if (card.authoritySource === null || card.authoritySource === undefined) {
943
+ failed.push("no well-formed authority-source record (fails closed)");
944
+ } else if (typeof card.statePath === "string" && card.statePath !== "") {
945
+ const provenance = verifyAuthorityProvenance({
946
+ authoritySource: { ...card.authoritySource, writerHmac: card.authorityWriterHmac },
947
+ cardId: card.cardId,
948
+ cardHash: card.hash ?? null,
949
+ statePath: card.statePath,
950
+ });
951
+ if (!provenance.ok) failed.push(provenance.reason);
952
+ } else {
953
+ // No writer state available: the bare record must still be well-formed
954
+ // and writer-authenticated.
955
+ const bare = { ...card.authoritySource };
956
+ delete bare.writerHmac;
957
+ if (!isValidAuthoritySource(bare)) {
958
+ failed.push("no well-formed authority-source record (fails closed)");
959
+ } else if (card.authorityWriterHmac === undefined || card.authorityWriterHmac === null || "writerHmac" in card.authoritySource) {
960
+ // The HMAC lives beside the record; a record carrying it inline was not
961
+ // written by the trusted writer.
962
+ failed.push("authority-source provenance missing or malformed (fails closed)");
963
+ }
964
+ }
965
+ if (!card.stoppingPoint) failed.push("stopping point not declared");
966
+ if (!(card.scope ?? []).length) failed.push("scope paths not declared");
967
+ for (const dep of card.dependencies ?? []) {
968
+ const depCard = boardIndex?.get?.(dep);
969
+ if (!depCard) {
970
+ failed.push(`dependency "${dep}" does not exist`);
971
+ } else if (depCard.lane !== "done") {
972
+ failed.push(`dependency "${dep}" is in lane "${depCard.lane}", not "done"`);
973
+ } else if ((depCard.flags ?? []).includes("cancelled")) {
974
+ failed.push(`dependency "${dep}" is cancelled`);
975
+ }
976
+ }
977
+ return { dispatchable: failed.length === 0, failedConditions: failed };
978
+ }
979
+
980
+ // ---------------------------------------------------------------------------
981
+ // Provider observation (§5 reversibility): everything registers behind the
982
+ // observation that a board file exists. No board file, no behavior change and
983
+ // no new tool.
984
+ // ---------------------------------------------------------------------------
985
+
986
+ export function observeBoardProvider({ boardPath }) {
987
+ const present = typeof boardPath === "string" && boardPath !== "" && existsSync(boardPath);
988
+ return { present, boardPath: present ? boardPath : null };
989
+ }
990
+
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.
997
+ const registered = [];
998
+ if (typeof pi?.registerTool === "function") {
999
+ pi.registerTool({
1000
+ name: "agentic_kanban_board",
1001
+ label: "Kanban Board",
1002
+ 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
+ parameters: { type: "object", additionalProperties: false, properties: {} },
1004
+ 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 };
1019
+ }
1020
+ let value;
1021
+ try {
1022
+ const markdown = readFileSync(resolvedBoardPath, "utf8");
1023
+ const validated = validateBoard(markdown);
1024
+ value = validated.ok
1025
+ ? { ok: true, nonAuthorizing: true, persisted: false, cards: validated.cards, errors: [] }
1026
+ : { ok: false, nonAuthorizing: true, persisted: false, cards: [], errors: validated.errors };
1027
+ } catch (error) {
1028
+ value = { ok: false, nonAuthorizing: true, persisted: false, cards: [], errors: [String(error?.message || error).slice(0, 512)] };
1029
+ }
1030
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1031
+ },
1032
+ });
1033
+ registered.push("agentic_kanban_board");
1034
+ }
1035
+ // The write tool (§3.5): card creation goes through the trusted writer
1036
+ // only. The tool never accepts a model-supplied cardId (the writer mints
1037
+ // it) and never accepts hashes (the writer computes them). It DOES accept
1038
+ // the authority record — that record is the governance input this tool
1039
+ // exists to capture: the user's instruction, or the user's approved
1040
+ // report proposal, quoted verbatim. A card without a genuine authority
1041
+ // record cannot be created.
1042
+ if (typeof pi?.registerTool === "function") {
1043
+ pi.registerTool({
1044
+ name: "agentic_kanban_board_write",
1045
+ label: "Kanban Board Write",
1046
+ description:
1047
+ "Create a 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: either the user's actual instruction to write this card, or the user's approved report proposal, with the user's words quoted verbatim. A card without a genuine authority record cannot be created; agents must quote the user's actual instruction and must never invent, paraphrase-as-quote, or fabricate one. The writer allocates the cardId and computes the integrity hashes; do not supply either.",
1048
+ parameters: {
1049
+ type: "object",
1050
+ additionalProperties: false,
1051
+ properties: {
1052
+ title: { type: "string", description: "Card title (human-visible)." },
1053
+ description: { type: "string", description: "Optional longer description." },
1054
+ priority: { type: "string", enum: [...PRIORITIES], description: "Optional priority (P0-P3)." },
1055
+ lane: { type: "string", enum: [...LANES], description: "Optional lane; defaults to backlog." },
1056
+ specification: { type: "string", description: "Specification text (hashed by the writer). Required for a dispatchable card." },
1057
+ definitionOfDone: { type: "string", description: "Definition-of-done text (hashed by the writer)." },
1058
+ stoppingPoint: { type: "string", description: "Declared stopping point for review." },
1059
+ scopePaths: { type: "array", items: { type: "string" }, description: "Repository-relative scope paths." },
1060
+ capabilities: { type: "array", items: { type: "string" }, description: "Allowed capability classes (validated against the board registry)." },
1061
+ dependencies: { type: "array", items: { type: "string" }, description: "Ordered blocked-by cardIds (existing cards)." },
1062
+ base: { type: "string", description: "Optional exact base revision: a full 40-hex Git commit SHA." },
1063
+ dueDate: { type: "string", description: "Optional due date, ISO yyyy-mm-dd." },
1064
+ flags: { type: "array", items: { type: "string", enum: [...FLAGS] }, description: "Optional flags (proposed/blocked/cancelled)." },
1065
+ authority: {
1066
+ type: "object",
1067
+ description: "REQUIRED authority record (§3.1/§3.5): { source: 'instruction' | 'report-proposal', sessionOrReportId, quotedInstruction }. Quote the user's actual instruction verbatim; never invent one.",
1068
+ additionalProperties: false,
1069
+ properties: {
1070
+ source: { type: "string", enum: ["instruction", "report-proposal"] },
1071
+ sessionOrReportId: { type: "string" },
1072
+ quotedInstruction: { type: "string", description: "The user's actual words. Required; a digest alone is not accepted through this tool." },
1073
+ },
1074
+ required: ["source", "sessionOrReportId", "quotedInstruction"],
1075
+ },
1076
+ },
1077
+ required: ["title", "specification", "definitionOfDone", "stoppingPoint", "scopePaths", "authority"],
1078
+ },
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
+ const resolvedBoardPath = (typeof resolveBoardPath === "function" ? resolveBoardPath(ctx?.cwd) : null) ?? boardPath ?? null;
1083
+ if (resolvedBoardPath === null || !existsSync(resolvedBoardPath)) {
1084
+ const value = {
1085
+ ok: false,
1086
+ persisted: false,
1087
+ boardUnavailable: true,
1088
+ code: "board-unavailable",
1089
+ reason: "no board.md or TASKS.md in this workspace (board-unavailable)",
1090
+ errors: ["no board.md or TASKS.md in this workspace (board-unavailable)"],
1091
+ };
1092
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1093
+ }
1094
+ // The writer allocates the cardId and computes all hashes; the tool
1095
+ // forwards only content and the authority record. Input is normalized
1096
+ // to the writer's field names; unknown fields are dropped here so the
1097
+ // writer's own validation is the single gate.
1098
+ const writerInput = {};
1099
+ if (input?.title !== undefined) writerInput.title = input.title;
1100
+ if (input?.description !== undefined) writerInput.description = input.description;
1101
+ if (input?.priority !== undefined) writerInput.priority = input.priority;
1102
+ if (input?.lane !== undefined) writerInput.lane = input.lane;
1103
+ if (input?.specification !== undefined) writerInput.spec = input.specification;
1104
+ if (input?.definitionOfDone !== undefined) writerInput.definitionOfDone = input.definitionOfDone;
1105
+ if (input?.stoppingPoint !== undefined) writerInput.stoppingPoint = input.stoppingPoint;
1106
+ if (input?.scopePaths !== undefined) writerInput.scope = input.scopePaths;
1107
+ if (input?.capabilities !== undefined) writerInput.capabilities = input.capabilities;
1108
+ if (input?.dependencies !== undefined) writerInput.dependencies = input.dependencies;
1109
+ if (input?.base !== undefined) writerInput.base = input.base;
1110
+ if (input?.dueDate !== undefined) writerInput.due = input.dueDate;
1111
+ if (input?.flags !== undefined) writerInput.flags = input.flags;
1112
+ // Required-field gate: the tool's contract requires these; a missing
1113
+ // one is a structured refusal before any write is attempted. The
1114
+ // authority record is deliberately NOT pre-gated: a missing or
1115
+ // malformed authority must surface as the writer's
1116
+ // authority-source-invalid refusal, so the governance reason is
1117
+ // always the one reported.
1118
+ const requiredFields = ["title", "specification", "definitionOfDone", "stoppingPoint", "scopePaths"];
1119
+ const missing = requiredFields.filter((field) => input?.[field] === undefined || input?.[field] === null || input?.[field] === ""
1120
+ || (Array.isArray(input?.[field]) && input[field].length === 0));
1121
+ if (missing.length > 0) {
1122
+ const value = {
1123
+ ok: false,
1124
+ persisted: false,
1125
+ code: "invalid-input",
1126
+ reason: `missing required field(s): ${missing.join(", ")}`,
1127
+ errors: [`missing required field(s): ${missing.join(", ")}`],
1128
+ };
1129
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1130
+ }
1131
+ // Writer errors become structured failures (ok:false with a code and
1132
+ // reason), never raw throws — the model sees the governance reason.
1133
+ let result;
1134
+ try {
1135
+ result = writeCard({
1136
+ boardPath: resolvedBoardPath,
1137
+ input: writerInput,
1138
+ authority: input?.authority,
1139
+ registries: {},
1140
+ surface: "tasks",
1141
+ requireExistingBoard: true,
1142
+ });
1143
+ } catch (error) {
1144
+ const code = typeof error?.code === "string" ? error.code : "writer-error";
1145
+ const value = {
1146
+ ok: false,
1147
+ persisted: false,
1148
+ code,
1149
+ reason: String(error?.message || error).slice(0, 512),
1150
+ errors: [String(error?.message || error).slice(0, 512)],
1151
+ };
1152
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1153
+ }
1154
+ let value;
1155
+ if (result.ok) {
1156
+ value = {
1157
+ ok: true,
1158
+ persisted: true,
1159
+ cardId: result.card.cardId,
1160
+ lane: result.card.lane,
1161
+ flags: [...(result.card.flags ?? [])],
1162
+ hashPresent: Boolean(result.card.hash),
1163
+ specHashPresent: Boolean(result.card.specHash),
1164
+ dodHashPresent: Boolean(result.card.dodHash),
1165
+ authorityWriterHmacPresent: Boolean(result.card.authorityWriterHmac),
1166
+ authoritySource: { ...result.card.authoritySource },
1167
+ };
1168
+ } else {
1169
+ value = {
1170
+ ok: false,
1171
+ persisted: false,
1172
+ code: result.code,
1173
+ reason: (result.errors ?? []).join("; ").slice(0, 512),
1174
+ errors: result.errors ?? [],
1175
+ ...(result.cardId ? { cardId: result.cardId } : {}),
1176
+ };
1177
+ }
1178
+ return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
1179
+ },
1180
+ });
1181
+ registered.push("agentic_kanban_board_write");
1182
+ }
1183
+ return { registered };
1184
+ }