@cairnkeep/cli 2.13.0 → 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.
- package/CHANGELOG.md +26 -1
- package/README.md +7 -4
- package/docs/context-packs.md +13 -3
- package/docs/harness-compatibility.md +1 -1
- package/docs/learning/CURRICULUM-MAP.md +5 -4
- package/docs/learning/FEATURE-GUIDE.md +6 -2
- package/docs/learning/README.md +2 -1
- package/docs/learning/lessons/L00-why-cairnkeep.md +1 -1
- package/docs/learning/lessons/L01-safe-trial.md +3 -3
- package/docs/learning/lessons/L02-installation.md +2 -2
- package/docs/learning/lessons/L03-first-project.md +1 -1
- package/docs/learning/lessons/L19-validated-skill-improvement.md +1 -1
- package/docs/learning/lessons/L20-mcp-tool-profiles.md +1 -1
- package/docs/learning/lessons/L21-context-packs.md +5 -1
- package/docs/learning/lessons/L22-native-windows.md +2 -2
- package/docs/learning/lessons/L23-guided-setup.md +2 -2
- package/docs/learning/lessons/L24-okf-exchange.md +111 -0
- package/docs/learning/tracks/operator.md +4 -1
- package/docs/learning/video-scripts/V24-okf-exchange.md +31 -0
- package/docs/open-knowledge-format.md +100 -0
- package/docs/operating.md +14 -0
- package/docs/privacy-and-data-flow.md +2 -0
- package/docs/releasing.md +6 -2
- package/docs/storage.md +6 -0
- package/examples/eval/bundled-fake.json +1 -1
- package/mcp-memory-server/dist/context-pack-cli.js +54 -1
- package/mcp-memory-server/dist/context-pack.js +243 -47
- package/mcp-memory-server/dist/index.js +13 -1
- package/mcp-memory-server/dist/mcp-tool-catalog.js +1 -0
- package/mcp-memory-server/dist/note-store.js +44 -2
- package/mcp-memory-server/dist/okf.js +432 -0
- package/mcp-memory-server/package.json +3 -1
- package/package.json +6 -2
- package/schemas/context-pack.schema.json +9 -0
- package/scripts/completion.sh +3 -3
- package/scripts/test-learning-docs.sh +2 -2
- package/scripts/test-release-sbom.sh +91 -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
|
+
}
|
|
@@ -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.
|
|
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": {
|
|
@@ -70,5 +71,8 @@
|
|
|
70
71
|
"pi-coding-agent",
|
|
71
72
|
"context",
|
|
72
73
|
"rag"
|
|
73
|
-
]
|
|
74
|
+
],
|
|
75
|
+
"devDependencies": {
|
|
76
|
+
"@cyclonedx/cyclonedx-npm": "6.0.0"
|
|
77
|
+
}
|
|
74
78
|
}
|
|
@@ -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,
|
package/scripts/completion.sh
CHANGED
|
@@ -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
|
|
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
|
|
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
|
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
set -euo pipefail
|
|
3
|
+
|
|
4
|
+
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
|
|
5
|
+
WORKFLOW="$ROOT/.github/workflows/publish.yml"
|
|
6
|
+
TEMP_DIR=$(mktemp -d)
|
|
7
|
+
trap 'rm -f "$TEMP_DIR/cairnkeep.cdx.json"; rmdir "$TEMP_DIR"' EXIT
|
|
8
|
+
|
|
9
|
+
fail() {
|
|
10
|
+
echo "FAIL: $*" >&2
|
|
11
|
+
exit 1
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
node - "$ROOT/package.json" "$ROOT/package-lock.json" "$WORKFLOW" <<'NODE'
|
|
15
|
+
const fs = require("node:fs");
|
|
16
|
+
|
|
17
|
+
const manifest = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
18
|
+
const lock = JSON.parse(fs.readFileSync(process.argv[3], "utf8"));
|
|
19
|
+
const workflow = fs.readFileSync(process.argv[4], "utf8");
|
|
20
|
+
const expected = "6.0.0";
|
|
21
|
+
|
|
22
|
+
if (manifest.devDependencies?.["@cyclonedx/cyclonedx-npm"] !== expected) {
|
|
23
|
+
throw new Error(`package.json must pin @cyclonedx/cyclonedx-npm exactly to ${expected}`);
|
|
24
|
+
}
|
|
25
|
+
if (lock.packages?.[""]?.devDependencies?.["@cyclonedx/cyclonedx-npm"] !== expected) {
|
|
26
|
+
throw new Error(`package-lock.json root must pin @cyclonedx/cyclonedx-npm exactly to ${expected}`);
|
|
27
|
+
}
|
|
28
|
+
if (lock.packages?.["node_modules/@cyclonedx/cyclonedx-npm"]?.version !== expected) {
|
|
29
|
+
throw new Error(`package-lock.json must resolve @cyclonedx/cyclonedx-npm ${expected}`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const artifactStep = workflow.indexOf("- name: Build and validate release artifacts");
|
|
33
|
+
const publishStep = workflow.indexOf("- name: Publish to npm with provenance");
|
|
34
|
+
const attachStep = workflow.indexOf("- name: Attach package and SBOM to GitHub release");
|
|
35
|
+
if (artifactStep < 0 || publishStep < 0 || attachStep < 0 || !(artifactStep < publishStep && publishStep < attachStep)) {
|
|
36
|
+
throw new Error("release artifacts must validate before npm publication and attach only after publication");
|
|
37
|
+
}
|
|
38
|
+
NODE
|
|
39
|
+
|
|
40
|
+
grep -Fq './node_modules/.bin/cyclonedx-npm' "$WORKFLOW" || fail "release workflow does not use the locked CycloneDX generator"
|
|
41
|
+
grep -Fq -- '--package-lock-only' "$WORKFLOW" || fail "release workflow does not use the package lock"
|
|
42
|
+
grep -Fq -- '--omit dev' "$WORKFLOW" || fail "release workflow does not omit development dependencies"
|
|
43
|
+
grep -Fq -- '--output-reproducible' "$WORKFLOW" || fail "release workflow does not request reproducible output"
|
|
44
|
+
grep -Fq -- '--spec-version 1.6' "$WORKFLOW" || fail "release workflow does not emit CycloneDX 1.6"
|
|
45
|
+
grep -Fq -- '--validate' "$WORKFLOW" || fail "release workflow does not validate the generated SBOM"
|
|
46
|
+
if grep -Fq 'npm sbom' "$WORKFLOW"; then
|
|
47
|
+
fail "release workflow still uses npm sbom"
|
|
48
|
+
fi
|
|
49
|
+
if grep -Fqi 'sbomsmith' "$WORKFLOW"; then
|
|
50
|
+
fail "release workflow must not depend on an external converter"
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
"$ROOT/node_modules/.bin/cyclonedx-npm" \
|
|
54
|
+
--package-lock-only \
|
|
55
|
+
--omit dev \
|
|
56
|
+
--output-reproducible \
|
|
57
|
+
--spec-version 1.6 \
|
|
58
|
+
--output-format JSON \
|
|
59
|
+
--output-file "$TEMP_DIR/cairnkeep.cdx.json" \
|
|
60
|
+
--validate \
|
|
61
|
+
"$ROOT/package.json"
|
|
62
|
+
|
|
63
|
+
node - "$TEMP_DIR/cairnkeep.cdx.json" <<'NODE'
|
|
64
|
+
const fs = require("node:fs");
|
|
65
|
+
|
|
66
|
+
const bom = JSON.parse(fs.readFileSync(process.argv[2], "utf8"));
|
|
67
|
+
if (bom.bomFormat !== "CycloneDX") throw new Error("release SBOM format is not CycloneDX");
|
|
68
|
+
if (bom.specVersion !== "1.6") throw new Error("release SBOM version is not CycloneDX 1.6");
|
|
69
|
+
if (!Array.isArray(bom.components) || bom.components.length === 0) throw new Error("release SBOM has no components");
|
|
70
|
+
if (!Array.isArray(bom.dependencies) || bom.dependencies.length === 0) throw new Error("release SBOM has no dependency graph");
|
|
71
|
+
|
|
72
|
+
const scpLike = /^[^/@\s:]+@[^/\s:]+:.+$/;
|
|
73
|
+
function inspect(value) {
|
|
74
|
+
if (Array.isArray(value)) {
|
|
75
|
+
for (const item of value) inspect(item);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
if (!value || typeof value !== "object") return;
|
|
79
|
+
if (Array.isArray(value.externalReferences)) {
|
|
80
|
+
for (const reference of value.externalReferences) {
|
|
81
|
+
if (typeof reference?.url === "string" && scpLike.test(reference.url)) {
|
|
82
|
+
throw new Error(`release SBOM contains an invalid SCP-style external reference: ${reference.url}`);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
for (const child of Object.values(value)) inspect(child);
|
|
87
|
+
}
|
|
88
|
+
inspect(bom);
|
|
89
|
+
NODE
|
|
90
|
+
|
|
91
|
+
echo "PASS: release SBOM is reproducible, schema-validated CycloneDX 1.6"
|