@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.
- package/.github/workflows/release-please.yml +13 -1
- package/.github/workflows/runtime-compat.yml +1 -1
- package/AGENTS.md +8 -1
- package/CHANGELOG.md +41 -0
- package/README.md +38 -19
- package/build/src/cli/flow-kit.js +9 -4
- package/build/src/cli/runtime-adapter.js +9 -5
- package/build/src/cli/telemetry-doctor.js +4 -1
- package/build/src/runtime-adapters.js +34 -0
- package/build/src/tools/build-universal-bundles.js +18 -1
- package/console.telemetry.json +115 -20
- package/docs/_layouts/default.html +2 -0
- package/docs/index.md +8 -0
- package/docs/integrations/index.md +4 -0
- package/docs/integrations/knowledge-kit-live.md +211 -0
- package/docs/kit-authoring-guide.md +169 -0
- package/docs/spec/runtime-hook-surface.md +56 -3
- package/evals/acceptance/run.sh +10 -1
- package/evals/acceptance/test_knowledge_kit_live.sh +221 -0
- package/evals/acceptance/test_pi_harness.sh +15 -0
- package/evals/integration/test_runtime_adapter_activation.sh +113 -1
- package/evals/static/test_universal_bundles.sh +10 -0
- package/integrations/strands/examples/knowledge_kit_live.py +461 -0
- package/integrations/strands/flow_agents_strands/steering.py +54 -1
- package/integrations/strands/tests/test_hooks.py +88 -0
- package/integrations/strands-ts/src/hooks.ts +104 -0
- package/integrations/strands-ts/test/test-steering.ts +159 -0
- package/kits/catalog.json +6 -0
- package/kits/knowledge/adapters/default-store/index.js +902 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1469 -0
- package/kits/knowledge/adapters/flow-runner/telemetry.js +174 -0
- package/kits/knowledge/adapters/similarity-vector/index.js +284 -0
- package/kits/knowledge/docs/README.md +328 -0
- package/kits/knowledge/docs/store-contract.md +650 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1234 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +675 -0
- package/kits/knowledge/evals/ingest-compile/suite.test.js +574 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1173 -0
- package/kits/knowledge/evals/similarity-vector/suite.test.js +685 -0
- package/kits/knowledge/evals/synthesis/suite.test.js +916 -0
- package/kits/knowledge/flows/compile.flow.json +60 -0
- package/kits/knowledge/flows/consolidate.flow.json +77 -0
- package/kits/knowledge/flows/ingest.flow.json +60 -0
- package/kits/knowledge/flows/retire.flow.json +77 -0
- package/kits/knowledge/flows/store-contract.flow.json +48 -0
- package/kits/knowledge/flows/synthesize.flow.json +77 -0
- package/kits/knowledge/kit.json +98 -0
- package/package.json +1 -1
- package/src/cli/flow-kit.ts +10 -4
- package/src/cli/runtime-adapter.ts +10 -5
- package/src/cli/telemetry-doctor.ts +4 -1
- package/src/runtime-adapters.ts +35 -0
- 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;
|