@cairnkeep/cli 2.3.1 → 2.5.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 (151) hide show
  1. package/CHANGELOG.md +85 -0
  2. package/README.md +232 -6
  3. package/bin/cairn +43 -2
  4. package/claude/capability-contract/hooks/capability-command-finish.sh +81 -0
  5. package/claude/capability-contract/hooks/capability-command-start.sh +76 -0
  6. package/claude/hooks/compaction-capture.sh +34 -0
  7. package/claude/hooks/memory-capture.sh +47 -5
  8. package/claude/hooks/memory-wakeup.sh +55 -2
  9. package/docs/building-an-overlay.md +4 -3
  10. package/docs/design/meta-agent.md +439 -0
  11. package/docs/harness-compatibility.md +117 -0
  12. package/docs/learning/CURRICULUM-MAP.md +91 -0
  13. package/docs/learning/FEATURE-GUIDE.md +86 -0
  14. package/docs/learning/PRODUCTION-PLAN.md +45 -14
  15. package/docs/learning/README.md +20 -1
  16. package/docs/learning/instructor-guide.md +6 -0
  17. package/docs/learning/lessons/L00-why-cairnkeep.md +20 -8
  18. package/docs/learning/lessons/L01-safe-trial.md +3 -3
  19. package/docs/learning/lessons/L02-installation.md +23 -5
  20. package/docs/learning/lessons/L03-first-project.md +7 -2
  21. package/docs/learning/lessons/L04-memory-fundamentals.md +4 -0
  22. package/docs/learning/lessons/L05-daily-workflow.md +4 -0
  23. package/docs/learning/lessons/L07-privacy-and-storage.md +3 -0
  24. package/docs/learning/lessons/L10-context-exploration.md +4 -2
  25. package/docs/learning/lessons/L12-managed-overlays.md +2 -0
  26. package/docs/learning/lessons/L13-session-evidence.md +71 -0
  27. package/docs/learning/lessons/L14-typed-memory.md +51 -0
  28. package/docs/learning/lessons/L15-capability-governance.md +56 -0
  29. package/docs/learning/lessons/L16-evaluation.md +67 -0
  30. package/docs/learning/lessons/L17-agent-boundary.md +55 -0
  31. package/docs/learning/templates/lesson-template.md +4 -1
  32. package/docs/learning/tracks/evidence-and-evaluation.md +20 -0
  33. package/docs/learning/tracks/operator.md +8 -2
  34. package/docs/learning/tracks/practitioner.md +6 -1
  35. package/docs/learning/tracks/quickstart.md +3 -0
  36. package/docs/learning/video-scripts/L00-why-cairnkeep.md +21 -17
  37. package/docs/learning/video-scripts/L02-installation.md +9 -2
  38. package/docs/learning/video-scripts/L03-first-project.md +3 -0
  39. package/docs/operating.md +567 -7
  40. package/docs/privacy-and-data-flow.md +417 -0
  41. package/docs/storage.md +252 -0
  42. package/examples/eval/README.md +42 -0
  43. package/examples/eval/adapter.json +12 -0
  44. package/examples/eval/bundled-fake.json +1 -0
  45. package/examples/eval/task-set.json +1 -0
  46. package/mcp-memory-server/dist/artifact-cli.js +184 -0
  47. package/mcp-memory-server/dist/artifact-schema.js +218 -0
  48. package/mcp-memory-server/dist/artifact-store.js +812 -0
  49. package/mcp-memory-server/dist/capability-adapter.js +286 -0
  50. package/mcp-memory-server/dist/capability-cli.js +361 -0
  51. package/mcp-memory-server/dist/capability-config.js +310 -0
  52. package/mcp-memory-server/dist/capability-harness.js +496 -0
  53. package/mcp-memory-server/dist/capability-registry.js +123 -0
  54. package/mcp-memory-server/dist/capability-schema.js +65 -0
  55. package/mcp-memory-server/dist/capability-store.js +431 -0
  56. package/mcp-memory-server/dist/compaction-normalize.js +267 -0
  57. package/mcp-memory-server/dist/eval-cli.js +523 -0
  58. package/mcp-memory-server/dist/eval-plan.js +346 -0
  59. package/mcp-memory-server/dist/eval-process.js +273 -0
  60. package/mcp-memory-server/dist/eval-report.js +609 -0
  61. package/mcp-memory-server/dist/eval-runner.js +827 -0
  62. package/mcp-memory-server/dist/eval-schema.js +368 -0
  63. package/mcp-memory-server/dist/eval-statistics.js +161 -0
  64. package/mcp-memory-server/dist/eval-workspace.js +313 -0
  65. package/mcp-memory-server/dist/failure-signature.js +141 -0
  66. package/mcp-memory-server/dist/index.js +600 -55
  67. package/mcp-memory-server/dist/node-cli.js +84 -0
  68. package/mcp-memory-server/dist/node-schema.js +170 -0
  69. package/mcp-memory-server/dist/node-store.js +455 -0
  70. package/mcp-memory-server/dist/note-cli.js +235 -0
  71. package/mcp-memory-server/dist/note-distiller.js +177 -0
  72. package/mcp-memory-server/dist/note-enrichment.js +93 -0
  73. package/mcp-memory-server/dist/note-schema.js +4 -0
  74. package/mcp-memory-server/dist/note-store.js +985 -0
  75. package/mcp-memory-server/dist/trajectory-cli.js +141 -0
  76. package/mcp-memory-server/dist/trajectory-normalize.js +448 -0
  77. package/mcp-memory-server/dist/trajectory-redaction.js +155 -0
  78. package/mcp-memory-server/dist/trajectory-schema.js +73 -0
  79. package/mcp-memory-server/dist/trajectory-store.js +339 -0
  80. package/mcp-memory-server/package.json +24 -2
  81. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.219.json +9 -0
  82. package/mcp-memory-server/scripts/fixtures/compaction-claude-code-2.1.220.json +9 -0
  83. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-event.json +6 -0
  84. package/mcp-memory-server/scripts/fixtures/compaction-opencode-1.17.20-messages.json +119 -0
  85. package/mcp-memory-server/scripts/fixtures/compaction-unknown-version.json +17 -0
  86. package/opencode/capability-contract/plugins/capability-command.ts +319 -0
  87. package/opencode/command/graphify.md +25 -14
  88. package/opencode/plugins/memory-capture.ts +76 -10
  89. package/opencode/plugins/memory-wakeup.ts +58 -6
  90. package/package.json +8 -4
  91. package/pi/extensions/cairnkeep-trajectory.ts +55 -0
  92. package/schemas/artifact.schema.json +133 -0
  93. package/schemas/capability-callback.schema.json +68 -0
  94. package/schemas/capability-contract.schema.json +126 -0
  95. package/schemas/eval-adapter.schema.json +43 -0
  96. package/schemas/eval-protocol.schema.json +114 -0
  97. package/schemas/eval-report.schema.json +423 -0
  98. package/schemas/eval-task-set.schema.json +114 -0
  99. package/schemas/memory-node.schema.json +90 -0
  100. package/schemas/note.schema.json +112 -0
  101. package/schemas/trajectory-redaction.schema.json +25 -0
  102. package/schemas/trajectory.schema.json +82 -0
  103. package/scripts/bootstrap.sh +6 -0
  104. package/scripts/completion.sh +119 -5
  105. package/scripts/doctor.sh +284 -0
  106. package/scripts/fake-eval-adapter.mjs +120 -0
  107. package/scripts/fixtures/capability-harness-contracts.json +408 -0
  108. package/scripts/install-audit-timer.sh +3 -1
  109. package/scripts/lib/capability-opencode-plugin-harness.mjs +291 -0
  110. package/scripts/lib/trajectory-opencode-plugin-harness.mjs +25 -0
  111. package/scripts/lib/trajectory-pi-extension-harness.mjs +42 -0
  112. package/scripts/memory-wiki-audit.sh +49 -0
  113. package/scripts/smoke-bash32.sh +9 -0
  114. package/scripts/sync-claude-assets.sh +99 -6
  115. package/scripts/sync-opencode-graphify-assets.sh +24 -3
  116. package/scripts/sync-opencode-plugin-assets.sh +31 -5
  117. package/scripts/sync-opencode-security-assets.sh +24 -3
  118. package/scripts/sync-opencode-wiki-assets.sh +24 -3
  119. package/scripts/sync-pi-assets.sh +72 -0
  120. package/scripts/test-audit-timer.sh +98 -0
  121. package/scripts/test-bootstrap-untracked.sh +49 -0
  122. package/scripts/test-cli-dispatch.sh +41 -2
  123. package/scripts/test-compaction-hooks.sh +495 -0
  124. package/scripts/test-completion.sh +77 -0
  125. package/scripts/test-doctor.sh +348 -1
  126. package/scripts/test-eval-cli.sh +175 -0
  127. package/scripts/test-launcher-hooks.sh +630 -3
  128. package/scripts/test-learning-docs.sh +20 -4
  129. package/scripts/test-notes-cli.sh +114 -0
  130. package/scripts/test-package-install.sh +254 -0
  131. package/scripts/test-phase16-node-lifecycle.sh +50 -0
  132. package/scripts/test-phase17-artifact-lifecycle.sh +370 -0
  133. package/scripts/test-phase18-capability-lifecycle.sh +504 -0
  134. package/scripts/test-phase18-harness-boundary.sh +1060 -0
  135. package/scripts/test-phase19-eval-lifecycle.sh +1015 -0
  136. package/scripts/test-trajectory-cli.sh +87 -0
  137. package/scripts/test-trajectory-hooks.sh +142 -0
  138. package/scripts/test-uninstall.sh +231 -5
  139. package/scripts/uninstall.sh +38 -5
  140. package/scripts/verify-dependency-security.mjs +121 -0
  141. package/scripts/verify-docs-parity.sh +527 -1
  142. package/scripts/verify-phase18-runtime-evidence.mjs +895 -0
  143. package/scripts/verify-phase19-runtime-evidence.mjs +947 -0
  144. package/templates/capabilities.json.template +7 -0
  145. package/templates/env.example.template +51 -0
  146. package/templates/memory-wiki-audit.service.template +1 -1
  147. package/templates/start-claude.sh.template +36 -0
  148. package/templates/start-kimi.sh.template +39 -0
  149. package/templates/start-opencode.sh.template +47 -5
  150. package/templates/start-pi.sh.template +30 -0
  151. package/templates/trajectory-redaction.json.template +4 -0
@@ -0,0 +1,985 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { chmodSync, closeSync, copyFileSync, existsSync, fsyncSync, mkdirSync, openSync, readFileSync, readdirSync, renameSync, rmSync, writeFileSync, } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { basename, dirname, isAbsolute, join, relative, resolve } from "node:path";
5
+ import { buildFailureSignature } from "./failure-signature.js";
6
+ import { NOTE_SCHEMA_VERSION, noteNodeSchema, } from "./note-schema.js";
7
+ import { memoryImportEnvelopeSchema, nodePathSchema, } from "./node-schema.js";
8
+ const MANAGED_START = "<!-- cairnkeep:managed:v1:start -->";
9
+ const MANAGED_END = "<!-- cairnkeep:managed:v1:end -->";
10
+ const MAX_PROCESSED_SESSIONS_PER_PROJECT = 4096;
11
+ function digest(domain, value) {
12
+ return createHash("sha256").update(`cairnkeep:notes:v1:${domain}\0${value}`).digest("hex");
13
+ }
14
+ function slug(value) {
15
+ return value.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "").slice(0, 80) || "project";
16
+ }
17
+ function baseDirectory() {
18
+ const configured = process.env.CAIRN_AGENTFS_BASE_DIR?.trim();
19
+ if (!configured)
20
+ return join(homedir(), ".cairnkeep");
21
+ if (configured === "~")
22
+ return homedir();
23
+ if (configured.startsWith("~/"))
24
+ return join(homedir(), configured.slice(2));
25
+ return resolve(configured);
26
+ }
27
+ export function getNoteLayout(projectRoot = process.cwd()) {
28
+ const canonicalRoot = resolve(projectRoot);
29
+ const projectId = `${slug(basename(canonicalRoot))}--${digest("project", canonicalRoot).slice(0, 12)}`;
30
+ const notesRoot = join(baseDirectory(), "notes");
31
+ const projectDir = join(notesRoot, "projects", projectId);
32
+ return {
33
+ notes_root: notesRoot,
34
+ project_id: projectId,
35
+ project_dir: projectDir,
36
+ hindsight_dir: join(projectDir, "hindsight"),
37
+ knowledge_dir: join(projectDir, "knowledge"),
38
+ manifest_path: join(notesRoot, ".cairnkeep", "manifest-v1.json"),
39
+ lock_path: join(notesRoot, ".cairnkeep", "locks", `${projectId}.lock`),
40
+ };
41
+ }
42
+ function emptyManifest() {
43
+ return { schema_version: NOTE_SCHEMA_VERSION, projects: {}, notes: {}, lookups: {} };
44
+ }
45
+ function loadManifest(notesRoot = join(baseDirectory(), "notes")) {
46
+ const path = join(notesRoot, ".cairnkeep", "manifest-v1.json");
47
+ if (!existsSync(path))
48
+ return emptyManifest();
49
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
50
+ if (parsed.schema_version !== NOTE_SCHEMA_VERSION) {
51
+ throw new Error(`Unsupported note manifest schema version ${String(parsed.schema_version)}; run \`cairn notes doctor\`.`);
52
+ }
53
+ if (!parsed.projects || !parsed.notes || !parsed.lookups)
54
+ throw new Error("Note manifest is incomplete; run `cairn notes doctor`.");
55
+ for (const entry of Object.values(parsed.notes)) {
56
+ if (isAbsolute(entry.path) || entry.path.split(/[\\/]/).includes(".."))
57
+ throw new Error("Note manifest contains an unsafe note path.");
58
+ noteNodeSchema.parse(entry.record);
59
+ }
60
+ return parsed;
61
+ }
62
+ function sortedObject(value) {
63
+ return Object.fromEntries(Object.entries(value).sort(([left], [right]) => left.localeCompare(right)));
64
+ }
65
+ function stableManifest(manifest) {
66
+ const projects = Object.fromEntries(Object.entries(manifest.projects).sort(([a], [b]) => a.localeCompare(b)).map(([id, project]) => [id, {
67
+ ...project,
68
+ processed_sessions: sortedObject(project.processed_sessions),
69
+ }]));
70
+ const notes = Object.fromEntries(Object.entries(manifest.notes).sort(([a], [b]) => a.localeCompare(b)));
71
+ const lookups = Object.fromEntries(Object.entries(manifest.lookups).sort(([a], [b]) => a.localeCompare(b)).map(([key, ids]) => [key, [...new Set(ids)].sort()]));
72
+ return { schema_version: NOTE_SCHEMA_VERSION, projects, notes, lookups };
73
+ }
74
+ function atomicWrite(path, bytes, mode = 0o600) {
75
+ if (existsSync(path) && readFileSync(path, "utf8") === bytes)
76
+ return;
77
+ mkdirSync(dirname(path), { recursive: true });
78
+ const temporary = join(dirname(path), `.${basename(path)}.${process.pid}.${randomBytes(6).toString("hex")}.tmp`);
79
+ try {
80
+ writeFileSync(temporary, bytes, { encoding: "utf8", mode });
81
+ chmodSync(temporary, mode);
82
+ renameSync(temporary, path);
83
+ }
84
+ finally {
85
+ rmSync(temporary, { force: true });
86
+ }
87
+ }
88
+ function acquireLock(path) {
89
+ mkdirSync(dirname(path), { recursive: true });
90
+ try {
91
+ mkdirSync(path);
92
+ }
93
+ catch (error) {
94
+ const code = error.code;
95
+ if (code === "EEXIST")
96
+ throw new Error(`Note project is locked at ${path}; retry after the active distillation finishes.`);
97
+ throw error;
98
+ }
99
+ try {
100
+ writeFileSync(join(path, "owner.json"), `${JSON.stringify({ pid: process.pid, started_at: new Date().toISOString() })}\n`, { mode: 0o600 });
101
+ }
102
+ catch (error) {
103
+ rmSync(path, { recursive: true, force: true });
104
+ throw error;
105
+ }
106
+ return () => rmSync(path, { recursive: true, force: true });
107
+ }
108
+ function occurrenceKey(value) {
109
+ return `${value.session_digest}:${value.sequence}:${value.outcome}`;
110
+ }
111
+ function sortOccurrences(values) {
112
+ return [...new Map(values.map((value) => [occurrenceKey(value), value])).values()].sort((left, right) => {
113
+ const ended = left.ended_at.localeCompare(right.ended_at);
114
+ if (ended !== 0)
115
+ return ended;
116
+ const session = left.session_id.localeCompare(right.session_id);
117
+ return session !== 0 ? session : left.sequence - right.sequence;
118
+ }).slice(-1024);
119
+ }
120
+ function statusFromOccurrences(values) {
121
+ const latest = sortOccurrences(values).at(-1);
122
+ if (latest?.outcome === "resolution")
123
+ return "resolved";
124
+ if (latest?.outcome === "abandonment")
125
+ return "abandoned";
126
+ return "unresolved";
127
+ }
128
+ function quote(value) {
129
+ return JSON.stringify(value ?? "");
130
+ }
131
+ function managedBody(note) {
132
+ if (note.node_type === "provenance") {
133
+ return [
134
+ MANAGED_START,
135
+ "",
136
+ `This project observation is represented by shared note \`${note.canonical_id}\`.`,
137
+ "",
138
+ `Status: **${note.status}**`,
139
+ "",
140
+ "### Provenance",
141
+ "",
142
+ ...note.occurrences.map((item) => `- ${item.ended_at} — ${item.outcome} — session \`${item.session_id}\``),
143
+ "",
144
+ MANAGED_END,
145
+ ].join("\n");
146
+ }
147
+ const safe = (value) => value.replace(/<!--\s*cairnkeep:/gi, "&lt;!-- cairnkeep:").replace(/\s+/g, " ").trim();
148
+ const enrichment = note.enrichment ? [
149
+ "",
150
+ "### Optional generated context",
151
+ "",
152
+ "This prose is non-authoritative; the signature, lifecycle, and provenance above remain deterministic.",
153
+ "",
154
+ safe(note.enrichment.summary),
155
+ "",
156
+ "#### Lessons",
157
+ "",
158
+ ...note.enrichment.lessons.map((item) => `- ${safe(item)}`),
159
+ "",
160
+ "#### Caveats",
161
+ "",
162
+ ...note.enrichment.caveats.map((item) => `- ${safe(item)}`),
163
+ ] : [];
164
+ return [
165
+ MANAGED_START,
166
+ "",
167
+ `Status: **${note.status ?? "n/a"}**`,
168
+ "",
169
+ note.signature ? `Error signature: \`${note.signature.lookup_keys.full}\`` : "",
170
+ note.signature?.component ? `Component: \`${note.signature.component}\`` : "",
171
+ "",
172
+ "### Evidence history",
173
+ "",
174
+ ...(note.occurrences.length > 0
175
+ ? note.occurrences.map((item) => `- ${item.ended_at} — ${item.outcome} — session \`${item.session_id}\`: ${safe(item.evidence)}`)
176
+ : ["- No occurrences recorded."]),
177
+ ...enrichment,
178
+ "",
179
+ MANAGED_END,
180
+ ].filter((line, index, all) => line !== "" || all[index - 1] !== "").join("\n");
181
+ }
182
+ function renderNote(note, existing) {
183
+ const parsed = noteNodeSchema.parse(note);
184
+ const frontmatter = [
185
+ "---",
186
+ `id: ${quote(parsed.id)}`,
187
+ `title: ${quote(parsed.title)}`,
188
+ `description: ${quote(parsed.description)}`,
189
+ `keywords: ${JSON.stringify(parsed.keywords)}`,
190
+ `node_type: ${parsed.node_type}`,
191
+ `tags: ${JSON.stringify(parsed.tags)}`,
192
+ ...(parsed.project_id ? [`project_id: ${quote(parsed.project_id)}`] : []),
193
+ ...(parsed.canonical_id ? [`canonical_id: ${quote(parsed.canonical_id)}`] : []),
194
+ ...(parsed.status ? [`status: ${parsed.status}`] : []),
195
+ ...(parsed.signature ? [
196
+ `signature_version: ${parsed.signature.signature_version}`,
197
+ `fingerprint: ${quote(parsed.signature.fingerprint)}`,
198
+ `normalized_error: ${quote(parsed.signature.normalized_message)}`,
199
+ `stack_digest: ${quote(parsed.signature.stack_digest)}`,
200
+ `component: ${quote(parsed.signature.component)}`,
201
+ `first_seen: ${quote(parsed.occurrences[0]?.ended_at)}`,
202
+ `last_seen: ${quote(parsed.occurrences.at(-1)?.ended_at)}`,
203
+ `occurrence_count: ${parsed.occurrences.length}`,
204
+ ] : []),
205
+ "---",
206
+ "",
207
+ `# ${parsed.title}`,
208
+ "",
209
+ ].join("\n");
210
+ let suffix = "\n\n## Maintainer notes\n\n";
211
+ if (existing !== undefined) {
212
+ const start = existing.indexOf(MANAGED_START);
213
+ const end = existing.indexOf(MANAGED_END);
214
+ if (start < 0 || end < start)
215
+ throw new Error(`Refusing to overwrite unmanaged note collision for ${parsed.id}.`);
216
+ suffix = existing.slice(end + MANAGED_END.length);
217
+ }
218
+ return `${frontmatter}${managedBody(parsed)}${suffix}`;
219
+ }
220
+ function resultFor(notesRoot, entry) {
221
+ const note = entry.record;
222
+ return {
223
+ id: note.id,
224
+ path: join(notesRoot, entry.path),
225
+ ...(note.project_id ? { project_id: note.project_id } : {}),
226
+ ...(note.status ? { status: note.status } : {}),
227
+ node_type: note.node_type,
228
+ ...(note.canonical_id ? { canonical_id: note.canonical_id } : {}),
229
+ };
230
+ }
231
+ function rebuildLookups(manifest) {
232
+ const lookups = {};
233
+ for (const [id, entry] of Object.entries(manifest.notes)) {
234
+ for (const key of Object.values(entry.record.signature?.lookup_keys ?? {})) {
235
+ (lookups[key] ??= []).push(id);
236
+ }
237
+ }
238
+ manifest.lookups = Object.fromEntries(Object.entries(lookups).map(([key, ids]) => [key, [...new Set(ids)].sort()]));
239
+ }
240
+ function statusRank(status) {
241
+ return status === "unresolved" ? 0 : status === "resolved" ? 1 : status === "abandoned" ? 2 : 3;
242
+ }
243
+ function projectIndex(manifest, projectId) {
244
+ const entries = Object.values(manifest.notes)
245
+ .filter(({ path, record }) => record.project_id === projectId || path.replaceAll("\\", "/").startsWith(`projects/${projectId}/`))
246
+ .sort((a, b) => statusRank(a.record.status) - statusRank(b.record.status) || a.record.title.localeCompare(b.record.title));
247
+ const section = (heading, status) => {
248
+ const matching = entries.filter(({ record }) => record.status === status);
249
+ return [`## ${heading}`, "", ...(matching.length > 0 ? matching.map((entry) => `- [${entry.record.title}](${relative(manifest.projects[projectId].project_dir, entry.path).replaceAll("\\", "/")})`) : ["- None"]), ""];
250
+ };
251
+ return [
252
+ "# Project notes",
253
+ "",
254
+ "Generated navigation for deterministic local notes.",
255
+ "",
256
+ ...section("Unresolved", "unresolved"),
257
+ ...section("Resolved", "resolved"),
258
+ ...section("Abandoned", "abandoned"),
259
+ ].join("\n");
260
+ }
261
+ function rootIndex(manifest) {
262
+ const shared = Object.values(manifest.notes).filter(({ record }) => record.node_type === "shared");
263
+ return [
264
+ "# Cairnkeep notes",
265
+ "",
266
+ "## How to navigate",
267
+ "",
268
+ "Start with the current project's unresolved hindsight notes. Use shared notes only when corroborated project provenance exists.",
269
+ "",
270
+ "## Projects",
271
+ "",
272
+ ...Object.entries(manifest.projects).sort(([a], [b]) => a.localeCompare(b)).map(([id, project]) => `- [${id}](${project.project_dir}/README.md)`),
273
+ "",
274
+ "## Shared notes",
275
+ "",
276
+ ...(shared.length > 0 ? shared.sort((a, b) => a.record.id.localeCompare(b.record.id)).map((entry) => `- [${entry.record.title}](${entry.path})`) : ["- None"]),
277
+ "",
278
+ ].join("\n");
279
+ }
280
+ function saveManifestAndIndexes(notesRoot, manifest, changedNoteIds) {
281
+ const stable = stableManifest(manifest);
282
+ const prepared = changedNoteIds.map((id) => {
283
+ const entry = stable.notes[id];
284
+ const path = join(notesRoot, entry.path);
285
+ const existing = existsSync(path) ? readFileSync(path, "utf8") : undefined;
286
+ return { path, bytes: renderNote(entry.record, existing) };
287
+ });
288
+ for (const item of prepared)
289
+ atomicWrite(item.path, item.bytes);
290
+ for (const [projectId, project] of Object.entries(stable.projects)) {
291
+ atomicWrite(join(notesRoot, project.project_dir, "README.md"), projectIndex(stable, projectId), 0o600);
292
+ mkdirSync(join(notesRoot, project.project_dir, "knowledge"), { recursive: true });
293
+ mkdirSync(join(notesRoot, project.project_dir, "hindsight"), { recursive: true });
294
+ }
295
+ atomicWrite(join(notesRoot, "README.md"), rootIndex(stable), 0o600);
296
+ atomicWrite(join(notesRoot, ".cairnkeep", "manifest-v1.json"), `${JSON.stringify(stable, null, 2)}\n`, 0o600);
297
+ Object.assign(manifest, stable);
298
+ }
299
+ function notePath(projectId, signature) {
300
+ return join("projects", projectId, "hindsight", `${signature.family}--${signature.fingerprint.slice(0, 16)}.md`);
301
+ }
302
+ function createNote(projectId, signature, occurrence) {
303
+ const id = `hindsight-${projectId.split("--").at(-1)}-${signature.fingerprint.slice(0, 16)}`;
304
+ return noteNodeSchema.parse({
305
+ schema_version: NOTE_SCHEMA_VERSION,
306
+ id,
307
+ title: signature.normalized_message.slice(0, 512),
308
+ description: `Hindsight record for ${signature.family}${signature.component ? ` in ${signature.component}` : ""}.`,
309
+ keywords: [...new Set([signature.family, signature.component, "hindsight"].filter(Boolean))].sort(),
310
+ node_type: "hindsight",
311
+ tags: ["failure", "hindsight", "project-local"],
312
+ project_id: projectId,
313
+ status: "unresolved",
314
+ signature,
315
+ occurrences: [occurrence],
316
+ created_at: occurrence.ended_at,
317
+ updated_at: occurrence.ended_at,
318
+ });
319
+ }
320
+ function mergeOccurrences(note, additions) {
321
+ const occurrences = sortOccurrences([...note.occurrences, ...additions]);
322
+ return noteNodeSchema.parse({
323
+ ...note,
324
+ occurrences,
325
+ status: statusFromOccurrences(occurrences),
326
+ updated_at: occurrences.at(-1)?.ended_at ?? note.updated_at,
327
+ });
328
+ }
329
+ export function applyDistilledSession(options) {
330
+ const layout = getNoteLayout(options.projectRoot);
331
+ const release = acquireLock(layout.lock_path);
332
+ try {
333
+ const manifest = loadManifest(layout.notes_root);
334
+ const projectDir = relative(layout.notes_root, layout.project_dir).replaceAll("\\", "/");
335
+ const project = manifest.projects[layout.project_id] ?? {
336
+ project_root: resolve(options.projectRoot),
337
+ project_dir: projectDir,
338
+ processed_sessions: {},
339
+ };
340
+ manifest.projects[layout.project_id] = project;
341
+ if (project.processed_sessions[options.sessionId] === options.sessionDigest) {
342
+ return { created: [], updated: [], already_processed: [options.sessionId] };
343
+ }
344
+ const createdIds = new Set();
345
+ const updatedIds = new Set();
346
+ for (const failure of options.failures) {
347
+ const provisional = createNote(layout.project_id, failure.signature, failure.occurrence);
348
+ const existing = manifest.notes[provisional.id];
349
+ if (!existing) {
350
+ const occurrences = failure.abandonment ? [failure.occurrence, failure.abandonment] : [failure.occurrence];
351
+ const record = mergeOccurrences(provisional, occurrences);
352
+ manifest.notes[record.id] = { path: notePath(layout.project_id, failure.signature), record };
353
+ createdIds.add(record.id);
354
+ }
355
+ else {
356
+ const additions = failure.abandonment ? [failure.occurrence, failure.abandonment] : [failure.occurrence];
357
+ existing.record = mergeOccurrences(existing.record, additions);
358
+ updatedIds.add(existing.record.id);
359
+ }
360
+ }
361
+ for (const success of options.successes) {
362
+ for (const entry of Object.values(manifest.notes)) {
363
+ const note = entry.record;
364
+ if (note.project_id !== layout.project_id || !note.signature)
365
+ continue;
366
+ const matches = note.occurrences.some((item) => item.outcome === "failure" && item.validation_key === success.validation_key);
367
+ if (!matches)
368
+ continue;
369
+ entry.record = mergeOccurrences(note, [success.occurrence]);
370
+ if (!createdIds.has(note.id))
371
+ updatedIds.add(note.id);
372
+ }
373
+ }
374
+ project.processed_sessions[options.sessionId] = options.sessionDigest;
375
+ const processedIds = Object.keys(project.processed_sessions).sort();
376
+ for (const expiredId of processedIds.slice(0, Math.max(0, processedIds.length - MAX_PROCESSED_SESSIONS_PER_PROJECT))) {
377
+ delete project.processed_sessions[expiredId];
378
+ }
379
+ rebuildLookups(manifest);
380
+ const changedIds = [...new Set([...createdIds, ...updatedIds])].sort();
381
+ saveManifestAndIndexes(layout.notes_root, manifest, changedIds);
382
+ return {
383
+ created: [...createdIds].sort().map((id) => resultFor(layout.notes_root, manifest.notes[id])),
384
+ updated: [...updatedIds].sort().map((id) => resultFor(layout.notes_root, manifest.notes[id])),
385
+ already_processed: changedIds.length === 0 ? [options.sessionId] : [],
386
+ };
387
+ }
388
+ finally {
389
+ release();
390
+ }
391
+ }
392
+ export async function searchHindsight(options) {
393
+ const layout = getNoteLayout(options.projectRoot);
394
+ const manifest = loadManifest(layout.notes_root);
395
+ const signature = buildFailureSignature(options.text, { root: options.projectRoot, component: options.component });
396
+ const keys = [signature.lookup_keys.full, signature.lookup_keys.message_stack, signature.lookup_keys.message_component, signature.lookup_keys.message];
397
+ const ranked = new Map();
398
+ keys.forEach((key, rank) => {
399
+ for (const id of manifest.lookups[key] ?? [])
400
+ if (!ranked.has(id))
401
+ ranked.set(id, rank);
402
+ });
403
+ const entries = [...ranked].map(([id, rank]) => ({ entry: manifest.notes[id], rank })).filter(({ entry }) => {
404
+ const note = entry.record;
405
+ return note.node_type === "shared" || note.project_id === layout.project_id;
406
+ }).sort((left, right) => left.rank - right.rank
407
+ || (left.entry.record.node_type === "provenance" ? -1 : 0)
408
+ || left.entry.record.id.localeCompare(right.entry.record.id));
409
+ return { schema_version: NOTE_SCHEMA_VERSION, mode: "exact", results: entries.map(({ entry }) => resultFor(layout.notes_root, entry)) };
410
+ }
411
+ export function getNoteEnrichmentEvidence(noteId) {
412
+ const manifest = loadManifest();
413
+ const note = manifest.notes[noteId]?.record;
414
+ if (!note?.signature || !note.status)
415
+ throw new Error(`Note "${noteId}" cannot be enriched.`);
416
+ return {
417
+ id: note.id,
418
+ normalized_error: note.signature.normalized_message,
419
+ component: note.signature.component,
420
+ status: note.status,
421
+ attempts: note.occurrences.map((item) => `${item.outcome}: ${item.evidence}`).slice(-64),
422
+ };
423
+ }
424
+ export function applyNoteEnrichment(noteId, enrichment) {
425
+ const notesRoot = join(baseDirectory(), "notes");
426
+ const initial = loadManifest(notesRoot);
427
+ const initialNote = initial.notes[noteId]?.record;
428
+ if (!initialNote?.project_id)
429
+ throw new Error(`Project note "${noteId}" was not found.`);
430
+ const release = acquireLock(join(notesRoot, ".cairnkeep", "locks", `${initialNote.project_id}.lock`));
431
+ try {
432
+ const manifest = loadManifest(notesRoot);
433
+ const entry = manifest.notes[noteId];
434
+ if (!entry || entry.record.project_id !== initialNote.project_id)
435
+ throw new Error(`Note "${noteId}" changed while its lock was being acquired.`);
436
+ entry.record = noteNodeSchema.parse({ ...entry.record, enrichment });
437
+ saveManifestAndIndexes(notesRoot, manifest, [noteId]);
438
+ }
439
+ finally {
440
+ release();
441
+ }
442
+ }
443
+ export async function promoteNotes(options) {
444
+ if (!options.confirm)
445
+ throw new Error("Promotion requires explicit confirmation.");
446
+ if (options.sourceNoteId === options.corroboratingNoteId)
447
+ throw new Error("Promotion requires notes from two distinct projects.");
448
+ const notesRoot = join(baseDirectory(), "notes");
449
+ const initial = loadManifest(notesRoot);
450
+ const initialSource = initial.notes[options.sourceNoteId];
451
+ const initialCorroborating = initial.notes[options.corroboratingNoteId];
452
+ if (!initialSource || !initialCorroborating)
453
+ throw new Error("Both promotion note IDs must exist.");
454
+ const initialLeft = initialSource.record;
455
+ const initialRight = initialCorroborating.record;
456
+ if (!initialLeft.project_id || !initialRight.project_id || initialLeft.project_id === initialRight.project_id)
457
+ throw new Error("Promotion requires evidence from distinct projects.");
458
+ const locks = [initialLeft.project_id, initialRight.project_id].sort().map((id) => join(notesRoot, ".cairnkeep", "locks", `${id}.lock`));
459
+ const releases = [];
460
+ try {
461
+ for (const lock of locks)
462
+ releases.push(acquireLock(lock));
463
+ const manifest = loadManifest(notesRoot);
464
+ const source = manifest.notes[options.sourceNoteId];
465
+ const corroborating = manifest.notes[options.corroboratingNoteId];
466
+ if (!source || !corroborating)
467
+ throw new Error("A promotion note changed while its lock was being acquired.");
468
+ const left = source.record;
469
+ const right = corroborating.record;
470
+ if (!left.project_id || !right.project_id || left.project_id === right.project_id)
471
+ throw new Error("Promotion requires evidence from distinct projects.");
472
+ if (!left.signature || !right.signature || left.signature.fingerprint !== right.signature.fingerprint)
473
+ throw new Error("Promotion notes are not signature-compatible.");
474
+ const sharedId = `shared-${left.signature.fingerprint.slice(0, 16)}`;
475
+ const occurrences = sortOccurrences([...left.occurrences, ...right.occurrences]);
476
+ const shared = noteNodeSchema.parse({
477
+ schema_version: NOTE_SCHEMA_VERSION,
478
+ id: sharedId,
479
+ title: left.title,
480
+ description: `Corroborated hindsight record for ${left.signature.family}.`,
481
+ keywords: [...new Set([...left.keywords, ...right.keywords, "shared"])].sort(),
482
+ node_type: "shared",
483
+ tags: [...new Set([...left.tags, ...right.tags, "shared"])].sort(),
484
+ status: statusFromOccurrences(occurrences),
485
+ signature: left.signature,
486
+ occurrences,
487
+ created_at: occurrences[0]?.ended_at ?? left.created_at,
488
+ updated_at: occurrences.at(-1)?.ended_at ?? left.updated_at,
489
+ });
490
+ const sharedPath = join("shared", `${left.signature.family}--${left.signature.fingerprint.slice(0, 16)}.md`);
491
+ manifest.notes[sharedId] = { path: sharedPath, record: shared };
492
+ for (const entry of [source, corroborating]) {
493
+ entry.record = noteNodeSchema.parse({ ...entry.record, node_type: "provenance", canonical_id: sharedId });
494
+ }
495
+ rebuildLookups(manifest);
496
+ saveManifestAndIndexes(notesRoot, manifest, [source.record.id, corroborating.record.id, sharedId]);
497
+ return { schema_version: NOTE_SCHEMA_VERSION, status: "promoted", shared_id: sharedId, shared_path: join(notesRoot, sharedPath) };
498
+ }
499
+ finally {
500
+ for (const release of releases.reverse())
501
+ release();
502
+ }
503
+ }
504
+ export function doctorNoteStore(repair = false, options = {}) {
505
+ const notesRoot = options.storeRoot ? join(options.storeRoot, ".cairnkeep-note-fixture") : join(baseDirectory(), "notes");
506
+ if (!existsSync(notesRoot))
507
+ return { schema_version: NOTE_SCHEMA_VERSION, exists: false, ok: true, repaired: false, issues: [], transactions: [] };
508
+ try {
509
+ const pending = pendingTransactionDirectories(notesRoot).map((directory) => JSON.parse(readFileSync(join(directory, "journal-v1.json"), "utf8")));
510
+ if (pending.length > 0) {
511
+ if (!repair)
512
+ return { schema_version: 1, exists: true, ok: false, repaired: false, issues: ["RECOVERY_REQUIRED"], transactions: pending.map(({ state }) => ({ state })) };
513
+ const recovery = recoverNoteTransactions({ notesRoot });
514
+ const transactions = pending.map(({ state }) => ({ state, action: state === "committed" ? "finalized" : "rolled_back" }));
515
+ return { schema_version: 1, exists: true, ok: recovery.issues.length === 0, repaired: recovery.repaired > 0, issues: recovery.issues, transactions };
516
+ }
517
+ if (!options.storeRoot) {
518
+ const manifest = loadManifest(notesRoot);
519
+ if (repair)
520
+ saveManifestAndIndexes(notesRoot, manifest, []);
521
+ }
522
+ return { schema_version: NOTE_SCHEMA_VERSION, exists: true, ok: true, repaired: repair, issues: [], transactions: [] };
523
+ }
524
+ catch (error) {
525
+ return { schema_version: NOTE_SCHEMA_VERSION, exists: true, ok: false, repaired: false, issues: [error instanceof Error ? error.message : String(error)], transactions: [] };
526
+ }
527
+ }
528
+ function fileHash(path) {
529
+ return existsSync(path) ? createHash("sha256").update(readFileSync(path)).digest("hex") : null;
530
+ }
531
+ function bytesHash(bytes) {
532
+ return bytes === null ? null : createHash("sha256").update(bytes).digest("hex");
533
+ }
534
+ function noteLayoutFor(options) {
535
+ const layout = getNoteLayout(options.projectRoot ?? process.cwd());
536
+ if (!options.projectId)
537
+ return layout;
538
+ const projectDir = join(layout.notes_root, "projects", options.projectId);
539
+ return {
540
+ ...layout,
541
+ project_id: options.projectId,
542
+ project_dir: projectDir,
543
+ hindsight_dir: join(projectDir, "hindsight"),
544
+ knowledge_dir: join(projectDir, "knowledge"),
545
+ lock_path: join(layout.notes_root, ".cairnkeep", "locks", `${options.projectId}.lock`),
546
+ };
547
+ }
548
+ export function resolveNoteTarget(options) {
549
+ const key = nodePathSchema.parse(options.key);
550
+ const layout = noteLayoutFor(options);
551
+ let relativePath;
552
+ let partition;
553
+ if (options.address_space === "project-notes") {
554
+ const [first] = key.split("/");
555
+ if (first !== "knowledge" && first !== "hindsight")
556
+ throw new Error("INVALID_PATH: project note keys must begin with knowledge/ or hindsight/.");
557
+ partition = first;
558
+ relativePath = join("projects", layout.project_id, `${key}.md`);
559
+ }
560
+ else {
561
+ const leaf = key.startsWith("shared/") ? key.slice("shared/".length) : key;
562
+ if (!leaf || leaf.includes("/"))
563
+ throw new Error("INVALID_PATH: shared note keys must name one shared leaf.");
564
+ partition = "shared";
565
+ relativePath = join("shared", `${leaf}.md`);
566
+ }
567
+ const absolutePath = resolve(layout.notes_root, relativePath);
568
+ const contained = relative(layout.notes_root, absolutePath);
569
+ if (contained.startsWith("..") || isAbsolute(contained))
570
+ throw new Error("INVALID_PATH: note target escapes the managed notes root.");
571
+ return { layout, relative_path: relativePath.replaceAll("\\", "/"), absolute_path: absolutePath, partition };
572
+ }
573
+ function addressedEntry(options) {
574
+ return {
575
+ schema_version: NOTE_SCHEMA_VERSION,
576
+ address_space: options.address_space,
577
+ scope: "project",
578
+ key: options.key,
579
+ value: JSON.stringify(options.entry.record),
580
+ node_type: options.entry.record.node_type,
581
+ tags: options.entry.record.tags,
582
+ path: join(options.notes_root, options.entry.path),
583
+ };
584
+ }
585
+ function findAddressedRecord(manifest, relativePath) {
586
+ return Object.values(manifest.notes).find((entry) => entry.path.replaceAll("\\", "/") === relativePath);
587
+ }
588
+ export function listAddressedNotes(options) {
589
+ const layout = noteLayoutFor(options);
590
+ const manifest = loadManifest(layout.notes_root);
591
+ const base = options.address_space === "project-notes" ? `projects/${layout.project_id}/` : "shared/";
592
+ return Object.values(manifest.notes).flatMap((entry) => {
593
+ const path = entry.path.replaceAll("\\", "/");
594
+ if (!path.startsWith(base))
595
+ return [];
596
+ const key = options.address_space === "project-notes" ? path.slice(base.length, -3) : path.slice("shared/".length, -3);
597
+ if (options.prefix && !key.startsWith(options.prefix))
598
+ return [];
599
+ if (options.node_types && !options.node_types.includes(entry.record.node_type))
600
+ return [];
601
+ if (options.tags_all && !options.tags_all.every((tag) => entry.record.tags.includes(tag)))
602
+ return [];
603
+ if (options.tags_any && !options.tags_any.some((tag) => entry.record.tags.includes(tag)))
604
+ return [];
605
+ return [addressedEntry({ address_space: options.address_space, key, entry, notes_root: layout.notes_root })];
606
+ }).sort((left, right) => left.key.localeCompare(right.key));
607
+ }
608
+ export function readAddressedNote(options) {
609
+ const target = resolveNoteTarget(options);
610
+ const entry = findAddressedRecord(loadManifest(target.layout.notes_root), target.relative_path);
611
+ return entry ? addressedEntry({ address_space: options.address_space, key: options.key, entry, notes_root: target.layout.notes_root }) : null;
612
+ }
613
+ export function searchAddressedNotes(options) {
614
+ const needle = options.query.toLowerCase();
615
+ return listAddressedNotes(options).filter((node) => {
616
+ const record = JSON.parse(node.value);
617
+ return node.key.toLowerCase().includes(needle)
618
+ || record.title.toLowerCase().includes(needle)
619
+ || record.description.toLowerCase().includes(needle)
620
+ || node.node_type === needle
621
+ || node.tags.includes(needle);
622
+ }).sort((left, right) => left.key.localeCompare(right.key));
623
+ }
624
+ function parseAddressedNote(options) {
625
+ let fromValue;
626
+ try {
627
+ fromValue = JSON.parse(options.value);
628
+ }
629
+ catch {
630
+ throw new Error("INVALID_SCHEMA: note values must contain the complete canonical JSON record.");
631
+ }
632
+ const record = noteNodeSchema.parse(options.note ?? fromValue);
633
+ if (options.note && JSON.stringify(noteNodeSchema.parse(fromValue)) !== JSON.stringify(record))
634
+ throw new Error("INVALID_SCHEMA: nested note and value disagree.");
635
+ if (options.node_type && options.node_type !== record.node_type)
636
+ throw new Error("INVALID_SCHEMA: node_type disagrees with the note record.");
637
+ if (options.tags && JSON.stringify(options.tags) !== JSON.stringify(record.tags))
638
+ throw new Error("INVALID_SCHEMA: tags disagree with the note record.");
639
+ const leaf = basename(options.target.relative_path, ".md");
640
+ if (leaf !== record.id)
641
+ throw new Error("INVALID_PATH: note key leaf must equal the note id.");
642
+ if (options.address_space === "shared-notes" && record.node_type !== "shared")
643
+ throw new Error("INVALID_SCHEMA: shared-notes accepts shared records only.");
644
+ if (options.address_space === "project-notes") {
645
+ if (options.target.partition === "knowledge" && record.node_type !== "knowledge")
646
+ throw new Error("INVALID_SCHEMA: knowledge/ accepts knowledge records only.");
647
+ if (options.target.partition === "hindsight" && !["hindsight", "provenance"].includes(record.node_type))
648
+ throw new Error("INVALID_SCHEMA: hindsight/ accepts hindsight or provenance records only.");
649
+ }
650
+ return record;
651
+ }
652
+ function manifestChanges(notesRoot, manifest, noteChanges) {
653
+ const stable = stableManifest(manifest);
654
+ const outputs = [...noteChanges];
655
+ for (const [projectId, project] of Object.entries(stable.projects)) {
656
+ outputs.push({ path: join(notesRoot, project.project_dir, "README.md"), bytes: projectIndex(stable, projectId) });
657
+ }
658
+ outputs.push({ path: join(notesRoot, "README.md"), bytes: rootIndex(stable) });
659
+ outputs.push({ path: join(notesRoot, ".cairnkeep", "manifest-v1.json"), bytes: `${JSON.stringify(stable, null, 2)}\n` });
660
+ return outputs.map(({ path, bytes }) => ({ path, bytes, before_hash: fileHash(path), final_hash: bytesHash(bytes) }))
661
+ .sort((left, right) => (left.path.endsWith("manifest-v1.json") ? 1 : right.path.endsWith("manifest-v1.json") ? -1 : left.path.localeCompare(right.path)));
662
+ }
663
+ function ensureProject(manifest, target, projectRoot) {
664
+ if (target.partition === "shared")
665
+ return;
666
+ manifest.projects[target.layout.project_id] ??= {
667
+ project_root: resolve(projectRoot ?? process.cwd()),
668
+ project_dir: relative(target.layout.notes_root, target.layout.project_dir).replaceAll("\\", "/"),
669
+ processed_sessions: {},
670
+ };
671
+ }
672
+ function cloneManifest(manifest) {
673
+ return JSON.parse(JSON.stringify(manifest));
674
+ }
675
+ export function planCreateAddressedNote(options) {
676
+ const target = resolveNoteTarget(options);
677
+ const manifest = cloneManifest(loadManifest(target.layout.notes_root));
678
+ if (findAddressedRecord(manifest, target.relative_path) || existsSync(target.absolute_path))
679
+ throw new Error("CONFLICT: note target already exists.");
680
+ const record = parseAddressedNote({ ...options, target });
681
+ ensureProject(manifest, target, options.projectRoot);
682
+ manifest.notes[record.id] = { path: target.relative_path, record };
683
+ rebuildLookups(manifest);
684
+ const node = addressedEntry({ address_space: options.address_space, key: options.key, entry: manifest.notes[record.id], notes_root: target.layout.notes_root });
685
+ return { schema_version: 1, operation: "create", notes_root: target.layout.notes_root, project_id: target.layout.project_id, changes: manifestChanges(target.layout.notes_root, manifest, [{ path: target.absolute_path, bytes: renderNote(record) }]), result: { ok: true, scope: "project", key: options.key, created: true, snapshot_key: null, node } };
686
+ }
687
+ function historyPath(notesRoot, addressSpace, key) {
688
+ return join(notesRoot, ".cairnkeep", "history", addressSpace, ...key.split("/"), `${new Date().toISOString()}-${randomBytes(6).toString("hex")}.json`);
689
+ }
690
+ export function planSupersedeAddressedNote(options) {
691
+ const target = resolveNoteTarget(options);
692
+ const manifest = cloneManifest(loadManifest(target.layout.notes_root));
693
+ const existing = findAddressedRecord(manifest, target.relative_path);
694
+ if (!existing || !existsSync(target.absolute_path))
695
+ return planCreateAddressedNote(options);
696
+ const record = parseAddressedNote({ ...options, target });
697
+ const prior = addressedEntry({ address_space: options.address_space, key: options.key, entry: existing, notes_root: target.layout.notes_root });
698
+ delete manifest.notes[existing.record.id];
699
+ manifest.notes[record.id] = { path: target.relative_path, record };
700
+ rebuildLookups(manifest);
701
+ const history = { schema_version: 1, event: "supersede", value: prior.value, node_type: prior.node_type, tags: prior.tags, at: new Date().toISOString(), reason: options.reason ?? null };
702
+ const historyFile = historyPath(target.layout.notes_root, options.address_space, options.key);
703
+ return { schema_version: 1, operation: "supersede", notes_root: target.layout.notes_root, project_id: target.layout.project_id, changes: manifestChanges(target.layout.notes_root, manifest, [{ path: target.absolute_path, bytes: renderNote(record, readFileSync(target.absolute_path, "utf8")) }, { path: historyFile, bytes: `${JSON.stringify(history)}\n` }]), result: { ok: true, scope: "project", key: options.key, created: false, snapshot_key: historyFile, previous_value: prior.value } };
704
+ }
705
+ export function planDeleteAddressedNote(options) {
706
+ const target = resolveNoteTarget(options);
707
+ const manifest = cloneManifest(loadManifest(target.layout.notes_root));
708
+ const existing = findAddressedRecord(manifest, target.relative_path);
709
+ if (!existing)
710
+ return { schema_version: 1, operation: "delete", notes_root: target.layout.notes_root, project_id: target.layout.project_id, changes: [], result: { ok: true, scope: "project", key: options.key, deleted: false, missing: true, snapshot_key: null } };
711
+ const prior = addressedEntry({ address_space: options.address_space, key: options.key, entry: existing, notes_root: target.layout.notes_root });
712
+ delete manifest.notes[existing.record.id];
713
+ rebuildLookups(manifest);
714
+ const history = { schema_version: 1, event: "delete", value: prior.value, node_type: prior.node_type, tags: prior.tags, at: new Date().toISOString(), reason: options.reason ?? null };
715
+ const historyFile = historyPath(target.layout.notes_root, options.address_space, options.key);
716
+ return { schema_version: 1, operation: "delete", notes_root: target.layout.notes_root, project_id: target.layout.project_id, changes: manifestChanges(target.layout.notes_root, manifest, [{ path: target.absolute_path, bytes: null }, { path: historyFile, bytes: `${JSON.stringify(history)}\n` }]), result: { ok: true, scope: "project", key: options.key, deleted: true, missing: false, snapshot_key: historyFile, final_snapshot: history } };
717
+ }
718
+ function transactionRoot(notesRoot) {
719
+ return join(notesRoot, ".cairnkeep", "transactions");
720
+ }
721
+ function pendingTransactionDirectories(notesRoot) {
722
+ const root = transactionRoot(notesRoot);
723
+ if (!existsSync(root))
724
+ return [];
725
+ return readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join(root, entry.name)).sort();
726
+ }
727
+ function syncFile(path) {
728
+ const descriptor = openSync(path, "r");
729
+ try {
730
+ fsyncSync(descriptor);
731
+ }
732
+ finally {
733
+ closeSync(descriptor);
734
+ }
735
+ }
736
+ function syncDirectory(path) {
737
+ const descriptor = openSync(path, "r");
738
+ try {
739
+ fsyncSync(descriptor);
740
+ }
741
+ finally {
742
+ closeSync(descriptor);
743
+ }
744
+ }
745
+ function durableWrite(path, bytes) {
746
+ atomicWrite(path, bytes);
747
+ syncFile(path);
748
+ syncDirectory(dirname(path));
749
+ }
750
+ function writeJournal(path, journal) {
751
+ durableWrite(path, `${JSON.stringify(journal, null, 2)}\n`);
752
+ }
753
+ function rollbackJournal(directory, journal) {
754
+ for (const change of [...journal.changes].reverse()) {
755
+ if (change.backup && existsSync(change.backup)) {
756
+ mkdirSync(dirname(change.path), { recursive: true });
757
+ copyFileSync(change.backup, change.path);
758
+ }
759
+ else {
760
+ rmSync(change.path, { force: true });
761
+ }
762
+ if (fileHash(change.path) !== change.before_hash)
763
+ throw new Error(`AUTHORITATIVE_CORRUPTION: failed to restore ${change.path}.`);
764
+ }
765
+ rmSync(directory, { recursive: true, force: true });
766
+ }
767
+ export function inspectNoteTransactions(options = {}) {
768
+ const notesRoot = options.notesRoot ?? join(options.storeRoot ?? baseDirectory(), options.storeRoot ? ".cairnkeep-note-fixture" : "notes");
769
+ return pendingTransactionDirectories(notesRoot).map((directory) => {
770
+ const journal = JSON.parse(readFileSync(join(directory, "journal-v1.json"), "utf8"));
771
+ return { transaction_id: journal.transaction_id, state: journal.state, directory };
772
+ });
773
+ }
774
+ export function recoverNoteTransactions(options) {
775
+ const directories = pendingTransactionDirectories(options.notesRoot);
776
+ let repaired = 0;
777
+ const issues = [];
778
+ for (const directory of directories) {
779
+ try {
780
+ const journal = JSON.parse(readFileSync(join(directory, "journal-v1.json"), "utf8"));
781
+ if (journal.schema_version !== NOTE_SCHEMA_VERSION)
782
+ throw new Error("Unsupported note transaction schema.");
783
+ if (journal.state === "committed") {
784
+ for (const change of journal.changes) {
785
+ if (fileHash(change.path) !== change.final_hash)
786
+ throw new Error(`AUTHORITATIVE_CORRUPTION: committed final hash differs for ${change.path}.`);
787
+ }
788
+ rmSync(directory, { recursive: true, force: true });
789
+ }
790
+ else {
791
+ rollbackJournal(directory, journal);
792
+ }
793
+ repaired += 1;
794
+ }
795
+ catch (error) {
796
+ issues.push(error instanceof Error ? error.message : String(error));
797
+ }
798
+ }
799
+ return { repaired, issues };
800
+ }
801
+ export async function commitJournaledNoteMutation(plan) {
802
+ if (pendingTransactionDirectories(plan.notes_root).length > 0) {
803
+ if (plan.probe_only)
804
+ return { status: "recovery_required" };
805
+ throw new Error("RECOVERY_REQUIRED: run cairn doctor --repair before another note mutation.");
806
+ }
807
+ if (plan.probe_only)
808
+ return { status: "ready" };
809
+ if (plan.changes.length === 0)
810
+ return plan.result;
811
+ const recoveryLock = join(plan.notes_root, ".cairnkeep", "locks", "recovery.lock");
812
+ const release = acquireLock(recoveryLock);
813
+ const transactionId = `${Date.now()}-${process.pid}-${randomBytes(6).toString("hex")}`;
814
+ const directory = join(transactionRoot(plan.notes_root), transactionId);
815
+ const journalPath = join(directory, "journal-v1.json");
816
+ let journal;
817
+ try {
818
+ mkdirSync(join(directory, "staged"), { recursive: true });
819
+ mkdirSync(join(directory, "backups"), { recursive: true });
820
+ const changes = plan.changes.map((change, index) => {
821
+ if (fileHash(change.path) !== change.before_hash)
822
+ throw new Error(`CONFLICT: note pre-image changed for ${change.path}.`);
823
+ const staged = change.bytes === null ? undefined : join(directory, "staged", `${index}.bin`);
824
+ const backup = change.before_hash === null ? undefined : join(directory, "backups", `${index}.bin`);
825
+ if (staged)
826
+ durableWrite(staged, change.bytes);
827
+ if (backup) {
828
+ copyFileSync(change.path, backup);
829
+ syncFile(backup);
830
+ }
831
+ return { ...change, ...(staged ? { staged } : {}), ...(backup ? { backup } : {}) };
832
+ });
833
+ journal = { schema_version: 1, transaction_id: transactionId, state: "prepared", operation: plan.operation, changes, completed_paths: [] };
834
+ writeJournal(journalPath, journal);
835
+ if (plan.inject_failure === "prepared")
836
+ throw new Error(`Injected ${plan.failure_mode ?? "exception"} failure at prepared.`);
837
+ journal.state = "committing";
838
+ writeJournal(journalPath, journal);
839
+ for (const change of journal.changes) {
840
+ mkdirSync(dirname(change.path), { recursive: true });
841
+ if (change.staged)
842
+ renameSync(change.staged, change.path);
843
+ else
844
+ rmSync(change.path, { force: true });
845
+ if (existsSync(change.path))
846
+ syncFile(change.path);
847
+ syncDirectory(dirname(change.path));
848
+ journal.completed_paths.push(change.path);
849
+ writeJournal(journalPath, journal);
850
+ if (plan.inject_failure === "committing")
851
+ throw new Error(`Injected ${plan.failure_mode ?? "exception"} failure while committing.`);
852
+ }
853
+ journal.state = "committed";
854
+ writeJournal(journalPath, journal);
855
+ if (plan.corrupt_final_hash) {
856
+ const target = journal.changes.find((change) => change.final_hash !== null)?.path;
857
+ if (target)
858
+ writeFileSync(target, "corrupt-final-bytes\n");
859
+ }
860
+ if (plan.inject_failure === "committed")
861
+ throw new Error(`Injected ${plan.failure_mode ?? "exception"} failure after commit.`);
862
+ rmSync(directory, { recursive: true, force: true });
863
+ return plan.result;
864
+ }
865
+ catch (error) {
866
+ if (!plan.inject_failure && journal)
867
+ rollbackJournal(directory, journal);
868
+ throw error;
869
+ }
870
+ finally {
871
+ release();
872
+ }
873
+ }
874
+ export async function applyNoteMutation(plan) {
875
+ if (plan.result.fixture === true) {
876
+ plan = { ...plan, changes: plan.changes.map((change) => ({ ...change, before_hash: fileHash(change.path) })) };
877
+ }
878
+ return commitJournaledNoteMutation(plan);
879
+ }
880
+ export async function createAddressedNote(options) {
881
+ return commitJournaledNoteMutation(planCreateAddressedNote(options));
882
+ }
883
+ export async function supersedeAddressedNote(options) {
884
+ return commitJournaledNoteMutation(planSupersedeAddressedNote(options));
885
+ }
886
+ export async function deleteAddressedNote(options) {
887
+ return commitJournaledNoteMutation(planDeleteAddressedNote(options));
888
+ }
889
+ export function listAddressedNoteHistory(options) {
890
+ const target = resolveNoteTarget(options);
891
+ const directory = join(target.layout.notes_root, ".cairnkeep", "history", options.address_space, ...options.key.split("/"));
892
+ if (!existsSync(directory))
893
+ return [];
894
+ return readdirSync(directory).filter((name) => name.endsWith(".json")).sort().map((name) => JSON.parse(readFileSync(join(directory, name), "utf8")));
895
+ }
896
+ function noteImportDigest(envelope) {
897
+ const canonical = JSON.stringify({ schema_version: envelope.schema_version, scope: envelope.scope, address_space: envelope.address_space, nodes: [...envelope.nodes].sort((a, b) => a.key.localeCompare(b.key)) });
898
+ return createHash("sha256").update("cairnkeep:memory-import:v1\0").update(canonical).digest("hex");
899
+ }
900
+ function importCounts(actions, dryRun) {
901
+ const counts = dryRun
902
+ ? { would_create: 0, would_replace: 0, unchanged: 0, rejected: 0 }
903
+ : { created: 0, replaced: 0, unchanged: 0, rejected: 0 };
904
+ for (const action of actions) {
905
+ const key = action.action === "would_create" ? "would_create" : action.action === "would_replace" ? "would_replace" : action.action;
906
+ counts[key] = (counts[key] ?? 0) + 1;
907
+ }
908
+ return counts;
909
+ }
910
+ export function planNoteImport(options) {
911
+ const envelope = memoryImportEnvelopeSchema.parse(options.input);
912
+ if (envelope.address_space === "memory")
913
+ throw new Error("UNSUPPORTED_TARGET: note import requires a note address space.");
914
+ if (envelope.scope !== "project")
915
+ throw new Error("INVALID_SCOPE: note address spaces require scope project.");
916
+ const addressSpace = envelope.address_space;
917
+ const firstTarget = resolveNoteTarget({ address_space: addressSpace, key: envelope.nodes[0].key, projectRoot: options.projectRoot, projectId: options.projectId });
918
+ const replayPath = join(firstTarget.layout.notes_root, ".cairnkeep", "note-import-replays-v1.json");
919
+ const replays = existsSync(replayPath) ? JSON.parse(readFileSync(replayPath, "utf8")) : {};
920
+ const batch_digest = noteImportDigest(envelope);
921
+ if (envelope.import_id && replays[envelope.import_id]) {
922
+ if (replays[envelope.import_id] !== batch_digest)
923
+ throw new Error(`IMPORT_ID_REUSE: ${envelope.import_id}`);
924
+ const actions = envelope.nodes.map((node) => ({ key: node.key, action: "unchanged" })).sort((a, b) => a.key.localeCompare(b.key));
925
+ const result = { schema_version: 1, scope: envelope.scope, address_space: envelope.address_space, batch_digest, import_id: envelope.import_id, dry_run: envelope.dry_run, conflict_policy: envelope.conflict_policy, committed: !envelope.dry_run, replayed: true, counts: importCounts(actions, envelope.dry_run), actions };
926
+ return { schema_version: 1, operation: "import", notes_root: firstTarget.layout.notes_root, project_id: firstTarget.layout.project_id, envelope, batch_digest, actions, conflict: false, changes: [], result };
927
+ }
928
+ const manifest = cloneManifest(loadManifest(firstTarget.layout.notes_root));
929
+ const noteChanges = [];
930
+ const actions = [];
931
+ for (const node of [...envelope.nodes].sort((a, b) => a.key.localeCompare(b.key))) {
932
+ const target = resolveNoteTarget({ address_space: addressSpace, key: node.key, projectRoot: options.projectRoot, projectId: options.projectId });
933
+ const existing = findAddressedRecord(manifest, target.relative_path);
934
+ const record = parseAddressedNote({ address_space: addressSpace, value: node.value, note: node.note, node_type: node.node_type, tags: node.tags, target });
935
+ if (existing && JSON.stringify(existing.record) === JSON.stringify(record)) {
936
+ actions.push({ key: node.key, action: "unchanged" });
937
+ continue;
938
+ }
939
+ if (existing && envelope.conflict_policy !== "supersede") {
940
+ actions.push({ key: node.key, action: "rejected", code: "CONFLICT" });
941
+ continue;
942
+ }
943
+ ensureProject(manifest, target, options.projectRoot);
944
+ if (existing) {
945
+ const prior = addressedEntry({ address_space: addressSpace, key: node.key, entry: existing, notes_root: target.layout.notes_root });
946
+ const history = { schema_version: 1, event: "supersede", value: prior.value, node_type: prior.node_type, tags: prior.tags, at: new Date().toISOString(), reason: `import ${envelope.import_id ?? batch_digest}` };
947
+ noteChanges.push({ path: historyPath(target.layout.notes_root, addressSpace, node.key), bytes: `${JSON.stringify(history)}\n` });
948
+ delete manifest.notes[existing.record.id];
949
+ }
950
+ manifest.notes[record.id] = { path: target.relative_path, record };
951
+ noteChanges.push({ path: target.absolute_path, bytes: renderNote(record, existing && existsSync(target.absolute_path) ? readFileSync(target.absolute_path, "utf8") : undefined) });
952
+ actions.push({ key: node.key, action: existing ? "would_replace" : "would_create" });
953
+ }
954
+ const conflict = actions.some((action) => action.action === "rejected");
955
+ rebuildLookups(manifest);
956
+ const dryActions = actions.sort((a, b) => a.key.localeCompare(b.key));
957
+ const result = { schema_version: 1, scope: envelope.scope, address_space: envelope.address_space, batch_digest, ...(envelope.import_id ? { import_id: envelope.import_id } : {}), dry_run: envelope.dry_run, conflict_policy: envelope.conflict_policy, committed: false, counts: importCounts(dryActions, true), actions: dryActions };
958
+ if (envelope.import_id && !envelope.dry_run && !conflict) {
959
+ replays[envelope.import_id] = batch_digest;
960
+ noteChanges.push({ path: replayPath, bytes: `${JSON.stringify(sortedObject(replays), null, 2)}\n` });
961
+ }
962
+ return { schema_version: 1, operation: "import", notes_root: firstTarget.layout.notes_root, project_id: firstTarget.layout.project_id, envelope, batch_digest, actions: dryActions, conflict, changes: envelope.dry_run || conflict ? [] : manifestChanges(firstTarget.layout.notes_root, manifest, noteChanges), result };
963
+ }
964
+ export async function commitNoteImport(plan) {
965
+ if (plan.conflict)
966
+ throw new Error("CONFLICT: note import contains a differing live node.");
967
+ if (plan.envelope.dry_run)
968
+ return plan.result;
969
+ const committedActions = plan.actions.map((action) => ({ ...action, action: action.action === "would_create" ? "created" : action.action === "would_replace" ? "replaced" : action.action }));
970
+ plan.result = { ...plan.result, committed: true, dry_run: false, counts: importCounts(committedActions, false), actions: committedActions };
971
+ return await commitJournaledNoteMutation(plan);
972
+ }
973
+ export function createNoteMutationFixture(options) {
974
+ const notesRoot = join(options.storeRoot, ".cairnkeep-note-fixture");
975
+ const target = join(notesRoot, "live.txt");
976
+ if (!existsSync(target)) {
977
+ mkdirSync(dirname(target), { recursive: true });
978
+ writeFileSync(target, "before\n");
979
+ }
980
+ const bytes = options.operation === "delete" ? null : `${options.operation}-after\n`;
981
+ return { schema_version: 1, operation: options.operation, notes_root: notesRoot, changes: [{ path: target, bytes, before_hash: fileHash(target), final_hash: bytesHash(bytes) }], result: { ok: true, fixture: true } };
982
+ }
983
+ export function repairNoteTransactions(options) {
984
+ return recoverNoteTransactions({ notesRoot: join(options.storeRoot, ".cairnkeep-note-fixture") });
985
+ }