@kontourai/flow-agents 2.4.0 → 3.0.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 +12 -0
- package/.github/workflows/trust-reconcile.yml +62 -4
- package/CHANGELOG.md +23 -0
- package/CONTEXT.md +21 -0
- package/build/src/cli/assignment-provider.d.ts +1 -0
- package/build/src/cli/assignment-provider.js +748 -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.js +157 -110
- package/build/src/cli.js +6 -0
- package/build/src/tools/validate-source-tree.js +1 -0
- package/context/contracts/artifact-contract.md +2 -0
- package/context/contracts/assignment-provider-contract.md +239 -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/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/settings/assignment-provider-settings.json +33 -0
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +180 -0
- package/docs/context-map.md +1 -0
- package/docs/decisions/index.md +3 -0
- package/docs/decisions/knowledge-store-provider.md +51 -0
- package/docs/decisions/model-routing.md +63 -0
- package/docs/decisions/standing-directives.md +66 -0
- package/docs/fixture-ownership.md +1 -0
- package/docs/workflow-shared-contracts.md +2 -1
- package/docs/workflow-usage-guide.md +6 -0
- package/evals/ci/run-baseline.sh +6 -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/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_fixture_retirement_audit.sh +2 -2
- 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 +14 -6
- 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 +79 -0
- package/evals/run.sh +10 -0
- package/evals/static/test_flowdef_codeowners_coverage.sh +6 -0
- package/evals/static/test_knowledge_providers.sh +23 -0
- package/kits/builder/skills/deliver/SKILL.md +19 -0
- package/kits/builder/skills/pull-work/SKILL.md +78 -10
- package/kits/knowledge/kit.json +35 -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 +125 -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 +24 -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/work-item/index.js +204 -0
- package/package.json +4 -2
- 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/ci/trust-reconcile.js +521 -24
- package/src/cli/assignment-provider.ts +845 -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 +147 -106
- package/src/cli.ts +6 -0
- package/src/tools/validate-source-tree.ts +1 -0
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* git-repo knowledge-store provider.
|
|
3
|
+
*
|
|
4
|
+
* Reads durable git-tracked knowledge as a typed graph:
|
|
5
|
+
* - docs/decisions/<slug>.md -> `decision` nodes; tombstones (superseded_by /
|
|
6
|
+
* merged_into / supersedes[]) -> supersedes / merged-into edges; evidence[]
|
|
7
|
+
* -> evidence-of edges (external, resolved:false).
|
|
8
|
+
* - CONTEXT.md glossary terms -> `note` nodes (vocabulary).
|
|
9
|
+
* - docs/learnings/*.md -> `note` nodes (learnings).
|
|
10
|
+
*
|
|
11
|
+
* Read side is authoritative here. The write side is proposals-only and renders
|
|
12
|
+
* a decision-registry topic file shaped for the promote sub-flow (the same
|
|
13
|
+
* frontmatter schemas/decision-record.schema.json validates) — the provider
|
|
14
|
+
* never writes to disk.
|
|
15
|
+
*
|
|
16
|
+
* @module providers/git-repo
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import * as fs from "node:fs";
|
|
20
|
+
import * as path from "node:path";
|
|
21
|
+
import { node, edge, proposal, provenance } from "../lib/model.js";
|
|
22
|
+
import { parseMarkdown } from "../../adapters/shared/codec.js";
|
|
23
|
+
|
|
24
|
+
const PROVIDER_ID = "git-repo";
|
|
25
|
+
|
|
26
|
+
function slugify(text) {
|
|
27
|
+
return String(text)
|
|
28
|
+
.toLowerCase()
|
|
29
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
30
|
+
.replace(/^-+|-+$/g, "");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/** Derive a stable external node id for a decision evidence ref. */
|
|
34
|
+
function evidenceNodeId(kind, ref) {
|
|
35
|
+
const tail = String(ref).replace(/^https?:\/\/github\.com\/[^/]+\/[^/]+\/(issues|pull)\//, "");
|
|
36
|
+
return `${kind}:${slugify(tail).slice(0, 60) || "ref"}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export class GitRepoProvider {
|
|
40
|
+
constructor({ repoRoot, agent } = {}) {
|
|
41
|
+
if (!repoRoot) throw new Error("GitRepoProvider requires { repoRoot }");
|
|
42
|
+
this.repoRoot = path.resolve(repoRoot);
|
|
43
|
+
this.agent = agent;
|
|
44
|
+
this.id = PROVIDER_ID;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
capabilities() {
|
|
48
|
+
return {
|
|
49
|
+
id: PROVIDER_ID,
|
|
50
|
+
node_types: ["decision", "note"],
|
|
51
|
+
edge_types: ["supersedes", "merged-into", "evidence-of"],
|
|
52
|
+
writable: false,
|
|
53
|
+
write_mode: "proposals-only",
|
|
54
|
+
proposal_targets: ["decision-topic"],
|
|
55
|
+
source_of_truth: "git-tracked docs (decision registry, CONTEXT.md, learnings)",
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
_decisionsDir() {
|
|
60
|
+
return path.join(this.repoRoot, "docs", "decisions");
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
_readDecisions() {
|
|
64
|
+
const dir = this._decisionsDir();
|
|
65
|
+
if (!fs.existsSync(dir)) return [];
|
|
66
|
+
return fs
|
|
67
|
+
.readdirSync(dir)
|
|
68
|
+
.filter((f) => f.endsWith(".md") && f !== "index.md")
|
|
69
|
+
.map((f) => {
|
|
70
|
+
const slug = f.replace(/\.md$/, "");
|
|
71
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
|
72
|
+
const { meta, body } = parseMarkdown(raw);
|
|
73
|
+
return { slug, meta: meta || {}, body, rel: `docs/decisions/${f}` };
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
_readVocabulary() {
|
|
78
|
+
const file = path.join(this.repoRoot, "CONTEXT.md");
|
|
79
|
+
if (!fs.existsSync(file)) return [];
|
|
80
|
+
const text = fs.readFileSync(file, "utf8");
|
|
81
|
+
const lines = text.split("\n");
|
|
82
|
+
const terms = [];
|
|
83
|
+
let inGlossary = false;
|
|
84
|
+
let current = null;
|
|
85
|
+
for (const line of lines) {
|
|
86
|
+
if (/^##\s+Glossary\s*$/.test(line)) { inGlossary = true; continue; }
|
|
87
|
+
if (/^##\s+/.test(line) && !/^##\s+Glossary/.test(line)) { inGlossary = false; }
|
|
88
|
+
if (!inGlossary) continue;
|
|
89
|
+
const h = line.match(/^###\s+(.+?)\s*$/);
|
|
90
|
+
if (h) {
|
|
91
|
+
if (current) terms.push(current);
|
|
92
|
+
current = { term: h[1].trim(), body: "" };
|
|
93
|
+
} else if (current && line.trim() && !line.startsWith("_Avoid_")) {
|
|
94
|
+
if (!current.body) current.body = line.trim();
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
if (current) terms.push(current);
|
|
98
|
+
return terms;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
_readLearnings() {
|
|
102
|
+
const dir = path.join(this.repoRoot, "docs", "learnings");
|
|
103
|
+
if (!fs.existsSync(dir)) return [];
|
|
104
|
+
return fs
|
|
105
|
+
.readdirSync(dir)
|
|
106
|
+
.filter((f) => f.endsWith(".md"))
|
|
107
|
+
.map((f) => {
|
|
108
|
+
const raw = fs.readFileSync(path.join(dir, f), "utf8");
|
|
109
|
+
const { meta, body } = parseMarkdown(raw);
|
|
110
|
+
const title = (meta && meta.title) || f.replace(/\.md$/, "");
|
|
111
|
+
return { file: f, title, body, rel: `docs/learnings/${f}` };
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async readNodes(options = {}) {
|
|
116
|
+
const nodes = [];
|
|
117
|
+
const now = new Date().toISOString();
|
|
118
|
+
|
|
119
|
+
for (const d of this._readDecisions()) {
|
|
120
|
+
const attributes = { registry_status: d.meta.status || "current" };
|
|
121
|
+
if (d.meta.decided) attributes.decided = d.meta.decided;
|
|
122
|
+
nodes.push(
|
|
123
|
+
node({
|
|
124
|
+
id: `decision:${d.slug}`,
|
|
125
|
+
type: "decision",
|
|
126
|
+
title: d.meta.subject || d.slug,
|
|
127
|
+
body: d.body || "",
|
|
128
|
+
attributes,
|
|
129
|
+
provenance: provenance({ provider: PROVIDER_ID, source: d.rel, retrievedAt: now, agent: this.agent }),
|
|
130
|
+
}),
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
for (const v of this._readVocabulary()) {
|
|
135
|
+
nodes.push(
|
|
136
|
+
node({
|
|
137
|
+
id: `vocab:${slugify(v.term)}`,
|
|
138
|
+
type: "note",
|
|
139
|
+
title: v.term,
|
|
140
|
+
body: v.body || "",
|
|
141
|
+
attributes: { kind: "vocabulary" },
|
|
142
|
+
provenance: provenance({ provider: PROVIDER_ID, source: "CONTEXT.md", locator: `glossary#${v.term}`, retrievedAt: now, agent: this.agent }),
|
|
143
|
+
}),
|
|
144
|
+
);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
for (const l of this._readLearnings()) {
|
|
148
|
+
nodes.push(
|
|
149
|
+
node({
|
|
150
|
+
id: `learning:${slugify(l.file.replace(/\.md$/, ""))}`,
|
|
151
|
+
type: "note",
|
|
152
|
+
title: l.title,
|
|
153
|
+
body: l.body || "",
|
|
154
|
+
attributes: { kind: "learning" },
|
|
155
|
+
provenance: provenance({ provider: PROVIDER_ID, source: l.rel, retrievedAt: now, agent: this.agent }),
|
|
156
|
+
}),
|
|
157
|
+
);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (options.type) return nodes.filter((n) => n.type === options.type);
|
|
161
|
+
return nodes;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async readEdges() {
|
|
165
|
+
const edges = [];
|
|
166
|
+
const now = new Date().toISOString();
|
|
167
|
+
for (const d of this._readDecisions()) {
|
|
168
|
+
const self = `decision:${d.slug}`;
|
|
169
|
+
const prov = (loc) => provenance({ provider: PROVIDER_ID, source: d.rel, locator: loc, retrievedAt: now, agent: this.agent });
|
|
170
|
+
|
|
171
|
+
// superseded_by: this subject moved to another slug -> that slug supersedes this
|
|
172
|
+
if (d.meta.superseded_by) {
|
|
173
|
+
edges.push(edge({ id: `${d.meta.superseded_by}--supersedes--${d.slug}`, type: "supersedes", from: `decision:${d.meta.superseded_by}`, to: self, provenance: prov("superseded_by") }));
|
|
174
|
+
}
|
|
175
|
+
// merged_into: this folded into another slug
|
|
176
|
+
if (d.meta.merged_into) {
|
|
177
|
+
edges.push(edge({ id: `${d.slug}--merged-into--${d.meta.merged_into}`, type: "merged-into", from: self, to: `decision:${d.meta.merged_into}`, provenance: prov("merged_into") }));
|
|
178
|
+
}
|
|
179
|
+
// supersedes[]: this file absorbed those slugs
|
|
180
|
+
for (const absorbed of Array.isArray(d.meta.supersedes) ? d.meta.supersedes : []) {
|
|
181
|
+
edges.push(edge({ id: `${d.slug}--supersedes--${absorbed}`, type: "supersedes", from: self, to: `decision:${absorbed}`, provenance: prov("supersedes") }));
|
|
182
|
+
}
|
|
183
|
+
// evidence[]: external provenance -> evidence-of (resolved:false, external ref)
|
|
184
|
+
for (const ev of Array.isArray(d.meta.evidence) ? d.meta.evidence : []) {
|
|
185
|
+
if (!ev || !ev.kind || !ev.ref) continue;
|
|
186
|
+
edges.push(edge({ id: `${d.slug}--evidence-of--${evidenceNodeId(ev.kind, ev.ref)}`, type: "evidence-of", from: self, to: evidenceNodeId(ev.kind, ev.ref), resolved: false, attributes: { evidence_kind: ev.kind, ref: ev.ref }, provenance: prov("evidence") }));
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
return edges;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async queryByType(type) {
|
|
193
|
+
return this.readNodes({ type });
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
async readGraph() {
|
|
197
|
+
const [nodes, edges] = await Promise.all([this.readNodes(), this.readEdges()]);
|
|
198
|
+
return { nodes, edges };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Propose a decision-registry topic file. Renders frontmatter compatible with
|
|
203
|
+
* schemas/decision-record.schema.json + the promote sub-flow. Never writes.
|
|
204
|
+
*/
|
|
205
|
+
async proposeWrite(intent = {}) {
|
|
206
|
+
const { subject, body = "", decided, evidence = [] } = intent;
|
|
207
|
+
if (!subject) throw new Error("git-repo proposeWrite requires intent.subject");
|
|
208
|
+
const slug = intent.slug || slugify(subject);
|
|
209
|
+
const evLines = (evidence.length ? evidence : [{ kind: "doc", ref: "TODO: link durable provenance" }])
|
|
210
|
+
.flatMap((e) => [` - kind: ${e.kind}`, ` ref: ${e.ref}`]);
|
|
211
|
+
const rendered = [
|
|
212
|
+
"---",
|
|
213
|
+
"status: current",
|
|
214
|
+
`subject: ${subject}`,
|
|
215
|
+
`decided: ${decided || new Date().toISOString().slice(0, 10)}`,
|
|
216
|
+
"evidence:",
|
|
217
|
+
...evLines,
|
|
218
|
+
"---",
|
|
219
|
+
"",
|
|
220
|
+
`# ${subject}`,
|
|
221
|
+
"",
|
|
222
|
+
body,
|
|
223
|
+
].join("\n");
|
|
224
|
+
return proposal({
|
|
225
|
+
provider: PROVIDER_ID,
|
|
226
|
+
kind: "decision-topic",
|
|
227
|
+
target: { path: `docs/decisions/${slug}.md`, slug },
|
|
228
|
+
payload: { subject, slug, decided, evidence, body },
|
|
229
|
+
rendered,
|
|
230
|
+
rationale: intent.rationale || "Proposed decision topic — enact via the promote sub-flow (write docs/decisions/<slug>.md, add the subject noun to CONTEXT.md, run check:decisions).",
|
|
231
|
+
provenance: provenance({ provider: PROVIDER_ID, source: `docs/decisions/${slug}.md`, agent: this.agent }),
|
|
232
|
+
});
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
export default GitRepoProvider;
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic health verbs — AC1 + AC2 (issue #317).
|
|
3
|
+
*
|
|
4
|
+
* AC1: the SAME health command over the vault provider and the git-repo
|
|
5
|
+
* provider yields schema-valid reports (R1, R2, R4).
|
|
6
|
+
* AC2: a backlog pass over the work-item adapter flags a SEEDED duplicate and
|
|
7
|
+
* a SEEDED broken blocker link, from a fixture — never the real board
|
|
8
|
+
* (R3, R4).
|
|
9
|
+
*
|
|
10
|
+
* Run: node --test kits/knowledge/providers/health/health-pass.test.js
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { test, describe } from "node:test";
|
|
14
|
+
import assert from "node:assert/strict";
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as os from "node:os";
|
|
17
|
+
import * as path from "node:path";
|
|
18
|
+
import { fileURLToPath } from "node:url";
|
|
19
|
+
|
|
20
|
+
import { MarkdownVaultProvider } from "../markdown-vault/index.js";
|
|
21
|
+
import { GitRepoProvider } from "../git-repo/index.js";
|
|
22
|
+
import { WorkItemProvider } from "../work-item/index.js";
|
|
23
|
+
import DefaultKnowledgeStore from "../../adapters/default-store/index.js";
|
|
24
|
+
import { detectDuplicates, checkDependencyLinkIntegrity } from "./index.js";
|
|
25
|
+
import { loadSchemas } from "../lib/model.js";
|
|
26
|
+
import { validate } from "../lib/schema-validate.js";
|
|
27
|
+
|
|
28
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
29
|
+
const FIXTURES = path.join(__dirname, "../conformance/fixtures");
|
|
30
|
+
const { healthReport: REPORT_SCHEMA } = loadSchemas();
|
|
31
|
+
|
|
32
|
+
function assertReport(report, expectCheck, expectProvider) {
|
|
33
|
+
const { valid, errors } = validate(report, REPORT_SCHEMA);
|
|
34
|
+
assert.ok(valid, `report not schema-valid:\n ${errors.join("\n ")}`);
|
|
35
|
+
assert.equal(report.check, expectCheck);
|
|
36
|
+
assert.equal(report.provider, expectProvider);
|
|
37
|
+
assert.equal(report.summary.finding_count, report.findings.length);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async function vaultGraph() {
|
|
41
|
+
const dir = fs.mkdtempSync(path.join(os.tmpdir(), "kg-vault-health-"));
|
|
42
|
+
const store = new DefaultKnowledgeStore({ storeRoot: dir });
|
|
43
|
+
const a = await store.create({ type: "compiled", title: "Caching strategy", body: "use LRU", category: "eng", provenance: { agent: "t" } });
|
|
44
|
+
const b = await store.create({ type: "compiled", title: "Caching strategy", body: "use LRU everywhere", category: "eng", provenance: { agent: "t" } });
|
|
45
|
+
await store.link(b, [{ target_id: a, kind: "refines" }], { agent: "t" });
|
|
46
|
+
const provider = new MarkdownVaultProvider({ store, storeRoot: dir, agent: "t" });
|
|
47
|
+
const graph = await provider.readGraph();
|
|
48
|
+
fs.rmSync(dir, { recursive: true, force: true });
|
|
49
|
+
return graph;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe("AC1: same health command over two providers yields schema-valid reports", () => {
|
|
53
|
+
test("vault provider: duplicate + integrity reports are schema-valid", async () => {
|
|
54
|
+
const graph = await vaultGraph();
|
|
55
|
+
assertReport(detectDuplicates(graph, { provider: "markdown-vault" }), "duplicate-detection", "markdown-vault");
|
|
56
|
+
assertReport(checkDependencyLinkIntegrity(graph, { provider: "markdown-vault" }), "dependency-link-integrity", "markdown-vault");
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test("git-repo provider: duplicate + integrity reports are schema-valid", async () => {
|
|
60
|
+
const provider = new GitRepoProvider({ repoRoot: path.join(FIXTURES, "git-repo"), agent: "t" });
|
|
61
|
+
const graph = await provider.readGraph();
|
|
62
|
+
assertReport(detectDuplicates(graph, { provider: "git-repo" }), "duplicate-detection", "git-repo");
|
|
63
|
+
assertReport(checkDependencyLinkIntegrity(graph, { provider: "git-repo" }), "dependency-link-integrity", "git-repo");
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
describe("AC2: backlog pass over the work-item adapter flags seeded dupe + broken blocker", () => {
|
|
68
|
+
async function workItemGraph() {
|
|
69
|
+
const issues = fs.readFileSync(path.join(FIXTURES, "work-item", "issues.json"), "utf8");
|
|
70
|
+
const provider = new WorkItemProvider({ repo: "seed/fixture", runner: async () => issues, agent: "t" });
|
|
71
|
+
return provider.readGraph();
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
test("duplicate detection flags the seeded #14 ~ #15 pair", async () => {
|
|
75
|
+
const graph = await workItemGraph();
|
|
76
|
+
const report = detectDuplicates(graph, { provider: "work-item" });
|
|
77
|
+
assertReport(report, "duplicate-detection", "work-item");
|
|
78
|
+
const dupe = report.findings.find(
|
|
79
|
+
(f) => f.node_ids.includes("issue:14") && f.node_ids.includes("issue:15"),
|
|
80
|
+
);
|
|
81
|
+
assert.ok(dupe, "expected a duplicate finding for issue:14 ~ issue:15");
|
|
82
|
+
assert.equal(dupe.kind, "duplicate-nodes");
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
test("dependency-link integrity flags the seeded broken blocker (#999 -> #20)", async () => {
|
|
86
|
+
const graph = await workItemGraph();
|
|
87
|
+
const report = checkDependencyLinkIntegrity(graph, { provider: "work-item" });
|
|
88
|
+
assertReport(report, "dependency-link-integrity", "work-item");
|
|
89
|
+
const broken = report.findings.find(
|
|
90
|
+
(f) => f.node_ids.includes("issue:999") && f.node_ids.includes("issue:20"),
|
|
91
|
+
);
|
|
92
|
+
assert.ok(broken, "expected a broken-blocker finding for issue:999 -> issue:20");
|
|
93
|
+
assert.equal(broken.kind, "broken-dependency-link");
|
|
94
|
+
assert.equal(broken.severity, "error");
|
|
95
|
+
// the valid blocker #14 -> #20 must NOT be flagged
|
|
96
|
+
const falsePositive = report.findings.find((f) => f.node_ids.includes("issue:14") && f.node_ids.includes("issue:20"));
|
|
97
|
+
assert.ok(!falsePositive, "valid blocker #14 -> #20 must not be flagged");
|
|
98
|
+
});
|
|
99
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Provider-agnostic knowledge-health verbs.
|
|
3
|
+
*
|
|
4
|
+
* Each verb takes a graph ({ nodes, edges }) produced by ANY provider that
|
|
5
|
+
* implements the read side of the knowledge-store contract and returns a
|
|
6
|
+
* schema-valid Knowledge Health Report (schemas/knowledge/health-report.schema.json).
|
|
7
|
+
* The verbs never inspect provider internals — that is what lets the SAME health
|
|
8
|
+
* command run identically over the vault, git-repo, and work-item providers
|
|
9
|
+
* (AC1, AC2, R4).
|
|
10
|
+
*
|
|
11
|
+
* @module providers/health
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { loadSchemas } from "../lib/model.js";
|
|
15
|
+
import { assertValid } from "../lib/schema-validate.js";
|
|
16
|
+
|
|
17
|
+
const { healthReport: HEALTH_SCHEMA } = loadSchemas();
|
|
18
|
+
|
|
19
|
+
/** Normalise a title into a comparable token set. */
|
|
20
|
+
function tokenize(title) {
|
|
21
|
+
return new Set(
|
|
22
|
+
String(title || "")
|
|
23
|
+
.toLowerCase()
|
|
24
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
25
|
+
.split(/\s+/)
|
|
26
|
+
.filter(Boolean),
|
|
27
|
+
);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function jaccard(a, b) {
|
|
31
|
+
if (a.size === 0 && b.size === 0) return 0;
|
|
32
|
+
let inter = 0;
|
|
33
|
+
for (const t of a) if (b.has(t)) inter++;
|
|
34
|
+
const union = a.size + b.size - inter;
|
|
35
|
+
return union === 0 ? 0 : inter / union;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function finalize(report) {
|
|
39
|
+
report.summary.finding_count = report.findings.length;
|
|
40
|
+
assertValid(report, HEALTH_SCHEMA, `health report (${report.check})`);
|
|
41
|
+
return report;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Duplicate detection: flag node PAIRS whose titles are the same or highly
|
|
46
|
+
* similar (token Jaccard >= threshold). Scoped to same-type pairs by default so
|
|
47
|
+
* a decision and an issue with a shared word are not conflated.
|
|
48
|
+
*
|
|
49
|
+
* @param {{nodes: object[], edges: object[]}} graph
|
|
50
|
+
* @param {object} [options]
|
|
51
|
+
* @param {string} [options.provider="unknown"]
|
|
52
|
+
* @param {number} [options.threshold=0.7]
|
|
53
|
+
* @param {boolean} [options.crossType=false] compare across node types when true
|
|
54
|
+
* @param {string} [options.generatedAt]
|
|
55
|
+
*/
|
|
56
|
+
export function detectDuplicates(graph, options = {}) {
|
|
57
|
+
const nodes = graph.nodes || [];
|
|
58
|
+
const edges = graph.edges || [];
|
|
59
|
+
const threshold = typeof options.threshold === "number" ? options.threshold : 0.7;
|
|
60
|
+
const crossType = options.crossType === true;
|
|
61
|
+
|
|
62
|
+
const report = {
|
|
63
|
+
schema_version: "1.0",
|
|
64
|
+
check: "duplicate-detection",
|
|
65
|
+
provider: options.provider || "unknown",
|
|
66
|
+
generated_at: options.generatedAt || new Date().toISOString(),
|
|
67
|
+
summary: { nodes_examined: nodes.length, edges_examined: edges.length, finding_count: 0 },
|
|
68
|
+
findings: [],
|
|
69
|
+
};
|
|
70
|
+
|
|
71
|
+
const tokenized = nodes.map((n) => ({ node: n, tokens: tokenize(n.title) }));
|
|
72
|
+
for (let i = 0; i < tokenized.length; i++) {
|
|
73
|
+
for (let j = i + 1; j < tokenized.length; j++) {
|
|
74
|
+
const a = tokenized[i];
|
|
75
|
+
const b = tokenized[j];
|
|
76
|
+
if (!crossType && a.node.type !== b.node.type) continue;
|
|
77
|
+
const score = jaccard(a.tokens, b.tokens);
|
|
78
|
+
if (score >= threshold) {
|
|
79
|
+
report.findings.push({
|
|
80
|
+
kind: "duplicate-nodes",
|
|
81
|
+
severity: "warning",
|
|
82
|
+
node_ids: [a.node.id, b.node.id],
|
|
83
|
+
message: `Possible duplicate ${a.node.type} nodes: "${a.node.title}" ~ "${b.node.title}" (similarity ${score.toFixed(2)})`,
|
|
84
|
+
evidence: {
|
|
85
|
+
similarity: Number(score.toFixed(4)),
|
|
86
|
+
threshold,
|
|
87
|
+
title_a: a.node.title,
|
|
88
|
+
title_b: b.node.title,
|
|
89
|
+
type: a.node.type,
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return finalize(report);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Dependency-link integrity: flag dependency edges whose endpoints do not
|
|
100
|
+
* resolve to a node present in the graph. An edge marked resolved:false is a
|
|
101
|
+
* deliberate external reference and is skipped.
|
|
102
|
+
*
|
|
103
|
+
* @param {{nodes: object[], edges: object[]}} graph
|
|
104
|
+
* @param {object} [options]
|
|
105
|
+
* @param {string} [options.provider="unknown"]
|
|
106
|
+
* @param {string[]} [options.dependencyTypes=["blocks"]] edge types treated as dependency links
|
|
107
|
+
* @param {string} [options.generatedAt]
|
|
108
|
+
*/
|
|
109
|
+
export function checkDependencyLinkIntegrity(graph, options = {}) {
|
|
110
|
+
const nodes = graph.nodes || [];
|
|
111
|
+
const edges = graph.edges || [];
|
|
112
|
+
const depTypes = new Set(options.dependencyTypes || ["blocks"]);
|
|
113
|
+
const ids = new Set(nodes.map((n) => n.id));
|
|
114
|
+
|
|
115
|
+
const report = {
|
|
116
|
+
schema_version: "1.0",
|
|
117
|
+
check: "dependency-link-integrity",
|
|
118
|
+
provider: options.provider || "unknown",
|
|
119
|
+
generated_at: options.generatedAt || new Date().toISOString(),
|
|
120
|
+
summary: { nodes_examined: nodes.length, edges_examined: edges.length, finding_count: 0 },
|
|
121
|
+
findings: [],
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
for (const e of edges) {
|
|
125
|
+
if (!depTypes.has(e.type)) continue;
|
|
126
|
+
if (e.resolved === false) continue; // deliberate external ref
|
|
127
|
+
const sourceMissing = !ids.has(e.from);
|
|
128
|
+
const targetMissing = !ids.has(e.to);
|
|
129
|
+
if (!sourceMissing && !targetMissing) continue;
|
|
130
|
+
report.findings.push({
|
|
131
|
+
kind: "broken-dependency-link",
|
|
132
|
+
severity: "error",
|
|
133
|
+
edge_ids: [e.id],
|
|
134
|
+
node_ids: [e.from, e.to],
|
|
135
|
+
message: `Broken ${e.type} link ${e.from} -> ${e.to}: ${
|
|
136
|
+
targetMissing ? `target '${e.to}' not found` : ""
|
|
137
|
+
}${targetMissing && sourceMissing ? "; " : ""}${sourceMissing ? `source '${e.from}' not found` : ""}`.trim(),
|
|
138
|
+
evidence: {
|
|
139
|
+
edge_type: e.type,
|
|
140
|
+
from: e.from,
|
|
141
|
+
to: e.to,
|
|
142
|
+
source_missing: sourceMissing,
|
|
143
|
+
target_missing: targetMissing,
|
|
144
|
+
},
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
return finalize(report);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export const HEALTH_VERBS = Object.freeze({
|
|
151
|
+
"duplicate-detection": detectDuplicates,
|
|
152
|
+
"dependency-link-integrity": checkDependencyLinkIntegrity,
|
|
153
|
+
});
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge Kit store providers — public entry point.
|
|
3
|
+
*
|
|
4
|
+
* Typed graph model (nodes / edges / provenance) with pluggable providers behind
|
|
5
|
+
* one read + proposals-only write interface, plus provider-agnostic health verbs.
|
|
6
|
+
* See context/contracts/knowledge-store-contract.md.
|
|
7
|
+
*
|
|
8
|
+
* @module providers
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export { MarkdownVaultProvider } from "./markdown-vault/index.js";
|
|
12
|
+
export { GitRepoProvider } from "./git-repo/index.js";
|
|
13
|
+
export { WorkItemProvider, parseWorkItemMetadata } from "./work-item/index.js";
|
|
14
|
+
export { detectDuplicates, checkDependencyLinkIntegrity, HEALTH_VERBS } from "./health/index.js";
|
|
15
|
+
export {
|
|
16
|
+
EDGE_TYPES,
|
|
17
|
+
CORE_NODE_TYPES,
|
|
18
|
+
loadSchemas,
|
|
19
|
+
node,
|
|
20
|
+
edge,
|
|
21
|
+
proposal,
|
|
22
|
+
provenance,
|
|
23
|
+
} from "./lib/model.js";
|
|
24
|
+
export { validate, assertValid } from "./lib/schema-validate.js";
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Knowledge graph model helpers: node / edge / proposal / provenance
|
|
3
|
+
* constructors, the closed edge-type vocabulary, the recommended node-type
|
|
4
|
+
* vocabulary, and loaders for the JSON schemas that define them.
|
|
5
|
+
*
|
|
6
|
+
* @module providers/lib/model
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import * as fs from "node:fs";
|
|
10
|
+
import * as path from "node:path";
|
|
11
|
+
import { fileURLToPath } from "node:url";
|
|
12
|
+
|
|
13
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
14
|
+
const SCHEMA_DIR = path.resolve(__dirname, "../../../../schemas/knowledge");
|
|
15
|
+
|
|
16
|
+
/** Closed vocabulary of edge types (schema-enforced). */
|
|
17
|
+
export const EDGE_TYPES = Object.freeze([
|
|
18
|
+
"supersedes",
|
|
19
|
+
"merged-into",
|
|
20
|
+
"blocks",
|
|
21
|
+
"evidence-of",
|
|
22
|
+
"mentions",
|
|
23
|
+
"relates",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Recommended core node types. EXTENSIBLE — providers may emit others. */
|
|
27
|
+
export const CORE_NODE_TYPES = Object.freeze([
|
|
28
|
+
"note",
|
|
29
|
+
"decision",
|
|
30
|
+
"issue",
|
|
31
|
+
"session",
|
|
32
|
+
"person",
|
|
33
|
+
]);
|
|
34
|
+
|
|
35
|
+
let _schemaCache = null;
|
|
36
|
+
/** Load and cache the four graph schemas. */
|
|
37
|
+
export function loadSchemas() {
|
|
38
|
+
if (_schemaCache) return _schemaCache;
|
|
39
|
+
const read = (name) => JSON.parse(fs.readFileSync(path.join(SCHEMA_DIR, name), "utf8"));
|
|
40
|
+
_schemaCache = {
|
|
41
|
+
node: read("node.schema.json"),
|
|
42
|
+
edge: read("edge.schema.json"),
|
|
43
|
+
proposal: read("proposal.schema.json"),
|
|
44
|
+
healthReport: read("health-report.schema.json"),
|
|
45
|
+
};
|
|
46
|
+
return _schemaCache;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Build a provenance object. */
|
|
50
|
+
export function provenance({ provider, source, locator, retrievedAt, agent }) {
|
|
51
|
+
const p = {
|
|
52
|
+
provider,
|
|
53
|
+
source,
|
|
54
|
+
retrieved_at: retrievedAt || new Date().toISOString(),
|
|
55
|
+
};
|
|
56
|
+
if (locator) p.locator = locator;
|
|
57
|
+
if (agent) p.agent = agent;
|
|
58
|
+
return p;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Build a node. Omits empty optional fields so schema additionalProperties holds. */
|
|
62
|
+
export function node({ id, type, title, body, attributes, provenance: prov }) {
|
|
63
|
+
const n = { id, type, title, provenance: prov };
|
|
64
|
+
if (body !== undefined) n.body = body;
|
|
65
|
+
if (attributes && Object.keys(attributes).length) n.attributes = attributes;
|
|
66
|
+
return n;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Build an edge. `resolved` defaults to true (an internal dependency link). */
|
|
70
|
+
export function edge({ id, type, from, to, resolved, attributes, provenance: prov }) {
|
|
71
|
+
const e = { id, type, from, to, provenance: prov };
|
|
72
|
+
if (resolved === false) e.resolved = false;
|
|
73
|
+
if (attributes && Object.keys(attributes).length) e.attributes = attributes;
|
|
74
|
+
return e;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** Build a proposal. status is always "proposed" — proposals-only by construction. */
|
|
78
|
+
export function proposal({ provider, kind, target, payload, rendered, rationale, provenance: prov }) {
|
|
79
|
+
const p = {
|
|
80
|
+
schema_version: "1.0",
|
|
81
|
+
provider,
|
|
82
|
+
kind,
|
|
83
|
+
target: target || {},
|
|
84
|
+
status: "proposed",
|
|
85
|
+
provenance: prov,
|
|
86
|
+
};
|
|
87
|
+
if (payload) p.payload = payload;
|
|
88
|
+
if (rendered !== undefined) p.rendered = rendered;
|
|
89
|
+
if (rationale) p.rationale = rationale;
|
|
90
|
+
return p;
|
|
91
|
+
}
|