@kontourai/flow-agents 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +12 -0
- package/CHANGELOG.md +32 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +188 -0
- package/build/src/cli/assignment-provider.js +193 -51
- package/build/src/cli/workflow-sidecar.d.ts +16 -6
- package/build/src/cli/workflow-sidecar.js +492 -45
- package/build/src/lib/flow-resolver.d.ts +12 -6
- package/build/src/lib/flow-resolver.js +30 -14
- package/build/src/tools/validate-source-tree.js +2 -1
- package/context/contracts/assignment-provider-contract.md +10 -1
- package/context/contracts/execution-contract.md +78 -0
- package/context/contracts/probe-docs-write-contract.md +187 -0
- package/context/scripts/hooks/config-protection.js +24 -4
- package/context/scripts/hooks/lib/config-protection-remedies.js +9 -0
- package/context/scripts/hooks/stop-goal-fit.js +262 -5
- package/context/scripts/hooks/workflow-steering.js +42 -0
- package/docs/adr/0001-flow-agents-consumes-flow.md +2 -0
- package/docs/adr/0002-flow-kits-as-extension-unit.md +2 -0
- package/docs/adr/0003-flow-agents-coordinates-kits-and-adapters.md +2 -0
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0006-typescript-first-source-policy.md +2 -0
- package/docs/adr/0007-flow-skill-kit-tool-boundary.md +2 -0
- package/docs/adr/0007-skill-audit.md +2 -0
- package/docs/adr/0008-kit-operation-boundary.md +2 -0
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +2 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +2 -0
- package/docs/adr/0011-mcp-posture.md +2 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +2 -0
- package/docs/adr/0013-context-lifecycle.md +2 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +2 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +2 -0
- package/docs/adr/0016-three-hard-boundary-model.md +2 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +2 -0
- package/docs/adr/0018-freeze-local-shell-heuristics.md +2 -0
- package/docs/adr/0019-kit-dependency-ownership.md +2 -0
- package/docs/adr/0020-trust-reconcile-manifest-and-claim-classification.md +2 -0
- package/docs/adr/0021-assignment-leases-and-stale-claim-takeover.md +3 -1
- package/docs/adr/0022-fail-closed-delivery-reconciliation-with-governed-exemptions.md +113 -0
- package/docs/adr/README.md +49 -0
- package/docs/adr/index.md +34 -0
- package/docs/decisions/agent-coordination.md +20 -0
- package/docs/decisions/anti-gaming-trust-security.md +20 -0
- package/docs/decisions/context-lifecycle.md +18 -0
- package/docs/decisions/core-domain-kit-boundary.md +18 -0
- package/docs/decisions/flow-flow-agents-boundary.md +18 -0
- package/docs/decisions/flow-kit.md +20 -0
- package/docs/decisions/flow-skill-kit-tool-boundary.md +20 -0
- package/docs/decisions/graph-knowledge-provider.md +63 -0
- package/docs/decisions/hook-core-kit-boundary.md +18 -0
- package/docs/decisions/index.md +19 -0
- package/docs/decisions/kit-dependency-ownership.md +18 -0
- package/docs/decisions/kit-operation-boundary.md +18 -0
- package/docs/decisions/knowledge-promote-sub-flow.md +59 -0
- package/docs/decisions/kontour-resource-contract.md +18 -0
- package/docs/decisions/mcp-posture.md +18 -0
- package/docs/decisions/three-hard-boundary-model.md +18 -0
- package/docs/decisions/trust-reconcile.md +20 -0
- package/docs/decisions/typescript-source-policy.md +48 -0
- package/docs/decisions/workflow-enforcement.md +18 -0
- package/docs/decisions/workflow-trust-state.md +20 -0
- package/docs/fixture-ownership.md +1 -1
- package/docs/workflow-usage-guide.md +1 -1
- package/evals/ci/run-baseline.sh +6 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -0
- package/evals/integration/test_checkpoint_signing.sh +4 -3
- package/evals/integration/test_current_json_per_actor.sh +516 -0
- package/evals/integration/test_ensure_session_ownership_guard.sh +435 -0
- package/evals/integration/test_gate_lockdown.sh +46 -0
- package/evals/integration/test_model_routing_escalation.sh +145 -0
- package/evals/integration/test_publish_delivery.sh +14 -6
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_stop_hook_release.sh +552 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +170 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +10 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_model_routing_hints.sh +107 -0
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/builder-shape/SKILL.md +10 -0
- package/kits/builder/skills/deliver/SKILL.md +69 -11
- package/kits/builder/skills/design-probe/SKILL.md +47 -0
- package/kits/builder/skills/execute-plan/SKILL.md +13 -0
- package/kits/builder/skills/fix-bug/SKILL.md +17 -0
- package/kits/builder/skills/idea-to-backlog/SKILL.md +10 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- package/kits/builder/skills/plan-work/SKILL.md +9 -0
- package/kits/builder/skills/pull-work/SKILL.md +10 -0
- package/kits/builder/skills/review-work/SKILL.md +11 -0
- package/kits/builder/skills/tdd-workflow/SKILL.md +17 -0
- package/kits/builder/skills/verify-work/SKILL.md +11 -0
- package/kits/knowledge/adapters/default-store/index.js +147 -18
- package/kits/knowledge/adapters/flow-runner/index.js +912 -16
- package/kits/knowledge/adapters/obsidian-store/index.js +97 -14
- package/kits/knowledge/adapters/shared/codec.js +265 -0
- package/kits/knowledge/docs/README.md +121 -2
- package/kits/knowledge/docs/store-contract.md +517 -7
- package/kits/knowledge/evals/audit-freshness/suite.test.js +92 -1
- package/kits/knowledge/evals/consolidate-incremental/suite.test.js +494 -0
- package/kits/knowledge/evals/consolidation/suite.test.js +1 -1
- package/kits/knowledge/evals/contract-suite/suite.test.js +212 -0
- package/kits/knowledge/evals/freshness/suite.test.js +339 -0
- package/kits/knowledge/evals/inbound-references/suite.test.js +351 -0
- package/kits/knowledge/evals/retirement/suite.test.js +1 -1
- package/kits/knowledge/evals/supersede-propagation/suite.test.js +384 -0
- package/kits/knowledge/flows/promote.flow.json +84 -0
- package/kits/knowledge/kit.json +15 -0
- package/kits/knowledge/promote/distill.js +96 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/CONTEXT.md +7 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction-policy.md +12 -0
- package/kits/knowledge/promote/fixtures/contradiction-registry/docs/decisions/cache-eviction.md +12 -0
- package/kits/knowledge/promote/fixtures/repo/CONTEXT.md +11 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/index.md +9 -0
- package/kits/knowledge/promote/fixtures/repo/docs/decisions/liveness-claim.md +12 -0
- package/kits/knowledge/promote/fixtures/session/acceptance.json +9 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-planner/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-verifier/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/agents/tool-worker-1-1/events.jsonl +1 -0
- package/kits/knowledge/promote/fixtures/session/kontourai-flow-agents-287--deliver.md +47 -0
- package/kits/knowledge/promote/fixtures/session/learning.json +35 -0
- package/kits/knowledge/promote/fixtures/session/state.json +19 -0
- package/kits/knowledge/promote/health.js +137 -0
- package/kits/knowledge/promote/index.js +176 -0
- package/kits/knowledge/promote/ingest.js +103 -0
- package/kits/knowledge/promote/lib.js +132 -0
- package/kits/knowledge/promote/link.js +84 -0
- package/kits/knowledge/promote/promote.test.js +174 -0
- package/kits/knowledge/providers/conformance/suite.test.js +18 -0
- package/kits/knowledge/providers/index.js +1 -0
- package/kits/knowledge/providers/neo4j/connection.js +121 -0
- package/kits/knowledge/providers/neo4j/cypher.js +190 -0
- package/kits/knowledge/providers/neo4j/fake-driver.js +123 -0
- package/kits/knowledge/providers/neo4j/fixtures/spike-ground-truth.json +29 -0
- package/kits/knowledge/providers/neo4j/graph-queries.js +210 -0
- package/kits/knowledge/providers/neo4j/index.js +280 -0
- package/kits/knowledge/providers/neo4j/integration.test.js +122 -0
- package/kits/knowledge/providers/neo4j/neo4j.test.js +219 -0
- package/kits/knowledge/providers/neo4j/sync.js +235 -0
- package/package.json +4 -2
- package/schemas/workflow-handoff.schema.json +6 -0
- package/scripts/README.md +1 -0
- package/scripts/ci/mint-attestation.js +33 -6
- package/scripts/ci/trust-reconcile.js +144 -26
- package/scripts/freeze-adrs.mjs +364 -0
- package/scripts/hooks/config-protection.js +24 -4
- 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 +262 -5
- package/scripts/hooks/workflow-steering.js +42 -0
- package/scripts/statusline/flow-agents-statusline.js +3 -1
- package/src/cli/assignment-provider.ts +239 -59
- package/src/cli/workflow-sidecar.ts +529 -43
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -57,6 +57,16 @@ import {
|
|
|
57
57
|
VALID_TYPES,
|
|
58
58
|
VALID_STATUS_TRANSITIONS,
|
|
59
59
|
validateCategory,
|
|
60
|
+
// Record-identity resolution layer (short-id prefix + slug aliases, issue #339)
|
|
61
|
+
resolveRecordId,
|
|
62
|
+
normalizeAliases,
|
|
63
|
+
loadAliasIndex,
|
|
64
|
+
saveAliasIndex,
|
|
65
|
+
registerAliases,
|
|
66
|
+
// Record-carried freshness + derived staleness (issue #341, Addendum J).
|
|
67
|
+
freshnessPatch,
|
|
68
|
+
decodeFreshnessFields,
|
|
69
|
+
isRecordStale,
|
|
60
70
|
} from "../shared/codec.js";
|
|
61
71
|
|
|
62
72
|
// ---------------------------------------------------------------------------
|
|
@@ -94,9 +104,33 @@ export class ObsidianKnowledgeStore {
|
|
|
94
104
|
this._graphPath = path.join(this._root, "graph-index.json");
|
|
95
105
|
// Path index (internal): { by_id: { id: { path, archived } }, by_path: { relPath: id } }
|
|
96
106
|
this._pathIndexPath = path.join(this._root, ".graph-index.json");
|
|
107
|
+
// Alias map (issue #339): { schema_version, by_slug: { slug: id } }. Keyed by
|
|
108
|
+
// full id, never by file path, so slugs survive the folder moves this adapter
|
|
109
|
+
// performs on recategorize/archive.
|
|
110
|
+
this._aliasPath = path.join(this._root, "alias-index.json");
|
|
97
111
|
fs.mkdirSync(this._root, { recursive: true });
|
|
98
112
|
}
|
|
99
113
|
|
|
114
|
+
// -------------------------------------------------------------------------
|
|
115
|
+
// Record-identity resolution (short-id prefix + slug alias, issue #339)
|
|
116
|
+
// -------------------------------------------------------------------------
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* Resolve a query token (exact id, slug alias, or unambiguous id prefix) to a
|
|
120
|
+
* single full record id, or null. Throws AMBIGUOUS_ID on a >1-match prefix.
|
|
121
|
+
* Ids and aliases are read from the persisted indexes, so resolution is
|
|
122
|
+
* unaffected by where a record's file currently lives.
|
|
123
|
+
*/
|
|
124
|
+
_resolveId(input, pathIndex, aliasIndex) {
|
|
125
|
+
pathIndex = pathIndex || this._loadPathIndex();
|
|
126
|
+
aliasIndex = aliasIndex || loadAliasIndex(this._aliasPath);
|
|
127
|
+
return resolveRecordId(input, {
|
|
128
|
+
idExists: (rid) => Boolean(pathIndex.by_id[rid]),
|
|
129
|
+
listIds: () => Object.keys(pathIndex.by_id),
|
|
130
|
+
bySlug: aliasIndex.by_slug,
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
100
134
|
// -------------------------------------------------------------------------
|
|
101
135
|
// Path / slug helpers
|
|
102
136
|
// -------------------------------------------------------------------------
|
|
@@ -272,7 +306,8 @@ export class ObsidianKnowledgeStore {
|
|
|
272
306
|
// Reconstruct the record body from the rendered markdown section.
|
|
273
307
|
// `meta` no longer contains `body` — the rendered text is the source of truth.
|
|
274
308
|
const body = this._parseBodyFromRendered(meta.type, renderedText);
|
|
275
|
-
|
|
309
|
+
// Coerce record-carried freshness fields to canonical types (#341).
|
|
310
|
+
return decodeFreshnessFields({ ...meta, body });
|
|
276
311
|
}
|
|
277
312
|
|
|
278
313
|
/**
|
|
@@ -505,6 +540,18 @@ export class ObsidianKnowledgeStore {
|
|
|
505
540
|
const id = input.id || randomUUID();
|
|
506
541
|
const now = this._now();
|
|
507
542
|
|
|
543
|
+
// Validate + reserve slug aliases before any write (issue #339); a
|
|
544
|
+
// SLUG_CONFLICT aborts create without leaving a partial note behind.
|
|
545
|
+
const aliases = normalizeAliases(input.aliases);
|
|
546
|
+
let aliasIndex = null;
|
|
547
|
+
if (aliases.length) {
|
|
548
|
+
aliasIndex = loadAliasIndex(this._aliasPath);
|
|
549
|
+
registerAliases(aliasIndex, id, aliases);
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
// Validate optional freshness fields (#341) before any write.
|
|
553
|
+
const fresh = freshnessPatch(input);
|
|
554
|
+
|
|
508
555
|
const explicitLinks = input.links || [];
|
|
509
556
|
const wikilinks = extractWikilinks(input.body || "");
|
|
510
557
|
const links = mergeLinks(explicitLinks, wikilinks);
|
|
@@ -515,9 +562,11 @@ export class ObsidianKnowledgeStore {
|
|
|
515
562
|
title: input.title,
|
|
516
563
|
category: input.category,
|
|
517
564
|
tags: input.tags || [],
|
|
565
|
+
...(aliases.length ? { aliases } : {}),
|
|
518
566
|
status: "active",
|
|
519
567
|
created_at: now,
|
|
520
568
|
updated_at: now,
|
|
569
|
+
...fresh,
|
|
521
570
|
provenance: {
|
|
522
571
|
agent: input.provenance.agent,
|
|
523
572
|
...(input.provenance.session_id ? { session_id: input.provenance.session_id } : {}),
|
|
@@ -535,6 +584,8 @@ export class ObsidianKnowledgeStore {
|
|
|
535
584
|
addLinksToGraph(graph, id, links);
|
|
536
585
|
saveGraph(this._graphPath, graph);
|
|
537
586
|
|
|
587
|
+
if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
|
|
588
|
+
|
|
538
589
|
return id;
|
|
539
590
|
}
|
|
540
591
|
|
|
@@ -550,7 +601,7 @@ export class ObsidianKnowledgeStore {
|
|
|
550
601
|
const record = this._readRecord(id, pathIndex);
|
|
551
602
|
if (!record) throw notFoundError(id);
|
|
552
603
|
|
|
553
|
-
const mutableKeys = ["title", "body", "category", "tags", "links"];
|
|
604
|
+
const mutableKeys = ["title", "body", "category", "tags", "links", "aliases", "expires_at", "ttl_seconds"];
|
|
554
605
|
const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
|
|
555
606
|
if (supplied.length === 0)
|
|
556
607
|
throw missingEvidenceError("update: at least one mutable field must be supplied");
|
|
@@ -558,8 +609,23 @@ export class ObsidianKnowledgeStore {
|
|
|
558
609
|
if (fields.category !== undefined && !validateCategory(fields.category))
|
|
559
610
|
throw missingEvidenceError(`update: invalid category: ${fields.category}`);
|
|
560
611
|
|
|
612
|
+
// Validate/normalize supplied freshness fields (#341); null/"" clears a field.
|
|
613
|
+
const fresh = freshnessPatch(fields);
|
|
614
|
+
|
|
561
615
|
const now = this._now();
|
|
562
616
|
|
|
617
|
+
// Slug aliases are append-only: union supplied aliases with existing ones so
|
|
618
|
+
// a previously issued slug keeps resolving after the file relocates (R3).
|
|
619
|
+
let mergedAliases = Array.isArray(record.aliases) ? record.aliases.slice() : [];
|
|
620
|
+
let aliasIndex = null;
|
|
621
|
+
if (fields.aliases !== undefined) {
|
|
622
|
+
const incoming = normalizeAliases(fields.aliases);
|
|
623
|
+
const seen = new Set(mergedAliases);
|
|
624
|
+
for (const s of incoming) if (!seen.has(s)) { seen.add(s); mergedAliases.push(s); }
|
|
625
|
+
aliasIndex = loadAliasIndex(this._aliasPath);
|
|
626
|
+
registerAliases(aliasIndex, id, mergedAliases);
|
|
627
|
+
}
|
|
628
|
+
|
|
563
629
|
let newLinks = record.links || [];
|
|
564
630
|
if (fields.links !== undefined) {
|
|
565
631
|
const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
|
|
@@ -575,6 +641,8 @@ export class ObsidianKnowledgeStore {
|
|
|
575
641
|
...(fields.body !== undefined ? { body: fields.body } : {}),
|
|
576
642
|
...(fields.category !== undefined ? { category: fields.category } : {}),
|
|
577
643
|
...(fields.tags !== undefined ? { tags: fields.tags } : {}),
|
|
644
|
+
...(mergedAliases.length ? { aliases: mergedAliases } : {}),
|
|
645
|
+
...fresh,
|
|
578
646
|
links: newLinks,
|
|
579
647
|
updated_at: now,
|
|
580
648
|
mutation_log: [
|
|
@@ -596,6 +664,8 @@ export class ObsidianKnowledgeStore {
|
|
|
596
664
|
|
|
597
665
|
this._writeRecord(updated, pathIndex);
|
|
598
666
|
this._savePathIndex(pathIndex);
|
|
667
|
+
|
|
668
|
+
if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
|
|
599
669
|
}
|
|
600
670
|
|
|
601
671
|
// -------------------------------------------------------------------------
|
|
@@ -956,7 +1026,12 @@ export class ObsidianKnowledgeStore {
|
|
|
956
1026
|
// -------------------------------------------------------------------------
|
|
957
1027
|
|
|
958
1028
|
async get(id) {
|
|
959
|
-
|
|
1029
|
+
// Accept an exact id, slug alias, or unambiguous id prefix (issue #339).
|
|
1030
|
+
// Unresolved → null; ambiguous prefix → throws AMBIGUOUS_ID.
|
|
1031
|
+
const pathIndex = this._loadPathIndex();
|
|
1032
|
+
const resolvedId = this._resolveId(id, pathIndex);
|
|
1033
|
+
if (!resolvedId) return null;
|
|
1034
|
+
return this._readRecord(resolvedId, pathIndex);
|
|
960
1035
|
}
|
|
961
1036
|
|
|
962
1037
|
// -------------------------------------------------------------------------
|
|
@@ -964,10 +1039,13 @@ export class ObsidianKnowledgeStore {
|
|
|
964
1039
|
// -------------------------------------------------------------------------
|
|
965
1040
|
|
|
966
1041
|
async getLinks(id) {
|
|
1042
|
+
// Resolve prefix/slug to a full id; fall back to the raw token when it
|
|
1043
|
+
// resolves to nothing so unknown ids return empty arrays (not throw).
|
|
1044
|
+
const key = this._resolveId(id) || id;
|
|
967
1045
|
const graph = loadGraph(this._graphPath);
|
|
968
1046
|
return {
|
|
969
|
-
forward: (graph.forward[
|
|
970
|
-
reverse: (graph.reverse[
|
|
1047
|
+
forward: (graph.forward[key] || []).map((l) => ({ ...l })),
|
|
1048
|
+
reverse: (graph.reverse[key] || []).map((l) => ({ ...l })),
|
|
971
1049
|
};
|
|
972
1050
|
}
|
|
973
1051
|
|
|
@@ -978,18 +1056,19 @@ export class ObsidianKnowledgeStore {
|
|
|
978
1056
|
async listByCategory(category, options = {}) {
|
|
979
1057
|
const records = this._allRecords();
|
|
980
1058
|
const includeRetired = options.includeRetired === true;
|
|
1059
|
+
// Derived staleness filter (#341): keep only records past their own effective
|
|
1060
|
+
// expiry at `now` (injectable for tests). Absent → no staleness filtering.
|
|
1061
|
+
const staleOnly = options.stale === true;
|
|
1062
|
+
const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
|
|
1063
|
+
const keep = (r) =>
|
|
1064
|
+
(includeRetired || (r.status || "active") !== "retired") &&
|
|
1065
|
+
(!staleOnly || isRecordStale(r, nowMs));
|
|
981
1066
|
if (options.prefix) {
|
|
982
1067
|
return records.filter(
|
|
983
|
-
(r) =>
|
|
984
|
-
(r.category === category || r.category.startsWith(`${category}.`)) &&
|
|
985
|
-
(includeRetired || (r.status || "active") !== "retired")
|
|
1068
|
+
(r) => (r.category === category || r.category.startsWith(`${category}.`)) && keep(r)
|
|
986
1069
|
);
|
|
987
1070
|
}
|
|
988
|
-
return records.filter(
|
|
989
|
-
(r) =>
|
|
990
|
-
r.category === category &&
|
|
991
|
-
(includeRetired || (r.status || "active") !== "retired")
|
|
992
|
-
);
|
|
1071
|
+
return records.filter((r) => r.category === category && keep(r));
|
|
993
1072
|
}
|
|
994
1073
|
|
|
995
1074
|
// -------------------------------------------------------------------------
|
|
@@ -998,10 +1077,14 @@ export class ObsidianKnowledgeStore {
|
|
|
998
1077
|
|
|
999
1078
|
async listByType(type, options = {}) {
|
|
1000
1079
|
const includeRetired = options.includeRetired === true;
|
|
1080
|
+
// Derived staleness filter (#341) — see listByCategory.
|
|
1081
|
+
const staleOnly = options.stale === true;
|
|
1082
|
+
const nowMs = options.now !== undefined ? new Date(options.now).getTime() : Date.now();
|
|
1001
1083
|
return this._allRecords().filter(
|
|
1002
1084
|
(r) =>
|
|
1003
1085
|
r.type === type &&
|
|
1004
|
-
(includeRetired || (r.status || "active") !== "retired")
|
|
1086
|
+
(includeRetired || (r.status || "active") !== "retired") &&
|
|
1087
|
+
(!staleOnly || isRecordStale(r, nowMs))
|
|
1005
1088
|
);
|
|
1006
1089
|
}
|
|
1007
1090
|
}
|
|
@@ -323,3 +323,268 @@ export function validateCategory(cat) {
|
|
|
323
323
|
if (!cat || typeof cat !== "string") return false;
|
|
324
324
|
return cat.split(".").every((seg) => CATEGORY_SEGMENT_RE.test(seg));
|
|
325
325
|
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// Record identity resolution — short-id prefix + slug aliases (issue #339)
|
|
329
|
+
//
|
|
330
|
+
// On-disk identity is unchanged: records remain keyed by their full `id`
|
|
331
|
+
// (§9). The functions below add a *resolution layer* on top of that identity
|
|
332
|
+
// so a query token may be the full id, a category-scoped human-readable slug
|
|
333
|
+
// alias, or an unambiguous id prefix. See store-contract.md Addendum H.
|
|
334
|
+
// ---------------------------------------------------------------------------
|
|
335
|
+
|
|
336
|
+
// Minimum length of an id prefix that `get`/`getLinks` will attempt to resolve.
|
|
337
|
+
// Tokens shorter than this are never treated as prefixes (they resolve to null
|
|
338
|
+
// rather than risk a wildly ambiguous match). The ops design partner cites
|
|
339
|
+
// records by 8-char short ids, so 8 is the contract minimum.
|
|
340
|
+
export const MIN_ID_PREFIX = 8;
|
|
341
|
+
|
|
342
|
+
// A slug alias is a lowercase, category-scoped handle, e.g.
|
|
343
|
+
// `decision.strategy/2026-07-03-gtm-direction`. It must start and end with an
|
|
344
|
+
// alphanumeric and may contain dots, slashes, hyphens and underscores between.
|
|
345
|
+
export const SLUG_PATTERN = "^[a-z0-9]([a-z0-9._/-]*[a-z0-9])?$";
|
|
346
|
+
const SLUG_RE = new RegExp(SLUG_PATTERN);
|
|
347
|
+
|
|
348
|
+
export function validateSlug(slug) {
|
|
349
|
+
return typeof slug === "string" && slug.length > 0 && slug.length <= 200 && SLUG_RE.test(slug);
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/**
|
|
353
|
+
* Validate + de-duplicate a caller-supplied `aliases` array. Throws
|
|
354
|
+
* MISSING_EVIDENCE (the contract's rejection channel) on a non-array or a
|
|
355
|
+
* malformed slug so bad aliases are rejected at the mutation boundary.
|
|
356
|
+
* @returns {string[]} normalized, order-preserving, de-duplicated slug list
|
|
357
|
+
*/
|
|
358
|
+
export function normalizeAliases(input) {
|
|
359
|
+
if (input === undefined || input === null) return [];
|
|
360
|
+
if (!Array.isArray(input))
|
|
361
|
+
throw missingEvidenceError("aliases must be an array of slug strings");
|
|
362
|
+
const out = [];
|
|
363
|
+
const seen = new Set();
|
|
364
|
+
for (const raw of input) {
|
|
365
|
+
if (!validateSlug(raw))
|
|
366
|
+
throw missingEvidenceError(
|
|
367
|
+
`invalid slug alias: ${JSON.stringify(raw)} (must match ${SLUG_PATTERN})`
|
|
368
|
+
);
|
|
369
|
+
if (!seen.has(raw)) { seen.add(raw); out.push(raw); }
|
|
370
|
+
}
|
|
371
|
+
return out;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export function ambiguousIdError(input, matches) {
|
|
375
|
+
const shown = matches.slice(0, 5).join(", ");
|
|
376
|
+
const err = new Error(
|
|
377
|
+
`Ambiguous id prefix "${input}" matches ${matches.length} records: ${shown}${matches.length > 5 ? ", …" : ""}`
|
|
378
|
+
);
|
|
379
|
+
err.code = "AMBIGUOUS_ID";
|
|
380
|
+
err.matches = matches.slice();
|
|
381
|
+
return err;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
/**
|
|
385
|
+
* Resolve a query token to a single record id.
|
|
386
|
+
*
|
|
387
|
+
* Resolution order (first hit wins):
|
|
388
|
+
* 1. Exact full-id match — preserves exact-id semantics and the O(1) hot path.
|
|
389
|
+
* 2. Slug alias — an entry in the alias map (`bySlug`) whose target still exists.
|
|
390
|
+
* 3. Unambiguous id prefix (length >= MIN_ID_PREFIX) matching exactly one id.
|
|
391
|
+
*
|
|
392
|
+
* @param {string} input
|
|
393
|
+
* @param {{ idExists:(id:string)=>boolean, listIds:()=>Iterable<string>, bySlug?:Record<string,string> }} ctx
|
|
394
|
+
* @returns {string|null} resolved id, or null when the token resolves to nothing
|
|
395
|
+
* @throws error with `code === "AMBIGUOUS_ID"` when a prefix matches >1 record
|
|
396
|
+
*/
|
|
397
|
+
export function resolveRecordId(input, ctx) {
|
|
398
|
+
if (typeof input !== "string" || input.length === 0) return null;
|
|
399
|
+
// 1. Exact full-id match.
|
|
400
|
+
if (ctx.idExists(input)) return input;
|
|
401
|
+
// 2. Slug alias.
|
|
402
|
+
const bySlug = ctx.bySlug || {};
|
|
403
|
+
if (Object.prototype.hasOwnProperty.call(bySlug, input)) {
|
|
404
|
+
const target = bySlug[input];
|
|
405
|
+
if (target && ctx.idExists(target)) return target;
|
|
406
|
+
}
|
|
407
|
+
// 3. Unambiguous id prefix.
|
|
408
|
+
if (input.length >= MIN_ID_PREFIX) {
|
|
409
|
+
const matches = [];
|
|
410
|
+
for (const id of ctx.listIds()) {
|
|
411
|
+
if (typeof id === "string" && id.startsWith(input)) matches.push(id);
|
|
412
|
+
}
|
|
413
|
+
if (matches.length === 1) return matches[0];
|
|
414
|
+
if (matches.length > 1) throw ambiguousIdError(input, matches);
|
|
415
|
+
}
|
|
416
|
+
return null;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
// Alias map (alias-index.json) — the durable slug → id mapping.
|
|
421
|
+
//
|
|
422
|
+
// Like the graph index, the alias map is a DERIVED cache: each record's own
|
|
423
|
+
// `aliases` array is the source of truth, so the map is fully rebuildable by
|
|
424
|
+
// scanning records. Keying by full id (never by category or file path) is what
|
|
425
|
+
// lets aliases survive store restructures — recategorizing a record or moving
|
|
426
|
+
// its file leaves the slug → id mapping untouched.
|
|
427
|
+
// ---------------------------------------------------------------------------
|
|
428
|
+
|
|
429
|
+
export const ALIAS_INDEX_SCHEMA_VERSION = "1.0";
|
|
430
|
+
|
|
431
|
+
export function emptyAliasIndex() {
|
|
432
|
+
return { schema_version: ALIAS_INDEX_SCHEMA_VERSION, by_slug: {} };
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
export function loadAliasIndex(aliasPath) {
|
|
436
|
+
if (!fs.existsSync(aliasPath)) return emptyAliasIndex();
|
|
437
|
+
try {
|
|
438
|
+
const idx = JSON.parse(fs.readFileSync(aliasPath, "utf8"));
|
|
439
|
+
if (!idx || typeof idx.by_slug !== "object" || idx.by_slug === null) return emptyAliasIndex();
|
|
440
|
+
return idx;
|
|
441
|
+
} catch {
|
|
442
|
+
return emptyAliasIndex();
|
|
443
|
+
}
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
export function saveAliasIndex(aliasPath, index) {
|
|
447
|
+
fs.writeFileSync(aliasPath, JSON.stringify(index, null, 2) + "\n", "utf8");
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* Point each slug at `id` in the alias map. A slug already owned by a
|
|
452
|
+
* different record is a genuine collision — throws `SLUG_CONFLICT` (rather than
|
|
453
|
+
* silently re-pointing the alias). Re-registering a slug already owned by `id`
|
|
454
|
+
* is a no-op, so this is idempotent per record.
|
|
455
|
+
*/
|
|
456
|
+
export function registerAliases(index, id, slugs) {
|
|
457
|
+
for (const slug of slugs) {
|
|
458
|
+
const owner = index.by_slug[slug];
|
|
459
|
+
if (owner && owner !== id) {
|
|
460
|
+
const err = new Error(`slug alias "${slug}" already assigned to record ${owner}`);
|
|
461
|
+
err.code = "SLUG_CONFLICT";
|
|
462
|
+
throw err;
|
|
463
|
+
}
|
|
464
|
+
index.by_slug[slug] = id;
|
|
465
|
+
}
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// ---------------------------------------------------------------------------
|
|
469
|
+
// Freshness + derived status (issue #341, store-contract Addendum J)
|
|
470
|
+
//
|
|
471
|
+
// Optional, backward-compatible record-carried expiry aligned to Kontour's own
|
|
472
|
+
// Hachure trust vocabulary (expiresAt / ttlSeconds; stale / superseded). Every
|
|
473
|
+
// helper below is a no-op / null / false for a record that carries NO freshness
|
|
474
|
+
// fields, so records written before this slice behave exactly as they did.
|
|
475
|
+
//
|
|
476
|
+
// `stale` and `superseded` are DERIVED states — computed here, never written
|
|
477
|
+
// back onto the record's stored `status` (Addendum B). "No silent rot" means a
|
|
478
|
+
// record goes VISIBLY stale under query, not that the store rewrites it behind
|
|
479
|
+
// the operator's back.
|
|
480
|
+
// ---------------------------------------------------------------------------
|
|
481
|
+
|
|
482
|
+
// The optional freshness fields on the record envelope (§1.1 / Addendum J.1).
|
|
483
|
+
export const FRESHNESS_KEYS = ["expires_at", "ttl_seconds"];
|
|
484
|
+
|
|
485
|
+
/** Validate an `expires_at` value: a non-empty ISO-8601 string. */
|
|
486
|
+
export function validateExpiresAt(value) {
|
|
487
|
+
if (typeof value !== "string" || !value.trim())
|
|
488
|
+
throw missingEvidenceError(
|
|
489
|
+
`expires_at must be a non-empty ISO-8601 string; got: ${JSON.stringify(value)}`
|
|
490
|
+
);
|
|
491
|
+
if (Number.isNaN(Date.parse(value)))
|
|
492
|
+
throw missingEvidenceError(
|
|
493
|
+
`expires_at must be a valid ISO-8601 timestamp; got: ${JSON.stringify(value)}`
|
|
494
|
+
);
|
|
495
|
+
return value;
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
/** Validate a `ttl_seconds` value: a positive, finite number of seconds. */
|
|
499
|
+
export function validateTtlSeconds(value) {
|
|
500
|
+
const n = typeof value === "string" && value.trim() !== "" ? Number(value) : value;
|
|
501
|
+
if (typeof n !== "number" || !Number.isFinite(n) || n <= 0)
|
|
502
|
+
throw missingEvidenceError(
|
|
503
|
+
`ttl_seconds must be a positive number of seconds; got: ${JSON.stringify(value)}`
|
|
504
|
+
);
|
|
505
|
+
return n;
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
/**
|
|
509
|
+
* Extract + validate the freshness fields from a create/update input, returning
|
|
510
|
+
* a patch object containing only the supplied keys (normalized). A key set
|
|
511
|
+
* explicitly to `null` or `""` is returned as `undefined` — the signal to CLEAR
|
|
512
|
+
* it (the YAML serializer drops undefined). Absent keys are omitted, so an input
|
|
513
|
+
* with no freshness fields yields `{}` (full backward compatibility). Throws
|
|
514
|
+
* MISSING_EVIDENCE — the contract's rejection channel — on a malformed value,
|
|
515
|
+
* at the mutation boundary, so bad freshness never reaches disk.
|
|
516
|
+
*/
|
|
517
|
+
export function freshnessPatch(input) {
|
|
518
|
+
const patch = {};
|
|
519
|
+
if (!input || typeof input !== "object") return patch;
|
|
520
|
+
if (Object.prototype.hasOwnProperty.call(input, "expires_at")) {
|
|
521
|
+
patch.expires_at =
|
|
522
|
+
input.expires_at === null || input.expires_at === ""
|
|
523
|
+
? undefined
|
|
524
|
+
: validateExpiresAt(input.expires_at);
|
|
525
|
+
}
|
|
526
|
+
if (Object.prototype.hasOwnProperty.call(input, "ttl_seconds")) {
|
|
527
|
+
patch.ttl_seconds =
|
|
528
|
+
input.ttl_seconds === null || input.ttl_seconds === ""
|
|
529
|
+
? undefined
|
|
530
|
+
: validateTtlSeconds(input.ttl_seconds);
|
|
531
|
+
}
|
|
532
|
+
return patch;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/**
|
|
536
|
+
* Coerce freshness fields to their canonical runtime types after a frontmatter
|
|
537
|
+
* read: the zero-dep YAML codec returns every scalar as a string, so a numeric
|
|
538
|
+
* `ttl_seconds` round-trips as text without this. Mutates and returns the
|
|
539
|
+
* record; a no-op when no freshness field is present.
|
|
540
|
+
*/
|
|
541
|
+
export function decodeFreshnessFields(record) {
|
|
542
|
+
if (record && record.ttl_seconds !== undefined && record.ttl_seconds !== null) {
|
|
543
|
+
const n = Number(record.ttl_seconds);
|
|
544
|
+
if (Number.isFinite(n)) record.ttl_seconds = n;
|
|
545
|
+
}
|
|
546
|
+
return record;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
/**
|
|
550
|
+
* The record's EFFECTIVE expiry as epoch-ms, or `null` when it carries no
|
|
551
|
+
* freshness fields. Precedence: an explicit `expires_at` wins over a derived
|
|
552
|
+
* `created_at + ttl_seconds`. Returns null (never throws) on an unparseable
|
|
553
|
+
* field, so malformed freshness can neither make a record spuriously
|
|
554
|
+
* "fresh forever" nor crash a read-only audit.
|
|
555
|
+
*/
|
|
556
|
+
export function effectiveExpiryMs(record) {
|
|
557
|
+
if (!record || typeof record !== "object") return null;
|
|
558
|
+
if (record.expires_at) {
|
|
559
|
+
const ms = Date.parse(record.expires_at);
|
|
560
|
+
if (!Number.isNaN(ms)) return ms;
|
|
561
|
+
}
|
|
562
|
+
if (record.ttl_seconds !== undefined && record.ttl_seconds !== null && record.created_at) {
|
|
563
|
+
const base = Date.parse(record.created_at);
|
|
564
|
+
const ttl = Number(record.ttl_seconds);
|
|
565
|
+
if (!Number.isNaN(base) && Number.isFinite(ttl)) return base + ttl * 1000;
|
|
566
|
+
}
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
/**
|
|
571
|
+
* Is the record STALE at instant `nowMs`? Derived, never stored. A record with
|
|
572
|
+
* no freshness fields is never stale via this path (backward compat). Boundary:
|
|
573
|
+
* the expiry instant itself counts as expired (`nowMs >= expiry`) — a record
|
|
574
|
+
* "expires_at T" is stale from T onward, inclusive.
|
|
575
|
+
*/
|
|
576
|
+
export function isRecordStale(record, nowMs) {
|
|
577
|
+
const exp = effectiveExpiryMs(record);
|
|
578
|
+
if (exp === null) return false;
|
|
579
|
+
return nowMs >= exp;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
/**
|
|
583
|
+
* Is the record SUPERSEDED? Derived from the Addendum A.5 supersede
|
|
584
|
+
* relationship, surfaced as the incoming `supersedes` edge in the graph
|
|
585
|
+
* (`getLinks(id).reverse`) — equivalently, the record carries a `superseded-by`
|
|
586
|
+
* mutation-log entry. Never stored as a `status`.
|
|
587
|
+
*/
|
|
588
|
+
export function isSupersededByLinks(reverseLinks) {
|
|
589
|
+
return Array.isArray(reverseLinks) && reverseLinks.some((l) => l && l.kind === "supersedes");
|
|
590
|
+
}
|
|
@@ -49,6 +49,29 @@ See [`store-contract.md`](store-contract.md) for the full specification. Quick r
|
|
|
49
49
|
|
|
50
50
|
Every mutation throws with `error.code === "MISSING_EVIDENCE"` when required evidence is absent.
|
|
51
51
|
|
|
52
|
+
**Record identity resolution** (Addendum H)
|
|
53
|
+
|
|
54
|
+
`get`/`getLinks` accept three handle forms, resolved in order: exact full `id`, then a
|
|
55
|
+
category-scoped **slug alias**, then an **unambiguous id prefix** (≥ 8 chars).
|
|
56
|
+
|
|
57
|
+
| Handle | Example | Resolves to |
|
|
58
|
+
|---|---|---|
|
|
59
|
+
| Full id | `12cc5573-1c4f-4a0e-8b6e-9d2f0a5b7c31` | the record (unchanged exact-id behavior) |
|
|
60
|
+
| Slug alias | `decision.strategy/2026-07-03-gtm-direction` | the record that carries that alias |
|
|
61
|
+
| Short-id prefix (≥ 8 chars, unique) | `12cc5573` | the single record whose id starts with it |
|
|
62
|
+
|
|
63
|
+
Aliases are caller-supplied via the optional `aliases: string[]` field on `create`/`update`,
|
|
64
|
+
append-only, and stored in an id-keyed alias map (`alias-index.json`) so a prefix or slug **still
|
|
65
|
+
resolves after a store restructure** (e.g. a recategorize that moves the file). An unresolved
|
|
66
|
+
handle returns `null` (`get`) / empty arrays (`getLinks`); an **ambiguous** prefix throws
|
|
67
|
+
`error.code === "AMBIGUOUS_ID"` — it is never silently guessed. On-disk identity (`records/<id>.md`)
|
|
68
|
+
is unchanged.
|
|
69
|
+
|
|
70
|
+
| Error code | Raised when |
|
|
71
|
+
|---|---|
|
|
72
|
+
| `AMBIGUOUS_ID` | an id prefix matches more than one record (carries a `matches` array) |
|
|
73
|
+
| `SLUG_CONFLICT` | a supplied slug alias is already assigned to a different record |
|
|
74
|
+
|
|
52
75
|
---
|
|
53
76
|
|
|
54
77
|
## Running the Contract Suite
|
|
@@ -89,6 +112,16 @@ All tests pass and exit 0. Any failure indicates a contract regression or an ada
|
|
|
89
112
|
| AC2 | Default adapter passes contract suite (command evidence). | Run `node --test kits/knowledge/evals/contract-suite/suite.test.js` — exit 0, all tests pass. |
|
|
90
113
|
| AC3 | Record round-trips raw → stored → queried with category + links intact. | Suite §2 "create: round-trip raw → stored → queried" tests this directly. |
|
|
91
114
|
|
|
115
|
+
**Identity resolution (#339)** — mapped to that issue's ACs:
|
|
116
|
+
|
|
117
|
+
| AC (#339) | Requirement | Evidence |
|
|
118
|
+
|---|---|---|
|
|
119
|
+
| AC1 | Unique 8-char prefix resolves; ambiguous prefix throws `AMBIGUOUS_ID`. | Suite §15 "identity: short-id prefix resolution (AC1)". |
|
|
120
|
+
| AC2 | Slug alias resolves via `get`/`getLinks` (set at create or update). | Suite §16 "identity: slug alias resolution (AC2)". |
|
|
121
|
+
| AC3 | Old slug + short-id prefix survive an `update` recategorize/move. | Suite §17 "identity: alias resolution survives restructure (AC3)". |
|
|
122
|
+
| AC4 | Existing sections pass unmodified; files remain `records/<id>.md`. | §2 round-trip, §13 graph consistency, §12 missing-record unchanged; on-disk naming untouched. |
|
|
123
|
+
| AC5 | Contract doc specifies prefix/slug/alias-map semantics. | [`store-contract.md`](store-contract.md) Addendum H + §1.1/§7/§8.1/§9 updates. |
|
|
124
|
+
|
|
92
125
|
---
|
|
93
126
|
|
|
94
127
|
## Default Adapter Details
|
|
@@ -103,6 +136,7 @@ built-ins only.
|
|
|
103
136
|
records/
|
|
104
137
|
<id>.md ← one markdown file per record, YAML frontmatter + body
|
|
105
138
|
graph-index.json ← forward + reverse link index, schema_version 1.0
|
|
139
|
+
alias-index.json ← slug → id alias map (Addendum H), schema_version 1.0
|
|
106
140
|
```
|
|
107
141
|
|
|
108
142
|
**Constructor**
|
|
@@ -120,8 +154,8 @@ const store = new DefaultKnowledgeStore({ storeRoot: '/path/to/store' });
|
|
|
120
154
|
- `propose(conceptId, proposerId, evidence)` → `Promise<void>`
|
|
121
155
|
- `apply(conceptId, proposerId, evidence)` → `Promise<void>`
|
|
122
156
|
- `reject(conceptId, proposerId, evidence)` → `Promise<void>`
|
|
123
|
-
- `get(
|
|
124
|
-
- `getLinks(
|
|
157
|
+
- `get(idOrHandle)` → `Promise<Record | null>` (exact id, slug alias, or unambiguous id prefix — Addendum H)
|
|
158
|
+
- `getLinks(idOrHandle)` → `Promise<{ forward: Link[], reverse: Link[] }>`
|
|
125
159
|
- `listByCategory(category, options?)` → `Promise<Record[]>`
|
|
126
160
|
- `listByType(type)` → `Promise<Record[]>`
|
|
127
161
|
|
|
@@ -326,3 +360,88 @@ const detector = createVectorSimilarityDetector({
|
|
|
326
360
|
|
|
327
361
|
The `embed` function is called once per `synthesize`/`consolidate` call with all texts in a
|
|
328
362
|
single batch (concept first, then candidates).
|
|
363
|
+
|
|
364
|
+
---
|
|
365
|
+
|
|
366
|
+
## Graph provider (opt-in)
|
|
367
|
+
|
|
368
|
+
The `neo4j` provider is a real, queryable graph implementation of the store
|
|
369
|
+
provider read interface (issue #327). It is the **owner's opt-in personal
|
|
370
|
+
default** — the file providers (`markdown-vault`, `git-repo`, `work-item`) remain
|
|
371
|
+
the portfolio default. The graph is a **materialized view** synced from those
|
|
372
|
+
providers: the file stores stay the source of truth, and the write side stays
|
|
373
|
+
proposals-only. `neo4j-driver` is an optional dependency; with no Neo4j reachable
|
|
374
|
+
everything degrades to the file providers with a clear message — it is never a
|
|
375
|
+
hard dependency.
|
|
376
|
+
|
|
377
|
+
### 1. Start Neo4j (Docker)
|
|
378
|
+
|
|
379
|
+
```bash
|
|
380
|
+
docker run -d --name kg-neo4j -p 7474:7474 -p 7687:7687 \
|
|
381
|
+
-e NEO4J_AUTH=neo4j/testpassword neo4j:5-community
|
|
382
|
+
```
|
|
383
|
+
|
|
384
|
+
### 2. Point the provider at it (connection by reference — never hardcoded)
|
|
385
|
+
|
|
386
|
+
```bash
|
|
387
|
+
export NEO4J_URI=bolt://localhost:7687
|
|
388
|
+
export NEO4J_USER=neo4j
|
|
389
|
+
export NEO4J_PASSWORD=testpassword # use your own secret; this is an example
|
|
390
|
+
export KNOWLEDGE_PROVIDER=neo4j # opt in (repo/user-level); default is `file`
|
|
391
|
+
npm install neo4j-driver # optional dependency, only if not already present
|
|
392
|
+
```
|
|
393
|
+
|
|
394
|
+
### 3. Sync the file stores into the graph (idempotent)
|
|
395
|
+
|
|
396
|
+
`sync` reads the other providers and MERGEs their combined graph into Neo4j. A
|
|
397
|
+
re-sync of unchanged stores reports **zero writes** (content-keyed hash guard);
|
|
398
|
+
a snapshot digest + timestamp is recorded on the graph.
|
|
399
|
+
|
|
400
|
+
```js
|
|
401
|
+
import { GitRepoProvider } from "./kits/knowledge/providers/git-repo/index.js";
|
|
402
|
+
import { WorkItemProvider } from "./kits/knowledge/providers/work-item/index.js";
|
|
403
|
+
import { syncToNeo4j } from "./kits/knowledge/providers/neo4j/index.js";
|
|
404
|
+
import { resolveNeo4jConfig, createDriver } from "./kits/knowledge/providers/neo4j/index.js";
|
|
405
|
+
|
|
406
|
+
const driver = await createDriver(resolveNeo4jConfig());
|
|
407
|
+
const providers = [
|
|
408
|
+
new GitRepoProvider({ repoRoot: process.cwd() }),
|
|
409
|
+
new WorkItemProvider({ repo: "kontourai/flow-agents" }), // reads via gh
|
|
410
|
+
];
|
|
411
|
+
console.log(await syncToNeo4j({ driver, providers })); // { writes, unchanged, digest, ... }
|
|
412
|
+
```
|
|
413
|
+
|
|
414
|
+
### 4. Query it — Neo4j Browser or the provider verbs
|
|
415
|
+
|
|
416
|
+
Open `http://localhost:7474` for the interactive Browser, or use the provider's
|
|
417
|
+
Cypher-backed query/health verbs (they fall back to a portable JS spelling with
|
|
418
|
+
identical answers when no Neo4j is reachable):
|
|
419
|
+
|
|
420
|
+
```js
|
|
421
|
+
import { Neo4jProvider } from "./kits/knowledge/providers/neo4j/index.js";
|
|
422
|
+
const kg = new Neo4jProvider({ driver });
|
|
423
|
+
await kg.transitiveBlockers("issue:313"); // Q1 transitive blocker closure
|
|
424
|
+
await kg.contradictionCandidates(); // Q2 divergent status over one subject
|
|
425
|
+
await kg.orphans(); // Q3 node-type-aware orphans
|
|
426
|
+
await kg.duplicateCandidates(); // Q4 duplicate candidates WITH stemming
|
|
427
|
+
await kg.shortestPath("decision:adr-0011", "decision:adr-0021"); // Q5 shortest path
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Example Cypher session in the Browser (the blocker subgraph the spike visualised):
|
|
431
|
+
|
|
432
|
+
```cypher
|
|
433
|
+
MATCH p=(a:Issue)-[:BLOCKS]->(b:Issue) RETURN p LIMIT 60;
|
|
434
|
+
```
|
|
435
|
+
|
|
436
|
+
### Running the full live integration locally
|
|
437
|
+
|
|
438
|
+
CI has no Neo4j, so the conformance + unit tests run against an injected fake
|
|
439
|
+
driver and the live integration test skips loudly. To run the live path:
|
|
440
|
+
|
|
441
|
+
```bash
|
|
442
|
+
docker run -d --name kg-neo4j -p 7474:7474 -p 7687:7687 \
|
|
443
|
+
-e NEO4J_AUTH=neo4j/testpassword neo4j:5-community
|
|
444
|
+
NEO4J_URI=bolt://localhost:7687 NEO4J_USER=neo4j NEO4J_PASSWORD=testpassword \
|
|
445
|
+
node --test kits/knowledge/providers/neo4j/integration.test.js
|
|
446
|
+
docker rm -f kg-neo4j
|
|
447
|
+
```
|