@evoclock/pi-agentic-driver 0.7.0 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +34 -4
- package/extensions/task-board.ts +31 -0
- package/package.json +4 -2
- package/scripts/aidr_writing_review.js +19 -0
- package/scripts/enforcement/task_board_core_pi.js +1183 -0
package/README.md
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# pi-agentic-driver v0.
|
|
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.
|
|
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.
|
|
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.
|
|
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,31 @@
|
|
|
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 (§5 reversibility): the board surface registers
|
|
5
|
+
// only when a real board file is observed in the workspace. No board file,
|
|
6
|
+
// no behavior change and no new tool.
|
|
7
|
+
|
|
8
|
+
import { existsSync } from "node:fs";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
|
|
11
|
+
// Documented board locations (design §2): workspace-level Obsidian Kanban
|
|
12
|
+
// Markdown, or vogelkop's simpler TASKS.md shape.
|
|
13
|
+
const BOARD_FILENAMES = ["board.md", "TASKS.md"];
|
|
14
|
+
|
|
15
|
+
export function resolveBoardPath(cwd) {
|
|
16
|
+
if (typeof cwd !== "string" || cwd === "") return null;
|
|
17
|
+
for (const name of BOARD_FILENAMES) {
|
|
18
|
+
const candidate = join(cwd, name);
|
|
19
|
+
if (existsSync(candidate)) return candidate;
|
|
20
|
+
}
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default async function taskBoardPi(pi) {
|
|
25
|
+
const module = await import(new URL("../scripts/enforcement/task_board_core_pi.js", import.meta.url).href);
|
|
26
|
+
const boardPath = resolveBoardPath(pi?.ctx?.cwd);
|
|
27
|
+
if (boardPath === null) {
|
|
28
|
+
return { registered: [], observation: { present: false, boardPath: null } };
|
|
29
|
+
}
|
|
30
|
+
return module.registerKanbanBoardTools(pi, { boardPath });
|
|
31
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@evoclock/pi-agentic-driver",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
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,1183 @@
|
|
|
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, { boardPath } = {}) {
|
|
992
|
+
const observation = observeBoardProvider({ boardPath });
|
|
993
|
+
if (!observation.present) return { registered: [], observation };
|
|
994
|
+
// Additive, fail-closed: registered only behind the observation,
|
|
995
|
+
// so removing the board file removes the surface (§5).
|
|
996
|
+
const registered = [];
|
|
997
|
+
if (typeof pi?.registerTool === "function") {
|
|
998
|
+
pi.registerTool({
|
|
999
|
+
name: "agentic_kanban_board",
|
|
1000
|
+
label: "Kanban Board",
|
|
1001
|
+
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.",
|
|
1002
|
+
parameters: { type: "object", additionalProperties: false, properties: {} },
|
|
1003
|
+
async execute() {
|
|
1004
|
+
// F7(b): every tool call re-observes the board. If the board file was
|
|
1005
|
+
// removed after registration, return an observed board-unavailable
|
|
1006
|
+
// result instead of silently serving a stale board. Registration
|
|
1007
|
+
// stays; the surface is gated per call.
|
|
1008
|
+
if (!existsSync(observation.boardPath)) {
|
|
1009
|
+
const value = {
|
|
1010
|
+
ok: false,
|
|
1011
|
+
nonAuthorizing: true,
|
|
1012
|
+
persisted: false,
|
|
1013
|
+
boardUnavailable: true,
|
|
1014
|
+
cards: [],
|
|
1015
|
+
errors: ["board file is no longer present (board-unavailable)"],
|
|
1016
|
+
};
|
|
1017
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
1018
|
+
}
|
|
1019
|
+
let value;
|
|
1020
|
+
try {
|
|
1021
|
+
const markdown = readFileSync(observation.boardPath, "utf8");
|
|
1022
|
+
const validated = validateBoard(markdown);
|
|
1023
|
+
value = validated.ok
|
|
1024
|
+
? { ok: true, nonAuthorizing: true, persisted: false, cards: validated.cards, errors: [] }
|
|
1025
|
+
: { ok: false, nonAuthorizing: true, persisted: false, cards: [], errors: validated.errors };
|
|
1026
|
+
} catch (error) {
|
|
1027
|
+
value = { ok: false, nonAuthorizing: true, persisted: false, cards: [], errors: [String(error?.message || error).slice(0, 512)] };
|
|
1028
|
+
}
|
|
1029
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
1030
|
+
},
|
|
1031
|
+
});
|
|
1032
|
+
registered.push("agentic_kanban_board");
|
|
1033
|
+
}
|
|
1034
|
+
// The write tool (§3.5): card creation goes through the trusted writer
|
|
1035
|
+
// only. The tool never accepts a model-supplied cardId (the writer mints
|
|
1036
|
+
// it) and never accepts hashes (the writer computes them). It DOES accept
|
|
1037
|
+
// the authority record — that record is the governance input this tool
|
|
1038
|
+
// exists to capture: the user's instruction, or the user's approved
|
|
1039
|
+
// report proposal, quoted verbatim. A card without a genuine authority
|
|
1040
|
+
// record cannot be created.
|
|
1041
|
+
if (typeof pi?.registerTool === "function") {
|
|
1042
|
+
pi.registerTool({
|
|
1043
|
+
name: "agentic_kanban_board_write",
|
|
1044
|
+
label: "Kanban Board Write",
|
|
1045
|
+
description:
|
|
1046
|
+
"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.",
|
|
1047
|
+
parameters: {
|
|
1048
|
+
type: "object",
|
|
1049
|
+
additionalProperties: false,
|
|
1050
|
+
properties: {
|
|
1051
|
+
title: { type: "string", description: "Card title (human-visible)." },
|
|
1052
|
+
description: { type: "string", description: "Optional longer description." },
|
|
1053
|
+
priority: { type: "string", enum: [...PRIORITIES], description: "Optional priority (P0-P3)." },
|
|
1054
|
+
lane: { type: "string", enum: [...LANES], description: "Optional lane; defaults to backlog." },
|
|
1055
|
+
specification: { type: "string", description: "Specification text (hashed by the writer). Required for a dispatchable card." },
|
|
1056
|
+
definitionOfDone: { type: "string", description: "Definition-of-done text (hashed by the writer)." },
|
|
1057
|
+
stoppingPoint: { type: "string", description: "Declared stopping point for review." },
|
|
1058
|
+
scopePaths: { type: "array", items: { type: "string" }, description: "Repository-relative scope paths." },
|
|
1059
|
+
capabilities: { type: "array", items: { type: "string" }, description: "Allowed capability classes (validated against the board registry)." },
|
|
1060
|
+
dependencies: { type: "array", items: { type: "string" }, description: "Ordered blocked-by cardIds (existing cards)." },
|
|
1061
|
+
base: { type: "string", description: "Optional exact base revision: a full 40-hex Git commit SHA." },
|
|
1062
|
+
dueDate: { type: "string", description: "Optional due date, ISO yyyy-mm-dd." },
|
|
1063
|
+
flags: { type: "array", items: { type: "string", enum: [...FLAGS] }, description: "Optional flags (proposed/blocked/cancelled)." },
|
|
1064
|
+
authority: {
|
|
1065
|
+
type: "object",
|
|
1066
|
+
description: "REQUIRED authority record (§3.1/§3.5): { source: 'instruction' | 'report-proposal', sessionOrReportId, quotedInstruction }. Quote the user's actual instruction verbatim; never invent one.",
|
|
1067
|
+
additionalProperties: false,
|
|
1068
|
+
properties: {
|
|
1069
|
+
source: { type: "string", enum: ["instruction", "report-proposal"] },
|
|
1070
|
+
sessionOrReportId: { type: "string" },
|
|
1071
|
+
quotedInstruction: { type: "string", description: "The user's actual words. Required; a digest alone is not accepted through this tool." },
|
|
1072
|
+
},
|
|
1073
|
+
required: ["source", "sessionOrReportId", "quotedInstruction"],
|
|
1074
|
+
},
|
|
1075
|
+
},
|
|
1076
|
+
required: ["title", "specification", "definitionOfDone", "stoppingPoint", "scopePaths", "authority"],
|
|
1077
|
+
},
|
|
1078
|
+
async execute(_toolContext, input) {
|
|
1079
|
+
// F7(b): re-observe the board on every call. If the board file was
|
|
1080
|
+
// removed after registration, return board-unavailable instead of
|
|
1081
|
+
// writing to a stale path.
|
|
1082
|
+
if (!existsSync(observation.boardPath)) {
|
|
1083
|
+
const value = {
|
|
1084
|
+
ok: false,
|
|
1085
|
+
persisted: false,
|
|
1086
|
+
boardUnavailable: true,
|
|
1087
|
+
code: "board-unavailable",
|
|
1088
|
+
reason: "board file is no longer present (board-unavailable)",
|
|
1089
|
+
errors: ["board file is no longer present (board-unavailable)"],
|
|
1090
|
+
};
|
|
1091
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
1092
|
+
}
|
|
1093
|
+
// The writer allocates the cardId and computes all hashes; the tool
|
|
1094
|
+
// forwards only content and the authority record. Input is normalized
|
|
1095
|
+
// to the writer's field names; unknown fields are dropped here so the
|
|
1096
|
+
// writer's own validation is the single gate.
|
|
1097
|
+
const writerInput = {};
|
|
1098
|
+
if (input?.title !== undefined) writerInput.title = input.title;
|
|
1099
|
+
if (input?.description !== undefined) writerInput.description = input.description;
|
|
1100
|
+
if (input?.priority !== undefined) writerInput.priority = input.priority;
|
|
1101
|
+
if (input?.lane !== undefined) writerInput.lane = input.lane;
|
|
1102
|
+
if (input?.specification !== undefined) writerInput.spec = input.specification;
|
|
1103
|
+
if (input?.definitionOfDone !== undefined) writerInput.definitionOfDone = input.definitionOfDone;
|
|
1104
|
+
if (input?.stoppingPoint !== undefined) writerInput.stoppingPoint = input.stoppingPoint;
|
|
1105
|
+
if (input?.scopePaths !== undefined) writerInput.scope = input.scopePaths;
|
|
1106
|
+
if (input?.capabilities !== undefined) writerInput.capabilities = input.capabilities;
|
|
1107
|
+
if (input?.dependencies !== undefined) writerInput.dependencies = input.dependencies;
|
|
1108
|
+
if (input?.base !== undefined) writerInput.base = input.base;
|
|
1109
|
+
if (input?.dueDate !== undefined) writerInput.due = input.dueDate;
|
|
1110
|
+
if (input?.flags !== undefined) writerInput.flags = input.flags;
|
|
1111
|
+
// Required-field gate: the tool's contract requires these; a missing
|
|
1112
|
+
// one is a structured refusal before any write is attempted. The
|
|
1113
|
+
// authority record is deliberately NOT pre-gated: a missing or
|
|
1114
|
+
// malformed authority must surface as the writer's
|
|
1115
|
+
// authority-source-invalid refusal, so the governance reason is
|
|
1116
|
+
// always the one reported.
|
|
1117
|
+
const requiredFields = ["title", "specification", "definitionOfDone", "stoppingPoint", "scopePaths"];
|
|
1118
|
+
const missing = requiredFields.filter((field) => input?.[field] === undefined || input?.[field] === null || input?.[field] === ""
|
|
1119
|
+
|| (Array.isArray(input?.[field]) && input[field].length === 0));
|
|
1120
|
+
if (missing.length > 0) {
|
|
1121
|
+
const value = {
|
|
1122
|
+
ok: false,
|
|
1123
|
+
persisted: false,
|
|
1124
|
+
code: "invalid-input",
|
|
1125
|
+
reason: `missing required field(s): ${missing.join(", ")}`,
|
|
1126
|
+
errors: [`missing required field(s): ${missing.join(", ")}`],
|
|
1127
|
+
};
|
|
1128
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
1129
|
+
}
|
|
1130
|
+
// Writer errors become structured failures (ok:false with a code and
|
|
1131
|
+
// reason), never raw throws — the model sees the governance reason.
|
|
1132
|
+
let result;
|
|
1133
|
+
try {
|
|
1134
|
+
result = writeCard({
|
|
1135
|
+
boardPath: observation.boardPath,
|
|
1136
|
+
input: writerInput,
|
|
1137
|
+
authority: input?.authority,
|
|
1138
|
+
registries: {},
|
|
1139
|
+
surface: "tasks",
|
|
1140
|
+
requireExistingBoard: true,
|
|
1141
|
+
});
|
|
1142
|
+
} catch (error) {
|
|
1143
|
+
const code = typeof error?.code === "string" ? error.code : "writer-error";
|
|
1144
|
+
const value = {
|
|
1145
|
+
ok: false,
|
|
1146
|
+
persisted: false,
|
|
1147
|
+
code,
|
|
1148
|
+
reason: String(error?.message || error).slice(0, 512),
|
|
1149
|
+
errors: [String(error?.message || error).slice(0, 512)],
|
|
1150
|
+
};
|
|
1151
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
1152
|
+
}
|
|
1153
|
+
let value;
|
|
1154
|
+
if (result.ok) {
|
|
1155
|
+
value = {
|
|
1156
|
+
ok: true,
|
|
1157
|
+
persisted: true,
|
|
1158
|
+
cardId: result.card.cardId,
|
|
1159
|
+
lane: result.card.lane,
|
|
1160
|
+
flags: [...(result.card.flags ?? [])],
|
|
1161
|
+
hashPresent: Boolean(result.card.hash),
|
|
1162
|
+
specHashPresent: Boolean(result.card.specHash),
|
|
1163
|
+
dodHashPresent: Boolean(result.card.dodHash),
|
|
1164
|
+
authorityWriterHmacPresent: Boolean(result.card.authorityWriterHmac),
|
|
1165
|
+
authoritySource: { ...result.card.authoritySource },
|
|
1166
|
+
};
|
|
1167
|
+
} else {
|
|
1168
|
+
value = {
|
|
1169
|
+
ok: false,
|
|
1170
|
+
persisted: false,
|
|
1171
|
+
code: result.code,
|
|
1172
|
+
reason: (result.errors ?? []).join("; ").slice(0, 512),
|
|
1173
|
+
errors: result.errors ?? [],
|
|
1174
|
+
...(result.cardId ? { cardId: result.cardId } : {}),
|
|
1175
|
+
};
|
|
1176
|
+
}
|
|
1177
|
+
return { content: [{ type: "text", text: JSON.stringify(value, null, 2) }], details: value };
|
|
1178
|
+
},
|
|
1179
|
+
});
|
|
1180
|
+
registered.push("agentic_kanban_board_write");
|
|
1181
|
+
}
|
|
1182
|
+
return { registered, observation };
|
|
1183
|
+
}
|