@kontourai/flow-agents 3.0.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 (131) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +143 -0
  6. package/build/src/cli/assignment-provider.js +106 -49
  7. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  8. package/build/src/cli/workflow-sidecar.js +392 -35
  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 +9 -0
  13. package/context/contracts/probe-docs-write-contract.md +187 -0
  14. package/context/scripts/hooks/config-protection.js +14 -1
  15. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  16. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  17. package/context/scripts/hooks/workflow-steering.js +42 -0
  18. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  19. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  20. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  21. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  22. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  23. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  24. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  25. package/docs/adr/0007-skill-audit.md +2 -0
  26. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  27. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  28. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  29. package/docs/adr/0011-mcp-posture.md +2 -0
  30. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  31. package/docs/adr/0013-context-lifecycle.md +2 -0
  32. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  33. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  34. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  35. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  36. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  37. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  38. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  39. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  40. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
  41. package/docs/adr/README.md +49 -0
  42. package/docs/adr/index.md +34 -0
  43. package/docs/decisions/agent-coordination.md +20 -0
  44. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  45. package/docs/decisions/context-lifecycle.md +18 -0
  46. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  47. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  48. package/docs/decisions/flow-kit.md +20 -0
  49. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  50. package/docs/decisions/graph-knowledge-provider.md +63 -0
  51. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  52. package/docs/decisions/index.md +19 -0
  53. package/docs/decisions/kit-dependency-ownership.md +18 -0
  54. package/docs/decisions/kit-operation-boundary.md +18 -0
  55. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  56. package/docs/decisions/kontour-resource-contract.md +18 -0
  57. package/docs/decisions/mcp-posture.md +18 -0
  58. package/docs/decisions/three-hard-boundary-model.md +18 -0
  59. package/docs/decisions/trust-reconcile.md +20 -0
  60. package/docs/decisions/typescript-source-policy.md +48 -0
  61. package/docs/decisions/workflow-enforcement.md +18 -0
  62. package/docs/decisions/workflow-trust-state.md +20 -0
  63. package/docs/fixture-ownership.md +1 -1
  64. package/docs/workflow-usage-guide.md +1 -1
  65. package/evals/ci/run-baseline.sh +4 -0
  66. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  67. package/evals/integration/test_current_json_per_actor.sh +516 -0
  68. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  69. package/evals/integration/test_gate_lockdown.sh +10 -0
  70. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  71. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  72. package/evals/run.sh +4 -0
  73. package/evals/static/test_knowledge_providers.sh +13 -4
  74. package/evals/static/test_workflow_skills.sh +15 -2
  75. package/kits/builder/skills/deliver/SKILL.md +17 -0
  76. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  77. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  78. package/kits/knowledge/adapters/default-store/index.js +92 -4
  79. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  80. package/kits/knowledge/adapters/shared/codec.js +141 -0
  81. package/kits/knowledge/docs/README.md +121 -2
  82. package/kits/knowledge/docs/store-contract.md +112 -4
  83. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  84. package/kits/knowledge/flows/promote.flow.json +84 -0
  85. package/kits/knowledge/kit.json +15 -0
  86. package/kits/knowledge/promote/distill.js +96 -0
  87. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  88. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  89. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  90. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  91. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  92. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  93. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  94. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  95. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  96. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  97. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  98. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  99. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  100. package/kits/knowledge/promote/health.js +137 -0
  101. package/kits/knowledge/promote/index.js +176 -0
  102. package/kits/knowledge/promote/ingest.js +103 -0
  103. package/kits/knowledge/promote/lib.js +132 -0
  104. package/kits/knowledge/promote/link.js +84 -0
  105. package/kits/knowledge/promote/promote.test.js +174 -0
  106. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  107. package/kits/knowledge/providers/index.js +1 -0
  108. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  109. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  110. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  111. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  112. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  113. package/kits/knowledge/providers/neo4j/index.js +280 -0
  114. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  115. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  116. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  117. package/package.json +4 -2
  118. package/scripts/README.md +1 -0
  119. package/scripts/freeze-adrs.mjs +364 -0
  120. package/scripts/hooks/config-protection.js +14 -1
  121. package/scripts/hooks/evidence-capture.js +4 -1
  122. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  123. package/scripts/hooks/lib/current-pointer.js +123 -0
  124. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  125. package/scripts/hooks/stop-goal-fit.js +4 -2
  126. package/scripts/hooks/workflow-steering.js +42 -0
  127. package/scripts/statusline/flow-agents-statusline.js +3 -1
  128. package/src/cli/assignment-provider.ts +137 -55
  129. package/src/cli/workflow-sidecar.ts +430 -33
  130. package/src/lib/flow-resolver.ts +35 -14
  131. package/src/tools/validate-source-tree.ts +2 -1
@@ -82,6 +82,43 @@ Maintain a compact running record in the active artifact or conversation when no
82
82
 
83
83
  When workflow artifacts exist, update the appropriate session, handoff, Probe record, or planning artifact according to the local artifact contract. Do not invent a project-specific storage format when the repository already defines one.
84
84
 
85
+ ## Docs-Write: Two-Delta Emission
86
+
87
+ Per `context/contracts/probe-docs-write-contract.md`, when a `decisions` entry above is a **decision subject** — a durable, reusable answer to a named domain question, not an implementation detail or transient planning choice — emit two deltas into durable docs in the same motion, before moving to the next branch:
88
+
89
+ 1. **Vocabulary delta**: the subject noun must exist as a `CONTEXT.md` glossary term. If absent, coin it first (a tight one- or two-sentence `### <Term>` entry); if present, refine the definition when the crystallized decision changes what it says (e.g. an "open" pointer becoming a stated answer).
90
+ 2. **Decision delta**: consult `docs/decisions/index.md` and propose revise-vs-create against the existing topic slugs (this includes any `needs-decision` stub already naming the subject); revise the existing topic file in place, or create `docs/decisions/<slug>.md` per `context/contracts/decision-registry-contract.md`. Run `npm run gen:decisions-index && npm run check:decisions` after writing.
91
+ 3. **Transcript provenance**: add a `session-archive` evidence ref pointing at this Probe's own session artifact (the live `.kontourai/flow-agents/<slug>/...` path — it does not need to be archived yet).
92
+
93
+ Example — coining a new term and creating its topic file:
94
+
95
+ ```markdown
96
+ ### Retry Budget
97
+
98
+ The maximum number of automatic retries a workflow step may attempt before routing back to the user. Configured per Flow Definition step, not globally.
99
+ ```
100
+
101
+ ```markdown
102
+ ---
103
+ status: current
104
+ subject: Retry budget
105
+ decided: 2026-07-03
106
+ evidence:
107
+ - kind: session-archive
108
+ ref: .kontourai/flow-agents/<slug>/<slug>--design-probe.md
109
+ - kind: issue
110
+ ref: https://github.com/kontourai/flow-agents/issues/<n>
111
+ ---
112
+
113
+ # Retry budget
114
+
115
+ Each Flow Definition step declares its own retry budget; there is no global default. Set during design-probe alignment on <n>.
116
+ ```
117
+
118
+ Example — revising a `needs-decision` stub seeded by the ADR-freeze cutover (issue #314) once its frozen ADR's decision is confirmed still current: flip `status: needs-decision` to `current`, set `decided` to today, keep the existing `adr` evidence ref, and append the `session-archive` ref for this Probe session plus a `pr` ref once one exists. See `context/contracts/probe-docs-write-contract.md` § Worked Example for the full step-by-step.
119
+
120
+ Do not write a numbered ADR — `docs/adr/` is frozen history (`docs/adr/README.md`); every decision delta targets the topic-keyed registry.
121
+
85
122
  ## Stop Conditions
86
123
 
87
124
  Stop probing when one of these is true:
@@ -115,15 +115,15 @@ When the gate fails, record `--status fail` with `--expectation pickup-probe-rea
115
115
 
116
116
 
117
117
 
118
- ## Docs And ADR Policy
118
+ ## Docs-Write Policy: Two-Delta Emission
119
119
 
120
- `pickup-probe` may identify durable terminology and decision gaps, but it must keep documentation changes narrow:
120
+ `pickup-probe` may identify durable terminology and decision gaps, but it must keep documentation changes narrow. Per `context/contracts/probe-docs-write-contract.md`, a **decision subject** crystallizing during pickup probing (a durable, reusable answer to a named domain question — not an implementation detail or transient planning choice) gets both deltas in the same motion:
121
121
 
122
- - Update `CONTEXT.md` inline only for glossary-style durable terminology decisions, using tight one- or two-sentence definitions and `_Avoid_` terms when useful. Keep implementation details out of `CONTEXT.md`.
122
+ - Update `CONTEXT.md` inline only for glossary-style durable terminology decisions, using tight one- or two-sentence definitions and `_Avoid_` terms when useful. Keep implementation details out of `CONTEXT.md`. Coin the subject term first if it is not already a glossary entry — vocabulary is the decision registry's topic namespace (`context/contracts/decision-registry-contract.md` § Slug rules) — or refine the existing entry when a decision changes what it says.
123
123
  - If a `CONTEXT-MAP.md` exists, update the relevant context instead of assuming the root glossary owns the term.
124
124
  - Create a lazy context file only when a resolved term or workflow concept has no existing home.
125
125
  - Do not create context files for transient planning notes, open questions, or provider snapshots.
126
- - Propose or create ADRs sparingly, only when all three are true: the decision is hard to reverse, surprising without context, and the result of a real trade-off.
126
+ - Record a decision delta at `docs/decisions/<slug>.md`: consult `docs/decisions/index.md` first and propose revise-vs-create (an existing topic file, including a `needs-decision` stub, is revised in place; only an uncovered subject gets a new file), then run `npm run gen:decisions-index && npm run check:decisions`. Link this Probe's own session artifact as a `session-archive` evidence ref (transcript provenance). Never propose or create a numbered ADR — `docs/adr/` is frozen history; decisions go into the topic-keyed registry.
127
127
  - Prefer workflow artifacts for pickup decisions, unresolved questions, accepted gaps, and route reasons until the work is accepted.
128
128
 
129
129
  ## Handoff To Plan Work
@@ -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
+ }