cairn-ai 0.2.6 → 0.2.7

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 (65) hide show
  1. package/.agents/workflows/cairn-memory.md +1 -1
  2. package/.agents/workflows/cairn-plan.md +2 -2
  3. package/.agents/workflows/cairn-review.md +1 -1
  4. package/.agents/workflows/cairn-work.md +3 -3
  5. package/.claude/agents/explorer.md +1 -1
  6. package/.claude/agents/worker.md +1 -1
  7. package/.claude/commands/cairn-memory.md +1 -1
  8. package/.claude/commands/cairn-plan.md +1 -1
  9. package/.claude/commands/cairn-review.md +1 -1
  10. package/.claude/commands/cairn-work.md +1 -1
  11. package/.codex-plugin/plugin.json +1 -1
  12. package/README.de.md +3 -2
  13. package/README.es.md +3 -2
  14. package/README.fr.md +3 -2
  15. package/README.ja.md +3 -2
  16. package/README.ko.md +7 -6
  17. package/README.md +5 -6
  18. package/README.pt.md +3 -2
  19. package/README.zh.md +3 -2
  20. package/agents/worker.md +1 -1
  21. package/commands/cairn-install.md +2 -1
  22. package/commands/cairn-memory.md +1 -1
  23. package/commands/cairn-plan.md +2 -2
  24. package/commands/cairn-review.md +1 -1
  25. package/commands/cairn-work.md +3 -3
  26. package/docs/model-guidance/README.md +4 -4
  27. package/docs/model-guidance/claude.md +1 -1
  28. package/docs/model-guidance/codex.md +2 -2
  29. package/package.json +4 -2
  30. package/scripts/cairn-cleanup.mjs +12 -6
  31. package/scripts/cairn-goal-cli.mjs +233 -0
  32. package/scripts/cairn-goal-evidence.mjs +179 -0
  33. package/scripts/cairn-goal-operations-evidence.mjs +124 -0
  34. package/scripts/cairn-goal-operations-mutations.mjs +63 -0
  35. package/scripts/cairn-goal-operations.mjs +177 -0
  36. package/scripts/cairn-goal-policy.mjs +33 -0
  37. package/scripts/cairn-goal-state-validation.mjs +199 -0
  38. package/scripts/cairn-goal-state.mjs +89 -0
  39. package/scripts/cairn-goal.mjs +32 -1078
  40. package/scripts/cairn-lifecycle-commands.mjs +200 -0
  41. package/scripts/cairn-lifecycle-config.mjs +149 -0
  42. package/scripts/cairn-lifecycle-integrity.mjs +218 -0
  43. package/scripts/cairn-lifecycle-legacy.mjs +142 -0
  44. package/scripts/cairn-lifecycle-lock.mjs +205 -0
  45. package/scripts/cairn-lifecycle-mirror.mjs +91 -0
  46. package/scripts/cairn-lifecycle-staging.mjs +152 -0
  47. package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
  48. package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
  49. package/scripts/cairn-lifecycle-transaction.mjs +12 -0
  50. package/scripts/cairn-lifecycle.mjs +103 -1185
  51. package/scripts/cairn-paths.mjs +35 -5
  52. package/scripts/cairn-safe-fs.mjs +61 -35
  53. package/scripts/cairn-state-context.mjs +148 -0
  54. package/scripts/cairn-state-hook.mjs +24 -0
  55. package/scripts/cairn-state-init.mjs +137 -0
  56. package/scripts/cairn-state.mjs +6 -286
  57. package/scripts/cairn-toolcheck-detect.mjs +229 -0
  58. package/scripts/cairn-toolcheck-runtime.mjs +178 -0
  59. package/scripts/cairn-toolcheck.mjs +79 -424
  60. package/scripts/cairn.mjs +17 -3
  61. package/skills/cairn-memory/SKILL.md +1 -1
  62. package/skills/cairn-plan/SKILL.md +3 -3
  63. package/skills/cairn-review/SKILL.md +1 -1
  64. package/skills/cairn-work/SKILL.md +4 -4
  65. package/scripts/cairn-state.sh +0 -167
@@ -0,0 +1,210 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, open, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { dirname, join, relative, resolve, sep } from "node:path";
4
+ import { cairnConfigProjection, readSharedConfigSnapshot, removeCairnConfig } from "./cairn-lifecycle-config.mjs";
5
+
6
+ export function createTransactionJournal(context) {
7
+ const {
8
+ marketplaceRoot, ownershipPath, pluginName, releaseVersion, transactionPath,
9
+ transactionRoot, expectedManagedTarget, targetDigest,
10
+ } = context;
11
+ async function createTransaction() {
12
+ const id = randomUUID();
13
+ const root = join(transactionRoot, id);
14
+ const transaction = {
15
+ schemaVersion: 1,
16
+ id,
17
+ state: "active",
18
+ root,
19
+ stageRoot: join(root, "stage"),
20
+ backupRoot: join(root, "backup"),
21
+ entries: [],
22
+ };
23
+ await mkdir(transaction.backupRoot, { recursive: true });
24
+ await writeTransactionJournal(transaction);
25
+ return transaction;
26
+ }
27
+
28
+ async function rollback(transaction) {
29
+ await rollbackEntries(transaction);
30
+ await rm(transaction.root, { recursive: true, force: true });
31
+ await rm(transactionPath, { force: true });
32
+ }
33
+
34
+ async function finishTransaction(transaction) {
35
+ transaction.state = "committed";
36
+ try {
37
+ await writeTransactionJournal(transaction);
38
+ } catch (error) {
39
+ transaction.state = "active";
40
+ throw error;
41
+ }
42
+ await rm(transaction.root, { recursive: true, force: true });
43
+ await rm(transactionPath, { force: true });
44
+ }
45
+
46
+ async function writeTransactionJournal(transaction) {
47
+ const durable = {
48
+ schemaVersion: transaction.schemaVersion,
49
+ id: transaction.id,
50
+ state: transaction.state,
51
+ entries: transaction.entries,
52
+ };
53
+ await durableReplaceFile(transactionPath, `${JSON.stringify(durable, null, 2)}\n`, transaction.id);
54
+ }
55
+
56
+ async function durableReplaceFile(path, content, nonce = randomUUID()) {
57
+ await mkdir(dirname(path), { recursive: true });
58
+ const candidate = `${path}.${nonce}.tmp`;
59
+ const file = await open(candidate, "w", 0o600);
60
+ try {
61
+ await file.writeFile(content, "utf8");
62
+ await file.sync();
63
+ } finally {
64
+ await file.close();
65
+ }
66
+ await rename(candidate, path);
67
+ // Directory fsync is unavailable on some Windows/filesystem combinations. The
68
+ // file fsync+atomic rename remains the portable floor; directory sync is best effort.
69
+ try {
70
+ const directory = await open(dirname(path), "r");
71
+ try { await directory.sync(); } finally { await directory.close(); }
72
+ } catch (error) {
73
+ if (!(["EINVAL", "ENOTSUP", "EISDIR", "EPERM", "EACCES"].includes(error?.code))) throw error;
74
+ }
75
+ }
76
+
77
+ async function recoverInterruptedTransaction() {
78
+ if (!(await exists(transactionPath))) return;
79
+ let journal;
80
+ try {
81
+ journal = JSON.parse(await readFile(transactionPath, "utf8"));
82
+ } catch {
83
+ throw new Error(`Lifecycle transaction journal is invalid: ${transactionPath}`);
84
+ }
85
+ validateTransactionJournal(journal, { strict: true });
86
+ if (journal.state === "committed") {
87
+ await rm(join(transactionRoot, journal.id), { recursive: true, force: true });
88
+ await rm(transactionPath, { force: true });
89
+ return;
90
+ }
91
+ await rollbackEntries(journal);
92
+ await rm(join(transactionRoot, journal.id), { recursive: true, force: true });
93
+ await rm(transactionPath, { force: true });
94
+ }
95
+
96
+ function validateTransactionJournal(journal, { strict = false } = {}) {
97
+ if (journal?.schemaVersion !== 1 || typeof journal.id !== "string" || !/^[a-f0-9-]{20,}$/i.test(journal.id)
98
+ || !["active", "committed"].includes(journal.state) || !Array.isArray(journal.entries)) {
99
+ throw new Error("Lifecycle transaction journal has an invalid schema.");
100
+ }
101
+ if (strict && !sameKeys(journal, ["schemaVersion", "id", "state", "entries"])) throw new Error("Lifecycle transaction journal has unknown fields.");
102
+ const allowedBackupRoot = resolve(transactionRoot, journal.id, "backup");
103
+ const ids = new Set();
104
+ const paths = new Set();
105
+ for (const entry of journal.entries) {
106
+ const expected = entry.id === "ownership"
107
+ ? { phase: "manifest", path: ownershipPath, type: "file" }
108
+ : entry.id === "previous-codex-runtime"
109
+ ? previousRuntimeTarget(entry.path)
110
+ : expectedManagedTarget(entry.id);
111
+ if (!expected || expected.path !== entry.path || expected.phase !== entry.phase || expected.type !== entry.type
112
+ || !["prepared", "applied"].includes(entry.status)
113
+ || !["replace", "remove"].includes(entry.operation)
114
+ || typeof entry.existed !== "boolean"
115
+ || !isInside(allowedBackupRoot, entry.backup)
116
+ || typeof entry.expectedNewDigest !== "string" || typeof entry.previousDigest !== "string") {
117
+ throw new Error(`Lifecycle transaction journal contains an unsafe entry: ${entry?.id ?? "unknown"}`);
118
+ }
119
+ if (strict && !sameKeys(entry, ["id", "phase", "path", "type", "backup", "existed", "previousDigest", "expectedNewDigest", "operation", "status"])) {
120
+ throw new Error(`Lifecycle transaction journal entry has unknown fields: ${entry.id}`);
121
+ }
122
+ if (ids.has(entry.id) || paths.has(entry.path)) throw new Error(`Lifecycle transaction journal contains duplicate targets: ${entry.id}`);
123
+ if (entry.operation === "remove" && entry.expectedNewDigest !== "missing") throw new Error(`Lifecycle remove entry has an invalid new digest: ${entry.id}`);
124
+ ids.add(entry.id);
125
+ paths.add(entry.path);
126
+ }
127
+ }
128
+
129
+ function previousRuntimeTarget(path) {
130
+ const parent = join(marketplaceRoot, pluginName);
131
+ const relation = relative(parent, resolve(path));
132
+ if (!relation || relation.includes(sep) || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(relation) || relation === releaseVersion) return null;
133
+ return { phase: "cleanup", path: join(parent, relation), type: "tree" };
134
+ }
135
+
136
+ function sameKeys(value, expected) {
137
+ return Object.keys(value).sort().join("\0") === [...expected].sort().join("\0");
138
+ }
139
+
140
+ async function rollbackEntries(transaction) {
141
+ validateTransactionJournal(transaction);
142
+ for (const entry of [...transaction.entries].reverse()) {
143
+ const destinationExists = await exists(entry.path);
144
+ const backupExists = await exists(entry.backup);
145
+ if (backupExists) {
146
+ if (entry.type === "config" && entry.status === "prepared") {
147
+ const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
148
+ if (destinationExists) {
149
+ const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
150
+ await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
151
+ await rm(entry.backup, { force: true });
152
+ } else {
153
+ await mkdir(dirname(entry.path), { recursive: true });
154
+ await rename(entry.backup, entry.path);
155
+ }
156
+ continue;
157
+ }
158
+ if (!entry.existed) throw new Error(`Cannot recover transaction because an unexpected backup exists: ${entry.path}`);
159
+ if (await targetDigest(entry.backup, entry.type) !== entry.previousDigest) {
160
+ throw new Error(`Cannot recover transaction because its durable backup changed: ${entry.path}`);
161
+ }
162
+ if (destinationExists) {
163
+ const current = await targetDigest(entry.path, entry.type);
164
+ if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover transaction because destination changed: ${entry.path}`);
165
+ if (entry.type === "config") {
166
+ const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
167
+ const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
168
+ await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
169
+ await rm(entry.backup, { force: true });
170
+ continue;
171
+ }
172
+ await rm(entry.path, { recursive: true, force: true });
173
+ }
174
+ await mkdir(dirname(entry.path), { recursive: true });
175
+ await rename(entry.backup, entry.path);
176
+ } else if (entry.status === "prepared") {
177
+ continue;
178
+ } else if (!entry.existed) {
179
+ if (destinationExists) {
180
+ const current = await targetDigest(entry.path, entry.type);
181
+ if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover new transaction target because it changed: ${entry.path}`);
182
+ if (entry.type === "config") {
183
+ const preserved = removeCairnConfig((await readSharedConfigSnapshot(entry.path)).text);
184
+ if (preserved.length > 0) await writeFile(entry.path, preserved);
185
+ else await rm(entry.path, { force: true });
186
+ } else await rm(entry.path, { recursive: true, force: true });
187
+ }
188
+ } else {
189
+ if (!destinationExists || await targetDigest(entry.path, entry.type) !== entry.previousDigest) {
190
+ throw new Error(`Cannot recover transaction because its durable backup is missing: ${entry.path}`);
191
+ }
192
+ }
193
+ }
194
+ }
195
+
196
+ return {
197
+ createTransaction, finishTransaction, recoverInterruptedTransaction, rollback,
198
+ writeTransactionJournal,
199
+ };
200
+
201
+ async function exists(path) {
202
+ try { await lstat(path); return true; }
203
+ catch (error) { if (error?.code === "ENOENT") return false; throw error; }
204
+ }
205
+
206
+ function isInside(root, candidate) {
207
+ const path = resolve(candidate);
208
+ return path === root || path.startsWith(`${root}${sep}`);
209
+ }
210
+ }
@@ -0,0 +1,119 @@
1
+ import { lstat, mkdir, rename, rm, writeFile } from "node:fs/promises";
2
+ import { dirname, join } from "node:path";
3
+ import { cairnConfigProjection, readSharedConfigSnapshot, removeCairnConfig } from "./cairn-lifecycle-config.mjs";
4
+
5
+ export function createTransactionTargets(context) {
6
+ const { targetDigest, writeTransactionJournal } = context;
7
+ async function replaceTarget(target, transaction) {
8
+ let configProjection = null;
9
+ if (target.type === "config") {
10
+ const snapshot = await readSharedConfigSnapshot(target.path);
11
+ configProjection = cairnConfigProjection((await readSharedConfigSnapshot(target.staged)).text);
12
+ if (snapshot.digest !== target.configSourceDigest) {
13
+ await writeFile(target.staged, `${removeCairnConfig(snapshot.text)}${configProjection}`);
14
+ target.configSourceDigest = snapshot.digest;
15
+ }
16
+ }
17
+ const existed = await exists(target.path);
18
+ const backup = join(transaction.backupRoot, String(transaction.entries.length));
19
+ await mkdir(dirname(backup), { recursive: true });
20
+ const entry = {
21
+ id: target.id,
22
+ phase: target.phase,
23
+ path: target.path,
24
+ type: target.type,
25
+ backup,
26
+ existed,
27
+ previousDigest: existed ? await targetDigest(target.path, target.type) : "missing",
28
+ expectedNewDigest: await targetDigest(target.staged, target.type),
29
+ operation: "replace",
30
+ status: "prepared",
31
+ };
32
+ transaction.entries.push(entry);
33
+ await writeTransactionJournal(transaction);
34
+ if (process.env.CAIRN_TEST_MUTATE_AFTER_PREFLIGHT_ID === target.id) {
35
+ await writeFile(target.path, process.env.CAIRN_TEST_MUTATION_CONTENT ?? "concurrent mutation\n");
36
+ }
37
+ if (target.type !== "config" && target.preflightDigest !== undefined
38
+ && await targetDigest(target.path, target.type) !== target.preflightDigest) {
39
+ throw new Error(`Managed artifact changed after lifecycle preflight: ${target.path}`);
40
+ }
41
+ if (target.type === "config" && process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE) {
42
+ const snapshot = await readSharedConfigSnapshot(target.path);
43
+ await writeFile(target.path, `${snapshot.text}${process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE}`);
44
+ }
45
+ if (target.type === "config" && process.env.CAIRN_TEST_REMOVE_CONFIG_BEFORE_REPLACE === "1") {
46
+ await rm(target.path, { force: true });
47
+ }
48
+ if (target.type === "config") {
49
+ let capturedExists = true;
50
+ try {
51
+ await renameLifecycleTarget(target.path, backup, target.id);
52
+ } catch (error) {
53
+ if (error?.code !== "ENOENT") throw error;
54
+ capturedExists = false;
55
+ }
56
+ if (process.env.CAIRN_TEST_CRASH_AFTER_CONFIG_CAPTURE === "1") process.exit(86);
57
+ const captured = await readSharedConfigSnapshot(backup);
58
+ if (capturedExists) await writeFile(target.staged, `${removeCairnConfig(captured.text)}${configProjection}`);
59
+ else await writeFile(target.staged, configProjection);
60
+ entry.existed = capturedExists;
61
+ entry.previousDigest = capturedExists ? await targetDigest(backup, target.type) : "missing";
62
+ entry.expectedNewDigest = await targetDigest(target.staged, target.type);
63
+ await writeTransactionJournal(transaction);
64
+ } else if (existed) await renameLifecycleTarget(target.path, backup, target.id);
65
+ await mkdir(dirname(target.path), { recursive: true });
66
+ await renameStagedLifecycleTarget(target.staged, target.path, target.id);
67
+ entry.status = "applied";
68
+ await writeTransactionJournal(transaction);
69
+ }
70
+
71
+ async function renameStagedLifecycleTarget(source, destination, targetId) {
72
+ if (process.env.CAIRN_TEST_FORCE_EXDEV_ID === targetId) {
73
+ throw new Error(`Cross-device replacement is not supported safely for lifecycle target: ${targetId}`);
74
+ }
75
+ await renameLifecycleTarget(source, destination, targetId);
76
+ }
77
+
78
+ async function renameLifecycleTarget(source, destination, targetId) {
79
+ try {
80
+ await rename(source, destination);
81
+ } catch (error) {
82
+ if (error?.code === "EXDEV") {
83
+ throw new Error(`Cross-device replacement is not supported safely for lifecycle target: ${targetId}`);
84
+ }
85
+ throw error;
86
+ }
87
+ }
88
+
89
+ async function removeManagedTarget(target, transaction) {
90
+ if (!(await exists(target.path))) throw new Error(`Managed uninstall target is missing: ${target.path}`);
91
+ const backup = join(transaction.backupRoot, String(transaction.entries.length));
92
+ await mkdir(dirname(backup), { recursive: true });
93
+ const entry = {
94
+ id: target.id,
95
+ phase: target.phase,
96
+ path: target.path,
97
+ type: target.type,
98
+ backup,
99
+ existed: true,
100
+ previousDigest: await targetDigest(target.path, target.type),
101
+ expectedNewDigest: "missing",
102
+ operation: "remove",
103
+ status: "prepared",
104
+ };
105
+ transaction.entries.push(entry);
106
+ await writeTransactionJournal(transaction);
107
+ await renameLifecycleTarget(target.path, backup, target.id);
108
+ entry.status = "applied";
109
+ await writeTransactionJournal(transaction);
110
+ }
111
+
112
+
113
+ return { removeManagedTarget, replaceTarget };
114
+
115
+ async function exists(path) {
116
+ try { await lstat(path); return true; }
117
+ catch (error) { if (error?.code === "ENOENT") return false; throw error; }
118
+ }
119
+ }
@@ -0,0 +1,12 @@
1
+ import { createLifecycleLock, DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS } from "./cairn-lifecycle-lock.mjs";
2
+ import { createTransactionJournal } from "./cairn-lifecycle-transaction-journal.mjs";
3
+ import { createTransactionTargets } from "./cairn-lifecycle-transaction-targets.mjs";
4
+
5
+ export { DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS };
6
+
7
+ export function createTransactionManager(context) {
8
+ const lock = createLifecycleLock(context);
9
+ const journal = createTransactionJournal(context);
10
+ const targets = createTransactionTargets({ ...context, writeTransactionJournal: journal.writeTransactionJournal });
11
+ return { ...journal, ...lock, ...targets };
12
+ }