@cairnkeep/cli 2.13.1 → 2.15.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +45 -1
  2. package/README.md +29 -6
  3. package/claude/commands/cairn-work.md +19 -0
  4. package/docs/context-packs.md +13 -3
  5. package/docs/design/team-mode.md +145 -0
  6. package/docs/harness-compatibility.md +12 -11
  7. package/docs/learning/CURRICULUM-MAP.md +8 -4
  8. package/docs/learning/FEATURE-GUIDE.md +13 -5
  9. package/docs/learning/README.md +3 -1
  10. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  11. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  12. package/docs/learning/lessons/L02-installation.md +2 -2
  13. package/docs/learning/lessons/L03-first-project.md +1 -1
  14. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  15. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  16. package/docs/learning/lessons/L21-context-packs.md +5 -1
  17. package/docs/learning/lessons/L22-native-windows.md +2 -2
  18. package/docs/learning/lessons/L23-guided-setup.md +2 -2
  19. package/docs/learning/lessons/L24-okf-exchange.md +111 -0
  20. package/docs/learning/lessons/L25-playbooks.md +114 -0
  21. package/docs/learning/tracks/operator.md +8 -2
  22. package/docs/learning/tracks/practitioner.md +5 -3
  23. package/docs/learning/video-scripts/V24-okf-exchange.md +31 -0
  24. package/docs/learning/video-scripts/V25-playbooks.md +38 -0
  25. package/docs/native-windows.md +12 -0
  26. package/docs/open-knowledge-format.md +100 -0
  27. package/docs/operating.md +68 -0
  28. package/docs/privacy-and-data-flow.md +25 -0
  29. package/docs/quickstart.md +17 -1
  30. package/docs/storage.md +28 -0
  31. package/examples/eval/bundled-fake.json +1 -1
  32. package/kimi/skills/cairn-work/SKILL.md +13 -0
  33. package/mcp-memory-server/dist/context-pack-cli.js +54 -1
  34. package/mcp-memory-server/dist/context-pack.js +243 -47
  35. package/mcp-memory-server/dist/index.js +13 -1
  36. package/mcp-memory-server/dist/mcp-tool-catalog.js +1 -0
  37. package/mcp-memory-server/dist/note-store.js +44 -2
  38. package/mcp-memory-server/dist/okf.js +432 -0
  39. package/mcp-memory-server/dist/playbook-cli.js +321 -0
  40. package/mcp-memory-server/dist/playbook-receipt.js +245 -0
  41. package/mcp-memory-server/dist/playbook-schema.js +56 -0
  42. package/mcp-memory-server/dist/playbook.js +383 -0
  43. package/mcp-memory-server/package.json +4 -1
  44. package/opencode/command/cairn-work.md +13 -0
  45. package/package.json +2 -1
  46. package/pi/prompts/cairn-work.md +12 -0
  47. package/schemas/context-pack.schema.json +9 -0
  48. package/schemas/playbook.schema.json +29 -0
  49. package/scripts/bootstrap.sh +17 -0
  50. package/scripts/cairn-cli.mjs +2 -0
  51. package/scripts/completion.sh +26 -6
  52. package/scripts/doctor.sh +14 -1
  53. package/scripts/playbook-instructions.mjs +108 -0
  54. package/scripts/setup-core.mjs +1 -0
  55. package/scripts/setup.mjs +8 -0
  56. package/scripts/sync-kimi-assets.sh +3 -3
  57. package/scripts/sync-opencode-memory-assets.sh +2 -1
  58. package/scripts/sync-pi-assets.sh +2 -1
  59. package/scripts/test-cli-dispatch.sh +2 -0
  60. package/scripts/test-completion.sh +11 -2
  61. package/scripts/test-graph-harness-adapters.sh +9 -3
  62. package/scripts/test-harness-registry.sh +11 -0
  63. package/scripts/test-learning-docs.sh +23 -2
  64. package/scripts/test-package-install.sh +26 -0
  65. package/scripts/test-playbook-instructions.sh +60 -0
  66. package/scripts/test-windows-native.mjs +7 -0
  67. package/scripts/uninstall.sh +16 -1
  68. package/scripts/windows-platform.mjs +24 -3
  69. package/templates/playbook-agent-instructions.md.template +14 -0
  70. package/templates/playbooks.json.template +5 -0
@@ -0,0 +1,432 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { existsSync, lstatSync } from "node:fs";
3
+ import { mkdir, mkdtemp, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, extname, isAbsolute, join, posix, relative, resolve, sep } from "node:path";
5
+ import { parseDocument, stringify } from "yaml";
6
+ import { z } from "zod";
7
+ import { hardenPrivatePath } from "./platform-security.js";
8
+ import { readSharedNoteForExport } from "./note-store.js";
9
+ import { redactLocalValue } from "./trajectory-redaction.js";
10
+ const MAX_FILE_BYTES = 1024 * 1024;
11
+ const MAX_ENTRIES = 1024;
12
+ const MAX_TOTAL_BYTES = 64 * 1024 * 1024;
13
+ const MAX_FRONTMATTER_BYTES = 64 * 1024;
14
+ const DIGEST = /^[a-f0-9]{64}$/;
15
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
16
+ const RESERVED = new Set(["index.md", "log.md"]);
17
+ const sourceSchema = z.object({
18
+ resource: z.string().min(1).max(4096),
19
+ id: z.string().min(1).max(256).optional(),
20
+ title: z.string().min(1).max(1024).optional(),
21
+ author: z.string().min(1).max(256).optional(),
22
+ usage_count: z.number().nonnegative().finite().optional(),
23
+ last_modified: z.string().regex(DATE).optional(),
24
+ usage_window: z.object({ from: z.string().regex(DATE), to: z.string().regex(DATE) }).strict().optional(),
25
+ }).passthrough();
26
+ const actorEventSchema = z.object({
27
+ by: z.string().min(1).max(256),
28
+ at: z.string().min(1).max(128).optional(),
29
+ }).strict();
30
+ const frontmatterSchema = z.object({
31
+ type: z.string().min(1).max(256),
32
+ title: z.string().min(1).max(1024).optional(),
33
+ description: z.string().min(1).max(4096).optional(),
34
+ resource: z.string().min(1).max(4096).optional(),
35
+ tags: z.array(z.string().min(1).max(128)).max(64).optional(),
36
+ sources: z.array(sourceSchema).max(64).optional(),
37
+ usage_window: z.object({ from: z.string().regex(DATE), to: z.string().regex(DATE) }).strict().optional(),
38
+ generated: actorEventSchema.optional(),
39
+ verified: z.union([actorEventSchema, z.array(actorEventSchema).max(64)]).optional(),
40
+ status: z.enum(["draft", "stable", "deprecated"]).optional(),
41
+ stale_after: z.string().regex(DATE).optional(),
42
+ timestamp: z.string().min(1).max(128).optional(),
43
+ }).passthrough();
44
+ function sha256(value) {
45
+ return createHash("sha256").update(value).digest("hex");
46
+ }
47
+ function canonical(value) {
48
+ if (Array.isArray(value))
49
+ return `[${value.map(canonical).join(",")}]`;
50
+ if (value && typeof value === "object") {
51
+ return `{${Object.entries(value)
52
+ .sort(([a], [b]) => a.localeCompare(b, "en"))
53
+ .map(([key, child]) => `${JSON.stringify(key)}:${canonical(child)}`).join(",")}}`;
54
+ }
55
+ return JSON.stringify(value);
56
+ }
57
+ function normalizedPath(value) {
58
+ if (!value || value.includes("\\") || isAbsolute(value)
59
+ || value.split("/").some((part) => part === "" || part === "." || part === "..")) {
60
+ throw new Error(`Unsafe OKF path: ${value}`);
61
+ }
62
+ return value;
63
+ }
64
+ function contained(root, candidate) {
65
+ const rel = relative(root, candidate);
66
+ return rel !== ".." && !rel.startsWith(`..${sep}`) && !isAbsolute(rel);
67
+ }
68
+ function utf8(bytes, path) {
69
+ let text;
70
+ try {
71
+ text = new TextDecoder("utf-8", { fatal: true }).decode(bytes);
72
+ }
73
+ catch {
74
+ throw new Error(`OKF file is not UTF-8: ${path}`);
75
+ }
76
+ if (Buffer.from(text, "utf8").compare(bytes) !== 0)
77
+ throw new Error(`OKF file is not canonical UTF-8: ${path}`);
78
+ return text;
79
+ }
80
+ async function walk(root, directory = root) {
81
+ const info = lstatSync(directory);
82
+ if (!info.isDirectory() || info.isSymbolicLink())
83
+ throw new Error("OKF bundles may not contain symlink directories.");
84
+ const files = [];
85
+ for (const entry of (await readdir(directory, { withFileTypes: true })).sort((a, b) => a.name.localeCompare(b.name, "en"))) {
86
+ if (directory === root && entry.name === ".git") {
87
+ if (entry.isSymbolicLink())
88
+ throw new Error("OKF repository metadata may not be a symlink.");
89
+ if (entry.isDirectory() || entry.isFile())
90
+ continue;
91
+ }
92
+ const absolute = join(directory, entry.name);
93
+ const path = relative(root, absolute).split(sep).join("/");
94
+ if (entry.isSymbolicLink())
95
+ throw new Error(`OKF bundles may not contain symlinks: ${path}`);
96
+ if (entry.isDirectory())
97
+ files.push(...await walk(root, absolute));
98
+ else if (entry.isFile())
99
+ files.push(normalizedPath(path));
100
+ else
101
+ throw new Error(`OKF bundles may contain regular files only: ${path}`);
102
+ if (files.length > MAX_ENTRIES)
103
+ throw new Error("OKF bundle exceeds the entry limit.");
104
+ }
105
+ return files;
106
+ }
107
+ function parseFrontmatter(text, path, required) {
108
+ const match = /^---[ \t]*\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/.exec(text);
109
+ if (!match) {
110
+ if (required)
111
+ throw new Error(`OKF concept requires YAML frontmatter with a type: ${path}`);
112
+ return null;
113
+ }
114
+ if (Buffer.byteLength(match[1], "utf8") > MAX_FRONTMATTER_BYTES)
115
+ throw new Error(`OKF frontmatter is too large: ${path}`);
116
+ const document = parseDocument(match[1], { prettyErrors: false, strict: true, uniqueKeys: true });
117
+ if (document.errors.length)
118
+ throw new Error(`Invalid OKF YAML frontmatter in ${path}: ${document.errors[0].message}`);
119
+ let value;
120
+ try {
121
+ value = document.toJS({ maxAliasCount: 0 });
122
+ }
123
+ catch (error) {
124
+ throw new Error(`OKF YAML aliases are not allowed in ${path}: ${error instanceof Error ? error.message : String(error)}`);
125
+ }
126
+ if (!value || typeof value !== "object" || Array.isArray(value))
127
+ throw new Error(`OKF frontmatter must be a mapping: ${path}`);
128
+ return { value: value, body: text.slice(match[0].length) };
129
+ }
130
+ function titleFrom(path, body) {
131
+ const heading = /^#\s+(.+)$/m.exec(body)?.[1]?.trim();
132
+ if (heading)
133
+ return heading.slice(0, 1024);
134
+ return basename(path, extname(path)).replace(/[-_]+/g, " ").replace(/\b\w/g, (value) => value.toUpperCase()).slice(0, 1024);
135
+ }
136
+ function conceptFrom(path, parsed) {
137
+ const value = frontmatterSchema.parse(parsed.value);
138
+ const verified = !value.verified ? [] : Array.isArray(value.verified) ? value.verified : [value.verified];
139
+ const trustTier = verified.some(({ by }) => by.startsWith("human:"))
140
+ ? "human-reviewed"
141
+ : verified.length ? "machine-confirmed" : "unverified";
142
+ const stale = Boolean(value.stale_after && new Date().toISOString().slice(0, 10) >= value.stale_after);
143
+ return {
144
+ concept_id: path.slice(0, -3),
145
+ type: value.type,
146
+ title: value.title ?? titleFrom(path, parsed.body),
147
+ description: value.description ?? null,
148
+ resource: value.resource ?? null,
149
+ tags: value.tags ?? [],
150
+ sources: (value.sources ?? []),
151
+ generated: value.generated ?? null,
152
+ verified,
153
+ trust_tier: trustTier,
154
+ status: value.status ?? "stable",
155
+ stale_after: value.stale_after ?? null,
156
+ stale,
157
+ timestamp: value.timestamp ?? null,
158
+ metadata: value,
159
+ };
160
+ }
161
+ function localLinkTarget(from, raw) {
162
+ const withoutTitle = raw.trim().replace(/\s+["'][\s\S]*["']$/, "");
163
+ const withoutFragment = withoutTitle.split("#", 1)[0].split("?", 1)[0];
164
+ if (!withoutFragment || withoutFragment.startsWith("#") || withoutFragment.startsWith("//")
165
+ || /^[a-z][a-z0-9+.-]*:/i.test(withoutFragment))
166
+ return null;
167
+ let decoded;
168
+ try {
169
+ decoded = decodeURIComponent(withoutFragment);
170
+ }
171
+ catch {
172
+ return "!invalid-uri";
173
+ }
174
+ if (decoded.includes("\\"))
175
+ return "!unsafe-path";
176
+ const joined = decoded.startsWith("/") ? decoded.slice(1) : posix.join(posix.dirname(from), decoded);
177
+ const normalized = posix.normalize(joined);
178
+ if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../") || normalized.startsWith("/"))
179
+ return "!unsafe-path";
180
+ return normalized.endsWith("/") ? `${normalized}index.md` : normalized;
181
+ }
182
+ function markdownLinks(text, from) {
183
+ const links = [];
184
+ for (const match of text.matchAll(/!?\[[^\]]*\]\(([^)]+)\)/g)) {
185
+ const target = localLinkTarget(from, match[1]);
186
+ if (target)
187
+ links.push(target);
188
+ }
189
+ return [...new Set(links)].sort((a, b) => a.localeCompare(b, "en"));
190
+ }
191
+ function frontmatterLinks(value, from) {
192
+ const candidates = [value.resource, value.computation];
193
+ if (Array.isArray(value.sources)) {
194
+ for (const source of value.sources) {
195
+ if (source && typeof source === "object" && !Array.isArray(source))
196
+ candidates.push(source.resource);
197
+ }
198
+ }
199
+ for (const key of ["executor", "attester"]) {
200
+ const entry = value[key];
201
+ if (entry && typeof entry === "object" && !Array.isArray(entry))
202
+ candidates.push(entry.resource);
203
+ }
204
+ const links = [];
205
+ for (const candidate of candidates) {
206
+ if (typeof candidate !== "string" || /\s/.test(candidate))
207
+ continue;
208
+ const target = localLinkTarget(from, candidate);
209
+ if (target)
210
+ links.push(target);
211
+ }
212
+ return [...new Set(links)].sort((a, b) => a.localeCompare(b, "en"));
213
+ }
214
+ function diagnosticsFor(path, concept) {
215
+ const diagnostics = [];
216
+ if (!concept.description)
217
+ diagnostics.push({ severity: "warning", code: "missing-description", path, message: "Concept has no description." });
218
+ if (!concept.sources.length)
219
+ diagnostics.push({ severity: "warning", code: "missing-sources", path, message: "Concept declares no sources." });
220
+ if (!concept.verified.length)
221
+ diagnostics.push({ severity: "warning", code: "unverified", path, message: "Concept has not been verified." });
222
+ if (concept.status === "deprecated")
223
+ diagnostics.push({ severity: "warning", code: "deprecated", path, message: "Concept is deprecated." });
224
+ if (concept.stale)
225
+ diagnostics.push({ severity: "warning", code: "stale", path, message: `Concept became stale on ${concept.stale_after}.` });
226
+ return diagnostics;
227
+ }
228
+ export async function indexOkfBundle(directory, selectedPaths, declaredVersion) {
229
+ const requested = resolve(directory);
230
+ const requestedInfo = lstatSync(requested);
231
+ if (!requestedInfo.isDirectory() || requestedInfo.isSymbolicLink())
232
+ throw new Error("OKF source must be a real directory.");
233
+ const root = requested;
234
+ const paths = selectedPaths ? selectedPaths.map(normalizedPath).sort((a, b) => a.localeCompare(b, "en")) : await walk(root);
235
+ const existing = new Set(paths);
236
+ const files = [];
237
+ const diagnostics = [];
238
+ let version = declaredVersion ?? "";
239
+ let sawV02 = false;
240
+ for (const path of paths) {
241
+ const absolute = resolve(root, ...path.split("/"));
242
+ if (!contained(root, absolute))
243
+ throw new Error(`Unsafe OKF path: ${path}`);
244
+ const info = lstatSync(absolute);
245
+ if (!info.isFile() || info.isSymbolicLink() || info.size > MAX_FILE_BYTES)
246
+ throw new Error(`OKF file is unsafe or too large: ${path}`);
247
+ const bytes = await readFile(absolute);
248
+ const text = utf8(bytes, path);
249
+ const name = basename(path);
250
+ const markdown = extname(path).toLowerCase() === ".md";
251
+ const role = !markdown ? "reference" : name === "index.md" ? "index" : name === "log.md" ? "log" : "concept";
252
+ const parsed = markdown ? parseFrontmatter(text, path, role === "concept") : null;
253
+ if (role === "index" && parsed) {
254
+ if (path !== "index.md")
255
+ throw new Error(`Only the root OKF index may contain frontmatter: ${path}`);
256
+ const keys = Object.keys(parsed.value);
257
+ if (keys.some((key) => key !== "okf_version") || typeof parsed.value.okf_version !== "string") {
258
+ throw new Error("The root OKF index frontmatter may contain only okf_version.");
259
+ }
260
+ version = parsed.value.okf_version;
261
+ }
262
+ if (role === "log" && parsed)
263
+ throw new Error(`OKF log files may not contain frontmatter: ${path}`);
264
+ const concept = role === "concept" && parsed ? conceptFrom(path, parsed) : null;
265
+ if (concept && (concept.generated || concept.verified.length || concept.stale_after || concept.sources.length || concept.status !== "stable"))
266
+ sawV02 = true;
267
+ if (concept)
268
+ diagnostics.push(...diagnosticsFor(path, concept));
269
+ const body = parsed?.body ?? text;
270
+ const requestedLinks = [...new Set([
271
+ ...markdownLinks(body, path),
272
+ ...(parsed ? frontmatterLinks(parsed.value, path) : []),
273
+ ])].sort((a, b) => a.localeCompare(b, "en"));
274
+ const outbound = [];
275
+ const broken = [];
276
+ for (const target of requestedLinks) {
277
+ if (target.startsWith("!") || !existing.has(target)) {
278
+ broken.push(target);
279
+ diagnostics.push({ severity: "warning", code: "broken-link", path, target, message: `Link target is not present in the bundle: ${target}` });
280
+ }
281
+ else if (!RESERVED.has(basename(target)))
282
+ outbound.push(target);
283
+ }
284
+ files.push({
285
+ path, role, sha256: sha256(bytes), bytes: bytes.byteLength,
286
+ title: concept?.title ?? titleFrom(path, body),
287
+ description: concept?.description ?? `${role[0].toUpperCase()}${role.slice(1)} file from an Open Knowledge Format bundle.`,
288
+ keywords: concept?.tags ?? [], concept,
289
+ outbound: [...new Set(outbound)].sort((a, b) => a.localeCompare(b, "en")),
290
+ broken_links: broken,
291
+ });
292
+ }
293
+ if (!version)
294
+ version = sawV02 ? "0.2" : "0.1";
295
+ if (!/^0\.(?:1|2)$/.test(version))
296
+ diagnostics.unshift({ severity: "warning", code: "unsupported-version", path: "index.md", message: `OKF version ${version} is not explicitly supported; consumed on a best-effort basis.` });
297
+ return { schema_version: 1, version, files, diagnostics };
298
+ }
299
+ export async function validateOkfBundle(directory) {
300
+ const root = resolve(directory);
301
+ const index = await indexOkfBundle(root);
302
+ const totalBytes = index.files.reduce((sum, file) => sum + file.bytes, 0);
303
+ if (totalBytes > MAX_TOTAL_BYTES)
304
+ throw new Error("OKF bundle exceeds the total size limit.");
305
+ return { ...index, root, concepts: index.files.flatMap((file) => file.concept ? [file.concept] : []), total_bytes: totalBytes };
306
+ }
307
+ function normalizeExportSource(value) {
308
+ const path = normalizedPath(value.replaceAll("\\", "/"));
309
+ const parts = path.split("/");
310
+ if ([".agentfs", ".ai", ".git", "node_modules"].includes(parts[0])
311
+ || (parts[0] === ".planning" && parts[1] !== "wiki")) {
312
+ throw new Error(`Private or sensitive project path cannot be exported: ${path}`);
313
+ }
314
+ if (extname(path).toLowerCase() !== ".md")
315
+ throw new Error(`OKF export accepts explicit Markdown files only: ${path}`);
316
+ if (RESERVED.has(basename(path)))
317
+ throw new Error(`Reserved OKF filenames cannot be exported as concepts: ${path}`);
318
+ return path;
319
+ }
320
+ function exportTarget(source) {
321
+ return source.startsWith(".planning/wiki/") ? `wiki/${source.slice(".planning/wiki/".length)}` : source;
322
+ }
323
+ function exportDescription(content) {
324
+ const paragraph = content.split(/\n\s*\n/).map((value) => value.trim()).find((value) => value && !value.startsWith("#"));
325
+ return (paragraph ?? "Explicitly selected reviewed project knowledge.").replace(/\s+/g, " ").slice(0, 512);
326
+ }
327
+ function renderConcept(path, content, kind, note) {
328
+ const frontmatter = stringify({
329
+ type: kind === "shared-note" ? "Cairnkeep Shared Note" : "Cairnkeep Reference",
330
+ title: note?.title ?? titleFrom(path, content),
331
+ description: note?.description ?? exportDescription(content),
332
+ tags: [...new Set(["cairnkeep", kind === "shared-note" ? "shared-note" : "reviewed-export", ...(note?.tags ?? [])])].sort(),
333
+ generated: { by: "process:cairnkeep-export" },
334
+ ...(note ? { verified: { by: "process:cairnkeep-promotion", at: note.updated_at } } : {}),
335
+ }).trimEnd();
336
+ return `---\n${frontmatter}\n---\n${content.startsWith("\n") ? "" : "\n"}${content}`;
337
+ }
338
+ async function exportMaterial(options) {
339
+ const projectRoot = resolve(options.projectRoot);
340
+ const info = lstatSync(projectRoot);
341
+ if (!info.isDirectory() || info.isSymbolicLink())
342
+ throw new Error("OKF export project must be a real directory.");
343
+ const requested = [...new Set(options.files.map(normalizeExportSource))].sort((a, b) => a.localeCompare(b, "en"));
344
+ const noteIds = [...new Set(options.noteIds ?? [])].sort((a, b) => a.localeCompare(b, "en"));
345
+ if (!requested.length && !noteIds.length)
346
+ throw new Error("OKF export requires at least one explicit --file or --note selection.");
347
+ const content = new Map();
348
+ const rows = [];
349
+ let replacements = 0;
350
+ for (const source of requested) {
351
+ const absolute = resolve(projectRoot, ...source.split("/"));
352
+ if (!contained(projectRoot, absolute))
353
+ throw new Error(`Unsafe OKF export source: ${source}`);
354
+ const sourceInfo = lstatSync(absolute);
355
+ if (!sourceInfo.isFile() || sourceInfo.isSymbolicLink() || sourceInfo.size > MAX_FILE_BYTES)
356
+ throw new Error(`OKF export source is unsafe or too large: ${source}`);
357
+ const bytes = await readFile(absolute);
358
+ const text = utf8(bytes, source);
359
+ const redacted = redactLocalValue(text, projectRoot);
360
+ replacements += redacted.replacement_count;
361
+ const target = normalizedPath(exportTarget(source));
362
+ if (content.has(target))
363
+ throw new Error(`Multiple OKF export selections map to ${target}.`);
364
+ const rendered = renderConcept(target, redacted.value, "file");
365
+ content.set(target, rendered);
366
+ rows.push({ kind: "file", source, path: target, source_digest: sha256(bytes), output_digest: sha256(rendered) });
367
+ }
368
+ for (const noteId of noteIds) {
369
+ if (!/^[a-z0-9][a-z0-9-]{0,255}$/.test(noteId))
370
+ throw new Error(`Invalid shared note ID: ${noteId}`);
371
+ const note = readSharedNoteForExport(noteId);
372
+ const redacted = redactLocalValue(note.content, projectRoot);
373
+ replacements += redacted.replacement_count;
374
+ const target = normalizedPath(`notes/${note.id}.md`);
375
+ if (content.has(target))
376
+ throw new Error(`Multiple OKF export selections map to ${target}.`);
377
+ const rendered = renderConcept(target, redacted.value, "shared-note", note);
378
+ content.set(target, rendered);
379
+ rows.push({ kind: "shared-note", source: `shared-note:${note.id}`, path: target, source_digest: sha256(note.content), output_digest: sha256(rendered) });
380
+ }
381
+ const indexBody = [
382
+ "---", 'okf_version: "0.2"', "---", "# Exported Cairnkeep knowledge", "",
383
+ ...rows.map((row) => `* [${titleFrom(row.path, content.get(row.path) ?? "")}](./${row.path}) - Explicitly selected and redacted knowledge.`),
384
+ "",
385
+ ].join("\n");
386
+ content.set("index.md", indexBody);
387
+ rows.unshift({ kind: "index", source: "generated", path: "index.md", source_digest: sha256("okf-export-index-v1"), output_digest: sha256(indexBody) });
388
+ const core = {
389
+ schema_version: 1,
390
+ okf_version: "0.2",
391
+ project_digest: sha256(projectRoot),
392
+ output_files: rows,
393
+ redaction_replacements: replacements,
394
+ };
395
+ return { plan: { ...core, confirmation_digest: sha256(canonical(core)) }, content };
396
+ }
397
+ export async function planOkfExport(options) {
398
+ return (await exportMaterial(options)).plan;
399
+ }
400
+ export async function applyOkfExport(options, confirmation) {
401
+ if (!DIGEST.test(confirmation))
402
+ throw new Error("OKF export confirmation must be a SHA-256 digest.");
403
+ const material = await exportMaterial(options);
404
+ if (material.plan.confirmation_digest !== confirmation)
405
+ throw new Error("OKF export confirmation digest does not match the inspected export.");
406
+ const output = resolve(options.outputDirectory);
407
+ if (existsSync(output))
408
+ throw new Error("OKF export output already exists; refusing to replace it.");
409
+ const parent = dirname(output);
410
+ await mkdir(parent, { recursive: true, mode: 0o700 });
411
+ const temporary = await mkdtemp(join(parent, `.${basename(output)}.${process.pid}.${randomBytes(4).toString("hex")}.tmp-`));
412
+ try {
413
+ for (const [path, value] of material.content) {
414
+ const target = resolve(temporary, ...path.split("/"));
415
+ if (!contained(temporary, target))
416
+ throw new Error(`Unsafe OKF export target: ${path}`);
417
+ await mkdir(dirname(target), { recursive: true, mode: 0o700 });
418
+ await writeFile(target, value, { encoding: "utf8", mode: 0o600, flag: "wx" });
419
+ hardenPrivatePath(target);
420
+ }
421
+ await validateOkfBundle(temporary);
422
+ hardenPrivatePath(temporary);
423
+ if (existsSync(output))
424
+ throw new Error("OKF export output appeared while publishing; refusing to replace it.");
425
+ await rename(temporary, output);
426
+ }
427
+ catch (error) {
428
+ await rm(temporary, { recursive: true, force: true });
429
+ throw error;
430
+ }
431
+ return { ...material.plan, applied: true, output_directory: output };
432
+ }