cairn-ai 0.2.2 → 0.2.4

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.
@@ -0,0 +1,246 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { hostname } from "node:os";
3
+ import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
4
+ import { dirname, join, relative, resolve, sep } from "node:path";
5
+
6
+ const LOCK_SCHEMA_VERSION = 1;
7
+ const DEFAULT_LOCK_TIMEOUT_MS = 5_000;
8
+ const DEFAULT_RETRY_MS = 25;
9
+ const DEFAULT_MALFORMED_STALE_MS = 30_000;
10
+ const TRANSIENT_LOCK_ERROR_CODES = new Set(["EACCES", "EBUSY", "EPERM"]);
11
+
12
+ export function stateLockPath(root = process.cwd()) {
13
+ return safePath(root, ".cairn/state.lock");
14
+ }
15
+
16
+ export function safePath(root, relativePath) {
17
+ const workspaceRoot = resolve(root);
18
+ const target = resolve(workspaceRoot, relativePath);
19
+ const relation = relative(workspaceRoot, target);
20
+ if (relation === ".." || relation.startsWith(`..${sep}`)) {
21
+ throw new Error(`Managed path must remain inside the workspace: ${relativePath}`);
22
+ }
23
+ return target;
24
+ }
25
+
26
+ export async function assertNoSymlinkComponents(root, target, { allowMissing = true } = {}) {
27
+ const workspaceRoot = resolve(root);
28
+ const absoluteTarget = resolve(target);
29
+ const relation = relative(workspaceRoot, absoluteTarget);
30
+ if (relation === ".." || relation.startsWith(`..${sep}`)) {
31
+ throw new Error(`Managed path must remain inside the workspace: ${target}`);
32
+ }
33
+ const components = relation === "" ? [] : relation.split(sep);
34
+ let current = workspaceRoot;
35
+ await rejectSymlink(current);
36
+ for (const component of components) {
37
+ current = join(current, component);
38
+ try {
39
+ await rejectSymlink(current);
40
+ } catch (error) {
41
+ if (allowMissing && error?.code === "ENOENT") return;
42
+ throw error;
43
+ }
44
+ }
45
+ }
46
+
47
+ export async function safeMkdir(root, relativePath) {
48
+ const workspaceRoot = resolve(root);
49
+ const target = safePath(workspaceRoot, relativePath);
50
+ await assertNoSymlinkComponents(workspaceRoot, workspaceRoot, { allowMissing: false });
51
+ const relation = relative(workspaceRoot, target);
52
+ let current = workspaceRoot;
53
+ for (const component of relation.split(sep).filter(Boolean)) {
54
+ current = join(current, component);
55
+ try {
56
+ const stat = await lstat(current);
57
+ if (stat.isSymbolicLink()) throw symlinkError(current);
58
+ if (!stat.isDirectory()) throw new Error(`Managed directory component is not a directory: ${current}`);
59
+ } catch (error) {
60
+ if (error?.code !== "ENOENT") throw error;
61
+ try {
62
+ await mkdir(current);
63
+ } catch (mkdirError) {
64
+ if (mkdirError?.code !== "EEXIST") throw mkdirError;
65
+ const stat = await lstat(current);
66
+ if (stat.isSymbolicLink()) throw symlinkError(current);
67
+ if (!stat.isDirectory()) throw new Error(`Managed directory component is not a directory: ${current}`);
68
+ }
69
+ }
70
+ }
71
+ return target;
72
+ }
73
+
74
+ export async function safeWriteFile(root, target, data, options) {
75
+ const absoluteTarget = safePath(root, relative(resolve(root), resolve(target)));
76
+ await assertNoSymlinkComponents(root, dirname(absoluteTarget), { allowMissing: false });
77
+ await assertNoSymlinkComponents(root, absoluteTarget);
78
+ return writeFile(absoluteTarget, data, options);
79
+ }
80
+
81
+ export async function withStateLock(root, operation, {
82
+ timeoutMs = DEFAULT_LOCK_TIMEOUT_MS,
83
+ retryMs = DEFAULT_RETRY_MS,
84
+ malformedStaleMs = DEFAULT_MALFORMED_STALE_MS,
85
+ testHooks = {},
86
+ } = {}) {
87
+ if (!Number.isFinite(timeoutMs) || timeoutMs < 0) throw new Error("lock timeoutMs must be a non-negative number");
88
+ const workspaceRoot = resolve(root);
89
+ await safeMkdir(workspaceRoot, ".cairn");
90
+ const lockPath = stateLockPath(workspaceRoot);
91
+ const owner = {
92
+ schemaVersion: LOCK_SCHEMA_VERSION,
93
+ pid: process.pid,
94
+ hostname: hostname(),
95
+ nonce: randomUUID(),
96
+ acquiredAt: new Date().toISOString(),
97
+ };
98
+ const deadline = Date.now() + timeoutMs;
99
+ while (true) {
100
+ try {
101
+ await safeWriteFile(workspaceRoot, lockPath, `${JSON.stringify(owner)}\n`, { encoding: "utf8", flag: "wx" });
102
+ await testHooks.afterLockWritten?.({ lockPath, owner: structuredClone(owner) });
103
+ if (await confirmsLockOwnership(lockPath, owner.nonce)) break;
104
+ } catch (error) {
105
+ if (isTransientLockError(error)) {
106
+ // Windows can briefly deny a lock file that another process is closing.
107
+ // Retry within the deadline; permission errors are not evidence of staleness.
108
+ } else if (error?.code === "EEXIST") {
109
+ try {
110
+ await testHooks.beforeContendedLockConfirmation?.({ lockPath });
111
+ if (await confirmsLockOwnership(lockPath, owner.nonce)) break;
112
+ const reclaimed = await reclaimStaleLock(workspaceRoot, lockPath, malformedStaleMs, testHooks);
113
+ if (reclaimed) continue;
114
+ } catch (inspectionError) {
115
+ if (!isTransientLockError(inspectionError)) throw inspectionError;
116
+ }
117
+ } else {
118
+ throw error;
119
+ }
120
+ }
121
+ if (Date.now() >= deadline) throw new Error(`Timed out waiting for Cairn state lock after ${timeoutMs}ms`);
122
+ await delay(Math.min(retryMs, Math.max(1, deadline - Date.now())));
123
+ }
124
+
125
+ try {
126
+ return await operation();
127
+ } finally {
128
+ await releaseOwnedLock(lockPath, owner.nonce);
129
+ }
130
+ }
131
+
132
+ async function reclaimStaleLock(root, lockPath, malformedStaleMs, testHooks) {
133
+ let observed;
134
+ let stat;
135
+ try {
136
+ [observed, stat] = await Promise.all([readFile(lockPath, "utf8"), lstat(lockPath)]);
137
+ } catch (error) {
138
+ if (error?.code === "ENOENT") return true;
139
+ throw error;
140
+ }
141
+ if (stat.isSymbolicLink()) throw symlinkError(lockPath);
142
+ const record = parseLockRecord(observed);
143
+ if (record) {
144
+ if (record.hostname !== hostname()) return false;
145
+ if (pidIsAlive(record.pid)) return false;
146
+ } else if (Date.now() - stat.mtimeMs <= malformedStaleMs) {
147
+ return false;
148
+ }
149
+
150
+ await testHooks.afterStaleObserved?.({ lockPath, observed, record: record ? structuredClone(record) : null });
151
+ const quarantine = safePath(root, `.cairn/state.lock.stale.${record?.nonce ?? "malformed"}.${randomUUID()}`);
152
+ try {
153
+ await rename(lockPath, quarantine);
154
+ } catch (error) {
155
+ if (error?.code === "ENOENT") return true;
156
+ throw error;
157
+ }
158
+ const moved = await readFile(quarantine, "utf8");
159
+ if (moved !== observed) {
160
+ const restored = await restoreMovedCandidate(lockPath, quarantine);
161
+ await testHooks.afterReclaimMismatch?.({ lockPath, restored });
162
+ return false;
163
+ }
164
+ await rm(quarantine, { force: true });
165
+ return true;
166
+ }
167
+
168
+ async function restoreMovedCandidate(lockPath, quarantine) {
169
+ try {
170
+ await link(quarantine, lockPath);
171
+ await rm(quarantine);
172
+ return true;
173
+ } catch (error) {
174
+ if (error?.code !== "EEXIST") throw error;
175
+ await rm(quarantine, { force: true });
176
+ return false;
177
+ }
178
+ }
179
+
180
+ async function confirmsLockOwnership(lockPath, nonce) {
181
+ try {
182
+ return parseLockRecord(await readFile(lockPath, "utf8"))?.nonce === nonce;
183
+ } catch (error) {
184
+ if (error?.code === "ENOENT") return false;
185
+ throw error;
186
+ }
187
+ }
188
+
189
+ async function releaseOwnedLock(lockPath, nonce) {
190
+ for (let attempt = 0; attempt < 8; attempt += 1) {
191
+ try {
192
+ const record = parseLockRecord(await readFile(lockPath, "utf8"));
193
+ if (record?.nonce !== nonce) return;
194
+ await rm(lockPath);
195
+ return;
196
+ } catch (error) {
197
+ if (error?.code === "ENOENT") return;
198
+ if (!isTransientLockError(error) || attempt === 7) throw error;
199
+ await delay(10 * (attempt + 1));
200
+ }
201
+ }
202
+ }
203
+
204
+ function isTransientLockError(error) {
205
+ return TRANSIENT_LOCK_ERROR_CODES.has(error?.code);
206
+ }
207
+
208
+ function parseLockRecord(text) {
209
+ try {
210
+ const value = JSON.parse(text);
211
+ if (value?.schemaVersion !== LOCK_SCHEMA_VERSION) return null;
212
+ if (!Number.isSafeInteger(value.pid) || value.pid <= 0) return null;
213
+ if (typeof value.hostname !== "string" || value.hostname.length === 0) return null;
214
+ if (typeof value.nonce !== "string" || value.nonce.length === 0) return null;
215
+ if (typeof value.acquiredAt !== "string" || Number.isNaN(Date.parse(value.acquiredAt))) return null;
216
+ return value;
217
+ } catch {
218
+ return null;
219
+ }
220
+ }
221
+
222
+ function pidIsAlive(pid) {
223
+ try {
224
+ process.kill(pid, 0);
225
+ return true;
226
+ } catch (error) {
227
+ if (error?.code === "EPERM") return true;
228
+ if (error?.code === "ESRCH") return false;
229
+ return true;
230
+ }
231
+ }
232
+
233
+ async function rejectSymlink(path) {
234
+ const stat = await lstat(path);
235
+ if (stat.isSymbolicLink()) throw symlinkError(path);
236
+ }
237
+
238
+ function symlinkError(path) {
239
+ const error = new Error(`Managed path must not contain a symbolic link: ${path}`);
240
+ error.code = "CAIRN_SYMLINK";
241
+ return error;
242
+ }
243
+
244
+ function delay(ms) {
245
+ return new Promise((resolvePromise) => setTimeout(resolvePromise, ms));
246
+ }
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  import { realpathSync } from "node:fs";
3
- import { mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { readFile } from "node:fs/promises";
4
4
  import { join } from "node:path";
5
5
  import { fileURLToPath, pathToFileURL } from "node:url";
6
6
  import { evaluateStop, isGoalOwnedBySession, readGoalState } from "./cairn-goal.mjs";
7
7
  import { resolveRepoRoot } from "./cairn-paths.mjs";
8
+ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile } from "./cairn-safe-fs.mjs";
8
9
 
9
10
  const memoryTemplate = `# MEMORY
10
11
 
@@ -116,7 +117,7 @@ const planTemplateKo = `# PLAN
116
117
 
117
118
  const contextLimits = {
118
119
  goalTitle: 64,
119
- taskId: 32,
120
+ roadmapTaskId: 24,
120
121
  taskTitle: 40,
121
122
  roadmapWindow: 3,
122
123
  };
@@ -157,17 +158,25 @@ export async function runStateResult(event = "manual", {
157
158
 
158
159
  if (event === "session-start" || event === "user-prompt-submit") {
159
160
  const state = await readGoalState({ root: resolvedRoot });
160
- if (!isGoalOwnedBySession(state, payload?.session_id)) return { status: 0, message: "", hookOutput: {} };
161
+ if (state?.goal?.status === "active" && !isGoalOwnedBySession(state, payload?.session_id)) {
162
+ return foreignSessionContextResult({ ko, event });
163
+ }
161
164
  return contextResult({ ko, state, event });
162
165
  }
163
166
 
164
167
  if (event === "post-tool-use") {
168
+ const message = ko
169
+ ? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
170
+ : "Cairn check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.";
165
171
  return {
166
172
  status: 0,
167
- message: ko
168
- ? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
169
- : "Cairn check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.",
170
- hookOutput: {},
173
+ message,
174
+ hookOutput: {
175
+ hookSpecificOutput: {
176
+ hookEventName: "PostToolUse",
177
+ additionalContext: message,
178
+ },
179
+ },
171
180
  };
172
181
  }
173
182
 
@@ -193,73 +202,98 @@ export async function runStateResult(event = "manual", {
193
202
  }
194
203
 
195
204
  async function initializeProject(root, ko) {
196
- await mkdir(join(root, "docs", "memory"), { recursive: true });
197
- await mkdir(join(root, "docs", "plan"), { recursive: true });
198
- await writeIfMissing(join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
199
- await writeIfMissing(join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
205
+ await safeMkdir(root, "docs/memory");
206
+ await safeMkdir(root, "docs/plan");
207
+ await writeIfMissing(root, join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
208
+ await writeIfMissing(root, join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
200
209
  }
201
210
 
202
- async function writeIfMissing(path, content) {
211
+ async function writeIfMissing(root, path, content) {
212
+ await assertNoSymlinkComponents(root, path);
203
213
  try {
204
214
  await readFile(path, "utf8");
205
215
  } catch (error) {
206
216
  if (error?.code !== "ENOENT") throw error;
207
- await writeFile(path, content);
217
+ try {
218
+ await safeWriteFile(root, path, content, { encoding: "utf8", flag: "wx" });
219
+ } catch (writeError) {
220
+ if (writeError?.code !== "EEXIST") throw writeError;
221
+ await assertNoSymlinkComponents(root, path, { allowMissing: false });
222
+ }
208
223
  }
209
224
  }
210
225
 
211
226
  function contextResult({ ko, state, event }) {
212
- const base = ko ? "Cairn: 루트 MEMORY.md를 읽으세요." : "Cairn: read root MEMORY.md.";
227
+ const base = ko ? "Cairn kernel: 루트 MEMORY.md를 먼저 읽으세요." : "Cairn kernel: read root MEMORY.md first.";
213
228
  const idlePolicy = event === "user-prompt-submit"
214
229
  ? (ko
215
- ? " 구현/계속 실행: 초기 트리아지 계획 작성 → update_plan → create_goal → 탐색 전 저장소 goal 시작 → 트리아지 후 계획 확정 → 구현. 상담·설명·계획 전용은 goal 없이 처리하세요."
216
- : " Implementation/continue: write an initial triage plan; call update_plan, then create_goal; start the repository goal before exploration; finalize the plan after triage, then implement. Skip goals for consultation, explanation, or plan-only requests.")
217
- : "";
218
- if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}` });
230
+ ? " 구현/계속 실행이면 cairn-plan을 로드하고 active plan과 current task를 복원하거나 생성하세요. 상담·설명·계획 전용은 goal 없이 처리하세요."
231
+ : " For implementation/continue, load cairn-plan and restore or create the active plan and current task. Keep consultation, explanation, and plan-only requests goal-free.")
232
+ : (ko
233
+ ? " 중요 작업 전에 cairn-plan을 로드하고 active plan과 current task를 복원하세요."
234
+ : " Before non-trivial work, load cairn-plan and restore the active plan and current task.");
235
+ const failClosed = ko
236
+ ? " state, skill, plan 또는 required reference가 없거나 읽을 수 없거나 일치하지 않으면 수정·위임·완료하지 말고 보고하거나 차단하세요."
237
+ : " If state, skill, plan, or a required ref is missing, unreadable, or inconsistent, do not edit, delegate, or complete; report/block.";
238
+ if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}${failClosed}` });
219
239
  const task = state.tasks.find((item) => item.status === "active")
220
240
  ?? state.tasks.find((item) => item.status === "pending")
221
241
  ?? state.tasks.find((item) => item.status === "blocked");
222
- const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
242
+ const planId = state.goal.planId;
243
+ const phase = task ? "cairn-work" : "cairn-review";
223
244
  const activeHeader = ko
224
- ? `Cairn active: "${goalTitle}". ${base}`
225
- : `Cairn active: "${goalTitle}". ${base}`;
245
+ ? `${base} ${phase}를 로드하고 정확한 plan ${planId}${task ? "과 아래 current task" : "의 완료 기준"}를 복원하세요. 압축·재시작·handoff·위임 뒤 다시 읽으세요.${failClosed}`
246
+ : `${base} Load ${phase}; restore exact plan ${planId}${task ? " and the current task below" : " completion criteria"}. Re-read after compaction/restart/handoff/delegation.${failClosed}`;
226
247
  if (!task) {
227
248
  return contextHookResult({
228
249
  event,
229
250
  message: [activeHeader, taskRoadmap(ko, state), activeGoalCompletionMessage(ko, state)].join("\n"),
230
251
  });
231
252
  }
232
- const taskId = clipText(task.id, contextLimits.taskId);
253
+ const taskId = task.id;
233
254
  const taskTitle = clipText(task.title, contextLimits.taskTitle);
234
- const continuation = ko
235
- ? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
236
- : `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`;
237
- const resume = event === "user-prompt-submit"
255
+ const continuation = taskId.length > 48
238
256
  ? (ko
239
- ? `곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 ${taskId}를 재개하세요.`
240
- : `After a side question, resume ${taskId} unless asked to pause, stop, or switch.`)
257
+ ? `정확한 current task: ${taskId}. 바인딩된 증거 뒤에만 완료하세요.`
258
+ : `Exact task: ${taskId}. Complete after bound evidence.`)
259
+ : (ko
260
+ ? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
261
+ : `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`);
262
+ const resume = event === "user-prompt-submit"
263
+ ? (taskId.length > 48
264
+ ? (ko
265
+ ? "곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 위의 정확한 task를 재개하세요."
266
+ : "Resume that exact task after side questions unless paused, stopped, or switched.")
267
+ : (ko
268
+ ? `곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 ${taskId}를 재개하세요.`
269
+ : `After a side question, resume ${taskId} unless asked to pause, stop, or switch.`))
241
270
  : "";
242
271
  return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, resume].filter(Boolean).join("\n") });
243
272
  }
244
273
 
274
+ function foreignSessionContextResult({ ko, event }) {
275
+ const message = ko
276
+ ? "Cairn kernel: 루트 MEMORY.md를 먼저 읽으세요. 저장소 goal을 다른 session이 소유합니다. cairn goal status로 상태를 확인하고 소유권이 해결될 때까지 Cairn 작업을 시작·수정·위임·완료하지 마세요. 이 capsule은 goal, plan, task 상세를 노출하지 않습니다."
277
+ : "Cairn kernel: read root MEMORY.md first. A repository goal is owned by another session. Inspect cairn goal status; until ownership is resolved, do not start, edit, delegate, or complete Cairn work. This capsule does not expose goal, plan, or task details.";
278
+ return contextHookResult({ event, message });
279
+ }
280
+
245
281
  function taskRoadmap(ko, state) {
246
- const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.taskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
282
+ const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.roadmapTaskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
247
283
  if (state.tasks.length <= contextLimits.roadmapWindow) {
248
284
  return `${ko ? "작업 단계" : "Work steps"}:\n${state.tasks.map(formatTask).join("\n")}`;
249
285
  }
250
286
 
251
287
  const currentIndex = Math.max(0, state.tasks.findIndex((task) => task.status === "active" || task.status === "pending"));
252
- const maxStart = state.tasks.length - contextLimits.roadmapWindow;
253
- const start = Math.min(Math.max(0, currentIndex - 1), maxStart);
254
- const visible = state.tasks.slice(start, start + contextLimits.roadmapWindow);
255
288
  const counts = Object.fromEntries([...TASK_STATUS_NAMES].map((status) => [status, state.tasks.filter((task) => task.status === status).length]));
256
289
  const countSummary = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status} ${count}`).join(", ");
257
- const omitted = state.tasks.length - visible.length;
290
+ const omitted = state.tasks.length - 1;
258
291
  const header = ko
259
292
  ? `작업 단계 (${state.tasks.length}개; ${countSummary})`
260
293
  : `Work steps (${state.tasks.length}; ${countSummary})`;
261
294
  const omission = ko ? `… ${omitted}개 단계 생략` : `… ${omitted} steps omitted`;
262
- return `${header}:\n${visible.map((task, index) => formatTask(task, start + index)).join("\n")}\n${omission}`;
295
+ const currentPosition = currentIndex + 1;
296
+ return `${header}. ${ko ? `현재 위치 ${currentPosition}` : `Current position ${currentPosition}`}; ${omission}`;
263
297
  }
264
298
 
265
299
  function contextHookResult({ event, message }) {