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,12 +1,16 @@
|
|
|
1
|
+
import { resolveRepoRoot } from "./cairn-paths.mjs";
|
|
2
|
+
|
|
1
3
|
export async function runGoalCli(args, {
|
|
2
4
|
stdout = console.log,
|
|
5
|
+
stderr = console.error,
|
|
3
6
|
defaultGoalEvidence,
|
|
4
7
|
operations,
|
|
5
8
|
}) {
|
|
6
9
|
const [firstCommand = "help", ...remaining] = args;
|
|
7
10
|
const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
|
|
8
11
|
const { options, positional, passthrough } = parseArgs(rest, command);
|
|
9
|
-
|
|
12
|
+
// Without --root, resolve the repository (HARNESS_REPO_ROOT or the nearest .git ancestor), not a subdirectory cwd.
|
|
13
|
+
const root = resolveRepoRoot({ explicitRoot: options.root ?? null });
|
|
10
14
|
const emit = options.quiet ? () => {} : stdout;
|
|
11
15
|
if (command === "start") {
|
|
12
16
|
const tasks = parseTasks(options.tasks ?? positional.slice(0));
|
|
@@ -49,6 +53,7 @@ export async function runGoalCli(args, {
|
|
|
49
53
|
taskId: options.task ?? (taskAction ? positional[1] : positional[0]),
|
|
50
54
|
status: options.status ?? taskAction ?? positional[1],
|
|
51
55
|
blocker: options.reason,
|
|
56
|
+
effortWaiver: options["allow-inherited-effort"],
|
|
52
57
|
});
|
|
53
58
|
emit(JSON.stringify(state));
|
|
54
59
|
return state;
|
|
@@ -68,7 +73,15 @@ export async function runGoalCli(args, {
|
|
|
68
73
|
root,
|
|
69
74
|
taskId: options.task ?? positional[0],
|
|
70
75
|
agentId: options.agent ?? positional[1] ?? null,
|
|
76
|
+
effort: options.effort,
|
|
77
|
+
effortSource: options["effort-source"],
|
|
78
|
+
route: options.route,
|
|
79
|
+
effortWaiver: options["allow-inherited-effort"],
|
|
71
80
|
});
|
|
81
|
+
const task = state.tasks.find((item) => item.id === String(options.task ?? positional[0]).trim());
|
|
82
|
+
if (task?.route === "heavy" && task.assignedAgentId && (!task.effort || task.effortSource === "not-applied")) {
|
|
83
|
+
stderr(`Cairn warning: Heavy task ${task.id} is assigned without an applied effort (effort ${task.effort ?? "none"}, source ${task.effortSource ?? "none"}); dispatch with the plan's effort and record --effort <level> --effort-source host-option.`);
|
|
84
|
+
}
|
|
72
85
|
emit(JSON.stringify(state));
|
|
73
86
|
return state;
|
|
74
87
|
}
|
|
@@ -91,7 +104,7 @@ export async function runGoalCli(args, {
|
|
|
91
104
|
const result = await operations.verifyAndRecord({
|
|
92
105
|
root,
|
|
93
106
|
taskId: options.task ?? positional[0],
|
|
94
|
-
kind: options.kind,
|
|
107
|
+
kind: splitValues(options.kind),
|
|
95
108
|
scope: options.scope,
|
|
96
109
|
watchPaths: splitValues(options.watch),
|
|
97
110
|
argv: passthrough,
|
|
@@ -99,16 +112,41 @@ export async function runGoalCli(args, {
|
|
|
99
112
|
? 600_000
|
|
100
113
|
: positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
|
|
101
114
|
});
|
|
102
|
-
|
|
103
|
-
id:
|
|
104
|
-
scope:
|
|
105
|
-
kind:
|
|
106
|
-
exitCode:
|
|
107
|
-
outputDigest:
|
|
108
|
-
workspaceFingerprint:
|
|
115
|
+
const summaries = result.receipts.map((receipt) => ({
|
|
116
|
+
id: receipt.id,
|
|
117
|
+
scope: receipt.scope,
|
|
118
|
+
kind: receipt.kind,
|
|
119
|
+
exitCode: receipt.exitCode,
|
|
120
|
+
outputDigest: receipt.outputDigest,
|
|
121
|
+
workspaceFingerprint: receipt.workspaceFingerprint,
|
|
109
122
|
}));
|
|
123
|
+
emit(JSON.stringify(summaries.length === 1 ? summaries[0] : summaries));
|
|
110
124
|
return result.state;
|
|
111
125
|
}
|
|
126
|
+
if (command === "finish") {
|
|
127
|
+
const state = await operations.finishGoal({
|
|
128
|
+
root,
|
|
129
|
+
watchPaths: splitValues(options.watch),
|
|
130
|
+
argv: passthrough,
|
|
131
|
+
timeoutMs: options["timeout-ms"] === undefined
|
|
132
|
+
? 600_000
|
|
133
|
+
: positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
|
|
134
|
+
});
|
|
135
|
+
emit(JSON.stringify({ id: state.goal.id, status: state.goal.status }));
|
|
136
|
+
return state;
|
|
137
|
+
}
|
|
138
|
+
if (command === "ready") {
|
|
139
|
+
const state = await operations.readGoalState({ root });
|
|
140
|
+
const ids = (status) => (state ? state.tasks.filter((task) => task.status === status).map((task) => task.id) : []);
|
|
141
|
+
const summary = { active: ids("active"), ready: operations.readyTasks(state).map((task) => task.id), blocked: ids("blocked") };
|
|
142
|
+
stdout(JSON.stringify(summary));
|
|
143
|
+
return summary;
|
|
144
|
+
}
|
|
145
|
+
if (command === "stats") {
|
|
146
|
+
const stats = operations.goalStats(await operations.readGoalState({ root }), root);
|
|
147
|
+
stdout(JSON.stringify(stats));
|
|
148
|
+
return stats;
|
|
149
|
+
}
|
|
112
150
|
if (["pause", "resume", "block", "cancel", "complete"].includes(command)) {
|
|
113
151
|
const state = await operations.transitionGoal({
|
|
114
152
|
root,
|
|
@@ -118,7 +156,7 @@ export async function runGoalCli(args, {
|
|
|
118
156
|
emit(JSON.stringify(state));
|
|
119
157
|
return state;
|
|
120
158
|
}
|
|
121
|
-
throw new Error("Usage: cairn-goal start|status|task|replan|claim|assign|receipt|verify|pause|resume|block|cancel|complete [--root PATH]");
|
|
159
|
+
throw new Error("Usage: cairn-goal start|status|ready|stats|task|replan|claim|assign|receipt|verify|finish|pause|resume|block|cancel|complete [--root PATH]");
|
|
122
160
|
}
|
|
123
161
|
|
|
124
162
|
function parseArgs(args, command) {
|
|
@@ -176,12 +214,15 @@ function allowedOptions(command) {
|
|
|
176
214
|
const byCommand = {
|
|
177
215
|
start: ["goal", "plan", "tasks", "criteria", "requiredEvidence", "session", "evidencePolicy", "evidence-policy"],
|
|
178
216
|
status: [],
|
|
179
|
-
task: ["task", "status", "reason"],
|
|
217
|
+
task: ["task", "status", "reason", "allow-inherited-effort"],
|
|
180
218
|
replan: ["tasks"],
|
|
181
219
|
claim: ["session"],
|
|
182
|
-
assign: ["task", "agent"],
|
|
220
|
+
assign: ["task", "agent", "effort", "effort-source", "route", "allow-inherited-effort"],
|
|
183
221
|
receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
|
|
184
222
|
verify: ["task", "kind", "scope", "watch", "timeout-ms"],
|
|
223
|
+
finish: ["watch", "timeout-ms"],
|
|
224
|
+
stats: [],
|
|
225
|
+
ready: [],
|
|
185
226
|
pause: [],
|
|
186
227
|
resume: [],
|
|
187
228
|
block: ["reason"],
|
|
@@ -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,104 @@ 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
|
+
tasks: state.tasks.map((task) => ({
|
|
179
|
+
id: task.id,
|
|
180
|
+
status: task.status,
|
|
181
|
+
route: task.route ?? null,
|
|
182
|
+
effort: task.effort ?? null,
|
|
183
|
+
effortSource: task.effortSource ?? null,
|
|
184
|
+
...(task.effortWaiver ? { effortWaiver: task.effortWaiver } : {}),
|
|
185
|
+
})),
|
|
186
|
+
};
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
// While other tasks run in parallel, an unscoped fingerprint would include their edits. Require a watch set,
|
|
190
|
+
// and keep it inside the task's declared writeScope so sibling edits cannot stale or fail this evidence.
|
|
191
|
+
function assertParallelWatch(state, task, watchPaths) {
|
|
192
|
+
const othersActive = state.tasks.filter((item) => item.status === "active" && item.id !== task?.id);
|
|
193
|
+
if (task ? othersActive.length === 0 : othersActive.length < 2) return;
|
|
194
|
+
if (watchPaths.length === 0) {
|
|
195
|
+
throw new Error(`Other tasks are active (${othersActive.map((item) => item.id).join(", ")}); pass --watch with this task's write scope`);
|
|
196
|
+
}
|
|
197
|
+
if (task?.writeScope) {
|
|
198
|
+
const outside = watchPaths.filter((path) => !task.writeScope.some((scope) => pathContains(scope, path)));
|
|
199
|
+
if (outside.length > 0) throw new Error(`--watch must stay inside task ${task.id} writeScope while other tasks are active: ${outside.join(", ")}`);
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function normalizeKinds(kind) {
|
|
204
|
+
const kinds = (Array.isArray(kind) ? kind : [kind]).map((item) => requiredText(item, "evidence kind"));
|
|
205
|
+
if (kinds.length === 0) throw new Error("evidence kind is required");
|
|
206
|
+
return [...new Set(kinds)];
|
|
124
207
|
}
|
|
@@ -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, assertEffortRecord, assertTaskGraph,
|
|
8
9
|
defaultGoalEvidence, normalizeCriteria, normalizeTask, optionalText, requiredText,
|
|
9
|
-
terminalGoalStatuses, timestamp, validEvidencePolicy, validGoalStatus, validTaskStatus, validateState,
|
|
10
|
+
terminalGoalStatuses, timestamp, validEvidencePolicy, validGoalStatus, validTaskRoute, 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,31 +115,33 @@ 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
|
|
|
118
|
-
export async function setTaskStatus({ root = process.cwd(), taskId, status, blocker } = {}) {
|
|
122
|
+
export async function setTaskStatus({ root = process.cwd(), taskId, status, blocker, effortWaiver } = {}) {
|
|
119
123
|
const nextStatus = validTaskStatus(status);
|
|
120
124
|
const normalizedTaskId = requiredText(taskId, "taskId");
|
|
125
|
+
const normalizedWaiver = optionalText(effortWaiver, "effortWaiver");
|
|
126
|
+
if (normalizedWaiver !== null && nextStatus !== "completed") throw new Error("--allow-inherited-effort applies only when completing a task");
|
|
121
127
|
return mutateGoal(root, (state) => {
|
|
122
128
|
assertGoalMutable(state);
|
|
123
129
|
const task = taskById(state, normalizedTaskId);
|
|
124
130
|
if (task.status === nextStatus) return state;
|
|
125
131
|
if (!taskTransitions[task.status].has(nextStatus)) throw new Error(`Cannot transition task ${task.id} from ${task.status} to ${nextStatus}`);
|
|
126
132
|
if (nextStatus === "active") {
|
|
127
|
-
|
|
128
|
-
|
|
133
|
+
assertCanBeActive(state.tasks, task, state.tasks.filter((item) => item.id !== task.id && item.status === "active"));
|
|
134
|
+
}
|
|
135
|
+
if (nextStatus === "completed") {
|
|
136
|
+
if (normalizedWaiver !== null) task.effortWaiver = normalizedWaiver;
|
|
137
|
+
ensureHeavyEffortApplied(task);
|
|
138
|
+
ensureTaskCanComplete(state, task, root);
|
|
129
139
|
}
|
|
130
|
-
if (nextStatus === "completed") ensureTaskCanComplete(state, task, root);
|
|
131
140
|
if (nextStatus === "blocked") task.blocker = requiredText(blocker, "blocker");
|
|
132
141
|
if (nextStatus === "active" || nextStatus === "pending") task.blocker = null;
|
|
133
142
|
task.status = nextStatus;
|
|
134
143
|
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
|
-
}
|
|
144
|
+
if (nextStatus === "completed" || nextStatus === "blocked") advanceGoal(state, nextStatus === "blocked" ? task.blocker : null);
|
|
140
145
|
state.goal.updatedAt = timestamp();
|
|
141
146
|
return state;
|
|
142
147
|
});
|
|
@@ -149,20 +154,34 @@ export async function replanGoal({ root = process.cwd(), tasks } = {}) {
|
|
|
149
154
|
if (state.goal.status !== "active") throw new Error("Only an active goal can be replanned");
|
|
150
155
|
const completedTasks = state.tasks.filter((task) => task.status === "completed");
|
|
151
156
|
const completedIds = new Set(completedTasks.map((task) => task.id));
|
|
152
|
-
const replacementTasks =
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
157
|
+
const replacementTasks = normalizeTaskList(
|
|
158
|
+
tasks.map((task) => (typeof task === "string" ? task : { ...task, status: undefined, blocker: undefined })),
|
|
159
|
+
completedTasks,
|
|
160
|
+
);
|
|
161
|
+
for (const task of replacementTasks) {
|
|
162
|
+
if (completedIds.has(task.id)) throw new Error(`Replanned task conflicts with completed task id: ${task.id}`);
|
|
163
|
+
}
|
|
158
164
|
state.tasks = [...completedTasks, ...replacementTasks];
|
|
165
|
+
assertTaskGraph(state.tasks);
|
|
159
166
|
state.receipts = state.receipts.filter((receipt) => receipt.scope === "task" && completedIds.has(receipt.taskId));
|
|
160
167
|
state.goal.blocker = null;
|
|
161
168
|
state.goal.updatedAt = timestamp();
|
|
169
|
+
advanceGoal(state);
|
|
162
170
|
return state;
|
|
163
171
|
});
|
|
164
172
|
}
|
|
165
173
|
|
|
174
|
+
// Normalizes tasks as pending; an omitted dependsOn follows the previous task across completed + new tasks.
|
|
175
|
+
function normalizeTaskList(tasks, precedingTasks) {
|
|
176
|
+
let previousId = precedingTasks.at(-1)?.id ?? null;
|
|
177
|
+
const normalized = tasks.map((task, index) => {
|
|
178
|
+
const result = normalizeTask(task, index, "pending", previousId);
|
|
179
|
+
previousId = result.id;
|
|
180
|
+
return result;
|
|
181
|
+
});
|
|
182
|
+
return normalized;
|
|
183
|
+
}
|
|
184
|
+
|
|
166
185
|
export async function claimGoal({ root = process.cwd(), sessionId } = {}) {
|
|
167
186
|
const normalizedSessionId = requiredText(sessionId, "sessionId");
|
|
168
187
|
return mutateGoal(root, (state) => {
|
|
@@ -173,13 +192,41 @@ export async function claimGoal({ root = process.cwd(), sessionId } = {}) {
|
|
|
173
192
|
});
|
|
174
193
|
}
|
|
175
194
|
|
|
176
|
-
|
|
195
|
+
// A Heavy task delegated to a worker must record the effort the host actually applied; a missing, not-applied, or
|
|
196
|
+
// inherited effort needs an explicit waiver reason. Unassigned (main-agent) and Light tasks are not checked.
|
|
197
|
+
function ensureHeavyEffortApplied(task) {
|
|
198
|
+
if (task.route !== "heavy" || !task.assignedAgentId || task.effortWaiver) return;
|
|
199
|
+
const source = task.effortSource ?? null;
|
|
200
|
+
if (task.effort && source !== "not-applied" && source !== "inherited") return;
|
|
201
|
+
throw new Error(`Heavy task ${task.id} assigned to ${task.assignedAgentId} has no applied effort (effort ${task.effort ?? "none"}, source ${source ?? "none"}); record goal assign ${task.id} --effort <low|medium|high|xhigh|max> --effort-source host-option, or complete with --allow-inherited-effort <reason>`);
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Omitted effort/route/waiver options keep stored values, so a reassignment can change only the agent. When effort
|
|
205
|
+
// or effortSource is given, both are replaced together; an omitted source is inferred from the effort.
|
|
206
|
+
export async function assignTask({
|
|
207
|
+
root = process.cwd(), taskId, agentId = null, effort, effortSource, route, effortWaiver,
|
|
208
|
+
} = {}) {
|
|
177
209
|
const normalizedTaskId = requiredText(taskId, "taskId");
|
|
178
210
|
const normalizedAgentId = agentId === null ? null : requiredText(agentId, "agentId");
|
|
211
|
+
const effortGiven = effort !== undefined || effortSource !== undefined;
|
|
212
|
+
const normalizedEffort = effort ?? null;
|
|
213
|
+
const normalizedSource = effortSource ?? (normalizedEffort === null ? null : normalizedEffort === "inherited" ? "inherited" : "host-option");
|
|
214
|
+
if (effortGiven) {
|
|
215
|
+
if (normalizedEffort === null && normalizedSource !== "not-applied") throw new Error("--effort-source requires --effort unless it is not-applied");
|
|
216
|
+
assertEffortRecord(normalizedEffort, normalizedSource, "assign");
|
|
217
|
+
}
|
|
218
|
+
const normalizedRoute = route === undefined ? undefined : validTaskRoute(route);
|
|
219
|
+
const normalizedWaiver = effortWaiver === undefined ? undefined : requiredText(effortWaiver, "effortWaiver");
|
|
179
220
|
return mutateGoal(root, (state) => {
|
|
180
221
|
assertGoalMutable(state);
|
|
181
222
|
const task = taskById(state, normalizedTaskId);
|
|
182
223
|
task.assignedAgentId = normalizedAgentId;
|
|
224
|
+
if (effortGiven) {
|
|
225
|
+
task.effort = normalizedEffort;
|
|
226
|
+
task.effortSource = normalizedSource;
|
|
227
|
+
}
|
|
228
|
+
if (normalizedRoute !== undefined) task.route = normalizedRoute;
|
|
229
|
+
if (normalizedWaiver !== undefined) task.effortWaiver = normalizedWaiver;
|
|
183
230
|
task.updatedAt = timestamp();
|
|
184
231
|
state.goal.updatedAt = timestamp();
|
|
185
232
|
return 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,14 +26,37 @@ 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 };
|
|
34
|
+
return {
|
|
35
|
+
block: true,
|
|
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.${effortWarning([assigned])}`,
|
|
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(", ");
|
|
27
42
|
return {
|
|
28
43
|
block: true,
|
|
29
|
-
reason: `Cairn
|
|
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.${effortWarning(active)}`,
|
|
30
45
|
};
|
|
31
46
|
}
|
|
32
47
|
return {
|
|
33
48
|
block: true,
|
|
34
|
-
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
|
|
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.${effortWarning(active)}`,
|
|
35
50
|
};
|
|
36
51
|
}
|
|
52
|
+
|
|
53
|
+
// Delegated work must record the effort the host applied; flag assigned tasks whose effort source is still empty.
|
|
54
|
+
function effortWarning(tasks) {
|
|
55
|
+
const ids = tasks.filter((item) => item.assignedAgentId && !item.effortSource).map((item) => item.id);
|
|
56
|
+
return ids.length === 0 ? "" : ` Effort unrecorded for assigned task ${ids.join(", ")}: record goal assign --effort/--effort-source.`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function clip(value, maxLength) {
|
|
60
|
+
const text = String(value ?? "");
|
|
61
|
+
return text.length <= maxLength ? text : `${text.slice(0, maxLength - 1)}…`;
|
|
62
|
+
}
|