cairn-ai 0.2.4 → 0.2.6

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.
@@ -2,10 +2,13 @@
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { lstatSync, readFileSync, readlinkSync, realpathSync, readdirSync } from "node:fs";
5
- import { readFile, rename, rm } from "node:fs/promises";
6
- import { join, relative, resolve, sep } from "node:path";
5
+ import { lstat, readFile, rename, rm } from "node:fs/promises";
6
+ import { dirname, join, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
8
  import { assertNoSymlinkComponents, safeMkdir, safeWriteFile, withStateLock } from "./cairn-safe-fs.mjs";
9
+ import { cairnHome, goalStateDirectory, worktreeId } from "./cairn-paths.mjs";
10
+
11
+ export { worktreeId } from "./cairn-paths.mjs";
9
12
 
10
13
  export const GOAL_STATE_VERSION = 2;
11
14
  export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
@@ -33,17 +36,17 @@ const taskTransitions = {
33
36
  };
34
37
 
35
38
  export function goalStatePath(root = process.cwd()) {
36
- return join(resolve(root), ".cairn", "state.json");
39
+ return join(goalStateDirectory(root), "state.json");
37
40
  }
38
41
 
39
42
  export async function readGoalState({ root = process.cwd() } = {}) {
40
43
  try {
41
44
  const path = goalStatePath(root);
42
- await assertNoSymlinkComponents(root, path);
45
+ await assertNoSymlinkComponents(cairnHome(), path);
43
46
  const text = await readFile(path, "utf8");
44
47
  return validateState(migrateState(JSON.parse(text)));
45
48
  } catch (error) {
46
- if (error?.code === "ENOENT") return null;
49
+ if (error?.code === "ENOENT") return migrateLegacyGoalState(root);
47
50
  if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
48
51
  throw error;
49
52
  }
@@ -54,8 +57,15 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
54
57
  const normalizedPlanId = boundedRequiredText(planId, "planId", PLAN_ID_MAX_LENGTH);
55
58
  if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
56
59
 
57
- return withStateLock(root, async () => {
58
- const existing = await readGoalState({ root });
60
+ return withGoalStateLock(root, async () => {
61
+ let existing = await readGoalState({ root });
62
+ const currentWorktreeId = worktreeId(root);
63
+ if (existing && existing.goal.worktreeId !== currentWorktreeId) {
64
+ const path = goalStatePath(root);
65
+ await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
66
+ await rm(path);
67
+ existing = null;
68
+ }
59
69
  if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
60
70
  throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
61
71
  }
@@ -75,6 +85,7 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
75
85
  completionCriteria: normalizeCriteria(completionCriteria),
76
86
  requiredEvidence: normalizeCriteria(requiredEvidence),
77
87
  evidencePolicy: validEvidencePolicy(evidencePolicy),
88
+ worktreeId: currentWorktreeId,
78
89
  ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
79
90
  blocker: null,
80
91
  createdAt: now,
@@ -88,18 +99,36 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
88
99
  });
89
100
  }
90
101
 
102
+ export async function reconcileWorktreeState({ root = process.cwd() } = {}) {
103
+ const initial = await readGoalState({ root });
104
+ if (!initial || initial.goal.worktreeId === worktreeId(root)) {
105
+ return { state: initial, removed: false };
106
+ }
107
+ return withGoalStateLock(root, async () => {
108
+ const state = await readGoalState({ root });
109
+ if (!state || state.goal.worktreeId === worktreeId(root)) {
110
+ return { state, removed: false };
111
+ }
112
+ const staleWorktreeId = state.goal.worktreeId;
113
+ const path = goalStatePath(root);
114
+ await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
115
+ await rm(path);
116
+ return { state: null, removed: true, staleWorktreeId };
117
+ });
118
+ }
119
+
91
120
  export async function writeGoalState({ root = process.cwd(), state } = {}) {
92
- return withStateLock(root, () => writeGoalStateUnlocked({ root, state }));
121
+ return withGoalStateLock(root, () => writeGoalStateUnlocked({ root, state }));
93
122
  }
94
123
 
95
124
  async function writeGoalStateUnlocked({ root = process.cwd(), state } = {}) {
96
125
  const validated = validateState(state);
97
126
  const path = goalStatePath(root);
98
- await safeMkdir(root, ".cairn");
99
- await assertNoSymlinkComponents(root, path);
127
+ await ensureGoalStateDirectory(root);
128
+ await assertNoSymlinkComponents(cairnHome(), path);
100
129
  const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
101
130
  try {
102
- await safeWriteFile(root, temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
131
+ await safeWriteFile(cairnHome(), temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
103
132
  await rename(temporaryPath, path);
104
133
  } catch (error) {
105
134
  await rm(temporaryPath, { force: true }).catch(() => {});
@@ -109,21 +138,38 @@ async function writeGoalStateUnlocked({ root = process.cwd(), state } = {}) {
109
138
 
110
139
  export async function transitionGoal({ root = process.cwd(), status, blocker } = {}) {
111
140
  const nextStatus = validGoalStatus(status);
141
+ if (terminalGoalStatuses.has(nextStatus)) {
142
+ return withGoalStateLock(root, async () => {
143
+ const state = await readGoalState({ root });
144
+ if (!state) throw new Error("No Cairn goal state exists; start a goal first");
145
+ const next = applyGoalTransition(structuredClone(state), nextStatus, blocker, root);
146
+ next.revision += 1;
147
+ validateState(next);
148
+ const path = goalStatePath(root);
149
+ await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
150
+ await rm(path);
151
+ return next;
152
+ });
153
+ }
112
154
  return mutateGoal(root, (state) => {
113
- const currentStatus = state.goal.status;
114
- if (currentStatus === nextStatus) return state;
115
- if (!goalTransitions[currentStatus].has(nextStatus)) {
116
- throw new Error(`Cannot transition goal from ${currentStatus} to ${nextStatus}`);
117
- }
118
- if (nextStatus === "completed") ensureGoalCanComplete(state, root);
119
- if (nextStatus === "blocked") state.goal.blocker = requiredText(blocker, "blocker");
120
- if (nextStatus === "active") state.goal.blocker = null;
121
- state.goal.status = nextStatus;
122
- state.goal.updatedAt = timestamp();
123
- return state;
155
+ return applyGoalTransition(state, nextStatus, blocker, root);
124
156
  });
125
157
  }
126
158
 
159
+ function applyGoalTransition(state, nextStatus, blocker, root) {
160
+ const currentStatus = state.goal.status;
161
+ if (currentStatus === nextStatus) return state;
162
+ if (!goalTransitions[currentStatus].has(nextStatus)) {
163
+ throw new Error(`Cannot transition goal from ${currentStatus} to ${nextStatus}`);
164
+ }
165
+ if (nextStatus === "completed") ensureGoalCanComplete(state, root);
166
+ if (nextStatus === "blocked") state.goal.blocker = requiredText(blocker, "blocker");
167
+ if (nextStatus === "active") state.goal.blocker = null;
168
+ state.goal.status = nextStatus;
169
+ state.goal.updatedAt = timestamp();
170
+ return state;
171
+ }
172
+
127
173
  export async function setTaskStatus({ root = process.cwd(), taskId, status, blocker } = {}) {
128
174
  const nextStatus = validTaskStatus(status);
129
175
  const normalizedTaskId = requiredText(taskId, "taskId");
@@ -153,6 +199,34 @@ export async function setTaskStatus({ root = process.cwd(), taskId, status, bloc
153
199
  });
154
200
  }
155
201
 
202
+ export async function replanGoal({ root = process.cwd(), tasks } = {}) {
203
+ if (!Array.isArray(tasks) || tasks.length === 0) {
204
+ throw new Error("replan tasks must contain at least one incomplete task");
205
+ }
206
+
207
+ return mutateGoal(root, (state) => {
208
+ assertGoalMutable(state);
209
+ if (state.goal.status !== "active") throw new Error("Only an active goal can be replanned");
210
+
211
+ const completedTasks = state.tasks.filter((task) => task.status === "completed");
212
+ const completedIds = new Set(completedTasks.map((task) => task.id));
213
+ const replacementTasks = tasks.map((task, index) => {
214
+ const source = typeof task === "string" ? task : { ...task, status: undefined, blocker: undefined };
215
+ const normalized = normalizeTask(source, index, index === 0 ? "active" : "pending");
216
+ if (completedIds.has(normalized.id)) {
217
+ throw new Error(`Replanned task conflicts with completed task id: ${normalized.id}`);
218
+ }
219
+ return normalized;
220
+ });
221
+
222
+ state.tasks = [...completedTasks, ...replacementTasks];
223
+ state.receipts = state.receipts.filter((receipt) => receipt.scope === "task" && completedIds.has(receipt.taskId));
224
+ state.goal.blocker = null;
225
+ state.goal.updatedAt = timestamp();
226
+ return state;
227
+ });
228
+ }
229
+
156
230
  export async function assignTask({ root = process.cwd(), taskId, agentId = null } = {}) {
157
231
  const normalizedTaskId = requiredText(taskId, "taskId");
158
232
  const normalizedAgentId = agentId === null ? null : requiredText(agentId, "agentId");
@@ -403,6 +477,11 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
403
477
  emit(JSON.stringify(state));
404
478
  return state;
405
479
  }
480
+ if (command === "replan") {
481
+ const state = await replanGoal({ root, tasks: parseTasks(options.tasks ?? positional.slice(0)) });
482
+ emit(JSON.stringify(state));
483
+ return state;
484
+ }
406
485
  if (command === "assign") {
407
486
  const state = await assignTask({ root, taskId: options.task ?? positional[0], agentId: options.agent ?? positional[1] ?? null });
408
487
  emit(JSON.stringify(state));
@@ -452,11 +531,11 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
452
531
  emit(JSON.stringify(state));
453
532
  return state;
454
533
  }
455
- throw new Error("Usage: cairn-goal start|status|task|assign|receipt|verify|pause|resume|block|cancel|complete [--root PATH]");
534
+ throw new Error("Usage: cairn-goal start|status|task|replan|assign|receipt|verify|pause|resume|block|cancel|complete [--root PATH]");
456
535
  }
457
536
 
458
537
  async function mutateGoal(root, mutate) {
459
- return withStateLock(root, async () => {
538
+ return withGoalStateLock(root, async () => {
460
539
  const state = await readGoalState({ root });
461
540
  if (!state) throw new Error("No Cairn goal state exists; start a goal first");
462
541
  const next = await mutate(structuredClone(state));
@@ -466,6 +545,44 @@ async function mutateGoal(root, mutate) {
466
545
  });
467
546
  }
468
547
 
548
+ async function withGoalStateLock(root, operation) {
549
+ await ensureGoalStateDirectory(root);
550
+ return withStateLock(goalStateDirectory(root), operation);
551
+ }
552
+
553
+ async function ensureGoalStateDirectory(root) {
554
+ const home = cairnHome();
555
+ let existingAncestor = home;
556
+ while (true) {
557
+ try {
558
+ const stat = await lstat(existingAncestor);
559
+ if (!stat.isDirectory()) throw new Error(`Cairn home ancestor is not a directory: ${existingAncestor}`);
560
+ break;
561
+ } catch (error) {
562
+ if (error?.code !== "ENOENT") throw error;
563
+ const parent = dirname(existingAncestor);
564
+ if (parent === existingAncestor) throw error;
565
+ existingAncestor = parent;
566
+ }
567
+ }
568
+ await safeMkdir(existingAncestor, relative(existingAncestor, goalStateDirectory(root)));
569
+ }
570
+
571
+ async function migrateLegacyGoalState(root) {
572
+ const legacyPath = join(resolve(root), ".cairn", "state.json");
573
+ try {
574
+ await assertNoSymlinkComponents(root, legacyPath, { allowMissing: false });
575
+ const state = validateState(migrateState(JSON.parse(await readFile(legacyPath, "utf8"))));
576
+ await writeGoalStateUnlocked({ root, state });
577
+ await rm(legacyPath);
578
+ return state;
579
+ } catch (error) {
580
+ if (error?.code === "ENOENT") return null;
581
+ if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
582
+ throw error;
583
+ }
584
+ }
585
+
469
586
  function normalizeTask(task, index, defaultStatus) {
470
587
  const source = typeof task === "string" ? { title: task } : task;
471
588
  if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
@@ -516,6 +633,7 @@ function validateState(value) {
516
633
  if (!Array.isArray(goal.requiredEvidence) || goal.requiredEvidence.length === 0) throw new Error("goal.requiredEvidence must be a non-empty array");
517
634
  goal.requiredEvidence.forEach((kind, index) => requiredText(kind, `goal.requiredEvidence[${index}]`));
518
635
  if (goal.ownerSessionId !== null && goal.ownerSessionId !== undefined) requiredText(goal.ownerSessionId, "goal.ownerSessionId");
636
+ if (goal.worktreeId !== null && goal.worktreeId !== undefined) requiredText(goal.worktreeId, "goal.worktreeId");
519
637
  if (goal.blocker !== null && goal.blocker !== undefined) requiredText(goal.blocker, "goal.blocker");
520
638
  if (!Array.isArray(value.tasks) || value.tasks.length === 0) throw new Error("Cairn goal state must contain tasks");
521
639
  if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state evidence records (receipts) must be an array");
@@ -916,6 +1034,7 @@ function allowedOptions(command) {
916
1034
  start: ["goal", "plan", "tasks", "criteria", "requiredEvidence", "session", "evidencePolicy", "evidence-policy"],
917
1035
  status: [],
918
1036
  task: ["task", "status", "reason"],
1037
+ replan: ["tasks"],
919
1038
  assign: ["task", "agent"],
920
1039
  receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
921
1040
  verify: ["task", "kind", "scope", "watch", "timeout-ms"],
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import { createHash, randomUUID } from "node:crypto";
3
3
  import { spawnSync } from "node:child_process";
4
- import { realpathSync } from "node:fs";
4
+ import { constants as fsConstants, realpathSync } from "node:fs";
5
5
  import {
6
6
  copyFile, cp, lstat, mkdir, mkdtemp, open, readFile, readdir, rename, rm, rmdir, writeFile,
7
7
  } from "node:fs/promises";
@@ -119,17 +119,18 @@ async function uninstall() {
119
119
  const conflicts = [];
120
120
  for (const record of ownership.targets) {
121
121
  const current = await targetDigest(record.path, record.type);
122
- if (current !== record.installedDigest) conflicts.push(record.path);
122
+ if (record.type !== "config" && current !== record.installedDigest) conflicts.push(record.path);
123
123
  }
124
124
  if (conflicts.length > 0) throw new Error(`Refusing to uninstall modified managed artifacts: ${conflicts.join(", ")}`);
125
125
  const transaction = await createTransaction();
126
126
  try {
127
127
  const configRecord = ownership.targets.find((record) => record.type === "config");
128
- if (configRecord) {
128
+ if (configRecord && await exists(configRecord.path)) {
129
+ const snapshot = await readSharedConfigSnapshot(configRecord.path);
129
130
  const staged = join(transaction.stageRoot, "uninstall-config.toml");
130
131
  await mkdir(dirname(staged), { recursive: true });
131
- await writeFile(staged, removeCairnConfig(await readText(configRecord.path)));
132
- await replaceTarget({ ...configRecord, staged }, transaction);
132
+ await writeFile(staged, removeCairnConfig(snapshot.text));
133
+ await replaceTarget({ ...configRecord, staged, configSourceDigest: snapshot.digest }, transaction);
133
134
  }
134
135
  injectFailure("uninstall-after-config");
135
136
  for (const phase of ["antigravity", "claude"]) {
@@ -166,7 +167,7 @@ async function doctor() {
166
167
  checks.push(["installed CLI script", await exists(join(versionedPluginRoot, "scripts", "cairn.mjs"))]);
167
168
  checks.push(["installed plan template", await exists(join(versionedPluginRoot, "templates", "work-plan.md"))]);
168
169
  checks.push(["installed model guidance", await exists(join(versionedPluginRoot, "docs", "model-guidance", "codex.md"))]);
169
- const config = await readText(configPath);
170
+ const config = (await readSharedConfigSnapshot(configPath)).text;
170
171
  checks.push(["config marketplace", splitSections(config).some((section) => section.header === "marketplaces.cairn")]);
171
172
  checks.push(["config plugin enabled", hasSetting(config, 'plugins."cairn@cairn"', "enabled", "true")]);
172
173
  checks.push(["Claude runtime locator", await validRuntimeLocator(claudeRuntimeLocatorPath)]);
@@ -204,7 +205,7 @@ async function ownershipDigestsValid(ownership) {
204
205
 
205
206
  async function stageInstall(stageRoot) {
206
207
  const targets = [];
207
- const add = (id, phase, destination, staged, type = "tree") => targets.push({ id, phase, path: destination, staged, type });
208
+ const add = (id, phase, destination, staged, type = "tree", details = {}) => targets.push({ id, phase, path: destination, staged, type, ...details });
208
209
  const sourceStage = join(stageRoot, "codex-source");
209
210
  const runtimeStage = join(stageRoot, "codex-runtime");
210
211
  await copyPluginCandidate(sourceStage, versionedPluginRoot);
@@ -239,8 +240,9 @@ async function stageInstall(stageRoot) {
239
240
 
240
241
  const configStage = join(stageRoot, "config.toml");
241
242
  const hookStates = await trustedHookStates(runtimeStage);
242
- await writeFile(configStage, updateConfig(await readText(configPath), hookStates));
243
- add("codex-config", "config", configPath, configStage, "config");
243
+ const configSnapshot = await readSharedConfigSnapshot(configPath);
244
+ await writeFile(configStage, updateConfig(configSnapshot.text, hookStates));
245
+ add("codex-config", "config", configPath, configStage, "config", { configSourceDigest: configSnapshot.digest });
244
246
  for (const target of targets) await assertRegularTree(target.staged);
245
247
  return targets;
246
248
  }
@@ -465,6 +467,15 @@ function pidIsAlive(pid) {
465
467
  function delay(ms) { return new Promise((resolvePromise) => setTimeout(resolvePromise, ms)); }
466
468
 
467
469
  async function replaceTarget(target, transaction) {
470
+ let configProjection = null;
471
+ if (target.type === "config") {
472
+ const snapshot = await readSharedConfigSnapshot(target.path);
473
+ configProjection = cairnConfigProjection((await readSharedConfigSnapshot(target.staged)).text);
474
+ if (snapshot.digest !== target.configSourceDigest) {
475
+ await writeFile(target.staged, `${removeCairnConfig(snapshot.text)}${configProjection}`);
476
+ target.configSourceDigest = snapshot.digest;
477
+ }
478
+ }
468
479
  const existed = await exists(target.path);
469
480
  const backup = join(transaction.backupRoot, String(transaction.entries.length));
470
481
  await mkdir(dirname(backup), { recursive: true });
@@ -482,7 +493,30 @@ async function replaceTarget(target, transaction) {
482
493
  };
483
494
  transaction.entries.push(entry);
484
495
  await writeTransactionJournal(transaction);
485
- if (existed) await rename(target.path, backup);
496
+ if (target.type === "config" && process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE) {
497
+ const snapshot = await readSharedConfigSnapshot(target.path);
498
+ await writeFile(target.path, `${snapshot.text}${process.env.CAIRN_TEST_APPEND_CONFIG_BEFORE_REPLACE}`);
499
+ }
500
+ if (target.type === "config" && process.env.CAIRN_TEST_REMOVE_CONFIG_BEFORE_REPLACE === "1") {
501
+ await rm(target.path, { force: true });
502
+ }
503
+ if (target.type === "config") {
504
+ let capturedExists = true;
505
+ try {
506
+ await rename(target.path, backup);
507
+ } catch (error) {
508
+ if (error?.code !== "ENOENT") throw error;
509
+ capturedExists = false;
510
+ }
511
+ if (process.env.CAIRN_TEST_CRASH_AFTER_CONFIG_CAPTURE === "1") process.exit(86);
512
+ const captured = await readSharedConfigSnapshot(backup);
513
+ if (capturedExists) await writeFile(target.staged, `${removeCairnConfig(captured.text)}${configProjection}`);
514
+ else await writeFile(target.staged, configProjection);
515
+ entry.existed = capturedExists;
516
+ entry.previousDigest = capturedExists ? await targetDigest(backup, target.type) : "missing";
517
+ entry.expectedNewDigest = await targetDigest(target.staged, target.type);
518
+ await writeTransactionJournal(transaction);
519
+ } else if (existed) await rename(target.path, backup);
486
520
  await mkdir(dirname(target.path), { recursive: true });
487
521
  await rename(target.staged, target.path);
488
522
  entry.status = "applied";
@@ -632,6 +666,18 @@ async function rollbackEntries(transaction) {
632
666
  const destinationExists = await exists(entry.path);
633
667
  const backupExists = await exists(entry.backup);
634
668
  if (backupExists) {
669
+ if (entry.type === "config" && entry.status === "prepared") {
670
+ const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
671
+ if (destinationExists) {
672
+ const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
673
+ await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
674
+ await rm(entry.backup, { force: true });
675
+ } else {
676
+ await mkdir(dirname(entry.path), { recursive: true });
677
+ await rename(entry.backup, entry.path);
678
+ }
679
+ continue;
680
+ }
635
681
  if (!entry.existed) throw new Error(`Cannot recover transaction because an unexpected backup exists: ${entry.path}`);
636
682
  if (await targetDigest(entry.backup, entry.type) !== entry.previousDigest) {
637
683
  throw new Error(`Cannot recover transaction because its durable backup changed: ${entry.path}`);
@@ -640,8 +686,8 @@ async function rollbackEntries(transaction) {
640
686
  const current = await targetDigest(entry.path, entry.type);
641
687
  if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover transaction because destination changed: ${entry.path}`);
642
688
  if (entry.type === "config") {
643
- const currentConfig = await readText(entry.path);
644
- const previousConfig = await readText(entry.backup);
689
+ const currentConfig = (await readSharedConfigSnapshot(entry.path)).text;
690
+ const previousConfig = (await readSharedConfigSnapshot(entry.backup)).text;
645
691
  await writeFile(entry.path, `${removeCairnConfig(currentConfig)}${cairnConfigProjection(previousConfig)}`);
646
692
  await rm(entry.backup, { force: true });
647
693
  continue;
@@ -650,12 +696,14 @@ async function rollbackEntries(transaction) {
650
696
  }
651
697
  await mkdir(dirname(entry.path), { recursive: true });
652
698
  await rename(entry.backup, entry.path);
699
+ } else if (entry.type === "config" && entry.status === "prepared" && !destinationExists) {
700
+ continue;
653
701
  } else if (!entry.existed) {
654
702
  if (destinationExists) {
655
703
  const current = await targetDigest(entry.path, entry.type);
656
704
  if (current !== entry.expectedNewDigest) throw new Error(`Cannot recover new transaction target because it changed: ${entry.path}`);
657
705
  if (entry.type === "config") {
658
- const preserved = removeCairnConfig(await readText(entry.path));
706
+ const preserved = removeCairnConfig((await readSharedConfigSnapshot(entry.path)).text);
659
707
  if (preserved.length > 0) await writeFile(entry.path, preserved);
660
708
  else await rm(entry.path, { force: true });
661
709
  } else await rm(entry.path, { recursive: true, force: true });
@@ -688,7 +736,7 @@ async function assertTargetsReplaceable(targets, ownership) {
688
736
  }
689
737
  const record = records.get(target.id);
690
738
  if (!record) {
691
- if (target.type === "config" && cairnConfigProjection(await readText(target.path)).length === 0) continue;
739
+ if (target.type === "config" && cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length === 0) continue;
692
740
  throw new Error(`Refusing to overwrite unmanaged artifact: ${target.path}`);
693
741
  }
694
742
  if (target.id === "codex-runtime" && ownership?.version !== releaseVersion) {
@@ -696,13 +744,14 @@ async function assertTargetsReplaceable(targets, ownership) {
696
744
  }
697
745
  if (record.path !== target.path || record.type !== target.type) throw new Error(`Ownership manifest target mismatch: ${target.id}`);
698
746
  const current = await targetDigest(target.path, target.type);
699
- if (current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
747
+ if (target.type !== "config" && current !== record.installedDigest) throw new Error(`Managed artifact was modified: ${target.path}`);
700
748
  }
701
749
  }
702
750
 
703
751
  async function assertOwnershipDigests(ownership) {
704
752
  for (const record of ownership.targets) {
705
- if (await targetDigest(record.path, record.type) !== record.installedDigest) {
753
+ const current = await targetDigest(record.path, record.type);
754
+ if (record.type !== "config" && current !== record.installedDigest) {
706
755
  throw new Error(`Managed artifact was modified: ${record.path}`);
707
756
  }
708
757
  }
@@ -806,7 +855,7 @@ function versionedRuntimeRoot(version) {
806
855
  async function anyManagedArtifactExists(targets) {
807
856
  for (const target of targets) {
808
857
  if (!(await exists(target.path))) continue;
809
- if (target.type !== "config" || cairnConfigProjection(await readText(target.path)).length > 0) return true;
858
+ if (target.type !== "config" || cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text).length > 0) return true;
810
859
  }
811
860
  return false;
812
861
  }
@@ -826,7 +875,7 @@ async function adoptLegacy(targets, transaction) {
826
875
  }
827
876
  }
828
877
  if (target.type === "config") {
829
- const projection = cairnConfigProjection(await readText(target.path));
878
+ const projection = cairnConfigProjection((await readSharedConfigSnapshot(target.path)).text);
830
879
  if (!projection.includes("[marketplaces.cairn]") || !projection.includes('[plugins."cairn@cairn"]')) {
831
880
  throw new Error(`Legacy Cairn config cannot be identified: ${target.path}`);
832
881
  }
@@ -923,7 +972,7 @@ async function verifyLegacyMirror(target) {
923
972
 
924
973
  export async function targetDigest(path, type) {
925
974
  if (!(await exists(path))) return "missing";
926
- if (type === "config") return sha(cairnConfigProjection(await readText(path)));
975
+ if (type === "config") return sha(cairnConfigProjection((await readSharedConfigSnapshot(path)).text));
927
976
  const info = await lstat(path);
928
977
  if (info.isSymbolicLink() || (!info.isFile() && !info.isDirectory())) throw new Error(`Managed artifact is not a regular file/tree: ${path}`);
929
978
  if (info.isFile()) return sha(await readFile(path));
@@ -969,19 +1018,60 @@ export function updateConfig(config, hookStates, { marketplacePath = marketplace
969
1018
  }
970
1019
 
971
1020
  export function removeCairnConfig(config) {
972
- return splitSections(config).filter((section) => {
973
- if (section.header === null) return true;
974
- if (section.header === "marketplaces.cairn") return false;
975
- if (section.header === 'plugins."cairn@cairn"') return false;
976
- return !(section.header.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
977
- }).map((section) => section.text).join("");
1021
+ return splitSections(config).map((section) => isCairnConfigSection(section)
1022
+ ? splitTrailingTomlTrivia(section.text).trivia
1023
+ : section.text).join("");
978
1024
  }
979
1025
 
980
1026
  function cairnConfigProjection(config) {
981
- return splitSections(config).filter((section) => section.header === "marketplaces.cairn"
1027
+ return splitSections(config).filter(isCairnConfigSection)
1028
+ .map((section) => splitTrailingTomlTrivia(section.text).body).join("");
1029
+ }
1030
+
1031
+ function isCairnConfigSection(section) {
1032
+ return section.header === "marketplaces.cairn"
982
1033
  || section.header === 'plugins."cairn@cairn"'
983
- || (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:")))
984
- .map((section) => section.text).join("");
1034
+ || (section.header?.startsWith("hooks.state.") && section.header.includes("cairn@cairn:"));
1035
+ }
1036
+
1037
+ function splitTrailingTomlTrivia(text) {
1038
+ const lines = text.split(/(?<=\n)/);
1039
+ let boundary = lines.length;
1040
+ while (boundary > 0) {
1041
+ const line = lines[boundary - 1];
1042
+ if (line.length === 0 || line.trim().length === 0 || line.trimStart().startsWith("#")) boundary -= 1;
1043
+ else break;
1044
+ }
1045
+ return { body: lines.slice(0, boundary).join(""), trivia: lines.slice(boundary).join("") };
1046
+ }
1047
+
1048
+ async function readSharedConfigSnapshot(path) {
1049
+ let pathInfo;
1050
+ try {
1051
+ pathInfo = await lstat(path);
1052
+ } catch (error) {
1053
+ if (error?.code === "ENOENT") return { exists: false, text: "", digest: "missing" };
1054
+ throw error;
1055
+ }
1056
+ if (pathInfo.isSymbolicLink() || !pathInfo.isFile()) throw new Error(`Managed config is not a regular file: ${path}`);
1057
+ let handle;
1058
+ try {
1059
+ const flags = fsConstants.O_RDONLY | (fsConstants.O_NOFOLLOW ?? 0) | (fsConstants.O_NONBLOCK ?? 0);
1060
+ handle = await open(path, flags);
1061
+ } catch (error) {
1062
+ if (["ELOOP", "EFTYPE", "ENXIO"].includes(error?.code)) throw new Error(`Managed config is not a regular file: ${path}`);
1063
+ throw error;
1064
+ }
1065
+ try {
1066
+ const openedInfo = await handle.stat();
1067
+ if (!openedInfo.isFile() || openedInfo.dev !== pathInfo.dev || openedInfo.ino !== pathInfo.ino) {
1068
+ throw new Error(`Managed config changed identity while opening: ${path}`);
1069
+ }
1070
+ const text = await handle.readFile("utf8");
1071
+ return { exists: true, text, digest: sha(text) };
1072
+ } finally {
1073
+ await handle.close();
1074
+ }
985
1075
  }
986
1076
 
987
1077
  export function ensureSetting(config, sectionName, key, value) {
@@ -1,9 +1,55 @@
1
- import { existsSync } from "node:fs";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
2
4
  import { dirname, join, resolve } from "node:path";
3
5
  import { fileURLToPath } from "node:url";
4
6
 
5
7
  export const RUNTIME_LOCATOR_SCHEMA_VERSION = 1;
6
8
 
9
+ export function cairnHome(env = process.env) {
10
+ const userHome = env.HOME ?? env.USERPROFILE ?? homedir();
11
+ return resolve(env.CAIRN_HOME ?? join(userHome, ".cairn"));
12
+ }
13
+
14
+ export function repositoryId(root = process.cwd()) {
15
+ return `project-${hashPath(gitLayout(root).commonDir)}`;
16
+ }
17
+
18
+ export function worktreeId(root = process.cwd()) {
19
+ return `worktree-${hashPath(gitLayout(root).gitDir)}`;
20
+ }
21
+
22
+ export function goalStateDirectory(root = process.cwd(), env = process.env) {
23
+ return join(cairnHome(env), "projects", repositoryId(root), "worktrees", worktreeId(root));
24
+ }
25
+
26
+ function gitLayout(root) {
27
+ const workspaceRoot = realpathSync(resolve(root));
28
+ const gitEntry = join(workspaceRoot, ".git");
29
+ try {
30
+ const stat = lstatSync(gitEntry);
31
+ const gitDir = stat.isDirectory() ? realpathSync(gitEntry) : resolveGitFile(workspaceRoot, gitEntry);
32
+ const commonFile = join(gitDir, "commondir");
33
+ const commonDir = existsSync(commonFile)
34
+ ? realpathSync(resolve(gitDir, readFileSync(commonFile, "utf8").trim()))
35
+ : gitDir;
36
+ return { gitDir, commonDir };
37
+ } catch (error) {
38
+ if (error?.code !== "ENOENT") throw error;
39
+ return { gitDir: workspaceRoot, commonDir: workspaceRoot };
40
+ }
41
+ }
42
+
43
+ function resolveGitFile(workspaceRoot, gitEntry) {
44
+ const match = readFileSync(gitEntry, "utf8").match(/^gitdir:\s*(.+)\s*$/m);
45
+ if (!match) throw new Error(`Invalid Git worktree file: ${gitEntry}`);
46
+ return realpathSync(resolve(workspaceRoot, match[1]));
47
+ }
48
+
49
+ function hashPath(value) {
50
+ return createHash("sha256").update(value).digest("hex");
51
+ }
52
+
7
53
  export function resolvePluginRoot(moduleUrl = import.meta.url) {
8
54
  return resolve(dirname(fileURLToPath(moduleUrl)), "..");
9
55
  }