@cairnkeep/cli 2.13.1 → 2.14.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 (35) hide show
  1. package/CHANGELOG.md +16 -1
  2. package/README.md +7 -4
  3. package/docs/context-packs.md +13 -3
  4. package/docs/harness-compatibility.md +1 -1
  5. package/docs/learning/CURRICULUM-MAP.md +5 -4
  6. package/docs/learning/FEATURE-GUIDE.md +6 -2
  7. package/docs/learning/README.md +2 -1
  8. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
  9. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  10. package/docs/learning/lessons/L02-installation.md +2 -2
  11. package/docs/learning/lessons/L03-first-project.md +1 -1
  12. package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
  13. package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
  14. package/docs/learning/lessons/L21-context-packs.md +5 -1
  15. package/docs/learning/lessons/L22-native-windows.md +2 -2
  16. package/docs/learning/lessons/L23-guided-setup.md +2 -2
  17. package/docs/learning/lessons/L24-okf-exchange.md +111 -0
  18. package/docs/learning/tracks/operator.md +4 -1
  19. package/docs/learning/video-scripts/V24-okf-exchange.md +31 -0
  20. package/docs/open-knowledge-format.md +100 -0
  21. package/docs/operating.md +14 -0
  22. package/docs/privacy-and-data-flow.md +2 -0
  23. package/docs/storage.md +6 -0
  24. package/examples/eval/bundled-fake.json +1 -1
  25. package/mcp-memory-server/dist/context-pack-cli.js +54 -1
  26. package/mcp-memory-server/dist/context-pack.js +243 -47
  27. package/mcp-memory-server/dist/index.js +13 -1
  28. package/mcp-memory-server/dist/mcp-tool-catalog.js +1 -0
  29. package/mcp-memory-server/dist/note-store.js +44 -2
  30. package/mcp-memory-server/dist/okf.js +432 -0
  31. package/mcp-memory-server/package.json +3 -1
  32. package/package.json +2 -1
  33. package/schemas/context-pack.schema.json +9 -0
  34. package/scripts/completion.sh +3 -3
  35. package/scripts/test-learning-docs.sh +2 -2
@@ -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
+ }
@@ -50,9 +50,10 @@
50
50
  "check:pi-mcp-bridge": "node scripts/smoke-pi-mcp-bridge.mjs",
51
51
  "check:context-pack": "node scripts/smoke-context-pack.mjs",
52
52
  "check:work-evidence": "node scripts/smoke-work-evidence.mjs",
53
+ "check:okf": "node scripts/smoke-okf.mjs",
53
54
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
54
55
  "test": "npm run test:smoke",
55
- "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:context-pack && npm run check:work-evidence",
56
+ "test:smoke": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:scope-guard && npm run check:http-guard && npm run check:remote-context && npm run check:explore-guard && npm run check:explore-cache && npm run check:explore-crossref && npm run check:route-guard && npm run check:reviewed-memory && npm run check:trajectory-roundtrip && npm run check:trajectory-redaction && npm run check:trajectory-retention && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment && npm run check:node-compat && npm run check:typed-nodes && npm run check:memory-import && npm run check:note-mcp && npm run check:node-doctor && npm run check:artifact-store && npm run check:artifact-mcp && npm run check:compaction-capture && npm run check:capability-contract && npm run check:capability-logging && npm run check:capability-harness && npm run check:capability-mcp && npm run check:eval-schema && npm run check:eval-process && npm run check:eval-statistics && npm run check:skill-lifecycle && npm run check:graph-cli && npm run check:mcp-trust && npm run check:pi-mcp-bridge && npm run check:context-pack && npm run check:work-evidence && npm run check:okf",
56
57
  "test:search": "npm run build && npm run check:search",
57
58
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
58
59
  "test:all": "npm run test:memory"
@@ -60,6 +61,7 @@
60
61
  "dependencies": {
61
62
  "@modelcontextprotocol/sdk": "^1.30.0",
62
63
  "agentfs-sdk": "^0.6.4",
64
+ "yaml": "2.9.0",
63
65
  "zod": "^4.4.3"
64
66
  },
65
67
  "devDependencies": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cairnkeep/cli",
3
- "version": "2.13.1",
3
+ "version": "2.14.0",
4
4
  "description": "A durable, harness-agnostic memory + context layer for coding agents and MCP clients.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -39,6 +39,7 @@
39
39
  "dependencies": {
40
40
  "@modelcontextprotocol/sdk": "^1.30.0",
41
41
  "agentfs-sdk": "^0.6.4",
42
+ "yaml": "2.9.0",
42
43
  "zod": "^4.4.3"
43
44
  },
44
45
  "scripts": {
@@ -12,6 +12,15 @@
12
12
  "title": { "type": "string", "minLength": 1, "maxLength": 256 },
13
13
  "description": { "type": "string", "minLength": 1, "maxLength": 2048 },
14
14
  "license": { "type": "string", "minLength": 1, "maxLength": 128 },
15
+ "source_format": {
16
+ "type": "object",
17
+ "additionalProperties": false,
18
+ "required": ["name", "version"],
19
+ "properties": {
20
+ "name": { "const": "okf" },
21
+ "version": { "type": "string", "minLength": 1, "maxLength": 32 }
22
+ }
23
+ },
15
24
  "files": {
16
25
  "type": "array",
17
26
  "maxItems": 1024,
@@ -56,7 +56,7 @@ _cairn_complete() {
56
56
  esac
57
57
  ;;
58
58
  mcp-tools) COMPREPLY=( $(compgen -W "list status set reset full read-only custom --tool --project --json" -- "$current") ) ;;
59
- pack) COMPREPLY=( $(compgen -W "init lock validate install list show remove enable disable update skills approve-skill revoke-skill --id --version --title --description --license --ref --project --project-id --check --apply --confirm --json" -- "$current") ) ;;
59
+ pack) COMPREPLY=( $(compgen -W "init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill --id --version --title --description --license --ref --project --project-id --output --file --note --check --apply --confirm --json" -- "$current") ) ;;
60
60
  notes) COMPREPLY=( $(compgen -W "distill search-error promote doctor --project --session --all-projects --para-root --text --component --with --confirm --repair --json" -- "$current") ) ;;
61
61
  eval)
62
62
  case "${COMP_WORDS[2]:-}" in
@@ -133,7 +133,7 @@ _cairn() {
133
133
  ;;
134
134
  evidence) _values 'work evidence command' list show delete prune doctor '--status[filter records]:status:(pending complete)' '--dry-run[report without mutation]' '--repair[remove safe temporary remnants]' '--json[emit JSON]' ;;
135
135
  mcp-tools) _values 'MCP tool profile command' list status set reset full read-only custom '--tool[allow an exact tool]:tool:' '--project[project root]:directory:_files -/' '--json[emit JSON]' ;;
136
- pack) _values 'context pack command' init lock validate install list show remove enable disable update skills approve-skill revoke-skill '--ref[pinned Git ref]:ref:' '--project[project root]:directory:_files -/' '--project-id[remote project ID]:project ID:' '--check[inspect update]' '--apply[apply update]' '--confirm[confirm digest]:digest:' '--json[emit JSON]' ;;
136
+ pack) _values 'context pack command' init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill '--ref[pinned Git ref]:ref:' '--project[project root]:directory:_files -/' '--project-id[remote project ID]:project ID:' '--output[export output directory]:directory:_files -/' '--file[explicit project Markdown file]:file:_files' '--note[promoted shared note ID]:note ID:' '--check[inspect update or export]' '--apply[apply update or export]' '--confirm[confirm digest]:digest:' '--json[emit JSON]' ;;
137
137
  capabilities)
138
138
  case $words[3] in
139
139
  list|status) _arguments '--json[emit JSON]' ;;
@@ -223,7 +223,7 @@ complete -c cairn -n "__fish_seen_subcommand_from mcp-tools; and __fish_seen_sub
223
223
  complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l project -r
224
224
  complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l tool -r
225
225
  complete -c cairn -n "__fish_seen_subcommand_from mcp-tools" -l json
226
- complete -c cairn -n "__fish_seen_subcommand_from pack" -a "init lock validate install list show remove enable disable update skills approve-skill revoke-skill"
226
+ complete -c cairn -n "__fish_seen_subcommand_from pack" -a "init lock validate install import-okf validate-okf export-okf list show remove enable disable update skills approve-skill revoke-skill"
227
227
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l project -r
228
228
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l project-id -r
229
229
  complete -c cairn -n "__fish_seen_subcommand_from pack" -l ref -r
@@ -22,7 +22,7 @@ done
22
22
 
23
23
  ready=0
24
24
  brief=0
25
- for number in $(seq -w 0 22); do
25
+ for number in $(seq -w 0 24); do
26
26
  matches=(docs/learning/lessons/L"$number"-*.md)
27
27
  [[ ${#matches[@]} -eq 1 && -f ${matches[0]} ]] || {
28
28
  echo "learning path must contain exactly one L$number lesson" >&2
@@ -53,7 +53,7 @@ grep -qF '`cairn sync-kimi --apply`' "$graph_lesson"
53
53
  grep -qF '`cairn sync-pi --apply`' "$graph_lesson"
54
54
  grep -qF '`graphify-out/`' "$graph_lesson"
55
55
 
56
- [[ $ready -eq 8 && $brief -eq 15 ]] || {
56
+ [[ $ready -eq 10 && $brief -eq 15 ]] || {
57
57
  echo "unexpected learning status totals: ready=$ready brief=$brief" >&2
58
58
  exit 1
59
59
  }