@cairnkeep/cli 2.6.0 → 2.8.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 (80) hide show
  1. package/CHANGELOG.md +35 -1
  2. package/README.md +45 -8
  3. package/bin/cairn +21 -3
  4. package/claude/commands/graphify.md +46 -55
  5. package/docs/design/meta-agent.md +39 -35
  6. package/docs/harness-compatibility.md +13 -5
  7. package/docs/learning/CURRICULUM-MAP.md +10 -3
  8. package/docs/learning/FEATURE-GUIDE.md +8 -5
  9. package/docs/learning/README.md +16 -14
  10. package/docs/learning/lessons/L00-why-cairnkeep.md +1 -5
  11. package/docs/learning/lessons/L01-safe-trial.md +3 -7
  12. package/docs/learning/lessons/L02-installation.md +6 -8
  13. package/docs/learning/lessons/L03-first-project.md +1 -5
  14. package/docs/learning/lessons/L04-memory-fundamentals.md +0 -6
  15. package/docs/learning/lessons/L05-daily-workflow.md +0 -5
  16. package/docs/learning/lessons/L06-repository-quality.md +0 -6
  17. package/docs/learning/lessons/L07-privacy-and-storage.md +0 -5
  18. package/docs/learning/lessons/L08-multiple-machines.md +0 -5
  19. package/docs/learning/lessons/L09-domain-knowledge.md +0 -5
  20. package/docs/learning/lessons/L10-context-exploration.md +0 -5
  21. package/docs/learning/lessons/L11-containers.md +0 -5
  22. package/docs/learning/lessons/L12-managed-overlays.md +0 -6
  23. package/docs/learning/lessons/L13-session-evidence.md +0 -6
  24. package/docs/learning/lessons/L14-typed-memory.md +0 -6
  25. package/docs/learning/lessons/L15-capability-governance.md +0 -6
  26. package/docs/learning/lessons/L16-evaluation.md +0 -7
  27. package/docs/learning/lessons/L17-agent-boundary.md +12 -14
  28. package/docs/learning/lessons/L18-local-code-graph.md +97 -0
  29. package/docs/learning/lessons/L19-validated-skill-improvement.md +87 -0
  30. package/docs/learning/templates/lesson-template.md +0 -4
  31. package/docs/learning/tracks/evidence-and-evaluation.md +5 -3
  32. package/docs/learning/tracks/practitioner.md +5 -4
  33. package/docs/operating.md +102 -9
  34. package/docs/privacy-and-data-flow.md +42 -0
  35. package/docs/skill-improvement.md +124 -0
  36. package/docs/storage.md +25 -0
  37. package/examples/eval/bundled-fake.json +1 -1
  38. package/kimi/skills/graphify/SKILL.md +33 -0
  39. package/mcp-memory-server/dist/capability-registry.js +2 -2
  40. package/mcp-memory-server/dist/eval-plan.js +22 -5
  41. package/mcp-memory-server/dist/eval-process.js +31 -0
  42. package/mcp-memory-server/dist/eval-runner.js +10 -4
  43. package/mcp-memory-server/dist/eval-schema.js +1 -1
  44. package/mcp-memory-server/dist/eval-workspace.js +50 -1
  45. package/mcp-memory-server/dist/graph-cli.js +386 -0
  46. package/mcp-memory-server/dist/skill-cli.js +236 -0
  47. package/mcp-memory-server/dist/skill-evaluation.js +244 -0
  48. package/mcp-memory-server/dist/skill-schema.js +164 -0
  49. package/mcp-memory-server/dist/skill-store.js +583 -0
  50. package/mcp-memory-server/package.json +3 -1
  51. package/opencode/command/graphify.md +48 -58
  52. package/package.json +2 -1
  53. package/pi/prompts/graphify.md +32 -0
  54. package/schemas/eval-report.schema.json +1 -1
  55. package/schemas/skill-adapter.schema.json +39 -0
  56. package/schemas/skill-proposal-protocol.schema.json +97 -0
  57. package/scripts/bootstrap.sh +2 -1
  58. package/scripts/completion.sh +55 -6
  59. package/scripts/smoke-bash32.sh +1 -0
  60. package/scripts/sync-kimi-assets.sh +77 -0
  61. package/scripts/sync-pi-assets.sh +52 -31
  62. package/scripts/test-bootstrap-untracked.sh +21 -0
  63. package/scripts/test-cli-dispatch.sh +4 -0
  64. package/scripts/test-completion.sh +16 -0
  65. package/scripts/test-graph-harness-adapters.sh +46 -0
  66. package/scripts/test-launcher-hooks.sh +4 -2
  67. package/scripts/test-learning-docs.sh +13 -11
  68. package/scripts/test-package-install.sh +24 -0
  69. package/scripts/test-skill-cli.sh +30 -0
  70. package/scripts/test-trajectory-hooks.sh +5 -1
  71. package/scripts/test-uninstall.sh +12 -2
  72. package/scripts/uninstall.sh +11 -4
  73. package/templates/graph-policy.md.template +26 -5
  74. package/docs/learning/PRODUCTION-PLAN.md +0 -153
  75. package/docs/learning/instructor-guide.md +0 -66
  76. package/docs/learning/templates/video-script-template.md +0 -50
  77. package/docs/learning/video-scripts/L00-why-cairnkeep.md +0 -80
  78. package/docs/learning/video-scripts/L01-safe-trial.md +0 -78
  79. package/docs/learning/video-scripts/L02-installation.md +0 -97
  80. package/docs/learning/video-scripts/L03-first-project.md +0 -99
@@ -0,0 +1,583 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { constants, existsSync, lstatSync, mkdirSync, openSync, closeSync, readFileSync, readSync, readdirSync, realpathSync, renameSync, rmSync, writeFileSync, chmodSync } from "node:fs";
3
+ import { delimiter, dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
4
+ import { canonicalDigest } from "./eval-schema.js";
5
+ import { EvalProcessError, runBoundedJsonProcess } from "./eval-process.js";
6
+ import { noteNodeSchema } from "./node-schema.js";
7
+ import { getNoteLayout, listAddressedNotes } from "./note-store.js";
8
+ import { MAX_SKILL_BYTES, SKILL_SCHEMA_VERSION, skillAdapterConfigSchema, skillApplicationSchema, skillCandidateSchema, skillEvaluationSchema, skillProposalRequestSchema, skillProposalResponseSchema, skillProposalSchema, skillRelativePathSchema, } from "./skill-schema.js";
9
+ const MAX_ARTIFACT_BYTES = 2 * 1024 * 1024;
10
+ const PROTECTED_ADAPTER_ENVIRONMENT = new Set([
11
+ "HOME", "USERPROFILE", "HOMEDRIVE", "HOMEPATH", "PATH", "PWD", "OLDPWD", "INIT_CWD",
12
+ "TMPDIR", "TMP", "TEMP", "XDG_CACHE_HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME",
13
+ "XDG_STATE_HOME", "XDG_RUNTIME_DIR", "LANG", "LC_ALL", "NODE_OPTIONS", "NODE_PATH",
14
+ "LD_PRELOAD", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES", "DYLD_LIBRARY_PATH",
15
+ "BASH_ENV", "ENV", "SHELLOPTS", "PYTHONHOME", "PYTHONPATH", "RUBYOPT", "PERL5OPT",
16
+ ]);
17
+ function sha256(value) {
18
+ return createHash("sha256").update(value).digest("hex");
19
+ }
20
+ export function sha256File(path) {
21
+ const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
22
+ const hash = createHash("sha256");
23
+ const buffer = Buffer.allocUnsafe(64 * 1024);
24
+ try {
25
+ let bytesRead = 0;
26
+ do {
27
+ bytesRead = readSync(descriptor, buffer, 0, buffer.byteLength, null);
28
+ if (bytesRead > 0)
29
+ hash.update(buffer.subarray(0, bytesRead));
30
+ } while (bytesRead > 0);
31
+ return hash.digest("hex");
32
+ }
33
+ finally {
34
+ closeSync(descriptor);
35
+ }
36
+ }
37
+ function isContained(root, candidate) {
38
+ const path = relative(root, candidate);
39
+ return path === "" || (!path.startsWith(`..${sep}`) && path !== ".." && !isAbsolute(path));
40
+ }
41
+ function requireProjectRoot(projectRoot) {
42
+ const absolute = resolve(projectRoot);
43
+ const info = lstatSync(absolute);
44
+ if (info.isSymbolicLink() || !info.isDirectory() || realpathSync(absolute) !== absolute) {
45
+ throw new Error("Project root must be a real, non-symlink directory.");
46
+ }
47
+ return absolute;
48
+ }
49
+ function privateDirectory(path) {
50
+ mkdirSync(path, { recursive: true, mode: 0o700 });
51
+ const info = lstatSync(path);
52
+ if (info.isSymbolicLink() || !info.isDirectory() || realpathSync(path) !== path) {
53
+ throw new Error(`Unsafe skill state directory: ${path}`);
54
+ }
55
+ chmodSync(path, 0o700);
56
+ }
57
+ export function getSkillStore(projectRoot = process.cwd(), create = true) {
58
+ const rootProject = requireProjectRoot(projectRoot);
59
+ const root = resolve(rootProject, ".agentfs", "skills");
60
+ if (!isContained(rootProject, root) || root === rootProject)
61
+ throw new Error("Skill state path escapes the project.");
62
+ const store = {
63
+ project_root: rootProject,
64
+ root,
65
+ candidates: join(root, "candidates"),
66
+ proposals: join(root, "proposals"),
67
+ evaluations: join(root, "evaluations"),
68
+ applications: join(root, "applications"),
69
+ backups: join(root, "backups"),
70
+ adapter_tmp: join(root, "adapter-tmp"),
71
+ };
72
+ if (create) {
73
+ privateDirectory(resolve(rootProject, ".agentfs"));
74
+ for (const path of Object.values(store).slice(1))
75
+ privateDirectory(path);
76
+ }
77
+ return store;
78
+ }
79
+ function artifactPath(directory, id) {
80
+ if (!/^[a-z0-9][a-z0-9._-]{0,127}$/.test(id))
81
+ throw new Error("Artifact ID is not canonical.");
82
+ const path = resolve(directory, `${id}.json`);
83
+ if (!isContained(directory, path) || path === directory)
84
+ throw new Error("Artifact path is unsafe.");
85
+ return path;
86
+ }
87
+ function atomicWrite(path, value) {
88
+ privateDirectory(dirname(path));
89
+ const bytes = `${JSON.stringify(value, null, 2)}\n`;
90
+ if (Buffer.byteLength(bytes, "utf8") > MAX_ARTIFACT_BYTES)
91
+ throw new Error("Skill artifact exceeds the storage limit.");
92
+ const temporary = join(dirname(path), `.tmp-${randomBytes(16).toString("hex")}`);
93
+ const descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
94
+ try {
95
+ writeFileSync(descriptor, bytes, "utf8");
96
+ }
97
+ finally {
98
+ closeSync(descriptor);
99
+ }
100
+ chmodSync(temporary, 0o600);
101
+ renameSync(temporary, path);
102
+ }
103
+ function readJson(path) {
104
+ const info = lstatSync(path);
105
+ if (info.isSymbolicLink() || !info.isFile() || info.size > MAX_ARTIFACT_BYTES || (info.mode & 0o077) !== 0) {
106
+ throw new Error(`Unsafe skill artifact: ${path}`);
107
+ }
108
+ const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
109
+ try {
110
+ return JSON.parse(readFileSync(descriptor, "utf8"));
111
+ }
112
+ finally {
113
+ closeSync(descriptor);
114
+ }
115
+ }
116
+ function listJson(directory, parse) {
117
+ if (!existsSync(directory))
118
+ return [];
119
+ const info = lstatSync(directory);
120
+ if (info.isSymbolicLink() || !info.isDirectory() || (info.mode & 0o077) !== 0)
121
+ throw new Error("Unsafe skill artifact directory.");
122
+ return [...new Set(readdirSync(directory))]
123
+ .filter((name) => /^[a-z0-9][a-z0-9._-]{0,127}\.json$/.test(name))
124
+ .sort()
125
+ .map((name) => parse(readJson(join(directory, name))));
126
+ }
127
+ function candidateId(noteId) {
128
+ return `candidate-${sha256(`cairnkeep:skill:candidate:v1\0${noteId}`).slice(0, 24)}`;
129
+ }
130
+ export function harvestSkillCandidates(options) {
131
+ const minimum = options.minimumOccurrences ?? 2;
132
+ if (!Number.isSafeInteger(minimum) || minimum < 2 || minimum > 1024) {
133
+ throw new Error("Minimum occurrences must be an integer from 2 through 1024.");
134
+ }
135
+ const store = getSkillStore(options.projectRoot);
136
+ const layout = getNoteLayout(store.project_root);
137
+ const addressed = listAddressedNotes({
138
+ address_space: "project-notes",
139
+ prefix: "hindsight/",
140
+ projectRoot: store.project_root,
141
+ node_types: ["hindsight"],
142
+ });
143
+ const candidates = [];
144
+ let skipped = 0;
145
+ for (const entry of addressed) {
146
+ const note = noteNodeSchema.parse(JSON.parse(entry.value));
147
+ if (!note.signature || !note.project_id) {
148
+ skipped += 1;
149
+ continue;
150
+ }
151
+ const failureSessions = new Set(note.occurrences.filter(({ outcome }) => outcome === "failure").map(({ session_id }) => session_id));
152
+ const resolutionSessions = new Set(note.occurrences.filter(({ outcome }) => outcome === "resolution").map(({ session_id }) => session_id));
153
+ if (failureSessions.size < minimum || resolutionSessions.size < 1) {
154
+ skipped += 1;
155
+ continue;
156
+ }
157
+ const selectedOccurrences = [];
158
+ const occurrenceKeys = new Set();
159
+ const selectedFailureSessions = new Set();
160
+ const addOccurrence = (occurrence) => {
161
+ const key = `${occurrence.session_digest}:${occurrence.sequence}:${occurrence.outcome}`;
162
+ if (occurrenceKeys.has(key) || selectedOccurrences.length >= 64)
163
+ return;
164
+ occurrenceKeys.add(key);
165
+ selectedOccurrences.push(occurrence);
166
+ };
167
+ const newestFirst = [...note.occurrences].reverse();
168
+ const resolutionEvidence = newestFirst.find(({ outcome }) => outcome === "resolution");
169
+ if (resolutionEvidence)
170
+ addOccurrence(resolutionEvidence);
171
+ for (const occurrence of newestFirst) {
172
+ if (occurrence.outcome !== "failure" || selectedFailureSessions.has(occurrence.session_id))
173
+ continue;
174
+ addOccurrence(occurrence);
175
+ selectedFailureSessions.add(occurrence.session_id);
176
+ if (selectedFailureSessions.size >= Math.min(minimum, 63))
177
+ break;
178
+ }
179
+ for (const occurrence of newestFirst)
180
+ addOccurrence(occurrence);
181
+ selectedOccurrences.sort((left, right) => left.ended_at.localeCompare(right.ended_at)
182
+ || left.session_id.localeCompare(right.session_id)
183
+ || left.sequence - right.sequence);
184
+ const occurrences = selectedOccurrences.map(({ session_id, session_digest, ended_at, outcome, tool_name, evidence }) => ({
185
+ session_id,
186
+ session_digest,
187
+ ended_at,
188
+ outcome,
189
+ ...(tool_name ? { tool_name } : {}),
190
+ evidence,
191
+ }));
192
+ const sourceDigest = canonicalDigest({
193
+ note_id: note.id,
194
+ signature: note.signature,
195
+ failure_count: failureSessions.size,
196
+ resolution_count: resolutionSessions.size,
197
+ occurrences,
198
+ enrichment: note.enrichment ?? null,
199
+ });
200
+ const id = candidateId(note.id);
201
+ let prior = null;
202
+ const path = artifactPath(store.candidates, id);
203
+ if (existsSync(path))
204
+ prior = skillCandidateSchema.parse(readJson(path));
205
+ const now = new Date().toISOString();
206
+ const unchangedApproved = prior?.source_digest === sourceDigest && prior.status === "approved";
207
+ const candidate = skillCandidateSchema.parse({
208
+ schema_version: SKILL_SCHEMA_VERSION,
209
+ id,
210
+ project_id: layout.project_id,
211
+ status: unchangedApproved ? "approved" : "pending_review",
212
+ note_id: note.id,
213
+ source_digest: sourceDigest,
214
+ title: note.title,
215
+ description: note.description,
216
+ failure_family: note.signature.family,
217
+ failure_count: failureSessions.size,
218
+ resolution_count: resolutionSessions.size,
219
+ occurrences,
220
+ lessons: note.enrichment?.lessons ?? [],
221
+ caveats: note.enrichment?.caveats ?? [],
222
+ created_at: prior?.created_at ?? now,
223
+ reviewed_at: unchangedApproved ? prior?.reviewed_at ?? now : null,
224
+ });
225
+ atomicWrite(path, candidate);
226
+ candidates.push(candidate);
227
+ }
228
+ return { schema_version: SKILL_SCHEMA_VERSION, candidates, skipped };
229
+ }
230
+ export function listSkillCandidates(projectRoot) {
231
+ const store = getSkillStore(projectRoot);
232
+ return listJson(store.candidates, (value) => skillCandidateSchema.parse(value));
233
+ }
234
+ export function readSkillCandidate(projectRoot, id) {
235
+ return skillCandidateSchema.parse(readJson(artifactPath(getSkillStore(projectRoot).candidates, id)));
236
+ }
237
+ export function approveSkillCandidate(projectRoot, id) {
238
+ const store = getSkillStore(projectRoot);
239
+ const candidate = readSkillCandidate(store.project_root, id);
240
+ const approved = skillCandidateSchema.parse({ ...candidate, status: "approved", reviewed_at: new Date().toISOString() });
241
+ atomicWrite(artifactPath(store.candidates, id), approved);
242
+ return approved;
243
+ }
244
+ function assertSafeTarget(projectRoot, relativePath) {
245
+ const canonicalPath = skillRelativePathSchema.parse(relativePath);
246
+ const root = requireProjectRoot(projectRoot);
247
+ const target = resolve(root, canonicalPath);
248
+ if (!isContained(root, target) || target === root)
249
+ throw new Error("Skill target escapes the project.");
250
+ let cursor = root;
251
+ for (const segment of canonicalPath.split("/")) {
252
+ cursor = join(cursor, segment);
253
+ const info = lstatSync(cursor);
254
+ if (info.isSymbolicLink())
255
+ throw new Error("Skill target crosses a symlink.");
256
+ }
257
+ const info = lstatSync(target);
258
+ if (!info.isFile() || info.size > MAX_SKILL_BYTES)
259
+ throw new Error("Skill target must be a regular file no larger than 256 KiB.");
260
+ const content = readFileSync(target, "utf8");
261
+ if (Buffer.byteLength(content, "utf8") > MAX_SKILL_BYTES)
262
+ throw new Error("Skill target exceeds 256 KiB.");
263
+ return { path: target, content, digest: sha256(content), mode: info.mode & 0o777 };
264
+ }
265
+ export function readSkillTarget(projectRoot, relativePath) {
266
+ return assertSafeTarget(projectRoot, relativePath);
267
+ }
268
+ function occurrenceCount(value, needle) {
269
+ if (!needle)
270
+ return 0;
271
+ let count = 0;
272
+ let index = 0;
273
+ while ((index = value.indexOf(needle, index)) >= 0) {
274
+ count += 1;
275
+ index += needle.length;
276
+ }
277
+ return count;
278
+ }
279
+ export function applySkillEdits(baseline, edits) {
280
+ let value = baseline;
281
+ for (const edit of edits) {
282
+ if (edit.operation === "add") {
283
+ if (value.includes(edit.content))
284
+ throw new Error("Skill edit would duplicate existing content.");
285
+ if (edit.anchor === null) {
286
+ value = `${value.replace(/\s*$/, "")}\n\n${edit.content.trim()}\n`;
287
+ continue;
288
+ }
289
+ if (occurrenceCount(value, edit.anchor) !== 1)
290
+ throw new Error("Skill edit anchor must match exactly once.");
291
+ value = value.replace(edit.anchor, () => `${edit.anchor}\n${edit.content}`);
292
+ continue;
293
+ }
294
+ if (edit.anchor === null || occurrenceCount(value, edit.anchor) !== 1) {
295
+ throw new Error("Skill edit anchor must match exactly once.");
296
+ }
297
+ const replacement = edit.operation === "replace" ? edit.content : "";
298
+ value = value.replace(edit.anchor, () => replacement);
299
+ }
300
+ if (Buffer.byteLength(value, "utf8") > MAX_SKILL_BYTES)
301
+ throw new Error("Candidate skill exceeds 256 KiB.");
302
+ if (value === baseline)
303
+ throw new Error("Skill proposal does not change the target.");
304
+ return value;
305
+ }
306
+ function loadAdapterConfig(path) {
307
+ const absolute = resolve(path);
308
+ const info = lstatSync(absolute);
309
+ if (info.isSymbolicLink() || !info.isFile() || info.size > 1024 * 1024)
310
+ throw new Error("Adapter config must be a bounded regular file.");
311
+ const config = skillAdapterConfigSchema.parse(readJsonUnrestricted(absolute));
312
+ if (config.environment_allowlist.some((name) => PROTECTED_ADAPTER_ENVIRONMENT.has(name))) {
313
+ throw new Error("Skill adapter environment allowlist contains an isolation-controlled variable.");
314
+ }
315
+ if (!isAbsolute(config.command.program))
316
+ throw new Error("Skill adapter program must be an absolute path.");
317
+ const programInfo = lstatSync(config.command.program);
318
+ if (programInfo.isSymbolicLink() || !programInfo.isFile() || (programInfo.mode & 0o111) === 0
319
+ || realpathSync(config.command.program) !== config.command.program) {
320
+ throw new Error("Skill adapter program must be a real executable file.");
321
+ }
322
+ return { config, digest: canonicalDigest(config), program_digest: sha256File(config.command.program) };
323
+ }
324
+ function readJsonUnrestricted(path) {
325
+ const descriptor = openSync(path, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
326
+ try {
327
+ return JSON.parse(readFileSync(descriptor, "utf8"));
328
+ }
329
+ finally {
330
+ closeSync(descriptor);
331
+ }
332
+ }
333
+ function adapterEnvironment(config, home) {
334
+ const env = {
335
+ HOME: home,
336
+ TMPDIR: home,
337
+ TMP: home,
338
+ TEMP: home,
339
+ XDG_CACHE_HOME: join(home, ".cache"),
340
+ XDG_CONFIG_HOME: join(home, ".config"),
341
+ XDG_DATA_HOME: join(home, ".local", "share"),
342
+ XDG_STATE_HOME: join(home, ".local", "state"),
343
+ PATH: [...new Set([dirname(process.execPath), "/usr/local/bin", "/usr/bin", "/bin"])].join(delimiter),
344
+ LANG: process.env.LANG ?? "C",
345
+ LC_ALL: process.env.LC_ALL ?? process.env.LANG ?? "C",
346
+ };
347
+ for (const name of config.environment_allowlist) {
348
+ const value = process.env[name];
349
+ if (value === undefined)
350
+ throw new Error(`Allowlisted adapter environment variable is not set: ${name}`);
351
+ env[name] = value;
352
+ }
353
+ return env;
354
+ }
355
+ export async function proposeSkill(options) {
356
+ const store = getSkillStore(options.projectRoot);
357
+ const candidate = readSkillCandidate(store.project_root, options.candidateId);
358
+ if (candidate.status !== "approved" || !candidate.reviewed_at)
359
+ throw new Error("Candidate evidence must be approved before proposal generation.");
360
+ const target = assertSafeTarget(store.project_root, options.targetPath);
361
+ const editBudget = options.editBudget ?? 4;
362
+ if (!Number.isSafeInteger(editBudget) || editBudget < 1 || editBudget > 16)
363
+ throw new Error("Edit budget must be from 1 through 16.");
364
+ const adapter = loadAdapterConfig(options.adapterPath);
365
+ const request = skillProposalRequestSchema.parse({
366
+ schema_version: SKILL_SCHEMA_VERSION,
367
+ operation: "propose",
368
+ candidate,
369
+ candidate_digest: canonicalDigest(candidate),
370
+ target: { path: options.targetPath, baseline_digest: target.digest, content: target.content },
371
+ edit_budget: editBudget,
372
+ });
373
+ const invocation = `adapter-${randomBytes(16).toString("hex")}`;
374
+ const adapterHome = join(store.adapter_tmp, invocation);
375
+ privateDirectory(adapterHome);
376
+ try {
377
+ const result = await runBoundedJsonProcess({
378
+ command: adapter.config.command,
379
+ request,
380
+ request_schema: skillProposalRequestSchema,
381
+ result_schema: skillProposalResponseSchema,
382
+ cwd: store.root,
383
+ env: adapterEnvironment(adapter.config, adapterHome),
384
+ timeout_ms: adapter.config.limits.elapsed_ms,
385
+ max_stdout_bytes: adapter.config.limits.stdout_bytes,
386
+ signal: options.signal,
387
+ });
388
+ if (result.result.status !== "completed")
389
+ throw new Error(`Skill adapter failed: ${result.result.error_code ?? "adapter_error"}`);
390
+ if (sha256File(adapter.config.command.program) !== adapter.program_digest) {
391
+ throw new Error("Skill adapter executable changed during proposal generation.");
392
+ }
393
+ if (result.result.edits.length > editBudget)
394
+ throw new Error("Skill adapter exceeded the approved edit budget.");
395
+ const candidateContent = applySkillEdits(target.content, result.result.edits);
396
+ const identity = {
397
+ candidate_id: candidate.id,
398
+ candidate_digest: canonicalDigest(candidate),
399
+ target_path: options.targetPath,
400
+ baseline_digest: target.digest,
401
+ candidate_content_digest: sha256(candidateContent),
402
+ adapter_config_digest: adapter.digest,
403
+ adapter_program_digest: adapter.program_digest,
404
+ edits: result.result.edits,
405
+ };
406
+ const proposal = skillProposalSchema.parse({
407
+ schema_version: SKILL_SCHEMA_VERSION,
408
+ id: `proposal-${canonicalDigest(identity).slice(0, 24)}`,
409
+ ...identity,
410
+ candidate_content: candidateContent,
411
+ target_mode: target.mode,
412
+ adapter_id: adapter.config.id,
413
+ created_at: new Date().toISOString(),
414
+ });
415
+ atomicWrite(artifactPath(store.proposals, proposal.id), proposal);
416
+ return proposal;
417
+ }
418
+ catch (error) {
419
+ if (error instanceof EvalProcessError)
420
+ throw new Error(`Skill adapter execution failed: ${error.code}`);
421
+ throw error;
422
+ }
423
+ finally {
424
+ rmSync(adapterHome, { recursive: true, force: true });
425
+ }
426
+ }
427
+ export function listSkillProposals(projectRoot) {
428
+ const store = getSkillStore(projectRoot);
429
+ return listJson(store.proposals, (value) => skillProposalSchema.parse(value));
430
+ }
431
+ export function readSkillProposal(projectRoot, id) {
432
+ return skillProposalSchema.parse(readJson(artifactPath(getSkillStore(projectRoot).proposals, id)));
433
+ }
434
+ export function writeSkillEvaluation(projectRoot, evaluation) {
435
+ atomicWrite(artifactPath(getSkillStore(projectRoot).evaluations, evaluation.id), skillEvaluationSchema.parse(evaluation));
436
+ }
437
+ export function readSkillEvaluation(projectRoot, id) {
438
+ return skillEvaluationSchema.parse(readJson(artifactPath(getSkillStore(projectRoot).evaluations, id)));
439
+ }
440
+ export function listSkillEvaluations(projectRoot) {
441
+ const store = getSkillStore(projectRoot);
442
+ return listJson(store.evaluations, (value) => skillEvaluationSchema.parse(value));
443
+ }
444
+ function atomicWriteTarget(path, content, mode) {
445
+ const temporary = join(dirname(path), `.cairn-skill-${randomBytes(16).toString("hex")}`);
446
+ const descriptor = openSync(temporary, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL, 0o600);
447
+ try {
448
+ writeFileSync(descriptor, content, "utf8");
449
+ }
450
+ finally {
451
+ closeSync(descriptor);
452
+ }
453
+ chmodSync(temporary, mode);
454
+ renameSync(temporary, path);
455
+ }
456
+ export function applySkillProposal(options) {
457
+ const store = getSkillStore(options.projectRoot);
458
+ const proposal = readSkillProposal(store.project_root, options.proposalId);
459
+ const evaluation = readSkillEvaluation(store.project_root, options.evaluationId);
460
+ const proposalDigest = canonicalDigest(proposal);
461
+ if (options.confirmDigest !== proposalDigest)
462
+ throw new Error("Confirmation digest does not match the proposal.");
463
+ if (evaluation.proposal_id !== proposal.id || evaluation.proposal_digest !== proposalDigest || evaluation.status !== "eligible") {
464
+ throw new Error("Proposal does not have an eligible matching evaluation.");
465
+ }
466
+ if (!evaluation.confirmation
467
+ || evaluation.exploration.unknown > 0
468
+ || evaluation.confirmation.unknown > 0
469
+ || evaluation.exploration.regressions > 0
470
+ || evaluation.confirmation.regressions > 0
471
+ || evaluation.exploration.improvements < evaluation.minimum_improvement
472
+ || evaluation.confirmation.improvements < evaluation.minimum_improvement) {
473
+ throw new Error("Eligible evaluation evidence does not satisfy the application gate.");
474
+ }
475
+ const target = assertSafeTarget(store.project_root, proposal.target_path);
476
+ if (target.digest !== proposal.baseline_digest)
477
+ throw new Error("Skill target changed after proposal generation.");
478
+ if (sha256(proposal.candidate_content) !== proposal.candidate_content_digest) {
479
+ throw new Error("Skill proposal content does not match its recorded digest.");
480
+ }
481
+ const evaluationDigest = canonicalDigest(evaluation);
482
+ const nonce = randomBytes(16).toString("hex");
483
+ const applicationId = `application-${canonicalDigest({ proposal: proposalDigest, evaluation: evaluationDigest, nonce }).slice(0, 24)}`;
484
+ const backupRelative = `.agentfs/skills/backups/${applicationId}.before`;
485
+ const backupPath = resolve(store.project_root, backupRelative);
486
+ if (!isContained(store.backups, backupPath))
487
+ throw new Error("Skill backup path is unsafe.");
488
+ const application = skillApplicationSchema.parse({
489
+ schema_version: SKILL_SCHEMA_VERSION,
490
+ id: applicationId,
491
+ proposal_id: proposal.id,
492
+ proposal_digest: proposalDigest,
493
+ evaluation_id: evaluation.id,
494
+ evaluation_digest: evaluationDigest,
495
+ target_path: proposal.target_path,
496
+ before_digest: proposal.baseline_digest,
497
+ applied_digest: proposal.candidate_content_digest,
498
+ backup_path: backupRelative,
499
+ target_mode: proposal.target_mode,
500
+ state: "applied",
501
+ applied_at: new Date().toISOString(),
502
+ rolled_back_at: null,
503
+ });
504
+ writeFileSync(backupPath, target.content, { encoding: "utf8", mode: 0o600, flag: "wx" });
505
+ chmodSync(backupPath, 0o600);
506
+ try {
507
+ atomicWriteTarget(target.path, proposal.candidate_content, proposal.target_mode);
508
+ if (sha256(readFileSync(target.path)) !== proposal.candidate_content_digest) {
509
+ throw new Error("Applied skill digest verification failed.");
510
+ }
511
+ atomicWrite(artifactPath(store.applications, application.id), application);
512
+ }
513
+ catch (error) {
514
+ const reason = error instanceof Error ? error.message : String(error);
515
+ let currentDigest = null;
516
+ try {
517
+ currentDigest = sha256(readFileSync(target.path));
518
+ }
519
+ catch {
520
+ // The private backup remains available for manual recovery.
521
+ }
522
+ if (currentDigest === proposal.candidate_content_digest) {
523
+ let restored = false;
524
+ try {
525
+ atomicWriteTarget(target.path, target.content, target.mode);
526
+ if (sha256(readFileSync(target.path)) !== target.digest)
527
+ throw new Error("restore_digest_mismatch");
528
+ restored = true;
529
+ }
530
+ catch {
531
+ // The private backup remains available for manual recovery.
532
+ }
533
+ if (!restored)
534
+ throw new Error(`Skill application failed and automatic restore failed; backup retained at ${backupRelative}: ${reason}`);
535
+ rmSync(backupPath, { force: true });
536
+ throw new Error(`Skill application failed; the original was restored: ${reason}`);
537
+ }
538
+ if (currentDigest === target.digest) {
539
+ rmSync(backupPath, { force: true });
540
+ throw new Error(`Skill application failed; the original target is unchanged: ${reason}`);
541
+ }
542
+ throw new Error(`Skill application failed and the target changed concurrently; backup retained at ${backupRelative}: ${reason}`);
543
+ }
544
+ return application;
545
+ }
546
+ export function listSkillApplications(projectRoot) {
547
+ const store = getSkillStore(projectRoot);
548
+ return listJson(store.applications, (value) => skillApplicationSchema.parse(value));
549
+ }
550
+ export function readSkillApplication(projectRoot, id) {
551
+ return skillApplicationSchema.parse(readJson(artifactPath(getSkillStore(projectRoot).applications, id)));
552
+ }
553
+ export function rollbackSkillApplication(options) {
554
+ if (!options.confirm)
555
+ throw new Error("Rollback requires --confirm.");
556
+ const store = getSkillStore(options.projectRoot);
557
+ const application = readSkillApplication(store.project_root, options.applicationId);
558
+ if (application.state !== "applied")
559
+ throw new Error("Skill application is not in the applied state.");
560
+ const target = assertSafeTarget(store.project_root, application.target_path);
561
+ if (target.digest !== application.applied_digest)
562
+ throw new Error("Skill target changed after application; rollback requires manual resolution.");
563
+ const backup = resolve(store.project_root, application.backup_path);
564
+ const backupInfo = lstatSync(backup);
565
+ if (!isContained(store.backups, backup) || backupInfo.isSymbolicLink() || !backupInfo.isFile()
566
+ || backupInfo.size > MAX_SKILL_BYTES || (backupInfo.mode & 0o077) !== 0) {
567
+ throw new Error("Skill backup is missing or unsafe.");
568
+ }
569
+ const original = readFileSync(backup, "utf8");
570
+ if (sha256(original) !== application.before_digest)
571
+ throw new Error("Skill backup digest does not match the application ledger.");
572
+ atomicWriteTarget(target.path, original, application.target_mode);
573
+ if (sha256(readFileSync(target.path)) !== application.before_digest) {
574
+ throw new Error("Skill rollback digest verification failed; the application remains marked as applied.");
575
+ }
576
+ const rolledBack = skillApplicationSchema.parse({
577
+ ...application,
578
+ state: "rolled_back",
579
+ rolled_back_at: new Date().toISOString(),
580
+ });
581
+ atomicWrite(artifactPath(store.applications, application.id), rolledBack);
582
+ return rolledBack;
583
+ }
@@ -44,9 +44,11 @@
44
44
  "check:eval-schema": "node scripts/smoke-eval-schema.mjs",
45
45
  "check:eval-process": "node scripts/smoke-eval-process.mjs",
46
46
  "check:eval-statistics": "node scripts/smoke-eval-statistics.mjs",
47
+ "check:skill-lifecycle": "node scripts/smoke-skill-lifecycle.mjs",
48
+ "check:graph-cli": "node scripts/smoke-graph-cli.mjs",
47
49
  "check:notes": "npm run build && npm run check:note-signatures && npm run check:note-distillation && npm run check:note-enrichment",
48
50
  "test": "npm run test:smoke",
49
- "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",
51
+ "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",
50
52
  "test:search": "npm run build && npm run check:search",
51
53
  "test:memory": "npm run build && npm run check:embeddings && npm run check:extract && npm run check:search",
52
54
  "test:all": "npm run test:memory"