@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,722 @@
1
+ /**
2
+ * Knowledge Kit — Entity Cards Eval Suite (Issue #48)
3
+ *
4
+ * Covers AC1-AC4 for person/entity cards with backlinks + gated resolution:
5
+ *
6
+ * AC1: compiling a raw note with 'Attendees: Dana Smith (Acme VP Eng), Lee Wong'
7
+ * yields two person cards with role text, each backlinking the raw+compiled
8
+ * notes; compiled note body wikilinks both cards.
9
+ *
10
+ * AC2: a second meeting with 'Dana Smith' updates the SAME card (new backlink,
11
+ * no duplicate); 'Dana S.' creates a separate card with a possible-duplicate
12
+ * link, NOT an auto-merge.
13
+ *
14
+ * AC3: card merge via propose/apply unions backlinks+aliases and supersedes the
15
+ * duplicate (queryable in archive); reject leaves both cards byte-identical.
16
+ *
17
+ * AC4: both store adapters pass the extended contract suite; Obsidian adapter
18
+ * renders cards in people/ with [[name]] resolution.
19
+ *
20
+ * Run:
21
+ * node --test kits/knowledge/evals/entities/suite.test.js
22
+ */
23
+
24
+ import { test, describe, before, after } from "node:test";
25
+ import assert from "node:assert/strict";
26
+ import * as fs from "node:fs";
27
+ import * as path from "node:path";
28
+ import * as os from "node:os";
29
+ import { fileURLToPath } from "node:url";
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ const KIT_ROOT = path.resolve(__dirname, "../../../..");
33
+
34
+ // ---------------------------------------------------------------------------
35
+ // Adapter resolution (same pattern as contract-suite)
36
+ // ---------------------------------------------------------------------------
37
+
38
+ function resolveAdapterPath() {
39
+ const adapterFlag = process.argv.find((a) => a.startsWith("--adapter="));
40
+ if (adapterFlag) return path.resolve(adapterFlag.slice("--adapter=".length));
41
+ if (process.env.KNOWLEDGE_ADAPTER) return path.resolve(process.env.KNOWLEDGE_ADAPTER);
42
+ return path.join(KIT_ROOT, "kits/knowledge/adapters/default-store/index.js");
43
+ }
44
+
45
+ const adapterPath = resolveAdapterPath();
46
+ const adapterModule = await import(adapterPath);
47
+ const AdapterClass = adapterModule.default
48
+ || adapterModule.DefaultKnowledgeStore
49
+ || adapterModule.ObsidianKnowledgeStore;
50
+
51
+ const runnerPath = path.join(KIT_ROOT, "kits/knowledge/adapters/flow-runner/index.js");
52
+ const extractorPath = path.join(KIT_ROOT, "kits/knowledge/adapters/flow-runner/entity-extractor.js");
53
+ const { KnowledgeFlowRunner } = await import(runnerPath);
54
+ const { defaultEntityExtractor, isPossibleDuplicate, isExactMatch, normalizeName } = await import(extractorPath);
55
+
56
+ // ---------------------------------------------------------------------------
57
+ // Helpers
58
+ // ---------------------------------------------------------------------------
59
+
60
+ function makeTempDir() {
61
+ return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-entities-"));
62
+ }
63
+
64
+ function makeStore(dir) {
65
+ return new AdapterClass({ storeRoot: dir });
66
+ }
67
+
68
+ function makeRunner(store, dir) {
69
+ return new KnowledgeFlowRunner({ store, workspace: dir, agent: "test-runner" });
70
+ }
71
+
72
+ // ---------------------------------------------------------------------------
73
+ // §1 Person type — contract extension (both adapters)
74
+ // ---------------------------------------------------------------------------
75
+ describe("person type: contract extension", () => {
76
+ let dir, store;
77
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
78
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
79
+
80
+ test("person type is accepted by create (C addendum)", async () => {
81
+ const id = await store.create({
82
+ type: "person",
83
+ title: "Dana Smith",
84
+ body: "**Role/Org:** Acme VP Eng",
85
+ category: "team",
86
+ tags: [],
87
+ provenance: { agent: "tester" },
88
+ });
89
+ assert.ok(id, "person record created");
90
+ const record = await store.get(id);
91
+ assert.equal(record.type, "person");
92
+ assert.equal(record.title, "Dana Smith");
93
+ });
94
+
95
+ test("person record round-trips with aliases in tags", async () => {
96
+ const id = await store.create({
97
+ type: "person",
98
+ title: "Dana Smith",
99
+ body: "**Role/Org:** Acme VP Eng",
100
+ category: "team",
101
+ tags: ["alias:Dana S."],
102
+ provenance: { agent: "tester" },
103
+ });
104
+ const record = await store.get(id);
105
+ assert.ok(record.tags.includes("alias:Dana S."), "alias tag round-trips");
106
+ });
107
+
108
+ test("listByType person returns only person records", async () => {
109
+ await store.create({
110
+ type: "raw",
111
+ title: "Some raw note",
112
+ body: "content",
113
+ category: "team",
114
+ provenance: { agent: "tester" },
115
+ });
116
+ const persons = await store.listByType("person");
117
+ assert.ok(persons.every((r) => r.type === "person"), "all returned are person type");
118
+ });
119
+
120
+ test("invalid type still rejected", async () => {
121
+ await assert.rejects(
122
+ () => store.create({ type: "bogus-type", title: "T", body: "B", category: "test", provenance: { agent: "tester" } }),
123
+ { code: "MISSING_EVIDENCE" }
124
+ );
125
+ });
126
+ });
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // §2 Entity extractor
130
+ // ---------------------------------------------------------------------------
131
+ describe("entity extractor: defaultEntityExtractor", () => {
132
+ test("parses Attendees line with role/org", async () => {
133
+ const record = {
134
+ body: "Attendees: Dana Smith (Acme VP Eng), Lee Wong\n\nMeeting notes here.",
135
+ };
136
+ const mentions = await defaultEntityExtractor(record);
137
+ assert.equal(mentions.length, 2);
138
+ const dana = mentions.find((m) => m.name === "Dana Smith");
139
+ assert.ok(dana, "Dana Smith found");
140
+ assert.ok(dana.role, "Dana has role text");
141
+ assert.ok(dana.role.includes("Acme VP Eng"), "role includes Acme VP Eng");
142
+ const lee = mentions.find((m) => m.name === "Lee Wong");
143
+ assert.ok(lee, "Lee Wong found");
144
+ });
145
+
146
+ test("parses Attendees line without role", async () => {
147
+ const record = { body: "Attendees: Lee Wong\n" };
148
+ const mentions = await defaultEntityExtractor(record);
149
+ assert.equal(mentions.length, 1);
150
+ assert.equal(mentions[0].name, "Lee Wong");
151
+ assert.equal(mentions[0].role, undefined);
152
+ });
153
+
154
+ test("deduplicates names across Attendees and wikilinks", async () => {
155
+ const record = {
156
+ body: "Attendees: Dana Smith (Acme VP Eng)\n[[Dana Smith]] confirmed.",
157
+ };
158
+ const mentions = await defaultEntityExtractor(record);
159
+ const danaCount = mentions.filter((m) => m.name === "Dana Smith").length;
160
+ assert.equal(danaCount, 1, "duplicate Dana Smith deduplicated");
161
+ });
162
+
163
+ test("extracts wikilinks when no Attendees line", async () => {
164
+ const record = { body: "Follow up with [[Lee Wong]] and [[Dana Smith]]." };
165
+ const mentions = await defaultEntityExtractor(record);
166
+ assert.equal(mentions.length, 2);
167
+ });
168
+
169
+ test("returns empty array for body with no names", async () => {
170
+ const record = { body: "General meeting notes about the API design." };
171
+ const mentions = await defaultEntityExtractor(record);
172
+ assert.equal(mentions.length, 0);
173
+ });
174
+ });
175
+
176
+ // ---------------------------------------------------------------------------
177
+ // §2b Entity extractor: trailing-punctuation regression (issue #48)
178
+ // ---------------------------------------------------------------------------
179
+ describe("entity extractor: trailing-punctuation regression (#48)", () => {
180
+ test("last-entry-with-trailing-period: role not folded into name", async () => {
181
+ // 'Lee Wong (Acme procurement).' — trailing period on last entry
182
+ // Before fix: name='Lee Wong (Acme procurement).', role=undefined
183
+ // After fix: name='Lee Wong', role='Acme procurement'
184
+ const record = {
185
+ body: "Attendees: Dana Smith (Acme VP Eng), Lee Wong (Acme procurement).",
186
+ };
187
+ const mentions = await defaultEntityExtractor(record);
188
+ assert.equal(mentions.length, 2, "two mentions extracted");
189
+ const lee = mentions.find((m) => m.name === "Lee Wong");
190
+ assert.ok(lee, "Lee Wong found with correct name (not 'Lee Wong Acme procurement')");
191
+ assert.ok(lee.role, "Lee Wong has role");
192
+ assert.ok(lee.role.includes("Acme procurement"), "role is 'Acme procurement'");
193
+ });
194
+
195
+ test("last-entry-no-period: baseline still works without trailing period", async () => {
196
+ const record = {
197
+ body: "Attendees: Dana Smith (Acme VP Eng), Lee Wong (Acme procurement)",
198
+ };
199
+ const mentions = await defaultEntityExtractor(record);
200
+ const lee = mentions.find((m) => m.name === "Lee Wong");
201
+ assert.ok(lee, "Lee Wong found");
202
+ assert.equal(lee.role, "Acme procurement", "role correct without trailing period");
203
+ });
204
+
205
+ test("single-attendee line with trailing period", async () => {
206
+ const record = {
207
+ body: "Attendees: Lee Wong (Acme procurement).",
208
+ };
209
+ const mentions = await defaultEntityExtractor(record);
210
+ assert.equal(mentions.length, 1, "one mention");
211
+ const lee = mentions[0];
212
+ assert.equal(lee.name, "Lee Wong", "name is 'Lee Wong'");
213
+ assert.equal(lee.role, "Acme procurement", "role is 'Acme procurement'");
214
+ });
215
+
216
+ test("single-attendee line without trailing period", async () => {
217
+ const record = {
218
+ body: "Attendees: Lee Wong (Acme procurement)",
219
+ };
220
+ const mentions = await defaultEntityExtractor(record);
221
+ assert.equal(mentions.length, 1, "one mention");
222
+ assert.equal(mentions[0].name, "Lee Wong");
223
+ assert.equal(mentions[0].role, "Acme procurement");
224
+ });
225
+
226
+ test("role containing comma: 'Lee Wong (Acme, procurement).'", async () => {
227
+ // Commas inside the parenthetical are part of the role, not entry separators
228
+ const record = {
229
+ body: "Attendees: Lee Wong (Acme, procurement).",
230
+ };
231
+ const mentions = await defaultEntityExtractor(record);
232
+ assert.equal(mentions.length, 1, "one mention despite comma in role");
233
+ const lee = mentions[0];
234
+ assert.equal(lee.name, "Lee Wong", "name correct");
235
+ assert.ok(lee.role.includes("Acme"), "role includes Acme");
236
+ assert.ok(lee.role.includes("procurement"), "role includes procurement");
237
+ });
238
+
239
+ test("role containing periods: 'Dr. Lee Wong (Acme Sr. Eng.)'", async () => {
240
+ const record = {
241
+ body: "Attendees: Dr. Lee Wong (Acme Sr. Eng.)",
242
+ };
243
+ const mentions = await defaultEntityExtractor(record);
244
+ assert.equal(mentions.length, 1, "one mention");
245
+ const lee = mentions[0];
246
+ assert.equal(lee.name, "Dr. Lee Wong", "name with title prefix correct");
247
+ assert.ok(lee.role.includes("Acme Sr. Eng"), "role with internal periods correct");
248
+ });
249
+ });
250
+
251
+ // ---------------------------------------------------------------------------
252
+ // §3 Name resolution helpers
253
+ // ---------------------------------------------------------------------------
254
+ describe("name resolution: exact match and possible-duplicate", () => {
255
+ test("isExactMatch handles case/whitespace", () => {
256
+ assert.ok(isExactMatch("Dana Smith", "dana smith"));
257
+ assert.ok(isExactMatch(" Dana Smith ", "Dana Smith"));
258
+ assert.ok(!isExactMatch("Dana Smith", "Dana S."));
259
+ });
260
+
261
+ test("isPossibleDuplicate: same surname + initial", () => {
262
+ assert.ok(isPossibleDuplicate("Dana S.", "Dana Smith"));
263
+ assert.ok(isPossibleDuplicate("D. Smith", "Dana Smith"));
264
+ assert.ok(!isPossibleDuplicate("Dana Smith", "Dana Smith"), "exact match is not duplicate");
265
+ assert.ok(!isPossibleDuplicate("Lee Wong", "Dana Smith"), "different person is not duplicate");
266
+ assert.ok(!isPossibleDuplicate("Dana Johnson", "Dana Smith"), "different surname is not duplicate");
267
+ });
268
+ });
269
+
270
+ // ---------------------------------------------------------------------------
271
+ // §4 AC1: compile + extractEntities yields person cards with role + backlinks
272
+ // ---------------------------------------------------------------------------
273
+ describe("AC1: compile + extractEntities yields person cards with backlinks", () => {
274
+ let dir, store, runner;
275
+ before(() => {
276
+ dir = makeTempDir();
277
+ store = makeStore(dir);
278
+ runner = makeRunner(store, dir);
279
+ });
280
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
281
+
282
+ test("AC1: two person cards created with role text", async () => {
283
+ const rawBody = "Attendees: Dana Smith (Acme VP Eng), Lee Wong\n\nDiscussed Q3 roadmap.";
284
+
285
+ // Capture raw
286
+ const { id: rawId } = await runner.capture(rawBody, {
287
+ title: "Acme Q3 Meeting",
288
+ category: "team.meeting",
289
+ });
290
+
291
+ // Compile
292
+ const { id: compiledId } = await runner.compile([rawId], {
293
+ title: "Compiled: Acme Q3 Meeting",
294
+ category: "team.meeting",
295
+ });
296
+
297
+ // Extract entities
298
+ const result = await runner.extractEntities(compiledId);
299
+
300
+ assert.equal(result.personCards.length, 2, "two person cards created");
301
+
302
+ const danaResult = result.personCards.find((c) => c.name === "Dana Smith");
303
+ const leeResult = result.personCards.find((c) => c.name === "Lee Wong");
304
+ assert.ok(danaResult, "Dana Smith card found");
305
+ assert.ok(leeResult, "Lee Wong card found");
306
+
307
+ // Verify Dana's card has role text
308
+ const danaCard = await store.get(danaResult.cardId);
309
+ assert.ok(danaCard.body.includes("Acme VP Eng"), "Dana card has role text");
310
+
311
+ // Verify both cards have appears-in links to the compiled record
312
+ const danaLinks = await store.getLinks(danaResult.cardId);
313
+ const danaCompiledLink = danaLinks.forward.some(
314
+ (l) => l.target_id === compiledId && l.kind === "appears-in"
315
+ );
316
+ assert.ok(danaCompiledLink, "Dana card links to compiled (appears-in)");
317
+
318
+ const leeLinks = await store.getLinks(leeResult.cardId);
319
+ const leeCompiledLink = leeLinks.forward.some(
320
+ (l) => l.target_id === compiledId && l.kind === "appears-in"
321
+ );
322
+ assert.ok(leeCompiledLink, "Lee card links to compiled (appears-in)");
323
+
324
+ // Verify compiled record has person links to both cards
325
+ const compiledLinks = await store.getLinks(compiledId);
326
+ const hasDanaPersonLink = compiledLinks.forward.some(
327
+ (l) => l.target_id === danaResult.cardId && l.kind === "person"
328
+ );
329
+ const hasLeePersonLink = compiledLinks.forward.some(
330
+ (l) => l.target_id === leeResult.cardId && l.kind === "person"
331
+ );
332
+ assert.ok(hasDanaPersonLink, "compiled links to Dana (person)");
333
+ assert.ok(hasLeePersonLink, "compiled links to Lee (person)");
334
+ });
335
+
336
+ test("AC1: person cards backlink the raw source records", async () => {
337
+ const rawBody = "Attendees: Tina Ramos (PM)\nProduct review discussion.";
338
+ const { id: rawId } = await runner.capture(rawBody, { title: "Product Review", category: "team.meeting" });
339
+ const { id: compiledId } = await runner.compile([rawId], { title: "Compiled: Product Review", category: "team.meeting" });
340
+ const { personCards } = await runner.extractEntities(compiledId);
341
+
342
+ assert.equal(personCards.length, 1, "one person card for Tina");
343
+ const tinaLinks = await store.getLinks(personCards[0].cardId);
344
+ const tinaRawLink = tinaLinks.forward.some(
345
+ (l) => l.target_id === rawId && l.kind === "appears-in"
346
+ );
347
+ assert.ok(tinaRawLink, "Tina card links to raw source (appears-in)");
348
+ });
349
+ });
350
+
351
+ // ---------------------------------------------------------------------------
352
+ // §5 AC2: idempotent resolution and possible-duplicate handling
353
+ // ---------------------------------------------------------------------------
354
+ describe("AC2: second meeting updates same card; initial creates separate card", () => {
355
+ let dir, store, runner;
356
+ before(() => {
357
+ dir = makeTempDir();
358
+ store = makeStore(dir);
359
+ runner = makeRunner(store, dir);
360
+ });
361
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
362
+
363
+ test("AC2a: second meeting with 'Dana Smith' updates the SAME card", async () => {
364
+ // First meeting
365
+ const raw1Body = "Attendees: Dana Smith (Acme VP Eng)\nQ3 kickoff.";
366
+ const { id: raw1Id } = await runner.capture(raw1Body, { title: "Acme Kickoff", category: "team.meeting" });
367
+ const { id: compiled1Id } = await runner.compile([raw1Id], { title: "Compiled: Acme Kickoff", category: "team.meeting" });
368
+ const result1 = await runner.extractEntities(compiled1Id);
369
+ const danaCardId = result1.personCards.find((c) => c.name === "Dana Smith").cardId;
370
+
371
+ // Second meeting
372
+ const raw2Body = "Attendees: Dana Smith (Acme VP Eng)\nQ4 planning.";
373
+ const { id: raw2Id } = await runner.capture(raw2Body, { title: "Acme Q4 Planning", category: "team.meeting" });
374
+ const { id: compiled2Id } = await runner.compile([raw2Id], { title: "Compiled: Q4 Planning", category: "team.meeting" });
375
+ const result2 = await runner.extractEntities(compiled2Id);
376
+
377
+ const danaMention2 = result2.personCards.find((c) => c.name === "Dana Smith");
378
+ assert.ok(danaMention2, "Dana Smith found in second extraction");
379
+ assert.equal(danaMention2.cardId, danaCardId, "same card id — no duplicate created");
380
+ assert.equal(danaMention2.created, false, "card was NOT newly created");
381
+
382
+ // Verify the card now has appears-in links to both compiled records
383
+ const danaLinks = await store.getLinks(danaCardId);
384
+ const hasCompiled1 = danaLinks.forward.some((l) => l.target_id === compiled1Id && l.kind === "appears-in");
385
+ const hasCompiled2 = danaLinks.forward.some((l) => l.target_id === compiled2Id && l.kind === "appears-in");
386
+ assert.ok(hasCompiled1, "Dana links to first compiled");
387
+ assert.ok(hasCompiled2, "Dana links to second compiled (new backlink)");
388
+ });
389
+
390
+ test("AC2b: 'Dana S.' creates SEPARATE card with possible-duplicate link", async () => {
391
+ // Create a known Dana Smith card first
392
+ const raw1Body = "Attendees: Dana Smith (Acme VP Eng)\nKickoff.";
393
+ const { id: raw1Id } = await runner.capture(raw1Body, { title: "Meeting A", category: "team.meeting" });
394
+ const { id: compiled1Id } = await runner.compile([raw1Id], { title: "Compiled: Meeting A", category: "team.meeting" });
395
+ const result1 = await runner.extractEntities(compiled1Id);
396
+ const danaCardId = result1.personCards.find((c) => c.name === "Dana Smith").cardId;
397
+
398
+ // Now process a record with 'Dana S.'
399
+ const raw2Body = "Attendees: Dana S.\nFollow-up call.";
400
+ const { id: raw2Id } = await runner.capture(raw2Body, { title: "Follow-up Call", category: "team.meeting" });
401
+ const { id: compiled2Id } = await runner.compile([raw2Id], { title: "Compiled: Follow-up Call", category: "team.meeting" });
402
+ const result2 = await runner.extractEntities(compiled2Id);
403
+
404
+ const danaS = result2.personCards.find((c) => c.name === "Dana S.");
405
+ assert.ok(danaS, "Dana S. card found");
406
+ assert.notEqual(danaS.cardId, danaCardId, "separate card — not auto-merged");
407
+ assert.equal(danaS.created, true, "new card was created");
408
+ assert.equal(danaS.duplicate, true, "flagged as possible duplicate");
409
+
410
+ // The new card should have a possible-duplicate related link to the original
411
+ const danaSLinks = await store.getLinks(danaS.cardId);
412
+ const hasDupLink = danaSLinks.forward.some(
413
+ (l) => l.target_id === danaCardId && l.kind === "related" && l.label === "possible-duplicate"
414
+ );
415
+ assert.ok(hasDupLink, "possible-duplicate related link from Dana S. to Dana Smith");
416
+ });
417
+ });
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // §6 AC3: merge via propose/apply/reject
421
+ // ---------------------------------------------------------------------------
422
+ describe("AC3: card merge via propose/apply unions backlinks+aliases; reject leaves byte-identical", () => {
423
+ let dir, store, runner;
424
+ before(() => {
425
+ dir = makeTempDir();
426
+ store = makeStore(dir);
427
+ runner = makeRunner(store, dir);
428
+ });
429
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
430
+
431
+ test("AC3-apply: merge unions aliases+backlinks and supersedes duplicate", async () => {
432
+ // Create the primary person card
433
+ const primaryId = await store.create({
434
+ type: "person",
435
+ title: "Dana Smith",
436
+ body: "**Role/Org:** Acme VP Eng",
437
+ category: "team.meeting",
438
+ tags: [],
439
+ provenance: { agent: "tester" },
440
+ });
441
+
442
+ // Create a raw + compiled + link to primary so it has appears-in
443
+ const rawId = await store.create({
444
+ type: "raw",
445
+ title: "Meeting A",
446
+ body: "Attendees: Dana Smith (Acme VP Eng)",
447
+ category: "team.meeting",
448
+ provenance: { agent: "tester" },
449
+ });
450
+ await store.link(primaryId, [{ target_id: rawId, kind: "appears-in" }], { agent: "tester" });
451
+
452
+ // Create the duplicate card with a different appears-in source
453
+ const duplicateId = await store.create({
454
+ type: "person",
455
+ title: "Dana S.",
456
+ body: "**Role/Org:** Acme",
457
+ category: "team.meeting",
458
+ tags: [],
459
+ provenance: { agent: "tester" },
460
+ });
461
+ const raw2Id = await store.create({
462
+ type: "raw",
463
+ title: "Meeting B",
464
+ body: "Attendees: Dana S.",
465
+ category: "team.meeting",
466
+ provenance: { agent: "tester" },
467
+ });
468
+ await store.link(duplicateId, [{ target_id: raw2Id, kind: "appears-in" }], { agent: "tester" });
469
+
470
+ // Merge: apply
471
+ const mergeResult = await runner.mergePerson(primaryId, duplicateId, {
472
+ decision: "apply",
473
+ rationale: "Confirmed same person via email domain",
474
+ });
475
+ assert.equal(mergeResult.decision, "apply");
476
+
477
+ // Primary now has alias:Dana S.
478
+ const primaryAfter = await store.get(primaryId);
479
+ assert.ok(
480
+ (primaryAfter.tags || []).includes("alias:Dana S."),
481
+ "alias added to primary card"
482
+ );
483
+
484
+ // Primary has the union of backlinks
485
+ const primaryLinks = await store.getLinks(primaryId);
486
+ const hasRaw1 = primaryLinks.forward.some((l) => l.target_id === rawId && l.kind === "appears-in");
487
+ const hasRaw2 = primaryLinks.forward.some((l) => l.target_id === raw2Id && l.kind === "appears-in");
488
+ assert.ok(hasRaw1, "primary retains original backlink");
489
+ assert.ok(hasRaw2, "primary gained backlink from duplicate");
490
+
491
+ // Duplicate is archived (superseded — still queryable)
492
+ const dupRecord = await store.get(duplicateId);
493
+ assert.ok(dupRecord, "duplicate still queryable (supersede-not-delete)");
494
+ const dupLog = dupRecord.mutation_log || [];
495
+ const supersededByEntry = dupLog.find((e) => e.op === "superseded-by");
496
+ assert.ok(supersededByEntry, "duplicate has superseded-by mutation log entry");
497
+ });
498
+
499
+ test("AC3-reject: reject leaves both cards byte-identical", async () => {
500
+ // Create primary and duplicate
501
+ const primaryId = await store.create({
502
+ type: "person",
503
+ title: "Lee Wong",
504
+ body: "**Role/Org:** Acme Engineer",
505
+ category: "team.meeting",
506
+ tags: [],
507
+ provenance: { agent: "tester" },
508
+ });
509
+ const duplicateId = await store.create({
510
+ type: "person",
511
+ title: "L. Wong",
512
+ body: "**Role/Org:** Acme",
513
+ category: "team.meeting",
514
+ tags: [],
515
+ provenance: { agent: "tester" },
516
+ });
517
+
518
+ // Snapshot the state before reject
519
+ const primaryBefore = await store.get(primaryId);
520
+ const dupBefore = await store.get(duplicateId);
521
+ const primaryBodyBefore = primaryBefore.body;
522
+ const dupBodyBefore = dupBefore.body;
523
+ const primaryTagsBefore = JSON.stringify(primaryBefore.tags || []);
524
+ const dupTagsBefore = JSON.stringify(dupBefore.tags || []);
525
+
526
+ // Reject the merge
527
+ await runner.mergePerson(primaryId, duplicateId, {
528
+ decision: "reject",
529
+ rejectReason: "Different people — different first names confirmed",
530
+ });
531
+
532
+ // Both cards have byte-identical body + tags
533
+ const primaryAfter = await store.get(primaryId);
534
+ const dupAfter = await store.get(duplicateId);
535
+ assert.equal(primaryAfter.body, primaryBodyBefore, "primary body unchanged");
536
+ assert.equal(dupAfter.body, dupBodyBefore, "duplicate body unchanged");
537
+ assert.equal(JSON.stringify(primaryAfter.tags || []), primaryTagsBefore, "primary tags unchanged");
538
+ assert.equal(JSON.stringify(dupAfter.tags || []), dupTagsBefore, "duplicate tags unchanged");
539
+ });
540
+ });
541
+
542
+ // ---------------------------------------------------------------------------
543
+ // §7 AC4: Obsidian adapter renders person cards in people/ folder
544
+ // ---------------------------------------------------------------------------
545
+
546
+ const obsidianAdapterPath = path.join(
547
+ KIT_ROOT, "kits/knowledge/adapters/obsidian-store/index.js"
548
+ );
549
+ let _obsidianModule = null;
550
+ try {
551
+ _obsidianModule = await import(obsidianAdapterPath);
552
+ } catch {
553
+ _obsidianModule = null;
554
+ }
555
+ const ObsidianStore = _obsidianModule?.default || _obsidianModule?.ObsidianKnowledgeStore;
556
+
557
+ describe("AC4: Obsidian adapter — people/ folder and wikilink rendering", () => {
558
+
559
+ if (!ObsidianStore) {
560
+ test("AC4: obsidian adapter not available — skip", () => {});
561
+ } else {
562
+ test("AC4: person record stored in people/ folder", async () => {
563
+ const dir = makeTempDir();
564
+ try {
565
+ const store = new ObsidianStore({ storeRoot: dir });
566
+ const id = await store.create({
567
+ type: "person",
568
+ title: "Dana Smith",
569
+ body: "**Role/Org:** Acme VP Eng",
570
+ category: "team.meeting",
571
+ tags: [],
572
+ provenance: { agent: "tester" },
573
+ });
574
+
575
+ // Verify the file is in people/
576
+ const files = [];
577
+ function walkDir(d) {
578
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
579
+ if (entry.isDirectory()) walkDir(path.join(d, entry.name));
580
+ else if (entry.name.endsWith(".md")) files.push(path.join(d, entry.name));
581
+ }
582
+ }
583
+ walkDir(dir);
584
+ const personFiles = files.filter((f) => f.includes("/people/"));
585
+ assert.ok(personFiles.length > 0, "person record stored in people/ folder");
586
+ assert.ok(personFiles.some((f) => f.includes("dana-smith")), "filename is slugified title");
587
+ } finally {
588
+ fs.rmSync(dir, { recursive: true, force: true });
589
+ }
590
+ });
591
+
592
+ test("AC4: person card rendered with Appears In section", async () => {
593
+ const dir = makeTempDir();
594
+ try {
595
+ const store = new ObsidianStore({ storeRoot: dir });
596
+ const rawId = await store.create({
597
+ type: "raw",
598
+ title: "Acme Meeting",
599
+ body: "Attendees: Dana Smith (Acme VP Eng)\n",
600
+ category: "team.meeting",
601
+ provenance: { agent: "tester" },
602
+ });
603
+ const personId = await store.create({
604
+ type: "person",
605
+ title: "Dana Smith",
606
+ body: "**Role/Org:** Acme VP Eng",
607
+ category: "team.meeting",
608
+ links: [{ target_id: rawId, kind: "appears-in" }],
609
+ provenance: { agent: "tester" },
610
+ });
611
+
612
+ // Read the written file
613
+ const files = [];
614
+ function walkDir(d) {
615
+ for (const entry of fs.readdirSync(d, { withFileTypes: true })) {
616
+ if (entry.isDirectory()) walkDir(path.join(d, entry.name));
617
+ else if (entry.name.endsWith(".md")) files.push(path.join(d, entry.name));
618
+ }
619
+ }
620
+ walkDir(dir);
621
+ const personFile = files.find((f) => f.includes("/people/") && f.includes("dana-smith"));
622
+ assert.ok(personFile, "person file found in people/");
623
+
624
+ const content = fs.readFileSync(personFile, "utf8");
625
+ assert.ok(content.includes("## Appears In"), "Appears In section present");
626
+ assert.ok(content.includes("[["), "wikilinks present in Appears In section");
627
+ } finally {
628
+ fs.rmSync(dir, { recursive: true, force: true });
629
+ }
630
+ });
631
+
632
+ test("AC4: Obsidian adapter passes extended contract suite for person type", async () => {
633
+ const dir = makeTempDir();
634
+ try {
635
+ const store = new ObsidianStore({ storeRoot: dir });
636
+ // Basic contract ops on person type
637
+ const id = await store.create({
638
+ type: "person",
639
+ title: "Test Person",
640
+ body: "Test body",
641
+ category: "test",
642
+ provenance: { agent: "tester" },
643
+ });
644
+ const record = await store.get(id);
645
+ assert.equal(record.type, "person");
646
+ assert.equal(record.title, "Test Person");
647
+
648
+ await store.update(id, { body: "Updated body" }, { agent: "tester" });
649
+ const updated = await store.get(id);
650
+ assert.equal(updated.body, "Updated body");
651
+
652
+ const persons = await store.listByType("person");
653
+ assert.ok(persons.some((r) => r.id === id));
654
+ } finally {
655
+ fs.rmSync(dir, { recursive: true, force: true });
656
+ }
657
+ });
658
+ }
659
+ });
660
+
661
+ // ---------------------------------------------------------------------------
662
+ // §8 Extended contract suite: person type validity (both adapters)
663
+ // ---------------------------------------------------------------------------
664
+ describe("extended contract: person type accepted as valid (both adapters)", () => {
665
+ let dir, store;
666
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
667
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
668
+
669
+ test("person is a VALID_TYPES member — create succeeds", async () => {
670
+ const id = await store.create({
671
+ type: "person",
672
+ title: "Contract Test Person",
673
+ body: "Test body",
674
+ category: "test",
675
+ provenance: { agent: "tester" },
676
+ });
677
+ assert.ok(id);
678
+ const r = await store.get(id);
679
+ assert.equal(r.type, "person");
680
+ });
681
+
682
+ test("propose accepts person as target (Addendum B: all types)", async () => {
683
+ const personId = await store.create({
684
+ type: "person",
685
+ title: "Proposal Target",
686
+ body: "body",
687
+ category: "test",
688
+ provenance: { agent: "tester" },
689
+ });
690
+ const proposerId = await store.create({
691
+ type: "raw",
692
+ title: "Proposer",
693
+ body: "p",
694
+ category: "test",
695
+ provenance: { agent: "tester" },
696
+ });
697
+ await store.propose(personId, proposerId, { agent: "tester", proposal: "merge proposal" });
698
+ const { forward } = await store.getLinks(proposerId);
699
+ assert.ok(forward.some((l) => l.target_id === personId && l.kind === "proposes"));
700
+ });
701
+
702
+ test("apply/reject work on person records", async () => {
703
+ const personId = await store.create({
704
+ type: "person",
705
+ title: "Apply Target Person",
706
+ body: "original body",
707
+ category: "test",
708
+ provenance: { agent: "tester" },
709
+ });
710
+ const proposerId = await store.create({
711
+ type: "raw",
712
+ title: "Proposer for Person",
713
+ body: "p",
714
+ category: "test",
715
+ provenance: { agent: "tester" },
716
+ });
717
+ await store.propose(personId, proposerId, { agent: "tester", proposal: "update body" });
718
+ await store.apply(personId, proposerId, { agent: "tester", new_body: "updated body", rationale: "confirmed" });
719
+ const after = await store.get(personId);
720
+ assert.equal(after.body, "updated body");
721
+ });
722
+ });