@hraness/kb 0.17.3 → 0.18.1

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/README.md CHANGED
@@ -14,34 +14,67 @@ system.
14
14
  Bun 1.3.14 or newer is required.
15
15
 
16
16
  ```sh
17
- bun add --global @hraness/kb@0.17.3
17
+ bun add --global @hraness/kb@0.18.1
18
+ kb --help
18
19
  ```
19
20
 
20
- ## Why kb
21
-
22
- - **Inspect what agents recover.** Markdown and Git stay authoritative,
23
- retrieval signals stay distinct, and indexes, embeddings, and graph views
24
- remain replaceable.
25
- - **Keep the application independent.** Application code imports neither the
26
- vault nor a hosted knowledge service. Capture and semantic adapters declare
27
- their network, browser, native-tool, and model-download effects.
21
+ ## Keep one decision available to the next session
28
22
 
29
- ## Create one durable note
23
+ Suppose a parser must stop retrying after three attempts. Record that constraint
24
+ in a note, then link the plan that will implement it:
30
25
 
31
- From a directory without an existing `kb/` path, this local task creates a
32
- vault, writes one typed note, and finds it without a network request or
33
- embedding model:
34
-
35
- ```sh
26
+ ```shell
36
27
  kb init kb
37
28
  kb note create notes/parser-contract \
38
- --title "Parser contract" --type concept --tag architecture --root kb
39
- kb search "parser contract" --root kb --mode exact
29
+ --title "Parser contract" --type concept --tag architecture \
30
+ --body "Parser retries stop after three attempts." --root kb
31
+ kb note create plans/parser-v2 \
32
+ --title "Parser v2" --type plan \
33
+ --body "The plan implements [[notes/parser-contract|the parser contract]]." \
34
+ --root kb
40
35
  ```
41
36
 
42
- The note is stored at `kb/notes/parser-contract.md`. Exact search reads the
43
- current Markdown and returns that record. Commit the vault when it should
44
- travel with the repository.
37
+ The first `kb note create` command stores ordinary Markdown at
38
+ `kb/notes/parser-contract.md` and assigns its stable `document_id`. Add the
39
+ exact code boundary to that note's frontmatter so path lookup can recover it:
40
+
41
+ ```yaml
42
+ repository_scopes:
43
+ - packages/parser
44
+ ```
45
+
46
+ Commit the vault with the repository. The Markdown and its Git history are the
47
+ durable record.
48
+
49
+ ## Recover the stopped session
50
+
51
+ In a later session, start from the code path and inspect each independent
52
+ signal:
53
+
54
+ ```shell
55
+ kb context packages/parser/src/index.ts --root kb --repo .
56
+ kb search "why parser retries stop" --root kb --mode exact \
57
+ --history --repo .
58
+ kb backlinks notes/parser-contract --root kb
59
+ kb history notes/parser-contract --root kb --repo .
60
+ ```
61
+
62
+ | Signal | What it recovers |
63
+ | --- | --- |
64
+ | Markdown | The current parser constraint in the file you can review and edit. |
65
+ | Backlinks | The plan that explicitly links to the constraint. |
66
+ | Exact search | The current note matched from its words, without a network request or embedding model. |
67
+ | Repository context | Inherited `AGENTS.md` guides and records scoped to `packages/parser`. |
68
+ | Git history | The commits and bounded co-change evidence associated with the note. |
69
+
70
+ Together, those views recover the persisted decision, related plan, applicable
71
+ rules, and provenance needed to resume the work. They do not reconstruct
72
+ private chat or prove that the note is still correct. Open the returned
73
+ Markdown and guides before acting on them.
74
+
75
+ The boundaries stay visible: Markdown and Git are authoritative, backlinks and
76
+ indexes are replaceable views, and Git work is opt-in. Application code imports
77
+ neither the vault nor a hosted knowledge service.
45
78
 
46
79
  <!-- hraness:kb-landing:end -->
47
80
 
@@ -51,22 +84,6 @@ travel with the repository.
51
84
  [KB source on GitHub](https://github.com/hraness/kb) ·
52
85
  [KB overview](https://hraness.com/kb)
53
86
 
54
- ## Use
55
-
56
- ```sh
57
- kb init kb
58
- kb clip https://example.com/article --output articles
59
- kb pdf ./report.pdf --output articles
60
- kb percolate notes/topic --root .
61
- kb context packages/parser/src/index.ts --root kb --repo .
62
- kb list --where type=plan --scope packages/parser --root .
63
- kb links notes/topic --root . --direction both
64
- kb search "parser-v2" --root . --mode exact
65
- kb portfolio search "parser-v2" --registry kb-portfolio.json \
66
- --workspace .. --shared
67
- kb history search packages/parser --root . --repo .. --json
68
- ```
69
-
70
87
  ## A knowledge base for your coding agents
71
88
 
72
89
  > Give coding agents durable, searchable memory beside the repository with plain Markdown, Git history, and replaceable local search.
@@ -193,51 +210,6 @@ Start with a short inherited `AGENTS.md` path for rules whose omission would mak
193
210
 
194
211
  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.
195
212
 
196
- ## Upgrade to v0.17.3
197
-
198
- Version 0.17.3 restructures the README around an inspectable first task,
199
- explicit operating boundaries, and a shorter path from installation to useful
200
- output. Runtime APIs and package behavior are unchanged.
201
-
202
- ## Upgrade to v0.17.2
203
-
204
- Version 0.17.2 improves package discovery through focused npm keywords, a more
205
- specific README opening, and direct links between npm, GitHub, and the project
206
- overview. Runtime APIs and package behavior are unchanged.
207
-
208
- ## Upgrade to v0.17.1
209
-
210
- Version 0.17.1 adds the public `@hraness/kb` npm installation path without
211
- changing the runtime API introduced in 0.17.0. Bun `1.3.14` or newer is now an
212
- explicit package requirement. Consumers should review the package's declared
213
- dual-use capture boundary and the lifecycle scripts used by optional browser
214
- and native search adapters before enabling those scripts.
215
-
216
- ## Upgrade to v0.17.0
217
-
218
- Version 0.17 adds selected portfolio federation, stable note identities,
219
- qualified external relations, search rules, capture inspection, and untrusted
220
- context packing. Consumers with typed fixtures or custom capture writers should
221
- make these migrations before upgrading:
222
-
223
- - Capture writers now emit manifest schema v4 and must provide the stored
224
- document `path`, exact UTF-8 `bytes`, and lowercase SHA-256 digest. The reader
225
- can inspect schema v1-v3, but verification reports their document integrity as
226
- unavailable instead of success.
227
- - `DecisionContextOutput.search` has been removed. Consume the bounded untrusted
228
- `context` projection and its `truncated` flag instead of transporting the raw
229
- search result into an agent prompt.
230
- - `VaultAnalysis` fixtures must include `externalAuthoredRelations`, even when
231
- the value is an empty array. This keeps qualified authored edges distinct
232
- from locally resolved graph edges.
233
- - `createNote` and `kb note create` now assign `document_id` to new ordinary
234
- notes. Preserve that ID across renames and update snapshots that intentionally
235
- assert the generated frontmatter.
236
-
237
- Existing Markdown is not rewritten automatically. Add IDs to maintained legacy
238
- notes only through reviewed edits, and keep every QMD, graph, portfolio, and
239
- audit projection disposable.
240
-
241
213
  ## Installation reference
242
214
 
243
215
  [Bun](https://bun.sh/docs/installation) is the required runtime.
@@ -247,9 +219,9 @@ audit projection disposable.
247
219
  Copy this prompt into Codex, Claude Code, or another coding agent:
248
220
 
249
221
  ```text
250
- Install the `kb` Agent Skill from `hraness/kb#v0.17.3` with the standard skills
222
+ Install the `kb` Agent Skill from `hraness/kb#v0.18.1` with the standard skills
251
223
  CLI. Use the skill's runtime instructions to install the exact
252
- `@hraness/kb@0.17.3` registry release only when the command is missing. Verify it
224
+ `@hraness/kb@0.18.1` registry release only when the command is missing. Verify it
253
225
  with `kb doctor` and `kb --help`, but do not initialize or modify a vault until
254
226
  I ask.
255
227
  ```
@@ -257,25 +229,25 @@ I ask.
257
229
  Install the single public skill with either runner:
258
230
 
259
231
  ```sh
260
- npx skills add hraness/kb#v0.17.3
261
- bunx skills add hraness/kb#v0.17.3
232
+ npx skills add hraness/kb#v0.18.1
233
+ bunx skills add hraness/kb#v0.18.1
262
234
  ```
263
235
 
264
236
  Both commands discover the same `kb` skill and install it into the selected
265
237
  agent runner. Skill installation is inert: it does not initialize a vault,
266
238
  refresh a catalog, or edit Markdown. When invoked, the skill uses an existing
267
239
  `kb` command or, when the command is missing, checks for Bun and installs the
268
- CLI from the immutable `@hraness/kb@0.17.3` npm version.
240
+ CLI from the immutable `@hraness/kb@0.18.1` npm version.
269
241
 
270
242
  The public skills CLI reads `skills/kb/` from the repository. The immutable
271
- `0.17.3` npm package includes the same tree under
243
+ `0.18.1` npm package includes the same tree under
272
244
  `node_modules/@hraness/kb/skills/kb/`, and the package check verifies that the
273
245
  installed skill is byte-identical to the repository source.
274
246
 
275
247
  Install the two global commands with Bun:
276
248
 
277
249
  ```sh
278
- bun add --global @hraness/kb@0.17.3
250
+ bun add --global @hraness/kb@0.18.1
279
251
  kb --help
280
252
  kb-evaluation-builder --help
281
253
  ```
@@ -283,7 +255,7 @@ kb-evaluation-builder --help
283
255
  The same registry package can be installed with npm:
284
256
 
285
257
  ```sh
286
- npm install --global --ignore-scripts @hraness/kb@0.17.3
258
+ npm install --global --ignore-scripts @hraness/kb@0.18.1
287
259
  kb --help
288
260
  ```
289
261
 
@@ -296,7 +268,7 @@ reviewed and enabled; run `kb doctor` to inspect the resulting capabilities.
296
268
  For programmatic use, add the exact npm version to a Bun project:
297
269
 
298
270
  ```sh
299
- bun add --exact @hraness/kb@0.17.3
271
+ bun add --exact @hraness/kb@0.18.1
300
272
  ```
301
273
 
302
274
  The resulting dependency should remain exact:
@@ -304,12 +276,13 @@ The resulting dependency should remain exact:
304
276
  ```json
305
277
  {
306
278
  "dependencies": {
307
- "@hraness/kb": "0.17.3"
279
+ "@hraness/kb": "0.18.1"
308
280
  }
309
281
  }
310
282
  ```
311
283
 
312
- Version 0.17.3 retains two public GitHub dependencies:
284
+ Version 0.18.1 retains three public GitHub dependencies: `@hraness/oh` at
285
+ immutable release `v0.2.0` for closure verification,
313
286
  `@steipete/sweet-cookie` at Hraness release `v0.4.2` for the cookie-scope safety
314
287
  fork, and `@tobilu/qmd` at commit
315
288
  `aa993dceb3ef8cfb71d470554ca437570f5a2b3c` for store-local model behavior. A
@@ -572,9 +545,9 @@ a vault. The package smoke test keeps future tagged packages byte-identical to
572
545
  that source tree.
573
546
 
574
547
  ```sh
575
- npx skills add hraness/kb#v0.17.3
548
+ npx skills add hraness/kb#v0.18.1
576
549
  # or
577
- bunx skills add hraness/kb#v0.17.3
550
+ bunx skills add hraness/kb#v0.18.1
578
551
  ```
579
552
 
580
553
  The skill invokes the installed `kb` command without depending on a repository
@@ -584,3 +557,69 @@ effect. The repository's phase-orchestration skill remains available to local
584
557
  repository agents but is marked internal, so public skill discovery omits it.
585
558
 
586
559
  See [Design](docs/design.md), [Portfolio federation](docs/portfolio.md), [Agent workflow](docs/agent-workflow.md), [PDF capture](docs/pdf.md), and [Contributing](CONTRIBUTING.md) for the durable contracts and development gate. hraness/kb is available under the [MIT License](LICENSE).
560
+
561
+ ## Release notes
562
+
563
+ ### Upgrade to v0.18.1
564
+
565
+ Version 0.18.1 restructures the public README and hosted projection around one
566
+ durable note, the exact recovery workflow, inspectable retrieval signals, and
567
+ explicit authority boundaries. Runtime APIs and package behavior are
568
+ unchanged.
569
+
570
+ ### Upgrade to v0.18.0
571
+
572
+ Version 0.18.0 adds a review-only adoption seam for exact dependency closures
573
+ from an Oh working authority. Trusted host code creates a
574
+ `createOhAdoptionPreparerV1` facade with the expected binding and head,
575
+ destination, rights clearance, review route, and conflict policy. The narrow
576
+ `prepare` call accepts only a capsule plus transformation and redaction
577
+ disclosures, returns deeply immutable deterministic Markdown and manifest
578
+ bytes with status `prepared`, and has no vault, Git, Oh-store, or promotion
579
+ capability. KB pins `@hraness/oh` v0.2.0 and delegates closure integrity to its
580
+ official store verifier.
581
+
582
+ ### Upgrade to v0.17.3
583
+
584
+ Version 0.17.3 restructures the README around an inspectable first task,
585
+ explicit operating boundaries, and a shorter path from installation to useful
586
+ output. Runtime APIs and package behavior are unchanged.
587
+
588
+ ### Upgrade to v0.17.2
589
+
590
+ Version 0.17.2 improves package discovery through focused npm keywords, a more
591
+ specific README opening, and direct links between npm, GitHub, and the project
592
+ overview. Runtime APIs and package behavior are unchanged.
593
+
594
+ ### Upgrade to v0.17.1
595
+
596
+ Version 0.17.1 adds the public `@hraness/kb` npm installation path without
597
+ changing the runtime API introduced in 0.17.0. Bun `1.3.14` or newer is now an
598
+ explicit package requirement. Consumers should review the package's declared
599
+ dual-use capture boundary and the lifecycle scripts used by optional browser
600
+ and native search adapters before enabling those scripts.
601
+
602
+ ### Upgrade to v0.17.0
603
+
604
+ Version 0.17 adds selected portfolio federation, stable note identities,
605
+ qualified external relations, search rules, capture inspection, and untrusted
606
+ context packing. Consumers with typed fixtures or custom capture writers should
607
+ make these migrations before upgrading:
608
+
609
+ - Capture writers now emit manifest schema v4 and must provide the stored
610
+ document `path`, exact UTF-8 `bytes`, and lowercase SHA-256 digest. The reader
611
+ can inspect schema v1-v3, but verification reports their document integrity as
612
+ unavailable instead of success.
613
+ - `DecisionContextOutput.search` has been removed. Consume the bounded untrusted
614
+ `context` projection and its `truncated` flag instead of transporting the raw
615
+ search result into an agent prompt.
616
+ - `VaultAnalysis` fixtures must include `externalAuthoredRelations`, even when
617
+ the value is an empty array. This keeps qualified authored edges distinct
618
+ from locally resolved graph edges.
619
+ - `createNote` and `kb note create` now assign `document_id` to new ordinary
620
+ notes. Preserve that ID across renames and update snapshots that intentionally
621
+ assert the generated frontmatter.
622
+
623
+ Existing Markdown is not rewritten automatically. Add IDs to maintained legacy
624
+ notes only through reviewed edits, and keep every QMD, graph, portfolio, and
625
+ audit projection disposable.
package/dist/index.js CHANGED
@@ -245,6 +245,314 @@ import {
245
245
  wikiLinks
246
246
  } from "./index-cxfrakt7.js";
247
247
  import"./index-1xxnjn0d.js";
248
+ // src/oh-adoption.ts
249
+ import { createHash } from "crypto";
250
+ import { posix } from "path";
251
+ import { canonicalJson, canonicalSha256 } from "@hraness/oh";
252
+ import {
253
+ parseOhHeadV1,
254
+ parseOhStoreBindingV1,
255
+ verifyOhDependencyClosureAgainstV1
256
+ } from "@hraness/oh/store";
257
+ var CODE_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u;
258
+ var RECORD_KEY_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u;
259
+ var MAX_CAPSULE_BYTES = 16 * 1024 * 1024;
260
+ var MAX_RECORDS = 1024;
261
+ var MAX_ROOTS = 256;
262
+ var MAX_TEXT_BYTES = 4096;
263
+ var MAX_STRUCTURAL_NODES = 262144;
264
+ var MAX_STRUCTURAL_DEPTH = 128;
265
+ function isRecord(value) {
266
+ if (typeof value !== "object" || value === null || Array.isArray(value))
267
+ return false;
268
+ const prototype = Object.getPrototypeOf(value);
269
+ return prototype === Object.prototype || prototype === null;
270
+ }
271
+ function exactKeys(value, keys) {
272
+ const actual = Reflect.ownKeys(value);
273
+ return actual.length === keys.length && actual.every((key) => {
274
+ if (typeof key !== "string" || !keys.includes(key))
275
+ return false;
276
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
277
+ return descriptor !== undefined && descriptor.enumerable && "value" in descriptor;
278
+ });
279
+ }
280
+ function validUnicode(value) {
281
+ for (let index = 0;index < value.length; index += 1) {
282
+ const code = value.charCodeAt(index);
283
+ if (code >= 55296 && code <= 56319) {
284
+ const next = value.charCodeAt(index + 1);
285
+ if (next < 56320 || next > 57343)
286
+ return false;
287
+ index += 1;
288
+ } else if (code >= 56320 && code <= 57343)
289
+ return false;
290
+ }
291
+ return true;
292
+ }
293
+ function structurallyBounded(value) {
294
+ const pending = [[value, 0]];
295
+ const seen = new Set;
296
+ let nodes = 0;
297
+ let scalarBytes = 0;
298
+ while (pending.length > 0) {
299
+ const [candidate, depth] = pending.pop();
300
+ nodes += 1;
301
+ scalarBytes += 4;
302
+ if (nodes > MAX_STRUCTURAL_NODES || depth > MAX_STRUCTURAL_DEPTH || scalarBytes > MAX_CAPSULE_BYTES)
303
+ return false;
304
+ if (typeof candidate === "string") {
305
+ if (!validUnicode(candidate))
306
+ return false;
307
+ scalarBytes += Buffer.byteLength(candidate, "utf8");
308
+ if (scalarBytes > MAX_CAPSULE_BYTES)
309
+ return false;
310
+ }
311
+ if (typeof candidate !== "object" || candidate === null)
312
+ continue;
313
+ if (seen.has(candidate))
314
+ return false;
315
+ seen.add(candidate);
316
+ if (Array.isArray(candidate)) {
317
+ if (candidate.length > MAX_STRUCTURAL_NODES)
318
+ return false;
319
+ const keys = Reflect.ownKeys(candidate);
320
+ if (keys.some((key) => key !== "length" && (typeof key !== "string" || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= candidate.length)))
321
+ return false;
322
+ for (let index = 0;index < candidate.length; index += 1) {
323
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
324
+ if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor))
325
+ return false;
326
+ pending.push([descriptor.value, depth + 1]);
327
+ }
328
+ } else if (isRecord(candidate)) {
329
+ const keys = Reflect.ownKeys(candidate);
330
+ if (keys.length > MAX_STRUCTURAL_NODES || keys.some((key) => typeof key !== "string"))
331
+ return false;
332
+ for (const key of keys) {
333
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
334
+ if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor) || !validUnicode(key))
335
+ return false;
336
+ scalarBytes += Buffer.byteLength(key, "utf8");
337
+ if (scalarBytes > MAX_CAPSULE_BYTES)
338
+ return false;
339
+ pending.push([descriptor.value, depth + 1]);
340
+ }
341
+ } else
342
+ return false;
343
+ }
344
+ return true;
345
+ }
346
+ function immutableClone(value) {
347
+ if (Array.isArray(value)) {
348
+ return Object.freeze(value.map((item) => immutableClone(item)));
349
+ }
350
+ if (isRecord(value)) {
351
+ const clone = {};
352
+ for (const key of Object.keys(value))
353
+ clone[key] = immutableClone(value[key]);
354
+ return Object.freeze(clone);
355
+ }
356
+ return value;
357
+ }
358
+ function code(value, maximum = 256) {
359
+ return typeof value === "string" && value.length <= maximum && CODE_PATTERN.test(value) ? value : null;
360
+ }
361
+ function recordKey(value) {
362
+ return typeof value === "string" && value.length <= 512 && RECORD_KEY_PATTERN.test(value) ? value : null;
363
+ }
364
+ function orderedUnique(values) {
365
+ return values.every((value, index) => index === 0 || values[index - 1] < value);
366
+ }
367
+ function unsafeReviewCodePoint(codePoint) {
368
+ return codePoint <= 31 || codePoint >= 127 && codePoint <= 159 || codePoint === 1564 || codePoint === 8206 || codePoint === 8207 || codePoint >= 8232 && codePoint <= 8238 || codePoint >= 8294 && codePoint <= 8297 || codePoint === 65279;
369
+ }
370
+ function singleLine(value) {
371
+ 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)
372
+ return null;
373
+ return value;
374
+ }
375
+ function parseExpectedSource(value) {
376
+ if (!isRecord(value) || !exactKeys(value, ["authorityId", "binding", "head", "v"]) || value.v !== 1)
377
+ return null;
378
+ const authorityId = code(value.authorityId);
379
+ const binding = parseOhStoreBindingV1(value.binding);
380
+ const head = parseOhHeadV1(value.head);
381
+ return authorityId !== null && binding !== null && binding.profile.profileKind === "working" && head !== null ? immutableClone({ authorityId, binding, head, v: 1 }) : null;
382
+ }
383
+ function parseDestination(value) {
384
+ if (!isRecord(value) || !exactKeys(value, ["purpose", "targetPath", "v"]) || value.v !== 1)
385
+ return null;
386
+ const purpose = code(value.purpose);
387
+ 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("."))) {
388
+ return null;
389
+ }
390
+ return { purpose, targetPath: value.targetPath, v: 1 };
391
+ }
392
+ function parseRights(value, purpose) {
393
+ if (!isRecord(value) || !exactKeys(value, ["decisionId", "disposition", "purpose", "v"]) || value.v !== 1 || value.disposition !== "cleared-for-purpose" || value.purpose !== purpose)
394
+ return null;
395
+ const decisionId = code(value.decisionId);
396
+ return decisionId === null ? null : { decisionId, disposition: "cleared-for-purpose", purpose, v: 1 };
397
+ }
398
+ function parseReview(value) {
399
+ if (!isRecord(value) || !exactKeys(value, ["route", "status", "v"]) || value.v !== 1 || value.status !== "required")
400
+ return null;
401
+ const route = code(value.route);
402
+ return route === null ? null : { route, status: "required", v: 1 };
403
+ }
404
+ function parseConflicts(value) {
405
+ 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)
406
+ return null;
407
+ const notes = value.notes.map(singleLine);
408
+ if (notes.some((note) => note === null))
409
+ return null;
410
+ const sorted = [...notes].sort();
411
+ return orderedUnique(sorted) ? { notes: sorted, status: value.status, v: 1 } : null;
412
+ }
413
+ function parseHostPolicy(value) {
414
+ if (!structurallyBounded(value) || !isRecord(value) || !exactKeys(value, ["conflicts", "destination", "expectedSource", "review", "rights", "v"]) || value.v !== 1)
415
+ return null;
416
+ const destination = parseDestination(value.destination);
417
+ const expectedSource = parseExpectedSource(value.expectedSource);
418
+ const conflicts = parseConflicts(value.conflicts);
419
+ const review = parseReview(value.review);
420
+ const rights = destination === null ? null : parseRights(value.rights, destination.purpose);
421
+ return destination !== null && expectedSource !== null && conflicts !== null && review !== null && rights !== null ? immutableClone({ conflicts, destination, expectedSource, review, rights, v: 1 }) : null;
422
+ }
423
+ function verifyCapsule(value, expectedSource) {
424
+ try {
425
+ 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)
426
+ return null;
427
+ const verified = verifyOhDependencyClosureAgainstV1(value, {
428
+ binding: expectedSource.binding,
429
+ head: expectedSource.head
430
+ });
431
+ return verified.ok && verified.closure.binding.profile.profileKind === "working" ? verified.closure : null;
432
+ } catch {
433
+ return null;
434
+ }
435
+ }
436
+ function parseDisclosures(value, keys) {
437
+ if (!Array.isArray(value) || value.length > 256)
438
+ return null;
439
+ const parsed = [];
440
+ for (const item of value) {
441
+ if (!isRecord(item) || !exactKeys(item, ["id", "recordKey", "summary", "v"]) || item.v !== 1)
442
+ return null;
443
+ const id = code(item.id);
444
+ const key = recordKey(item.recordKey);
445
+ const summary = singleLine(item.summary);
446
+ if (id === null || key === null || summary === null || !keys.has(key))
447
+ return null;
448
+ parsed.push({ id, recordKey: key, summary, v: 1 });
449
+ }
450
+ parsed.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
451
+ return orderedUnique(parsed.map((item) => item.id)) ? parsed : null;
452
+ }
453
+ function markdownEscape(value) {
454
+ return value.replace(/[\\`*_{}\[\]<>()#+.!|>-]/gu, "\\$&");
455
+ }
456
+ function renderMarkdown(manifest, candidateSha256) {
457
+ const lines = [
458
+ "# Oh adoption candidate",
459
+ "",
460
+ `- Status: \`${manifest.status}\``,
461
+ `- Candidate: \`sha256:${candidateSha256}\``,
462
+ `- Destination: \`${manifest.destination.targetPath}\``,
463
+ `- Purpose: \`${manifest.destination.purpose}\``,
464
+ `- Source authority: \`${manifest.source.authorityId}\``,
465
+ `- Source binding: \`${manifest.source.binding.bindingSha256}\``,
466
+ `- Source head sequence: \`${manifest.source.head.sequence}\``,
467
+ `- Source head operation: \`${manifest.source.head.operationSha256 ?? "empty"}\``,
468
+ `- Source graph revision: \`${manifest.source.head.graphRevisionSha256 ?? "empty"}\``,
469
+ `- Source records digest: \`${manifest.source.head.recordsSha256}\``,
470
+ `- Closure: \`${manifest.source.closureSha256}\``,
471
+ "",
472
+ "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.",
473
+ "",
474
+ "## Required decisions",
475
+ "",
476
+ `- Rights: \`${manifest.rights.disposition}\` via \`${manifest.rights.decisionId}\` for \`${manifest.rights.purpose}\``,
477
+ `- Review: \`${manifest.review.status}\` via \`${manifest.review.route}\``,
478
+ `- Conflicts: \`${manifest.conflicts.status}\``,
479
+ ...manifest.conflicts.notes.map((note) => ` - ${markdownEscape(note)}`),
480
+ "",
481
+ "## Selected roots",
482
+ "",
483
+ ...manifest.source.roots.map((root) => `- \`${root}\``),
484
+ "",
485
+ "## Exact source records",
486
+ ""
487
+ ];
488
+ for (const record of manifest.source.records) {
489
+ lines.push(`### \`${record.key}\``, "", `- Kind: \`${record.kind}\``, `- Digest: \`${record.recordSha256}\``, `- Dependencies: ${record.dependencies.length === 0 ? "none" : record.dependencies.map((key) => `\`${key}\``).join(", ")}`, "");
490
+ }
491
+ 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)}`), "");
492
+ return `${lines.join(`
493
+ `)}
494
+ `;
495
+ }
496
+ function prepareWithPolicy(value, policy) {
497
+ if (!structurallyBounded(value) || !isRecord(value) || !exactKeys(value, ["capsule", "redactions", "transformations", "v"]) || value.v !== 1) {
498
+ throw new TypeError("Invalid Oh adoption preparation input.");
499
+ }
500
+ const capsule = verifyCapsule(value.capsule, policy.expectedSource);
501
+ if (capsule === null)
502
+ throw new TypeError("The source capsule is invalid for the bound authority and head.");
503
+ const recordKeys = new Set(capsule.records.map((record) => record.key));
504
+ const transformations = parseDisclosures(value.transformations, recordKeys);
505
+ const redactions = parseDisclosures(value.redactions, recordKeys);
506
+ const roots = new Set(capsule.roots);
507
+ if (transformations === null || redactions === null || capsule.records.filter((record) => roots.has(record.key)).every((record) => record.kind === "view")) {
508
+ throw new TypeError("Adoption requires valid disclosures and an authoritative root.");
509
+ }
510
+ const source = {
511
+ authorityId: policy.expectedSource.authorityId,
512
+ binding: { bindingSha256: capsule.binding.bindingSha256, v: 1 },
513
+ closureSha256: capsule.closureSha256,
514
+ head: capsule.head,
515
+ records: capsule.records.map((record) => ({
516
+ dependencies: record.dependencies,
517
+ key: record.key,
518
+ kind: record.kind,
519
+ recordSha256: record.recordSha256,
520
+ v: 1
521
+ })),
522
+ roots: capsule.roots,
523
+ v: 1
524
+ };
525
+ const manifest = {
526
+ conflicts: policy.conflicts,
527
+ destination: policy.destination,
528
+ format: "hraness.kb.oh-adoption-candidate.v1",
529
+ redactions,
530
+ review: policy.review,
531
+ rights: policy.rights,
532
+ source,
533
+ status: "prepared",
534
+ transformations,
535
+ v: 1
536
+ };
537
+ const candidateSha256 = canonicalSha256(manifest);
538
+ const markdown = renderMarkdown(manifest, candidateSha256);
539
+ if (Buffer.byteLength(markdown, "utf8") > MAX_CAPSULE_BYTES) {
540
+ throw new RangeError("The adoption candidate exceeds its Markdown byte limit.");
541
+ }
542
+ return immutableClone({
543
+ artifactSha256: createHash("sha256").update(markdown).digest("hex"),
544
+ candidateSha256,
545
+ manifest,
546
+ markdown,
547
+ v: 1
548
+ });
549
+ }
550
+ function createOhAdoptionPreparerV1(value) {
551
+ const policy = parseHostPolicy(value);
552
+ if (policy === null)
553
+ throw new TypeError("Invalid Oh adoption host policy.");
554
+ return Object.freeze({ prepare: (input) => prepareWithPolicy(input, policy) });
555
+ }
248
556
  export {
249
557
  workflowFromUnknown,
250
558
  wikiLinks,
@@ -330,6 +638,7 @@ export {
330
638
  createVerifiedEmbeddingModelLease,
331
639
  createSyntheticRankFusionFixture,
332
640
  createRepresentativeRetrievalFixture,
641
+ createOhAdoptionPreparerV1,
333
642
  createNote,
334
643
  createConceptNote,
335
644
  compareAgentGuideAudits,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hraness/kb",
3
- "version": "0.17.3",
3
+ "version": "0.18.1",
4
4
  "description": "A knowledge base for coding agents, built from Markdown, backlinks, semantic search, and Git context.",
5
5
  "license": "MIT",
6
6
  "contentPolicy": {
@@ -335,6 +335,7 @@
335
335
  "src/init.ts",
336
336
  "src/navigation.ts",
337
337
  "src/note-lock.ts",
338
+ "src/oh-adoption.ts",
338
339
  "src/percolate.ts",
339
340
  "src/portfolio.ts",
340
341
  "src/portfolio-audit.ts",
@@ -391,6 +392,7 @@
391
392
  "prepack": "bun run check"
392
393
  },
393
394
  "dependencies": {
395
+ "@hraness/oh": "github:hraness/oh#v0.2.0",
394
396
  "@steipete/sweet-cookie": "github:hraness/sweet-cookie#v0.4.2",
395
397
  "@tobilu/qmd": "git+https://github.com/hraness/qmd.git#aa993dceb3ef8cfb71d470554ca437570f5a2b3c",
396
398
  "agent-browser": "0.32.3",
@@ -3,12 +3,13 @@ name: kb
3
3
  description: >-
4
4
  Operate a hraness/kb local-first Markdown knowledge base for coding-agent
5
5
  memory. Use when a user asks to search or query a KB or Obsidian vault; load
6
- repository context, plans, decisions, concepts, backlinks, semantic search,
7
- or Git provenance; save, clip, scrape, or archive a URL, article, social
8
- thread, signed-in browser page, or PDF as auditable Markdown; create or
9
- update a durable plan in the vault; or refresh, check, percolate, and
10
- maintain its knowledge graph. Do not use for generic web research, generic
11
- PDF reading, or ordinary planning that will not use a hraness/kb vault.
6
+ or recover repository context, plans, decisions, concepts, backlinks,
7
+ semantic search, or Git provenance from an earlier coding session; save,
8
+ clip, scrape, or archive a URL, article, social thread, signed-in browser
9
+ page, or PDF as auditable Markdown; create or update a durable plan in the
10
+ vault; or refresh, check, percolate, and maintain its knowledge graph. Do not
11
+ use for generic web research, generic PDF reading, or ordinary planning that
12
+ will not use a hraness/kb vault.
12
13
  ---
13
14
 
14
15
  # Work with KB
@@ -31,7 +32,7 @@ missing:
31
32
  ```sh
32
33
  command -v kb >/dev/null 2>&1 || {
33
34
  command -v bun >/dev/null 2>&1 || exit 1
34
- bun add --global @hraness/kb@0.17.3
35
+ bun add --global @hraness/kb@0.18.1
35
36
  }
36
37
  kb --help
37
38
  ```
@@ -50,7 +51,7 @@ Initialize or mutate a vault only when the user's request requires that change.
50
51
 
51
52
  | User intent | Read |
52
53
  | --- | --- |
53
- | Find notes, search one vault or an authorized portfolio, load repository-path context, inspect plans or decisions, follow backlinks or relationships, audit vault organization, or retrieve Git provenance | [Query the knowledge base](references/query.md) |
54
+ | Recover work from an earlier session, find notes, search one vault or an authorized portfolio, load repository-path context, inspect plans or decisions, follow backlinks or relationships, audit vault organization, or retrieve Git provenance | [Query the knowledge base](references/query.md) |
54
55
  | Save, clip, scrape, or archive a URL, article, social post or thread, GitHub or Discourse discussion, signed-in page, feed, inbox, private document, WhatsApp conversation, or YouTube page | [Capture web content](references/save-url.md); add [browser authentication](references/url-authentication.md) for signed-in sources and [platform routing](references/url-platforms.md) when route choice or completeness matters |
55
56
  | Import, extract, archive, OCR, or convert a local or public PDF into Markdown | [Save a PDF](references/save-pdf.md); add [PDF image review](references/pdf-review.md) for scans, screenshots, conversations, charts, or mixed media |
56
57
  | Create or update an implementation plan, proposal, RFC, migration plan, execution audit, or phased checklist in the vault | [Write a durable plan](references/plan.md) and [use its structure](references/plan-structure.md) |
@@ -14,6 +14,27 @@ authority; search scores, metadata rows, and graph results are derived views.
14
14
  - Pass the resolved path to every `--root`; do not scan a repository root merely
15
15
  because that is where the agent session started.
16
16
 
17
+ ## Recover a stopped session
18
+
19
+ When the user asks to resume earlier work, begin with the path being changed.
20
+ Keep each retrieval signal separate so the agent can inspect why a record was
21
+ returned:
22
+
23
+ ```sh
24
+ kb context packages/parser/src/index.ts --root "$KB_ROOT" --repo "$KB_REPO"
25
+ kb search "why parser retries stop" --root "$KB_ROOT" --mode exact \
26
+ --history --repo "$KB_REPO" --json
27
+ kb backlinks notes/parser-contract --root "$KB_ROOT" --json
28
+ kb history notes/parser-contract --root "$KB_ROOT" \
29
+ --repo "$KB_REPO" --json
30
+ ```
31
+
32
+ Replace the example path, query, and note ID with values from the current task.
33
+ Read the inherited guides and authoritative Markdown returned by these views.
34
+ Use the backlink to inspect related plans and use Git history as provenance,
35
+ not as proof that the note remains correct. This workflow recovers only context
36
+ that was persisted in files or Git; it does not reconstruct private chat.
37
+
17
38
  ## Choose the retrieval lane
18
39
 
19
40
  - Repository file or directory: run `kb context` first. Read its inherited
package/src/index.ts CHANGED
@@ -9,6 +9,7 @@ export * from "./git.js";
9
9
  export * from "./graph.js";
10
10
  export * from "./init.js";
11
11
  export * from "./navigation.js";
12
+ export * from "./oh-adoption.js";
12
13
  export * from "./percolate.js";
13
14
  export * from "./query.js";
14
15
  export * from "./repository-memory.js";
@@ -0,0 +1,444 @@
1
+ import { createHash } from "node:crypto";
2
+ import { posix } from "node:path";
3
+
4
+ import { canonicalJson, canonicalSha256 } from "@hraness/oh";
5
+ import {
6
+ parseOhHeadV1,
7
+ parseOhStoreBindingV1,
8
+ verifyOhDependencyClosureAgainstV1,
9
+ type OhDependencyClosureV1,
10
+ type OhHeadV1,
11
+ type OhStoreBindingV1,
12
+ } from "@hraness/oh/store";
13
+
14
+ const CODE_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u;
15
+ const RECORD_KEY_PATTERN = /^[a-z][a-z0-9]*(?:[._:/-][a-z0-9]+)*$/u;
16
+ const MAX_CAPSULE_BYTES = 16 * 1024 * 1024;
17
+ const MAX_RECORDS = 1_024;
18
+ const MAX_ROOTS = 256;
19
+ const MAX_TEXT_BYTES = 4_096;
20
+ const MAX_STRUCTURAL_NODES = 262_144;
21
+ const MAX_STRUCTURAL_DEPTH = 128;
22
+
23
+ export interface OhAdoptionExpectedSourceV1 {
24
+ readonly authorityId: string;
25
+ readonly binding: OhStoreBindingV1;
26
+ readonly head: OhHeadV1;
27
+ readonly v: 1;
28
+ }
29
+
30
+ export interface OhAdoptionDestinationV1 {
31
+ readonly purpose: string;
32
+ readonly targetPath: string;
33
+ readonly v: 1;
34
+ }
35
+
36
+ export interface OhAdoptionRightsClearanceV1 {
37
+ readonly decisionId: string;
38
+ readonly disposition: "cleared-for-purpose";
39
+ readonly purpose: string;
40
+ readonly v: 1;
41
+ }
42
+
43
+ export interface OhAdoptionReviewRequirementV1 {
44
+ readonly route: string;
45
+ readonly status: "required";
46
+ readonly v: 1;
47
+ }
48
+
49
+ export interface OhAdoptionConflictReviewV1 {
50
+ readonly notes: readonly string[];
51
+ readonly status: "none-observed" | "requires-resolution";
52
+ readonly v: 1;
53
+ }
54
+
55
+ export interface OhAdoptionChangeDisclosureV1 {
56
+ readonly id: string;
57
+ readonly recordKey: string;
58
+ readonly summary: string;
59
+ readonly v: 1;
60
+ }
61
+
62
+ export interface OhAdoptionHostPolicyV1 {
63
+ readonly conflicts: OhAdoptionConflictReviewV1;
64
+ readonly destination: OhAdoptionDestinationV1;
65
+ readonly expectedSource: OhAdoptionExpectedSourceV1;
66
+ readonly review: OhAdoptionReviewRequirementV1;
67
+ readonly rights: OhAdoptionRightsClearanceV1;
68
+ readonly v: 1;
69
+ }
70
+
71
+ export interface OhAdoptionPrepareInputV1 {
72
+ readonly capsule: OhDependencyClosureV1;
73
+ readonly redactions: readonly OhAdoptionChangeDisclosureV1[];
74
+ readonly transformations: readonly OhAdoptionChangeDisclosureV1[];
75
+ readonly v: 1;
76
+ }
77
+
78
+ export interface OhAdoptionCandidateV1 {
79
+ readonly artifactSha256: string;
80
+ readonly candidateSha256: string;
81
+ readonly manifest: Readonly<{
82
+ readonly conflicts: OhAdoptionConflictReviewV1;
83
+ readonly destination: OhAdoptionDestinationV1;
84
+ readonly format: "hraness.kb.oh-adoption-candidate.v1";
85
+ readonly redactions: readonly OhAdoptionChangeDisclosureV1[];
86
+ readonly review: OhAdoptionReviewRequirementV1;
87
+ readonly rights: OhAdoptionRightsClearanceV1;
88
+ readonly source: Readonly<{
89
+ readonly authorityId: string;
90
+ readonly binding: Readonly<{
91
+ readonly bindingSha256: string;
92
+ readonly v: 1;
93
+ }>;
94
+ readonly closureSha256: string;
95
+ readonly head: OhHeadV1;
96
+ readonly records: readonly Readonly<{
97
+ readonly dependencies: readonly string[];
98
+ readonly key: string;
99
+ readonly kind: string;
100
+ readonly recordSha256: string;
101
+ readonly v: 1;
102
+ }>[];
103
+ readonly roots: readonly string[];
104
+ readonly v: 1;
105
+ }>;
106
+ readonly status: "prepared";
107
+ readonly transformations: readonly OhAdoptionChangeDisclosureV1[];
108
+ readonly v: 1;
109
+ }>;
110
+ readonly markdown: string;
111
+ readonly v: 1;
112
+ }
113
+
114
+ export interface OhAdoptionPreparerV1 {
115
+ /** Accepts capsule bytes and disclosures only; all authority and policy are host-bound. */
116
+ prepare(value: unknown): OhAdoptionCandidateV1;
117
+ }
118
+
119
+ function isRecord(value: unknown): value is Record<string, unknown> {
120
+ if (typeof value !== "object" || value === null || Array.isArray(value)) return false;
121
+ const prototype = Object.getPrototypeOf(value);
122
+ return prototype === Object.prototype || prototype === null;
123
+ }
124
+
125
+ function exactKeys(value: Record<string, unknown>, keys: readonly string[]): boolean {
126
+ const actual = Reflect.ownKeys(value);
127
+ return actual.length === keys.length && actual.every((key) => {
128
+ if (typeof key !== "string" || !keys.includes(key)) return false;
129
+ const descriptor = Object.getOwnPropertyDescriptor(value, key);
130
+ return descriptor !== undefined && descriptor.enumerable && "value" in descriptor;
131
+ });
132
+ }
133
+
134
+ function validUnicode(value: string): boolean {
135
+ for (let index = 0; index < value.length; index += 1) {
136
+ const code = value.charCodeAt(index);
137
+ if (code >= 0xd800 && code <= 0xdbff) {
138
+ const next = value.charCodeAt(index + 1);
139
+ if (next < 0xdc00 || next > 0xdfff) return false;
140
+ index += 1;
141
+ } else if (code >= 0xdc00 && code <= 0xdfff) return false;
142
+ }
143
+ return true;
144
+ }
145
+
146
+ function structurallyBounded(value: unknown): boolean {
147
+ const pending: Array<readonly [unknown, number]> = [[value, 0]];
148
+ const seen = new Set<object>();
149
+ let nodes = 0;
150
+ let scalarBytes = 0;
151
+ while (pending.length > 0) {
152
+ const [candidate, depth] = pending.pop() as readonly [unknown, number];
153
+ nodes += 1;
154
+ scalarBytes += 4;
155
+ if (nodes > MAX_STRUCTURAL_NODES || depth > MAX_STRUCTURAL_DEPTH
156
+ || scalarBytes > MAX_CAPSULE_BYTES) return false;
157
+ if (typeof candidate === "string") {
158
+ if (!validUnicode(candidate)) return false;
159
+ scalarBytes += Buffer.byteLength(candidate, "utf8");
160
+ if (scalarBytes > MAX_CAPSULE_BYTES) return false;
161
+ }
162
+ if (typeof candidate !== "object" || candidate === null) continue;
163
+ if (seen.has(candidate)) return false;
164
+ seen.add(candidate);
165
+ if (Array.isArray(candidate)) {
166
+ if (candidate.length > MAX_STRUCTURAL_NODES) return false;
167
+ const keys = Reflect.ownKeys(candidate);
168
+ if (keys.some((key) => key !== "length" && (typeof key !== "string"
169
+ || !/^(?:0|[1-9][0-9]*)$/u.test(key) || Number(key) >= candidate.length))) return false;
170
+ for (let index = 0; index < candidate.length; index += 1) {
171
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, String(index));
172
+ if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)) return false;
173
+ pending.push([descriptor.value, depth + 1]);
174
+ }
175
+ } else if (isRecord(candidate)) {
176
+ const keys = Reflect.ownKeys(candidate);
177
+ if (keys.length > MAX_STRUCTURAL_NODES || keys.some((key) => typeof key !== "string")) return false;
178
+ for (const key of keys as string[]) {
179
+ const descriptor = Object.getOwnPropertyDescriptor(candidate, key);
180
+ if (descriptor === undefined || !descriptor.enumerable || !("value" in descriptor)
181
+ || !validUnicode(key)) return false;
182
+ scalarBytes += Buffer.byteLength(key, "utf8");
183
+ if (scalarBytes > MAX_CAPSULE_BYTES) return false;
184
+ pending.push([descriptor.value, depth + 1]);
185
+ }
186
+ } else return false;
187
+ }
188
+ return true;
189
+ }
190
+
191
+ function immutableClone<T>(value: T): T {
192
+ if (Array.isArray(value)) {
193
+ return Object.freeze(value.map((item) => immutableClone(item))) as T;
194
+ }
195
+ if (isRecord(value)) {
196
+ const clone: Record<string, unknown> = {};
197
+ for (const key of Object.keys(value)) clone[key] = immutableClone(value[key]);
198
+ return Object.freeze(clone) as T;
199
+ }
200
+ return value;
201
+ }
202
+
203
+ function code(value: unknown, maximum = 256): string | null {
204
+ return typeof value === "string" && value.length <= maximum && CODE_PATTERN.test(value) ? value : null;
205
+ }
206
+
207
+ function recordKey(value: unknown): string | null {
208
+ return typeof value === "string" && value.length <= 512 && RECORD_KEY_PATTERN.test(value) ? value : null;
209
+ }
210
+
211
+ function orderedUnique(values: readonly string[]): boolean {
212
+ return values.every((value, index) => index === 0 || (values[index - 1] as string) < value);
213
+ }
214
+
215
+ function unsafeReviewCodePoint(codePoint: number): boolean {
216
+ return codePoint <= 0x1f
217
+ || (codePoint >= 0x7f && codePoint <= 0x9f)
218
+ || codePoint === 0x061c
219
+ || codePoint === 0x200e
220
+ || codePoint === 0x200f
221
+ || (codePoint >= 0x2028 && codePoint <= 0x202e)
222
+ || (codePoint >= 0x2066 && codePoint <= 0x2069)
223
+ || codePoint === 0xfeff;
224
+ }
225
+
226
+ function singleLine(value: unknown): string | null {
227
+ if (typeof value !== "string" || value.length < 1 || value.normalize("NFC") !== value
228
+ || !validUnicode(value) || [...value].some((character) =>
229
+ unsafeReviewCodePoint(character.codePointAt(0) ?? 0))
230
+ || Buffer.byteLength(value, "utf8") > MAX_TEXT_BYTES) return null;
231
+ return value;
232
+ }
233
+
234
+ function parseExpectedSource(value: unknown): OhAdoptionExpectedSourceV1 | null {
235
+ if (!isRecord(value) || !exactKeys(value, ["authorityId", "binding", "head", "v"])
236
+ || value.v !== 1) return null;
237
+ const authorityId = code(value.authorityId);
238
+ const binding = parseOhStoreBindingV1(value.binding);
239
+ const head = parseOhHeadV1(value.head);
240
+ return authorityId !== null && binding !== null && binding.profile.profileKind === "working" && head !== null
241
+ ? immutableClone({ authorityId, binding, head, v: 1 }) : null;
242
+ }
243
+
244
+ function parseDestination(value: unknown): OhAdoptionDestinationV1 | null {
245
+ if (!isRecord(value) || !exactKeys(value, ["purpose", "targetPath", "v"]) || value.v !== 1) return null;
246
+ const purpose = code(value.purpose);
247
+ if (purpose === null || typeof value.targetPath !== "string" || value.targetPath.length > 512
248
+ || value.targetPath.includes("\\") || value.targetPath.startsWith("/")
249
+ || posix.normalize(value.targetPath) !== value.targetPath
250
+ || !/^notes\/[a-z0-9][a-z0-9._/-]*\.md$/u.test(value.targetPath)
251
+ || value.targetPath.split("/").some((segment) => segment === "." || segment === ".." || segment.startsWith("."))) {
252
+ return null;
253
+ }
254
+ return { purpose, targetPath: value.targetPath, v: 1 };
255
+ }
256
+
257
+ function parseRights(value: unknown, purpose: string): OhAdoptionRightsClearanceV1 | null {
258
+ if (!isRecord(value) || !exactKeys(value, ["decisionId", "disposition", "purpose", "v"])
259
+ || value.v !== 1 || value.disposition !== "cleared-for-purpose" || value.purpose !== purpose) return null;
260
+ const decisionId = code(value.decisionId);
261
+ return decisionId === null ? null
262
+ : { decisionId, disposition: "cleared-for-purpose", purpose, v: 1 };
263
+ }
264
+
265
+ function parseReview(value: unknown): OhAdoptionReviewRequirementV1 | null {
266
+ if (!isRecord(value) || !exactKeys(value, ["route", "status", "v"])
267
+ || value.v !== 1 || value.status !== "required") return null;
268
+ const route = code(value.route);
269
+ return route === null ? null : { route, status: "required", v: 1 };
270
+ }
271
+
272
+ function parseConflicts(value: unknown): OhAdoptionConflictReviewV1 | null {
273
+ if (!isRecord(value) || !exactKeys(value, ["notes", "status", "v"]) || value.v !== 1
274
+ || (value.status !== "none-observed" && value.status !== "requires-resolution")
275
+ || !Array.isArray(value.notes) || value.notes.length < 1 || value.notes.length > 64) return null;
276
+ const notes = value.notes.map(singleLine);
277
+ if (notes.some((note) => note === null)) return null;
278
+ const sorted = [...notes as string[]].sort();
279
+ return orderedUnique(sorted) ? { notes: sorted, status: value.status, v: 1 } : null;
280
+ }
281
+
282
+ function parseHostPolicy(value: unknown): OhAdoptionHostPolicyV1 | null {
283
+ if (!structurallyBounded(value) || !isRecord(value)
284
+ || !exactKeys(value, ["conflicts", "destination", "expectedSource", "review", "rights", "v"])
285
+ || value.v !== 1) return null;
286
+ const destination = parseDestination(value.destination);
287
+ const expectedSource = parseExpectedSource(value.expectedSource);
288
+ const conflicts = parseConflicts(value.conflicts);
289
+ const review = parseReview(value.review);
290
+ const rights = destination === null ? null : parseRights(value.rights, destination.purpose);
291
+ return destination !== null && expectedSource !== null && conflicts !== null && review !== null && rights !== null
292
+ ? immutableClone({ conflicts, destination, expectedSource, review, rights, v: 1 }) : null;
293
+ }
294
+
295
+ function verifyCapsule(
296
+ value: unknown,
297
+ expectedSource: OhAdoptionExpectedSourceV1,
298
+ ): OhDependencyClosureV1 | null {
299
+ try {
300
+ if (!structurallyBounded(value) || !isRecord(value)
301
+ || !exactKeys(value, ["binding", "closureSha256", "head", "records", "roots", "v"])
302
+ || value.v !== 1 || !Array.isArray(value.records) || !Array.isArray(value.roots)
303
+ || value.records.length < 1 || value.records.length > MAX_RECORDS
304
+ || value.roots.length < 1 || value.roots.length > MAX_ROOTS
305
+ || Buffer.byteLength(canonicalJson(value), "utf8") > MAX_CAPSULE_BYTES) return null;
306
+ const verified = verifyOhDependencyClosureAgainstV1(value, {
307
+ binding: expectedSource.binding,
308
+ head: expectedSource.head,
309
+ });
310
+ return verified.ok && verified.closure.binding.profile.profileKind === "working"
311
+ ? verified.closure : null;
312
+ } catch {
313
+ return null;
314
+ }
315
+ }
316
+
317
+ function parseDisclosures(value: unknown, keys: ReadonlySet<string>): readonly OhAdoptionChangeDisclosureV1[] | null {
318
+ if (!Array.isArray(value) || value.length > 256) return null;
319
+ const parsed: OhAdoptionChangeDisclosureV1[] = [];
320
+ for (const item of value) {
321
+ if (!isRecord(item) || !exactKeys(item, ["id", "recordKey", "summary", "v"]) || item.v !== 1) return null;
322
+ const id = code(item.id);
323
+ const key = recordKey(item.recordKey);
324
+ const summary = singleLine(item.summary);
325
+ if (id === null || key === null || summary === null || !keys.has(key)) return null;
326
+ parsed.push({ id, recordKey: key, summary, v: 1 });
327
+ }
328
+ parsed.sort((left, right) => left.id < right.id ? -1 : left.id > right.id ? 1 : 0);
329
+ return orderedUnique(parsed.map((item) => item.id)) ? parsed : null;
330
+ }
331
+
332
+ function markdownEscape(value: string): string {
333
+ return value.replace(/[\\`*_{}\[\]<>()#+.!|>-]/gu, "\\$&");
334
+ }
335
+
336
+ function renderMarkdown(manifest: OhAdoptionCandidateV1["manifest"], candidateSha256: string): string {
337
+ const lines = [
338
+ "# Oh adoption candidate",
339
+ "",
340
+ `- Status: \`${manifest.status}\``,
341
+ `- Candidate: \`sha256:${candidateSha256}\``,
342
+ `- Destination: \`${manifest.destination.targetPath}\``,
343
+ `- Purpose: \`${manifest.destination.purpose}\``,
344
+ `- Source authority: \`${manifest.source.authorityId}\``,
345
+ `- Source binding: \`${manifest.source.binding.bindingSha256}\``,
346
+ `- Source head sequence: \`${manifest.source.head.sequence}\``,
347
+ `- Source head operation: \`${manifest.source.head.operationSha256 ?? "empty"}\``,
348
+ `- Source graph revision: \`${manifest.source.head.graphRevisionSha256 ?? "empty"}\``,
349
+ `- Source records digest: \`${manifest.source.head.recordsSha256}\``,
350
+ `- Closure: \`${manifest.source.closureSha256}\``,
351
+ "",
352
+ "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.",
353
+ "",
354
+ "## Required decisions",
355
+ "",
356
+ `- Rights: \`${manifest.rights.disposition}\` via \`${manifest.rights.decisionId}\` for \`${manifest.rights.purpose}\``,
357
+ `- Review: \`${manifest.review.status}\` via \`${manifest.review.route}\``,
358
+ `- Conflicts: \`${manifest.conflicts.status}\``,
359
+ ...manifest.conflicts.notes.map((note) => ` - ${markdownEscape(note)}`),
360
+ "",
361
+ "## Selected roots",
362
+ "",
363
+ ...manifest.source.roots.map((root) => `- \`${root}\``),
364
+ "",
365
+ "## Exact source records",
366
+ "",
367
+ ];
368
+ for (const record of manifest.source.records) {
369
+ lines.push(`### \`${record.key}\``, "", `- Kind: \`${record.kind}\``,
370
+ `- Digest: \`${record.recordSha256}\``,
371
+ `- Dependencies: ${record.dependencies.length === 0 ? "none" : record.dependencies.map((key) => `\`${key}\``).join(", ")}`, "");
372
+ }
373
+ lines.push("## Transformations", "",
374
+ ...(manifest.transformations.length === 0 ? ["- None declared."] : manifest.transformations.map((item) =>
375
+ `- \`${item.id}\` on \`${item.recordKey}\`: ${markdownEscape(item.summary)}`)), "",
376
+ "## Redactions", "",
377
+ ...(manifest.redactions.length === 0 ? ["- None declared."] : manifest.redactions.map((item) =>
378
+ `- \`${item.id}\` on \`${item.recordKey}\`: ${markdownEscape(item.summary)}`)), "");
379
+ return `${lines.join("\n")}\n`;
380
+ }
381
+
382
+ function prepareWithPolicy(
383
+ value: unknown,
384
+ policy: OhAdoptionHostPolicyV1,
385
+ ): OhAdoptionCandidateV1 {
386
+ if (!structurallyBounded(value) || !isRecord(value)
387
+ || !exactKeys(value, ["capsule", "redactions", "transformations", "v"]) || value.v !== 1) {
388
+ throw new TypeError("Invalid Oh adoption preparation input.");
389
+ }
390
+ const capsule = verifyCapsule(value.capsule, policy.expectedSource);
391
+ if (capsule === null) throw new TypeError("The source capsule is invalid for the bound authority and head.");
392
+ const recordKeys = new Set(capsule.records.map((record) => record.key));
393
+ const transformations = parseDisclosures(value.transformations, recordKeys);
394
+ const redactions = parseDisclosures(value.redactions, recordKeys);
395
+ const roots = new Set(capsule.roots);
396
+ if (transformations === null || redactions === null
397
+ || capsule.records.filter((record) => roots.has(record.key)).every((record) => record.kind === "view")) {
398
+ throw new TypeError("Adoption requires valid disclosures and an authoritative root.");
399
+ }
400
+ const source = {
401
+ authorityId: policy.expectedSource.authorityId,
402
+ binding: { bindingSha256: capsule.binding.bindingSha256, v: 1 as const },
403
+ closureSha256: capsule.closureSha256,
404
+ head: capsule.head,
405
+ records: capsule.records.map((record) => ({ dependencies: record.dependencies, key: record.key,
406
+ kind: record.kind, recordSha256: record.recordSha256, v: 1 as const })),
407
+ roots: capsule.roots,
408
+ v: 1 as const,
409
+ };
410
+ const manifest = {
411
+ conflicts: policy.conflicts,
412
+ destination: policy.destination,
413
+ format: "hraness.kb.oh-adoption-candidate.v1" as const,
414
+ redactions,
415
+ review: policy.review,
416
+ rights: policy.rights,
417
+ source,
418
+ status: "prepared" as const,
419
+ transformations,
420
+ v: 1 as const,
421
+ };
422
+ const candidateSha256 = canonicalSha256(manifest);
423
+ const markdown = renderMarkdown(manifest, candidateSha256);
424
+ if (Buffer.byteLength(markdown, "utf8") > MAX_CAPSULE_BYTES) {
425
+ throw new RangeError("The adoption candidate exceeds its Markdown byte limit.");
426
+ }
427
+ return immutableClone({
428
+ artifactSha256: createHash("sha256").update(markdown).digest("hex"),
429
+ candidateSha256,
430
+ manifest,
431
+ markdown,
432
+ v: 1,
433
+ });
434
+ }
435
+
436
+ /**
437
+ * Captures all authority, destination, rights, review, and conflict policy in
438
+ * trusted host code. The returned facade can only prepare inert review bytes.
439
+ */
440
+ export function createOhAdoptionPreparerV1(value: unknown): OhAdoptionPreparerV1 {
441
+ const policy = parseHostPolicy(value);
442
+ if (policy === null) throw new TypeError("Invalid Oh adoption host policy.");
443
+ return Object.freeze({ prepare: (input: unknown) => prepareWithPolicy(input, policy) });
444
+ }