cairn-ai 0.2.0 → 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.
@@ -1,15 +1,17 @@
1
1
  #!/usr/bin/env node
2
- import { randomUUID } from "node:crypto";
3
- import { realpathSync } from "node:fs";
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 = 1;
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({ root = process.cwd(), taskId, kind, scope = "task", command, exitCode, timestamp: receiptTimestamp, goalId, planId } = {}) {
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, "receipt kind");
181
+ const normalizedKind = requiredText(kind, "evidence kind");
156
182
  const normalizedCommand = validReceiptCommand(command);
157
- if (exitCode !== 0) throw new Error("receipt exitCode must be 0");
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("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");
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 receipt, then hand off.`,
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 receipt before marking it complete.`,
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
- stdout(JSON.stringify(state));
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
- stdout(JSON.stringify(state));
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
- stdout(JSON.stringify(state));
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
- stdout(JSON.stringify(state));
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
- stdout(JSON.stringify(state));
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 receipt must be an object");
352
- requiredText(receipt.id, "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, "receipt.kind");
488
+ requiredText(receipt.kind, "evidence.kind");
355
489
  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}`);
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 receipt must not contain taskId");
493
+ throw new Error("goal-scope evidence record must not contain taskId");
360
494
  }
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");
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("receipt exitCode must be 0");
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 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(", ")}`);
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 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(", ")}`);
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 validReceiptForTask(receipt, state, task) {
386
- try {
387
- validateReceipt(receipt, state);
388
- return receipt.taskId === task.id;
389
- } catch {
390
- return false;
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
- function validReceiptForGoal(receipt, state) {
395
- try {
396
- validateReceipt(receipt, state);
397
- return receipt.scope === "goal";
398
- } catch {
399
- return false;
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 receipt scope: ${value}`);
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, "receipt command");
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("receipt command cannot be skipped, placeholder, or incomplete evidence");
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 [key, inline] = value.slice(2).split("=", 2);
493
- options[key] = inline ?? args[++index];
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) {