executant 1.20.0 → 1.21.1

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.
Files changed (3) hide show
  1. package/README.md +44 -0
  2. package/dist/index.js +193 -60
  3. package/package.json +1 -1
package/README.md CHANGED
@@ -115,10 +115,43 @@ steps:
115
115
  This is pass {{item}} of 5. Review src/runner.ts for untested edge cases.
116
116
  ```
117
117
 
118
+ ## Variables at Runtime
119
+
120
+ Pass `--var KEY=VALUE` on the command line to override or supply workflow vars without editing the YAML:
121
+
122
+ ```bash
123
+ executant --var env=staging --var region=eu-west-1 deploy.yaml
124
+ ```
125
+
126
+ CLI vars override any same-named vars in the workflow's `vars:` section. Multiple `--var` flags are accepted.
127
+
118
128
  ## Quality Controls
119
129
 
120
130
  - **`llm_as_judge: true`** — after a step completes, Claude evaluates the output; retries with feedback on FAIL, up to 5×
121
131
  - **`self_healing: true`** — on script failure, Claude diagnoses and repairs the command, then re-runs it, up to 5×
132
+ - **`timeout_seconds: N`** — kill the step after N seconds and fail with exit code 3. Works for both script and prompt steps.
133
+
134
+ ```yaml
135
+ steps:
136
+ - name: install
137
+ command: npm ci
138
+ timeout_seconds: 120 # fail if install takes longer than 2 min
139
+
140
+ - name: implement
141
+ prompt: Implement the feature described above.
142
+ timeout_seconds: 1800 # 30 min ceiling for the Claude step
143
+ ```
144
+
145
+ ## Cancellation
146
+
147
+ Write a `.executant-cancel` file in the **same directory as the workflow YAML** to stop the workflow cleanly **between steps**:
148
+
149
+ ```bash
150
+ executant long-workflow.yaml &
151
+ touch .executant-cancel # workflow stops at the next step boundary; exits 4
152
+ ```
153
+
154
+ The file is deleted automatically. This is a cooperative, process-safe alternative to SIGTERM — no mid-step git state corruption. The cancel file is always resolved relative to the workflow file, so the location is predictable regardless of which directory you invoked executant from.
122
155
 
123
156
  ## Interjection
124
157
 
@@ -162,9 +195,20 @@ executant workflow.yaml # run a workflow
162
195
  executant --ci workflow.yaml # headless, NDJSON to stdout
163
196
  executant --step <name|n> wf.yaml # run one step by name or index
164
197
  executant --from-step <n> wf.yaml # resume from step n
198
+ executant --var KEY=VALUE wf.yaml # override a workflow var at runtime
165
199
  executant update # upgrade to latest version
166
200
  ```
167
201
 
202
+ ### Exit codes
203
+
204
+ | Code | Meaning |
205
+ |------|---------|
206
+ | `0` | All steps completed successfully |
207
+ | `1` | A step failed at runtime |
208
+ | `2` | YAML or variable validation error |
209
+ | `3` | A step timed out (`timeout_seconds` exceeded) |
210
+ | `4` | Cancelled via `.executant-cancel` file |
211
+
168
212
  ## Development
169
213
 
170
214
  ```bash
package/dist/index.js CHANGED
@@ -52,8 +52,8 @@ var init_update = __esm({
52
52
  // src/index.ts
53
53
  import React3 from "react";
54
54
  import { render } from "ink";
55
- import { readFileSync as readFileSync6 } from "node:fs";
56
- import { dirname as dirname4, join as join4 } from "node:path";
55
+ import { mkdirSync as mkdirSync4, readFileSync as readFileSync6 } from "node:fs";
56
+ import { dirname as dirname4, join as join5, resolve as resolve3 } from "node:path";
57
57
  import { fileURLToPath as fileURLToPath2 } from "node:url";
58
58
 
59
59
  // src/load-workflow.ts
@@ -154,7 +154,8 @@ var RawStepSchema = z.lazy(
154
154
  forEach: z.union([z.array(z.string()), z.string()]).optional(),
155
155
  repeat: z.number().int().positive().optional(),
156
156
  context: z.array(z.string()).optional(),
157
- steps: z.array(RawStepSchema).min(1).optional()
157
+ steps: z.array(RawStepSchema).min(1).optional(),
158
+ timeout_seconds: z.number().positive().optional()
158
159
  })
159
160
  );
160
161
  var RawWorkflowSchema = z.object({
@@ -162,7 +163,7 @@ var RawWorkflowSchema = z.object({
162
163
  steps: z.array(RawStepSchema),
163
164
  vars: z.record(z.string(), z.string()).optional()
164
165
  });
165
- function loadWorkflow(filePath2) {
166
+ function loadWorkflow(filePath2, cliVars2 = {}) {
166
167
  let raw;
167
168
  try {
168
169
  raw = readFileSync2(filePath2, "utf8");
@@ -179,7 +180,7 @@ function loadWorkflow(filePath2) {
179
180
  throw new Error(`Invalid workflow file "${filePath2}":
180
181
  ${detail}`);
181
182
  }
182
- const vars = doc.vars ?? {};
183
+ const vars = { ...doc.vars ?? {}, ...cliVars2 };
183
184
  const seen = /* @__PURE__ */ new Set();
184
185
  for (const step of doc.steps) {
185
186
  if (seen.has(step.name)) {
@@ -243,6 +244,9 @@ function convertInnerStep(step, vars, name, continueOnError) {
243
244
  maxHealingAttempts: step.max_healing_attempts,
244
245
  ...step.output && {
245
246
  output: resolveOutputFile(step.output, vars, name)
247
+ },
248
+ ...step.timeout_seconds !== void 0 && {
249
+ timeoutSeconds: step.timeout_seconds
246
250
  }
247
251
  };
248
252
  }
@@ -267,7 +271,10 @@ function convertInnerStep(step, vars, name, continueOnError) {
267
271
  llmAsJudge: step.llm_as_judge,
268
272
  allowedTools: step.allowed_tools,
269
273
  model: "sonnet",
270
- ...contextFiles.length > 0 && { contextFiles }
274
+ ...contextFiles.length > 0 && { contextFiles },
275
+ ...step.timeout_seconds !== void 0 && {
276
+ timeoutSeconds: step.timeout_seconds
277
+ }
271
278
  };
272
279
  }
273
280
  default:
@@ -309,14 +316,42 @@ function substituteVars(text, vars, stepName, field) {
309
316
 
310
317
  // src/runner.ts
311
318
  import { exec } from "node:child_process";
312
- import { mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
313
- import { dirname as dirname2 } from "node:path";
319
+ import {
320
+ existsSync,
321
+ mkdirSync,
322
+ readFileSync as readFileSync3,
323
+ unlinkSync,
324
+ writeFileSync
325
+ } from "node:fs";
326
+ import { dirname as dirname2, join as join2 } from "node:path";
314
327
  import { promisify } from "node:util";
315
328
  import { z as z2 } from "zod";
316
329
 
317
330
  // src/tasks/command.ts
318
331
  import { spawn } from "node:child_process";
319
332
 
333
+ // src/types.ts
334
+ var InterjectChannel = class {
335
+ _queue = [];
336
+ /** Called by the TUI when the user submits an interjection message. */
337
+ interject(message) {
338
+ this._queue.push(message);
339
+ }
340
+ /** Drains and returns any queued messages (for non-Claude steps to consume). */
341
+ consumeQueue() {
342
+ const q = this._queue.slice();
343
+ this._queue = [];
344
+ return q;
345
+ }
346
+ };
347
+ var TimeoutError = class extends Error {
348
+ exitCode = 3;
349
+ constructor(stepName, seconds) {
350
+ super(`Step "${stepName}" timed out after ${seconds}s`);
351
+ this.name = "TimeoutError";
352
+ }
353
+ };
354
+
320
355
  // src/tasks/stream.ts
321
356
  var AsyncQueue = class {
322
357
  buf = [];
@@ -332,8 +367,8 @@ var AsyncQueue = class {
332
367
  }
333
368
  next() {
334
369
  if (this.buf.length > 0) return Promise.resolve(this.buf.shift());
335
- return new Promise((resolve3) => {
336
- this.waiter = resolve3;
370
+ return new Promise((resolve4) => {
371
+ this.waiter = resolve4;
337
372
  });
338
373
  }
339
374
  async *[Symbol.asyncIterator]() {
@@ -369,11 +404,30 @@ async function* mergeStreamsToLines(...streams) {
369
404
  yield* q;
370
405
  }
371
406
  function waitForExit(proc) {
372
- return new Promise((resolve3, reject) => {
373
- proc.on("close", (code) => resolve3(code ?? 0));
407
+ return new Promise((resolve4, reject) => {
408
+ proc.on("close", (code) => resolve4(code ?? 0));
374
409
  proc.on("error", reject);
375
410
  });
376
411
  }
412
+ function startTimeout(proc, taskName, timeoutSeconds) {
413
+ if (timeoutSeconds == null) return { check: () => {
414
+ }, cancel: () => {
415
+ } };
416
+ let timedOut = false;
417
+ const timer = setTimeout(() => {
418
+ timedOut = true;
419
+ try {
420
+ proc.kill();
421
+ } catch {
422
+ }
423
+ }, timeoutSeconds * 1e3);
424
+ return {
425
+ check: () => {
426
+ if (timedOut) throw new TimeoutError(taskName, timeoutSeconds);
427
+ },
428
+ cancel: () => clearTimeout(timer)
429
+ };
430
+ }
377
431
 
378
432
  // src/tasks/command.ts
379
433
  var CommandError = class extends Error {
@@ -391,12 +445,22 @@ async function* runCommand(task) {
391
445
  const proc = spawn("bash", ["-c", task.command], {
392
446
  stdio: ["ignore", "pipe", "pipe"]
393
447
  });
394
- for await (const line of mergeStreamsToLines(proc.stdout, proc.stderr)) {
395
- yield { type: "output:text", index: -1, text: line };
396
- }
397
- const code = await waitForExit(proc);
398
- if (code !== 0) {
399
- throw new CommandError(code, task.command, `Command "${task.name}" exited with code ${code}`);
448
+ const timeout = startTimeout(proc, task.name, task.timeoutSeconds);
449
+ try {
450
+ for await (const line of mergeStreamsToLines(proc.stdout, proc.stderr)) {
451
+ yield { type: "output:text", index: -1, text: line };
452
+ }
453
+ const code = await waitForExit(proc);
454
+ timeout.check();
455
+ if (code !== 0) {
456
+ throw new CommandError(
457
+ code,
458
+ task.command,
459
+ `Command "${task.name}" exited with code ${code}`
460
+ );
461
+ }
462
+ } finally {
463
+ timeout.cancel();
400
464
  }
401
465
  }
402
466
 
@@ -458,6 +522,7 @@ async function* runClaude(task) {
458
522
  };
459
523
  process.once("SIGTERM", cleanup);
460
524
  process.once("SIGHUP", cleanup);
525
+ const timeout = startTimeout(proc, task.name, task.timeoutSeconds);
461
526
  const plainLines = [];
462
527
  try {
463
528
  for await (const line of mergeStreamsToLines(proc.stdout, proc.stderr)) {
@@ -474,8 +539,10 @@ async function* runClaude(task) {
474
539
  }
475
540
  }
476
541
  const code = await waitForExit(proc);
542
+ timeout.check();
477
543
  if (code !== 0) throw buildExitError(code, plainLines);
478
544
  } finally {
545
+ timeout.cancel();
479
546
  process.off("SIGTERM", cleanup);
480
547
  process.off("SIGHUP", cleanup);
481
548
  }
@@ -560,10 +627,28 @@ function shouldSkipStep(stepNumber, name, options2) {
560
627
  }
561
628
  return options2.fromStep !== void 0 && stepNumber < options2.fromStep[0];
562
629
  }
630
+ var LAST_OUTPUT_MAX_LINES = 100;
563
631
  async function* runWorkflow(workflow2, options2 = {}, channel2) {
564
632
  const workflowStart = Date.now();
633
+ const cancelFile = join2(
634
+ options2.workDir ?? process.cwd(),
635
+ ".executant-cancel"
636
+ );
565
637
  yield { type: "workflow:start", workflow: workflow2 };
638
+ let lastStepOutput;
566
639
  for (const [i, task] of workflow2.tasks.entries()) {
640
+ if (existsSync(cancelFile)) {
641
+ try {
642
+ unlinkSync(cancelFile);
643
+ } catch {
644
+ }
645
+ yield {
646
+ type: "workflow:cancelled",
647
+ workflow: workflow2,
648
+ durationMs: Date.now() - workflowStart
649
+ };
650
+ return;
651
+ }
567
652
  const stepNumber = i + 1;
568
653
  if (shouldSkipStep(stepNumber, task.name, options2)) {
569
654
  yield { type: "step:skip", index: i, name: task.name };
@@ -572,14 +657,20 @@ async function* runWorkflow(workflow2, options2 = {}, channel2) {
572
657
  const stepStart = Date.now();
573
658
  yield { type: "step:start", index: i, name: task.name };
574
659
  const from = options2.fromStep && options2.fromStep[0] === stepNumber ? options2.fromStep.slice(1) : void 0;
660
+ const lines = [];
575
661
  try {
576
662
  for await (const event of runStep(task, from, channel2)) {
577
663
  if (event.type === "step:iteration" || event.type === "step:inner" || event.type === "output:text" || event.type === "output:tool") {
664
+ if (event.type === "output:text") {
665
+ if (lines.length >= LAST_OUTPUT_MAX_LINES) lines.shift();
666
+ lines.push(event.text);
667
+ }
578
668
  yield { ...event, index: i };
579
669
  } else {
580
670
  yield event;
581
671
  }
582
672
  }
673
+ lastStepOutput = lines.join("\n") || void 0;
583
674
  yield {
584
675
  type: "step:complete",
585
676
  index: i,
@@ -588,14 +679,23 @@ async function* runWorkflow(workflow2, options2 = {}, channel2) {
588
679
  };
589
680
  } catch (err) {
590
681
  const error = normalizeError(err);
591
- yield { type: "step:error", index: i, name: task.name, error };
682
+ const lastOutput = lines.join("\n") || void 0;
683
+ lastStepOutput = lastOutput;
684
+ yield {
685
+ type: "step:error",
686
+ index: i,
687
+ name: task.name,
688
+ error,
689
+ lastOutput
690
+ };
592
691
  if (!task.continueOnError) throw error;
593
692
  }
594
693
  }
595
694
  yield {
596
695
  type: "workflow:complete",
597
696
  workflow: workflow2,
598
- durationMs: Date.now() - workflowStart
697
+ durationMs: Date.now() - workflowStart,
698
+ lastOutput: lastStepOutput
599
699
  };
600
700
  }
601
701
  async function* runStep(task, from, channel2) {
@@ -1058,6 +1158,7 @@ function reducer(state, event) {
1058
1158
  case "workflow:start":
1059
1159
  return { ...state, startTime: Date.now() };
1060
1160
  case "workflow:complete":
1161
+ case "workflow:cancelled":
1061
1162
  return { ...state, endTime: Date.now() };
1062
1163
  case "step:start":
1063
1164
  return updateTask(state, event.index, {
@@ -1454,10 +1555,15 @@ function App({
1454
1555
  if (event.type === "workflow:complete") {
1455
1556
  setTimeout(() => exit(), EXIT_DELAY_MS);
1456
1557
  }
1558
+ if (event.type === "workflow:cancelled") {
1559
+ process.exitCode = 4;
1560
+ setTimeout(() => exit(), EXIT_DELAY_MS);
1561
+ }
1457
1562
  }
1458
1563
  } catch (err) {
1459
1564
  if (!active) return;
1460
1565
  dispatch({ type: "log", level: "error", text: getErrorMessage(err) });
1566
+ process.exitCode = err instanceof TimeoutError ? 3 : 1;
1461
1567
  setTimeout(
1462
1568
  () => exit(err instanceof Error ? err : new Error(getErrorMessage(err))),
1463
1569
  EXIT_DELAY_MS
@@ -1601,8 +1707,8 @@ function App({
1601
1707
  }
1602
1708
 
1603
1709
  // src/plan.ts
1604
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
1605
- import { join as join2, resolve } from "node:path";
1710
+ import { existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
1711
+ import { join as join3, resolve } from "node:path";
1606
1712
  import { dump as dumpYaml } from "js-yaml";
1607
1713
  import { z as z3 } from "zod";
1608
1714
  import { zodToJsonSchema as zodToJsonSchema2 } from "zod-to-json-schema";
@@ -1630,7 +1736,7 @@ function walkUp(startDir, check) {
1630
1736
  while (true) {
1631
1737
  const found = check(dir);
1632
1738
  if (found !== null) return found;
1633
- const parent = join2(dir, "..");
1739
+ const parent = join3(dir, "..");
1634
1740
  if (resolve(parent) === resolve(dir)) return null;
1635
1741
  dir = parent;
1636
1742
  }
@@ -1638,13 +1744,13 @@ function walkUp(startDir, check) {
1638
1744
  function findGitRoot(startDir) {
1639
1745
  return walkUp(
1640
1746
  startDir,
1641
- (dir) => existsSync(join2(dir, ".git")) ? dir : null
1747
+ (dir) => existsSync2(join3(dir, ".git")) ? dir : null
1642
1748
  );
1643
1749
  }
1644
1750
  function findProjectRoot(startDir) {
1645
1751
  return walkUp(startDir, (dir) => {
1646
- const candidate = join2(dir, ".claude", "executant.local", "tasks");
1647
- return existsSync(candidate) ? candidate : null;
1752
+ const candidate = join3(dir, ".claude", "executant.local", "tasks");
1753
+ return existsSync2(candidate) ? candidate : null;
1648
1754
  });
1649
1755
  }
1650
1756
  function isSimpleRequest(description) {
@@ -1684,7 +1790,7 @@ Examples:
1684
1790
  console.error("Error: -f/--file requires a file path argument");
1685
1791
  process.exit(1);
1686
1792
  }
1687
- if (!existsSync(filePath2)) {
1793
+ if (!existsSync2(filePath2)) {
1688
1794
  console.error(`Error: File not found: ${filePath2}`);
1689
1795
  process.exit(1);
1690
1796
  }
@@ -1712,14 +1818,14 @@ Examples:
1712
1818
  let taskDir = findProjectRoot(process.cwd());
1713
1819
  if (!taskDir) {
1714
1820
  const base = findGitRoot(process.cwd()) ?? process.cwd();
1715
- taskDir = join2(base, ".claude", "executant.local", "tasks");
1821
+ taskDir = join3(base, ".claude", "executant.local", "tasks");
1716
1822
  mkdirSync2(taskDir, { recursive: true });
1717
1823
  }
1718
- const todoDir = join2(taskDir, "todo");
1824
+ const todoDir = join3(taskDir, "todo");
1719
1825
  mkdirSync2(todoDir, { recursive: true });
1720
1826
  const slug = slugify(description);
1721
1827
  const ts = timestamp();
1722
- const taskFile = join2(todoDir, `${ts}-${slug}.yaml`);
1828
+ const taskFile = join3(todoDir, `${ts}-${slug}.yaml`);
1723
1829
  return { description, taskFile, todoDir, fast };
1724
1830
  }
1725
1831
  async function runPass3Judge(description, workflow2) {
@@ -2037,7 +2143,7 @@ ${PLAN_SYSTEM_RULES}`,
2037
2143
  }
2038
2144
 
2039
2145
  // src/refine.ts
2040
- import { existsSync as existsSync2, readFileSync as readFileSync5 } from "node:fs";
2146
+ import { existsSync as existsSync3, readFileSync as readFileSync5 } from "node:fs";
2041
2147
  import { load as loadYaml } from "js-yaml";
2042
2148
  var PLAN_REFINE_PROMPT = loadPrompt("plan-refine");
2043
2149
  var PLAN_SYSTEM_RULES2 = loadPrompt("plan-system-rules");
@@ -2064,7 +2170,7 @@ Examples:
2064
2170
  console.error("Usage: executant refine <task-file> [INSTRUCTIONS]");
2065
2171
  process.exit(1);
2066
2172
  }
2067
- if (!existsSync2(taskFile)) {
2173
+ if (!existsSync3(taskFile)) {
2068
2174
  console.error(`Error: File not found: ${taskFile}`);
2069
2175
  process.exit(1);
2070
2176
  }
@@ -2089,7 +2195,7 @@ Examples:
2089
2195
  console.error("Error: -f/--file requires a file path argument");
2090
2196
  process.exit(1);
2091
2197
  }
2092
- if (!existsSync2(filePath2)) {
2198
+ if (!existsSync3(filePath2)) {
2093
2199
  console.error(`Error: File not found: ${filePath2}`);
2094
2200
  process.exit(1);
2095
2201
  }
@@ -2325,13 +2431,13 @@ function PlanApp({ description, events: events2 }) {
2325
2431
  }
2326
2432
 
2327
2433
  // src/logger.ts
2328
- import { appendFileSync, existsSync as existsSync3, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
2329
- import { dirname as dirname3, join as join3, resolve as resolve2 } from "node:path";
2434
+ import { appendFileSync, existsSync as existsSync4, mkdirSync as mkdirSync3, writeFileSync as writeFileSync3 } from "node:fs";
2435
+ import { dirname as dirname3, join as join4, resolve as resolve2 } from "node:path";
2330
2436
  function findExecutantLocalDir(startDir) {
2331
2437
  let dir = resolve2(startDir);
2332
2438
  while (true) {
2333
- const candidate = join3(dir, ".claude", "executant.local");
2334
- if (existsSync3(candidate)) return candidate;
2439
+ const candidate = join4(dir, ".claude", "executant.local");
2440
+ if (existsSync4(candidate)) return candidate;
2335
2441
  const parent = dirname3(dir);
2336
2442
  if (parent === dir) return null;
2337
2443
  dir = parent;
@@ -2340,7 +2446,7 @@ function findExecutantLocalDir(startDir) {
2340
2446
  function resolveLogDir(workflowFilePath) {
2341
2447
  const startDir = dirname3(resolve2(workflowFilePath));
2342
2448
  const executantLocal = findExecutantLocalDir(startDir);
2343
- return executantLocal ? join3(executantLocal, "logs") : join3(startDir, "logs");
2449
+ return executantLocal ? join4(executantLocal, "logs") : join4(startDir, "logs");
2344
2450
  }
2345
2451
  var INIT_STATE = {
2346
2452
  logFile: "",
@@ -2353,7 +2459,7 @@ function appendLog(logFile, text) {
2353
2459
  }
2354
2460
  function onWorkflowStart(ctx, s) {
2355
2461
  mkdirSync3(ctx.logDir, { recursive: true });
2356
- const logFile = join3(ctx.logDir, `${ctx.ts}_${ctx.slug}.log`);
2462
+ const logFile = join4(ctx.logDir, `${ctx.ts}_${ctx.slug}.log`);
2357
2463
  writeFileSync3(
2358
2464
  logFile,
2359
2465
  `# Execution Log
@@ -2450,6 +2556,7 @@ function reduce(ctx, s, event) {
2450
2556
  case "log":
2451
2557
  return onLogMessage(s, event.level, event.text);
2452
2558
  case "workflow:complete":
2559
+ case "workflow:cancelled":
2453
2560
  return onWorkflowComplete(ctx, s);
2454
2561
  default:
2455
2562
  return s;
@@ -2481,25 +2588,10 @@ async function* withLogger(gen, logger2) {
2481
2588
  }
2482
2589
  }
2483
2590
 
2484
- // src/types.ts
2485
- var InterjectChannel = class {
2486
- _queue = [];
2487
- /** Called by the TUI when the user submits an interjection message. */
2488
- interject(message) {
2489
- this._queue.push(message);
2490
- }
2491
- /** Drains and returns any queued messages (for non-Claude steps to consume). */
2492
- consumeQueue() {
2493
- const q = this._queue.slice();
2494
- this._queue = [];
2495
- return q;
2496
- }
2497
- };
2498
-
2499
2591
  // src/index.ts
2500
2592
  var CURRENT_VERSION = JSON.parse(
2501
2593
  readFileSync6(
2502
- join4(dirname4(fileURLToPath2(import.meta.url)), "../package.json"),
2594
+ join5(dirname4(fileURLToPath2(import.meta.url)), "../package.json"),
2503
2595
  "utf-8"
2504
2596
  )
2505
2597
  ).version;
@@ -2558,6 +2650,7 @@ Options:
2558
2650
  --ci Headless mode \u2014 print events as NDJSON, no TUI
2559
2651
  --step <name|index> Run only the named step or step at 1-based index
2560
2652
  --from-step <n> Resume from step n (e.g. 3, 3.2, 2.5.4.3 \u2014 1-based path)
2653
+ --var KEY=VALUE Override or supply a workflow var at runtime (repeatable)
2561
2654
  --help, -h Show this help
2562
2655
 
2563
2656
  Commands:
@@ -2599,6 +2692,18 @@ YAML \u2014 script step fields (type: script | command, or inferred when command
2599
2692
  self_healing bool On failure, Claude diagnoses and fixes iteratively
2600
2693
  up to 5 attempts with accumulated context (default: false)
2601
2694
  max_healing_attempts int Override max self-healing retries (default: 5)
2695
+ timeout_seconds number Kill the step and fail with exit code 3 after N seconds
2696
+
2697
+ Cancellation:
2698
+ Write a .executant-cancel file in the working directory to stop execution
2699
+ cleanly between steps (exit code 4). The file is deleted automatically.
2700
+
2701
+ Exit codes:
2702
+ 0 All steps completed successfully
2703
+ 1 A step failed at runtime
2704
+ 2 YAML or variable validation error
2705
+ 3 A step timed out (timeout_seconds exceeded)
2706
+ 4 Cancelled via .executant-cancel file
2602
2707
 
2603
2708
  YAML \u2014 log step fields (type: log, or inferred when message is present and prompt is absent):
2604
2709
  message string Text to emit as a progress marker
@@ -2624,6 +2729,7 @@ Example:
2624
2729
  var ciMode = false;
2625
2730
  var stepFilter;
2626
2731
  var fromStep;
2732
+ var cliVars = {};
2627
2733
  var positional = [];
2628
2734
  for (let i = 0; i < rawArgs.length; i++) {
2629
2735
  const a = rawArgs[i];
@@ -2649,6 +2755,18 @@ for (let i = 0; i < rawArgs.length; i++) {
2649
2755
  process.exit(1);
2650
2756
  }
2651
2757
  fromStep = parts;
2758
+ } else if (a === "--var") {
2759
+ if (!rawArgs[i + 1]) {
2760
+ console.error("--var requires a KEY=VALUE argument");
2761
+ process.exit(1);
2762
+ }
2763
+ const pair = rawArgs[++i];
2764
+ const eq = pair.indexOf("=");
2765
+ if (eq <= 0) {
2766
+ console.error(`--var value must be KEY=VALUE, got: ${pair}`);
2767
+ process.exit(1);
2768
+ }
2769
+ cliVars[pair.slice(0, eq)] = pair.slice(eq + 1);
2652
2770
  } else {
2653
2771
  positional.push(a);
2654
2772
  }
@@ -2660,12 +2778,21 @@ if (!filePath) {
2660
2778
  }
2661
2779
  var workflow;
2662
2780
  try {
2663
- workflow = loadWorkflow(filePath);
2781
+ workflow = loadWorkflow(filePath, cliVars);
2664
2782
  } catch (err) {
2665
2783
  console.error(getErrorMessage(err));
2666
- process.exit(1);
2784
+ process.exit(2);
2667
2785
  }
2668
- var options = { stepFilter, fromStep };
2786
+ var localDir = findExecutantLocalDir(dirname4(resolve3(filePath)));
2787
+ if (localDir) {
2788
+ mkdirSync4(join5(localDir, "tasks", "todo"), { recursive: true });
2789
+ mkdirSync4(join5(localDir, "tasks", "done"), { recursive: true });
2790
+ }
2791
+ var options = {
2792
+ stepFilter,
2793
+ fromStep,
2794
+ workDir: dirname4(resolve3(filePath))
2795
+ };
2669
2796
  var channel = new InterjectChannel();
2670
2797
  var rawEvents = runWorkflow(workflow, options, channel);
2671
2798
  var logger = createLogger(resolveLogDir(filePath), workflow.goal);
@@ -2680,11 +2807,17 @@ function errorReplacer(_key, value) {
2680
2807
  if (ciMode) {
2681
2808
  (async () => {
2682
2809
  for await (const event of events) {
2683
- process.stdout.write(JSON.stringify(event, errorReplacer) + "\n");
2810
+ const line = JSON.stringify(event, errorReplacer) + "\n";
2811
+ if (event.type === "workflow:cancelled") {
2812
+ process.stdout.write(line, () => process.exit(4));
2813
+ return;
2814
+ }
2815
+ process.stdout.write(line);
2684
2816
  }
2685
2817
  })().catch((err) => {
2818
+ const code = err instanceof TimeoutError ? 3 : 1;
2686
2819
  console.error(err);
2687
- process.exit(1);
2820
+ process.exit(code);
2688
2821
  });
2689
2822
  } else {
2690
2823
  render(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "executant",
3
- "version": "1.20.0",
3
+ "version": "1.21.1",
4
4
  "description": "Harness for YAML-defined workflows that enables stepping through Claude sessions and bash commands",
5
5
  "repository": {
6
6
  "type": "git",