@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
@@ -0,0 +1,383 @@
1
+ /**
2
+ * Knowledge Kit — Canonicalize-Category Eval Suite (#106 hygiene #4)
3
+ *
4
+ * knowledge.canonicalize-category audits category sprawl and proposes
5
+ * flattening / regrouping / retirement — each finding citing its evidence
6
+ * (the metric that fired + the offending category / record ids). The audit is
7
+ * READ-ONLY, OPTIONAL, and CONFIGURABLE (each check independently toggleable).
8
+ *
9
+ * Covers:
10
+ * - orphan-prefix: an intermediate prefix node holding no record directly but
11
+ * with descendants is flagged (→ flatten); a multi-segment prefix carrying
12
+ * exactly one record (deep path) is flagged (→ flatten).
13
+ * - too-many-leaves: a parent fanning out past the leaf budget is flagged
14
+ * (→ regroup) and lists the offending leaves; boundary (count == budget is
15
+ * NOT flagged, count > budget IS flagged).
16
+ * - implemented-active: a status:"active" record carrying an implemented
17
+ * marker tag is flagged (→ retire); marker matching is case-insensitive.
18
+ * - every finding cites its kind + category + recordIds + metric + evidence.
19
+ * - opt-in: a disabled check (orphan off / no leaf budget / empty markers)
20
+ * contributes no findings.
21
+ * - retired records are excluded from the survey (terminal).
22
+ * - read-only invariant: no record is mutated by the audit.
23
+ * - gate telemetry (survey-gate + propose-gate) is emitted.
24
+ * - module-level canonicalizeCategory export delegates to the runner.
25
+ *
26
+ * Run:
27
+ * node --test kits/knowledge/evals/canonicalize-category/suite.test.js
28
+ */
29
+
30
+ import { test, describe, before, after } from "node:test";
31
+ import assert from "node:assert/strict";
32
+ import * as fs from "node:fs";
33
+ import * as path from "node:path";
34
+ import * as os from "node:os";
35
+ import { fileURLToPath } from "node:url";
36
+
37
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
38
+ const KIT_ROOT = path.resolve(__dirname, "../..");
39
+
40
+ const adapterPath = path.join(KIT_ROOT, "adapters/default-store/index.js");
41
+ const runnerPath = path.join(KIT_ROOT, "adapters/flow-runner/index.js");
42
+
43
+ const { DefaultKnowledgeStore } = await import(adapterPath);
44
+ const { KnowledgeFlowRunner, canonicalizeCategory } = await import(runnerPath);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Helpers
48
+ // ---------------------------------------------------------------------------
49
+
50
+ function makeTempDir() {
51
+ return fs.mkdtempSync(path.join(os.tmpdir(), "knowledge-canonicalize-category-"));
52
+ }
53
+
54
+ function makeStore(dir) {
55
+ return new DefaultKnowledgeStore({ storeRoot: dir });
56
+ }
57
+
58
+ function makeRunner(store, dir) {
59
+ return new KnowledgeFlowRunner({
60
+ store,
61
+ workspace: dir,
62
+ agent: "canonicalize-category-test-runner",
63
+ sessionId: "canonicalize-category-session-001",
64
+ });
65
+ }
66
+
67
+ function readTelemetryEvents(dir) {
68
+ const sinkPath = path.join(dir, ".telemetry", "full.jsonl");
69
+ if (!fs.existsSync(sinkPath)) return [];
70
+ return fs.readFileSync(sinkPath, "utf8")
71
+ .trim()
72
+ .split("\n")
73
+ .filter(Boolean)
74
+ .map((line) => JSON.parse(line));
75
+ }
76
+
77
+ let _seq = 0;
78
+ function rec(store, { id, type = "raw", title, category, tags }) {
79
+ return store.create({
80
+ id: id || `rec-${++_seq}`,
81
+ type,
82
+ title: title || `Record ${id || _seq}`,
83
+ body: `Body of ${title || id || _seq}`,
84
+ category,
85
+ tags: tags || [],
86
+ provenance: { agent: "fixture" },
87
+ });
88
+ }
89
+
90
+ // ---------------------------------------------------------------------------
91
+ // Suite
92
+ // ---------------------------------------------------------------------------
93
+
94
+ describe("Knowledge Kit Canonicalize-Category Suite (#106)", () => {
95
+ let dir;
96
+ let store;
97
+ let runner;
98
+
99
+ before(async () => {
100
+ dir = makeTempDir();
101
+ store = makeStore(dir);
102
+ runner = makeRunner(store, dir);
103
+
104
+ // ── orphan-prefix: empty intermediate node ──────────────────────────────
105
+ // No record sits at "platform.api" directly, but two do under it. The
106
+ // "platform.api" prefix is an empty intermediate node → flatten.
107
+ await rec(store, { id: "api-auth", title: "Auth endpoint", category: "platform.api.auth" });
108
+ await rec(store, { id: "api-users", title: "Users endpoint", category: "platform.api.users" });
109
+
110
+ // ── orphan-prefix: single-record deep path ──────────────────────────────
111
+ // "archive.2019.notes.misc" carries exactly one record and nothing else in
112
+ // its subtree → a deep path with no branching value → flatten.
113
+ await rec(store, { id: "lonely", title: "Lone deep note", category: "archive.2019.notes.misc" });
114
+
115
+ // ── too-many-leaves: "tags" parent fans out to 4 direct leaf categories ──
116
+ await rec(store, { id: "t-red", title: "Red", category: "tags.red" });
117
+ await rec(store, { id: "t-green", title: "Green", category: "tags.green" });
118
+ await rec(store, { id: "t-blue", title: "Blue", category: "tags.blue" });
119
+ await rec(store, { id: "t-amber", title: "Amber", category: "tags.amber" });
120
+
121
+ // ── implemented-active: active record carrying an implemented marker ─────
122
+ await rec(store, {
123
+ id: "shipped-feature", title: "Shipped feature note",
124
+ category: "work.features", tags: ["Implemented", "q2"],
125
+ });
126
+ // A control: active but no marker → never flagged as implemented-active.
127
+ await rec(store, {
128
+ id: "open-feature", title: "Open feature note",
129
+ category: "work.features", tags: ["q2"],
130
+ });
131
+ });
132
+
133
+ after(() => {
134
+ if (dir) fs.rmSync(dir, { recursive: true, force: true });
135
+ });
136
+
137
+ const fullConfig = () => ({
138
+ checkOrphanPrefixes: true,
139
+ maxLeavesPerParent: 3,
140
+ implementedMarkers: ["implemented", "shipped"],
141
+ });
142
+
143
+ test("orphan-prefix: empty intermediate node is flagged for flattening", async () => {
144
+ const result = await runner.canonicalizeCategory(fullConfig());
145
+ const f = result.findings.find(
146
+ (x) => x.kind === "orphan-prefix" && x.category === "platform.api"
147
+ );
148
+ assert.ok(f, "platform.api (no direct record, has descendants) is flagged");
149
+ assert.equal(f.metric, "empty-intermediate-node");
150
+ assert.equal(f.proposedAction, "flatten");
151
+ assert.deepEqual(f.recordIds.sort(), ["api-auth", "api-users"]);
152
+ assert.equal(f.evidence.directRecordCount, 0);
153
+ assert.equal(f.evidence.subtreeRecordCount, 2);
154
+ });
155
+
156
+ test("orphan-prefix: single-record deep path is flagged for flattening", async () => {
157
+ const result = await runner.canonicalizeCategory(fullConfig());
158
+ const f = result.findings.find(
159
+ (x) => x.kind === "orphan-prefix" && x.category === "archive.2019.notes.misc"
160
+ );
161
+ assert.ok(f, "the deep single-record path is flagged");
162
+ assert.equal(f.metric, "single-record-deep-path");
163
+ assert.equal(f.proposedAction, "flatten");
164
+ assert.deepEqual(f.recordIds, ["lonely"]);
165
+ assert.equal(f.evidence.subtreeRecordCount, 1);
166
+ });
167
+
168
+ test("a lone deep record yields ONE finding, not one per empty ancestor", async () => {
169
+ // archive.2019.notes.misc carries one record; its empty ancestors (archive,
170
+ // archive.2019, archive.2019.notes) must NOT each produce a finding — the
171
+ // single-record-deep-path finding at the leaf subsumes the whole chain.
172
+ const result = await runner.canonicalizeCategory({ checkOrphanPrefixes: true });
173
+ const archiveFindings = result.findings.filter(
174
+ (x) => x.kind === "orphan-prefix" && x.category.startsWith("archive")
175
+ );
176
+ assert.equal(archiveFindings.length, 1, "exactly one finding for the lone deep record");
177
+ assert.equal(archiveFindings[0].category, "archive.2019.notes.misc");
178
+ assert.equal(archiveFindings[0].metric, "single-record-deep-path");
179
+ });
180
+
181
+ test("too-many-leaves: parent past the fan-out budget is flagged and lists the leaves", async () => {
182
+ const result = await runner.canonicalizeCategory(fullConfig());
183
+ const f = result.findings.find((x) => x.kind === "too-many-leaves" && x.category === "tags");
184
+ assert.ok(f, "tags (4 leaves > budget 3) is flagged");
185
+ assert.equal(f.metric, "leaf-fan-out");
186
+ assert.equal(f.proposedAction, "regroup");
187
+ assert.equal(f.evidence.leafCount, 4);
188
+ assert.equal(f.evidence.maxLeavesPerParent, 3);
189
+ assert.deepEqual(
190
+ f.evidence.leaves.sort(),
191
+ ["tags.amber", "tags.blue", "tags.green", "tags.red"]
192
+ );
193
+ assert.deepEqual(
194
+ f.recordIds.sort(),
195
+ ["t-amber", "t-blue", "t-green", "t-red"]
196
+ );
197
+ });
198
+
199
+ test("too-many-leaves boundary: count == budget is NOT flagged; count > budget IS flagged", async () => {
200
+ const bdir = makeTempDir();
201
+ const bstore = makeStore(bdir);
202
+ const brunner = makeRunner(bstore, bdir);
203
+ try {
204
+ // Parent "a" has exactly 3 leaves; parent "b" has 4.
205
+ await rec(bstore, { id: "a1", category: "a.one" });
206
+ await rec(bstore, { id: "a2", category: "a.two" });
207
+ await rec(bstore, { id: "a3", category: "a.three" });
208
+ await rec(bstore, { id: "b1", category: "b.one" });
209
+ await rec(bstore, { id: "b2", category: "b.two" });
210
+ await rec(bstore, { id: "b3", category: "b.three" });
211
+ await rec(bstore, { id: "b4", category: "b.four" });
212
+ const result = await brunner.canonicalizeCategory({
213
+ checkOrphanPrefixes: false,
214
+ maxLeavesPerParent: 3,
215
+ });
216
+ const cats = result.findings
217
+ .filter((x) => x.kind === "too-many-leaves")
218
+ .map((x) => x.category);
219
+ assert.ok(!cats.includes("a"), "3 leaves == budget is NOT flagged");
220
+ assert.ok(cats.includes("b"), "4 leaves > budget IS flagged");
221
+ } finally {
222
+ fs.rmSync(bdir, { recursive: true, force: true });
223
+ }
224
+ });
225
+
226
+ test("implemented-active: active record with an implemented marker is flagged for retirement (case-insensitive)", async () => {
227
+ const result = await runner.canonicalizeCategory(fullConfig());
228
+ const f = result.findings.find(
229
+ (x) => x.kind === "implemented-active" && x.recordIds[0] === "shipped-feature"
230
+ );
231
+ assert.ok(f, "the active+Implemented-tagged record is flagged");
232
+ assert.equal(f.metric, "implemented-marker-on-active");
233
+ assert.equal(f.proposedAction, "retire");
234
+ assert.equal(f.evidence.status, "active");
235
+ // "Implemented" tag matches the "implemented" marker case-insensitively.
236
+ assert.deepEqual(f.evidence.matchedMarkers, ["Implemented"]);
237
+ // The unmarked active record is never flagged as implemented-active.
238
+ assert.ok(
239
+ !result.findings.some(
240
+ (x) => x.kind === "implemented-active" && x.recordIds[0] === "open-feature"
241
+ ),
242
+ "an active record with no implemented marker is not flagged"
243
+ );
244
+ });
245
+
246
+ test("every finding cites its kind + category + recordIds + metric + evidence + proposedAction", async () => {
247
+ const result = await runner.canonicalizeCategory(fullConfig());
248
+ assert.ok(result.findings.length > 0, "the configured fixture produces findings");
249
+ const KINDS = new Set(["orphan-prefix", "too-many-leaves", "implemented-active"]);
250
+ const ACTIONS = new Set(["flatten", "regroup", "retire"]);
251
+ for (const f of result.findings) {
252
+ assert.ok(KINDS.has(f.kind), `finding kind is recognised: ${f.kind}`);
253
+ assert.ok(typeof f.category === "string", "finding cites a category");
254
+ assert.ok(Array.isArray(f.recordIds) && f.recordIds.length > 0, "finding cites offending record ids");
255
+ assert.ok(f.metric, "finding cites the metric that fired");
256
+ assert.ok(f.evidence && typeof f.evidence === "object", "finding carries an evidence object");
257
+ assert.ok(f.evidence.reason, "evidence explains the finding");
258
+ assert.ok(ACTIONS.has(f.proposedAction), `finding proposes a valid action: ${f.proposedAction}`);
259
+ }
260
+ });
261
+
262
+ test("opt-in: each check is independently toggleable — disabled checks contribute no findings", async () => {
263
+ // Only orphan-prefix on; no leaf budget, no markers.
264
+ const orphanOnly = await runner.canonicalizeCategory({ checkOrphanPrefixes: true });
265
+ assert.ok(
266
+ orphanOnly.findings.every((f) => f.kind === "orphan-prefix"),
267
+ "with no leaf budget and no markers, only orphan-prefix findings appear"
268
+ );
269
+
270
+ // All checks off → no findings at all.
271
+ const allOff = await runner.canonicalizeCategory({
272
+ checkOrphanPrefixes: false,
273
+ // maxLeavesPerParent omitted → leaf check disabled
274
+ implementedMarkers: [], // implemented check disabled
275
+ });
276
+ assert.equal(allOff.findings.length, 0, "all checks disabled → no findings (opt-in)");
277
+
278
+ // Only implemented-active on.
279
+ const implOnly = await runner.canonicalizeCategory({
280
+ checkOrphanPrefixes: false,
281
+ implementedMarkers: ["implemented", "shipped"],
282
+ });
283
+ assert.ok(
284
+ implOnly.findings.every((f) => f.kind === "implemented-active"),
285
+ "only implemented-active findings when only that check is enabled"
286
+ );
287
+ assert.ok(
288
+ implOnly.findings.some((f) => f.recordIds[0] === "shipped-feature"),
289
+ "the marked record is still caught"
290
+ );
291
+ });
292
+
293
+ test("retired records are excluded from the survey (terminal)", async () => {
294
+ const rdir = makeTempDir();
295
+ const rstore = makeStore(rdir);
296
+ const rrunner = makeRunner(rstore, rdir);
297
+ try {
298
+ // A record that WOULD be implemented-active, then actually retire it.
299
+ await rec(rstore, {
300
+ id: "done-and-retired", title: "Done and retired",
301
+ category: "work.features", tags: ["implemented"],
302
+ });
303
+ await rstore.retire("done-and-retired", "retired", {
304
+ agent: "fixture",
305
+ rationale: "Already retired properly.",
306
+ });
307
+ const result = await rrunner.canonicalizeCategory({
308
+ checkOrphanPrefixes: true,
309
+ maxLeavesPerParent: 1,
310
+ implementedMarkers: ["implemented"],
311
+ });
312
+ assert.ok(
313
+ !result.findings.some((f) => f.recordIds.includes("done-and-retired")),
314
+ "a retired record is never surfaced as sprawl"
315
+ );
316
+ } finally {
317
+ fs.rmSync(rdir, { recursive: true, force: true });
318
+ }
319
+ });
320
+
321
+ test("read-only invariant: the audit mutates no record", async () => {
322
+ const before = {};
323
+ for (const id of ["api-auth", "lonely", "t-red", "shipped-feature"]) {
324
+ before[id] = fs.readFileSync(path.join(dir, "records", `${id}.md`), "utf8");
325
+ }
326
+ await runner.canonicalizeCategory(fullConfig());
327
+ for (const id of Object.keys(before)) {
328
+ const after = fs.readFileSync(path.join(dir, "records", `${id}.md`), "utf8");
329
+ assert.equal(after, before[id], `record ${id} is byte-identical after the audit`);
330
+ }
331
+ });
332
+
333
+ test("survey accounting: surveyed + categories reflect the working set", async () => {
334
+ const result = await runner.canonicalizeCategory(fullConfig());
335
+ // 9 fixture records were created.
336
+ assert.equal(result.surveyed, 9, "all 9 working-set records are surveyed");
337
+ assert.ok(result.categories > 0, "the distinct category-prefix count is reported");
338
+ });
339
+
340
+ test("invalid maxLeavesPerParent (< 1) is rejected", async () => {
341
+ await assert.rejects(
342
+ () => runner.canonicalizeCategory({ maxLeavesPerParent: 0 }),
343
+ /maxLeavesPerParent must be >= 1/
344
+ );
345
+ });
346
+
347
+ test("gate telemetry: survey-gate and propose-gate events are emitted", async () => {
348
+ const tdir = makeTempDir();
349
+ const tstore = makeStore(tdir);
350
+ const trunner = makeRunner(tstore, tdir);
351
+ try {
352
+ await rec(tstore, { id: "tg-1", category: "x.deep.path" });
353
+ const result = await trunner.canonicalizeCategory({ checkOrphanPrefixes: true });
354
+ assert.ok(
355
+ result.telemetryEvents.length >= 4,
356
+ "survey-gate + propose-gate produce at least 4 in/out events"
357
+ );
358
+ const persisted = readTelemetryEvents(tdir);
359
+ const flowEvents = persisted.filter((e) =>
360
+ JSON.stringify(e).includes("knowledge.canonicalize-category")
361
+ );
362
+ assert.ok(flowEvents.length > 0, "canonicalize-category telemetry is persisted to the sink");
363
+ } finally {
364
+ fs.rmSync(tdir, { recursive: true, force: true });
365
+ }
366
+ });
367
+
368
+ test("module-level canonicalizeCategory export delegates to the runner", async () => {
369
+ const result = await canonicalizeCategory({
370
+ store,
371
+ workspace: dir,
372
+ agent: "canonicalize-category-test-runner",
373
+ checkOrphanPrefixes: false,
374
+ implementedMarkers: ["implemented"],
375
+ });
376
+ assert.ok(
377
+ result.findings.some(
378
+ (f) => f.kind === "implemented-active" && f.recordIds[0] === "shipped-feature"
379
+ ),
380
+ "the module-level export produces the same findings as the runner method"
381
+ );
382
+ });
383
+ });
@@ -223,6 +223,117 @@ describe("Knowledge Kit Store Contract Suite", () => {
223
223
  // -----------------------------------------------------------------------
224
224
  // §3 links + graph index
225
225
  // -----------------------------------------------------------------------
226
+ describe("reindex: rebuild graph index from records (recovery, #106)", () => {
227
+ let dir, store;
228
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
229
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
230
+
231
+ test("recovers a lost graph index from records' links", async (t) => {
232
+ if (typeof store.reindex !== "function") { t.skip("adapter has no reindex()"); return; }
233
+ const aId = await store.create({ type: "raw", title: "A", body: "a", category: "test", provenance: { agent: "tester" } });
234
+ const bId = await store.create({
235
+ type: "compiled", title: "B", body: `see [[${aId}]]`, category: "test",
236
+ provenance: { agent: "tester", source_ids: [aId] },
237
+ });
238
+ // Records are the source of truth; destroy the derived index.
239
+ fs.rmSync(path.join(dir, "graph-index.json"), { force: true });
240
+
241
+ const result = await store.reindex();
242
+ assert.equal(result.records, 2, "all records scanned");
243
+ assert.equal(result.changed, true, "rebuild after loss reports drift");
244
+
245
+ const { forward } = await store.getLinks(bId);
246
+ assert.ok(forward.some((l) => l.target_id === aId && l.kind === "related"),
247
+ "b → a edge recovered into the index");
248
+ const { reverse } = await store.getLinks(aId);
249
+ assert.ok(reverse.some((l) => l.source_id === bId), "reverse edge recovered");
250
+ });
251
+
252
+ test("is idempotent on a clean index (no spurious drift)", async (t) => {
253
+ if (typeof store.reindex !== "function") { t.skip("adapter has no reindex()"); return; }
254
+ await store.create({ type: "raw", title: "Solo", body: "x", category: "test", provenance: { agent: "tester" } });
255
+ await store.reindex(); // canonicalize
256
+ const second = await store.reindex(); // expect a no-op
257
+ assert.equal(second.changed, false, "reindex of a clean index reports no change");
258
+ });
259
+
260
+ test("detects and repairs a corrupted index", async (t) => {
261
+ if (typeof store.reindex !== "function") { t.skip("adapter has no reindex()"); return; }
262
+ const aId = await store.create({ type: "raw", title: "CA", body: "a", category: "test", provenance: { agent: "tester" } });
263
+ const bId = await store.create({
264
+ type: "compiled", title: "CB", body: `ref [[${aId}]]`, category: "test",
265
+ provenance: { agent: "tester", source_ids: [aId] },
266
+ });
267
+ // Corrupt: a bogus edge plus the real edge missing.
268
+ fs.writeFileSync(path.join(dir, "graph-index.json"),
269
+ JSON.stringify({ schema_version: "1.0", forward: { bogus: [{ target_id: "ghost", kind: "related" }] }, reverse: {} }));
270
+
271
+ const result = await store.reindex();
272
+ assert.equal(result.changed, true, "corruption reported as drift");
273
+ const { forward } = await store.getLinks(bId);
274
+ assert.ok(forward.some((l) => l.target_id === aId), "real edge restored");
275
+ const ghost = await store.getLinks("bogus");
276
+ assert.equal(ghost.forward.length, 0, "bogus edge purged");
277
+ });
278
+ });
279
+
280
+ // -----------------------------------------------------------------------
281
+ // close-proposal: retire is the supported op for closing a spent proposal
282
+ // artifact on apply — active → retired, safely and idempotently (#106).
283
+ // -----------------------------------------------------------------------
284
+ describe("close-proposal: retire safely closes a proposal artifact (#106)", () => {
285
+ let dir, store;
286
+ before(() => { dir = makeTempDir(); store = makeStore(dir); });
287
+ after(() => fs.rmSync(dir, { recursive: true, force: true }));
288
+
289
+ test("retire closes an active proposal artifact (active → retired), record intact", async () => {
290
+ // The transient proposal artifact a propose→apply flow mints is a `raw`
291
+ // record. Closing it on apply is done via the existing retire op.
292
+ const artifactId = await store.create({
293
+ type: "raw",
294
+ title: "Retirement proposal: Some record",
295
+ body: "Retirement proposal for record X.",
296
+ category: "ops.decisions",
297
+ provenance: { agent: "tester", note: "Retirement proposal for X" },
298
+ });
299
+ const before = await store.get(artifactId);
300
+ assert.equal(before.status || "active", "active", "artifact starts active");
301
+
302
+ await store.retire(artifactId, "retired", {
303
+ agent: "tester",
304
+ rationale: "Auto-closing spent proposal artifact after apply (#106).",
305
+ });
306
+
307
+ const after = await store.get(artifactId);
308
+ assert.equal(after.status, "retired", "artifact is closed (retired) after apply");
309
+ assert.ok(after, "artifact is retired, not deleted");
310
+ assert.equal(after.body, before.body, "artifact body intact (non-destructive close)");
311
+ const log = (after.mutation_log || []).find((e) => e.op === "retire");
312
+ assert.ok(log, "close is recorded as a retire mutation-log entry");
313
+ });
314
+
315
+ test("re-closing an already-retired artifact is rejected (terminal — safe, no twin)", async () => {
316
+ const artifactId = await store.create({
317
+ type: "raw",
318
+ title: "Retirement proposal: Already closed",
319
+ body: "spent",
320
+ category: "ops.decisions",
321
+ provenance: { agent: "tester" },
322
+ });
323
+ await store.retire(artifactId, "retired", { agent: "tester", rationale: "first close" });
324
+
325
+ // A second close must be rejected by the transition table (retired is
326
+ // terminal) — the flow treats this as a safe no-op rather than spawning
327
+ // a double-prefixed twin.
328
+ await assertMissingEvidence(
329
+ () => store.retire(artifactId, "retired", { agent: "tester", rationale: "second close" }),
330
+ "re-close of a retired artifact"
331
+ );
332
+ const after = await store.get(artifactId);
333
+ assert.equal(after.status, "retired", "artifact stays retired (idempotent close)");
334
+ });
335
+ });
336
+
226
337
  describe("links: graph index consistency", () => {
227
338
  let dir, store;
228
339
  before(() => { dir = makeTempDir(); store = makeStore(dir); });