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
@@ -1,9 +1,55 @@
1
- import { existsSync } from "node:fs";
2
- import { dirname, join, resolve } from "node:path";
1
+ import { createHash } from "node:crypto";
2
+ import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
3
+ import { homedir } from "node:os";
4
+ import { dirname, join, relative, 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
  }
@@ -87,12 +133,42 @@ export function createRuntimeLocator(pluginRoot) {
87
133
  };
88
134
  }
89
135
 
136
+ const runtimeEntryRelativePaths = [
137
+ join("scripts", "cairn.mjs"),
138
+ join("scripts", "cairn-lifecycle.mjs"),
139
+ join("scripts", "cairn-state.mjs"),
140
+ join("scripts", "cairn-toolcheck.mjs"),
141
+ join("scripts", "cairn-goal.mjs"),
142
+ join("scripts", "cairn-cleanup.mjs"),
143
+ ];
144
+ const localModuleSpecifier = /^(?:import|export)\s+(?:(?:\{[^}]*\}|\*\s+as\s+\w+|[\w$]+)(?:\s*,\s*(?:\{[^}]*\}|\*\s+as\s+\w+))?\s+from\s+)?["'](\.[^"']+)["']/gm;
145
+
146
+ export function runtimeRequiredRelativePaths(pluginRoot) {
147
+ const root = resolve(pluginRoot);
148
+ const pending = runtimeEntryRelativePaths.map((path) => join(root, path));
149
+ const closure = new Set();
150
+
151
+ while (pending.length > 0) {
152
+ const path = pending.pop();
153
+ if (closure.has(path)) continue;
154
+ closure.add(path);
155
+ const source = readFileSync(path, "utf8");
156
+ for (const match of source.matchAll(localModuleSpecifier)) pending.push(resolve(dirname(path), match[1]));
157
+ }
158
+
159
+ return [...closure].map((path) => relative(root, path)).sort();
160
+ }
161
+
90
162
  export function runtimeRequiredPaths(locator) {
91
- if (!locator || locator.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION || locator.plugin !== "cairn") return [];
163
+ if (!locator || locator.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION || locator.plugin !== "cairn"
164
+ || typeof locator.pluginRoot !== "string") return [];
165
+ const root = locator.pluginRoot;
92
166
  return [
93
- locator.entrypoints?.cli,
94
- locator.entrypoints?.state,
95
- locator.entrypoints?.toolcheck,
167
+ join(root, "package.json"),
168
+ join(root, ".codex-plugin", "plugin.json"),
169
+ join(root, "hooks", "hooks.json"),
170
+ ...runtimeRequiredRelativePaths(root).map((path) => join(root, path)),
171
+ join(root, "scripts", "release-integrity-0.2.2.json"),
96
172
  locator.resources?.commands,
97
173
  locator.resources?.agents,
98
174
  locator.resources?.skills,
@@ -1,6 +1,6 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { hostname } from "node:os";
3
- import { link, lstat, mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
3
+ import { lstat, mkdir, readFile, readdir, rename, rm, writeFile } from "node:fs/promises";
4
4
  import { dirname, join, relative, resolve, sep } from "node:path";
5
5
 
6
6
  const LOCK_SCHEMA_VERSION = 1;
@@ -100,7 +100,7 @@ export async function withStateLock(root, operation, {
100
100
  try {
101
101
  await safeWriteFile(workspaceRoot, lockPath, `${JSON.stringify(owner)}\n`, { encoding: "utf8", flag: "wx" });
102
102
  await testHooks.afterLockWritten?.({ lockPath, owner: structuredClone(owner) });
103
- if (await confirmsLockOwnership(lockPath, owner.nonce)) break;
103
+ if (await confirmsUnclaimedLockOwnership(workspaceRoot, lockPath, owner.nonce)) break;
104
104
  } catch (error) {
105
105
  if (isTransientLockError(error)) {
106
106
  // Windows can briefly deny a lock file that another process is closing.
@@ -108,8 +108,8 @@ export async function withStateLock(root, operation, {
108
108
  } else if (error?.code === "EEXIST") {
109
109
  try {
110
110
  await testHooks.beforeContendedLockConfirmation?.({ lockPath });
111
- if (await confirmsLockOwnership(lockPath, owner.nonce)) break;
112
- const reclaimed = await reclaimStaleLock(workspaceRoot, lockPath, malformedStaleMs, testHooks);
111
+ if (await confirmsUnclaimedLockOwnership(workspaceRoot, lockPath, owner.nonce)) break;
112
+ const reclaimed = await reclaimStaleLock(workspaceRoot, lockPath, owner, malformedStaleMs, testHooks);
113
113
  if (reclaimed) continue;
114
114
  } catch (inspectionError) {
115
115
  if (!isTransientLockError(inspectionError)) throw inspectionError;
@@ -129,7 +129,7 @@ export async function withStateLock(root, operation, {
129
129
  }
130
130
  }
131
131
 
132
- async function reclaimStaleLock(root, lockPath, malformedStaleMs, testHooks) {
132
+ async function reclaimStaleLock(root, lockPath, owner, malformedStaleMs, testHooks) {
133
133
  let observed;
134
134
  let stat;
135
135
  try {
@@ -140,44 +140,47 @@ async function reclaimStaleLock(root, lockPath, malformedStaleMs, testHooks) {
140
140
  }
141
141
  if (stat.isSymbolicLink()) throw symlinkError(lockPath);
142
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
- }
143
+ if (!isReclaimableLock(record, stat, malformedStaleMs)) return false;
149
144
 
150
145
  await testHooks.afterStaleObserved?.({ lockPath, observed, record: record ? structuredClone(record) : null });
151
- const quarantine = safePath(root, `.cairn/state.lock.stale.${record?.nonce ?? "malformed"}.${randomUUID()}`);
146
+ const claimPath = safePath(root, `.cairn/state.lock.reclaim.${owner.nonce}`);
147
+ await safeWriteFile(root, claimPath, `${JSON.stringify(owner)}\n`, { encoding: "utf8", flag: "wx" });
152
148
  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
- }
149
+ await testHooks.afterReclaimInterlock?.({ lockPath });
150
+ let claimed;
151
+ let claimedStat;
152
+ try {
153
+ [claimed, claimedStat] = await Promise.all([readFile(lockPath, "utf8"), lstat(lockPath)]);
154
+ } catch (error) {
155
+ if (error?.code === "ENOENT") return true;
156
+ throw error;
157
+ }
158
+ if (claimedStat.isSymbolicLink()) throw symlinkError(lockPath);
159
+ const claimedRecord = parseLockRecord(claimed);
160
+ if (!isReclaimableLock(claimedRecord, claimedStat, malformedStaleMs)) return false;
167
161
 
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;
162
+ const quarantine = safePath(root, `.cairn/state.lock.stale.${claimedRecord?.nonce ?? "malformed"}.${randomUUID()}`);
163
+ try {
164
+ await rename(lockPath, quarantine);
165
+ } catch (error) {
166
+ if (error?.code === "ENOENT") return true;
167
+ throw error;
168
+ }
169
+ const movedStat = await lstat(quarantine);
175
170
  await rm(quarantine, { force: true });
176
- return false;
171
+ return movedStat.dev === claimedStat.dev && movedStat.ino === claimedStat.ino;
172
+ } finally {
173
+ await releaseOwnedLock(claimPath, owner.nonce);
177
174
  }
178
175
  }
179
176
 
180
- async function confirmsLockOwnership(lockPath, nonce) {
177
+ function isReclaimableLock(record, stat, malformedStaleMs) {
178
+ if (record) return record.hostname === hostname() && !pidIsAlive(record.pid);
179
+ return Date.now() - stat.mtimeMs > malformedStaleMs;
180
+ }
181
+
182
+ async function confirmsUnclaimedLockOwnership(root, lockPath, nonce) {
183
+ if (await activeReclaimClaimExists(root)) return false;
181
184
  try {
182
185
  return parseLockRecord(await readFile(lockPath, "utf8"))?.nonce === nonce;
183
186
  } catch (error) {
@@ -186,6 +189,29 @@ async function confirmsLockOwnership(lockPath, nonce) {
186
189
  }
187
190
  }
188
191
 
192
+ async function activeReclaimClaimExists(root) {
193
+ const claimNames = (await readdir(safePath(root, ".cairn")))
194
+ .filter((name) => name.startsWith("state.lock.reclaim."));
195
+ for (const name of claimNames) {
196
+ const claimPath = safePath(root, `.cairn/${name}`);
197
+ let stat;
198
+ let record;
199
+ try {
200
+ [stat, record] = await Promise.all([lstat(claimPath), readFile(claimPath, "utf8").then(parseLockRecord)]);
201
+ } catch (error) {
202
+ if (error?.code === "ENOENT") continue;
203
+ throw error;
204
+ }
205
+ if (stat.isSymbolicLink()) throw symlinkError(claimPath);
206
+ if (record?.hostname === hostname() && !pidIsAlive(record.pid)) {
207
+ await releaseOwnedLock(claimPath, record.nonce);
208
+ continue;
209
+ }
210
+ return true;
211
+ }
212
+ return false;
213
+ }
214
+
189
215
  async function releaseOwnedLock(lockPath, nonce) {
190
216
  for (let attempt = 0; attempt < 8; attempt += 1) {
191
217
  try {
@@ -0,0 +1,148 @@
1
+ const contextLimits = {
2
+ goalTitle: 64,
3
+ roadmapTaskId: 24,
4
+ taskTitle: 40,
5
+ roadmapWindow: 3,
6
+ };
7
+
8
+ export function prependContext(result, notice) {
9
+ const specific = result.hookOutput?.hookSpecificOutput;
10
+ if (!specific) return { ...result, message: `${notice}\n${result.message}` };
11
+ return {
12
+ ...result,
13
+ message: `${notice}\n${result.message}`,
14
+ hookOutput: {
15
+ ...result.hookOutput,
16
+ hookSpecificOutput: {
17
+ ...specific,
18
+ additionalContext: `${notice}\n${specific.additionalContext}`,
19
+ },
20
+ },
21
+ };
22
+ }
23
+
24
+ export function contextResult({ ko, state, event }) {
25
+ const base = kernelBase(ko);
26
+ const idlePolicy = event === "user-prompt-submit"
27
+ ? (ko
28
+ ? " 비단순 구현이나 계획된 작업 재개는 cairn-plan과 plan/task를 복원·생성하세요. 대상이 확정된 Git/GitHub 상태·fetch·checkout·merge·push·PR 작업은 코드 수정·충돌 해결·파괴적 복구·릴리스/배포·설계가 필요하지 않으면 plan/goal 없이 실행하세요. 상담·설명·계획 전용도 goal 없이 처리하세요."
29
+ : " For non-trivial implementation or planned-work continuation, load cairn-plan and restore/create its plan and task. Known-target Git/GitHub operations stay plan/goal-free unless they require code edits, conflict resolution, destructive recovery, release/deploy, or design. Consultation, explanation, and plan-only requests are also goal-free.")
30
+ : (ko
31
+ ? " 중요 작업 전에 cairn-plan을 로드하고 active plan과 current task를 복원하세요."
32
+ : " Before non-trivial work, load cairn-plan and restore the active plan and current task.");
33
+ const failClosed = ko
34
+ ? " active state와 그 skill·plan·required task reference가 없거나 읽을 수 없거나 일치하지 않을 때만 중단하세요."
35
+ : " Stop only if active state and its skill, plan, or required task ref is missing, unreadable, or inconsistent.";
36
+ if (state && (state.goal.status === "paused" || state.goal.status === "blocked")) {
37
+ return interruptedContextResult(event, ko, state);
38
+ }
39
+ if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}${failClosed}` });
40
+ const task = state.tasks.find((item) => item.status === "active")
41
+ ?? state.tasks.find((item) => item.status === "pending")
42
+ ?? state.tasks.find((item) => item.status === "blocked");
43
+ const planId = state.goal.planId;
44
+ const phase = task ? "cairn-work" : "cairn-review";
45
+ const activeHeader = ko
46
+ ? `${base} ${phase}를 로드하고 정확한 plan ${planId}${task ? "과 아래 current task" : "의 완료 기준"}를 복원하세요. 압축·재시작·handoff·위임 뒤 다시 읽으세요.${failClosed}`
47
+ : `${base} Load ${phase}; restore exact plan ${planId}${task ? " and the current task below" : " completion criteria"}. Re-read after compaction/restart/handoff/delegation.${failClosed}`;
48
+ if (!task) {
49
+ return contextHookResult({
50
+ event,
51
+ message: [activeHeader, taskRoadmap(ko, state), activeGoalCompletionMessage(ko, state)].join("\n"),
52
+ });
53
+ }
54
+ const taskId = task.id;
55
+ const taskTitle = clipText(task.title, contextLimits.taskTitle);
56
+ const continuation = taskId.length > 48
57
+ ? (ko
58
+ ? `정확한 current task: ${taskId}. 바인딩된 증거 뒤에만 완료하세요.`
59
+ : `Exact task: ${taskId}. Complete after bound evidence.`)
60
+ : (ko
61
+ ? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
62
+ : `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`);
63
+ const resume = event === "user-prompt-submit"
64
+ ? (taskId.length > 48
65
+ ? (ko
66
+ ? "곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 위의 정확한 task를 재개하세요."
67
+ : "Resume that exact task after side questions unless paused, stopped, or switched.")
68
+ : (ko
69
+ ? `곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 ${taskId}를 재개하세요.`
70
+ : `After a side question, resume ${taskId} unless asked to pause, stop, or switch.`))
71
+ : "";
72
+ return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, resume].filter(Boolean).join("\n") });
73
+ }
74
+
75
+ function kernelBase(ko) {
76
+ return ko ? "Cairn kernel: 루트 MEMORY.md는 선택 사항이며, 있으면 읽으세요." : "Cairn kernel: root MEMORY.md is optional; read it when present.";
77
+ }
78
+
79
+ function interruptedContextResult(event, ko, state) {
80
+ const task = state.tasks.find((item) => item.status === "active")
81
+ ?? state.tasks.find((item) => item.status === "pending")
82
+ ?? state.tasks.find((item) => item.status === "blocked");
83
+ const taskContext = task
84
+ ? (ko
85
+ ? `현재 task: ${task.id} (${clipText(task.title, contextLimits.taskTitle)}), 상태 ${task.status}.`
86
+ : `Current task: ${task.id} (${clipText(task.title, contextLimits.taskTitle)}), status ${task.status}.`)
87
+ : (ko ? "현재 task: 없음." : "Current task: none.");
88
+ const blocker = state.goal.blocker ?? task?.blocker ?? (ko ? "없음" : "none");
89
+ const recovery = ko
90
+ ? "자동으로 재개하지 마세요. 정확한 context를 복원하고 원인을 처리한 뒤 명시적으로 resume하세요."
91
+ : "Do not resume automatically. Restore this exact context, address the interruption, then resume explicitly.";
92
+ const message = ko
93
+ ? `${kernelBase(ko)} 정확한 plan: ${state.goal.planId}. ${taskContext} Goal 상태: ${state.goal.status}. Blocker: ${blocker}. ${recovery}`
94
+ : `${kernelBase(ko)} Exact plan: ${state.goal.planId}. ${taskContext} Goal status: ${state.goal.status}. Blocker: ${blocker}. ${recovery}`;
95
+ return contextHookResult({ event, message });
96
+ }
97
+
98
+ export function foreignSessionContextResult({ ko, event }) {
99
+ const message = ko
100
+ ? "Cairn kernel: 루트 MEMORY.md는 선택 사항이며, 있으면 읽으세요. 저장소 goal을 다른 session이 소유합니다. cairn goal status로 상태를 확인하고 소유권이 해결될 때까지 Cairn 작업을 시작·수정·위임·완료하지 마세요. 이 capsule은 goal, plan, task 상세를 노출하지 않습니다."
101
+ : "Cairn kernel: root MEMORY.md is optional; read it when present. 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.";
102
+ return contextHookResult({ event, message });
103
+ }
104
+
105
+ function taskRoadmap(ko, state) {
106
+ const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.roadmapTaskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
107
+ if (state.tasks.length <= contextLimits.roadmapWindow) {
108
+ return `${ko ? "작업 단계" : "Work steps"}:\n${state.tasks.map(formatTask).join("\n")}`;
109
+ }
110
+
111
+ const currentIndex = Math.max(0, state.tasks.findIndex((task) => task.status === "active" || task.status === "pending"));
112
+ const counts = Object.fromEntries([...TASK_STATUS_NAMES].map((status) => [status, state.tasks.filter((task) => task.status === status).length]));
113
+ const countSummary = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status} ${count}`).join(", ");
114
+ const omitted = state.tasks.length - 1;
115
+ const header = ko
116
+ ? `작업 단계 (${state.tasks.length}개; ${countSummary})`
117
+ : `Work steps (${state.tasks.length}; ${countSummary})`;
118
+ const omission = ko ? `… ${omitted}개 단계 생략` : `… ${omitted} steps omitted`;
119
+ const currentPosition = currentIndex + 1;
120
+ return `${header}. ${ko ? `현재 위치 ${currentPosition}` : `Current position ${currentPosition}`}; ${omission}`;
121
+ }
122
+
123
+ function contextHookResult({ event, message }) {
124
+ return {
125
+ status: 0,
126
+ message,
127
+ hookOutput: {
128
+ hookSpecificOutput: {
129
+ hookEventName: event === "session-start" ? "SessionStart" : "UserPromptSubmit",
130
+ additionalContext: message,
131
+ },
132
+ },
133
+ };
134
+ }
135
+
136
+ function activeGoalCompletionMessage(ko, state) {
137
+ const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
138
+ return ko
139
+ ? `활성 목표 "${goalTitle}"의 모든 task가 완료되었습니다. 완료 기준을 확인하고 명시적으로 complete 상태로 전이하세요.`
140
+ : `All tasks for active goal "${goalTitle}" are complete. Verify criteria and explicitly transition the goal to completed.`;
141
+ }
142
+
143
+ const TASK_STATUS_NAMES = ["active", "pending", "blocked", "completed"];
144
+
145
+ function clipText(value, maxLength) {
146
+ const text = String(value ?? "");
147
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
148
+ }
@@ -0,0 +1,24 @@
1
+ export function continuationReason(reason, payload) {
2
+ const continuation = payload?.stop_hook_active ? " This turn was already continued; the active goal remains persisted for the next session." : "";
3
+ return `${reason}${continuation}`;
4
+ }
5
+
6
+ export function stopHookOutput(gate, payload) {
7
+ if (!gate.block) return { continue: true };
8
+ const reason = continuationReason(gate.reason, payload);
9
+ if (payload?.stop_hook_active) {
10
+ return { continue: true, systemMessage: reason };
11
+ }
12
+ return { decision: "block", reason };
13
+ }
14
+
15
+ export function stopAllowedMessage({ ko, state, event }) {
16
+ if (state?.goal?.status) {
17
+ return ko
18
+ ? `Cairn 종료 게이트: 목표 상태가 ${state.goal.status}이므로 종료를 허용합니다.`
19
+ : `Cairn stop gate: goal status is ${state.goal.status}; allowing stop.`;
20
+ }
21
+ return ko
22
+ ? `${event === "subagent-stop" ? "서브에이전트" : "작업"} 종료 게이트: 활성 Cairn 목표가 없어 종료를 허용합니다.`
23
+ : `${event === "subagent-stop" ? "Subagent" : "Turn"} stop gate: no active Cairn goal; allowing stop.`;
24
+ }
@@ -0,0 +1,137 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile } from "./cairn-safe-fs.mjs";
4
+
5
+ const memoryTemplate = `# MEMORY
6
+
7
+ This file is a short index of persistent repository knowledge.
8
+
9
+ ## Domain Knowledge
10
+
11
+ - Link detailed notes under \`docs/memory/\`.
12
+
13
+ ## Policy
14
+
15
+ - Prefer precise repository exploration before implementation.
16
+ - Preserve proper nouns, file names, variable names, service names, alert names, MCP tool names, and agent names exactly as written.
17
+ - Read only the detailed memory files needed for the current task.
18
+ - Write user-visible responses and generated or updated documentation, plans, and memory artifacts in the OS locale unless the user asks for another language.
19
+
20
+ ## Update Rules
21
+
22
+ - Keep root files short.
23
+ - Move detailed domain knowledge to \`docs/memory/<domain>.md\`.
24
+ - Record facts with source paths, commands, and observed behavior.
25
+ `;
26
+
27
+ const memoryTemplateKo = `# MEMORY
28
+
29
+ 이 파일은 지속적으로 필요한 저장소 지식의 짧은 색인입니다.
30
+
31
+ ## Domain Knowledge
32
+
33
+ - 자세한 기록은 \`docs/memory/\` 아래에 연결합니다.
34
+
35
+ ## Policy
36
+
37
+ - 구현 전에는 저장소를 정확히 탐색합니다.
38
+ - 고유명사, 파일 이름, 변수 이름, 서비스 이름, 알림 이름, MCP 도구 이름, 에이전트 이름은 쓰인 그대로 보존합니다.
39
+ - 현재 작업에 필요한 상세 메모리 파일만 읽습니다.
40
+ - 사용자가 다른 언어를 요청하지 않는 한, 사용자에게 보이는 응답과 생성 또는 갱신하는 문서, 계획, 메모리 산출물은 OS locale 언어로 작성합니다.
41
+
42
+ ## Update Rules
43
+
44
+ - 루트 파일은 짧게 유지합니다.
45
+ - 자세한 도메인 지식은 \`docs/memory/<domain>.md\`로 옮깁니다.
46
+ - 사실은 출처 경로, 명령, 관찰된 동작과 함께 기록합니다.
47
+ `;
48
+
49
+ const planTemplate = `# PLAN
50
+
51
+ This file is a short index of active and completed work plans.
52
+
53
+ ## Active Plans
54
+
55
+ - Link detailed plans under \`docs/plan/\`.
56
+
57
+ ## Completed Plans
58
+
59
+ - Move completed topics here with evidence links.
60
+
61
+ ## Planning Rules
62
+
63
+ - Read project-root \`MEMORY.md\` first when it exists; if it is absent, continue without repository memory.
64
+ - For non-trivial implementation or continuation of planned work, first write or restore a plan with \`triage-plan\` active and synchronize it before exploration. Known-target Git/GitHub operations stay plan/goal-free unless they require code edits, conflict resolution, destructive recovery, release/deploy, or design.
65
+ - Plans must be decision-complete before implementation.
66
+ - Run complexity triage before applying agent, plugin, or delegated workflow guidance.
67
+ - Record the selected Light Path or Heavy Path and the checked Heavy Path signals in \`docs/plan/<topic>.md\`.
68
+ - Record request, planning, and code checkpoints. When the route changes before editing, synchronize the plan artifact, repository goal task roadmap through \`goal replan\`, and native UI plan. After editing starts, a new Heavy Path signal promotes Light Path to Heavy Path; stop further edits, mark affected evidence stale, synchronize all three roadmaps, and repeat the code checkpoint.
69
+ - Keep models inherited. Record requested/effective reasoning effort per task: Light planning/implementation/verification=\`medium\`; Heavy planning/review/implementation=\`high\`; final verification/review=\`xhigh\`. A route change also synchronizes the reasoning effort profile, preserving completed profiles and recalculating incomplete profiles. Unsupported host/value means effective=\`inherited\` with no model/global config change.
70
+ - Split implementation into small module tasks.
71
+ - Detect repository stack and required LSP/check tools before implementation.
72
+ - Record missing required tools and suggested commands. Run only pinned, supported installation steps after explicit user approval.
73
+ - Each task normally passes exactly two gates.
74
+ - Module acceptance verification.
75
+ - Surface integration verification.
76
+ - Run dry-run or check mode before external-state mutation when available.
77
+ - Write user-visible responses and generated or updated documentation, plans, and memory artifacts in the OS locale unless the user asks for another language.
78
+ - Use at most two verification passes per task by default.
79
+ - If a gate fails, diagnose once, shrink the task or split it into sub-tasks, and rerun both gates.
80
+ - After two failed passes, record the blocker in \`docs/plan/<topic>.md\`.
81
+ `;
82
+
83
+ const planTemplateKo = `# PLAN
84
+
85
+ 이 파일은 진행 중이거나 완료된 작업 계획의 짧은 색인입니다.
86
+
87
+ ## Active Plans
88
+
89
+ - 상세 계획은 \`docs/plan/\` 아래에 연결합니다.
90
+
91
+ ## Completed Plans
92
+
93
+ - 완료된 주제는 증거 링크와 함께 이곳으로 옮깁니다.
94
+
95
+ ## Planning Rules
96
+
97
+ - 프로젝트 루트 \`MEMORY.md\`가 있으면 먼저 읽고, 없으면 저장소 메모리 없이 계속 진행합니다.
98
+ - 비단순 구현 또는 계획된 작업 재개는 먼저 \`triage-plan\`이 active인 계획을 쓰거나 복원하고 탐색 전에 동기화합니다. 대상이 확정된 Git/GitHub 운영은 코드 수정·충돌 해결·파괴적 복구·릴리스/배포·설계가 필요하지 않으면 plan/goal 없이 실행합니다.
99
+ - 계획은 구현 전에 의사결정이 완료된 상태여야 합니다.
100
+ - 에이전트, 플러그인, 위임 워크플로 지침을 적용하기 전에 복잡도 트리아지를 실행합니다.
101
+ - 선택한 Light Path 또는 Heavy Path와 확인한 Heavy Path 신호를 \`docs/plan/<topic>.md\`에 기록합니다.
102
+ - 요청, 계획, 코드 체크포인트를 기록합니다. 편집 전에 경로가 바뀌면 plan artifact, 저장소 goal task roadmap, native UI plan을 동기화합니다. 편집 뒤 새 Heavy Path 신호가 나오면 추가 편집을 중단하고 관련 증거를 stale로 표시한 뒤 세 roadmap과 코드 체크포인트를 다시 맞춥니다.
103
+ - 모델은 상속하고 task별 requested/effective reasoning effort를 기록합니다. Light 계획/구현/검증은 \`medium\`, Heavy 계획/검토/구현은 \`high\`, 최종 검증/검토는 \`xhigh\`입니다. 경로 변경 시 reasoning effort profile도 동기화하고 완료 profile은 보존하며 미완료 profile은 재계산합니다. 미지원 host/value는 model/global config 변경 없이 effective=\`inherited\`입니다.
104
+ - 구현은 작은 모듈 작업으로 나눕니다.
105
+ - 구현 전에 저장소 스택과 필요한 LSP/check 도구를 감지합니다.
106
+ - 필요한 도구가 없으면 누락 상태와 제안 명령을 기록합니다. 명시적 사용자 승인 뒤에만 고정된 지원 설치 단계를 실행합니다.
107
+ - 각 작업은 보통 정확히 두 게이트를 통과합니다.
108
+ - 모듈 수용 검증.
109
+ - 표면 통합 검증.
110
+ - 외부 상태 변경 전에는 가능한 경우 dry-run 또는 check mode를 실행합니다.
111
+ - 사용자가 다른 언어를 요청하지 않는 한, 사용자에게 보이는 응답과 생성 또는 갱신하는 문서, 계획, 메모리 산출물은 OS locale 언어로 작성합니다.
112
+ - 기본적으로 작업당 검증은 최대 두 번만 수행합니다.
113
+ - 게이트가 실패하면 한 번 진단하고, 작업을 줄이거나 sub-task로 나눈 뒤 두 게이트를 다시 실행합니다.
114
+ - 두 번 실패한 뒤에는 blocker를 \`docs/plan/<topic>.md\`에 기록합니다.
115
+ `;
116
+
117
+ export async function initializeProject(root, ko) {
118
+ await safeMkdir(root, "docs/memory");
119
+ await safeMkdir(root, "docs/plan");
120
+ await writeIfMissing(root, join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
121
+ await writeIfMissing(root, join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
122
+ }
123
+
124
+ async function writeIfMissing(root, path, content) {
125
+ await assertNoSymlinkComponents(root, path);
126
+ try {
127
+ await readFile(path, "utf8");
128
+ } catch (error) {
129
+ if (error?.code !== "ENOENT") throw error;
130
+ try {
131
+ await safeWriteFile(root, path, content, { encoding: "utf8", flag: "wx" });
132
+ } catch (writeError) {
133
+ if (writeError?.code !== "EEXIST") throw writeError;
134
+ await assertNoSymlinkComponents(root, path, { allowMissing: false });
135
+ }
136
+ }
137
+ }