cairn-ai 0.2.5 → 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 (69) hide show
  1. package/.agents/workflows/cairn-memory.md +2 -2
  2. package/.agents/workflows/cairn-plan.md +7 -3
  3. package/.agents/workflows/cairn-review.md +2 -2
  4. package/.agents/workflows/cairn-work.md +7 -3
  5. package/.claude/agents/explorer.md +2 -2
  6. package/.claude/agents/worker.md +2 -2
  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 +4 -4
  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 +8 -5
  17. package/README.md +10 -7
  18. package/README.pt.md +3 -2
  19. package/README.zh.md +3 -2
  20. package/agents/explorer.md +2 -2
  21. package/agents/worker.md +6 -2
  22. package/commands/cairn-install.md +2 -1
  23. package/commands/cairn-memory.md +2 -2
  24. package/commands/cairn-plan.md +8 -4
  25. package/commands/cairn-review.md +2 -2
  26. package/commands/cairn-uninstall.md +1 -1
  27. package/commands/cairn-work.md +7 -3
  28. package/docs/model-guidance/README.md +16 -4
  29. package/docs/model-guidance/claude.md +2 -0
  30. package/docs/model-guidance/codex.md +4 -2
  31. package/package.json +5 -3
  32. package/scripts/cairn-cleanup.mjs +67 -0
  33. package/scripts/cairn-goal-cli.mjs +233 -0
  34. package/scripts/cairn-goal-evidence.mjs +179 -0
  35. package/scripts/cairn-goal-operations-evidence.mjs +124 -0
  36. package/scripts/cairn-goal-operations-mutations.mjs +63 -0
  37. package/scripts/cairn-goal-operations.mjs +177 -0
  38. package/scripts/cairn-goal-policy.mjs +33 -0
  39. package/scripts/cairn-goal-state-validation.mjs +199 -0
  40. package/scripts/cairn-goal-state.mjs +89 -0
  41. package/scripts/cairn-goal.mjs +34 -961
  42. package/scripts/cairn-lifecycle-commands.mjs +200 -0
  43. package/scripts/cairn-lifecycle-config.mjs +149 -0
  44. package/scripts/cairn-lifecycle-integrity.mjs +218 -0
  45. package/scripts/cairn-lifecycle-legacy.mjs +142 -0
  46. package/scripts/cairn-lifecycle-lock.mjs +205 -0
  47. package/scripts/cairn-lifecycle-mirror.mjs +91 -0
  48. package/scripts/cairn-lifecycle-staging.mjs +152 -0
  49. package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
  50. package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
  51. package/scripts/cairn-lifecycle-transaction.mjs +12 -0
  52. package/scripts/cairn-lifecycle.mjs +103 -1185
  53. package/scripts/cairn-paths.mjs +82 -6
  54. package/scripts/cairn-safe-fs.mjs +61 -35
  55. package/scripts/cairn-state-context.mjs +148 -0
  56. package/scripts/cairn-state-hook.mjs +24 -0
  57. package/scripts/cairn-state-init.mjs +137 -0
  58. package/scripts/cairn-state.mjs +15 -269
  59. package/scripts/cairn-toolcheck-detect.mjs +229 -0
  60. package/scripts/cairn-toolcheck-runtime.mjs +178 -0
  61. package/scripts/cairn-toolcheck.mjs +79 -424
  62. package/scripts/cairn.mjs +49 -27
  63. package/skills/cairn-memory/SKILL.md +4 -4
  64. package/skills/cairn-plan/SKILL.md +29 -10
  65. package/skills/cairn-review/SKILL.md +5 -5
  66. package/skills/cairn-work/SKILL.md +35 -22
  67. package/templates/PLAN.md +3 -1
  68. package/templates/work-plan.md +21 -0
  69. package/scripts/cairn-state.sh +0 -167
@@ -0,0 +1,142 @@
1
+ import { createHash } from "node:crypto";
2
+ import { cp, lstat, mkdir, mkdtemp, readFile, rm } from "node:fs/promises";
3
+ import { tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ import { cairnConfigProjection, readSharedConfigSnapshot } from "./cairn-lifecycle-config.mjs";
6
+ import {
7
+ antigravityRuntimeLocatorPath, antigravitySkillLocatorPath, renderMirrorFile,
8
+ validLegacyLocator, writeLocator,
9
+ } from "./cairn-lifecycle-mirror.mjs";
10
+
11
+ export function createLegacyIntegrity(context) {
12
+ const {
13
+ antigravityCliHome, antigravityHome, claudeRuntimeLocatorPath,
14
+ installedPluginRoot, legacyAntigravityHome, marketplaceName, pluginName,
15
+ pluginRoot, regularFiles, skillNames, targetDigest,
16
+ } = context;
17
+ async function adoptLegacy(targets, transaction) {
18
+ if (!(await exists(installedPluginRoot))) throw new Error("Existing lifecycle artifacts are unmanaged; legacy Cairn root is missing.");
19
+ await verifyLegacy022Root(installedPluginRoot);
20
+ const records = [];
21
+ for (const target of targets) {
22
+ if (!(await exists(target.path))) continue;
23
+ if (target.id === "codex-runtime") continue;
24
+ if (target.id === "marketplace") {
25
+ const marketplace = await readJson(target.path);
26
+ const entry = marketplace?.plugins?.find((plugin) => plugin.name === pluginName);
27
+ if (marketplace?.name !== marketplaceName || entry?.source?.source !== "local" || entry?.source?.path !== "./plugins/cairn") {
28
+ throw new Error(`Legacy Cairn marketplace cannot be identified: ${target.path}`);
29
+ }
30
+ }
31
+ if (target.type === "config") {
32
+ const projection = cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text);
33
+ if (!projection.includes("[marketplaces.cairn]") || !projection.includes('[plugins."cairn@cairn"]')) {
34
+ throw new Error(`Legacy Cairn config cannot be identified: ${target.path}`);
35
+ }
36
+ } else if (target.id.endsWith("-runtime")) {
37
+ const locator = await readJson(target.path);
38
+ if (!validLegacyLocator(locator, installedPluginRoot)) throw new Error(`Legacy runtime mirror is invalid: ${target.path}`);
39
+ } else if (target.id !== "codex-source" && target.id !== "marketplace") {
40
+ await verifyLegacyMirror(target);
41
+ }
42
+ const backup = join(transaction.backupRoot, `legacy-${records.length}`);
43
+ await mkdir(dirname(backup), { recursive: true });
44
+ await cp(target.path, backup, { recursive: true });
45
+ records.push({ id: target.id, phase: target.phase, path: target.path, type: target.type, installedDigest: await targetDigest(target.path, target.type), previous: null });
46
+ }
47
+ return { schemaVersion: 1, plugin: pluginName, version: "0.2.2", transactionId: `legacy-${transaction.id}`, targets: records };
48
+ }
49
+
50
+ async function verifyLegacy022Root(root) {
51
+ const integrity = JSON.parse(await readFile(join(pluginRoot, "scripts", "release-integrity-0.2.2.json"), "utf8"));
52
+ const allowedGenerated = new Set([".cairn-runtime.json", ...skillNames().map((name) => `skills/${name}/references/cairn-runtime.json`)]);
53
+ const actual = await regularFiles(root);
54
+ const expected = new Set([...Object.keys(integrity.files), ...allowedGenerated]);
55
+ if (actual.length !== expected.size || actual.some((file) => !expected.has(file))) throw new Error("Legacy Cairn 0.2.2 root file set does not match the pinned SHA-256 file-hash allowlist.");
56
+ for (const [path, expectedHash] of Object.entries(integrity.files)) {
57
+ const content = await readFile(join(root, path));
58
+ if (path === ".codex-plugin/plugin.json") {
59
+ const manifest = JSON.parse(content);
60
+ if (manifest.name !== "cairn" || manifest.version !== "0.2.2" || manifest.hooks !== "./hooks/hooks.json") throw new Error("Legacy Cairn manifest identity is invalid.");
61
+ delete manifest.hooks;
62
+ if (sha(`${JSON.stringify(manifest, null, 2)}\n`) !== `sha256:${expectedHash}`) throw new Error("Legacy Cairn manifest differs from release 0.2.2.");
63
+ } else if (sha(content) !== `sha256:${expectedHash}`) throw new Error(`Legacy Cairn release file was modified: ${path}`);
64
+ }
65
+ for (const path of allowedGenerated) {
66
+ const locator = JSON.parse(await readFile(join(root, path), "utf8"));
67
+ if (!validLegacyLocator(locator, root)) throw new Error(`Legacy runtime locator is invalid: ${path}`);
68
+ }
69
+ }
70
+
71
+ async function verifyLegacyMirror(target) {
72
+ // Legacy mirrors are accepted only when byte-identical to a deterministic render
73
+ // from the verified release tree. Unknown or user-modified mirrors are preserved.
74
+ const temp = await mkdtemp(join(tmpdir(), "cairn-legacy-mirror-"));
75
+ try {
76
+ const expected = join(temp, "expected");
77
+ if (target.id.startsWith("claude-command-")) {
78
+ const name = target.id.slice("claude-command-".length);
79
+ await renderMirrorFile(join(installedPluginRoot, ".claude", "commands", `cairn-${name}.md`), expected, { locatorPath: claudeRuntimeLocatorPath, runtimeRoot: installedPluginRoot });
80
+ } else if (target.id.startsWith("claude-agent-")) {
81
+ const name = target.id.slice("claude-agent-".length);
82
+ await renderMirrorFile(join(installedPluginRoot, ".claude", "agents", `${name}.md`), expected, { locatorPath: claudeRuntimeLocatorPath, runtimeRoot: installedPluginRoot });
83
+ } else if (target.id.startsWith("ide-skill-")) {
84
+ const name = target.id.slice("ide-skill-".length);
85
+ await cp(join(installedPluginRoot, "skills", name), expected, { recursive: true });
86
+ const locator = antigravitySkillLocatorPath(antigravityHome, name);
87
+ await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), join(expected, "SKILL.md"), { locatorPath: locator, runtimeRoot: installedPluginRoot, includeLocatorNotice: true });
88
+ await writeLocator(join(expected, "references", "cairn-runtime.json"), installedPluginRoot);
89
+ } else if (target.id.startsWith("cli-skill-")) {
90
+ const name = target.id.slice("cli-skill-".length);
91
+ await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), expected, {
92
+ locatorPath: antigravityRuntimeLocatorPath(antigravityCliHome),
93
+ runtimeRoot: installedPluginRoot,
94
+ includeLocatorNotice: true,
95
+ });
96
+ } else if (target.id.startsWith("legacy-ide-skill-") || target.id.startsWith("legacy-cli-skill-")) {
97
+ const isIde = target.id.startsWith("legacy-ide-skill-");
98
+ const prefix = isIde ? "legacy-ide-skill-" : "legacy-cli-skill-";
99
+ const name = target.id.slice(prefix.length);
100
+ const root = isIde ? legacyAntigravityHome : antigravityCliHome;
101
+ const locator = antigravitySkillLocatorPath(root, name);
102
+ await cp(join(installedPluginRoot, "skills", name), expected, { recursive: true });
103
+ await renderMirrorFile(join(installedPluginRoot, "skills", name, "SKILL.md"), join(expected, "SKILL.md"), { locatorPath: locator, runtimeRoot: installedPluginRoot, includeLocatorNotice: true });
104
+ await writeLocator(join(expected, "references", "cairn-runtime.json"), installedPluginRoot);
105
+ } else if (target.id.startsWith("legacy-ide-workflow-")) {
106
+ const name = target.id.slice("legacy-ide-workflow-".length);
107
+ await renderMirrorFile(join(installedPluginRoot, ".agents", "workflows", `cairn-${name}.md`), expected, {
108
+ locatorPath: antigravityRuntimeLocatorPath(legacyAntigravityHome),
109
+ runtimeRoot: installedPluginRoot,
110
+ });
111
+ } else if (target.id.startsWith("ide-workflow-") || target.id.startsWith("cli-workflow-")) {
112
+ const isIde = target.id.startsWith("ide-workflow-");
113
+ const prefix = isIde ? "ide-workflow-" : "cli-workflow-";
114
+ const name = target.id.slice(prefix.length);
115
+ const root = isIde ? antigravityHome : antigravityCliHome;
116
+ await renderMirrorFile(join(installedPluginRoot, ".agents", "workflows", `cairn-${name}.md`), expected, { locatorPath: antigravityRuntimeLocatorPath(root), runtimeRoot: installedPluginRoot });
117
+ } else {
118
+ throw new Error(`Legacy mirror target is not recognized: ${target.id}`);
119
+ }
120
+ if (await targetDigest(expected, "file") !== await targetDigest(target.path, target.type)) throw new Error(`Legacy mirror was modified: ${target.path}`);
121
+ } finally {
122
+ await rm(temp, { recursive: true, force: true });
123
+ }
124
+ }
125
+
126
+
127
+ return { adoptLegacy, verifyLegacy022Root };
128
+
129
+ function sha(value) {
130
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
131
+ }
132
+
133
+ async function readJson(path) {
134
+ if (!(await exists(path))) return null;
135
+ return JSON.parse(await readFile(path, "utf8"));
136
+ }
137
+
138
+ async function exists(path) {
139
+ try { await lstat(path); return true; }
140
+ catch (error) { if (error?.code === "ENOENT") return false; throw error; }
141
+ }
142
+ }
@@ -0,0 +1,205 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { lstat, mkdir, readFile, readdir, rename, rm, rmdir, writeFile } from "node:fs/promises";
3
+ import { hostname } from "node:os";
4
+ import { basename, dirname, join } from "node:path";
5
+
6
+ export const DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS = 60_000;
7
+
8
+ export function createLifecycleLock({ lifecycleLockPath }) {
9
+ async function withLifecycleLock(operation, {
10
+ timeoutMs = DEFAULT_LIFECYCLE_LOCK_TIMEOUT_MS,
11
+ retryMs = 25,
12
+ malformedStaleMs = 30_000,
13
+ afterRelease,
14
+ testHooks = {},
15
+ } = {}) {
16
+ await mkdir(dirname(lifecycleLockPath), { recursive: true });
17
+ if (process.env.CAIRN_TEST_PRUNE_LOCK_PARENT_BEFORE_ACQUIRE === "1") await rmdir(dirname(lifecycleLockPath));
18
+ const owner = { schemaVersion: 1, pid: process.pid, hostname: hostname(), nonce: randomUUID(), acquiredAt: new Date().toISOString() };
19
+ const deadline = Date.now() + timeoutMs;
20
+ let waitReported = false;
21
+ while (true) {
22
+ let acquired = false;
23
+ try {
24
+ await writeFile(lifecycleLockPath, `${JSON.stringify(owner)}\n`, { flag: "wx" });
25
+ await testHooks.afterLockWritten?.({ owner: structuredClone(owner) });
26
+ if (process.env.CAIRN_TEST_REPLACE_LOCK_AFTER_ACQUIRE === "1") {
27
+ await writeFile(lifecycleLockPath, `${JSON.stringify({ ...owner, nonce: randomUUID() })}\n`);
28
+ }
29
+ const written = parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"));
30
+ if (written?.nonce !== owner.nonce) throw new Error("Cairn lifecycle lock ownership changed after acquisition");
31
+ acquired = !(await activeReclaimClaimExists());
32
+ } catch (error) {
33
+ if (error?.code === "ENOENT") {
34
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
35
+ await mkdir(dirname(lifecycleLockPath), { recursive: true });
36
+ continue;
37
+ }
38
+ if (error?.code !== "EEXIST") throw error;
39
+ if (!waitReported && process.env.CAIRN_TEST_REPORT_LOCK_WAIT === "1") {
40
+ console.log("CAIRN_TEST_LOCK_WAIT");
41
+ waitReported = true;
42
+ }
43
+ await testHooks.beforeContendedLockConfirmation?.();
44
+ acquired = await confirmsUnclaimedLifecycleLockOwnership(owner.nonce);
45
+ if (!acquired && await reclaimLifecycleLock(owner, malformedStaleMs, testHooks)) continue;
46
+ }
47
+ if (acquired) {
48
+ if (process.env.CAIRN_TEST_HOLD_LOCK_STDIN === "1") {
49
+ console.log("CAIRN_TEST_LOCK_ACQUIRED");
50
+ await new Promise((resolvePromise) => process.stdin.once("data", resolvePromise));
51
+ }
52
+ break;
53
+ }
54
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn lifecycle lock after ${timeoutMs}ms`);
55
+ await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
56
+ }
57
+ let operationSucceeded = false;
58
+ try {
59
+ const result = await operation();
60
+ operationSucceeded = true;
61
+ return result;
62
+ } finally {
63
+ if (process.env.CAIRN_TEST_REPLACE_LOCK_BEFORE_RELEASE === "1") {
64
+ await writeFile(lifecycleLockPath, `${JSON.stringify({ ...owner, nonce: randomUUID() })}\n`);
65
+ }
66
+ const released = await releaseOwnedLock(lifecycleLockPath, owner);
67
+ if (operationSucceeded && released && afterRelease) await afterRelease();
68
+ }
69
+ }
70
+
71
+ async function releaseOwnedLock(path, owner) {
72
+ let current;
73
+ try {
74
+ current = parseLifecycleLock(await readFile(path, "utf8"));
75
+ } catch (error) {
76
+ if (error?.code === "ENOENT") return false;
77
+ throw error;
78
+ }
79
+ if (current?.nonce !== owner.nonce) return false;
80
+ const releasedPath = `${path}.released.${owner.nonce}`;
81
+ try {
82
+ await rename(path, releasedPath);
83
+ } catch (error) {
84
+ if (error?.code === "ENOENT") return false;
85
+ throw error;
86
+ }
87
+ const released = parseLifecycleLock(await readFile(releasedPath, "utf8"));
88
+ if (released?.nonce !== owner.nonce) {
89
+ if (!(await exists(path))) await rename(releasedPath, path);
90
+ return false;
91
+ }
92
+ await rm(releasedPath, { force: true });
93
+ return true;
94
+ }
95
+
96
+ async function reclaimLifecycleLock(owner, malformedStaleMs, testHooks) {
97
+ let observed;
98
+ let info;
99
+ try {
100
+ [observed, info] = await Promise.all([readFile(lifecycleLockPath, "utf8"), lstat(lifecycleLockPath)]);
101
+ } catch (error) {
102
+ if (error?.code === "ENOENT") return true;
103
+ throw error;
104
+ }
105
+ if (info.isSymbolicLink()) throw new Error(`Lifecycle lock must not be a symlink: ${lifecycleLockPath}`);
106
+ const record = parseLifecycleLock(observed);
107
+ if (!isReclaimableLifecycleLock(record, info, malformedStaleMs)) return false;
108
+ await testHooks.afterStaleObserved?.();
109
+
110
+ const claimPath = `${lifecycleLockPath}.reclaim.${owner.nonce}`;
111
+ await writeFile(claimPath, `${JSON.stringify(owner)}\n`, { flag: "wx" });
112
+ try {
113
+ await testHooks.afterReclaimInterlock?.();
114
+ let claimed;
115
+ let claimedInfo;
116
+ try {
117
+ [claimed, claimedInfo] = await Promise.all([readFile(lifecycleLockPath, "utf8"), lstat(lifecycleLockPath)]);
118
+ } catch (error) {
119
+ if (error?.code === "ENOENT") return true;
120
+ throw error;
121
+ }
122
+ if (claimedInfo.isSymbolicLink()) throw new Error(`Lifecycle lock must not be a symlink: ${lifecycleLockPath}`);
123
+ const claimedRecord = parseLifecycleLock(claimed);
124
+ if (!isReclaimableLifecycleLock(claimedRecord, claimedInfo, malformedStaleMs)) return false;
125
+
126
+ const quarantine = `${lifecycleLockPath}.stale.${claimedRecord?.nonce ?? "malformed"}.${randomUUID()}`;
127
+ try {
128
+ await rename(lifecycleLockPath, quarantine);
129
+ } catch (error) {
130
+ if (error?.code === "ENOENT") return true;
131
+ throw error;
132
+ }
133
+ if (await readFile(quarantine, "utf8") !== claimed) {
134
+ if (!(await exists(lifecycleLockPath))) await rename(quarantine, lifecycleLockPath);
135
+ throw new Error("Cairn lifecycle lock ownership changed during stale recovery");
136
+ }
137
+ await rm(quarantine, { force: true });
138
+ return true;
139
+ } finally {
140
+ await releaseOwnedLock(claimPath, owner);
141
+ }
142
+ }
143
+
144
+ function isReclaimableLifecycleLock(record, info, malformedStaleMs) {
145
+ if (record) return record.hostname === hostname() && !pidIsAlive(record.pid);
146
+ return Date.now() - info.mtimeMs > malformedStaleMs;
147
+ }
148
+
149
+ async function confirmsUnclaimedLifecycleLockOwnership(nonce) {
150
+ if (await activeReclaimClaimExists()) return false;
151
+ try {
152
+ return parseLifecycleLock(await readFile(lifecycleLockPath, "utf8"))?.nonce === nonce;
153
+ } catch (error) {
154
+ if (error?.code === "ENOENT") return false;
155
+ throw error;
156
+ }
157
+ }
158
+
159
+ async function activeReclaimClaimExists() {
160
+ const claimNames = (await readdir(dirname(lifecycleLockPath)))
161
+ .filter((name) => name.startsWith(`${basename(lifecycleLockPath)}.reclaim.`));
162
+ for (const name of claimNames) {
163
+ const claimPath = join(dirname(lifecycleLockPath), name);
164
+ let info;
165
+ let record;
166
+ try {
167
+ [info, record] = await Promise.all([lstat(claimPath), readFile(claimPath, "utf8").then(parseLifecycleLock)]);
168
+ } catch (error) {
169
+ if (error?.code === "ENOENT") continue;
170
+ throw error;
171
+ }
172
+ if (info.isSymbolicLink()) throw new Error(`Lifecycle reclaim claim must not be a symlink: ${claimPath}`);
173
+ if (record?.hostname === hostname() && !pidIsAlive(record.pid)) {
174
+ await releaseOwnedLock(claimPath, record);
175
+ continue;
176
+ }
177
+ return true;
178
+ }
179
+ return false;
180
+ }
181
+
182
+ function parseLifecycleLock(text) {
183
+ try {
184
+ const value = JSON.parse(text);
185
+ if (value?.schemaVersion !== 1 || !Number.isSafeInteger(value.pid) || value.pid <= 0
186
+ || typeof value.hostname !== "string" || value.hostname.length === 0 || typeof value.nonce !== "string" || value.nonce.length === 0
187
+ || typeof value.acquiredAt !== "string" || Number.isNaN(Date.parse(value.acquiredAt))) return null;
188
+ return value;
189
+ } catch { return null; }
190
+ }
191
+
192
+ function pidIsAlive(pid) {
193
+ try { process.kill(pid, 0); return true; } catch (error) { return error?.code !== "ESRCH"; }
194
+ }
195
+
196
+ function delay(ms) { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); }
197
+
198
+
199
+ return { withLifecycleLock };
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
+ }
@@ -0,0 +1,91 @@
1
+ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
2
+ import { dirname, join, posix, resolve, win32 } from "node:path";
3
+ import {
4
+ RUNTIME_LOCATOR_SCHEMA_VERSION,
5
+ createRuntimeLocator,
6
+ runtimeRequiredPaths,
7
+ } from "./cairn-paths.mjs";
8
+
9
+ export function antigravityRuntimeLocatorPath(root) {
10
+ return join(root, "cairn", "runtime.json");
11
+ }
12
+
13
+ export function installedSkillLocatorPath(runtimeRoot, name) {
14
+ return join(runtimeRoot, "skills", name, "references", "cairn-runtime.json");
15
+ }
16
+
17
+ export function antigravitySkillLocatorPath(root, name) {
18
+ return join(root, "skills", name, "references", "cairn-runtime.json");
19
+ }
20
+
21
+ export async function writeLocator(path, root) {
22
+ await mkdir(dirname(path), { recursive: true });
23
+ await writeFile(path, `${JSON.stringify(createRuntimeLocator(root), null, 2)}\n`);
24
+ }
25
+
26
+ export function validLegacyLocator(locator, root) {
27
+ const expected = createRuntimeLocator(root);
28
+ return locator?.schemaVersion === RUNTIME_LOCATOR_SCHEMA_VERSION
29
+ && locator.pluginRoot === expected.pluginRoot
30
+ && JSON.stringify(locator.entrypoints) === JSON.stringify(expected.entrypoints)
31
+ && JSON.stringify(locator.resources) === JSON.stringify(expected.resources);
32
+ }
33
+
34
+ export async function validRuntimeLocator(path, runtimeRoot) {
35
+ try {
36
+ const locator = JSON.parse(await readFile(path, "utf8"));
37
+ const expected = createRuntimeLocator(runtimeRoot);
38
+ if (locator?.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION || resolve(locator.pluginRoot ?? ".") !== resolve(runtimeRoot)) return false;
39
+ for (const [name, value] of Object.entries(expected.entrypoints)) if (locator.entrypoints?.[name] !== value) return false;
40
+ for (const [name, value] of Object.entries(expected.resources)) if (locator.resources?.[name] !== value) return false;
41
+ return (await Promise.all(runtimeRequiredPaths(locator).map(exists))).every(Boolean);
42
+ } catch {
43
+ return false;
44
+ }
45
+ }
46
+
47
+ export async function renderMirrorFile(source, destination, { locatorPath, runtimeRoot, includeLocatorNotice = false }) {
48
+ const content = await readFile(source, "utf8");
49
+ const notice = includeLocatorNotice ? `\n\n## Installed Cairn runtime\n\nRead the structured runtime locator at \`${JSON.stringify(locatorPath)}\`. Resolve Cairn scripts and static resources from its absolute paths, never from the target project.\n` : "";
50
+ await mkdir(dirname(destination), { recursive: true });
51
+ await writeFile(destination, `${renderInstalledMirror(content, { runtimeRoot, locatorPath }).trimEnd()}${notice}`);
52
+ }
53
+
54
+ export function renderInstalledMirror(content, { runtimeRoot, locatorPath, platform = process.platform }) {
55
+ const platformJoin = platform === "win32" ? win32.join : posix.join;
56
+ const quotedCli = quoteShellArg(platformJoin(runtimeRoot, "scripts", "cairn.mjs"), platform);
57
+ let rendered = content.replaceAll("{{CAIRN_RUNTIME_LOCATOR_JSON}}", JSON.stringify(locatorPath))
58
+ .replace(/\bnode\s+scripts\/cairn\.mjs\b/g, `node ${quotedCli}`)
59
+ .replace(/\bnode\s+scripts\/([A-Za-z0-9._/-]+)/g, (_, path) => `node ${quoteShellArg(platformJoin(runtimeRoot, "scripts", path), platform)}`);
60
+ rendered = rendered.replace(/(^|[\s`("'=])(commands|agents|templates|docs\/model-guidance)\/([A-Za-z0-9._<>/-]+)/gm,
61
+ (_, prefix, directory, suffix) => `${prefix}${platformJoin(runtimeRoot, directory, suffix)}`);
62
+ return rendered;
63
+ }
64
+
65
+ export function quoteShellArg(value, platform = process.platform) {
66
+ return platform === "win32" ? `"${value.replaceAll('"', '""')}"` : `'${value.replaceAll("'", "'\\''")}'`;
67
+ }
68
+
69
+ export async function validateAntigravitySkills(root, skillNames, runtimeRoot) {
70
+ try {
71
+ if (!(await validRuntimeLocator(antigravityRuntimeLocatorPath(root), runtimeRoot))) return false;
72
+ for (const name of skillNames) {
73
+ const content = await readFile(join(root, "skills", `${name}.md`), "utf8");
74
+ if (!/^---\r?\n[\s\S]*?\r?\n---\r?\n/.test(content) || !/^name:\s*\S+/m.test(content) || !/^description:\s*\S+/m.test(content)) return false;
75
+ if (!content.includes(JSON.stringify(antigravityRuntimeLocatorPath(root)))) return false;
76
+ }
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ async function exists(path) {
84
+ try {
85
+ await lstat(path);
86
+ return true;
87
+ } catch (error) {
88
+ if (error?.code === "ENOENT") return false;
89
+ throw error;
90
+ }
91
+ }
@@ -0,0 +1,152 @@
1
+ import { copyFile, cp, lstat, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
2
+ import { dirname, isAbsolute, join, relative, sep } from "node:path";
3
+ import { readSharedConfigSnapshot, trustedHookStates } from "./cairn-lifecycle-config.mjs";
4
+ import {
5
+ antigravityRuntimeLocatorPath, antigravitySkillLocatorPath, renderMirrorFile, writeLocator,
6
+ } from "./cairn-lifecycle-mirror.mjs";
7
+
8
+ export function createStaging(context) {
9
+ const {
10
+ agentNames, antigravityCliHome, antigravityHome, claudeHome,
11
+ claudeRuntimeLocatorPath, commandNames, configPath, installedPluginRoot,
12
+ legacyAntigravityHome, marketplaceJsonPath, marketplaceName, pluginName,
13
+ pluginRoot, skillNames, updateConfig, versionedPluginRoot, assertRegularTree,
14
+ } = context;
15
+ async function stageInstall(stageRoot) {
16
+ const targets = [];
17
+ const add = (id, phase, destination, staged, type = "tree", details = {}) => targets.push({ id, phase, path: destination, staged, type, ...details });
18
+ const sourceStage = join(stageRoot, "codex-source");
19
+ const runtimeStage = join(stageRoot, "codex-runtime");
20
+ await copyPluginCandidate(sourceStage, versionedPluginRoot);
21
+ await copyPluginCandidate(runtimeStage, versionedPluginRoot);
22
+ add("codex-source", "codex", installedPluginRoot, sourceStage);
23
+ add("codex-runtime", "codex", versionedPluginRoot, runtimeStage);
24
+
25
+ const marketplaceStage = join(stageRoot, "marketplace.json");
26
+ await writeFile(marketplaceStage, `${JSON.stringify({
27
+ name: marketplaceName,
28
+ interface: { displayName: "Cairn" },
29
+ plugins: [{ name: pluginName, source: { source: "local", path: "./plugins/cairn" }, policy: { installation: "AVAILABLE", authentication: "ON_INSTALL" }, category: "Productivity" }],
30
+ }, null, 2)}\n`);
31
+ add("marketplace", "codex", marketplaceJsonPath, marketplaceStage, "file");
32
+
33
+ const claudeLocator = join(stageRoot, "claude-runtime.json");
34
+ await writeLocator(claudeLocator, versionedPluginRoot);
35
+ add("claude-runtime", "claude", claudeRuntimeLocatorPath, claudeLocator, "file");
36
+ for (const name of commandNames()) {
37
+ const staged = join(stageRoot, "claude-commands", `cairn-${name}.md`);
38
+ await renderMirrorFile(join(runtimeStage, ".claude", "commands", `cairn-${name}.md`), staged, { locatorPath: claudeRuntimeLocatorPath, runtimeRoot: versionedPluginRoot });
39
+ add(`claude-command-${name}`, "claude", join(claudeHome, "commands", `cairn-${name}.md`), staged, "file");
40
+ }
41
+ for (const name of agentNames()) {
42
+ const staged = join(stageRoot, "claude-agents", `cairn-${name}.md`);
43
+ await renderMirrorFile(join(runtimeStage, ".claude", "agents", `${name}.md`), staged, { locatorPath: claudeRuntimeLocatorPath, runtimeRoot: versionedPluginRoot });
44
+ add(`claude-agent-${name}`, "claude", join(claudeHome, "agents", `cairn-${name}.md`), staged, "file");
45
+ }
46
+
47
+ await stageAntigravity(targets, stageRoot, runtimeStage, antigravityHome, "ide", false);
48
+ await stageAntigravity(targets, stageRoot, runtimeStage, antigravityCliHome, "cli", true);
49
+
50
+ const configStage = join(stageRoot, "config.toml");
51
+ const hookStates = await trustedHookStates(runtimeStage);
52
+ const configSnapshot = await readSharedConfigSnapshot(configPath);
53
+ await writeFile(configStage, updateConfig(configSnapshot.text, hookStates));
54
+ add("codex-config", "config", configPath, configStage, "config", { configSourceDigest: configSnapshot.digest });
55
+ for (const target of targets) await assertRegularTree(target.staged);
56
+ return targets;
57
+ }
58
+
59
+ async function stageAntigravity(targets, stageRoot, runtimeStage, destinationRoot, label, flatSkills) {
60
+ const locatorPath = antigravityRuntimeLocatorPath(destinationRoot);
61
+ const locatorStage = join(stageRoot, `${label}-runtime.json`);
62
+ await writeLocator(locatorStage, versionedPluginRoot);
63
+ targets.push({ id: `${label}-runtime`, phase: "antigravity", path: locatorPath, staged: locatorStage, type: "file" });
64
+ for (const name of skillNames()) {
65
+ if (flatSkills) {
66
+ const staged = join(stageRoot, `${label}-skills`, `${name}.md`);
67
+ await renderMirrorFile(join(runtimeStage, "skills", name, "SKILL.md"), staged, { locatorPath, runtimeRoot: versionedPluginRoot, includeLocatorNotice: true });
68
+ targets.push({ id: `${label}-skill-${name}`, phase: "antigravity", path: join(destinationRoot, "skills", `${name}.md`), staged, type: "file" });
69
+ } else {
70
+ const staged = join(stageRoot, `${label}-skills`, name);
71
+ await cp(join(runtimeStage, "skills", name), staged, { recursive: true });
72
+ await renderMirrorFile(join(runtimeStage, "skills", name, "SKILL.md"), join(staged, "SKILL.md"), {
73
+ locatorPath: antigravitySkillLocatorPath(destinationRoot, name),
74
+ runtimeRoot: versionedPluginRoot,
75
+ includeLocatorNotice: true,
76
+ });
77
+ await writeLocator(join(staged, "references", "cairn-runtime.json"), versionedPluginRoot);
78
+ targets.push({ id: `${label}-skill-${name}`, phase: "antigravity", path: join(destinationRoot, "skills", name), staged, type: "tree" });
79
+ }
80
+ }
81
+ for (const name of commandNames()) {
82
+ const staged = join(stageRoot, `${label}-workflows`, `cairn-${name}.md`);
83
+ await renderMirrorFile(join(runtimeStage, ".agents", "workflows", `cairn-${name}.md`), staged, { locatorPath, runtimeRoot: versionedPluginRoot });
84
+ targets.push({ id: `${label}-workflow-${name}`, phase: "antigravity", path: join(destinationRoot, "workflows", `cairn-${name}.md`), staged, type: "file" });
85
+ }
86
+ }
87
+
88
+ async function legacyMigrationTargets() {
89
+ const candidates = [{ id: "legacy-ide-runtime", phase: "antigravity", path: antigravityRuntimeLocatorPath(legacyAntigravityHome), type: "file", operation: "remove" }];
90
+ for (const name of skillNames()) {
91
+ candidates.push({ id: `legacy-ide-skill-${name}`, phase: "antigravity", path: join(legacyAntigravityHome, "skills", name), type: "tree", operation: "remove" });
92
+ candidates.push({ id: `legacy-cli-skill-${name}`, phase: "antigravity", path: join(antigravityCliHome, "skills", name), type: "tree", operation: "remove" });
93
+ }
94
+ for (const name of commandNames()) candidates.push({ id: `legacy-ide-workflow-${name}`, phase: "antigravity", path: join(legacyAntigravityHome, "workflows", `cairn-${name}.md`), type: "file", operation: "remove" });
95
+ const present = [];
96
+ for (const candidate of candidates) if (await exists(candidate.path)) present.push(candidate);
97
+ return present;
98
+ }
99
+
100
+ async function copyPluginCandidate(destination, runtimeRoot) {
101
+ await copyPluginTree(pluginRoot, destination);
102
+ const manifestPath = join(destination, ".codex-plugin", "plugin.json");
103
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
104
+ manifest.hooks = "./hooks/hooks.json";
105
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`);
106
+ await writeLocator(join(destination, ".cairn-runtime.json"), runtimeRoot);
107
+ for (const name of skillNames()) await writeLocator(join(destination, "skills", name, "references", "cairn-runtime.json"), runtimeRoot);
108
+ }
109
+
110
+ async function copyPluginTree(source, destination) {
111
+ if (!shouldCopyPluginPath(source, pluginRoot)) return;
112
+ const info = await lstat(source);
113
+ if (info.isSymbolicLink() || (!info.isDirectory() && !info.isFile())) {
114
+ throw new Error(`Plugin source must contain only regular files and directories: ${source}`);
115
+ }
116
+ if (info.isFile()) {
117
+ await mkdir(dirname(destination), { recursive: true });
118
+ await copyFile(source, destination);
119
+ return;
120
+ }
121
+ await mkdir(destination, { recursive: true });
122
+ const entries = await readdir(source);
123
+ entries.sort();
124
+ for (const entry of entries) await copyPluginTree(join(source, entry), join(destination, entry));
125
+ }
126
+
127
+
128
+ return { legacyMigrationTargets, stageInstall };
129
+
130
+ async function exists(path) {
131
+ try { await lstat(path); return true; }
132
+ catch (error) { if (error?.code === "ENOENT") return false; throw error; }
133
+ }
134
+ }
135
+
136
+ export function pathHasSegment(path, segment) { return path.split(/[\\/]+/).includes(segment); }
137
+
138
+ export function shouldCopyPluginPath(source, pluginRoot) {
139
+ const name = source.split(/[\\/]+/).at(-1);
140
+ if ([".git", "node_modules", ".cairn"].includes(name)) return false;
141
+ if (sep === "/" && source.includes("\\")) return true;
142
+ const relativePath = relative(pluginRoot, source);
143
+ if (isAbsolute(relativePath)) return true;
144
+ const path = relativePath.split(sep).join("/");
145
+ if (path === "") return true;
146
+ if (path.startsWith("../") || path === "..") return true;
147
+ if (/^(package\.json|LICENSE|README(?:\.[^.]+)?\.md)$/.test(path)) return true;
148
+ if (path === "docs") return true;
149
+ if (path.startsWith("docs/")) return path === "docs/model-guidance" || path.startsWith("docs/model-guidance/");
150
+ return [".agents", ".claude", ".codex-plugin", "agents", "commands", "hooks", "scripts", "skills", "templates"]
151
+ .some((directory) => path === directory || path.startsWith(`${directory}/`));
152
+ }