cairn-ai 0.2.1 → 0.2.3
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-plan.md +11 -11
- package/.agents/workflows/cairn-work.md +10 -8
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +12 -18
- package/README.de.md +13 -11
- package/README.es.md +13 -11
- package/README.fr.md +13 -11
- package/README.ja.md +13 -11
- package/README.ko.md +15 -13
- package/README.md +15 -13
- package/README.pt.md +13 -11
- package/README.zh.md +13 -11
- package/commands/cairn-doctor.md +5 -3
- package/commands/cairn-install.md +5 -4
- package/commands/cairn-plan.md +13 -16
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-uninstall.md +4 -2
- package/commands/cairn-upgrade.md +6 -6
- package/commands/cairn-work.md +12 -11
- package/docs/model-guidance/README.md +6 -2
- package/docs/model-guidance/codex.md +5 -0
- package/hooks/hooks.json +1 -1
- package/package.json +2 -2
- package/scripts/cairn-goal.mjs +522 -101
- package/scripts/cairn-lifecycle.mjs +926 -313
- package/scripts/cairn-safe-fs.mjs +246 -0
- package/scripts/cairn-state.mjs +80 -30
- package/scripts/cairn-state.sh +2 -0
- package/scripts/cairn-toolcheck.mjs +116 -34
- package/scripts/cairn.mjs +9 -8
- package/scripts/release-integrity-0.2.2.json +68 -0
- package/skills/cairn-plan/SKILL.md +23 -17
- package/skills/cairn-review/SKILL.md +1 -1
- package/skills/cairn-work/SKILL.md +39 -16
- package/templates/PLAN.md +1 -0
- package/templates/work-plan.md +23 -2
|
@@ -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
|
|
|
@@ -65,6 +66,7 @@ This file is a short index of active and completed work plans.
|
|
|
65
66
|
## Planning Rules
|
|
66
67
|
|
|
67
68
|
- Every agent must start assigned work by reading the project-root \`MEMORY.md\` for domain knowledge and repository policy.
|
|
69
|
+
- For implementation or continued execution, first write an initial plan with \`triage-plan\` active, synchronize it to native UI plan/goal tools and the repository goal before exploration, then update it after triage.
|
|
68
70
|
- Plans must be decision-complete before implementation.
|
|
69
71
|
- Run complexity triage before applying agent, plugin, or delegated workflow guidance.
|
|
70
72
|
- Record the selected Light Path or Heavy Path and the checked Heavy Path signals in \`docs/plan/<topic>.md\`.
|
|
@@ -96,6 +98,7 @@ const planTemplateKo = `# PLAN
|
|
|
96
98
|
## Planning Rules
|
|
97
99
|
|
|
98
100
|
- 모든 에이전트는 배정된 작업을 시작할 때 도메인 지식과 저장소 정책을 위해 프로젝트 루트 \`MEMORY.md\`를 먼저 읽어야 합니다.
|
|
101
|
+
- 구현 또는 계속 실행 요청은 먼저 \`triage-plan\`이 active인 초기 계획을 쓰고, 탐색 전에 UI plan/goal과 저장소 goal을 동기화한 뒤 트리아지 결과로 계획을 갱신합니다.
|
|
99
102
|
- 계획은 구현 전에 의사결정이 완료된 상태여야 합니다.
|
|
100
103
|
- 에이전트, 플러그인, 위임 워크플로 지침을 적용하기 전에 복잡도 트리아지를 실행합니다.
|
|
101
104
|
- 선택한 Light Path 또는 Heavy Path와 확인한 Heavy Path 신호를 \`docs/plan/<topic>.md\`에 기록합니다.
|
|
@@ -112,6 +115,13 @@ const planTemplateKo = `# PLAN
|
|
|
112
115
|
- 두 번 실패한 뒤에는 blocker를 \`docs/plan/<topic>.md\`에 기록합니다.
|
|
113
116
|
`;
|
|
114
117
|
|
|
118
|
+
const contextLimits = {
|
|
119
|
+
goalTitle: 64,
|
|
120
|
+
taskId: 32,
|
|
121
|
+
taskTitle: 40,
|
|
122
|
+
roadmapWindow: 3,
|
|
123
|
+
};
|
|
124
|
+
|
|
115
125
|
if (isCliEntry()) {
|
|
116
126
|
const event = process.argv[2] ?? "manual";
|
|
117
127
|
const payload = await readHookInput();
|
|
@@ -153,12 +163,18 @@ export async function runStateResult(event = "manual", {
|
|
|
153
163
|
}
|
|
154
164
|
|
|
155
165
|
if (event === "post-tool-use") {
|
|
166
|
+
const message = ko
|
|
167
|
+
? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
|
|
168
|
+
: "Cairn check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.";
|
|
156
169
|
return {
|
|
157
170
|
status: 0,
|
|
158
|
-
message
|
|
159
|
-
|
|
160
|
-
:
|
|
161
|
-
|
|
171
|
+
message,
|
|
172
|
+
hookOutput: {
|
|
173
|
+
hookSpecificOutput: {
|
|
174
|
+
hookEventName: "PostToolUse",
|
|
175
|
+
additionalContext: message,
|
|
176
|
+
},
|
|
177
|
+
},
|
|
162
178
|
};
|
|
163
179
|
}
|
|
164
180
|
|
|
@@ -184,53 +200,79 @@ export async function runStateResult(event = "manual", {
|
|
|
184
200
|
}
|
|
185
201
|
|
|
186
202
|
async function initializeProject(root, ko) {
|
|
187
|
-
await
|
|
188
|
-
await
|
|
189
|
-
await writeIfMissing(join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
|
|
190
|
-
await writeIfMissing(join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
|
|
203
|
+
await safeMkdir(root, "docs/memory");
|
|
204
|
+
await safeMkdir(root, "docs/plan");
|
|
205
|
+
await writeIfMissing(root, join(root, "MEMORY.md"), ko ? memoryTemplateKo : memoryTemplate);
|
|
206
|
+
await writeIfMissing(root, join(root, "PLAN.md"), ko ? planTemplateKo : planTemplate);
|
|
191
207
|
}
|
|
192
208
|
|
|
193
|
-
async function writeIfMissing(path, content) {
|
|
209
|
+
async function writeIfMissing(root, path, content) {
|
|
210
|
+
await assertNoSymlinkComponents(root, path);
|
|
194
211
|
try {
|
|
195
212
|
await readFile(path, "utf8");
|
|
196
213
|
} catch (error) {
|
|
197
214
|
if (error?.code !== "ENOENT") throw error;
|
|
198
|
-
|
|
215
|
+
try {
|
|
216
|
+
await safeWriteFile(root, path, content, { encoding: "utf8", flag: "wx" });
|
|
217
|
+
} catch (writeError) {
|
|
218
|
+
if (writeError?.code !== "EEXIST") throw writeError;
|
|
219
|
+
await assertNoSymlinkComponents(root, path, { allowMissing: false });
|
|
220
|
+
}
|
|
199
221
|
}
|
|
200
222
|
}
|
|
201
223
|
|
|
202
224
|
function contextResult({ ko, state, event }) {
|
|
203
|
-
const base = ko
|
|
204
|
-
|
|
205
|
-
: "Cairn context: read the project-root MEMORY.md before working.";
|
|
206
|
-
const goalPolicy = event === "user-prompt-submit"
|
|
225
|
+
const base = ko ? "Cairn: 루트 MEMORY.md를 읽으세요." : "Cairn: read root MEMORY.md.";
|
|
226
|
+
const idlePolicy = event === "user-prompt-submit"
|
|
207
227
|
? (ko
|
|
208
|
-
? "
|
|
209
|
-
: "
|
|
228
|
+
? " 구현/계속 실행: 초기 트리아지 계획 작성 → update_plan → create_goal → 탐색 전 저장소 goal 시작 → 트리아지 후 계획 확정 → 구현. 상담·설명·계획 전용은 goal 없이 처리하세요."
|
|
229
|
+
: " 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.")
|
|
210
230
|
: "";
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
231
|
+
if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}` });
|
|
232
|
+
const task = state.tasks.find((item) => item.status === "active")
|
|
233
|
+
?? state.tasks.find((item) => item.status === "pending")
|
|
234
|
+
?? state.tasks.find((item) => item.status === "blocked");
|
|
235
|
+
const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
|
|
236
|
+
const activeHeader = ko
|
|
237
|
+
? `Cairn active: "${goalTitle}". ${base}`
|
|
238
|
+
: `Cairn active: "${goalTitle}". ${base}`;
|
|
214
239
|
if (!task) {
|
|
215
240
|
return contextHookResult({
|
|
216
241
|
event,
|
|
217
|
-
message: [
|
|
242
|
+
message: [activeHeader, taskRoadmap(ko, state), activeGoalCompletionMessage(ko, state)].join("\n"),
|
|
218
243
|
});
|
|
219
244
|
}
|
|
245
|
+
const taskId = clipText(task.id, contextLimits.taskId);
|
|
246
|
+
const taskTitle = clipText(task.title, contextLimits.taskTitle);
|
|
220
247
|
const continuation = ko
|
|
221
|
-
?
|
|
222
|
-
: `
|
|
248
|
+
? `현재: ${taskId} (${taskTitle}). 바인딩된 증거 뒤에만 완료하세요.`
|
|
249
|
+
: `Current: ${taskId} (${taskTitle}). Complete only after bound evidence.`;
|
|
223
250
|
const resume = event === "user-prompt-submit"
|
|
224
251
|
? (ko
|
|
225
|
-
? `곁가지
|
|
226
|
-
: `After
|
|
252
|
+
? `곁가지 질문 뒤 일시정지·중단·전환 요청이 없으면 ${taskId}를 재개하세요.`
|
|
253
|
+
: `After a side question, resume ${taskId} unless asked to pause, stop, or switch.`)
|
|
227
254
|
: "";
|
|
228
|
-
return contextHookResult({ event, message: [
|
|
255
|
+
return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, resume].filter(Boolean).join("\n") });
|
|
229
256
|
}
|
|
230
257
|
|
|
231
258
|
function taskRoadmap(ko, state) {
|
|
232
|
-
const
|
|
233
|
-
|
|
259
|
+
const formatTask = (task, index) => `${index + 1}. ${clipText(task.id, contextLimits.taskId)} [${task.status}] ${clipText(task.title, contextLimits.taskTitle)}`;
|
|
260
|
+
if (state.tasks.length <= contextLimits.roadmapWindow) {
|
|
261
|
+
return `${ko ? "작업 단계" : "Work steps"}:\n${state.tasks.map(formatTask).join("\n")}`;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
const currentIndex = Math.max(0, state.tasks.findIndex((task) => task.status === "active" || task.status === "pending"));
|
|
265
|
+
const maxStart = state.tasks.length - contextLimits.roadmapWindow;
|
|
266
|
+
const start = Math.min(Math.max(0, currentIndex - 1), maxStart);
|
|
267
|
+
const visible = state.tasks.slice(start, start + contextLimits.roadmapWindow);
|
|
268
|
+
const counts = Object.fromEntries([...TASK_STATUS_NAMES].map((status) => [status, state.tasks.filter((task) => task.status === status).length]));
|
|
269
|
+
const countSummary = Object.entries(counts).filter(([, count]) => count > 0).map(([status, count]) => `${status} ${count}`).join(", ");
|
|
270
|
+
const omitted = state.tasks.length - visible.length;
|
|
271
|
+
const header = ko
|
|
272
|
+
? `작업 단계 (${state.tasks.length}개; ${countSummary})`
|
|
273
|
+
: `Work steps (${state.tasks.length}; ${countSummary})`;
|
|
274
|
+
const omission = ko ? `… ${omitted}개 단계 생략` : `… ${omitted} steps omitted`;
|
|
275
|
+
return `${header}:\n${visible.map((task, index) => formatTask(task, start + index)).join("\n")}\n${omission}`;
|
|
234
276
|
}
|
|
235
277
|
|
|
236
278
|
function contextHookResult({ event, message }) {
|
|
@@ -272,9 +314,17 @@ function stopAllowedMessage({ ko, state, event }) {
|
|
|
272
314
|
}
|
|
273
315
|
|
|
274
316
|
function activeGoalCompletionMessage(ko, state) {
|
|
317
|
+
const goalTitle = clipText(state.goal.title, contextLimits.goalTitle);
|
|
275
318
|
return ko
|
|
276
|
-
? `활성 목표 "${
|
|
277
|
-
: `All tasks for active goal "${
|
|
319
|
+
? `활성 목표 "${goalTitle}"의 모든 task가 완료되었습니다. 완료 기준을 확인하고 명시적으로 complete 상태로 전이하세요.`
|
|
320
|
+
: `All tasks for active goal "${goalTitle}" are complete. Verify criteria and explicitly transition the goal to completed.`;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
const TASK_STATUS_NAMES = ["active", "pending", "blocked", "completed"];
|
|
324
|
+
|
|
325
|
+
function clipText(value, maxLength) {
|
|
326
|
+
const text = String(value ?? "");
|
|
327
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
278
328
|
}
|
|
279
329
|
|
|
280
330
|
function resolveRoot(root, payload) {
|
package/scripts/cairn-state.sh
CHANGED
|
@@ -85,6 +85,7 @@ if [ ! -f "$plan" ]; then
|
|
|
85
85
|
## Planning Rules
|
|
86
86
|
|
|
87
87
|
- 모든 에이전트는 배정된 작업을 시작할 때 도메인 지식과 저장소 정책을 위해 프로젝트 루트 `MEMORY.md`를 먼저 읽어야 합니다.
|
|
88
|
+
- 구현 또는 계속 실행 요청은 먼저 `triage-plan`이 active인 초기 계획을 쓰고, 탐색 전에 UI plan/goal과 저장소 goal을 동기화한 뒤 트리아지 결과로 계획을 갱신합니다.
|
|
88
89
|
- 계획은 구현 전에 의사결정이 완료된 상태여야 합니다.
|
|
89
90
|
- 에이전트, 플러그인, 위임 워크플로 지침을 적용하기 전에 복잡도 트리아지를 실행합니다.
|
|
90
91
|
- 선택한 Light Path 또는 Heavy Path와 확인한 Heavy Path 신호를 `docs/plan/<topic>.md`에 기록합니다.
|
|
@@ -117,6 +118,7 @@ This file is a short index of active and completed work plans.
|
|
|
117
118
|
## Planning Rules
|
|
118
119
|
|
|
119
120
|
- Every agent must start assigned work by reading the project-root `MEMORY.md` for domain knowledge and repository policy.
|
|
121
|
+
- For implementation or continued execution, first write an initial plan with `triage-plan` active, synchronize it to native UI plan/goal tools and the repository goal before exploration, then update it after triage.
|
|
120
122
|
- Plans must be decision-complete before implementation.
|
|
121
123
|
- Split implementation into small module tasks.
|
|
122
124
|
- Detect repository stack and required LSP/check tools before implementation.
|