@kontourai/flow-agents 3.0.0 → 3.2.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 (158) hide show
  1. package/.github/workflows/ci.yml +12 -0
  2. package/CHANGELOG.md +32 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +188 -0
  6. package/build/src/cli/assignment-provider.js +193 -51
  7. package/build/src/cli/workflow-sidecar.d.ts +16 -6
  8. package/build/src/cli/workflow-sidecar.js +492 -45
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +10 -1
  13. package/context/contracts/execution-contract.md +78 -0
  14. package/context/contracts/probe-docs-write-contract.md +187 -0
  15. package/context/scripts/hooks/config-protection.js +24 -4
  16. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  17. package/context/scripts/hooks/stop-goal-fit.js +262 -5
  18. package/context/scripts/hooks/workflow-steering.js +42 -0
  19. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  20. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  21. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  22. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  23. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  24. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  25. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  26. package/docs/adr/0007-skill-audit.md +2 -0
  27. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  28. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  29. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  30. package/docs/adr/0011-mcp-posture.md +2 -0
  31. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  32. package/docs/adr/0013-context-lifecycle.md +2 -0
  33. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  34. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  35. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  36. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  37. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  38. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  39. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  40. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  41. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
  42. package/docs/adr/README.md +49 -0
  43. package/docs/adr/index.md +34 -0
  44. package/docs/decisions/agent-coordination.md +20 -0
  45. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  46. package/docs/decisions/context-lifecycle.md +18 -0
  47. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  48. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  49. package/docs/decisions/flow-kit.md +20 -0
  50. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  51. package/docs/decisions/graph-knowledge-provider.md +63 -0
  52. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  53. package/docs/decisions/index.md +19 -0
  54. package/docs/decisions/kit-dependency-ownership.md +18 -0
  55. package/docs/decisions/kit-operation-boundary.md +18 -0
  56. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  57. package/docs/decisions/kontour-resource-contract.md +18 -0
  58. package/docs/decisions/mcp-posture.md +18 -0
  59. package/docs/decisions/three-hard-boundary-model.md +18 -0
  60. package/docs/decisions/trust-reconcile.md +20 -0
  61. package/docs/decisions/typescript-source-policy.md +48 -0
  62. package/docs/decisions/workflow-enforcement.md +18 -0
  63. package/docs/decisions/workflow-trust-state.md +20 -0
  64. package/docs/fixture-ownership.md +1 -1
  65. package/docs/workflow-usage-guide.md +1 -1
  66. package/evals/ci/run-baseline.sh +6 -0
  67. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  68. package/evals/integration/test_checkpoint_signing.sh +4 -3
  69. package/evals/integration/test_current_json_per_actor.sh +516 -0
  70. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  71. package/evals/integration/test_gate_lockdown.sh +46 -0
  72. package/evals/integration/test_model_routing_escalation.sh +145 -0
  73. package/evals/integration/test_publish_delivery.sh +14 -6
  74. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  75. package/evals/integration/test_stop_hook_release.sh +552 -0
  76. package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
  77. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  78. package/evals/run.sh +10 -0
  79. package/evals/static/test_knowledge_providers.sh +13 -4
  80. package/evals/static/test_model_routing_hints.sh +107 -0
  81. package/evals/static/test_workflow_skills.sh +15 -2
  82. package/kits/builder/skills/builder-shape/SKILL.md +10 -0
  83. package/kits/builder/skills/deliver/SKILL.md +69 -11
  84. package/kits/builder/skills/design-probe/SKILL.md +47 -0
  85. package/kits/builder/skills/execute-plan/SKILL.md +13 -0
  86. package/kits/builder/skills/fix-bug/SKILL.md +17 -0
  87. package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
  88. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  89. package/kits/builder/skills/plan-work/SKILL.md +9 -0
  90. package/kits/builder/skills/pull-work/SKILL.md +10 -0
  91. package/kits/builder/skills/review-work/SKILL.md +11 -0
  92. package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
  93. package/kits/builder/skills/verify-work/SKILL.md +11 -0
  94. package/kits/knowledge/adapters/default-store/index.js +147 -18
  95. package/kits/knowledge/adapters/flow-runner/index.js +912 -16
  96. package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
  97. package/kits/knowledge/adapters/shared/codec.js +265 -0
  98. package/kits/knowledge/docs/README.md +121 -2
  99. package/kits/knowledge/docs/store-contract.md +517 -7
  100. package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
  101. package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
  102. package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
  103. package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
  104. package/kits/knowledge/evals/freshness/suite.test.js +339 -0
  105. package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
  106. package/kits/knowledge/evals/retirement/suite.test.js +1 -1
  107. package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
  108. package/kits/knowledge/flows/promote.flow.json +84 -0
  109. package/kits/knowledge/kit.json +15 -0
  110. package/kits/knowledge/promote/distill.js +96 -0
  111. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  112. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  113. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  114. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  115. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  116. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  117. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  118. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  119. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  120. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  121. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  122. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  123. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  124. package/kits/knowledge/promote/health.js +137 -0
  125. package/kits/knowledge/promote/index.js +176 -0
  126. package/kits/knowledge/promote/ingest.js +103 -0
  127. package/kits/knowledge/promote/lib.js +132 -0
  128. package/kits/knowledge/promote/link.js +84 -0
  129. package/kits/knowledge/promote/promote.test.js +174 -0
  130. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  131. package/kits/knowledge/providers/index.js +1 -0
  132. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  133. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  134. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  135. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  136. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  137. package/kits/knowledge/providers/neo4j/index.js +280 -0
  138. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  139. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  140. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  141. package/package.json +4 -2
  142. package/schemas/workflow-handoff.schema.json +6 -0
  143. package/scripts/README.md +1 -0
  144. package/scripts/ci/mint-attestation.js +33 -6
  145. package/scripts/ci/trust-reconcile.js +144 -26
  146. package/scripts/freeze-adrs.mjs +364 -0
  147. package/scripts/hooks/config-protection.js +24 -4
  148. package/scripts/hooks/evidence-capture.js +4 -1
  149. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  150. package/scripts/hooks/lib/current-pointer.js +123 -0
  151. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  152. package/scripts/hooks/stop-goal-fit.js +262 -5
  153. package/scripts/hooks/workflow-steering.js +42 -0
  154. package/scripts/statusline/flow-agents-statusline.js +3 -1
  155. package/src/cli/assignment-provider.ts +239 -59
  156. package/src/cli/workflow-sidecar.ts +529 -43
  157. package/src/lib/flow-resolver.ts +35 -14
  158. package/src/tools/validate-source-tree.ts +2 -1
@@ -57,6 +57,16 @@ 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,
66
+ // Record-carried freshness + derived staleness (issue #341, Addendum J).
67
+ freshnessPatch,
68
+ decodeFreshnessFields,
69
+ isRecordStale,
60
70
  } from "../shared/codec.js";
61
71
 
62
72
  // ---------------------------------------------------------------------------
@@ -94,9 +104,33 @@ export class ObsidianKnowledgeStore {
94
104
  this._graphPath = path.join(this._root, "graph-index.json");
95
105
  // Path index (internal): { by_id: { id: { path, archived } }, by_path: { relPath: id } }
96
106
  this._pathIndexPath = path.join(this._root, ".graph-index.json");
107
+ // Alias map (issue #339): { schema_version, by_slug: { slug: id } }. Keyed by
108
+ // full id, never by file path, so slugs survive the folder moves this adapter
109
+ // performs on recategorize/archive.
110
+ this._aliasPath = path.join(this._root, "alias-index.json");
97
111
  fs.mkdirSync(this._root, { recursive: true });
98
112
  }
99
113
 
114
+ // -------------------------------------------------------------------------
115
+ // Record-identity resolution (short-id prefix + slug alias, issue #339)
116
+ // -------------------------------------------------------------------------
117
+
118
+ /**
119
+ * Resolve a query token (exact id, slug alias, or unambiguous id prefix) to a
120
+ * single full record id, or null. Throws AMBIGUOUS_ID on a >1-match prefix.
121
+ * Ids and aliases are read from the persisted indexes, so resolution is
122
+ * unaffected by where a record's file currently lives.
123
+ */
124
+ _resolveId(input, pathIndex, aliasIndex) {
125
+ pathIndex = pathIndex || this._loadPathIndex();
126
+ aliasIndex = aliasIndex || loadAliasIndex(this._aliasPath);
127
+ return resolveRecordId(input, {
128
+ idExists: (rid) => Boolean(pathIndex.by_id[rid]),
129
+ listIds: () => Object.keys(pathIndex.by_id),
130
+ bySlug: aliasIndex.by_slug,
131
+ });
132
+ }
133
+
100
134
  // -------------------------------------------------------------------------
101
135
  // Path / slug helpers
102
136
  // -------------------------------------------------------------------------
@@ -272,7 +306,8 @@ export class ObsidianKnowledgeStore {
272
306
  // Reconstruct the record body from the rendered markdown section.
273
307
  // `meta` no longer contains `body` — the rendered text is the source of truth.
274
308
  const body = this._parseBodyFromRendered(meta.type, renderedText);
275
- return { ...meta, body };
309
+ // Coerce record-carried freshness fields to canonical types (#341).
310
+ return decodeFreshnessFields({ ...meta, body });
276
311
  }
277
312
 
278
313
  /**
@@ -505,6 +540,18 @@ export class ObsidianKnowledgeStore {
505
540
  const id = input.id || randomUUID();
506
541
  const now = this._now();
507
542
 
543
+ // Validate + reserve slug aliases before any write (issue #339); a
544
+ // SLUG_CONFLICT aborts create without leaving a partial note behind.
545
+ const aliases = normalizeAliases(input.aliases);
546
+ let aliasIndex = null;
547
+ if (aliases.length) {
548
+ aliasIndex = loadAliasIndex(this._aliasPath);
549
+ registerAliases(aliasIndex, id, aliases);
550
+ }
551
+
552
+ // Validate optional freshness fields (#341) before any write.
553
+ const fresh = freshnessPatch(input);
554
+
508
555
  const explicitLinks = input.links || [];
509
556
  const wikilinks = extractWikilinks(input.body || "");
510
557
  const links = mergeLinks(explicitLinks, wikilinks);
@@ -515,9 +562,11 @@ export class ObsidianKnowledgeStore {
515
562
  title: input.title,
516
563
  category: input.category,
517
564
  tags: input.tags || [],
565
+ ...(aliases.length ? { aliases } : {}),
518
566
  status: "active",
519
567
  created_at: now,
520
568
  updated_at: now,
569
+ ...fresh,
521
570
  provenance: {
522
571
  agent: input.provenance.agent,
523
572
  ...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
@@ -535,6 +584,8 @@ export class ObsidianKnowledgeStore {
535
584
  addLinksToGraph(graph, id, links);
536
585
  saveGraph(this._graphPath, graph);
537
586
 
587
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
588
+
538
589
  return id;
539
590
  }
540
591
 
@@ -550,7 +601,7 @@ export class ObsidianKnowledgeStore {
550
601
  const record = this._readRecord(id, pathIndex);
551
602
  if (!record) throw notFoundError(id);
552
603
 
553
- const mutableKeys = ["title", "body", "category", "tags", "links"];
604
+ const mutableKeys = ["title", "body", "category", "tags", "links", "aliases", "expires_at", "ttl_seconds"];
554
605
  const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
555
606
  if (supplied.length === 0)
556
607
  throw missingEvidenceError("update: at least one mutable field must be supplied");
@@ -558,8 +609,23 @@ export class ObsidianKnowledgeStore {
558
609
  if (fields.category !== undefined && !validateCategory(fields.category))
559
610
  throw missingEvidenceError(`update: invalid category: ${fields.category}`);
560
611
 
612
+ // Validate/normalize supplied freshness fields (#341); null/"" clears a field.
613
+ const fresh = freshnessPatch(fields);
614
+
561
615
  const now = this._now();
562
616
 
617
+ // Slug aliases are append-only: union supplied aliases with existing ones so
618
+ // a previously issued slug keeps resolving after the file relocates (R3).
619
+ let mergedAliases = Array.isArray(record.aliases) ? record.aliases.slice() : [];
620
+ let aliasIndex = null;
621
+ if (fields.aliases !== undefined) {
622
+ const incoming = normalizeAliases(fields.aliases);
623
+ const seen = new Set(mergedAliases);
624
+ for (const s of incoming) if (!seen.has(s)) { seen.add(s); mergedAliases.push(s); }
625
+ aliasIndex = loadAliasIndex(this._aliasPath);
626
+ registerAliases(aliasIndex, id, mergedAliases);
627
+ }
628
+
563
629
  let newLinks = record.links || [];
564
630
  if (fields.links !== undefined) {
565
631
  const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
@@ -575,6 +641,8 @@ export class ObsidianKnowledgeStore {
575
641
  ...(fields.body !== undefined ? { body: fields.body } : {}),
576
642
  ...(fields.category !== undefined ? { category: fields.category } : {}),
577
643
  ...(fields.tags !== undefined ? { tags: fields.tags } : {}),
644
+ ...(mergedAliases.length ? { aliases: mergedAliases } : {}),
645
+ ...fresh,
578
646
  links: newLinks,
579
647
  updated_at: now,
580
648
  mutation_log: [
@@ -596,6 +664,8 @@ export class ObsidianKnowledgeStore {
596
664
 
597
665
  this._writeRecord(updated, pathIndex);
598
666
  this._savePathIndex(pathIndex);
667
+
668
+ if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
599
669
  }
600
670
 
601
671
  // -------------------------------------------------------------------------
@@ -956,7 +1026,12 @@ export class ObsidianKnowledgeStore {
956
1026
  // -------------------------------------------------------------------------
957
1027
 
958
1028
  async get(id) {
959
- return this._readRecord(id);
1029
+ // Accept an exact id, slug alias, or unambiguous id prefix (issue #339).
1030
+ // Unresolved → null; ambiguous prefix → throws AMBIGUOUS_ID.
1031
+ const pathIndex = this._loadPathIndex();
1032
+ const resolvedId = this._resolveId(id, pathIndex);
1033
+ if (!resolvedId) return null;
1034
+ return this._readRecord(resolvedId, pathIndex);
960
1035
  }
961
1036
 
962
1037
  // -------------------------------------------------------------------------
@@ -964,10 +1039,13 @@ export class ObsidianKnowledgeStore {
964
1039
  // -------------------------------------------------------------------------
965
1040
 
966
1041
  async getLinks(id) {
1042
+ // Resolve prefix/slug to a full id; fall back to the raw token when it
1043
+ // resolves to nothing so unknown ids return empty arrays (not throw).
1044
+ const key = this._resolveId(id) || id;
967
1045
  const graph = loadGraph(this._graphPath);
968
1046
  return {
969
- forward: (graph.forward[id] || []).map((l) => ({ ...l })),
970
- reverse: (graph.reverse[id] || []).map((l) => ({ ...l })),
1047
+ forward: (graph.forward[key] || []).map((l) => ({ ...l })),
1048
+ reverse: (graph.reverse[key] || []).map((l) => ({ ...l })),
971
1049
  };
972
1050
  }
973
1051
 
@@ -978,18 +1056,19 @@ export class ObsidianKnowledgeStore {
978
1056
  async listByCategory(category, options = {}) {
979
1057
  const records = this._allRecords();
980
1058
  const includeRetired = options.includeRetired === true;
1059
+ // Derived staleness filter (#341): keep only records past their own effective
1060
+ // expiry at `now` (injectable for tests). Absent → no staleness filtering.
1061
+ const staleOnly = options.stale === true;
1062
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
1063
+ const keep = (r) =>
1064
+ (includeRetired || (r.status || "active") !== "retired") &&
1065
+ (!staleOnly || isRecordStale(r, nowMs));
981
1066
  if (options.prefix) {
982
1067
  return records.filter(
983
- (r) =>
984
- (r.category === category || r.category.startsWith(`${category}.`)) &&
985
- (includeRetired || (r.status || "active") !== "retired")
1068
+ (r) => (r.category === category || r.category.startsWith(`${category}.`)) && keep(r)
986
1069
  );
987
1070
  }
988
- return records.filter(
989
- (r) =>
990
- r.category === category &&
991
- (includeRetired || (r.status || "active") !== "retired")
992
- );
1071
+ return records.filter((r) => r.category === category && keep(r));
993
1072
  }
994
1073
 
995
1074
  // -------------------------------------------------------------------------
@@ -998,10 +1077,14 @@ export class ObsidianKnowledgeStore {
998
1077
 
999
1078
  async listByType(type, options = {}) {
1000
1079
  const includeRetired = options.includeRetired === true;
1080
+ // Derived staleness filter (#341) — see listByCategory.
1081
+ const staleOnly = options.stale === true;
1082
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
1001
1083
  return this._allRecords().filter(
1002
1084
  (r) =>
1003
1085
  r.type === type &&
1004
- (includeRetired || (r.status || "active") !== "retired")
1086
+ (includeRetired || (r.status || "active") !== "retired") &&
1087
+ (!staleOnly || isRecordStale(r, nowMs))
1005
1088
  );
1006
1089
  }
1007
1090
  }
@@ -323,3 +323,268 @@ 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
+ }
467
+
468
+ // ---------------------------------------------------------------------------
469
+ // Freshness + derived status (issue #341, store-contract Addendum J)
470
+ //
471
+ // Optional, backward-compatible record-carried expiry aligned to Kontour's own
472
+ // Hachure trust vocabulary (expiresAt / ttlSeconds; stale / superseded). Every
473
+ // helper below is a no-op / null / false for a record that carries NO freshness
474
+ // fields, so records written before this slice behave exactly as they did.
475
+ //
476
+ // `stale` and `superseded` are DERIVED states — computed here, never written
477
+ // back onto the record's stored `status` (Addendum B). "No silent rot" means a
478
+ // record goes VISIBLY stale under query, not that the store rewrites it behind
479
+ // the operator's back.
480
+ // ---------------------------------------------------------------------------
481
+
482
+ // The optional freshness fields on the record envelope (§1.1 / Addendum J.1).
483
+ export const FRESHNESS_KEYS = ["expires_at", "ttl_seconds"];
484
+
485
+ /** Validate an `expires_at` value: a non-empty ISO-8601 string. */
486
+ export function validateExpiresAt(value) {
487
+ if (typeof value !== "string" || !value.trim())
488
+ throw missingEvidenceError(
489
+ `expires_at must be a non-empty ISO-8601 string; got: ${JSON.stringify(value)}`
490
+ );
491
+ if (Number.isNaN(Date.parse(value)))
492
+ throw missingEvidenceError(
493
+ `expires_at must be a valid ISO-8601 timestamp; got: ${JSON.stringify(value)}`
494
+ );
495
+ return value;
496
+ }
497
+
498
+ /** Validate a `ttl_seconds` value: a positive, finite number of seconds. */
499
+ export function validateTtlSeconds(value) {
500
+ const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
501
+ if (typeof n !== "number" || !Number.isFinite(n) || n <= 0)
502
+ throw missingEvidenceError(
503
+ `ttl_seconds must be a positive number of seconds; got: ${JSON.stringify(value)}`
504
+ );
505
+ return n;
506
+ }
507
+
508
+ /**
509
+ * Extract + validate the freshness fields from a create/update input, returning
510
+ * a patch object containing only the supplied keys (normalized). A key set
511
+ * explicitly to `null` or `""` is returned as `undefined` — the signal to CLEAR
512
+ * it (the YAML serializer drops undefined). Absent keys are omitted, so an input
513
+ * with no freshness fields yields `{}` (full backward compatibility). Throws
514
+ * MISSING_EVIDENCE — the contract's rejection channel — on a malformed value,
515
+ * at the mutation boundary, so bad freshness never reaches disk.
516
+ */
517
+ export function freshnessPatch(input) {
518
+ const patch = {};
519
+ if (!input || typeof input !== "object") return patch;
520
+ if (Object.prototype.hasOwnProperty.call(input, "expires_at")) {
521
+ patch.expires_at =
522
+ input.expires_at === null || input.expires_at === ""
523
+ ? undefined
524
+ : validateExpiresAt(input.expires_at);
525
+ }
526
+ if (Object.prototype.hasOwnProperty.call(input, "ttl_seconds")) {
527
+ patch.ttl_seconds =
528
+ input.ttl_seconds === null || input.ttl_seconds === ""
529
+ ? undefined
530
+ : validateTtlSeconds(input.ttl_seconds);
531
+ }
532
+ return patch;
533
+ }
534
+
535
+ /**
536
+ * Coerce freshness fields to their canonical runtime types after a frontmatter
537
+ * read: the zero-dep YAML codec returns every scalar as a string, so a numeric
538
+ * `ttl_seconds` round-trips as text without this. Mutates and returns the
539
+ * record; a no-op when no freshness field is present.
540
+ */
541
+ export function decodeFreshnessFields(record) {
542
+ if (record && record.ttl_seconds !== undefined && record.ttl_seconds !== null) {
543
+ const n = Number(record.ttl_seconds);
544
+ if (Number.isFinite(n)) record.ttl_seconds = n;
545
+ }
546
+ return record;
547
+ }
548
+
549
+ /**
550
+ * The record's EFFECTIVE expiry as epoch-ms, or `null` when it carries no
551
+ * freshness fields. Precedence: an explicit `expires_at` wins over a derived
552
+ * `created_at + ttl_seconds`. Returns null (never throws) on an unparseable
553
+ * field, so malformed freshness can neither make a record spuriously
554
+ * "fresh forever" nor crash a read-only audit.
555
+ */
556
+ export function effectiveExpiryMs(record) {
557
+ if (!record || typeof record !== "object") return null;
558
+ if (record.expires_at) {
559
+ const ms = Date.parse(record.expires_at);
560
+ if (!Number.isNaN(ms)) return ms;
561
+ }
562
+ if (record.ttl_seconds !== undefined && record.ttl_seconds !== null && record.created_at) {
563
+ const base = Date.parse(record.created_at);
564
+ const ttl = Number(record.ttl_seconds);
565
+ if (!Number.isNaN(base) && Number.isFinite(ttl)) return base + ttl * 1000;
566
+ }
567
+ return null;
568
+ }
569
+
570
+ /**
571
+ * Is the record STALE at instant `nowMs`? Derived, never stored. A record with
572
+ * no freshness fields is never stale via this path (backward compat). Boundary:
573
+ * the expiry instant itself counts as expired (`nowMs >= expiry`) — a record
574
+ * "expires_at T" is stale from T onward, inclusive.
575
+ */
576
+ export function isRecordStale(record, nowMs) {
577
+ const exp = effectiveExpiryMs(record);
578
+ if (exp === null) return false;
579
+ return nowMs >= exp;
580
+ }
581
+
582
+ /**
583
+ * Is the record SUPERSEDED? Derived from the Addendum A.5 supersede
584
+ * relationship, surfaced as the incoming `supersedes` edge in the graph
585
+ * (`getLinks(id).reverse`) — equivalently, the record carries a `superseded-by`
586
+ * mutation-log entry. Never stored as a `status`.
587
+ */
588
+ export function isSupersededByLinks(reverseLinks) {
589
+ return Array.isArray(reverseLinks) && reverseLinks.some((l) => l && l.kind === "supersedes");
590
+ }
@@ -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
+ ```