@kontourai/flow-agents 0.1.2 → 0.3.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 (117) hide show
  1. package/.github/dependabot.yml +23 -0
  2. package/.github/workflows/release-please.yml +31 -0
  3. package/.github/workflows/runtime-compat.yml +118 -0
  4. package/CHANGELOG.md +46 -0
  5. package/CONTRIBUTING.md +4 -0
  6. package/README.md +80 -18
  7. package/build/src/cli/flow-kit.js +9 -4
  8. package/build/src/cli/init.js +215 -5
  9. package/build/src/cli/runtime-adapter.js +9 -5
  10. package/build/src/cli/telemetry-doctor.js +4 -1
  11. package/build/src/cli/utterance-check.js +65 -1
  12. package/build/src/runtime-adapters.js +34 -0
  13. package/build/src/tools/build-universal-bundles.js +285 -0
  14. package/build/src/tools/filter-installed-packs.js +3 -0
  15. package/build/src/tools/validate-source-tree.js +5 -1
  16. package/console.telemetry.json +115 -20
  17. package/context/scripts/telemetry/lib/config.sh +5 -1
  18. package/context/settings/flow-agents-settings.json +7 -0
  19. package/docs/_layouts/default.html +2 -0
  20. package/docs/context-map.md +1 -0
  21. package/docs/index.md +53 -4
  22. package/docs/integrations/conformance.md +246 -0
  23. package/docs/integrations/framework-adapter.md +275 -0
  24. package/docs/integrations/harness-install.md +213 -0
  25. package/docs/integrations/index.md +58 -0
  26. package/docs/integrations/knowledge-kit-live.md +211 -0
  27. package/docs/kit-authoring-guide.md +169 -0
  28. package/docs/north-star.md +2 -2
  29. package/docs/spec/runtime-hook-surface.md +525 -0
  30. package/docs/survey-utterance-check.md +211 -94
  31. package/docs/vision.md +45 -0
  32. package/evals/acceptance/run.sh +13 -2
  33. package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
  34. package/evals/acceptance/test_opencode_harness.sh +121 -0
  35. package/evals/acceptance/test_pi_harness.sh +113 -0
  36. package/evals/integration/test_bundle_install.sh +226 -1
  37. package/evals/integration/test_bundle_lifecycle.sh +641 -0
  38. package/evals/integration/test_runtime_adapter_activation.sh +113 -1
  39. package/evals/integration/test_utterance_check.sh +291 -44
  40. package/evals/run.sh +2 -0
  41. package/evals/static/test_universal_bundles.sh +137 -2
  42. package/integrations/strands/README.md +256 -0
  43. package/integrations/strands/example.py +74 -0
  44. package/integrations/strands/examples/knowledge_kit_live.py +461 -0
  45. package/integrations/strands/flow_agents_strands/__init__.py +27 -0
  46. package/integrations/strands/flow_agents_strands/hooks.py +194 -0
  47. package/integrations/strands/flow_agents_strands/policy.py +348 -0
  48. package/integrations/strands/flow_agents_strands/steering.py +225 -0
  49. package/integrations/strands/flow_agents_strands/telemetry.py +238 -0
  50. package/integrations/strands/pyproject.toml +38 -0
  51. package/integrations/strands/tests/__init__.py +0 -0
  52. package/integrations/strands/tests/test_hooks.py +392 -0
  53. package/integrations/strands/tests/test_policy.py +315 -0
  54. package/integrations/strands/tests/test_telemetry.py +184 -0
  55. package/integrations/strands-ts/README.md +224 -0
  56. package/integrations/strands-ts/bin/conformance-shim.mjs +257 -0
  57. package/integrations/strands-ts/package.json +53 -0
  58. package/integrations/strands-ts/src/hooks.ts +312 -0
  59. package/integrations/strands-ts/src/index.ts +22 -0
  60. package/integrations/strands-ts/src/policy.ts +345 -0
  61. package/integrations/strands-ts/src/telemetry.ts +251 -0
  62. package/integrations/strands-ts/test/test-policy.ts +322 -0
  63. package/integrations/strands-ts/test/test-steering.ts +159 -0
  64. package/integrations/strands-ts/test/test-telemetry.ts +226 -0
  65. package/integrations/strands-ts/tsconfig.json +20 -0
  66. package/kits/catalog.json +6 -0
  67. package/kits/knowledge/adapters/default-store/index.js +821 -0
  68. package/kits/knowledge/adapters/flow-runner/index.js +1179 -0
  69. package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
  70. package/kits/knowledge/docs/README.md +135 -0
  71. package/kits/knowledge/docs/store-contract.md +526 -0
  72. package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
  73. package/kits/knowledge/evals/contract-suite/suite.test.js +670 -0
  74. package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
  75. package/kits/knowledge/evals/synthesis/suite.test.js +909 -0
  76. package/kits/knowledge/flows/compile.flow.json +60 -0
  77. package/kits/knowledge/flows/consolidate.flow.json +77 -0
  78. package/kits/knowledge/flows/ingest.flow.json +60 -0
  79. package/kits/knowledge/flows/store-contract.flow.json +48 -0
  80. package/kits/knowledge/flows/synthesize.flow.json +77 -0
  81. package/kits/knowledge/kit.json +78 -0
  82. package/package.json +7 -2
  83. package/packaging/conformance/README.md +142 -0
  84. package/packaging/conformance/fixtures/config-protection--allow-no-path.json +18 -0
  85. package/packaging/conformance/fixtures/config-protection--allow-safe-file.json +20 -0
  86. package/packaging/conformance/fixtures/config-protection--block-biome.json +20 -0
  87. package/packaging/conformance/fixtures/config-protection--block-eslintrc.json +20 -0
  88. package/packaging/conformance/fixtures/quality-gate--allow-no-path.json +17 -0
  89. package/packaging/conformance/fixtures/quality-gate--allow-nonexistent-file.json +19 -0
  90. package/packaging/conformance/fixtures/stop-goal-fit--allow-clean-cwd.json +17 -0
  91. package/packaging/conformance/fixtures/stop-goal-fit--block-strict-mode.json +23 -0
  92. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +21 -0
  93. package/packaging/conformance/fixtures/workflow-steering--allow-no-state.json +16 -0
  94. package/packaging/conformance/fixtures/workflow-steering--inject-active-state.json +29 -0
  95. package/packaging/conformance/fixtures/workflow-steering--inject-subagent-steering.json +25 -0
  96. package/packaging/conformance/package.json +4 -0
  97. package/packaging/conformance/run-conformance.js +322 -0
  98. package/packaging/manifest.json +59 -0
  99. package/schemas/flow-agents-settings.schema.json +48 -0
  100. package/scripts/README.md +4 -0
  101. package/scripts/dogfood.js +16 -0
  102. package/scripts/hooks/opencode-hook-adapter.js +123 -0
  103. package/scripts/hooks/opencode-telemetry-hook.js +101 -0
  104. package/scripts/hooks/pi-hook-adapter.js +123 -0
  105. package/scripts/hooks/pi-telemetry-hook.js +105 -0
  106. package/scripts/hooks/run-hook.js +8 -0
  107. package/scripts/hooks/utterance-check.js +124 -22
  108. package/scripts/telemetry/lib/config.sh +5 -1
  109. package/src/cli/flow-kit.ts +10 -4
  110. package/src/cli/init.ts +219 -6
  111. package/src/cli/runtime-adapter.ts +10 -5
  112. package/src/cli/telemetry-doctor.ts +4 -1
  113. package/src/cli/utterance-check.ts +71 -1
  114. package/src/runtime-adapters.ts +35 -0
  115. package/src/tools/build-universal-bundles.ts +283 -0
  116. package/src/tools/filter-installed-packs.ts +3 -0
  117. package/src/tools/validate-source-tree.ts +5 -1
@@ -0,0 +1,1179 @@
1
+ /**
2
+ * Knowledge Kit — Flow Runner
3
+ *
4
+ * Executable flow logic that implements the knowledge.ingest, knowledge.compile,
5
+ * and knowledge.synthesize flows against a KnowledgeStoreAdapter. This is the
6
+ * callable entry point for S5's live agent tools.
7
+ *
8
+ * Zero runtime dependencies beyond Node.js built-ins.
9
+ *
10
+ * Exports:
11
+ * - KnowledgeFlowRunner (class)
12
+ * - capture(rawText, meta, options) — ingest flow: capture → classify → store as raw
13
+ * - compile(rawIds[], options) — compile flow: select → compile → link with provenance
14
+ * - synthesize(conceptId | topicSelector, options) — synthesize flow:
15
+ * detect-cluster → propose → evidence-gate → apply-or-reject
16
+ * - defaultSimilarityDetector — pluggable similarity interface default (R3)
17
+ *
18
+ * Telemetry:
19
+ * Gate events are emitted to <workspace>/.telemetry/full.jsonl using
20
+ * canonical schema v0.3.0 events (preToolUse at gate entry, postToolUse at gate exit).
21
+ *
22
+ * Similarity Interface (R3):
23
+ * A SimilarityDetector is a function with the signature:
24
+ * async (concept: Record, candidates: Record[], store: KnowledgeStoreAdapter) => string[]
25
+ * It receives the target concept, all compiled candidates, and the store for link lookups.
26
+ * It returns an array of record IDs deemed similar (the cluster).
27
+ * The default implementation (defaultSimilarityDetector) uses:
28
+ * - category match: candidate.category === concept.category (or prefix match)
29
+ * - link-overlap heuristic: |shared target_ids| / |union target_ids| >= threshold
30
+ *
31
+ * @module adapters/flow-runner
32
+ */
33
+
34
+ import * as path from "node:path";
35
+ import { fileURLToPath } from "node:url";
36
+ import { KnowledgeTelemetry } from "./telemetry.js";
37
+
38
+ // ---------------------------------------------------------------------------
39
+ // Error helpers
40
+ // ---------------------------------------------------------------------------
41
+
42
+ function missingEvidenceError(message) {
43
+ const err = new Error(message);
44
+ err.code = "MISSING_EVIDENCE";
45
+ return err;
46
+ }
47
+
48
+ // ---------------------------------------------------------------------------
49
+ // Classification heuristics
50
+ // ---------------------------------------------------------------------------
51
+
52
+ // Infer a category from raw text and provided meta. The caller may supply
53
+ // category directly in meta; otherwise we derive it from keywords.
54
+ function inferCategory(rawText, meta) {
55
+ if (meta?.category) return meta.category;
56
+
57
+ const text = (rawText || "").toLowerCase();
58
+
59
+ // Simple keyword-based classifier — good enough for an ingest gate
60
+ if (/\b(api|rest|graphql|endpoint|http)\b/.test(text)) return "engineering.api";
61
+ if (/\b(test|spec|unit|assertion)\b/.test(text)) return "engineering.testing";
62
+ if (/\b(architecture|design|pattern|system)\b/.test(text)) return "engineering.architecture";
63
+ if (/\b(meeting|standup|decision|action item)\b/.test(text)) return "team.meeting";
64
+ if (/\b(research|study|paper|finding)\b/.test(text)) return "research.notes";
65
+ if (/\b(bug|fix|issue|error|exception)\b/.test(text)) return "engineering.bugs";
66
+ if (/\b(deploy|release|version|ci|cd)\b/.test(text)) return "engineering.ops";
67
+
68
+ return "general";
69
+ }
70
+
71
+ // Derive a title from the raw text (first line, truncated)
72
+ function inferTitle(rawText, meta) {
73
+ if (meta?.title) return meta.title;
74
+ const firstLine = (rawText || "").split("\n")[0].trim().slice(0, 80);
75
+ return firstLine || "Untitled capture";
76
+ }
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Similarity detection — pluggable interface (R3)
80
+ // ---------------------------------------------------------------------------
81
+
82
+ /**
83
+ * Default similarity detector: category match + link-overlap heuristic.
84
+ *
85
+ * SimilarityDetector interface:
86
+ * async (concept: Record, candidates: Record[], store: KnowledgeStoreAdapter) => string[]
87
+ *
88
+ * Returns the IDs of candidates deemed similar to the concept.
89
+ *
90
+ * Algorithm (v1):
91
+ * 1. Category match: candidate.category starts with concept.category (prefix match)
92
+ * OR concept.category starts with candidate.category. Excludes non-matches unless
93
+ * the threshold is lowered.
94
+ * 2. Link-overlap: compute Jaccard similarity of outbound link target_ids between
95
+ * concept and candidate. Candidates with Jaccard >= LINK_OVERLAP_THRESHOLD are
96
+ * included.
97
+ * 3. A candidate passes if it satisfies EITHER criterion.
98
+ *
99
+ * @param {object} concept - concept record
100
+ * @param {object[]} candidates - compiled records
101
+ * @param {object} store - KnowledgeStoreAdapter (for getLinks)
102
+ * @returns {Promise<string[]>} IDs of similar compiled records
103
+ */
104
+ export async function defaultSimilarityDetector(concept, candidates, store) {
105
+ const LINK_OVERLAP_THRESHOLD = 0.1; // Jaccard threshold for link overlap
106
+
107
+ const conceptLinks = await store.getLinks(concept.id);
108
+ const conceptTargets = new Set(
109
+ (conceptLinks.forward || []).map((l) => l.target_id)
110
+ );
111
+
112
+ const similar = [];
113
+
114
+ for (const candidate of candidates) {
115
+ // Check 1: category overlap (prefix match in either direction)
116
+ const catMatch =
117
+ candidate.category === concept.category ||
118
+ candidate.category.startsWith(`${concept.category}.`) ||
119
+ concept.category.startsWith(`${candidate.category}.`);
120
+
121
+ // Check 2: link-overlap heuristic (Jaccard similarity of outbound link targets)
122
+ let jaccard = 0;
123
+ if (conceptTargets.size > 0) {
124
+ const candidateLinks = await store.getLinks(candidate.id);
125
+ const candidateTargets = new Set(
126
+ (candidateLinks.forward || []).map((l) => l.target_id)
127
+ );
128
+ const intersection = [...conceptTargets].filter((t) => candidateTargets.has(t));
129
+ const union = new Set([...conceptTargets, ...candidateTargets]);
130
+ jaccard = union.size > 0 ? intersection.length / union.size : 0;
131
+ }
132
+
133
+ if (catMatch || jaccard >= LINK_OVERLAP_THRESHOLD) {
134
+ similar.push(candidate.id);
135
+ }
136
+ }
137
+
138
+ return similar;
139
+ }
140
+
141
+ // ---------------------------------------------------------------------------
142
+ // KnowledgeFlowRunner
143
+ // ---------------------------------------------------------------------------
144
+
145
+ export class KnowledgeFlowRunner {
146
+ /**
147
+ * @param {{
148
+ * store: KnowledgeStoreAdapter,
149
+ * workspace?: string,
150
+ * agent?: string,
151
+ * sessionId?: string
152
+ * }} options
153
+ */
154
+ constructor({ store, workspace, agent, sessionId } = {}) {
155
+ if (!store) throw new Error("KnowledgeFlowRunner: store adapter is required");
156
+ this._store = store;
157
+ this._agent = agent || "knowledge-flow-runner";
158
+ this._telemetry = new KnowledgeTelemetry({
159
+ workspace,
160
+ agentName: this._agent,
161
+ sessionId,
162
+ });
163
+ }
164
+
165
+ // -------------------------------------------------------------------------
166
+ // knowledge.ingest flow
167
+ // Steps: capture → classify → route → done
168
+ // Gate: classify-gate — classification recorded (category + type=raw)
169
+ // -------------------------------------------------------------------------
170
+
171
+ /**
172
+ * Execute the ingest flow: capture raw text, classify it, store as a raw
173
+ * record, and route it.
174
+ *
175
+ * @param {string} rawText - the raw content to capture
176
+ * @param {object} [meta] - optional metadata overrides:
177
+ * - title: string - record title (inferred from first line if absent)
178
+ * - category: string - dot-separated category (inferred if absent)
179
+ * - tags: string[] - tag list
180
+ * - agent: string - override agent name
181
+ * - session_id: string - session identifier
182
+ * - note: string - provenance note
183
+ * @returns {Promise<{ id: string, record: object, telemetryEvents: object[] }>}
184
+ */
185
+ async capture(rawText, meta = {}) {
186
+ const events = [];
187
+
188
+ // ── Step: capture ──────────────────────────────────────────────────────
189
+ if (!rawText || typeof rawText !== "string" || !rawText.trim()) {
190
+ throw missingEvidenceError("capture: rawText must be a non-empty string");
191
+ }
192
+
193
+ // ── Gate: classify-gate ────────────────────────────────────────────────
194
+ // Evidence required: category (non-empty) + type="raw"
195
+ const category = inferCategory(rawText, meta);
196
+ const title = inferTitle(rawText, meta);
197
+
198
+ const gateContext = {
199
+ flow: "knowledge.ingest",
200
+ gate: "classify-gate",
201
+ evidence: {
202
+ type: "raw",
203
+ category,
204
+ title,
205
+ },
206
+ };
207
+
208
+ // Emit gate entry event (preToolUse)
209
+ const gateInEvent = this._telemetry.emitGate("knowledge.ingest", "classify-gate", gateContext);
210
+ events.push(gateInEvent);
211
+
212
+ // Enforce gate: category must be valid (non-empty, proper format)
213
+ if (!category || typeof category !== "string" || !category.trim()) {
214
+ throw missingEvidenceError("classify-gate: classification failed — category is empty");
215
+ }
216
+ if (!/^[a-z0-9_-]+(\.[a-z0-9_-]+)*$/.test(category)) {
217
+ throw missingEvidenceError(`classify-gate: classification failed — invalid category: ${category}`);
218
+ }
219
+
220
+ // ── Step: classify — create the raw record in the store ────────────────
221
+ const provenance = {
222
+ agent: meta?.agent || this._agent,
223
+ ...(meta?.session_id ? { session_id: meta.session_id } : {}),
224
+ ...(meta?.note ? { note: meta.note } : {}),
225
+ };
226
+
227
+ const recordId = await this._store.create({
228
+ type: "raw",
229
+ title,
230
+ body: rawText,
231
+ category,
232
+ tags: meta?.tags || [],
233
+ provenance,
234
+ });
235
+
236
+ // Emit gate exit event (postToolUse) — classification recorded
237
+ const gateOutEvent = this._telemetry.emitGateResult("knowledge.ingest", "classify-gate", {
238
+ record_id: recordId,
239
+ type: "raw",
240
+ category,
241
+ title,
242
+ });
243
+ events.push(gateOutEvent);
244
+
245
+ // ── Step: route — record routing decision ─────────────────────────────
246
+ // Default routing: queue for compilation (operator can override via meta.routing)
247
+ const routing = meta?.routing || "queue-for-compile";
248
+
249
+ const routeGateInEvent = this._telemetry.emitGate("knowledge.ingest", "route-gate", {
250
+ flow: "knowledge.ingest",
251
+ gate: "route-gate",
252
+ record_id: recordId,
253
+ routing_decision: routing,
254
+ });
255
+ events.push(routeGateInEvent);
256
+
257
+ const routeGateOutEvent = this._telemetry.emitGateResult("knowledge.ingest", "route-gate", {
258
+ record_id: recordId,
259
+ routing_decision: routing,
260
+ });
261
+ events.push(routeGateOutEvent);
262
+
263
+ const record = await this._store.get(recordId);
264
+ return { id: recordId, record, telemetryEvents: events };
265
+ }
266
+
267
+ // -------------------------------------------------------------------------
268
+ // knowledge.compile flow
269
+ // Steps: select-raws → compile → link → done
270
+ // Gate: compile-gate — compiled record carries provenance refs to EVERY
271
+ // consumed raw (provenance.source_ids + source links)
272
+ // -------------------------------------------------------------------------
273
+
274
+ /**
275
+ * Execute the compile flow: select raw records, compile them into a
276
+ * normalized note, create the compiled record with full provenance, and
277
+ * verify all provenance refs resolve.
278
+ *
279
+ * @param {string[]} rawIds - IDs of raw records to compile
280
+ * @param {object} [options]
281
+ * - title: string - title for the compiled record
282
+ * - body: string - compiled body (if omitted, concatenates raw bodies)
283
+ * - category: string - category (defaults to most common raw category)
284
+ * - tags: string[] - tags
285
+ * - agent: string - override agent
286
+ * - session_id: string - session id
287
+ * - note: string - provenance note
288
+ * @returns {Promise<{ id: string, record: object, telemetryEvents: object[] }>}
289
+ */
290
+ async compile(rawIds, options = {}) {
291
+ const events = [];
292
+
293
+ // ── Step: select-raws ──────────────────────────────────────────────────
294
+ if (!Array.isArray(rawIds) || rawIds.length === 0) {
295
+ throw missingEvidenceError("compile: rawIds must be a non-empty array");
296
+ }
297
+
298
+ // Emit select-raws gate entry
299
+ const selectGateIn = this._telemetry.emitGate("knowledge.compile", "select-raws-gate", {
300
+ flow: "knowledge.compile",
301
+ gate: "select-raws-gate",
302
+ raw_ids: rawIds,
303
+ });
304
+ events.push(selectGateIn);
305
+
306
+ // Fetch all raw records — reject if any is missing or not type=raw
307
+ const rawRecords = [];
308
+ for (const rawId of rawIds) {
309
+ const rec = await this._store.get(rawId);
310
+ if (!rec) {
311
+ throw missingEvidenceError(`select-raws-gate: raw record not found: ${rawId}`);
312
+ }
313
+ if (rec.type !== "raw") {
314
+ throw missingEvidenceError(`select-raws-gate: record ${rawId} is type="${rec.type}", expected "raw"`);
315
+ }
316
+ rawRecords.push(rec);
317
+ }
318
+
319
+ const selectGateOut = this._telemetry.emitGateResult("knowledge.compile", "select-raws-gate", {
320
+ raw_ids: rawIds,
321
+ count: rawRecords.length,
322
+ });
323
+ events.push(selectGateOut);
324
+
325
+ // ── Step: compile ──────────────────────────────────────────────────────
326
+ // Build compiled body from raws if not provided
327
+ const compiledBody = options.body
328
+ || rawRecords.map((r) => `## ${r.title}\n\n${r.body}`).join("\n\n---\n\n");
329
+
330
+ // Determine category: use option, or most-common raw category
331
+ const compiledCategory = options.category || mostCommonCategory(rawRecords);
332
+
333
+ const compiledTitle = options.title
334
+ || (rawRecords.length === 1
335
+ ? `Compiled: ${rawRecords[0].title}`
336
+ : `Compiled from ${rawRecords.length} sources`);
337
+
338
+ // Build source links — one per raw record with kind="source"
339
+ const sourceLinks = rawIds.map((rawId) => ({
340
+ target_id: rawId,
341
+ kind: "source",
342
+ }));
343
+
344
+ // Provenance: source_ids must list EVERY consumed raw ID
345
+ const provenance = {
346
+ agent: options.agent || this._agent,
347
+ source_ids: rawIds, // ← required by compile-gate
348
+ ...(options.session_id ? { session_id: options.session_id } : {}),
349
+ ...(options.note ? { note: options.note } : {}),
350
+ };
351
+
352
+ // ── Gate: compile-gate — emit before store.create ─────────────────────
353
+ const compileGateIn = this._telemetry.emitGate("knowledge.compile", "compile-gate", {
354
+ flow: "knowledge.compile",
355
+ gate: "compile-gate",
356
+ evidence: {
357
+ source_ids: rawIds,
358
+ source_links: sourceLinks,
359
+ category: compiledCategory,
360
+ },
361
+ });
362
+ events.push(compileGateIn);
363
+
364
+ // Enforce: provenance.source_ids must cover ALL rawIds
365
+ if (!provenance.source_ids || provenance.source_ids.length !== rawIds.length) {
366
+ throw missingEvidenceError(
367
+ `compile-gate: provenance.source_ids must list every consumed raw ID; ` +
368
+ `expected ${rawIds.length} entries, got ${provenance.source_ids?.length ?? 0}`
369
+ );
370
+ }
371
+
372
+ const compiledId = await this._store.create({
373
+ type: "compiled",
374
+ title: compiledTitle,
375
+ body: compiledBody,
376
+ category: compiledCategory,
377
+ tags: options.tags || [],
378
+ links: sourceLinks,
379
+ provenance,
380
+ });
381
+
382
+ const compileGateOut = this._telemetry.emitGateResult("knowledge.compile", "compile-gate", {
383
+ compiled_id: compiledId,
384
+ source_ids: rawIds,
385
+ source_link_count: sourceLinks.length,
386
+ });
387
+ events.push(compileGateOut);
388
+
389
+ // ── Step: link — verify provenance refs resolve ────────────────────────
390
+ const linkGateIn = this._telemetry.emitGate("knowledge.compile", "link-gate", {
391
+ flow: "knowledge.compile",
392
+ gate: "link-gate",
393
+ compiled_id: compiledId,
394
+ expected_raw_ids: rawIds,
395
+ });
396
+ events.push(linkGateIn);
397
+
398
+ // Verify: every provenance ref resolves via store.get()
399
+ for (const rawId of rawIds) {
400
+ const ref = await this._store.get(rawId);
401
+ if (!ref) {
402
+ throw missingEvidenceError(`link-gate: provenance ref ${rawId} does not resolve`);
403
+ }
404
+ }
405
+
406
+ // Verify: graph index reflects source links
407
+ const { forward } = await this._store.getLinks(compiledId);
408
+ for (const rawId of rawIds) {
409
+ const hasSourceLink = forward.some(
410
+ (l) => l.target_id === rawId && l.kind === "source"
411
+ );
412
+ if (!hasSourceLink) {
413
+ throw missingEvidenceError(
414
+ `link-gate: source link to raw ${rawId} missing from graph index`
415
+ );
416
+ }
417
+ }
418
+
419
+ const linkGateOut = this._telemetry.emitGateResult("knowledge.compile", "link-gate", {
420
+ compiled_id: compiledId,
421
+ resolved_raw_ids: rawIds,
422
+ graph_links_verified: rawIds.length,
423
+ });
424
+ events.push(linkGateOut);
425
+
426
+ const record = await this._store.get(compiledId);
427
+ return { id: compiledId, record, telemetryEvents: events };
428
+ }
429
+
430
+ // -------------------------------------------------------------------------
431
+ // knowledge.synthesize flow
432
+ // Steps: detect-cluster → propose → evidence-gate → apply-or-reject → done
433
+ // Gate: evidence-gate — proposal carries source refs; no direct mutation (AC1)
434
+ // apply-gate — apply or reject via store ops only (never direct write)
435
+ // rejection leaves concept byte-identical (AC2)
436
+ // apply updates with provenance to all sources (AC3)
437
+ // -------------------------------------------------------------------------
438
+
439
+ /**
440
+ * Execute the synthesize flow: detect similar compiled records, create a
441
+ * proposal via the store's propose op (never a direct mutation), gate the
442
+ * evidence, then apply or reject.
443
+ *
444
+ * @param {string|object} conceptIdOrSelector
445
+ * - string: ID of an existing concept record to synthesize toward.
446
+ * - object topicSelector: { category } — concept located by category.
447
+ * @param {object} [options]
448
+ * - proposedBody: string — the proposed replacement body (required)
449
+ * - rationale: string — reason for the proposal (required for apply)
450
+ * - decision: "apply"|"reject" — gate decision (default "apply")
451
+ * - rejectReason: string — reason for rejection (required when decision="reject")
452
+ * - agent: string — override agent name
453
+ * - session_id: string — session id
454
+ * - note: string — provenance note
455
+ * - similarityDetector: fn — pluggable detector (R3); see SimilarityDetector interface
456
+ * @returns {Promise<{
457
+ * conceptId: string,
458
+ * proposerId: string,
459
+ * cluster: string[],
460
+ * decision: "apply"|"reject",
461
+ * telemetryEvents: object[]
462
+ * }>}
463
+ */
464
+ async synthesize(conceptIdOrSelector, options = {}) {
465
+ const events = [];
466
+ const agent = options.agent || this._agent;
467
+
468
+ // ── Step: detect-cluster ───────────────────────────────────────────────
469
+ // Resolve concept id; locate similar compiled records via similarity detector
470
+
471
+ const detector = options.similarityDetector || defaultSimilarityDetector;
472
+
473
+ // Resolve the concept record
474
+ let conceptId;
475
+ if (typeof conceptIdOrSelector === "string") {
476
+ conceptId = conceptIdOrSelector;
477
+ const concept = await this._store.get(conceptId);
478
+ if (!concept) {
479
+ throw missingEvidenceError(`synthesize: concept not found: ${conceptId}`);
480
+ }
481
+ if (concept.type !== "concept") {
482
+ throw missingEvidenceError(
483
+ `synthesize: record ${conceptId} is type="${concept.type}", expected "concept"`
484
+ );
485
+ }
486
+ } else if (conceptIdOrSelector && typeof conceptIdOrSelector === "object") {
487
+ // topicSelector: find by category
488
+ const sel = conceptIdOrSelector;
489
+ if (!sel.category) {
490
+ throw missingEvidenceError("synthesize: topicSelector must include a category");
491
+ }
492
+ const concepts = (await this._store.listByType("concept")).filter(
493
+ (r) => r.category === sel.category
494
+ );
495
+ if (concepts.length === 0) {
496
+ throw missingEvidenceError(
497
+ `synthesize: no concept found for category: ${sel.category}`
498
+ );
499
+ }
500
+ conceptId = concepts[0].id;
501
+ } else {
502
+ throw missingEvidenceError(
503
+ "synthesize: conceptIdOrSelector must be a string id or topicSelector object"
504
+ );
505
+ }
506
+
507
+ const concept = await this._store.get(conceptId);
508
+
509
+ // Emit detect-cluster gate entry
510
+ const detectGateIn = this._telemetry.emitGate(
511
+ "knowledge.synthesize",
512
+ "detect-cluster-gate",
513
+ {
514
+ flow: "knowledge.synthesize",
515
+ gate: "detect-cluster-gate",
516
+ concept_id: conceptId,
517
+ concept_category: concept.category,
518
+ }
519
+ );
520
+ events.push(detectGateIn);
521
+
522
+ // Run similarity detection
523
+ const allCompiled = await this._store.listByType("compiled");
524
+ const cluster = await detector(concept, allCompiled, this._store);
525
+
526
+ if (!Array.isArray(cluster) || cluster.length === 0) {
527
+ throw missingEvidenceError(
528
+ "detect-cluster-gate: no similar compiled records found; " +
529
+ "synthesis requires at least one similar source"
530
+ );
531
+ }
532
+
533
+ const detectGateOut = this._telemetry.emitGateResult(
534
+ "knowledge.synthesize",
535
+ "detect-cluster-gate",
536
+ {
537
+ concept_id: conceptId,
538
+ cluster_ids: cluster,
539
+ cluster_size: cluster.length,
540
+ }
541
+ );
542
+ events.push(detectGateOut);
543
+
544
+ // ── Step: propose ──────────────────────────────────────────────────────
545
+ // The proposing record is the first compiled record in the cluster.
546
+ // We use the store's propose op — never a direct mutation (AC1).
547
+
548
+ if (!options.proposedBody || !options.proposedBody.trim()) {
549
+ throw missingEvidenceError("synthesize: options.proposedBody is required");
550
+ }
551
+
552
+ const proposerId = cluster[0];
553
+
554
+ const proposeGateIn = this._telemetry.emitGate("knowledge.synthesize", "propose-gate", {
555
+ flow: "knowledge.synthesize",
556
+ gate: "propose-gate",
557
+ concept_id: conceptId,
558
+ proposer_id: proposerId,
559
+ source_ids: cluster,
560
+ });
561
+ events.push(proposeGateIn);
562
+
563
+ // Create proposal via store propose op (not direct mutation — AC1)
564
+ await this._store.propose(conceptId, proposerId, {
565
+ agent,
566
+ proposal: options.proposedBody,
567
+ ...(options.note ? { note: options.note } : {}),
568
+ });
569
+
570
+ const proposeGateOut = this._telemetry.emitGateResult(
571
+ "knowledge.synthesize",
572
+ "propose-gate",
573
+ {
574
+ concept_id: conceptId,
575
+ proposer_id: proposerId,
576
+ source_ids: cluster,
577
+ proposal_recorded: true,
578
+ }
579
+ );
580
+ events.push(proposeGateOut);
581
+
582
+ // ── Step: evidence-gate ────────────────────────────────────────────────
583
+ // Verify proposal carries source refs and all source records exist
584
+
585
+ const evidenceGateIn = this._telemetry.emitGate(
586
+ "knowledge.synthesize",
587
+ "evidence-gate",
588
+ {
589
+ flow: "knowledge.synthesize",
590
+ gate: "evidence-gate",
591
+ concept_id: conceptId,
592
+ proposer_id: proposerId,
593
+ source_ids: cluster,
594
+ }
595
+ );
596
+ events.push(evidenceGateIn);
597
+
598
+ // Enforce: source_ids must be non-empty
599
+ if (!cluster || cluster.length === 0) {
600
+ throw missingEvidenceError(
601
+ "evidence-gate: proposal must carry at least one source_id reference"
602
+ );
603
+ }
604
+
605
+ // Enforce: every source record must exist
606
+ for (const srcId of cluster) {
607
+ const ref = await this._store.get(srcId);
608
+ if (!ref) {
609
+ throw missingEvidenceError(
610
+ `evidence-gate: source record ${srcId} does not exist in store`
611
+ );
612
+ }
613
+ }
614
+
615
+ // Enforce: proposer must have a "proposes" link to the concept
616
+ const { forward } = await this._store.getLinks(proposerId);
617
+ const hasProposesLink = forward.some(
618
+ (l) => l.target_id === conceptId && l.kind === "proposes"
619
+ );
620
+ if (!hasProposesLink) {
621
+ throw missingEvidenceError(
622
+ `evidence-gate: proposer ${proposerId} must have a "proposes" link to concept ${conceptId}`
623
+ );
624
+ }
625
+
626
+ const evidenceGateOut = this._telemetry.emitGateResult(
627
+ "knowledge.synthesize",
628
+ "evidence-gate",
629
+ {
630
+ concept_id: conceptId,
631
+ proposer_id: proposerId,
632
+ source_ids: cluster,
633
+ sources_verified: cluster.length,
634
+ proposes_link_verified: true,
635
+ }
636
+ );
637
+ events.push(evidenceGateOut);
638
+
639
+ // ── Step: apply-or-reject ──────────────────────────────────────────────
640
+ // Gate decision: "apply" (default) or "reject"
641
+
642
+ const decision = options.decision || "apply";
643
+
644
+ const applyGateIn = this._telemetry.emitGate("knowledge.synthesize", "apply-gate", {
645
+ flow: "knowledge.synthesize",
646
+ gate: "apply-gate",
647
+ concept_id: conceptId,
648
+ proposer_id: proposerId,
649
+ decision,
650
+ });
651
+ events.push(applyGateIn);
652
+
653
+ if (decision === "apply") {
654
+ if (!options.rationale || !options.rationale.trim()) {
655
+ throw missingEvidenceError(
656
+ "apply-gate: options.rationale is required when decision=apply"
657
+ );
658
+ }
659
+ // Apply via store apply op — updates concept body with provenance to
660
+ // all contributing sources (AC3)
661
+ await this._store.apply(conceptId, proposerId, {
662
+ agent,
663
+ new_body: options.proposedBody,
664
+ rationale: options.rationale,
665
+ });
666
+ } else if (decision === "reject") {
667
+ if (!options.rejectReason || !options.rejectReason.trim()) {
668
+ throw missingEvidenceError(
669
+ "apply-gate: options.rejectReason is required when decision=reject"
670
+ );
671
+ }
672
+ // Reject via store reject op — concept body remains untouched (AC2)
673
+ await this._store.reject(conceptId, proposerId, {
674
+ agent,
675
+ reason: options.rejectReason,
676
+ });
677
+ } else {
678
+ throw missingEvidenceError(
679
+ `apply-gate: decision must be "apply" or "reject"; got: ${decision}`
680
+ );
681
+ }
682
+
683
+ const applyGateOut = this._telemetry.emitGateResult(
684
+ "knowledge.synthesize",
685
+ "apply-gate",
686
+ {
687
+ concept_id: conceptId,
688
+ proposer_id: proposerId,
689
+ decision,
690
+ source_ids: cluster,
691
+ }
692
+ );
693
+ events.push(applyGateOut);
694
+
695
+ return { conceptId, proposerId, cluster, decision, telemetryEvents: events };
696
+ }
697
+ // -------------------------------------------------------------------------
698
+ // knowledge.consolidate flow
699
+ // Steps: related-event → propose → evidence-gate → apply-or-reject → done
700
+ // Gate: evidence-gate — proposal carries source refs; no direct snapshot
701
+ // mutation (AC1); rejection leaves snapshot unchanged (AC2 reject path).
702
+ // apply-gate — apply or reject via store ops only.
703
+ // apply creates a new snapshot and supersedes the prior
704
+ // one(s); superseded snapshots remain queryable (AC2/R3).
705
+ //
706
+ // Machinery reuse: consolidate shares the same propose→evidence-gate→
707
+ // apply-or-reject gate pattern as synthesize. The propose op is called on the
708
+ // snapshot record (store contract §A.5 supersede enforces supersede-not-delete).
709
+ // -------------------------------------------------------------------------
710
+
711
+ /**
712
+ * Execute the consolidate flow: detect compiled records linked to a snapshot
713
+ * topic, create a consolidation proposal (never a direct mutation), gate the
714
+ * evidence, then apply or reject.
715
+ *
716
+ * On apply:
717
+ * 1. A new snapshot record is created with the proposed body and full
718
+ * provenance (source_ids referencing every contributing compiled record).
719
+ * 2. The store supersede op links the new snapshot to any prior snapshot(s)
720
+ * for the same topic — prior snapshots are NEVER deleted (R3).
721
+ * 3. Returns the new snapshot id plus a supersedes chain for traceability.
722
+ *
723
+ * On reject:
724
+ * The snapshot state is unchanged (byte-identical, AC1/AC2).
725
+ *
726
+ * @param {string|object} snapshotIdOrTopic
727
+ * - string: ID of an existing snapshot record to consolidate against.
728
+ * - object topicSelector: { topic } — snapshot located by topic tag.
729
+ * If no snapshot exists for the topic yet, a new one will be created on apply.
730
+ * @param {object} [options]
731
+ * - proposedBody: string — the proposed snapshot body (required)
732
+ * - rationale: string — reason for the consolidation (required for apply)
733
+ * - decision: "apply"|"reject" — gate decision (default "apply")
734
+ * - rejectReason: string — reason for rejection (required when decision="reject")
735
+ * - agent: string — override agent name
736
+ * - session_id: string — session id
737
+ * - note: string — provenance note
738
+ * - category: string — category for new snapshot (required when creating)
739
+ * - similarityDetector: fn — pluggable detector (same interface as synthesize R3)
740
+ * @returns {Promise<{
741
+ * snapshotId: string,
742
+ * proposerId: string,
743
+ * cluster: string[],
744
+ * decision: "apply"|"reject",
745
+ * newSnapshotId: string|null,
746
+ * supersededIds: string[],
747
+ * telemetryEvents: object[]
748
+ * }>}
749
+ */
750
+ async consolidate(snapshotIdOrTopic, options = {}) {
751
+ const events = [];
752
+ const agent = options.agent || this._agent;
753
+
754
+ // ── Step: related-event ────────────────────────────────────────────────
755
+ // Resolve snapshot target; locate related compiled records via detector.
756
+
757
+ const detector = options.similarityDetector || defaultSimilarityDetector;
758
+
759
+ // Resolve the snapshot record (or find it by topic tag).
760
+ // If no snapshot yet exists and decision=apply, we will create one.
761
+ let snapshotId = null;
762
+ let existingSnapshot = null;
763
+ let topic = null;
764
+ let category = options.category || null;
765
+
766
+ if (typeof snapshotIdOrTopic === "string") {
767
+ snapshotId = snapshotIdOrTopic;
768
+ existingSnapshot = await this._store.get(snapshotId);
769
+ if (!existingSnapshot) {
770
+ throw missingEvidenceError(`consolidate: snapshot not found: ${snapshotId}`);
771
+ }
772
+ if (existingSnapshot.type !== "snapshot") {
773
+ throw missingEvidenceError(
774
+ `consolidate: record ${snapshotId} is type="${existingSnapshot.type}", expected "snapshot"`
775
+ );
776
+ }
777
+ // Extract topic from tags (stored as "topic:<value>")
778
+ const topicTag = (existingSnapshot.tags || []).find((t) => t.startsWith("topic:"));
779
+ topic = topicTag ? topicTag.slice(6) : existingSnapshot.category;
780
+ category = category || existingSnapshot.category;
781
+ } else if (snapshotIdOrTopic && typeof snapshotIdOrTopic === "object") {
782
+ const sel = snapshotIdOrTopic;
783
+ topic = sel.topic || sel.category;
784
+ if (!topic) {
785
+ throw missingEvidenceError(
786
+ "consolidate: topicSelector must include a topic or category field"
787
+ );
788
+ }
789
+ // Find existing snapshot by topic tag
790
+ const allSnapshots = await this._store.listByType("snapshot");
791
+ const matches = allSnapshots.filter((s) => {
792
+ const topicTag = (s.tags || []).find((t) => t.startsWith("topic:"));
793
+ const snapshotTopic = topicTag ? topicTag.slice(6) : s.category;
794
+ return snapshotTopic === topic;
795
+ });
796
+ if (matches.length > 0) {
797
+ // Use the most recently created snapshot (no superseded-by log entry = current)
798
+ const current = matches.find((s) => {
799
+ const log = s.mutation_log || [];
800
+ return !log.some((e) => e.op === "superseded-by");
801
+ }) || matches[matches.length - 1];
802
+ existingSnapshot = current;
803
+ snapshotId = current.id;
804
+ }
805
+ // If no existing snapshot, we will create one on apply
806
+ category = category || sel.category || topic.replace(/[^a-z0-9.]/g, "-") || "general";
807
+ } else {
808
+ throw missingEvidenceError(
809
+ "consolidate: snapshotIdOrTopic must be a string id or topicSelector object"
810
+ );
811
+ }
812
+
813
+ // ── Gate: related-event-gate ───────────────────────────────────────────
814
+ // Run similarity detection to find compiled records related to the topic.
815
+ // We use a concept-like proxy to run the similarity detector: a synthetic
816
+ // object with the same category as the snapshot.
817
+
818
+ const snapshotProxy = existingSnapshot || {
819
+ id: "__probe__",
820
+ type: "snapshot",
821
+ category: category || "general",
822
+ tags: [`topic:${topic}`],
823
+ links: [],
824
+ };
825
+
826
+ const relatedGateIn = this._telemetry.emitGate(
827
+ "knowledge.consolidate",
828
+ "related-event-gate",
829
+ {
830
+ flow: "knowledge.consolidate",
831
+ gate: "related-event-gate",
832
+ snapshot_id: snapshotId,
833
+ topic,
834
+ snapshot_category: snapshotProxy.category,
835
+ }
836
+ );
837
+ events.push(relatedGateIn);
838
+
839
+ // Run the detector: pass all compiled records as candidates
840
+ const allCompiled = await this._store.listByType("compiled");
841
+ const cluster = await detector(snapshotProxy, allCompiled, this._store);
842
+
843
+ if (!Array.isArray(cluster) || cluster.length === 0) {
844
+ throw missingEvidenceError(
845
+ "related-event-gate: no compiled records related to snapshot topic found; " +
846
+ "consolidation requires at least one related source"
847
+ );
848
+ }
849
+
850
+ const relatedGateOut = this._telemetry.emitGateResult(
851
+ "knowledge.consolidate",
852
+ "related-event-gate",
853
+ {
854
+ snapshot_id: snapshotId,
855
+ topic,
856
+ cluster_ids: cluster,
857
+ cluster_size: cluster.length,
858
+ }
859
+ );
860
+ events.push(relatedGateOut);
861
+
862
+ // ── Step: propose ──────────────────────────────────────────────────────
863
+ // The proposing record is the first compiled record in the cluster.
864
+ // We use the store's propose op — never a direct snapshot mutation (AC1).
865
+ //
866
+ // When the snapshot does not exist yet (first consolidation for the topic),
867
+ // we create a placeholder snapshot record to attach the proposal to.
868
+
869
+ if (!options.proposedBody || !options.proposedBody.trim()) {
870
+ throw missingEvidenceError("consolidate: options.proposedBody is required");
871
+ }
872
+
873
+ // Ensure a snapshot record exists to propose against
874
+ if (!snapshotId) {
875
+ // Create a placeholder snapshot (empty body) so propose has a target
876
+ const topicTag = `topic:${topic}`;
877
+ snapshotId = await this._store.create({
878
+ type: "snapshot",
879
+ title: `Snapshot: ${topic}`,
880
+ body: "(pending consolidation)",
881
+ category: category || "general",
882
+ tags: [topicTag],
883
+ provenance: {
884
+ agent,
885
+ note: `Placeholder created for first consolidation of topic: ${topic}`,
886
+ ...(options.session_id ? { session_id: options.session_id } : {}),
887
+ },
888
+ });
889
+ existingSnapshot = await this._store.get(snapshotId);
890
+ }
891
+
892
+ const proposerId = cluster[0];
893
+
894
+ const proposeGateIn = this._telemetry.emitGate(
895
+ "knowledge.consolidate",
896
+ "propose-gate",
897
+ {
898
+ flow: "knowledge.consolidate",
899
+ gate: "propose-gate",
900
+ snapshot_id: snapshotId,
901
+ proposer_id: proposerId,
902
+ source_ids: cluster,
903
+ }
904
+ );
905
+ events.push(proposeGateIn);
906
+
907
+ // Create proposal via store propose op (not direct mutation — AC1)
908
+ // We repurpose the propose/apply/reject ops: snapshot acts as the "concept"
909
+ // target here. The contract allows propose/apply/reject against concept-type
910
+ // records, but snapshots are a distinct type. We call propose directly on
911
+ // the store's propose method with the snapshot's id.
912
+ await this._store.propose(snapshotId, proposerId, {
913
+ agent,
914
+ proposal: options.proposedBody,
915
+ ...(options.note ? { note: options.note } : {}),
916
+ });
917
+
918
+ const proposeGateOut = this._telemetry.emitGateResult(
919
+ "knowledge.consolidate",
920
+ "propose-gate",
921
+ {
922
+ snapshot_id: snapshotId,
923
+ proposer_id: proposerId,
924
+ source_ids: cluster,
925
+ proposal_recorded: true,
926
+ }
927
+ );
928
+ events.push(proposeGateOut);
929
+
930
+ // ── Step: evidence-gate ────────────────────────────────────────────────
931
+ // Verify proposal carries source refs and all source records exist.
932
+
933
+ const evidenceGateIn = this._telemetry.emitGate(
934
+ "knowledge.consolidate",
935
+ "evidence-gate",
936
+ {
937
+ flow: "knowledge.consolidate",
938
+ gate: "evidence-gate",
939
+ snapshot_id: snapshotId,
940
+ proposer_id: proposerId,
941
+ source_ids: cluster,
942
+ }
943
+ );
944
+ events.push(evidenceGateIn);
945
+
946
+ // Enforce: source_ids must be non-empty
947
+ if (!cluster || cluster.length === 0) {
948
+ throw missingEvidenceError(
949
+ "evidence-gate: proposal must carry at least one source_id reference"
950
+ );
951
+ }
952
+
953
+ // Enforce: every source record must exist
954
+ for (const srcId of cluster) {
955
+ const ref = await this._store.get(srcId);
956
+ if (!ref) {
957
+ throw missingEvidenceError(
958
+ `evidence-gate: source record ${srcId} does not exist in store`
959
+ );
960
+ }
961
+ }
962
+
963
+ // Enforce: proposer must have a "proposes" link to the snapshot
964
+ const { forward } = await this._store.getLinks(proposerId);
965
+ const hasProposesLink = forward.some(
966
+ (l) => l.target_id === snapshotId && l.kind === "proposes"
967
+ );
968
+ if (!hasProposesLink) {
969
+ throw missingEvidenceError(
970
+ `evidence-gate: proposer ${proposerId} must have a "proposes" link to snapshot ${snapshotId}`
971
+ );
972
+ }
973
+
974
+ const evidenceGateOut = this._telemetry.emitGateResult(
975
+ "knowledge.consolidate",
976
+ "evidence-gate",
977
+ {
978
+ snapshot_id: snapshotId,
979
+ proposer_id: proposerId,
980
+ source_ids: cluster,
981
+ sources_verified: cluster.length,
982
+ proposes_link_verified: true,
983
+ }
984
+ );
985
+ events.push(evidenceGateOut);
986
+
987
+ // ── Step: apply-or-reject ──────────────────────────────────────────────
988
+ // Gate decision: "apply" (default) or "reject"
989
+
990
+ const decision = options.decision || "apply";
991
+
992
+ const applyGateIn = this._telemetry.emitGate(
993
+ "knowledge.consolidate",
994
+ "apply-gate",
995
+ {
996
+ flow: "knowledge.consolidate",
997
+ gate: "apply-gate",
998
+ snapshot_id: snapshotId,
999
+ proposer_id: proposerId,
1000
+ decision,
1001
+ }
1002
+ );
1003
+ events.push(applyGateIn);
1004
+
1005
+ let newSnapshotId = null;
1006
+ let supersededIds = [];
1007
+
1008
+ if (decision === "apply") {
1009
+ if (!options.rationale || !options.rationale.trim()) {
1010
+ throw missingEvidenceError(
1011
+ "apply-gate: options.rationale is required when decision=apply"
1012
+ );
1013
+ }
1014
+
1015
+ // Collect all prior (non-superseded) snapshots for the same topic,
1016
+ // so we can supersede them after creating the new snapshot.
1017
+ const allSnapshots = await this._store.listByType("snapshot");
1018
+ const priorSnapshotIds = allSnapshots
1019
+ .filter((s) => {
1020
+ if (s.id === snapshotId) return false; // we'll include the placeholder below
1021
+ const topicTag = (s.tags || []).find((t) => t.startsWith("topic:"));
1022
+ const snapshotTopic = topicTag ? topicTag.slice(6) : s.category;
1023
+ return snapshotTopic === topic;
1024
+ })
1025
+ .map((s) => s.id);
1026
+
1027
+ // The placeholder snapshot (created above or passed in) is also superseded
1028
+ // unless it already has content (i.e., was the prior live snapshot).
1029
+ const placeholderSnapshot = await this._store.get(snapshotId);
1030
+ const isPlaceholder =
1031
+ placeholderSnapshot && placeholderSnapshot.body === "(pending consolidation)";
1032
+
1033
+ // Create the new definitive snapshot with the proposed body
1034
+ const topicTag = `topic:${topic}`;
1035
+ const sourceLinks = cluster.map((cid) => ({ target_id: cid, kind: "source" }));
1036
+
1037
+ newSnapshotId = await this._store.create({
1038
+ type: "snapshot",
1039
+ title: `Snapshot: ${topic}`,
1040
+ body: options.proposedBody,
1041
+ category: existingSnapshot?.category || category || "general",
1042
+ tags: [topicTag],
1043
+ links: sourceLinks,
1044
+ provenance: {
1045
+ agent,
1046
+ source_ids: cluster,
1047
+ note: options.rationale,
1048
+ ...(options.session_id ? { session_id: options.session_id } : {}),
1049
+ },
1050
+ });
1051
+
1052
+ // Collect all snapshot IDs that this new snapshot supersedes
1053
+ supersededIds = [
1054
+ ...(isPlaceholder ? [snapshotId] : [snapshotId]),
1055
+ ...priorSnapshotIds,
1056
+ ];
1057
+ // Deduplicate
1058
+ supersededIds = [...new Set(supersededIds)];
1059
+
1060
+ // Supersede all prior snapshots — NEVER deletes them (R3)
1061
+ if (supersededIds.length > 0) {
1062
+ await this._store.supersede(newSnapshotId, supersededIds, {
1063
+ agent,
1064
+ rationale: options.rationale,
1065
+ ...(options.note ? { note: options.note } : {}),
1066
+ });
1067
+ }
1068
+ } else if (decision === "reject") {
1069
+ if (!options.rejectReason || !options.rejectReason.trim()) {
1070
+ throw missingEvidenceError(
1071
+ "apply-gate: options.rejectReason is required when decision=reject"
1072
+ );
1073
+ }
1074
+ // Reject: snapshot body remains unchanged (AC1)
1075
+ await this._store.reject(snapshotId, proposerId, {
1076
+ agent,
1077
+ reason: options.rejectReason,
1078
+ });
1079
+ } else {
1080
+ throw missingEvidenceError(
1081
+ `apply-gate: decision must be "apply" or "reject"; got: ${decision}`
1082
+ );
1083
+ }
1084
+
1085
+ const applyGateOut = this._telemetry.emitGateResult(
1086
+ "knowledge.consolidate",
1087
+ "apply-gate",
1088
+ {
1089
+ snapshot_id: snapshotId,
1090
+ proposer_id: proposerId,
1091
+ decision,
1092
+ source_ids: cluster,
1093
+ new_snapshot_id: newSnapshotId,
1094
+ superseded_ids: supersededIds,
1095
+ }
1096
+ );
1097
+ events.push(applyGateOut);
1098
+
1099
+ return {
1100
+ snapshotId,
1101
+ proposerId,
1102
+ cluster,
1103
+ decision,
1104
+ newSnapshotId,
1105
+ supersededIds,
1106
+ telemetryEvents: events,
1107
+ };
1108
+ }
1109
+
1110
+ }
1111
+
1112
+ // ---------------------------------------------------------------------------
1113
+ // Helpers
1114
+ // ---------------------------------------------------------------------------
1115
+
1116
+ function mostCommonCategory(records) {
1117
+ const counts = {};
1118
+ for (const r of records) {
1119
+ counts[r.category] = (counts[r.category] || 0) + 1;
1120
+ }
1121
+ return Object.entries(counts).sort((a, b) => b[1] - a[1])[0]?.[0] || "general";
1122
+ }
1123
+
1124
+ // ---------------------------------------------------------------------------
1125
+ // Convenience function exports (module-level wrappers for tool calling)
1126
+ // ---------------------------------------------------------------------------
1127
+
1128
+ /**
1129
+ * Module-level capture: creates an ephemeral runner using the provided store.
1130
+ *
1131
+ * @param {string} rawText
1132
+ * @param {object} meta
1133
+ * @param {{ store, workspace?, agent?, sessionId? }} options
1134
+ */
1135
+ export async function capture(rawText, meta, { store, workspace, agent, sessionId } = {}) {
1136
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
1137
+ return runner.capture(rawText, meta);
1138
+ }
1139
+
1140
+ /**
1141
+ * Module-level compile: creates an ephemeral runner using the provided store.
1142
+ *
1143
+ * @param {string[]} rawIds
1144
+ * @param {object} options (merged into compile options + runner options)
1145
+ */
1146
+ export async function compile(rawIds, { store, workspace, agent, sessionId, ...compileOpts } = {}) {
1147
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
1148
+ return runner.compile(rawIds, compileOpts);
1149
+ }
1150
+
1151
+ /**
1152
+ * Module-level synthesize: creates an ephemeral runner using the provided store.
1153
+ *
1154
+ * @param {string|object} conceptIdOrSelector
1155
+ * @param {object} options (merged into synthesize options + runner options)
1156
+ */
1157
+ export async function synthesize(
1158
+ conceptIdOrSelector,
1159
+ { store, workspace, agent, sessionId, ...synthOpts } = {}
1160
+ ) {
1161
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
1162
+ return runner.synthesize(conceptIdOrSelector, synthOpts);
1163
+ }
1164
+
1165
+ /**
1166
+ * Module-level consolidate: creates an ephemeral runner using the provided store.
1167
+ *
1168
+ * @param {string|object} snapshotIdOrTopic
1169
+ * @param {object} options (merged into consolidate options + runner options)
1170
+ */
1171
+ export async function consolidate(
1172
+ snapshotIdOrTopic,
1173
+ { store, workspace, agent, sessionId, ...consolidateOpts } = {}
1174
+ ) {
1175
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
1176
+ return runner.consolidate(snapshotIdOrTopic, consolidateOpts);
1177
+ }
1178
+
1179
+ export default KnowledgeFlowRunner;