@kontourai/flow-agents 3.0.0 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (131) hide show
  1. package/.github/workflows/ci.yml +8 -0
  2. package/CHANGELOG.md +15 -0
  3. package/CONTEXT.md +67 -1
  4. package/README.md +4 -0
  5. package/build/src/cli/assignment-provider.d.ts +143 -0
  6. package/build/src/cli/assignment-provider.js +106 -49
  7. package/build/src/cli/workflow-sidecar.d.ts +2 -2
  8. package/build/src/cli/workflow-sidecar.js +392 -35
  9. package/build/src/lib/flow-resolver.d.ts +12 -6
  10. package/build/src/lib/flow-resolver.js +30 -14
  11. package/build/src/tools/validate-source-tree.js +2 -1
  12. package/context/contracts/assignment-provider-contract.md +9 -0
  13. package/context/contracts/probe-docs-write-contract.md +187 -0
  14. package/context/scripts/hooks/config-protection.js +14 -1
  15. package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
  16. package/context/scripts/hooks/stop-goal-fit.js +4 -2
  17. package/context/scripts/hooks/workflow-steering.js +42 -0
  18. package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
  19. package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
  20. package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
  21. package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
  22. package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
  23. package/docs/adr/0006-typescript-first-source-policy.md +2 -0
  24. package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
  25. package/docs/adr/0007-skill-audit.md +2 -0
  26. package/docs/adr/0008-kit-operation-boundary.md +2 -0
  27. package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
  28. package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
  29. package/docs/adr/0011-mcp-posture.md +2 -0
  30. package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
  31. package/docs/adr/0013-context-lifecycle.md +2 -0
  32. package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
  33. package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
  34. package/docs/adr/0016-three-hard-boundary-model.md +2 -0
  35. package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
  36. package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
  37. package/docs/adr/0019-kit-dependency-ownership.md +2 -0
  38. package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
  39. package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
  40. package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +2 -0
  41. package/docs/adr/README.md +49 -0
  42. package/docs/adr/index.md +34 -0
  43. package/docs/decisions/agent-coordination.md +20 -0
  44. package/docs/decisions/anti-gaming-trust-security.md +20 -0
  45. package/docs/decisions/context-lifecycle.md +18 -0
  46. package/docs/decisions/core-domain-kit-boundary.md +18 -0
  47. package/docs/decisions/flow-flow-agents-boundary.md +18 -0
  48. package/docs/decisions/flow-kit.md +20 -0
  49. package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
  50. package/docs/decisions/graph-knowledge-provider.md +63 -0
  51. package/docs/decisions/hook-core-kit-boundary.md +18 -0
  52. package/docs/decisions/index.md +19 -0
  53. package/docs/decisions/kit-dependency-ownership.md +18 -0
  54. package/docs/decisions/kit-operation-boundary.md +18 -0
  55. package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
  56. package/docs/decisions/kontour-resource-contract.md +18 -0
  57. package/docs/decisions/mcp-posture.md +18 -0
  58. package/docs/decisions/three-hard-boundary-model.md +18 -0
  59. package/docs/decisions/trust-reconcile.md +20 -0
  60. package/docs/decisions/typescript-source-policy.md +48 -0
  61. package/docs/decisions/workflow-enforcement.md +18 -0
  62. package/docs/decisions/workflow-trust-state.md +20 -0
  63. package/docs/fixture-ownership.md +1 -1
  64. package/docs/workflow-usage-guide.md +1 -1
  65. package/evals/ci/run-baseline.sh +4 -0
  66. package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
  67. package/evals/integration/test_current_json_per_actor.sh +516 -0
  68. package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
  69. package/evals/integration/test_gate_lockdown.sh +10 -0
  70. package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
  71. package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
  72. package/evals/run.sh +4 -0
  73. package/evals/static/test_knowledge_providers.sh +13 -4
  74. package/evals/static/test_workflow_skills.sh +15 -2
  75. package/kits/builder/skills/deliver/SKILL.md +17 -0
  76. package/kits/builder/skills/design-probe/SKILL.md +37 -0
  77. package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
  78. package/kits/knowledge/adapters/default-store/index.js +92 -4
  79. package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
  80. package/kits/knowledge/adapters/shared/codec.js +141 -0
  81. package/kits/knowledge/docs/README.md +121 -2
  82. package/kits/knowledge/docs/store-contract.md +112 -4
  83. package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
  84. package/kits/knowledge/flows/promote.flow.json +84 -0
  85. package/kits/knowledge/kit.json +15 -0
  86. package/kits/knowledge/promote/distill.js +96 -0
  87. package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
  88. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
  89. package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
  90. package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
  91. package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
  92. package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
  93. package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
  94. package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
  95. package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
  96. package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
  97. package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
  98. package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
  99. package/kits/knowledge/promote/fixtures/session/state.json +19 -0
  100. package/kits/knowledge/promote/health.js +137 -0
  101. package/kits/knowledge/promote/index.js +176 -0
  102. package/kits/knowledge/promote/ingest.js +103 -0
  103. package/kits/knowledge/promote/lib.js +132 -0
  104. package/kits/knowledge/promote/link.js +84 -0
  105. package/kits/knowledge/promote/promote.test.js +174 -0
  106. package/kits/knowledge/providers/conformance/suite.test.js +18 -0
  107. package/kits/knowledge/providers/index.js +1 -0
  108. package/kits/knowledge/providers/neo4j/connection.js +121 -0
  109. package/kits/knowledge/providers/neo4j/cypher.js +190 -0
  110. package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
  111. package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
  112. package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
  113. package/kits/knowledge/providers/neo4j/index.js +280 -0
  114. package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
  115. package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
  116. package/kits/knowledge/providers/neo4j/sync.js +235 -0
  117. package/package.json +4 -2
  118. package/scripts/README.md +1 -0
  119. package/scripts/freeze-adrs.mjs +364 -0
  120. package/scripts/hooks/config-protection.js +14 -1
  121. package/scripts/hooks/evidence-capture.js +4 -1
  122. package/scripts/hooks/lib/config-protection-remedies.js +9 -0
  123. package/scripts/hooks/lib/current-pointer.js +123 -0
  124. package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
  125. package/scripts/hooks/stop-goal-fit.js +4 -2
  126. package/scripts/hooks/workflow-steering.js +42 -0
  127. package/scripts/statusline/flow-agents-statusline.js +3 -1
  128. package/src/cli/assignment-provider.ts +137 -55
  129. package/src/cli/workflow-sidecar.ts +430 -33
  130. package/src/lib/flow-resolver.ts +35 -14
  131. package/src/tools/validate-source-tree.ts +2 -1
@@ -0,0 +1,103 @@
1
+ /**
2
+ * Step 1 — INGEST.
3
+ *
4
+ * Read a completed session directory (read-only) and collect its durable
5
+ * residue: the plan / Definition Of Done, verification evidence, review
6
+ * critique, learning records, and delegate transcripts. Ingest never mutates
7
+ * the session and never reads outside `sessionDir`.
8
+ *
9
+ * @module promote/ingest
10
+ */
11
+
12
+ import * as fs from "node:fs";
13
+ import * as path from "node:path";
14
+ import { readIfExists, markdownSection, parseDecisionList } from "./lib.js";
15
+
16
+ function readJson(file) {
17
+ const raw = readIfExists(file);
18
+ if (!raw) return null;
19
+ try { return JSON.parse(raw); } catch { return null; }
20
+ }
21
+
22
+ function findSessionMarkdown(sessionDir, slug) {
23
+ // Prefer the deliver session file, then plan-work, then any <slug>--*.md.
24
+ const preferred = [`${slug}--deliver.md`, `${slug}--plan-work.md`];
25
+ for (const name of preferred) {
26
+ const p = path.join(sessionDir, name);
27
+ if (fs.existsSync(p)) return p;
28
+ }
29
+ let names = [];
30
+ try { names = fs.readdirSync(sessionDir); } catch { return null; }
31
+ const md = names.find((n) => n.endsWith(".md") && n.startsWith(`${slug}--`)) || names.find((n) => n.endsWith(".md"));
32
+ return md ? path.join(sessionDir, md) : null;
33
+ }
34
+
35
+ function collectTranscriptRefs(sessionDir) {
36
+ const agentsDir = path.join(sessionDir, "agents");
37
+ const refs = [];
38
+ let agents = [];
39
+ try { agents = fs.readdirSync(agentsDir); } catch { return refs; }
40
+ for (const agent of agents) {
41
+ const events = path.join(agentsDir, agent, "events.jsonl");
42
+ if (fs.existsSync(events)) refs.push(path.join("agents", agent, "events.jsonl"));
43
+ }
44
+ return refs.sort();
45
+ }
46
+
47
+ /**
48
+ * @param {string} sessionDir absolute path to a completed session directory.
49
+ * @param {object} [options]
50
+ * @param {string} [options.slug] override the derived slug.
51
+ * @returns residue object consumed by distill/link/health.
52
+ */
53
+ export function ingestSession(sessionDir, options = {}) {
54
+ if (!sessionDir || !fs.existsSync(sessionDir)) {
55
+ throw new Error(`ingestSession: session directory not found: ${sessionDir}`);
56
+ }
57
+ const state = readJson(path.join(sessionDir, "state.json")) || {};
58
+ const slug = options.slug || state.task_slug || path.basename(sessionDir);
59
+ const repo = state.repo || null;
60
+
61
+ const mdPath = findSessionMarkdown(sessionDir, slug);
62
+ const md = mdPath ? readIfExists(mdPath) : "";
63
+ const planSummary = markdownSection(md, "Plan");
64
+ const definitionOfDone = markdownSection(md, "Definition Of Done");
65
+ const decisionsSection = markdownSection(md, "Decisions");
66
+
67
+ const learning = readJson(path.join(sessionDir, "learning.json")) || {};
68
+ const learnings = Array.isArray(learning.records) ? learning.records : [];
69
+
70
+ const acceptance = readJson(path.join(sessionDir, "acceptance.json"));
71
+ const critique = readJson(path.join(sessionDir, "critique.json"));
72
+
73
+ // Structured decisions: explicit `## Decisions` list first, else derive one
74
+ // durable decision from the Definition Of Done outcome so a delivered session
75
+ // always yields at least one candidate delta (the operator refines the draft).
76
+ let decisions = parseDecisionList(decisionsSection);
77
+ if (decisions.length === 0 && definitionOfDone) {
78
+ const outcome = definitionOfDone.match(/User outcome:\*\*\s*(.+?)\s*$/im);
79
+ if (outcome) decisions = [{ subject: null, body: outcome[1].trim(), derived: true }];
80
+ }
81
+
82
+ const touchedFiles = new Set();
83
+ for (const rec of learnings) {
84
+ for (const ref of Array.isArray(rec.source_refs) ? rec.source_refs : []) touchedFiles.add(ref);
85
+ }
86
+
87
+ return {
88
+ sessionDir,
89
+ sessionMarkdown: mdPath ? path.basename(mdPath) : null,
90
+ slug,
91
+ repo,
92
+ status: state.status || null,
93
+ phase: state.phase || null,
94
+ planSummary,
95
+ definitionOfDone,
96
+ decisions,
97
+ learnings,
98
+ evidence: acceptance || (fs.existsSync(path.join(sessionDir, "trust.bundle")) ? { source: "trust.bundle" } : null),
99
+ critiqueSummary: critique && (critique.summary || critique.status) ? { status: critique.status, summary: critique.summary } : null,
100
+ transcriptRefs: collectTranscriptRefs(sessionDir),
101
+ touchedFiles: [...touchedFiles].sort(),
102
+ };
103
+ }
@@ -0,0 +1,132 @@
1
+ /**
2
+ * Shared helpers for the knowledge promote sub-flow.
3
+ *
4
+ * Zero external dependencies (mirrors scripts/check-decisions.cjs and
5
+ * providers/lib/schema-validate.js) so the sub-flow runs in any node:test lane.
6
+ *
7
+ * @module promote/lib
8
+ */
9
+
10
+ import * as fs from "node:fs";
11
+ import * as path from "node:path";
12
+
13
+ /** Read a file, returning "" when it is absent (ingest is tolerant of shape). */
14
+ export function readIfExists(file) {
15
+ try {
16
+ return fs.readFileSync(file, "utf8");
17
+ } catch {
18
+ return "";
19
+ }
20
+ }
21
+
22
+ /** kebab-case slug from a subject noun phrase (matches the registry slug rule). */
23
+ export function slugify(text) {
24
+ return String(text || "")
25
+ .toLowerCase()
26
+ .replace(/[^a-z0-9]+/g, "-")
27
+ .replace(/^-+|-+$/g, "");
28
+ }
29
+
30
+ /**
31
+ * Extract the body of a `## <Heading>` section from a markdown session file.
32
+ * Returns the lines between the heading and the next `## ` heading.
33
+ */
34
+ export function markdownSection(md, heading) {
35
+ const lines = String(md || "").split(/\r?\n/);
36
+ const out = [];
37
+ let inSection = false;
38
+ for (const line of lines) {
39
+ if (/^##\s+/.test(line)) {
40
+ inSection = new RegExp(`^##\\s+${heading}\\s*$`, "i").test(line);
41
+ continue;
42
+ }
43
+ if (inSection) out.push(line);
44
+ }
45
+ return out.join("\n").trim();
46
+ }
47
+
48
+ /**
49
+ * Parse `## Decisions` list items of the form:
50
+ * - **Subject noun** — the current decision body.
51
+ * Returns [{ subject, body }]. Tolerant of `-` or `*` bullets and `—`/`-` dashes.
52
+ */
53
+ export function parseDecisionList(sectionText) {
54
+ const items = [];
55
+ for (const raw of String(sectionText || "").split(/\r?\n/)) {
56
+ const m = raw.match(/^\s*[-*]\s+\*\*(.+?)\*\*\s*[—-]\s*(.+?)\s*$/);
57
+ if (m) items.push({ subject: m[1].trim(), body: m[2].trim() });
58
+ }
59
+ return items;
60
+ }
61
+
62
+ /** Read the `## Glossary` term set from a repo CONTEXT.md (lowercased term names). */
63
+ export function readGlossaryTerms(repoRoot) {
64
+ const text = readIfExists(path.join(repoRoot, "CONTEXT.md"));
65
+ const terms = new Set();
66
+ let inGlossary = false;
67
+ for (const line of text.split(/\r?\n/)) {
68
+ if (/^##\s+Glossary\s*$/.test(line)) { inGlossary = true; continue; }
69
+ if (/^##\s+/.test(line) && !/^##\s+Glossary/.test(line)) inGlossary = false;
70
+ if (!inGlossary) continue;
71
+ const h = line.match(/^###\s+(.+?)\s*$/);
72
+ if (h) terms.add(h[1].trim().toLowerCase());
73
+ }
74
+ return terms;
75
+ }
76
+
77
+ const EVIDENCE_KINDS = ["issue", "pr", "commit", "session-archive", "adr", "doc", "url"];
78
+ const SLUG_RE = /^[a-z0-9]+(-[a-z0-9]+)*$/;
79
+ const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
80
+ const SECRET_PATTERNS = [
81
+ /AKIA[A-Z0-9]{16}/,
82
+ /ASIA[A-Z0-9]{16}/,
83
+ /gh[pousr]_[A-Za-z0-9_]{36,}/,
84
+ /BEGIN[A-Z ]*PRIVATE KEY/,
85
+ /eyJ[A-Za-z0-9_-]{10,}\.eyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}/,
86
+ /(secret|password|token|api[_-]?key)\s*[:=]\s*["'][^"']{8,}/i,
87
+ ];
88
+
89
+ /**
90
+ * Validate a draft decision delta against the decision-record contract (#310)
91
+ * BEFORE it is proposed. Mirrors schemas/decision-record.schema.json /
92
+ * scripts/check-decisions.cjs rules directly (zero-dep, same discipline).
93
+ * Returns a list of error strings; empty means schema-valid.
94
+ */
95
+ export function validateDecisionDelta(delta) {
96
+ const errors = [];
97
+ const { subject, slug, decided, evidence, status = "current" } = delta;
98
+ if (!["current", "superseded", "merged", "needs-decision"].includes(status)) {
99
+ errors.push(`status '${status}' is not a valid decision-record status`);
100
+ }
101
+ if (typeof subject !== "string" || subject.trim() === "") errors.push("subject must be a non-empty noun phrase");
102
+ if (typeof slug !== "string" || !SLUG_RE.test(slug)) errors.push(`slug '${slug}' is not valid kebab-case`);
103
+ if (typeof decided !== "string" || !DATE_RE.test(decided)) errors.push(`decided '${decided}' must be an ISO date (YYYY-MM-DD)`);
104
+ if (!Array.isArray(evidence) || evidence.length === 0) {
105
+ errors.push("evidence must be a non-empty array of {kind, ref}");
106
+ } else {
107
+ evidence.forEach((e, i) => {
108
+ if (!e || typeof e !== "object") { errors.push(`evidence[${i}] must be an object`); return; }
109
+ if (!EVIDENCE_KINDS.includes(e.kind)) errors.push(`evidence[${i}].kind '${e.kind}' is not one of ${EVIDENCE_KINDS.join(", ")}`);
110
+ if (typeof e.ref !== "string" || e.ref.trim() === "") { errors.push(`evidence[${i}].ref must be a non-empty string`); return; }
111
+ if (SECRET_PATTERNS.some((re) => re.test(e.ref))) errors.push(`evidence[${i}].ref contains a secret-shaped literal; link durable provenance, never a credential`);
112
+ });
113
+ }
114
+ return errors;
115
+ }
116
+
117
+ /** Recursively hash every file under a directory into a {relpath: sha} map (for AC3 diff proof). */
118
+ export function snapshotDir(dir) {
119
+ const out = {};
120
+ const walk = (d, rel) => {
121
+ let entries;
122
+ try { entries = fs.readdirSync(d, { withFileTypes: true }); } catch { return; }
123
+ for (const ent of entries) {
124
+ const abs = path.join(d, ent.name);
125
+ const r = rel ? `${rel}/${ent.name}` : ent.name;
126
+ if (ent.isDirectory()) walk(abs, r);
127
+ else out[r] = fs.readFileSync(abs, "utf8");
128
+ }
129
+ };
130
+ walk(dir, "");
131
+ return out;
132
+ }
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Step 3 — LINK.
3
+ *
4
+ * Attach provenance to every draft delta: the pull request, the merge commit
5
+ * SHA, the archived session artifact path, and the touched decision topics.
6
+ * The provenance links become evidence[] refs on the draft decision topics so a
7
+ * reader can trace each proposed delta back to the delivery that produced it.
8
+ *
9
+ * Rendering the decision topic is delegated to the #317 git-repo provider's
10
+ * proposeWrite (proposals-only, never writes) so the sub-flow reuses the same
11
+ * decision-topic renderer the provider contract defines — no forked writer.
12
+ *
13
+ * @module promote/link
14
+ */
15
+
16
+ import { GitRepoProvider } from "../providers/git-repo/index.js";
17
+ import { validateDecisionDelta } from "./lib.js";
18
+ import { loadSchemas } from "../providers/lib/model.js";
19
+ import { assertValid } from "../providers/lib/schema-validate.js";
20
+
21
+ const { proposal: PROPOSAL_SCHEMA } = loadSchemas();
22
+
23
+ /**
24
+ * @param {{decisions:object[], vocabulary:object[], learnings:object[]}} distilled
25
+ * @param {object} provenance
26
+ * @param {string} [provenance.pr] PR URL/number.
27
+ * @param {string} [provenance.mergeSha] merge commit SHA.
28
+ * @param {string} [provenance.sessionArchivePath] path to the archived session artifact.
29
+ * @param {object} [options]
30
+ * @param {string} [options.repoRoot] repo root for the git-repo provider.
31
+ * @param {string} [options.agent]
32
+ * @returns {{decisions:object[], vocabulary:object[], learnings:object[], manifest:object}}
33
+ */
34
+ export function link(distilled, provenance = {}, options = {}) {
35
+ const provider = new GitRepoProvider({ repoRoot: options.repoRoot || process.cwd(), agent: options.agent });
36
+ const touchedTopics = distilled.decisions.map((d) => d.slug);
37
+
38
+ const provEvidence = [];
39
+ if (provenance.pr) provEvidence.push({ kind: "pr", ref: provenance.pr });
40
+ if (provenance.mergeSha) provEvidence.push({ kind: "commit", ref: provenance.mergeSha });
41
+ if (provenance.sessionArchivePath) provEvidence.push({ kind: "session-archive", ref: provenance.sessionArchivePath });
42
+
43
+ const decisions = distilled.decisions.map((delta) => {
44
+ // Fold provenance links into evidence[], de-duplicated by kind+ref.
45
+ const seen = new Set(delta.evidence.map((e) => `${e.kind}:${e.ref}`));
46
+ const evidence = [...delta.evidence];
47
+ for (const e of provEvidence) {
48
+ const key = `${e.kind}:${e.ref}`;
49
+ if (!seen.has(key)) { evidence.push(e); seen.add(key); }
50
+ }
51
+ const linked = { ...delta, evidence };
52
+
53
+ // Re-validate the linked delta before proposing (R2 discipline holds after linking too).
54
+ const errors = validateDecisionDelta(linked);
55
+ if (errors.length) throw new Error(`linked decision draft '${linked.slug}' failed validation: ${errors.join("; ")}`);
56
+
57
+ // Render via the git-repo provider's proposals-only proposeWrite.
58
+ const rendered = provider.proposeWrite({
59
+ subject: linked.subject,
60
+ slug: linked.slug,
61
+ body: linked.body,
62
+ decided: linked.decided,
63
+ evidence: linked.evidence,
64
+ rationale: `Promoted from session ${linked.derived ? "(derived from Definition Of Done) " : ""}— enact by writing docs/decisions/${linked.slug}.md, adding the subject noun to CONTEXT.md, and running check:decisions.`,
65
+ });
66
+ // provider.proposeWrite is async in the contract; call synchronously-resolved value.
67
+ return Promise.resolve(rendered).then((prop) => {
68
+ assertValid(prop, PROPOSAL_SCHEMA, `decision-topic proposal '${linked.slug}'`);
69
+ return { ...linked, proposal: prop, rendered: prop.rendered };
70
+ });
71
+ });
72
+
73
+ return Promise.all(decisions).then((resolvedDecisions) => ({
74
+ decisions: resolvedDecisions,
75
+ vocabulary: distilled.vocabulary,
76
+ learnings: distilled.learnings,
77
+ manifest: {
78
+ pr: provenance.pr || null,
79
+ merge_sha: provenance.mergeSha || null,
80
+ session_archive: provenance.sessionArchivePath || null,
81
+ touched_topics: touchedTopics,
82
+ },
83
+ }));
84
+ }
@@ -0,0 +1,174 @@
1
+ /**
2
+ * Knowledge promote sub-flow — AC1..AC3 (issue #313).
3
+ *
4
+ * AC1: running the sub-flow on a completed session dir emits >=1 schema-valid
5
+ * draft decision delta with correct provenance links (R1, R2). The draft
6
+ * is validated against #310's REAL registry validator
7
+ * (scripts/check-decisions.cjs) as command evidence. The session fixture
8
+ * is modeled faithfully on the real completed session
9
+ * `.kontourai/flow-agents/kontourai-flow-agents-287` (actor-identity work,
10
+ * status accepted) — a portable, committed copy so the lane is
11
+ * deterministic and CI-runnable.
12
+ * AC2: a fixture registry with a seeded contradiction (two CURRENT topics, one
13
+ * subject noun, divergent content) yields a contradiction report naming
14
+ * BOTH topics and a merge-repair proposal naming a merge target (R3).
15
+ * AC3: a filesystem diff during a run shows zero writes outside the
16
+ * session/proposal dir (R4).
17
+ *
18
+ * Run: node --test kits/knowledge/promote/promote.test.js
19
+ */
20
+
21
+ import { test, describe } from "node:test";
22
+ import assert from "node:assert/strict";
23
+ import * as fs from "node:fs";
24
+ import * as os from "node:os";
25
+ import * as path from "node:path";
26
+ import { execFileSync } from "node:child_process";
27
+ import { fileURLToPath } from "node:url";
28
+
29
+ import { runPromote, health } from "./index.js";
30
+ import { snapshotDir } from "./lib.js";
31
+ import { GitRepoProvider } from "../providers/git-repo/index.js";
32
+ import { loadSchemas } from "../providers/lib/model.js";
33
+ import { validate } from "../providers/lib/schema-validate.js";
34
+
35
+ const __dirname = path.dirname(fileURLToPath(import.meta.url));
36
+ const REPO_ROOT = path.resolve(__dirname, "../../..");
37
+ const FIX = path.join(__dirname, "fixtures");
38
+ const SESSION = path.join(FIX, "session");
39
+ const CLEAN_REPO = path.join(FIX, "repo");
40
+ const CONTRADICTION_REPO = path.join(FIX, "contradiction-registry");
41
+ const CONFORMANCE_GIT = path.resolve(__dirname, "../providers/conformance/fixtures/git-repo");
42
+ const { proposal: PROPOSAL_SCHEMA, healthReport: REPORT_SCHEMA } = loadSchemas();
43
+
44
+ function tmpOut(tag) {
45
+ return fs.mkdtempSync(path.join(os.tmpdir(), `promote-${tag}-`));
46
+ }
47
+
48
+ const PROVENANCE = {
49
+ pr: "https://github.com/kontourai/flow-agents/pull/391",
50
+ mergeSha: "abc1234def5678",
51
+ sessionArchivePath: ".kontourai/flow-agents/kontourai-flow-agents-287/archive/kontourai-flow-agents-287--deliver.md",
52
+ };
53
+
54
+ describe("AC1: real completed session -> schema-valid draft delta with provenance", () => {
55
+ test("emits >=1 decision delta carrying PR + merge SHA + session-archive links", async () => {
56
+ const outDir = tmpOut("ac1");
57
+ const result = await runPromote({ sessionDir: SESSION, repoRoot: CLEAN_REPO, provenance: PROVENANCE, outDir });
58
+
59
+ assert.ok(result.decisions.length >= 1, "expected at least one draft decision delta");
60
+ const delta = result.decisions.find((d) => d.slug === "actor-identity") || result.decisions[0];
61
+ const kinds = new Set(delta.evidence.map((e) => e.kind));
62
+ assert.ok(kinds.has("pr"), "delta must link the PR");
63
+ assert.ok(kinds.has("commit"), "delta must link the merge SHA (commit)");
64
+ assert.ok(kinds.has("session-archive"), "delta must link the archived session artifact");
65
+ const prRef = delta.evidence.find((e) => e.kind === "pr").ref;
66
+ assert.equal(prRef, PROVENANCE.pr);
67
+
68
+ // The draft topic file was written under the proposals dir.
69
+ const draftFile = path.join(outDir, "decisions", `${delta.slug}.md`);
70
+ assert.ok(fs.existsSync(draftFile), "draft topic file written under proposals/decisions");
71
+
72
+ // Command evidence (R2): the REAL #310 validator accepts the drafts.
73
+ const decisionsDir = path.join(outDir, "decisions");
74
+ const env = { ...process.env, FLOW_AGENTS_DECISIONS_DIR: decisionsDir, FORCE_COLOR: "0" };
75
+ execFileSync("node", [path.join(REPO_ROOT, "scripts/check-decisions.cjs"), "gen-index"], { env, cwd: REPO_ROOT });
76
+ const checkOut = execFileSync("node", [path.join(REPO_ROOT, "scripts/check-decisions.cjs"), "check"], { env, cwd: REPO_ROOT, encoding: "utf8" });
77
+ assert.match(checkOut, /Decision registry check passed/, "drafts must pass the real #310 registry validator");
78
+
79
+ fs.rmSync(outDir, { recursive: true, force: true });
80
+ });
81
+
82
+ test("ingests plan, learnings, and transcripts from the session", async () => {
83
+ const result = await runPromote({ sessionDir: SESSION, repoRoot: CLEAN_REPO, provenance: PROVENANCE, write: false });
84
+ assert.ok(result.learnings.length >= 1, "expected learning deltas from learning.json");
85
+ assert.ok(result.ingested.transcript_refs.length >= 1, "expected delegate transcript refs");
86
+ assert.equal(result.ingested.status, "accepted");
87
+ });
88
+ });
89
+
90
+ describe("AC2: seeded contradiction -> report naming both topics + merge proposal", () => {
91
+ test("duplicate-detection report names both topics and a merge-repair proposal is emitted", async () => {
92
+ const { report, mergeProposals } = await health({ repoRoot: CONTRADICTION_REPO });
93
+
94
+ const { valid, errors } = validate(report, REPORT_SCHEMA);
95
+ assert.ok(valid, `report not schema-valid:\n ${errors.join("\n ")}`);
96
+ const finding = report.findings.find(
97
+ (f) => f.node_ids.includes("decision:cache-eviction-policy") && f.node_ids.includes("decision:cache-eviction"),
98
+ );
99
+ assert.ok(finding, "expected a contradiction finding naming BOTH cache-eviction topics");
100
+
101
+ assert.equal(mergeProposals.length, 1, "expected exactly one merge-repair proposal");
102
+ const mp = mergeProposals[0];
103
+ const p = validate(mp, PROPOSAL_SCHEMA);
104
+ assert.ok(p.valid, `merge-repair proposal not schema-valid:\n ${p.errors.join("\n ")}`);
105
+ assert.equal(mp.status, "proposed", "merge-repair must be proposals-only (never auto-applied)");
106
+ assert.equal(mp.kind, "decision-topic");
107
+ assert.deepEqual([...mp.target.topics].sort(), ["cache-eviction", "cache-eviction-policy"]);
108
+ // Merge target = the more-recently-decided topic (cache-eviction @ 2026-06-25).
109
+ assert.equal(mp.target.merge_into, "cache-eviction");
110
+ assert.match(mp.rendered, /merged_into: cache-eviction/);
111
+ });
112
+
113
+ test("a resolved supersede/merge pair is NOT flagged as a contradiction", async () => {
114
+ // The conformance git-repo fixture has sprocket-shape (current) + old-sprocket-shape (merged
115
+ // tombstone) — a RESOLVED pair. It must not produce a merge-repair proposal.
116
+ const { mergeProposals } = await health({ repoRoot: CONFORMANCE_GIT });
117
+ const badPair = mergeProposals.find((mp) => mp.target.topics.includes("old-sprocket-shape"));
118
+ assert.ok(!badPair, "a resolved merge tombstone must not be re-flagged as a contradiction");
119
+ });
120
+ });
121
+
122
+ describe("AC3: zero writes outside the session/proposal dir", () => {
123
+ test("the source repo registry is byte-identical before and after a run", async () => {
124
+ const before = snapshotDir(CONTRADICTION_REPO);
125
+ const beforeSession = snapshotDir(SESSION);
126
+ const outDir = tmpOut("ac3");
127
+
128
+ const result = await runPromote({ sessionDir: SESSION, repoRoot: CONTRADICTION_REPO, provenance: PROVENANCE, outDir });
129
+
130
+ // The read source (repoRoot) and the read session are untouched.
131
+ assert.deepEqual(snapshotDir(CONTRADICTION_REPO), before, "repoRoot registry must be untouched");
132
+ assert.deepEqual(snapshotDir(SESSION), beforeSession, "session dir must be untouched");
133
+
134
+ // Every written path is under outDir.
135
+ assert.ok(result.written.length > 0, "expected drafts to be written");
136
+ for (const rel of result.written) {
137
+ const abs = path.resolve(outDir, rel);
138
+ assert.ok(abs.startsWith(path.resolve(outDir) + path.sep), `write escaped outDir: ${rel}`);
139
+ }
140
+ fs.rmSync(outDir, { recursive: true, force: true });
141
+ });
142
+
143
+ test("default outDir lands under the session directory", async () => {
144
+ // Copy the session fixture to a temp dir so the default proposals/ write is isolated.
145
+ const tmpSession = tmpOut("ac3-default");
146
+ for (const rel of Object.keys(snapshotDir(SESSION))) {
147
+ const src = path.join(SESSION, rel);
148
+ const dst = path.join(tmpSession, rel);
149
+ fs.mkdirSync(path.dirname(dst), { recursive: true });
150
+ fs.copyFileSync(src, dst);
151
+ }
152
+ const result = await runPromote({ sessionDir: tmpSession, repoRoot: CLEAN_REPO, provenance: PROVENANCE });
153
+ assert.equal(result.out_dir, path.join(tmpSession, "proposals"));
154
+ assert.ok(fs.existsSync(path.join(tmpSession, "proposals", "README.md")));
155
+ fs.rmSync(tmpSession, { recursive: true, force: true });
156
+ });
157
+ });
158
+
159
+ describe("R1: the FlowDefinition is composable (true sub-flow nesting)", () => {
160
+ test("promote.flow.json exports every gate claim so a parent uses_flow step resolves", () => {
161
+ const flow = JSON.parse(fs.readFileSync(path.join(REPO_ROOT, "kits/knowledge/flows/promote.flow.json"), "utf8"));
162
+ assert.equal(flow.id, "knowledge.promote");
163
+ const stepIds = flow.steps.map((s) => s.id);
164
+ for (const step of ["ingest", "distill", "link", "health"]) {
165
+ assert.ok(stepIds.includes(step), `missing step ${step}`);
166
+ const gate = Object.values(flow.gates).find((g) => g.step === step);
167
+ assert.ok(gate, `missing gate for step ${step}`);
168
+ const claimType = gate.expects[0].bundle_claim.claimType;
169
+ // Every gate claim MUST be exported for a parent step's uses_flow edge to
170
+ // resolve (src/lib/flow-resolver.ts requires all child expects be exported).
171
+ assert.ok(flow.exports.includes(claimType), `gate claim ${claimType} must be exported for composability`);
172
+ }
173
+ });
174
+ });
@@ -17,6 +17,9 @@ import { fileURLToPath } from "node:url";
17
17
  import { MarkdownVaultProvider } from "../markdown-vault/index.js";
18
18
  import { GitRepoProvider } from "../git-repo/index.js";
19
19
  import { WorkItemProvider } from "../work-item/index.js";
20
+ import { Neo4jProvider } from "../neo4j/index.js";
21
+ import { syncToNeo4j } from "../neo4j/sync.js";
22
+ import { makeFakeDriver } from "../neo4j/fake-driver.js";
20
23
  import DefaultKnowledgeStore from "../../adapters/default-store/index.js";
21
24
  import { loadSchemas, EDGE_TYPES } from "../lib/model.js";
22
25
  import { validate } from "../lib/schema-validate.js";
@@ -54,10 +57,25 @@ async function buildWorkItem() {
54
57
  return { provider, proposeIntent: { issue: 14, commentBody: "draft comment" }, cleanup: () => {} };
55
58
  }
56
59
 
60
+ async function buildNeo4j() {
61
+ // Materialize the same git-repo + work-item fixtures the other providers use
62
+ // into an injected fake driver, then read them back through the neo4j provider.
63
+ // This exercises the read side over the graph store with no Docker (issue #327
64
+ // CI proof: conformance passes for the neo4j read side via an injected mock).
65
+ const gitRepo = new GitRepoProvider({ repoRoot: path.join(FIXTURES, "git-repo"), agent: "test" });
66
+ const issues = fs.readFileSync(path.join(FIXTURES, "work-item", "issues.json"), "utf8");
67
+ const workItem = new WorkItemProvider({ repo: "seed/fixture", runner: async () => issues, agent: "test" });
68
+ const driver = makeFakeDriver();
69
+ await syncToNeo4j({ driver, providers: [gitRepo, workItem] });
70
+ const provider = new Neo4jProvider({ driver, agent: "test" });
71
+ return { provider, proposeIntent: { kind: "create-node", type: "note", title: "Proposed graph note" }, cleanup: () => {} };
72
+ }
73
+
57
74
  const PROVIDERS = [
58
75
  { name: "markdown-vault", build: buildVault, expectedId: "markdown-vault" },
59
76
  { name: "git-repo", build: buildGitRepo, expectedId: "git-repo" },
60
77
  { name: "work-item", build: buildWorkItem, expectedId: "work-item" },
78
+ { name: "neo4j", build: buildNeo4j, expectedId: "neo4j" },
61
79
  ];
62
80
 
63
81
  for (const { name, build, expectedId } of PROVIDERS) {
@@ -11,6 +11,7 @@
11
11
  export { MarkdownVaultProvider } from "./markdown-vault/index.js";
12
12
  export { GitRepoProvider } from "./git-repo/index.js";
13
13
  export { WorkItemProvider, parseWorkItemMetadata } from "./work-item/index.js";
14
+ export { Neo4jProvider } from "./neo4j/index.js";
14
15
  export { detectDuplicates, checkDependencyLinkIntegrity, HEALTH_VERBS } from "./health/index.js";
15
16
  export {
16
17
  EDGE_TYPES,
@@ -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
+ }