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.
- package/.agents/workflows/cairn-work.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +8 -13
- package/README.de.md +12 -10
- package/README.es.md +12 -10
- package/README.fr.md +12 -10
- package/README.ja.md +12 -10
- package/README.ko.md +12 -10
- package/README.md +12 -10
- package/README.pt.md +12 -10
- package/README.zh.md +12 -10
- package/agents/explorer.md +2 -0
- package/agents/worker.md +2 -0
- package/commands/cairn-doctor.md +5 -3
- package/commands/cairn-install.md +5 -4
- package/commands/cairn-uninstall.md +6 -2
- package/commands/cairn-upgrade.md +6 -6
- package/commands/cairn-work.md +2 -2
- package/docs/model-guidance/README.md +11 -1
- package/docs/model-guidance/codex.md +3 -1
- package/package.json +2 -2
- package/scripts/cairn-goal.mjs +179 -49
- package/scripts/cairn-lifecycle.mjs +990 -313
- package/scripts/cairn-safe-fs.mjs +246 -0
- package/scripts/cairn-state.mjs +68 -34
- package/scripts/cairn-toolcheck.mjs +116 -34
- package/scripts/release-integrity-0.2.2.json +68 -0
- package/skills/cairn-plan/SKILL.md +4 -0
- package/skills/cairn-review/SKILL.md +2 -0
- package/skills/cairn-work/SKILL.md +6 -0
- package/templates/work-plan.md +2 -0
|
@@ -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
|
+
}
|
package/scripts/cairn-state.mjs
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { realpathSync } from "node:fs";
|
|
3
|
-
import {
|
|
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
|
-
|
|
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))
|
|
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
|
|
168
|
-
|
|
169
|
-
:
|
|
170
|
-
|
|
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
|
|
197
|
-
await
|
|
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
|
-
|
|
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
|
-
? " 구현/계속
|
|
216
|
-
: "
|
|
217
|
-
:
|
|
218
|
-
|
|
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
|
|
242
|
+
const planId = state.goal.planId;
|
|
243
|
+
const phase = task ? "cairn-work" : "cairn-review";
|
|
223
244
|
const activeHeader = ko
|
|
224
|
-
?
|
|
225
|
-
:
|
|
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 =
|
|
253
|
+
const taskId = task.id;
|
|
233
254
|
const taskTitle = clipText(task.title, contextLimits.taskTitle);
|
|
234
|
-
const continuation =
|
|
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
|
-
?
|
|
240
|
-
: `
|
|
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.
|
|
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 -
|
|
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
|
-
|
|
295
|
+
const currentPosition = currentIndex + 1;
|
|
296
|
+
return `${header}. ${ko ? `현재 위치 ${currentPosition}` : `Current position ${currentPosition}`}; ${omission}`;
|
|
263
297
|
}
|
|
264
298
|
|
|
265
299
|
function contextHookResult({ event, message }) {
|