cairn-ai 0.2.2 → 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.
@@ -8,13 +8,13 @@ Run from the published or global package, not from the installed cache copy:
8
8
  bunx cairn-ai@latest upgrade
9
9
  ```
10
10
 
11
- The current custom lifecycle remains in use. The cached plugin root is a replacement target and must not upgrade itself.
11
+ The current custom marketplace lifecycle remains in use. The cached plugin root is a replacement target and must not upgrade itself.
12
12
 
13
13
  Actions:
14
14
 
15
- - Replace the Cairn installation in the Codex cache.
16
- - Recompute hook trust state and update settings.
17
- - Overwrite Claude Code mirror files with the latest content.
18
- - Overwrite Antigravity IDE/CLI skills and workflows mirror files with the latest content.
15
+ - Replace only files tracked by the ownership manifest whose current digest still matches the installed digest.
16
+ - Recompute Cairn-owned hook trust state without rewriting public feature or agent settings.
17
+ - Replace owned Claude Code and Antigravity mirror files with the latest content.
19
18
  - Refresh runtime locators for every installed surface.
20
- - Create `*.cairn-backup-*` backups before changing settings.
19
+ - Stage and validate all candidates before commit; rollback committed phases in reverse order on failure.
20
+ - Preserve and report a conflict for modified, unmanaged, or invalid legacy artifacts.
@@ -19,9 +19,9 @@ Procedure:
19
19
  9. Give implementation the test contract, failing evidence, exact file ownership, and constraints. Require minimal implementation and no repeated broad discovery. Preserve status, final-report, close-and-review, recursive bounded delegation, and main-agent fallback rules.
20
20
  10. Treat tool exit codes and bounded summaries as authoritative. Keep success output concise; expand only the failing test and related code context.
21
21
  11. For Heavy Path, run the recorded automated test command and record explicit `Tests:` evidence.
22
- 12. Run focused module acceptance, then one final surface integration check through `goal verify -- ...` so the tool exit code, bounded output, and watched-workspace fingerprint are recorded together.
22
+ 12. Run focused module acceptance, then one final surface integration check through `goal verify -- ...`. Verification defaults to 600,000 ms (10 minutes), may be shortened with `--timeout-ms`, and is capped at 3,600,000 ms (1 hour). The command runs without holding the state lock; evidence is recorded only if the starting goal/task identity and the pre/post watched fingerprint still match.
23
23
  13. Before package verification, inspect package lifecycle scripts. Run normal `npm pack --dry-run` by default. Content-producing or unknown lifecycle scripts must never use `--ignore-scripts`; only absent or proven content-neutral scripts may use it when the prior full check remains fresh.
24
- 14. Record evidence bound to goal/task/plan. Missing, failed, skipped, stale, or placeholder evidence never completes work; relevant mutation invalidates affected evidence.
24
+ 14. Record evidence at the semantic boundary that proves the claimed behavior, bound to goal/task/plan. Missing, failed, skipped, stale, or placeholder evidence never completes work; relevant mutation invalidates affected evidence.
25
25
  15. Complete the task and continue the goal. Use `--quiet` on successful state mutations; workers report only their assigned task and never select the next one.
26
26
  16. Update the plan, goal state, and `PLAN.md` only after evidence exists.
27
27
  17. Answer side questions briefly and resume unless explicitly asked to pause, stop, or switch.
@@ -29,7 +29,7 @@ The goal is not to make prompts long by model. The goal is to stabilize the same
29
29
  - Treat missing, failed, skipped, stale, or placeholder evidence as failure. Task evidence records must be bound to the current goal, task, and plan.
30
30
  - Treat the repository Cairn state as the fail-closed transition authority. Advance the matching Codex UI plan step only after the evidence-gated repository task transition succeeds.
31
31
  - Spend reasoning on a focused executable test contract before implementation: requirements, invariants, boundaries, and failure modes. Give implementation only that contract, failing evidence, file scope, and constraints; require the minimum passing change.
32
- - Run verification through `goal verify -- ...`; treat tool exit codes and bounded machine summaries as authoritative. Compress success output and expand context only for failing tests and related code. The exact argv, output digest, and watched-workspace fingerprint make stale evidence fail closed.
32
+ - Run verification through `goal verify -- ...`; its default timeout is 600,000 ms and its maximum is 3,600,000 ms. Treat tool exit codes and bounded machine summaries as authoritative. Record evidence only when the starting goal/task identity and pre/post watched fingerprint still match, and choose the watch set and command at the semantic boundary that proves the claim. Compress success output and expand context only for failing tests and related code.
33
33
  - Use a verification ladder: focused contract tests, one full check after the final change, then package dry-run while the full check remains fresh. Inspect package lifecycle scripts first and run normal `npm pack --dry-run` by default. Content-producing or unknown lifecycle scripts must never use `--ignore-scripts`; only absent or proven content-neutral scripts may use it.
34
34
  - Every implementation task must pass module acceptance verification and surface integration verification.
35
35
  - Before mutating external state, run the closest available dry-run, check, plan, diff, validate, or equivalent command.
@@ -42,7 +42,7 @@ Codex-family models are strong at small implementation tasks, explicit tool use,
42
42
  ### Implementation
43
43
 
44
44
  - Invest Codex reasoning in executable test design first, then hand the bounded failing contract to implementation and require the minimum passing patch.
45
- - Run verification through `goal verify -- ...`. On success, retain exact argv, tool exit code, pass count, output digest, and watched-workspace fingerprint. Expand diagnostics only for the failing test and its related file/symbol scope; reject stale evidence after related changes.
45
+ - Run verification through `goal verify -- ...`. Its default timeout is 600,000 ms (10 minutes), capped at 3,600,000 ms (1 hour). On success, retain exact argv, tool exit code, pass count, output digest, and watched-workspace fingerprint; commit evidence only when the starting goal/task identity and pre/post watched fingerprint match. Choose the watch set and command at the semantic boundary that proves the claim. Expand diagnostics only for the failing test and its related file/symbol scope; reject stale evidence after related changes.
46
46
  - If acting as the user-called/main agent, orchestrate the work and delegate actual implementation edits to `worker` whenever subagent tools are available, regardless of Light Path or Heavy Path.
47
47
  - If acting as the user-called/main agent, immediately relay received subagent status events to the user. If no mid-run reporting channel exists, relay observable events such as assignment, waiting, and final completion.
48
48
  - If acting as the user-called/main agent, close or release completed subagents after capturing their final report and evidence, then review the final report and evidence before marking the work complete.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cairn-ai",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "description": "Token-efficient agent harness with tool readiness for Codex, Claude Code, and Antigravity",
5
5
  "license": "MIT",
6
6
  "author": {
@@ -39,7 +39,7 @@
39
39
  "README.*.md"
40
40
  ],
41
41
  "scripts": {
42
- "check": "node --check scripts/cairn.mjs && node --check scripts/cairn-paths.mjs && node --check scripts/cairn-goal.mjs && node --check scripts/cairn-state.mjs && node --check scripts/cairn-lifecycle.mjs && node --check scripts/cairn-toolcheck.mjs && node -e \"JSON.parse(require('node:fs').readFileSync('package.json','utf8')); JSON.parse(require('node:fs').readFileSync('.codex-plugin/plugin.json','utf8')); JSON.parse(require('node:fs').readFileSync('hooks/hooks.json','utf8'))\" && npm test",
42
+ "check": "node --check scripts/cairn.mjs && node --check scripts/cairn-paths.mjs && node --check scripts/cairn-safe-fs.mjs && node --check scripts/cairn-goal.mjs && node --check scripts/cairn-state.mjs && node --check scripts/cairn-lifecycle.mjs && node --check scripts/cairn-toolcheck.mjs && node -e \"JSON.parse(require('node:fs').readFileSync('package.json','utf8')); JSON.parse(require('node:fs').readFileSync('.codex-plugin/plugin.json','utf8')); JSON.parse(require('node:fs').readFileSync('hooks/hooks.json','utf8')); JSON.parse(require('node:fs').readFileSync('scripts/release-integrity-0.2.2.json','utf8'))\" && npm test",
43
43
  "test": "node --test",
44
44
  "prepack": "npm run check"
45
45
  },
@@ -2,9 +2,10 @@
2
2
  import { spawnSync } from "node:child_process";
3
3
  import { createHash, randomUUID } from "node:crypto";
4
4
  import { lstatSync, readFileSync, readlinkSync, realpathSync, readdirSync } from "node:fs";
5
- import { mkdir, readFile, rename, rm, writeFile } from "node:fs/promises";
6
- import { dirname, join, relative, resolve, sep } from "node:path";
5
+ import { readFile, rename, rm } from "node:fs/promises";
6
+ import { join, relative, resolve, sep } from "node:path";
7
7
  import { fileURLToPath, pathToFileURL } from "node:url";
8
+ import { assertNoSymlinkComponents, safeMkdir, safeWriteFile, withStateLock } from "./cairn-safe-fs.mjs";
8
9
 
9
10
  export const GOAL_STATE_VERSION = 2;
10
11
  export const GOAL_STATUSES = new Set(["active", "paused", "blocked", "cancelled", "completed"]);
@@ -34,7 +35,9 @@ export function goalStatePath(root = process.cwd()) {
34
35
 
35
36
  export async function readGoalState({ root = process.cwd() } = {}) {
36
37
  try {
37
- const text = await readFile(goalStatePath(root), "utf8");
38
+ const path = goalStatePath(root);
39
+ await assertNoSymlinkComponents(root, path);
40
+ const text = await readFile(path, "utf8");
38
41
  return validateState(migrateState(JSON.parse(text)));
39
42
  } catch (error) {
40
43
  if (error?.code === "ENOENT") return null;
@@ -48,44 +51,51 @@ export async function startGoal({ root = process.cwd(), goal, planId, tasks, com
48
51
  const normalizedPlanId = requiredText(planId, "planId");
49
52
  if (!Array.isArray(tasks) || tasks.length === 0) throw new Error("tasks must contain at least one task");
50
53
 
51
- const existing = await readGoalState({ root });
52
- if (existing && !terminalGoalStatuses.has(existing.goal.status)) {
53
- throw new Error(`An active Cairn goal already exists (${existing.goal.id}); pause, block, cancel, or complete it first`);
54
- }
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
+ }
55
59
 
56
- const now = timestamp();
57
- const goalId = `goal-${randomUUID()}`;
58
- const normalizedTasks = tasks.map((task, index) => normalizeTask(task, index, index === 0 ? "active" : "pending"));
59
- const state = {
60
- schemaVersion: GOAL_STATE_VERSION,
61
- revision: 1,
62
- goal: {
63
- id: goalId,
64
- title: normalizedGoal,
65
- planId: normalizedPlanId,
66
- status: "active",
67
- completionCriteria: normalizeCriteria(completionCriteria),
68
- requiredEvidence: normalizeCriteria(requiredEvidence),
69
- evidencePolicy: validEvidencePolicy(evidencePolicy),
70
- ownerSessionId: optionalText(ownerSessionId, "ownerSessionId"),
71
- blocker: null,
72
- createdAt: now,
73
- updatedAt: now,
74
- },
75
- tasks: normalizedTasks,
76
- receipts: [],
77
- };
78
- await writeGoalState({ root, state });
79
- 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
+ });
80
85
  }
81
86
 
82
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 } = {}) {
83
92
  const validated = validateState(state);
84
93
  const path = goalStatePath(root);
85
- await mkdir(dirname(path), { recursive: true });
94
+ await safeMkdir(root, ".cairn");
95
+ await assertNoSymlinkComponents(root, path);
86
96
  const temporaryPath = `${path}.${process.pid}.${randomUUID()}.tmp`;
87
97
  try {
88
- await writeFile(temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
98
+ await safeWriteFile(root, temporaryPath, `${JSON.stringify(validated, null, 2)}\n`, "utf8");
89
99
  await rename(temporaryPath, path);
90
100
  } catch (error) {
91
101
  await rm(temporaryPath, { force: true }).catch(() => {});
@@ -175,6 +185,8 @@ async function recordEvidence({
175
185
  summary,
176
186
  workspaceFingerprint: fingerprint,
177
187
  watchPaths = [],
188
+ expectedIdentity,
189
+ expectedFingerprint,
178
190
  } = {}) {
179
191
  const normalizedScope = validReceiptScope(scope);
180
192
  const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
@@ -189,6 +201,16 @@ async function recordEvidence({
189
201
  return mutateGoal(root, (state) => {
190
202
  assertGoalMutable(state);
191
203
  const task = normalizedScope === "task" ? taskById(state, normalizedTaskId) : null;
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
+ }
192
214
  if (goalId !== undefined && goalId !== state.goal.id) throw new Error("evidence goalId does not match the active goal");
193
215
  if (planId !== undefined && planId !== state.goal.planId) throw new Error("evidence planId does not match the active goal");
194
216
  const receipt = {
@@ -219,6 +241,7 @@ export async function verifyAndRecord({
219
241
  argv,
220
242
  watchPaths = [],
221
243
  runner = spawnSync,
244
+ timeoutMs = 600_000,
222
245
  } = {}) {
223
246
  const normalizedArgv = validArgv(argv);
224
247
  const normalizedWatchPaths = normalizeWatchPaths(root, watchPaths);
@@ -228,13 +251,23 @@ export async function verifyAndRecord({
228
251
  if (!initialState) throw new Error("No Cairn goal state exists; start a goal first");
229
252
  assertGoalMutable(initialState);
230
253
  const normalizedTaskId = normalizedScope === "task" ? requiredText(taskId, "taskId") : undefined;
231
- if (normalizedScope === "task") taskById(initialState, normalizedTaskId);
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);
232
263
  const result = runner(normalizedArgv[0], normalizedArgv.slice(1), {
233
264
  cwd: resolve(root),
234
265
  encoding: "utf8",
235
266
  maxBuffer: 16 * 1024 * 1024,
236
267
  shell: false,
268
+ timeout: validTimeout(timeoutMs),
237
269
  });
270
+ if (result.error?.code === "ETIMEDOUT") throw new Error(`Verification timed out after ${timeoutMs}ms`);
238
271
  if (result.error) throw new Error(`Verification could not start: ${result.error.message}`);
239
272
  const exitCode = Number.isInteger(result.status) ? result.status : 1;
240
273
  const output = combinedOutput(result.stdout, result.stderr);
@@ -245,6 +278,14 @@ export async function verifyAndRecord({
245
278
 
246
279
  const outputDigest = sha256(output);
247
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
+ }
248
289
  const command = normalizedArgv.map((value) => JSON.stringify(value)).join(" ");
249
290
  const state = await recordEvidence({
250
291
  root,
@@ -259,6 +300,10 @@ export async function verifyAndRecord({
259
300
  summary: successSummary(output),
260
301
  workspaceFingerprint: fingerprint,
261
302
  watchPaths: normalizedWatchPaths,
303
+ goalId: identity.goalId,
304
+ planId: identity.planId,
305
+ expectedIdentity: identity,
306
+ expectedFingerprint: fingerprint,
262
307
  });
263
308
  return { state, evidence: state.receipts.at(-1) };
264
309
  }
@@ -312,7 +357,7 @@ export function evaluateStop(state, { subagent = false, agentId } = {}) {
312
357
  export async function handleGoalCli(args = process.argv.slice(2), { stdout = console.log } = {}) {
313
358
  const [firstCommand = "help", ...remaining] = args;
314
359
  const [command = "help", ...rest] = firstCommand === "goal" ? remaining : [firstCommand, ...remaining];
315
- const { options, positional, passthrough } = parseArgs(rest);
360
+ const { options, positional, passthrough } = parseArgs(rest, command);
316
361
  const root = options.root ?? process.cwd();
317
362
  const emit = options.quiet ? () => {} : stdout;
318
363
  if (command === "start") {
@@ -360,7 +405,7 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
360
405
  command: options.command,
361
406
  exitCode: numberOption(options.exitCode ?? options["exit-code"] ?? options.exit),
362
407
  timestamp: options.timestamp,
363
- goalId: options.goalId,
408
+ goalId: options.goalId ?? options["goal-id"],
364
409
  planId: options.plan,
365
410
  });
366
411
  emit(JSON.stringify(state));
@@ -374,6 +419,7 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
374
419
  scope: options.scope,
375
420
  watchPaths: splitValues(options.watch),
376
421
  argv: passthrough,
422
+ timeoutMs: options["timeout-ms"] === undefined ? 600_000 : positiveIntegerOption(options["timeout-ms"], "timeout-ms"),
377
423
  });
378
424
  emit(JSON.stringify({
379
425
  id: result.evidence.id,
@@ -398,12 +444,14 @@ export async function handleGoalCli(args = process.argv.slice(2), { stdout = con
398
444
  }
399
445
 
400
446
  async function mutateGoal(root, mutate) {
401
- const state = await readGoalState({ root });
402
- if (!state) throw new Error("No Cairn goal state exists; start a goal first");
403
- const next = await mutate(structuredClone(state));
404
- next.revision += 1;
405
- await writeGoalState({ root, state: next });
406
- 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
+ });
407
455
  }
408
456
 
409
457
  function normalizeTask(task, index, defaultStatus) {
@@ -786,11 +834,13 @@ function timestamp() {
786
834
  return new Date().toISOString();
787
835
  }
788
836
 
789
- function parseArgs(args) {
837
+ function parseArgs(args, command) {
790
838
  const options = {};
791
839
  const positional = [];
792
840
  const passthrough = [];
793
841
  const booleanOptions = new Set(["quiet"]);
842
+ const allowed = allowedOptions(command);
843
+ const seenCanonicalOptions = new Set();
794
844
  for (let index = 0; index < args.length; index += 1) {
795
845
  const value = args[index];
796
846
  if (value === "--") {
@@ -805,13 +855,54 @@ function parseArgs(args) {
805
855
  const equalsIndex = option.indexOf("=");
806
856
  const key = equalsIndex === -1 ? option : option.slice(0, equalsIndex);
807
857
  const inline = equalsIndex === -1 ? undefined : option.slice(equalsIndex + 1);
808
- if (inline !== undefined) options[key] = booleanOptions.has(key) ? inline !== "false" : inline;
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
+ }
809
867
  else if (booleanOptions.has(key)) options[key] = true;
810
- else options[key] = args[++index];
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
+ }
811
874
  }
812
875
  return { options, positional, passthrough };
813
876
  }
814
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] ?? [])]);
904
+ }
905
+
815
906
  function parseTasks(value) {
816
907
  if (Array.isArray(value)) return value;
817
908
  if (typeof value !== "string" || value.trim().length === 0) throw new Error("Provide tasks with --tasks JSON or positional task titles");
@@ -837,6 +928,19 @@ function numberOption(value) {
837
928
  return result;
838
929
  }
839
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
+
840
944
  function isCliEntry() {
841
945
  if (!process.argv[1]) return false;
842
946
  try {