cairn-ai 0.2.1 → 0.2.2
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 -11
- package/.agents/workflows/cairn-work.md +10 -8
- package/.claude/commands/cairn-plan.md +1 -1
- package/.claude/commands/cairn-work.md +1 -1
- package/.codex-plugin/plugin.json +12 -18
- package/README.de.md +3 -1
- package/README.es.md +3 -1
- package/README.fr.md +3 -1
- package/README.ja.md +3 -1
- package/README.ko.md +5 -3
- package/README.md +5 -3
- package/README.pt.md +3 -1
- package/README.zh.md +3 -1
- package/commands/cairn-plan.md +13 -16
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-work.md +12 -11
- package/docs/model-guidance/README.md +6 -2
- package/docs/model-guidance/codex.md +5 -0
- package/hooks/hooks.json +1 -1
- package/package.json +1 -1
- package/scripts/cairn-goal.mjs +379 -62
- package/scripts/cairn-state.mjs +58 -21
- package/scripts/cairn-state.sh +2 -0
- package/scripts/cairn.mjs +9 -8
- package/skills/cairn-plan/SKILL.md +23 -17
- package/skills/cairn-review/SKILL.md +1 -1
- package/skills/cairn-work/SKILL.md +37 -16
- package/templates/PLAN.md +1 -0
- package/templates/work-plan.md +21 -2
package/scripts/cairn-goal.mjs
CHANGED
|
@@ -1,15 +1,17 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
2
|
+
import { spawnSync } from "node:child_process";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { lstatSync, readFileSync, readlinkSync, realpathSync, readdirSync } from "node:fs";
|
|
4
5
|
import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
|
|
5
|
-
import { dirname, join, resolve } from "node:path";
|
|
6
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
6
7
|
import { fileURLToPath, pathToFileURL } from "node:url";
|
|
7
8
|
|
|
8
|
-
export const GOAL_STATE_VERSION =
|
|
9
|
+
export const GOAL_STATE_VERSION = 2;
|
|
9
10
|
export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
|
|
10
11
|
export const TASK_STATUSES = new Set(["pending", "active", "blocked", "completed"]);
|
|
11
12
|
|
|
12
13
|
const terminalGoalStatuses = new Set(["cancelled", "completed"]);
|
|
14
|
+
const evidencePolicies = new Set(["declared", "tool-bound"]);
|
|
13
15
|
const defaultTaskEvidence = ["moduleAcceptance", "surfaceIntegration"];
|
|
14
16
|
const defaultGoalEvidence = ["finalReview"];
|
|
15
17
|
const goalTransitions = {
|
|
@@ -33,7 +35,7 @@ export function goalStatePath(root = process.cwd()) {
|
|
|
33
35
|
export async function readGoalState({ root = process.cwd() } = {}) {
|
|
34
36
|
try {
|
|
35
37
|
const text = await readFile(goalStatePath(root), "utf8");
|
|
36
|
-
return validateState(JSON.parse(text));
|
|
38
|
+
return validateState(migrateState(JSON.parse(text)));
|
|
37
39
|
} catch (error) {
|
|
38
40
|
if (error?.code === "ENOENT") return null;
|
|
39
41
|
if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
|
|
@@ -41,7 +43,7 @@ export async function readGoalState({ root = process.cwd() } = {}) {
|
|
|
41
43
|
}
|
|
42
44
|
}
|
|
43
45
|
|
|
44
|
-
export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null } = {}) {
|
|
46
|
+
export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null, evidencePolicy = "tool-bound" } = {}) {
|
|
45
47
|
const normalizedGoal = requiredText(goal, "goal");
|
|
46
48
|
const normalizedPlanId = requiredText(planId, "planId");
|
|
47
49
|
if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
|
|
@@ -64,6 +66,7 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
|
|
|
64
66
|
status: "active",
|
|
65
67
|
completionCriteria: normalizeCriteria(completionCriteria),
|
|
66
68
|
requiredEvidence: normalizeCriteria(requiredEvidence),
|
|
69
|
+
evidencePolicy: validEvidencePolicy(evidencePolicy),
|
|
67
70
|
ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
|
|
68
71
|
blocker: null,
|
|
69
72
|
createdAt: now,
|
|
@@ -98,7 +101,7 @@ export async function transitionGoal({ root = process.cwd(), status, blocker } =
|
|
|
98
101
|
if (!goalTransitions[currentStatus].has(nextStatus)) {
|
|
99
102
|
throw new Error(`Cannot transition goal from ${currentStatus} to ${nextStatus}`);
|
|
100
103
|
}
|
|
101
|
-
if (nextStatus === "completed") ensureGoalCanComplete(state);
|
|
104
|
+
if (nextStatus === "completed") ensureGoalCanComplete(state, root);
|
|
102
105
|
if (nextStatus === "blocked") state.goal.blocker = requiredText(blocker, "blocker");
|
|
103
106
|
if (nextStatus === "active") state.goal.blocker = null;
|
|
104
107
|
state.goal.status = nextStatus;
|
|
@@ -121,7 +124,7 @@ export async function setTaskStatus({ root = process.cwd(), taskId, status, bloc
|
|
|
121
124
|
const anotherActive = state.tasks.find((item) => item.id !== task.id && item.status === "active");
|
|
122
125
|
if (anotherActive) throw new Error(`Task ${anotherActive.id} is already active`);
|
|
123
126
|
}
|
|
124
|
-
if (nextStatus === "completed") ensureTaskCanComplete(state, task);
|
|
127
|
+
if (nextStatus === "completed") ensureTaskCanComplete(state, task, root);
|
|
125
128
|
if (nextStatus === "blocked") task.blocker = requiredText(blocker, "blocker");
|
|
126
129
|
if (nextStatus === "active" || nextStatus === "pending") task.blocker = null;
|
|
127
130
|
task.status = nextStatus;
|
|
@@ -149,18 +152,45 @@ export async function assignTask({ root = process.cwd(), taskId, agentId = null
|
|
|
149
152
|
});
|
|
150
153
|
}
|
|
151
154
|
|
|
152
|
-
export async function recordReceipt(
|
|
155
|
+
export async function recordReceipt(options = {}) {
|
|
156
|
+
if (options.source !== undefined && options.source !== "declared") {
|
|
157
|
+
throw new Error("Only goal verify can create tool evidence");
|
|
158
|
+
}
|
|
159
|
+
return recordEvidence({ ...options, source: "declared" });
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function recordEvidence({
|
|
163
|
+
root = process.cwd(),
|
|
164
|
+
taskId,
|
|
165
|
+
kind,
|
|
166
|
+
scope = "task",
|
|
167
|
+
command,
|
|
168
|
+
exitCode,
|
|
169
|
+
timestamp: receiptTimestamp,
|
|
170
|
+
goalId,
|
|
171
|
+
planId,
|
|
172
|
+
source,
|
|
173
|
+
argv,
|
|
174
|
+
outputDigest,
|
|
175
|
+
summary,
|
|
176
|
+
workspaceFingerprint: fingerprint,
|
|
177
|
+
watchPaths = [],
|
|
178
|
+
} = {}) {
|
|
153
179
|
const normalizedScope = validReceiptScope(scope);
|
|
154
180
|
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
155
|
-
const normalizedKind = requiredText(kind, "
|
|
181
|
+
const normalizedKind = requiredText(kind, "evidence kind");
|
|
156
182
|
const normalizedCommand = validReceiptCommand(command);
|
|
157
|
-
|
|
183
|
+
const normalizedSource = validReceiptSource(source);
|
|
184
|
+
if (exitCode !== 0) throw new Error("evidence exitCode must be 0");
|
|
158
185
|
const normalizedTimestamp = validTimestamp(receiptTimestamp ?? timestamp());
|
|
186
|
+
const toolFields = normalizedSource === "tool"
|
|
187
|
+
? normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths })
|
|
188
|
+
: {};
|
|
159
189
|
return mutateGoal(root, (state) => {
|
|
160
190
|
assertGoalMutable(state);
|
|
161
191
|
const task = normalizedScope === "task" ? taskById(state, normalizedTaskId) : null;
|
|
162
|
-
if (goalId !== undefined && goalId !== state.goal.id) throw new Error("
|
|
163
|
-
if (planId !== undefined && planId !== state.goal.planId) throw new Error("
|
|
192
|
+
if (goalId !== undefined && goalId !== state.goal.id) throw new Error("evidence goalId does not match the active goal");
|
|
193
|
+
if (planId !== undefined && planId !== state.goal.planId) throw new Error("evidence planId does not match the active goal");
|
|
164
194
|
const receipt = {
|
|
165
195
|
id: `receipt-${randomUUID()}`,
|
|
166
196
|
scope: normalizedScope,
|
|
@@ -171,6 +201,8 @@ export async function recordReceipt({ root = process.cwd(), taskId, kind, scope
|
|
|
171
201
|
command: normalizedCommand,
|
|
172
202
|
exitCode: 0,
|
|
173
203
|
timestamp: normalizedTimestamp,
|
|
204
|
+
source: normalizedSource,
|
|
205
|
+
...toolFields,
|
|
174
206
|
};
|
|
175
207
|
state.receipts.push(receipt);
|
|
176
208
|
if (task) task.updatedAt = timestamp();
|
|
@@ -179,6 +211,70 @@ export async function recordReceipt({ root = process.cwd(), taskId, kind, scope
|
|
|
179
211
|
});
|
|
180
212
|
}
|
|
181
213
|
|
|
214
|
+
export async function verifyAndRecord({
|
|
215
|
+
root = process.cwd(),
|
|
216
|
+
taskId,
|
|
217
|
+
kind,
|
|
218
|
+
scope = "task",
|
|
219
|
+
argv,
|
|
220
|
+
watchPaths = [],
|
|
221
|
+
runner = spawnSync,
|
|
222
|
+
} = {}) {
|
|
223
|
+
const normalizedArgv = validArgv(argv);
|
|
224
|
+
const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
|
|
225
|
+
const normalizedScope = validReceiptScope(scope);
|
|
226
|
+
const normalizedKind = requiredText(kind, "evidence kind");
|
|
227
|
+
const initialState = await readGoalState({ root });
|
|
228
|
+
if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
|
|
229
|
+
assertGoalMutable(initialState);
|
|
230
|
+
const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
|
|
231
|
+
if (normalizedScope === "task") taskById(initialState, normalizedTaskId);
|
|
232
|
+
const result = runner(normalizedArgv[0], normalizedArgv.slice(1), {
|
|
233
|
+
cwd: resolve(root),
|
|
234
|
+
encoding: "utf8",
|
|
235
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
236
|
+
shell: false,
|
|
237
|
+
});
|
|
238
|
+
if (result.error) throw new Error(`Verification could not start: ${result.error.message}`);
|
|
239
|
+
const exitCode = Number.isInteger(result.status) ? result.status : 1;
|
|
240
|
+
const output = combinedOutput(result.stdout, result.stderr);
|
|
241
|
+
if (exitCode !== 0) {
|
|
242
|
+
const detail = boundedText(output, 2000);
|
|
243
|
+
throw new Error(`Verification failed with exit ${exitCode}${detail ? `:\n${detail}` : ""}`);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
const outputDigest = sha256(output);
|
|
247
|
+
const fingerprint = workspaceFingerprint(root, normalizedWatchPaths);
|
|
248
|
+
const command = normalizedArgv.map((value) => JSON.stringify(value)).join(" ");
|
|
249
|
+
const state = await recordEvidence({
|
|
250
|
+
root,
|
|
251
|
+
taskId: normalizedTaskId,
|
|
252
|
+
kind: normalizedKind,
|
|
253
|
+
scope: normalizedScope,
|
|
254
|
+
command,
|
|
255
|
+
exitCode: 0,
|
|
256
|
+
source: "tool",
|
|
257
|
+
argv: normalizedArgv,
|
|
258
|
+
outputDigest,
|
|
259
|
+
summary: successSummary(output),
|
|
260
|
+
workspaceFingerprint: fingerprint,
|
|
261
|
+
watchPaths: normalizedWatchPaths,
|
|
262
|
+
});
|
|
263
|
+
return { state, evidence: state.receipts.at(-1) };
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export function workspaceFingerprint(root = process.cwd(), watchPaths = []) {
|
|
267
|
+
const workspaceRoot = resolve(root);
|
|
268
|
+
const normalizedWatchPaths = normalizeWatchPaths(workspaceRoot, watchPaths);
|
|
269
|
+
const gitPaths = normalizedWatchPaths.length === 0 ? listGitWorkspacePaths(workspaceRoot, normalizedWatchPaths) : null;
|
|
270
|
+
const paths = normalizedWatchPaths.length > 0
|
|
271
|
+
? listFilesystemPaths(workspaceRoot, normalizedWatchPaths)
|
|
272
|
+
: (gitPaths ?? listFilesystemPaths(workspaceRoot, normalizedWatchPaths));
|
|
273
|
+
const hash = createHash("sha256");
|
|
274
|
+
for (const path of [...new Set(paths)].sort()) hashWorkspacePath(hash, workspaceRoot, path);
|
|
275
|
+
return `sha256:${hash.digest("hex")}`;
|
|
276
|
+
}
|
|
277
|
+
|
|
182
278
|
export function currentTask(state) {
|
|
183
279
|
if (!state) return null;
|
|
184
280
|
return state.tasks.find((task) => task.status === "active")
|
|
@@ -204,20 +300,21 @@ export function evaluateStop(state, { subagent = false, agentId } = {}) {
|
|
|
204
300
|
if (!agentId || task.assignedAgentId !== agentId) return { block: false };
|
|
205
301
|
return {
|
|
206
302
|
block: true,
|
|
207
|
-
reason: `Cairn task ${task.id} (${task.title}) is assigned to this subagent and is ${task.status}. Continue only this assigned task, record a successful
|
|
303
|
+
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.`,
|
|
208
304
|
};
|
|
209
305
|
}
|
|
210
306
|
return {
|
|
211
307
|
block: true,
|
|
212
|
-
reason: `Cairn goal "${state.goal.title}" is active. Continue current task ${task.id} (${task.title}); its status is ${task.status}. Record a successful
|
|
308
|
+
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.`,
|
|
213
309
|
};
|
|
214
310
|
}
|
|
215
311
|
|
|
216
312
|
export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log } = {}) {
|
|
217
313
|
const [firstCommand = "help", ...remaining] = args;
|
|
218
314
|
const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
|
|
219
|
-
const { options, positional } = parseArgs(rest);
|
|
315
|
+
const { options, positional, passthrough } = parseArgs(rest);
|
|
220
316
|
const root = options.root ?? process.cwd();
|
|
317
|
+
const emit = options.quiet ? () => {} : stdout;
|
|
221
318
|
if (command === "start") {
|
|
222
319
|
const tasks = parseTasks(options.tasks ?? positional.slice(0));
|
|
223
320
|
const state = await startGoal({
|
|
@@ -228,8 +325,9 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
|
|
|
228
325
|
completionCriteria: splitValues(options.criteria),
|
|
229
326
|
requiredEvidence: options.requiredEvidence ? splitValues(options.requiredEvidence) : defaultGoalEvidence,
|
|
230
327
|
ownerSessionId: options.session,
|
|
328
|
+
evidencePolicy: options.evidencePolicy ?? options["evidence-policy"] ?? "tool-bound",
|
|
231
329
|
});
|
|
232
|
-
|
|
330
|
+
emit(JSON.stringify(state));
|
|
233
331
|
return state;
|
|
234
332
|
}
|
|
235
333
|
if (command === "status") {
|
|
@@ -245,12 +343,12 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
|
|
|
245
343
|
status: options.status ?? taskAction ?? positional[1],
|
|
246
344
|
blocker: options.reason,
|
|
247
345
|
});
|
|
248
|
-
|
|
346
|
+
emit(JSON.stringify(state));
|
|
249
347
|
return state;
|
|
250
348
|
}
|
|
251
349
|
if (command === "assign") {
|
|
252
350
|
const state = await assignTask({ root, taskId: options.task ?? positional[0], agentId: options.agent ?? positional[1] ?? null });
|
|
253
|
-
|
|
351
|
+
emit(JSON.stringify(state));
|
|
254
352
|
return state;
|
|
255
353
|
}
|
|
256
354
|
if (command === "receipt") {
|
|
@@ -265,19 +363,38 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
|
|
|
265
363
|
goalId: options.goalId,
|
|
266
364
|
planId: options.plan,
|
|
267
365
|
});
|
|
268
|
-
|
|
366
|
+
emit(JSON.stringify(state));
|
|
269
367
|
return state;
|
|
270
368
|
}
|
|
369
|
+
if (command === "verify") {
|
|
370
|
+
const result = await verifyAndRecord({
|
|
371
|
+
root,
|
|
372
|
+
taskId: options.task ?? positional[0],
|
|
373
|
+
kind: options.kind,
|
|
374
|
+
scope: options.scope,
|
|
375
|
+
watchPaths: splitValues(options.watch),
|
|
376
|
+
argv: passthrough,
|
|
377
|
+
});
|
|
378
|
+
emit(JSON.stringify({
|
|
379
|
+
id: result.evidence.id,
|
|
380
|
+
scope: result.evidence.scope,
|
|
381
|
+
kind: result.evidence.kind,
|
|
382
|
+
exitCode: result.evidence.exitCode,
|
|
383
|
+
outputDigest: result.evidence.outputDigest,
|
|
384
|
+
workspaceFingerprint: result.evidence.workspaceFingerprint,
|
|
385
|
+
}));
|
|
386
|
+
return result.state;
|
|
387
|
+
}
|
|
271
388
|
if (["pause", "resume", "block", "cancel", "complete"].includes(command)) {
|
|
272
389
|
const state = await transitionGoal({
|
|
273
390
|
root,
|
|
274
391
|
status: { pause: "paused", resume: "active", block: "blocked", cancel: "cancelled", complete: "completed" }[command],
|
|
275
392
|
blocker: options.reason,
|
|
276
393
|
});
|
|
277
|
-
|
|
394
|
+
emit(JSON.stringify(state));
|
|
278
395
|
return state;
|
|
279
396
|
}
|
|
280
|
-
throw new Error("Usage: cairn-goal start|status|task|assign|receipt|pause|resume|block|cancel|complete [--root PATH]");
|
|
397
|
+
throw new Error("Usage: cairn-goal start|status|task|assign|receipt|verify|pause|resume|block|cancel|complete [--root PATH]");
|
|
281
398
|
}
|
|
282
399
|
|
|
283
400
|
async function mutateGoal(root, mutate) {
|
|
@@ -306,6 +423,22 @@ function normalizeTask(task, index, defaultStatus) {
|
|
|
306
423
|
};
|
|
307
424
|
}
|
|
308
425
|
|
|
426
|
+
function migrateState(value) {
|
|
427
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return value;
|
|
428
|
+
if (value.schemaVersion !== 1) return value;
|
|
429
|
+
return {
|
|
430
|
+
...value,
|
|
431
|
+
schemaVersion: GOAL_STATE_VERSION,
|
|
432
|
+
goal: {
|
|
433
|
+
...value.goal,
|
|
434
|
+
evidencePolicy: value.goal?.evidencePolicy ?? "tool-bound",
|
|
435
|
+
},
|
|
436
|
+
receipts: Array.isArray(value.receipts)
|
|
437
|
+
? value.receipts.map((receipt) => ({ ...receipt, source: "declared" }))
|
|
438
|
+
: value.receipts,
|
|
439
|
+
};
|
|
440
|
+
}
|
|
441
|
+
|
|
309
442
|
function validateState(value) {
|
|
310
443
|
if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Cairn goal state must be an object");
|
|
311
444
|
if (value.schemaVersion !== GOAL_STATE_VERSION) throw new Error(`Unsupported Cairn goal state schema version: ${value.schemaVersion}`);
|
|
@@ -316,6 +449,7 @@ function validateState(value) {
|
|
|
316
449
|
requiredText(goal.title, "goal.title");
|
|
317
450
|
requiredText(goal.planId, "goal.planId");
|
|
318
451
|
validGoalStatus(goal.status);
|
|
452
|
+
validEvidencePolicy(goal.evidencePolicy);
|
|
319
453
|
validTimestamp(goal.createdAt);
|
|
320
454
|
validTimestamp(goal.updatedAt);
|
|
321
455
|
if (!Array.isArray(goal.completionCriteria)) throw new Error("goal.completionCriteria must be an array");
|
|
@@ -324,7 +458,7 @@ function validateState(value) {
|
|
|
324
458
|
if (goal.ownerSessionId !== null && goal.ownerSessionId !== undefined) requiredText(goal.ownerSessionId, "goal.ownerSessionId");
|
|
325
459
|
if (goal.blocker !== null && goal.blocker !== undefined) requiredText(goal.blocker, "goal.blocker");
|
|
326
460
|
if (!Array.isArray(value.tasks) || value.tasks.length === 0) throw new Error("Cairn goal state must contain tasks");
|
|
327
|
-
if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state receipts must be an array");
|
|
461
|
+
if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state evidence records (receipts) must be an array");
|
|
328
462
|
const ids = new Set();
|
|
329
463
|
let activeTasks = 0;
|
|
330
464
|
for (const task of value.tasks) {
|
|
@@ -348,56 +482,70 @@ function validateState(value) {
|
|
|
348
482
|
}
|
|
349
483
|
|
|
350
484
|
function validateReceipt(receipt, state) {
|
|
351
|
-
if (!receipt || typeof receipt !== "object") throw new Error("Each
|
|
352
|
-
requiredText(receipt.id, "
|
|
485
|
+
if (!receipt || typeof receipt !== "object") throw new Error("Each evidence record must be an object");
|
|
486
|
+
requiredText(receipt.id, "evidence.id");
|
|
353
487
|
const scope = validReceiptScope(receipt.scope);
|
|
354
|
-
requiredText(receipt.kind, "
|
|
488
|
+
requiredText(receipt.kind, "evidence.kind");
|
|
355
489
|
if (scope === "task") {
|
|
356
|
-
requiredText(receipt.taskId, "
|
|
357
|
-
if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`
|
|
490
|
+
requiredText(receipt.taskId, "evidence.taskId");
|
|
491
|
+
if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`Evidence record references unknown task: ${receipt.taskId}`);
|
|
358
492
|
} else if (receipt.taskId !== undefined && receipt.taskId !== null) {
|
|
359
|
-
throw new Error("goal-scope
|
|
493
|
+
throw new Error("goal-scope evidence record must not contain taskId");
|
|
360
494
|
}
|
|
361
|
-
if (receipt.goalId !== state.goal.id) throw new Error("
|
|
362
|
-
if (receipt.planId !== state.goal.planId) throw new Error("
|
|
495
|
+
if (receipt.goalId !== state.goal.id) throw new Error("evidence goalId does not match goal");
|
|
496
|
+
if (receipt.planId !== state.goal.planId) throw new Error("evidence planId does not match goal");
|
|
363
497
|
validReceiptCommand(receipt.command);
|
|
364
|
-
if (receipt.exitCode !== 0) throw new Error("
|
|
498
|
+
if (receipt.exitCode !== 0) throw new Error("evidence exitCode must be 0");
|
|
365
499
|
validTimestamp(receipt.timestamp);
|
|
500
|
+
const source = validReceiptSource(receipt.source);
|
|
501
|
+
if (source === "tool") {
|
|
502
|
+
normalizeToolEvidence({
|
|
503
|
+
argv: receipt.argv,
|
|
504
|
+
outputDigest: receipt.outputDigest,
|
|
505
|
+
summary: receipt.summary,
|
|
506
|
+
fingerprint: receipt.workspaceFingerprint,
|
|
507
|
+
watchPaths: receipt.watchPaths,
|
|
508
|
+
});
|
|
509
|
+
}
|
|
366
510
|
}
|
|
367
511
|
|
|
368
|
-
function ensureTaskCanComplete(state, task) {
|
|
369
|
-
const
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
if (missingKinds.length > 0) throw new Error(`Task ${task.id} cannot be completed without successful, bound receipts: ${missingKinds.join(", ")}`);
|
|
512
|
+
function ensureTaskCanComplete(state, task, root) {
|
|
513
|
+
for (const kind of task.requiredEvidence) {
|
|
514
|
+
ensureCurrentEvidence({ state, root, scope: "task", task, kind });
|
|
515
|
+
}
|
|
373
516
|
}
|
|
374
517
|
|
|
375
|
-
function ensureGoalCanComplete(state) {
|
|
518
|
+
function ensureGoalCanComplete(state, root) {
|
|
376
519
|
const incomplete = state.tasks.filter((task) => task.status !== "completed");
|
|
377
520
|
if (incomplete.length > 0) throw new Error(`Goal cannot be completed while tasks remain: ${incomplete.map((task) => task.id).join(", ")}`);
|
|
378
|
-
for (const task of state.tasks) ensureTaskCanComplete(state, task);
|
|
379
|
-
const
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
if (missingKinds.length > 0) throw new Error(`Goal cannot be completed without successful, bound receipts: ${missingKinds.join(", ")}`);
|
|
521
|
+
for (const task of state.tasks) ensureTaskCanComplete(state, task, root);
|
|
522
|
+
for (const kind of state.goal.requiredEvidence) {
|
|
523
|
+
ensureCurrentEvidence({ state, root, scope: "goal", kind });
|
|
524
|
+
}
|
|
383
525
|
}
|
|
384
526
|
|
|
385
|
-
function
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
527
|
+
function ensureCurrentEvidence({ state, root, scope, task, kind }) {
|
|
528
|
+
const candidates = state.receipts.filter((receipt) => (
|
|
529
|
+
receipt.scope === scope
|
|
530
|
+
&& receipt.kind === kind
|
|
531
|
+
&& (scope === "goal" || receipt.taskId === task.id)
|
|
532
|
+
));
|
|
533
|
+
const subject = scope === "goal" ? "Goal" : `Task ${task.id}`;
|
|
534
|
+
if (state.goal.evidencePolicy === "declared") {
|
|
535
|
+
if (candidates.length === 0) {
|
|
536
|
+
throw new Error(`${subject} cannot be completed without successful, bound evidence record: ${kind}`);
|
|
537
|
+
}
|
|
538
|
+
return;
|
|
391
539
|
}
|
|
392
|
-
}
|
|
393
540
|
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
return
|
|
541
|
+
const toolEvidence = candidates.filter((receipt) => receipt.source === "tool");
|
|
542
|
+
if (toolEvidence.length === 0) {
|
|
543
|
+
throw new Error(`${subject} cannot be completed without tool-bound evidence record: ${kind}`);
|
|
544
|
+
}
|
|
545
|
+
for (const receipt of [...toolEvidence].reverse()) {
|
|
546
|
+
if (workspaceFingerprint(root, receipt.watchPaths) === receipt.workspaceFingerprint) return;
|
|
400
547
|
}
|
|
548
|
+
throw new Error(`${subject} has stale evidence record: ${kind}`);
|
|
401
549
|
}
|
|
402
550
|
|
|
403
551
|
function activateNextPendingTask(state) {
|
|
@@ -431,6 +579,11 @@ function validGoalStatus(value) {
|
|
|
431
579
|
return value;
|
|
432
580
|
}
|
|
433
581
|
|
|
582
|
+
function validEvidencePolicy(value) {
|
|
583
|
+
if (!evidencePolicies.has(value)) throw new Error(`Invalid evidence policy: ${value}`);
|
|
584
|
+
return value;
|
|
585
|
+
}
|
|
586
|
+
|
|
434
587
|
function validTaskStatus(value) {
|
|
435
588
|
if (!TASK_STATUSES.has(value)) throw new Error(`Invalid task status: ${value}`);
|
|
436
589
|
return value;
|
|
@@ -450,14 +603,19 @@ function taskStatusFromAction(value) {
|
|
|
450
603
|
}
|
|
451
604
|
|
|
452
605
|
function validReceiptScope(value) {
|
|
453
|
-
if (value !== "task" && value !== "goal") throw new Error(`Invalid
|
|
606
|
+
if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value}`);
|
|
607
|
+
return value;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
function validReceiptSource(value) {
|
|
611
|
+
if (value !== "declared" && value !== "tool") throw new Error(`Invalid evidence source: ${value}`);
|
|
454
612
|
return value;
|
|
455
613
|
}
|
|
456
614
|
|
|
457
615
|
function validReceiptCommand(value) {
|
|
458
|
-
const command = requiredText(value, "
|
|
616
|
+
const command = requiredText(value, "evidence command");
|
|
459
617
|
if (/\b(skip(?:ped)?|todo|tbd|n\/?a|not[ -]?run|not[ -]?applicable|placeholder)\b/i.test(command)) {
|
|
460
|
-
throw new Error("
|
|
618
|
+
throw new Error("evidence command cannot be skipped, placeholder, or incomplete");
|
|
461
619
|
}
|
|
462
620
|
return command;
|
|
463
621
|
}
|
|
@@ -472,6 +630,154 @@ function normalizeCriteria(criteria) {
|
|
|
472
630
|
return criteria.map((item, index) => requiredText(item, `completionCriteria[${index}]`));
|
|
473
631
|
}
|
|
474
632
|
|
|
633
|
+
function normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths }) {
|
|
634
|
+
const normalizedArgv = validArgv(argv);
|
|
635
|
+
const normalizedOutputDigest = validSha256(outputDigest, "evidence outputDigest");
|
|
636
|
+
const normalizedSummary = requiredText(summary, "evidence summary");
|
|
637
|
+
const normalizedFingerprint = validSha256(fingerprint, "evidence workspaceFingerprint");
|
|
638
|
+
if (!Array.isArray(watchPaths)) throw new Error("evidence watchPaths must be an array");
|
|
639
|
+
const normalizedWatchPaths = watchPaths.map((item, index) => requiredText(item, `evidence watchPaths[${index}]`));
|
|
640
|
+
return {
|
|
641
|
+
argv: normalizedArgv,
|
|
642
|
+
outputDigest: normalizedOutputDigest,
|
|
643
|
+
summary: normalizedSummary,
|
|
644
|
+
workspaceFingerprint: normalizedFingerprint,
|
|
645
|
+
watchPaths: normalizedWatchPaths,
|
|
646
|
+
};
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
function validArgv(argv) {
|
|
650
|
+
if (!Array.isArray(argv) || argv.length === 0) throw new Error("verification command must follow -- as argv");
|
|
651
|
+
return argv.map((item, index) => {
|
|
652
|
+
if (typeof item !== "string") throw new Error(`verification argv[${index}] must be a string`);
|
|
653
|
+
if (index === 0 && item.trim().length === 0) throw new Error("verification executable must be a non-empty string");
|
|
654
|
+
return item;
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
function validSha256(value, label) {
|
|
659
|
+
if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) {
|
|
660
|
+
throw new Error(`${label} must be a sha256 digest`);
|
|
661
|
+
}
|
|
662
|
+
return value;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function normalizeWatchPaths(root, watchPaths) {
|
|
666
|
+
if (!Array.isArray(watchPaths)) throw new Error("watchPaths must be an array");
|
|
667
|
+
const workspaceRoot = resolve(root);
|
|
668
|
+
return [...new Set(watchPaths.map((item, index) => {
|
|
669
|
+
const source = requiredText(item, `watchPaths[${index}]`);
|
|
670
|
+
const absolutePath = resolve(workspaceRoot, source);
|
|
671
|
+
const relativePath = relative(workspaceRoot, absolutePath);
|
|
672
|
+
if (relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
|
|
673
|
+
throw new Error(`watch path must remain inside the workspace: ${source}`);
|
|
674
|
+
}
|
|
675
|
+
const normalizedPath = relativePath === "" ? "." : relativePath.split(sep).join("/");
|
|
676
|
+
if (isInternalStatePath(normalizedPath)) throw new Error(`watch path cannot target internal Cairn or Git state: ${source}`);
|
|
677
|
+
return normalizedPath;
|
|
678
|
+
}))];
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
function listGitWorkspacePaths(root, watchPaths) {
|
|
682
|
+
const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"];
|
|
683
|
+
if (watchPaths.length > 0) args.push("--", ...watchPaths);
|
|
684
|
+
const result = spawnSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
|
|
685
|
+
if (result.status !== 0 || result.error) return null;
|
|
686
|
+
const paths = result.stdout.split("\0").filter(Boolean).filter((path) => !isInternalStatePath(path));
|
|
687
|
+
for (const watchPath of watchPaths) {
|
|
688
|
+
try {
|
|
689
|
+
if (!lstatSync(join(root, ...watchPath.split("/"))).isDirectory() && !paths.includes(watchPath)) paths.push(watchPath);
|
|
690
|
+
} catch (error) {
|
|
691
|
+
if (error?.code === "ENOENT") paths.push(watchPath);
|
|
692
|
+
else throw error;
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return paths;
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
function listFilesystemPaths(root, watchPaths) {
|
|
699
|
+
const paths = [];
|
|
700
|
+
const visit = (relativePath) => {
|
|
701
|
+
if (isInternalStatePath(relativePath)) return;
|
|
702
|
+
const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
|
|
703
|
+
let stat;
|
|
704
|
+
try {
|
|
705
|
+
stat = lstatSync(absolutePath);
|
|
706
|
+
} catch (error) {
|
|
707
|
+
if (error?.code === "ENOENT") {
|
|
708
|
+
paths.push(relativePath);
|
|
709
|
+
return;
|
|
710
|
+
}
|
|
711
|
+
throw error;
|
|
712
|
+
}
|
|
713
|
+
if (!stat.isDirectory()) {
|
|
714
|
+
paths.push(relativePath);
|
|
715
|
+
return;
|
|
716
|
+
}
|
|
717
|
+
const entries = readdirSync(absolutePath).sort();
|
|
718
|
+
if (entries.length === 0) paths.push(relativePath);
|
|
719
|
+
for (const entry of entries) {
|
|
720
|
+
const child = relativePath === "." ? entry : `${relativePath}/${entry}`;
|
|
721
|
+
visit(child);
|
|
722
|
+
}
|
|
723
|
+
};
|
|
724
|
+
for (const path of watchPaths.length > 0 ? watchPaths : ["."]) visit(path);
|
|
725
|
+
return paths;
|
|
726
|
+
}
|
|
727
|
+
|
|
728
|
+
function hashWorkspacePath(hash, root, relativePath) {
|
|
729
|
+
const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
|
|
730
|
+
hash.update(`${relativePath}\0`);
|
|
731
|
+
let stat;
|
|
732
|
+
try {
|
|
733
|
+
stat = lstatSync(absolutePath);
|
|
734
|
+
} catch (error) {
|
|
735
|
+
if (error?.code === "ENOENT") {
|
|
736
|
+
hash.update("missing\0");
|
|
737
|
+
return;
|
|
738
|
+
}
|
|
739
|
+
throw error;
|
|
740
|
+
}
|
|
741
|
+
hash.update(`${stat.mode}\0`);
|
|
742
|
+
if (stat.isSymbolicLink()) {
|
|
743
|
+
hash.update(`symlink\0${readlinkSync(absolutePath)}\0`);
|
|
744
|
+
} else if (stat.isFile()) {
|
|
745
|
+
hash.update("file\0");
|
|
746
|
+
hash.update(readFileSync(absolutePath));
|
|
747
|
+
hash.update("\0");
|
|
748
|
+
} else if (stat.isDirectory()) {
|
|
749
|
+
hash.update("directory\0");
|
|
750
|
+
} else {
|
|
751
|
+
hash.update("other\0");
|
|
752
|
+
}
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function isInternalStatePath(path) {
|
|
756
|
+
const firstSegment = path.split("/")[0];
|
|
757
|
+
return firstSegment === ".git" || firstSegment === ".cairn";
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
function combinedOutput(stdout, stderr) {
|
|
761
|
+
return [stdout, stderr].filter((value) => typeof value === "string" && value.length > 0).join("\n").trim();
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
function boundedText(value, maxLength) {
|
|
765
|
+
const text = String(value ?? "").trim();
|
|
766
|
+
if (text.length <= maxLength) return text;
|
|
767
|
+
return `…${text.slice(-(maxLength - 1))}`;
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
function successSummary(output) {
|
|
771
|
+
const text = String(output ?? "");
|
|
772
|
+
const outputBytes = Buffer.byteLength(text);
|
|
773
|
+
const outputLines = text.length === 0 ? 0 : text.split(/\r?\n/).length;
|
|
774
|
+
return `verification passed; outputBytes=${outputBytes}; outputLines=${outputLines}`;
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
function sha256(value) {
|
|
778
|
+
return `sha256:${createHash("sha256").update(value).digest("hex")}`;
|
|
779
|
+
}
|
|
780
|
+
|
|
475
781
|
function optionalText(value, label) {
|
|
476
782
|
return value === undefined || value === null ? null : requiredText(value, label);
|
|
477
783
|
}
|
|
@@ -483,16 +789,27 @@ function timestamp() {
|
|
|
483
789
|
function parseArgs(args) {
|
|
484
790
|
const options = {};
|
|
485
791
|
const positional = [];
|
|
792
|
+
const passthrough = [];
|
|
793
|
+
const booleanOptions = new Set(["quiet"]);
|
|
486
794
|
for (let index = 0; index < args.length; index += 1) {
|
|
487
795
|
const value = args[index];
|
|
796
|
+
if (value === "--") {
|
|
797
|
+
passthrough.push(...args.slice(index + 1));
|
|
798
|
+
break;
|
|
799
|
+
}
|
|
488
800
|
if (!value.startsWith("--")) {
|
|
489
801
|
positional.push(value);
|
|
490
802
|
continue;
|
|
491
803
|
}
|
|
492
|
-
const
|
|
493
|
-
|
|
804
|
+
const option = value.slice(2);
|
|
805
|
+
const equalsIndex = option.indexOf("=");
|
|
806
|
+
const key = equalsIndex === -1 ? option : option.slice(0, equalsIndex);
|
|
807
|
+
const inline = equalsIndex === -1 ? undefined : option.slice(equalsIndex + 1);
|
|
808
|
+
if (inline !== undefined) options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
|
|
809
|
+
else if (booleanOptions.has(key)) options[key] = true;
|
|
810
|
+
else options[key] = args[++index];
|
|
494
811
|
}
|
|
495
|
-
return { options, positional };
|
|
812
|
+
return { options, positional, passthrough };
|
|
496
813
|
}
|
|
497
814
|
|
|
498
815
|
function parseTasks(value) {
|