cairn-ai 0.2.6 → 0.2.8
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-memory.md +2 -2
- package/.agents/workflows/cairn-plan.md +3 -3
- package/.agents/workflows/cairn-review.md +2 -2
- package/.agents/workflows/cairn-work.md +4 -4
- package/.claude/agents/explorer.md +1 -1
- package/.claude/agents/worker.md +1 -1
- package/.claude/commands/cairn-memory.md +1 -1
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-review.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +1 -1
- package/README.de.md +3 -2
- package/README.es.md +3 -2
- package/README.fr.md +3 -2
- package/README.ja.md +3 -2
- package/README.ko.md +16 -8
- package/README.md +14 -8
- package/README.pt.md +3 -2
- package/README.zh.md +3 -2
- package/agents/worker.md +3 -1
- package/commands/cairn-install.md +2 -1
- package/commands/cairn-memory.md +2 -2
- package/commands/cairn-plan.md +3 -3
- package/commands/cairn-review.md +2 -2
- package/commands/cairn-work.md +4 -4
- package/docs/model-guidance/README.md +5 -5
- package/docs/model-guidance/claude.md +1 -1
- package/docs/model-guidance/codex.md +3 -3
- package/package.json +4 -2
- package/scripts/cairn-cleanup.mjs +12 -6
- package/scripts/cairn-goal-cli.mjs +239 -0
- package/scripts/cairn-goal-evidence.mjs +186 -0
- package/scripts/cairn-goal-operations-evidence.mjs +124 -0
- package/scripts/cairn-goal-operations-mutations.mjs +63 -0
- package/scripts/cairn-goal-operations.mjs +187 -0
- package/scripts/cairn-goal-policy.mjs +36 -0
- package/scripts/cairn-goal-state-validation.mjs +199 -0
- package/scripts/cairn-goal-state.mjs +124 -0
- package/scripts/cairn-goal.mjs +33 -1078
- package/scripts/cairn-lifecycle-commands.mjs +200 -0
- package/scripts/cairn-lifecycle-config.mjs +153 -0
- package/scripts/cairn-lifecycle-integrity.mjs +218 -0
- package/scripts/cairn-lifecycle-legacy.mjs +142 -0
- package/scripts/cairn-lifecycle-lock.mjs +205 -0
- package/scripts/cairn-lifecycle-mirror.mjs +91 -0
- package/scripts/cairn-lifecycle-staging.mjs +152 -0
- package/scripts/cairn-lifecycle-transaction-journal.mjs +210 -0
- package/scripts/cairn-lifecycle-transaction-targets.mjs +119 -0
- package/scripts/cairn-lifecycle-transaction.mjs +12 -0
- package/scripts/cairn-lifecycle.mjs +103 -1185
- package/scripts/cairn-paths.mjs +35 -5
- package/scripts/cairn-safe-fs.mjs +61 -35
- package/scripts/cairn-state-context.mjs +156 -0
- package/scripts/cairn-state-hook.mjs +26 -0
- package/scripts/cairn-state-init.mjs +137 -0
- package/scripts/cairn-state.mjs +32 -286
- package/scripts/cairn-toolcheck-detect.mjs +229 -0
- package/scripts/cairn-toolcheck-runtime.mjs +178 -0
- package/scripts/cairn-toolcheck.mjs +79 -424
- package/scripts/cairn.mjs +17 -3
- package/skills/cairn-memory/SKILL.md +3 -2
- package/skills/cairn-plan/SKILL.md +7 -4
- package/skills/cairn-review/SKILL.md +4 -4
- package/skills/cairn-work/SKILL.md +23 -7
- package/scripts/cairn-state.sh +0 -167
package/scripts/cairn-goal.mjs
CHANGED
|
@@ -1,1091 +1,46 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
-
import { lstatSync, readFileSync, readlinkSync, realpathSync, readdirSync } from "node:fs";
|
|
5
|
-
import { lstat, readFile, rename, rm } from "node:fs/promises";
|
|
6
|
-
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
2
|
+
import { realpathSync } from "node:fs";
|
|
7
3
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
4
|
+
import { runGoalCli } from "./cairn-goal-cli.mjs";
|
|
5
|
+
import { assignTask, claimGoal, replanGoal, setTaskStatus, startGoal, transitionGoal } from "./cairn-goal-operations.mjs";
|
|
6
|
+
import { recordReceipt, verifyAndRecord } from "./cairn-goal-operations-evidence.mjs";
|
|
7
|
+
import { readGoalState } from "./cairn-goal-state.mjs";
|
|
8
|
+
import { defaultGoalEvidence } from "./cairn-goal-state-validation.mjs";
|
|
10
9
|
|
|
10
|
+
export { workspaceFingerprint } from "./cairn-goal-evidence.mjs";
|
|
11
11
|
export { worktreeId } from "./cairn-paths.mjs";
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
active: new Set(["paused", "blocked", "cancelled", "completed"]),
|
|
26
|
-
paused: new Set(["active", "blocked", "cancelled"]),
|
|
27
|
-
blocked: new Set(["active", "paused", "cancelled"]),
|
|
28
|
-
cancelled: new Set(),
|
|
29
|
-
completed: new Set(),
|
|
30
|
-
};
|
|
31
|
-
const taskTransitions = {
|
|
32
|
-
pending: new Set(["active", "blocked"]),
|
|
33
|
-
active: new Set(["pending", "blocked", "completed"]),
|
|
34
|
-
blocked: new Set(["pending", "active"]),
|
|
35
|
-
completed: new Set(),
|
|
36
|
-
};
|
|
37
|
-
|
|
38
|
-
export function goalStatePath(root = process.cwd()) {
|
|
39
|
-
return join(goalStateDirectory(root), "state.json");
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
export async function readGoalState({ root = process.cwd() } = {}) {
|
|
43
|
-
try {
|
|
44
|
-
const path = goalStatePath(root);
|
|
45
|
-
await assertNoSymlinkComponents(cairnHome(), path);
|
|
46
|
-
const text = await readFile(path, "utf8");
|
|
47
|
-
return validateState(migrateState(JSON.parse(text)));
|
|
48
|
-
} catch (error) {
|
|
49
|
-
if (error?.code === "ENOENT") return migrateLegacyGoalState(root);
|
|
50
|
-
if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
|
|
51
|
-
throw error;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null, evidencePolicy = "tool-bound" } = {}) {
|
|
56
|
-
const normalizedGoal = requiredText(goal, "goal");
|
|
57
|
-
const normalizedPlanId = boundedRequiredText(planId, "planId", PLAN_ID_MAX_LENGTH);
|
|
58
|
-
if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
|
|
59
|
-
|
|
60
|
-
return withGoalStateLock(root, async () => {
|
|
61
|
-
let existing = await readGoalState({ root });
|
|
62
|
-
const currentWorktreeId = worktreeId(root);
|
|
63
|
-
if (existing && existing.goal.worktreeId !== currentWorktreeId) {
|
|
64
|
-
const path = goalStatePath(root);
|
|
65
|
-
await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
|
|
66
|
-
await rm(path);
|
|
67
|
-
existing = null;
|
|
68
|
-
}
|
|
69
|
-
if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
|
|
70
|
-
throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const now = timestamp();
|
|
74
|
-
const goalId = `goal-${randomUUID()}`;
|
|
75
|
-
const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
|
|
76
|
-
for (const task of normalizedTasks) assertRecoveryReferenceBudget(normalizedPlanId, task.id);
|
|
77
|
-
const state = {
|
|
78
|
-
schemaVersion: GOAL_STATE_VERSION,
|
|
79
|
-
revision: 1,
|
|
80
|
-
goal: {
|
|
81
|
-
id: goalId,
|
|
82
|
-
title: normalizedGoal,
|
|
83
|
-
planId: normalizedPlanId,
|
|
84
|
-
status: "active",
|
|
85
|
-
completionCriteria: normalizeCriteria(completionCriteria),
|
|
86
|
-
requiredEvidence: normalizeCriteria(requiredEvidence),
|
|
87
|
-
evidencePolicy: validEvidencePolicy(evidencePolicy),
|
|
88
|
-
worktreeId: currentWorktreeId,
|
|
89
|
-
ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
|
|
90
|
-
blocker: null,
|
|
91
|
-
createdAt: now,
|
|
92
|
-
updatedAt: now,
|
|
93
|
-
},
|
|
94
|
-
tasks: normalizedTasks,
|
|
95
|
-
receipts: [],
|
|
96
|
-
};
|
|
97
|
-
await writeGoalStateUnlocked({ root, state });
|
|
98
|
-
return state;
|
|
99
|
-
});
|
|
100
|
-
}
|
|
101
|
-
|
|
102
|
-
export async function reconcileWorktreeState({ root = process.cwd() } = {}) {
|
|
103
|
-
const initial = await readGoalState({ root });
|
|
104
|
-
if (!initial || initial.goal.worktreeId === worktreeId(root)) {
|
|
105
|
-
return { state: initial, removed: false };
|
|
106
|
-
}
|
|
107
|
-
return withGoalStateLock(root, async () => {
|
|
108
|
-
const state = await readGoalState({ root });
|
|
109
|
-
if (!state || state.goal.worktreeId === worktreeId(root)) {
|
|
110
|
-
return { state, removed: false };
|
|
111
|
-
}
|
|
112
|
-
const staleWorktreeId = state.goal.worktreeId;
|
|
113
|
-
const path = goalStatePath(root);
|
|
114
|
-
await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
|
|
115
|
-
await rm(path);
|
|
116
|
-
return { state: null, removed: true, staleWorktreeId };
|
|
117
|
-
});
|
|
118
|
-
}
|
|
119
|
-
|
|
120
|
-
export async function writeGoalState({ root = process.cwd(), state } = {}) {
|
|
121
|
-
return withGoalStateLock(root, () => writeGoalStateUnlocked({ root, state }));
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
async function writeGoalStateUnlocked({ root = process.cwd(), state } = {}) {
|
|
125
|
-
const validated = validateState(state);
|
|
126
|
-
const path = goalStatePath(root);
|
|
127
|
-
await ensureGoalStateDirectory(root);
|
|
128
|
-
await assertNoSymlinkComponents(cairnHome(), path);
|
|
129
|
-
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
130
|
-
try {
|
|
131
|
-
await safeWriteFile(cairnHome(), temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
|
|
132
|
-
await rename(temporaryPath, path);
|
|
133
|
-
} catch (error) {
|
|
134
|
-
await rm(temporaryPath, { force: true }).catch(() => {});
|
|
135
|
-
throw error;
|
|
136
|
-
}
|
|
137
|
-
}
|
|
138
|
-
|
|
139
|
-
export async function transitionGoal({ root = process.cwd(), status, blocker } = {}) {
|
|
140
|
-
const nextStatus = validGoalStatus(status);
|
|
141
|
-
if (terminalGoalStatuses.has(nextStatus)) {
|
|
142
|
-
return withGoalStateLock(root, async () => {
|
|
143
|
-
const state = await readGoalState({ root });
|
|
144
|
-
if (!state) throw new Error("No Cairn goal state exists; start a goal first");
|
|
145
|
-
const next = applyGoalTransition(structuredClone(state), nextStatus, blocker, root);
|
|
146
|
-
next.revision += 1;
|
|
147
|
-
validateState(next);
|
|
148
|
-
const path = goalStatePath(root);
|
|
149
|
-
await assertNoSymlinkComponents(cairnHome(), path, { allowMissing: false });
|
|
150
|
-
await rm(path);
|
|
151
|
-
return next;
|
|
152
|
-
});
|
|
153
|
-
}
|
|
154
|
-
return mutateGoal(root, (state) => {
|
|
155
|
-
return applyGoalTransition(state, nextStatus, blocker, root);
|
|
156
|
-
});
|
|
157
|
-
}
|
|
158
|
-
|
|
159
|
-
function applyGoalTransition(state, nextStatus, blocker, root) {
|
|
160
|
-
const currentStatus = state.goal.status;
|
|
161
|
-
if (currentStatus === nextStatus) return state;
|
|
162
|
-
if (!goalTransitions[currentStatus].has(nextStatus)) {
|
|
163
|
-
throw new Error(`Cannot transition goal from ${currentStatus} to ${nextStatus}`);
|
|
164
|
-
}
|
|
165
|
-
if (nextStatus === "completed") ensureGoalCanComplete(state, root);
|
|
166
|
-
if (nextStatus === "blocked") state.goal.blocker = requiredText(blocker, "blocker");
|
|
167
|
-
if (nextStatus === "active") state.goal.blocker = null;
|
|
168
|
-
state.goal.status = nextStatus;
|
|
169
|
-
state.goal.updatedAt = timestamp();
|
|
170
|
-
return state;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
export async function setTaskStatus({ root = process.cwd(), taskId, status, blocker } = {}) {
|
|
174
|
-
const nextStatus = validTaskStatus(status);
|
|
175
|
-
const normalizedTaskId = requiredText(taskId, "taskId");
|
|
176
|
-
return mutateGoal(root, (state) => {
|
|
177
|
-
assertGoalMutable(state);
|
|
178
|
-
const task = taskById(state, normalizedTaskId);
|
|
179
|
-
if (task.status === nextStatus) return state;
|
|
180
|
-
if (!taskTransitions[task.status].has(nextStatus)) {
|
|
181
|
-
throw new Error(`Cannot transition task ${task.id} from ${task.status} to ${nextStatus}`);
|
|
182
|
-
}
|
|
183
|
-
if (nextStatus === "active") {
|
|
184
|
-
const anotherActive = state.tasks.find((item) => item.id !== task.id && item.status === "active");
|
|
185
|
-
if (anotherActive) throw new Error(`Task ${anotherActive.id} is already active`);
|
|
186
|
-
}
|
|
187
|
-
if (nextStatus === "completed") ensureTaskCanComplete(state, task, root);
|
|
188
|
-
if (nextStatus === "blocked") task.blocker = requiredText(blocker, "blocker");
|
|
189
|
-
if (nextStatus === "active" || nextStatus === "pending") task.blocker = null;
|
|
190
|
-
task.status = nextStatus;
|
|
191
|
-
task.updatedAt = timestamp();
|
|
192
|
-
if (nextStatus === "completed") activateNextPendingTask(state);
|
|
193
|
-
if (nextStatus === "blocked" && !activateNextPendingTask(state)) {
|
|
194
|
-
state.goal.status = "blocked";
|
|
195
|
-
state.goal.blocker = task.blocker;
|
|
196
|
-
}
|
|
197
|
-
state.goal.updatedAt = timestamp();
|
|
198
|
-
return state;
|
|
199
|
-
});
|
|
200
|
-
}
|
|
201
|
-
|
|
202
|
-
export async function replanGoal({ root = process.cwd(), tasks } = {}) {
|
|
203
|
-
if (!Array.isArray(tasks) || tasks.length === 0) {
|
|
204
|
-
throw new Error("replan tasks must contain at least one incomplete task");
|
|
205
|
-
}
|
|
206
|
-
|
|
207
|
-
return mutateGoal(root, (state) => {
|
|
208
|
-
assertGoalMutable(state);
|
|
209
|
-
if (state.goal.status !== "active") throw new Error("Only an active goal can be replanned");
|
|
210
|
-
|
|
211
|
-
const completedTasks = state.tasks.filter((task) => task.status === "completed");
|
|
212
|
-
const completedIds = new Set(completedTasks.map((task) => task.id));
|
|
213
|
-
const replacementTasks = tasks.map((task, index) => {
|
|
214
|
-
const source = typeof task === "string" ? task : { ...task, status: undefined, blocker: undefined };
|
|
215
|
-
const normalized = normalizeTask(source, index, index === 0 ? "active" : "pending");
|
|
216
|
-
if (completedIds.has(normalized.id)) {
|
|
217
|
-
throw new Error(`Replanned task conflicts with completed task id: ${normalized.id}`);
|
|
218
|
-
}
|
|
219
|
-
return normalized;
|
|
220
|
-
});
|
|
221
|
-
|
|
222
|
-
state.tasks = [...completedTasks, ...replacementTasks];
|
|
223
|
-
state.receipts = state.receipts.filter((receipt) => receipt.scope === "task" && completedIds.has(receipt.taskId));
|
|
224
|
-
state.goal.blocker = null;
|
|
225
|
-
state.goal.updatedAt = timestamp();
|
|
226
|
-
return state;
|
|
227
|
-
});
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
export async function assignTask({ root = process.cwd(), taskId, agentId = null } = {}) {
|
|
231
|
-
const normalizedTaskId = requiredText(taskId, "taskId");
|
|
232
|
-
const normalizedAgentId = agentId === null ? null : requiredText(agentId, "agentId");
|
|
233
|
-
return mutateGoal(root, (state) => {
|
|
234
|
-
assertGoalMutable(state);
|
|
235
|
-
const task = taskById(state, normalizedTaskId);
|
|
236
|
-
task.assignedAgentId = normalizedAgentId;
|
|
237
|
-
task.updatedAt = timestamp();
|
|
238
|
-
state.goal.updatedAt = timestamp();
|
|
239
|
-
return state;
|
|
240
|
-
});
|
|
241
|
-
}
|
|
242
|
-
|
|
243
|
-
export async function recordReceipt(options = {}) {
|
|
244
|
-
if (options.source !== undefined && options.source !== "declared") {
|
|
245
|
-
throw new Error("Only goal verify can create tool evidence");
|
|
246
|
-
}
|
|
247
|
-
return recordEvidence({ ...options, source: "declared" });
|
|
248
|
-
}
|
|
249
|
-
|
|
250
|
-
async function recordEvidence({
|
|
251
|
-
root = process.cwd(),
|
|
252
|
-
taskId,
|
|
253
|
-
kind,
|
|
254
|
-
scope = "task",
|
|
255
|
-
command,
|
|
256
|
-
exitCode,
|
|
257
|
-
timestamp: receiptTimestamp,
|
|
258
|
-
goalId,
|
|
259
|
-
planId,
|
|
260
|
-
source,
|
|
261
|
-
argv,
|
|
262
|
-
outputDigest,
|
|
263
|
-
summary,
|
|
264
|
-
workspaceFingerprint: fingerprint,
|
|
265
|
-
watchPaths = [],
|
|
266
|
-
expectedIdentity,
|
|
267
|
-
expectedFingerprint,
|
|
268
|
-
} = {}) {
|
|
269
|
-
const normalizedScope = validReceiptScope(scope);
|
|
270
|
-
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
271
|
-
const normalizedKind = requiredText(kind, "evidence kind");
|
|
272
|
-
const normalizedCommand = validReceiptCommand(command);
|
|
273
|
-
const normalizedSource = validReceiptSource(source);
|
|
274
|
-
if (exitCode !== 0) throw new Error("evidence exitCode must be 0");
|
|
275
|
-
const normalizedTimestamp = validTimestamp(receiptTimestamp ?? timestamp());
|
|
276
|
-
const toolFields = normalizedSource === "tool"
|
|
277
|
-
? normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths })
|
|
278
|
-
: {};
|
|
279
|
-
return mutateGoal(root, (state) => {
|
|
280
|
-
assertGoalMutable(state);
|
|
281
|
-
const task = normalizedScope === "task" ? taskById(state, normalizedTaskId) : null;
|
|
282
|
-
if (expectedIdentity && (
|
|
283
|
-
state.goal.id !== expectedIdentity.goalId
|
|
284
|
-
|| state.goal.planId !== expectedIdentity.planId
|
|
285
|
-
)) throw new Error("Goal identity changed during verification; evidence was not recorded");
|
|
286
|
-
if (expectedIdentity && normalizedScope === "task" && (
|
|
287
|
-
task?.id !== expectedIdentity.taskId
|
|
288
|
-
|| task?.status !== expectedIdentity.taskStatus
|
|
289
|
-
|| task?.assignedAgentId !== expectedIdentity.taskAgentId
|
|
290
|
-
)) throw new Error("Verification task changed during verification; evidence was not recorded");
|
|
291
|
-
if (expectedFingerprint && workspaceFingerprint(root, watchPaths) !== expectedFingerprint) {
|
|
292
|
-
throw new Error("Watched workspace changed during verification; evidence was not recorded");
|
|
293
|
-
}
|
|
294
|
-
if (goalId !== undefined && goalId !== state.goal.id) throw new Error("evidence goalId does not match the active goal");
|
|
295
|
-
if (planId !== undefined && planId !== state.goal.planId) throw new Error("evidence planId does not match the active goal");
|
|
296
|
-
const receipt = {
|
|
297
|
-
id: `receipt-${randomUUID()}`,
|
|
298
|
-
scope: normalizedScope,
|
|
299
|
-
kind: normalizedKind,
|
|
300
|
-
...(task ? { taskId: task.id } : {}),
|
|
301
|
-
goalId: state.goal.id,
|
|
302
|
-
planId: state.goal.planId,
|
|
303
|
-
command: normalizedCommand,
|
|
304
|
-
exitCode: 0,
|
|
305
|
-
timestamp: normalizedTimestamp,
|
|
306
|
-
source: normalizedSource,
|
|
307
|
-
...toolFields,
|
|
308
|
-
};
|
|
309
|
-
state.receipts.push(receipt);
|
|
310
|
-
if (task) task.updatedAt = timestamp();
|
|
311
|
-
state.goal.updatedAt = timestamp();
|
|
312
|
-
return state;
|
|
313
|
-
});
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
export async function verifyAndRecord({
|
|
317
|
-
root = process.cwd(),
|
|
318
|
-
taskId,
|
|
319
|
-
kind,
|
|
320
|
-
scope = "task",
|
|
321
|
-
argv,
|
|
322
|
-
watchPaths = [],
|
|
323
|
-
runner = spawnSync,
|
|
324
|
-
timeoutMs = 600_000,
|
|
325
|
-
} = {}) {
|
|
326
|
-
const normalizedArgv = validArgv(argv);
|
|
327
|
-
const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
|
|
328
|
-
const normalizedScope = validReceiptScope(scope);
|
|
329
|
-
const normalizedKind = requiredText(kind, "evidence kind");
|
|
330
|
-
const initialState = await readGoalState({ root });
|
|
331
|
-
if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
|
|
332
|
-
assertGoalMutable(initialState);
|
|
333
|
-
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
334
|
-
const initialTask = normalizedScope === "task" ? taskById(initialState, normalizedTaskId) : null;
|
|
335
|
-
if (initialTask && !["active", "completed"].includes(initialTask.status)) {
|
|
336
|
-
throw new Error(`Verification task must be active or completed: ${initialTask.id}`);
|
|
337
|
-
}
|
|
338
|
-
const identity = {
|
|
339
|
-
goalId: initialState.goal.id,
|
|
340
|
-
planId: initialState.goal.planId,
|
|
341
|
-
taskId: normalizedTaskId,
|
|
342
|
-
taskStatus: initialTask?.status,
|
|
343
|
-
taskAgentId: initialTask?.assignedAgentId,
|
|
344
|
-
};
|
|
345
|
-
const initialFingerprint = workspaceFingerprint(root, normalizedWatchPaths);
|
|
346
|
-
const result = runner(normalizedArgv[0], normalizedArgv.slice(1), {
|
|
347
|
-
cwd: resolve(root),
|
|
348
|
-
encoding: "utf8",
|
|
349
|
-
maxBuffer: 16 * 1024 * 1024,
|
|
350
|
-
shell: false,
|
|
351
|
-
timeout: validTimeout(timeoutMs),
|
|
352
|
-
});
|
|
353
|
-
if (result.error?.code === "ETIMEDOUT") throw new Error(`Verification timed out after ${timeoutMs}ms`);
|
|
354
|
-
if (result.error) throw new Error(`Verification could not start: ${result.error.message}`);
|
|
355
|
-
const exitCode = Number.isInteger(result.status) ? result.status : 1;
|
|
356
|
-
const output = combinedOutput(result.stdout, result.stderr);
|
|
357
|
-
if (exitCode !== 0) {
|
|
358
|
-
const detail = boundedText(output, 2000);
|
|
359
|
-
throw new Error(`Verification failed with exit ${exitCode}${detail ? `:\n${detail}` : ""}`);
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
const outputDigest = sha256(output);
|
|
363
|
-
const fingerprint = workspaceFingerprint(root, normalizedWatchPaths);
|
|
364
|
-
if (fingerprint !== initialFingerprint) throw new Error("Watched workspace changed during verification; evidence was not recorded");
|
|
365
|
-
const postState = await readGoalState({ root });
|
|
366
|
-
if (!postState || postState.goal.id !== identity.goalId || postState.goal.planId !== identity.planId) {
|
|
367
|
-
throw new Error("Goal identity changed during verification; evidence was not recorded");
|
|
368
|
-
}
|
|
369
|
-
if (normalizedScope === "task") {
|
|
370
|
-
const postTask = taskById(postState, normalizedTaskId);
|
|
371
|
-
if (postTask.status !== identity.taskStatus || postTask.assignedAgentId !== identity.taskAgentId) {
|
|
372
|
-
throw new Error("Verification task changed during verification; evidence was not recorded");
|
|
373
|
-
}
|
|
374
|
-
}
|
|
375
|
-
const command = normalizedArgv.map((value) => JSON.stringify(value)).join(" ");
|
|
376
|
-
const state = await recordEvidence({
|
|
377
|
-
root,
|
|
378
|
-
taskId: normalizedTaskId,
|
|
379
|
-
kind: normalizedKind,
|
|
380
|
-
scope: normalizedScope,
|
|
381
|
-
command,
|
|
382
|
-
exitCode: 0,
|
|
383
|
-
source: "tool",
|
|
384
|
-
argv: normalizedArgv,
|
|
385
|
-
outputDigest,
|
|
386
|
-
summary: successSummary(output),
|
|
387
|
-
workspaceFingerprint: fingerprint,
|
|
388
|
-
watchPaths: normalizedWatchPaths,
|
|
389
|
-
goalId: identity.goalId,
|
|
390
|
-
planId: identity.planId,
|
|
391
|
-
expectedIdentity: identity,
|
|
392
|
-
expectedFingerprint: fingerprint,
|
|
393
|
-
});
|
|
394
|
-
return { state, evidence: state.receipts.at(-1) };
|
|
395
|
-
}
|
|
396
|
-
|
|
397
|
-
export function workspaceFingerprint(root = process.cwd(), watchPaths = []) {
|
|
398
|
-
const workspaceRoot = resolve(root);
|
|
399
|
-
const normalizedWatchPaths = normalizeWatchPaths(workspaceRoot, watchPaths);
|
|
400
|
-
const gitPaths = normalizedWatchPaths.length === 0 ? listGitWorkspacePaths(workspaceRoot, normalizedWatchPaths) : null;
|
|
401
|
-
const paths = normalizedWatchPaths.length > 0
|
|
402
|
-
? listFilesystemPaths(workspaceRoot, normalizedWatchPaths)
|
|
403
|
-
: (gitPaths ?? listFilesystemPaths(workspaceRoot, normalizedWatchPaths));
|
|
404
|
-
const hash = createHash("sha256");
|
|
405
|
-
for (const path of [...new Set(paths)].sort()) hashWorkspacePath(hash, workspaceRoot, path);
|
|
406
|
-
return `sha256:${hash.digest("hex")}`;
|
|
407
|
-
}
|
|
408
|
-
|
|
409
|
-
export function currentTask(state) {
|
|
410
|
-
if (!state) return null;
|
|
411
|
-
return state.tasks.find((task) => task.status === "active")
|
|
412
|
-
?? state.tasks.find((task) => task.status === "pending")
|
|
413
|
-
?? state.tasks.find((task) => task.status === "blocked")
|
|
414
|
-
?? null;
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
export function isGoalOwnedBySession(state, sessionId) {
|
|
418
|
-
return !state?.goal?.ownerSessionId || state.goal.ownerSessionId === sessionId;
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
export function evaluateStop(state, { subagent = false, agentId } = {}) {
|
|
422
|
-
if (!state || state.goal.status !== "active") return { block: false };
|
|
423
|
-
const task = currentTask(state);
|
|
424
|
-
if (!task) {
|
|
425
|
-
return {
|
|
426
|
-
block: true,
|
|
427
|
-
reason: `Cairn goal "${state.goal.title}" has no incomplete task, but is still active. Verify completion criteria and explicitly mark the goal complete.`,
|
|
428
|
-
};
|
|
429
|
-
}
|
|
430
|
-
if (subagent) {
|
|
431
|
-
if (!agentId || task.assignedAgentId !== agentId) return { block: false };
|
|
432
|
-
return {
|
|
433
|
-
block: true,
|
|
434
|
-
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.`,
|
|
435
|
-
};
|
|
436
|
-
}
|
|
437
|
-
return {
|
|
438
|
-
block: true,
|
|
439
|
-
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.`,
|
|
440
|
-
};
|
|
441
|
-
}
|
|
12
|
+
export {
|
|
13
|
+
GOAL_STATE_CONTRACT,
|
|
14
|
+
GOAL_STATE_VERSION,
|
|
15
|
+
GOAL_STATUSES,
|
|
16
|
+
PLAN_ID_MAX_LENGTH,
|
|
17
|
+
RECOVERY_REFERENCE_MAX_LENGTH,
|
|
18
|
+
TASK_ID_MAX_LENGTH,
|
|
19
|
+
TASK_STATUSES,
|
|
20
|
+
} from "./cairn-goal-state-validation.mjs";
|
|
21
|
+
export { goalStatePath, readGoalState, writeGoalState } from "./cairn-goal-state.mjs";
|
|
22
|
+
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 { currentTask, evaluateStop, isGoalOwnedBySession } from "./cairn-goal-policy.mjs";
|
|
442
25
|
|
|
443
26
|
export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log } = {}) {
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
requiredEvidence: options.requiredEvidence ? splitValues(options.requiredEvidence) : defaultGoalEvidence,
|
|
458
|
-
ownerSessionId: options.session,
|
|
459
|
-
evidencePolicy: options.evidencePolicy ?? options["evidence-policy"] ?? "tool-bound",
|
|
460
|
-
});
|
|
461
|
-
emit(JSON.stringify(state));
|
|
462
|
-
return state;
|
|
463
|
-
}
|
|
464
|
-
if (command === "status") {
|
|
465
|
-
const state = await readGoalState({ root });
|
|
466
|
-
stdout(JSON.stringify(state));
|
|
467
|
-
return state;
|
|
468
|
-
}
|
|
469
|
-
if (command === "task") {
|
|
470
|
-
const taskAction = taskStatusFromAction(positional[0]);
|
|
471
|
-
const state = await setTaskStatus({
|
|
472
|
-
root,
|
|
473
|
-
taskId: options.task ?? (taskAction ? positional[1] : positional[0]),
|
|
474
|
-
status: options.status ?? taskAction ?? positional[1],
|
|
475
|
-
blocker: options.reason,
|
|
476
|
-
});
|
|
477
|
-
emit(JSON.stringify(state));
|
|
478
|
-
return state;
|
|
479
|
-
}
|
|
480
|
-
if (command === "replan") {
|
|
481
|
-
const state = await replanGoal({ root, tasks: parseTasks(options.tasks ?? positional.slice(0)) });
|
|
482
|
-
emit(JSON.stringify(state));
|
|
483
|
-
return state;
|
|
484
|
-
}
|
|
485
|
-
if (command === "assign") {
|
|
486
|
-
const state = await assignTask({ root, taskId: options.task ?? positional[0], agentId: options.agent ?? positional[1] ?? null });
|
|
487
|
-
emit(JSON.stringify(state));
|
|
488
|
-
return state;
|
|
489
|
-
}
|
|
490
|
-
if (command === "receipt") {
|
|
491
|
-
const state = await recordReceipt({
|
|
492
|
-
root,
|
|
493
|
-
taskId: options.task ?? positional[0],
|
|
494
|
-
kind: options.kind,
|
|
495
|
-
scope: options.scope,
|
|
496
|
-
command: options.command,
|
|
497
|
-
exitCode: numberOption(options.exitCode ?? options["exit-code"] ?? options.exit),
|
|
498
|
-
timestamp: options.timestamp,
|
|
499
|
-
goalId: options.goalId ?? options["goal-id"],
|
|
500
|
-
planId: options.plan,
|
|
501
|
-
});
|
|
502
|
-
emit(JSON.stringify(state));
|
|
503
|
-
return state;
|
|
504
|
-
}
|
|
505
|
-
if (command === "verify") {
|
|
506
|
-
const result = await verifyAndRecord({
|
|
507
|
-
root,
|
|
508
|
-
taskId: options.task ?? positional[0],
|
|
509
|
-
kind: options.kind,
|
|
510
|
-
scope: options.scope,
|
|
511
|
-
watchPaths: splitValues(options.watch),
|
|
512
|
-
argv: passthrough,
|
|
513
|
-
timeoutMs: options["timeout-ms"] === undefined ? 600_000 : positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
|
|
514
|
-
});
|
|
515
|
-
emit(JSON.stringify({
|
|
516
|
-
id: result.evidence.id,
|
|
517
|
-
scope: result.evidence.scope,
|
|
518
|
-
kind: result.evidence.kind,
|
|
519
|
-
exitCode: result.evidence.exitCode,
|
|
520
|
-
outputDigest: result.evidence.outputDigest,
|
|
521
|
-
workspaceFingerprint: result.evidence.workspaceFingerprint,
|
|
522
|
-
}));
|
|
523
|
-
return result.state;
|
|
524
|
-
}
|
|
525
|
-
if (["pause", "resume", "block", "cancel", "complete"].includes(command)) {
|
|
526
|
-
const state = await transitionGoal({
|
|
527
|
-
root,
|
|
528
|
-
status: { pause: "paused", resume: "active", block: "blocked", cancel: "cancelled", complete: "completed" }[command],
|
|
529
|
-
blocker: options.reason,
|
|
530
|
-
});
|
|
531
|
-
emit(JSON.stringify(state));
|
|
532
|
-
return state;
|
|
533
|
-
}
|
|
534
|
-
throw new Error("Usage: cairn-goal start|status|task|replan|assign|receipt|verify|pause|resume|block|cancel|complete [--root PATH]");
|
|
535
|
-
}
|
|
536
|
-
|
|
537
|
-
async function mutateGoal(root, mutate) {
|
|
538
|
-
return withGoalStateLock(root, async () => {
|
|
539
|
-
const state = await readGoalState({ root });
|
|
540
|
-
if (!state) throw new Error("No Cairn goal state exists; start a goal first");
|
|
541
|
-
const next = await mutate(structuredClone(state));
|
|
542
|
-
next.revision += 1;
|
|
543
|
-
await writeGoalStateUnlocked({ root, state: next });
|
|
544
|
-
return next;
|
|
545
|
-
});
|
|
546
|
-
}
|
|
547
|
-
|
|
548
|
-
async function withGoalStateLock(root, operation) {
|
|
549
|
-
await ensureGoalStateDirectory(root);
|
|
550
|
-
return withStateLock(goalStateDirectory(root), operation);
|
|
551
|
-
}
|
|
552
|
-
|
|
553
|
-
async function ensureGoalStateDirectory(root) {
|
|
554
|
-
const home = cairnHome();
|
|
555
|
-
let existingAncestor = home;
|
|
556
|
-
while (true) {
|
|
557
|
-
try {
|
|
558
|
-
const stat = await lstat(existingAncestor);
|
|
559
|
-
if (!stat.isDirectory()) throw new Error(`Cairn home ancestor is not a directory: ${existingAncestor}`);
|
|
560
|
-
break;
|
|
561
|
-
} catch (error) {
|
|
562
|
-
if (error?.code !== "ENOENT") throw error;
|
|
563
|
-
const parent = dirname(existingAncestor);
|
|
564
|
-
if (parent === existingAncestor) throw error;
|
|
565
|
-
existingAncestor = parent;
|
|
566
|
-
}
|
|
567
|
-
}
|
|
568
|
-
await safeMkdir(existingAncestor, relative(existingAncestor, goalStateDirectory(root)));
|
|
569
|
-
}
|
|
570
|
-
|
|
571
|
-
async function migrateLegacyGoalState(root) {
|
|
572
|
-
const legacyPath = join(resolve(root), ".cairn", "state.json");
|
|
573
|
-
try {
|
|
574
|
-
await assertNoSymlinkComponents(root, legacyPath, { allowMissing: false });
|
|
575
|
-
const state = validateState(migrateState(JSON.parse(await readFile(legacyPath, "utf8"))));
|
|
576
|
-
await writeGoalStateUnlocked({ root, state });
|
|
577
|
-
await rm(legacyPath);
|
|
578
|
-
return state;
|
|
579
|
-
} catch (error) {
|
|
580
|
-
if (error?.code === "ENOENT") return null;
|
|
581
|
-
if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
|
|
582
|
-
throw error;
|
|
583
|
-
}
|
|
584
|
-
}
|
|
585
|
-
|
|
586
|
-
function normalizeTask(task, index, defaultStatus) {
|
|
587
|
-
const source = typeof task === "string" ? { title: task } : task;
|
|
588
|
-
if (!source || typeof source !== "object") throw new Error(`tasks[${index}] must be a string or object`);
|
|
589
|
-
const id = source.id === undefined ? `task-${index + 1}` : boundedRequiredText(source.id, `tasks[${index}].id`, TASK_ID_MAX_LENGTH);
|
|
590
|
-
const title = requiredText(source.title, `tasks[${index}].title`);
|
|
591
|
-
return {
|
|
592
|
-
id,
|
|
593
|
-
title,
|
|
594
|
-
status: source.status === undefined ? defaultStatus : validTaskStatus(source.status),
|
|
595
|
-
assignedAgentId: source.assignedAgentId === undefined || source.assignedAgentId === null ? null : requiredText(source.assignedAgentId, `tasks[${index}].assignedAgentId`),
|
|
596
|
-
requiredEvidence: normalizeCriteria(source.requiredEvidence ?? defaultTaskEvidence),
|
|
597
|
-
blocker: null,
|
|
598
|
-
createdAt: timestamp(),
|
|
599
|
-
updatedAt: timestamp(),
|
|
600
|
-
};
|
|
601
|
-
}
|
|
602
|
-
|
|
603
|
-
function migrateState(value) {
|
|
604
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
605
|
-
if (value.schemaVersion !== 1) return value;
|
|
606
|
-
return {
|
|
607
|
-
...value,
|
|
608
|
-
schemaVersion: GOAL_STATE_VERSION,
|
|
609
|
-
goal: {
|
|
610
|
-
...value.goal,
|
|
611
|
-
evidencePolicy: value.goal?.evidencePolicy ?? "tool-bound",
|
|
27
|
+
return runGoalCli(args, {
|
|
28
|
+
stdout,
|
|
29
|
+
defaultGoalEvidence,
|
|
30
|
+
operations: {
|
|
31
|
+
assignTask,
|
|
32
|
+
claimGoal,
|
|
33
|
+
readGoalState,
|
|
34
|
+
recordReceipt,
|
|
35
|
+
replanGoal,
|
|
36
|
+
setTaskStatus,
|
|
37
|
+
startGoal,
|
|
38
|
+
transitionGoal,
|
|
39
|
+
verifyAndRecord,
|
|
612
40
|
},
|
|
613
|
-
receipts: Array.isArray(value.receipts)
|
|
614
|
-
? value.receipts.map((receipt) => ({ ...receipt, source: "declared" }))
|
|
615
|
-
: value.receipts,
|
|
616
|
-
};
|
|
617
|
-
}
|
|
618
|
-
|
|
619
|
-
function validateState(value) {
|
|
620
|
-
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Cairn goal state must be an object");
|
|
621
|
-
if (value.schemaVersion !== GOAL_STATE_VERSION) throw new Error(`Unsupported Cairn goal state schema version: ${value.schemaVersion}`);
|
|
622
|
-
if (!Number.isSafeInteger(value.revision) || value.revision < 1) throw new Error("Cairn goal state revision must be a positive integer");
|
|
623
|
-
if (!value.goal || typeof value.goal !== "object") throw new Error("Cairn goal state is missing goal");
|
|
624
|
-
const goal = value.goal;
|
|
625
|
-
requiredText(goal.id, "goal.id");
|
|
626
|
-
requiredText(goal.title, "goal.title");
|
|
627
|
-
const planId = boundedRequiredText(goal.planId, "goal.planId", PLAN_ID_MAX_LENGTH);
|
|
628
|
-
validGoalStatus(goal.status);
|
|
629
|
-
validEvidencePolicy(goal.evidencePolicy);
|
|
630
|
-
validTimestamp(goal.createdAt);
|
|
631
|
-
validTimestamp(goal.updatedAt);
|
|
632
|
-
if (!Array.isArray(goal.completionCriteria)) throw new Error("goal.completionCriteria must be an array");
|
|
633
|
-
if (!Array.isArray(goal.requiredEvidence) || goal.requiredEvidence.length === 0) throw new Error("goal.requiredEvidence must be a non-empty array");
|
|
634
|
-
goal.requiredEvidence.forEach((kind, index) => requiredText(kind, `goal.requiredEvidence[${index}]`));
|
|
635
|
-
if (goal.ownerSessionId !== null && goal.ownerSessionId !== undefined) requiredText(goal.ownerSessionId, "goal.ownerSessionId");
|
|
636
|
-
if (goal.worktreeId !== null && goal.worktreeId !== undefined) requiredText(goal.worktreeId, "goal.worktreeId");
|
|
637
|
-
if (goal.blocker !== null && goal.blocker !== undefined) requiredText(goal.blocker, "goal.blocker");
|
|
638
|
-
if (!Array.isArray(value.tasks) || value.tasks.length === 0) throw new Error("Cairn goal state must contain tasks");
|
|
639
|
-
if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state evidence records (receipts) must be an array");
|
|
640
|
-
const ids = new Set();
|
|
641
|
-
let activeTasks = 0;
|
|
642
|
-
for (const task of value.tasks) {
|
|
643
|
-
if (!task || typeof task !== "object") throw new Error("Each task must be an object");
|
|
644
|
-
const taskId = boundedRequiredText(task.id, "task.id", TASK_ID_MAX_LENGTH);
|
|
645
|
-
assertRecoveryReferenceBudget(planId, taskId);
|
|
646
|
-
if (ids.has(task.id)) throw new Error(`Duplicate task id: ${task.id}`);
|
|
647
|
-
ids.add(task.id);
|
|
648
|
-
requiredText(task.title, "task.title");
|
|
649
|
-
validTaskStatus(task.status);
|
|
650
|
-
if (task.status === "active") activeTasks += 1;
|
|
651
|
-
if (task.assignedAgentId !== null && task.assignedAgentId !== undefined) requiredText(task.assignedAgentId, "task.assignedAgentId");
|
|
652
|
-
if (!Array.isArray(task.requiredEvidence) || task.requiredEvidence.length === 0) throw new Error(`task ${task.id} requiredEvidence must be a non-empty array`);
|
|
653
|
-
task.requiredEvidence.forEach((kind, index) => requiredText(kind, `task ${task.id} requiredEvidence[${index}]`));
|
|
654
|
-
if (task.blocker !== null && task.blocker !== undefined) requiredText(task.blocker, `task ${task.id} blocker`);
|
|
655
|
-
validTimestamp(task.createdAt);
|
|
656
|
-
validTimestamp(task.updatedAt);
|
|
657
|
-
}
|
|
658
|
-
if (activeTasks > 1) throw new Error("Only one task may be active");
|
|
659
|
-
for (const receipt of value.receipts) validateReceipt(receipt, value);
|
|
660
|
-
return value;
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
function validateReceipt(receipt, state) {
|
|
664
|
-
if (!receipt || typeof receipt !== "object") throw new Error("Each evidence record must be an object");
|
|
665
|
-
requiredText(receipt.id, "evidence.id");
|
|
666
|
-
const scope = validReceiptScope(receipt.scope);
|
|
667
|
-
requiredText(receipt.kind, "evidence.kind");
|
|
668
|
-
if (scope === "task") {
|
|
669
|
-
requiredText(receipt.taskId, "evidence.taskId");
|
|
670
|
-
if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`Evidence record references unknown task: ${receipt.taskId}`);
|
|
671
|
-
} else if (receipt.taskId !== undefined && receipt.taskId !== null) {
|
|
672
|
-
throw new Error("goal-scope evidence record must not contain taskId");
|
|
673
|
-
}
|
|
674
|
-
if (receipt.goalId !== state.goal.id) throw new Error("evidence goalId does not match goal");
|
|
675
|
-
if (receipt.planId !== state.goal.planId) throw new Error("evidence planId does not match goal");
|
|
676
|
-
validReceiptCommand(receipt.command);
|
|
677
|
-
if (receipt.exitCode !== 0) throw new Error("evidence exitCode must be 0");
|
|
678
|
-
validTimestamp(receipt.timestamp);
|
|
679
|
-
const source = validReceiptSource(receipt.source);
|
|
680
|
-
if (source === "tool") {
|
|
681
|
-
normalizeToolEvidence({
|
|
682
|
-
argv: receipt.argv,
|
|
683
|
-
outputDigest: receipt.outputDigest,
|
|
684
|
-
summary: receipt.summary,
|
|
685
|
-
fingerprint: receipt.workspaceFingerprint,
|
|
686
|
-
watchPaths: receipt.watchPaths,
|
|
687
|
-
});
|
|
688
|
-
}
|
|
689
|
-
}
|
|
690
|
-
|
|
691
|
-
function ensureTaskCanComplete(state, task, root) {
|
|
692
|
-
for (const kind of task.requiredEvidence) {
|
|
693
|
-
ensureCurrentEvidence({ state, root, scope: "task", task, kind });
|
|
694
|
-
}
|
|
695
|
-
}
|
|
696
|
-
|
|
697
|
-
function ensureGoalCanComplete(state, root) {
|
|
698
|
-
const incomplete = state.tasks.filter((task) => task.status !== "completed");
|
|
699
|
-
if (incomplete.length > 0) throw new Error(`Goal cannot be completed while tasks remain: ${incomplete.map((task) => task.id).join(", ")}`);
|
|
700
|
-
for (const task of state.tasks) ensureTaskCanComplete(state, task, root);
|
|
701
|
-
for (const kind of state.goal.requiredEvidence) {
|
|
702
|
-
ensureCurrentEvidence({ state, root, scope: "goal", kind });
|
|
703
|
-
}
|
|
704
|
-
}
|
|
705
|
-
|
|
706
|
-
function ensureCurrentEvidence({ state, root, scope, task, kind }) {
|
|
707
|
-
const candidates = state.receipts.filter((receipt) => (
|
|
708
|
-
receipt.scope === scope
|
|
709
|
-
&& receipt.kind === kind
|
|
710
|
-
&& (scope === "goal" || receipt.taskId === task.id)
|
|
711
|
-
));
|
|
712
|
-
const subject = scope === "goal" ? "Goal" : `Task ${task.id}`;
|
|
713
|
-
if (state.goal.evidencePolicy === "declared") {
|
|
714
|
-
if (candidates.length === 0) {
|
|
715
|
-
throw new Error(`${subject} cannot be completed without successful, bound evidence record: ${kind}`);
|
|
716
|
-
}
|
|
717
|
-
return;
|
|
718
|
-
}
|
|
719
|
-
|
|
720
|
-
const toolEvidence = candidates.filter((receipt) => receipt.source === "tool");
|
|
721
|
-
if (toolEvidence.length === 0) {
|
|
722
|
-
throw new Error(`${subject} cannot be completed without tool-bound evidence record: ${kind}`);
|
|
723
|
-
}
|
|
724
|
-
for (const receipt of [...toolEvidence].reverse()) {
|
|
725
|
-
if (workspaceFingerprint(root, receipt.watchPaths) === receipt.workspaceFingerprint) return;
|
|
726
|
-
}
|
|
727
|
-
throw new Error(`${subject} has stale evidence record: ${kind}`);
|
|
728
|
-
}
|
|
729
|
-
|
|
730
|
-
function activateNextPendingTask(state) {
|
|
731
|
-
if (state.tasks.some((task) => task.status === "active")) return true;
|
|
732
|
-
const next = state.tasks.find((task) => task.status === "pending");
|
|
733
|
-
if (next) {
|
|
734
|
-
next.status = "active";
|
|
735
|
-
next.updatedAt = timestamp();
|
|
736
|
-
return true;
|
|
737
|
-
}
|
|
738
|
-
return false;
|
|
739
|
-
}
|
|
740
|
-
|
|
741
|
-
function assertGoalMutable(state) {
|
|
742
|
-
if (terminalGoalStatuses.has(state.goal.status)) throw new Error(`Cannot change a ${state.goal.status} goal`);
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
function taskById(state, taskId) {
|
|
746
|
-
const task = state.tasks.find((item) => item.id === taskId);
|
|
747
|
-
if (!task) throw new Error(`Unknown task: ${taskId}`);
|
|
748
|
-
return task;
|
|
749
|
-
}
|
|
750
|
-
|
|
751
|
-
function requiredText(value, label) {
|
|
752
|
-
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${label} must be a non-empty string`);
|
|
753
|
-
return value.trim();
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
function boundedRequiredText(value, label, maxLength) {
|
|
757
|
-
const text = requiredText(value, label);
|
|
758
|
-
if (value !== text) throw new Error(`${label} must not have leading or trailing whitespace`);
|
|
759
|
-
if (text.length > maxLength) throw new Error(`${label} must be at most ${maxLength} characters`);
|
|
760
|
-
return text;
|
|
761
|
-
}
|
|
762
|
-
|
|
763
|
-
function assertRecoveryReferenceBudget(planId, taskId) {
|
|
764
|
-
if (planId.length + taskId.length > RECOVERY_REFERENCE_MAX_LENGTH) {
|
|
765
|
-
throw new Error(`planId and taskId must total at most ${RECOVERY_REFERENCE_MAX_LENGTH} characters for exact recovery context`);
|
|
766
|
-
}
|
|
767
|
-
}
|
|
768
|
-
|
|
769
|
-
function validGoalStatus(value) {
|
|
770
|
-
if (!GOAL_STATUSES.has(value)) throw new Error(`Invalid goal status: ${value}`);
|
|
771
|
-
return value;
|
|
772
|
-
}
|
|
773
|
-
|
|
774
|
-
function validEvidencePolicy(value) {
|
|
775
|
-
if (!evidencePolicies.has(value)) throw new Error(`Invalid evidence policy: ${value}`);
|
|
776
|
-
return value;
|
|
777
|
-
}
|
|
778
|
-
|
|
779
|
-
function validTaskStatus(value) {
|
|
780
|
-
if (!TASK_STATUSES.has(value)) throw new Error(`Invalid task status: ${value}`);
|
|
781
|
-
return value;
|
|
782
|
-
}
|
|
783
|
-
|
|
784
|
-
function taskStatusFromAction(value) {
|
|
785
|
-
return {
|
|
786
|
-
start: "active",
|
|
787
|
-
activate: "active",
|
|
788
|
-
active: "active",
|
|
789
|
-
complete: "completed",
|
|
790
|
-
completed: "completed",
|
|
791
|
-
block: "blocked",
|
|
792
|
-
blocked: "blocked",
|
|
793
|
-
pending: "pending",
|
|
794
|
-
}[value] ?? null;
|
|
795
|
-
}
|
|
796
|
-
|
|
797
|
-
function validReceiptScope(value) {
|
|
798
|
-
if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value}`);
|
|
799
|
-
return value;
|
|
800
|
-
}
|
|
801
|
-
|
|
802
|
-
function validReceiptSource(value) {
|
|
803
|
-
if (value !== "declared" && value !== "tool") throw new Error(`Invalid evidence source: ${value}`);
|
|
804
|
-
return value;
|
|
805
|
-
}
|
|
806
|
-
|
|
807
|
-
function validReceiptCommand(value) {
|
|
808
|
-
const command = requiredText(value, "evidence command");
|
|
809
|
-
if (/\b(skip(?:ped)?|todo|tbd|n\/?a|not[ -]?run|not[ -]?applicable|placeholder)\b/i.test(command)) {
|
|
810
|
-
throw new Error("evidence command cannot be skipped, placeholder, or incomplete");
|
|
811
|
-
}
|
|
812
|
-
return command;
|
|
813
|
-
}
|
|
814
|
-
|
|
815
|
-
function validTimestamp(value) {
|
|
816
|
-
if (typeof value !== "string" || Number.isNaN(Date.parse(value))) throw new Error("timestamp must be an ISO-8601 date string");
|
|
817
|
-
return value;
|
|
818
|
-
}
|
|
819
|
-
|
|
820
|
-
function normalizeCriteria(criteria) {
|
|
821
|
-
if (!Array.isArray(criteria)) throw new Error("completionCriteria must be an array");
|
|
822
|
-
return criteria.map((item, index) => requiredText(item, `completionCriteria[${index}]`));
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
function normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths }) {
|
|
826
|
-
const normalizedArgv = validArgv(argv);
|
|
827
|
-
const normalizedOutputDigest = validSha256(outputDigest, "evidence outputDigest");
|
|
828
|
-
const normalizedSummary = requiredText(summary, "evidence summary");
|
|
829
|
-
const normalizedFingerprint = validSha256(fingerprint, "evidence workspaceFingerprint");
|
|
830
|
-
if (!Array.isArray(watchPaths)) throw new Error("evidence watchPaths must be an array");
|
|
831
|
-
const normalizedWatchPaths = watchPaths.map((item, index) => requiredText(item, `evidence watchPaths[${index}]`));
|
|
832
|
-
return {
|
|
833
|
-
argv: normalizedArgv,
|
|
834
|
-
outputDigest: normalizedOutputDigest,
|
|
835
|
-
summary: normalizedSummary,
|
|
836
|
-
workspaceFingerprint: normalizedFingerprint,
|
|
837
|
-
watchPaths: normalizedWatchPaths,
|
|
838
|
-
};
|
|
839
|
-
}
|
|
840
|
-
|
|
841
|
-
function validArgv(argv) {
|
|
842
|
-
if (!Array.isArray(argv) || argv.length === 0) throw new Error("verification command must follow -- as argv");
|
|
843
|
-
return argv.map((item, index) => {
|
|
844
|
-
if (typeof item !== "string") throw new Error(`verification argv[${index}] must be a string`);
|
|
845
|
-
if (index === 0 && item.trim().length === 0) throw new Error("verification executable must be a non-empty string");
|
|
846
|
-
return item;
|
|
847
41
|
});
|
|
848
42
|
}
|
|
849
43
|
|
|
850
|
-
function validSha256(value, label) {
|
|
851
|
-
if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) {
|
|
852
|
-
throw new Error(`${label} must be a sha256 digest`);
|
|
853
|
-
}
|
|
854
|
-
return value;
|
|
855
|
-
}
|
|
856
|
-
|
|
857
|
-
function normalizeWatchPaths(root, watchPaths) {
|
|
858
|
-
if (!Array.isArray(watchPaths)) throw new Error("watchPaths must be an array");
|
|
859
|
-
const workspaceRoot = resolve(root);
|
|
860
|
-
return [...new Set(watchPaths.map((item, index) => {
|
|
861
|
-
const source = requiredText(item, `watchPaths[${index}]`);
|
|
862
|
-
const absolutePath = resolve(workspaceRoot, source);
|
|
863
|
-
const relativePath = relative(workspaceRoot, absolutePath);
|
|
864
|
-
if (relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
|
|
865
|
-
throw new Error(`watch path must remain inside the workspace: ${source}`);
|
|
866
|
-
}
|
|
867
|
-
const normalizedPath = relativePath === "" ? "." : relativePath.split(sep).join("/");
|
|
868
|
-
if (isInternalStatePath(normalizedPath)) throw new Error(`watch path cannot target internal Cairn or Git state: ${source}`);
|
|
869
|
-
return normalizedPath;
|
|
870
|
-
}))];
|
|
871
|
-
}
|
|
872
|
-
|
|
873
|
-
function listGitWorkspacePaths(root, watchPaths) {
|
|
874
|
-
const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"];
|
|
875
|
-
if (watchPaths.length > 0) args.push("--", ...watchPaths);
|
|
876
|
-
const result = spawnSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
877
|
-
if (result.status !== 0 || result.error) return null;
|
|
878
|
-
const paths = result.stdout.split("\0").filter(Boolean).filter((path) => !isInternalStatePath(path));
|
|
879
|
-
for (const watchPath of watchPaths) {
|
|
880
|
-
try {
|
|
881
|
-
if (!lstatSync(join(root, ...watchPath.split("/"))).isDirectory() && !paths.includes(watchPath)) paths.push(watchPath);
|
|
882
|
-
} catch (error) {
|
|
883
|
-
if (error?.code === "ENOENT") paths.push(watchPath);
|
|
884
|
-
else throw error;
|
|
885
|
-
}
|
|
886
|
-
}
|
|
887
|
-
return paths;
|
|
888
|
-
}
|
|
889
|
-
|
|
890
|
-
function listFilesystemPaths(root, watchPaths) {
|
|
891
|
-
const paths = [];
|
|
892
|
-
const visit = (relativePath) => {
|
|
893
|
-
if (isInternalStatePath(relativePath)) return;
|
|
894
|
-
const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
|
|
895
|
-
let stat;
|
|
896
|
-
try {
|
|
897
|
-
stat = lstatSync(absolutePath);
|
|
898
|
-
} catch (error) {
|
|
899
|
-
if (error?.code === "ENOENT") {
|
|
900
|
-
paths.push(relativePath);
|
|
901
|
-
return;
|
|
902
|
-
}
|
|
903
|
-
throw error;
|
|
904
|
-
}
|
|
905
|
-
if (!stat.isDirectory()) {
|
|
906
|
-
paths.push(relativePath);
|
|
907
|
-
return;
|
|
908
|
-
}
|
|
909
|
-
const entries = readdirSync(absolutePath).sort();
|
|
910
|
-
if (entries.length === 0) paths.push(relativePath);
|
|
911
|
-
for (const entry of entries) {
|
|
912
|
-
const child = relativePath === "." ? entry : `${relativePath}/${entry}`;
|
|
913
|
-
visit(child);
|
|
914
|
-
}
|
|
915
|
-
};
|
|
916
|
-
for (const path of watchPaths.length > 0 ? watchPaths : ["."]) visit(path);
|
|
917
|
-
return paths;
|
|
918
|
-
}
|
|
919
|
-
|
|
920
|
-
function hashWorkspacePath(hash, root, relativePath) {
|
|
921
|
-
const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
|
|
922
|
-
hash.update(`${relativePath}\0`);
|
|
923
|
-
let stat;
|
|
924
|
-
try {
|
|
925
|
-
stat = lstatSync(absolutePath);
|
|
926
|
-
} catch (error) {
|
|
927
|
-
if (error?.code === "ENOENT") {
|
|
928
|
-
hash.update("missing\0");
|
|
929
|
-
return;
|
|
930
|
-
}
|
|
931
|
-
throw error;
|
|
932
|
-
}
|
|
933
|
-
hash.update(`${stat.mode}\0`);
|
|
934
|
-
if (stat.isSymbolicLink()) {
|
|
935
|
-
hash.update(`symlink\0${readlinkSync(absolutePath)}\0`);
|
|
936
|
-
} else if (stat.isFile()) {
|
|
937
|
-
hash.update("file\0");
|
|
938
|
-
hash.update(readFileSync(absolutePath));
|
|
939
|
-
hash.update("\0");
|
|
940
|
-
} else if (stat.isDirectory()) {
|
|
941
|
-
hash.update("directory\0");
|
|
942
|
-
} else {
|
|
943
|
-
hash.update("other\0");
|
|
944
|
-
}
|
|
945
|
-
}
|
|
946
|
-
|
|
947
|
-
function isInternalStatePath(path) {
|
|
948
|
-
const firstSegment = path.split("/")[0];
|
|
949
|
-
return firstSegment === ".git" || firstSegment === ".cairn";
|
|
950
|
-
}
|
|
951
|
-
|
|
952
|
-
function combinedOutput(stdout, stderr) {
|
|
953
|
-
return [stdout, stderr].filter((value) => typeof value === "string" && value.length > 0).join("\n").trim();
|
|
954
|
-
}
|
|
955
|
-
|
|
956
|
-
function boundedText(value, maxLength) {
|
|
957
|
-
const text = String(value ?? "").trim();
|
|
958
|
-
if (text.length <= maxLength) return text;
|
|
959
|
-
return `…${text.slice(-(maxLength - 1))}`;
|
|
960
|
-
}
|
|
961
|
-
|
|
962
|
-
function successSummary(output) {
|
|
963
|
-
const text = String(output ?? "");
|
|
964
|
-
const outputBytes = Buffer.byteLength(text);
|
|
965
|
-
const outputLines = text.length === 0 ? 0 : text.split(/\r?\n/).length;
|
|
966
|
-
return `verification passed; outputBytes=${outputBytes}; outputLines=${outputLines}`;
|
|
967
|
-
}
|
|
968
|
-
|
|
969
|
-
function sha256(value) {
|
|
970
|
-
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
971
|
-
}
|
|
972
|
-
|
|
973
|
-
function optionalText(value, label) {
|
|
974
|
-
return value === undefined || value === null ? null : requiredText(value, label);
|
|
975
|
-
}
|
|
976
|
-
|
|
977
|
-
function timestamp() {
|
|
978
|
-
return new Date().toISOString();
|
|
979
|
-
}
|
|
980
|
-
|
|
981
|
-
function parseArgs(args, command) {
|
|
982
|
-
const options = {};
|
|
983
|
-
const positional = [];
|
|
984
|
-
const passthrough = [];
|
|
985
|
-
const booleanOptions = new Set(["quiet"]);
|
|
986
|
-
const allowed = allowedOptions(command);
|
|
987
|
-
const seenCanonicalOptions = new Set();
|
|
988
|
-
for (let index = 0; index < args.length; index += 1) {
|
|
989
|
-
const value = args[index];
|
|
990
|
-
if (value === "--") {
|
|
991
|
-
passthrough.push(...args.slice(index + 1));
|
|
992
|
-
break;
|
|
993
|
-
}
|
|
994
|
-
if (!value.startsWith("--")) {
|
|
995
|
-
positional.push(value);
|
|
996
|
-
continue;
|
|
997
|
-
}
|
|
998
|
-
const option = value.slice(2);
|
|
999
|
-
const equalsIndex = option.indexOf("=");
|
|
1000
|
-
const key = equalsIndex === -1 ? option : option.slice(0, equalsIndex);
|
|
1001
|
-
const inline = equalsIndex === -1 ? undefined : option.slice(equalsIndex + 1);
|
|
1002
|
-
if (!allowed.has(key)) throw new Error(`Unknown option for ${command}: --${key}`);
|
|
1003
|
-
const canonicalKey = canonicalOptionKey(key);
|
|
1004
|
-
if (seenCanonicalOptions.has(canonicalKey)) throw new Error(`--${canonicalKey} may only be provided once`);
|
|
1005
|
-
seenCanonicalOptions.add(canonicalKey);
|
|
1006
|
-
if (inline !== undefined) {
|
|
1007
|
-
if (!booleanOptions.has(key) && inline.length === 0) throw new Error(`--${key} requires a value`);
|
|
1008
|
-
if (booleanOptions.has(key) && !["true", "false"].includes(inline)) throw new Error(`--${key} must be true or false`);
|
|
1009
|
-
options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
|
|
1010
|
-
}
|
|
1011
|
-
else if (booleanOptions.has(key)) options[key] = true;
|
|
1012
|
-
else {
|
|
1013
|
-
const next = args[index + 1];
|
|
1014
|
-
if (next === undefined || next === "--" || next.startsWith("--")) throw new Error(`--${key} requires a value`);
|
|
1015
|
-
options[key] = next;
|
|
1016
|
-
index += 1;
|
|
1017
|
-
}
|
|
1018
|
-
}
|
|
1019
|
-
return { options, positional, passthrough };
|
|
1020
|
-
}
|
|
1021
|
-
|
|
1022
|
-
function canonicalOptionKey(key) {
|
|
1023
|
-
return {
|
|
1024
|
-
exitCode: "exit-code",
|
|
1025
|
-
exit: "exit-code",
|
|
1026
|
-
goalId: "goal-id",
|
|
1027
|
-
evidencePolicy: "evidence-policy",
|
|
1028
|
-
}[key] ?? key;
|
|
1029
|
-
}
|
|
1030
|
-
|
|
1031
|
-
function allowedOptions(command) {
|
|
1032
|
-
const common = ["root", "quiet"];
|
|
1033
|
-
const byCommand = {
|
|
1034
|
-
start: ["goal", "plan", "tasks", "criteria", "requiredEvidence", "session", "evidencePolicy", "evidence-policy"],
|
|
1035
|
-
status: [],
|
|
1036
|
-
task: ["task", "status", "reason"],
|
|
1037
|
-
replan: ["tasks"],
|
|
1038
|
-
assign: ["task", "agent"],
|
|
1039
|
-
receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
|
|
1040
|
-
verify: ["task", "kind", "scope", "watch", "timeout-ms"],
|
|
1041
|
-
pause: [],
|
|
1042
|
-
resume: [],
|
|
1043
|
-
block: ["reason"],
|
|
1044
|
-
cancel: [],
|
|
1045
|
-
complete: [],
|
|
1046
|
-
help: [],
|
|
1047
|
-
};
|
|
1048
|
-
return new Set([...common, ...(byCommand[command] ?? [])]);
|
|
1049
|
-
}
|
|
1050
|
-
|
|
1051
|
-
function parseTasks(value) {
|
|
1052
|
-
if (Array.isArray(value)) return value;
|
|
1053
|
-
if (typeof value !== "string" || value.trim().length === 0) throw new Error("Provide tasks with --tasks JSON or positional task titles");
|
|
1054
|
-
try {
|
|
1055
|
-
const parsed = JSON.parse(value);
|
|
1056
|
-
if (!Array.isArray(parsed)) throw new Error("--tasks JSON must be an array");
|
|
1057
|
-
return parsed;
|
|
1058
|
-
} catch (error) {
|
|
1059
|
-
if (error instanceof SyntaxError) return value.split("|").map((item) => item.trim()).filter(Boolean);
|
|
1060
|
-
throw error;
|
|
1061
|
-
}
|
|
1062
|
-
}
|
|
1063
|
-
|
|
1064
|
-
function splitValues(value) {
|
|
1065
|
-
if (!value) return [];
|
|
1066
|
-
return String(value).split("|").map((item) => item.trim()).filter(Boolean);
|
|
1067
|
-
}
|
|
1068
|
-
|
|
1069
|
-
function numberOption(value) {
|
|
1070
|
-
if (value === undefined) return undefined;
|
|
1071
|
-
const result = Number(value);
|
|
1072
|
-
if (!Number.isInteger(result)) throw new Error("exitCode must be an integer");
|
|
1073
|
-
return result;
|
|
1074
|
-
}
|
|
1075
|
-
|
|
1076
|
-
function positiveIntegerOption(value, label) {
|
|
1077
|
-
const result = Number(value);
|
|
1078
|
-
if (!Number.isSafeInteger(result) || result <= 0 || result > 3_600_000) {
|
|
1079
|
-
throw new Error(`${label} must be an integer between 1 and 3600000`);
|
|
1080
|
-
}
|
|
1081
|
-
return result;
|
|
1082
|
-
}
|
|
1083
|
-
|
|
1084
|
-
function validTimeout(value) {
|
|
1085
|
-
if (!Number.isSafeInteger(value) || value <= 0) throw new Error("timeoutMs must be a positive integer");
|
|
1086
|
-
return value;
|
|
1087
|
-
}
|
|
1088
|
-
|
|
1089
44
|
function isCliEntry() {
|
|
1090
45
|
if (!process.argv[1]) return false;
|
|
1091
46
|
try {
|