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("