@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.
Files changed (195) hide show
  1. package/.github/CODEOWNERS +8 -0
  2. package/.github/workflows/ci.yml +20 -0
  3. package/.github/workflows/trust-reconcile.yml +62 -4
  4. package/CHANGELOG.md +38 -0
  5. package/CONTEXT.md +88 -1
  6. package/README.md +4 -0
  7. package/build/src/cli/assignment-provider.d.ts +144 -0
  8. package/build/src/cli/assignment-provider.js +805 -0
  9. package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
  10. package/build/src/cli/effective-assignment-provider-settings.js +125 -0
  11. package/build/src/cli/validate-workflow-artifacts.js +5 -1
  12. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  13. package/build/src/cli/workflow-sidecar.js +549 -145
  14. package/build/src/cli.js +6 -0
  15. package/build/src/lib/flow-resolver.d.ts +12 -6
  16. package/build/src/lib/flow-resolver.js +30 -14
  17. package/build/src/tools/validate-source-tree.js +2 -0
  18. package/context/contracts/artifact-contract.md +2 -0
  19. package/context/contracts/assignment-provider-contract.md +248 -0
  20. package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
  21. package/context/contracts/decision-registry-contract.md +2 -0
  22. package/context/contracts/delivery-contract.md +2 -0
  23. package/context/contracts/execution-contract.md +25 -0
  24. package/context/contracts/governance-adapter-contract.md +2 -0
  25. package/context/contracts/knowledge-store-contract.md +197 -0
  26. package/context/contracts/planning-contract.md +2 -0
  27. package/context/contracts/probe-docs-write-contract.md +187 -0
  28. package/context/contracts/review-contract.md +2 -0
  29. package/context/contracts/sandbox-policy.md +2 -0
  30. package/context/contracts/standing-directives.md +13 -0
  31. package/context/contracts/verification-contract.md +2 -0
  32. package/context/contracts/work-item-contract.md +2 -0
  33. package/context/scripts/hooks/config-protection.js +14 -1
  34. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  35. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  36. package/context/scripts/hooks/workflow-steering.js +42 -0
  37. package/context/settings/assignment-provider-settings.json +33 -0
  38. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  39. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  40. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  41. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  42. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  43. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  44. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  45. package/docs/adr/0007-skill-audit.md +2 -0
  46. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  47. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  48. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  49. package/docs/adr/0011-mcp-posture.md +2 -0
  50. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  51. package/docs/adr/0013-context-lifecycle.md +2 -0
  52. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  53. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  54. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  55. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  56. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  57. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  58. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  59. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  60. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
  61. package/docs/adr/README.md +49 -0
  62. package/docs/adr/index.md +34 -0
  63. package/docs/context-map.md +1 -0
  64. package/docs/decisions/agent-coordination.md +20 -0
  65. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  66. package/docs/decisions/context-lifecycle.md +18 -0
  67. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  68. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  69. package/docs/decisions/flow-kit.md +20 -0
  70. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  71. package/docs/decisions/graph-knowledge-provider.md +63 -0
  72. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  73. package/docs/decisions/index.md +22 -0
  74. package/docs/decisions/kit-dependency-ownership.md +18 -0
  75. package/docs/decisions/kit-operation-boundary.md +18 -0
  76. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  77. package/docs/decisions/knowledge-store-provider.md +51 -0
  78. package/docs/decisions/kontour-resource-contract.md +18 -0
  79. package/docs/decisions/mcp-posture.md +18 -0
  80. package/docs/decisions/model-routing.md +63 -0
  81. package/docs/decisions/standing-directives.md +66 -0
  82. package/docs/decisions/three-hard-boundary-model.md +18 -0
  83. package/docs/decisions/trust-reconcile.md +20 -0
  84. package/docs/decisions/typescript-source-policy.md +48 -0
  85. package/docs/decisions/workflow-enforcement.md +18 -0
  86. package/docs/decisions/workflow-trust-state.md +20 -0
  87. package/docs/fixture-ownership.md +1 -0
  88. package/docs/workflow-shared-contracts.md +2 -1
  89. package/docs/workflow-usage-guide.md +7 -1
  90. package/evals/ci/run-baseline.sh +10 -0
  91. package/evals/fixtures/assignment-provider/actor-a.json +6 -0
  92. package/evals/fixtures/assignment-provider/actor-b.json +6 -0
  93. package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
  94. package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
  95. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  96. package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
  97. package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
  98. package/evals/integration/test_assignment_provider_github.sh +318 -0
  99. package/evals/integration/test_assignment_provider_local_file.sh +222 -0
  100. package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
  101. package/evals/integration/test_current_json_per_actor.sh +516 -0
  102. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  103. package/evals/integration/test_fixture_retirement_audit.sh +2 -2
  104. package/evals/integration/test_gate_lockdown.sh +10 -0
  105. package/evals/integration/test_publish_delivery.sh +21 -4
  106. package/evals/integration/test_pull_work_assignment_join.sh +132 -0
  107. package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
  108. package/evals/integration/test_reconcile_soundness.sh +33 -9
  109. package/evals/integration/test_trust_reconcile.sh +9 -8
  110. package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
  111. package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
  112. package/evals/run.sh +14 -0
  113. package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
  114. package/evals/static/test_knowledge_providers.sh +32 -0
  115. package/evals/static/test_workflow_skills.sh +15 -2
  116. package/kits/builder/skills/deliver/SKILL.md +36 -0
  117. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  118. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  119. package/kits/builder/skills/pull-work/SKILL.md +78 -10
  120. package/kits/knowledge/adapters/default-store/index.js +92 -4
  121. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  122. package/kits/knowledge/adapters/shared/codec.js +141 -0
  123. package/kits/knowledge/docs/README.md +121 -2
  124. package/kits/knowledge/docs/store-contract.md +112 -4
  125. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  126. package/kits/knowledge/flows/promote.flow.json +84 -0
  127. package/kits/knowledge/kit.json +50 -0
  128. package/kits/knowledge/promote/distill.js +96 -0
  129. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  130. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  131. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  132. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  133. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  134. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  135. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  136. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  137. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  138. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  139. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  140. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  141. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  142. package/kits/knowledge/promote/health.js +137 -0
  143. package/kits/knowledge/promote/index.js +176 -0
  144. package/kits/knowledge/promote/ingest.js +103 -0
  145. package/kits/knowledge/promote/lib.js +132 -0
  146. package/kits/knowledge/promote/link.js +84 -0
  147. package/kits/knowledge/promote/promote.test.js +174 -0
  148. package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
  149. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
  150. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
  151. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
  152. package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
  153. package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
  154. package/kits/knowledge/providers/conformance/suite.test.js +143 -0
  155. package/kits/knowledge/providers/git-repo/index.js +236 -0
  156. package/kits/knowledge/providers/health/health-pass.test.js +99 -0
  157. package/kits/knowledge/providers/health/index.js +153 -0
  158. package/kits/knowledge/providers/index.js +25 -0
  159. package/kits/knowledge/providers/lib/model.js +91 -0
  160. package/kits/knowledge/providers/lib/schema-validate.js +119 -0
  161. package/kits/knowledge/providers/markdown-vault/index.js +169 -0
  162. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  163. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  164. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  165. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  166. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  167. package/kits/knowledge/providers/neo4j/index.js +280 -0
  168. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  169. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  170. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  171. package/kits/knowledge/providers/work-item/index.js +204 -0
  172. package/package.json +7 -3
  173. package/schemas/assignment-provider-settings.schema.json +125 -0
  174. package/schemas/knowledge/edge.schema.json +54 -0
  175. package/schemas/knowledge/health-report.schema.json +45 -0
  176. package/schemas/knowledge/node.schema.json +49 -0
  177. package/schemas/knowledge/proposal.schema.json +53 -0
  178. package/scripts/README.md +1 -0
  179. package/scripts/ci/trust-reconcile.js +521 -24
  180. package/scripts/freeze-adrs.mjs +364 -0
  181. package/scripts/hooks/config-protection.js +14 -1
  182. package/scripts/hooks/evidence-capture.js +4 -1
  183. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  184. package/scripts/hooks/lib/current-pointer.js +123 -0
  185. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  186. package/scripts/hooks/stop-goal-fit.js +4 -2
  187. package/scripts/hooks/workflow-steering.js +42 -0
  188. package/scripts/statusline/flow-agents-statusline.js +3 -1
  189. package/src/cli/assignment-provider.ts +927 -0
  190. package/src/cli/effective-assignment-provider-settings.ts +112 -0
  191. package/src/cli/validate-workflow-artifacts.ts +5 -1
  192. package/src/cli/workflow-sidecar.ts +577 -139
  193. package/src/cli.ts +6 -0
  194. package/src/lib/flow-resolver.ts +35 -14
  195. package/src/tools/validate-source-tree.ts +2 -0
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Minimal, dependency-free JSON-Schema validator.
3
+ *
4
+ * Supports exactly the draft-07 subset used by the Knowledge Kit graph schemas
5
+ * (schemas/knowledge/*.schema.json): type, required, properties,
6
+ * additionalProperties:false, enum, const, items, minLength, minItems, integer,
7
+ * and local `$ref` into `#/$defs/*`. It is intentionally NOT a general validator
8
+ * — it exists so health reports and conformance tests can assert schema validity
9
+ * without pulling an external dependency (the same zero-dep discipline the
10
+ * decision-registry validator follows).
11
+ *
12
+ * @module providers/lib/schema-validate
13
+ */
14
+
15
+ function typeOf(value) {
16
+ if (value === null) return "null";
17
+ if (Array.isArray(value)) return "array";
18
+ return typeof value;
19
+ }
20
+
21
+ function matchesType(value, type) {
22
+ if (type === "integer") return typeof value === "number" && Number.isInteger(value);
23
+ if (type === "number") return typeof value === "number";
24
+ if (type === "object") return typeOf(value) === "object";
25
+ if (type === "array") return Array.isArray(value);
26
+ return typeOf(value) === type;
27
+ }
28
+
29
+ function resolveRef(ref, root) {
30
+ if (!ref.startsWith("#/")) throw new Error(`Unsupported $ref (only local #/ refs): ${ref}`);
31
+ const parts = ref.slice(2).split("/");
32
+ let node = root;
33
+ for (const part of parts) {
34
+ node = node && node[part];
35
+ }
36
+ if (!node) throw new Error(`Unresolved $ref: ${ref}`);
37
+ return node;
38
+ }
39
+
40
+ function validateNode(value, schema, root, pathStr, errors) {
41
+ if (schema.$ref) {
42
+ return validateNode(value, resolveRef(schema.$ref, root), root, pathStr, errors);
43
+ }
44
+
45
+ if ("const" in schema) {
46
+ if (value !== schema.const) {
47
+ errors.push(`${pathStr}: expected const ${JSON.stringify(schema.const)}, got ${JSON.stringify(value)}`);
48
+ }
49
+ }
50
+
51
+ if (schema.enum) {
52
+ if (!schema.enum.includes(value)) {
53
+ errors.push(`${pathStr}: value ${JSON.stringify(value)} not in enum ${JSON.stringify(schema.enum)}`);
54
+ }
55
+ }
56
+
57
+ if (schema.type) {
58
+ const types = Array.isArray(schema.type) ? schema.type : [schema.type];
59
+ if (!types.some((t) => matchesType(value, t))) {
60
+ errors.push(`${pathStr}: expected type ${types.join("|")}, got ${typeOf(value)}`);
61
+ return; // further checks assume the type held
62
+ }
63
+ }
64
+
65
+ if (typeof value === "string") {
66
+ if (typeof schema.minLength === "number" && value.length < schema.minLength) {
67
+ errors.push(`${pathStr}: string shorter than minLength ${schema.minLength}`);
68
+ }
69
+ }
70
+
71
+ if (Array.isArray(value)) {
72
+ if (typeof schema.minItems === "number" && value.length < schema.minItems) {
73
+ errors.push(`${pathStr}: array shorter than minItems ${schema.minItems}`);
74
+ }
75
+ if (schema.items) {
76
+ value.forEach((item, i) => validateNode(item, schema.items, root, `${pathStr}[${i}]`, errors));
77
+ }
78
+ }
79
+
80
+ if (typeOf(value) === "object") {
81
+ const props = schema.properties || {};
82
+ for (const req of schema.required || []) {
83
+ if (!(req in value)) errors.push(`${pathStr}: missing required property '${req}'`);
84
+ }
85
+ if (schema.additionalProperties === false) {
86
+ for (const key of Object.keys(value)) {
87
+ if (!(key in props)) errors.push(`${pathStr}: unexpected property '${key}'`);
88
+ }
89
+ }
90
+ for (const [key, propSchema] of Object.entries(props)) {
91
+ if (key in value) {
92
+ validateNode(value[key], propSchema, root, `${pathStr}/${key}`, errors);
93
+ }
94
+ }
95
+ }
96
+ }
97
+
98
+ /**
99
+ * Validate `value` against `schema`.
100
+ * @returns {{ valid: boolean, errors: string[] }}
101
+ */
102
+ export function validate(value, schema) {
103
+ const errors = [];
104
+ validateNode(value, schema, schema, "$", errors);
105
+ return { valid: errors.length === 0, errors };
106
+ }
107
+
108
+ /**
109
+ * Assert validity, throwing a descriptive error when invalid. Handy in tests.
110
+ */
111
+ export function assertValid(value, schema, label = "value") {
112
+ const { valid, errors } = validate(value, schema);
113
+ if (!valid) {
114
+ const err = new Error(`${label} failed schema validation:\n ${errors.join("\n ")}`);
115
+ err.code = "SCHEMA_INVALID";
116
+ err.errors = errors;
117
+ throw err;
118
+ }
119
+ }
@@ -0,0 +1,169 @@
1
+ /**
2
+ * markdown-vault knowledge-store provider.
3
+ *
4
+ * Adapts the existing Knowledge Kit store (the markdown-vault behaviour:
5
+ * markdown files + YAML frontmatter + [[wikilinks]] + a JSON graph index) to
6
+ * the knowledge-store PROVIDER contract (typed nodes/edges/provenance). It is a
7
+ * thin READ + PROPOSE wrapper over an existing store adapter instance — it does
8
+ * NOT reimplement or modify the adapter, so existing Knowledge Kit skills are
9
+ * unaffected. Wikilinks/frontmatter remain the native form; proposeWrite renders
10
+ * a new note in exactly that form for a human to file.
11
+ *
12
+ * @module providers/markdown-vault
13
+ */
14
+
15
+ import { node, edge, proposal, provenance } from "../lib/model.js";
16
+ import DefaultKnowledgeStore from "../../adapters/default-store/index.js";
17
+
18
+ const PROVIDER_ID = "markdown-vault";
19
+
20
+ /** Vault record type -> graph node type. Extensible: unknowns fall back to note. */
21
+ function nodeTypeFor(recordType) {
22
+ return recordType === "person" ? "person" : "note";
23
+ }
24
+
25
+ /** Vault link kind -> closed graph edge type. */
26
+ function edgeTypeFor(linkKind) {
27
+ switch (linkKind) {
28
+ case "supersedes":
29
+ case "refines":
30
+ return "supersedes";
31
+ case "source":
32
+ case "example":
33
+ return "evidence-of";
34
+ case "appears-in":
35
+ case "person":
36
+ return "mentions";
37
+ case "proposes":
38
+ case "related":
39
+ default:
40
+ return "relates";
41
+ }
42
+ }
43
+
44
+ const ALL_RECORD_TYPES = ["raw", "compiled", "concept", "snapshot", "person"];
45
+
46
+ export class MarkdownVaultProvider {
47
+ constructor({ store, storeRoot, agent } = {}) {
48
+ if (!store && !storeRoot) {
49
+ throw new Error("MarkdownVaultProvider requires { store } or { storeRoot }");
50
+ }
51
+ this.store = store || new DefaultKnowledgeStore({ storeRoot });
52
+ this.storeRoot = storeRoot || "(injected store)";
53
+ this.agent = agent;
54
+ this.id = PROVIDER_ID;
55
+ }
56
+
57
+ capabilities() {
58
+ return {
59
+ id: PROVIDER_ID,
60
+ node_types: ["note", "person"],
61
+ edge_types: ["supersedes", "evidence-of", "mentions", "relates"],
62
+ writable: false,
63
+ write_mode: "proposals-only",
64
+ proposal_targets: ["create-node"],
65
+ source_of_truth: "human-curated markdown vault",
66
+ };
67
+ }
68
+
69
+ async _allRecords() {
70
+ const out = [];
71
+ for (const t of ALL_RECORD_TYPES) {
72
+ const recs = await this.store.listByType(t, { includeRetired: true });
73
+ for (const r of recs) out.push(r);
74
+ }
75
+ return out;
76
+ }
77
+
78
+ async readNodes(options = {}) {
79
+ const records = await this._allRecords();
80
+ const nodes = records.map((r) => {
81
+ const attributes = { record_type: r.type };
82
+ if (r.category) attributes.category = r.category;
83
+ if (r.status) attributes.status = r.status;
84
+ if (Array.isArray(r.tags) && r.tags.length) attributes.tags = r.tags;
85
+ return node({
86
+ id: r.id,
87
+ type: nodeTypeFor(r.type),
88
+ title: r.title,
89
+ body: typeof r.body === "string" ? r.body : "",
90
+ attributes,
91
+ provenance: provenance({
92
+ provider: PROVIDER_ID,
93
+ source: `${this.storeRoot}#${r.id}`,
94
+ locator: r.type,
95
+ agent: this.agent,
96
+ }),
97
+ });
98
+ });
99
+ if (options.type) return nodes.filter((n) => n.type === options.type);
100
+ return nodes;
101
+ }
102
+
103
+ async readEdges() {
104
+ const records = await this._allRecords();
105
+ const edges = [];
106
+ for (const r of records) {
107
+ const links = await this.store.getLinks(r.id);
108
+ for (const l of links.forward || []) {
109
+ edges.push(
110
+ edge({
111
+ id: `${r.id}--${l.kind}--${l.target_id}`,
112
+ type: edgeTypeFor(l.kind),
113
+ from: r.id,
114
+ to: l.target_id,
115
+ attributes: { vault_link_kind: l.kind },
116
+ provenance: provenance({
117
+ provider: PROVIDER_ID,
118
+ source: `${this.storeRoot}#${r.id}`,
119
+ locator: `links.${l.kind}`,
120
+ agent: this.agent,
121
+ }),
122
+ }),
123
+ );
124
+ }
125
+ }
126
+ return edges;
127
+ }
128
+
129
+ async queryByType(type) {
130
+ return this.readNodes({ type });
131
+ }
132
+
133
+ async readGraph() {
134
+ const [nodes, edges] = await Promise.all([this.readNodes(), this.readEdges()]);
135
+ return { nodes, edges };
136
+ }
137
+
138
+ /**
139
+ * Propose a new vault note. Renders the native markdown-vault form
140
+ * (frontmatter + [[wikilinks]]) WITHOUT touching the store — proposals only.
141
+ */
142
+ async proposeWrite(intent = {}) {
143
+ const { title, body = "", category = "inbox", tags = [], links = [] } = intent;
144
+ if (!title) throw new Error("markdown-vault proposeWrite requires intent.title");
145
+ const wikilinks = links.map((l) => `[[${l.target_id}${l.label ? `|${l.label}` : ""}]]`).join(" ");
146
+ const fm = [
147
+ "---",
148
+ "type: compiled",
149
+ `title: ${title}`,
150
+ `category: ${category}`,
151
+ tags.length ? `tags: [${tags.join(", ")}]` : "tags: []",
152
+ "---",
153
+ "",
154
+ body,
155
+ wikilinks ? `\n${wikilinks}` : "",
156
+ ].join("\n");
157
+ return proposal({
158
+ provider: PROVIDER_ID,
159
+ kind: "create-node",
160
+ target: { store_root: this.storeRoot, category },
161
+ payload: { type: "note", title, body, category, tags, links },
162
+ rendered: fm,
163
+ rationale: intent.rationale || "Proposed vault note (file it via the Knowledge Kit ingest/compile flow).",
164
+ provenance: provenance({ provider: PROVIDER_ID, source: this.storeRoot, agent: this.agent }),
165
+ });
166
+ }
167
+ }
168
+
169
+ export default MarkdownVaultProvider;
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Connection + selection for the neo4j knowledge-store provider.
3
+ *
4
+ * Credentials are resolved BY REFERENCE, never hardcoded: the standard
5
+ * neo4j-driver environment variables NEO4J_URI / NEO4J_USER / NEO4J_PASSWORD.
6
+ * (This repo does not yet consume @kontourai/datum — PR #365's model-routing
7
+ * adoption stayed config-file based — so we follow the existing env-reference
8
+ * precedent rather than inventing a datum coupling. If/when datum lands, resolve
9
+ * these three names through it without touching call sites.)
10
+ *
11
+ * The driver is a LAZY, OPTIONAL dependency: neo4j-driver is an
12
+ * optionalDependency, imported on first use inside a try/catch. A missing module
13
+ * OR an unreachable server both degrade to the file providers (AC4) — the graph
14
+ * is never a hard dependency of any repo or workflow.
15
+ *
16
+ * @module providers/neo4j/connection
17
+ */
18
+
19
+ /**
20
+ * Resolve connection config by reference. Returns { uri, user, password,
21
+ * database } or null when NEO4J_URI is unset (the "no graph configured" signal).
22
+ * @param {object} [env=process.env]
23
+ */
24
+ export function resolveNeo4jConfig(env = process.env) {
25
+ const uri = env.NEO4J_URI;
26
+ if (!uri) return null;
27
+ return {
28
+ uri,
29
+ user: env.NEO4J_USER || "neo4j",
30
+ password: env.NEO4J_PASSWORD || "",
31
+ database: env.NEO4J_DATABASE || "neo4j",
32
+ };
33
+ }
34
+
35
+ /**
36
+ * Lazily import neo4j-driver and construct a driver. Returns null if the module
37
+ * is not installed (optional dependency absent) — the caller degrades.
38
+ * @param {object} config from resolveNeo4jConfig
39
+ */
40
+ export async function createDriver(config) {
41
+ if (!config) return null;
42
+ let neo4j;
43
+ try {
44
+ neo4j = (await import("neo4j-driver")).default;
45
+ } catch {
46
+ return null; // optional dependency not installed -> degrade
47
+ }
48
+ const driver = neo4j.driver(config.uri, neo4j.auth.basic(config.user, config.password));
49
+ // Real (non-fake) driver: the live Cypher analytics backend is available.
50
+ driver.supportsCypherAnalytics = true;
51
+ driver._kgDatabase = config.database;
52
+ return driver;
53
+ }
54
+
55
+ /**
56
+ * Probe whether a Neo4j server is reachable with these credentials. Never
57
+ * throws — returns false on any failure. Used by selectKnowledgeProvider so a
58
+ * down/absent server degrades cleanly instead of erroring.
59
+ * @param {object} driver
60
+ */
61
+ export async function isReachable(driver, { timeoutMs = 4000 } = {}) {
62
+ if (!driver) return false;
63
+ try {
64
+ await withTimeout(driver.verifyConnectivity(), timeoutMs);
65
+ return true;
66
+ } catch {
67
+ return false;
68
+ }
69
+ }
70
+
71
+ function withTimeout(promise, ms) {
72
+ return Promise.race([
73
+ promise,
74
+ new Promise((_, reject) => setTimeout(() => reject(new Error("timeout")), ms).unref?.()),
75
+ ]);
76
+ }
77
+
78
+ /**
79
+ * Select the knowledge provider set. The default is the file providers; the
80
+ * neo4j graph is an OPT-IN personal default. Selection is config-driven:
81
+ * `preference` (from repo/user config or KNOWLEDGE_PROVIDER env) chooses neo4j;
82
+ * anything else, or an unreachable/absent Neo4j, falls back to the file
83
+ * providers with a single clear message (AC4).
84
+ *
85
+ * @param {object} options
86
+ * @param {"neo4j"|"file"} [options.preference] explicit preference (default from env)
87
+ * @param {() => object[]|Promise<object[]>} options.fileProviders factory for the file provider set
88
+ * @param {(cfg:object)=>Promise<object>} [options.neo4jFactory] builds the neo4j provider (driver injected)
89
+ * @param {object} [options.env=process.env]
90
+ * @param {(msg:string)=>void} [options.log] one-line status sink (default console.error)
91
+ * @returns {Promise<{ provider:string, providers:object[], graph:object|null, message:string }>}
92
+ */
93
+ export async function selectKnowledgeProvider(options = {}) {
94
+ const env = options.env || process.env;
95
+ const log = options.log || ((m) => console.error(m));
96
+ const preference = options.preference || env.KNOWLEDGE_PROVIDER || "file";
97
+
98
+ const fallback = async (message) => {
99
+ log(message);
100
+ return { provider: "file", providers: await options.fileProviders(), graph: null, message };
101
+ };
102
+
103
+ if (preference !== "neo4j") {
104
+ return { provider: "file", providers: await options.fileProviders(), graph: null, message: "" };
105
+ }
106
+
107
+ const config = resolveNeo4jConfig(env);
108
+ if (!config) {
109
+ return fallback("knowledge: neo4j selected but NEO4J_URI is unset — using file providers.");
110
+ }
111
+ const driver = options.driver || (await createDriver(config));
112
+ if (!driver) {
113
+ return fallback("knowledge: neo4j-driver not installed — using file providers (run `npm install neo4j-driver`).");
114
+ }
115
+ if (!(await isReachable(driver))) {
116
+ await driver.close?.();
117
+ return fallback(`knowledge: no Neo4j reachable at ${config.uri} — using file providers.`);
118
+ }
119
+ const provider = await options.neo4jFactory({ driver, database: config.database });
120
+ return { provider: "neo4j", providers: [provider], graph: provider, message: `knowledge: neo4j provider selected (${config.uri}).` };
121
+ }
@@ -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
+ }