@kontourai/flow-agents 0.4.0 → 1.0.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 (52) hide show
  1. package/.github/workflows/kit-gates-demo.yml +171 -0
  2. package/CHANGELOG.md +35 -0
  3. package/CONTEXT.md +1 -1
  4. package/README.md +13 -2
  5. package/build/src/cli/flow-kit.js +41 -2
  6. package/build/src/flow-kit/validate.js +98 -0
  7. package/build/src/tools/validate-source-tree.js +2 -1
  8. package/context/scripts/hooks/config-protection.js +217 -15
  9. package/docs/fixture-ownership.md +1 -0
  10. package/docs/index.md +9 -1
  11. package/docs/kit-authoring-guide.md +126 -0
  12. package/docs/knowledge-kit.md +69 -0
  13. package/docs/vision.md +22 -0
  14. package/evals/fixtures/kit-conformance-levels/k0-flows-only/flows/review.flow.json +26 -0
  15. package/evals/fixtures/kit-conformance-levels/k0-flows-only/kit.json +13 -0
  16. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/docs/README.md +3 -0
  17. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/flows/build.flow.json +26 -0
  18. package/evals/fixtures/kit-conformance-levels/k1-agent-extension/kit.json +20 -0
  19. package/evals/fixtures/kit-conformance-levels/k2-with-evals/docs/README.md +3 -0
  20. package/evals/fixtures/kit-conformance-levels/k2-with-evals/eval-suites/contract-suite/suite.test.js +1 -0
  21. package/evals/fixtures/kit-conformance-levels/k2-with-evals/flows/synthesize.flow.json +26 -0
  22. package/evals/fixtures/kit-conformance-levels/k2-with-evals/kit.json +27 -0
  23. package/evals/fixtures/kit-conformance-levels/third-party-extension/flows/review.flow.json +26 -0
  24. package/evals/fixtures/kit-conformance-levels/third-party-extension/kit.json +19 -0
  25. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  26. package/evals/integration/test_hook_category_behaviors.sh +51 -0
  27. package/evals/integration/test_kit_conformance_levels.sh +209 -0
  28. package/evals/run.sh +2 -0
  29. package/kits/catalog.json +6 -0
  30. package/kits/knowledge/adapters/default-store/index.js +2 -2
  31. package/kits/knowledge/adapters/flow-runner/entity-extractor.js +194 -0
  32. package/kits/knowledge/adapters/flow-runner/index.js +349 -0
  33. package/kits/knowledge/adapters/obsidian-store/README.md +141 -0
  34. package/kits/knowledge/adapters/obsidian-store/demo.js +181 -0
  35. package/kits/knowledge/adapters/obsidian-store/index.js +868 -0
  36. package/kits/knowledge/adapters/shared/codec.js +325 -0
  37. package/kits/knowledge/docs/store-contract.md +72 -0
  38. package/kits/knowledge/evals/entities/demo-acme.js +125 -0
  39. package/kits/knowledge/evals/entities/suite.test.js +722 -0
  40. package/kits/knowledge/kit.json +10 -0
  41. package/kits/release-evidence/fixtures/claims/README.md +14 -0
  42. package/kits/release-evidence/fixtures/claims/fail-rejected-release.trust.json +22 -0
  43. package/kits/release-evidence/fixtures/claims/pass-trusted-release.trust.json +22 -0
  44. package/kits/release-evidence/flows/release-evidence.flow.json +38 -0
  45. package/kits/release-evidence/kit.json +13 -0
  46. package/package.json +1 -1
  47. package/packaging/conformance/fixtures/config-protection--allow-no-verify-in-string.json +20 -0
  48. package/packaging/conformance/fixtures/config-protection--block-git-no-verify.json +23 -0
  49. package/scripts/hooks/config-protection.js +217 -15
  50. package/src/cli/flow-kit.ts +40 -2
  51. package/src/flow-kit/validate.ts +127 -0
  52. package/src/tools/validate-source-tree.ts +2 -1
@@ -0,0 +1,868 @@
1
+ /**
2
+ * Knowledge Kit — Obsidian Store Adapter
3
+ *
4
+ * Implements the Knowledge Kit store contract where each record on disk
5
+ * is ONE human-canonical Obsidian markdown note.
6
+ *
7
+ * Storage layout:
8
+ * <storeRoot>/
9
+ * <category-as-path>/<title-slug>.md (active records)
10
+ * archive/<category-as-path>/<title-slug>.md (superseded records)
11
+ * graph-index.json (link graph — required by suite §13)
12
+ * .graph-index.json (path index — id→{path,archived})
13
+ *
14
+ * Frontmatter carries ALL contract fields; body rendered for Obsidian readability.
15
+ * Category dots map to directory segments: "eng.api" → "eng/api/".
16
+ * Filename: slugified title, collision-suffixed (-2, -3, …).
17
+ * Superseded records MOVE to archive/ (supersede-not-delete invariant).
18
+ *
19
+ * Zero runtime dependencies beyond Node.js built-ins.
20
+ *
21
+ * @module adapters/obsidian-store
22
+ */
23
+
24
+ import * as fs from "node:fs";
25
+ import * as path from "node:path";
26
+ import { randomUUID } from "node:crypto";
27
+
28
+ import {
29
+ missingEvidenceError,
30
+ notFoundError,
31
+ parseMarkdown,
32
+ serializeYaml,
33
+ extractWikilinks,
34
+ mergeLinks,
35
+ loadGraph,
36
+ saveGraph,
37
+ addLinksToGraph,
38
+ removeLinksFromGraph,
39
+ VALID_TYPES,
40
+ VALID_STATUS_TRANSITIONS,
41
+ validateCategory,
42
+ } from "../shared/codec.js";
43
+
44
+ // ---------------------------------------------------------------------------
45
+ // ObsidianKnowledgeStore
46
+ // ---------------------------------------------------------------------------
47
+
48
+ export class ObsidianKnowledgeStore {
49
+ /**
50
+ * @param {{ storeRoot: string }} options
51
+ */
52
+ constructor({ storeRoot, sourcesDir = "sources", dimensions = [] }) {
53
+ if (!storeRoot) throw new Error("storeRoot is required");
54
+ this._sourcesDir = sourcesDir;
55
+ // Named dimensions for category segments AFTER the first (domain) segment,
56
+ // written into frontmatter as derived fields so vault views can filter on
57
+ // them (e.g. dimensions: ["territory","customer","initiative"] turns
58
+ // category sales.east.acme.renewal into territory: east, customer: acme,
59
+ // initiative: renewal). Domain kits supply the names; core stays neutral.
60
+ this._dimensions = dimensions;
61
+ this._root = path.resolve(storeRoot);
62
+ // Link graph (required by suite §13): { schema_version, forward, reverse }
63
+ this._graphPath = path.join(this._root, "graph-index.json");
64
+ // Path index (internal): { by_id: { id: { path, archived } }, by_path: { relPath: id } }
65
+ this._pathIndexPath = path.join(this._root, ".graph-index.json");
66
+ fs.mkdirSync(this._root, { recursive: true });
67
+ }
68
+
69
+ // -------------------------------------------------------------------------
70
+ // Path / slug helpers
71
+ // -------------------------------------------------------------------------
72
+
73
+ _slugify(title) {
74
+ return title
75
+ .toLowerCase()
76
+ .replace(/[^a-z0-9]+/g, "-")
77
+ .replace(/^-+|-+$/g, "")
78
+ || "untitled";
79
+ }
80
+
81
+ /**
82
+ * Compute a unique relative path for a record, respecting collision suffix.
83
+ * Category dots → directory separators: "eng.api" → "eng/api".
84
+ *
85
+ * Layout rule: insight records (snapshot, concept) live at the category
86
+ * node root so a human browsing the tree sees the living overviews first;
87
+ * source-level records (raw, compiled) nest one level down in a sources
88
+ * subfolder (name configurable via constructor `sourcesDir`, default
89
+ * "sources" — a domain kit may choose e.g. "meetings").
90
+ */
91
+ _computeRelPath(category, title, id, pathIndex, type) {
92
+ let catDir;
93
+ if (type === "person") {
94
+ // Person records always go to the top-level people/ folder regardless of
95
+ // category — they are cross-cutting entities, not domain-specific notes.
96
+ catDir = "people";
97
+ } else {
98
+ catDir = category.replace(/\./g, "/");
99
+ if (type === "raw" || type === "compiled") {
100
+ catDir = `${catDir}/${this._sourcesDir}`;
101
+ }
102
+ }
103
+ const baseSlug = this._slugify(title);
104
+ let slug = baseSlug;
105
+ let suffix = 2;
106
+ while (true) {
107
+ const relPath = `${catDir}/${slug}.md`;
108
+ const existingId = pathIndex.by_path[relPath];
109
+ if (!existingId || existingId === id) return relPath;
110
+ slug = `${baseSlug}-${suffix++}`;
111
+ }
112
+ }
113
+
114
+ // -------------------------------------------------------------------------
115
+ // Path index I/O
116
+ // -------------------------------------------------------------------------
117
+
118
+ _loadPathIndex() {
119
+ if (!fs.existsSync(this._pathIndexPath)) return { by_id: {}, by_path: {} };
120
+ try {
121
+ return JSON.parse(fs.readFileSync(this._pathIndexPath, "utf8"));
122
+ } catch {
123
+ return { by_id: {}, by_path: {} };
124
+ }
125
+ }
126
+
127
+ _savePathIndex(index) {
128
+ fs.writeFileSync(this._pathIndexPath, JSON.stringify(index, null, 2) + "\n", "utf8");
129
+ }
130
+
131
+ // -------------------------------------------------------------------------
132
+ // Record I/O
133
+ // -------------------------------------------------------------------------
134
+
135
+ /**
136
+ * Returns the absolute path for a record id, or null if not indexed.
137
+ */
138
+ _getAbsPath(id, pathIndex) {
139
+ const entry = (pathIndex || this._loadPathIndex()).by_id[id];
140
+ if (!entry) return null;
141
+ return path.join(this._root, entry.path);
142
+ }
143
+
144
+ /**
145
+ * Read a record by id. Returns the full record object (all fields from
146
+ * frontmatter, with `body` included) or null if not found.
147
+ */
148
+ _readRecord(id, pathIndex) {
149
+ const absPath = this._getAbsPath(id, pathIndex);
150
+ if (!absPath || !fs.existsSync(absPath)) return null;
151
+ const text = fs.readFileSync(absPath, "utf8");
152
+ const { meta } = parseMarkdown(text);
153
+ if (!meta.id) return null;
154
+ // body is stored in frontmatter as meta.body
155
+ return { ...meta };
156
+ }
157
+
158
+ /**
159
+ * Write a record to disk.
160
+ *
161
+ * - On first write: computes slug path, registers in path index.
162
+ * - On update with title change: renames file (old deleted, new path used).
163
+ * - All contract fields stored in YAML frontmatter.
164
+ * - Below the frontmatter: Obsidian-readable human body for the note type.
165
+ */
166
+ _writeRecord(record, pathIndex) {
167
+ const ownedIndex = !pathIndex;
168
+ if (ownedIndex) pathIndex = this._loadPathIndex();
169
+
170
+ const existingEntry = pathIndex.by_id[record.id];
171
+ let targetRelPath;
172
+
173
+ if (existingEntry && existingEntry.archived) {
174
+ // Archived record — keep in archive path (supersede-not-delete writes back there)
175
+ targetRelPath = existingEntry.path;
176
+ } else if (existingEntry) {
177
+ // Existing active record — check if path needs to change (title changed)
178
+ const newRelPath = this._computeRelPath(record.category, record.title, record.id, pathIndex, record.type);
179
+ if (newRelPath !== existingEntry.path) {
180
+ // Move: delete old file, register new path
181
+ const oldAbs = path.join(this._root, existingEntry.path);
182
+ if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs);
183
+ delete pathIndex.by_path[existingEntry.path];
184
+ pathIndex.by_id[record.id] = { path: newRelPath, archived: false };
185
+ pathIndex.by_path[newRelPath] = record.id;
186
+ targetRelPath = newRelPath;
187
+ } else {
188
+ targetRelPath = existingEntry.path;
189
+ }
190
+ } else {
191
+ // New record
192
+ const newRelPath = this._computeRelPath(record.category, record.title, record.id, pathIndex, record.type);
193
+ pathIndex.by_id[record.id] = { path: newRelPath, archived: false };
194
+ pathIndex.by_path[newRelPath] = record.id;
195
+ targetRelPath = newRelPath;
196
+ }
197
+
198
+ // Render: all contract fields in frontmatter; human body below
199
+ const { body, ...frontmatterFields } = record;
200
+ // Derived dimension fields (territory: east, customer: acme, ...) from
201
+ // category segments after the domain segment — presentation-only, never
202
+ // read back as contract fields (id/category remain canonical).
203
+ const derived = {};
204
+ if (this._dimensions.length && record.category) {
205
+ const segs = record.category.split(".").slice(1);
206
+ this._dimensions.forEach((name, i) => { if (segs[i]) derived[name] = segs[i]; });
207
+ }
208
+ // Store body in frontmatter so round-trip is lossless
209
+ const frontmatter = { ...frontmatterFields, ...derived, body };
210
+ const obsidianBody = this._renderObsidianBody(record, pathIndex);
211
+ const text = `---\n${serializeYaml(frontmatter)}\n---\n\n${obsidianBody}`;
212
+
213
+ const absPath = path.join(this._root, targetRelPath);
214
+ fs.mkdirSync(path.dirname(absPath), { recursive: true });
215
+ fs.writeFileSync(absPath, text, "utf8");
216
+
217
+ if (ownedIndex) this._savePathIndex(pathIndex);
218
+ return targetRelPath;
219
+ }
220
+
221
+ /**
222
+ * Move an active record to archive/ and mark it archived in path index.
223
+ * Called after writing the superseded-by mutation log entry.
224
+ */
225
+ _archiveRecord(id, pathIndex) {
226
+ const ownedIndex = !pathIndex;
227
+ if (ownedIndex) pathIndex = this._loadPathIndex();
228
+
229
+ const entry = pathIndex.by_id[id];
230
+ if (!entry || entry.archived) {
231
+ if (ownedIndex) this._savePathIndex(pathIndex);
232
+ return;
233
+ }
234
+
235
+ const archiveRelPath = `archive/${entry.path}`;
236
+ const archiveAbs = path.join(this._root, archiveRelPath);
237
+ fs.mkdirSync(path.dirname(archiveAbs), { recursive: true });
238
+
239
+ const currentAbs = path.join(this._root, entry.path);
240
+ if (fs.existsSync(currentAbs)) fs.renameSync(currentAbs, archiveAbs);
241
+
242
+ delete pathIndex.by_path[entry.path];
243
+ pathIndex.by_id[id] = { path: archiveRelPath, archived: true };
244
+ pathIndex.by_path[archiveRelPath] = id;
245
+
246
+ if (ownedIndex) this._savePathIndex(pathIndex);
247
+ }
248
+
249
+ // -------------------------------------------------------------------------
250
+ // Obsidian body rendering
251
+ // -------------------------------------------------------------------------
252
+
253
+ /**
254
+ * Resolve a record id to its filename slug for use as a wikilink target.
255
+ * Falls back to the id itself if not in the index.
256
+ */
257
+ _idToFilename(id, pathIndex) {
258
+ const entry = pathIndex.by_id[id];
259
+ if (!entry) return id;
260
+ return path.basename(entry.path, ".md");
261
+ }
262
+
263
+ /**
264
+ * Render the human-readable Obsidian body below the frontmatter fence.
265
+ * This is decorative — the canonical data lives in frontmatter.
266
+ */
267
+ _renderObsidianBody(record, pathIndex) {
268
+ const links = record.links || [];
269
+ const relatedLinks = links.filter((l) => l.kind === "related" || l.kind === "refines");
270
+ const sourceLinks = links.filter((l) => l.kind === "source");
271
+
272
+ const wikiLinks = (linkList) =>
273
+ linkList
274
+ .map((l) => {
275
+ // Skip unresolvable targets (bad/missing id) rather than emitting
276
+ // a literal [[undefined]] into the note.
277
+ if (!l.target_id) return null;
278
+ const slug = this._idToFilename(l.target_id, pathIndex);
279
+ if (!slug) return null;
280
+ return l.label ? `[[${slug}|${l.label}]]` : `[[${slug}]]`;
281
+ })
282
+ .filter(Boolean)
283
+ .join(", ");
284
+
285
+ const sections = [];
286
+
287
+ if (record.type === "raw") {
288
+ sections.push(`> [!note]- Raw Notes\n> ${record.body.replace(/\n/g, "\n> ")}`);
289
+ } else if (record.type === "person") {
290
+ // Person cards: lead with the body (role/org prose), then People links,
291
+ // then Appears In backlinks to sources, then Related.
292
+ sections.push(record.body);
293
+ } else {
294
+ // compiled / concept / snapshot: insight as readable body
295
+ sections.push(record.body);
296
+ }
297
+
298
+ if (sourceLinks.length > 0) {
299
+ sections.push(`## Sources\n\n${wikiLinks(sourceLinks)}`);
300
+ }
301
+
302
+ // Person cards: render appears-in links (backlinks to raw+compiled records)
303
+ const appearsInLinks = links.filter((l) => l.kind === "appears-in");
304
+ if (record.type === "person" && appearsInLinks.length > 0) {
305
+ sections.push(`## Appears In\n\n${wikiLinks(appearsInLinks)}`);
306
+ }
307
+
308
+ // Compiled/person records: render people links (links to person cards)
309
+ const peopleLinks = links.filter((l) => l.kind === "person");
310
+ if (peopleLinks.length > 0) {
311
+ sections.push(`## People\n\n${wikiLinks(peopleLinks)}`);
312
+ }
313
+
314
+ if (relatedLinks.length > 0) {
315
+ sections.push(`## Related\n\n${wikiLinks(relatedLinks)}`);
316
+ }
317
+
318
+ return sections.join("\n\n");
319
+ }
320
+
321
+ // -------------------------------------------------------------------------
322
+ // _allRecords: walk path index
323
+ // -------------------------------------------------------------------------
324
+
325
+ _allRecords() {
326
+ const pathIndex = this._loadPathIndex();
327
+ const records = [];
328
+ for (const [id, entry] of Object.entries(pathIndex.by_id)) {
329
+ if (entry.archived) continue;
330
+ const record = this._readRecord(id, pathIndex);
331
+ if (record) records.push(record);
332
+ }
333
+ return records;
334
+ }
335
+
336
+ _now() {
337
+ return new Date().toISOString();
338
+ }
339
+
340
+ // =========================================================================
341
+ // Store contract operations
342
+ // =========================================================================
343
+
344
+ // -------------------------------------------------------------------------
345
+ // create
346
+ // -------------------------------------------------------------------------
347
+
348
+ async create(input) {
349
+ if (!input.type) throw missingEvidenceError("create: missing required field: type");
350
+ if (!VALID_TYPES.has(input.type))
351
+ throw missingEvidenceError(`create: type must be one of raw, compiled, concept, snapshot, person; got: ${input.type}`);
352
+ if (!input.title || !input.title.trim())
353
+ throw missingEvidenceError("create: missing required field: title");
354
+ if (!input.body && input.body !== "")
355
+ throw missingEvidenceError("create: missing required field: body");
356
+ if (input.body !== undefined && !input.body.trim && typeof input.body !== "string")
357
+ throw missingEvidenceError("create: body must be a string");
358
+ if (!input.category) throw missingEvidenceError("create: missing required field: category");
359
+ if (!validateCategory(input.category))
360
+ throw missingEvidenceError(`create: invalid category: ${input.category}`);
361
+ if (!input.provenance?.agent)
362
+ throw missingEvidenceError("create: missing required provenance field: provenance.agent");
363
+
364
+ const id = input.id || randomUUID();
365
+ const now = this._now();
366
+
367
+ const explicitLinks = input.links || [];
368
+ const wikilinks = extractWikilinks(input.body || "");
369
+ const links = mergeLinks(explicitLinks, wikilinks);
370
+
371
+ const record = {
372
+ id,
373
+ type: input.type,
374
+ title: input.title,
375
+ category: input.category,
376
+ tags: input.tags || [],
377
+ status: "active",
378
+ created_at: now,
379
+ updated_at: now,
380
+ provenance: {
381
+ agent: input.provenance.agent,
382
+ ...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
383
+ ...(input.provenance.source_ids?.length ? { source_ids: input.provenance.source_ids } : {}),
384
+ ...(input.provenance.note ? { note: input.provenance.note } : {}),
385
+ },
386
+ links,
387
+ mutation_log: [],
388
+ body: input.body || "",
389
+ };
390
+
391
+ this._writeRecord(record);
392
+
393
+ const graph = loadGraph(this._graphPath);
394
+ addLinksToGraph(graph, id, links);
395
+ saveGraph(this._graphPath, graph);
396
+
397
+ return id;
398
+ }
399
+
400
+ // -------------------------------------------------------------------------
401
+ // update
402
+ // -------------------------------------------------------------------------
403
+
404
+ async update(id, fields, evidence) {
405
+ if (!evidence?.agent)
406
+ throw missingEvidenceError("update: missing required evidence field: agent");
407
+
408
+ const pathIndex = this._loadPathIndex();
409
+ const record = this._readRecord(id, pathIndex);
410
+ if (!record) throw notFoundError(id);
411
+
412
+ const mutableKeys = ["title", "body", "category", "tags", "links"];
413
+ const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
414
+ if (supplied.length === 0)
415
+ throw missingEvidenceError("update: at least one mutable field must be supplied");
416
+
417
+ if (fields.category !== undefined && !validateCategory(fields.category))
418
+ throw missingEvidenceError(`update: invalid category: ${fields.category}`);
419
+
420
+ const now = this._now();
421
+
422
+ let newLinks = record.links || [];
423
+ if (fields.links !== undefined) {
424
+ const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
425
+ newLinks = mergeLinks(fields.links, wikilinks);
426
+ } else if (fields.body !== undefined) {
427
+ const wikilinks = extractWikilinks(fields.body);
428
+ newLinks = mergeLinks(record.links || [], wikilinks);
429
+ }
430
+
431
+ const updated = {
432
+ ...record,
433
+ ...(fields.title !== undefined ? { title: fields.title } : {}),
434
+ ...(fields.body !== undefined ? { body: fields.body } : {}),
435
+ ...(fields.category !== undefined ? { category: fields.category } : {}),
436
+ ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
437
+ links: newLinks,
438
+ updated_at: now,
439
+ mutation_log: [
440
+ ...(record.mutation_log || []),
441
+ {
442
+ op: "update",
443
+ at: now,
444
+ agent: evidence.agent,
445
+ ...(evidence.note ? { note: evidence.note } : {}),
446
+ evidence: { fields: supplied },
447
+ },
448
+ ],
449
+ };
450
+
451
+ const graph = loadGraph(this._graphPath);
452
+ removeLinksFromGraph(graph, id);
453
+ addLinksToGraph(graph, id, newLinks);
454
+ saveGraph(this._graphPath, graph);
455
+
456
+ this._writeRecord(updated, pathIndex);
457
+ this._savePathIndex(pathIndex);
458
+ }
459
+
460
+ // -------------------------------------------------------------------------
461
+ // link
462
+ // -------------------------------------------------------------------------
463
+
464
+ async link(sourceId, links, evidence) {
465
+ if (!evidence?.agent)
466
+ throw missingEvidenceError("link: missing required evidence field: agent");
467
+ if (!links || links.length === 0)
468
+ throw missingEvidenceError("link: links array must be non-empty");
469
+
470
+ const pathIndex = this._loadPathIndex();
471
+ const source = this._readRecord(sourceId, pathIndex);
472
+ if (!source) throw notFoundError(sourceId);
473
+
474
+ for (const l of links) {
475
+ if (!this._readRecord(l.target_id, pathIndex)) throw notFoundError(l.target_id);
476
+ }
477
+
478
+ const now = this._now();
479
+ const existingLinks = source.links || [];
480
+
481
+ const key = (l) => `${l.target_id}::${l.kind}`;
482
+ const seen = new Set(existingLinks.map(key));
483
+ const newLinks = [...existingLinks];
484
+ for (const l of links) {
485
+ if (!seen.has(key(l))) {
486
+ newLinks.push(l);
487
+ seen.add(key(l));
488
+ }
489
+ }
490
+
491
+ const updated = {
492
+ ...source,
493
+ links: newLinks,
494
+ updated_at: now,
495
+ mutation_log: [
496
+ ...(source.mutation_log || []),
497
+ {
498
+ op: "link",
499
+ at: now,
500
+ agent: evidence.agent,
501
+ ...(evidence.note ? { note: evidence.note } : {}),
502
+ evidence: { added: links },
503
+ },
504
+ ],
505
+ };
506
+
507
+ const graph = loadGraph(this._graphPath);
508
+ removeLinksFromGraph(graph, sourceId);
509
+ addLinksToGraph(graph, sourceId, newLinks);
510
+ saveGraph(this._graphPath, graph);
511
+
512
+ this._writeRecord(updated, pathIndex);
513
+ this._savePathIndex(pathIndex);
514
+ }
515
+
516
+ // -------------------------------------------------------------------------
517
+ // propose
518
+ // -------------------------------------------------------------------------
519
+
520
+ async propose(conceptId, proposerId, evidence) {
521
+ if (!evidence?.agent)
522
+ throw missingEvidenceError("propose: missing required evidence field: agent");
523
+ if (!evidence?.proposal || !evidence.proposal.trim())
524
+ throw missingEvidenceError("propose: missing required evidence field: proposal");
525
+
526
+ const pathIndex = this._loadPathIndex();
527
+ const concept = this._readRecord(conceptId, pathIndex);
528
+ if (!concept) throw notFoundError(conceptId);
529
+
530
+ const proposer = this._readRecord(proposerId, pathIndex);
531
+ if (!proposer) throw notFoundError(proposerId);
532
+
533
+ const now = this._now();
534
+
535
+ const proposerLinks = proposer.links || [];
536
+ const alreadyLinked = proposerLinks.some(
537
+ (l) => l.target_id === conceptId && l.kind === "proposes"
538
+ );
539
+ if (!alreadyLinked) {
540
+ const updatedProposer = {
541
+ ...proposer,
542
+ links: [...proposerLinks, { target_id: conceptId, kind: "proposes" }],
543
+ updated_at: now,
544
+ mutation_log: [
545
+ ...(proposer.mutation_log || []),
546
+ {
547
+ op: "propose",
548
+ at: now,
549
+ agent: evidence.agent,
550
+ evidence: { concept_id: conceptId, proposal: evidence.proposal },
551
+ },
552
+ ],
553
+ };
554
+ this._writeRecord(updatedProposer, pathIndex);
555
+
556
+ const graph = loadGraph(this._graphPath);
557
+ removeLinksFromGraph(graph, proposerId);
558
+ addLinksToGraph(graph, proposerId, updatedProposer.links);
559
+ saveGraph(this._graphPath, graph);
560
+ }
561
+
562
+ const updatedConcept = {
563
+ ...concept,
564
+ mutation_log: [
565
+ ...(concept.mutation_log || []),
566
+ {
567
+ op: "propose",
568
+ at: now,
569
+ agent: evidence.agent,
570
+ evidence: { proposer_id: proposerId, proposal: evidence.proposal },
571
+ },
572
+ ],
573
+ };
574
+ this._writeRecord(updatedConcept, pathIndex);
575
+ this._savePathIndex(pathIndex);
576
+ }
577
+
578
+ // -------------------------------------------------------------------------
579
+ // apply
580
+ // -------------------------------------------------------------------------
581
+
582
+ async apply(conceptId, proposerId, evidence) {
583
+ if (!evidence?.agent)
584
+ throw missingEvidenceError("apply: missing required evidence field: agent");
585
+ if (!evidence?.new_body && evidence?.new_body !== "")
586
+ throw missingEvidenceError("apply: missing required evidence field: new_body");
587
+ if (!evidence?.new_body?.trim?.())
588
+ throw missingEvidenceError("apply: new_body must be non-empty");
589
+ if (!evidence?.rationale || !evidence.rationale.trim())
590
+ throw missingEvidenceError("apply: missing required evidence field: rationale");
591
+
592
+ const pathIndex = this._loadPathIndex();
593
+ const concept = this._readRecord(conceptId, pathIndex);
594
+ if (!concept) throw notFoundError(conceptId);
595
+
596
+ const proposer = this._readRecord(proposerId, pathIndex);
597
+ if (!proposer) throw notFoundError(proposerId);
598
+
599
+ const proposerLinks = proposer.links || [];
600
+ const hasProposesLink = proposerLinks.some(
601
+ (l) => l.target_id === conceptId && l.kind === "proposes"
602
+ );
603
+ if (!hasProposesLink)
604
+ throw missingEvidenceError(`apply: no "proposes" link from ${proposerId} to ${conceptId}`);
605
+
606
+ const now = this._now();
607
+ const updatedConcept = {
608
+ ...concept,
609
+ body: evidence.new_body,
610
+ updated_at: now,
611
+ mutation_log: [
612
+ ...(concept.mutation_log || []),
613
+ {
614
+ op: "apply",
615
+ at: now,
616
+ agent: evidence.agent,
617
+ evidence: { proposer_id: proposerId, rationale: evidence.rationale },
618
+ },
619
+ ],
620
+ };
621
+ this._writeRecord(updatedConcept, pathIndex);
622
+ this._savePathIndex(pathIndex);
623
+ }
624
+
625
+ // -------------------------------------------------------------------------
626
+ // reject
627
+ // -------------------------------------------------------------------------
628
+
629
+ async reject(conceptId, proposerId, evidence) {
630
+ if (!evidence?.agent)
631
+ throw missingEvidenceError("reject: missing required evidence field: agent");
632
+ if (!evidence?.reason || !evidence.reason.trim())
633
+ throw missingEvidenceError("reject: missing required evidence field: reason");
634
+
635
+ const pathIndex = this._loadPathIndex();
636
+ const concept = this._readRecord(conceptId, pathIndex);
637
+ if (!concept) throw notFoundError(conceptId);
638
+
639
+ const proposer = this._readRecord(proposerId, pathIndex);
640
+ if (!proposer) throw notFoundError(proposerId);
641
+
642
+ const proposerLinks = proposer.links || [];
643
+ const hasProposesLink = proposerLinks.some(
644
+ (l) => l.target_id === conceptId && l.kind === "proposes"
645
+ );
646
+ if (!hasProposesLink)
647
+ throw missingEvidenceError(`reject: no "proposes" link from ${proposerId} to ${conceptId}`);
648
+
649
+ const now = this._now();
650
+ const updatedConcept = {
651
+ ...concept,
652
+ // updated_at NOT changed — concept body was not mutated
653
+ mutation_log: [
654
+ ...(concept.mutation_log || []),
655
+ {
656
+ op: "reject",
657
+ at: now,
658
+ agent: evidence.agent,
659
+ evidence: { proposer_id: proposerId, reason: evidence.reason },
660
+ },
661
+ ],
662
+ };
663
+ this._writeRecord(updatedConcept, pathIndex);
664
+ this._savePathIndex(pathIndex);
665
+ }
666
+
667
+ // -------------------------------------------------------------------------
668
+ // supersede (Addendum A)
669
+ // -------------------------------------------------------------------------
670
+
671
+ async supersede(newId, supersededIds, evidence) {
672
+ if (!evidence?.agent)
673
+ throw missingEvidenceError("supersede: missing required evidence field: agent");
674
+ if (!evidence?.rationale || !evidence.rationale.trim())
675
+ throw missingEvidenceError("supersede: missing required evidence field: rationale");
676
+ if (!supersededIds || supersededIds.length === 0)
677
+ throw missingEvidenceError("supersede: supersededIds must be a non-empty array");
678
+
679
+ const pathIndex = this._loadPathIndex();
680
+ const newRecord = this._readRecord(newId, pathIndex);
681
+ if (!newRecord) throw notFoundError(newId);
682
+
683
+ for (const sid of supersededIds) {
684
+ const rec = this._readRecord(sid, pathIndex);
685
+ if (!rec) throw notFoundError(sid);
686
+ }
687
+
688
+ const now = this._now();
689
+
690
+ const supersededLinks = supersededIds.map((sid) => ({
691
+ target_id: sid,
692
+ kind: "supersedes",
693
+ }));
694
+
695
+ const existingLinks = newRecord.links || [];
696
+ const key = (l) => `${l.target_id}::${l.kind}`;
697
+ const seen = new Set(existingLinks.map(key));
698
+ const newLinks = [...existingLinks];
699
+ for (const l of supersededLinks) {
700
+ if (!seen.has(key(l))) {
701
+ newLinks.push(l);
702
+ seen.add(key(l));
703
+ }
704
+ }
705
+
706
+ const updatedNew = {
707
+ ...newRecord,
708
+ links: newLinks,
709
+ updated_at: now,
710
+ mutation_log: [
711
+ ...(newRecord.mutation_log || []),
712
+ {
713
+ op: "supersede",
714
+ at: now,
715
+ agent: evidence.agent,
716
+ rationale: evidence.rationale,
717
+ ...(evidence.note ? { note: evidence.note } : {}),
718
+ evidence: { superseded_count: supersededIds.length },
719
+ },
720
+ ],
721
+ };
722
+
723
+ const graph = loadGraph(this._graphPath);
724
+ removeLinksFromGraph(graph, newId);
725
+ addLinksToGraph(graph, newId, newLinks);
726
+ saveGraph(this._graphPath, graph);
727
+
728
+ this._writeRecord(updatedNew, pathIndex);
729
+
730
+ // Write superseded-by mutation log to each superseded record, then archive
731
+ for (const sid of supersededIds) {
732
+ const supersededRec = this._readRecord(sid, pathIndex);
733
+ if (!supersededRec) continue;
734
+ const updatedSuperseded = {
735
+ ...supersededRec,
736
+ // updated_at NOT changed — content not mutated
737
+ mutation_log: [
738
+ ...(supersededRec.mutation_log || []),
739
+ {
740
+ op: "superseded-by",
741
+ at: now,
742
+ agent: evidence.agent,
743
+ new_id: newId,
744
+ rationale: evidence.rationale,
745
+ ...(evidence.note ? { note: evidence.note } : {}),
746
+ evidence: { superseded_by_id: newId },
747
+ },
748
+ ],
749
+ };
750
+ this._writeRecord(updatedSuperseded, pathIndex);
751
+ // Move superseded file to archive/ (supersede-not-delete invariant)
752
+ this._archiveRecord(sid, pathIndex);
753
+ }
754
+
755
+ this._savePathIndex(pathIndex);
756
+ }
757
+
758
+ // -------------------------------------------------------------------------
759
+ // retire (Addendum B)
760
+ // -------------------------------------------------------------------------
761
+
762
+ async retire(id, targetStatus, evidence) {
763
+ if (!evidence?.agent)
764
+ throw missingEvidenceError("retire: missing required evidence field: agent");
765
+ if (!evidence?.rationale || !evidence.rationale.trim())
766
+ throw missingEvidenceError("retire: missing required evidence field: rationale");
767
+ if (targetStatus !== "implemented" && targetStatus !== "retired")
768
+ throw missingEvidenceError(
769
+ `retire: targetStatus must be "implemented" or "retired"; got: ${targetStatus}`
770
+ );
771
+ if (targetStatus === "implemented" && (!evidence.implementedByRef || !evidence.implementedByRef.trim()))
772
+ throw missingEvidenceError(
773
+ 'retire: implementedByRef is required when targetStatus is "implemented"'
774
+ );
775
+
776
+ const pathIndex = this._loadPathIndex();
777
+ const record = this._readRecord(id, pathIndex);
778
+ if (!record) throw notFoundError(id);
779
+
780
+ const currentStatus = record.status || "active";
781
+ const allowed = VALID_STATUS_TRANSITIONS[currentStatus];
782
+ if (!allowed || !allowed.has(targetStatus)) {
783
+ throw missingEvidenceError(
784
+ `retire: invalid transition from "${currentStatus}" to "${targetStatus}"`
785
+ );
786
+ }
787
+
788
+ const now = this._now();
789
+ const updated = {
790
+ ...record,
791
+ status: targetStatus,
792
+ updated_at: now,
793
+ mutation_log: [
794
+ ...(record.mutation_log || []),
795
+ {
796
+ op: "retire",
797
+ at: now,
798
+ agent: evidence.agent,
799
+ ...(evidence.note ? { note: evidence.note } : {}),
800
+ evidence: {
801
+ targetStatus,
802
+ rationale: evidence.rationale,
803
+ ...(evidence.implementedByRef ? { implementedByRef: evidence.implementedByRef } : {}),
804
+ ...(evidence.supersededByRef ? { supersededByRef: evidence.supersededByRef } : {}),
805
+ },
806
+ },
807
+ ],
808
+ };
809
+ this._writeRecord(updated, pathIndex);
810
+ this._savePathIndex(pathIndex);
811
+ }
812
+
813
+ // -------------------------------------------------------------------------
814
+ // get
815
+ // -------------------------------------------------------------------------
816
+
817
+ async get(id) {
818
+ return this._readRecord(id);
819
+ }
820
+
821
+ // -------------------------------------------------------------------------
822
+ // getLinks
823
+ // -------------------------------------------------------------------------
824
+
825
+ async getLinks(id) {
826
+ const graph = loadGraph(this._graphPath);
827
+ return {
828
+ forward: (graph.forward[id] || []).map((l) => ({ ...l })),
829
+ reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
830
+ };
831
+ }
832
+
833
+ // -------------------------------------------------------------------------
834
+ // listByCategory
835
+ // -------------------------------------------------------------------------
836
+
837
+ async listByCategory(category, options = {}) {
838
+ const records = this._allRecords();
839
+ const includeRetired = options.includeRetired === true;
840
+ if (options.prefix) {
841
+ return records.filter(
842
+ (r) =>
843
+ (r.category === category || r.category.startsWith(`${category}.`)) &&
844
+ (includeRetired || (r.status || "active") !== "retired")
845
+ );
846
+ }
847
+ return records.filter(
848
+ (r) =>
849
+ r.category === category &&
850
+ (includeRetired || (r.status || "active") !== "retired")
851
+ );
852
+ }
853
+
854
+ // -------------------------------------------------------------------------
855
+ // listByType
856
+ // -------------------------------------------------------------------------
857
+
858
+ async listByType(type, options = {}) {
859
+ const includeRetired = options.includeRetired === true;
860
+ return this._allRecords().filter(
861
+ (r) =>
862
+ r.type === type &&
863
+ (includeRetired || (r.status || "active") !== "retired")
864
+ );
865
+ }
866
+ }
867
+
868
+ export default ObsidianKnowledgeStore;