@kontourai/flow-agents 2.4.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.
- package/.github/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +38 -0
- package/CONTEXT.md +88 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +144 -0
- package/build/src/cli/assignment-provider.js +805 -0
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/validate-workflow-artifacts.js +5 -1
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +549 -145
- package/build/src/cli.js +6 -0
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +248 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
- package/context/contracts/decision-registry-contract.md +2 -0
- package/context/contracts/delivery-contract.md +2 -0
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +2 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/contracts/review-contract.md +2 -0
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/contracts/work-item-contract.md +2 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/context-map.md +1 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +22 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +7 -1
- package/evals/ci/run-baseline.sh +10 -0
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_publish_delivery.sh +21 -4
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
- package/evals/integration/test_reconcile_soundness.sh +33 -9
- package/evals/integration/test_trust_reconcile.sh +9 -8
- package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +32 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +36 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +50 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +143 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +25 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/package.json +7 -3
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +521 -24
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +927 -0
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/validate-workflow-artifacts.ts +5 -1
- package/src/cli/workflow-sidecar.ts +577 -139
- package/src/cli.ts +6 -0
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -0
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory injected driver mock for the neo4j provider.
|
|
3
|
+
*
|
|
4
|
+
* Implements exactly the fixed statements the provider and sync command issue
|
|
5
|
+
* (node/edge/snapshot reads; hash-guarded MERGE writes) over an in-process
|
|
6
|
+
* property-bag graph, returning honest neo4j-driver-shaped results and update
|
|
7
|
+
* counters. This is the "injected driver mock" the CI proof relies on (issue
|
|
8
|
+
* #327): the read side conforms and the sync idempotency counters (writes>0 then
|
|
9
|
+
* writes=0) are provable with no Docker present.
|
|
10
|
+
*
|
|
11
|
+
* It deliberately does NOT interpret the analytic canonical queries — it reports
|
|
12
|
+
* supportsCypherAnalytics=false, so the provider uses the portable JS backend
|
|
13
|
+
* for those. The live driver interprets them; integration.test.js asserts parity.
|
|
14
|
+
*
|
|
15
|
+
* @module providers/neo4j/fake-driver
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import {
|
|
19
|
+
READ_NODES_CYPHER,
|
|
20
|
+
READ_EDGES_CYPHER,
|
|
21
|
+
READ_SNAPSHOT_CYPHER,
|
|
22
|
+
} from "./cypher.js";
|
|
23
|
+
|
|
24
|
+
function record(map) {
|
|
25
|
+
return { get: (key) => map[key] };
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function counters(updates) {
|
|
29
|
+
return { summary: { counters: { updates: () => updates } } };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function makeFakeDriver() {
|
|
33
|
+
const nodes = new Map(); // id -> props (incl. _kg_label, _kg_hash)
|
|
34
|
+
const rels = new Map(); // id -> props (incl. _kg_rel, _kg_hash)
|
|
35
|
+
let snapshot = null;
|
|
36
|
+
|
|
37
|
+
function run(cypher, params = {}) {
|
|
38
|
+
// --- reads ---
|
|
39
|
+
if (cypher === READ_NODES_CYPHER) {
|
|
40
|
+
return Promise.resolve({ records: [...nodes.values()].map((p) => record({ props: strip(p) })) });
|
|
41
|
+
}
|
|
42
|
+
if (cypher === READ_EDGES_CYPHER) {
|
|
43
|
+
return Promise.resolve({ records: [...rels.values()].map((p) => record({ props: strip(p) })) });
|
|
44
|
+
}
|
|
45
|
+
if (cypher === READ_SNAPSHOT_CYPHER) {
|
|
46
|
+
return Promise.resolve({ records: snapshot ? [record({ props: snapshot })] : [] });
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// --- node MERGE ---
|
|
50
|
+
let m = cypher.match(/MERGE \(n:([A-Za-z0-9_]+) \{id:r\.id\}\)/);
|
|
51
|
+
if (m) {
|
|
52
|
+
let nodesCreated = 0;
|
|
53
|
+
let propertiesSet = 0;
|
|
54
|
+
for (const r of params.rows || []) {
|
|
55
|
+
const existing = nodes.get(r.id);
|
|
56
|
+
if (!existing) {
|
|
57
|
+
nodes.set(r.id, { ...r, _kg_label: m[1] });
|
|
58
|
+
nodesCreated++;
|
|
59
|
+
propertiesSet += Object.keys(r).length;
|
|
60
|
+
} else if (existing._kg_hash !== r._kg_hash) {
|
|
61
|
+
nodes.set(r.id, { ...existing, ...r, _kg_label: m[1] });
|
|
62
|
+
propertiesSet += Object.keys(r).length;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return Promise.resolve(counters({ nodesCreated, relationshipsCreated: 0, propertiesSet }));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// --- edge MERGE ---
|
|
69
|
+
m = cypher.match(/MERGE \(a\)-\[x:([A-Za-z0-9_]+) \{id:r\.id\}\]->\(b\)/);
|
|
70
|
+
if (m) {
|
|
71
|
+
let relationshipsCreated = 0;
|
|
72
|
+
let propertiesSet = 0;
|
|
73
|
+
for (const r of params.rows || []) {
|
|
74
|
+
if (!nodes.has(r.from) || !nodes.has(r.to)) continue; // MATCH endpoints failed
|
|
75
|
+
const existing = rels.get(r.id);
|
|
76
|
+
if (!existing) {
|
|
77
|
+
rels.set(r.id, { ...r, _kg_rel: m[1] });
|
|
78
|
+
relationshipsCreated++;
|
|
79
|
+
propertiesSet += Object.keys(r).length;
|
|
80
|
+
} else if (existing._kg_hash !== r._kg_hash) {
|
|
81
|
+
rels.set(r.id, { ...existing, ...r, _kg_rel: m[1] });
|
|
82
|
+
propertiesSet += Object.keys(r).length;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return Promise.resolve(counters({ nodesCreated: 0, relationshipsCreated, propertiesSet }));
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// --- snapshot MERGE ---
|
|
89
|
+
if (/MERGE \(s:_KGSnapshot \{id:\$id\}\)/.test(cypher)) {
|
|
90
|
+
let nodesCreated = 0;
|
|
91
|
+
let propertiesSet = 0;
|
|
92
|
+
if (!snapshot) {
|
|
93
|
+
snapshot = { id: params.id, digest: params.digest, synced_at: params.syncedAt, node_count: params.nc, edge_count: params.ec };
|
|
94
|
+
nodesCreated = 1;
|
|
95
|
+
propertiesSet = 4;
|
|
96
|
+
} else if (snapshot.digest !== params.digest) {
|
|
97
|
+
snapshot = { ...snapshot, digest: params.digest, synced_at: params.syncedAt, node_count: params.nc, edge_count: params.ec };
|
|
98
|
+
propertiesSet = 4;
|
|
99
|
+
}
|
|
100
|
+
return Promise.resolve(counters({ nodesCreated, relationshipsCreated: 0, propertiesSet }));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
throw new Error(`fake-driver: unhandled statement:\n${cypher}`);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function strip(p) {
|
|
107
|
+
// Return only the stored Neo4j properties (drop the fake's bookkeeping keys).
|
|
108
|
+
const { _kg_label, _kg_rel, ...rest } = p;
|
|
109
|
+
return rest;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const session = () => ({ run, close: () => Promise.resolve() });
|
|
113
|
+
return {
|
|
114
|
+
supportsCypherAnalytics: false,
|
|
115
|
+
__fake: true,
|
|
116
|
+
session,
|
|
117
|
+
verifyConnectivity: () => Promise.resolve(true),
|
|
118
|
+
close: () => Promise.resolve(),
|
|
119
|
+
_state: { nodes, rels, get snapshot() { return snapshot; } },
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export default makeFakeDriver;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"_comment": "Compact graph encoding the #318 spike's ground-truth cases for the five canonical queries (docs/spikes/graph-provider-2026-07.md). Not the full 200-node portfolio; the KEY findings each query proved. Used by both the JS backend (CI) and the live Cypher backend (integration) so AC3 asserts the same results in both.",
|
|
3
|
+
"nodes": [
|
|
4
|
+
{ "id": "issue:310", "type": "issue", "title": "Decision registry shape", "attributes": { "state": "CLOSED" }, "provenance": { "provider": "work-item", "source": "fixture#310", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
5
|
+
{ "id": "issue:312", "type": "issue", "title": "Flow-agents build lane", "attributes": { "state": "OPEN" }, "provenance": { "provider": "work-item", "source": "fixture#312", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
6
|
+
{ "id": "issue:313", "type": "issue", "title": "Knowledge promote sub-flow", "attributes": { "state": "OPEN" }, "provenance": { "provider": "work-item", "source": "fixture#313", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
7
|
+
{ "id": "issue:317", "type": "issue", "title": "Knowledge store provider interface", "attributes": { "state": "OPEN" }, "provenance": { "provider": "work-item", "source": "fixture#317", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
8
|
+
{ "id": "issue:137", "type": "issue", "title": "Core vs domain boundary", "attributes": { "state": "OPEN" }, "provenance": { "provider": "work-item", "source": "fixture#137", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
9
|
+
{ "id": "issue:traverse-8", "type": "issue", "title": "Chunk extraction dedup", "attributes": { "state": "OPEN" }, "provenance": { "provider": "work-item", "source": "traverse#8", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
10
|
+
{ "id": "issue:traverse-14", "type": "issue", "title": "Chunking extractor deduplication", "attributes": { "state": "OPEN" }, "provenance": { "provider": "work-item", "source": "traverse#14", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
11
|
+
{ "id": "decision:adr-0007", "type": "decision", "title": "Flow Skill Kit Tool Boundary", "attributes": { "status": "Accepted" }, "provenance": { "provider": "git-repo", "source": "docs/adr/0007.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
12
|
+
{ "id": "decision:adr-0007b", "type": "decision", "title": "Flow Skill Kit Tool Boundary", "attributes": { "status": "" }, "provenance": { "provider": "git-repo", "source": "docs/adr/0007b.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
13
|
+
{ "id": "decision:adr-0011", "type": "decision", "title": "Trust reconcile and delivery", "attributes": { "status": "Accepted" }, "provenance": { "provider": "git-repo", "source": "docs/adr/0011.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
14
|
+
{ "id": "decision:adr-0021", "type": "decision", "title": "Assignment leases and takeover", "attributes": { "status": "Draft" }, "provenance": { "provider": "git-repo", "source": "docs/adr/0021.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
15
|
+
{ "id": "decision:orphan-adr", "type": "decision", "title": "Undiscoverable stale decision", "attributes": { "status": "Accepted" }, "provenance": { "provider": "git-repo", "source": "docs/adr/orphan.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
16
|
+
{ "id": "session:s1", "type": "session", "title": "Session 2026-07-02 sweep", "provenance": { "provider": "markdown-vault", "source": "archive#s1", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
17
|
+
{ "id": "learning:l1", "type": "learning", "title": "Improvement program note", "provenance": { "provider": "git-repo", "source": "docs/learnings/2026-07.md", "retrieved_at": "2026-07-03T05:14:42Z" } }
|
|
18
|
+
],
|
|
19
|
+
"edges": [
|
|
20
|
+
{ "id": "e1", "type": "blocks", "from": "issue:310", "to": "issue:312", "provenance": { "provider": "work-item", "source": "fixture#312", "locator": "metadata.blockers", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
21
|
+
{ "id": "e2", "type": "blocks", "from": "issue:312", "to": "issue:313", "provenance": { "provider": "work-item", "source": "fixture#313", "locator": "metadata.blockers", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
22
|
+
{ "id": "e3", "type": "mentions", "from": "decision:adr-0011", "to": "issue:137", "provenance": { "provider": "git-repo", "source": "docs/adr/0011.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
23
|
+
{ "id": "e4", "type": "mentions", "from": "decision:adr-0021", "to": "issue:137", "provenance": { "provider": "git-repo", "source": "docs/adr/0021.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
24
|
+
{ "id": "e5", "type": "evidence-of", "from": "session:s1", "to": "decision:adr-0011", "provenance": { "provider": "markdown-vault", "source": "archive#s1", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
25
|
+
{ "id": "e6", "type": "evidence-of", "from": "session:s1", "to": "decision:adr-0021", "provenance": { "provider": "markdown-vault", "source": "archive#s1", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
26
|
+
{ "id": "e7", "type": "mentions", "from": "learning:l1", "to": "decision:adr-0007", "provenance": { "provider": "git-repo", "source": "docs/learnings/2026-07.md", "retrieved_at": "2026-07-03T05:14:42Z" } },
|
|
27
|
+
{ "id": "e8", "type": "mentions", "from": "learning:l1", "to": "decision:adr-0007b", "provenance": { "provider": "git-repo", "source": "docs/learnings/2026-07.md", "retrieved_at": "2026-07-03T05:14:42Z" } }
|
|
28
|
+
]
|
|
29
|
+
}
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Portable JS spelling of the five canonical spike queries, over a contract
|
|
3
|
+
* graph ({ nodes, edges }).
|
|
4
|
+
*
|
|
5
|
+
* The #318 spike proved "the value is the MODEL and the QUERIES, not either
|
|
6
|
+
* engine" — at portfolio scale an in-memory JS graph answers every canonical
|
|
7
|
+
* query in milliseconds. These functions are that spelling: they back the
|
|
8
|
+
* neo4j provider's query/health verbs when no live Neo4j is reachable (AC4
|
|
9
|
+
* graceful degradation to file providers), and they are the ground-truth oracle
|
|
10
|
+
* the live Cypher path is asserted against (AC3). Same answers, two backends —
|
|
11
|
+
* exactly the portability the spike's dialect-shim note calls for.
|
|
12
|
+
*
|
|
13
|
+
* @module providers/neo4j/graph-queries
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/** Tokenise a title into lowercased alnum tokens. */
|
|
17
|
+
function tokens(title) {
|
|
18
|
+
return String(title || "")
|
|
19
|
+
.toLowerCase()
|
|
20
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
21
|
+
.split(/\s+/)
|
|
22
|
+
.filter(Boolean);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Stemmed token set: 5-char prefix of tokens of length >= 5 (the spike's Q4
|
|
27
|
+
* stemming — "chunk"/"chunking" -> "chunk", "extraction"/"extractor" -> "extra").
|
|
28
|
+
* Short tokens are dropped, matching the spike's `WHERE size(w)>=5`.
|
|
29
|
+
*/
|
|
30
|
+
function stemSet(title) {
|
|
31
|
+
return new Set(tokens(title).filter((w) => w.length >= 5).map((w) => w.slice(0, 5)));
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** Exact token set (what naive title matching sees — the spike's miss). */
|
|
35
|
+
function exactSet(title) {
|
|
36
|
+
return new Set(tokens(title));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function jaccard(a, b) {
|
|
40
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
41
|
+
let inter = 0;
|
|
42
|
+
for (const t of a) if (b.has(t)) inter++;
|
|
43
|
+
const union = a.size + b.size - inter;
|
|
44
|
+
return union === 0 ? 0 : inter / union;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Q1 — transitive blocker closure. A `blocks` edge is from -> to ("from must
|
|
49
|
+
* complete before to"), so a node's transitive blockers are reached by walking
|
|
50
|
+
* INCOMING blocks edges. Returns blocker nodes (excluding the root) with state.
|
|
51
|
+
*/
|
|
52
|
+
export function transitiveBlockers(graph, { rootId, maxDepth = 10 } = {}) {
|
|
53
|
+
const edges = (graph.edges || []).filter((e) => e.type === "blocks");
|
|
54
|
+
const byId = new Map((graph.nodes || []).map((n) => [n.id, n]));
|
|
55
|
+
const incoming = new Map();
|
|
56
|
+
for (const e of edges) {
|
|
57
|
+
if (!incoming.has(e.to)) incoming.set(e.to, []);
|
|
58
|
+
incoming.get(e.to).push(e.from);
|
|
59
|
+
}
|
|
60
|
+
const seen = new Set();
|
|
61
|
+
let frontier = [rootId];
|
|
62
|
+
let depth = 0;
|
|
63
|
+
while (frontier.length && depth < maxDepth) {
|
|
64
|
+
const next = [];
|
|
65
|
+
for (const node of frontier) {
|
|
66
|
+
for (const from of incoming.get(node) || []) {
|
|
67
|
+
if (from === rootId || seen.has(from)) continue;
|
|
68
|
+
seen.add(from);
|
|
69
|
+
next.push(from);
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
frontier = next;
|
|
73
|
+
depth++;
|
|
74
|
+
}
|
|
75
|
+
return [...seen]
|
|
76
|
+
.sort()
|
|
77
|
+
.map((id) => ({ id, state: (byId.get(id)?.attributes || {}).state ?? null }));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Q2 — contradiction candidates: decision nodes that share a subject (stemmed
|
|
82
|
+
* title match) but carry divergent status. The ADR-0007 numbering-collision
|
|
83
|
+
* failure mode the spike caught red-handed.
|
|
84
|
+
*/
|
|
85
|
+
export function contradictionCandidates(graph, { threshold = 0.7 } = {}) {
|
|
86
|
+
const decisions = (graph.nodes || []).filter((n) => n.type === "decision");
|
|
87
|
+
const out = [];
|
|
88
|
+
for (let i = 0; i < decisions.length; i++) {
|
|
89
|
+
for (let j = i + 1; j < decisions.length; j++) {
|
|
90
|
+
const a = decisions[i];
|
|
91
|
+
const b = decisions[j];
|
|
92
|
+
const sim = jaccard(stemSet(a.title), stemSet(b.title));
|
|
93
|
+
if (sim < threshold) continue;
|
|
94
|
+
const sa = status(a);
|
|
95
|
+
const sb = status(b);
|
|
96
|
+
if (sa === sb) continue;
|
|
97
|
+
out.push({
|
|
98
|
+
a_id: a.id,
|
|
99
|
+
b_id: b.id,
|
|
100
|
+
a_status: sa,
|
|
101
|
+
b_status: sb,
|
|
102
|
+
title: a.title,
|
|
103
|
+
similarity: Number(sim.toFixed(4)),
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
return out.sort((x, y) => (x.a_id + x.b_id).localeCompare(y.a_id + y.b_id));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function status(n) {
|
|
111
|
+
const a = n.attributes || {};
|
|
112
|
+
return a.status ?? a.registry_status ?? "";
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Q3 — orphans, node-type aware. Leaf/source-only types (session, pr, learning)
|
|
117
|
+
* originate edges by construction, so flagging them is noise (spike Q3 lesson).
|
|
118
|
+
* Meaningful orphans are non-leaf nodes with no inbound edge.
|
|
119
|
+
*/
|
|
120
|
+
export function orphans(graph, { excludeTypes = ["session", "pr", "learning"] } = {}) {
|
|
121
|
+
const exclude = new Set(excludeTypes);
|
|
122
|
+
const inbound = new Set((graph.edges || []).map((e) => e.to));
|
|
123
|
+
return (graph.nodes || [])
|
|
124
|
+
.filter((n) => !exclude.has(n.type) && !inbound.has(n.id))
|
|
125
|
+
.map((n) => ({ id: n.id, type: n.type, title: n.title }))
|
|
126
|
+
.sort((a, b) => a.id.localeCompare(b.id));
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Q4 — duplicate candidates WITH stemming. Same-type pairs whose STEMMED token
|
|
131
|
+
* sets cross the threshold. Exact-token Jaccard is reported alongside so callers
|
|
132
|
+
* (and the AC3 test) can see the spike's finding: exact tokens miss traverse#14,
|
|
133
|
+
* stemming catches it.
|
|
134
|
+
*/
|
|
135
|
+
export function duplicateCandidates(graph, { threshold = 0.7, crossType = false } = {}) {
|
|
136
|
+
const nodes = graph.nodes || [];
|
|
137
|
+
const out = [];
|
|
138
|
+
for (let i = 0; i < nodes.length; i++) {
|
|
139
|
+
for (let j = i + 1; j < nodes.length; j++) {
|
|
140
|
+
const a = nodes[i];
|
|
141
|
+
const b = nodes[j];
|
|
142
|
+
if (!crossType && a.type !== b.type) continue;
|
|
143
|
+
const stemmed = jaccard(stemSet(a.title), stemSet(b.title));
|
|
144
|
+
const exact = jaccard(exactSet(a.title), exactSet(b.title));
|
|
145
|
+
if (stemmed < threshold) continue;
|
|
146
|
+
out.push({
|
|
147
|
+
a_id: a.id,
|
|
148
|
+
b_id: b.id,
|
|
149
|
+
a_title: a.title,
|
|
150
|
+
b_title: b.title,
|
|
151
|
+
type: a.type,
|
|
152
|
+
stemmed_similarity: Number(stemmed.toFixed(4)),
|
|
153
|
+
exact_similarity: Number(exact.toFixed(4)),
|
|
154
|
+
caught_only_by_stemming: exact < threshold,
|
|
155
|
+
});
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
return out.sort((x, y) => y.stemmed_similarity - x.stemmed_similarity);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Q5 — shortest path between two nodes over the undirected edge set. Returns the
|
|
163
|
+
* node-id path and its length, or null if unreachable within maxDepth.
|
|
164
|
+
*/
|
|
165
|
+
export function shortestPath(graph, { fromId, toId, maxDepth = 8 } = {}) {
|
|
166
|
+
if (fromId === toId) return { path: [fromId], length: 0 };
|
|
167
|
+
const adj = new Map();
|
|
168
|
+
const add = (u, v) => {
|
|
169
|
+
if (!adj.has(u)) adj.set(u, new Set());
|
|
170
|
+
adj.get(u).add(v);
|
|
171
|
+
};
|
|
172
|
+
for (const e of graph.edges || []) {
|
|
173
|
+
add(e.from, e.to);
|
|
174
|
+
add(e.to, e.from);
|
|
175
|
+
}
|
|
176
|
+
const prev = new Map([[fromId, null]]);
|
|
177
|
+
let frontier = [fromId];
|
|
178
|
+
let depth = 0;
|
|
179
|
+
while (frontier.length && depth < maxDepth) {
|
|
180
|
+
const next = [];
|
|
181
|
+
for (const u of frontier) {
|
|
182
|
+
for (const v of adj.get(u) || []) {
|
|
183
|
+
if (prev.has(v)) continue;
|
|
184
|
+
prev.set(v, u);
|
|
185
|
+
if (v === toId) {
|
|
186
|
+
const path = [];
|
|
187
|
+
let cur = toId;
|
|
188
|
+
while (cur != null) {
|
|
189
|
+
path.unshift(cur);
|
|
190
|
+
cur = prev.get(cur);
|
|
191
|
+
}
|
|
192
|
+
return { path, length: path.length - 1 };
|
|
193
|
+
}
|
|
194
|
+
next.push(v);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
frontier = next;
|
|
198
|
+
depth++;
|
|
199
|
+
}
|
|
200
|
+
return null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Named registry of the five canonical query verbs (portable JS backend). */
|
|
204
|
+
export const CANONICAL_QUERIES = Object.freeze({
|
|
205
|
+
transitiveBlockers,
|
|
206
|
+
contradictionCandidates,
|
|
207
|
+
orphans,
|
|
208
|
+
duplicateCandidates,
|
|
209
|
+
shortestPath,
|
|
210
|
+
});
|
|
@@ -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";
|