@kontourai/flow-agents 1.4.0 → 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (180) 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/trust-reconcile.yml +113 -0
  8. package/AGENTS.md +13 -0
  9. package/CHANGELOG.md +95 -0
  10. package/CONTRIBUTING.md +4 -4
  11. package/README.md +1 -0
  12. package/agents/tool-planner.json +1 -1
  13. package/build/src/cli/init.js +242 -20
  14. package/build/src/cli/validate-workflow-artifacts.js +19 -2
  15. package/build/src/cli/verify.d.ts +1 -0
  16. package/build/src/cli/verify.js +90 -0
  17. package/build/src/cli/workflow-sidecar.d.ts +300 -8
  18. package/build/src/cli/workflow-sidecar.js +1934 -83
  19. package/build/src/cli.js +2 -3
  20. package/build/src/lib/flow-resolver.d.ts +82 -0
  21. package/build/src/lib/flow-resolver.js +237 -0
  22. package/build/src/tools/build-universal-bundles.js +34 -22
  23. package/build/src/tools/generate-context-map.js +3 -16
  24. package/build/src/tools/validate-source-tree.d.ts +1 -1
  25. package/build/src/tools/validate-source-tree.js +42 -162
  26. package/context/contracts/artifact-contract.md +10 -0
  27. package/context/contracts/delivery-contract.md +1 -0
  28. package/context/contracts/review-contract.md +1 -0
  29. package/context/contracts/verification-contract.md +2 -0
  30. package/context/gate-awareness.md +39 -0
  31. package/context/scripts/hooks/stop-goal-fit.js +632 -70
  32. package/docs/adr/0001-flow-agents-consumes-flow.md +1 -1
  33. package/docs/adr/0002-flow-kits-as-extension-unit.md +1 -1
  34. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  35. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  36. package/docs/adr/0007-skill-audit.md +1 -1
  37. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +95 -0
  38. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +139 -0
  39. package/docs/adr/0011-mcp-posture.md +100 -0
  40. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +119 -0
  41. package/docs/adr/0013-context-lifecycle.md +151 -0
  42. package/docs/adr/0014-core-vs-domain-kit-boundary.md +143 -0
  43. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +120 -0
  44. package/docs/adr/0016-three-hard-boundary-model.md +71 -0
  45. package/docs/adr/0017-anti-gaming-trust-security-model.md +155 -0
  46. package/docs/agent-system-guidebook.md +5 -12
  47. package/docs/context-map.md +4 -10
  48. package/docs/index.md +3 -2
  49. package/docs/integrations/framework-adapter.md +19 -6
  50. package/docs/integrations/index.md +2 -2
  51. package/docs/north-star.md +4 -4
  52. package/docs/operating-layers.md +3 -3
  53. package/docs/plans/adr-0010-phase2-gate-recompute.md +55 -0
  54. package/docs/repository-structure.md +2 -2
  55. package/docs/skills-map.md +1 -0
  56. package/docs/spec/runtime-hook-surface.md +62 -9
  57. package/docs/standards-register.md +3 -3
  58. package/docs/survey-utterance-check.md +1 -1
  59. package/docs/trust-anchor-adoption.md +197 -0
  60. package/docs/verifiable-trust.md +95 -0
  61. package/docs/veritas-integration.md +2 -2
  62. package/docs/workflow-usage-guide.md +69 -0
  63. package/evals/acceptance/DEMO-false-completion.md +144 -0
  64. package/evals/acceptance/demo-cast.sh +92 -0
  65. package/evals/acceptance/demo-false-completion.sh +72 -0
  66. package/evals/acceptance/demo-real-evidence.sh +104 -0
  67. package/evals/acceptance/demo.tape +29 -0
  68. package/evals/acceptance/prove-capture-teeth-declared.sh +335 -0
  69. package/evals/acceptance/prove-capture-teeth.sh +114 -0
  70. package/evals/acceptance/prove-teeth.sh +105 -0
  71. package/evals/ci/antigaming-suite.sh +54 -0
  72. package/evals/ci/run-baseline.sh +2 -0
  73. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/flows/review.flow.json +26 -0
  74. package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/kit.json +20 -0
  75. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/flows/review.flow.json +26 -0
  76. package/evals/fixtures/flow-kit-repository/valid-unknown-extension/kit.json +18 -0
  77. package/evals/integration/test_builder_step_producers.sh +379 -0
  78. package/evals/integration/test_bundle_install.sh +35 -71
  79. package/evals/integration/test_bundle_lifecycle.sh +39 -2
  80. package/evals/integration/test_captured_fail_reconciliation.sh +820 -0
  81. package/evals/integration/test_checkpoint_signing.sh +489 -0
  82. package/evals/integration/test_claim_lookup.sh +352 -0
  83. package/evals/integration/test_command_log_integrity.sh +275 -0
  84. package/evals/integration/test_context_map.sh +0 -2
  85. package/evals/integration/test_dual_emit_flow_step.sh +278 -0
  86. package/evals/integration/test_enforcer_expects_driven.sh +281 -0
  87. package/evals/integration/test_evidence_capture_hook.sh +185 -0
  88. package/evals/integration/test_flow_kit_repository.sh +2 -0
  89. package/evals/integration/test_flowdef_session_activation.sh +273 -0
  90. package/evals/integration/test_flowdef_session_history_preservation.sh +250 -0
  91. package/evals/integration/test_gate_bypass_chain.sh +448 -0
  92. package/evals/integration/test_gate_lockdown.sh +1137 -0
  93. package/evals/integration/test_gate_review_inquiry_records.sh +399 -0
  94. package/evals/integration/test_goal_fit_escape_hatch.sh +73 -0
  95. package/evals/integration/test_goal_fit_hook.sh +69 -4
  96. package/evals/integration/test_goal_fit_rederive.sh +263 -0
  97. package/evals/integration/test_install_merge.sh +1176 -0
  98. package/evals/integration/test_mint_attestation.sh +373 -0
  99. package/evals/integration/test_phase_map_and_gate_claim.sh +365 -0
  100. package/evals/integration/test_publish_delivery.sh +269 -0
  101. package/evals/integration/test_reconcile_soundness.sh +528 -0
  102. package/evals/integration/test_resolvefirststep_security.sh +208 -0
  103. package/evals/integration/test_session_resume_roundtrip.sh +286 -0
  104. package/evals/integration/test_trust_checkpoint.sh +325 -0
  105. package/evals/integration/test_trust_reconcile.sh +293 -0
  106. package/evals/integration/test_verify_cli.sh +208 -0
  107. package/evals/integration/test_workflow_sidecar_writer.sh +549 -34
  108. package/evals/lib/node.sh +0 -6
  109. package/evals/run.sh +45 -0
  110. package/evals/static/test_workflow_skills.sh +6 -13
  111. package/install.sh +0 -7
  112. package/integrations/strands-ts/README.md +25 -15
  113. package/integrations/veritas/flow-agents.adapter.json +1 -2
  114. package/kits/builder/flows/build.flow.json +59 -12
  115. package/kits/builder/kit.json +85 -15
  116. package/kits/builder/skills/continue-work/SKILL.md +116 -0
  117. package/kits/builder/skills/deliver/SKILL.md +36 -6
  118. package/kits/builder/skills/design-probe/SKILL.md +28 -0
  119. package/kits/builder/skills/execute-plan/SKILL.md +9 -1
  120. package/kits/builder/skills/gate-review/SKILL.md +234 -0
  121. package/kits/builder/skills/learning-review/SKILL.md +30 -0
  122. package/kits/builder/skills/pickup-probe/SKILL.md +29 -0
  123. package/kits/builder/skills/plan-work/SKILL.md +13 -1
  124. package/kits/builder/skills/pull-work/SKILL.md +19 -0
  125. package/kits/knowledge/adapters/default-store/index.js +38 -0
  126. package/kits/knowledge/adapters/flow-runner/index.js +1620 -0
  127. package/kits/knowledge/adapters/obsidian-store/index.js +36 -6
  128. package/kits/knowledge/docs/store-contract.md +314 -0
  129. package/kits/knowledge/evals/audit-freshness/suite.test.js +368 -0
  130. package/kits/knowledge/evals/canonicalize-category/suite.test.js +383 -0
  131. package/kits/knowledge/evals/contract-suite/suite.test.js +111 -0
  132. package/kits/knowledge/evals/detect-contradictions/suite.test.js +324 -0
  133. package/kits/knowledge/evals/entities/suite.test.js +40 -0
  134. package/kits/knowledge/evals/glossary-sync/suite.test.js +416 -0
  135. package/kits/knowledge/evals/hygiene-review/suite.test.js +396 -0
  136. package/kits/knowledge/evals/retirement/suite.test.js +145 -0
  137. package/kits/knowledge/flows/audit-freshness.flow.json +44 -0
  138. package/kits/knowledge/flows/canonicalize-category.flow.json +44 -0
  139. package/kits/knowledge/flows/detect-contradictions.flow.json +44 -0
  140. package/kits/knowledge/flows/glossary-sync.flow.json +61 -0
  141. package/kits/knowledge/flows/hygiene-review.flow.json +43 -0
  142. package/kits/knowledge/kit.json +51 -1
  143. package/package.json +4 -4
  144. package/packaging/conformance/README.md +10 -2
  145. package/packaging/conformance/fixtures/evidence-capture--allow-records-command.json +29 -0
  146. package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +29 -0
  147. package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +30 -0
  148. package/packaging/conformance/fixtures/stop-goal-fit--block-mode.json +23 -0
  149. package/packaging/conformance/fixtures/stop-goal-fit--off-mode.json +24 -0
  150. package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +5 -2
  151. package/packaging/conformance/fixtures/stop-goal-fit--warn-no-bundle.json +23 -0
  152. package/packaging/conformance/fixtures/workflow-steering--reground-active-prompt.json +30 -0
  153. package/packaging/conformance/fixtures/workflow-steering--reground-session-start.json +30 -0
  154. package/packaging/conformance/run-conformance.js +1 -1
  155. package/scripts/README.md +2 -1
  156. package/scripts/build-universal-bundles.js +0 -1
  157. package/scripts/ci/mint-attestation.js +221 -0
  158. package/scripts/ci/trust-reconcile.js +545 -0
  159. package/scripts/hooks/config-protection.js +423 -1
  160. package/scripts/hooks/evidence-capture.js +348 -0
  161. package/scripts/hooks/lib/liveness-read.js +113 -0
  162. package/scripts/hooks/run-hook.js +6 -1
  163. package/scripts/hooks/stop-goal-fit.js +1471 -79
  164. package/scripts/hooks/workflow-steering.js +135 -5
  165. package/scripts/install-codex-home.sh +39 -0
  166. package/scripts/install-merge.js +330 -0
  167. package/src/cli/init.ts +218 -20
  168. package/src/cli/validate-workflow-artifacts.ts +18 -2
  169. package/src/cli/verify.ts +100 -0
  170. package/src/cli/workflow-sidecar.ts +2064 -77
  171. package/src/cli.ts +2 -3
  172. package/src/lib/flow-resolver.ts +284 -0
  173. package/src/tools/build-universal-bundles.ts +34 -21
  174. package/src/tools/generate-context-map.ts +3 -17
  175. package/src/tools/validate-source-tree.ts +44 -104
  176. package/build/src/tools/filter-installed-packs.d.ts +0 -2
  177. package/build/src/tools/filter-installed-packs.js +0 -135
  178. package/packaging/packs.json +0 -49
  179. package/scripts/filter-installed-packs.js +0 -2
  180. package/src/tools/filter-installed-packs.ts +0 -132
@@ -142,6 +142,32 @@ export class ObsidianKnowledgeStore {
142
142
  }
143
143
  }
144
144
 
145
+ /**
146
+ * Resolve a store-relative path from the persisted path index.
147
+ *
148
+ * The path index is local metadata, but it may be tampered with. Never feed
149
+ * an indexed path directly to fs/path helpers without this containment check.
150
+ */
151
+ _resolveStorePath(relPath) {
152
+ if (typeof relPath !== "string" || !relPath) {
153
+ throw new Error("Invalid store path in path index");
154
+ }
155
+ if (path.isAbsolute(relPath)) {
156
+ throw new Error(`Path index entry escapes store root: ${relPath}`);
157
+ }
158
+
159
+ const absPath = path.resolve(this._root, relPath);
160
+ const relativeToRoot = path.relative(this._root, absPath);
161
+ if (
162
+ relativeToRoot === ".."
163
+ || relativeToRoot.startsWith(`..${path.sep}`)
164
+ || path.isAbsolute(relativeToRoot)
165
+ ) {
166
+ throw new Error(`Path index entry escapes store root: ${relPath}`);
167
+ }
168
+ return absPath;
169
+ }
170
+
145
171
  // -------------------------------------------------------------------------
146
172
  // Path index I/O
147
173
  // -------------------------------------------------------------------------
@@ -230,7 +256,7 @@ export class ObsidianKnowledgeStore {
230
256
  _getAbsPath(id, pathIndex) {
231
257
  const entry = (pathIndex || this._loadPathIndex()).by_id[id];
232
258
  if (!entry) return null;
233
- return path.join(this._root, entry.path);
259
+ return this._resolveStorePath(entry.path);
234
260
  }
235
261
 
236
262
  /**
@@ -266,24 +292,28 @@ export class ObsidianKnowledgeStore {
266
292
 
267
293
  if (existingEntry && existingEntry.archived) {
268
294
  // Archived record — keep in archive path (supersede-not-delete writes back there)
295
+ this._resolveStorePath(existingEntry.path);
269
296
  targetRelPath = existingEntry.path;
270
297
  } else if (existingEntry) {
271
298
  // Existing active record — check if path needs to change (title changed)
272
299
  const newRelPath = this._computeRelPath(record.category, record.title, record.id, pathIndex, record.type);
300
+ this._resolveStorePath(newRelPath);
273
301
  if (newRelPath !== existingEntry.path) {
274
302
  // Move: delete old file, register new path
275
- const oldAbs = path.join(this._root, existingEntry.path);
303
+ const oldAbs = this._resolveStorePath(existingEntry.path);
276
304
  if (fs.existsSync(oldAbs)) fs.unlinkSync(oldAbs);
277
305
  delete pathIndex.by_path[existingEntry.path];
278
306
  pathIndex.by_id[record.id] = { path: newRelPath, archived: false };
279
307
  pathIndex.by_path[newRelPath] = record.id;
280
308
  targetRelPath = newRelPath;
281
309
  } else {
310
+ this._resolveStorePath(existingEntry.path);
282
311
  targetRelPath = existingEntry.path;
283
312
  }
284
313
  } else {
285
314
  // New record
286
315
  const newRelPath = this._computeRelPath(record.category, record.title, record.id, pathIndex, record.type);
316
+ this._resolveStorePath(newRelPath);
287
317
  pathIndex.by_id[record.id] = { path: newRelPath, archived: false };
288
318
  pathIndex.by_path[newRelPath] = record.id;
289
319
  targetRelPath = newRelPath;
@@ -303,7 +333,7 @@ export class ObsidianKnowledgeStore {
303
333
  const obsidianBody = this._renderObsidianBody(record, pathIndex);
304
334
  const text = `---\n${serializeYaml(frontmatter)}\n---\n\n${obsidianBody}`;
305
335
 
306
- const absPath = path.join(this._root, targetRelPath);
336
+ const absPath = this._resolveStorePath(targetRelPath);
307
337
  fs.mkdirSync(path.dirname(absPath), { recursive: true });
308
338
  fs.writeFileSync(absPath, text, "utf8");
309
339
 
@@ -326,10 +356,10 @@ export class ObsidianKnowledgeStore {
326
356
  }
327
357
 
328
358
  const archiveRelPath = `archive/${entry.path}`;
329
- const archiveAbs = path.join(this._root, archiveRelPath);
359
+ const archiveAbs = this._resolveStorePath(archiveRelPath);
330
360
  fs.mkdirSync(path.dirname(archiveAbs), { recursive: true });
331
361
 
332
- const currentAbs = path.join(this._root, entry.path);
362
+ const currentAbs = this._resolveStorePath(entry.path);
333
363
  if (fs.existsSync(currentAbs)) fs.renameSync(currentAbs, archiveAbs);
334
364
 
335
365
  delete pathIndex.by_path[entry.path];
@@ -350,7 +380,7 @@ export class ObsidianKnowledgeStore {
350
380
  _idToFilename(id, pathIndex) {
351
381
  const entry = pathIndex.by_id[id];
352
382
  if (!entry) return id;
353
- return path.basename(entry.path, ".md");
383
+ return path.basename(this._resolveStorePath(entry.path), ".md");
354
384
  }
355
385
 
356
386
  /**
@@ -167,6 +167,20 @@ The default adapter stores the index as `graph-index.json` at the store root.
167
167
  }
168
168
  ```
169
169
 
170
+ ### 5.2 Reindex (recovery)
171
+
172
+ The graph index is a **derived cache**: each record's own `links` array is the source of truth.
173
+ An adapter MAY expose a `reindex()` recovery operation that rebuilds the index from scratch by
174
+ scanning every record's `links` — the supported way to recover from a lost, hand-edited, or
175
+ drifted `graph-index.json`.
176
+
177
+ | Operation | Signature | Behavior |
178
+ | --- | --- | --- |
179
+ | `reindex` | `() => { records, links, forwardSources, reverseTargets, changed }` | Rebuild the index authoritatively from records' `links`. Deterministic (records processed in id order). `changed` is true when the rebuilt index differs from the one on disk (compared order-independently), so callers can detect drift. Does **not** mutate records or append to the mutation log — it only repairs the derived cache. |
180
+
181
+ `reindex()` is optional in the contract (not every adapter maintains a separate index), but any
182
+ adapter that persists a derived index SHOULD provide it as the recovery path.
183
+
170
184
  ---
171
185
 
172
186
  ## 6. Mutation Operations
@@ -649,6 +663,37 @@ Retired records MUST remain reachable from:
649
663
  There is no deletion of records. Physical purge (if ever needed) is a separate, future policy
650
664
  hook not defined in this version.
651
665
 
666
+ ### B.7 Proposal-Artifact Lifecycle (close-on-apply)
667
+
668
+ A flow that gates a change through `propose` → `apply` mints a transient **proposal artifact**:
669
+ a `raw` record (e.g. the `knowledge.retire` flow's `"Retirement proposal: <title>"` record) whose
670
+ sole purpose is to carry the `"proposes"` link and the proposal text to the target.
671
+
672
+ Once the proposal has been **applied**, that artifact is *spent*. It MUST NOT be left as an
673
+ `active` record:
674
+
675
+ - A lingering active artifact pollutes the default working set (`listByType` / `listByCategory`)
676
+ and is re-surfaced by hygiene sweeps.
677
+ - Hand-retiring it spawns a double-prefixed twin
678
+ (`"Retirement proposal: Retirement proposal: …"`), because the retire flow mints a *new*
679
+ proposal artifact for the artifact itself (#106).
680
+
681
+ **Rule.** On `apply` (and only on `apply`), the flow auto-closes the spent proposal artifact by
682
+ **retiring it via the existing `retire` op** (§B.4) — `active → retired`. There is no separate
683
+ "close" mutation; the close reuses `retire`. This close is:
684
+
685
+ - **safe** — it touches only the named artifact, never the apply target;
686
+ - **idempotent** — a no-op if the artifact is already retired/implemented (the §B.2 transition
687
+ table makes `retired` terminal, so a re-close is rejected and treated as a no-op);
688
+ - **non-fatal** — the proposal has already been applied; a failure to close the artifact does not
689
+ fail the flow (it is surfaced on the result instead).
690
+
691
+ **`reject` is unchanged.** A rejected proposal is *not* spent — the artifact remains `active` so the
692
+ proposal can be revisited. Closing happens only on the apply path.
693
+
694
+ Do not hand-retire proposal artifacts: applying the proposal closes them. The retire lifecycle
695
+ expects the artifact to exist transiently and to be auto-closed on apply.
696
+
652
697
  ---
653
698
 
654
699
  ## Addendum C — Person Record Type (Entity Cards)
@@ -720,3 +765,272 @@ Card merge uses the existing `propose → apply/reject` gate:
720
765
  - **apply**: unions body, adds `alias:<duplicate title>` tag to primary, unions `appears-in` links, calls
721
766
  `store.supersede(primaryId, [duplicateId])` to archive the duplicate (supersede-not-delete invariant).
722
767
  - **reject**: both cards remain byte-identical.
768
+
769
+ ## Addendum D — Freshness Audit (Hygiene #1, #106)
770
+
771
+ ### D.1 Read-Only Maintenance Layer
772
+
773
+ `knowledge.audit-freshness` is the first of the Knowledge Kit's *maintenance* flows. The Kit is
774
+ strong at filing (ingest → compile → synthesize → consolidate → retire) but, until this slice, had
775
+ no way to surface records that have gone stale. The audit is a **read-only** survey: it NEVER
776
+ mutates a record. It returns *flags* proposing an action; the operator routes each flag through an
777
+ existing gated flow — `knowledge.retire` to archive, or a fresh `capture`/`compile` to refresh. The
778
+ audit forks no new mutation path.
779
+
780
+ Staleness is domain-sensitive (a `radar` signal goes stale in days; a `decisions` record may stay
781
+ canonical for a year), so the audit is **optional and configurable** — thresholds are supplied per
782
+ call. A category with no configured threshold (and no default) is simply skipped: auditing is
783
+ **opt-in**.
784
+
785
+ ### D.2 `auditFreshness` Flow-Runner Operation
786
+
787
+ `KnowledgeFlowRunner.auditFreshness(options)` (also the module-level `auditFreshness({ store, ... })`):
788
+
789
+ **Options:**
790
+
791
+ | Option | Type | Default | Description |
792
+ |---|---|---|---|
793
+ | `thresholds` | `{ [category]: number }` | `{}` | Per-category staleness threshold in **days**. |
794
+ | `defaultThresholdDays` | `number` | none | Fallback for categories not matched by `thresholds`. When absent, unmatched categories are skipped. |
795
+ | `actions` | `{ [category]: "archive"\|"refresh" }` | `{}` | Per-category override of the proposed action. |
796
+ | `defaultAction` | `"archive"\|"refresh"` | `"refresh"` | Proposed action when no per-category action matches. |
797
+ | `types` | `string[]` | `["raw","compiled","concept","snapshot"]` | Record types to audit. |
798
+ | `now` | `string\|number\|Date` | current time | Reference "now" for the age computation (injectable for tests). |
799
+ | `agent` | `string` | runner agent | Agent recorded on the audit telemetry. |
800
+
801
+ **Threshold / action resolution** is dot-hierarchy **longest-prefix**: a record in
802
+ `radar.signals.weak` prefers a `radar.signals` entry over a `radar` one, then falls back to the
803
+ default. The matched key is surfaced on each flag as `matchedThresholdKey` (`"*"` denotes the
804
+ default).
805
+
806
+ **Last mutation** is the most recent of the record's `updated_at` and its latest `mutation_log`
807
+ entry `at` — both are refreshed by every mutating op (§1.1 / §4.2), so the later of the two is
808
+ authoritative even if an adapter lags one. It falls back to `created_at` when neither is present.
809
+
810
+ **Flagging:** a record is flagged only when its age in **whole days** *strictly exceeds* its
811
+ resolved threshold (`ageDays > thresholdDays`). Retired records are never flagged — the default
812
+ `listByType` query excludes them, and `retired` is terminal.
813
+
814
+ ### D.3 Flag Evidence Guarantee
815
+
816
+ Every flag carries the evidence that produced it — a flag can never be emitted without citing both
817
+ the last-mutation instant and the threshold that fired:
818
+
819
+ ```ts
820
+ interface FreshnessFlag {
821
+ recordId: string;
822
+ title: string;
823
+ type: string;
824
+ category: string;
825
+ status: string;
826
+ lastMutationAt: string; // ISO-8601 — the cited last mutation
827
+ ageDays: number; // whole days since lastMutationAt
828
+ thresholdDays: number; // the threshold that fired
829
+ matchedThresholdKey: string; // category key the threshold matched ("*" = default)
830
+ proposedAction: "archive" | "refresh";
831
+ }
832
+ ```
833
+
834
+ `auditFreshness` returns `{ audited, skipped, flags, telemetryEvents }` where `audited` counts
835
+ records that had a resolvable threshold, `skipped` counts opt-out categories, and `flags` lists the
836
+ stale records. Gate telemetry is emitted at `collect-gate` and `flag-gate`
837
+ (`knowledge.audit-freshness`).
838
+
839
+ ## Addendum E — Category Canonicalization (Hygiene #4, #106)
840
+
841
+ ### E.1 Read-Only Category-Sprawl Audit
842
+
843
+ `knowledge.canonicalize-category` is a *maintenance* flow, like the freshness audit (Addendum D): a
844
+ **read-only** survey that NEVER mutates a record. Where freshness measures records against *time*,
845
+ this audit measures the *shape of the category hierarchy*. As a Knowledge base grows by filing, its
846
+ category tree degrades in concrete ways the dogfooding surfaced (#106) — orphan prefixes, parents
847
+ that fan out into too many leaves, and records that were implemented but never retired. The audit
848
+ returns *findings* proposing a fix; the operator routes each through an existing gated flow —
849
+ `knowledge.retire` to retire, or an `update` (recategorize) to flatten/regroup. The audit forks no
850
+ new mutation path.
851
+
852
+ Sprawl is domain-sensitive (a flat radar feed differs from a deep decisions taxonomy), so each
853
+ check is **optional and configurable**: a disabled check — or an empty implemented-marker list —
854
+ contributes no findings. The audit is **opt-in** per check.
855
+
856
+ ### E.2 `canonicalizeCategory` Flow-Runner Operation
857
+
858
+ `KnowledgeFlowRunner.canonicalizeCategory(options)` (also the module-level
859
+ `canonicalizeCategory({ store, ... })`):
860
+
861
+ **Options:**
862
+
863
+ | Option | Type | Default | Description |
864
+ |---|---|---|---|
865
+ | `checkOrphanPrefixes` | `boolean` | `true` | Enable the orphan-prefix check. |
866
+ | `maxLeavesPerParent` | `number` | none | Leaf fan-out budget; a parent with strictly more direct child leaf categories is flagged. Omit to disable the check. |
867
+ | `implementedMarkers` | `string[]` | `[]` | Tag markers meaning "implemented" (case-insensitive). Empty → the implemented-active check is disabled. |
868
+ | `types` | `string[]` | `["raw","compiled","concept","snapshot"]` | Record types to survey. |
869
+ | `agent` | `string` | runner agent | Agent recorded on the audit telemetry. |
870
+
871
+ **Finding kinds** (each independently toggleable):
872
+
873
+ - **`orphan-prefix`** (`proposedAction: "flatten"`) — an intermediate prefix node that holds **no
874
+ record directly** while it has descendants (`metric: "empty-intermediate-node"`), OR a
875
+ multi-segment prefix whose **entire subtree is a single record** carried directly there
876
+ (`metric: "single-record-deep-path"`) — depth without branching value.
877
+ - **`too-many-leaves`** (`proposedAction: "regroup"`) — a parent prefix whose count of direct child
878
+ **leaf** categories *strictly exceeds* `maxLeavesPerParent` (`metric: "leaf-fan-out"`). A leaf is
879
+ a record-bearing category with no record-bearing descendant. The finding lists the offending
880
+ leaves.
881
+ - **`implemented-active`** (`proposedAction: "retire"`) — a record still `status:"active"` that
882
+ carries an `implementedMarkers` tag (`metric: "implemented-marker-on-active"`); it should have
883
+ transitioned via `retire` (§B.4) but lingers in the working set.
884
+
885
+ Retired records are never flagged — the default `listByType` query excludes them, and `retired` is
886
+ terminal (so it is not sprawl to flatten).
887
+
888
+ ### E.3 Finding Evidence Guarantee
889
+
890
+ Every finding carries the evidence that produced it — a finding can never be emitted without citing
891
+ the metric that fired and the offending category / record ids:
892
+
893
+ ```ts
894
+ interface CategoryFinding {
895
+ kind: "orphan-prefix" | "too-many-leaves" | "implemented-active";
896
+ category: string; // the offending category / parent prefix
897
+ recordIds: string[]; // the affected record ids
898
+ metric: string; // the rule that fired
899
+ evidence: object; // rule-specific (counts, leaf list, matched markers, reason)
900
+ proposedAction: "flatten" | "regroup" | "retire";
901
+ }
902
+ ```
903
+
904
+ `canonicalizeCategory` returns `{ surveyed, categories, findings, telemetryEvents }` where
905
+ `surveyed` counts the records examined, `categories` counts the distinct category prefixes in the
906
+ tree, and `findings` lists the sprawl. Gate telemetry is emitted at `survey-gate` and `propose-gate`
907
+ (`knowledge.canonicalize-category`).
908
+
909
+ ## Addendum F — Glossary Sync (Hygiene #3, #106)
910
+
911
+ ### F.1 Keeping the Glossary in Sync with Canonical Docs
912
+
913
+ `knowledge.glossary-sync` is a *maintenance* flow that keeps the **glossary** — the working set of
914
+ `concept` records — in sync with the **canonical docs** that define those terms. The Kit can file
915
+ concepts but, until this slice, had no way to (a) promote a term that a canonical doc defines but no
916
+ concept yet captures (a **gap**), or (b) notice when a concept's definition has **drifted** from its
917
+ canonical source (**out-of-date**).
918
+
919
+ The flow surveys a **configurable** glossary source list (the issue's "glossary source list") and is
920
+ **opt-in**: an empty/absent source list does nothing. It is **read-only by default** — it returns a
921
+ classification plan and mutates nothing; `apply: true` enacts the plan through the **existing**
922
+ concept-record ops (no forked mutation path).
923
+
924
+ ### F.2 `glossarySync` Flow-Runner Operation
925
+
926
+ `KnowledgeFlowRunner.glossarySync(options)` (also module-level `glossarySync({ store, ... })`):
927
+
928
+ | Option | Type | Default | Description |
929
+ |---|---|---|---|
930
+ | `sources` | `Array<string \| { category, prefix? }>` | `[]` | The configurable glossary source list: each entry is a canonical-doc record id, or a category selector. An unknown id is rejected (the list is evidence). Empty → opt-in no-op. |
931
+ | `termExtractor` | `(doc) => Array<{ term, definition }>` | `defaultTermExtractor` | Pluggable extractor; the default parses glossary-style lines (`**Term** — def`, `Term: def`, list items). |
932
+ | `conceptCategory` | `string` | source doc's category | Category for matched/proposed concepts. |
933
+ | `apply` | `boolean` | `false` | `false` → read-only plan. `true` → enact via store ops. |
934
+
935
+ **Classification.** Each extracted term is matched against existing `concept` records by **normalized
936
+ term** (case/space-insensitive title) within the resolved category:
937
+
938
+ - **gap** — no concept captures the term.
939
+ - **outdated** — a concept exists but its body has **drifted** from the canonical definition.
940
+ Drift is **whitespace-insensitive** (cosmetic reflow is not drift; substantive change is).
941
+ - **current** — the concept matches the canonical definition.
942
+
943
+ ### F.3 Consume-Never-Fork Enactment
944
+
945
+ In `apply` mode the plan is enacted through the existing gated ops, with the **canonical doc as the
946
+ proposer** (it is the evidence for the definition) — no new mutation path is forked:
947
+
948
+ - **gap** → `store.create(concept)` then `store.propose` + `store.apply` (doc → concept).
949
+ - **outdated** → `store.propose` + `store.apply` on the existing concept (`new_body` = canonical).
950
+
951
+ `glossarySync` returns `{ sourcesAudited, entries, gaps, outdated, current, applied, telemetryEvents }`.
952
+ Every classified entry cites its evidence — the source doc id + title, the term, and the canonical
953
+ definition (outdated entries also cite the drifted `currentBody`). Gate telemetry is emitted at
954
+ `collect-gate`, `diff-gate`, and `propose-gate` (`knowledge.glossary-sync`).
955
+
956
+ ## Addendum G — Contradiction Detection (Hygiene #2, #106)
957
+
958
+ ### G.1 Read-Only Maintenance Layer
959
+
960
+ `knowledge.detect-contradictions` is the second of the Knowledge Kit's *maintenance* flows. Like
961
+ `audit-freshness` (Addendum D) it is a **read-only** survey: it NEVER mutates a record. It returns
962
+ *flags* identifying a conflicting pair; the operator routes each through an existing gated flow —
963
+ `knowledge.retire` to drop the stale assertion, or a fresh `capture`/`compile`/`consolidate` to
964
+ reconcile. The audit forks no new mutation path.
965
+
966
+ Contradiction is domain-sensitive (what counts as a conflict in `radar` differs from `decisions`),
967
+ so the audit is **optional and configurable**: it audits only the categories supplied in
968
+ `categories` (or, when omitted, every category present in the compiled set), and it judges conflicts
969
+ with a **pluggable contradiction fn**.
970
+
971
+ ### G.2 `detectContradictions` Flow-Runner Operation
972
+
973
+ `KnowledgeFlowRunner.detectContradictions(options)` (also the module-level
974
+ `detectContradictions({ store, ... })`) compares **compiled** records **within a category** and
975
+ flags conflicting assertions. Comparison is two-staged and reuses the Kit's existing pluggable
976
+ adapters (consume-never-fork):
977
+
978
+ 1. **Scope** with the `SimilarityDetector` (the same interface `synthesize` uses) — only records the
979
+ detector deems similar (about the same thing) are candidates. The vector similarity adapter drops
980
+ straight in.
981
+ 2. **Judge** with the `ContradictionDetector` — for each similar pair, decide whether the assertions
982
+ conflict.
983
+
984
+ **Options:**
985
+
986
+ | Option | Type | Default | Description |
987
+ |---|---|---|---|
988
+ | `categories` | `string[]` | all present | Categories to audit. Opt-in scoping; omit to audit every category in the compiled set. |
989
+ | `similarityDetector` | `fn` | `defaultSimilarityDetector` | Pluggable `(record, candidates, store) => string[]` — scopes which pairs are compared. |
990
+ | `contradictionDetector` | `fn` | `defaultContradictionDetector` | Pluggable `(recordA, recordB, store?) => null \| { reason }` — judges whether a pair conflicts. May be async. |
991
+ | `agent` | `string` | runner agent | Agent recorded on the audit telemetry. |
992
+
993
+ **Comparison scope:** retired records are excluded (the default `listByType` query drops them, and
994
+ `retired` is terminal); records are grouped by exact category, so cross-category pairs are never
995
+ formed; each unordered pair is compared at most once.
996
+
997
+ ### G.3 `ContradictionDetector` Interface and the Default
998
+
999
+ ```ts
1000
+ type ContradictionDetector = (
1001
+ recordA: Record,
1002
+ recordB: Record,
1003
+ store?: KnowledgeStoreAdapter
1004
+ ) => (null | { reason: string }) | Promise<null | { reason: string }>;
1005
+ ```
1006
+
1007
+ The two records passed in are already known to be *about the same thing* (the caller scopes by
1008
+ category + similarity). The detector's only job is to decide whether their assertions conflict.
1009
+ Return `null` for no conflict, or `{ reason }` carrying a human-readable explanation.
1010
+
1011
+ The default (`defaultContradictionDetector`) is a deliberately conservative **opposing-polarity
1012
+ heuristic**: it splits each body into clauses, tags each affirmative or negative by the presence of
1013
+ a negation, and reports a conflict when one record AFFIRMS a clause whose salient content tokens
1014
+ contain those of a clause the other NEGATES (token-containment, not exact equality, so trailing
1015
+ detail on one side does not hide the conflict). It is intentionally replaceable — an embedding/NLI
1016
+ model is the obvious upgrade, injected the same way the vector similarity adapter is.
1017
+
1018
+ ### G.4 Flag Evidence Guarantee
1019
+
1020
+ Every flag cites **both** conflicting record ids — a flag can never be emitted without them:
1021
+
1022
+ ```ts
1023
+ interface ContradictionFlag {
1024
+ recordIdA: string; // canonically ordered: recordIdA < recordIdB
1025
+ recordIdB: string;
1026
+ titleA: string;
1027
+ titleB: string;
1028
+ category: string; // the shared category
1029
+ reason: string; // why the contradiction fn fired
1030
+ }
1031
+ ```
1032
+
1033
+ `detectContradictions` returns `{ audited, compared, flags, telemetryEvents }` where `audited`
1034
+ counts the in-scope compiled records, `compared` counts the similar pairs the contradiction fn
1035
+ judged, and `flags` lists the conflicting pairs. Gate telemetry is emitted at `collect-gate` and
1036
+ `flag-gate` (`knowledge.detect-contradictions`).