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.
- package/.agents/workflows/cairn-memory.md +1 -1
- package/.agents/workflows/cairn-plan.md +6 -2
- package/.agents/workflows/cairn-review.md +1 -1
- package/.agents/workflows/cairn-work.md +5 -1
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.codex-plugin/plugin.json +4 -4
- package/README.ko.md +2 -0
- package/README.md +8 -4
- package/agents/explorer.md +2 -2
- package/agents/worker.md +6 -2
- package/commands/cairn-memory.md +1 -1
- package/commands/cairn-plan.md +7 -3
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-uninstall.md +1 -1
- package/commands/cairn-work.md +5 -1
- package/docs/model-guidance/README.md +14 -2
- package/docs/model-guidance/claude.md +2 -0
- package/docs/model-guidance/codex.md +3 -1
- package/package.json +2 -2
- package/scripts/cairn-cleanup.mjs +61 -0
- package/scripts/cairn-goal.mjs +143 -24
- package/scripts/cairn-lifecycle.mjs +118 -28
- package/scripts/cairn-paths.mjs +47 -1
- package/scripts/cairn-state.mjs +41 -15
- package/scripts/cairn-state.sh +6 -6
- package/scripts/cairn.mjs +32 -24
- package/skills/cairn-memory/SKILL.md +3 -3
- package/skills/cairn-plan/SKILL.md +28 -9
- package/skills/cairn-review/SKILL.md +4 -4
- package/skills/cairn-work/SKILL.md +33 -20
- package/templates/PLAN.md +3 -1
- package/templates/work-plan.md +21 -0
package/scripts/cairn-goal.mjs
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
|
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
|
|
58
|
-
|
|
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
|
|
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
|
|
99
|
-
await assertNoSymlinkComponents(
|
|
127
|
+
await ensureGoalStateDirectory(root);
|
|
128
|
+
await assertNoSymlinkComponents(cairnHome(), path);
|
|
100
129
|
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
101
130
|
try {
|
|
102
|
-
await safeWriteFile(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
|
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
|
-
|
|
243
|
-
|
|
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 (
|
|
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
|
|
644
|
-
const previousConfig = await
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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).
|
|
973
|
-
|
|
974
|
-
|
|
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(
|
|
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
|
-
|
|
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) {
|
package/scripts/cairn-paths.mjs
CHANGED
|
@@ -1,9 +1,55 @@
|
|
|
1
|
-
import {
|
|
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
|
}
|