cairn-ai 0.2.9 → 0.3.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.
@@ -20,8 +20,27 @@ export function ensureTaskCanComplete(state, task, root) {
20
20
  export function ensureGoalCanComplete(state, root) {
21
21
  const incomplete = state.tasks.filter((task) => task.status !== "completed");
22
22
  if (incomplete.length > 0) throw new Error(`Goal cannot be completed while tasks remain: ${incomplete.map((task) => task.id).join(", ")}`);
23
+ const goalReceipts = state.goal.requiredEvidence.map((kind) => ensureCurrentEvidence({ state, root, scope: "goal", kind }));
24
+ // Task freshness was enforced when each task completed. Fresh goal-level evidence recorded after every task
25
+ // receipt supersedes it, so later edits do not force reruns of every earlier task's checks.
26
+ const lastTaskReceipt = state.receipts.findLastIndex((receipt) => receipt.scope === "task");
27
+ const superseded = goalReceipts.length > 0
28
+ && goalReceipts.every((receipt) => state.receipts.indexOf(receipt) > lastTaskReceipt);
29
+ if (superseded) {
30
+ for (const task of state.tasks) ensureTaskEvidenceExists(state, task);
31
+ return;
32
+ }
23
33
  for (const task of state.tasks) ensureTaskCanComplete(state, task, root);
24
- for (const kind of state.goal.requiredEvidence) ensureCurrentEvidence({ state, root, scope: "goal", kind });
34
+ }
35
+
36
+ function ensureTaskEvidenceExists(state, task) {
37
+ for (const kind of task.requiredEvidence) {
38
+ const recorded = state.receipts.some((receipt) => (
39
+ receipt.scope === "task" && receipt.taskId === task.id && receipt.kind === kind
40
+ && (state.goal.evidencePolicy === "declared" || receipt.source === "tool")
41
+ ));
42
+ if (!recorded) throw new Error(`Task ${task.id} cannot be completed without tool-bound evidence record: ${kind}`);
43
+ }
25
44
  }
26
45
 
27
46
  function ensureCurrentEvidence({ state, root, scope, task, kind }) {
@@ -31,25 +50,40 @@ function ensureCurrentEvidence({ state, root, scope, task, kind }) {
31
50
  const subject = scope === "goal" ? "Goal" : `Task ${task.id}`;
32
51
  if (state.goal.evidencePolicy === "declared") {
33
52
  if (candidates.length === 0) throw new Error(`${subject} cannot be completed without successful, bound evidence record: ${kind}`);
34
- return;
53
+ return candidates.at(-1);
35
54
  }
36
55
  const toolEvidence = candidates.filter((receipt) => receipt.source === "tool");
37
56
  if (toolEvidence.length === 0) throw new Error(`${subject} cannot be completed without tool-bound evidence record: ${kind}`);
38
57
  for (const receipt of [...toolEvidence].reverse()) {
39
- if (workspaceFingerprint(root, receipt.watchPaths) === receipt.workspaceFingerprint) return;
58
+ if (isReceiptFresh(root, receipt)) return receipt;
40
59
  }
41
60
  throw new Error(`${subject} has stale evidence record: ${kind}`);
42
61
  }
43
62
 
44
- export function activateNextPendingTask(state) {
45
- if (state.tasks.some((task) => task.status === "active")) return true;
46
- const next = state.tasks.find((task) => task.status === "pending");
63
+ export function isReceiptFresh(root, receipt) {
64
+ return receipt.source === "tool" && workspaceFingerprint(root, receipt.watchPaths) === receipt.workspaceFingerprint;
65
+ }
66
+
67
+ export function readyTasks(state) {
68
+ if (!state) return [];
69
+ const completed = new Set(state.tasks.filter((task) => task.status === "completed").map((task) => task.id));
70
+ return state.tasks.filter((task) => task.status === "pending" && task.dependsOn.every((id) => completed.has(id)));
71
+ }
72
+
73
+ // Keeps the goal moving after a task leaves "active": when nothing is active, activate the first ready task in
74
+ // list order (the sequential behavior); when nothing is active or ready while work remains, block the goal.
75
+ export function advanceGoal(state, preferredBlocker = null, { block = true } = {}) {
76
+ if (state.tasks.some((task) => task.status === "active")) return;
77
+ const next = readyTasks(state)[0];
47
78
  if (next) {
48
79
  next.status = "active";
49
80
  next.updatedAt = new Date().toISOString();
50
- return true;
81
+ return;
51
82
  }
52
- return false;
83
+ if (!block || state.tasks.every((task) => task.status === "completed")) return;
84
+ state.goal.status = "blocked";
85
+ state.goal.blocker = preferredBlocker ?? state.tasks.find((task) => task.status === "blocked")?.blocker
86
+ ?? "No task is ready: remaining tasks wait on unfinished dependencies";
53
87
  }
54
88
 
55
89
  export function assertGoalMutable(state) {
@@ -5,11 +5,12 @@ import { assertNoSymlinkComponents } from "./cairn-safe-fs.mjs";
5
5
  import { goalStatePath, readGoalState, readGoalStateUnlocked, withGoalStateLock, writeGoalStateUnlocked } from "./cairn-goal-state.mjs";
6
6
  import {
7
7
  GOAL_STATE_VERSION, PLAN_ID_MAX_LENGTH, assertRecoveryReferenceBudget, boundedRequiredText,
8
+ assertCanBeActive, assertTaskGraph,
8
9
  defaultGoalEvidence, normalizeCriteria, normalizeTask, optionalText, requiredText,
9
10
  terminalGoalStatuses, timestamp, validEvidencePolicy, validGoalStatus, validTaskStatus, validateState,
10
11
  } from "./cairn-goal-state-validation.mjs";
11
12
  import {
12
- activateNextPendingTask, assertGoalMutable, ensureGoalCanComplete, ensureTaskCanComplete,
13
+ advanceGoal, assertGoalMutable, ensureGoalCanComplete, ensureTaskCanComplete,
13
14
  mutateGoal, taskById,
14
15
  } from "./cairn-goal-operations-mutations.mjs";
15
16
 
@@ -44,7 +45,8 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
44
45
  throw new Error(`An unfinished Cairn goal already exists (${existing.goal.id}, ${existing.goal.status}); add the new work to it with goal replan (resume it first if paused or blocked), or cancel or complete it before starting another goal`);
45
46
  }
46
47
  const now = timestamp();
47
- const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
48
+ const normalizedTasks = normalizeTaskList(tasks, []);
49
+ assertTaskGraph(normalizedTasks);
48
50
  for (const task of normalizedTasks) assertRecoveryReferenceBudget(normalizedPlanId, task.id);
49
51
  const state = {
50
52
  schemaVersion: GOAL_STATE_VERSION,
@@ -66,6 +68,7 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
66
68
  tasks: normalizedTasks,
67
69
  receipts: [],
68
70
  };
71
+ advanceGoal(state);
69
72
  await writeGoalStateUnlocked({ root, state });
70
73
  return state;
71
74
  });
@@ -112,6 +115,7 @@ function applyGoalTransition(state, nextStatus, blocker, root) {
112
115
  if (nextStatus === "active") state.goal.blocker = null;
113
116
  state.goal.status = nextStatus;
114
117
  state.goal.updatedAt = timestamp();
118
+ if (nextStatus === "active" && currentStatus === "blocked") advanceGoal(state, null, { block: false });
115
119
  return state;
116
120
  }
117
121
 
@@ -124,19 +128,14 @@ export async function setTaskStatus({ root = process.cwd(), taskId, status, bloc
124
128
  if (task.status === nextStatus) return state;
125
129
  if (!taskTransitions[task.status].has(nextStatus)) throw new Error(`Cannot transition task ${task.id} from ${task.status} to ${nextStatus}`);
126
130
  if (nextStatus === "active") {
127
- const anotherActive = state.tasks.find((item) => item.id !== task.id && item.status === "active");
128
- if (anotherActive) throw new Error(`Task ${anotherActive.id} is already active`);
131
+ assertCanBeActive(state.tasks, task, state.tasks.filter((item) => item.id !== task.id && item.status === "active"));
129
132
  }
130
133
  if (nextStatus === "completed") ensureTaskCanComplete(state, task, root);
131
134
  if (nextStatus === "blocked") task.blocker = requiredText(blocker, "blocker");
132
135
  if (nextStatus === "active" || nextStatus === "pending") task.blocker = null;
133
136
  task.status = nextStatus;
134
137
  task.updatedAt = timestamp();
135
- if (nextStatus === "completed") activateNextPendingTask(state);
136
- if (nextStatus === "blocked" && !activateNextPendingTask(state)) {
137
- state.goal.status = "blocked";
138
- state.goal.blocker = task.blocker;
139
- }
138
+ if (nextStatus === "completed" || nextStatus === "blocked") advanceGoal(state, nextStatus === "blocked" ? task.blocker : null);
140
139
  state.goal.updatedAt = timestamp();
141
140
  return state;
142
141
  });
@@ -149,20 +148,34 @@ export async function replanGoal({ root = process.cwd(), tasks } = {}) {
149
148
  if (state.goal.status !== "active") throw new Error("Only an active goal can be replanned");
150
149
  const completedTasks = state.tasks.filter((task) => task.status === "completed");
151
150
  const completedIds = new Set(completedTasks.map((task) => task.id));
152
- const replacementTasks = tasks.map((task, index) => {
153
- const source = typeof task === "string" ? task : { ...task, status: undefined, blocker: undefined };
154
- const normalized = normalizeTask(source, index, index === 0 ? "active" : "pending");
155
- if (completedIds.has(normalized.id)) throw new Error(`Replanned task conflicts with completed task id: ${normalized.id}`);
156
- return normalized;
157
- });
151
+ const replacementTasks = normalizeTaskList(
152
+ tasks.map((task) => (typeof task === "string" ? task : { ...task, status: undefined, blocker: undefined })),
153
+ completedTasks,
154
+ );
155
+ for (const task of replacementTasks) {
156
+ if (completedIds.has(task.id)) throw new Error(`Replanned task conflicts with completed task id: ${task.id}`);
157
+ }
158
158
  state.tasks = [...completedTasks, ...replacementTasks];
159
+ assertTaskGraph(state.tasks);
159
160
  state.receipts = state.receipts.filter((receipt) => receipt.scope === "task" && completedIds.has(receipt.taskId));
160
161
  state.goal.blocker = null;
161
162
  state.goal.updatedAt = timestamp();
163
+ advanceGoal(state);
162
164
  return state;
163
165
  });
164
166
  }
165
167
 
168
+ // Normalizes tasks as pending; an omitted dependsOn follows the previous task across completed + new tasks.
169
+ function normalizeTaskList(tasks, precedingTasks) {
170
+ let previousId = precedingTasks.at(-1)?.id ?? null;
171
+ const normalized = tasks.map((task, index) => {
172
+ const result = normalizeTask(task, index, "pending", previousId);
173
+ previousId = result.id;
174
+ return result;
175
+ });
176
+ return normalized;
177
+ }
178
+
166
179
  export async function claimGoal({ root = process.cwd(), sessionId } = {}) {
167
180
  const normalizedSessionId = requiredText(sessionId, "sessionId");
168
181
  return mutateGoal(root, (state) => {
@@ -1,6 +1,9 @@
1
+ import { readyTasks } from "./cairn-goal-operations-mutations.mjs";
2
+
1
3
  export function currentTask(state) {
2
4
  if (!state) return null;
3
5
  return state.tasks.find((task) => task.status === "active")
6
+ ?? readyTasks(state)[0]
4
7
  ?? state.tasks.find((task) => task.status === "pending")
5
8
  ?? state.tasks.find((task) => task.status === "blocked")
6
9
  ?? null;
@@ -23,10 +26,22 @@ export function evaluateStop(state, { subagent = false, agentId } = {}) {
23
26
  };
24
27
  }
25
28
  if (subagent) {
26
- if (!agentId || task.assignedAgentId !== agentId) return { block: false };
29
+ // Parallel workers each own a task; gate on the stopping agent's own unfinished task, not the first active one.
30
+ const assigned = !agentId ? null
31
+ : state.tasks.find((item) => item.status === "active" && item.assignedAgentId === agentId)
32
+ ?? (task.assignedAgentId === agentId ? task : null);
33
+ if (!assigned) return { block: false };
27
34
  return {
28
35
  block: true,
29
- reason: `Cairn task ${task.id} (${task.title}) is assigned to this subagent and is ${task.status}. Continue only this assigned task, record a successful evidence record, then hand off.`,
36
+ reason: `Cairn task ${assigned.id} (${assigned.title}) is assigned to this subagent and is ${assigned.status}. Continue only this assigned task, record a successful evidence record, then hand off.`,
37
+ };
38
+ }
39
+ const active = state.tasks.filter((item) => item.status === "active");
40
+ if (active.length > 1) {
41
+ const list = active.map((item) => `${item.id} (${clip(item.title, 40)})`).join(", ");
42
+ return {
43
+ block: true,
44
+ reason: `Cairn goal "${state.goal.title}" is active with parallel tasks ${list}. Collect or continue each one; record successful evidence before marking a task complete.`,
30
45
  };
31
46
  }
32
47
  return {
@@ -34,3 +49,8 @@ export function evaluateStop(state, { subagent = false, agentId } = {}) {
34
49
  reason: `Cairn goal "${state.goal.title}" is active. Continue current task ${task.id} (${task.title}); its status is ${task.status}. Record a successful evidence record before marking it complete.`,
35
50
  };
36
51
  }
52
+
53
+ function clip(value, maxLength) {
54
+ const text = String(value ?? "");
55
+ return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
56
+ }
@@ -1,6 +1,7 @@
1
1
  import { normalizeVerificationArgv } from "./cairn-goal-evidence.mjs";
2
2
 
3
- export const GOAL_STATE_VERSION = 2;
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;
@@ -43,7 +44,6 @@ export function validateState(value) {
43
44
  if (!Array.isArray(value.tasks) || value.tasks.length === 0) throw new Error("Cairn goal state must contain tasks");
44
45
  if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state evidence records (receipts) must be an array");
45
46
  const ids = new Set();
46
- let activeTasks = 0;
47
47
  for (const task of value.tasks) {
48
48
  if (!task || typeof task !== "object") throw new Error("Each task must be an object");
49
49
  const taskId = boundedRequiredText(task.id, "task.id", TASK_ID_MAX_LENGTH);
@@ -52,7 +52,8 @@ export function validateState(value) {
52
52
  ids.add(task.id);
53
53
  requiredText(task.title, "task.title");
54
54
  validTaskStatus(task.status);
55
- if (task.status === "active") activeTasks += 1;
55
+ if (!Array.isArray(task.dependsOn)) throw new Error(`task ${task.id} dependsOn must be an array`);
56
+ if (task.writeScope !== null && !Array.isArray(task.writeScope)) throw new Error(`task ${task.id} writeScope must be an array or null`);
56
57
  if (task.assignedAgentId !== null && task.assignedAgentId !== undefined) requiredText(task.assignedAgentId, "task.assignedAgentId");
57
58
  if (!Array.isArray(task.requiredEvidence) || task.requiredEvidence.length === 0) throw new Error(`task ${task.id} requiredEvidence must be a non-empty array`);
58
59
  task.requiredEvidence.forEach((kind, index) => requiredText(kind, `task ${task.id} requiredEvidence[${index}]`));
@@ -60,7 +61,9 @@ export function validateState(value) {
60
61
  validTimestamp(task.createdAt);
61
62
  validTimestamp(task.updatedAt);
62
63
  }
63
- if (activeTasks > 1) throw new Error("Only one task may be active");
64
+ assertTaskGraph(value.tasks);
65
+ const active = value.tasks.filter((task) => task.status === "active");
66
+ for (const task of active) assertCanRunAlongside(task, active.filter((other) => other !== task));
64
67
  for (const receipt of value.receipts) validateReceipt(receipt, value);
65
68
  return value;
66
69
  }
@@ -86,7 +89,9 @@ function validateReceipt(receipt, state) {
86
89
  }
87
90
  }
88
91
 
89
- export function normalizeTask(task, index, defaultStatus) {
92
+ // dependsOn omitted means "after the previous task in list order" so existing sequential plans keep their order;
93
+ // an explicit [] means no dependency.
94
+ export function normalizeTask(task, index, defaultStatus, previousTaskId = null) {
90
95
  const source = typeof task === "string" ? { title: task } : task;
91
96
  if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
92
97
  const id = source.id === undefined ? `task-${index + 1}` : boundedRequiredText(source.id, `tasks[${index}].id`, TASK_ID_MAX_LENGTH);
@@ -97,6 +102,12 @@ export function normalizeTask(task, index, defaultStatus) {
97
102
  status: source.status === undefined ? defaultStatus : validTaskStatus(source.status),
98
103
  assignedAgentId: source.assignedAgentId === undefined || source.assignedAgentId === null ? null : requiredText(source.assignedAgentId, `tasks[${index}].assignedAgentId`),
99
104
  requiredEvidence: normalizeCriteria(source.requiredEvidence ?? defaultTaskEvidence),
105
+ dependsOn: source.dependsOn === undefined
106
+ ? (previousTaskId === null ? [] : [previousTaskId])
107
+ : normalizeDependsOn(source.dependsOn, `tasks[${index}].dependsOn`),
108
+ writeScope: source.writeScope === undefined || source.writeScope === null
109
+ ? null
110
+ : normalizeWriteScope(source.writeScope, `tasks[${index}].writeScope`),
100
111
  blocker: null,
101
112
  createdAt: timestamp(),
102
113
  updatedAt: timestamp(),
@@ -104,13 +115,103 @@ export function normalizeTask(task, index, defaultStatus) {
104
115
  }
105
116
 
106
117
  export function migrateState(value) {
107
- if (!value || typeof value !== "object" || Array.isArray(value) || value.schemaVersion !== 1) return value;
108
- return {
109
- ...value,
110
- schemaVersion: GOAL_STATE_VERSION,
111
- goal: { ...value.goal, evidencePolicy: value.goal?.evidencePolicy ?? "tool-bound" },
112
- receipts: Array.isArray(value.receipts) ? value.receipts.map((receipt) => ({ ...receipt, source: "declared" })) : value.receipts,
118
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
119
+ let state = value;
120
+ if (state.schemaVersion === 1) {
121
+ state = {
122
+ ...state,
123
+ schemaVersion: 2,
124
+ goal: { ...state.goal, evidencePolicy: state.goal?.evidencePolicy ?? "tool-bound" },
125
+ receipts: Array.isArray(state.receipts) ? state.receipts.map((receipt) => ({ ...receipt, source: "declared" })) : state.receipts,
126
+ };
127
+ }
128
+ if (state.schemaVersion === 2) {
129
+ // v2 tasks ran strictly in list order; make that order explicit as previous-task dependencies.
130
+ state = {
131
+ ...state,
132
+ schemaVersion: 3,
133
+ tasks: Array.isArray(state.tasks)
134
+ ? state.tasks.map((task, index) => ({
135
+ ...task,
136
+ dependsOn: index === 0 ? [] : [state.tasks[index - 1]?.id],
137
+ writeScope: null,
138
+ }))
139
+ : state.tasks,
140
+ };
141
+ }
142
+ return state;
143
+ }
144
+
145
+ export function normalizeDependsOn(value, label) {
146
+ if (!Array.isArray(value)) throw new Error(`${label} must be an array of task ids`);
147
+ return [...new Set(value.map((item, index) => boundedRequiredText(item, `${label}[${index}]`, TASK_ID_MAX_LENGTH)))];
148
+ }
149
+
150
+ export function normalizeWriteScope(value, label) {
151
+ if (!Array.isArray(value) || value.length === 0) throw new Error(`${label} writeScope must be a non-empty array of repository paths`);
152
+ return [...new Set(value.map((item, index) => {
153
+ const path = requiredText(item, `${label}[${index}]`).replace(/\\/g, "/").replace(/\/{2,}/g, "/")
154
+ .replace(/^(\.\/)+/, "").replace(/\/+$/, "");
155
+ if (/[*?[\]{}]/.test(path)) throw new Error(`${label} writeScope must list literal paths, not globs: ${item}`);
156
+ if (path === "" || path === "." || path.startsWith("/") || /^[a-zA-Z]:/.test(path) || path.split("/").includes("..")) {
157
+ throw new Error(`${label} writeScope must stay inside the repository: ${item}`);
158
+ }
159
+ return path;
160
+ }))];
161
+ }
162
+
163
+ // Segment-wise prefix match, case-insensitive so case-insensitive filesystems cannot hide an overlap.
164
+ export function writeScopesOverlap(left, right) {
165
+ return left.some((a) => right.some((b) => pathContains(a, b) || pathContains(b, a)));
166
+ }
167
+
168
+ export function pathContains(scope, path) {
169
+ const outer = scope.toLowerCase();
170
+ const inner = path.toLowerCase();
171
+ return inner === outer || inner.startsWith(`${outer}/`);
172
+ }
173
+
174
+ export function assertTaskGraph(tasks) {
175
+ const byId = new Map();
176
+ for (const task of tasks) {
177
+ if (byId.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
178
+ byId.set(task.id, task);
179
+ }
180
+ for (const task of tasks) {
181
+ for (const dependency of task.dependsOn) {
182
+ if (dependency === task.id) throw new Error(`Task ${task.id} cannot depend on itself`);
183
+ if (!byId.has(dependency)) throw new Error(`Task ${task.id} depends on unknown task: ${dependency}`);
184
+ }
185
+ }
186
+ const visiting = new Set();
187
+ const done = new Set();
188
+ const visit = (task, path) => {
189
+ if (done.has(task.id)) return;
190
+ if (visiting.has(task.id)) throw new Error(`Task dependency cycle: ${[...path, task.id].join(" -> ")}`);
191
+ visiting.add(task.id);
192
+ for (const dependency of task.dependsOn) visit(byId.get(dependency), [...path, task.id]);
193
+ visiting.delete(task.id);
194
+ done.add(task.id);
113
195
  };
196
+ for (const task of tasks) visit(task, []);
197
+ }
198
+
199
+ // Throws unless task may be active next to otherActive: dependencies completed, under the cap, and disjoint
200
+ // writeScopes. A task without writeScope may only run alone.
201
+ export function assertCanBeActive(tasks, task, otherActive) {
202
+ const waiting = task.dependsOn.filter((id) => tasks.find((item) => item.id === id)?.status !== "completed");
203
+ if (waiting.length > 0) throw new Error(`Task ${task.id} is waiting for: ${waiting.join(", ")}`);
204
+ assertCanRunAlongside(task, otherActive);
205
+ }
206
+
207
+ function assertCanRunAlongside(task, otherActive) {
208
+ if (otherActive.length === 0) return;
209
+ if (otherActive.length + 1 > MAX_ACTIVE_TASKS) throw new Error(`Only at most ${MAX_ACTIVE_TASKS} active tasks are allowed`);
210
+ if (task.writeScope === null) throw new Error(`Task ${task.id} without writeScope cannot run alongside active task ${otherActive[0].id}`);
211
+ for (const other of otherActive) {
212
+ if (other.writeScope === null) throw new Error(`Task ${task.id} cannot run alongside active task ${other.id} without writeScope`);
213
+ if (writeScopesOverlap(task.writeScope, other.writeScope)) throw new Error(`Task ${task.id} writeScope overlaps active task ${other.id}`);
214
+ }
114
215
  }
115
216
 
116
217
  export function requiredText(value, label) {
@@ -145,7 +246,7 @@ export function validTaskStatus(value) {
145
246
  }
146
247
 
147
248
  export function validReceiptScope(value) {
148
- if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value}`);
249
+ if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value} (allowed: task, goal)`);
149
250
  return value;
150
251
  }
151
252
 
@@ -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 join(goalStateDirectory(root), "stop-guard.json");
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 = stopGuardPath(root);
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 writeStopGuard(root = process.cwd(), value) {
47
+ export async function writeHookGuard(root = process.cwd(), name, value) {
36
48
  try {
37
- await safeWriteFile(cairnHome(), stopGuardPath(root), `${JSON.stringify(value)}\n`, "utf8");
49
+ await safeWriteFile(cairnHome(), hookGuardPath(root, name), `${JSON.stringify(value)}\n`, "utf8");
38
50
  } catch {
39
- // The guard only prevents no-progress continuation loops; failing to persist it must not break the stop hook.
51
+ // Guards only deduplicate hook output; failing to persist one must not break the hook.
40
52
  }
41
53
  }
42
54
 
@@ -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,8 +21,9 @@ 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
28
  export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log } = {}) {
27
29
  return runGoalCli(args, {
@@ -30,7 +32,10 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
30
32
  operations: {
31
33
  assignTask,
32
34
  claimGoal,
35
+ finishGoal,
36
+ goalStats,
33
37
  readGoalState,
38
+ readyTasks,
34
39
  recordReceipt,
35
40
  replanGoal,
36
41
  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.tasks.find((item) => item.status === "active")
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) {
@@ -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 result = contextResult({ ko, state, event });
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 progressed = previous?.goalId !== state.goal.id
111
- || previous?.scope !== scope
112
- || previous?.revision !== state.revision;
113
- if (progressed) await writeStopGuard(root, { goalId: state.goal.id, scope, revision: state.revision });
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.`;