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.
- 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 +13 -11
- package/README.es.md +13 -11
- package/README.fr.md +13 -11
- package/README.ja.md +13 -11
- package/README.ko.md +15 -13
- package/README.md +15 -13
- package/README.pt.md +13 -11
- package/README.zh.md +13 -11
- package/commands/cairn-doctor.md +5 -3
- package/commands/cairn-install.md +5 -4
- package/commands/cairn-plan.md +13 -16
- package/commands/cairn-review.md +1 -1
- package/commands/cairn-uninstall.md +4 -2
- package/commands/cairn-upgrade.md +6 -6
- 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 +2 -2
- package/scripts/cairn-goal.mjs +522 -101
- package/scripts/cairn-lifecycle.mjs +926 -313
- package/scripts/cairn-safe-fs.mjs +246 -0
- package/scripts/cairn-state.mjs +80 -30
- package/scripts/cairn-state.sh +2 -0
- package/scripts/cairn-toolcheck.mjs +116 -34
- package/scripts/cairn.mjs +9 -8
- package/scripts/release-integrity-0.2.2.json +68 -0
- package/skills/cairn-plan/SKILL.md +23 -17
- package/skills/cairn-review/SKILL.md +1 -1
- package/skills/cairn-work/SKILL.md +39 -16
- package/templates/PLAN.md +1 -0
- package/templates/work-plan.md +23 -2
package/scripts/cairn-goal.mjs
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
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";
|
|
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 =
|
|
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
|
|
36
|
-
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
|
94
|
+
await safeMkdir(root, ".cairn");
|
|
95
|
+
await assertNoSymlinkComponents(root, path);
|
|
83
96
|
const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
|
|
84
97
|
try {
|
|
85
|
-
await
|
|
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(
|
|
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, "
|
|
193
|
+
const normalizedKind = requiredText(kind, "evidence kind");
|
|
156
194
|
const normalizedCommand = validReceiptCommand(command);
|
|
157
|
-
|
|
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 (
|
|
163
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
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
|
|
352
|
-
requiredText(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, "
|
|
536
|
+
requiredText(receipt.kind, "evidence.kind");
|
|
355
537
|
if (scope === "task") {
|
|
356
|
-
requiredText(receipt.taskId, "
|
|
357
|
-
if (!state.tasks.some((task) => task.id === receipt.taskId)) throw new Error(`
|
|
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
|
|
541
|
+
throw new Error("goal-scope evidence record must not contain taskId");
|
|
360
542
|
}
|
|
361
|
-
if (receipt.goalId !== state.goal.id) throw new Error("
|
|
362
|
-
if (receipt.planId !== state.goal.planId) throw new Error("
|
|
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("
|
|
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
|
|
370
|
-
|
|
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
|
|
380
|
-
|
|
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
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
return
|
|
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
|
|
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, "
|
|
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("
|
|
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
|
|
493
|
-
|
|
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 {
|