diff --git a/README.md b/README.md
index 1e2c09d..fa7bda3 100644
--- a/README.md
+++ b/README.md
@@ -14,7 +14,7 @@ system.
Bun 1.3.14 or newer is required.
```sh
-bun add --global @hraness/kb@0.17.3
+bun add --global @hraness/kb@0.18.0
```
## Why kb
@@ -193,6 +193,18 @@ Start with a short inherited `AGENTS.md` path for rules whose omission would mak
Treat the knowledge base as repository-adjacent durable memory. Authored Markdown and Git are the record; catalogs, indexes, embeddings, and graph views are replaceable ways to find and inspect it. Checks can validate structure, captures can preserve a selected surface, and similarity can suggest candidates. None of those mechanisms proves that a source is trustworthy or an explanation is still true. People and agents must revise the knowledge as the repository changes.
+## Upgrade to v0.18.0
+
+Version 0.18.0 adds a review-only adoption seam for exact dependency closures
+from an Oh working authority. Trusted host code creates a
+`createOhAdoptionPreparerV1` facade with the expected binding and head,
+destination, rights clearance, review route, and conflict policy. The narrow
+`prepare` call accepts only a capsule plus transformation and redaction
+disclosures, returns deeply immutable deterministic Markdown and manifest
+bytes with status `prepared`, and has no vault, Git, Oh-store, or promotion
+capability. KB pins `@hraness/oh` v0.2.0 and delegates closure integrity to its
+official store verifier.
+
## Upgrade to v0.17.3
Version 0.17.3 restructures the README around an inspectable first task,
@@ -247,9 +259,9 @@ audit projection disposable.
Copy this prompt into Codex, Claude Code, or another coding agent:
```text
-Install the `kb` Agent Skill from `hraness/kb#v0.17.3` with the standard skills
+Install the `kb` Agent Skill from `hraness/kb#v0.18.0` with the standard skills
CLI. Use the skill's runtime instructions to install the exact
-`@hraness/kb@0.17.3` registry release only when the command is missing. Verify it
+`@hraness/kb@0.18.0` registry release only when the command is missing. Verify it
with `kb doctor` and `kb --help`, but do not initialize or modify a vault until
I ask.
```
@@ -257,25 +269,25 @@ I ask.
Install the single public skill with either runner:
```sh
-npx skills add hraness/kb#v0.17.3
-bunx skills add hraness/kb#v0.17.3
+npx skills add hraness/kb#v0.18.0
+bunx skills add hraness/kb#v0.18.0
```
Both commands discover the same `kb` skill and install it into the selected
agent runner. Skill installation is inert: it does not initialize a vault,
refresh a catalog, or edit Markdown. When invoked, the skill uses an existing
`kb` command or, when the command is missing, checks for Bun and installs the
-CLI from the immutable `@hraness/kb@0.17.3` npm version.
+CLI from the immutable `@hraness/kb@0.18.0` npm version.
The public skills CLI reads `skills/kb/` from the repository. The immutable
-`0.17.3` npm package includes the same tree under
+`0.18.0` npm package includes the same tree under
`node_modules/@hraness/kb/skills/kb/`, and the package check verifies that the
installed skill is byte-identical to the repository source.
Install the two global commands with Bun:
```sh
-bun add --global @hraness/kb@0.17.3
+bun add --global @hraness/kb@0.18.0
kb --help
kb-evaluation-builder --help
```
@@ -283,7 +295,7 @@ kb-evaluation-builder --help
The same registry package can be installed with npm:
```sh
-npm install --global --ignore-scripts @hraness/kb@0.17.3
+npm install --global --ignore-scripts @hraness/kb@0.18.0
kb --help
```
@@ -296,7 +308,7 @@ reviewed and enabled; run `kb doctor` to inspect the resulting capabilities.
For programmatic use, add the exact npm version to a Bun project:
```sh
-bun add --exact @hraness/kb@0.17.3
+bun add --exact @hraness/kb@0.18.0
```
The resulting dependency should remain exact:
@@ -304,12 +316,13 @@ The resulting dependency should remain exact:
```json
{
"dependencies": {
- "@hraness/kb": "0.17.3"
+ "@hraness/kb": "0.18.0"
}
}
```
-Version 0.17.3 retains two public GitHub dependencies:
+Version 0.18.0 retains three public GitHub dependencies: `@hraness/oh` at
+immutable release `v0.2.0` for closure verification,
`@steipete/sweet-cookie` at Hraness release `v0.4.2` for the cookie-scope safety
fork, and `@tobilu/qmd` at commit
`aa993dceb3ef8cfb71d470554ca437570f5a2b3c` for store-local model behavior. A
@@ -572,9 +585,9 @@ a vault. The package smoke test keeps future tagged packages byte-identical to
that source tree.
```sh
-npx skills add hraness/kb#v0.17.3
+npx skills add hraness/kb#v0.18.0
# or
-bunx skills add hraness/kb#v0.17.3
+bunx skills add hraness/kb#v0.18.0
```
The skill invokes the installed `kb` command without depending on a repository
diff --git a/bun.lock b/bun.lock
index 8e0a1e5..6b7c8d3 100644
--- a/bun.lock
+++ b/bun.lock
@@ -5,6 +5,7 @@
"": {
"name": "@hraness/kb",
"dependencies": {
+ "@hraness/oh": "github:hraness/oh#v0.2.0",
"@steipete/sweet-cookie": "github:hraness/sweet-cookie#v0.4.2",
"@tobilu/qmd": "git+https://github.com/hraness/qmd.git#aa993dceb3ef8cfb71d470554ca437570f5a2b3c",
"agent-browser": "0.32.3",
@@ -22,6 +23,8 @@
"packages": {
"@hono/node-server": ["@hono/node-server@1.19.17", "", { "peerDependencies": { "hono": "^4" } }, "sha512-dSneS5qhiauZWGDCeK4o695Xd9nUNjviSZCMQrj10eetr8Uln1ucn6bbphOM6UynAMMtNIzZNSpL9vnASJwrPQ=="],
+ "@hraness/oh": ["@hraness/oh@github:hraness/oh#89fb133", { "peerDependencies": { "@libsql/client": ">=0.17.4 <1", "@suss/datalog": "0.20.0", "@tobilu/qmd": "2.5.3" }, "optionalPeers": ["@libsql/client", "@suss/datalog", "@tobilu/qmd"], "bin": { "oh": "./dist/cli.js" } }, "hraness-oh-89fb133", "sha512-huk8DqOAhnwMm4QAOU+JiyxfdfUF0pZth1jnzOkOmZQe6NqZjR+LXAe2swIVdBMn1hCHb47+c7IqFH3QUTBWTQ=="],
+
"@huggingface/jinja": ["@huggingface/jinja@0.5.9", "", {}, "sha512-uWTG+l3VJRsl7EXxYizuL3P+cCPoc3cRqbWWRcQN0FhejRfbdq0RNhCmbY/YDtnTcz9icdLYuLDjsnz4d8JMuw=="],
"@isaacs/fs-minipass": ["@isaacs/fs-minipass@4.0.1", "", { "dependencies": { "minipass": "^7.0.4" } }, "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w=="],
diff --git a/dist/index.js b/dist/index.js
index 4c011e6..0a73e51 100644
--- a/dist/index.js
+++ b/dist/index.js
@@ -245,6 +245,314 @@ import {
wikiLinks
} from "./index-cxfrakt7.js";
import"./index-1xxnjn0d.js";
+// src/oh-adoption.ts
+import { createHash } from "crypto";
+import { posix } from "path";
+import { canonicalJson, canonicalSha256 } from "@hraness/oh";
+import {
+ parseOhHeadV1,
+ parseOhStoreBindingV1,
+ verifyOhDependencyClosureAgainstV1
+} from "@hraness/oh/store";
+var CODE_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u;
+var RECORD_KEY_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u;
+var MAX_CAPSULE_BYTES = 16 * 1024 * 1024;
+var MAX_RECORDS = 1024;
+var MAX_ROOTS = 256;
+var MAX_TEXT_BYTES = 4096;
+var MAX_STRUCTURAL_NODES = 262144;
+var MAX_STRUCTURAL_DEPTH = 128;
+function isRecord(value) {
+ if (typeof value !== "object" || value === null || Array.isArray(value))
+ return false;
+ const prototype = Object.getPrototypeOf(value);
+ return prototype === Object.prototype || prototype === null;
+}
+function exactKeys(value, keys) {
+ const actual = Reflect.ownKeys(value);
+ return actual.length === keys.length && actual.every((key) => {
+ if (typeof key !== "string" || !keys.includes(key))
+ return false;
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
+ return descriptor !== undefined && descriptor.enumerable && "value" in descriptor;
+ });
+}
+function validUnicode(value) {
+ for (let index = 0;index < value.length; index += 1) {
+ const code = value.charCodeAt(index);
+ if (code >= 55296 && code <= 56319) {
+ const next = value.charCodeAt(index + 1);
+ if (next < 56320 || next > 57343)
+ return false;
+ index += 1;
+ } else if (code >= 56320 && code <= 57343)
+ return false;
+ }
+ return true;
+}
+function structurallyBounded(value) {
+ const pending = [[value, 0]];
+ const seen = new Set;
+ let nodes = 0;
+ let scalarBytes = 0;
+ while (pending.length > 0) {
+ const [candidate, depth] = pending.pop();
+ nodes += 1;
+ scalarBytes += 4;
+ if (nodes > MAX_STRUCTURAL_NODES || depth > MAX_STRUCTURAL_DEPTH || scalarBytes > MAX_CAPSULE_BYTES)
+ return false;
+ if (typeof candidate === "string") {
+ if (!validUnicode(candidate))
+ return false;
+ scalarBytes += Buffer.byteLength(candidate, "utf8");
+ if (scalarBytes > MAX_CAPSULE_BYTES)
+ return false;
+ }
+ if (typeof candidate !== "object" || candidate === null)
+ continue;
+ if (seen.has(candidate))
+ return false;
+ seen.add(candidate);
+ if (Array.isArray(candidate)) {
+ if (candidate.length > MAX_STRUCTURAL_NODES)
+ return false;
+ const keys = Reflect.ownKeys(candidate);
+ if (keys.some((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= candidate.length)))
+ return false;
+ for (let index = 0;index < candidate.length; index += 1) {
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
+ if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor))
+ return false;
+ pending.push([descriptor.value, depth + 1]);
+ }
+ } else if (isRecord(candidate)) {
+ const keys = Reflect.ownKeys(candidate);
+ if (keys.length > MAX_STRUCTURAL_NODES || keys.some((key) => typeof key !== "string"))
+ return false;
+ for (const key of keys) {
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
+ if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor) || !validUnicode(key))
+ return false;
+ scalarBytes += Buffer.byteLength(key, "utf8");
+ if (scalarBytes > MAX_CAPSULE_BYTES)
+ return false;
+ pending.push([descriptor.value, depth + 1]);
+ }
+ } else
+ return false;
+ }
+ return true;
+}
+function immutableClone(value) {
+ if (Array.isArray(value)) {
+ return Object.freeze(value.map((item) => immutableClone(item)));
+ }
+ if (isRecord(value)) {
+ const clone = {};
+ for (const key of Object.keys(value))
+ clone[key] = immutableClone(value[key]);
+ return Object.freeze(clone);
+ }
+ return value;
+}
+function code(value, maximum = 256) {
+ return typeof value === "string" && value.length <= maximum && CODE_PATTERN.test(value) ? value : null;
+}
+function recordKey(value) {
+ return typeof value === "string" && value.length <= 512 && RECORD_KEY_PATTERN.test(value) ? value : null;
+}
+function orderedUnique(values) {
+ return values.every((value, index) => index === 0 || values[index - 1] < value);
+}
+function unsafeReviewCodePoint(codePoint) {
+ return codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint === 1564 || codePoint === 8206 || codePoint === 8207 || codePoint >= 8232 && codePoint <= 8238 || codePoint >= 8294 && codePoint <= 8297 || codePoint === 65279;
+}
+function singleLine(value) {
+ if (typeof value !== "string" || value.length < 1 || value.normalize("NFC") !== value || !validUnicode(value) || [...value].some((character) => unsafeReviewCodePoint(character.codePointAt(0) ?? 0)) || Buffer.byteLength(value, "utf8") > MAX_TEXT_BYTES)
+ return null;
+ return value;
+}
+function parseExpectedSource(value) {
+ if (!isRecord(value) || !exactKeys(value, ["authorityId", "binding", "head", "v"]) || value.v !== 1)
+ return null;
+ const authorityId = code(value.authorityId);
+ const binding = parseOhStoreBindingV1(value.binding);
+ const head = parseOhHeadV1(value.head);
+ return authorityId !== null && binding !== null && binding.profile.profileKind === "working" && head !== null ? immutableClone({ authorityId, binding, head, v: 1 }) : null;
+}
+function parseDestination(value) {
+ if (!isRecord(value) || !exactKeys(value, ["purpose", "targetPath", "v"]) || value.v !== 1)
+ return null;
+ const purpose = code(value.purpose);
+ if (purpose === null || typeof value.targetPath !== "string" || value.targetPath.length > 512 || value.targetPath.includes("\\") || value.targetPath.startsWith("/") || posix.normalize(value.targetPath) !== value.targetPath || !/^notes\/[a-z0-9][a-z0-9._/-]*\.md$/u.test(value.targetPath) || value.targetPath.split("/").some((segment) => segment === "." || segment === ".." || segment.startsWith("."))) {
+ return null;
+ }
+ return { purpose, targetPath: value.targetPath, v: 1 };
+}
+function parseRights(value, purpose) {
+ if (!isRecord(value) || !exactKeys(value, ["decisionId", "disposition", "purpose", "v"]) || value.v !== 1 || value.disposition !== "cleared-for-purpose" || value.purpose !== purpose)
+ return null;
+ const decisionId = code(value.decisionId);
+ return decisionId === null ? null : { decisionId, disposition: "cleared-for-purpose", purpose, v: 1 };
+}
+function parseReview(value) {
+ if (!isRecord(value) || !exactKeys(value, ["route", "status", "v"]) || value.v !== 1 || value.status !== "required")
+ return null;
+ const route = code(value.route);
+ return route === null ? null : { route, status: "required", v: 1 };
+}
+function parseConflicts(value) {
+ if (!isRecord(value) || !exactKeys(value, ["notes", "status", "v"]) || value.v !== 1 || value.status !== "none-observed" && value.status !== "requires-resolution" || !Array.isArray(value.notes) || value.notes.length < 1 || value.notes.length > 64)
+ return null;
+ const notes = value.notes.map(singleLine);
+ if (notes.some((note) => note === null))
+ return null;
+ const sorted = [...notes].sort();
+ return orderedUnique(sorted) ? { notes: sorted, status: value.status, v: 1 } : null;
+}
+function parseHostPolicy(value) {
+ if (!structurallyBounded(value) || !isRecord(value) || !exactKeys(value, ["conflicts", "destination", "expectedSource", "review", "rights", "v"]) || value.v !== 1)
+ return null;
+ const destination = parseDestination(value.destination);
+ const expectedSource = parseExpectedSource(value.expectedSource);
+ const conflicts = parseConflicts(value.conflicts);
+ const review = parseReview(value.review);
+ const rights = destination === null ? null : parseRights(value.rights, destination.purpose);
+ return destination !== null && expectedSource !== null && conflicts !== null && review !== null && rights !== null ? immutableClone({ conflicts, destination, expectedSource, review, rights, v: 1 }) : null;
+}
+function verifyCapsule(value, expectedSource) {
+ try {
+ if (!structurallyBounded(value) || !isRecord(value) || !exactKeys(value, ["binding", "closureSha256", "head", "records", "roots", "v"]) || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots) || value.records.length < 1 || value.records.length > MAX_RECORDS || value.roots.length < 1 || value.roots.length > MAX_ROOTS || Buffer.byteLength(canonicalJson(value), "utf8") > MAX_CAPSULE_BYTES)
+ return null;
+ const verified = verifyOhDependencyClosureAgainstV1(value, {
+ binding: expectedSource.binding,
+ head: expectedSource.head
+ });
+ return verified.ok && verified.closure.binding.profile.profileKind === "working" ? verified.closure : null;
+ } catch {
+ return null;
+ }
+}
+function parseDisclosures(value, keys) {
+ if (!Array.isArray(value) || value.length > 256)
+ return null;
+ const parsed = [];
+ for (const item of value) {
+ if (!isRecord(item) || !exactKeys(item, ["id", "recordKey", "summary", "v"]) || item.v !== 1)
+ return null;
+ const id = code(item.id);
+ const key = recordKey(item.recordKey);
+ const summary = singleLine(item.summary);
+ if (id === null || key === null || summary === null || !keys.has(key))
+ return null;
+ parsed.push({ id, recordKey: key, summary, v: 1 });
+ }
+ parsed.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
+ return orderedUnique(parsed.map((item) => item.id)) ? parsed : null;
+}
+function markdownEscape(value) {
+ return value.replace(/[\\`*_{}\[\]<>()#+.!|>-]/gu, "\\$&");
+}
+function renderMarkdown(manifest, candidateSha256) {
+ const lines = [
+ "# Oh adoption candidate",
+ "",
+ `- Status: \`${manifest.status}\``,
+ `- Candidate: \`sha256:${candidateSha256}\``,
+ `- Destination: \`${manifest.destination.targetPath}\``,
+ `- Purpose: \`${manifest.destination.purpose}\``,
+ `- Source authority: \`${manifest.source.authorityId}\``,
+ `- Source binding: \`${manifest.source.binding.bindingSha256}\``,
+ `- Source head sequence: \`${manifest.source.head.sequence}\``,
+ `- Source head operation: \`${manifest.source.head.operationSha256 ?? "empty"}\``,
+ `- Source graph revision: \`${manifest.source.head.graphRevisionSha256 ?? "empty"}\``,
+ `- Source records digest: \`${manifest.source.head.recordsSha256}\``,
+ `- Closure: \`${manifest.source.closureSha256}\``,
+ "",
+ "This is a review candidate, not reviewed knowledge. It does not mutate a vault or adopt the source operation chain, database, projection, or derived tuples.",
+ "",
+ "## Required decisions",
+ "",
+ `- Rights: \`${manifest.rights.disposition}\` via \`${manifest.rights.decisionId}\` for \`${manifest.rights.purpose}\``,
+ `- Review: \`${manifest.review.status}\` via \`${manifest.review.route}\``,
+ `- Conflicts: \`${manifest.conflicts.status}\``,
+ ...manifest.conflicts.notes.map((note) => ` - ${markdownEscape(note)}`),
+ "",
+ "## Selected roots",
+ "",
+ ...manifest.source.roots.map((root) => `- \`${root}\``),
+ "",
+ "## Exact source records",
+ ""
+ ];
+ for (const record of manifest.source.records) {
+ lines.push(`### \`${record.key}\``, "", `- Kind: \`${record.kind}\``, `- Digest: \`${record.recordSha256}\``, `- Dependencies: ${record.dependencies.length === 0 ? "none" : record.dependencies.map((key) => `\`${key}\``).join(", ")}`, "");
+ }
+ lines.push("## Transformations", "", ...manifest.transformations.length === 0 ? ["- None declared."] : manifest.transformations.map((item) => `- \`${item.id}\` on \`${item.recordKey}\`: ${markdownEscape(item.summary)}`), "", "## Redactions", "", ...manifest.redactions.length === 0 ? ["- None declared."] : manifest.redactions.map((item) => `- \`${item.id}\` on \`${item.recordKey}\`: ${markdownEscape(item.summary)}`), "");
+ return `${lines.join(`
+`)}
+`;
+}
+function prepareWithPolicy(value, policy) {
+ if (!structurallyBounded(value) || !isRecord(value) || !exactKeys(value, ["capsule", "redactions", "transformations", "v"]) || value.v !== 1) {
+ throw new TypeError("Invalid Oh adoption preparation input.");
+ }
+ const capsule = verifyCapsule(value.capsule, policy.expectedSource);
+ if (capsule === null)
+ throw new TypeError("The source capsule is invalid for the bound authority and head.");
+ const recordKeys = new Set(capsule.records.map((record) => record.key));
+ const transformations = parseDisclosures(value.transformations, recordKeys);
+ const redactions = parseDisclosures(value.redactions, recordKeys);
+ const roots = new Set(capsule.roots);
+ if (transformations === null || redactions === null || capsule.records.filter((record) => roots.has(record.key)).every((record) => record.kind === "view")) {
+ throw new TypeError("Adoption requires valid disclosures and an authoritative root.");
+ }
+ const source = {
+ authorityId: policy.expectedSource.authorityId,
+ binding: { bindingSha256: capsule.binding.bindingSha256, v: 1 },
+ closureSha256: capsule.closureSha256,
+ head: capsule.head,
+ records: capsule.records.map((record) => ({
+ dependencies: record.dependencies,
+ key: record.key,
+ kind: record.kind,
+ recordSha256: record.recordSha256,
+ v: 1
+ })),
+ roots: capsule.roots,
+ v: 1
+ };
+ const manifest = {
+ conflicts: policy.conflicts,
+ destination: policy.destination,
+ format: "hraness.kb.oh-adoption-candidate.v1",
+ redactions,
+ review: policy.review,
+ rights: policy.rights,
+ source,
+ status: "prepared",
+ transformations,
+ v: 1
+ };
+ const candidateSha256 = canonicalSha256(manifest);
+ const markdown = renderMarkdown(manifest, candidateSha256);
+ if (Buffer.byteLength(markdown, "utf8") > MAX_CAPSULE_BYTES) {
+ throw new RangeError("The adoption candidate exceeds its Markdown byte limit.");
+ }
+ return immutableClone({
+ artifactSha256: createHash("sha256").update(markdown).digest("hex"),
+ candidateSha256,
+ manifest,
+ markdown,
+ v: 1
+ });
+}
+function createOhAdoptionPreparerV1(value) {
+ const policy = parseHostPolicy(value);
+ if (policy === null)
+ throw new TypeError("Invalid Oh adoption host policy.");
+ return Object.freeze({ prepare: (input) => prepareWithPolicy(input, policy) });
+}
export {
workflowFromUnknown,
wikiLinks,
@@ -330,6 +638,7 @@ export {
createVerifiedEmbeddingModelLease,
createSyntheticRankFusionFixture,
createRepresentativeRetrievalFixture,
+ createOhAdoptionPreparerV1,
createNote,
createConceptNote,
compareAgentGuideAudits,
diff --git a/docs/design.md b/docs/design.md
index b2df449..2ee8769 100644
--- a/docs/design.md
+++ b/docs/design.md
@@ -217,6 +217,30 @@ and avoids a repository-wide merge hotspot. A future cache may live outside the
vault only if measurements justify it; it must be content-addressed by source
and analysis version and rebuild on any mismatch.
+## Oh adoption stops at a review candidate
+
+`createOhAdoptionPreparerV1` captures, in trusted host code, one exact Oh
+working-authority binding and head plus the destination purpose and proposed
+`notes/` path, a purpose-matched rights decision, the required review route,
+and the conflict assessment. Its returned facade accepts only an Oh dependency
+closure and explicit transformation or redaction disclosures. A model cannot
+replace the source authority, destination, rights, review, or conflict policy
+inside a preparation call.
+
+KB delegates contract, binding, head, record, and exact dependency-closure
+verification to the immutable `@hraness/oh` v0.2.0 store API. KB keeps lower
+local byte, record, root, depth, and node ceilings and rejects accessors,
+symbols, cycles, canonical-authority bindings, tampered or incomplete records,
+over-complete closures, wrong bindings or heads, and derived-only roots. The
+review artifact records the source authority and binding digest, full head,
+closure roots, and record digests without copying source realm or space IDs.
+
+The returned status is always `prepared`. The function does not open a vault,
+write a note, invoke Git, import an operation chain or database, retain a
+projection, or write to canonical Oh. A reviewer must inspect the candidate and
+author destination Markdown through KB's existing revision-checked write path;
+the source's proposed assertion is never relabeled as reviewed knowledge.
+
## Catalog ownership is explicit
A managed vault gives one marked region in `index.md` to the tool. `kb refresh`
diff --git a/docs/publishing.md b/docs/publishing.md
index 396715f..1fb329b 100644
--- a/docs/publishing.md
+++ b/docs/publishing.md
@@ -131,7 +131,7 @@ keep the tag and npm version immutable. After the recovery workflow is on
current `main`, dispatch it with the existing tag:
```sh
-gh workflow run release.yml --ref main -f tag=v0.17.3
+gh workflow run release.yml --ref main -f tag=v0.18.0
```
The recovery path accepts only the newest stable repository tag. It freshly
diff --git a/package.json b/package.json
index 772a15d..4efd09d 100644
--- a/package.json
+++ b/package.json
@@ -1,6 +1,6 @@
{
"name": "@hraness/kb",
- "version": "0.17.3",
+ "version": "0.18.0",
"description": "A knowledge base for coding agents, built from Markdown, backlinks, semantic search, and Git context.",
"license": "MIT",
"contentPolicy": {
@@ -335,6 +335,7 @@
"src/init.ts",
"src/navigation.ts",
"src/note-lock.ts",
+ "src/oh-adoption.ts",
"src/percolate.ts",
"src/portfolio.ts",
"src/portfolio-audit.ts",
@@ -391,6 +392,7 @@
"prepack": "bun run check"
},
"dependencies": {
+ "@hraness/oh": "github:hraness/oh#v0.2.0",
"@steipete/sweet-cookie": "github:hraness/sweet-cookie#v0.4.2",
"@tobilu/qmd": "git+https://github.com/hraness/qmd.git#aa993dceb3ef8cfb71d470554ca437570f5a2b3c",
"agent-browser": "0.32.3",
diff --git a/portfolio-inventory.json b/portfolio-inventory.json
index db51592..234cbeb 100644
--- a/portfolio-inventory.json
+++ b/portfolio-inventory.json
@@ -8,10 +8,17 @@
"name": "@hraness/kb",
"path": ".",
"visibility": "public",
- "version": "0.17.3"
+ "version": "0.18.0"
}
],
"dependencies": [
+ {
+ "from": "@hraness/kb",
+ "scope": "runtime",
+ "specifier": "github:hraness/oh#v0.2.0",
+ "to": "@hraness/oh",
+ "sourceRepository": "hraness/oh"
+ },
{
"from": "@hraness/kb",
"scope": "runtime",
diff --git a/scripts/npm-release-workflow.test.ts b/scripts/npm-release-workflow.test.ts
index 14c0727..fc4fbcd 100644
--- a/scripts/npm-release-workflow.test.ts
+++ b/scripts/npm-release-workflow.test.ts
@@ -120,7 +120,7 @@ describe("npm release workflows", () => {
readonly version?: unknown;
};
expect(manifest).toEqual(expect.objectContaining({
- version: "0.17.3",
+ version: "0.18.0",
description: "A knowledge base for coding agents, built from Markdown, backlinks, semantic search, and Git context.",
keywords: [
"knowledge-base",
@@ -409,8 +409,8 @@ describe("canonical npm package identity", () => {
sourcePackJson,
});
const verified = await verifyNpmPackageIdentity(validInput);
- expect(verified.fileCount).toBe(200);
- expect(verified.unpackedBytes).toBe(4_861_496);
+ expect(verified.fileCount).toBe(201);
+ expect(verified.unpackedBytes).toBe(4_895_276);
expect(verified.sourceArchiveSha512).not.toBe(verified.registryArchiveSha512);
const originalTar = gunzipSync(sourceBytes);
diff --git a/skills/kb/SKILL.md b/skills/kb/SKILL.md
index 724bfc3..85d3078 100644
--- a/skills/kb/SKILL.md
+++ b/skills/kb/SKILL.md
@@ -31,7 +31,7 @@ missing:
```sh
command -v kb >/dev/null 2>&1 || {
command -v bun >/dev/null 2>&1 || exit 1
- bun add --global @hraness/kb@0.17.3
+ bun add --global @hraness/kb@0.18.0
}
kb --help
```
diff --git a/src/index.ts b/src/index.ts
index 27e2d0d..eabd179 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -9,6 +9,7 @@ export * from "./git.js";
export * from "./graph.js";
export * from "./init.js";
export * from "./navigation.js";
+export * from "./oh-adoption.js";
export * from "./percolate.js";
export * from "./query.js";
export * from "./repository-memory.js";
diff --git a/src/oh-adoption.test.ts b/src/oh-adoption.test.ts
new file mode 100644
index 0000000..c73ae01
--- /dev/null
+++ b/src/oh-adoption.test.ts
@@ -0,0 +1,281 @@
+import { describe, expect, test } from "bun:test";
+import { createHash } from "node:crypto";
+
+import {
+ canonicalSha256,
+ createKnowledgeGraphRecordV1,
+ knowledgeGraphRecordRefV1,
+ type KnowledgeGraphRecordKindV1,
+} from "@hraness/oh";
+import {
+ createOhDependencyClosureV1,
+ createOhStoreBindingV1,
+ OH_CANONICAL_STORE_PROFILE_V1,
+ OH_WORKING_STORE_PROFILE_V1,
+} from "@hraness/oh/store";
+
+import { createOhAdoptionPreparerV1 } from "./oh-adoption.js";
+
+function fixture(kind: KnowledgeGraphRecordKindV1 = "assertion") {
+ const binding = createOhStoreBindingV1({
+ profile: OH_WORKING_STORE_PROFILE_V1,
+ realmId: "tenant:test/thread:one",
+ spaceId: "thread:one",
+ v: 1,
+ });
+ const evidence = createKnowledgeGraphRecordV1({
+ dependencies: [],
+ key: "evidence:source",
+ kind: "evidence",
+ v: 1,
+ value: { locator: "https://example.test/source" },
+ });
+ const assertion = createKnowledgeGraphRecordV1({
+ dependencies: ["evidence:source"],
+ key: "assertion:candidate",
+ kind,
+ v: 1,
+ value: { state: "proposed", text: "A bounded candidate." },
+ });
+ const records = [assertion, evidence];
+ const head = {
+ generation: 3,
+ graphRevisionSha256: canonicalSha256({ fixture: "graph-revision", v: 1 }),
+ operationSha256: canonicalSha256({ fixture: "operation", v: 1 }),
+ recordsSha256: canonicalSha256(records.map(knowledgeGraphRecordRefV1)),
+ sequence: 3,
+ v: 1,
+ } as const;
+ const capsule = createOhDependencyClosureV1({
+ binding,
+ roots: [assertion.key],
+ snapshot: { head, records, v: 1 },
+ });
+ const expectedSource = {
+ authorityId: "sponge.working.primary",
+ binding,
+ head,
+ v: 1,
+ } as const;
+ const hostPolicy = {
+ conflicts: {
+ notes: ["No destination collision was found; review must confirm."],
+ status: "none-observed",
+ v: 1,
+ },
+ destination: {
+ purpose: "kb.maintained-knowledge",
+ targetPath: "notes/adopted-candidate.md",
+ v: 1,
+ },
+ expectedSource,
+ review: { route: "kb.adoption-review", status: "required", v: 1 },
+ rights: {
+ decisionId: "rights:decision-one",
+ disposition: "cleared-for-purpose",
+ purpose: "kb.maintained-knowledge",
+ v: 1,
+ },
+ v: 1,
+ } as const;
+ const prepareInput = {
+ capsule,
+ redactions: [],
+ transformations: [{
+ id: "transform:normalize-title",
+ recordKey: assertion.key,
+ summary: "Normalize the title without changing the proposed claim.",
+ v: 1,
+ }],
+ v: 1,
+ } as const;
+ return { assertion, binding, capsule, evidence, expectedSource, head, hostPolicy, prepareInput };
+}
+
+describe("Oh dependency-closure adoption", () => {
+ test("prepares deterministic immutable review bytes from a host-bound policy", () => {
+ const { hostPolicy, prepareInput } = fixture();
+ const preparer = createOhAdoptionPreparerV1(hostPolicy);
+ const first = preparer.prepare(prepareInput);
+ const second = preparer.prepare({ ...prepareInput,
+ transformations: [...prepareInput.transformations].reverse() });
+ expect(second).toEqual(first);
+ expect(first.manifest.status).toBe("prepared");
+ expect(first.manifest.review.status).toBe("required");
+ expect(first.markdown).toContain("This is a review candidate, not reviewed knowledge.");
+ expect(first.markdown).toContain("does not mutate a vault");
+ expect(first.markdown).not.toContain("status: reviewed");
+ expect(first.artifactSha256).toBe(createHash("sha256").update(first.markdown).digest("hex"));
+ expect(Object.keys(first)).toEqual(["artifactSha256", "candidateSha256", "manifest", "markdown", "v"]);
+ expect(Object.keys(first.manifest.source.binding)).toEqual(["bindingSha256", "v"]);
+ expect("realmId" in first.manifest.source.binding).toBeFalse();
+ expect(Object.isFrozen(first)).toBeTrue();
+ expect(Object.isFrozen(first.manifest)).toBeTrue();
+ expect(Object.isFrozen(first.manifest.source.head)).toBeTrue();
+ expect(Object.isFrozen(first.manifest.source.records)).toBeTrue();
+ expect(Object.isFrozen(first.manifest.source.records[0]!.dependencies)).toBeTrue();
+ });
+
+ test("binds authority and policy before exposing the narrow preparation facade", () => {
+ const { hostPolicy, prepareInput } = fixture();
+ const mutablePolicy = structuredClone(hostPolicy);
+ const preparer = createOhAdoptionPreparerV1(mutablePolicy);
+ Reflect.set(mutablePolicy.destination, "targetPath", "notes/laundered.md");
+ Reflect.set(mutablePolicy.expectedSource, "authorityId", "attacker.working");
+ Reflect.set(mutablePolicy.conflicts.notes, "0", "Silently replace the destination.");
+ const candidate = preparer.prepare(prepareInput);
+ expect(candidate.manifest.destination.targetPath).toBe("notes/adopted-candidate.md");
+ expect(candidate.manifest.source.authorityId).toBe("sponge.working.primary");
+ expect(candidate.manifest.conflicts.notes).toEqual([
+ "No destination collision was found; review must confirm.",
+ ]);
+ expect(Object.keys(preparer)).toEqual(["prepare"]);
+ for (const forbidden of ["commit", "purge", "store", "write", "expectedSource", "destination"]) {
+ expect(forbidden in preparer).toBeFalse();
+ }
+ expect(() => preparer.prepare({ ...prepareInput,
+ destination: hostPolicy.destination })).toThrow("Invalid Oh adoption preparation input");
+ expect(() => preparer.prepare({ ...prepareInput,
+ expectedSource: hostPolicy.expectedSource })).toThrow("Invalid Oh adoption preparation input");
+ });
+
+ test("uses Oh's verifier for exact binding, head, closure, and records", () => {
+ const { assertion, binding, capsule, evidence, head, hostPolicy, prepareInput } = fixture();
+ const wrongHead = createOhAdoptionPreparerV1({ ...hostPolicy,
+ expectedSource: { ...hostPolicy.expectedSource,
+ head: { ...head, operationSha256: canonicalSha256({ wrong: "head" }) } } });
+ expect(() => wrongHead.prepare(prepareInput)).toThrow("bound authority and head");
+
+ const otherBinding = createOhStoreBindingV1({ profile: OH_WORKING_STORE_PROFILE_V1,
+ realmId: "tenant:test/thread:other", spaceId: "thread:other", v: 1 });
+ const wrongBinding = createOhAdoptionPreparerV1({ ...hostPolicy,
+ expectedSource: { ...hostPolicy.expectedSource, binding: otherBinding } });
+ expect(() => wrongBinding.prepare(prepareInput)).toThrow("bound authority and head");
+
+ const preparer = createOhAdoptionPreparerV1(hostPolicy);
+ expect(() => preparer.prepare({ ...prepareInput, capsule: { ...capsule,
+ records: [{ ...assertion, value: { state: "reviewed", text: "Tampered." } }, evidence] } }))
+ .toThrow("source capsule");
+ expect(() => preparer.prepare({ ...prepareInput,
+ capsule: { ...capsule, records: [assertion] } })).toThrow("source capsule");
+
+ const extra = createKnowledgeGraphRecordV1({ dependencies: [], key: "entity:smuggled",
+ kind: "entity", v: 1, value: { name: "Smuggled" } });
+ const extraPayload = { binding, head,
+ records: [...capsule.records, extra].sort((left, right) => left.key.localeCompare(right.key)),
+ roots: capsule.roots, v: 1 as const };
+ expect(() => preparer.prepare({ ...prepareInput, capsule: { ...extraPayload,
+ closureSha256: canonicalSha256(extraPayload) } })).toThrow("source capsule");
+ });
+
+ test("fails closed on unsafe host policy, canonical sources, and derived-only roots", () => {
+ const { head, hostPolicy } = fixture();
+ for (const targetPath of ["../notes/out.md", "/tmp/out.md", "notes/../../out.md",
+ "plans/out.md", "notes/out.txt", "notes/.hidden.md"]) {
+ expect(() => createOhAdoptionPreparerV1({ ...hostPolicy,
+ destination: { ...hostPolicy.destination, targetPath } })).toThrow("host policy");
+ }
+ const { rights: _rights, ...withoutRights } = hostPolicy;
+ expect(() => createOhAdoptionPreparerV1(withoutRights)).toThrow("host policy");
+ expect(() => createOhAdoptionPreparerV1({ ...hostPolicy,
+ review: { route: "kb.adoption-review", status: "reviewed", v: 1 } })).toThrow("host policy");
+ expect(() => createOhAdoptionPreparerV1({ ...hostPolicy,
+ rights: { ...hostPolicy.rights, purpose: "kb.some-other-purpose" } })).toThrow("host policy");
+
+ const canonicalBinding = createOhStoreBindingV1({ profile: OH_CANONICAL_STORE_PROFILE_V1,
+ realmId: "tenant:test/canonical", spaceId: "canonical", v: 1 });
+ expect(() => createOhAdoptionPreparerV1({ ...hostPolicy,
+ expectedSource: { ...hostPolicy.expectedSource, binding: canonicalBinding, head } }))
+ .toThrow("host policy");
+
+ const derived = fixture("view");
+ expect(() => createOhAdoptionPreparerV1(derived.hostPolicy).prepare(derived.prepareInput))
+ .toThrow("authoritative root");
+ expect(() => createOhAdoptionPreparerV1(hostPolicy).prepare({
+ authority: "derived", rows: [], v: 1,
+ })).toThrow("preparation input");
+ });
+
+ test("requires exact record disclosures and escapes host review prose", () => {
+ const { hostPolicy, prepareInput } = fixture();
+ const preparer = createOhAdoptionPreparerV1(hostPolicy);
+ expect(() => preparer.prepare({ ...prepareInput,
+ redactions: [{ id: "redact:one", recordKey: "entity:outside", summary: "Remove", v: 1 }] }))
+ .toThrow("valid disclosures");
+ const candidate = createOhAdoptionPreparerV1({ ...hostPolicy,
+ conflicts: { notes: [""], status: "requires-resolution", v: 1 } })
+ .prepare(prepareInput);
+ expect(candidate.markdown).not.toContain("