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