@kontourai/flow-agents 0.1.2 → 0.3.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 (117) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/release-please.yml +31 -0
  3. package/.github/workflows/runtime-compat.yml +118 -0
  4. package/CHANGELOG.md +46 -0
  5. package/CONTRIBUTING.md +4 -0
  6. package/README.md +80 -18
  7. package/build/src/cli/flow-kit.js +9 -4
  8. package/build/src/cli/init.js +215 -5
  9. package/build/src/cli/runtime-adapter.js +9 -5
  10. package/build/src/cli/telemetry-doctor.js +4 -1
  11. package/build/src/cli/utterance-check.js +65 -1
  12. package/build/src/runtime-adapters.js +34 -0
  13. package/build/src/tools/build-universal-bundles.js +285 -0
  14. package/build/src/tools/filter-installed-packs.js +3 -0
  15. package/build/src/tools/validate-source-tree.js +5 -1
  16. package/console.telemetry.json +115 -20
  17. package/context/scripts/telemetry/lib/config.sh +5 -1
  18. package/context/settings/flow-agents-settings.json +7 -0
  19. package/docs/_layouts/default.html +2 -0
  20. package/docs/context-map.md +1 -0
  21. package/docs/index.md +53 -4
  22. package/docs/integrations/conformance.md +246 -0
  23. package/docs/integrations/framework-adapter.md +275 -0
  24. package/docs/integrations/harness-install.md +213 -0
  25. package/docs/integrations/index.md +58 -0
  26. package/docs/integrations/knowledge-kit-live.md +211 -0
  27. package/docs/kit-authoring-guide.md +169 -0
  28. package/docs/north-star.md +2 -2
  29. package/docs/spec/runtime-hook-surface.md +525 -0
  30. package/docs/survey-utterance-check.md +211 -94
  31. package/docs/vision.md +45 -0
  32. package/evals/acceptance/run.sh +13 -2
  33. package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
  34. package/evals/acceptance/test_opencode_harness.sh +121 -0
  35. package/evals/acceptance/test_pi_harness.sh +113 -0
  36. package/evals/integration/test_bundle_install.sh +226 -1
  37. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  38. package/evals/integration/test_runtime_adapter_activation.sh +113 -1
  39. package/evals/integration/test_utterance_check.sh +291 -44
  40. package/evals/run.sh +2 -0
  41. package/evals/static/test_universal_bundles.sh +137 -2
  42. package/integrations/strands/README.md +256 -0
  43. package/integrations/strands/example.py +74 -0
  44. package/integrations/strands/examples/knowledge_kit_live.py +461 -0
  45. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  46. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  47. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  48. package/integrations/strands/flow_agents_strands/steering.py +225 -0
  49. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  50. package/integrations/strands/pyproject.toml +38 -0
  51. package/integrations/strands/tests/__init__.py +0 -0
  52. package/integrations/strands/tests/test_hooks.py +392 -0
  53. package/integrations/strands/tests/test_policy.py +315 -0
  54. package/integrations/strands/tests/test_telemetry.py +184 -0
  55. package/integrations/strands-ts/README.md +224 -0
  56. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  57. package/integrations/strands-ts/package.json +53 -0
  58. package/integrations/strands-ts/src/hooks.ts +312 -0
  59. package/integrations/strands-ts/src/index.ts +22 -0
  60. package/integrations/strands-ts/src/policy.ts +345 -0
  61. package/integrations/strands-ts/src/telemetry.ts +251 -0
  62. package/integrations/strands-ts/test/test-policy.ts +322 -0
  63. package/integrations/strands-ts/test/test-steering.ts +159 -0
  64. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  65. package/integrations/strands-ts/tsconfig.json +20 -0
  66. package/kits/catalog.json +6 -0
  67. package/kits/knowledge/adapters/default-store/index.js +821 -0
  68. package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
  69. package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
  70. package/kits/knowledge/docs/README.md +135 -0
  71. package/kits/knowledge/docs/store-contract.md +526 -0
  72. package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
  73. package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
  74. package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
  75. package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
  76. package/kits/knowledge/flows/compile.flow.json +60 -0
  77. package/kits/knowledge/flows/consolidate.flow.json +77 -0
  78. package/kits/knowledge/flows/ingest.flow.json +60 -0
  79. package/kits/knowledge/flows/store-contract.flow.json +48 -0
  80. package/kits/knowledge/flows/synthesize.flow.json +77 -0
  81. package/kits/knowledge/kit.json +78 -0
  82. package/package.json +7 -2
  83. package/packaging/conformance/README.md +142 -0
  84. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  85. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  86. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  87. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  88. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  89. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  90. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  91. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  92. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  93. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  94. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  95. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  96. package/packaging/conformance/package.json +4 -0
  97. package/packaging/conformance/run-conformance.js +322 -0
  98. package/packaging/manifest.json +59 -0
  99. package/schemas/flow-agents-settings.schema.json +48 -0
  100. package/scripts/README.md +4 -0
  101. package/scripts/dogfood.js +16 -0
  102. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  103. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  104. package/scripts/hooks/pi-hook-adapter.js +123 -0
  105. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  106. package/scripts/hooks/run-hook.js +8 -0
  107. package/scripts/hooks/utterance-check.js +124 -22
  108. package/scripts/telemetry/lib/config.sh +5 -1
  109. package/src/cli/flow-kit.ts +10 -4
  110. package/src/cli/init.ts +219 -6
  111. package/src/cli/runtime-adapter.ts +10 -5
  112. package/src/cli/telemetry-doctor.ts +4 -1
  113. package/src/cli/utterance-check.ts +71 -1
  114. package/src/runtime-adapters.ts +35 -0
  115. package/src/tools/build-universal-bundles.ts +283 -0
  116. package/src/tools/filter-installed-packs.ts +3 -0
  117. package/src/tools/validate-source-tree.ts +5 -1
@@ -0,0 +1,821 @@
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
+ lines.push(`${pad} - ${firstKey}: ${yamlScalar(firstVal)}`);
171
+ for (const [k, v] of entries.slice(1)) {
172
+ lines.push(`${pad} ${k}: ${yamlScalar(v)}`);
173
+ }
174
+ } else {
175
+ lines.push(`${pad} - ${yamlScalar(item)}`);
176
+ }
177
+ }
178
+ }
179
+ } else if (typeof value === "object") {
180
+ lines.push(`${pad}${key}:`);
181
+ lines.push(serializeYaml(value, indent + 2));
182
+ } else {
183
+ lines.push(`${pad}${key}: ${yamlScalar(value)}`);
184
+ }
185
+ }
186
+ return lines.join("\n");
187
+ }
188
+
189
+ function yamlScalar(v) {
190
+ if (typeof v === "string") {
191
+ // Quote if it contains special chars
192
+ if (/[:#\[\]{},&*?|<>=!%@`"'\n]/.test(v) || v.trim() !== v || v === "") {
193
+ return `"${v.replace(/\\/g, "\\\\").replace(/"/g, '\\"')}"`;
194
+ }
195
+ return v;
196
+ }
197
+ return String(v);
198
+ }
199
+
200
+ function serializeMarkdown(meta, body) {
201
+ return `---\n${serializeYaml(meta)}\n---\n\n${body}`;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Wikilink parser / indexer
206
+ // ---------------------------------------------------------------------------
207
+
208
+ const WIKILINK_RE = /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g;
209
+
210
+ /**
211
+ * Extract all [[target_id]] and [[target_id|label]] links from body text.
212
+ * Returns Link objects.
213
+ */
214
+ function extractWikilinks(body) {
215
+ const links = [];
216
+ for (const match of body.matchAll(WIKILINK_RE)) {
217
+ links.push({ target_id: match[1].trim(), kind: "related", label: match[2]?.trim() });
218
+ }
219
+ return links;
220
+ }
221
+
222
+ /**
223
+ * Merge explicit links array with wikilink-derived links.
224
+ * De-duplicates by (target_id, kind); explicit links win on conflict.
225
+ */
226
+ function mergeLinks(explicit, wikilinks) {
227
+ const key = (l) => `${l.target_id}::${l.kind}`;
228
+ const seen = new Set(explicit.map(key));
229
+ const merged = [...explicit];
230
+ for (const wl of wikilinks) {
231
+ if (!seen.has(key(wl))) {
232
+ merged.push(wl);
233
+ seen.add(key(wl));
234
+ }
235
+ }
236
+ return merged;
237
+ }
238
+
239
+ // ---------------------------------------------------------------------------
240
+ // Graph index
241
+ // ---------------------------------------------------------------------------
242
+
243
+ const GRAPH_SCHEMA_VERSION = "1.0";
244
+
245
+ function emptyGraph() {
246
+ return { schema_version: GRAPH_SCHEMA_VERSION, forward: {}, reverse: {} };
247
+ }
248
+
249
+ function loadGraph(graphPath) {
250
+ if (!fs.existsSync(graphPath)) return emptyGraph();
251
+ try {
252
+ return JSON.parse(fs.readFileSync(graphPath, "utf8"));
253
+ } catch {
254
+ return emptyGraph();
255
+ }
256
+ }
257
+
258
+ function saveGraph(graphPath, graph) {
259
+ fs.writeFileSync(graphPath, JSON.stringify(graph, null, 2) + "\n", "utf8");
260
+ }
261
+
262
+ function addLinksToGraph(graph, sourceId, links) {
263
+ if (!graph.forward[sourceId]) graph.forward[sourceId] = [];
264
+ for (const link of links) {
265
+ const { target_id, kind, label } = link;
266
+ // Idempotent: skip if already present
267
+ const exists = graph.forward[sourceId].some(
268
+ (l) => l.target_id === target_id && l.kind === kind
269
+ );
270
+ if (!exists) {
271
+ const entry = { target_id, kind };
272
+ if (label) entry.label = label;
273
+ graph.forward[sourceId].push(entry);
274
+ if (!graph.reverse[target_id]) graph.reverse[target_id] = [];
275
+ graph.reverse[target_id].push({ source_id: sourceId, kind });
276
+ }
277
+ }
278
+ }
279
+
280
+ function removeLinksFromGraph(graph, sourceId) {
281
+ const oldForward = graph.forward[sourceId] || [];
282
+ for (const link of oldForward) {
283
+ const rev = graph.reverse[link.target_id] || [];
284
+ graph.reverse[link.target_id] = rev.filter((r) => r.source_id !== sourceId);
285
+ if (graph.reverse[link.target_id].length === 0) delete graph.reverse[link.target_id];
286
+ }
287
+ delete graph.forward[sourceId];
288
+ }
289
+
290
+ // ---------------------------------------------------------------------------
291
+ // Validation helpers
292
+ // ---------------------------------------------------------------------------
293
+
294
+ const VALID_TYPES = new Set(["raw", "compiled", "concept", "snapshot"]);
295
+ const CATEGORY_SEGMENT_RE = /^[a-z0-9_-]+$/;
296
+
297
+ function validateCategory(cat) {
298
+ if (!cat || typeof cat !== "string") return false;
299
+ return cat.split(".").every((seg) => CATEGORY_SEGMENT_RE.test(seg));
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // DefaultKnowledgeStore
304
+ // ---------------------------------------------------------------------------
305
+
306
+ export class DefaultKnowledgeStore {
307
+ /**
308
+ * @param {{ storeRoot: string }} options
309
+ */
310
+ constructor({ storeRoot }) {
311
+ if (!storeRoot) throw new Error("storeRoot is required");
312
+ this._root = path.resolve(storeRoot);
313
+ this._recordsDir = path.join(this._root, "records");
314
+ this._graphPath = path.join(this._root, "graph-index.json");
315
+ fs.mkdirSync(this._recordsDir, { recursive: true });
316
+ }
317
+
318
+ // -------------------------------------------------------------------------
319
+ // Internal helpers
320
+ // -------------------------------------------------------------------------
321
+
322
+ _recordPath(id) {
323
+ return path.join(this._recordsDir, `${id}.md`);
324
+ }
325
+
326
+ _readRecord(id) {
327
+ const p = this._recordPath(id);
328
+ if (!fs.existsSync(p)) return null;
329
+ const text = fs.readFileSync(p, "utf8");
330
+ const { meta, body } = parseMarkdown(text);
331
+ return { ...meta, body };
332
+ }
333
+
334
+ _writeRecord(record) {
335
+ const { body, ...meta } = record;
336
+ const text = serializeMarkdown(meta, body);
337
+ fs.writeFileSync(this._recordPath(record.id), text, "utf8");
338
+ }
339
+
340
+ _now() {
341
+ return new Date().toISOString();
342
+ }
343
+
344
+ // -------------------------------------------------------------------------
345
+ // create
346
+ // -------------------------------------------------------------------------
347
+
348
+ async create(input) {
349
+ // Required field enforcement
350
+ if (!input.type) throw missingEvidenceError("create: missing required field: type");
351
+ if (!VALID_TYPES.has(input.type))
352
+ throw missingEvidenceError(`create: type must be raw, compiled, concept, or snapshot; got: ${input.type}`);
353
+ if (!input.title || !input.title.trim())
354
+ throw missingEvidenceError("create: missing required field: title");
355
+ if (!input.body && input.body !== "")
356
+ throw missingEvidenceError("create: missing required field: body");
357
+ if (input.body !== undefined && !input.body.trim && typeof input.body !== "string")
358
+ throw missingEvidenceError("create: body must be a string");
359
+ if (!input.category) throw missingEvidenceError("create: missing required field: category");
360
+ if (!validateCategory(input.category))
361
+ throw missingEvidenceError(`create: invalid category: ${input.category}`);
362
+ if (!input.provenance?.agent)
363
+ throw missingEvidenceError("create: missing required provenance field: provenance.agent");
364
+
365
+ const id = input.id || randomUUID();
366
+ const now = this._now();
367
+
368
+ // Merge explicit links + wikilinks from body
369
+ const explicitLinks = input.links || [];
370
+ const wikilinks = extractWikilinks(input.body || "");
371
+ const links = mergeLinks(explicitLinks, wikilinks);
372
+
373
+ const record = {
374
+ id,
375
+ type: input.type,
376
+ title: input.title,
377
+ category: input.category,
378
+ tags: input.tags || [],
379
+ created_at: now,
380
+ updated_at: now,
381
+ provenance: {
382
+ agent: input.provenance.agent,
383
+ ...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
384
+ ...(input.provenance.source_ids?.length ? { source_ids: input.provenance.source_ids } : {}),
385
+ ...(input.provenance.note ? { note: input.provenance.note } : {}),
386
+ },
387
+ links,
388
+ mutation_log: [],
389
+ body: input.body || "",
390
+ };
391
+
392
+ this._writeRecord(record);
393
+
394
+ // Update graph index
395
+ const graph = loadGraph(this._graphPath);
396
+ addLinksToGraph(graph, id, links);
397
+ saveGraph(this._graphPath, graph);
398
+
399
+ return id;
400
+ }
401
+
402
+ // -------------------------------------------------------------------------
403
+ // update
404
+ // -------------------------------------------------------------------------
405
+
406
+ async update(id, fields, evidence) {
407
+ if (!evidence?.agent)
408
+ throw missingEvidenceError("update: missing required evidence field: agent");
409
+
410
+ const record = this._readRecord(id);
411
+ if (!record) throw notFoundError(id);
412
+
413
+ const mutableKeys = ["title", "body", "category", "tags", "links"];
414
+ const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
415
+ if (supplied.length === 0)
416
+ throw missingEvidenceError("update: at least one mutable field must be supplied");
417
+
418
+ if (fields.category !== undefined && !validateCategory(fields.category))
419
+ throw missingEvidenceError(`update: invalid category: ${fields.category}`);
420
+
421
+ const now = this._now();
422
+
423
+ // Merge links if updated
424
+ let newLinks = record.links || [];
425
+ if (fields.links !== undefined) {
426
+ const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
427
+ newLinks = mergeLinks(fields.links, wikilinks);
428
+ } else if (fields.body !== undefined) {
429
+ const wikilinks = extractWikilinks(fields.body);
430
+ newLinks = mergeLinks(record.links || [], wikilinks);
431
+ }
432
+
433
+ const updated = {
434
+ ...record,
435
+ ...(fields.title !== undefined ? { title: fields.title } : {}),
436
+ ...(fields.body !== undefined ? { body: fields.body } : {}),
437
+ ...(fields.category !== undefined ? { category: fields.category } : {}),
438
+ ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
439
+ links: newLinks,
440
+ updated_at: now,
441
+ mutation_log: [
442
+ ...(record.mutation_log || []),
443
+ {
444
+ op: "update",
445
+ at: now,
446
+ agent: evidence.agent,
447
+ ...(evidence.note ? { note: evidence.note } : {}),
448
+ evidence: { fields: supplied },
449
+ },
450
+ ],
451
+ };
452
+
453
+ // Update graph index
454
+ const graph = loadGraph(this._graphPath);
455
+ removeLinksFromGraph(graph, id);
456
+ addLinksToGraph(graph, id, newLinks);
457
+ saveGraph(this._graphPath, graph);
458
+
459
+ this._writeRecord(updated);
460
+ }
461
+
462
+ // -------------------------------------------------------------------------
463
+ // link
464
+ // -------------------------------------------------------------------------
465
+
466
+ async link(sourceId, links, evidence) {
467
+ if (!evidence?.agent)
468
+ throw missingEvidenceError("link: missing required evidence field: agent");
469
+ if (!links || links.length === 0)
470
+ throw missingEvidenceError("link: links array must be non-empty");
471
+
472
+ const source = this._readRecord(sourceId);
473
+ if (!source) throw notFoundError(sourceId);
474
+
475
+ for (const l of links) {
476
+ if (!this._readRecord(l.target_id)) throw notFoundError(l.target_id);
477
+ }
478
+
479
+ const now = this._now();
480
+ const existingLinks = source.links || [];
481
+
482
+ // Idempotent merge
483
+ const key = (l) => `${l.target_id}::${l.kind}`;
484
+ const seen = new Set(existingLinks.map(key));
485
+ const newLinks = [...existingLinks];
486
+ for (const l of links) {
487
+ if (!seen.has(key(l))) {
488
+ newLinks.push(l);
489
+ seen.add(key(l));
490
+ }
491
+ }
492
+
493
+ const updated = {
494
+ ...source,
495
+ links: newLinks,
496
+ updated_at: now,
497
+ mutation_log: [
498
+ ...(source.mutation_log || []),
499
+ {
500
+ op: "link",
501
+ at: now,
502
+ agent: evidence.agent,
503
+ ...(evidence.note ? { note: evidence.note } : {}),
504
+ evidence: { added: links },
505
+ },
506
+ ],
507
+ };
508
+
509
+ const graph = loadGraph(this._graphPath);
510
+ removeLinksFromGraph(graph, sourceId);
511
+ addLinksToGraph(graph, sourceId, newLinks);
512
+ saveGraph(this._graphPath, graph);
513
+
514
+ this._writeRecord(updated);
515
+ }
516
+
517
+ // -------------------------------------------------------------------------
518
+ // propose
519
+ // -------------------------------------------------------------------------
520
+
521
+ async propose(conceptId, proposerId, evidence) {
522
+ if (!evidence?.agent)
523
+ throw missingEvidenceError("propose: missing required evidence field: agent");
524
+ if (!evidence?.proposal || !evidence.proposal.trim())
525
+ throw missingEvidenceError("propose: missing required evidence field: proposal");
526
+
527
+ const concept = this._readRecord(conceptId);
528
+ if (!concept) throw notFoundError(conceptId);
529
+ if (concept.type !== "concept" && concept.type !== "snapshot")
530
+ throw missingEvidenceError(`propose: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
531
+
532
+ const proposer = this._readRecord(proposerId);
533
+ if (!proposer) throw notFoundError(proposerId);
534
+
535
+ const now = this._now();
536
+
537
+ // Add proposes link from proposer to concept
538
+ const proposerLinks = proposer.links || [];
539
+ const alreadyLinked = proposerLinks.some(
540
+ (l) => l.target_id === conceptId && l.kind === "proposes"
541
+ );
542
+ if (!alreadyLinked) {
543
+ const updatedProposer = {
544
+ ...proposer,
545
+ links: [...proposerLinks, { target_id: conceptId, kind: "proposes" }],
546
+ updated_at: now,
547
+ mutation_log: [
548
+ ...(proposer.mutation_log || []),
549
+ {
550
+ op: "propose",
551
+ at: now,
552
+ agent: evidence.agent,
553
+ evidence: { concept_id: conceptId, proposal: evidence.proposal },
554
+ },
555
+ ],
556
+ };
557
+ this._writeRecord(updatedProposer);
558
+
559
+ const graph = loadGraph(this._graphPath);
560
+ removeLinksFromGraph(graph, proposerId);
561
+ addLinksToGraph(graph, proposerId, updatedProposer.links);
562
+ saveGraph(this._graphPath, graph);
563
+ }
564
+
565
+ // Append mutation log to concept
566
+ const updatedConcept = {
567
+ ...concept,
568
+ mutation_log: [
569
+ ...(concept.mutation_log || []),
570
+ {
571
+ op: "propose",
572
+ at: now,
573
+ agent: evidence.agent,
574
+ evidence: { proposer_id: proposerId, proposal: evidence.proposal },
575
+ },
576
+ ],
577
+ };
578
+ this._writeRecord(updatedConcept);
579
+ }
580
+
581
+ // -------------------------------------------------------------------------
582
+ // apply
583
+ // -------------------------------------------------------------------------
584
+
585
+ async apply(conceptId, proposerId, evidence) {
586
+ if (!evidence?.agent)
587
+ throw missingEvidenceError("apply: missing required evidence field: agent");
588
+ if (!evidence?.new_body && evidence?.new_body !== "")
589
+ throw missingEvidenceError("apply: missing required evidence field: new_body");
590
+ if (!evidence?.new_body?.trim?.())
591
+ throw missingEvidenceError("apply: new_body must be non-empty");
592
+ if (!evidence?.rationale || !evidence.rationale.trim())
593
+ throw missingEvidenceError("apply: missing required evidence field: rationale");
594
+
595
+ const concept = this._readRecord(conceptId);
596
+ if (!concept) throw notFoundError(conceptId);
597
+ if (concept.type !== "concept" && concept.type !== "snapshot")
598
+ throw missingEvidenceError(`apply: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
599
+
600
+ const proposer = this._readRecord(proposerId);
601
+ if (!proposer) throw notFoundError(proposerId);
602
+
603
+ const proposerLinks = proposer.links || [];
604
+ const hasProposesLink = proposerLinks.some(
605
+ (l) => l.target_id === conceptId && l.kind === "proposes"
606
+ );
607
+ if (!hasProposesLink)
608
+ throw missingEvidenceError(`apply: no "proposes" link from ${proposerId} to ${conceptId}`);
609
+
610
+ const now = this._now();
611
+ const updatedConcept = {
612
+ ...concept,
613
+ body: evidence.new_body,
614
+ updated_at: now,
615
+ mutation_log: [
616
+ ...(concept.mutation_log || []),
617
+ {
618
+ op: "apply",
619
+ at: now,
620
+ agent: evidence.agent,
621
+ evidence: { proposer_id: proposerId, rationale: evidence.rationale },
622
+ },
623
+ ],
624
+ };
625
+ this._writeRecord(updatedConcept);
626
+ }
627
+
628
+ // -------------------------------------------------------------------------
629
+ // reject
630
+ // -------------------------------------------------------------------------
631
+
632
+ async reject(conceptId, proposerId, evidence) {
633
+ if (!evidence?.agent)
634
+ throw missingEvidenceError("reject: missing required evidence field: agent");
635
+ if (!evidence?.reason || !evidence.reason.trim())
636
+ throw missingEvidenceError("reject: missing required evidence field: reason");
637
+
638
+ const concept = this._readRecord(conceptId);
639
+ if (!concept) throw notFoundError(conceptId);
640
+ if (concept.type !== "concept" && concept.type !== "snapshot")
641
+ throw missingEvidenceError(`reject: concept_id must reference a concept or snapshot record; got type: ${concept.type}`);
642
+
643
+ const proposer = this._readRecord(proposerId);
644
+ if (!proposer) throw notFoundError(proposerId);
645
+
646
+ const proposerLinks = proposer.links || [];
647
+ const hasProposesLink = proposerLinks.some(
648
+ (l) => l.target_id === conceptId && l.kind === "proposes"
649
+ );
650
+ if (!hasProposesLink)
651
+ throw missingEvidenceError(`reject: no "proposes" link from ${proposerId} to ${conceptId}`);
652
+
653
+ const now = this._now();
654
+ const updatedConcept = {
655
+ ...concept,
656
+ // updated_at NOT changed — concept body was not mutated
657
+ mutation_log: [
658
+ ...(concept.mutation_log || []),
659
+ {
660
+ op: "reject",
661
+ at: now,
662
+ agent: evidence.agent,
663
+ evidence: { proposer_id: proposerId, reason: evidence.reason },
664
+ },
665
+ ],
666
+ };
667
+ this._writeRecord(updatedConcept);
668
+ }
669
+
670
+
671
+ // -------------------------------------------------------------------------
672
+ // supersede
673
+ // -------------------------------------------------------------------------
674
+
675
+ async supersede(newId, supersededIds, evidence) {
676
+ if (!evidence?.agent)
677
+ throw missingEvidenceError("supersede: missing required evidence field: agent");
678
+ if (!evidence?.rationale || !evidence.rationale.trim())
679
+ throw missingEvidenceError("supersede: missing required evidence field: rationale");
680
+ if (!supersededIds || supersededIds.length === 0)
681
+ throw missingEvidenceError("supersede: supersededIds must be a non-empty array");
682
+
683
+ const newRecord = this._readRecord(newId);
684
+ if (!newRecord) throw notFoundError(newId);
685
+
686
+ // Verify all superseded records exist
687
+ for (const sid of supersededIds) {
688
+ const rec = this._readRecord(sid);
689
+ if (!rec) throw notFoundError(sid);
690
+ }
691
+
692
+ const now = this._now();
693
+
694
+ // Add supersedes links from newId to each superseded record
695
+ const supersededLinks = supersededIds.map((sid) => ({
696
+ target_id: sid,
697
+ kind: "supersedes",
698
+ }));
699
+
700
+ // Update newId record: add supersedes links + mutation log entry
701
+ const existingLinks = newRecord.links || [];
702
+ const key = (l) => `${l.target_id}::${l.kind}`;
703
+ const seen = new Set(existingLinks.map(key));
704
+ const newLinks = [...existingLinks];
705
+ for (const l of supersededLinks) {
706
+ if (!seen.has(key(l))) {
707
+ newLinks.push(l);
708
+ seen.add(key(l));
709
+ }
710
+ }
711
+
712
+ const updatedNew = {
713
+ ...newRecord,
714
+ links: newLinks,
715
+ updated_at: now,
716
+ mutation_log: [
717
+ ...(newRecord.mutation_log || []),
718
+ {
719
+ op: "supersede",
720
+ at: now,
721
+ agent: evidence.agent,
722
+ rationale: evidence.rationale,
723
+ ...(evidence.note ? { note: evidence.note } : {}),
724
+ evidence: { superseded_count: supersededIds.length },
725
+ },
726
+ ],
727
+ };
728
+
729
+ const graph = loadGraph(this._graphPath);
730
+ removeLinksFromGraph(graph, newId);
731
+ addLinksToGraph(graph, newId, newLinks);
732
+ saveGraph(this._graphPath, graph);
733
+
734
+ this._writeRecord(updatedNew);
735
+
736
+ // Append superseded-by mutation log entry to each superseded record
737
+ // Records are NOT deleted — supersede-not-delete invariant
738
+ for (const sid of supersededIds) {
739
+ const supersededRec = this._readRecord(sid);
740
+ if (!supersededRec) continue; // already verified above; defensive
741
+ const updatedSuperseded = {
742
+ ...supersededRec,
743
+ // updated_at NOT changed — the record content is not mutated
744
+ mutation_log: [
745
+ ...(supersededRec.mutation_log || []),
746
+ {
747
+ op: "superseded-by",
748
+ at: now,
749
+ agent: evidence.agent,
750
+ new_id: newId,
751
+ rationale: evidence.rationale,
752
+ ...(evidence.note ? { note: evidence.note } : {}),
753
+ evidence: { superseded_by_id: newId },
754
+ },
755
+ ],
756
+ };
757
+ this._writeRecord(updatedSuperseded);
758
+ }
759
+ }
760
+
761
+
762
+ // -------------------------------------------------------------------------
763
+ // get
764
+ // -------------------------------------------------------------------------
765
+
766
+ async get(id) {
767
+ return this._readRecord(id);
768
+ }
769
+
770
+ // -------------------------------------------------------------------------
771
+ // getLinks
772
+ // -------------------------------------------------------------------------
773
+
774
+ async getLinks(id) {
775
+ const graph = loadGraph(this._graphPath);
776
+ return {
777
+ forward: (graph.forward[id] || []).map((l) => ({ ...l })),
778
+ reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
779
+ };
780
+ }
781
+
782
+ // -------------------------------------------------------------------------
783
+ // listByCategory
784
+ // -------------------------------------------------------------------------
785
+
786
+ async listByCategory(category, options = {}) {
787
+ const records = this._allRecords();
788
+ if (options.prefix) {
789
+ return records.filter(
790
+ (r) => r.category === category || r.category.startsWith(`${category}.`)
791
+ );
792
+ }
793
+ return records.filter((r) => r.category === category);
794
+ }
795
+
796
+ // -------------------------------------------------------------------------
797
+ // listByType
798
+ // -------------------------------------------------------------------------
799
+
800
+ async listByType(type) {
801
+ return this._allRecords().filter((r) => r.type === type);
802
+ }
803
+
804
+ // -------------------------------------------------------------------------
805
+ // Internal: read all records
806
+ // -------------------------------------------------------------------------
807
+
808
+ _allRecords() {
809
+ if (!fs.existsSync(this._recordsDir)) return [];
810
+ const files = fs.readdirSync(this._recordsDir).filter((f) => f.endsWith(".md"));
811
+ const records = [];
812
+ for (const file of files) {
813
+ const id = file.slice(0, -3);
814
+ const record = this._readRecord(id);
815
+ if (record) records.push(record);
816
+ }
817
+ return records;
818
+ }
819
+ }
820
+
821
+ export default DefaultKnowledgeStore;