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,200 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { lstat, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { dirname, join } from "node:path";
4
+ import { hasSetting, readSharedConfigSnapshot, removeCairnConfig, splitSections } from "./cairn-lifecycle-config.mjs";
5
+ import {
6
+ installedSkillLocatorPath, validRuntimeLocator, validateAntigravitySkills,
7
+ } from "./cairn-lifecycle-mirror.mjs";
8
+
9
+ export function createCommands(context) {
10
+ const {
11
+ antigravityCliHome, antigravityHome, claudeRuntimeLocatorPath,
12
+ codexHome, configPath, installedPluginRoot, installedRuntimeLocatorPath,
13
+ marketplaceJsonPath, ownershipPath, phases, pluginName, pluginRoot,
14
+ pruneUninstallScaffolds, releaseVersion, samePath, skillNames, t, transaction,
15
+ versionedPluginRoot, staging, integrity,
16
+ } = context;
17
+ const {
18
+ createTransaction, finishTransaction, recoverInterruptedTransaction,
19
+ removeManagedTarget, replaceTarget, rollback,
20
+ } = transaction;
21
+ const { legacyMigrationTargets, stageInstall } = staging;
22
+ const {
23
+ adoptLegacy, anyManagedArtifactExists, assertOwnershipDigests,
24
+ assertTargetsReplaceable, ownershipDigestsValid, readOwnership, targetDigest,
25
+ targetRecord, validateOwnershipShape, versionedRuntimeRoot,
26
+ } = integrity;
27
+ async function install(mode) {
28
+ if (samePath(pluginRoot, installedPluginRoot) || samePath(pluginRoot, versionedPluginRoot)) {
29
+ throw new Error("Run install or upgrade from the Cairn package/global source, not from the installed cache.");
30
+ }
31
+ await recoverInterruptedTransaction();
32
+ const transaction = await createTransaction();
33
+ try {
34
+ const targets = await stageInstall(transaction.stageRoot);
35
+ if (process.env.CAIRN_TEST_CRASH_AFTER_STAGE === "1") process.exit(87);
36
+ targets.push(...await legacyMigrationTargets());
37
+ let ownership = await readOwnership();
38
+ if (ownership) {
39
+ validateOwnershipShape(ownership);
40
+ await assertOwnershipDigests(ownership);
41
+ if (ownership.version !== releaseVersion) {
42
+ targets.push({
43
+ id: "previous-codex-runtime",
44
+ phase: "cleanup",
45
+ path: versionedRuntimeRoot(ownership.version),
46
+ type: "tree",
47
+ operation: "remove",
48
+ });
49
+ }
50
+ }
51
+ else if (await anyManagedArtifactExists(targets)) ownership = await adoptLegacy(targets, transaction);
52
+ await assertTargetsReplaceable(targets, ownership);
53
+ for (const phase of phases) {
54
+ for (const target of targets.filter((candidate) => candidate.phase === phase)) {
55
+ if (target.operation === "remove") await removeManagedTarget(target, transaction);
56
+ else await replaceTarget(target, transaction);
57
+ }
58
+ injectFailure(`after-${phase}`);
59
+ }
60
+ const records = [];
61
+ for (const target of targets.filter((candidate) => candidate.operation !== "remove")) records.push(await targetRecord(target));
62
+ const nextOwnership = {
63
+ schemaVersion: 1,
64
+ plugin: pluginName,
65
+ version: releaseVersion,
66
+ transactionId: transaction.id,
67
+ installedAt: new Date().toISOString(),
68
+ targets: records,
69
+ };
70
+ const ownershipStage = join(transaction.stageRoot, "lifecycle.json");
71
+ await mkdir(dirname(ownershipStage), { recursive: true });
72
+ await writeFile(ownershipStage, `${JSON.stringify(nextOwnership, null, 2)}\n`);
73
+ await replaceTarget({ id: "ownership", phase: "manifest", path: ownershipPath, staged: ownershipStage, type: "file" }, transaction);
74
+ await finishTransaction(transaction);
75
+ console.log(t(mode === "upgrade" ? "upgradeComplete" : "installComplete"));
76
+ console.log(`Codex plugin source: ${installedPluginRoot}`);
77
+ console.log(`Codex versioned runtime: ${versionedPluginRoot}`);
78
+ console.log(`Ownership manifest: ${ownershipPath}`);
79
+ } catch (error) {
80
+ if (transaction.state !== "committed") await rollback(transaction);
81
+ throw error;
82
+ }
83
+ }
84
+
85
+ async function uninstall() {
86
+ await recoverInterruptedTransaction();
87
+ const ownership = await readOwnership({ required: true });
88
+ validateOwnershipShape(ownership, { required: true });
89
+ const conflicts = [];
90
+ for (const record of ownership.targets) {
91
+ const current = await targetDigest(record.path, record.type);
92
+ if (record.type !== "config" && current !== record.installedDigest) conflicts.push(record.path);
93
+ }
94
+ if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
95
+ const transaction = await createTransaction();
96
+ try {
97
+ const configRecord = ownership.targets.find((record) => record.type === "config");
98
+ if (configRecord && await exists(configRecord.path)) {
99
+ const snapshot = await readSharedConfigSnapshot(configRecord.path);
100
+ const staged = join(transaction.stageRoot, "uninstall-config.toml");
101
+ await mkdir(dirname(staged), { recursive: true });
102
+ await writeFile(staged, removeCairnConfig(snapshot.text));
103
+ await replaceTarget({ ...configRecord, staged, configSourceDigest: snapshot.digest }, transaction);
104
+ }
105
+ injectFailure("uninstall-after-config");
106
+ for (const phase of ["antigravity", "claude"]) {
107
+ for (const record of ownership.targets.filter((candidate) => candidate.phase === phase).reverse()) await removeManagedTarget(record, transaction);
108
+ }
109
+ injectFailure("uninstall-after-external");
110
+ for (const record of ownership.targets.filter((candidate) => candidate.phase === "codex").reverse()) await removeManagedTarget(record, transaction);
111
+ injectFailure("uninstall-after-codex");
112
+ await removeManagedTarget({ id: "ownership", phase: "manifest", path: ownershipPath, type: "file" }, transaction);
113
+ injectFailure("uninstall-after-ownership");
114
+ await finishTransaction(transaction);
115
+ injectFailure("uninstall-after-commit");
116
+ } catch (error) {
117
+ if (transaction.state !== "committed") await rollback(transaction);
118
+ throw error;
119
+ }
120
+ await pruneUninstallScaffolds();
121
+ console.log(t("uninstallComplete"));
122
+ }
123
+
124
+ async function doctor() {
125
+ const checks = [];
126
+ const ownership = await readOwnership();
127
+ checks.push(["source", await exists(join(pluginRoot, ".codex-plugin", "plugin.json"))]);
128
+ checks.push(["ownership manifest", Boolean(ownership)]);
129
+ checks.push(["ownership digests", await ownershipDigestsValid(ownership)]);
130
+ checks.push(["custom marketplace source", await exists(join(installedPluginRoot, ".codex-plugin", "plugin.json"))]);
131
+ checks.push(["versioned runtime", await exists(join(versionedPluginRoot, ".codex-plugin", "plugin.json"))]);
132
+ checks.push(["marketplace", await exists(marketplaceJsonPath)]);
133
+ checks.push(["installed hooks manifest field", (await readJson(join(versionedPluginRoot, ".codex-plugin", "plugin.json")))?.hooks === "./hooks/hooks.json"]);
134
+ checks.push(["hooks file", await exists(join(versionedPluginRoot, "hooks", "hooks.json"))]);
135
+ checks.push(["installed runtime locator", await validRuntimeLocator(installedRuntimeLocatorPath, versionedPluginRoot)]);
136
+ checks.push(["installed skill locators", (await Promise.all(skillNames().map((name) => validRuntimeLocator(installedSkillLocatorPath(versionedPluginRoot, name), versionedPluginRoot)))).every(Boolean)]);
137
+ checks.push(["installed CLI script", await exists(join(versionedPluginRoot, "scripts", "cairn.mjs"))]);
138
+ checks.push(["installed plan template", await exists(join(versionedPluginRoot, "templates", "work-plan.md"))]);
139
+ checks.push(["installed model guidance", await exists(join(versionedPluginRoot, "docs", "model-guidance", "codex.md"))]);
140
+ const config = (await readSharedConfigSnapshot(configPath)).text;
141
+ checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.cairn")]);
142
+ checks.push(["config plugin enabled", hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
143
+ checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath, versionedPluginRoot)]);
144
+ checks.push(["Antigravity IDE skills", await exists(join(antigravityHome, "skills", "cairn-plan", "SKILL.md"))]);
145
+ checks.push(["Antigravity CLI flat skills", await exists(join(antigravityCliHome, "skills", "cairn-plan.md"))]);
146
+
147
+ const codexStatus = hostJson(["plugin", "list", "--json"], { CODEX_HOME: codexHome });
148
+ const codexEntry = findPluginStatus(codexStatus, "cairn@cairn");
149
+ checks.push(["Codex plugin installed", Boolean(codexEntry?.installed)]);
150
+ checks.push(["Codex plugin enabled", Boolean(codexEntry?.enabled)]);
151
+ checks.push(["Codex plugin version", codexEntry?.version === releaseVersion]);
152
+
153
+ const featureResult = spawnSync("codex", ["features", "list"], { encoding: "utf8", env: { ...process.env, CODEX_HOME: codexHome } });
154
+ const featureStates = featureResult.status === 0 && !featureResult.error ? parseCodexFeatureList(featureResult.stdout) : null;
155
+ checks.push(["Codex feature plugins", featureStates?.plugins === true]);
156
+ checks.push(["Codex feature hooks", featureStates?.hooks === true]);
157
+ checks.push(["Codex feature multi_agent", featureStates?.multi_agent === true]);
158
+
159
+ const agy = spawnSync("agy", ["--version"], { encoding: "utf8", env: process.env });
160
+ checks.push(["Antigravity CLI readiness", !agy.error && agy.status === 0 && /^\d+\.\d+\.\d+/.test(agy.stdout.trim())]);
161
+ checks.push(["Antigravity CLI skill validation", await validateAntigravitySkills(antigravityCliHome, skillNames(), versionedPluginRoot)]);
162
+ for (const [name, ok] of checks) console.log(`${ok ? "OK" : "FAIL"} ${name}`);
163
+ if (checks.some(([, ok]) => !ok)) process.exitCode = 1;
164
+ }
165
+
166
+
167
+ function injectFailure(point) {
168
+ if (process.env.CAIRN_TEST_FAIL_PHASE === point) throw new Error(`Injected lifecycle failure: ${point}`);
169
+ }
170
+
171
+ function hostJson(args, extraEnv = {}) {
172
+ const result = spawnSync("codex", args, { encoding: "utf8", env: { ...process.env, ...extraEnv } });
173
+ if (result.error || result.status !== 0) return null;
174
+ try { return JSON.parse(result.stdout); } catch { return null; }
175
+ }
176
+
177
+ function findPluginStatus(value, id) {
178
+ const entries = Array.isArray(value) ? value : value?.installed ?? value?.plugins ?? value?.data ?? [];
179
+ return entries.find((entry) => entry.pluginId === id || entry.id === id || entry.name === id || `${entry.name}@${entry.marketplaceName ?? entry.marketplace}` === id) ?? null;
180
+ }
181
+
182
+ function parseCodexFeatureList(output) {
183
+ if (typeof output !== "string" || output.trim().length === 0) return null;
184
+ const features = {};
185
+ for (const line of output.split(/\r?\n/)) {
186
+ const match = line.trim().match(/^([A-Za-z0-9_]+)\s+.+?\s+(true|false)$/);
187
+ if (match) features[match[1]] = match[2] === "true";
188
+ }
189
+ return Object.keys(features).length > 0 ? features : null;
190
+ }
191
+
192
+ async function readJson(path) { if (!(await exists(path))) return null; return JSON.parse(await readFile(path, "utf8")); }
193
+
194
+ return { doctor, install, uninstall };
195
+
196
+ async function exists(path) {
197
+ try { await lstat(path); return true; }
198
+ catch (error) { if (error?.code === "ENOENT") return false; throw error; }
199
+ }
200
+ }
@@ -0,0 +1,149 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants as fsConstants } from "node:fs";
3
+ import { lstat, open, readFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+
6
+ export function updateConfig(config, hookStates, { marketplacePath } = {}) {
7
+ if (marketplacePath === undefined) throw new Error("Cairn marketplace path is required");
8
+ let next = removeCairnConfig(config);
9
+ next = append(next, `[marketplaces.cairn]\nlast_updated = ${JSON.stringify(new Date().toISOString().replace(/\.\d{3}Z$/, "Z"))}\nsource_type = "local"\nsource = ${JSON.stringify(marketplacePath)}\n`);
10
+ next = append(next, '[plugins."cairn@cairn"]\nenabled = true\n');
11
+ for (const state of hookStates) next = append(next, `[hooks.state.${JSON.stringify(state.key)}]\ntrusted_hash = ${JSON.stringify(state.trustedHash)}\n`);
12
+ return next;
13
+ }
14
+
15
+ export function removeCairnConfig(config) {
16
+ return splitSections(config).map((section) => isCairnConfigSection(section)
17
+ ? splitTrailingTomlTrivia(section.text).trivia
18
+ : section.text).join("");
19
+ }
20
+
21
+ export function cairnConfigProjection(config) {
22
+ return splitSections(config).filter(isCairnConfigSection)
23
+ .map((section) => splitTrailingTomlTrivia(section.text).body).join("");
24
+ }
25
+
26
+ function isCairnConfigSection(section) {
27
+ return section.header === "marketplaces.cairn"
28
+ || section.header === 'plugins."cairn@cairn"'
29
+ || (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
30
+ }
31
+
32
+ function splitTrailingTomlTrivia(text) {
33
+ const lines = text.split(/(?<=\n)/);
34
+ let boundary = lines.length;
35
+ while (boundary > 0) {
36
+ const line = lines[boundary - 1];
37
+ if (line.length === 0 || line.trim().length === 0 || line.trimStart().startsWith("#")) boundary -= 1;
38
+ else break;
39
+ }
40
+ return { body: lines.slice(0, boundary).join(""), trivia: lines.slice(boundary).join("") };
41
+ }
42
+
43
+ export async function readSharedConfigSnapshot(path) {
44
+ let pathInfo;
45
+ try {
46
+ pathInfo = await lstat(path);
47
+ } catch (error) {
48
+ if (error?.code === "ENOENT") return { exists: false, text: "", digest: "missing" };
49
+ throw error;
50
+ }
51
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`Managed config is not a regular file: ${path}`);
52
+ let handle;
53
+ try {
54
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
55
+ handle = await open(path, flags);
56
+ } catch (error) {
57
+ if (["ELOOP", "EFTYPE", "ENXIO"].includes(error?.code)) throw new Error(`Managed config is not a regular file: ${path}`);
58
+ throw error;
59
+ }
60
+ try {
61
+ const openedInfo = await handle.stat();
62
+ if (!openedInfo.isFile() || openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
63
+ throw new Error(`Managed config changed identity while opening: ${path}`);
64
+ }
65
+ const text = await handle.readFile("utf8");
66
+ return { exists: true, text, digest: sha(text) };
67
+ } finally {
68
+ await handle.close();
69
+ }
70
+ }
71
+
72
+ export function ensureSetting(config, sectionName, key, value) {
73
+ const sections = splitSections(config);
74
+ const index = sections.findIndex((section) => section.header === sectionName);
75
+ if (index === -1) return append(config, `[${sectionName}]\n${key} = ${value}\n`);
76
+ const lines = sections[index].text.trimEnd().split("\n");
77
+ const settingIndex = lines.findIndex((line) => line.trim().startsWith(`${key} =`));
78
+ if (settingIndex === -1) lines.push(`${key} = ${value}`);
79
+ else lines[settingIndex] = `${key} = ${value}`;
80
+ sections[index].text = `${lines.join("\n")}\n`;
81
+ return sections.map((section) => section.text).join("");
82
+ }
83
+
84
+ export function hasSetting(config, sectionName, key, value) {
85
+ const section = splitSections(config).find((candidate) => candidate.header === sectionName);
86
+ return section?.text.split("\n").some((line) => line.trim() === `${key} = ${value}`) ?? false;
87
+ }
88
+
89
+ export function splitSections(config) {
90
+ const result = [];
91
+ let current = { header: null, text: "" };
92
+ let multiline = null;
93
+ for (const line of config.split(/(?<=\n)/)) {
94
+ const delimiterCount = (delimiter) => line.split(delimiter).length - 1;
95
+ if (multiline) {
96
+ current.text += line;
97
+ if (delimiterCount(multiline) % 2 === 1) multiline = null;
98
+ continue;
99
+ }
100
+ const doubleCount = delimiterCount('"""');
101
+ const singleCount = delimiterCount("'''");
102
+ const match = line.trim().match(/^\[([^\]]+)\]$/);
103
+ if (match) {
104
+ if (current.text.length > 0) result.push(current);
105
+ current = { header: match[1], text: line };
106
+ } else current.text += line;
107
+ if (doubleCount % 2 === 1) multiline = '"""';
108
+ else if (singleCount % 2 === 1) multiline = "'''";
109
+ }
110
+ if (current.text.length > 0) result.push(current);
111
+ return result;
112
+ }
113
+
114
+ export async function trustedHookStates(root) {
115
+ const parsed = JSON.parse(await readFile(join(root, "hooks", "hooks.json"), "utf8"));
116
+ const states = [];
117
+ const labels = { SessionStart: "session_start", UserPromptSubmit: "user_prompt_submit", PostToolUse: "post_tool_use", Stop: "stop", SubagentStop: "subagent_stop" };
118
+ for (const [eventName, groups] of Object.entries(parsed?.hooks ?? {})) {
119
+ const eventLabel = labels[eventName];
120
+ if (!eventLabel || !Array.isArray(groups)) continue;
121
+ for (const [groupIndex, group] of groups.entries()) for (const [handlerIndex, handler] of (group.hooks ?? []).entries()) {
122
+ states.push({ key: `cairn@cairn:hooks/hooks.json:${eventLabel}:${groupIndex}:${handlerIndex}`, trustedHash: hookHash(eventLabel, group.matcher, handler) });
123
+ }
124
+ }
125
+ return states;
126
+ }
127
+
128
+ export function hookHash(eventName, matcher, handler) {
129
+ const normalized = { type: "command", command: handler.command, timeout: Math.max(Number(handler.timeout ?? 600), 1), async: false };
130
+ if (typeof handler.statusMessage === "string") normalized.statusMessage = handler.statusMessage;
131
+ const identity = { event_name: eventName, hooks: [normalized] };
132
+ if (typeof matcher === "string") identity.matcher = matcher;
133
+ return sha(JSON.stringify(canonical(identity)));
134
+ }
135
+
136
+ export function canonical(value) {
137
+ if (Array.isArray(value)) return value.map(canonical);
138
+ if (value === null || typeof value !== "object") return value;
139
+ return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical(value[key])]));
140
+ }
141
+
142
+ function append(config, block) {
143
+ if (config.length === 0) return `${block.trimEnd()}\n`;
144
+ return `${config}${config.endsWith("\n") ? "" : "\n"}${block.trimEnd()}\n`;
145
+ }
146
+
147
+ function sha(value) {
148
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
149
+ }
@@ -0,0 +1,218 @@
1
+ import { createHash } from "node:crypto";
2
+ import { lstat, readFile, readdir } from "node:fs/promises";
3
+ import { basename, join, relative, sep } from "node:path";
4
+ import { cairnConfigProjection, readSharedConfigSnapshot } from "./cairn-lifecycle-config.mjs";
5
+ import { createLegacyIntegrity } from "./cairn-lifecycle-legacy.mjs";
6
+ import { antigravityRuntimeLocatorPath } from "./cairn-lifecycle-mirror.mjs";
7
+
8
+ export function createIntegrity(context) {
9
+ const {
10
+ antigravityCliHome, antigravityHome, claudeHome, claudeRuntimeLocatorPath,
11
+ commandNames, configPath, installedPluginRoot, legacyAntigravityHome,
12
+ marketplaceJsonPath, marketplaceRoot, ownershipPath, phases, pluginName,
13
+ releaseVersion, skillNames,
14
+ } = context;
15
+ async function assertTargetsReplaceable(targets, ownership) {
16
+ const records = new Map((ownership?.targets ?? []).map((record) => [record.id, record]));
17
+ for (const target of targets) {
18
+ if (!(await exists(target.path))) {
19
+ target.preflightDigest = "missing";
20
+ continue;
21
+ }
22
+ const current = await targetDigest(target.path, target.type);
23
+ target.preflightDigest = current;
24
+ if (target.id === "previous-codex-runtime") {
25
+ const previous = records.get("codex-runtime");
26
+ if (!previous || previous.path !== target.path || previous.type !== target.type) throw new Error("Previous runtime does not match ownership manifest.");
27
+ continue;
28
+ }
29
+ const record = records.get(target.id);
30
+ if (!record) {
31
+ if (target.type === "config" && cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length === 0) continue;
32
+ throw new Error(`Refusing to overwrite unmanaged artifact: ${target.path}`);
33
+ }
34
+ if (target.id === "codex-runtime" && ownership?.version !== releaseVersion) {
35
+ throw new Error(`Refusing to overwrite unmanaged current-version runtime: ${target.path}`);
36
+ }
37
+ if (record.path !== target.path || record.type !== target.type) throw new Error(`Ownership manifest target mismatch: ${target.id}`);
38
+ if (target.type !== "config" && current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
39
+ }
40
+ }
41
+
42
+ async function assertOwnershipDigests(ownership) {
43
+ for (const record of ownership.targets) {
44
+ const current = await targetDigest(record.path, record.type);
45
+ if (record.type !== "config" && current !== record.installedDigest) {
46
+ throw new Error(`Managed artifact was modified: ${record.path}`);
47
+ }
48
+ }
49
+ }
50
+
51
+ async function targetRecord(target) {
52
+ return { id: target.id, phase: target.phase, path: target.path, type: target.type, installedDigest: await targetDigest(target.path, target.type), previous: null };
53
+ }
54
+
55
+ async function readOwnership({ required = false } = {}) {
56
+ if (!(await exists(ownershipPath))) {
57
+ if (required) throw new Error(`Ownership manifest is missing: ${ownershipPath}`);
58
+ return null;
59
+ }
60
+ try {
61
+ return JSON.parse(await readFile(ownershipPath, "utf8"));
62
+ } catch {
63
+ throw new Error(`Ownership manifest is invalid: ${ownershipPath}`);
64
+ }
65
+ }
66
+
67
+ function validateOwnershipShape(ownership, { required = false } = {}) {
68
+ if (!ownership) {
69
+ if (required) throw new Error("Ownership manifest is required.");
70
+ return;
71
+ }
72
+ if (!sameKeys(ownership, ["schemaVersion", "plugin", "version", "transactionId", "installedAt", "targets"])
73
+ || ownership.schemaVersion !== 1 || ownership.plugin !== pluginName
74
+ || typeof ownership.version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(ownership.version)
75
+ || typeof ownership.transactionId !== "string" || !/^[a-f0-9]{8}-[a-f0-9]{4}-4[a-f0-9]{3}-[89ab][a-f0-9]{3}-[a-f0-9]{12}$/i.test(ownership.transactionId)
76
+ || typeof ownership.installedAt !== "string" || Number.isNaN(Date.parse(ownership.installedAt))
77
+ || !Array.isArray(ownership.targets)) {
78
+ throw new Error("Ownership manifest schema or plugin identity is invalid.");
79
+ }
80
+ const ids = new Set();
81
+ const paths = new Set();
82
+ for (const record of ownership.targets) {
83
+ if (!record || !sameKeys(record, ["id", "phase", "path", "type", "installedDigest", "previous"])
84
+ || typeof record.id !== "string" || record.id.length === 0 || ids.has(record.id)
85
+ || typeof record.path !== "string" || record.path.length === 0 || paths.has(record.path)
86
+ || typeof record.phase !== "string" || !phases.includes(record.phase)
87
+ || typeof record.type !== "string" || !["file", "tree", "config"].includes(record.type)
88
+ || typeof record.installedDigest !== "string" || !/^sha256:[a-f0-9]{64}$/.test(record.installedDigest)
89
+ || record.previous !== null) {
90
+ throw new Error("Ownership manifest contains an invalid or duplicate target.");
91
+ }
92
+ const expected = expectedManagedTarget(record.id, ownership.version);
93
+ if (!expected || expected.path !== record.path || expected.phase !== record.phase || expected.type !== record.type) {
94
+ throw new Error(`Ownership manifest target is outside the Cairn allowlist: ${record.id}`);
95
+ }
96
+ ids.add(record.id);
97
+ paths.add(record.path);
98
+ }
99
+ const requiredTargets = requiredOwnershipTargets(ownership.version);
100
+ if (ids.size !== requiredTargets.size || [...requiredTargets.keys()].some((id) => !ids.has(id))) {
101
+ throw new Error("Ownership manifest does not contain the complete managed target set.");
102
+ }
103
+ }
104
+
105
+ function requiredOwnershipTargets(version = releaseVersion) {
106
+ const ids = ["codex-source", "codex-runtime", "marketplace", "claude-runtime", "ide-runtime", "cli-runtime", "codex-config"];
107
+ for (const name of commandNames()) ids.push(`claude-command-${name}`, `ide-workflow-${name}`, `cli-workflow-${name}`);
108
+ for (const name of ["explorer", "worker"]) ids.push(`claude-agent-${name}`);
109
+ for (const name of skillNames()) ids.push(`ide-skill-${name}`, `cli-skill-${name}`);
110
+ return new Map(ids.map((id) => [id, expectedManagedTarget(id, version)]));
111
+ }
112
+
113
+ function expectedManagedTarget(id, version = releaseVersion) {
114
+ const exact = {
115
+ "codex-source": { phase: "codex", path: installedPluginRoot, type: "tree" },
116
+ "codex-runtime": { phase: "codex", path: versionedRuntimeRoot(version), type: "tree" },
117
+ marketplace: { phase: "codex", path: marketplaceJsonPath, type: "file" },
118
+ "claude-runtime": { phase: "claude", path: claudeRuntimeLocatorPath, type: "file" },
119
+ "ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityHome), type: "file" },
120
+ "cli-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(antigravityCliHome), type: "file" },
121
+ "legacy-ide-runtime": { phase: "antigravity", path: antigravityRuntimeLocatorPath(legacyAntigravityHome), type: "file" },
122
+ "codex-config": { phase: "config", path: configPath, type: "config" },
123
+ };
124
+ if (exact[id]) return exact[id];
125
+ for (const name of commandNames()) {
126
+ if (id === `claude-command-${name}`) return { phase: "claude", path: join(claudeHome, "commands", `cairn-${name}.md`), type: "file" };
127
+ if (id === `ide-workflow-${name}`) return { phase: "antigravity", path: join(antigravityHome, "workflows", `cairn-${name}.md`), type: "file" };
128
+ if (id === `cli-workflow-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "workflows", `cairn-${name}.md`), type: "file" };
129
+ if (id === `legacy-ide-workflow-${name}`) return { phase: "antigravity", path: join(legacyAntigravityHome, "workflows", `cairn-${name}.md`), type: "file" };
130
+ }
131
+ for (const name of ["explorer", "worker"]) if (id === `claude-agent-${name}`) return { phase: "claude", path: join(claudeHome, "agents", `cairn-${name}.md`), type: "file" };
132
+ for (const name of skillNames()) {
133
+ if (id === `ide-skill-${name}`) return { phase: "antigravity", path: join(antigravityHome, "skills", name), type: "tree" };
134
+ if (id === `cli-skill-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "skills", `${name}.md`), type: "file" };
135
+ if (id === `legacy-ide-skill-${name}`) return { phase: "antigravity", path: join(legacyAntigravityHome, "skills", name), type: "tree" };
136
+ if (id === `legacy-cli-skill-${name}`) return { phase: "antigravity", path: join(antigravityCliHome, "skills", name), type: "tree" };
137
+ }
138
+ return null;
139
+ }
140
+
141
+ function versionedRuntimeRoot(version) {
142
+ if (typeof version !== "string" || !/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(version)) throw new Error(`Invalid Cairn runtime version: ${version}`);
143
+ return join(marketplaceRoot, pluginName, version);
144
+ }
145
+
146
+ async function anyManagedArtifactExists(targets) {
147
+ for (const target of targets) {
148
+ if (!(await exists(target.path))) continue;
149
+ if (target.type !== "config" || cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length > 0) return true;
150
+ }
151
+ return false;
152
+ }
153
+
154
+ async function targetDigest(path, type) {
155
+ if (!(await exists(path))) return "missing";
156
+ if (type === "config") return sha(cairnConfigProjection((await readSharedConfigSnapshot(path)).text));
157
+ const info = await lstat(path);
158
+ if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) throw new Error(`Managed artifact is not a regular file/tree: ${path}`);
159
+ if (info.isFile()) return sha(await readFile(path));
160
+ const files = await regularFiles(path);
161
+ const hash = createHash("sha256");
162
+ for (const file of files) {
163
+ hash.update(file).update("\0").update(await readFile(join(path, file))).update("\0");
164
+ }
165
+ return `sha256:${hash.digest("hex")}`;
166
+ }
167
+
168
+ async function regularFiles(root) {
169
+ const output = [];
170
+ async function walk(directory) {
171
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
172
+ const path = join(directory, entry.name);
173
+ if (entry.isSymbolicLink() || (!entry.isDirectory() && !entry.isFile())) throw new Error(`Symlink or special file is not allowed in managed tree: ${path}`);
174
+ if (entry.isDirectory()) await walk(path);
175
+ else output.push(relative(root, path).split(sep).join("/"));
176
+ }
177
+ }
178
+ const info = await lstat(root);
179
+ if (info.isSymbolicLink()) throw new Error(`Symlink is not allowed: ${root}`);
180
+ if (info.isFile()) return [basename(root)];
181
+ await walk(root);
182
+ return output.sort();
183
+ }
184
+
185
+ async function assertRegularTree(path) {
186
+ await regularFiles(path);
187
+ }
188
+
189
+ function sha(value) {
190
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
191
+ }
192
+
193
+ async function ownershipDigestsValid(ownership) {
194
+ try {
195
+ validateOwnershipShape(ownership, { required: true });
196
+ for (const record of ownership.targets) if (await targetDigest(record.path, record.type) !== record.installedDigest) return false;
197
+ return true;
198
+ } catch {
199
+ return false;
200
+ }
201
+ }
202
+
203
+ const legacy = createLegacyIntegrity({ ...context, regularFiles, targetDigest });
204
+ return {
205
+ ...legacy, anyManagedArtifactExists, assertOwnershipDigests, assertRegularTree,
206
+ assertTargetsReplaceable, expectedManagedTarget, ownershipDigestsValid, readOwnership,
207
+ targetDigest, targetRecord, validateOwnershipShape, versionedRuntimeRoot,
208
+ };
209
+
210
+ function sameKeys(value, expected) {
211
+ return Object.keys(value).sort().join("\0") === [...expected].sort().join("\0");
212
+ }
213
+
214
+ async function exists(path) {
215
+ try { await lstat(path); return true; }
216
+ catch (error) { if (error?.code === "ENOENT") return false; throw error; }
217
+ }
218
+ }