@kontourai/flow-agents 0.2.0 → 0.4.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.
Files changed (53) hide show
  1. package/.github/workflows/release-please.yml +13 -1
  2. package/.github/workflows/runtime-compat.yml +1 -1
  3. package/AGENTS.md +8 -1
  4. package/CHANGELOG.md +41 -0
  5. package/README.md +38 -19
  6. package/build/src/cli/flow-kit.js +9 -4
  7. package/build/src/cli/runtime-adapter.js +9 -5
  8. package/build/src/cli/telemetry-doctor.js +4 -1
  9. package/build/src/runtime-adapters.js +34 -0
  10. package/build/src/tools/build-universal-bundles.js +18 -1
  11. package/console.telemetry.json +115 -20
  12. package/docs/_layouts/default.html +2 -0
  13. package/docs/index.md +8 -0
  14. package/docs/integrations/index.md +4 -0
  15. package/docs/integrations/knowledge-kit-live.md +211 -0
  16. package/docs/kit-authoring-guide.md +169 -0
  17. package/docs/spec/runtime-hook-surface.md +56 -3
  18. package/evals/acceptance/run.sh +10 -1
  19. package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
  20. package/evals/acceptance/test_pi_harness.sh +15 -0
  21. package/evals/integration/test_runtime_adapter_activation.sh +113 -1
  22. package/evals/static/test_universal_bundles.sh +10 -0
  23. package/integrations/strands/examples/knowledge_kit_live.py +461 -0
  24. package/integrations/strands/flow_agents_strands/steering.py +54 -1
  25. package/integrations/strands/tests/test_hooks.py +88 -0
  26. package/integrations/strands-ts/src/hooks.ts +104 -0
  27. package/integrations/strands-ts/test/test-steering.ts +159 -0
  28. package/kits/catalog.json +6 -0
  29. package/kits/knowledge/adapters/default-store/index.js +902 -0
  30. package/kits/knowledge/adapters/flow-runner/index.js +1469 -0
  31. package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
  32. package/kits/knowledge/adapters/similarity-vector/index.js +284 -0
  33. package/kits/knowledge/docs/README.md +328 -0
  34. package/kits/knowledge/docs/store-contract.md +650 -0
  35. package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
  36. package/kits/knowledge/evals/contract-suite/suite.test.js +675 -0
  37. package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
  38. package/kits/knowledge/evals/retirement/suite.test.js +1173 -0
  39. package/kits/knowledge/evals/similarity-vector/suite.test.js +685 -0
  40. package/kits/knowledge/evals/synthesis/suite.test.js +916 -0
  41. package/kits/knowledge/flows/compile.flow.json +60 -0
  42. package/kits/knowledge/flows/consolidate.flow.json +77 -0
  43. package/kits/knowledge/flows/ingest.flow.json +60 -0
  44. package/kits/knowledge/flows/retire.flow.json +77 -0
  45. package/kits/knowledge/flows/store-contract.flow.json +48 -0
  46. package/kits/knowledge/flows/synthesize.flow.json +77 -0
  47. package/kits/knowledge/kit.json +98 -0
  48. package/package.json +1 -1
  49. package/src/cli/flow-kit.ts +10 -4
  50. package/src/cli/runtime-adapter.ts +10 -5
  51. package/src/cli/telemetry-doctor.ts +4 -1
  52. package/src/runtime-adapters.ts +35 -0
  53. package/src/tools/build-universal-bundles.ts +18 -1
@@ -0,0 +1,902 @@
1
+ /**
2
+ * Knowledge Kit — Default Store Adapter
3
+ *
4
+ * Implements the Knowledge Kit store contract over:
5
+ * - Markdown files with YAML frontmatter (records/<id>.md)
6
+ * - [[wikilink]] style inline links
7
+ * - JSON graph index (graph-index.json)
8
+ *
9
+ * Zero runtime dependencies beyond Node.js built-ins.
10
+ * Store root is passed as a constructor argument: new DefaultKnowledgeStore({ storeRoot }).
11
+ *
12
+ * @module adapters/default-store
13
+ */
14
+
15
+ import * as fs from "node:fs";
16
+ import * as path from "node:path";
17
+ import { randomUUID } from "node:crypto";
18
+
19
+ // ---------------------------------------------------------------------------
20
+ // Error helpers
21
+ // ---------------------------------------------------------------------------
22
+
23
+ function missingEvidenceError(message) {
24
+ const err = new Error(message);
25
+ err.code = "MISSING_EVIDENCE";
26
+ return err;
27
+ }
28
+
29
+ function notFoundError(id) {
30
+ const err = new Error(`Record not found: ${id}`);
31
+ err.code = "NOT_FOUND";
32
+ return err;
33
+ }
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // YAML frontmatter codec (no external deps — handles the subset we need)
37
+ // ---------------------------------------------------------------------------
38
+
39
+ /**
40
+ * Parse a markdown file that begins with a YAML frontmatter block.
41
+ * Returns { meta, body }.
42
+ */
43
+ function parseMarkdown(text) {
44
+ if (!text.startsWith("---\n")) {
45
+ return { meta: {}, body: text };
46
+ }
47
+ const end = text.indexOf("\n---\n", 4);
48
+ if (end === -1) {
49
+ return { meta: {}, body: text };
50
+ }
51
+ const yaml = text.slice(4, end);
52
+ const body = text.slice(end + 5).replace(/^\n+/, "");
53
+ return { meta: parseYaml(yaml), body };
54
+ }
55
+
56
+ /**
57
+ * Minimal YAML parser: handles the scalar/list/nested-object subset
58
+ * emitted by serializeYaml below. Not a general YAML parser.
59
+ */
60
+ function parseYaml(yaml) {
61
+ const lines = yaml.split("\n");
62
+ return parseYamlLines(lines, 0, 0).value;
63
+ }
64
+
65
+ function parseYamlLines(lines, start, baseIndent) {
66
+ const obj = {};
67
+ let i = start;
68
+ while (i < lines.length) {
69
+ const line = lines[i];
70
+ if (line.trim() === "" || line.trim().startsWith("#")) { i++; continue; }
71
+ const indent = line.search(/\S/);
72
+ if (indent < baseIndent) break;
73
+ if (indent > baseIndent) { i++; continue; }
74
+
75
+ // key: value OR key:
76
+ const colonIdx = line.indexOf(":");
77
+ if (colonIdx === -1) { i++; continue; }
78
+ const key = line.slice(indent, colonIdx).trim();
79
+ const rest = line.slice(colonIdx + 1).trim();
80
+
81
+ if (rest.startsWith("[")) {
82
+ // Inline array: [a, b, c]
83
+ const inner = rest.slice(1, rest.lastIndexOf("]"));
84
+ obj[key] = inner ? inner.split(",").map((s) => unquote(s.trim())).filter(Boolean) : [];
85
+ i++;
86
+ } else if (rest === "") {
87
+ // Block: peek ahead
88
+ i++;
89
+ if (i < lines.length) {
90
+ const nextLine = lines[i];
91
+ const nextIndent = nextLine.search(/\S/);
92
+ if (nextIndent > baseIndent && nextLine.trimStart().startsWith("- ")) {
93
+ // Block sequence
94
+ const arr = [];
95
+ while (i < lines.length) {
96
+ const l = lines[i];
97
+ if (l.trim() === "") { i++; continue; }
98
+ const ind = l.search(/\S/);
99
+ if (ind < nextIndent) break;
100
+ if (l.trimStart().startsWith("- ")) {
101
+ const itemText = l.trimStart().slice(2).trim();
102
+ if (itemText.includes(": ") || (i + 1 < lines.length && lines[i + 1].search(/\S/) > ind + 1)) {
103
+ // Object item
104
+ const childLines = [" ".repeat(ind + 2) + itemText];
105
+ i++;
106
+ while (i < lines.length) {
107
+ const cl = lines[i];
108
+ const ci = cl.search(/\S/);
109
+ if (cl.trim() === "" || ci <= ind) break;
110
+ childLines.push(cl);
111
+ i++;
112
+ }
113
+ arr.push(parseYamlLines(childLines, 0, ind + 2).value);
114
+ } else {
115
+ arr.push(unquote(itemText));
116
+ i++;
117
+ }
118
+ } else {
119
+ break;
120
+ }
121
+ }
122
+ obj[key] = arr;
123
+ } else if (nextIndent > baseIndent) {
124
+ // Nested mapping
125
+ const result = parseYamlLines(lines, i, nextIndent);
126
+ obj[key] = result.value;
127
+ i = result.next;
128
+ } else {
129
+ obj[key] = null;
130
+ }
131
+ } else {
132
+ obj[key] = null;
133
+ }
134
+ } else {
135
+ obj[key] = unquote(rest);
136
+ i++;
137
+ }
138
+ }
139
+ return { value: obj, next: i };
140
+ }
141
+
142
+ function unquote(s) {
143
+ if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
144
+ return s.slice(1, -1);
145
+ }
146
+ return s;
147
+ }
148
+
149
+ /**
150
+ * Serialize an object to YAML-ish text suitable for frontmatter.
151
+ * Only handles strings, numbers, arrays of primitives, and shallow objects.
152
+ */
153
+ function serializeYaml(obj, indent = 0) {
154
+ const pad = " ".repeat(indent);
155
+ const lines = [];
156
+ for (const [key, value] of Object.entries(obj)) {
157
+ if (value === undefined || value === null) continue;
158
+ if (Array.isArray(value)) {
159
+ if (value.length === 0) {
160
+ lines.push(`${pad}${key}: []`);
161
+ } else if (value.every((v) => typeof v !== "object")) {
162
+ lines.push(`${pad}${key}: [${value.map(yamlScalar).join(", ")}]`);
163
+ } else {
164
+ lines.push(`${pad}${key}:`);
165
+ for (const item of value) {
166
+ if (typeof item === "object" && item !== null) {
167
+ const entries = Object.entries(item).filter(([, v]) => v !== undefined && v !== null);
168
+ if (entries.length === 0) { lines.push(`${pad} - {}`); continue; }
169
+ const [firstKey, firstVal] = entries[0];
170
+ if (typeof firstVal === "object" && firstVal !== null && !Array.isArray(firstVal)) {
171
+ lines.push(`${pad} - ${firstKey}:`);
172
+ lines.push(serializeYaml(firstVal, indent + 6));
173
+ } else {
174
+ lines.push(`${pad} - ${firstKey}: ${yamlScalar(firstVal)}`);
175
+ }
176
+ for (const [k, v] of entries.slice(1)) {
177
+ if (typeof v === "object" && v !== null && !Array.isArray(v)) {
178
+ lines.push(`${pad} ${k}:`);
179
+ lines.push(serializeYaml(v, indent + 6));
180
+ } else {
181
+ lines.push(`${pad} ${k}: ${yamlScalar(v)}`);
182
+ }
183
+ }
184
+ } else {
185
+ lines.push(`${pad} - ${yamlScalar(item)}`);
186
+ }
187
+ }
188
+ }
189
+ } else if (typeof value === "object") {
190
+ lines.push(`${pad}${key}:`);
191
+ lines.push(serializeYaml(value, indent + 2));
192
+ } else {
193
+ lines.push(`${pad}${key}: ${yamlScalar(value)}`);
194
+ }
195
+ }
196
+ return lines.join("\n");
197
+ }
198
+
199
+ function yamlScalar(v) {
200
+ if (typeof v === "string") {
201
+ // Quote if it contains special chars
202
+ if (/[:#\[\]{},&*?|<>=!%@`"'\n]/.test(v) || v.trim() !== v || v === "") {
203
+ return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
204
+ }
205
+ return v;
206
+ }
207
+ return String(v);
208
+ }
209
+
210
+ function serializeMarkdown(meta, body) {
211
+ return `---\n${serializeYaml(meta)}\n---\n\n${body}`;
212
+ }
213
+
214
+ // ---------------------------------------------------------------------------
215
+ // Wikilink parser / indexer
216
+ // ---------------------------------------------------------------------------
217
+
218
+ const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
219
+
220
+ /**
221
+ * Extract all [[target_id]] and [[target_id|label]] links from body text.
222
+ * Returns Link objects.
223
+ */
224
+ function extractWikilinks(body) {
225
+ const links = [];
226
+ for (const match of body.matchAll(WIKILINK_RE)) {
227
+ links.push({ target_id: match[1].trim(), kind: "related", label: match[2]?.trim() });
228
+ }
229
+ return links;
230
+ }
231
+
232
+ /**
233
+ * Merge explicit links array with wikilink-derived links.
234
+ * De-duplicates by (target_id, kind); explicit links win on conflict.
235
+ */
236
+ function mergeLinks(explicit, wikilinks) {
237
+ const key = (l) => `${l.target_id}::${l.kind}`;
238
+ const seen = new Set(explicit.map(key));
239
+ const merged = [...explicit];
240
+ for (const wl of wikilinks) {
241
+ if (!seen.has(key(wl))) {
242
+ merged.push(wl);
243
+ seen.add(key(wl));
244
+ }
245
+ }
246
+ return merged;
247
+ }
248
+
249
+ // ---------------------------------------------------------------------------
250
+ // Graph index
251
+ // ---------------------------------------------------------------------------
252
+
253
+ const GRAPH_SCHEMA_VERSION = "1.0";
254
+
255
+ function emptyGraph() {
256
+ return { schema_version: GRAPH_SCHEMA_VERSION, forward: {}, reverse: {} };
257
+ }
258
+
259
+ function loadGraph(graphPath) {
260
+ if (!fs.existsSync(graphPath)) return emptyGraph();
261
+ try {
262
+ return JSON.parse(fs.readFileSync(graphPath, "utf8"));
263
+ } catch {
264
+ return emptyGraph();
265
+ }
266
+ }
267
+
268
+ function saveGraph(graphPath, graph) {
269
+ fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf8");
270
+ }
271
+
272
+ function addLinksToGraph(graph, sourceId, links) {
273
+ if (!graph.forward[sourceId]) graph.forward[sourceId] = [];
274
+ for (const link of links) {
275
+ const { target_id, kind, label } = link;
276
+ // Idempotent: skip if already present
277
+ const exists = graph.forward[sourceId].some(
278
+ (l) => l.target_id === target_id && l.kind === kind
279
+ );
280
+ if (!exists) {
281
+ const entry = { target_id, kind };
282
+ if (label) entry.label = label;
283
+ graph.forward[sourceId].push(entry);
284
+ if (!graph.reverse[target_id]) graph.reverse[target_id] = [];
285
+ graph.reverse[target_id].push({ source_id: sourceId, kind });
286
+ }
287
+ }
288
+ }
289
+
290
+ function removeLinksFromGraph(graph, sourceId) {
291
+ const oldForward = graph.forward[sourceId] || [];
292
+ for (const link of oldForward) {
293
+ const rev = graph.reverse[link.target_id] || [];
294
+ graph.reverse[link.target_id] = rev.filter((r) => r.source_id !== sourceId);
295
+ if (graph.reverse[link.target_id].length === 0) delete graph.reverse[link.target_id];
296
+ }
297
+ delete graph.forward[sourceId];
298
+ }
299
+
300
+ // ---------------------------------------------------------------------------
301
+ // Validation helpers
302
+ // ---------------------------------------------------------------------------
303
+
304
+ const VALID_TYPES = new Set(["raw", "compiled", "concept", "snapshot"]);
305
+ const VALID_STATUSES = new Set(["active", "implemented", "retired"]);
306
+ const CATEGORY_SEGMENT_RE = /^[a-z0-9_-]+$/;
307
+
308
+ // Status transition table: from → allowed targets
309
+ const VALID_STATUS_TRANSITIONS = {
310
+ active: new Set(["implemented", "retired"]),
311
+ implemented: new Set(["retired"]),
312
+ retired: new Set(), // terminal — no further transitions
313
+ };
314
+
315
+ function validateCategory(cat) {
316
+ if (!cat || typeof cat !== "string") return false;
317
+ return cat.split(".").every((seg) => CATEGORY_SEGMENT_RE.test(seg));
318
+ }
319
+
320
+ // ---------------------------------------------------------------------------
321
+ // DefaultKnowledgeStore
322
+ // ---------------------------------------------------------------------------
323
+
324
+ export class DefaultKnowledgeStore {
325
+ /**
326
+ * @param {{ storeRoot: string }} options
327
+ */
328
+ constructor({ storeRoot }) {
329
+ if (!storeRoot) throw new Error("storeRoot is required");
330
+ this._root = path.resolve(storeRoot);
331
+ this._recordsDir = path.join(this._root, "records");
332
+ this._graphPath = path.join(this._root, "graph-index.json");
333
+ fs.mkdirSync(this._recordsDir, { recursive: true });
334
+ }
335
+
336
+ // -------------------------------------------------------------------------
337
+ // Internal helpers
338
+ // -------------------------------------------------------------------------
339
+
340
+ _recordPath(id) {
341
+ return path.join(this._recordsDir, `${id}.md`);
342
+ }
343
+
344
+ _readRecord(id) {
345
+ const p = this._recordPath(id);
346
+ if (!fs.existsSync(p)) return null;
347
+ const text = fs.readFileSync(p, "utf8");
348
+ const { meta, body } = parseMarkdown(text);
349
+ return { ...meta, body };
350
+ }
351
+
352
+ _writeRecord(record) {
353
+ const { body, ...meta } = record;
354
+ const text = serializeMarkdown(meta, body);
355
+ fs.writeFileSync(this._recordPath(record.id), text, "utf8");
356
+ }
357
+
358
+ _now() {
359
+ return new Date().toISOString();
360
+ }
361
+
362
+ // -------------------------------------------------------------------------
363
+ // create
364
+ // -------------------------------------------------------------------------
365
+
366
+ async create(input) {
367
+ // Required field enforcement
368
+ if (!input.type) throw missingEvidenceError("create: missing required field: type");
369
+ if (!VALID_TYPES.has(input.type))
370
+ throw missingEvidenceError(`create: type must be raw, compiled, concept, or snapshot; got: ${input.type}`);
371
+ if (!input.title || !input.title.trim())
372
+ throw missingEvidenceError("create: missing required field: title");
373
+ if (!input.body && input.body !== "")
374
+ throw missingEvidenceError("create: missing required field: body");
375
+ if (input.body !== undefined && !input.body.trim && typeof input.body !== "string")
376
+ throw missingEvidenceError("create: body must be a string");
377
+ if (!input.category) throw missingEvidenceError("create: missing required field: category");
378
+ if (!validateCategory(input.category))
379
+ throw missingEvidenceError(`create: invalid category: ${input.category}`);
380
+ if (!input.provenance?.agent)
381
+ throw missingEvidenceError("create: missing required provenance field: provenance.agent");
382
+
383
+ const id = input.id || randomUUID();
384
+ const now = this._now();
385
+
386
+ // Merge explicit links + wikilinks from body
387
+ const explicitLinks = input.links || [];
388
+ const wikilinks = extractWikilinks(input.body || "");
389
+ const links = mergeLinks(explicitLinks, wikilinks);
390
+
391
+ const record = {
392
+ id,
393
+ type: input.type,
394
+ title: input.title,
395
+ category: input.category,
396
+ tags: input.tags || [],
397
+ status: "active",
398
+ created_at: now,
399
+ updated_at: now,
400
+ provenance: {
401
+ agent: input.provenance.agent,
402
+ ...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
403
+ ...(input.provenance.source_ids?.length ? { source_ids: input.provenance.source_ids } : {}),
404
+ ...(input.provenance.note ? { note: input.provenance.note } : {}),
405
+ },
406
+ links,
407
+ mutation_log: [],
408
+ body: input.body || "",
409
+ };
410
+
411
+ this._writeRecord(record);
412
+
413
+ // Update graph index
414
+ const graph = loadGraph(this._graphPath);
415
+ addLinksToGraph(graph, id, links);
416
+ saveGraph(this._graphPath, graph);
417
+
418
+ return id;
419
+ }
420
+
421
+ // -------------------------------------------------------------------------
422
+ // update
423
+ // -------------------------------------------------------------------------
424
+
425
+ async update(id, fields, evidence) {
426
+ if (!evidence?.agent)
427
+ throw missingEvidenceError("update: missing required evidence field: agent");
428
+
429
+ const record = this._readRecord(id);
430
+ if (!record) throw notFoundError(id);
431
+
432
+ const mutableKeys = ["title", "body", "category", "tags", "links"];
433
+ const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
434
+ if (supplied.length === 0)
435
+ throw missingEvidenceError("update: at least one mutable field must be supplied");
436
+
437
+ if (fields.category !== undefined && !validateCategory(fields.category))
438
+ throw missingEvidenceError(`update: invalid category: ${fields.category}`);
439
+
440
+ const now = this._now();
441
+
442
+ // Merge links if updated
443
+ let newLinks = record.links || [];
444
+ if (fields.links !== undefined) {
445
+ const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
446
+ newLinks = mergeLinks(fields.links, wikilinks);
447
+ } else if (fields.body !== undefined) {
448
+ const wikilinks = extractWikilinks(fields.body);
449
+ newLinks = mergeLinks(record.links || [], wikilinks);
450
+ }
451
+
452
+ const updated = {
453
+ ...record,
454
+ ...(fields.title !== undefined ? { title: fields.title } : {}),
455
+ ...(fields.body !== undefined ? { body: fields.body } : {}),
456
+ ...(fields.category !== undefined ? { category: fields.category } : {}),
457
+ ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
458
+ links: newLinks,
459
+ updated_at: now,
460
+ mutation_log: [
461
+ ...(record.mutation_log || []),
462
+ {
463
+ op: "update",
464
+ at: now,
465
+ agent: evidence.agent,
466
+ ...(evidence.note ? { note: evidence.note } : {}),
467
+ evidence: { fields: supplied },
468
+ },
469
+ ],
470
+ };
471
+
472
+ // Update graph index
473
+ const graph = loadGraph(this._graphPath);
474
+ removeLinksFromGraph(graph, id);
475
+ addLinksToGraph(graph, id, newLinks);
476
+ saveGraph(this._graphPath, graph);
477
+
478
+ this._writeRecord(updated);
479
+ }
480
+
481
+ // -------------------------------------------------------------------------
482
+ // link
483
+ // -------------------------------------------------------------------------
484
+
485
+ async link(sourceId, links, evidence) {
486
+ if (!evidence?.agent)
487
+ throw missingEvidenceError("link: missing required evidence field: agent");
488
+ if (!links || links.length === 0)
489
+ throw missingEvidenceError("link: links array must be non-empty");
490
+
491
+ const source = this._readRecord(sourceId);
492
+ if (!source) throw notFoundError(sourceId);
493
+
494
+ for (const l of links) {
495
+ if (!this._readRecord(l.target_id)) throw notFoundError(l.target_id);
496
+ }
497
+
498
+ const now = this._now();
499
+ const existingLinks = source.links || [];
500
+
501
+ // Idempotent merge
502
+ const key = (l) => `${l.target_id}::${l.kind}`;
503
+ const seen = new Set(existingLinks.map(key));
504
+ const newLinks = [...existingLinks];
505
+ for (const l of links) {
506
+ if (!seen.has(key(l))) {
507
+ newLinks.push(l);
508
+ seen.add(key(l));
509
+ }
510
+ }
511
+
512
+ const updated = {
513
+ ...source,
514
+ links: newLinks,
515
+ updated_at: now,
516
+ mutation_log: [
517
+ ...(source.mutation_log || []),
518
+ {
519
+ op: "link",
520
+ at: now,
521
+ agent: evidence.agent,
522
+ ...(evidence.note ? { note: evidence.note } : {}),
523
+ evidence: { added: links },
524
+ },
525
+ ],
526
+ };
527
+
528
+ const graph = loadGraph(this._graphPath);
529
+ removeLinksFromGraph(graph, sourceId);
530
+ addLinksToGraph(graph, sourceId, newLinks);
531
+ saveGraph(this._graphPath, graph);
532
+
533
+ this._writeRecord(updated);
534
+ }
535
+
536
+ // -------------------------------------------------------------------------
537
+ // propose
538
+ // -------------------------------------------------------------------------
539
+
540
+ async propose(conceptId, proposerId, evidence) {
541
+ if (!evidence?.agent)
542
+ throw missingEvidenceError("propose: missing required evidence field: agent");
543
+ if (!evidence?.proposal || !evidence.proposal.trim())
544
+ throw missingEvidenceError("propose: missing required evidence field: proposal");
545
+
546
+ const concept = this._readRecord(conceptId);
547
+ if (!concept) throw notFoundError(conceptId);
548
+ // Any record type may receive a proposal (retire flow uses this for all types)
549
+
550
+ const proposer = this._readRecord(proposerId);
551
+ if (!proposer) throw notFoundError(proposerId);
552
+
553
+ const now = this._now();
554
+
555
+ // Add proposes link from proposer to concept
556
+ const proposerLinks = proposer.links || [];
557
+ const alreadyLinked = proposerLinks.some(
558
+ (l) => l.target_id === conceptId && l.kind === "proposes"
559
+ );
560
+ if (!alreadyLinked) {
561
+ const updatedProposer = {
562
+ ...proposer,
563
+ links: [...proposerLinks, { target_id: conceptId, kind: "proposes" }],
564
+ updated_at: now,
565
+ mutation_log: [
566
+ ...(proposer.mutation_log || []),
567
+ {
568
+ op: "propose",
569
+ at: now,
570
+ agent: evidence.agent,
571
+ evidence: { concept_id: conceptId, proposal: evidence.proposal },
572
+ },
573
+ ],
574
+ };
575
+ this._writeRecord(updatedProposer);
576
+
577
+ const graph = loadGraph(this._graphPath);
578
+ removeLinksFromGraph(graph, proposerId);
579
+ addLinksToGraph(graph, proposerId, updatedProposer.links);
580
+ saveGraph(this._graphPath, graph);
581
+ }
582
+
583
+ // Append mutation log to concept
584
+ const updatedConcept = {
585
+ ...concept,
586
+ mutation_log: [
587
+ ...(concept.mutation_log || []),
588
+ {
589
+ op: "propose",
590
+ at: now,
591
+ agent: evidence.agent,
592
+ evidence: { proposer_id: proposerId, proposal: evidence.proposal },
593
+ },
594
+ ],
595
+ };
596
+ this._writeRecord(updatedConcept);
597
+ }
598
+
599
+ // -------------------------------------------------------------------------
600
+ // apply
601
+ // -------------------------------------------------------------------------
602
+
603
+ async apply(conceptId, proposerId, evidence) {
604
+ if (!evidence?.agent)
605
+ throw missingEvidenceError("apply: missing required evidence field: agent");
606
+ if (!evidence?.new_body && evidence?.new_body !== "")
607
+ throw missingEvidenceError("apply: missing required evidence field: new_body");
608
+ if (!evidence?.new_body?.trim?.())
609
+ throw missingEvidenceError("apply: new_body must be non-empty");
610
+ if (!evidence?.rationale || !evidence.rationale.trim())
611
+ throw missingEvidenceError("apply: missing required evidence field: rationale");
612
+
613
+ const concept = this._readRecord(conceptId);
614
+ if (!concept) throw notFoundError(conceptId);
615
+ // Any record type may be the apply target
616
+
617
+ const proposer = this._readRecord(proposerId);
618
+ if (!proposer) throw notFoundError(proposerId);
619
+
620
+ const proposerLinks = proposer.links || [];
621
+ const hasProposesLink = proposerLinks.some(
622
+ (l) => l.target_id === conceptId && l.kind === "proposes"
623
+ );
624
+ if (!hasProposesLink)
625
+ throw missingEvidenceError(`apply: no "proposes" link from ${proposerId} to ${conceptId}`);
626
+
627
+ const now = this._now();
628
+ const updatedConcept = {
629
+ ...concept,
630
+ body: evidence.new_body,
631
+ updated_at: now,
632
+ mutation_log: [
633
+ ...(concept.mutation_log || []),
634
+ {
635
+ op: "apply",
636
+ at: now,
637
+ agent: evidence.agent,
638
+ evidence: { proposer_id: proposerId, rationale: evidence.rationale },
639
+ },
640
+ ],
641
+ };
642
+ this._writeRecord(updatedConcept);
643
+ }
644
+
645
+ // -------------------------------------------------------------------------
646
+ // reject
647
+ // -------------------------------------------------------------------------
648
+
649
+ async reject(conceptId, proposerId, evidence) {
650
+ if (!evidence?.agent)
651
+ throw missingEvidenceError("reject: missing required evidence field: agent");
652
+ if (!evidence?.reason || !evidence.reason.trim())
653
+ throw missingEvidenceError("reject: missing required evidence field: reason");
654
+
655
+ const concept = this._readRecord(conceptId);
656
+ if (!concept) throw notFoundError(conceptId);
657
+ // Any record type may be the reject target
658
+
659
+ const proposer = this._readRecord(proposerId);
660
+ if (!proposer) throw notFoundError(proposerId);
661
+
662
+ const proposerLinks = proposer.links || [];
663
+ const hasProposesLink = proposerLinks.some(
664
+ (l) => l.target_id === conceptId && l.kind === "proposes"
665
+ );
666
+ if (!hasProposesLink)
667
+ throw missingEvidenceError(`reject: no "proposes" link from ${proposerId} to ${conceptId}`);
668
+
669
+ const now = this._now();
670
+ const updatedConcept = {
671
+ ...concept,
672
+ // updated_at NOT changed — concept body was not mutated
673
+ mutation_log: [
674
+ ...(concept.mutation_log || []),
675
+ {
676
+ op: "reject",
677
+ at: now,
678
+ agent: evidence.agent,
679
+ evidence: { proposer_id: proposerId, reason: evidence.reason },
680
+ },
681
+ ],
682
+ };
683
+ this._writeRecord(updatedConcept);
684
+ }
685
+
686
+
687
+ // -------------------------------------------------------------------------
688
+ // supersede
689
+ // -------------------------------------------------------------------------
690
+
691
+ async supersede(newId, supersededIds, evidence) {
692
+ if (!evidence?.agent)
693
+ throw missingEvidenceError("supersede: missing required evidence field: agent");
694
+ if (!evidence?.rationale || !evidence.rationale.trim())
695
+ throw missingEvidenceError("supersede: missing required evidence field: rationale");
696
+ if (!supersededIds || supersededIds.length === 0)
697
+ throw missingEvidenceError("supersede: supersededIds must be a non-empty array");
698
+
699
+ const newRecord = this._readRecord(newId);
700
+ if (!newRecord) throw notFoundError(newId);
701
+
702
+ // Verify all superseded records exist
703
+ for (const sid of supersededIds) {
704
+ const rec = this._readRecord(sid);
705
+ if (!rec) throw notFoundError(sid);
706
+ }
707
+
708
+ const now = this._now();
709
+
710
+ // Add supersedes links from newId to each superseded record
711
+ const supersededLinks = supersededIds.map((sid) => ({
712
+ target_id: sid,
713
+ kind: "supersedes",
714
+ }));
715
+
716
+ // Update newId record: add supersedes links + mutation log entry
717
+ const existingLinks = newRecord.links || [];
718
+ const key = (l) => `${l.target_id}::${l.kind}`;
719
+ const seen = new Set(existingLinks.map(key));
720
+ const newLinks = [...existingLinks];
721
+ for (const l of supersededLinks) {
722
+ if (!seen.has(key(l))) {
723
+ newLinks.push(l);
724
+ seen.add(key(l));
725
+ }
726
+ }
727
+
728
+ const updatedNew = {
729
+ ...newRecord,
730
+ links: newLinks,
731
+ updated_at: now,
732
+ mutation_log: [
733
+ ...(newRecord.mutation_log || []),
734
+ {
735
+ op: "supersede",
736
+ at: now,
737
+ agent: evidence.agent,
738
+ rationale: evidence.rationale,
739
+ ...(evidence.note ? { note: evidence.note } : {}),
740
+ evidence: { superseded_count: supersededIds.length },
741
+ },
742
+ ],
743
+ };
744
+
745
+ const graph = loadGraph(this._graphPath);
746
+ removeLinksFromGraph(graph, newId);
747
+ addLinksToGraph(graph, newId, newLinks);
748
+ saveGraph(this._graphPath, graph);
749
+
750
+ this._writeRecord(updatedNew);
751
+
752
+ // Append superseded-by mutation log entry to each superseded record
753
+ // Records are NOT deleted — supersede-not-delete invariant
754
+ for (const sid of supersededIds) {
755
+ const supersededRec = this._readRecord(sid);
756
+ if (!supersededRec) continue; // already verified above; defensive
757
+ const updatedSuperseded = {
758
+ ...supersededRec,
759
+ // updated_at NOT changed — the record content is not mutated
760
+ mutation_log: [
761
+ ...(supersededRec.mutation_log || []),
762
+ {
763
+ op: "superseded-by",
764
+ at: now,
765
+ agent: evidence.agent,
766
+ new_id: newId,
767
+ rationale: evidence.rationale,
768
+ ...(evidence.note ? { note: evidence.note } : {}),
769
+ evidence: { superseded_by_id: newId },
770
+ },
771
+ ],
772
+ };
773
+ this._writeRecord(updatedSuperseded);
774
+ }
775
+ }
776
+
777
+
778
+ // -------------------------------------------------------------------------
779
+ // retire (Addendum B — S7)
780
+ // -------------------------------------------------------------------------
781
+
782
+ async retire(id, targetStatus, evidence) {
783
+ if (!evidence?.agent)
784
+ throw missingEvidenceError("retire: missing required evidence field: agent");
785
+ if (!evidence?.rationale || !evidence.rationale.trim())
786
+ throw missingEvidenceError("retire: missing required evidence field: rationale");
787
+ if (targetStatus !== "implemented" && targetStatus !== "retired")
788
+ throw missingEvidenceError(
789
+ `retire: targetStatus must be "implemented" or "retired"; got: ${targetStatus}`
790
+ );
791
+ if (targetStatus === "implemented" && (!evidence.implementedByRef || !evidence.implementedByRef.trim()))
792
+ throw missingEvidenceError(
793
+ 'retire: implementedByRef is required when targetStatus is "implemented"'
794
+ );
795
+
796
+ const record = this._readRecord(id);
797
+ if (!record) throw notFoundError(id);
798
+
799
+ const currentStatus = record.status || "active";
800
+ const allowed = VALID_STATUS_TRANSITIONS[currentStatus];
801
+ if (!allowed || !allowed.has(targetStatus)) {
802
+ throw missingEvidenceError(
803
+ `retire: invalid transition from "${currentStatus}" to "${targetStatus}"`
804
+ );
805
+ }
806
+
807
+ const now = this._now();
808
+ const updated = {
809
+ ...record,
810
+ status: targetStatus,
811
+ updated_at: now,
812
+ mutation_log: [
813
+ ...(record.mutation_log || []),
814
+ {
815
+ op: "retire",
816
+ at: now,
817
+ agent: evidence.agent,
818
+ ...(evidence.note ? { note: evidence.note } : {}),
819
+ evidence: {
820
+ targetStatus,
821
+ rationale: evidence.rationale,
822
+ ...(evidence.implementedByRef ? { implementedByRef: evidence.implementedByRef } : {}),
823
+ ...(evidence.supersededByRef ? { supersededByRef: evidence.supersededByRef } : {}),
824
+ },
825
+ },
826
+ ],
827
+ };
828
+ this._writeRecord(updated);
829
+ }
830
+
831
+ // -------------------------------------------------------------------------
832
+ // get
833
+ // -------------------------------------------------------------------------
834
+
835
+ async get(id) {
836
+ return this._readRecord(id);
837
+ }
838
+
839
+ // -------------------------------------------------------------------------
840
+ // getLinks
841
+ // -------------------------------------------------------------------------
842
+
843
+ async getLinks(id) {
844
+ const graph = loadGraph(this._graphPath);
845
+ return {
846
+ forward: (graph.forward[id] || []).map((l) => ({ ...l })),
847
+ reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
848
+ };
849
+ }
850
+
851
+ // -------------------------------------------------------------------------
852
+ // listByCategory
853
+ // -------------------------------------------------------------------------
854
+
855
+ async listByCategory(category, options = {}) {
856
+ const records = this._allRecords();
857
+ const includeRetired = options.includeRetired === true;
858
+ if (options.prefix) {
859
+ return records.filter(
860
+ (r) =>
861
+ (r.category === category || r.category.startsWith(`${category}.`)) &&
862
+ (includeRetired || (r.status || "active") !== "retired")
863
+ );
864
+ }
865
+ return records.filter(
866
+ (r) =>
867
+ r.category === category &&
868
+ (includeRetired || (r.status || "active") !== "retired")
869
+ );
870
+ }
871
+
872
+ // -------------------------------------------------------------------------
873
+ // listByType
874
+ // -------------------------------------------------------------------------
875
+
876
+ async listByType(type, options = {}) {
877
+ const includeRetired = options.includeRetired === true;
878
+ return this._allRecords().filter(
879
+ (r) =>
880
+ r.type === type &&
881
+ (includeRetired || (r.status || "active") !== "retired")
882
+ );
883
+ }
884
+
885
+ // -------------------------------------------------------------------------
886
+ // Internal: read all records
887
+ // -------------------------------------------------------------------------
888
+
889
+ _allRecords() {
890
+ if (!fs.existsSync(this._recordsDir)) return [];
891
+ const files = fs.readdirSync(this._recordsDir).filter((f) => f.endsWith(".md"));
892
+ const records = [];
893
+ for (const file of files) {
894
+ const id = file.slice(0, -3);
895
+ const record = this._readRecord(id);
896
+ if (record) records.push(record);
897
+ }
898
+ return records;
899
+ }
900
+ }
901
+
902
+ export default DefaultKnowledgeStore;