cairn-ai 0.2.9 → 0.4.0
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 -18
- package/.agents/workflows/cairn-work.md +14 -20
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/agents/worker.md +1 -1
- package/commands/cairn-plan.md +9 -15
- package/commands/cairn-work.md +13 -21
- package/docs/model-guidance/README.md +8 -6
- package/docs/model-guidance/claude.md +2 -1
- package/docs/model-guidance/codex.md +1 -1
- package/package.json +1 -1
- package/scripts/cairn-goal-cli.mjs +53 -12
- package/scripts/cairn-goal-operations-evidence.mjs +105 -22
- package/scripts/cairn-goal-operations-mutations.mjs +42 -8
- package/scripts/cairn-goal-operations.mjs +66 -19
- package/scripts/cairn-goal-policy.mjs +29 -3
- package/scripts/cairn-goal-state-validation.mjs +141 -12
- package/scripts/cairn-goal-state.mjs +17 -5
- package/scripts/cairn-goal.mjs +9 -3
- package/scripts/cairn-state-context.mjs +13 -4
- package/scripts/cairn-state-init.mjs +2 -2
- package/scripts/cairn-state.mjs +48 -6
- package/scripts/cairn.mjs +1 -1
- package/skills/cairn-plan/SKILL.md +34 -175
- package/skills/cairn-plan/delegation.md +62 -0
- package/skills/cairn-plan/heavy-path.md +26 -0
- package/skills/cairn-plan/planning-rules.md +68 -0
- package/skills/cairn-work/SKILL.md +38 -143
- package/skills/cairn-work/delegation.md +62 -0
- package/skills/cairn-work/heavy-path.md +16 -0
- package/skills/cairn-work/recovery.md +29 -0
- package/skills/cairn-work/verification.md +65 -0
- package/templates/PLAN.md +1 -1
- package/templates/work-plan.md +19 -7
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { normalizeVerificationArgv } from "./cairn-goal-evidence.mjs";
|
|
2
2
|
|
|
3
|
-
export const GOAL_STATE_VERSION =
|
|
3
|
+
export const GOAL_STATE_VERSION = 3;
|
|
4
|
+
export const MAX_ACTIVE_TASKS = 4;
|
|
4
5
|
export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
|
|
5
6
|
export const TASK_STATUSES = new Set(["pending", "active", "blocked", "completed"]);
|
|
6
7
|
export const PLAN_ID_MAX_LENGTH = 128;
|
|
@@ -20,6 +21,10 @@ export const defaultTaskEvidence = ["moduleAcceptance", "surfaceIntegration"];
|
|
|
20
21
|
export const defaultGoalEvidence = ["finalReview"];
|
|
21
22
|
|
|
22
23
|
const evidencePolicies = new Set(["declared", "tool-bound"]);
|
|
24
|
+
export const TASK_ROUTES = new Set(["light", "heavy"]);
|
|
25
|
+
export const CONCRETE_EFFORTS = new Set(["low", "medium", "high", "xhigh", "max"]);
|
|
26
|
+
export const TASK_EFFORTS = new Set([...CONCRETE_EFFORTS, "inherited"]);
|
|
27
|
+
export const EFFORT_SOURCES = new Set(["host-option", "inherited", "not-applied"]);
|
|
23
28
|
|
|
24
29
|
export function validateState(value) {
|
|
25
30
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Cairn goal state must be an object");
|
|
@@ -43,7 +48,6 @@ export function validateState(value) {
|
|
|
43
48
|
if (!Array.isArray(value.tasks) || value.tasks.length === 0) throw new Error("Cairn goal state must contain tasks");
|
|
44
49
|
if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state evidence records (receipts) must be an array");
|
|
45
50
|
const ids = new Set();
|
|
46
|
-
let activeTasks = 0;
|
|
47
51
|
for (const task of value.tasks) {
|
|
48
52
|
if (!task || typeof task !== "object") throw new Error("Each task must be an object");
|
|
49
53
|
const taskId = boundedRequiredText(task.id, "task.id", TASK_ID_MAX_LENGTH);
|
|
@@ -52,15 +56,22 @@ export function validateState(value) {
|
|
|
52
56
|
ids.add(task.id);
|
|
53
57
|
requiredText(task.title, "task.title");
|
|
54
58
|
validTaskStatus(task.status);
|
|
55
|
-
if (task.
|
|
59
|
+
if (!Array.isArray(task.dependsOn)) throw new Error(`task ${task.id} dependsOn must be an array`);
|
|
60
|
+
if (task.writeScope !== null && !Array.isArray(task.writeScope)) throw new Error(`task ${task.id} writeScope must be an array or null`);
|
|
56
61
|
if (task.assignedAgentId !== null && task.assignedAgentId !== undefined) requiredText(task.assignedAgentId, "task.assignedAgentId");
|
|
57
62
|
if (!Array.isArray(task.requiredEvidence) || task.requiredEvidence.length === 0) throw new Error(`task ${task.id} requiredEvidence must be a non-empty array`);
|
|
58
63
|
task.requiredEvidence.forEach((kind, index) => requiredText(kind, `task ${task.id} requiredEvidence[${index}]`));
|
|
59
64
|
if (task.blocker !== null && task.blocker !== undefined) requiredText(task.blocker, `task ${task.id} blocker`);
|
|
65
|
+
// Optional since v3 states predate them; absent means unrecorded.
|
|
66
|
+
if (task.route !== null && task.route !== undefined) validTaskRoute(task.route, `task ${task.id} route`);
|
|
67
|
+
assertEffortRecord(task.effort ?? null, task.effortSource ?? null, `task ${task.id}`);
|
|
68
|
+
if (task.effortWaiver !== null && task.effortWaiver !== undefined) requiredText(task.effortWaiver, `task ${task.id} effortWaiver`);
|
|
60
69
|
validTimestamp(task.createdAt);
|
|
61
70
|
validTimestamp(task.updatedAt);
|
|
62
71
|
}
|
|
63
|
-
|
|
72
|
+
assertTaskGraph(value.tasks);
|
|
73
|
+
const active = value.tasks.filter((task) => task.status === "active");
|
|
74
|
+
for (const task of active) assertCanRunAlongside(task, active.filter((other) => other !== task));
|
|
64
75
|
for (const receipt of value.receipts) validateReceipt(receipt, value);
|
|
65
76
|
return value;
|
|
66
77
|
}
|
|
@@ -86,7 +97,9 @@ function validateReceipt(receipt, state) {
|
|
|
86
97
|
}
|
|
87
98
|
}
|
|
88
99
|
|
|
89
|
-
|
|
100
|
+
// dependsOn omitted means "after the previous task in list order" so existing sequential plans keep their order;
|
|
101
|
+
// an explicit [] means no dependency.
|
|
102
|
+
export function normalizeTask(task, index, defaultStatus, previousTaskId = null) {
|
|
90
103
|
const source = typeof task === "string" ? { title: task } : task;
|
|
91
104
|
if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
|
|
92
105
|
const id = source.id === undefined ? `task-${index + 1}` : boundedRequiredText(source.id, `tasks[${index}].id`, TASK_ID_MAX_LENGTH);
|
|
@@ -97,20 +110,136 @@ export function normalizeTask(task, index, defaultStatus) {
|
|
|
97
110
|
status: source.status === undefined ? defaultStatus : validTaskStatus(source.status),
|
|
98
111
|
assignedAgentId: source.assignedAgentId === undefined || source.assignedAgentId === null ? null : requiredText(source.assignedAgentId, `tasks[${index}].assignedAgentId`),
|
|
99
112
|
requiredEvidence: normalizeCriteria(source.requiredEvidence ?? defaultTaskEvidence),
|
|
113
|
+
dependsOn: source.dependsOn === undefined
|
|
114
|
+
? (previousTaskId === null ? [] : [previousTaskId])
|
|
115
|
+
: normalizeDependsOn(source.dependsOn, `tasks[${index}].dependsOn`),
|
|
116
|
+
writeScope: source.writeScope === undefined || source.writeScope === null
|
|
117
|
+
? null
|
|
118
|
+
: normalizeWriteScope(source.writeScope, `tasks[${index}].writeScope`),
|
|
119
|
+
route: source.route === undefined || source.route === null ? null : validTaskRoute(source.route, `tasks[${index}].route`),
|
|
120
|
+
effort: null,
|
|
121
|
+
effortSource: null,
|
|
122
|
+
effortWaiver: null,
|
|
100
123
|
blocker: null,
|
|
101
124
|
createdAt: timestamp(),
|
|
102
125
|
updatedAt: timestamp(),
|
|
103
126
|
};
|
|
104
127
|
}
|
|
105
128
|
|
|
129
|
+
export function validTaskRoute(value, label = "route") {
|
|
130
|
+
if (!TASK_ROUTES.has(value)) throw new Error(`${label} must be one of: ${[...TASK_ROUTES].join(", ")}`);
|
|
131
|
+
return value;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// effort "inherited" pairs only with source "inherited"; source "host-option" needs a concrete level; a source
|
|
135
|
+
// without an effort is only meaningful as "not-applied".
|
|
136
|
+
export function assertEffortRecord(effort, source, label = "task") {
|
|
137
|
+
if (effort !== null && !TASK_EFFORTS.has(effort)) throw new Error(`${label} effort must be one of: ${[...TASK_EFFORTS].join(", ")}`);
|
|
138
|
+
if (source !== null && !EFFORT_SOURCES.has(source)) throw new Error(`${label} effortSource must be one of: ${[...EFFORT_SOURCES].join(", ")}`);
|
|
139
|
+
if (effort === "inherited" && source !== "inherited") throw new Error(`${label} effort inherited requires effortSource inherited`);
|
|
140
|
+
if (source === "host-option" && !CONCRETE_EFFORTS.has(effort)) throw new Error(`${label} effortSource host-option requires a concrete effort (${[...CONCRETE_EFFORTS].join(", ")})`);
|
|
141
|
+
if (source === "inherited" && effort === null) throw new Error(`${label} effortSource inherited requires an effort`);
|
|
142
|
+
if (effort !== null && source === null) throw new Error(`${label} effort requires an effortSource`);
|
|
143
|
+
}
|
|
144
|
+
|
|
106
145
|
export function migrateState(value) {
|
|
107
|
-
if (!value || typeof value !== "object" || Array.isArray(value)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
146
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
147
|
+
let state = value;
|
|
148
|
+
if (state.schemaVersion === 1) {
|
|
149
|
+
state = {
|
|
150
|
+
...state,
|
|
151
|
+
schemaVersion: 2,
|
|
152
|
+
goal: { ...state.goal, evidencePolicy: state.goal?.evidencePolicy ?? "tool-bound" },
|
|
153
|
+
receipts: Array.isArray(state.receipts) ? state.receipts.map((receipt) => ({ ...receipt, source: "declared" })) : state.receipts,
|
|
154
|
+
};
|
|
155
|
+
}
|
|
156
|
+
if (state.schemaVersion === 2) {
|
|
157
|
+
// v2 tasks ran strictly in list order; make that order explicit as previous-task dependencies.
|
|
158
|
+
state = {
|
|
159
|
+
...state,
|
|
160
|
+
schemaVersion: 3,
|
|
161
|
+
tasks: Array.isArray(state.tasks)
|
|
162
|
+
? state.tasks.map((task, index) => ({
|
|
163
|
+
...task,
|
|
164
|
+
dependsOn: index === 0 ? [] : [state.tasks[index - 1]?.id],
|
|
165
|
+
writeScope: null,
|
|
166
|
+
}))
|
|
167
|
+
: state.tasks,
|
|
168
|
+
};
|
|
169
|
+
}
|
|
170
|
+
return state;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
export function normalizeDependsOn(value, label) {
|
|
174
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array of task ids`);
|
|
175
|
+
return [...new Set(value.map((item, index) => boundedRequiredText(item, `${label}[${index}]`, TASK_ID_MAX_LENGTH)))];
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export function normalizeWriteScope(value, label) {
|
|
179
|
+
if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} writeScope must be a non-empty array of repository paths`);
|
|
180
|
+
return [...new Set(value.map((item, index) => {
|
|
181
|
+
const path = requiredText(item, `${label}[${index}]`).replace(/\\/g, "/").replace(/\/{2,}/g, "/")
|
|
182
|
+
.replace(/^(\.\/)+/, "").replace(/\/+$/, "");
|
|
183
|
+
if (/[*?[\]{}]/.test(path)) throw new Error(`${label} writeScope must list literal paths, not globs: ${item}`);
|
|
184
|
+
if (path === "" || path === "." || path.startsWith("/") || /^[a-zA-Z]:/.test(path) || path.split("/").includes("..")) {
|
|
185
|
+
throw new Error(`${label} writeScope must stay inside the repository: ${item}`);
|
|
186
|
+
}
|
|
187
|
+
return path;
|
|
188
|
+
}))];
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Segment-wise prefix match, case-insensitive so case-insensitive filesystems cannot hide an overlap.
|
|
192
|
+
export function writeScopesOverlap(left, right) {
|
|
193
|
+
return left.some((a) => right.some((b) => pathContains(a, b) || pathContains(b, a)));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function pathContains(scope, path) {
|
|
197
|
+
const outer = scope.toLowerCase();
|
|
198
|
+
const inner = path.toLowerCase();
|
|
199
|
+
return inner === outer || inner.startsWith(`${outer}/`);
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
export function assertTaskGraph(tasks) {
|
|
203
|
+
const byId = new Map();
|
|
204
|
+
for (const task of tasks) {
|
|
205
|
+
if (byId.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
|
|
206
|
+
byId.set(task.id, task);
|
|
207
|
+
}
|
|
208
|
+
for (const task of tasks) {
|
|
209
|
+
for (const dependency of task.dependsOn) {
|
|
210
|
+
if (dependency === task.id) throw new Error(`Task ${task.id} cannot depend on itself`);
|
|
211
|
+
if (!byId.has(dependency)) throw new Error(`Task ${task.id} depends on unknown task: ${dependency}`);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const visiting = new Set();
|
|
215
|
+
const done = new Set();
|
|
216
|
+
const visit = (task, path) => {
|
|
217
|
+
if (done.has(task.id)) return;
|
|
218
|
+
if (visiting.has(task.id)) throw new Error(`Task dependency cycle: ${[...path, task.id].join(" -> ")}`);
|
|
219
|
+
visiting.add(task.id);
|
|
220
|
+
for (const dependency of task.dependsOn) visit(byId.get(dependency), [...path, task.id]);
|
|
221
|
+
visiting.delete(task.id);
|
|
222
|
+
done.add(task.id);
|
|
113
223
|
};
|
|
224
|
+
for (const task of tasks) visit(task, []);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Throws unless task may be active next to otherActive: dependencies completed, under the cap, and disjoint
|
|
228
|
+
// writeScopes. A task without writeScope may only run alone.
|
|
229
|
+
export function assertCanBeActive(tasks, task, otherActive) {
|
|
230
|
+
const waiting = task.dependsOn.filter((id) => tasks.find((item) => item.id === id)?.status !== "completed");
|
|
231
|
+
if (waiting.length > 0) throw new Error(`Task ${task.id} is waiting for: ${waiting.join(", ")}`);
|
|
232
|
+
assertCanRunAlongside(task, otherActive);
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function assertCanRunAlongside(task, otherActive) {
|
|
236
|
+
if (otherActive.length === 0) return;
|
|
237
|
+
if (otherActive.length + 1 > MAX_ACTIVE_TASKS) throw new Error(`Only at most ${MAX_ACTIVE_TASKS} active tasks are allowed`);
|
|
238
|
+
if (task.writeScope === null) throw new Error(`Task ${task.id} without writeScope cannot run alongside active task ${otherActive[0].id}`);
|
|
239
|
+
for (const other of otherActive) {
|
|
240
|
+
if (other.writeScope === null) throw new Error(`Task ${task.id} cannot run alongside active task ${other.id} without writeScope`);
|
|
241
|
+
if (writeScopesOverlap(task.writeScope, other.writeScope)) throw new Error(`Task ${task.id} writeScope overlaps active task ${other.id}`);
|
|
242
|
+
}
|
|
114
243
|
}
|
|
115
244
|
|
|
116
245
|
export function requiredText(value, label) {
|
|
@@ -145,7 +274,7 @@ export function validTaskStatus(value) {
|
|
|
145
274
|
}
|
|
146
275
|
|
|
147
276
|
export function validReceiptScope(value) {
|
|
148
|
-
if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value}`);
|
|
277
|
+
if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value} (allowed: task, goal)`);
|
|
149
278
|
return value;
|
|
150
279
|
}
|
|
151
280
|
|
|
@@ -18,12 +18,24 @@ export async function readGoalState({ root = process.cwd() } = {}) {
|
|
|
18
18
|
}
|
|
19
19
|
|
|
20
20
|
export function stopGuardPath(root = process.cwd()) {
|
|
21
|
-
return
|
|
21
|
+
return hookGuardPath(root, "stop");
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function hookGuardPath(root = process.cwd(), name) {
|
|
25
|
+
return join(goalStateDirectory(root), `${name}-guard.json`);
|
|
22
26
|
}
|
|
23
27
|
|
|
24
28
|
export async function readStopGuard(root = process.cwd()) {
|
|
29
|
+
return readHookGuard(root, "stop");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function writeStopGuard(root = process.cwd(), value) {
|
|
33
|
+
return writeHookGuard(root, "stop", value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export async function readHookGuard(root = process.cwd(), name) {
|
|
25
37
|
try {
|
|
26
|
-
const path =
|
|
38
|
+
const path = hookGuardPath(root, name);
|
|
27
39
|
await assertNoSymlinkComponents(cairnHome(), path);
|
|
28
40
|
const value = JSON.parse(await readFile(path, "utf8"));
|
|
29
41
|
return value && typeof value === "object" ? value : null;
|
|
@@ -32,11 +44,11 @@ export async function readStopGuard(root = process.cwd()) {
|
|
|
32
44
|
}
|
|
33
45
|
}
|
|
34
46
|
|
|
35
|
-
export async function
|
|
47
|
+
export async function writeHookGuard(root = process.cwd(), name, value) {
|
|
36
48
|
try {
|
|
37
|
-
await safeWriteFile(cairnHome(),
|
|
49
|
+
await safeWriteFile(cairnHome(), hookGuardPath(root, name), `${JSON.stringify(value)}\n`, "utf8");
|
|
38
50
|
} catch {
|
|
39
|
-
//
|
|
51
|
+
// Guards only deduplicate hook output; failing to persist one must not break the hook.
|
|
40
52
|
}
|
|
41
53
|
}
|
|
42
54
|
|
package/scripts/cairn-goal.mjs
CHANGED
|
@@ -3,8 +3,9 @@ import { realpathSync } from "node:fs";
|
|
|
3
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
4
4
|
import { runGoalCli } from "./cairn-goal-cli.mjs";
|
|
5
5
|
import { assignTask, claimGoal, replanGoal, setTaskStatus, startGoal, transitionGoal } from "./cairn-goal-operations.mjs";
|
|
6
|
-
import { recordReceipt, verifyAndRecord } from "./cairn-goal-operations-evidence.mjs";
|
|
6
|
+
import { finishGoal, goalStats, recordReceipt, verifyAndRecord } from "./cairn-goal-operations-evidence.mjs";
|
|
7
7
|
import { readGoalState } from "./cairn-goal-state.mjs";
|
|
8
|
+
import { readyTasks } from "./cairn-goal-operations-mutations.mjs";
|
|
8
9
|
import { defaultGoalEvidence } from "./cairn-goal-state-validation.mjs";
|
|
9
10
|
|
|
10
11
|
export { workspaceFingerprint } from "./cairn-goal-evidence.mjs";
|
|
@@ -20,17 +21,22 @@ export {
|
|
|
20
21
|
} from "./cairn-goal-state-validation.mjs";
|
|
21
22
|
export { goalStatePath, readGoalState, writeGoalState } from "./cairn-goal-state.mjs";
|
|
22
23
|
export { assignTask, claimGoal, reconcileWorktreeState, replanGoal, setTaskStatus, startGoal, transitionGoal } from "./cairn-goal-operations.mjs";
|
|
23
|
-
export { recordReceipt, verifyAndRecord } from "./cairn-goal-operations-evidence.mjs";
|
|
24
|
+
export { finishGoal, goalStats, recordReceipt, verifyAndRecord } from "./cairn-goal-operations-evidence.mjs";
|
|
24
25
|
export { currentTask, evaluateStop, isGoalOwnedBySession } from "./cairn-goal-policy.mjs";
|
|
26
|
+
export { readyTasks } from "./cairn-goal-operations-mutations.mjs";
|
|
25
27
|
|
|
26
|
-
export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log } = {}) {
|
|
28
|
+
export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log, stderr = console.error } = {}) {
|
|
27
29
|
return runGoalCli(args, {
|
|
28
30
|
stdout,
|
|
31
|
+
stderr,
|
|
29
32
|
defaultGoalEvidence,
|
|
30
33
|
operations: {
|
|
31
34
|
assignTask,
|
|
32
35
|
claimGoal,
|
|
36
|
+
finishGoal,
|
|
37
|
+
goalStats,
|
|
33
38
|
readGoalState,
|
|
39
|
+
readyTasks,
|
|
34
40
|
recordReceipt,
|
|
35
41
|
replanGoal,
|
|
36
42
|
setTaskStatus,
|
|
@@ -3,6 +3,7 @@ const contextLimits = {
|
|
|
3
3
|
roadmapTaskId: 24,
|
|
4
4
|
taskTitle: 40,
|
|
5
5
|
roadmapWindow: 3,
|
|
6
|
+
parallelTitle: 16,
|
|
6
7
|
};
|
|
7
8
|
|
|
8
9
|
export function prependContext(result, notice) {
|
|
@@ -21,6 +22,8 @@ export function prependContext(result, notice) {
|
|
|
21
22
|
};
|
|
22
23
|
}
|
|
23
24
|
|
|
25
|
+
import { currentTask } from "./cairn-goal-policy.mjs";
|
|
26
|
+
|
|
24
27
|
export function contextResult({ ko, state, event }) {
|
|
25
28
|
const base = kernelBase(ko);
|
|
26
29
|
const idlePolicy = event === "user-prompt-submit"
|
|
@@ -37,9 +40,7 @@ export function contextResult({ ko, state, event }) {
|
|
|
37
40
|
return interruptedContextResult(event, ko, state);
|
|
38
41
|
}
|
|
39
42
|
if (!state || state.goal.status !== "active") return contextHookResult({ event, message: `${base}${idlePolicy}${failClosed}` });
|
|
40
|
-
const task = state
|
|
41
|
-
?? state.tasks.find((item) => item.status === "pending")
|
|
42
|
-
?? state.tasks.find((item) => item.status === "blocked");
|
|
43
|
+
const task = currentTask(state);
|
|
43
44
|
const planId = state.goal.planId;
|
|
44
45
|
const phase = task ? "cairn-work" : "cairn-review";
|
|
45
46
|
const activeHeader = ko
|
|
@@ -69,7 +70,15 @@ export function contextResult({ ko, state, event }) {
|
|
|
69
70
|
? `곁가지 질문·조향 뒤 ${taskId}를 재개하고 남은 task를 최종 검토까지 진행하세요(일시정지·중단·전환 요청 제외).`
|
|
70
71
|
: `After a side question or steer, resume ${taskId}, then advance remaining tasks to final review unless asked to pause, stop, or switch.`))
|
|
71
72
|
: "";
|
|
72
|
-
return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, resume].filter(Boolean).join("\n") });
|
|
73
|
+
return contextHookResult({ event, message: [activeHeader, taskRoadmap(ko, state), continuation, parallelLine(ko, state), resume].filter(Boolean).join("\n") });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Only when several tasks run at once: their ids and clipped titles, never the dependency graph.
|
|
77
|
+
function parallelLine(ko, state) {
|
|
78
|
+
const active = state.tasks.filter((task) => task.status === "active");
|
|
79
|
+
if (active.length < 2) return "";
|
|
80
|
+
const list = active.map((task) => `${clipText(task.id, contextLimits.roadmapTaskId)} (${clipText(task.title, contextLimits.parallelTitle)})`).join(", ");
|
|
81
|
+
return `${ko ? "병렬 active" : "Parallel active"}: ${list}`;
|
|
73
82
|
}
|
|
74
83
|
|
|
75
84
|
function kernelBase(ko) {
|
|
@@ -66,7 +66,7 @@ This file is a short index of active and completed work plans.
|
|
|
66
66
|
- Run complexity triage before applying agent, plugin, or delegated workflow guidance.
|
|
67
67
|
- Record the selected Light Path or Heavy Path and the checked Heavy Path signals in \`docs/plan/<topic>.md\`.
|
|
68
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
|
-
-
|
|
69
|
+
- Choose each task's model by nature (fast/balanced/stronger/strongest) and record requested/effective model and 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. Every new task/worker dispatch MUST pass the requested effort through the host option (Claude Code: Agent \`effort\`, never fork) and the chosen model the same way (Agent \`model\`); Effective is the passed value, \`not-applied: <reason>\` when not passed, \`inherited\` only when the host has no option. Never change global/user config.
|
|
70
70
|
- Split implementation into small module tasks.
|
|
71
71
|
- Detect repository stack and required LSP/check tools before implementation.
|
|
72
72
|
- Record missing required tools and suggested commands. Run only pinned, supported installation steps after explicit user approval.
|
|
@@ -100,7 +100,7 @@ const planTemplateKo = `# PLAN
|
|
|
100
100
|
- 에이전트, 플러그인, 위임 워크플로 지침을 적용하기 전에 복잡도 트리아지를 실행합니다.
|
|
101
101
|
- 선택한 Light Path 또는 Heavy Path와 확인한 Heavy Path 신호를 \`docs/plan/<topic>.md\`에 기록합니다.
|
|
102
102
|
- 요청, 계획, 코드 체크포인트를 기록합니다. 편집 전에 경로가 바뀌면 plan artifact, 저장소 goal task roadmap, native UI plan을 동기화합니다. 편집 뒤 새 Heavy Path 신호가 나오면 추가 편집을 중단하고 관련 증거를 stale로 표시한 뒤 세 roadmap과 코드 체크포인트를 다시 맞춥니다.
|
|
103
|
-
-
|
|
103
|
+
- task 성격에 따라 task별 모델(fast/balanced/stronger/strongest)을 정하고 requested/effective model과 reasoning effort를 기록합니다. Light 계획/구현/검증은 \`medium\`, Heavy 계획/검토/구현은 \`high\`, 최종 검증/검토는 \`xhigh\`입니다. 경로 변경 시 reasoning effort profile도 동기화하고 완료 profile은 보존하며 미완료 profile은 재계산합니다. 모든 새 task/worker dispatch는 정한 모델과 요청 effort를 host option으로 반드시 전달합니다(Claude Code: Agent \`model\`/\`effort\`, fork 금지). Effective는 전달한 값, 전달하지 못하면 \`not-applied: <사유>\`, host에 option이 없을 때만 \`inherited\`이며 전역/사용자 config는 바꾸지 않습니다.
|
|
104
104
|
- 구현은 작은 모듈 작업으로 나눕니다.
|
|
105
105
|
- 구현 전에 저장소 스택과 필요한 LSP/check 도구를 감지합니다.
|
|
106
106
|
- 필요한 도구가 없으면 누락 상태와 제안 명령을 기록합니다. 명시적 사용자 승인 뒤에만 고정된 지원 설치 단계를 실행합니다.
|
package/scripts/cairn-state.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { contextResult, foreignSessionContextResult, prependContext } from "./ca
|
|
|
5
5
|
import { continuationReason, stopAllowedMessage, stopHookOutput } from "./cairn-state-hook.mjs";
|
|
6
6
|
import { initializeProject } from "./cairn-state-init.mjs";
|
|
7
7
|
import { evaluateStop, isGoalOwnedBySession, reconcileWorktreeState } from "./cairn-goal.mjs";
|
|
8
|
-
import { readStopGuard, writeStopGuard } from "./cairn-goal-state.mjs";
|
|
8
|
+
import { readHookGuard, readStopGuard, writeHookGuard, writeStopGuard } from "./cairn-goal-state.mjs";
|
|
9
9
|
import { resolveRepoRoot } from "./cairn-paths.mjs";
|
|
10
10
|
|
|
11
11
|
const recoverableGoalStatuses = new Set(["active", "paused", "blocked"]);
|
|
@@ -52,7 +52,10 @@ export async function runStateResult(event = "manual", {
|
|
|
52
52
|
if (recoverableGoalStatuses.has(state?.goal?.status) && !isGoalOwnedBySession(state, payload?.session_id)) {
|
|
53
53
|
return foreignSessionContextResult({ ko, event, sessionId: payload?.session_id, root: resolvedRoot });
|
|
54
54
|
}
|
|
55
|
-
const
|
|
55
|
+
const context = contextResult({ ko, state, event });
|
|
56
|
+
// SessionStart (also after compaction) carries a literal CLI prefix so agents do not rebuild or store it in a
|
|
57
|
+
// shell variable, which does not survive between tool calls.
|
|
58
|
+
const result = event === "session-start" ? appendContext(context, cliPrefixLine()) : context;
|
|
56
59
|
if (!reconciled.removed) return result;
|
|
57
60
|
const notice = ko
|
|
58
61
|
? "Cairn worktree 점검: 현재 worktree에 속하지 않는 stale Cairn 상태를 제거했습니다."
|
|
@@ -67,6 +70,19 @@ export async function runStateResult(event = "manual", {
|
|
|
67
70
|
if (state?.goal?.status !== "active" || !isGoalOwnedBySession(state, payload?.session_id)) {
|
|
68
71
|
return { status: 0, message: "", hookOutput: {} };
|
|
69
72
|
}
|
|
73
|
+
// Remind once per goal, task, and session; repeating it after every edit only grows the cached prefix.
|
|
74
|
+
const reminderKey = {
|
|
75
|
+
goalId: state.goal.id,
|
|
76
|
+
taskId: state.tasks.find((task) => task.status === "active")?.id ?? null,
|
|
77
|
+
sessionId: payload?.session_id ?? null,
|
|
78
|
+
};
|
|
79
|
+
const previousReminder = await readHookGuard(resolvedRoot, "reminder");
|
|
80
|
+
if (previousReminder?.goalId === reminderKey.goalId
|
|
81
|
+
&& previousReminder?.taskId === reminderKey.taskId
|
|
82
|
+
&& previousReminder?.sessionId === reminderKey.sessionId) {
|
|
83
|
+
return { status: 0, message: "", hookOutput: {} };
|
|
84
|
+
}
|
|
85
|
+
await writeHookGuard(resolvedRoot, "reminder", reminderKey);
|
|
70
86
|
const message = ko
|
|
71
87
|
? "Cairn 점검: 외부 상태 변경에는 dry-run/check 증거 기록이 필요합니다. 활성 목표가 있다면 현재 task에 성공 증거를 기록하세요."
|
|
72
88
|
: "Cairn check: external-state changes need dry-run/check evidence. When a goal is active, record successful evidence for its current task.";
|
|
@@ -106,14 +122,40 @@ export async function runStateResult(event = "manual", {
|
|
|
106
122
|
|
|
107
123
|
async function recordStopProgress(root, state, event, payload) {
|
|
108
124
|
const scope = event === "subagent-stop" ? `subagent:${payload?.agent_id ?? ""}` : "turn";
|
|
125
|
+
// Parallel workers share this guard: keep one entry per scope, and measure a worker's progress by its own task
|
|
126
|
+
// so sibling writes (which bump the goal revision) do not look like progress.
|
|
127
|
+
const assigned = event === "subagent-stop"
|
|
128
|
+
? state.tasks.find((task) => task.status === "active" && task.assignedAgentId === payload?.agent_id)
|
|
129
|
+
: null;
|
|
130
|
+
const marker = assigned ? `${assigned.id}:${assigned.status}:${assigned.updatedAt}` : state.revision;
|
|
109
131
|
const previous = await readStopGuard(root);
|
|
110
|
-
const
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
132
|
+
const scopes = previous?.goalId === state.goal.id && previous?.scopes && typeof previous.scopes === "object" ? previous.scopes : {};
|
|
133
|
+
const progressed = scopes[scope] !== marker;
|
|
134
|
+
if (progressed) {
|
|
135
|
+
const entries = Object.entries({ ...scopes, [scope]: marker }).slice(-16);
|
|
136
|
+
await writeStopGuard(root, { goalId: state.goal.id, scopes: Object.fromEntries(entries) });
|
|
137
|
+
}
|
|
114
138
|
return progressed;
|
|
115
139
|
}
|
|
116
140
|
|
|
141
|
+
// Only the CLI path: goal commands resolve the repository root themselves, and a second absolute path would let
|
|
142
|
+
// long install or checkout paths push the SessionStart capsule past its budget.
|
|
143
|
+
function cliPrefixLine() {
|
|
144
|
+
const cli = fileURLToPath(new URL("./cairn.mjs", import.meta.url));
|
|
145
|
+
return `Cairn CLI: node ${JSON.stringify(cli)} goal <command>`;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function appendContext(result, line) {
|
|
149
|
+
const specific = result.hookOutput?.hookSpecificOutput;
|
|
150
|
+
return {
|
|
151
|
+
...result,
|
|
152
|
+
message: `${result.message}\n${line}`,
|
|
153
|
+
hookOutput: specific
|
|
154
|
+
? { ...result.hookOutput, hookSpecificOutput: { ...specific, additionalContext: `${specific.additionalContext}\n${line}` } }
|
|
155
|
+
: result.hookOutput,
|
|
156
|
+
};
|
|
157
|
+
}
|
|
158
|
+
|
|
117
159
|
function hookFailureResult(event, error) {
|
|
118
160
|
const detail = String(error?.message ?? error).split("\n")[0].slice(0, 300);
|
|
119
161
|
const message = `Cairn ${event} hook could not read goal state: ${detail}. Run cairn goal status to inspect it; do not treat an active goal as finished.`;
|
package/scripts/cairn.mjs
CHANGED
|
@@ -84,7 +84,7 @@ const workExecutionPolicy = "The user-called/main agent orchestrates, while work
|
|
|
84
84
|
const tokenEfficientWorkPolicy = "Use a test contract for requirements, invariants, boundaries, and failure modes before the minimum implementation. The tool exit code is authoritative. Run goal verify -- <argv> and expand only failures. Inspect package lifecycle scripts: content-producing or unknown scripts must never use --ignore-scripts; only absent or proven content-neutral scripts may use it while prior full-check evidence remains fresh.";
|
|
85
85
|
const stagedTriagePolicy = "Use request, planning, and code checkpoints. Before the first edit, evidence may change either route; 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.";
|
|
86
86
|
const stagedTriagePolicyKo = "요청, 계획, 코드 체크포인트를 사용합니다. 첫 편집 전에는 증거에 따라 어느 경로로든 바꿀 수 있으며 plan artifact, 저장소 goal task roadmap, native UI plan을 동기화합니다. 편집 뒤 새 Heavy Path 신호가 나오면 Light Path를 Heavy Path로 승격하고 추가 편집을 중단하며 관련 증거를 stale로 표시한 뒤 세 roadmap과 코드 체크포인트를 다시 맞춥니다.";
|
|
87
|
-
const reasoningEffortPolicy = "
|
|
87
|
+
const reasoningEffortPolicy = "Choose a model per task (fast/balanced/stronger/strongest) and pass it like effort; route reasoning effort by task: Light planning/implementation/verification=medium; Heavy planning/review/implementation=high; final verification/review=xhigh. Every new task/worker dispatch MUST pass the requested effort through the host option (Claude Code: Agent `effort`, never fork) and the chosen model the same way (Agent `model`); Effective is the passed value, `not-applied: <reason>` when not passed, `inherited` only when the host has no option. Never change global/user config.";
|
|
88
88
|
const artifactLocalePolicy = "Write user-visible responses and generated or updated documentation, plans, and memory artifacts in the OS locale unless the user asks for another language.";
|
|
89
89
|
const artifactLocalePolicyKo = "사용자가 다른 언어를 요청하지 않는 한, 사용자에게 보이는 응답과 생성 또는 갱신하는 문서, 계획, 메모리 산출물은 OS locale 언어로 작성합니다.";
|
|
90
90
|
|