@kontourai/flow-agents 2.4.0 → 3.1.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 (195) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/CHANGELOG.md +38 -0
  5. package/CONTEXT.md +88 -1
  6. package/README.md +4 -0
  7. package/build/src/cli/assignment-provider.d.ts +144 -0
  8. package/build/src/cli/assignment-provider.js +805 -0
  9. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  10. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  11. package/build/src/cli/validate-workflow-artifacts.js +5 -1
  12. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  13. package/build/src/cli/workflow-sidecar.js +549 -145
  14. package/build/src/cli.js +6 -0
  15. package/build/src/lib/flow-resolver.d.ts +12 -6
  16. package/build/src/lib/flow-resolver.js +30 -14
  17. package/build/src/tools/validate-source-tree.js +2 -0
  18. package/context/contracts/artifact-contract.md +2 -0
  19. package/context/contracts/assignment-provider-contract.md +248 -0
  20. package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
  21. package/context/contracts/decision-registry-contract.md +2 -0
  22. package/context/contracts/delivery-contract.md +2 -0
  23. package/context/contracts/execution-contract.md +25 -0
  24. package/context/contracts/governance-adapter-contract.md +2 -0
  25. package/context/contracts/knowledge-store-contract.md +197 -0
  26. package/context/contracts/planning-contract.md +2 -0
  27. package/context/contracts/probe-docs-write-contract.md +187 -0
  28. package/context/contracts/review-contract.md +2 -0
  29. package/context/contracts/sandbox-policy.md +2 -0
  30. package/context/contracts/standing-directives.md +13 -0
  31. package/context/contracts/verification-contract.md +2 -0
  32. package/context/contracts/work-item-contract.md +2 -0
  33. package/context/scripts/hooks/config-protection.js +14 -1
  34. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  35. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  36. package/context/scripts/hooks/workflow-steering.js +42 -0
  37. package/context/settings/assignment-provider-settings.json +33 -0
  38. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  39. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  40. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  41. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  42. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  43. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  44. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  45. package/docs/adr/0007-skill-audit.md +2 -0
  46. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  47. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  48. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  49. package/docs/adr/0011-mcp-posture.md +2 -0
  50. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  51. package/docs/adr/0013-context-lifecycle.md +2 -0
  52. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  54. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  55. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  56. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  57. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  58. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  59. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  60. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
  61. package/docs/adr/README.md +49 -0
  62. package/docs/adr/index.md +34 -0
  63. package/docs/context-map.md +1 -0
  64. package/docs/decisions/agent-coordination.md +20 -0
  65. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  66. package/docs/decisions/context-lifecycle.md +18 -0
  67. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  68. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  69. package/docs/decisions/flow-kit.md +20 -0
  70. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  71. package/docs/decisions/graph-knowledge-provider.md +63 -0
  72. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  73. package/docs/decisions/index.md +22 -0
  74. package/docs/decisions/kit-dependency-ownership.md +18 -0
  75. package/docs/decisions/kit-operation-boundary.md +18 -0
  76. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  77. package/docs/decisions/knowledge-store-provider.md +51 -0
  78. package/docs/decisions/kontour-resource-contract.md +18 -0
  79. package/docs/decisions/mcp-posture.md +18 -0
  80. package/docs/decisions/model-routing.md +63 -0
  81. package/docs/decisions/standing-directives.md +66 -0
  82. package/docs/decisions/three-hard-boundary-model.md +18 -0
  83. package/docs/decisions/trust-reconcile.md +20 -0
  84. package/docs/decisions/typescript-source-policy.md +48 -0
  85. package/docs/decisions/workflow-enforcement.md +18 -0
  86. package/docs/decisions/workflow-trust-state.md +20 -0
  87. package/docs/fixture-ownership.md +1 -0
  88. package/docs/workflow-shared-contracts.md +2 -1
  89. package/docs/workflow-usage-guide.md +7 -1
  90. package/evals/ci/run-baseline.sh +10 -0
  91. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  92. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  93. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  94. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  95. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  96. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  97. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  98. package/evals/integration/test_assignment_provider_github.sh +318 -0
  99. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  100. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  101. package/evals/integration/test_current_json_per_actor.sh +516 -0
  102. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  103. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  104. package/evals/integration/test_gate_lockdown.sh +10 -0
  105. package/evals/integration/test_publish_delivery.sh +21 -4
  106. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  107. package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
  108. package/evals/integration/test_reconcile_soundness.sh +33 -9
  109. package/evals/integration/test_trust_reconcile.sh +9 -8
  110. package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
  111. package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
  112. package/evals/run.sh +14 -0
  113. package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
  114. package/evals/static/test_knowledge_providers.sh +32 -0
  115. package/evals/static/test_workflow_skills.sh +15 -2
  116. package/kits/builder/skills/deliver/SKILL.md +36 -0
  117. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  118. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  119. package/kits/builder/skills/pull-work/SKILL.md +78 -10
  120. package/kits/knowledge/adapters/default-store/index.js +92 -4
  121. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  122. package/kits/knowledge/adapters/shared/codec.js +141 -0
  123. package/kits/knowledge/docs/README.md +121 -2
  124. package/kits/knowledge/docs/store-contract.md +112 -4
  125. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  126. package/kits/knowledge/flows/promote.flow.json +84 -0
  127. package/kits/knowledge/kit.json +50 -0
  128. package/kits/knowledge/promote/distill.js +96 -0
  129. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  130. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  131. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  132. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  133. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  134. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  135. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  136. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  137. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  138. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  139. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  140. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  141. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  142. package/kits/knowledge/promote/health.js +137 -0
  143. package/kits/knowledge/promote/index.js +176 -0
  144. package/kits/knowledge/promote/ingest.js +103 -0
  145. package/kits/knowledge/promote/lib.js +132 -0
  146. package/kits/knowledge/promote/link.js +84 -0
  147. package/kits/knowledge/promote/promote.test.js +174 -0
  148. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  149. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  150. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  151. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  152. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  153. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  154. package/kits/knowledge/providers/conformance/suite.test.js +143 -0
  155. package/kits/knowledge/providers/git-repo/index.js +236 -0
  156. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  157. package/kits/knowledge/providers/health/index.js +153 -0
  158. package/kits/knowledge/providers/index.js +25 -0
  159. package/kits/knowledge/providers/lib/model.js +91 -0
  160. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  161. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  162. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  163. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  164. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  165. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  166. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  167. package/kits/knowledge/providers/neo4j/index.js +280 -0
  168. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  169. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  170. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  171. package/kits/knowledge/providers/work-item/index.js +204 -0
  172. package/package.json +7 -3
  173. package/schemas/assignment-provider-settings.schema.json +125 -0
  174. package/schemas/knowledge/edge.schema.json +54 -0
  175. package/schemas/knowledge/health-report.schema.json +45 -0
  176. package/schemas/knowledge/node.schema.json +49 -0
  177. package/schemas/knowledge/proposal.schema.json +53 -0
  178. package/scripts/README.md +1 -0
  179. package/scripts/ci/trust-reconcile.js +521 -24
  180. package/scripts/freeze-adrs.mjs +364 -0
  181. package/scripts/hooks/config-protection.js +14 -1
  182. package/scripts/hooks/evidence-capture.js +4 -1
  183. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  184. package/scripts/hooks/lib/current-pointer.js +123 -0
  185. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  186. package/scripts/hooks/stop-goal-fit.js +4 -2
  187. package/scripts/hooks/workflow-steering.js +42 -0
  188. package/scripts/statusline/flow-agents-statusline.js +3 -1
  189. package/src/cli/assignment-provider.ts +927 -0
  190. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  191. package/src/cli/validate-workflow-artifacts.ts +5 -1
  192. package/src/cli/workflow-sidecar.ts +577 -139
  193. package/src/cli.ts +6 -0
  194. package/src/lib/flow-resolver.ts +35 -14
  195. package/src/tools/validate-source-tree.ts +2 -0
@@ -16,6 +16,18 @@ import * as fs from "node:fs";
16
16
  import * as path from "node:path";
17
17
  import { randomUUID } from "node:crypto";
18
18
 
19
+ // Record-identity resolution layer (short-id prefix + slug aliases, issue #339).
20
+ // Single-sourced in the shared codec so both bundled adapters resolve
21
+ // identically; the rest of this adapter keeps its own zero-import helpers.
22
+ import {
23
+ resolveRecordId,
24
+ normalizeAliases,
25
+ emptyAliasIndex,
26
+ loadAliasIndex,
27
+ saveAliasIndex,
28
+ registerAliases,
29
+ } from "../shared/codec.js";
30
+
19
31
  // ---------------------------------------------------------------------------
20
32
  // Error helpers
21
33
  // ---------------------------------------------------------------------------
@@ -340,6 +352,7 @@ export class DefaultKnowledgeStore {
340
352
  this._root = path.resolve(storeRoot);
341
353
  this._recordsDir = path.join(this._root, "records");
342
354
  this._graphPath = path.join(this._root, "graph-index.json");
355
+ this._aliasPath = path.join(this._root, "alias-index.json");
343
356
  fs.mkdirSync(this._recordsDir, { recursive: true });
344
357
  }
345
358
 
@@ -351,6 +364,35 @@ export class DefaultKnowledgeStore {
351
364
  return path.join(this._recordsDir, `${id}.md`);
352
365
  }
353
366
 
367
+ // -- Record-identity resolution (short-id prefix + slug alias, issue #339) --
368
+
369
+ /** Does a record file exist for this exact full id? */
370
+ _idExists(id) {
371
+ return fs.existsSync(this._recordPath(id));
372
+ }
373
+
374
+ /** All full record ids, derived cheaply from the records/ directory. */
375
+ _listIds() {
376
+ if (!fs.existsSync(this._recordsDir)) return [];
377
+ return fs.readdirSync(this._recordsDir)
378
+ .filter((f) => f.endsWith(".md"))
379
+ .map((f) => f.slice(0, -3));
380
+ }
381
+
382
+ /**
383
+ * Resolve a query token (exact id, slug alias, or unambiguous id prefix) to a
384
+ * single full record id, or null when it resolves to nothing. Throws
385
+ * AMBIGUOUS_ID when a prefix matches more than one record.
386
+ */
387
+ _resolveId(input) {
388
+ const aliasIndex = loadAliasIndex(this._aliasPath);
389
+ return resolveRecordId(input, {
390
+ idExists: (rid) => this._idExists(rid),
391
+ listIds: () => this._listIds(),
392
+ bySlug: aliasIndex.by_slug,
393
+ });
394
+ }
395
+
354
396
  _readRecord(id) {
355
397
  const p = this._recordPath(id);
356
398
  if (!fs.existsSync(p)) return null;
@@ -393,6 +435,15 @@ export class DefaultKnowledgeStore {
393
435
  const id = input.id || randomUUID();
394
436
  const now = this._now();
395
437
 
438
+ // Validate slug aliases (issue #339) and reserve them in the alias map
439
+ // BEFORE any write, so a SLUG_CONFLICT aborts create without a partial record.
440
+ const aliases = normalizeAliases(input.aliases);
441
+ let aliasIndex = null;
442
+ if (aliases.length) {
443
+ aliasIndex = loadAliasIndex(this._aliasPath);
444
+ registerAliases(aliasIndex, id, aliases);
445
+ }
446
+
396
447
  // Merge explicit links + wikilinks from body
397
448
  const explicitLinks = input.links || [];
398
449
  const wikilinks = extractWikilinks(input.body || "");
@@ -404,6 +455,7 @@ export class DefaultKnowledgeStore {
404
455
  title: input.title,
405
456
  category: input.category,
406
457
  tags: input.tags || [],
458
+ ...(aliases.length ? { aliases } : {}),
407
459
  status: "active",
408
460
  created_at: now,
409
461
  updated_at: now,
@@ -425,6 +477,9 @@ export class DefaultKnowledgeStore {
425
477
  addLinksToGraph(graph, id, links);
426
478
  saveGraph(this._graphPath, graph);
427
479
 
480
+ // Persist the alias map only after the record is on disk.
481
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
482
+
428
483
  return id;
429
484
  }
430
485
 
@@ -439,7 +494,7 @@ export class DefaultKnowledgeStore {
439
494
  const record = this._readRecord(id);
440
495
  if (!record) throw notFoundError(id);
441
496
 
442
- const mutableKeys = ["title", "body", "category", "tags", "links"];
497
+ const mutableKeys = ["title", "body", "category", "tags", "links", "aliases"];
443
498
  const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
444
499
  if (supplied.length === 0)
445
500
  throw missingEvidenceError("update: at least one mutable field must be supplied");
@@ -449,6 +504,18 @@ export class DefaultKnowledgeStore {
449
504
 
450
505
  const now = this._now();
451
506
 
507
+ // Slug aliases are append-only: supplied aliases are UNIONED with existing
508
+ // ones so a previously issued slug keeps resolving after a restructure (R3).
509
+ let mergedAliases = Array.isArray(record.aliases) ? record.aliases.slice() : [];
510
+ let aliasIndex = null;
511
+ if (fields.aliases !== undefined) {
512
+ const incoming = normalizeAliases(fields.aliases);
513
+ const seen = new Set(mergedAliases);
514
+ for (const s of incoming) if (!seen.has(s)) { seen.add(s); mergedAliases.push(s); }
515
+ aliasIndex = loadAliasIndex(this._aliasPath);
516
+ registerAliases(aliasIndex, id, mergedAliases);
517
+ }
518
+
452
519
  // Merge links if updated
453
520
  let newLinks = record.links || [];
454
521
  if (fields.links !== undefined) {
@@ -465,6 +532,7 @@ export class DefaultKnowledgeStore {
465
532
  ...(fields.body !== undefined ? { body: fields.body } : {}),
466
533
  ...(fields.category !== undefined ? { category: fields.category } : {}),
467
534
  ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
535
+ ...(mergedAliases.length ? { aliases: mergedAliases } : {}),
468
536
  links: newLinks,
469
537
  updated_at: now,
470
538
  mutation_log: [
@@ -486,6 +554,8 @@ export class DefaultKnowledgeStore {
486
554
  saveGraph(this._graphPath, graph);
487
555
 
488
556
  this._writeRecord(updated);
557
+
558
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
489
559
  }
490
560
 
491
561
  // -------------------------------------------------------------------------
@@ -843,7 +913,12 @@ export class DefaultKnowledgeStore {
843
913
  // -------------------------------------------------------------------------
844
914
 
845
915
  async get(id) {
846
- return this._readRecord(id);
916
+ // Accept an exact id, a slug alias, or an unambiguous id prefix (issue #339).
917
+ // Unresolved → null (unchanged missing-record semantics); ambiguous prefix
918
+ // → throws AMBIGUOUS_ID.
919
+ const resolvedId = this._resolveId(id);
920
+ if (!resolvedId) return null;
921
+ return this._readRecord(resolvedId);
847
922
  }
848
923
 
849
924
  // -------------------------------------------------------------------------
@@ -851,10 +926,13 @@ export class DefaultKnowledgeStore {
851
926
  // -------------------------------------------------------------------------
852
927
 
853
928
  async getLinks(id) {
929
+ // Resolve prefix/slug to a full id; fall back to the raw token when it
930
+ // resolves to nothing so unknown ids still return empty arrays (not throw).
931
+ const key = this._resolveId(id) || id;
854
932
  const graph = loadGraph(this._graphPath);
855
933
  return {
856
- forward: (graph.forward[id] || []).map((l) => ({ ...l })),
857
- reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
934
+ forward: (graph.forward[key] || []).map((l) => ({ ...l })),
935
+ reverse: (graph.reverse[key] || []).map((l) => ({ ...l })),
858
936
  };
859
937
  }
860
938
 
@@ -911,6 +989,16 @@ export class DefaultKnowledgeStore {
911
989
  const links = Object.values(rebuilt.forward).reduce((n, arr) => n + arr.length, 0);
912
990
  const changed = canonicalGraph(loadGraph(this._graphPath)) !== canonicalGraph(rebuilt);
913
991
  saveGraph(this._graphPath, rebuilt);
992
+
993
+ // The alias map is likewise derived from records' `aliases` — rebuild it so
994
+ // a lost/hand-edited alias-index.json recovers on the same recovery path.
995
+ const rebuiltAliases = emptyAliasIndex();
996
+ for (const record of records) {
997
+ const slugs = normalizeAliases(record.aliases);
998
+ if (slugs.length) registerAliases(rebuiltAliases, record.id, slugs);
999
+ }
1000
+ saveAliasIndex(this._aliasPath, rebuiltAliases);
1001
+
914
1002
  return {
915
1003
  records: records.length,
916
1004
  links,
@@ -57,6 +57,12 @@ import {
57
57
  VALID_TYPES,
58
58
  VALID_STATUS_TRANSITIONS,
59
59
  validateCategory,
60
+ // Record-identity resolution layer (short-id prefix + slug aliases, issue #339)
61
+ resolveRecordId,
62
+ normalizeAliases,
63
+ loadAliasIndex,
64
+ saveAliasIndex,
65
+ registerAliases,
60
66
  } from "../shared/codec.js";
61
67
 
62
68
  // ---------------------------------------------------------------------------
@@ -94,9 +100,33 @@ export class ObsidianKnowledgeStore {
94
100
  this._graphPath = path.join(this._root, "graph-index.json");
95
101
  // Path index (internal): { by_id: { id: { path, archived } }, by_path: { relPath: id } }
96
102
  this._pathIndexPath = path.join(this._root, ".graph-index.json");
103
+ // Alias map (issue #339): { schema_version, by_slug: { slug: id } }. Keyed by
104
+ // full id, never by file path, so slugs survive the folder moves this adapter
105
+ // performs on recategorize/archive.
106
+ this._aliasPath = path.join(this._root, "alias-index.json");
97
107
  fs.mkdirSync(this._root, { recursive: true });
98
108
  }
99
109
 
110
+ // -------------------------------------------------------------------------
111
+ // Record-identity resolution (short-id prefix + slug alias, issue #339)
112
+ // -------------------------------------------------------------------------
113
+
114
+ /**
115
+ * Resolve a query token (exact id, slug alias, or unambiguous id prefix) to a
116
+ * single full record id, or null. Throws AMBIGUOUS_ID on a >1-match prefix.
117
+ * Ids and aliases are read from the persisted indexes, so resolution is
118
+ * unaffected by where a record's file currently lives.
119
+ */
120
+ _resolveId(input, pathIndex, aliasIndex) {
121
+ pathIndex = pathIndex || this._loadPathIndex();
122
+ aliasIndex = aliasIndex || loadAliasIndex(this._aliasPath);
123
+ return resolveRecordId(input, {
124
+ idExists: (rid) => Boolean(pathIndex.by_id[rid]),
125
+ listIds: () => Object.keys(pathIndex.by_id),
126
+ bySlug: aliasIndex.by_slug,
127
+ });
128
+ }
129
+
100
130
  // -------------------------------------------------------------------------
101
131
  // Path / slug helpers
102
132
  // -------------------------------------------------------------------------
@@ -505,6 +535,15 @@ export class ObsidianKnowledgeStore {
505
535
  const id = input.id || randomUUID();
506
536
  const now = this._now();
507
537
 
538
+ // Validate + reserve slug aliases before any write (issue #339); a
539
+ // SLUG_CONFLICT aborts create without leaving a partial note behind.
540
+ const aliases = normalizeAliases(input.aliases);
541
+ let aliasIndex = null;
542
+ if (aliases.length) {
543
+ aliasIndex = loadAliasIndex(this._aliasPath);
544
+ registerAliases(aliasIndex, id, aliases);
545
+ }
546
+
508
547
  const explicitLinks = input.links || [];
509
548
  const wikilinks = extractWikilinks(input.body || "");
510
549
  const links = mergeLinks(explicitLinks, wikilinks);
@@ -515,6 +554,7 @@ export class ObsidianKnowledgeStore {
515
554
  title: input.title,
516
555
  category: input.category,
517
556
  tags: input.tags || [],
557
+ ...(aliases.length ? { aliases } : {}),
518
558
  status: "active",
519
559
  created_at: now,
520
560
  updated_at: now,
@@ -535,6 +575,8 @@ export class ObsidianKnowledgeStore {
535
575
  addLinksToGraph(graph, id, links);
536
576
  saveGraph(this._graphPath, graph);
537
577
 
578
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
579
+
538
580
  return id;
539
581
  }
540
582
 
@@ -550,7 +592,7 @@ export class ObsidianKnowledgeStore {
550
592
  const record = this._readRecord(id, pathIndex);
551
593
  if (!record) throw notFoundError(id);
552
594
 
553
- const mutableKeys = ["title", "body", "category", "tags", "links"];
595
+ const mutableKeys = ["title", "body", "category", "tags", "links", "aliases"];
554
596
  const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
555
597
  if (supplied.length === 0)
556
598
  throw missingEvidenceError("update: at least one mutable field must be supplied");
@@ -560,6 +602,18 @@ export class ObsidianKnowledgeStore {
560
602
 
561
603
  const now = this._now();
562
604
 
605
+ // Slug aliases are append-only: union supplied aliases with existing ones so
606
+ // a previously issued slug keeps resolving after the file relocates (R3).
607
+ let mergedAliases = Array.isArray(record.aliases) ? record.aliases.slice() : [];
608
+ let aliasIndex = null;
609
+ if (fields.aliases !== undefined) {
610
+ const incoming = normalizeAliases(fields.aliases);
611
+ const seen = new Set(mergedAliases);
612
+ for (const s of incoming) if (!seen.has(s)) { seen.add(s); mergedAliases.push(s); }
613
+ aliasIndex = loadAliasIndex(this._aliasPath);
614
+ registerAliases(aliasIndex, id, mergedAliases);
615
+ }
616
+
563
617
  let newLinks = record.links || [];
564
618
  if (fields.links !== undefined) {
565
619
  const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
@@ -575,6 +629,7 @@ export class ObsidianKnowledgeStore {
575
629
  ...(fields.body !== undefined ? { body: fields.body } : {}),
576
630
  ...(fields.category !== undefined ? { category: fields.category } : {}),
577
631
  ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
632
+ ...(mergedAliases.length ? { aliases: mergedAliases } : {}),
578
633
  links: newLinks,
579
634
  updated_at: now,
580
635
  mutation_log: [
@@ -596,6 +651,8 @@ export class ObsidianKnowledgeStore {
596
651
 
597
652
  this._writeRecord(updated, pathIndex);
598
653
  this._savePathIndex(pathIndex);
654
+
655
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
599
656
  }
600
657
 
601
658
  // -------------------------------------------------------------------------
@@ -956,7 +1013,12 @@ export class ObsidianKnowledgeStore {
956
1013
  // -------------------------------------------------------------------------
957
1014
 
958
1015
  async get(id) {
959
- return this._readRecord(id);
1016
+ // Accept an exact id, slug alias, or unambiguous id prefix (issue #339).
1017
+ // Unresolved → null; ambiguous prefix → throws AMBIGUOUS_ID.
1018
+ const pathIndex = this._loadPathIndex();
1019
+ const resolvedId = this._resolveId(id, pathIndex);
1020
+ if (!resolvedId) return null;
1021
+ return this._readRecord(resolvedId, pathIndex);
960
1022
  }
961
1023
 
962
1024
  // -------------------------------------------------------------------------
@@ -964,10 +1026,13 @@ export class ObsidianKnowledgeStore {
964
1026
  // -------------------------------------------------------------------------
965
1027
 
966
1028
  async getLinks(id) {
1029
+ // Resolve prefix/slug to a full id; fall back to the raw token when it
1030
+ // resolves to nothing so unknown ids return empty arrays (not throw).
1031
+ const key = this._resolveId(id) || id;
967
1032
  const graph = loadGraph(this._graphPath);
968
1033
  return {
969
- forward: (graph.forward[id] || []).map((l) => ({ ...l })),
970
- reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
1034
+ forward: (graph.forward[key] || []).map((l) => ({ ...l })),
1035
+ reverse: (graph.reverse[key] || []).map((l) => ({ ...l })),
971
1036
  };
972
1037
  }
973
1038
 
@@ -323,3 +323,144 @@ export function validateCategory(cat) {
323
323
  if (!cat || typeof cat !== "string") return false;
324
324
  return cat.split(".").every((seg) => CATEGORY_SEGMENT_RE.test(seg));
325
325
  }
326
+
327
+ // ---------------------------------------------------------------------------
328
+ // Record identity resolution — short-id prefix + slug aliases (issue #339)
329
+ //
330
+ // On-disk identity is unchanged: records remain keyed by their full `id`
331
+ // (§9). The functions below add a *resolution layer* on top of that identity
332
+ // so a query token may be the full id, a category-scoped human-readable slug
333
+ // alias, or an unambiguous id prefix. See store-contract.md Addendum H.
334
+ // ---------------------------------------------------------------------------
335
+
336
+ // Minimum length of an id prefix that `get`/`getLinks` will attempt to resolve.
337
+ // Tokens shorter than this are never treated as prefixes (they resolve to null
338
+ // rather than risk a wildly ambiguous match). The ops design partner cites
339
+ // records by 8-char short ids, so 8 is the contract minimum.
340
+ export const MIN_ID_PREFIX = 8;
341
+
342
+ // A slug alias is a lowercase, category-scoped handle, e.g.
343
+ // `decision.strategy/2026-07-03-gtm-direction`. It must start and end with an
344
+ // alphanumeric and may contain dots, slashes, hyphens and underscores between.
345
+ export const SLUG_PATTERN = "^[a-z0-9]([a-z0-9._/-]*[a-z0-9])?$";
346
+ const SLUG_RE = new RegExp(SLUG_PATTERN);
347
+
348
+ export function validateSlug(slug) {
349
+ return typeof slug === "string" && slug.length > 0 && slug.length <= 200 && SLUG_RE.test(slug);
350
+ }
351
+
352
+ /**
353
+ * Validate + de-duplicate a caller-supplied `aliases` array. Throws
354
+ * MISSING_EVIDENCE (the contract's rejection channel) on a non-array or a
355
+ * malformed slug so bad aliases are rejected at the mutation boundary.
356
+ * @returns {string[]} normalized, order-preserving, de-duplicated slug list
357
+ */
358
+ export function normalizeAliases(input) {
359
+ if (input === undefined || input === null) return [];
360
+ if (!Array.isArray(input))
361
+ throw missingEvidenceError("aliases must be an array of slug strings");
362
+ const out = [];
363
+ const seen = new Set();
364
+ for (const raw of input) {
365
+ if (!validateSlug(raw))
366
+ throw missingEvidenceError(
367
+ `invalid slug alias: ${JSON.stringify(raw)} (must match ${SLUG_PATTERN})`
368
+ );
369
+ if (!seen.has(raw)) { seen.add(raw); out.push(raw); }
370
+ }
371
+ return out;
372
+ }
373
+
374
+ export function ambiguousIdError(input, matches) {
375
+ const shown = matches.slice(0, 5).join(", ");
376
+ const err = new Error(
377
+ `Ambiguous id prefix "${input}" matches ${matches.length} records: ${shown}${matches.length > 5 ? ", …" : ""}`
378
+ );
379
+ err.code = "AMBIGUOUS_ID";
380
+ err.matches = matches.slice();
381
+ return err;
382
+ }
383
+
384
+ /**
385
+ * Resolve a query token to a single record id.
386
+ *
387
+ * Resolution order (first hit wins):
388
+ * 1. Exact full-id match — preserves exact-id semantics and the O(1) hot path.
389
+ * 2. Slug alias — an entry in the alias map (`bySlug`) whose target still exists.
390
+ * 3. Unambiguous id prefix (length >= MIN_ID_PREFIX) matching exactly one id.
391
+ *
392
+ * @param {string} input
393
+ * @param {{ idExists:(id:string)=>boolean, listIds:()=>Iterable<string>, bySlug?:Record<string,string> }} ctx
394
+ * @returns {string|null} resolved id, or null when the token resolves to nothing
395
+ * @throws error with `code === "AMBIGUOUS_ID"` when a prefix matches >1 record
396
+ */
397
+ export function resolveRecordId(input, ctx) {
398
+ if (typeof input !== "string" || input.length === 0) return null;
399
+ // 1. Exact full-id match.
400
+ if (ctx.idExists(input)) return input;
401
+ // 2. Slug alias.
402
+ const bySlug = ctx.bySlug || {};
403
+ if (Object.prototype.hasOwnProperty.call(bySlug, input)) {
404
+ const target = bySlug[input];
405
+ if (target && ctx.idExists(target)) return target;
406
+ }
407
+ // 3. Unambiguous id prefix.
408
+ if (input.length >= MIN_ID_PREFIX) {
409
+ const matches = [];
410
+ for (const id of ctx.listIds()) {
411
+ if (typeof id === "string" && id.startsWith(input)) matches.push(id);
412
+ }
413
+ if (matches.length === 1) return matches[0];
414
+ if (matches.length > 1) throw ambiguousIdError(input, matches);
415
+ }
416
+ return null;
417
+ }
418
+
419
+ // ---------------------------------------------------------------------------
420
+ // Alias map (alias-index.json) — the durable slug → id mapping.
421
+ //
422
+ // Like the graph index, the alias map is a DERIVED cache: each record's own
423
+ // `aliases` array is the source of truth, so the map is fully rebuildable by
424
+ // scanning records. Keying by full id (never by category or file path) is what
425
+ // lets aliases survive store restructures — recategorizing a record or moving
426
+ // its file leaves the slug → id mapping untouched.
427
+ // ---------------------------------------------------------------------------
428
+
429
+ export const ALIAS_INDEX_SCHEMA_VERSION = "1.0";
430
+
431
+ export function emptyAliasIndex() {
432
+ return { schema_version: ALIAS_INDEX_SCHEMA_VERSION, by_slug: {} };
433
+ }
434
+
435
+ export function loadAliasIndex(aliasPath) {
436
+ if (!fs.existsSync(aliasPath)) return emptyAliasIndex();
437
+ try {
438
+ const idx = JSON.parse(fs.readFileSync(aliasPath, "utf8"));
439
+ if (!idx || typeof idx.by_slug !== "object" || idx.by_slug === null) return emptyAliasIndex();
440
+ return idx;
441
+ } catch {
442
+ return emptyAliasIndex();
443
+ }
444
+ }
445
+
446
+ export function saveAliasIndex(aliasPath, index) {
447
+ fs.writeFileSync(aliasPath, JSON.stringify(index, null, 2) + "\n", "utf8");
448
+ }
449
+
450
+ /**
451
+ * Point each slug at `id` in the alias map. A slug already owned by a
452
+ * different record is a genuine collision — throws `SLUG_CONFLICT` (rather than
453
+ * silently re-pointing the alias). Re-registering a slug already owned by `id`
454
+ * is a no-op, so this is idempotent per record.
455
+ */
456
+ export function registerAliases(index, id, slugs) {
457
+ for (const slug of slugs) {
458
+ const owner = index.by_slug[slug];
459
+ if (owner && owner !== id) {
460
+ const err = new Error(`slug alias "${slug}" already assigned to record ${owner}`);
461
+ err.code = "SLUG_CONFLICT";
462
+ throw err;
463
+ }
464
+ index.by_slug[slug] = id;
465
+ }
466
+ }
@@ -49,6 +49,29 @@ See [`store-contract.md`](store-contract.md) for the full specification. Quick r
49
49
 
50
50
  Every mutation throws with `error.code === "MISSING_EVIDENCE"` when required evidence is absent.
51
51
 
52
+ **Record identity resolution** (Addendum H)
53
+
54
+ `get`/`getLinks` accept three handle forms, resolved in order: exact full `id`, then a
55
+ category-scoped **slug alias**, then an **unambiguous id prefix** (≥ 8 chars).
56
+
57
+ | Handle | Example | Resolves to |
58
+ |---|---|---|
59
+ | Full id | `12cc5573-1c4f-4a0e-8b6e-9d2f0a5b7c31` | the record (unchanged exact-id behavior) |
60
+ | Slug alias | `decision.strategy/2026-07-03-gtm-direction` | the record that carries that alias |
61
+ | Short-id prefix (≥ 8 chars, unique) | `12cc5573` | the single record whose id starts with it |
62
+
63
+ Aliases are caller-supplied via the optional `aliases: string[]` field on `create`/`update`,
64
+ append-only, and stored in an id-keyed alias map (`alias-index.json`) so a prefix or slug **still
65
+ resolves after a store restructure** (e.g. a recategorize that moves the file). An unresolved
66
+ handle returns `null` (`get`) / empty arrays (`getLinks`); an **ambiguous** prefix throws
67
+ `error.code === "AMBIGUOUS_ID"` — it is never silently guessed. On-disk identity (`records/<id>.md`)
68
+ is unchanged.
69
+
70
+ | Error code | Raised when |
71
+ |---|---|
72
+ | `AMBIGUOUS_ID` | an id prefix matches more than one record (carries a `matches` array) |
73
+ | `SLUG_CONFLICT` | a supplied slug alias is already assigned to a different record |
74
+
52
75
  ---
53
76
 
54
77
  ## Running the Contract Suite
@@ -89,6 +112,16 @@ All tests pass and exit 0. Any failure indicates a contract regression or an ada
89
112
  | AC2 | Default adapter passes contract suite (command evidence). | Run `node --test kits/knowledge/evals/contract-suite/suite.test.js` — exit 0, all tests pass. |
90
113
  | AC3 | Record round-trips raw → stored → queried with category + links intact. | Suite §2 "create: round-trip raw → stored → queried" tests this directly. |
91
114
 
115
+ **Identity resolution (#339)** — mapped to that issue's ACs:
116
+
117
+ | AC (#339) | Requirement | Evidence |
118
+ |---|---|---|
119
+ | AC1 | Unique 8-char prefix resolves; ambiguous prefix throws `AMBIGUOUS_ID`. | Suite §15 "identity: short-id prefix resolution (AC1)". |
120
+ | AC2 | Slug alias resolves via `get`/`getLinks` (set at create or update). | Suite §16 "identity: slug alias resolution (AC2)". |
121
+ | AC3 | Old slug + short-id prefix survive an `update` recategorize/move. | Suite §17 "identity: alias resolution survives restructure (AC3)". |
122
+ | AC4 | Existing sections pass unmodified; files remain `records/<id>.md`. | §2 round-trip, §13 graph consistency, §12 missing-record unchanged; on-disk naming untouched. |
123
+ | AC5 | Contract doc specifies prefix/slug/alias-map semantics. | [`store-contract.md`](store-contract.md) Addendum H + §1.1/§7/§8.1/§9 updates. |
124
+
92
125
  ---
93
126
 
94
127
  ## Default Adapter Details
@@ -103,6 +136,7 @@ built-ins only.
103
136
  records/
104
137
  <id>.md ← one markdown file per record, YAML frontmatter + body
105
138
  graph-index.json ← forward + reverse link index, schema_version 1.0
139
+ alias-index.json ← slug → id alias map (Addendum H), schema_version 1.0
106
140
  ```
107
141
 
108
142
  **Constructor**
@@ -120,8 +154,8 @@ const store = new DefaultKnowledgeStore({ storeRoot: '/path/to/store' });
120
154
  - `propose(conceptId, proposerId, evidence)` → `Promise<void>`
121
155
  - `apply(conceptId, proposerId, evidence)` → `Promise<void>`
122
156
  - `reject(conceptId, proposerId, evidence)` → `Promise<void>`
123
- - `get(id)` → `Promise<Record | null>`
124
- - `getLinks(id)` → `Promise<{ forward: Link[], reverse: Link[] }>`
157
+ - `get(idOrHandle)` → `Promise<Record | null>` (exact id, slug alias, or unambiguous id prefix — Addendum H)
158
+ - `getLinks(idOrHandle)` → `Promise<{ forward: Link[], reverse: Link[] }>`
125
159
  - `listByCategory(category, options?)` → `Promise<Record[]>`
126
160
  - `listByType(type)` → `Promise<Record[]>`
127
161
 
@@ -326,3 +360,88 @@ const detector = createVectorSimilarityDetector({
326
360
 
327
361
  The `embed` function is called once per `synthesize`/`consolidate` call with all texts in a
328
362
  single batch (concept first, then candidates).
363
+
364
+ ---
365
+
366
+ ## Graph provider (opt-in)
367
+
368
+ The `neo4j` provider is a real, queryable graph implementation of the store
369
+ provider read interface (issue #327). It is the **owner's opt-in personal
370
+ default** — the file providers (`markdown-vault`, `git-repo`, `work-item`) remain
371
+ the portfolio default. The graph is a **materialized view** synced from those
372
+ providers: the file stores stay the source of truth, and the write side stays
373
+ proposals-only. `neo4j-driver` is an optional dependency; with no Neo4j reachable
374
+ everything degrades to the file providers with a clear message — it is never a
375
+ hard dependency.
376
+
377
+ ### 1. Start Neo4j (Docker)
378
+
379
+ ```bash
380
+ docker run -d --name kg-neo4j -p 7474:7474 -p 7687:7687 \
381
+ -e NEO4J_AUTH=neo4j/testpassword neo4j:5-community
382
+ ```
383
+
384
+ ### 2. Point the provider at it (connection by reference — never hardcoded)
385
+
386
+ ```bash
387
+ export NEO4J_URI=bolt://localhost:7687
388
+ export NEO4J_USER=neo4j
389
+ export NEO4J_PASSWORD=testpassword # use your own secret; this is an example
390
+ export KNOWLEDGE_PROVIDER=neo4j # opt in (repo/user-level); default is `file`
391
+ npm install neo4j-driver # optional dependency, only if not already present
392
+ ```
393
+
394
+ ### 3. Sync the file stores into the graph (idempotent)
395
+
396
+ `sync` reads the other providers and MERGEs their combined graph into Neo4j. A
397
+ re-sync of unchanged stores reports **zero writes** (content-keyed hash guard);
398
+ a snapshot digest + timestamp is recorded on the graph.
399
+
400
+ ```js
401
+ import { GitRepoProvider } from "./kits/knowledge/providers/git-repo/index.js";
402
+ import { WorkItemProvider } from "./kits/knowledge/providers/work-item/index.js";
403
+ import { syncToNeo4j } from "./kits/knowledge/providers/neo4j/index.js";
404
+ import { resolveNeo4jConfig, createDriver } from "./kits/knowledge/providers/neo4j/index.js";
405
+
406
+ const driver = await createDriver(resolveNeo4jConfig());
407
+ const providers = [
408
+ new GitRepoProvider({ repoRoot: process.cwd() }),
409
+ new WorkItemProvider({ repo: "kontourai/flow-agents" }), // reads via gh
410
+ ];
411
+ console.log(await syncToNeo4j({ driver, providers })); // { writes, unchanged, digest, ... }
412
+ ```
413
+
414
+ ### 4. Query it — Neo4j Browser or the provider verbs
415
+
416
+ Open `http://localhost:7474` for the interactive Browser, or use the provider's
417
+ Cypher-backed query/health verbs (they fall back to a portable JS spelling with
418
+ identical answers when no Neo4j is reachable):
419
+
420
+ ```js
421
+ import { Neo4jProvider } from "./kits/knowledge/providers/neo4j/index.js";
422
+ const kg = new Neo4jProvider({ driver });
423
+ await kg.transitiveBlockers("issue:313"); // Q1 transitive blocker closure
424
+ await kg.contradictionCandidates(); // Q2 divergent status over one subject
425
+ await kg.orphans(); // Q3 node-type-aware orphans
426
+ await kg.duplicateCandidates(); // Q4 duplicate candidates WITH stemming
427
+ await kg.shortestPath("decision:adr-0011", "decision:adr-0021"); // Q5 shortest path
428
+ ```
429
+
430
+ Example Cypher session in the Browser (the blocker subgraph the spike visualised):
431
+
432
+ ```cypher
433
+ MATCH p=(a:Issue)-[:BLOCKS]->(b:Issue) RETURN p LIMIT 60;
434
+ ```
435
+
436
+ ### Running the full live integration locally
437
+
438
+ CI has no Neo4j, so the conformance + unit tests run against an injected fake
439
+ driver and the live integration test skips loudly. To run the live path:
440
+
441
+ ```bash
442
+ docker run -d --name kg-neo4j -p 7474:7474 -p 7687:7687 \
443
+ -e NEO4J_AUTH=neo4j/testpassword neo4j:5-community
444
+ NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=testpassword \
445
+ node --test kits/knowledge/providers/neo4j/integration.test.js
446
+ docker rm -f kg-neo4j
447
+ ```