@kontourai/flow-agents 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/workflows/ci.yml +8 -0
- package/CHANGELOG.md +15 -0
- package/CONTEXT.md +67 -1
- package/README.md +4 -0
- package/build/src/cli/assignment-provider.d.ts +143 -0
- package/build/src/cli/assignment-provider.js +106 -49
- package/build/src/cli/workflow-sidecar.d.ts +2 -2
- package/build/src/cli/workflow-sidecar.js +392 -35
- 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 +9 -0
- package/context/contracts/probe-docs-write-contract.md +187 -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/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 +2 -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 +4 -0
- package/evals/fixtures/assignment-provider/hostile-effective-state.json +11 -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_gate_lockdown.sh +10 -0
- package/evals/integration/test_pull_work_liveness_preflight.sh +6 -1
- package/evals/integration/test_workflow_sidecar_writer.sh +12 -3
- package/evals/run.sh +4 -0
- package/evals/static/test_knowledge_providers.sh +13 -4
- package/evals/static/test_workflow_skills.sh +15 -2
- package/kits/builder/skills/deliver/SKILL.md +17 -0
- package/kits/builder/skills/design-probe/SKILL.md +37 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +4 -4
- 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 +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/scripts/README.md +1 -0
- 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 +137 -55
- package/src/cli/workflow-sidecar.ts +430 -33
- package/src/lib/flow-resolver.ts +35 -14
- package/src/tools/validate-source-tree.ts +2 -1
|
@@ -82,6 +82,43 @@ Maintain a compact running record in the active artifact or conversation when no
|
|
|
82
82
|
|
|
83
83
|
When workflow artifacts exist, update the appropriate session, handoff, Probe record, or planning artifact according to the local artifact contract. Do not invent a project-specific storage format when the repository already defines one.
|
|
84
84
|
|
|
85
|
+
## Docs-Write: Two-Delta Emission
|
|
86
|
+
|
|
87
|
+
Per `context/contracts/probe-docs-write-contract.md`, when a `decisions` entry above is a **decision subject** — a durable, reusable answer to a named domain question, not an implementation detail or transient planning choice — emit two deltas into durable docs in the same motion, before moving to the next branch:
|
|
88
|
+
|
|
89
|
+
1. **Vocabulary delta**: the subject noun must exist as a `CONTEXT.md` glossary term. If absent, coin it first (a tight one- or two-sentence `### <Term>` entry); if present, refine the definition when the crystallized decision changes what it says (e.g. an "open" pointer becoming a stated answer).
|
|
90
|
+
2. **Decision delta**: consult `docs/decisions/index.md` and propose revise-vs-create against the existing topic slugs (this includes any `needs-decision` stub already naming the subject); revise the existing topic file in place, or create `docs/decisions/<slug>.md` per `context/contracts/decision-registry-contract.md`. Run `npm run gen:decisions-index && npm run check:decisions` after writing.
|
|
91
|
+
3. **Transcript provenance**: add a `session-archive` evidence ref pointing at this Probe's own session artifact (the live `.kontourai/flow-agents/<slug>/...` path — it does not need to be archived yet).
|
|
92
|
+
|
|
93
|
+
Example — coining a new term and creating its topic file:
|
|
94
|
+
|
|
95
|
+
```markdown
|
|
96
|
+
### Retry Budget
|
|
97
|
+
|
|
98
|
+
The maximum number of automatic retries a workflow step may attempt before routing back to the user. Configured per Flow Definition step, not globally.
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
```markdown
|
|
102
|
+
---
|
|
103
|
+
status: current
|
|
104
|
+
subject: Retry budget
|
|
105
|
+
decided: 2026-07-03
|
|
106
|
+
evidence:
|
|
107
|
+
- kind: session-archive
|
|
108
|
+
ref: .kontourai/flow-agents/<slug>/<slug>--design-probe.md
|
|
109
|
+
- kind: issue
|
|
110
|
+
ref: https://github.com/kontourai/flow-agents/issues/<n>
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
# Retry budget
|
|
114
|
+
|
|
115
|
+
Each Flow Definition step declares its own retry budget; there is no global default. Set during design-probe alignment on <n>.
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Example — revising a `needs-decision` stub seeded by the ADR-freeze cutover (issue #314) once its frozen ADR's decision is confirmed still current: flip `status: needs-decision` to `current`, set `decided` to today, keep the existing `adr` evidence ref, and append the `session-archive` ref for this Probe session plus a `pr` ref once one exists. See `context/contracts/probe-docs-write-contract.md` § Worked Example for the full step-by-step.
|
|
119
|
+
|
|
120
|
+
Do not write a numbered ADR — `docs/adr/` is frozen history (`docs/adr/README.md`); every decision delta targets the topic-keyed registry.
|
|
121
|
+
|
|
85
122
|
## Stop Conditions
|
|
86
123
|
|
|
87
124
|
Stop probing when one of these is true:
|
|
@@ -115,15 +115,15 @@ When the gate fails, record `--status fail` with `--expectation pickup-probe-rea
|
|
|
115
115
|
|
|
116
116
|
|
|
117
117
|
|
|
118
|
-
## Docs
|
|
118
|
+
## Docs-Write Policy: Two-Delta Emission
|
|
119
119
|
|
|
120
|
-
`pickup-probe` may identify durable terminology and decision gaps, but it must keep documentation changes narrow:
|
|
120
|
+
`pickup-probe` may identify durable terminology and decision gaps, but it must keep documentation changes narrow. Per `context/contracts/probe-docs-write-contract.md`, a **decision subject** crystallizing during pickup probing (a durable, reusable answer to a named domain question — not an implementation detail or transient planning choice) gets both deltas in the same motion:
|
|
121
121
|
|
|
122
|
-
- Update `CONTEXT.md` inline only for glossary-style durable terminology decisions, using tight one- or two-sentence definitions and `_Avoid_` terms when useful. Keep implementation details out of `CONTEXT.md`.
|
|
122
|
+
- Update `CONTEXT.md` inline only for glossary-style durable terminology decisions, using tight one- or two-sentence definitions and `_Avoid_` terms when useful. Keep implementation details out of `CONTEXT.md`. Coin the subject term first if it is not already a glossary entry — vocabulary is the decision registry's topic namespace (`context/contracts/decision-registry-contract.md` § Slug rules) — or refine the existing entry when a decision changes what it says.
|
|
123
123
|
- If a `CONTEXT-MAP.md` exists, update the relevant context instead of assuming the root glossary owns the term.
|
|
124
124
|
- Create a lazy context file only when a resolved term or workflow concept has no existing home.
|
|
125
125
|
- Do not create context files for transient planning notes, open questions, or provider snapshots.
|
|
126
|
-
-
|
|
126
|
+
- Record a decision delta at `docs/decisions/<slug>.md`: consult `docs/decisions/index.md` first and propose revise-vs-create (an existing topic file, including a `needs-decision` stub, is revised in place; only an uncovered subject gets a new file), then run `npm run gen:decisions-index && npm run check:decisions`. Link this Probe's own session artifact as a `session-archive` evidence ref (transcript provenance). Never propose or create a numbered ADR — `docs/adr/` is frozen history; decisions go into the topic-keyed registry.
|
|
127
127
|
- Prefer workflow artifacts for pickup decisions, unresolved questions, accepted gaps, and route reasons until the work is accepted.
|
|
128
128
|
|
|
129
129
|
## Handoff To Plan Work
|
|
@@ -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
|
+
}
|