@kontourai/flow-agents 3.0.0 → 3.1.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 (131) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +143 -0
  6. package/build/src/cli/assignment-provider.js +106 -49
  7. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  8. package/build/src/cli/workflow-sidecar.js +392 -35
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +9 -0
  13. package/context/contracts/probe-docs-write-contract.md +187 -0
  14. package/context/scripts/hooks/config-protection.js +14 -1
  15. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  16. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  17. package/context/scripts/hooks/workflow-steering.js +42 -0
  18. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  19. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  20. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  21. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  22. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  23. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  24. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  25. package/docs/adr/0007-skill-audit.md +2 -0
  26. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  27. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  28. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  29. package/docs/adr/0011-mcp-posture.md +2 -0
  30. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  31. package/docs/adr/0013-context-lifecycle.md +2 -0
  32. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  33. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  34. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  35. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  36. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  37. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  38. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  39. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  40. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
  41. package/docs/adr/README.md +49 -0
  42. package/docs/adr/index.md +34 -0
  43. package/docs/decisions/agent-coordination.md +20 -0
  44. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  45. package/docs/decisions/context-lifecycle.md +18 -0
  46. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  47. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  48. package/docs/decisions/flow-kit.md +20 -0
  49. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  50. package/docs/decisions/graph-knowledge-provider.md +63 -0
  51. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  52. package/docs/decisions/index.md +19 -0
  53. package/docs/decisions/kit-dependency-ownership.md +18 -0
  54. package/docs/decisions/kit-operation-boundary.md +18 -0
  55. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  56. package/docs/decisions/kontour-resource-contract.md +18 -0
  57. package/docs/decisions/mcp-posture.md +18 -0
  58. package/docs/decisions/three-hard-boundary-model.md +18 -0
  59. package/docs/decisions/trust-reconcile.md +20 -0
  60. package/docs/decisions/typescript-source-policy.md +48 -0
  61. package/docs/decisions/workflow-enforcement.md +18 -0
  62. package/docs/decisions/workflow-trust-state.md +20 -0
  63. package/docs/fixture-ownership.md +1 -1
  64. package/docs/workflow-usage-guide.md +1 -1
  65. package/evals/ci/run-baseline.sh +4 -0
  66. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  67. package/evals/integration/test_current_json_per_actor.sh +516 -0
  68. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  69. package/evals/integration/test_gate_lockdown.sh +10 -0
  70. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  71. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  72. package/evals/run.sh +4 -0
  73. package/evals/static/test_knowledge_providers.sh +13 -4
  74. package/evals/static/test_workflow_skills.sh +15 -2
  75. package/kits/builder/skills/deliver/SKILL.md +17 -0
  76. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  77. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  78. package/kits/knowledge/adapters/default-store/index.js +92 -4
  79. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  80. package/kits/knowledge/adapters/shared/codec.js +141 -0
  81. package/kits/knowledge/docs/README.md +121 -2
  82. package/kits/knowledge/docs/store-contract.md +112 -4
  83. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  84. package/kits/knowledge/flows/promote.flow.json +84 -0
  85. package/kits/knowledge/kit.json +15 -0
  86. package/kits/knowledge/promote/distill.js +96 -0
  87. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  88. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  89. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  90. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  91. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  92. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  93. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  94. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  95. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  96. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  97. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  98. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  99. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  100. package/kits/knowledge/promote/health.js +137 -0
  101. package/kits/knowledge/promote/index.js +176 -0
  102. package/kits/knowledge/promote/ingest.js +103 -0
  103. package/kits/knowledge/promote/lib.js +132 -0
  104. package/kits/knowledge/promote/link.js +84 -0
  105. package/kits/knowledge/promote/promote.test.js +174 -0
  106. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  107. package/kits/knowledge/providers/index.js +1 -0
  108. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  109. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  110. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  111. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  112. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  113. package/kits/knowledge/providers/neo4j/index.js +280 -0
  114. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  115. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  116. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  117. package/package.json +4 -2
  118. package/scripts/README.md +1 -0
  119. package/scripts/freeze-adrs.mjs +364 -0
  120. package/scripts/hooks/config-protection.js +14 -1
  121. package/scripts/hooks/evidence-capture.js +4 -1
  122. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  123. package/scripts/hooks/lib/current-pointer.js +123 -0
  124. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  125. package/scripts/hooks/stop-goal-fit.js +4 -2
  126. package/scripts/hooks/workflow-steering.js +42 -0
  127. package/scripts/statusline/flow-agents-statusline.js +3 -1
  128. package/src/cli/assignment-provider.ts +137 -55
  129. package/src/cli/workflow-sidecar.ts +430 -33
  130. package/src/lib/flow-resolver.ts +35 -14
  131. package/src/tools/validate-source-tree.ts +2 -1
@@ -0,0 +1,280 @@
1
+ /**
2
+ * neo4j knowledge-store provider (opt-in personal default; file providers remain
3
+ * the portfolio default — issue #327).
4
+ *
5
+ * Implements the #317 read + proposals-only interface over Neo4j Community. The
6
+ * graph is a MATERIALIZED VIEW synced from the file/work-item providers (see
7
+ * sync.js) — the file stores stay the source of truth, and the write side is
8
+ * proposals-only so the graph never becomes an authority that bypasses the
9
+ * human-curated stores.
10
+ *
11
+ * Query/health verbs run Cypher-backed when a live Neo4j is present (the five
12
+ * canonical spike queries) and fall back to the portable JS spelling
13
+ * (graph-queries.js) otherwise — same answers, two backends (AC3/AC4).
14
+ *
15
+ * The driver is INJECTABLE: unit tests pass a fake driver (fake-driver.js) so
16
+ * the read side is conformance-tested with no Docker; live integration passes a
17
+ * real neo4j-driver. Absence of either degrades to file providers (AC4).
18
+ *
19
+ * @module providers/neo4j
20
+ */
21
+
22
+ import { node as buildNode, edge as buildEdge, proposal, provenance } from "../lib/model.js";
23
+ import { detectDuplicates, checkDependencyLinkIntegrity } from "../health/index.js";
24
+ import {
25
+ READ_NODES_CYPHER,
26
+ READ_EDGES_CYPHER,
27
+ CANONICAL_CYPHER,
28
+ renderBounds,
29
+ propsToNode,
30
+ propsToEdge,
31
+ } from "./cypher.js";
32
+ import * as jsq from "./graph-queries.js";
33
+
34
+ const PROVIDER_ID = "neo4j";
35
+
36
+ /** Coerce a neo4j-driver Integer (or number) to a JS number. */
37
+ function num(v) {
38
+ if (v == null) return null;
39
+ if (typeof v === "number") return v;
40
+ if (typeof v.toNumber === "function") return v.toNumber();
41
+ return Number(v);
42
+ }
43
+
44
+ export class Neo4jProvider {
45
+ /**
46
+ * @param {object} options
47
+ * @param {object} [options.driver] neo4j driver or injected fake
48
+ * @param {string} [options.database]
49
+ * @param {{nodes,edges}} [options.sourceGraph] read directly from memory (degraded/test)
50
+ * @param {string} [options.agent]
51
+ */
52
+ constructor({ driver, database, sourceGraph, agent } = {}) {
53
+ if (!driver && !sourceGraph) {
54
+ throw new Error("Neo4jProvider requires { driver } or { sourceGraph }");
55
+ }
56
+ this.driver = driver;
57
+ this.database = database;
58
+ this.sourceGraph = sourceGraph;
59
+ this.agent = agent;
60
+ this.id = PROVIDER_ID;
61
+ // Live Cypher analytics only when a real driver advertises support; the fake
62
+ // driver and the in-memory path use the portable JS backend.
63
+ this._cypherAnalytics = Boolean(driver && driver.supportsCypherAnalytics);
64
+ }
65
+
66
+ capabilities() {
67
+ return {
68
+ id: PROVIDER_ID,
69
+ node_types: ["issue", "decision", "note", "session", "person"],
70
+ edge_types: ["blocks", "evidence-of", "supersedes", "merged-into", "mentions", "relates"],
71
+ writable: false,
72
+ write_mode: "proposals-only",
73
+ proposal_targets: ["create-node", "add-edge"],
74
+ source_of_truth: "file providers (vault, git-repo, work-item); the graph is a synced materialized view",
75
+ };
76
+ }
77
+
78
+ _session() {
79
+ return this.driver.session(this.database ? { database: this.database } : undefined);
80
+ }
81
+
82
+ async _run(cypher, params) {
83
+ const session = this._session();
84
+ try {
85
+ return await session.run(cypher, params);
86
+ } finally {
87
+ await session.close();
88
+ }
89
+ }
90
+
91
+
92
+ // The graph is a materialized VIEW: neo4j produced this read, but the element
93
+ // originated in a curated store. Stamp provider=neo4j (what the contract's
94
+ // provenance.provider means — "the provider that produced the element") while
95
+ // preserving the origin store in source + locator, so every fact stays
96
+ // traceable back to where it came from.
97
+ _viewProvenance(orig = {}) {
98
+ const originProvider = orig.provider && orig.provider !== PROVIDER_ID ? orig.provider : null;
99
+ const locatorBits = [];
100
+ if (originProvider) locatorBits.push(`origin:${originProvider}`);
101
+ if (orig.locator) locatorBits.push(orig.locator);
102
+ const prov = {
103
+ provider: PROVIDER_ID,
104
+ source: orig.source || "neo4j://materialized-view",
105
+ retrieved_at: orig.retrieved_at || new Date().toISOString(),
106
+ };
107
+ if (locatorBits.length) prov.locator = locatorBits.join("#");
108
+ if (this.agent) prov.agent = this.agent;
109
+ return prov;
110
+ }
111
+
112
+ async readNodes(options = {}) {
113
+ let nodes;
114
+ if (this.sourceGraph && !this.driver) {
115
+ nodes = (this.sourceGraph.nodes || []).map((n) => this._normalizeNode(n));
116
+ } else {
117
+ const res = await this._run(READ_NODES_CYPHER);
118
+ nodes = res.records.map((r) => { const n = propsToNode(r.get("props")); n.provenance = this._viewProvenance(n.provenance); return n; });
119
+ }
120
+ if (options.type) return nodes.filter((n) => n.type === options.type);
121
+ return nodes;
122
+ }
123
+
124
+ async readEdges() {
125
+ if (this.sourceGraph && !this.driver) {
126
+ return (this.sourceGraph.edges || []).map((e) => this._normalizeEdge(e));
127
+ }
128
+ const res = await this._run(READ_EDGES_CYPHER);
129
+ return res.records.map((r) => { const e = propsToEdge(r.get("props")); e.provenance = this._viewProvenance(e.provenance); return e; });
130
+ }
131
+
132
+ // Normalise an in-memory node/edge through the model constructors so the
133
+ // sourceGraph path emits the same schema-clean shape as the Cypher path.
134
+ _normalizeNode(n) {
135
+ return buildNode({
136
+ id: n.id,
137
+ type: n.type,
138
+ title: n.title,
139
+ body: n.body,
140
+ attributes: n.attributes,
141
+ provenance: this._viewProvenance(n.provenance || {}),
142
+ });
143
+ }
144
+
145
+ _normalizeEdge(e) {
146
+ return buildEdge({
147
+ id: e.id,
148
+ type: e.type,
149
+ from: e.from,
150
+ to: e.to,
151
+ resolved: e.resolved,
152
+ attributes: e.attributes,
153
+ provenance: this._viewProvenance(e.provenance || {}),
154
+ });
155
+ }
156
+
157
+ async queryByType(type) {
158
+ return this.readNodes({ type });
159
+ }
160
+
161
+ async readGraph() {
162
+ const [nodes, edges] = await Promise.all([this.readNodes(), this.readEdges()]);
163
+ return { nodes, edges };
164
+ }
165
+
166
+ /**
167
+ * Proposals-only write. The graph is a view: an intended change is rendered as
168
+ * the Cypher an enactor WOULD run, but proposeWrite never mutates the store,
169
+ * and the rationale is explicit that the real change must land in the source
170
+ * store and reach the graph via `sync` — the graph is never an authority that
171
+ * bypasses the curated stores.
172
+ */
173
+ async proposeWrite(intent = {}) {
174
+ const kind = intent.kind === "add-edge" ? "add-edge" : "create-node";
175
+ let rendered;
176
+ let payload;
177
+ if (kind === "add-edge") {
178
+ const { type = "relates", from, to } = intent;
179
+ if (!from || !to) throw new Error("neo4j proposeWrite add-edge requires intent.from and intent.to");
180
+ const rel = String(type).toUpperCase().replace(/[^A-Z0-9]+/g, "_");
181
+ payload = { type, from, to };
182
+ rendered = `MATCH (a {id:$from}),(b {id:$to}) MERGE (a)-[:${rel}]->(b) // proposal — enact upstream, then \`sync\``;
183
+ } else {
184
+ const { type = "note", title } = intent;
185
+ if (!title) throw new Error("neo4j proposeWrite create-node requires intent.title");
186
+ const label = String(type).charAt(0).toUpperCase() + String(type).slice(1);
187
+ payload = { type, title, body: intent.body || "" };
188
+ rendered = `MERGE (n:${label} {id:$id}) SET n.title=$title // proposal — enact upstream, then \`sync\``;
189
+ }
190
+ return proposal({
191
+ provider: PROVIDER_ID,
192
+ kind,
193
+ target: { store: "neo4j (materialized view)" },
194
+ payload,
195
+ rendered,
196
+ rationale:
197
+ "Proposed graph change. The graph is a materialized VIEW: enact the change in the source store (vault / git-repo / work-item) and re-run `sync` — never write the graph directly as an authority.",
198
+ provenance: provenance({ provider: PROVIDER_ID, source: "neo4j://materialized-view", agent: this.agent }),
199
+ });
200
+ }
201
+
202
+ // --- canonical query / health verbs -------------------------------------
203
+
204
+ async transitiveBlockers(rootId, opts = {}) {
205
+ const maxDepth = opts.maxDepth || 10;
206
+ if (this._cypherAnalytics) {
207
+ const res = await this._run(renderBounds(CANONICAL_CYPHER.transitiveBlockers, maxDepth), { rootId });
208
+ return res.records.map((r) => ({ id: r.get("id"), state: r.get("state") ?? null }));
209
+ }
210
+ return jsq.transitiveBlockers(await this.readGraph(), { rootId, maxDepth });
211
+ }
212
+
213
+ async contradictionCandidates(opts = {}) {
214
+ if (this._cypherAnalytics) {
215
+ const res = await this._run(CANONICAL_CYPHER.contradictionCandidates, {});
216
+ return res.records.map((r) => ({
217
+ a_id: r.get("a_id"),
218
+ b_id: r.get("b_id"),
219
+ a_status: r.get("a_status"),
220
+ b_status: r.get("b_status"),
221
+ title: r.get("title"),
222
+ }));
223
+ }
224
+ return jsq.contradictionCandidates(await this.readGraph(), opts);
225
+ }
226
+
227
+ async orphans(opts = {}) {
228
+ if (this._cypherAnalytics) {
229
+ const res = await this._run(CANONICAL_CYPHER.orphans, {});
230
+ return res.records.map((r) => ({ id: r.get("id"), label: r.get("label") }));
231
+ }
232
+ return jsq.orphans(await this.readGraph(), opts);
233
+ }
234
+
235
+ async duplicateCandidates(opts = {}) {
236
+ const threshold = typeof opts.threshold === "number" ? opts.threshold : 0.7;
237
+ if (this._cypherAnalytics) {
238
+ const res = await this._run(CANONICAL_CYPHER.duplicateStemmed, { threshold });
239
+ return res.records.map((r) => ({
240
+ a_id: r.get("a_id"),
241
+ b_id: r.get("b_id"),
242
+ a_title: r.get("a_title"),
243
+ b_title: r.get("b_title"),
244
+ }));
245
+ }
246
+ return jsq.duplicateCandidates(await this.readGraph(), { ...opts, threshold });
247
+ }
248
+
249
+ async shortestPath(fromId, toId, opts = {}) {
250
+ const maxDepth = opts.maxDepth || 8;
251
+ if (this._cypherAnalytics) {
252
+ const res = await this._run(renderBounds(CANONICAL_CYPHER.shortestPath, maxDepth), { fromId, toId });
253
+ const rec = res.records[0];
254
+ if (!rec) return null;
255
+ return { path: rec.get("path"), length: num(rec.get("length")) };
256
+ }
257
+ return jsq.shortestPath(await this.readGraph(), { fromId, toId, maxDepth });
258
+ }
259
+
260
+ /** Provider-agnostic duplicate-detection health report (schema-valid). */
261
+ async detectDuplicates(opts = {}) {
262
+ return detectDuplicates(await this.readGraph(), { provider: PROVIDER_ID, ...opts });
263
+ }
264
+
265
+ /** Provider-agnostic dependency-link-integrity health report (schema-valid). */
266
+ async checkDependencyLinkIntegrity(opts = {}) {
267
+ return checkDependencyLinkIntegrity(await this.readGraph(), { provider: PROVIDER_ID, ...opts });
268
+ }
269
+ }
270
+
271
+ export default Neo4jProvider;
272
+ export { syncToNeo4j, materializeGraph, planSync, computeDigest } from "./sync.js";
273
+ export {
274
+ resolveNeo4jConfig,
275
+ createDriver,
276
+ isReachable,
277
+ selectKnowledgeProvider,
278
+ } from "./connection.js";
279
+ export { CANONICAL_QUERIES } from "./graph-queries.js";
280
+ export { CANONICAL_CYPHER, DIALECT_NOTES } from "./cypher.js";
@@ -0,0 +1,122 @@
1
+ /**
2
+ * neo4j provider — LIVE integration test (gated).
3
+ *
4
+ * Runs against a real Neo4j Community instance ONLY when NEO4J_URI is set and
5
+ * reachable; otherwise it SKIPS LOUDLY (CI has no Neo4j — the CI proof is the
6
+ * unit tests + conformance with the injected fake driver). To run locally:
7
+ *
8
+ * docker run -d --name kg-neo4j -p 7474:7474 -p 7687:7687 \
9
+ * -e NEO4J_AUTH=neo4j/testpassword neo4j:5-community
10
+ * NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=testpassword \
11
+ * node --test kits/knowledge/providers/neo4j/integration.test.js
12
+ *
13
+ * Proves end-to-end what the fake driver can only approximate: real MERGE
14
+ * idempotency counters (AC1) and the five canonical queries executing as LIVE
15
+ * Cypher (AC3), asserted to agree with the portable JS backend (parity).
16
+ *
17
+ * @module providers/neo4j/integration.test
18
+ */
19
+
20
+ import { test, describe, before, after } from "node:test";
21
+ import assert from "node:assert/strict";
22
+ import * as fs from "node:fs";
23
+ import * as path from "node:path";
24
+ import { fileURLToPath } from "node:url";
25
+
26
+ import { Neo4jProvider } from "./index.js";
27
+ import { syncToNeo4j } from "./sync.js";
28
+ import { resolveNeo4jConfig, createDriver, isReachable } from "./connection.js";
29
+ import * as jsq from "./graph-queries.js";
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ const GRAPH = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "spike-ground-truth.json"), "utf8"));
33
+
34
+ const config = resolveNeo4jConfig();
35
+ let driver = null;
36
+ let live = false;
37
+
38
+ before(async () => {
39
+ if (!config) return;
40
+ driver = await createDriver(config);
41
+ live = await isReachable(driver);
42
+ });
43
+
44
+ after(async () => {
45
+ if (driver) await driver.close();
46
+ });
47
+
48
+ describe("neo4j LIVE integration", () => {
49
+ test("live Neo4j round-trip, idempotent sync (AC1) and canonical Cypher queries (AC3)", async (t) => {
50
+ if (!config) {
51
+ t.skip("SKIP (loud): NEO4J_URI unset — live Neo4j integration not run. See file header to run locally.");
52
+ return;
53
+ }
54
+ if (!live) {
55
+ t.skip(`SKIP (loud): Neo4j configured (${config.uri}) but not reachable — live integration not run.`);
56
+ return;
57
+ }
58
+
59
+ const graph = { nodes: JSON.parse(JSON.stringify(GRAPH.nodes)), edges: JSON.parse(JSON.stringify(GRAPH.edges)) };
60
+
61
+ // Clean slate.
62
+ {
63
+ const s = driver.session({ database: config.database });
64
+ try {
65
+ await s.run("MATCH (n) DETACH DELETE n");
66
+ } finally {
67
+ await s.close();
68
+ }
69
+ }
70
+
71
+ // AC1: first sync writes; second reports zero; forced re-sync also zero.
72
+ const first = await syncToNeo4j({ driver, database: config.database, graph });
73
+ assert.ok(first.writes > 0, "first live sync must write");
74
+ const second = await syncToNeo4j({ driver, database: config.database, graph });
75
+ assert.equal(second.writes, 0, "second live sync must report zero writes (MERGE idempotency)");
76
+ const forced = await syncToNeo4j({ driver, database: config.database, graph, force: true });
77
+ assert.equal(forced.writes, 0, "forced live re-sync of identical data must produce zero writes");
78
+
79
+ const provider = new Neo4jProvider({ driver, database: config.database, agent: "integration" });
80
+ assert.equal(provider._cypherAnalytics, true, "live driver must use the Cypher analytics backend");
81
+
82
+ // Read side round-trips.
83
+ const nodes = await provider.readNodes();
84
+ assert.ok(nodes.length >= GRAPH.nodes.length);
85
+
86
+ // AC3 via LIVE Cypher, asserted equal to the JS oracle over the same graph.
87
+ const blockers = await provider.transitiveBlockers("issue:313");
88
+ assert.deepEqual(
89
+ blockers.map((b) => b.id).sort(),
90
+ ["issue:310", "issue:312"],
91
+ "Q1 live Cypher: #313 blockers = {#310,#312}, not #317",
92
+ );
93
+
94
+ const contradictions = await provider.contradictionCandidates();
95
+ assert.ok(
96
+ contradictions.some((c) => c.a_id === "decision:adr-0007" && c.b_id === "decision:adr-0007b"),
97
+ "Q2 live Cypher: ADR-0007 collision flagged",
98
+ );
99
+
100
+ const orphans = await provider.orphans();
101
+ const orphanIds = orphans.map((o) => o.id);
102
+ assert.ok(orphanIds.includes("decision:orphan-adr"), "Q3 live Cypher: stale decision orphan");
103
+ assert.ok(!orphanIds.includes("session:s1"), "Q3 live Cypher: leaf types excluded");
104
+
105
+ const dups = await provider.duplicateCandidates();
106
+ assert.ok(
107
+ dups.some(
108
+ (d) =>
109
+ (d.a_id === "issue:traverse-8" && d.b_id === "issue:traverse-14") ||
110
+ (d.a_id === "issue:traverse-14" && d.b_id === "issue:traverse-8"),
111
+ ),
112
+ "Q4 live Cypher: stemmed dup catches the traverse pair",
113
+ );
114
+ // Parity: the same stemmed dup is (not) found by exact tokens in JS.
115
+ const jsDups = jsq.duplicateCandidates(graph, { threshold: 0.7 });
116
+ const jsHit = jsDups.find((d) => d.a_id.startsWith("issue:traverse") && d.b_id.startsWith("issue:traverse"));
117
+ assert.ok(jsHit && jsHit.caught_only_by_stemming, "JS oracle agrees: caught only by stemming");
118
+
119
+ const sp = await provider.shortestPath("decision:adr-0011", "decision:adr-0021");
120
+ assert.equal(sp.length, 2, "Q5 live Cypher: shortest path length 2");
121
+ });
122
+ });
@@ -0,0 +1,219 @@
1
+ /**
2
+ * neo4j provider — unit tests (CI-safe, no Docker, injected fake driver).
3
+ *
4
+ * Covers the parts of issue #327 that do NOT need a live server:
5
+ * - sync idempotency (AC1): writes>0 on first sync, 0 on re-sync, 0 on a
6
+ * FORCED re-sync (hash-guard) — proven against the fake driver's counters.
7
+ * - canonical queries (AC3): the five spike queries over the ground-truth
8
+ * fixture reproduce the spike's results, incl. the stemmed-dup case.
9
+ * - marker tolerance: a throwing provider does not kill the sync.
10
+ * - graceful degradation (AC4): selection falls back to file providers with a
11
+ * clear one-line message when no Neo4j is reachable / configured.
12
+ * - proposals-only write.
13
+ *
14
+ * Run: node --test kits/knowledge/providers/neo4j/neo4j.test.js
15
+ */
16
+
17
+ import { test, describe } from "node:test";
18
+ import assert from "node:assert/strict";
19
+ import * as fs from "node:fs";
20
+ import * as path from "node:path";
21
+ import { fileURLToPath } from "node:url";
22
+
23
+ import { Neo4jProvider } from "./index.js";
24
+ import { syncToNeo4j, materializeGraph, planSync, computeDigest } from "./sync.js";
25
+ import { selectKnowledgeProvider } from "./connection.js";
26
+ import { makeFakeDriver } from "./fake-driver.js";
27
+ import * as jsq from "./graph-queries.js";
28
+ import { loadSchemas } from "../lib/model.js";
29
+ import { validate } from "../lib/schema-validate.js";
30
+
31
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
32
+ const GRAPH = JSON.parse(fs.readFileSync(path.join(__dirname, "fixtures", "spike-ground-truth.json"), "utf8"));
33
+ const schemas = loadSchemas();
34
+
35
+ function fixtureGraph() {
36
+ return { nodes: JSON.parse(JSON.stringify(GRAPH.nodes)), edges: JSON.parse(JSON.stringify(GRAPH.edges)) };
37
+ }
38
+
39
+ describe("neo4j sync — idempotency (AC1)", () => {
40
+ test("first sync writes; second sync reports zero writes; forced re-sync also zero (hash-guard)", async () => {
41
+ const driver = makeFakeDriver();
42
+ const graph = fixtureGraph();
43
+
44
+ const first = await syncToNeo4j({ driver, graph });
45
+ assert.ok(first.writes > 0, `expected writes on first sync, got ${first.writes}`);
46
+ assert.equal(first.unchanged, false);
47
+ assert.ok(first.digest && first.snapshot.synced_at);
48
+
49
+ const second = await syncToNeo4j({ driver, graph });
50
+ assert.equal(second.writes, 0, "second sync must report zero writes");
51
+ assert.equal(second.unchanged, true);
52
+
53
+ // Force past the digest short-circuit: the hash-guarded MERGE must STILL
54
+ // produce zero created nodes/rels and zero property sets on identical data.
55
+ const forced = await syncToNeo4j({ driver, graph, force: true });
56
+ assert.equal(forced.writes, 0, "forced re-sync of identical data must produce zero writes");
57
+ });
58
+
59
+ test("planSync is a pure no-op when the digest is unchanged", () => {
60
+ const graph = fixtureGraph();
61
+ const digest = computeDigest(graph);
62
+ const plan = planSync(graph, digest);
63
+ assert.equal(plan.unchanged, true);
64
+ assert.equal(plan.statements.length, 0);
65
+ const changed = planSync(graph, "different-digest");
66
+ assert.equal(changed.unchanged, false);
67
+ assert.ok(changed.statements.length > 0);
68
+ });
69
+ });
70
+
71
+ describe("neo4j read side round-trips through the (fake) driver", () => {
72
+ test("synced graph reads back as schema-valid nodes/edges", async () => {
73
+ const driver = makeFakeDriver();
74
+ await syncToNeo4j({ driver, graph: fixtureGraph() });
75
+ const provider = new Neo4jProvider({ driver });
76
+
77
+ const nodes = await provider.readNodes();
78
+ assert.ok(nodes.length > 0);
79
+ for (const n of nodes) {
80
+ const { valid, errors } = validate(n, schemas.node);
81
+ assert.ok(valid, `node ${n.id} invalid: ${errors.join(", ")}`);
82
+ }
83
+ const edges = await provider.readEdges();
84
+ for (const e of edges) {
85
+ const { valid, errors } = validate(e, schemas.edge);
86
+ assert.ok(valid, `edge ${e.id} invalid: ${errors.join(", ")}`);
87
+ }
88
+ // Edges to nodes present in the fixture materialize; all 8 fixture edges have
89
+ // resolvable endpoints, so the view keeps them.
90
+ assert.equal(edges.length, GRAPH.edges.length);
91
+ });
92
+ });
93
+
94
+ describe("neo4j canonical queries reproduce the spike ground truth (AC3)", () => {
95
+ const provider = new Neo4jProvider({ sourceGraph: fixtureGraph() });
96
+
97
+ test("Q1 transitive blockers of #313 = {#312 OPEN, #310 CLOSED}, NOT #317", async () => {
98
+ const blockers = await provider.transitiveBlockers("issue:313");
99
+ const ids = blockers.map((b) => b.id).sort();
100
+ assert.deepEqual(ids, ["issue:310", "issue:312"]);
101
+ assert.equal(blockers.find((b) => b.id === "issue:310").state, "CLOSED");
102
+ assert.equal(blockers.find((b) => b.id === "issue:312").state, "OPEN");
103
+ assert.ok(!ids.includes("issue:317"), "the spike headline: no #313 -> #317 edge exists");
104
+ });
105
+
106
+ test("Q2 contradiction candidates catch the ADR-0007 collision", async () => {
107
+ const cands = await provider.contradictionCandidates();
108
+ const hit = cands.find((c) => c.a_id === "decision:adr-0007" && c.b_id === "decision:adr-0007b");
109
+ assert.ok(hit, "ADR-0007 vs ADR-0007b (same subject, divergent status) must be flagged");
110
+ // A different-subject decision pair must NOT be a contradiction.
111
+ assert.ok(!cands.some((c) => c.a_id === "decision:adr-0011" && c.b_id === "decision:adr-0021"));
112
+ });
113
+
114
+ test("Q3 orphans are node-type aware: the stale decision surfaces, leaf types do not", async () => {
115
+ const orphans = await provider.orphans();
116
+ const ids = orphans.map((o) => o.id);
117
+ assert.ok(ids.includes("decision:orphan-adr"), "the undiscoverable decision is a meaningful orphan");
118
+ assert.ok(!ids.includes("session:s1"), "sessions are source-only leaves — never flagged");
119
+ assert.ok(!ids.includes("learning:l1"), "learnings are source-only leaves — never flagged");
120
+ });
121
+
122
+ test("Q4 duplicate detection needs stemming: traverse#14 ~ #8 caught only by stemming", async () => {
123
+ const dups = await provider.duplicateCandidates();
124
+ const hit = dups.find(
125
+ (d) =>
126
+ (d.a_id === "issue:traverse-8" && d.b_id === "issue:traverse-14") ||
127
+ (d.a_id === "issue:traverse-14" && d.b_id === "issue:traverse-8"),
128
+ );
129
+ assert.ok(hit, "the known traverse duplicate must be detected");
130
+ assert.equal(hit.caught_only_by_stemming, true, "exact tokens miss it; stemming catches it");
131
+ assert.ok(hit.exact_similarity < 0.7 && hit.stemmed_similarity >= 0.7);
132
+ });
133
+
134
+ test("Q5 shortest path between two decisions is length 2 through a shared node", async () => {
135
+ const sp = await provider.shortestPath("decision:adr-0011", "decision:adr-0021");
136
+ assert.ok(sp, "a path must exist");
137
+ assert.equal(sp.length, 2);
138
+ assert.equal(sp.path[0], "decision:adr-0011");
139
+ assert.equal(sp.path[sp.path.length - 1], "decision:adr-0021");
140
+ });
141
+
142
+ test("naive exact-token duplicate detection MISSES the traverse dup (the spike's finding)", () => {
143
+ // Direct evidence of the finding: exact tokens produce no cross-threshold hit
144
+ // for the traverse pair, stemming does.
145
+ const g = { nodes: fixtureGraph().nodes.filter((n) => n.id.startsWith("issue:traverse")), edges: [] };
146
+ const dups = jsq.duplicateCandidates(g, { threshold: 0.7 });
147
+ assert.equal(dups.length, 1);
148
+ assert.equal(dups[0].exact_similarity, 0);
149
+ assert.ok(dups[0].stemmed_similarity >= 0.7);
150
+ });
151
+ });
152
+
153
+ describe("marker tolerance (spike finding 3)", () => {
154
+ test("a provider that throws while reading does not kill the sync", async () => {
155
+ const good = new Neo4jProvider({ sourceGraph: fixtureGraph() });
156
+ const exploding = {
157
+ id: "work-item",
158
+ async readGraph() {
159
+ throw new Error("simulated malformed metadata marker: 3/19 failed JSON.parse");
160
+ },
161
+ };
162
+ const mat = await materializeGraph([good, exploding]);
163
+ assert.ok(mat.nodes.length > 0, "the healthy provider's graph still materialized");
164
+ assert.equal(mat.errors.length, 1);
165
+ assert.match(mat.errors[0].error, /malformed metadata marker/);
166
+ });
167
+ });
168
+
169
+ describe("graceful degradation (AC4)", () => {
170
+ test("neo4j selected but NEO4J_URI unset -> file providers with a clear message", async () => {
171
+ const msgs = [];
172
+ const result = await selectKnowledgeProvider({
173
+ preference: "neo4j",
174
+ env: {},
175
+ fileProviders: async () => [{ id: "git-repo" }],
176
+ log: (m) => msgs.push(m),
177
+ });
178
+ assert.equal(result.provider, "file");
179
+ assert.equal(result.providers[0].id, "git-repo");
180
+ assert.match(msgs[0], /NEO4J_URI is unset/);
181
+ });
182
+
183
+ test("neo4j reachable path selects the neo4j provider", async () => {
184
+ const driver = makeFakeDriver();
185
+ const result = await selectKnowledgeProvider({
186
+ preference: "neo4j",
187
+ env: { NEO4J_URI: "bolt://localhost:7687" },
188
+ driver,
189
+ fileProviders: async () => [{ id: "git-repo" }],
190
+ neo4jFactory: async ({ driver: d }) => new Neo4jProvider({ driver: d }),
191
+ });
192
+ assert.equal(result.provider, "neo4j");
193
+ assert.ok(result.graph instanceof Neo4jProvider);
194
+ });
195
+
196
+ test("default preference is file (graph is opt-in only)", async () => {
197
+ const result = await selectKnowledgeProvider({
198
+ env: {},
199
+ fileProviders: async () => [{ id: "git-repo" }],
200
+ });
201
+ assert.equal(result.provider, "file");
202
+ });
203
+ });
204
+
205
+ describe("proposals-only write", () => {
206
+ test("proposeWrite returns a schema-valid 'proposed' proposal and mutates nothing", async () => {
207
+ const driver = makeFakeDriver();
208
+ await syncToNeo4j({ driver, graph: fixtureGraph() });
209
+ const provider = new Neo4jProvider({ driver });
210
+ const before = await provider.readNodes();
211
+ const p = await provider.proposeWrite({ kind: "create-node", type: "note", title: "Proposed" });
212
+ const { valid, errors } = validate(p, schemas.proposal);
213
+ assert.ok(valid, `proposal invalid: ${errors.join(", ")}`);
214
+ assert.equal(p.status, "proposed");
215
+ assert.equal(p.provider, "neo4j");
216
+ const after = await provider.readNodes();
217
+ assert.equal(after.length, before.length, "proposeWrite must not mutate the store");
218
+ });
219
+ });