@kontourai/flow-agents 2.4.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +8 -0
- package/.github/workflows/ci.yml +20 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +38 -0
- package/CONTEXT.md +88 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +144 -0
- package/build/src/cli/assignment-provider.js +805 -0
- package/build/src/cli/effective-assignment-provider-settings.d.ts +1 -0
- package/build/src/cli/effective-assignment-provider-settings.js +125 -0
- package/build/src/cli/validate-workflow-artifacts.js +5 -1
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +549 -145
- package/build/src/cli.js +6 -0
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +248 -0
- package/context/contracts/builder-kit-workflow-state-contract.md +2 -0
- package/context/contracts/decision-registry-contract.md +2 -0
- package/context/contracts/delivery-contract.md +2 -0
- package/context/contracts/execution-contract.md +25 -0
- package/context/contracts/governance-adapter-contract.md +2 -0
- package/context/contracts/knowledge-store-contract.md +197 -0
- package/context/contracts/planning-contract.md +2 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/contracts/review-contract.md +2 -0
- package/context/contracts/sandbox-policy.md +2 -0
- package/context/contracts/standing-directives.md +13 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/contracts/work-item-contract.md +2 -0
- package/context/scripts/hooks/config-protection.js +14 -1
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +4 -2
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/context/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +182 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/context-map.md +1 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +22 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +7 -1
- package/evals/ci/run-baseline.sh +10 -0
- package/evals/fixtures/assignment-provider/actor-a.json +6 -0
- package/evals/fixtures/assignment-provider/actor-b.json +6 -0
- package/evals/fixtures/assignment-provider/github-issue-claimed.json +27 -0
- package/evals/fixtures/assignment-provider/github-issue-unassigned.json +7 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/fixtures/assignment-provider/liveness-fresh.json +9 -0
- package/evals/fixtures/assignment-provider/liveness-stale.json +9 -0
- package/evals/integration/test_assignment_provider_github.sh +318 -0
- package/evals/integration/test_assignment_provider_local_file.sh +222 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +182 -0
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_gate_lockdown.sh +10 -0
- package/evals/integration/test_publish_delivery.sh +21 -4
- package/evals/integration/test_pull_work_assignment_join.sh +132 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +20 -7
- package/evals/integration/test_reconcile_soundness.sh +33 -9
- package/evals/integration/test_trust_reconcile.sh +9 -8
- package/evals/integration/test_trust_reconcile_negatives.sh +608 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +91 -3
- package/evals/run.sh +14 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +32 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +36 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/adapters/default-store/index.js +92 -4
- package/kits/knowledge/adapters/obsidian-store/index.js +69 -4
- package/kits/knowledge/adapters/shared/codec.js +141 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +112 -4
- package/kits/knowledge/evals/contract-suite/suite.test.js +176 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +50 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/CONTEXT.md +12 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md +13 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md +14 -0
- package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/learnings/fixture-learning.md +7 -0
- package/kits/knowledge/providers/conformance/fixtures/work-item/issues.json +30 -0
- package/kits/knowledge/providers/conformance/suite.test.js +143 -0
- package/kits/knowledge/providers/git-repo/index.js +236 -0
- package/kits/knowledge/providers/health/health-pass.test.js +99 -0
- package/kits/knowledge/providers/health/index.js +153 -0
- package/kits/knowledge/providers/index.js +25 -0
- package/kits/knowledge/providers/lib/model.js +91 -0
- package/kits/knowledge/providers/lib/schema-validate.js +119 -0
- package/kits/knowledge/providers/markdown-vault/index.js +169 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/kits/knowledge/providers/work-item/index.js +204 -0
- package/package.json +7 -3
- package/schemas/assignment-provider-settings.schema.json +125 -0
- package/schemas/knowledge/edge.schema.json +54 -0
- package/schemas/knowledge/health-report.schema.json +45 -0
- package/schemas/knowledge/node.schema.json +49 -0
- package/schemas/knowledge/proposal.schema.json +53 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/trust-reconcile.js +521 -24
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +14 -1
- package/scripts/hooks/evidence-capture.js +4 -1
- package/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/scripts/hooks/lib/current-pointer.js +123 -0
- package/scripts/hooks/lib/liveness-heartbeat.js +75 -35
- package/scripts/hooks/stop-goal-fit.js +4 -2
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +927 -0
- package/src/cli/effective-assignment-provider-settings.ts +112 -0
- package/src/cli/validate-workflow-artifacts.ts +5 -1
- package/src/cli/workflow-sidecar.ts +577 -139
- package/src/cli.ts +6 -0
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -0
|
@@ -0,0 +1,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
|
+
});
|
package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/old-sprocket-shape.md
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: merged
|
|
3
|
+
subject: Old sprocket shape
|
|
4
|
+
decided: 2026-07-02
|
|
5
|
+
merged_into: sprocket-shape
|
|
6
|
+
evidence:
|
|
7
|
+
- kind: pr
|
|
8
|
+
ref: https://github.com/kontourai/flow-agents/pull/319
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
# Old sprocket shape
|
|
12
|
+
|
|
13
|
+
Merged into [sprocket-shape](./sprocket-shape.md).
|
package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/sprocket-shape.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: current
|
|
3
|
+
subject: Sprocket shape
|
|
4
|
+
decided: 2026-07-02
|
|
5
|
+
supersedes:
|
|
6
|
+
- old-sprocket-shape
|
|
7
|
+
evidence:
|
|
8
|
+
- kind: pr
|
|
9
|
+
ref: https://github.com/kontourai/flow-agents/pull/319
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Sprocket shape
|
|
13
|
+
|
|
14
|
+
Sprockets are round. Absorbs the old-sprocket-shape topic.
|
package/kits/knowledge/providers/conformance/fixtures/git-repo/docs/decisions/widget-format.md
ADDED
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
---
|
|
2
|
+
status: current
|
|
3
|
+
subject: Widget format
|
|
4
|
+
decided: 2026-07-01
|
|
5
|
+
evidence:
|
|
6
|
+
- kind: issue
|
|
7
|
+
ref: https://github.com/kontourai/flow-agents/issues/317
|
|
8
|
+
- kind: doc
|
|
9
|
+
ref: docs/learnings/fixture-learning.md
|
|
10
|
+
---
|
|
11
|
+
|
|
12
|
+
# Widget format
|
|
13
|
+
|
|
14
|
+
Widgets are stored as JSON. Lean rationale linked via evidence.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"number": 14,
|
|
4
|
+
"title": "Add dark mode toggle to settings",
|
|
5
|
+
"state": "OPEN",
|
|
6
|
+
"labels": [{ "name": "enhancement" }],
|
|
7
|
+
"body": "Users want a dark mode toggle in the settings screen.\n\n<!-- flow-agents:work-item-metadata\n{\n \"schema_version\": \"1.0\",\n \"blockers\": []\n}\n-->"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"number": 15,
|
|
11
|
+
"title": "Add dark-mode toggle to settings page",
|
|
12
|
+
"state": "OPEN",
|
|
13
|
+
"labels": [{ "name": "enhancement" }],
|
|
14
|
+
"body": "Duplicate of the dark mode toggle request. Settings page needs a dark-mode toggle."
|
|
15
|
+
},
|
|
16
|
+
{
|
|
17
|
+
"number": 20,
|
|
18
|
+
"title": "Ship theme system",
|
|
19
|
+
"state": "OPEN",
|
|
20
|
+
"labels": [{ "name": "feature" }],
|
|
21
|
+
"body": "Overarching theme work. Relates to #14.\n\n<!-- flow-agents:work-item-metadata\n{\n \"schema_version\": \"1.0\",\n \"blockers\": [\n { \"repo\": \"seed/fixture\", \"number\": 14 },\n { \"repo\": \"seed/fixture\", \"number\": 999 }\n ]\n}\n-->"
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
"number": 21,
|
|
25
|
+
"title": "Document theme tokens",
|
|
26
|
+
"state": "OPEN",
|
|
27
|
+
"labels": [],
|
|
28
|
+
"body": "Blocked by #20. Also see #14 for the toggle."
|
|
29
|
+
}
|
|
30
|
+
]
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Store Provider — Conformance Suite (issue #317, AC3).
|
|
3
|
+
*
|
|
4
|
+
* One parameterized suite every provider must pass. Proves that markdown-vault,
|
|
5
|
+
* git-repo, and work-item all satisfy the read + proposals-only interface and
|
|
6
|
+
* emit schema-valid nodes/edges/proposals. Run:
|
|
7
|
+
* node --test kits/knowledge/providers/conformance/suite.test.js
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { test, describe, before, after } from "node:test";
|
|
11
|
+
import assert from "node:assert/strict";
|
|
12
|
+
import * as fs from "node:fs";
|
|
13
|
+
import * as os from "node:os";
|
|
14
|
+
import * as path from "node:path";
|
|
15
|
+
import { fileURLToPath } from "node:url";
|
|
16
|
+
|
|
17
|
+
import { MarkdownVaultProvider } from "../markdown-vault/index.js";
|
|
18
|
+
import { GitRepoProvider } from "../git-repo/index.js";
|
|
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";
|
|
23
|
+
import DefaultKnowledgeStore from "../../adapters/default-store/index.js";
|
|
24
|
+
import { loadSchemas, EDGE_TYPES } from "../lib/model.js";
|
|
25
|
+
import { validate } from "../lib/schema-validate.js";
|
|
26
|
+
|
|
27
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
28
|
+
const FIXTURES = path.join(__dirname, "fixtures");
|
|
29
|
+
const schemas = loadSchemas();
|
|
30
|
+
|
|
31
|
+
function assertSchema(value, schema, label) {
|
|
32
|
+
const { valid, errors } = validate(value, schema);
|
|
33
|
+
assert.ok(valid, `${label} not schema-valid:\n ${errors.join("\n ")}`);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// --- per-provider fixture builders --------------------------------------
|
|
37
|
+
|
|
38
|
+
async function buildVault() {
|
|
39
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kg-vault-conf-"));
|
|
40
|
+
const store = new DefaultKnowledgeStore({ storeRoot: dir });
|
|
41
|
+
const rawId = await store.create({ type: "raw", title: "Raw source", body: "raw material", category: "eng", provenance: { agent: "test" } });
|
|
42
|
+
const compiledId = await store.create({ type: "compiled", title: "Compiled note", body: "distilled", category: "eng", provenance: { agent: "test" } });
|
|
43
|
+
await store.link(compiledId, [{ target_id: rawId, kind: "source" }], { agent: "test" });
|
|
44
|
+
const provider = new MarkdownVaultProvider({ store, storeRoot: dir, agent: "test" });
|
|
45
|
+
return { provider, proposeIntent: { title: "Proposed note", body: "content", category: "eng" }, cleanup: () => fs.rmSync(dir, { recursive: true, force: true }) };
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function buildGitRepo() {
|
|
49
|
+
const provider = new GitRepoProvider({ repoRoot: path.join(FIXTURES, "git-repo"), agent: "test" });
|
|
50
|
+
return { provider, proposeIntent: { subject: "Fixture decision", body: "the answer", evidence: [{ kind: "issue", ref: "#317" }] }, cleanup: () => {} };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async function buildWorkItem() {
|
|
54
|
+
const issues = fs.readFileSync(path.join(FIXTURES, "work-item", "issues.json"), "utf8");
|
|
55
|
+
const runner = async () => issues;
|
|
56
|
+
const provider = new WorkItemProvider({ repo: "seed/fixture", runner, agent: "test" });
|
|
57
|
+
return { provider, proposeIntent: { issue: 14, commentBody: "draft comment" }, cleanup: () => {} };
|
|
58
|
+
}
|
|
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
|
+
|
|
74
|
+
const PROVIDERS = [
|
|
75
|
+
{ name: "markdown-vault", build: buildVault, expectedId: "markdown-vault" },
|
|
76
|
+
{ name: "git-repo", build: buildGitRepo, expectedId: "git-repo" },
|
|
77
|
+
{ name: "work-item", build: buildWorkItem, expectedId: "work-item" },
|
|
78
|
+
{ name: "neo4j", build: buildNeo4j, expectedId: "neo4j" },
|
|
79
|
+
];
|
|
80
|
+
|
|
81
|
+
for (const { name, build, expectedId } of PROVIDERS) {
|
|
82
|
+
describe(`conformance: ${name}`, () => {
|
|
83
|
+
let ctx;
|
|
84
|
+
before(async () => { ctx = await build(); });
|
|
85
|
+
after(() => ctx.cleanup());
|
|
86
|
+
|
|
87
|
+
test("capabilities() declares proposals-only, non-writable", () => {
|
|
88
|
+
const cap = ctx.provider.capabilities();
|
|
89
|
+
assert.equal(cap.id, expectedId);
|
|
90
|
+
assert.equal(cap.writable, false);
|
|
91
|
+
assert.equal(cap.write_mode, "proposals-only");
|
|
92
|
+
assert.ok(Array.isArray(cap.proposal_targets) && cap.proposal_targets.length > 0);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test("readNodes() returns schema-valid nodes with unique ids and provider provenance", async () => {
|
|
96
|
+
const nodes = await ctx.provider.readNodes();
|
|
97
|
+
assert.ok(Array.isArray(nodes) && nodes.length > 0, "expected at least one node");
|
|
98
|
+
const ids = new Set();
|
|
99
|
+
for (const n of nodes) {
|
|
100
|
+
assertSchema(n, schemas.node, `${name} node ${n.id}`);
|
|
101
|
+
assert.equal(n.provenance.provider, expectedId);
|
|
102
|
+
assert.ok(!ids.has(n.id), `duplicate node id ${n.id}`);
|
|
103
|
+
ids.add(n.id);
|
|
104
|
+
}
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("readEdges() returns schema-valid edges in the closed edge vocabulary", async () => {
|
|
108
|
+
const edges = await ctx.provider.readEdges();
|
|
109
|
+
assert.ok(Array.isArray(edges));
|
|
110
|
+
for (const e of edges) {
|
|
111
|
+
assertSchema(e, schemas.edge, `${name} edge ${e.id}`);
|
|
112
|
+
assert.ok(EDGE_TYPES.includes(e.type), `edge type ${e.type} outside closed vocabulary`);
|
|
113
|
+
assert.equal(e.provenance.provider, expectedId);
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
test("queryByType() is a consistent subset of readNodes()", async () => {
|
|
118
|
+
const nodes = await ctx.provider.readNodes();
|
|
119
|
+
const types = [...new Set(nodes.map((n) => n.type))];
|
|
120
|
+
for (const t of types) {
|
|
121
|
+
const q = await ctx.provider.queryByType(t);
|
|
122
|
+
assert.ok(q.every((n) => n.type === t), `queryByType(${t}) returned off-type node`);
|
|
123
|
+
assert.equal(q.length, nodes.filter((n) => n.type === t).length);
|
|
124
|
+
}
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
test("readGraph() returns { nodes, edges }", async () => {
|
|
128
|
+
const g = await ctx.provider.readGraph();
|
|
129
|
+
assert.ok(Array.isArray(g.nodes) && Array.isArray(g.edges));
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
test("proposeWrite() returns a schema-valid 'proposed' proposal and mutates nothing", async () => {
|
|
133
|
+
const before = await ctx.provider.readNodes();
|
|
134
|
+
const p = await ctx.provider.proposeWrite(ctx.proposeIntent);
|
|
135
|
+
assertSchema(p, schemas.proposal, `${name} proposal`);
|
|
136
|
+
assert.equal(p.status, "proposed");
|
|
137
|
+
assert.equal(p.provider, expectedId);
|
|
138
|
+
assert.equal(typeof p.rendered, "string");
|
|
139
|
+
const after = await ctx.provider.readNodes();
|
|
140
|
+
assert.equal(after.length, before.length, "proposeWrite must not add nodes to the store");
|
|
141
|
+
});
|
|
142
|
+
});
|
|
143
|
+
}
|