cairn-ai 0.2.8 → 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.
- 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 +5 -3
- package/README.md +5 -3
- package/commands/cairn-install.md +1 -0
- package/commands/cairn-plan.md +9 -15
- package/commands/cairn-work.md +13 -21
- package/docs/model-guidance/README.md +2 -2
- package/package.json +1 -1
- package/scripts/cairn-goal-cli.mjs +41 -10
- package/scripts/cairn-goal-operations-evidence.mjs +97 -22
- package/scripts/cairn-goal-operations-mutations.mjs +42 -8
- package/scripts/cairn-goal-operations.mjs +28 -15
- package/scripts/cairn-goal-policy.mjs +22 -2
- package/scripts/cairn-goal-state-validation.mjs +113 -12
- package/scripts/cairn-goal-state.mjs +17 -5
- package/scripts/cairn-goal.mjs +7 -2
- package/scripts/cairn-lifecycle-claude.mjs +142 -0
- package/scripts/cairn-lifecycle-commands.mjs +41 -3
- package/scripts/cairn-lifecycle-integrity.mjs +15 -2
- package/scripts/cairn-lifecycle-staging.mjs +9 -2
- package/scripts/cairn-lifecycle.mjs +5 -2
- package/scripts/cairn-state-context.mjs +13 -4
- package/scripts/cairn-state.mjs +48 -6
- package/skills/cairn-plan/SKILL.md +34 -175
- package/skills/cairn-plan/delegation.md +43 -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 +37 -142
- package/skills/cairn-work/delegation.md +43 -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/work-plan.md +3 -1
|
@@ -2,10 +2,11 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { normalizeVerificationArgv, normalizeWatchPaths, runVerificationEvidence, workspaceFingerprint } from "./cairn-goal-evidence.mjs";
|
|
3
3
|
import { readGoalState } from "./cairn-goal-state.mjs";
|
|
4
4
|
import {
|
|
5
|
-
normalizeToolEvidence, requiredText, timestamp, validReceiptCommand, validReceiptScope,
|
|
5
|
+
normalizeToolEvidence, pathContains, requiredText, timestamp, validReceiptCommand, validReceiptScope,
|
|
6
6
|
validReceiptSource, validTimestamp,
|
|
7
7
|
} from "./cairn-goal-state-validation.mjs";
|
|
8
|
-
import { assertGoalMutable, mutateGoal, taskById } from "./cairn-goal-operations-mutations.mjs";
|
|
8
|
+
import { assertGoalMutable, isReceiptFresh, mutateGoal, taskById } from "./cairn-goal-operations-mutations.mjs";
|
|
9
|
+
import { setTaskStatus, transitionGoal } from "./cairn-goal-operations.mjs";
|
|
9
10
|
|
|
10
11
|
export async function recordReceipt(options = {}) {
|
|
11
12
|
if (options.source !== undefined && options.source !== "declared") throw new Error("Only goal verify can create tool evidence");
|
|
@@ -69,7 +70,7 @@ export async function verifyAndRecord({
|
|
|
69
70
|
const normalizedArgv = normalizeVerificationArgv(argv);
|
|
70
71
|
const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
|
|
71
72
|
const normalizedScope = validReceiptScope(scope);
|
|
72
|
-
const
|
|
73
|
+
const normalizedKinds = normalizeKinds(kind);
|
|
73
74
|
const initialState = await readGoalState({ root });
|
|
74
75
|
if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
|
|
75
76
|
assertGoalMutable(initialState);
|
|
@@ -78,6 +79,7 @@ export async function verifyAndRecord({
|
|
|
78
79
|
if (initialTask && !["active", "completed"].includes(initialTask.status)) {
|
|
79
80
|
throw new Error(`Verification task must be active or completed: ${initialTask.id}`);
|
|
80
81
|
}
|
|
82
|
+
assertParallelWatch(initialState, initialTask, normalizedWatchPaths);
|
|
81
83
|
const identity = {
|
|
82
84
|
goalId: initialState.goal.id,
|
|
83
85
|
planId: initialState.goal.planId,
|
|
@@ -102,23 +104,96 @@ export async function verifyAndRecord({
|
|
|
102
104
|
throw new Error("Verification task changed during verification; evidence was not recorded");
|
|
103
105
|
}
|
|
104
106
|
}
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
107
|
+
// One verification run may prove several evidence kinds; record one receipt per kind without rerunning it.
|
|
108
|
+
const receipts = [];
|
|
109
|
+
let state;
|
|
110
|
+
for (const normalizedKind of normalizedKinds) {
|
|
111
|
+
state = await recordEvidence({
|
|
112
|
+
root,
|
|
113
|
+
taskId: normalizedTaskId,
|
|
114
|
+
kind: normalizedKind,
|
|
115
|
+
scope: normalizedScope,
|
|
116
|
+
command: evidence.command,
|
|
117
|
+
exitCode: 0,
|
|
118
|
+
source: "tool",
|
|
119
|
+
argv: evidence.argv,
|
|
120
|
+
outputDigest: evidence.outputDigest,
|
|
121
|
+
summary: evidence.summary,
|
|
122
|
+
workspaceFingerprint: evidence.workspaceFingerprint,
|
|
123
|
+
watchPaths: evidence.watchPaths,
|
|
124
|
+
goalId: identity.goalId,
|
|
125
|
+
planId: identity.planId,
|
|
126
|
+
expectedIdentity: identity,
|
|
127
|
+
expectedFingerprint: evidence.workspaceFingerprint,
|
|
128
|
+
});
|
|
129
|
+
receipts.push(state.receipts.at(-1));
|
|
130
|
+
}
|
|
131
|
+
return { state, evidence: receipts.at(-1), receipts };
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// Completes the active task, proves every goal-level evidence kind with one run, and completes the goal.
|
|
135
|
+
export async function finishGoal({
|
|
136
|
+
root = process.cwd(), argv = [], watchPaths = [], runner, timeoutMs = 600_000,
|
|
137
|
+
} = {}) {
|
|
138
|
+
const initialState = await readGoalState({ root });
|
|
139
|
+
if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
|
|
140
|
+
assertGoalMutable(initialState);
|
|
141
|
+
const active = initialState.tasks.find((task) => task.status === "active");
|
|
142
|
+
const unfinished = initialState.tasks.filter((task) => task.status !== "completed" && task !== active);
|
|
143
|
+
if (unfinished.length > 0) {
|
|
144
|
+
throw new Error(`Goal cannot be finished while other tasks remain: ${unfinished.map((task) => task.id).join(", ")}`);
|
|
145
|
+
}
|
|
146
|
+
const goalKinds = initialState.goal.requiredEvidence;
|
|
147
|
+
if (goalKinds.length > 0) normalizeVerificationArgv(argv);
|
|
148
|
+
if (active) await setTaskStatus({ root, taskId: active.id, status: "completed" });
|
|
149
|
+
if (goalKinds.length > 0) {
|
|
150
|
+
await verifyAndRecord({ root, scope: "goal", kind: goalKinds, argv, watchPaths, runner, timeoutMs });
|
|
151
|
+
}
|
|
152
|
+
return transitionGoal({ root, status: "completed" });
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Overhead measurement: how many verifications ran, how many were reruns, and which latest receipts are stale.
|
|
156
|
+
export function goalStats(state, root = process.cwd()) {
|
|
157
|
+
if (!state) return null;
|
|
158
|
+
const byKind = {};
|
|
159
|
+
const latest = new Map();
|
|
160
|
+
let reruns = 0;
|
|
161
|
+
for (const receipt of state.receipts) {
|
|
162
|
+
byKind[receipt.kind] = (byKind[receipt.kind] ?? 0) + 1;
|
|
163
|
+
const key = `${receipt.scope}\0${receipt.taskId ?? ""}\0${receipt.kind}`;
|
|
164
|
+
if (latest.has(key)) reruns += 1;
|
|
165
|
+
latest.set(key, receipt);
|
|
166
|
+
}
|
|
167
|
+
const stale = [...latest.values()]
|
|
168
|
+
.filter((receipt) => receipt.source === "tool" && !isReceiptFresh(root, receipt))
|
|
169
|
+
.map((receipt) => ({ scope: receipt.scope, ...(receipt.taskId ? { taskId: receipt.taskId } : {}), kind: receipt.kind }));
|
|
170
|
+
return {
|
|
171
|
+
goalId: state.goal.id,
|
|
172
|
+
status: state.goal.status,
|
|
173
|
+
receipts: state.receipts.length,
|
|
174
|
+
toolReceipts: state.receipts.filter((receipt) => receipt.source === "tool").length,
|
|
175
|
+
byKind,
|
|
176
|
+
reruns,
|
|
177
|
+
stale,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// While other tasks run in parallel, an unscoped fingerprint would include their edits. Require a watch set,
|
|
182
|
+
// and keep it inside the task's declared writeScope so sibling edits cannot stale or fail this evidence.
|
|
183
|
+
function assertParallelWatch(state, task, watchPaths) {
|
|
184
|
+
const othersActive = state.tasks.filter((item) => item.status === "active" && item.id !== task?.id);
|
|
185
|
+
if (task ? othersActive.length === 0 : othersActive.length < 2) return;
|
|
186
|
+
if (watchPaths.length === 0) {
|
|
187
|
+
throw new Error(`Other tasks are active (${othersActive.map((item) => item.id).join(", ")}); pass --watch with this task's write scope`);
|
|
188
|
+
}
|
|
189
|
+
if (task?.writeScope) {
|
|
190
|
+
const outside = watchPaths.filter((path) => !task.writeScope.some((scope) => pathContains(scope, path)));
|
|
191
|
+
if (outside.length > 0) throw new Error(`--watch must stay inside task ${task.id} writeScope while other tasks are active: ${outside.join(", ")}`);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function normalizeKinds(kind) {
|
|
196
|
+
const kinds = (Array.isArray(kind) ? kind : [kind]).map((item) => requiredText(item, "evidence kind"));
|
|
197
|
+
if (kinds.length === 0) throw new Error("evidence kind is required");
|
|
198
|
+
return [...new Set(kinds)];
|
|
124
199
|
}
|
|
@@ -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
|
-
|
|
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 (
|
|
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
|
|
45
|
-
|
|
46
|
-
|
|
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
|
|
81
|
+
return;
|
|
51
82
|
}
|
|
52
|
-
return
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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")
|
|
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 =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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 ${
|
|
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 =
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
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
|
|
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,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,
|