cairn-ai 0.2.1 → 0.2.3

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.
@@ -1,15 +1,18 @@
1
1
  #!/usr/bin/env node
2
- import { randomUUID } from "node:crypto";
3
- import { realpathSync } from "node:fs";
4
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
5
- import { dirname, join, resolve } from "node:path";
2
+ import { spawnSync } from "node:child_process";
3
+ import { createHash, randomUUID } from "node:crypto";
4
+ import { lstatSync, readFileSync, readlinkSync, realpathSync, readdirSync } from "node:fs";
5
+ import { readFile, rename, rm } from "node:fs/promises";
6
+ import { join, relative, resolve, sep } from "node:path";
6
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile, withStateLock } from "./cairn-safe-fs.mjs";
7
9
 
8
- export const GOAL_STATE_VERSION = 1;
10
+ export const GOAL_STATE_VERSION = 2;
9
11
  export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
10
12
  export const TASK_STATUSES = new Set(["pending", "active", "blocked", "completed"]);
11
13
 
12
14
  const terminalGoalStatuses = new Set(["cancelled", "completed"]);
15
+ const evidencePolicies = new Set(["declared", "tool-bound"]);
13
16
  const defaultTaskEvidence = ["moduleAcceptance", "surfaceIntegration"];
14
17
  const defaultGoalEvidence = ["finalReview"];
15
18
  const goalTransitions = {
@@ -32,8 +35,10 @@ export function goalStatePath(root = process.cwd()) {
32
35
 
33
36
  export async function readGoalState({ root = process.cwd() } = {}) {
34
37
  try {
35
- const text = await readFile(goalStatePath(root), "utf8");
36
- return validateState(JSON.parse(text));
38
+ const path = goalStatePath(root);
39
+ await assertNoSymlinkComponents(root, path);
40
+ const text = await readFile(path, "utf8");
41
+ return validateState(migrateState(JSON.parse(text)));
37
42
  } catch (error) {
38
43
  if (error?.code === "ENOENT") return null;
39
44
  if (error instanceof SyntaxError) throw new Error(`Cairn goal state is invalid JSON: ${error.message}`);
@@ -41,48 +46,56 @@ export async function readGoalState({ root = process.cwd() } = {}) {
41
46
  }
42
47
  }
43
48
 
44
- export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null } = {}) {
49
+ export async function startGoal({ root = process.cwd(), goal, planId, tasks, completionCriteria = [], requiredEvidence = defaultGoalEvidence, ownerSessionId = null, evidencePolicy = "tool-bound" } = {}) {
45
50
  const normalizedGoal = requiredText(goal, "goal");
46
51
  const normalizedPlanId = requiredText(planId, "planId");
47
52
  if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
48
53
 
49
- const existing = await readGoalState({ root });
50
- if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
51
- throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
52
- }
54
+ return withStateLock(root, async () => {
55
+ const existing = await readGoalState({ root });
56
+ if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
57
+ throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
58
+ }
53
59
 
54
- const now = timestamp();
55
- const goalId = `goal-${randomUUID()}`;
56
- const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
57
- const state = {
58
- schemaVersion: GOAL_STATE_VERSION,
59
- revision: 1,
60
- goal: {
61
- id: goalId,
62
- title: normalizedGoal,
63
- planId: normalizedPlanId,
64
- status: "active",
65
- completionCriteria: normalizeCriteria(completionCriteria),
66
- requiredEvidence: normalizeCriteria(requiredEvidence),
67
- ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
68
- blocker: null,
69
- createdAt: now,
70
- updatedAt: now,
71
- },
72
- tasks: normalizedTasks,
73
- receipts: [],
74
- };
75
- await writeGoalState({ root, state });
76
- return state;
60
+ const now = timestamp();
61
+ const goalId = `goal-${randomUUID()}`;
62
+ const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
63
+ const state = {
64
+ schemaVersion: GOAL_STATE_VERSION,
65
+ revision: 1,
66
+ goal: {
67
+ id: goalId,
68
+ title: normalizedGoal,
69
+ planId: normalizedPlanId,
70
+ status: "active",
71
+ completionCriteria: normalizeCriteria(completionCriteria),
72
+ requiredEvidence: normalizeCriteria(requiredEvidence),
73
+ evidencePolicy: validEvidencePolicy(evidencePolicy),
74
+ ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
75
+ blocker: null,
76
+ createdAt: now,
77
+ updatedAt: now,
78
+ },
79
+ tasks: normalizedTasks,
80
+ receipts: [],
81
+ };
82
+ await writeGoalStateUnlocked({ root, state });
83
+ return state;
84
+ });
77
85
  }
78
86
 
79
87
  export async function writeGoalState({ root = process.cwd(), state } = {}) {
88
+ return withStateLock(root, () => writeGoalStateUnlocked({ root, state }));
89
+ }
90
+
91
+ async function writeGoalStateUnlocked({ root = process.cwd(), state } = {}) {
80
92
  const validated = validateState(state);
81
93
  const path = goalStatePath(root);
82
- await mkdir(dirname(path), { recursive: true });
94
+ await safeMkdir(root, ".cairn");
95
+ await assertNoSymlinkComponents(root, path);
83
96
  const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
84
97
  try {
85
- await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
98
+ await safeWriteFile(root, temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
86
99
  await rename(temporaryPath, path);
87
100
  } catch (error) {
88
101
  await rm(temporaryPath, { force: true }).catch(() => {});
@@ -98,7 +111,7 @@ export async function transitionGoal({ root = process.cwd(), status, blocker } =
98
111
  if (!goalTransitions[currentStatus].has(nextStatus)) {
99
112
  throw new Error(`Cannot transition goal from ${currentStatus} to ${nextStatus}`);
100
113
  }
101
- if (nextStatus === "completed") ensureGoalCanComplete(state);
114
+ if (nextStatus === "completed") ensureGoalCanComplete(state, root);
102
115
  if (nextStatus === "blocked") state.goal.blocker = requiredText(blocker, "blocker");
103
116
  if (nextStatus === "active") state.goal.blocker = null;
104
117
  state.goal.status = nextStatus;
@@ -121,7 +134,7 @@ export async function setTaskStatus({ root = process.cwd(), taskId, status, bloc
121
134
  const anotherActive = state.tasks.find((item) => item.id !== task.id && item.status === "active");
122
135
  if (anotherActive) throw new Error(`Task ${anotherActive.id} is already active`);
123
136
  }
124
- if (nextStatus === "completed") ensureTaskCanComplete(state, task);
137
+ if (nextStatus === "completed") ensureTaskCanComplete(state, task, root);
125
138
  if (nextStatus === "blocked") task.blocker = requiredText(blocker, "blocker");
126
139
  if (nextStatus === "active" || nextStatus === "pending") task.blocker = null;
127
140
  task.status = nextStatus;
@@ -149,18 +162,57 @@ export async function assignTask({ root = process.cwd(), taskId, agentId = null
149
162
  });
150
163
  }
151
164
 
152
- export async function recordReceipt({ root = process.cwd(), taskId, kind, scope = "task", command, exitCode, timestamp: receiptTimestamp, goalId, planId } = {}) {
165
+ export async function recordReceipt(options = {}) {
166
+ if (options.source !== undefined && options.source !== "declared") {
167
+ throw new Error("Only goal verify can create tool evidence");
168
+ }
169
+ return recordEvidence({ ...options, source: "declared" });
170
+ }
171
+
172
+ async function recordEvidence({
173
+ root = process.cwd(),
174
+ taskId,
175
+ kind,
176
+ scope = "task",
177
+ command,
178
+ exitCode,
179
+ timestamp: receiptTimestamp,
180
+ goalId,
181
+ planId,
182
+ source,
183
+ argv,
184
+ outputDigest,
185
+ summary,
186
+ workspaceFingerprint: fingerprint,
187
+ watchPaths = [],
188
+ expectedIdentity,
189
+ expectedFingerprint,
190
+ } = {}) {
153
191
  const normalizedScope = validReceiptScope(scope);
154
192
  const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
155
- const normalizedKind = requiredText(kind, "receipt kind");
193
+ const normalizedKind = requiredText(kind, "evidence kind");
156
194
  const normalizedCommand = validReceiptCommand(command);
157
- if (exitCode !== 0) throw new Error("receipt exitCode must be 0");
195
+ const normalizedSource = validReceiptSource(source);
196
+ if (exitCode !== 0) throw new Error("evidence exitCode must be 0");
158
197
  const normalizedTimestamp = validTimestamp(receiptTimestamp ?? timestamp());
198
+ const toolFields = normalizedSource === "tool"
199
+ ? normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths })
200
+ : {};
159
201
  return mutateGoal(root, (state) => {
160
202
  assertGoalMutable(state);
161
203
  const task = normalizedScope === "task" ? taskById(state, normalizedTaskId) : null;
162
- if (goalId !== undefined && goalId !== state.goal.id) throw new Error("receipt goalId does not match the active goal");
163
- if (planId !== undefined && planId !== state.goal.planId) throw new Error("receipt planId does not match the active goal");
204
+ if (expectedIdentity && (
205
+ state.goal.id !== expectedIdentity.goalId
206
+ || state.goal.planId !== expectedIdentity.planId
207
+ )) throw new Error("Goal identity changed during verification; evidence was not recorded");
208
+ if (expectedIdentity && normalizedScope === "task" && (
209
+ task?.id !== expectedIdentity.taskId || task?.status !== expectedIdentity.taskStatus
210
+ )) throw new Error("Verification task changed during verification; evidence was not recorded");
211
+ if (expectedFingerprint && workspaceFingerprint(root, watchPaths) !== expectedFingerprint) {
212
+ throw new Error("Watched workspace changed during verification; evidence was not recorded");
213
+ }
214
+ if (goalId !== undefined && goalId !== state.goal.id) throw new Error("evidence goalId does not match the active goal");
215
+ if (planId !== undefined && planId !== state.goal.planId) throw new Error("evidence planId does not match the active goal");
164
216
  const receipt = {
165
217
  id: `receipt-${randomUUID()}`,
166
218
  scope: normalizedScope,
@@ -171,6 +223,8 @@ export async function recordReceipt({ root = process.cwd(), taskId, kind, scope
171
223
  command: normalizedCommand,
172
224
  exitCode: 0,
173
225
  timestamp: normalizedTimestamp,
226
+ source: normalizedSource,
227
+ ...toolFields,
174
228
  };
175
229
  state.receipts.push(receipt);
176
230
  if (task) task.updatedAt = timestamp();
@@ -179,6 +233,93 @@ export async function recordReceipt({ root = process.cwd(), taskId, kind, scope
179
233
  });
180
234
  }
181
235
 
236
+ export async function verifyAndRecord({
237
+ root = process.cwd(),
238
+ taskId,
239
+ kind,
240
+ scope = "task",
241
+ argv,
242
+ watchPaths = [],
243
+ runner = spawnSync,
244
+ timeoutMs = 600_000,
245
+ } = {}) {
246
+ const normalizedArgv = validArgv(argv);
247
+ const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
248
+ const normalizedScope = validReceiptScope(scope);
249
+ const normalizedKind = requiredText(kind, "evidence kind");
250
+ const initialState = await readGoalState({ root });
251
+ if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
252
+ assertGoalMutable(initialState);
253
+ const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
254
+ const initialTask = normalizedScope === "task" ? taskById(initialState, normalizedTaskId) : null;
255
+ if (initialTask && initialTask.status !== "active") throw new Error(`Verification task must be active: ${initialTask.id}`);
256
+ const identity = {
257
+ goalId: initialState.goal.id,
258
+ planId: initialState.goal.planId,
259
+ taskId: normalizedTaskId,
260
+ taskStatus: initialTask?.status,
261
+ };
262
+ const initialFingerprint = workspaceFingerprint(root, normalizedWatchPaths);
263
+ const result = runner(normalizedArgv[0], normalizedArgv.slice(1), {
264
+ cwd: resolve(root),
265
+ encoding: "utf8",
266
+ maxBuffer: 16 * 1024 * 1024,
267
+ shell: false,
268
+ timeout: validTimeout(timeoutMs),
269
+ });
270
+ if (result.error?.code === "ETIMEDOUT") throw new Error(`Verification timed out after ${timeoutMs}ms`);
271
+ if (result.error) throw new Error(`Verification could not start: ${result.error.message}`);
272
+ const exitCode = Number.isInteger(result.status) ? result.status : 1;
273
+ const output = combinedOutput(result.stdout, result.stderr);
274
+ if (exitCode !== 0) {
275
+ const detail = boundedText(output, 2000);
276
+ throw new Error(`Verification failed with exit ${exitCode}${detail ? `:\n${detail}` : ""}`);
277
+ }
278
+
279
+ const outputDigest = sha256(output);
280
+ const fingerprint = workspaceFingerprint(root, normalizedWatchPaths);
281
+ if (fingerprint !== initialFingerprint) throw new Error("Watched workspace changed during verification; evidence was not recorded");
282
+ const postState = await readGoalState({ root });
283
+ if (!postState || postState.goal.id !== identity.goalId || postState.goal.planId !== identity.planId) {
284
+ throw new Error("Goal identity changed during verification; evidence was not recorded");
285
+ }
286
+ if (normalizedScope === "task" && taskById(postState, normalizedTaskId).status !== identity.taskStatus) {
287
+ throw new Error("Verification task is no longer active; evidence was not recorded");
288
+ }
289
+ const command = normalizedArgv.map((value) => JSON.stringify(value)).join(" ");
290
+ const state = await recordEvidence({
291
+ root,
292
+ taskId: normalizedTaskId,
293
+ kind: normalizedKind,
294
+ scope: normalizedScope,
295
+ command,
296
+ exitCode: 0,
297
+ source: "tool",
298
+ argv: normalizedArgv,
299
+ outputDigest,
300
+ summary: successSummary(output),
301
+ workspaceFingerprint: fingerprint,
302
+ watchPaths: normalizedWatchPaths,
303
+ goalId: identity.goalId,
304
+ planId: identity.planId,
305
+ expectedIdentity: identity,
306
+ expectedFingerprint: fingerprint,
307
+ });
308
+ return { state, evidence: state.receipts.at(-1) };
309
+ }
310
+
311
+ export function workspaceFingerprint(root = process.cwd(), watchPaths = []) {
312
+ const workspaceRoot = resolve(root);
313
+ const normalizedWatchPaths = normalizeWatchPaths(workspaceRoot, watchPaths);
314
+ const gitPaths = normalizedWatchPaths.length === 0 ? listGitWorkspacePaths(workspaceRoot, normalizedWatchPaths) : null;
315
+ const paths = normalizedWatchPaths.length > 0
316
+ ? listFilesystemPaths(workspaceRoot, normalizedWatchPaths)
317
+ : (gitPaths ?? listFilesystemPaths(workspaceRoot, normalizedWatchPaths));
318
+ const hash = createHash("sha256");
319
+ for (const path of [...new Set(paths)].sort()) hashWorkspacePath(hash, workspaceRoot, path);
320
+ return `sha256:${hash.digest("hex")}`;
321
+ }
322
+
182
323
  export function currentTask(state) {
183
324
  if (!state) return null;
184
325
  return state.tasks.find((task) => task.status === "active")
@@ -204,20 +345,21 @@ export function evaluateStop(state, { subagent = false, agentId } = {}) {
204
345
  if (!agentId || task.assignedAgentId !== agentId) return { block: false };
205
346
  return {
206
347
  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 receipt, then hand off.`,
348
+ 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
349
  };
209
350
  }
210
351
  return {
211
352
  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 receipt before marking it complete.`,
353
+ 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
354
  };
214
355
  }
215
356
 
216
357
  export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log } = {}) {
217
358
  const [firstCommand = "help", ...remaining] = args;
218
359
  const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
219
- const { options, positional } = parseArgs(rest);
360
+ const { options, positional, passthrough } = parseArgs(rest, command);
220
361
  const root = options.root ?? process.cwd();
362
+ const emit = options.quiet ? () => {} : stdout;
221
363
  if (command === "start") {
222
364
  const tasks = parseTasks(options.tasks ?? positional.slice(0));
223
365
  const state = await startGoal({
@@ -228,8 +370,9 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
228
370
  completionCriteria: splitValues(options.criteria),
229
371
  requiredEvidence: options.requiredEvidence ? splitValues(options.requiredEvidence) : defaultGoalEvidence,
230
372
  ownerSessionId: options.session,
373
+ evidencePolicy: options.evidencePolicy ?? options["evidence-policy"] ?? "tool-bound",
231
374
  });
232
- stdout(JSON.stringify(state));
375
+ emit(JSON.stringify(state));
233
376
  return state;
234
377
  }
235
378
  if (command === "status") {
@@ -245,12 +388,12 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
245
388
  status: options.status ?? taskAction ?? positional[1],
246
389
  blocker: options.reason,
247
390
  });
248
- stdout(JSON.stringify(state));
391
+ emit(JSON.stringify(state));
249
392
  return state;
250
393
  }
251
394
  if (command === "assign") {
252
395
  const state = await assignTask({ root, taskId: options.task ?? positional[0], agentId: options.agent ?? positional[1] ?? null });
253
- stdout(JSON.stringify(state));
396
+ emit(JSON.stringify(state));
254
397
  return state;
255
398
  }
256
399
  if (command === "receipt") {
@@ -262,31 +405,53 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
262
405
  command: options.command,
263
406
  exitCode: numberOption(options.exitCode ?? options["exit-code"] ?? options.exit),
264
407
  timestamp: options.timestamp,
265
- goalId: options.goalId,
408
+ goalId: options.goalId ?? options["goal-id"],
266
409
  planId: options.plan,
267
410
  });
268
- stdout(JSON.stringify(state));
411
+ emit(JSON.stringify(state));
269
412
  return state;
270
413
  }
414
+ if (command === "verify") {
415
+ const result = await verifyAndRecord({
416
+ root,
417
+ taskId: options.task ?? positional[0],
418
+ kind: options.kind,
419
+ scope: options.scope,
420
+ watchPaths: splitValues(options.watch),
421
+ argv: passthrough,
422
+ timeoutMs: options["timeout-ms"] === undefined ? 600_000 : positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
423
+ });
424
+ emit(JSON.stringify({
425
+ id: result.evidence.id,
426
+ scope: result.evidence.scope,
427
+ kind: result.evidence.kind,
428
+ exitCode: result.evidence.exitCode,
429
+ outputDigest: result.evidence.outputDigest,
430
+ workspaceFingerprint: result.evidence.workspaceFingerprint,
431
+ }));
432
+ return result.state;
433
+ }
271
434
  if (["pause", "resume", "block", "cancel", "complete"].includes(command)) {
272
435
  const state = await transitionGoal({
273
436
  root,
274
437
  status: { pause: "paused", resume: "active", block: "blocked", cancel: "cancelled", complete: "completed" }[command],
275
438
  blocker: options.reason,
276
439
  });
277
- stdout(JSON.stringify(state));
440
+ emit(JSON.stringify(state));
278
441
  return state;
279
442
  }
280
- throw new Error("Usage: cairn-goal start|status|task|assign|receipt|pause|resume|block|cancel|complete [--root PATH]");
443
+ throw new Error("Usage: cairn-goal start|status|task|assign|receipt|verify|pause|resume|block|cancel|complete [--root PATH]");
281
444
  }
282
445
 
283
446
  async function mutateGoal(root, mutate) {
284
- const state = await readGoalState({ root });
285
- if (!state) throw new Error("No Cairn goal state exists; start a goal first");
286
- const next = await mutate(structuredClone(state));
287
- next.revision += 1;
288
- await writeGoalState({ root, state: next });
289
- return next;
447
+ return withStateLock(root, async () => {
448
+ const state = await readGoalState({ root });
449
+ if (!state) throw new Error("No Cairn goal state exists; start a goal first");
450
+ const next = await mutate(structuredClone(state));
451
+ next.revision += 1;
452
+ await writeGoalStateUnlocked({ root, state: next });
453
+ return next;
454
+ });
290
455
  }
291
456
 
292
457
  function normalizeTask(task, index, defaultStatus) {
@@ -306,6 +471,22 @@ function normalizeTask(task, index, defaultStatus) {
306
471
  };
307
472
  }
308
473
 
474
+ function migrateState(value) {
475
+ if (!value || typeof value !== "object" || Array.isArray(value)) return value;
476
+ if (value.schemaVersion !== 1) return value;
477
+ return {
478
+ ...value,
479
+ schemaVersion: GOAL_STATE_VERSION,
480
+ goal: {
481
+ ...value.goal,
482
+ evidencePolicy: value.goal?.evidencePolicy ?? "tool-bound",
483
+ },
484
+ receipts: Array.isArray(value.receipts)
485
+ ? value.receipts.map((receipt) => ({ ...receipt, source: "declared" }))
486
+ : value.receipts,
487
+ };
488
+ }
489
+
309
490
  function validateState(value) {
310
491
  if (!value || typeof value !== "object" || Array.isArray(value)) throw new Error("Cairn goal state must be an object");
311
492
  if (value.schemaVersion !== GOAL_STATE_VERSION) throw new Error(`Unsupported Cairn goal state schema version: ${value.schemaVersion}`);
@@ -316,6 +497,7 @@ function validateState(value) {
316
497
  requiredText(goal.title, "goal.title");
317
498
  requiredText(goal.planId, "goal.planId");
318
499
  validGoalStatus(goal.status);
500
+ validEvidencePolicy(goal.evidencePolicy);
319
501
  validTimestamp(goal.createdAt);
320
502
  validTimestamp(goal.updatedAt);
321
503
  if (!Array.isArray(goal.completionCriteria)) throw new Error("goal.completionCriteria must be an array");
@@ -324,7 +506,7 @@ function validateState(value) {
324
506
  if (goal.ownerSessionId !== null && goal.ownerSessionId !== undefined) requiredText(goal.ownerSessionId, "goal.ownerSessionId");
325
507
  if (goal.blocker !== null && goal.blocker !== undefined) requiredText(goal.blocker, "goal.blocker");
326
508
  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");
509
+ if (!Array.isArray(value.receipts)) throw new Error("Cairn goal state evidence records (receipts) must be an array");
328
510
  const ids = new Set();
329
511
  let activeTasks = 0;
330
512
  for (const task of value.tasks) {
@@ -348,56 +530,70 @@ function validateState(value) {
348
530
  }
349
531
 
350
532
  function validateReceipt(receipt, state) {
351
- if (!receipt || typeof receipt !== "object") throw new Error("Each receipt must be an object");
352
- requiredText(receipt.id, "receipt.id");
533
+ if (!receipt || typeof receipt !== "object") throw new Error("Each evidence record must be an object");
534
+ requiredText(receipt.id, "evidence.id");
353
535
  const scope = validReceiptScope(receipt.scope);
354
- requiredText(receipt.kind, "receipt.kind");
536
+ requiredText(receipt.kind, "evidence.kind");
355
537
  if (scope === "task") {
356
- requiredText(receipt.taskId, "receipt.taskId");
357
- if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`Receipt references unknown task: ${receipt.taskId}`);
538
+ requiredText(receipt.taskId, "evidence.taskId");
539
+ if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`Evidence record references unknown task: ${receipt.taskId}`);
358
540
  } else if (receipt.taskId !== undefined && receipt.taskId !== null) {
359
- throw new Error("goal-scope receipt must not contain taskId");
541
+ throw new Error("goal-scope evidence record must not contain taskId");
360
542
  }
361
- if (receipt.goalId !== state.goal.id) throw new Error("receipt goalId does not match goal");
362
- if (receipt.planId !== state.goal.planId) throw new Error("receipt planId does not match goal");
543
+ if (receipt.goalId !== state.goal.id) throw new Error("evidence goalId does not match goal");
544
+ if (receipt.planId !== state.goal.planId) throw new Error("evidence planId does not match goal");
363
545
  validReceiptCommand(receipt.command);
364
- if (receipt.exitCode !== 0) throw new Error("receipt exitCode must be 0");
546
+ if (receipt.exitCode !== 0) throw new Error("evidence exitCode must be 0");
365
547
  validTimestamp(receipt.timestamp);
548
+ const source = validReceiptSource(receipt.source);
549
+ if (source === "tool") {
550
+ normalizeToolEvidence({
551
+ argv: receipt.argv,
552
+ outputDigest: receipt.outputDigest,
553
+ summary: receipt.summary,
554
+ fingerprint: receipt.workspaceFingerprint,
555
+ watchPaths: receipt.watchPaths,
556
+ });
557
+ }
366
558
  }
367
559
 
368
- function ensureTaskCanComplete(state, task) {
369
- const missingKinds = task.requiredEvidence.filter((kind) => !state.receipts.some((item) => (
370
- item.scope === "task" && item.kind === kind && item.taskId === task.id && validReceiptForTask(item, state, task)
371
- )));
372
- if (missingKinds.length > 0) throw new Error(`Task ${task.id} cannot be completed without successful, bound receipts: ${missingKinds.join(", ")}`);
560
+ function ensureTaskCanComplete(state, task, root) {
561
+ for (const kind of task.requiredEvidence) {
562
+ ensureCurrentEvidence({ state, root, scope: "task", task, kind });
563
+ }
373
564
  }
374
565
 
375
- function ensureGoalCanComplete(state) {
566
+ function ensureGoalCanComplete(state, root) {
376
567
  const incomplete = state.tasks.filter((task) => task.status !== "completed");
377
568
  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 missingKinds = state.goal.requiredEvidence.filter((kind) => !state.receipts.some((receipt) => (
380
- receipt.scope === "goal" && receipt.kind === kind && validReceiptForGoal(receipt, state)
381
- )));
382
- if (missingKinds.length > 0) throw new Error(`Goal cannot be completed without successful, bound receipts: ${missingKinds.join(", ")}`);
569
+ for (const task of state.tasks) ensureTaskCanComplete(state, task, root);
570
+ for (const kind of state.goal.requiredEvidence) {
571
+ ensureCurrentEvidence({ state, root, scope: "goal", kind });
572
+ }
383
573
  }
384
574
 
385
- function validReceiptForTask(receipt, state, task) {
386
- try {
387
- validateReceipt(receipt, state);
388
- return receipt.taskId === task.id;
389
- } catch {
390
- return false;
575
+ function ensureCurrentEvidence({ state, root, scope, task, kind }) {
576
+ const candidates = state.receipts.filter((receipt) => (
577
+ receipt.scope === scope
578
+ && receipt.kind === kind
579
+ && (scope === "goal" || receipt.taskId === task.id)
580
+ ));
581
+ const subject = scope === "goal" ? "Goal" : `Task ${task.id}`;
582
+ if (state.goal.evidencePolicy === "declared") {
583
+ if (candidates.length === 0) {
584
+ throw new Error(`${subject} cannot be completed without successful, bound evidence record: ${kind}`);
585
+ }
586
+ return;
391
587
  }
392
- }
393
588
 
394
- function validReceiptForGoal(receipt, state) {
395
- try {
396
- validateReceipt(receipt, state);
397
- return receipt.scope === "goal";
398
- } catch {
399
- return false;
589
+ const toolEvidence = candidates.filter((receipt) => receipt.source === "tool");
590
+ if (toolEvidence.length === 0) {
591
+ throw new Error(`${subject} cannot be completed without tool-bound evidence record: ${kind}`);
592
+ }
593
+ for (const receipt of [...toolEvidence].reverse()) {
594
+ if (workspaceFingerprint(root, receipt.watchPaths) === receipt.workspaceFingerprint) return;
400
595
  }
596
+ throw new Error(`${subject} has stale evidence record: ${kind}`);
401
597
  }
402
598
 
403
599
  function activateNextPendingTask(state) {
@@ -431,6 +627,11 @@ function validGoalStatus(value) {
431
627
  return value;
432
628
  }
433
629
 
630
+ function validEvidencePolicy(value) {
631
+ if (!evidencePolicies.has(value)) throw new Error(`Invalid evidence policy: ${value}`);
632
+ return value;
633
+ }
634
+
434
635
  function validTaskStatus(value) {
435
636
  if (!TASK_STATUSES.has(value)) throw new Error(`Invalid task status: ${value}`);
436
637
  return value;
@@ -450,14 +651,19 @@ function taskStatusFromAction(value) {
450
651
  }
451
652
 
452
653
  function validReceiptScope(value) {
453
- if (value !== "task" && value !== "goal") throw new Error(`Invalid receipt scope: ${value}`);
654
+ if (value !== "task" && value !== "goal") throw new Error(`Invalid evidence scope: ${value}`);
655
+ return value;
656
+ }
657
+
658
+ function validReceiptSource(value) {
659
+ if (value !== "declared" && value !== "tool") throw new Error(`Invalid evidence source: ${value}`);
454
660
  return value;
455
661
  }
456
662
 
457
663
  function validReceiptCommand(value) {
458
- const command = requiredText(value, "receipt command");
664
+ const command = requiredText(value, "evidence command");
459
665
  if (/\b(skip(?:ped)?|todo|tbd|n\/?a|not[ -]?run|not[ -]?applicable|placeholder)\b/i.test(command)) {
460
- throw new Error("receipt command cannot be skipped, placeholder, or incomplete evidence");
666
+ throw new Error("evidence command cannot be skipped, placeholder, or incomplete");
461
667
  }
462
668
  return command;
463
669
  }
@@ -472,6 +678,154 @@ function normalizeCriteria(criteria) {
472
678
  return criteria.map((item, index) => requiredText(item, `completionCriteria[${index}]`));
473
679
  }
474
680
 
681
+ function normalizeToolEvidence({ argv, outputDigest, summary, fingerprint, watchPaths }) {
682
+ const normalizedArgv = validArgv(argv);
683
+ const normalizedOutputDigest = validSha256(outputDigest, "evidence outputDigest");
684
+ const normalizedSummary = requiredText(summary, "evidence summary");
685
+ const normalizedFingerprint = validSha256(fingerprint, "evidence workspaceFingerprint");
686
+ if (!Array.isArray(watchPaths)) throw new Error("evidence watchPaths must be an array");
687
+ const normalizedWatchPaths = watchPaths.map((item, index) => requiredText(item, `evidence watchPaths[${index}]`));
688
+ return {
689
+ argv: normalizedArgv,
690
+ outputDigest: normalizedOutputDigest,
691
+ summary: normalizedSummary,
692
+ workspaceFingerprint: normalizedFingerprint,
693
+ watchPaths: normalizedWatchPaths,
694
+ };
695
+ }
696
+
697
+ function validArgv(argv) {
698
+ if (!Array.isArray(argv) || argv.length === 0) throw new Error("verification command must follow -- as argv");
699
+ return argv.map((item, index) => {
700
+ if (typeof item !== "string") throw new Error(`verification argv[${index}] must be a string`);
701
+ if (index === 0 && item.trim().length === 0) throw new Error("verification executable must be a non-empty string");
702
+ return item;
703
+ });
704
+ }
705
+
706
+ function validSha256(value, label) {
707
+ if (typeof value !== "string" || !/^sha256:[a-f0-9]{64}$/.test(value)) {
708
+ throw new Error(`${label} must be a sha256 digest`);
709
+ }
710
+ return value;
711
+ }
712
+
713
+ function normalizeWatchPaths(root, watchPaths) {
714
+ if (!Array.isArray(watchPaths)) throw new Error("watchPaths must be an array");
715
+ const workspaceRoot = resolve(root);
716
+ return [...new Set(watchPaths.map((item, index) => {
717
+ const source = requiredText(item, `watchPaths[${index}]`);
718
+ const absolutePath = resolve(workspaceRoot, source);
719
+ const relativePath = relative(workspaceRoot, absolutePath);
720
+ if (relativePath === ".." || relativePath.startsWith(`..${sep}`)) {
721
+ throw new Error(`watch path must remain inside the workspace: ${source}`);
722
+ }
723
+ const normalizedPath = relativePath === "" ? "." : relativePath.split(sep).join("/");
724
+ if (isInternalStatePath(normalizedPath)) throw new Error(`watch path cannot target internal Cairn or Git state: ${source}`);
725
+ return normalizedPath;
726
+ }))];
727
+ }
728
+
729
+ function listGitWorkspacePaths(root, watchPaths) {
730
+ const args = ["-C", root, "ls-files", "-z", "--cached", "--others", "--exclude-standard"];
731
+ if (watchPaths.length > 0) args.push("--", ...watchPaths);
732
+ const result = spawnSync("git", args, { encoding: "utf8", maxBuffer: 16 * 1024 * 1024 });
733
+ if (result.status !== 0 || result.error) return null;
734
+ const paths = result.stdout.split("\0").filter(Boolean).filter((path) => !isInternalStatePath(path));
735
+ for (const watchPath of watchPaths) {
736
+ try {
737
+ if (!lstatSync(join(root, ...watchPath.split("/"))).isDirectory() && !paths.includes(watchPath)) paths.push(watchPath);
738
+ } catch (error) {
739
+ if (error?.code === "ENOENT") paths.push(watchPath);
740
+ else throw error;
741
+ }
742
+ }
743
+ return paths;
744
+ }
745
+
746
+ function listFilesystemPaths(root, watchPaths) {
747
+ const paths = [];
748
+ const visit = (relativePath) => {
749
+ if (isInternalStatePath(relativePath)) return;
750
+ const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
751
+ let stat;
752
+ try {
753
+ stat = lstatSync(absolutePath);
754
+ } catch (error) {
755
+ if (error?.code === "ENOENT") {
756
+ paths.push(relativePath);
757
+ return;
758
+ }
759
+ throw error;
760
+ }
761
+ if (!stat.isDirectory()) {
762
+ paths.push(relativePath);
763
+ return;
764
+ }
765
+ const entries = readdirSync(absolutePath).sort();
766
+ if (entries.length === 0) paths.push(relativePath);
767
+ for (const entry of entries) {
768
+ const child = relativePath === "." ? entry : `${relativePath}/${entry}`;
769
+ visit(child);
770
+ }
771
+ };
772
+ for (const path of watchPaths.length > 0 ? watchPaths : ["."]) visit(path);
773
+ return paths;
774
+ }
775
+
776
+ function hashWorkspacePath(hash, root, relativePath) {
777
+ const absolutePath = relativePath === "." ? root : join(root, ...relativePath.split("/"));
778
+ hash.update(`${relativePath}\0`);
779
+ let stat;
780
+ try {
781
+ stat = lstatSync(absolutePath);
782
+ } catch (error) {
783
+ if (error?.code === "ENOENT") {
784
+ hash.update("missing\0");
785
+ return;
786
+ }
787
+ throw error;
788
+ }
789
+ hash.update(`${stat.mode}\0`);
790
+ if (stat.isSymbolicLink()) {
791
+ hash.update(`symlink\0${readlinkSync(absolutePath)}\0`);
792
+ } else if (stat.isFile()) {
793
+ hash.update("file\0");
794
+ hash.update(readFileSync(absolutePath));
795
+ hash.update("\0");
796
+ } else if (stat.isDirectory()) {
797
+ hash.update("directory\0");
798
+ } else {
799
+ hash.update("other\0");
800
+ }
801
+ }
802
+
803
+ function isInternalStatePath(path) {
804
+ const firstSegment = path.split("/")[0];
805
+ return firstSegment === ".git" || firstSegment === ".cairn";
806
+ }
807
+
808
+ function combinedOutput(stdout, stderr) {
809
+ return [stdout, stderr].filter((value) => typeof value === "string" && value.length > 0).join("\n").trim();
810
+ }
811
+
812
+ function boundedText(value, maxLength) {
813
+ const text = String(value ?? "").trim();
814
+ if (text.length <= maxLength) return text;
815
+ return `…${text.slice(-(maxLength - 1))}`;
816
+ }
817
+
818
+ function successSummary(output) {
819
+ const text = String(output ?? "");
820
+ const outputBytes = Buffer.byteLength(text);
821
+ const outputLines = text.length === 0 ? 0 : text.split(/\r?\n/).length;
822
+ return `verification passed; outputBytes=${outputBytes}; outputLines=${outputLines}`;
823
+ }
824
+
825
+ function sha256(value) {
826
+ return `sha256:${createHash("sha256").update(value).digest("hex")}`;
827
+ }
828
+
475
829
  function optionalText(value, label) {
476
830
  return value === undefined || value === null ? null : requiredText(value, label);
477
831
  }
@@ -480,19 +834,73 @@ function timestamp() {
480
834
  return new Date().toISOString();
481
835
  }
482
836
 
483
- function parseArgs(args) {
837
+ function parseArgs(args, command) {
484
838
  const options = {};
485
839
  const positional = [];
840
+ const passthrough = [];
841
+ const booleanOptions = new Set(["quiet"]);
842
+ const allowed = allowedOptions(command);
843
+ const seenCanonicalOptions = new Set();
486
844
  for (let index = 0; index < args.length; index += 1) {
487
845
  const value = args[index];
846
+ if (value === "--") {
847
+ passthrough.push(...args.slice(index + 1));
848
+ break;
849
+ }
488
850
  if (!value.startsWith("--")) {
489
851
  positional.push(value);
490
852
  continue;
491
853
  }
492
- const [key, inline] = value.slice(2).split("=", 2);
493
- options[key] = inline ?? args[++index];
854
+ const option = value.slice(2);
855
+ const equalsIndex = option.indexOf("=");
856
+ const key = equalsIndex === -1 ? option : option.slice(0, equalsIndex);
857
+ const inline = equalsIndex === -1 ? undefined : option.slice(equalsIndex + 1);
858
+ if (!allowed.has(key)) throw new Error(`Unknown option for ${command}: --${key}`);
859
+ const canonicalKey = canonicalOptionKey(key);
860
+ if (seenCanonicalOptions.has(canonicalKey)) throw new Error(`--${canonicalKey} may only be provided once`);
861
+ seenCanonicalOptions.add(canonicalKey);
862
+ if (inline !== undefined) {
863
+ if (!booleanOptions.has(key) && inline.length === 0) throw new Error(`--${key} requires a value`);
864
+ if (booleanOptions.has(key) && !["true", "false"].includes(inline)) throw new Error(`--${key} must be true or false`);
865
+ options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
866
+ }
867
+ else if (booleanOptions.has(key)) options[key] = true;
868
+ else {
869
+ const next = args[index + 1];
870
+ if (next === undefined || next === "--" || next.startsWith("--")) throw new Error(`--${key} requires a value`);
871
+ options[key] = next;
872
+ index += 1;
873
+ }
494
874
  }
495
- return { options, positional };
875
+ return { options, positional, passthrough };
876
+ }
877
+
878
+ function canonicalOptionKey(key) {
879
+ return {
880
+ exitCode: "exit-code",
881
+ exit: "exit-code",
882
+ goalId: "goal-id",
883
+ evidencePolicy: "evidence-policy",
884
+ }[key] ?? key;
885
+ }
886
+
887
+ function allowedOptions(command) {
888
+ const common = ["root", "quiet"];
889
+ const byCommand = {
890
+ start: ["goal", "plan", "tasks", "criteria", "requiredEvidence", "session", "evidencePolicy", "evidence-policy"],
891
+ status: [],
892
+ task: ["task", "status", "reason"],
893
+ assign: ["task", "agent"],
894
+ receipt: ["task", "kind", "scope", "command", "exitCode", "exit-code", "exit", "timestamp", "goalId", "goal-id", "plan"],
895
+ verify: ["task", "kind", "scope", "watch", "timeout-ms"],
896
+ pause: [],
897
+ resume: [],
898
+ block: ["reason"],
899
+ cancel: [],
900
+ complete: [],
901
+ help: [],
902
+ };
903
+ return new Set([...common, ...(byCommand[command] ?? [])]);
496
904
  }
497
905
 
498
906
  function parseTasks(value) {
@@ -520,6 +928,19 @@ function numberOption(value) {
520
928
  return result;
521
929
  }
522
930
 
931
+ function positiveIntegerOption(value, label) {
932
+ const result = Number(value);
933
+ if (!Number.isSafeInteger(result) || result <= 0 || result > 3_600_000) {
934
+ throw new Error(`${label} must be an integer between 1 and 3600000`);
935
+ }
936
+ return result;
937
+ }
938
+
939
+ function validTimeout(value) {
940
+ if (!Number.isSafeInteger(value) || value <= 0) throw new Error("timeoutMs must be a positive integer");
941
+ return value;
942
+ }
943
+
523
944
  function isCliEntry() {
524
945
  if (!process.argv[1]) return false;
525
946
  try {