@kontourai/flow-agents 1.4.0 → 2.0.1

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 (184) hide show
  1. package/.github/CODEOWNERS +29 -0
  2. package/.github/actions/trust-verify/action.yml +145 -0
  3. package/.github/workflows/ci.yml +11 -4
  4. package/.github/workflows/kit-gates-demo.yml +2 -2
  5. package/.github/workflows/publish-npm.yml +10 -2
  6. package/.github/workflows/release-please.yml +1 -1
  7. package/.github/workflows/runtime-compat.yml +1 -1
  8. package/.github/workflows/trust-reconcile.yml +113 -0
  9. package/AGENTS.md +13 -0
  10. package/CHANGELOG.md +103 -0
  11. package/CONTRIBUTING.md +4 -4
  12. package/README.md +1 -0
  13. package/agents/tool-planner.json +1 -1
  14. package/build/src/cli/init.js +242 -20
  15. package/build/src/cli/validate-workflow-artifacts.js +19 -2
  16. package/build/src/cli/verify.d.ts +1 -0
  17. package/build/src/cli/verify.js +90 -0
  18. package/build/src/cli/workflow-sidecar.d.ts +316 -8
  19. package/build/src/cli/workflow-sidecar.js +1996 -91
  20. package/build/src/cli.js +2 -3
  21. package/build/src/lib/flow-resolver.d.ts +111 -0
  22. package/build/src/lib/flow-resolver.js +308 -0
  23. package/build/src/tools/build-universal-bundles.js +34 -22
  24. package/build/src/tools/generate-context-map.js +3 -16
  25. package/build/src/tools/validate-source-tree.d.ts +1 -1
  26. package/build/src/tools/validate-source-tree.js +42 -162
  27. package/context/contracts/artifact-contract.md +10 -0
  28. package/context/contracts/delivery-contract.md +1 -0
  29. package/context/contracts/review-contract.md +1 -0
  30. package/context/contracts/verification-contract.md +2 -0
  31. package/context/gate-awareness.md +39 -0
  32. package/context/scripts/hooks/stop-goal-fit.js +632 -70
  33. package/docs/adr/0001-flow-agents-consumes-flow.md +1 -1
  34. package/docs/adr/0002-flow-kits-as-extension-unit.md +1 -1
  35. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  36. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  37. package/docs/adr/0007-skill-audit.md +1 -1
  38. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +95 -0
  39. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +139 -0
  40. package/docs/adr/0011-mcp-posture.md +100 -0
  41. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +119 -0
  42. package/docs/adr/0013-context-lifecycle.md +151 -0
  43. package/docs/adr/0014-core-vs-domain-kit-boundary.md +143 -0
  44. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +120 -0
  45. package/docs/adr/0016-three-hard-boundary-model.md +71 -0
  46. package/docs/adr/0017-anti-gaming-trust-security-model.md +155 -0
  47. package/docs/agent-system-guidebook.md +5 -12
  48. package/docs/context-map.md +4 -10
  49. package/docs/index.md +3 -2
  50. package/docs/integrations/framework-adapter.md +19 -6
  51. package/docs/integrations/index.md +2 -2
  52. package/docs/north-star.md +4 -4
  53. package/docs/operating-layers.md +3 -3
  54. package/docs/plans/adr-0010-phase2-gate-recompute.md +55 -0
  55. package/docs/repository-structure.md +2 -2
  56. package/docs/skills-map.md +1 -0
  57. package/docs/spec/runtime-hook-surface.md +62 -9
  58. package/docs/standards-register.md +3 -3
  59. package/docs/survey-utterance-check.md +1 -1
  60. package/docs/trust-anchor-adoption.md +197 -0
  61. package/docs/verifiable-trust.md +95 -0
  62. package/docs/veritas-integration.md +2 -2
  63. package/docs/workflow-usage-guide.md +69 -0
  64. package/evals/acceptance/DEMO-false-completion.md +144 -0
  65. package/evals/acceptance/demo-cast.sh +92 -0
  66. package/evals/acceptance/demo-false-completion.sh +72 -0
  67. package/evals/acceptance/demo-real-evidence.sh +104 -0
  68. package/evals/acceptance/demo.tape +29 -0
  69. package/evals/acceptance/prove-capture-teeth-declared.sh +335 -0
  70. package/evals/acceptance/prove-capture-teeth.sh +114 -0
  71. package/evals/acceptance/prove-teeth.sh +105 -0
  72. package/evals/ci/antigaming-suite.sh +55 -0
  73. package/evals/ci/run-baseline.sh +2 -0
  74. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/flows/review.flow.json +26 -0
  75. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/kit.json +20 -0
  76. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/flows/review.flow.json +26 -0
  77. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/kit.json +18 -0
  78. package/evals/integration/test_builder_step_producers.sh +379 -0
  79. package/evals/integration/test_bundle_install.sh +35 -71
  80. package/evals/integration/test_bundle_lifecycle.sh +39 -2
  81. package/evals/integration/test_captured_fail_reconciliation.sh +820 -0
  82. package/evals/integration/test_checkpoint_signing.sh +489 -0
  83. package/evals/integration/test_claim_lookup.sh +352 -0
  84. package/evals/integration/test_command_log_fork_classification.sh +134 -0
  85. package/evals/integration/test_command_log_integrity.sh +275 -0
  86. package/evals/integration/test_context_map.sh +0 -2
  87. package/evals/integration/test_dual_emit_flow_step.sh +278 -0
  88. package/evals/integration/test_enforcer_expects_driven.sh +281 -0
  89. package/evals/integration/test_evidence_capture_hook.sh +185 -0
  90. package/evals/integration/test_flow_kit_repository.sh +2 -0
  91. package/evals/integration/test_flowdef_session_activation.sh +273 -0
  92. package/evals/integration/test_flowdef_session_history_preservation.sh +250 -0
  93. package/evals/integration/test_gate_bypass_chain.sh +448 -0
  94. package/evals/integration/test_gate_lockdown.sh +1137 -0
  95. package/evals/integration/test_gate_review_inquiry_records.sh +399 -0
  96. package/evals/integration/test_goal_fit_escape_hatch.sh +73 -0
  97. package/evals/integration/test_goal_fit_hook.sh +69 -4
  98. package/evals/integration/test_goal_fit_rederive.sh +263 -0
  99. package/evals/integration/test_install_merge.sh +1176 -0
  100. package/evals/integration/test_kit_identity_trust.sh +393 -0
  101. package/evals/integration/test_mint_attestation.sh +373 -0
  102. package/evals/integration/test_phase_map_and_gate_claim.sh +365 -0
  103. package/evals/integration/test_publish_delivery.sh +269 -0
  104. package/evals/integration/test_reconcile_soundness.sh +528 -0
  105. package/evals/integration/test_resolvefirststep_security.sh +208 -0
  106. package/evals/integration/test_session_resume_roundtrip.sh +286 -0
  107. package/evals/integration/test_trust_checkpoint.sh +325 -0
  108. package/evals/integration/test_trust_reconcile.sh +293 -0
  109. package/evals/integration/test_verify_cli.sh +208 -0
  110. package/evals/integration/test_workflow_sidecar_writer.sh +549 -34
  111. package/evals/lib/node.sh +0 -6
  112. package/evals/run.sh +47 -0
  113. package/evals/static/test_workflow_skills.sh +6 -13
  114. package/install.sh +0 -7
  115. package/integrations/strands-ts/README.md +25 -15
  116. package/integrations/veritas/flow-agents.adapter.json +1 -2
  117. package/kits/builder/flows/build.flow.json +59 -12
  118. package/kits/builder/kit.json +85 -15
  119. package/kits/builder/skills/continue-work/SKILL.md +116 -0
  120. package/kits/builder/skills/deliver/SKILL.md +36 -6
  121. package/kits/builder/skills/design-probe/SKILL.md +28 -0
  122. package/kits/builder/skills/execute-plan/SKILL.md +9 -1
  123. package/kits/builder/skills/gate-review/SKILL.md +234 -0
  124. package/kits/builder/skills/learning-review/SKILL.md +30 -0
  125. package/kits/builder/skills/pickup-probe/SKILL.md +29 -0
  126. package/kits/builder/skills/plan-work/SKILL.md +13 -1
  127. package/kits/builder/skills/pull-work/SKILL.md +19 -0
  128. package/kits/knowledge/adapters/default-store/index.js +38 -0
  129. package/kits/knowledge/adapters/flow-runner/index.js +1620 -0
  130. package/kits/knowledge/adapters/obsidian-store/index.js +36 -6
  131. package/kits/knowledge/docs/store-contract.md +314 -0
  132. package/kits/knowledge/evals/audit-freshness/suite.test.js +368 -0
  133. package/kits/knowledge/evals/canonicalize-category/suite.test.js +383 -0
  134. package/kits/knowledge/evals/contract-suite/suite.test.js +111 -0
  135. package/kits/knowledge/evals/detect-contradictions/suite.test.js +324 -0
  136. package/kits/knowledge/evals/entities/suite.test.js +40 -0
  137. package/kits/knowledge/evals/glossary-sync/suite.test.js +416 -0
  138. package/kits/knowledge/evals/hygiene-review/suite.test.js +396 -0
  139. package/kits/knowledge/evals/retirement/suite.test.js +145 -0
  140. package/kits/knowledge/flows/audit-freshness.flow.json +44 -0
  141. package/kits/knowledge/flows/canonicalize-category.flow.json +44 -0
  142. package/kits/knowledge/flows/detect-contradictions.flow.json +44 -0
  143. package/kits/knowledge/flows/glossary-sync.flow.json +61 -0
  144. package/kits/knowledge/flows/hygiene-review.flow.json +43 -0
  145. package/kits/knowledge/kit.json +51 -1
  146. package/package.json +6 -6
  147. package/packaging/conformance/README.md +10 -2
  148. package/packaging/conformance/fixtures/evidence-capture--allow-records-command.json +29 -0
  149. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +29 -0
  150. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +30 -0
  151. package/packaging/conformance/fixtures/stop-goal-fit--block-mode.json +23 -0
  152. package/packaging/conformance/fixtures/stop-goal-fit--off-mode.json +24 -0
  153. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +5 -2
  154. package/packaging/conformance/fixtures/stop-goal-fit--warn-no-bundle.json +23 -0
  155. package/packaging/conformance/fixtures/workflow-steering--reground-active-prompt.json +30 -0
  156. package/packaging/conformance/fixtures/workflow-steering--reground-session-start.json +30 -0
  157. package/packaging/conformance/run-conformance.js +1 -1
  158. package/scripts/README.md +2 -1
  159. package/scripts/build-universal-bundles.js +0 -1
  160. package/scripts/ci/mint-attestation.js +221 -0
  161. package/scripts/ci/trust-reconcile.js +545 -0
  162. package/scripts/hooks/config-protection.js +423 -1
  163. package/scripts/hooks/evidence-capture.js +348 -0
  164. package/scripts/hooks/lib/liveness-read.js +113 -0
  165. package/scripts/hooks/run-hook.js +6 -1
  166. package/scripts/hooks/stop-goal-fit.js +1524 -79
  167. package/scripts/hooks/workflow-steering.js +135 -5
  168. package/scripts/install-codex-home.sh +39 -0
  169. package/scripts/install-merge.js +330 -0
  170. package/scripts/repair-command-log.js +115 -0
  171. package/src/cli/init.ts +218 -20
  172. package/src/cli/validate-workflow-artifacts.ts +18 -2
  173. package/src/cli/verify.ts +100 -0
  174. package/src/cli/workflow-sidecar.ts +2127 -84
  175. package/src/cli.ts +2 -3
  176. package/src/lib/flow-resolver.ts +369 -0
  177. package/src/tools/build-universal-bundles.ts +34 -21
  178. package/src/tools/generate-context-map.ts +3 -17
  179. package/src/tools/validate-source-tree.ts +44 -104
  180. package/build/src/tools/filter-installed-packs.d.ts +0 -2
  181. package/build/src/tools/filter-installed-packs.js +0 -135
  182. package/packaging/packs.json +0 -49
  183. package/scripts/filter-installed-packs.js +0 -2
  184. package/src/tools/filter-installed-packs.ts +0 -132
@@ -52,6 +52,209 @@ function missingEvidenceError(message) {
52
52
  return err;
53
53
  }
54
54
 
55
+ // ---------------------------------------------------------------------------
56
+ // Freshness-audit helpers (knowledge.audit-freshness — #106)
57
+ // ---------------------------------------------------------------------------
58
+
59
+ /**
60
+ * The authoritative "last mutation" instant for a record: the most recent of
61
+ * the record's `updated_at` and its latest `mutation_log` entry `at`. Both are
62
+ * refreshed by every mutating op (store contract §1.1 / §4.2); taking the later
63
+ * of the two is robust even if an adapter lags one behind the other. Falls back
64
+ * to `created_at` when neither is present.
65
+ *
66
+ * @param {object} record
67
+ * @returns {string} ISO-8601 timestamp
68
+ */
69
+ function lastMutationOf(record) {
70
+ const candidates = [];
71
+ if (record.updated_at) candidates.push(record.updated_at);
72
+ const log = Array.isArray(record.mutation_log) ? record.mutation_log : [];
73
+ for (const entry of log) {
74
+ if (entry && entry.at) candidates.push(entry.at);
75
+ }
76
+ if (candidates.length === 0) return record.created_at || record.updated_at || "";
77
+ // Lexicographic max works for ISO-8601 UTC timestamps.
78
+ return candidates.reduce((max, t) => (t > max ? t : max));
79
+ }
80
+
81
+ /**
82
+ * Resolve the staleness threshold for a category using dot-hierarchy
83
+ * longest-prefix matching: a record in `radar.signals.weak` prefers a
84
+ * `radar.signals` threshold over a `radar` one, then falls back to
85
+ * `defaultThresholdDays`. Returns `null` when no threshold applies (the
86
+ * category is then skipped — auditing is opt-in).
87
+ *
88
+ * @param {string} category
89
+ * @param {Record<string, number>} thresholds
90
+ * @param {number|null} defaultThresholdDays
91
+ * @returns {{ thresholdDays: number, matchedKey: string } | null}
92
+ */
93
+ function resolveThreshold(category, thresholds, defaultThresholdDays) {
94
+ const segments = (category || "").split(".");
95
+ for (let i = segments.length; i > 0; i -= 1) {
96
+ const key = segments.slice(0, i).join(".");
97
+ if (Object.prototype.hasOwnProperty.call(thresholds, key)) {
98
+ return { thresholdDays: thresholds[key], matchedKey: key };
99
+ }
100
+ }
101
+ if (defaultThresholdDays !== null && defaultThresholdDays !== undefined) {
102
+ return { thresholdDays: defaultThresholdDays, matchedKey: "*" };
103
+ }
104
+ return null;
105
+ }
106
+
107
+ /**
108
+ * Resolve the proposed action ("archive" | "refresh") for a flagged record's
109
+ * category, using the same dot-hierarchy longest-prefix matching, falling back
110
+ * to `defaultAction`.
111
+ *
112
+ * @param {string} category
113
+ * @param {Record<string, "archive"|"refresh">} actions
114
+ * @param {"archive"|"refresh"} defaultAction
115
+ * @returns {"archive"|"refresh"}
116
+ */
117
+ function resolveAction(category, actions, defaultAction) {
118
+ const segments = (category || "").split(".");
119
+ for (let i = segments.length; i > 0; i -= 1) {
120
+ const key = segments.slice(0, i).join(".");
121
+ if (Object.prototype.hasOwnProperty.call(actions, key)) {
122
+ return actions[key];
123
+ }
124
+ }
125
+ return defaultAction;
126
+ }
127
+
128
+ // ---------------------------------------------------------------------------
129
+ // Glossary-sync helpers (knowledge.glossary-sync — #106)
130
+ // ---------------------------------------------------------------------------
131
+
132
+ /**
133
+ * Normalize a glossary term for comparison/matching: trimmed, collapsed inner
134
+ * whitespace, lower-cased. Concept lookup is case/space-insensitive on the term
135
+ * so "API Gateway", "api gateway", and "Api Gateway" all resolve to the same
136
+ * concept — terms are identity, not prose.
137
+ *
138
+ * @param {string} term
139
+ * @returns {string}
140
+ */
141
+ function normalizeTerm(term) {
142
+ return String(term || "").trim().replace(/\s+/g, " ").toLowerCase();
143
+ }
144
+
145
+ /**
146
+ * Compare a canonical definition against an existing concept body for the
147
+ * purpose of staleness detection. Whitespace-insensitive (leading/trailing +
148
+ * collapsed runs + trailing newline) so that cosmetic reflow is NOT treated as
149
+ * drift; any substantive difference is. Returns true when they are equivalent.
150
+ *
151
+ * @param {string} a
152
+ * @param {string} b
153
+ * @returns {boolean}
154
+ */
155
+ function definitionsEquivalent(a, b) {
156
+ const norm = (s) => String(s || "").trim().replace(/\s+/g, " ");
157
+ return norm(a) === norm(b);
158
+ }
159
+
160
+ /**
161
+ * Default term extractor: parse glossary-style entries out of a canonical doc's
162
+ * body. Pluggable — a caller may pass their own `termExtractor(doc) => entries`
163
+ * (e.g. to read a structured front-matter glossary). The default recognizes the
164
+ * two most common hand-written glossary line shapes, one entry per line:
165
+ *
166
+ * - **Term** — definition (bold term, em/en-dash or hyphen separator)
167
+ * - Term: definition (leading term, colon separator)
168
+ * - - **Term**: definition (markdown list item, either separator)
169
+ *
170
+ * A term must be 1–80 chars and a definition non-empty; lines that don't match
171
+ * are ignored (prose between entries is not a term). Duplicate terms within one
172
+ * doc keep the FIRST definition (canonical-doc order is authoritative).
173
+ *
174
+ * @param {object} doc a canonical-source record ({ id, title, body, category, ... })
175
+ * @returns {Array<{ term: string, definition: string }>}
176
+ */
177
+ function defaultTermExtractor(doc) {
178
+ const body = String(doc?.body || "");
179
+ const out = [];
180
+ const seen = new Set();
181
+ // Separator: em-dash, en-dash, or hyphen surrounded by spaces, OR a colon.
182
+ const SEP = "(?:\\s+[—–-]\\s+|:\\s+)";
183
+ // Bold term: **Term** SEP definition (optionally a leading list marker)
184
+ const boldRe = new RegExp(`^\\s*(?:[-*]\\s+)?\\*\\*(.+?)\\*\\*${SEP}(.+)$`);
185
+ // Plain term: Term: definition (colon only, to avoid eating prose dashes)
186
+ const plainRe = /^\s*(?:[-*]\s+)?([^:\n*][^:\n]{0,79}?):\s+(.+)$/;
187
+ for (const rawLine of body.split("\n")) {
188
+ const line = rawLine.replace(/\s+$/, "");
189
+ if (!line.trim()) continue;
190
+ let m = boldRe.exec(line);
191
+ if (!m) m = plainRe.exec(line);
192
+ if (!m) continue;
193
+ const term = m[1].trim();
194
+ const definition = m[2].trim();
195
+ if (!term || term.length > 80 || !definition) continue;
196
+ const key = normalizeTerm(term);
197
+ if (seen.has(key)) continue;
198
+ seen.add(key);
199
+ out.push({ term, definition });
200
+ }
201
+ return out;
202
+ }
203
+
204
+ // ---------------------------------------------------------------------------
205
+ // Category-canonicalization helpers (knowledge.canonicalize-category — #106)
206
+ // ---------------------------------------------------------------------------
207
+
208
+ /**
209
+ * The set of distinct dot-prefixes a category contributes, from its own full
210
+ * path down to (but NOT including) the empty root. `radar.signals.weak` yields
211
+ * `["radar", "radar.signals", "radar.signals.weak"]`. Used to build the
212
+ * category tree (which prefixes are *nodes*) and to find which prefixes hold a
213
+ * record directly vs. only via descendants.
214
+ *
215
+ * @param {string} category
216
+ * @returns {string[]}
217
+ */
218
+ function categoryPrefixes(category) {
219
+ const segments = (category || "").split(".").filter(Boolean);
220
+ const out = [];
221
+ for (let i = 1; i <= segments.length; i += 1) {
222
+ out.push(segments.slice(0, i).join("."));
223
+ }
224
+ return out;
225
+ }
226
+
227
+ /**
228
+ * The immediate parent prefix of a category, or "" for a top-level category.
229
+ * `radar.signals.weak` → `radar.signals`; `radar` → "".
230
+ *
231
+ * @param {string} category
232
+ * @returns {string}
233
+ */
234
+ function parentPrefix(category) {
235
+ const segments = (category || "").split(".").filter(Boolean);
236
+ if (segments.length <= 1) return "";
237
+ return segments.slice(0, -1).join(".");
238
+ }
239
+
240
+ /**
241
+ * Does a record count as "implemented-but-active" sprawl? A record is flagged
242
+ * when its status is still `"active"` yet it carries one of the operator-
243
+ * supplied "implemented" marker tags (e.g. `implemented`, `shipped`, `done`) —
244
+ * it should have transitioned to `implemented`/`retired` via `retire` but never
245
+ * did, so it lingers in the working set. Marker matching is case-insensitive.
246
+ *
247
+ * @param {object} record
248
+ * @param {Set<string>} markerSet lower-cased implemented-marker tags
249
+ * @returns {boolean}
250
+ */
251
+ function isImplementedButActive(record, markerSet) {
252
+ if (markerSet.size === 0) return false;
253
+ if ((record.status || "active") !== "active") return false;
254
+ const tags = Array.isArray(record.tags) ? record.tags : [];
255
+ return tags.some((t) => markerSet.has(String(t).toLowerCase()));
256
+ }
257
+
55
258
  // ---------------------------------------------------------------------------
56
259
  // Classification heuristics
57
260
  // ---------------------------------------------------------------------------
@@ -148,6 +351,111 @@ export async function defaultSimilarityDetector(concept, candidates, store) {
148
351
  return similar;
149
352
  }
150
353
 
354
+ // ===========================================================================
355
+ // Contradiction detection — pluggable interface (knowledge.detect-contradictions — #106)
356
+ // ===========================================================================
357
+
358
+ /**
359
+ * Default contradiction detector: opposing-polarity heuristic.
360
+ *
361
+ * ContradictionDetector interface (mirrors SimilarityDetector — R3):
362
+ * (recordA: Record, recordB: Record) => null | { reason: string }
363
+ * Return `null` when the two records' assertions do NOT conflict, or an object
364
+ * carrying a human-readable `reason` when they DO. May be async.
365
+ *
366
+ * The two records passed in are already known to be *about the same thing* (the
367
+ * caller scopes comparisons to a category and to records the similarity adapter
368
+ * deems similar). The detector's only job is to decide whether their assertions
369
+ * conflict — never to re-judge subject overlap.
370
+ *
371
+ * Default heuristic (v1, deliberately conservative):
372
+ * Detects opposing polarity over a shared subject. Each record's body is split
373
+ * into clauses; each clause is reduced to its set of salient content tokens
374
+ * (stop-words and the negation tokens stripped) and tagged affirmative or
375
+ * negative by whether it carries a negation. A contradiction is reported when
376
+ * one record AFFIRMS a clause whose content tokens contain those of a clause
377
+ * the other record NEGATES (e.g. "use REST for the public API" vs "do not use
378
+ * REST"). Token-containment (not exact equality) is used so trailing detail on
379
+ * one side does not hide the conflict. Returns the conflicting phrase as the
380
+ * reason.
381
+ *
382
+ * This is intentionally simple and replaceable — the issue calls for a
383
+ * *pluggable* contradiction fn precisely because real contradiction detection is
384
+ * domain-sensitive (an embedding/NLI model is the obvious upgrade, injected the
385
+ * same way the vector similarity adapter is).
386
+ *
387
+ * @param {object} recordA
388
+ * @param {object} recordB
389
+ * @returns {{ reason: string } | null}
390
+ */
391
+ const NEGATION_RE = /\b(?:not|never|no longer|don't|do not|doesn't|does not|isn't|is not|won't|will not|cannot|can't|avoid|stop|deprecated?|disallow(?:ed)?|forbidden?)\b/;
392
+
393
+ const STOP_WORDS = new Set([
394
+ "we", "i", "you", "they", "it", "the", "a", "an", "should", "must", "will",
395
+ "shall", "to", "please", "for", "of", "and", "or", "is", "are", "be", "this",
396
+ "that", "with", "as", "on", "in", "at", "by", "all", "any", "our", "their",
397
+ ]);
398
+
399
+ /**
400
+ * Reduce a record body into clauses, each tagged affirmative/negative by whether
401
+ * it carries a negation, with salient content tokens (stop-words + negation
402
+ * tokens stripped). Returned as { affirmed: Clause[], negated: Clause[] } where
403
+ * a Clause is { tokens: Set<string>, phrase: string }.
404
+ */
405
+ function assertionClauses(body) {
406
+ const affirmed = [];
407
+ const negated = [];
408
+ for (const raw of String(body || "").toLowerCase().split(/[.;,\n!?]+/)) {
409
+ const clause = raw.trim();
410
+ if (!clause) continue;
411
+ const isNegated = NEGATION_RE.test(clause);
412
+ const phrase = clause.replace(NEGATION_RE, " ").replace(/\s+/g, " ").trim();
413
+ const tokens = new Set(
414
+ phrase
415
+ .replace(/[^a-z0-9 ]+/g, " ")
416
+ .split(/\s+/)
417
+ .filter((t) => t.length > 1 && !STOP_WORDS.has(t))
418
+ );
419
+ if (tokens.size === 0) continue;
420
+ (isNegated ? negated : affirmed).push({ tokens, phrase });
421
+ }
422
+ return { affirmed, negated };
423
+ }
424
+
425
+ // Every token of `inner` is present in `outer` (subset containment).
426
+ function tokensContain(outer, inner) {
427
+ if (inner.size === 0) return false;
428
+ for (const t of inner) if (!outer.has(t)) return false;
429
+ return true;
430
+ }
431
+
432
+ export function defaultContradictionDetector(recordA, recordB) {
433
+ const a = assertionClauses(recordA.body);
434
+ const b = assertionClauses(recordB.body);
435
+
436
+ // A contradicts B when one AFFIRMS a clause whose content tokens contain those
437
+ // of a clause the other NEGATES (token-containment, either direction).
438
+ for (const aff of a.affirmed) {
439
+ for (const neg of b.negated) {
440
+ if (tokensContain(aff.tokens, neg.tokens)) {
441
+ return {
442
+ reason: `opposing assertions over "${neg.phrase}": ${recordA.id} affirms, ${recordB.id} negates`,
443
+ };
444
+ }
445
+ }
446
+ }
447
+ for (const aff of b.affirmed) {
448
+ for (const neg of a.negated) {
449
+ if (tokensContain(aff.tokens, neg.tokens)) {
450
+ return {
451
+ reason: `opposing assertions over "${neg.phrase}": ${recordB.id} affirms, ${recordA.id} negates`,
452
+ };
453
+ }
454
+ }
455
+ }
456
+ return null;
457
+ }
458
+
151
459
  // ---------------------------------------------------------------------------
152
460
  // KnowledgeFlowRunner
153
461
  // ---------------------------------------------------------------------------
@@ -1117,6 +1425,55 @@ export class KnowledgeFlowRunner {
1117
1425
  };
1118
1426
  }
1119
1427
 
1428
+ // -------------------------------------------------------------------------
1429
+ // close-proposal primitive (#106)
1430
+ //
1431
+ // A flow that gates a change through propose→apply mints a transient proposal
1432
+ // *artifact* record (the proposer) solely to carry the "proposes" link + the
1433
+ // proposal text. Once the proposal is APPLIED, that artifact is spent — it
1434
+ // should not linger as an `active` record. Left active it shows up in
1435
+ // working-set queries and hygiene sweeps, and re-retiring it spawns a
1436
+ // double-prefixed "Retirement proposal: Retirement proposal: …" twin (#106).
1437
+ //
1438
+ // This closes a spent proposal artifact by retiring it via the store's
1439
+ // existing `retire` op — no parallel mechanism. It is:
1440
+ // - safe: only touches the named artifact; never the apply target.
1441
+ // - idempotent: a no-op if the artifact is already retired/implemented.
1442
+ // - non-fatal: a close failure never fails the (already-applied) flow.
1443
+ // -------------------------------------------------------------------------
1444
+
1445
+ /**
1446
+ * Auto-retire a spent proposal artifact after its proposal has been applied.
1447
+ *
1448
+ * @param {string} artifactId - ID of the transient proposer record to close.
1449
+ * @param {object} [opts]
1450
+ * - agent: string — agent recording the close (defaults to runner agent)
1451
+ * - rationale: string — close rationale (mutation-log evidence)
1452
+ * @returns {Promise<{ closed: boolean, reason?: string }>}
1453
+ */
1454
+ async _closeProposalArtifact(artifactId, opts = {}) {
1455
+ const agent = opts.agent || this._agent;
1456
+ const artifact = await this._store.get(artifactId);
1457
+ // Already gone or already closed — nothing to do (idempotent).
1458
+ if (!artifact) return { closed: false, reason: "artifact not found" };
1459
+ if ((artifact.status || "active") === "retired") {
1460
+ return { closed: false, reason: "already retired" };
1461
+ }
1462
+ try {
1463
+ await this._store.retire(artifactId, "retired", {
1464
+ agent,
1465
+ rationale:
1466
+ opts.rationale ||
1467
+ "Auto-closing spent proposal artifact after the proposal was applied (#106).",
1468
+ });
1469
+ return { closed: true };
1470
+ } catch (err) {
1471
+ // The proposal was already applied successfully; a failure to tidy up the
1472
+ // artifact must not fail the flow. Surface it on the result instead.
1473
+ return { closed: false, reason: err.message };
1474
+ }
1475
+ }
1476
+
1120
1477
  // -------------------------------------------------------------------------
1121
1478
  // knowledge.retire flow (Addendum B — S7)
1122
1479
  // Steps: identify → propose-retirement → evidence-gate → apply-or-reject → done
@@ -1344,6 +1701,7 @@ export class KnowledgeFlowRunner {
1344
1701
  });
1345
1702
  events.push(applyGateIn);
1346
1703
 
1704
+ let proposalClosed = false;
1347
1705
  if (decision === "apply") {
1348
1706
  // Apply via store retire op — transitions status, appends mutation log (AC1)
1349
1707
  await this._store.retire(recordId, targetStatus, {
@@ -1353,6 +1711,13 @@ export class KnowledgeFlowRunner {
1353
1711
  ...(options.supersededByRef ? { supersededByRef: options.supersededByRef } : {}),
1354
1712
  ...(options.note ? { note: options.note } : {}),
1355
1713
  });
1714
+ // Close the spent retirement-proposal artifact so it does not linger as an
1715
+ // active record (which would spawn a double-prefixed twin on re-retire). #106
1716
+ const closeResult = await this._closeProposalArtifact(proposerId, {
1717
+ agent,
1718
+ rationale: `Auto-closing retirement-proposal artifact after retiring ${recordId} (#106).`,
1719
+ });
1720
+ proposalClosed = closeResult.closed;
1356
1721
  } else if (decision === "reject") {
1357
1722
  if (!options.rejectReason || !options.rejectReason.trim()) {
1358
1723
  throw missingEvidenceError(
@@ -1385,6 +1750,1193 @@ export class KnowledgeFlowRunner {
1385
1750
  decision,
1386
1751
  previousStatus,
1387
1752
  proposerId,
1753
+ proposalClosed,
1754
+ telemetryEvents: events,
1755
+ };
1756
+ }
1757
+
1758
+
1759
+ // -------------------------------------------------------------------------
1760
+ // knowledge.audit-freshness flow (hygiene #1 — #106)
1761
+ // Steps: collect → measure → flag-gate → done
1762
+ // Gate: flag-gate — every flag cites its evidence (last-mutation + the
1763
+ // threshold that fired). No flag is emitted without both.
1764
+ //
1765
+ // This is a READ-ONLY audit. It NEVER mutates a record. It surveys the
1766
+ // working set, measures each record's age against a per-category staleness
1767
+ // threshold, and returns flags proposing an action (archive / refresh). The
1768
+ // operator routes each flag through the existing gated flows (knowledge.retire
1769
+ // to archive; a fresh capture/compile to refresh) — the audit forks no new
1770
+ // mutation path. Staleness is domain-sensitive (radar ≠ decisions), so the
1771
+ // thresholds are OPTIONAL and CONFIGURABLE per category; a category with no
1772
+ // threshold (and no default) is simply not audited (opt-in).
1773
+ // -------------------------------------------------------------------------
1774
+
1775
+ /**
1776
+ * Audit the working set for records past their per-category staleness
1777
+ * threshold and propose archive/refresh for each. Read-only: mutates nothing.
1778
+ *
1779
+ * Threshold resolution is dot-hierarchy longest-prefix: a record in
1780
+ * `radar.signals.weak` matches a `radar.signals` threshold before a `radar`
1781
+ * one, falling back to `defaultThresholdDays` if neither is configured. A
1782
+ * category that resolves to no threshold is skipped (opt-in auditing).
1783
+ *
1784
+ * "Last mutation" is the most recent of the record's `updated_at` and the
1785
+ * latest `mutation_log` entry `at` — both are refreshed by every mutating op
1786
+ * (store contract §1.1 / §4.2), so the later of the two is authoritative even
1787
+ * if an adapter lags one.
1788
+ *
1789
+ * @param {object} [options]
1790
+ * - thresholds: { [category: string]: number } — per-category staleness in days
1791
+ * - defaultThresholdDays: number — fallback for unmatched categories (default: none → skip)
1792
+ * - actions: { [category: string]: "archive"|"refresh" } — per-category proposed action override
1793
+ * - defaultAction: "archive"|"refresh" — fallback proposed action (default "refresh")
1794
+ * - types: string[] — record types to audit (default ["raw","compiled","concept","snapshot"])
1795
+ * - now: string|number|Date — reference "now" for age (default: current time; injectable for tests)
1796
+ * - agent: string — override agent name
1797
+ * @returns {Promise<{
1798
+ * audited: number,
1799
+ * skipped: number,
1800
+ * flags: Array<{
1801
+ * recordId: string, title: string, type: string, category: string,
1802
+ * status: string, lastMutationAt: string, ageDays: number,
1803
+ * thresholdDays: number, matchedThresholdKey: string,
1804
+ * proposedAction: "archive"|"refresh"
1805
+ * }>,
1806
+ * telemetryEvents: object[]
1807
+ * }>}
1808
+ */
1809
+ async auditFreshness(options = {}) {
1810
+ const events = [];
1811
+ const agent = options.agent || this._agent;
1812
+
1813
+ const thresholds = options.thresholds || {};
1814
+ const defaultThresholdDays =
1815
+ typeof options.defaultThresholdDays === "number"
1816
+ ? options.defaultThresholdDays
1817
+ : null;
1818
+ const actions = options.actions || {};
1819
+ const defaultAction = options.defaultAction || "refresh";
1820
+ if (defaultAction !== "archive" && defaultAction !== "refresh") {
1821
+ throw missingEvidenceError(
1822
+ `audit-freshness: defaultAction must be "archive" or "refresh"; got: ${defaultAction}`
1823
+ );
1824
+ }
1825
+ const types = Array.isArray(options.types) && options.types.length
1826
+ ? options.types
1827
+ : ["raw", "compiled", "concept", "snapshot"];
1828
+
1829
+ const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
1830
+ if (Number.isNaN(nowMs)) {
1831
+ throw missingEvidenceError(`audit-freshness: invalid "now" reference: ${options.now}`);
1832
+ }
1833
+
1834
+ // ── Step: collect ──────────────────────────────────────────────────────
1835
+ // Gather the working set (default queries already exclude retired records —
1836
+ // retired is terminal, so there is nothing to flag there).
1837
+ const collectGateIn = this._telemetry.emitGate("knowledge.audit-freshness", "collect-gate", {
1838
+ flow: "knowledge.audit-freshness",
1839
+ gate: "collect-gate",
1840
+ types,
1841
+ threshold_categories: Object.keys(thresholds),
1842
+ default_threshold_days: defaultThresholdDays,
1843
+ });
1844
+ events.push(collectGateIn);
1845
+
1846
+ const seen = new Set();
1847
+ const records = [];
1848
+ for (const type of types) {
1849
+ const recs = await this._store.listByType(type);
1850
+ for (const r of recs) {
1851
+ if (seen.has(r.id)) continue;
1852
+ seen.add(r.id);
1853
+ records.push(r);
1854
+ }
1855
+ }
1856
+
1857
+ const collectGateOut = this._telemetry.emitGateResult("knowledge.audit-freshness", "collect-gate", {
1858
+ collected: records.length,
1859
+ });
1860
+ events.push(collectGateOut);
1861
+
1862
+ // ── Step: measure + flag-gate ──────────────────────────────────────────
1863
+ const flagGateIn = this._telemetry.emitGate("knowledge.audit-freshness", "flag-gate", {
1864
+ flow: "knowledge.audit-freshness",
1865
+ gate: "flag-gate",
1866
+ collected: records.length,
1867
+ });
1868
+ events.push(flagGateIn);
1869
+
1870
+ const flags = [];
1871
+ let skipped = 0;
1872
+ let audited = 0;
1873
+
1874
+ for (const record of records) {
1875
+ const resolved = resolveThreshold(record.category, thresholds, defaultThresholdDays);
1876
+ if (resolved === null) {
1877
+ // No threshold configured for this category (and no default) → opt out.
1878
+ skipped += 1;
1879
+ continue;
1880
+ }
1881
+ audited += 1;
1882
+
1883
+ const lastMutationAt = lastMutationOf(record);
1884
+ const lastMs = new Date(lastMutationAt).getTime();
1885
+ // ageDays floored to whole days; an unparseable timestamp is treated as 0.
1886
+ const ageDays = Number.isNaN(lastMs)
1887
+ ? 0
1888
+ : Math.floor((nowMs - lastMs) / 86_400_000);
1889
+
1890
+ // Flag only when STRICTLY past the threshold. Evidence (last-mutation +
1891
+ // the threshold key/value that fired) is carried on every flag — the
1892
+ // flag-gate requires both, so a flag can never be emitted without them.
1893
+ if (ageDays > resolved.thresholdDays) {
1894
+ const proposedAction =
1895
+ resolveAction(record.category, actions, defaultAction);
1896
+ flags.push({
1897
+ recordId: record.id,
1898
+ title: record.title,
1899
+ type: record.type,
1900
+ category: record.category,
1901
+ status: record.status || "active",
1902
+ lastMutationAt,
1903
+ ageDays,
1904
+ thresholdDays: resolved.thresholdDays,
1905
+ matchedThresholdKey: resolved.matchedKey,
1906
+ proposedAction,
1907
+ });
1908
+ }
1909
+ }
1910
+
1911
+ const flagGateOut = this._telemetry.emitGateResult("knowledge.audit-freshness", "flag-gate", {
1912
+ audited,
1913
+ skipped,
1914
+ flagged: flags.length,
1915
+ agent,
1916
+ });
1917
+ events.push(flagGateOut);
1918
+
1919
+ return { audited, skipped, flags, telemetryEvents: events };
1920
+ }
1921
+
1922
+
1923
+ // -------------------------------------------------------------------------
1924
+ // knowledge.glossary-sync flow (hygiene #3 — #106)
1925
+ // Steps: collect → extract → diff-gate → propose-gate → done
1926
+ // Gate: diff-gate — every entry is classified (gap / outdated / current)
1927
+ // and cites its evidence: the canonical source doc,
1928
+ // the extracted term + definition, and (for outdated)
1929
+ // the existing concept's drifted body. No entry is
1930
+ // emitted without the source it came from.
1931
+ // propose-gate — when apply mode is on, every gap/outdated entry is
1932
+ // routed through the EXISTING concept-record
1933
+ // propose→apply ops (create → propose → apply), with
1934
+ // the canonical doc as the proposer (it is the
1935
+ // evidence). Forks no mutation path.
1936
+ //
1937
+ // Keeps the glossary (the working set of `concept` records) in sync with the
1938
+ // canonical docs that DEFINE those terms. The Kit can file concepts but had no
1939
+ // way to (a) promote a term that a canonical doc defines but no concept yet
1940
+ // captures (a GAP), or (b) notice when a concept's definition has DRIFTED from
1941
+ // its canonical source (OUT-OF-DATE). This flow surveys a CONFIGURABLE list of
1942
+ // canonical source docs (the "glossary source list" — opt-in; empty list does
1943
+ // nothing), extracts their term→definition entries with a PLUGGABLE extractor,
1944
+ // diffs them against existing concept records, and surfaces a plan.
1945
+ //
1946
+ // Read-only by DEFAULT (returns the classification + a proposal plan, mutating
1947
+ // nothing). With `apply: true` it consumes the existing store ops to enact the
1948
+ // plan — never a forked mutation path:
1949
+ // - gap → store.create(concept) then propose+apply from the source doc
1950
+ // - outdated → store.propose + store.apply on the existing concept
1951
+ // - current → no-op
1952
+ // -------------------------------------------------------------------------
1953
+
1954
+ /**
1955
+ * Sync the glossary (concept records) against a configurable list of canonical
1956
+ * source docs. Read-only by default; `apply: true` enacts the plan via the
1957
+ * existing concept-record propose→apply ops (consume-never-fork).
1958
+ *
1959
+ * Each canonical doc is parsed into term→definition entries (pluggable
1960
+ * `termExtractor`); each entry is classified against the existing concepts:
1961
+ * - "gap": no concept captures the term → propose a canonical definition
1962
+ * - "outdated": a concept exists but its body has drifted from the canonical
1963
+ * definition → propose the update
1964
+ * - "current": the concept already matches the canonical definition → no-op
1965
+ *
1966
+ * Matching is by normalized term (case/space-insensitive) within the resolved
1967
+ * concept category. Drift is whitespace-insensitive: cosmetic reflow is not
1968
+ * flagged, substantive change is.
1969
+ *
1970
+ * @param {object} [options]
1971
+ * - sources: Array<string | { category: string, prefix?: boolean }>
1972
+ * The CONFIGURABLE glossary source list. Each entry is either a record id
1973
+ * (a single canonical doc) or a category selector ({ category, prefix }).
1974
+ * Empty/absent → nothing is synced (opt-in).
1975
+ * - termExtractor: (doc) => Array<{ term, definition }>
1976
+ * Pluggable extractor; default parses glossary-style lines (defaultTermExtractor).
1977
+ * - conceptCategory: string
1978
+ * Category for matched/proposed concepts. Default: the source doc's own category.
1979
+ * - apply: boolean (default false)
1980
+ * false → read-only (returns the plan). true → enact via store ops.
1981
+ * - now / agent / session_id: passthrough provenance/telemetry.
1982
+ * @returns {Promise<{
1983
+ * sourcesAudited: number,
1984
+ * entries: number,
1985
+ * gaps: GlossaryEntry[],
1986
+ * outdated: GlossaryEntry[],
1987
+ * current: GlossaryEntry[],
1988
+ * applied: Array<{ term: string, conceptId: string, action: "create"|"update" }>,
1989
+ * telemetryEvents: object[]
1990
+ * }>}
1991
+ * where GlossaryEntry = { term, definition, sourceDocId, sourceDocTitle,
1992
+ * category, classification, conceptId?: string, currentBody?: string }
1993
+ */
1994
+ async glossarySync(options = {}) {
1995
+ const events = [];
1996
+ const agent = options.agent || this._agent;
1997
+ const sources = Array.isArray(options.sources) ? options.sources : [];
1998
+ const extractor =
1999
+ typeof options.termExtractor === "function"
2000
+ ? options.termExtractor
2001
+ : defaultTermExtractor;
2002
+ const applyMode = options.apply === true;
2003
+
2004
+ // ── Step: collect ────────────────────────────────────────────────────────
2005
+ // Resolve the configurable glossary source list to concrete canonical docs.
2006
+ // A source is either a record id or a { category, prefix } selector. Missing
2007
+ // ids are surfaced as an error (the source list is evidence — a typo must not
2008
+ // pass silently). Auditing is opt-in: an empty list collects nothing.
2009
+ const collectGateIn = this._telemetry.emitGate("knowledge.glossary-sync", "collect-gate", {
2010
+ flow: "knowledge.glossary-sync",
2011
+ gate: "collect-gate",
2012
+ source_count: sources.length,
2013
+ apply: applyMode,
2014
+ });
2015
+ events.push(collectGateIn);
2016
+
2017
+ const docs = [];
2018
+ const seenDocs = new Set();
2019
+ for (const source of sources) {
2020
+ let resolved = [];
2021
+ if (typeof source === "string") {
2022
+ const doc = await this._store.get(source);
2023
+ if (!doc) {
2024
+ throw missingEvidenceError(
2025
+ `glossary-sync: source doc not found: ${source}`
2026
+ );
2027
+ }
2028
+ resolved = [doc];
2029
+ } else if (source && typeof source === "object" && source.category) {
2030
+ resolved = await this._store.listByCategory(source.category, {
2031
+ prefix: source.prefix === true,
2032
+ });
2033
+ } else {
2034
+ throw missingEvidenceError(
2035
+ "glossary-sync: each source must be a record id or a { category } selector"
2036
+ );
2037
+ }
2038
+ for (const doc of resolved) {
2039
+ if (seenDocs.has(doc.id)) continue;
2040
+ seenDocs.add(doc.id);
2041
+ docs.push(doc);
2042
+ }
2043
+ }
2044
+
2045
+ const collectGateOut = this._telemetry.emitGateResult("knowledge.glossary-sync", "collect-gate", {
2046
+ sources_resolved: docs.length,
2047
+ });
2048
+ events.push(collectGateOut);
2049
+
2050
+ // ── Step: extract + diff-gate ────────────────────────────────────────────
2051
+ // Extract term→definition entries from each canonical doc and classify each
2052
+ // against the existing concept working set. Every classification cites the
2053
+ // source doc it came from — the diff-gate requires that evidence.
2054
+ const diffGateIn = this._telemetry.emitGate("knowledge.glossary-sync", "diff-gate", {
2055
+ flow: "knowledge.glossary-sync",
2056
+ gate: "diff-gate",
2057
+ sources_resolved: docs.length,
2058
+ });
2059
+ events.push(diffGateIn);
2060
+
2061
+ const allConcepts = await this._store.listByType("concept");
2062
+
2063
+ const gaps = [];
2064
+ const outdated = [];
2065
+ const current = [];
2066
+ let entryCount = 0;
2067
+
2068
+ for (const doc of docs) {
2069
+ const extracted = extractor(doc) || [];
2070
+ for (const { term, definition } of extracted) {
2071
+ if (!term || !definition) continue;
2072
+ entryCount += 1;
2073
+ const category = options.conceptCategory || doc.category;
2074
+ const normTerm = normalizeTerm(term);
2075
+ // A concept "captures" the term when its normalized title matches the
2076
+ // term within the resolved category (concepts are the glossary).
2077
+ const match = allConcepts.find(
2078
+ (c) =>
2079
+ c.category === category && normalizeTerm(c.title) === normTerm
2080
+ );
2081
+
2082
+ const base = {
2083
+ term,
2084
+ definition,
2085
+ sourceDocId: doc.id,
2086
+ sourceDocTitle: doc.title,
2087
+ category,
2088
+ };
2089
+
2090
+ if (!match) {
2091
+ gaps.push({ ...base, classification: "gap" });
2092
+ } else if (!definitionsEquivalent(match.body, definition)) {
2093
+ outdated.push({
2094
+ ...base,
2095
+ classification: "outdated",
2096
+ conceptId: match.id,
2097
+ currentBody: match.body,
2098
+ });
2099
+ } else {
2100
+ current.push({
2101
+ ...base,
2102
+ classification: "current",
2103
+ conceptId: match.id,
2104
+ });
2105
+ }
2106
+ }
2107
+ }
2108
+
2109
+ const diffGateOut = this._telemetry.emitGateResult("knowledge.glossary-sync", "diff-gate", {
2110
+ entries: entryCount,
2111
+ gaps: gaps.length,
2112
+ outdated: outdated.length,
2113
+ current: current.length,
2114
+ });
2115
+ events.push(diffGateOut);
2116
+
2117
+ // ── Step: propose-gate ───────────────────────────────────────────────────
2118
+ // Read-only by default: return the plan, mutate nothing. With apply=true,
2119
+ // enact each gap/outdated entry through the EXISTING concept-record ops —
2120
+ // the canonical source doc is the proposer (it is the evidence for the
2121
+ // definition), so no new mutation path is forked.
2122
+ const proposeGateIn = this._telemetry.emitGate("knowledge.glossary-sync", "propose-gate", {
2123
+ flow: "knowledge.glossary-sync",
2124
+ gate: "propose-gate",
2125
+ apply: applyMode,
2126
+ gaps: gaps.length,
2127
+ outdated: outdated.length,
2128
+ });
2129
+ events.push(proposeGateIn);
2130
+
2131
+ const applied = [];
2132
+ if (applyMode) {
2133
+ for (const entry of gaps) {
2134
+ // Create the concept (definition = canonical body), then record the
2135
+ // canonical doc's authorship through propose→apply so the lineage is the
2136
+ // same gated path every other concept mutation uses.
2137
+ const conceptId = await this._store.create({
2138
+ type: "concept",
2139
+ title: entry.term,
2140
+ body: entry.definition,
2141
+ category: entry.category,
2142
+ provenance: {
2143
+ agent,
2144
+ ...(options.session_id ? { session_id: options.session_id } : {}),
2145
+ source_ids: [entry.sourceDocId],
2146
+ note: `glossary-sync: canonical definition from ${entry.sourceDocId}`,
2147
+ },
2148
+ });
2149
+ await this._store.propose(conceptId, entry.sourceDocId, {
2150
+ agent,
2151
+ proposal: `Canonical definition for "${entry.term}" from ${entry.sourceDocTitle}.`,
2152
+ });
2153
+ await this._store.apply(conceptId, entry.sourceDocId, {
2154
+ agent,
2155
+ new_body: entry.definition,
2156
+ rationale: `glossary-sync: adopt canonical definition for "${entry.term}" from ${entry.sourceDocId}.`,
2157
+ });
2158
+ applied.push({ term: entry.term, conceptId, action: "create" });
2159
+ }
2160
+ for (const entry of outdated) {
2161
+ await this._store.propose(entry.conceptId, entry.sourceDocId, {
2162
+ agent,
2163
+ proposal: `Update "${entry.term}" to the canonical definition from ${entry.sourceDocTitle}.`,
2164
+ });
2165
+ await this._store.apply(entry.conceptId, entry.sourceDocId, {
2166
+ agent,
2167
+ new_body: entry.definition,
2168
+ rationale: `glossary-sync: refresh "${entry.term}" from canonical source ${entry.sourceDocId} (definition had drifted).`,
2169
+ });
2170
+ applied.push({ term: entry.term, conceptId: entry.conceptId, action: "update" });
2171
+ }
2172
+ }
2173
+
2174
+ const proposeGateOut = this._telemetry.emitGateResult("knowledge.glossary-sync", "propose-gate", {
2175
+ applied: applied.length,
2176
+ apply: applyMode,
2177
+ agent,
2178
+ });
2179
+ events.push(proposeGateOut);
2180
+
2181
+ return {
2182
+ sourcesAudited: docs.length,
2183
+ entries: entryCount,
2184
+ gaps,
2185
+ outdated,
2186
+ current,
2187
+ applied,
2188
+ telemetryEvents: events,
2189
+ };
2190
+ }
2191
+
2192
+
2193
+ // =========================================================================
2194
+ // knowledge.detect-contradictions flow (#106 hygiene #2)
2195
+ // Steps: collect → compare → flag-gate → done
2196
+ // Gates: collect-gate (comparison set), flag-gate (flags cite both ids)
2197
+ // =========================================================================
2198
+
2199
+ /**
2200
+ * Audit compiled records within a category for conflicting assertions and
2201
+ * flag each conflicting pair with BOTH record ids. Read-only: mutates nothing.
2202
+ *
2203
+ * Comparison is two-staged and reuses the existing pluggable adapters
2204
+ * (consume-never-fork):
2205
+ * 1. SCOPE with the similarity adapter — only records the detector deems
2206
+ * similar (about the same thing) are candidates for contradiction. This
2207
+ * reuses the exact SimilarityDetector interface that `synthesize` uses, so
2208
+ * the vector similarity adapter drops straight in.
2209
+ * 2. JUDGE with the pluggable contradiction fn — for each similar pair, the
2210
+ * ContradictionDetector decides whether the assertions conflict. The
2211
+ * default (defaultContradictionDetector) is an opposing-polarity heuristic;
2212
+ * callers inject a domain-specific fn (e.g. an NLI model) the same way.
2213
+ *
2214
+ * Which categories to audit is configurable and opt-in: a category is audited
2215
+ * only when it appears in `categories` (or `categories` is omitted, in which
2216
+ * case every category present in the collected compiled set is audited).
2217
+ * Retired records are never compared — the default `listByType` query excludes
2218
+ * them, and `retired` is terminal.
2219
+ *
2220
+ * @param {object} [options]
2221
+ * - categories: string[] — categories to audit (default: all present). Opt-in scoping.
2222
+ * - similarityDetector: fn — pluggable detector (same interface as synthesize R3); default category/link heuristic
2223
+ * - contradictionDetector: fn — pluggable (recordA, recordB) => null | { reason } ; default opposing-polarity heuristic
2224
+ * - agent: string — override agent name
2225
+ * @returns {Promise<{
2226
+ * audited: number,
2227
+ * compared: number,
2228
+ * flags: Array<{
2229
+ * recordIdA: string, recordIdB: string,
2230
+ * titleA: string, titleB: string,
2231
+ * category: string, reason: string
2232
+ * }>,
2233
+ * telemetryEvents: object[]
2234
+ * }>}
2235
+ */
2236
+ async detectContradictions(options = {}) {
2237
+ const events = [];
2238
+ const agent = options.agent || this._agent;
2239
+ const similarityDetector = options.similarityDetector || defaultSimilarityDetector;
2240
+ const contradictionDetector = options.contradictionDetector || defaultContradictionDetector;
2241
+ const categoryFilter =
2242
+ Array.isArray(options.categories) && options.categories.length
2243
+ ? new Set(options.categories)
2244
+ : null;
2245
+
2246
+ // ── Step: collect ────────────────────────────────────────────────────────
2247
+ // Gather compiled records grouped by category (default query excludes
2248
+ // retired records — retired is terminal, nothing to compare there).
2249
+ const collectGateIn = this._telemetry.emitGate(
2250
+ "knowledge.detect-contradictions",
2251
+ "collect-gate",
2252
+ {
2253
+ flow: "knowledge.detect-contradictions",
2254
+ gate: "collect-gate",
2255
+ categories: categoryFilter ? [...categoryFilter] : "all",
2256
+ }
2257
+ );
2258
+ events.push(collectGateIn);
2259
+
2260
+ const compiled = (await this._store.listByType("compiled")).filter(
2261
+ (r) => (r.status || "active") !== "retired"
2262
+ );
2263
+
2264
+ const byCategory = new Map();
2265
+ for (const record of compiled) {
2266
+ if (categoryFilter && !categoryFilter.has(record.category)) continue;
2267
+ if (!byCategory.has(record.category)) byCategory.set(record.category, []);
2268
+ byCategory.get(record.category).push(record);
2269
+ }
2270
+
2271
+ const collectGateOut = this._telemetry.emitGateResult(
2272
+ "knowledge.detect-contradictions",
2273
+ "collect-gate",
2274
+ {
2275
+ categories_audited: byCategory.size,
2276
+ collected: compiled.length,
2277
+ }
2278
+ );
2279
+ events.push(collectGateOut);
2280
+
2281
+ // ── Step: compare + flag-gate ────────────────────────────────────────────
2282
+ const flagGateIn = this._telemetry.emitGate(
2283
+ "knowledge.detect-contradictions",
2284
+ "flag-gate",
2285
+ {
2286
+ flow: "knowledge.detect-contradictions",
2287
+ gate: "flag-gate",
2288
+ categories_audited: byCategory.size,
2289
+ }
2290
+ );
2291
+ events.push(flagGateIn);
2292
+
2293
+ const flags = [];
2294
+ let audited = 0;
2295
+ let compared = 0;
2296
+ const seenPairs = new Set();
2297
+
2298
+ for (const [category, records] of byCategory) {
2299
+ audited += records.length;
2300
+ for (const record of records) {
2301
+ // Stage 1: scope to similar records via the similarity adapter. The
2302
+ // candidate set is the rest of this category's records.
2303
+ const candidates = records.filter((r) => r.id !== record.id);
2304
+ const similarIds = await similarityDetector(record, candidates, this._store);
2305
+ const similarSet = new Set(similarIds);
2306
+
2307
+ // Stage 2: judge each similar pair with the contradiction fn.
2308
+ for (const other of candidates) {
2309
+ if (!similarSet.has(other.id)) continue;
2310
+ // Compare each unordered pair exactly once.
2311
+ const pairKey =
2312
+ record.id < other.id ? `${record.id}|${other.id}` : `${other.id}|${record.id}`;
2313
+ if (seenPairs.has(pairKey)) continue;
2314
+ seenPairs.add(pairKey);
2315
+ compared += 1;
2316
+
2317
+ const verdict = await contradictionDetector(record, other, this._store);
2318
+ // A flag is emitted only with BOTH ids + a reason — the flag-gate
2319
+ // requires the evidence, so a flag can never be emitted without it.
2320
+ if (verdict && verdict.reason) {
2321
+ const [idA, idB, recA, recB] =
2322
+ record.id < other.id
2323
+ ? [record.id, other.id, record, other]
2324
+ : [other.id, record.id, other, record];
2325
+ flags.push({
2326
+ recordIdA: idA,
2327
+ recordIdB: idB,
2328
+ titleA: recA.title,
2329
+ titleB: recB.title,
2330
+ category,
2331
+ reason: verdict.reason,
2332
+ });
2333
+ }
2334
+ }
2335
+ }
2336
+ }
2337
+
2338
+ const flagGateOut = this._telemetry.emitGateResult(
2339
+ "knowledge.detect-contradictions",
2340
+ "flag-gate",
2341
+ {
2342
+ audited,
2343
+ compared,
2344
+ flagged: flags.length,
2345
+ agent,
2346
+ }
2347
+ );
2348
+ events.push(flagGateOut);
2349
+
2350
+ return { audited, compared, flags, telemetryEvents: events };
2351
+ }
2352
+
2353
+
2354
+ // -------------------------------------------------------------------------
2355
+ // knowledge.canonicalize-category flow (hygiene #4 — #106)
2356
+ // Steps: survey → assess → propose-gate → done
2357
+ // Gate: propose-gate — every category-sprawl finding cites its evidence
2358
+ // (the metric that fired + the offending category/record ids). No
2359
+ // finding is emitted without it.
2360
+ //
2361
+ // This is a READ-ONLY audit. It NEVER mutates a record. It surveys the
2362
+ // category hierarchy of the working set and flags three kinds of sprawl the
2363
+ // dogfooding surfaced (#106):
2364
+ // - orphan-prefix — an intermediate prefix node that holds NO record
2365
+ // directly yet has descendants, OR a prefix whose
2366
+ // whole subtree is a single leaf record (a deep path
2367
+ // carrying one record adds depth without branching
2368
+ // value). Proposes flattening to the parent.
2369
+ // - too-many-leaves — a parent prefix with more direct child leaf
2370
+ // categories than the configured fan-out budget.
2371
+ // Proposes regrouping under fewer leaves.
2372
+ // - implemented-active — a record still status:"active" but carrying an
2373
+ // operator-supplied "implemented" marker tag; it
2374
+ // should have transitioned via retire but lingers in
2375
+ // the working set. Proposes retire.
2376
+ //
2377
+ // Each finding PROPOSES (flatten / regroup / retire); the operator routes it
2378
+ // through the existing gated flows — knowledge.retire to retire, an `update`
2379
+ // (recategorize) to flatten/regroup. The audit forks no new mutation path.
2380
+ // Sprawl is domain-sensitive, so the checks are OPTIONAL and CONFIGURABLE: a
2381
+ // disabled check (or an empty marker list) simply contributes no findings.
2382
+ // -------------------------------------------------------------------------
2383
+
2384
+ /**
2385
+ * Audit the category hierarchy of the working set for sprawl and propose
2386
+ * flattening / regrouping / retirement. Read-only: mutates nothing.
2387
+ *
2388
+ * Findings are of three kinds (each independently toggleable):
2389
+ * - `"orphan-prefix"`: an intermediate prefix node carrying no record
2390
+ * directly while it has descendants, or a prefix whose entire subtree is
2391
+ * exactly one leaf record. `proposedAction: "flatten"`.
2392
+ * - `"too-many-leaves"`: a parent prefix whose direct child leaf-category
2393
+ * count exceeds `maxLeavesPerParent`. `proposedAction: "regroup"`.
2394
+ * - `"implemented-active"`: a record still `status:"active"` carrying an
2395
+ * `implementedMarkers` tag. `proposedAction: "retire"`.
2396
+ *
2397
+ * @param {object} [options]
2398
+ * - maxLeavesPerParent: number — fan-out budget; a parent with more direct
2399
+ * child leaf categories is flagged. Omit /
2400
+ * null to disable the leaf-count check.
2401
+ * - implementedMarkers: string[] — tag markers that mean "implemented"
2402
+ * (case-insensitive). Empty/omitted →
2403
+ * the implemented-active check is disabled.
2404
+ * - checkOrphanPrefixes: boolean — enable the orphan-prefix check (default true).
2405
+ * - types: string[] — record types to survey
2406
+ * (default ["raw","compiled","concept","snapshot"]).
2407
+ * - agent: string — override agent name.
2408
+ * @returns {Promise<{
2409
+ * surveyed: number,
2410
+ * categories: number,
2411
+ * findings: Array<{
2412
+ * kind: "orphan-prefix"|"too-many-leaves"|"implemented-active",
2413
+ * category: string,
2414
+ * recordIds: string[],
2415
+ * metric: string,
2416
+ * evidence: object,
2417
+ * proposedAction: "flatten"|"regroup"|"retire"
2418
+ * }>,
2419
+ * telemetryEvents: object[]
2420
+ * }>}
2421
+ */
2422
+ async canonicalizeCategory(options = {}) {
2423
+ const events = [];
2424
+ const agent = options.agent || this._agent;
2425
+
2426
+ const checkOrphanPrefixes = options.checkOrphanPrefixes !== false;
2427
+ const maxLeavesPerParent =
2428
+ typeof options.maxLeavesPerParent === "number"
2429
+ ? options.maxLeavesPerParent
2430
+ : null;
2431
+ if (maxLeavesPerParent !== null && maxLeavesPerParent < 1) {
2432
+ throw missingEvidenceError(
2433
+ `canonicalize-category: maxLeavesPerParent must be >= 1; got: ${maxLeavesPerParent}`
2434
+ );
2435
+ }
2436
+ const markerSet = new Set(
2437
+ (Array.isArray(options.implementedMarkers) ? options.implementedMarkers : [])
2438
+ .map((m) => String(m).toLowerCase())
2439
+ );
2440
+ const types = Array.isArray(options.types) && options.types.length
2441
+ ? options.types
2442
+ : ["raw", "compiled", "concept", "snapshot"];
2443
+
2444
+ // ── Step: survey ───────────────────────────────────────────────────────
2445
+ // Gather the working set (default queries already exclude retired records —
2446
+ // retired is terminal, so it is not category sprawl to flatten).
2447
+ const surveyGateIn = this._telemetry.emitGate("knowledge.canonicalize-category", "survey-gate", {
2448
+ flow: "knowledge.canonicalize-category",
2449
+ gate: "survey-gate",
2450
+ types,
2451
+ check_orphan_prefixes: checkOrphanPrefixes,
2452
+ max_leaves_per_parent: maxLeavesPerParent,
2453
+ implemented_markers: [...markerSet],
2454
+ });
2455
+ events.push(surveyGateIn);
2456
+
2457
+ const seen = new Set();
2458
+ const records = [];
2459
+ for (const type of types) {
2460
+ const recs = await this._store.listByType(type);
2461
+ for (const r of recs) {
2462
+ if (seen.has(r.id)) continue;
2463
+ seen.add(r.id);
2464
+ records.push(r);
2465
+ }
2466
+ }
2467
+
2468
+ // Build the category tree.
2469
+ // directIds[prefix] — ids of records whose category IS exactly prefix.
2470
+ // subtreeIds[prefix] — ids of records at prefix OR any descendant.
2471
+ // childLeaves[parent] — direct child leaf categories (a leaf = a category
2472
+ // that is itself a record-bearing category with no
2473
+ // deeper record-bearing descendant).
2474
+ const directIds = new Map();
2475
+ const subtreeIds = new Map();
2476
+ const allCategories = new Set();
2477
+ for (const r of records) {
2478
+ const cat = r.category || "";
2479
+ if (!directIds.has(cat)) directIds.set(cat, []);
2480
+ directIds.get(cat).push(r.id);
2481
+ for (const prefix of categoryPrefixes(cat)) {
2482
+ allCategories.add(prefix);
2483
+ if (!subtreeIds.has(prefix)) subtreeIds.set(prefix, []);
2484
+ subtreeIds.get(prefix).push(r.id);
2485
+ }
2486
+ }
2487
+
2488
+ const surveyGateOut = this._telemetry.emitGateResult("knowledge.canonicalize-category", "survey-gate", {
2489
+ surveyed: records.length,
2490
+ categories: allCategories.size,
2491
+ });
2492
+ events.push(surveyGateOut);
2493
+
2494
+ // ── Step: assess + propose-gate ────────────────────────────────────────
2495
+ const proposeGateIn = this._telemetry.emitGate("knowledge.canonicalize-category", "propose-gate", {
2496
+ flow: "knowledge.canonicalize-category",
2497
+ gate: "propose-gate",
2498
+ surveyed: records.length,
2499
+ categories: allCategories.size,
2500
+ });
2501
+ events.push(proposeGateIn);
2502
+
2503
+ const findings = [];
2504
+
2505
+ // (1) orphan-prefix: an intermediate prefix node with no direct record but
2506
+ // descendants, OR a prefix whose entire subtree is a single leaf record.
2507
+ if (checkOrphanPrefixes) {
2508
+ for (const prefix of allCategories) {
2509
+ const direct = directIds.get(prefix) || [];
2510
+ const subtree = subtreeIds.get(prefix) || [];
2511
+ const hasDescendantRecord = subtree.length > direct.length;
2512
+ // Only consider non-leaf (intermediate) prefixes for the "empty node"
2513
+ // case — a prefix that has descendants but holds nothing itself. When
2514
+ // the whole subtree is a SINGLE record, the deeper single-record-deep-path
2515
+ // finding (emitted at the record-bearing leaf) already covers the whole
2516
+ // chain — don't also report every empty ancestor of that lone record.
2517
+ if (direct.length === 0 && hasDescendantRecord && subtree.length > 1) {
2518
+ findings.push({
2519
+ kind: "orphan-prefix",
2520
+ category: prefix,
2521
+ recordIds: [...subtree],
2522
+ metric: "empty-intermediate-node",
2523
+ evidence: {
2524
+ directRecordCount: 0,
2525
+ subtreeRecordCount: subtree.length,
2526
+ parent: parentPrefix(prefix),
2527
+ reason:
2528
+ "Prefix holds no record directly; it only nests descendants. Flatten its subtree up to the parent.",
2529
+ },
2530
+ proposedAction: "flatten",
2531
+ });
2532
+ } else if (subtree.length === 1 && categoryPrefixes(prefix).length > 1) {
2533
+ // A deep path (>1 segment) whose whole subtree is one record adds
2534
+ // depth without branching value — flatten toward the parent. Only
2535
+ // report this at the deepest such prefix that still owns the record
2536
+ // directly (avoid double-reporting every ancestor of a lone record).
2537
+ if (direct.length === 1) {
2538
+ findings.push({
2539
+ kind: "orphan-prefix",
2540
+ category: prefix,
2541
+ recordIds: [...subtree],
2542
+ metric: "single-record-deep-path",
2543
+ evidence: {
2544
+ directRecordCount: 1,
2545
+ subtreeRecordCount: 1,
2546
+ depth: categoryPrefixes(prefix).length,
2547
+ parent: parentPrefix(prefix),
2548
+ reason:
2549
+ "A multi-segment category carries exactly one record and no siblings in its subtree — depth without branching value. Consider flattening to the parent.",
2550
+ },
2551
+ proposedAction: "flatten",
2552
+ });
2553
+ }
2554
+ }
2555
+ }
2556
+ }
2557
+
2558
+ // (2) too-many-leaves: a parent prefix whose direct child leaf-category
2559
+ // count exceeds the fan-out budget. A "direct child leaf category" is a
2560
+ // record-bearing category exactly one segment deeper than the parent
2561
+ // with no record-bearing descendant of its own.
2562
+ if (maxLeavesPerParent !== null) {
2563
+ const childLeaves = new Map(); // parent → Set<childCategory>
2564
+ for (const cat of allCategories) {
2565
+ const direct = directIds.get(cat) || [];
2566
+ const subtree = subtreeIds.get(cat) || [];
2567
+ const isLeaf = direct.length > 0 && subtree.length === direct.length;
2568
+ if (!isLeaf) continue;
2569
+ const parent = parentPrefix(cat);
2570
+ if (!parent) continue; // top-level leaves have no parent prefix to regroup under
2571
+ if (!childLeaves.has(parent)) childLeaves.set(parent, new Set());
2572
+ childLeaves.get(parent).add(cat);
2573
+ }
2574
+ for (const [parent, leaves] of childLeaves) {
2575
+ if (leaves.size > maxLeavesPerParent) {
2576
+ const leafList = [...leaves].sort();
2577
+ const recordIds = [];
2578
+ for (const leaf of leafList) {
2579
+ for (const id of directIds.get(leaf) || []) recordIds.push(id);
2580
+ }
2581
+ findings.push({
2582
+ kind: "too-many-leaves",
2583
+ category: parent,
2584
+ recordIds,
2585
+ metric: "leaf-fan-out",
2586
+ evidence: {
2587
+ leafCount: leaves.size,
2588
+ maxLeavesPerParent,
2589
+ leaves: leafList,
2590
+ reason:
2591
+ "Parent prefix fans out to more direct leaf categories than the configured budget — regroup the leaves under fewer categories.",
2592
+ },
2593
+ proposedAction: "regroup",
2594
+ });
2595
+ }
2596
+ }
2597
+ }
2598
+
2599
+ // (3) implemented-active: a still-active record carrying an implemented marker.
2600
+ if (markerSet.size > 0) {
2601
+ for (const r of records) {
2602
+ if (isImplementedButActive(r, markerSet)) {
2603
+ const tags = Array.isArray(r.tags) ? r.tags : [];
2604
+ const matched = tags.filter((t) => markerSet.has(String(t).toLowerCase()));
2605
+ findings.push({
2606
+ kind: "implemented-active",
2607
+ category: r.category || "",
2608
+ recordIds: [r.id],
2609
+ metric: "implemented-marker-on-active",
2610
+ evidence: {
2611
+ recordId: r.id,
2612
+ title: r.title,
2613
+ status: r.status || "active",
2614
+ matchedMarkers: matched,
2615
+ reason:
2616
+ "Record is still status:'active' but is tagged implemented — it should have transitioned via retire but lingers in the working set.",
2617
+ },
2618
+ proposedAction: "retire",
2619
+ });
2620
+ }
2621
+ }
2622
+ }
2623
+
2624
+ const proposeGateOut = this._telemetry.emitGateResult("knowledge.canonicalize-category", "propose-gate", {
2625
+ surveyed: records.length,
2626
+ categories: allCategories.size,
2627
+ findings: findings.length,
2628
+ agent,
2629
+ });
2630
+ events.push(proposeGateOut);
2631
+
2632
+ return {
2633
+ surveyed: records.length,
2634
+ categories: allCategories.size,
2635
+ findings,
2636
+ telemetryEvents: events,
2637
+ };
2638
+ }
2639
+
2640
+
2641
+ // -------------------------------------------------------------------------
2642
+ // knowledge.hygiene-review flow (hygiene #5 — #106, closes the issue)
2643
+ // Steps: orchestrate → review-gate → done
2644
+ // Gate: review-gate — the unified review carries every proposal collected
2645
+ // from the four hygiene flows, each citing the flow it came from and
2646
+ // its underlying evidence. No proposal is synthesized here — the
2647
+ // review only relays what the gated flows already produced.
2648
+ //
2649
+ // This is a THIN ORCHESTRATOR over hygiene flows #1–#4 (consume-never-fork).
2650
+ // It reimplements NO detection logic: it simply runs auditFreshness,
2651
+ // detectContradictions, glossarySync, and canonicalizeCategory — the EXISTING
2652
+ // flow-runner methods, with their EXISTING gates — and folds their findings
2653
+ // into one operator-facing review of proposed actions (adopt / retire /
2654
+ // merge). Read-only by DEFAULT, exactly like the flows it runs: it mutates
2655
+ // nothing of its own.
2656
+ //
2657
+ // It forks NO new propose→approve gate. Every flow it runs already routes its
2658
+ // own proposals through the Kit's existing gated ops:
2659
+ // - audit-freshness → each flag proposes archive (→ knowledge.retire) or
2660
+ // refresh (→ a fresh capture/compile); the flow's
2661
+ // flag-gate guarantees the evidence.
2662
+ // - detect-contradictions → each flag proposes reconciling (→ knowledge.retire
2663
+ // to drop the stale assertion, or capture/compile/
2664
+ // consolidate to reconcile); flag-gate guards it.
2665
+ // - glossary-sync → its OWN propose-gate enacts gaps/drift through the
2666
+ // existing concept-record propose→apply ops with the
2667
+ // canonical doc as proposer. When the operator opts to
2668
+ // apply, hygiene-review delegates straight back to
2669
+ // glossarySync({ apply: true }) — it does NOT touch
2670
+ // the store itself.
2671
+ // - canonicalize-category → each finding proposes flatten/regroup (→ an
2672
+ // update/recategorize) or retire (→ knowledge.retire);
2673
+ // propose-gate guards it.
2674
+ // The "adopt/retire/merge" surface the issue asks for is therefore a *view*
2675
+ // over those existing gated proposals, not a new mutation path.
2676
+ // -------------------------------------------------------------------------
2677
+
2678
+ /**
2679
+ * Orchestrate the four Knowledge-Kit hygiene flows (#106 #1–#4) and present a
2680
+ * single unified review of their proposed actions (adopt / retire / merge)
2681
+ * through the flows' EXISTING propose→approve gates.
2682
+ *
2683
+ * READ-ONLY by default (like every flow it runs): it runs the four audits and
2684
+ * returns their collected proposals; it mutates nothing itself. The only path
2685
+ * that mutates is `glossary.apply: true`, which delegates verbatim to
2686
+ * `glossarySync({ apply: true })` so the change rides the existing gated
2687
+ * concept-record propose→apply lineage — hygiene-review never forks a gate or
2688
+ * writes to the store directly.
2689
+ *
2690
+ * The four sub-audits are configurable + opt-in, mirroring the flows: an audit
2691
+ * whose config block is omitted is SKIPPED (its `skipped: true` is surfaced),
2692
+ * so an empty call does nothing — hygiene is opt-in end-to-end.
2693
+ *
2694
+ * Each collected proposal is normalized to one of three operator decisions:
2695
+ * - `"retire"` — drop/archive a record (audit-freshness archive,
2696
+ * contradiction reconcile, canonicalize retire). Route
2697
+ * through knowledge.retire.
2698
+ * - `"adopt"` — create/refresh a definition or record (audit-freshness
2699
+ * refresh, glossary gap/outdated). Route through the source
2700
+ * flow's gated op (glossary delegates here on apply).
2701
+ * - `"merge"` — reconcile/regroup overlapping records (canonicalize
2702
+ * flatten/regroup). Route through an update/recategorize.
2703
+ * Every proposal cites `sourceFlow` + the underlying finding so the operator
2704
+ * sees the evidence the originating flow's gate already vouched for.
2705
+ *
2706
+ * @param {object} [options]
2707
+ * - freshness: object|false — auditFreshness options. Omit/false → skip.
2708
+ * - contradictions: object|false — detectContradictions options. Omit/false → skip.
2709
+ * - glossary: object|false — glossarySync options. Omit/false → skip.
2710
+ * Pass `{ ..., apply: true }` to enact the glossary plan through the
2711
+ * existing gated propose→apply ops (the ONLY apply this orchestrator does,
2712
+ * and it delegates to glossarySync — it forks nothing).
2713
+ * - canonicalize: object|false — canonicalizeCategory options. Omit/false → skip.
2714
+ * - agent: string — override agent name for telemetry.
2715
+ * @returns {Promise<{
2716
+ * ranFlows: string[],
2717
+ * skippedFlows: string[],
2718
+ * audits: {
2719
+ * freshness?: object, contradictions?: object,
2720
+ * glossary?: object, canonicalize?: object
2721
+ * },
2722
+ * proposals: Array<{
2723
+ * decision: "adopt"|"retire"|"merge",
2724
+ * sourceFlow: string,
2725
+ * proposedAction: string,
2726
+ * route: string,
2727
+ * recordIds: string[],
2728
+ * evidence: object
2729
+ * }>,
2730
+ * summary: { total: number, adopt: number, retire: number, merge: number },
2731
+ * telemetryEvents: object[]
2732
+ * }>}
2733
+ */
2734
+ async hygieneReview(options = {}) {
2735
+ const events = [];
2736
+ const agent = options.agent || this._agent;
2737
+
2738
+ // Which sub-audits the operator opted into. An omitted/false block is
2739
+ // skipped (hygiene is opt-in, like each flow). `true` runs with defaults.
2740
+ const blocks = {
2741
+ freshness: options.freshness,
2742
+ contradictions: options.contradictions,
2743
+ glossary: options.glossary,
2744
+ canonicalize: options.canonicalize,
2745
+ };
2746
+ const wants = (b) => b !== undefined && b !== false && b !== null;
2747
+ const optsOf = (b) => (b === true ? {} : b);
2748
+
2749
+ const ranFlows = [];
2750
+ const skippedFlows = [];
2751
+ const audits = {};
2752
+ const proposals = [];
2753
+
2754
+ // ── Step: orchestrate ───────────────────────────────────────────────────
2755
+ // Run each opted-in hygiene flow via its EXISTING runner method. No
2756
+ // detection logic lives here — we only invoke and collect.
2757
+ const orchestrateGateIn = this._telemetry.emitGate(
2758
+ "knowledge.hygiene-review",
2759
+ "orchestrate-gate",
2760
+ {
2761
+ flow: "knowledge.hygiene-review",
2762
+ gate: "orchestrate-gate",
2763
+ requested: Object.keys(blocks).filter((k) => wants(blocks[k])),
2764
+ }
2765
+ );
2766
+ events.push(orchestrateGateIn);
2767
+
2768
+ // #1 audit-freshness → archive (retire) / refresh (adopt)
2769
+ if (wants(blocks.freshness)) {
2770
+ ranFlows.push("knowledge.audit-freshness");
2771
+ const res = await this.auditFreshness({ agent, ...optsOf(blocks.freshness) });
2772
+ audits.freshness = res;
2773
+ events.push(...res.telemetryEvents);
2774
+ for (const flag of res.flags) {
2775
+ const isArchive = flag.proposedAction === "archive";
2776
+ proposals.push({
2777
+ decision: isArchive ? "retire" : "adopt",
2778
+ sourceFlow: "knowledge.audit-freshness",
2779
+ proposedAction: flag.proposedAction,
2780
+ route: isArchive
2781
+ ? "knowledge.retire"
2782
+ : "knowledge.capture/compile (refresh)",
2783
+ recordIds: [flag.recordId],
2784
+ evidence: {
2785
+ title: flag.title,
2786
+ category: flag.category,
2787
+ ageDays: flag.ageDays,
2788
+ thresholdDays: flag.thresholdDays,
2789
+ matchedThresholdKey: flag.matchedThresholdKey,
2790
+ lastMutationAt: flag.lastMutationAt,
2791
+ },
2792
+ });
2793
+ }
2794
+ } else {
2795
+ skippedFlows.push("knowledge.audit-freshness");
2796
+ }
2797
+
2798
+ // #2 detect-contradictions → reconcile (retire the stale assertion)
2799
+ if (wants(blocks.contradictions)) {
2800
+ ranFlows.push("knowledge.detect-contradictions");
2801
+ const res = await this.detectContradictions({ agent, ...optsOf(blocks.contradictions) });
2802
+ audits.contradictions = res;
2803
+ events.push(...res.telemetryEvents);
2804
+ for (const flag of res.flags) {
2805
+ proposals.push({
2806
+ decision: "retire",
2807
+ sourceFlow: "knowledge.detect-contradictions",
2808
+ proposedAction: "reconcile",
2809
+ route: "knowledge.retire (drop stale assertion) | capture/compile/consolidate",
2810
+ recordIds: [flag.recordIdA, flag.recordIdB],
2811
+ evidence: {
2812
+ titleA: flag.titleA,
2813
+ titleB: flag.titleB,
2814
+ category: flag.category,
2815
+ reason: flag.reason,
2816
+ },
2817
+ });
2818
+ }
2819
+ } else {
2820
+ skippedFlows.push("knowledge.detect-contradictions");
2821
+ }
2822
+
2823
+ // #3 glossary-sync → adopt a canonical definition (gap = new, outdated =
2824
+ // refresh). The ONLY apply this orchestrator performs is delegated straight
2825
+ // back to glossarySync's OWN gated propose→apply — we never write the store.
2826
+ if (wants(blocks.glossary)) {
2827
+ ranFlows.push("knowledge.glossary-sync");
2828
+ const res = await this.glossarySync({ agent, ...optsOf(blocks.glossary) });
2829
+ audits.glossary = res;
2830
+ events.push(...res.telemetryEvents);
2831
+ for (const entry of res.gaps) {
2832
+ proposals.push({
2833
+ decision: "adopt",
2834
+ sourceFlow: "knowledge.glossary-sync",
2835
+ proposedAction: "create-concept",
2836
+ route: "knowledge.glossary-sync apply (store.create→propose→apply)",
2837
+ recordIds: [entry.sourceDocId],
2838
+ evidence: {
2839
+ term: entry.term,
2840
+ definition: entry.definition,
2841
+ sourceDocId: entry.sourceDocId,
2842
+ sourceDocTitle: entry.sourceDocTitle,
2843
+ category: entry.category,
2844
+ classification: "gap",
2845
+ },
2846
+ });
2847
+ }
2848
+ for (const entry of res.outdated) {
2849
+ proposals.push({
2850
+ decision: "adopt",
2851
+ sourceFlow: "knowledge.glossary-sync",
2852
+ proposedAction: "refresh-concept",
2853
+ route: "knowledge.glossary-sync apply (store.propose→apply)",
2854
+ recordIds: [entry.conceptId, entry.sourceDocId],
2855
+ evidence: {
2856
+ term: entry.term,
2857
+ definition: entry.definition,
2858
+ sourceDocId: entry.sourceDocId,
2859
+ conceptId: entry.conceptId,
2860
+ currentBody: entry.currentBody,
2861
+ classification: "outdated",
2862
+ },
2863
+ });
2864
+ }
2865
+ } else {
2866
+ skippedFlows.push("knowledge.glossary-sync");
2867
+ }
2868
+
2869
+ // #4 canonicalize-category → flatten/regroup (merge) / retire
2870
+ if (wants(blocks.canonicalize)) {
2871
+ ranFlows.push("knowledge.canonicalize-category");
2872
+ const res = await this.canonicalizeCategory({ agent, ...optsOf(blocks.canonicalize) });
2873
+ audits.canonicalize = res;
2874
+ events.push(...res.telemetryEvents);
2875
+ for (const finding of res.findings) {
2876
+ const isRetire = finding.proposedAction === "retire";
2877
+ proposals.push({
2878
+ decision: isRetire ? "retire" : "merge",
2879
+ sourceFlow: "knowledge.canonicalize-category",
2880
+ proposedAction: finding.proposedAction,
2881
+ route: isRetire
2882
+ ? "knowledge.retire"
2883
+ : "update/recategorize (flatten/regroup)",
2884
+ recordIds: finding.recordIds,
2885
+ evidence: {
2886
+ kind: finding.kind,
2887
+ category: finding.category,
2888
+ metric: finding.metric,
2889
+ ...finding.evidence,
2890
+ },
2891
+ });
2892
+ }
2893
+ } else {
2894
+ skippedFlows.push("knowledge.canonicalize-category");
2895
+ }
2896
+
2897
+ const orchestrateGateOut = this._telemetry.emitGateResult(
2898
+ "knowledge.hygiene-review",
2899
+ "orchestrate-gate",
2900
+ { ran: ranFlows.length, skipped: skippedFlows.length }
2901
+ );
2902
+ events.push(orchestrateGateOut);
2903
+
2904
+ // ── Step: review-gate ───────────────────────────────────────────────────
2905
+ // The unified review relays the collected proposals. It SYNTHESIZES no
2906
+ // proposal of its own — every entry traces to a flow whose gate already
2907
+ // vouched for the evidence (consume-never-fork). The operator adopts /
2908
+ // retires / merges each by routing it back through that flow's gated op.
2909
+ const reviewGateIn = this._telemetry.emitGate(
2910
+ "knowledge.hygiene-review",
2911
+ "review-gate",
2912
+ {
2913
+ flow: "knowledge.hygiene-review",
2914
+ gate: "review-gate",
2915
+ proposals: proposals.length,
2916
+ }
2917
+ );
2918
+ events.push(reviewGateIn);
2919
+
2920
+ const summary = {
2921
+ total: proposals.length,
2922
+ adopt: proposals.filter((p) => p.decision === "adopt").length,
2923
+ retire: proposals.filter((p) => p.decision === "retire").length,
2924
+ merge: proposals.filter((p) => p.decision === "merge").length,
2925
+ };
2926
+
2927
+ const reviewGateOut = this._telemetry.emitGateResult(
2928
+ "knowledge.hygiene-review",
2929
+ "review-gate",
2930
+ { ...summary, agent }
2931
+ );
2932
+ events.push(reviewGateOut);
2933
+
2934
+ return {
2935
+ ranFlows,
2936
+ skippedFlows,
2937
+ audits,
2938
+ proposals,
2939
+ summary,
1388
2940
  telemetryEvents: events,
1389
2941
  };
1390
2942
  }
@@ -1785,6 +3337,74 @@ export async function retire(
1785
3337
  return runner.retire(recordId, retireOpts);
1786
3338
  }
1787
3339
 
3340
+ /**
3341
+ * Module-level audit-freshness: creates an ephemeral runner using the provided
3342
+ * store. Read-only — mutates nothing.
3343
+ *
3344
+ * @param {object} options (merged into auditFreshness options + runner options)
3345
+ */
3346
+ export async function auditFreshness(
3347
+ { store, workspace, agent, sessionId, ...auditOpts } = {}
3348
+ ) {
3349
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
3350
+ return runner.auditFreshness(auditOpts);
3351
+ }
3352
+
3353
+ /**
3354
+ * Module-level category-canonicalization audit: creates an ephemeral runner
3355
+ * using the provided store. Read-only — mutates nothing. (#106 hygiene #4)
3356
+ *
3357
+ * @param {object} options (merged into canonicalizeCategory options + runner options)
3358
+ */
3359
+ export async function canonicalizeCategory(
3360
+ { store, workspace, agent, sessionId, ...auditOpts } = {}
3361
+ ) {
3362
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
3363
+ return runner.canonicalizeCategory(auditOpts);
3364
+ }
3365
+
3366
+ /**
3367
+ * Module-level glossary-sync: creates an ephemeral runner using the provided
3368
+ * store. Read-only by default; pass `apply: true` to enact the plan via the
3369
+ * existing concept-record propose→apply ops.
3370
+ *
3371
+ * @param {object} options (merged into glossarySync options + runner options)
3372
+ */
3373
+ export async function glossarySync(
3374
+ { store, workspace, agent, sessionId, ...syncOpts } = {}
3375
+ ) {
3376
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
3377
+ return runner.glossarySync(syncOpts);
3378
+ }
3379
+
3380
+ /**
3381
+ * Module-level detect-contradictions: creates an ephemeral runner using the
3382
+ * provided store. Read-only — mutates nothing. (#106 hygiene #2)
3383
+ *
3384
+ * @param {object} options (merged into detectContradictions options + runner options)
3385
+ */
3386
+ export async function detectContradictions(
3387
+ { store, workspace, agent, sessionId, ...detectOpts } = {}
3388
+ ) {
3389
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
3390
+ return runner.detectContradictions(detectOpts);
3391
+ }
3392
+
3393
+ /**
3394
+ * Module-level hygiene-review: creates an ephemeral runner using the provided
3395
+ * store and orchestrates the four hygiene flows (#106 hygiene #5). Read-only by
3396
+ * default — the only apply is `glossary.apply: true`, which the runner delegates
3397
+ * to glossarySync's own gated propose→apply ops (consume-never-fork). Closes #106.
3398
+ *
3399
+ * @param {object} options (merged into hygieneReview options + runner options)
3400
+ */
3401
+ export async function hygieneReview(
3402
+ { store, workspace, agent, sessionId, ...reviewOpts } = {}
3403
+ ) {
3404
+ const runner = new KnowledgeFlowRunner({ store, workspace, agent, sessionId });
3405
+ return runner.hygieneReview(reviewOpts);
3406
+ }
3407
+
1788
3408
  export default KnowledgeFlowRunner;
1789
3409
 
1790
3410
  /**