executant 1.0.0 → 1.4.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.
package/README.md CHANGED
@@ -1,6 +1,11 @@
1
- # executant
1
+ <img width="1774" height="887" alt="e58fdd14-77a1-4207-99c2-fb8603e3f625" src="https://github.com/user-attachments/assets/8d57a6ee-0fd3-43c9-bdff-1538fe931337" />
2
2
 
3
- Harness for YAML-defined workflows that enables stepping through Claude sessions and bash commands. Define the steps, the quality criteria, and how failures recover -> Executant runs them.
3
+ # Executant
4
+
5
+ Harness for YAML-defined workflows that enables stepping through Claude sessions and bash commands.
6
+
7
+ ## Advisory
8
+ Built for personal use by Coston. Public for sharing the approach. Use at your own risk.
4
9
 
5
10
  ## Install
6
11
 
@@ -42,6 +47,13 @@ executant plan "convert all CoffeeScript files to TypeScript and run tests"
42
47
 
43
48
  Generates a workflow YAML in your project's task directory using a three-pass Claude pipeline (research → decompose → validate). Also accepts `-f file` or stdin.
44
49
 
50
+ For self-contained requests (repetition patterns, forEach loops, or anything that doesn't need codebase exploration), the research pass is skipped automatically — going straight to decompose + validate. Use `-q` / `--fast` to force-skip research for any request:
51
+
52
+ ```bash
53
+ executant plan -q "repeat the following prompt 20 times: review src/ for issues"
54
+ executant plan --fast "for each file in the list, run the linter"
55
+ ```
56
+
45
57
  ## Context & Variables
46
58
 
47
59
  Use `vars` to define shared values substituted as `{{var_name}}` in any prompt or command. Pair with `context` to inject file contents directly into a prompt at runtime, and `output` to pipe a script step's stdout into a file for downstream steps to read.
@@ -75,6 +87,16 @@ steps:
75
87
  command: npx eslint src/{{item}}
76
88
  ```
77
89
 
90
+ Use `repeat: N` as shorthand when there is no meaningful list — just a count. `{{item}}` is the 1-based iteration number:
91
+
92
+ ```yaml
93
+ steps:
94
+ - name: iterative audit
95
+ repeat: 5
96
+ prompt: |
97
+ This is pass {{item}} of 5. Review src/runner.ts for untested edge cases.
98
+ ```
99
+
78
100
  ## Quality Controls
79
101
 
80
102
  - **`llm_as_judge: true`** — after a step completes, Claude evaluates the output; retries with feedback on FAIL, up to 5×
@@ -92,30 +114,18 @@ steps:
92
114
  | `judge-demo.yaml` | LLM-as-judge retry loop |
93
115
  | `logging-demo.yaml` | Log steps, self-healing, judge |
94
116
  | `git-status-summary.yaml` | Real-world git workflow |
117
+ | `repeat-demo.yaml` | Running a step N times with `repeat` |
95
118
 
96
119
  See the [`examples/`](examples/) directory.
97
120
 
98
121
  ## CLI
99
122
 
100
123
  ```bash
101
- executant plan "description" # generate a workflow YAML
124
+ executant plan "description" # generate a workflow YAML (auto-detects fast path)
125
+ executant plan -q "description" # skip research pass (fast path)
102
126
  executant workflow.yaml # run a workflow
103
127
  executant --ci workflow.yaml # headless, NDJSON to stdout
104
128
  executant --step <name|n> wf.yaml # run one step by name or index
105
129
  executant --from-step <n> wf.yaml # resume from step n
106
130
  executant update # upgrade to latest version
107
131
  ```
108
-
109
- ## Development
110
-
111
- ```bash
112
- git clone https://github.com/coston/executant
113
- cd executant
114
- npm install
115
- npm run dev examples/hello-world.yaml # run without building
116
- npm test # run unit tests
117
- ```
118
-
119
- ## License
120
-
121
- MIT
package/dist/index.js CHANGED
@@ -14,34 +14,21 @@ var update_exports = {};
14
14
  __export(update_exports, {
15
15
  checkForUpdate: () => checkForUpdate,
16
16
  compareSemver: () => compareSemver,
17
- doUpdate: () => doUpdate,
18
- parseVersionsFromGitOutput: () => parseVersionsFromGitOutput
17
+ doUpdate: () => doUpdate
19
18
  });
20
19
  import { exec as exec2 } from "node:child_process";
21
20
  import { promisify as promisify2 } from "node:util";
22
21
  async function checkForUpdate(currentVersion) {
23
22
  try {
24
- const { stdout } = await execPromise2(
25
- "git ls-remote --tags https://github.com/coston/executant.git",
26
- { timeout: 5e3 }
27
- );
28
- const versions = parseVersionsFromGitOutput(stdout);
29
- const latest = versions.sort(compareSemver).at(-1);
23
+ const { stdout } = await execPromise2("npm view executant version", { timeout: 5e3 });
24
+ const latest = stdout.trim();
30
25
  return latest && isNewer(latest, currentVersion) ? latest : null;
31
26
  } catch {
32
27
  return null;
33
28
  }
34
29
  }
35
30
  async function doUpdate() {
36
- await execPromise2("npm install -g github:coston/executant");
37
- }
38
- function parseVersionsFromGitOutput(stdout) {
39
- const versions = [];
40
- for (const line of stdout.split("\n")) {
41
- const m = line.match(/refs\/tags\/v?(\d+\.\d+\.\d+)$/);
42
- if (m) versions.push(m[1]);
43
- }
44
- return versions;
31
+ await execPromise2("npm install -g executant");
45
32
  }
46
33
  function compareSemver(a, b) {
47
34
  const pa = a.split(".").map(Number);
@@ -65,9 +52,9 @@ var init_update = __esm({
65
52
  // src/index.ts
66
53
  import React3 from "react";
67
54
  import { render } from "ink";
68
- import { readFileSync as readFileSync5 } from "node:fs";
55
+ import { readFileSync as readFileSync6 } from "node:fs";
69
56
  import { dirname as dirname5, join as join5 } from "node:path";
70
- import { fileURLToPath as fileURLToPath4 } from "node:url";
57
+ import { fileURLToPath as fileURLToPath3 } from "node:url";
71
58
 
72
59
  // src/load-workflow.ts
73
60
  import { readFileSync } from "node:fs";
@@ -86,6 +73,7 @@ var RawStepSchema = z.object({
86
73
  llm_as_judge: z.boolean().optional(),
87
74
  allowed_tools: z.array(z.string()).optional(),
88
75
  forEach: z.union([z.array(z.string()), z.string()]).optional(),
76
+ repeat: z.number().int().positive().optional(),
89
77
  context: z.array(z.string()).optional()
90
78
  });
91
79
  var RawWorkflowSchema = z.object({
@@ -121,6 +109,20 @@ ${detail}`);
121
109
  function convertStep(step, vars) {
122
110
  const name = step.name;
123
111
  const continueOnError = step.continue_on_error ?? false;
112
+ if (step.repeat !== void 0 && step.forEach !== void 0) {
113
+ throw new Error(`Step "${name}" cannot have both repeat and forEach`);
114
+ }
115
+ if (step.repeat !== void 0) {
116
+ const items = Array.from({ length: step.repeat }, (_2, i) => String(i + 1));
117
+ const { repeat: _, ...innerStep } = step;
118
+ return {
119
+ type: "forEach",
120
+ name,
121
+ continueOnError,
122
+ forEach: items,
123
+ inner: convertInnerStep(innerStep, vars, name, continueOnError)
124
+ };
125
+ }
124
126
  if (step.forEach !== void 0) {
125
127
  const { forEach: _, ...innerStep } = step;
126
128
  return {
@@ -209,9 +211,8 @@ function substituteVars(text, vars, stepName, field) {
209
211
 
210
212
  // src/runner.ts
211
213
  import { exec } from "node:child_process";
212
- import { mkdirSync, readFileSync as readFileSync2, writeFileSync } from "node:fs";
213
- import { dirname, join } from "node:path";
214
- import { fileURLToPath } from "node:url";
214
+ import { mkdirSync, readFileSync as readFileSync3, writeFileSync } from "node:fs";
215
+ import { dirname as dirname2 } from "node:path";
215
216
  import { promisify } from "node:util";
216
217
  import { z as z2 } from "zod";
217
218
 
@@ -306,6 +307,16 @@ import { execSync, spawn as spawn2 } from "node:child_process";
306
307
  import { zodToJsonSchema } from "zod-to-json-schema";
307
308
 
308
309
  // src/lib/utils.ts
310
+ import { readFileSync as readFileSync2 } from "node:fs";
311
+ import { dirname, join } from "node:path";
312
+ import { fileURLToPath } from "node:url";
313
+ var PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "..", "prompts");
314
+ function stripPromptHeader(raw) {
315
+ return raw.replace(/^(#[^\n]*\n)+\n?/, "").trim();
316
+ }
317
+ function loadPrompt(name) {
318
+ return stripPromptHeader(readFileSync2(join(PROMPTS_DIR, `${name}.txt`), "utf8"));
319
+ }
309
320
  function findOutermostBraces(text) {
310
321
  const start = text.indexOf("{");
311
322
  if (start === -1) return null;
@@ -468,7 +479,6 @@ async function runClaudeStructured(task, schema) {
468
479
  }
469
480
 
470
481
  // src/runner.ts
471
- var PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "prompts");
472
482
  var JUDGE_RETRY_CONTEXT = loadPrompt("judge-retry-context");
473
483
  var SELF_HEALING_PROMPT = loadPrompt("self-healing-fix");
474
484
  var JUDGE_EVALUATION_PROMPT = loadPrompt("judge-evaluation");
@@ -535,7 +545,7 @@ async function* runStep(task) {
535
545
  if (task.output) {
536
546
  const lines = [];
537
547
  yield* collectLines(gen, lines);
538
- mkdirSync(dirname(task.output), { recursive: true });
548
+ mkdirSync(dirname2(task.output), { recursive: true });
539
549
  writeFileSync(task.output, lines.join("\n"), "utf8");
540
550
  } else {
541
551
  yield* gen;
@@ -691,7 +701,7 @@ async function* collectLines(gen, lines) {
691
701
  }
692
702
  function readContextFile(filePath2) {
693
703
  try {
694
- return readFileSync2(filePath2, "utf8");
704
+ return readFileSync3(filePath2, "utf8");
695
705
  } catch (err) {
696
706
  const msg = err instanceof Error ? err.message : String(err);
697
707
  throw new Error(`Context file "${filePath2}" could not be read: ${msg}`);
@@ -707,10 +717,6 @@ ${readContextFile(fp)}
707
717
 
708
718
  ${task.prompt}` };
709
719
  }
710
- function loadPrompt(name) {
711
- const raw = readFileSync2(join(PROMPTS_DIR, `${name}.txt`), "utf8");
712
- return raw.replace(/^(#[^\n]*\n)+\n?/, "");
713
- }
714
720
  function buildHealingPrompt(command, exitCode, output, attemptHistory) {
715
721
  return SELF_HEALING_PROMPT.replaceAll("{{COMMAND}}", command).replaceAll("{{EXIT_CODE}}", String(exitCode)).replaceAll("{{OUTPUT}}", output).replaceAll("{{ATTEMPT_HISTORY}}", attemptHistory);
716
722
  }
@@ -1136,20 +1142,18 @@ function App({ workflow: workflow2, events: events2, options: options2, updateCh
1136
1142
  }
1137
1143
 
1138
1144
  // src/plan.ts
1139
- import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
1140
- import { dirname as dirname2, join as join2, resolve } from "node:path";
1141
- import { fileURLToPath as fileURLToPath2 } from "node:url";
1145
+ import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
1146
+ import { join as join2, resolve } from "node:path";
1142
1147
  import { dump as dumpYaml } from "js-yaml";
1143
1148
  import { z as z3 } from "zod";
1144
1149
  import { zodToJsonSchema as zodToJsonSchema2 } from "zod-to-json-schema";
1145
- var PROMPTS_DIR2 = join2(dirname2(fileURLToPath2(import.meta.url)), "prompts");
1146
- var PLAN_RESEARCH_PROMPT = readFileSync3(join2(PROMPTS_DIR2, "plan-research.txt"), "utf8");
1147
- var PLAN_DECOMPOSE_PROMPT = readFileSync3(join2(PROMPTS_DIR2, "plan-decompose.txt"), "utf8");
1148
- var PLAN_JUDGE_PROMPT = readFileSync3(join2(PROMPTS_DIR2, "plan-judge.txt"), "utf8");
1149
- var PLAN_SYSTEM_RULES = readFileSync3(join2(PROMPTS_DIR2, "plan-system-rules.txt"), "utf8").trim();
1150
- var PLAN_RETRY_PARSE_ERROR = readFileSync3(join2(PROMPTS_DIR2, "plan-retry-parse-error.txt"), "utf8").trim();
1151
- var PLAN_RETRY_SCHEMA_ERROR = readFileSync3(join2(PROMPTS_DIR2, "plan-retry-schema-error.txt"), "utf8").trim();
1152
- var PLAN_RETRY_JUDGE = readFileSync3(join2(PROMPTS_DIR2, "plan-retry-judge.txt"), "utf8").trim();
1150
+ var PLAN_RESEARCH_PROMPT = loadPrompt("plan-research");
1151
+ var PLAN_DECOMPOSE_PROMPT = loadPrompt("plan-decompose");
1152
+ var PLAN_JUDGE_PROMPT = loadPrompt("plan-judge");
1153
+ var PLAN_SYSTEM_RULES = loadPrompt("plan-system-rules");
1154
+ var PLAN_RETRY_PARSE_ERROR = loadPrompt("plan-retry-parse-error");
1155
+ var PLAN_RETRY_SCHEMA_ERROR = loadPrompt("plan-retry-schema-error");
1156
+ var PLAN_RETRY_JUDGE = loadPrompt("plan-retry-judge");
1153
1157
  var MAX_PLAN_RETRIES = 3;
1154
1158
  var TOTAL_PLAN_STAGES = 3;
1155
1159
  var StepSchema = z3.object({
@@ -1197,15 +1201,29 @@ function findProjectRoot(startDir) {
1197
1201
  return existsSync(candidate) ? candidate : null;
1198
1202
  });
1199
1203
  }
1204
+ function isSimpleRequest(description) {
1205
+ if (/\b\d+\s+(times|iterations?|passes)\b/i.test(description)) return true;
1206
+ if (/\bfor\s+each\b/i.test(description)) return true;
1207
+ return false;
1208
+ }
1200
1209
  function parsePlanArgs(rawArgs2) {
1201
1210
  let description = "";
1202
- if (rawArgs2[0] === "-h" || rawArgs2[0] === "--help") {
1211
+ let fast = false;
1212
+ const args = rawArgs2.filter((a) => {
1213
+ if (a === "-q" || a === "--fast") {
1214
+ fast = true;
1215
+ return false;
1216
+ }
1217
+ return true;
1218
+ });
1219
+ if (args[0] === "-h" || args[0] === "--help") {
1203
1220
  console.log(`Usage: executant plan [OPTIONS] [DESCRIPTION]
1204
1221
 
1205
1222
  Generate a task plan from a description.
1206
1223
 
1207
1224
  Options:
1208
1225
  -f, --file <path> Read prompt from file
1226
+ -q, --fast Skip codebase research (auto-detected for simple tasks)
1209
1227
  -h, --help Show this help message
1210
1228
 
1211
1229
  Examples:
@@ -1214,8 +1232,8 @@ Examples:
1214
1232
  cat prompt.txt | executant plan`);
1215
1233
  process.exit(0);
1216
1234
  }
1217
- if (rawArgs2[0] === "-f" || rawArgs2[0] === "--file") {
1218
- const filePath2 = rawArgs2[1];
1235
+ if (args[0] === "-f" || args[0] === "--file") {
1236
+ const filePath2 = args[1];
1219
1237
  if (!filePath2) {
1220
1238
  console.error("Error: -f/--file requires a file path argument");
1221
1239
  process.exit(1);
@@ -1225,16 +1243,16 @@ Examples:
1225
1243
  process.exit(1);
1226
1244
  }
1227
1245
  try {
1228
- description = readFileSync3(filePath2, "utf8").trim();
1246
+ description = readFileSync4(filePath2, "utf8").trim();
1229
1247
  } catch {
1230
1248
  console.error(`Error: Cannot read file: ${filePath2}`);
1231
1249
  process.exit(1);
1232
1250
  }
1233
- } else if (rawArgs2.length > 0) {
1234
- description = rawArgs2.join(" ").trim();
1251
+ } else if (args.length > 0) {
1252
+ description = args.join(" ").trim();
1235
1253
  } else if (!process.stdin.isTTY) {
1236
1254
  try {
1237
- description = readFileSync3("/dev/stdin", "utf8").trim();
1255
+ description = readFileSync4("/dev/stdin", "utf8").trim();
1238
1256
  } catch {
1239
1257
  }
1240
1258
  }
@@ -1256,7 +1274,7 @@ Examples:
1256
1274
  const slug = slugify(description);
1257
1275
  const ts = timestamp();
1258
1276
  const taskFile = join2(todoDir, `${ts}-${slug}.yaml`);
1259
- return { description, taskFile, todoDir };
1277
+ return { description, taskFile, todoDir, fast };
1260
1278
  }
1261
1279
  async function runPass3Judge(description, workflow2) {
1262
1280
  try {
@@ -1275,38 +1293,48 @@ async function runPass3Judge(description, workflow2) {
1275
1293
  }
1276
1294
  async function* streamPlan(args) {
1277
1295
  const { description, taskFile } = args;
1296
+ const skipResearch = args.fast || isSimpleRequest(description);
1278
1297
  yield { type: "plan:start", description };
1279
- yield { type: "plan:stages", names: ["Research & Planning", "Decompose to Steps", "Validate"] };
1280
- yield { type: "plan:stage", stage: 1, total: TOTAL_PLAN_STAGES, name: "Research & Planning" };
1281
- const researchLines = [];
1282
- try {
1283
- const researchTask = {
1284
- type: "claude",
1285
- name: "plan:research",
1286
- prompt: PLAN_RESEARCH_PROMPT.replace("{{DESCRIPTION}}", description),
1287
- allowedTools: ["Read", "Glob", "Grep"],
1288
- permissionMode: "bypassPermissions",
1289
- model: "opus"
1290
- };
1291
- for await (const event of runClaude(researchTask)) {
1292
- if (event.type === "output:tool") {
1293
- yield { type: "plan:tool", tool: event.tool, input: event.input };
1294
- } else if (event.type === "output:text") {
1295
- researchLines.push(event.text);
1296
- yield { type: "plan:text", text: event.text };
1298
+ let researchDoc;
1299
+ if (skipResearch) {
1300
+ yield { type: "plan:stages", names: ["Decompose to Steps", "Validate"] };
1301
+ researchDoc = "No codebase research performed \u2014 the task is self-contained. Work directly from the user's original goal.";
1302
+ } else {
1303
+ yield { type: "plan:stages", names: ["Research & Planning", "Decompose to Steps", "Validate"] };
1304
+ yield { type: "plan:stage", stage: 1, total: TOTAL_PLAN_STAGES, name: "Research & Planning" };
1305
+ const researchLines = [];
1306
+ try {
1307
+ const researchTask = {
1308
+ type: "claude",
1309
+ name: "plan:research",
1310
+ prompt: PLAN_RESEARCH_PROMPT.replace("{{DESCRIPTION}}", description),
1311
+ allowedTools: ["Read", "Glob", "Grep"],
1312
+ permissionMode: "bypassPermissions",
1313
+ model: "opus"
1314
+ };
1315
+ for await (const event of runClaude(researchTask)) {
1316
+ if (event.type === "output:tool") {
1317
+ yield { type: "plan:tool", tool: event.tool, input: event.input };
1318
+ } else if (event.type === "output:text") {
1319
+ researchLines.push(event.text);
1320
+ yield { type: "plan:text", text: event.text };
1321
+ }
1297
1322
  }
1323
+ } catch (err) {
1324
+ const msg = err instanceof Error ? err.message : String(err);
1325
+ yield { type: "plan:error", message: `Research pass failed: ${msg}` };
1326
+ return;
1327
+ }
1328
+ researchDoc = researchLines.join("\n");
1329
+ if (!researchDoc.trim()) {
1330
+ yield { type: "plan:error", message: "Research pass produced no output \u2014 cannot decompose" };
1331
+ return;
1298
1332
  }
1299
- } catch (err) {
1300
- const msg = err instanceof Error ? err.message : String(err);
1301
- yield { type: "plan:error", message: `Research pass failed: ${msg}` };
1302
- return;
1303
- }
1304
- const researchDoc = researchLines.join("\n");
1305
- if (!researchDoc.trim()) {
1306
- yield { type: "plan:error", message: "Research pass produced no output \u2014 cannot decompose" };
1307
- return;
1308
1333
  }
1309
- yield { type: "plan:stage", stage: 2, total: TOTAL_PLAN_STAGES, name: "Decompose to Steps" };
1334
+ const decomposeStage = skipResearch ? 1 : 2;
1335
+ const validateStage = skipResearch ? 2 : 3;
1336
+ const totalStages = skipResearch ? 2 : TOTAL_PLAN_STAGES;
1337
+ yield { type: "plan:stage", stage: decomposeStage, total: totalStages, name: "Decompose to Steps" };
1310
1338
  let retryPrefix = "";
1311
1339
  for (let attempt = 0; attempt < MAX_PLAN_RETRIES; attempt++) {
1312
1340
  if (attempt > 0) {
@@ -1316,7 +1344,7 @@ async function* streamPlan(args) {
1316
1344
  maxAttempts: MAX_PLAN_RETRIES,
1317
1345
  reason: retryPrefix.replace(/\n/g, " ")
1318
1346
  };
1319
- yield { type: "plan:stage", stage: 2, total: TOTAL_PLAN_STAGES, name: "Decompose to Steps" };
1347
+ yield { type: "plan:stage", stage: decomposeStage, total: totalStages, name: "Decompose to Steps" };
1320
1348
  }
1321
1349
  const basePrompt = PLAN_DECOMPOSE_PROMPT.replace("{{DESCRIPTION}}", description).replace("{{RESEARCH_DOC}}", researchDoc);
1322
1350
  const decomposeTask = {
@@ -1373,7 +1401,7 @@ ${issues}` };
1373
1401
  retryPrefix = PLAN_RETRY_SCHEMA_ERROR.replace("{{ISSUES}}", issues);
1374
1402
  continue;
1375
1403
  }
1376
- yield { type: "plan:stage", stage: 3, total: TOTAL_PLAN_STAGES, name: "Validate" };
1404
+ yield { type: "plan:stage", stage: validateStage, total: totalStages, name: "Validate" };
1377
1405
  const judgeResult = await runPass3Judge(description, zodResult.data);
1378
1406
  if (judgeResult.skipped) {
1379
1407
  yield { type: "plan:warn", message: "Judge skipped due to error \u2014 proceeding without validation" };
@@ -1941,9 +1969,9 @@ async function* withLogger(gen, logger2) {
1941
1969
  }
1942
1970
 
1943
1971
  // src/retrospective.ts
1944
- import { existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "node:fs";
1972
+ import { existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
1945
1973
  import { basename, dirname as dirname4, join as join4, resolve as resolve3 } from "node:path";
1946
- import { fileURLToPath as fileURLToPath3 } from "node:url";
1974
+ import { fileURLToPath as fileURLToPath2 } from "node:url";
1947
1975
  import { spawnSync } from "node:child_process";
1948
1976
  import { load as parseYaml2 } from "js-yaml";
1949
1977
  import { z as z4 } from "zod";
@@ -1951,8 +1979,8 @@ var RetrospectiveOutputSchema = z4.object({
1951
1979
  improved_yaml: z4.string(),
1952
1980
  changelog: z4.string()
1953
1981
  });
1954
- var PROMPTS_DIR3 = join4(dirname4(fileURLToPath3(import.meta.url)), "prompts");
1955
- var RETROSPECTIVE_PROMPT = readFileSync4(join4(PROMPTS_DIR3, "retrospective-analysis.txt"), "utf8");
1982
+ var PROMPTS_DIR2 = join4(dirname4(fileURLToPath2(import.meta.url)), "prompts");
1983
+ var RETROSPECTIVE_PROMPT = readFileSync5(join4(PROMPTS_DIR2, "retrospective-analysis.txt"), "utf8");
1956
1984
  async function runRetrospective(workflowFilePath, workflow2, highlightsDir, runTimestamp) {
1957
1985
  try {
1958
1986
  await doRetrospective(workflowFilePath, workflow2, highlightsDir, runTimestamp);
@@ -1996,12 +2024,12 @@ ${metrics}
1996
2024
  `);
1997
2025
  console.log("Analyzing execution and generating improvements...\n");
1998
2026
  const highlightContents = runHighlights.map((f) => {
1999
- const content = readFileSync4(join4(highlightsDir, f), "utf8");
2027
+ const content = readFileSync5(join4(highlightsDir, f), "utf8");
2000
2028
  return `### ${f}
2001
2029
 
2002
2030
  ${content}`;
2003
2031
  }).join("\n\n---\n\n");
2004
- const originalYaml = readFileSync4(workflowFilePath, "utf8");
2032
+ const originalYaml = readFileSync5(workflowFilePath, "utf8");
2005
2033
  const taskName = basename(workflowFilePath, ".yaml");
2006
2034
  const prompt = RETROSPECTIVE_PROMPT.replaceAll("{{TASK_NAME}}", taskName).replaceAll("{{ORIGINAL_GOAL}}", workflow2.goal).replaceAll("{{ORIGINAL_YAML}}", originalYaml).replaceAll("{{HIGHLIGHTS}}", highlightContents).replaceAll("{{METRICS}}", metrics);
2007
2035
  const result = spawnSync(
@@ -2078,7 +2106,7 @@ function extractJson(text) {
2078
2106
 
2079
2107
  // src/index.ts
2080
2108
  var CURRENT_VERSION = JSON.parse(
2081
- readFileSync5(join5(dirname5(fileURLToPath4(import.meta.url)), "../package.json"), "utf-8")
2109
+ readFileSync6(join5(dirname5(fileURLToPath3(import.meta.url)), "../package.json"), "utf-8")
2082
2110
  ).version;
2083
2111
  var rawArgs = process.argv.slice(2);
2084
2112
  if (rawArgs[0] === "plan") {
@@ -51,6 +51,11 @@ Complete structure with all available options:
51
51
  "name": "foreach_prompt_step",
52
52
  "forEach": "git diff --name-only HEAD~1",
53
53
  "prompt": "Review {{item}} for issues and suggest improvements."
54
+ },
55
+ {
56
+ "name": "repeated_audit",
57
+ "repeat": 20,
58
+ "prompt": "Review the codebase for issues. This is pass {{item}} of 20."
54
59
  }
55
60
  ]
56
61
  }
@@ -63,6 +68,7 @@ Optional step fields (can be combined):
63
68
  - `continue_on_error: true` — Allow failures without stopping (script steps only)
64
69
  - `output: "var_name"` — Capture script step stdout to the file path named by this var
65
70
  - `context: ["var_name"]` — Inject file contents into a prompt step (prepended before the prompt text)
71
+ - `repeat: N` — Run this step N times sequentially (mutually exclusive with forEach). {{item}} is the 1-based iteration number.
66
72
 
67
73
  **Variable substitution**: Use `{{var_name}}` in any `prompt` or `command` to insert the variable's value.
68
74
 
@@ -114,6 +120,12 @@ If yes, extract it to `vars` and replace with `{{var_name}}`.
114
120
 
115
121
  **REQUIRED: Always use `forEach` instead of enumerating items inline in a prompt.**
116
122
 
123
+ **Use `repeat: N` when:**
124
+ - The user asks to run the same prompt or command multiple times ("do this 20 times", "repeat 5 times", "run N iterations")
125
+ - The step is identical each time — only the iteration number ({{item}}) differs
126
+ - Prefer `repeat` over `forEach` when there is no meaningful list of items — just a count
127
+ - NEVER expand "do X N times" into N separate steps — always use `repeat: N`
128
+
117
129
  ## Atomicity (MANDATORY)
118
130
 
119
131
  Each step must do ONE focused thing. If a step description contains "and" — split it.
@@ -10,14 +10,14 @@
10
10
  # {{DESCRIPTION}} - The user's natural language task description
11
11
  # ============================================================================
12
12
 
13
- You are a senior engineer performing codebase research to plan a task. Your output is a
14
- structured markdown document — NOT JSON. Explore freely, think carefully, and produce a
15
- thorough plan that a decomposition pass can convert to executable workflow steps.
13
+ Research the codebase and produce a structured markdown execution plan NOT JSON.
14
+ Explore freely, think carefully, and produce a thorough plan that a decomposition
15
+ pass can convert to executable workflow steps.
16
16
 
17
17
  ## Your Mission
18
18
 
19
- Research the codebase and produce an execution plan document for the task described at the
20
- bottom of this prompt. Use Read, Glob, and Grep to understand the project before planning.
19
+ Use Read, Glob, and Grep to understand the project, then produce an execution plan
20
+ document for the task described at the bottom of this prompt.
21
21
 
22
22
  ## Research Process
23
23
 
@@ -26,6 +26,9 @@ bottom of this prompt. Use Read, Glob, and Grep to understand the project before
26
26
  3. **Plan the approach** — Order the work logically, identify dependencies between steps
27
27
  4. **Find verification commands** — Locate lint, test, and build commands in package.json,
28
28
  Makefile, pyproject.toml, or similar config files
29
+ 5. **Detect repetition intent** — If the task description says "do X N times", "repeat N times",
30
+ "run N iterations", or similar, note this explicitly in the Step Breakdown section so Pass 2
31
+ emits a `repeat: N` step rather than N separate steps.
29
32
 
30
33
  ## Required Output Sections
31
34
 
@@ -75,6 +78,7 @@ Anything the step decomposer needs to know:
75
78
  - Environment dependencies or assumptions
76
79
  - Cross-step data flow (does one step's output feed the next?)
77
80
  - Steps that are safe to skip if they fail (`continue_on_error`)
81
+ - Repetition intent: if the description uses "N times" or "N iterations", flag it here so the decomposer uses `repeat: N`
78
82
 
79
83
  ---
80
84
 
package/package.json CHANGED
@@ -1,16 +1,21 @@
1
1
  {
2
2
  "name": "executant",
3
- "version": "1.0.0",
4
- "description": "TypeScript TUI workflow runner",
3
+ "version": "1.4.1",
4
+ "description": "Harness for YAML-defined workflows that enables stepping through Claude sessions and bash commands",
5
+ "repository": {
6
+ "type": "git",
7
+ "url": "https://github.com/coston/executant"
8
+ },
5
9
  "type": "module",
6
10
  "main": "dist/index.js",
7
11
  "bin": {
8
- "executant": "./dist/index.js"
12
+ "executant": "dist/index.js"
9
13
  },
10
14
  "files": [
11
15
  "dist"
12
16
  ],
13
17
  "scripts": {
18
+ "prepare": "husky",
14
19
  "bundle": "esbuild src/index.ts --bundle --platform=node --format=esm --packages=external --outfile=dist/index.js && rm -rf dist/prompts && cp -r src/prompts dist/prompts",
15
20
  "dev": "tsx src/index.ts",
16
21
  "start": "node dist/index.js",
@@ -30,7 +35,6 @@
30
35
  "@commitlint/cli": "^20.5.0",
31
36
  "@commitlint/config-conventional": "^20.5.0",
32
37
  "@semantic-release/git": "^10.0.1",
33
- "@semantic-release/npm": "^13.1.5",
34
38
  "@types/js-yaml": "^4.0.9",
35
39
  "@types/node": "^20.14.0",
36
40
  "@types/react": "^18.3.3",
@@ -53,16 +57,13 @@
53
57
  "*.{ts,tsx}": "eslint --fix"
54
58
  },
55
59
  "release": {
56
- "branches": [
57
- "main"
58
- ],
59
60
  "plugins": [
60
61
  "@semantic-release/commit-analyzer",
61
62
  "@semantic-release/release-notes-generator",
62
63
  [
63
64
  "@semantic-release/npm",
64
65
  {
65
- "npmPublish": true
66
+ "npmPublish": false
66
67
  }
67
68
  ],
68
69
  [