cairn-ai 0.2.6 → 0.2.8
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 +2 -2
- package/.agents/workflows/cairn-plan.md +3 -3
- package/.agents/workflows/cairn-review.md +2 -2
- package/.agents/workflows/cairn-work.md +4 -4
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.claude/commands/cairn-memory.md +1 -1
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-review.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +3 -2
- package/README.es.md +3 -2
- package/README.fr.md +3 -2
- package/README.ja.md +3 -2
- package/README.ko.md +16 -8
- package/README.md +14 -8
- package/README.pt.md +3 -2
- package/README.zh.md +3 -2
- package/agents/worker.md +3 -1
- package/commands/cairn-install.md +2 -1
- package/commands/cairn-memory.md +2 -2
- package/commands/cairn-plan.md +3 -3
- package/commands/cairn-review.md +2 -2
- package/commands/cairn-work.md +4 -4
- package/docs/model-guidance/README.md +5 -5
- package/docs/model-guidance/claude.md +1 -1
- package/docs/model-guidance/codex.md +3 -3
- package/package.json +4 -2
- package/scripts/cairn-cleanup.mjs +12 -6
- package/scripts/cairn-goal-cli.mjs +239 -0
- package/scripts/cairn-goal-evidence.mjs +186 -0
- package/scripts/cairn-goal-operations-evidence.mjs +124 -0
- package/scripts/cairn-goal-operations-mutations.mjs +63 -0
- package/scripts/cairn-goal-operations.mjs +187 -0
- package/scripts/cairn-goal-policy.mjs +36 -0
- package/scripts/cairn-goal-state-validation.mjs +199 -0
- package/scripts/cairn-goal-state.mjs +124 -0
- package/scripts/cairn-goal.mjs +33 -1078
- package/scripts/cairn-lifecycle-commands.mjs +200 -0
- package/scripts/cairn-lifecycle-config.mjs +153 -0
- package/scripts/cairn-lifecycle-integrity.mjs +218 -0
- package/scripts/cairn-lifecycle-legacy.mjs +142 -0
- package/scripts/cairn-lifecycle-lock.mjs +205 -0
- package/scripts/cairn-lifecycle-mirror.mjs +91 -0
- package/scripts/cairn-lifecycle-staging.mjs +152 -0
- package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
- package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/cairn-lifecycle-transaction.mjs +12 -0
- package/scripts/cairn-lifecycle.mjs +103 -1185
- package/scripts/cairn-paths.mjs +35 -5
- package/scripts/cairn-safe-fs.mjs +61 -35
- package/scripts/cairn-state-context.mjs +156 -0
- package/scripts/cairn-state-hook.mjs +26 -0
- package/scripts/cairn-state-init.mjs +137 -0
- package/scripts/cairn-state.mjs +32 -286
- package/scripts/cairn-toolcheck-detect.mjs +229 -0
- package/scripts/cairn-toolcheck-runtime.mjs +178 -0
- package/scripts/cairn-toolcheck.mjs +79 -424
- package/scripts/cairn.mjs +17 -3
- package/skills/cairn-memory/SKILL.md +3 -2
- package/skills/cairn-plan/SKILL.md +7 -4
- package/skills/cairn-review/SKILL.md +4 -4
- package/skills/cairn-work/SKILL.md +23 -7
- package/scripts/cairn-state.sh +0 -167
package/scripts/cairn-paths.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { createHash } from "node:crypto";
|
|
2
2
|
import { existsSync, lstatSync, readFileSync, realpathSync } from "node:fs";
|
|
3
3
|
import { homedir } from "node:os";
|
|
4
|
-
import { dirname, join, resolve } from "node:path";
|
|
4
|
+
import { dirname, join, relative, resolve } from "node:path";
|
|
5
5
|
import { fileURLToPath } from "node:url";
|
|
6
6
|
|
|
7
7
|
export const RUNTIME_LOCATOR_SCHEMA_VERSION = 1;
|
|
@@ -133,12 +133,42 @@ export function createRuntimeLocator(pluginRoot) {
|
|
|
133
133
|
};
|
|
134
134
|
}
|
|
135
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
|
+
|
|
136
162
|
export function runtimeRequiredPaths(locator) {
|
|
137
|
-
if (!locator || locator.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION || locator.plugin !== "cairn"
|
|
163
|
+
if (!locator || locator.schemaVersion !== RUNTIME_LOCATOR_SCHEMA_VERSION || locator.plugin !== "cairn"
|
|
164
|
+
|| typeof locator.pluginRoot !== "string") return [];
|
|
165
|
+
const root = locator.pluginRoot;
|
|
138
166
|
return [
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
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"),
|
|
142
172
|
locator.resources?.commands,
|
|
143
173
|
locator.resources?.agents,
|
|
144
174
|
locator.resources?.skills,
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { randomUUID } from "node:crypto";
|
|
2
2
|
import { hostname } from "node:os";
|
|
3
|
-
import {
|
|
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
|
|
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
|
|
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
|
|
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
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
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
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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
|
-
|
|
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,156 @@
|
|
|
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.${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
|
+
: "After side questions or steers, resume that exact task; advance to final review unless paused, stopped, or switched.")
|
|
68
|
+
: (ko
|
|
69
|
+
? `곁가지 질문·조향 뒤 ${taskId}를 재개하고 남은 task를 최종 검토까지 진행하세요(일시정지·중단·전환 요청 제외).`
|
|
70
|
+
: `After a side question or steer, resume ${taskId}, then advance remaining tasks to final review 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
|
+
? "goal 상태를 자동으로 바꾸지 마세요. 정확한 context를 복원하고 원인을 처리한 뒤 goal resume을 명시적으로 실행하고 현재 task를 이어가세요."
|
|
91
|
+
: "Do not change goal status automatically. Restore this exact context, address the interruption, then run goal resume explicitly and continue the current task.";
|
|
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, sessionId }) {
|
|
99
|
+
const claim = typeof sessionId === "string" && sessionId.length > 0
|
|
100
|
+
? (ko
|
|
101
|
+
? ` 이 session이 같은 작업을 이어받는 경우(재개·압축·handoff·fork)에만 cairn goal claim --session ${JSON.stringify(sessionId)} --root <repoRoot>로 소유권을 넘겨받고 이어가세요.`
|
|
102
|
+
: ` Only if this session continues that same work (resume, compaction, handoff, fork), take ownership with cairn goal claim --session ${JSON.stringify(sessionId)} --root <repoRoot>, then continue.`)
|
|
103
|
+
: "";
|
|
104
|
+
const message = ko
|
|
105
|
+
? `Cairn kernel: 루트 MEMORY.md는 선택 사항이며, 있으면 읽으세요. 저장소 goal을 다른 session이 소유합니다. cairn goal status로 상태를 확인하고 소유권이 해결될 때까지 Cairn 작업을 시작·수정·위임·완료하지 마세요.${claim} 이 capsule은 goal, plan, task 상세를 노출하지 않습니다.`
|
|
106
|
+
: `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.${claim} This capsule does not expose goal, plan, or task details.`;
|
|
107
|
+
return contextHookResult({ event, message });
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function taskRoadmap(ko, state) {
|
|
111
|
+
const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.roadmapTaskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
|
|
112
|
+
if (state.tasks.length <= contextLimits.roadmapWindow) {
|
|
113
|
+
return `${ko ? "작업 단계" : "Work steps"}:\n${state.tasks.map(formatTask).join("\n")}`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
const currentIndex = Math.max(0, state.tasks.findIndex((task) => task.status === "active" || task.status === "pending"));
|
|
117
|
+
const counts = Object.fromEntries([...TASK_STATUS_NAMES].map((status) => [status, state.tasks.filter((task) => task.status === status).length]));
|
|
118
|
+
const countSummary = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status} ${count}`).join(", ");
|
|
119
|
+
const omitted = state.tasks.length - 1;
|
|
120
|
+
const header = ko
|
|
121
|
+
? `작업 단계 (${state.tasks.length}개; ${countSummary})`
|
|
122
|
+
: `Work steps (${state.tasks.length}; ${countSummary})`;
|
|
123
|
+
const omission = ko ? `… ${omitted}개 단계 생략` : `… ${omitted} steps omitted`;
|
|
124
|
+
const currentPosition = currentIndex + 1;
|
|
125
|
+
const upcoming = state.tasks.slice(currentIndex + 1).filter((task) => task.status === "pending").slice(0, 2)
|
|
126
|
+
.map((task) => clipText(task.id, contextLimits.roadmapTaskId));
|
|
127
|
+
const next = upcoming.length > 0 ? `; ${ko ? "다음" : "next"} ${upcoming.join(", ")}` : "";
|
|
128
|
+
return `${header}. ${ko ? `현재 위치 ${currentPosition}` : `Current position ${currentPosition}`}; ${omission}${next}`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
function contextHookResult({ event, message }) {
|
|
132
|
+
return {
|
|
133
|
+
status: 0,
|
|
134
|
+
message,
|
|
135
|
+
hookOutput: {
|
|
136
|
+
hookSpecificOutput: {
|
|
137
|
+
hookEventName: event === "session-start" ? "SessionStart" : "UserPromptSubmit",
|
|
138
|
+
additionalContext: message,
|
|
139
|
+
},
|
|
140
|
+
},
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function activeGoalCompletionMessage(ko, state) {
|
|
145
|
+
const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
|
|
146
|
+
return ko
|
|
147
|
+
? `활성 목표 "${goalTitle}"의 모든 task가 완료되었습니다. 완료 기준을 확인하고 명시적으로 complete 상태로 전이하세요.`
|
|
148
|
+
: `All tasks for active goal "${goalTitle}" are complete. Verify criteria and explicitly transition the goal to completed.`;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const TASK_STATUS_NAMES = ["active", "pending", "blocked", "completed"];
|
|
152
|
+
|
|
153
|
+
function clipText(value, maxLength) {
|
|
154
|
+
const text = String(value ?? "");
|
|
155
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
156
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function continuationReason(reason, payload, { progressed = false } = {}) {
|
|
2
|
+
if (!payload?.stop_hook_active || progressed) return reason;
|
|
3
|
+
return `${reason} This turn was already continued without new Cairn state progress, so the loop guard allows this stop. The goal is still active and incomplete: resume the current task on the next prompt; do not treat it as finished or deferred.`;
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
// stop_hook_active alone would allow every second stop. Block again whenever the goal state advanced
|
|
7
|
+
// since the previous continuation, so a steered turn keeps driving the goal; allow only a no-progress repeat.
|
|
8
|
+
export function stopHookOutput(gate, payload, { progressed = false } = {}) {
|
|
9
|
+
if (!gate.block) return { continue: true };
|
|
10
|
+
const reason = continuationReason(gate.reason, payload, { progressed });
|
|
11
|
+
if (payload?.stop_hook_active && !progressed) {
|
|
12
|
+
return { continue: true, systemMessage: reason };
|
|
13
|
+
}
|
|
14
|
+
return { decision: "block", reason };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function stopAllowedMessage({ ko, state, event }) {
|
|
18
|
+
if (state?.goal?.status) {
|
|
19
|
+
return ko
|
|
20
|
+
? `Cairn 종료 게이트: 목표 상태가 ${state.goal.status}이므로 종료를 허용합니다.`
|
|
21
|
+
: `Cairn stop gate: goal status is ${state.goal.status}; allowing stop.`;
|
|
22
|
+
}
|
|
23
|
+
return ko
|
|
24
|
+
? `${event === "subagent-stop" ? "서브에이전트" : "작업"} 종료 게이트: 활성 Cairn 목표가 없어 종료를 허용합니다.`
|
|
25
|
+
: `${event === "subagent-stop" ? "Subagent" : "Turn"} stop gate: no active Cairn goal; allowing stop.`;
|
|
26
|
+
}
|
|
@@ -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
|
+
}
|