@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.
- package/.github/workflows/ci.yml +8 -0
- package/CHANGELOG.md +15 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +143 -0
- package/build/src/cli/assignment-provider.js +106 -49
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +392 -35
- 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 -1
- package/context/contracts/assignment-provider-contract.md +9 -0
- package/context/contracts/probe-docs-write-contract.md +187 -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/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 +2 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -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 +19 -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/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -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 -1
- package/docs/workflow-usage-guide.md +1 -1
- package/evals/ci/run-baseline.sh +4 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -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_gate_lockdown.sh +10 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +4 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +17 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- 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 +15 -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/suite.test.js +18 -0
- package/kits/knowledge/providers/index.js +1 -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/package.json +4 -2
- package/scripts/README.md +1 -0
- 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 +137 -55
- package/src/cli/workflow-sidecar.ts +430 -33
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Cypher for the neo4j knowledge-store provider.
|
|
3
|
+
*
|
|
4
|
+
* Two responsibilities:
|
|
5
|
+
* 1. The label/relationship mapping between the storage-independent graph model
|
|
6
|
+
* (contract node `type` / edge `type`) and Neo4j's labelled property graph,
|
|
7
|
+
* plus the read/merge statements the provider and sync command issue.
|
|
8
|
+
* 2. The five CANONICAL health/analysis queries from the #318 spike
|
|
9
|
+
* (docs/spikes/graph-provider-2026-07.md §3), written in the Neo4j dialect.
|
|
10
|
+
* The spike found ~80% of Cypher portable and isolated the three divergent
|
|
11
|
+
* areas (existence predicates, string/list stdlib incl. 0- vs 1-indexed
|
|
12
|
+
* substring, shortest-path/path-projection). These strings are the Neo4j
|
|
13
|
+
* spelling; graph-queries.js is the portable JS spelling used in degraded
|
|
14
|
+
* mode. The live integration test asserts the two agree.
|
|
15
|
+
*
|
|
16
|
+
* Nodes are stored one Neo4j label per contract type (Issue, Decision, Note,
|
|
17
|
+
* Session, Person, …) with a flat property bag; nested `attributes`/`provenance`
|
|
18
|
+
* are serialised to `*_json` strings (Neo4j properties cannot nest) and a few
|
|
19
|
+
* scalars (`state`, `status`) are promoted for queryability, matching the
|
|
20
|
+
* property catalogue the spike's Neo4j Browser panel showed.
|
|
21
|
+
*
|
|
22
|
+
* @module providers/neo4j/cypher
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
/** Sanitise a contract node type into a Neo4j label (PascalCase, alnum only). */
|
|
26
|
+
export function typeToLabel(type) {
|
|
27
|
+
const clean = String(type || "node").replace(/[^A-Za-z0-9]+/g, " ").trim();
|
|
28
|
+
if (!clean) return "Node";
|
|
29
|
+
return clean
|
|
30
|
+
.split(/\s+/)
|
|
31
|
+
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
|
32
|
+
.join("");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** Sanitise a closed-vocabulary edge type into a Neo4j relationship type. */
|
|
36
|
+
export function edgeTypeToRel(type) {
|
|
37
|
+
return String(type || "relates").toUpperCase().replace(/[^A-Z0-9]+/g, "_");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Reserved label for the sync snapshot metadata node (never part of the graph). */
|
|
41
|
+
export const SNAPSHOT_LABEL = "_KGSnapshot";
|
|
42
|
+
export const SNAPSHOT_ID = "current";
|
|
43
|
+
|
|
44
|
+
/** Serialise a contract node into a flat Neo4j property bag. */
|
|
45
|
+
export function nodeToProps(n, hash) {
|
|
46
|
+
const props = {
|
|
47
|
+
id: n.id,
|
|
48
|
+
type: n.type,
|
|
49
|
+
title: n.title,
|
|
50
|
+
body: n.body || "",
|
|
51
|
+
attributes_json: JSON.stringify(n.attributes || {}),
|
|
52
|
+
provenance_json: JSON.stringify(n.provenance || {}),
|
|
53
|
+
_kg_hash: hash,
|
|
54
|
+
};
|
|
55
|
+
// Promote a few queryable scalars (matches the spike's property catalogue).
|
|
56
|
+
const attrs = n.attributes || {};
|
|
57
|
+
if (attrs.state != null) props.state = String(attrs.state);
|
|
58
|
+
if (attrs.status != null) props.status = String(attrs.status);
|
|
59
|
+
if (attrs.registry_status != null) props.status = String(attrs.registry_status);
|
|
60
|
+
return props;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Rebuild a contract node from a Neo4j property bag. */
|
|
64
|
+
export function propsToNode(props) {
|
|
65
|
+
const n = {
|
|
66
|
+
id: props.id,
|
|
67
|
+
type: props.type,
|
|
68
|
+
title: props.title,
|
|
69
|
+
provenance: safeParse(props.provenance_json, {}),
|
|
70
|
+
};
|
|
71
|
+
if (props.body) n.body = props.body;
|
|
72
|
+
const attrs = safeParse(props.attributes_json, {});
|
|
73
|
+
if (attrs && Object.keys(attrs).length) n.attributes = attrs;
|
|
74
|
+
return n;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Serialise a contract edge into a flat Neo4j property bag. */
|
|
78
|
+
export function edgeToProps(e, hash) {
|
|
79
|
+
const props = {
|
|
80
|
+
id: e.id,
|
|
81
|
+
type: e.type,
|
|
82
|
+
from: e.from,
|
|
83
|
+
to: e.to,
|
|
84
|
+
resolved: e.resolved === false ? false : true,
|
|
85
|
+
attributes_json: JSON.stringify(e.attributes || {}),
|
|
86
|
+
provenance_json: JSON.stringify(e.provenance || {}),
|
|
87
|
+
_kg_hash: hash,
|
|
88
|
+
};
|
|
89
|
+
return props;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** Rebuild a contract edge from a Neo4j relationship property bag. */
|
|
93
|
+
export function propsToEdge(props) {
|
|
94
|
+
const e = {
|
|
95
|
+
id: props.id,
|
|
96
|
+
type: props.type,
|
|
97
|
+
from: props.from,
|
|
98
|
+
to: props.to,
|
|
99
|
+
provenance: safeParse(props.provenance_json, {}),
|
|
100
|
+
};
|
|
101
|
+
if (props.resolved === false) e.resolved = false;
|
|
102
|
+
const attrs = safeParse(props.attributes_json, {});
|
|
103
|
+
if (attrs && Object.keys(attrs).length) e.attributes = attrs;
|
|
104
|
+
return e;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function safeParse(s, fallback) {
|
|
108
|
+
try {
|
|
109
|
+
return JSON.parse(s);
|
|
110
|
+
} catch {
|
|
111
|
+
return fallback;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
// --- fixed read statements ------------------------------------------------
|
|
116
|
+
|
|
117
|
+
/** All graph nodes (excludes the snapshot metadata node). */
|
|
118
|
+
export const READ_NODES_CYPHER = `MATCH (n) WHERE NOT n:${SNAPSHOT_LABEL} RETURN properties(n) AS props`;
|
|
119
|
+
|
|
120
|
+
/** All graph relationships as flat property bags. */
|
|
121
|
+
export const READ_EDGES_CYPHER = `MATCH ()-[r]->() RETURN properties(r) AS props`;
|
|
122
|
+
|
|
123
|
+
/** The recorded sync snapshot (digest + timestamp), or null. */
|
|
124
|
+
export const READ_SNAPSHOT_CYPHER = `MATCH (s:${SNAPSHOT_LABEL} {id:$id}) RETURN properties(s) AS props`;
|
|
125
|
+
|
|
126
|
+
// --- canonical queries (Neo4j dialect, from the spike) --------------------
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* The five canonical queries as Neo4j Cypher. `blocks` is stored from→to
|
|
130
|
+
* ("from must complete before to"), so a node's transitive blockers are reached
|
|
131
|
+
* by following INCOMING :BLOCKS. Placeholders are $-parameters (never string
|
|
132
|
+
* interpolation — the spike's escaping lesson).
|
|
133
|
+
*/
|
|
134
|
+
export const CANONICAL_CYPHER = Object.freeze({
|
|
135
|
+
// Q1 — transitive blocker closure (spike: variable-length *1..N, portable).
|
|
136
|
+
transitiveBlockers: `MATCH (b:Issue)-[:BLOCKS*1..$maxDepth]->(x:Issue {id:$rootId})
|
|
137
|
+
RETURN DISTINCT b.id AS id, b.state AS state ORDER BY b.id`,
|
|
138
|
+
|
|
139
|
+
// Q2 — contradiction candidates: decisions sharing a subject with divergent
|
|
140
|
+
// status (the ADR-0007 numbering-collision failure mode).
|
|
141
|
+
contradictionCandidates: `MATCH (a:Decision),(b:Decision)
|
|
142
|
+
WHERE a.id < b.id AND a.title = b.title AND coalesce(a.status,'') <> coalesce(b.status,'')
|
|
143
|
+
RETURN a.id AS a_id, b.id AS b_id, a.status AS a_status, b.status AS b_status, a.title AS title`,
|
|
144
|
+
|
|
145
|
+
// Q3 — orphans, node-type aware (spike Q3: leaf types are source-only; the
|
|
146
|
+
// meaningful query is issues/decisions with no inbound edge).
|
|
147
|
+
orphans: `MATCH (n) WHERE NOT n:${SNAPSHOT_LABEL} AND NOT ( ()-->(n) )
|
|
148
|
+
AND NOT n:Session AND NOT n:Pr AND NOT n:Learning
|
|
149
|
+
RETURN labels(n)[0] AS label, n.id AS id ORDER BY id`,
|
|
150
|
+
|
|
151
|
+
// Q4 — duplicate candidates WITH 5-char-prefix stemming (spike Q4: exact
|
|
152
|
+
// tokens missed traverse#14~#8; substring() is 0-indexed in Neo4j).
|
|
153
|
+
duplicateStemmed: `MATCH (a),(b) WHERE a.id < b.id AND labels(a)[0] = labels(b)[0]
|
|
154
|
+
WITH a, b,
|
|
155
|
+
[w IN split(toLower(a.title),' ') WHERE size(w)>=5 | substring(w,0,5)] AS sa,
|
|
156
|
+
[w IN split(toLower(b.title),' ') WHERE size(w)>=5 | substring(w,0,5)] AS sb
|
|
157
|
+
WITH a, b, sa, sb, size([w IN sa WHERE w IN sb]) AS inter
|
|
158
|
+
WHERE inter > 0 AND toFloat(inter)/size(sa + [w IN sb WHERE NOT w IN sa]) >= $threshold
|
|
159
|
+
RETURN a.id AS a_id, b.id AS b_id, a.title AS a_title, b.title AS b_title`,
|
|
160
|
+
|
|
161
|
+
// Q5 — shortest path between two decisions (spike Q5: shortestPath() +
|
|
162
|
+
// list-comprehension projection is the Neo4j spelling).
|
|
163
|
+
shortestPath: `MATCH p=shortestPath((a {id:$fromId})-[*..$maxDepth]-(b {id:$toId}))
|
|
164
|
+
RETURN [n IN nodes(p) | n.id] AS path, length(p) AS length`,
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The spike's dialect note, kept with the queries so a future Kuzu/other-engine
|
|
169
|
+
* provider has the divergence map: existence predicates, string/list stdlib
|
|
170
|
+
* (incl. 0- vs 1-indexed substring), and shortest-path/path-projection spelling.
|
|
171
|
+
*/
|
|
172
|
+
export const DIALECT_NOTES = Object.freeze({
|
|
173
|
+
engine: "neo4j-community-5",
|
|
174
|
+
divergent_areas: ["existence-predicates", "string-list-stdlib", "shortest-path-projection"],
|
|
175
|
+
substring_indexing: "0-based",
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Neo4j forbids a parameter in a variable-length bound (`*1..$maxDepth`). Bounds
|
|
180
|
+
* must be literals, so we validate maxDepth is a small positive integer and
|
|
181
|
+
* interpolate it — the ONLY interpolation in this module; every value stays a
|
|
182
|
+
* $-parameter (the spike's parameterise-don't-escape lesson).
|
|
183
|
+
*/
|
|
184
|
+
export function renderBounds(cypher, maxDepth) {
|
|
185
|
+
const n = Number(maxDepth);
|
|
186
|
+
if (!Number.isInteger(n) || n < 1 || n > 100) {
|
|
187
|
+
throw new Error(`renderBounds: maxDepth must be an integer in [1,100], got ${maxDepth}`);
|
|
188
|
+
}
|
|
189
|
+
return cypher.replace(/\$maxDepth/g, String(n));
|
|
190
|
+
}
|
|
@@ -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
|
+
});
|