@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
|
@@ -16,6 +16,18 @@ import * as fs from "node:fs";
|
|
|
16
16
|
import * as path from "node:path";
|
|
17
17
|
import { randomUUID } from "node:crypto";
|
|
18
18
|
|
|
19
|
+
// Record-identity resolution layer (short-id prefix + slug aliases, issue #339).
|
|
20
|
+
// Single-sourced in the shared codec so both bundled adapters resolve
|
|
21
|
+
// identically; the rest of this adapter keeps its own zero-import helpers.
|
|
22
|
+
import {
|
|
23
|
+
resolveRecordId,
|
|
24
|
+
normalizeAliases,
|
|
25
|
+
emptyAliasIndex,
|
|
26
|
+
loadAliasIndex,
|
|
27
|
+
saveAliasIndex,
|
|
28
|
+
registerAliases,
|
|
29
|
+
} from "../shared/codec.js";
|
|
30
|
+
|
|
19
31
|
// ---------------------------------------------------------------------------
|
|
20
32
|
// Error helpers
|
|
21
33
|
// ---------------------------------------------------------------------------
|
|
@@ -340,6 +352,7 @@ export class DefaultKnowledgeStore {
|
|
|
340
352
|
this._root = path.resolve(storeRoot);
|
|
341
353
|
this._recordsDir = path.join(this._root, "records");
|
|
342
354
|
this._graphPath = path.join(this._root, "graph-index.json");
|
|
355
|
+
this._aliasPath = path.join(this._root, "alias-index.json");
|
|
343
356
|
fs.mkdirSync(this._recordsDir, { recursive: true });
|
|
344
357
|
}
|
|
345
358
|
|
|
@@ -351,6 +364,35 @@ export class DefaultKnowledgeStore {
|
|
|
351
364
|
return path.join(this._recordsDir, `${id}.md`);
|
|
352
365
|
}
|
|
353
366
|
|
|
367
|
+
// -- Record-identity resolution (short-id prefix + slug alias, issue #339) --
|
|
368
|
+
|
|
369
|
+
/** Does a record file exist for this exact full id? */
|
|
370
|
+
_idExists(id) {
|
|
371
|
+
return fs.existsSync(this._recordPath(id));
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** All full record ids, derived cheaply from the records/ directory. */
|
|
375
|
+
_listIds() {
|
|
376
|
+
if (!fs.existsSync(this._recordsDir)) return [];
|
|
377
|
+
return fs.readdirSync(this._recordsDir)
|
|
378
|
+
.filter((f) => f.endsWith(".md"))
|
|
379
|
+
.map((f) => f.slice(0, -3));
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
/**
|
|
383
|
+
* Resolve a query token (exact id, slug alias, or unambiguous id prefix) to a
|
|
384
|
+
* single full record id, or null when it resolves to nothing. Throws
|
|
385
|
+
* AMBIGUOUS_ID when a prefix matches more than one record.
|
|
386
|
+
*/
|
|
387
|
+
_resolveId(input) {
|
|
388
|
+
const aliasIndex = loadAliasIndex(this._aliasPath);
|
|
389
|
+
return resolveRecordId(input, {
|
|
390
|
+
idExists: (rid) => this._idExists(rid),
|
|
391
|
+
listIds: () => this._listIds(),
|
|
392
|
+
bySlug: aliasIndex.by_slug,
|
|
393
|
+
});
|
|
394
|
+
}
|
|
395
|
+
|
|
354
396
|
_readRecord(id) {
|
|
355
397
|
const p = this._recordPath(id);
|
|
356
398
|
if (!fs.existsSync(p)) return null;
|
|
@@ -393,6 +435,15 @@ export class DefaultKnowledgeStore {
|
|
|
393
435
|
const id = input.id || randomUUID();
|
|
394
436
|
const now = this._now();
|
|
395
437
|
|
|
438
|
+
// Validate slug aliases (issue #339) and reserve them in the alias map
|
|
439
|
+
// BEFORE any write, so a SLUG_CONFLICT aborts create without a partial record.
|
|
440
|
+
const aliases = normalizeAliases(input.aliases);
|
|
441
|
+
let aliasIndex = null;
|
|
442
|
+
if (aliases.length) {
|
|
443
|
+
aliasIndex = loadAliasIndex(this._aliasPath);
|
|
444
|
+
registerAliases(aliasIndex, id, aliases);
|
|
445
|
+
}
|
|
446
|
+
|
|
396
447
|
// Merge explicit links + wikilinks from body
|
|
397
448
|
const explicitLinks = input.links || [];
|
|
398
449
|
const wikilinks = extractWikilinks(input.body || "");
|
|
@@ -404,6 +455,7 @@ export class DefaultKnowledgeStore {
|
|
|
404
455
|
title: input.title,
|
|
405
456
|
category: input.category,
|
|
406
457
|
tags: input.tags || [],
|
|
458
|
+
...(aliases.length ? { aliases } : {}),
|
|
407
459
|
status: "active",
|
|
408
460
|
created_at: now,
|
|
409
461
|
updated_at: now,
|
|
@@ -425,6 +477,9 @@ export class DefaultKnowledgeStore {
|
|
|
425
477
|
addLinksToGraph(graph, id, links);
|
|
426
478
|
saveGraph(this._graphPath, graph);
|
|
427
479
|
|
|
480
|
+
// Persist the alias map only after the record is on disk.
|
|
481
|
+
if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
|
|
482
|
+
|
|
428
483
|
return id;
|
|
429
484
|
}
|
|
430
485
|
|
|
@@ -439,7 +494,7 @@ export class DefaultKnowledgeStore {
|
|
|
439
494
|
const record = this._readRecord(id);
|
|
440
495
|
if (!record) throw notFoundError(id);
|
|
441
496
|
|
|
442
|
-
const mutableKeys = ["title", "body", "category", "tags", "links"];
|
|
497
|
+
const mutableKeys = ["title", "body", "category", "tags", "links", "aliases"];
|
|
443
498
|
const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
|
|
444
499
|
if (supplied.length === 0)
|
|
445
500
|
throw missingEvidenceError("update: at least one mutable field must be supplied");
|
|
@@ -449,6 +504,18 @@ export class DefaultKnowledgeStore {
|
|
|
449
504
|
|
|
450
505
|
const now = this._now();
|
|
451
506
|
|
|
507
|
+
// Slug aliases are append-only: supplied aliases are UNIONED with existing
|
|
508
|
+
// ones so a previously issued slug keeps resolving after a restructure (R3).
|
|
509
|
+
let mergedAliases = Array.isArray(record.aliases) ? record.aliases.slice() : [];
|
|
510
|
+
let aliasIndex = null;
|
|
511
|
+
if (fields.aliases !== undefined) {
|
|
512
|
+
const incoming = normalizeAliases(fields.aliases);
|
|
513
|
+
const seen = new Set(mergedAliases);
|
|
514
|
+
for (const s of incoming) if (!seen.has(s)) { seen.add(s); mergedAliases.push(s); }
|
|
515
|
+
aliasIndex = loadAliasIndex(this._aliasPath);
|
|
516
|
+
registerAliases(aliasIndex, id, mergedAliases);
|
|
517
|
+
}
|
|
518
|
+
|
|
452
519
|
// Merge links if updated
|
|
453
520
|
let newLinks = record.links || [];
|
|
454
521
|
if (fields.links !== undefined) {
|
|
@@ -465,6 +532,7 @@ export class DefaultKnowledgeStore {
|
|
|
465
532
|
...(fields.body !== undefined ? { body: fields.body } : {}),
|
|
466
533
|
...(fields.category !== undefined ? { category: fields.category } : {}),
|
|
467
534
|
...(fields.tags !== undefined ? { tags: fields.tags } : {}),
|
|
535
|
+
...(mergedAliases.length ? { aliases: mergedAliases } : {}),
|
|
468
536
|
links: newLinks,
|
|
469
537
|
updated_at: now,
|
|
470
538
|
mutation_log: [
|
|
@@ -486,6 +554,8 @@ export class DefaultKnowledgeStore {
|
|
|
486
554
|
saveGraph(this._graphPath, graph);
|
|
487
555
|
|
|
488
556
|
this._writeRecord(updated);
|
|
557
|
+
|
|
558
|
+
if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
|
|
489
559
|
}
|
|
490
560
|
|
|
491
561
|
// -------------------------------------------------------------------------
|
|
@@ -843,7 +913,12 @@ export class DefaultKnowledgeStore {
|
|
|
843
913
|
// -------------------------------------------------------------------------
|
|
844
914
|
|
|
845
915
|
async get(id) {
|
|
846
|
-
|
|
916
|
+
// Accept an exact id, a slug alias, or an unambiguous id prefix (issue #339).
|
|
917
|
+
// Unresolved → null (unchanged missing-record semantics); ambiguous prefix
|
|
918
|
+
// → throws AMBIGUOUS_ID.
|
|
919
|
+
const resolvedId = this._resolveId(id);
|
|
920
|
+
if (!resolvedId) return null;
|
|
921
|
+
return this._readRecord(resolvedId);
|
|
847
922
|
}
|
|
848
923
|
|
|
849
924
|
// -------------------------------------------------------------------------
|
|
@@ -851,10 +926,13 @@ export class DefaultKnowledgeStore {
|
|
|
851
926
|
// -------------------------------------------------------------------------
|
|
852
927
|
|
|
853
928
|
async getLinks(id) {
|
|
929
|
+
// Resolve prefix/slug to a full id; fall back to the raw token when it
|
|
930
|
+
// resolves to nothing so unknown ids still return empty arrays (not throw).
|
|
931
|
+
const key = this._resolveId(id) || id;
|
|
854
932
|
const graph = loadGraph(this._graphPath);
|
|
855
933
|
return {
|
|
856
|
-
forward: (graph.forward[
|
|
857
|
-
reverse: (graph.reverse[
|
|
934
|
+
forward: (graph.forward[key] || []).map((l) => ({ ...l })),
|
|
935
|
+
reverse: (graph.reverse[key] || []).map((l) => ({ ...l })),
|
|
858
936
|
};
|
|
859
937
|
}
|
|
860
938
|
|
|
@@ -911,6 +989,16 @@ export class DefaultKnowledgeStore {
|
|
|
911
989
|
const links = Object.values(rebuilt.forward).reduce((n, arr) => n + arr.length, 0);
|
|
912
990
|
const changed = canonicalGraph(loadGraph(this._graphPath)) !== canonicalGraph(rebuilt);
|
|
913
991
|
saveGraph(this._graphPath, rebuilt);
|
|
992
|
+
|
|
993
|
+
// The alias map is likewise derived from records' `aliases` — rebuild it so
|
|
994
|
+
// a lost/hand-edited alias-index.json recovers on the same recovery path.
|
|
995
|
+
const rebuiltAliases = emptyAliasIndex();
|
|
996
|
+
for (const record of records) {
|
|
997
|
+
const slugs = normalizeAliases(record.aliases);
|
|
998
|
+
if (slugs.length) registerAliases(rebuiltAliases, record.id, slugs);
|
|
999
|
+
}
|
|
1000
|
+
saveAliasIndex(this._aliasPath, rebuiltAliases);
|
|
1001
|
+
|
|
914
1002
|
return {
|
|
915
1003
|
records: records.length,
|
|
916
1004
|
links,
|
|
@@ -57,6 +57,12 @@ 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,
|
|
60
66
|
} from "../shared/codec.js";
|
|
61
67
|
|
|
62
68
|
// ---------------------------------------------------------------------------
|
|
@@ -94,9 +100,33 @@ export class ObsidianKnowledgeStore {
|
|
|
94
100
|
this._graphPath = path.join(this._root, "graph-index.json");
|
|
95
101
|
// Path index (internal): { by_id: { id: { path, archived } }, by_path: { relPath: id } }
|
|
96
102
|
this._pathIndexPath = path.join(this._root, ".graph-index.json");
|
|
103
|
+
// Alias map (issue #339): { schema_version, by_slug: { slug: id } }. Keyed by
|
|
104
|
+
// full id, never by file path, so slugs survive the folder moves this adapter
|
|
105
|
+
// performs on recategorize/archive.
|
|
106
|
+
this._aliasPath = path.join(this._root, "alias-index.json");
|
|
97
107
|
fs.mkdirSync(this._root, { recursive: true });
|
|
98
108
|
}
|
|
99
109
|
|
|
110
|
+
// -------------------------------------------------------------------------
|
|
111
|
+
// Record-identity resolution (short-id prefix + slug alias, issue #339)
|
|
112
|
+
// -------------------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Resolve a query token (exact id, slug alias, or unambiguous id prefix) to a
|
|
116
|
+
* single full record id, or null. Throws AMBIGUOUS_ID on a >1-match prefix.
|
|
117
|
+
* Ids and aliases are read from the persisted indexes, so resolution is
|
|
118
|
+
* unaffected by where a record's file currently lives.
|
|
119
|
+
*/
|
|
120
|
+
_resolveId(input, pathIndex, aliasIndex) {
|
|
121
|
+
pathIndex = pathIndex || this._loadPathIndex();
|
|
122
|
+
aliasIndex = aliasIndex || loadAliasIndex(this._aliasPath);
|
|
123
|
+
return resolveRecordId(input, {
|
|
124
|
+
idExists: (rid) => Boolean(pathIndex.by_id[rid]),
|
|
125
|
+
listIds: () => Object.keys(pathIndex.by_id),
|
|
126
|
+
bySlug: aliasIndex.by_slug,
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
100
130
|
// -------------------------------------------------------------------------
|
|
101
131
|
// Path / slug helpers
|
|
102
132
|
// -------------------------------------------------------------------------
|
|
@@ -505,6 +535,15 @@ export class ObsidianKnowledgeStore {
|
|
|
505
535
|
const id = input.id || randomUUID();
|
|
506
536
|
const now = this._now();
|
|
507
537
|
|
|
538
|
+
// Validate + reserve slug aliases before any write (issue #339); a
|
|
539
|
+
// SLUG_CONFLICT aborts create without leaving a partial note behind.
|
|
540
|
+
const aliases = normalizeAliases(input.aliases);
|
|
541
|
+
let aliasIndex = null;
|
|
542
|
+
if (aliases.length) {
|
|
543
|
+
aliasIndex = loadAliasIndex(this._aliasPath);
|
|
544
|
+
registerAliases(aliasIndex, id, aliases);
|
|
545
|
+
}
|
|
546
|
+
|
|
508
547
|
const explicitLinks = input.links || [];
|
|
509
548
|
const wikilinks = extractWikilinks(input.body || "");
|
|
510
549
|
const links = mergeLinks(explicitLinks, wikilinks);
|
|
@@ -515,6 +554,7 @@ export class ObsidianKnowledgeStore {
|
|
|
515
554
|
title: input.title,
|
|
516
555
|
category: input.category,
|
|
517
556
|
tags: input.tags || [],
|
|
557
|
+
...(aliases.length ? { aliases } : {}),
|
|
518
558
|
status: "active",
|
|
519
559
|
created_at: now,
|
|
520
560
|
updated_at: now,
|
|
@@ -535,6 +575,8 @@ export class ObsidianKnowledgeStore {
|
|
|
535
575
|
addLinksToGraph(graph, id, links);
|
|
536
576
|
saveGraph(this._graphPath, graph);
|
|
537
577
|
|
|
578
|
+
if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
|
|
579
|
+
|
|
538
580
|
return id;
|
|
539
581
|
}
|
|
540
582
|
|
|
@@ -550,7 +592,7 @@ export class ObsidianKnowledgeStore {
|
|
|
550
592
|
const record = this._readRecord(id, pathIndex);
|
|
551
593
|
if (!record) throw notFoundError(id);
|
|
552
594
|
|
|
553
|
-
const mutableKeys = ["title", "body", "category", "tags", "links"];
|
|
595
|
+
const mutableKeys = ["title", "body", "category", "tags", "links", "aliases"];
|
|
554
596
|
const supplied = mutableKeys.filter((k) => fields[k] !== undefined);
|
|
555
597
|
if (supplied.length === 0)
|
|
556
598
|
throw missingEvidenceError("update: at least one mutable field must be supplied");
|
|
@@ -560,6 +602,18 @@ export class ObsidianKnowledgeStore {
|
|
|
560
602
|
|
|
561
603
|
const now = this._now();
|
|
562
604
|
|
|
605
|
+
// Slug aliases are append-only: union supplied aliases with existing ones so
|
|
606
|
+
// a previously issued slug keeps resolving after the file relocates (R3).
|
|
607
|
+
let mergedAliases = Array.isArray(record.aliases) ? record.aliases.slice() : [];
|
|
608
|
+
let aliasIndex = null;
|
|
609
|
+
if (fields.aliases !== undefined) {
|
|
610
|
+
const incoming = normalizeAliases(fields.aliases);
|
|
611
|
+
const seen = new Set(mergedAliases);
|
|
612
|
+
for (const s of incoming) if (!seen.has(s)) { seen.add(s); mergedAliases.push(s); }
|
|
613
|
+
aliasIndex = loadAliasIndex(this._aliasPath);
|
|
614
|
+
registerAliases(aliasIndex, id, mergedAliases);
|
|
615
|
+
}
|
|
616
|
+
|
|
563
617
|
let newLinks = record.links || [];
|
|
564
618
|
if (fields.links !== undefined) {
|
|
565
619
|
const wikilinks = extractWikilinks(fields.body !== undefined ? fields.body : record.body);
|
|
@@ -575,6 +629,7 @@ export class ObsidianKnowledgeStore {
|
|
|
575
629
|
...(fields.body !== undefined ? { body: fields.body } : {}),
|
|
576
630
|
...(fields.category !== undefined ? { category: fields.category } : {}),
|
|
577
631
|
...(fields.tags !== undefined ? { tags: fields.tags } : {}),
|
|
632
|
+
...(mergedAliases.length ? { aliases: mergedAliases } : {}),
|
|
578
633
|
links: newLinks,
|
|
579
634
|
updated_at: now,
|
|
580
635
|
mutation_log: [
|
|
@@ -596,6 +651,8 @@ export class ObsidianKnowledgeStore {
|
|
|
596
651
|
|
|
597
652
|
this._writeRecord(updated, pathIndex);
|
|
598
653
|
this._savePathIndex(pathIndex);
|
|
654
|
+
|
|
655
|
+
if (aliasIndex) saveAliasIndex(this._aliasPath, aliasIndex);
|
|
599
656
|
}
|
|
600
657
|
|
|
601
658
|
// -------------------------------------------------------------------------
|
|
@@ -956,7 +1013,12 @@ export class ObsidianKnowledgeStore {
|
|
|
956
1013
|
// -------------------------------------------------------------------------
|
|
957
1014
|
|
|
958
1015
|
async get(id) {
|
|
959
|
-
|
|
1016
|
+
// Accept an exact id, slug alias, or unambiguous id prefix (issue #339).
|
|
1017
|
+
// Unresolved → null; ambiguous prefix → throws AMBIGUOUS_ID.
|
|
1018
|
+
const pathIndex = this._loadPathIndex();
|
|
1019
|
+
const resolvedId = this._resolveId(id, pathIndex);
|
|
1020
|
+
if (!resolvedId) return null;
|
|
1021
|
+
return this._readRecord(resolvedId, pathIndex);
|
|
960
1022
|
}
|
|
961
1023
|
|
|
962
1024
|
// -------------------------------------------------------------------------
|
|
@@ -964,10 +1026,13 @@ export class ObsidianKnowledgeStore {
|
|
|
964
1026
|
// -------------------------------------------------------------------------
|
|
965
1027
|
|
|
966
1028
|
async getLinks(id) {
|
|
1029
|
+
// Resolve prefix/slug to a full id; fall back to the raw token when it
|
|
1030
|
+
// resolves to nothing so unknown ids return empty arrays (not throw).
|
|
1031
|
+
const key = this._resolveId(id) || id;
|
|
967
1032
|
const graph = loadGraph(this._graphPath);
|
|
968
1033
|
return {
|
|
969
|
-
forward: (graph.forward[
|
|
970
|
-
reverse: (graph.reverse[
|
|
1034
|
+
forward: (graph.forward[key] || []).map((l) => ({ ...l })),
|
|
1035
|
+
reverse: (graph.reverse[key] || []).map((l) => ({ ...l })),
|
|
971
1036
|
};
|
|
972
1037
|
}
|
|
973
1038
|
|
|
@@ -323,3 +323,144 @@ 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
|
+
}
|
|
@@ -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
|
+
```
|