executant 1.0.0 → 1.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -17
- package/dist/index.js +116 -87
- package/dist/prompts/plan-decompose.txt +12 -0
- package/dist/prompts/plan-research.txt +9 -5
- package/package.json +9 -8
package/README.md
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
55
|
+
import { readFileSync as readFileSync6 } from "node:fs";
|
|
69
56
|
import { dirname as dirname5, join as join5 } from "node:path";
|
|
70
|
-
import { fileURLToPath as
|
|
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
|
|
213
|
-
import { dirname
|
|
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,17 @@ 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 { basename, dirname, join } from "node:path";
|
|
312
|
+
import { fileURLToPath } from "node:url";
|
|
313
|
+
var __dir = dirname(fileURLToPath(import.meta.url));
|
|
314
|
+
var PROMPTS_DIR = basename(__dir) === "lib" ? join(__dir, "..", "prompts") : join(__dir, "prompts");
|
|
315
|
+
function stripPromptHeader(raw) {
|
|
316
|
+
return raw.replace(/^(#[^\n]*\n)+\n?/, "").trim();
|
|
317
|
+
}
|
|
318
|
+
function loadPrompt(name) {
|
|
319
|
+
return stripPromptHeader(readFileSync2(join(PROMPTS_DIR, `${name}.txt`), "utf8"));
|
|
320
|
+
}
|
|
309
321
|
function findOutermostBraces(text) {
|
|
310
322
|
const start = text.indexOf("{");
|
|
311
323
|
if (start === -1) return null;
|
|
@@ -468,7 +480,6 @@ async function runClaudeStructured(task, schema) {
|
|
|
468
480
|
}
|
|
469
481
|
|
|
470
482
|
// src/runner.ts
|
|
471
|
-
var PROMPTS_DIR = join(dirname(fileURLToPath(import.meta.url)), "prompts");
|
|
472
483
|
var JUDGE_RETRY_CONTEXT = loadPrompt("judge-retry-context");
|
|
473
484
|
var SELF_HEALING_PROMPT = loadPrompt("self-healing-fix");
|
|
474
485
|
var JUDGE_EVALUATION_PROMPT = loadPrompt("judge-evaluation");
|
|
@@ -535,7 +546,7 @@ async function* runStep(task) {
|
|
|
535
546
|
if (task.output) {
|
|
536
547
|
const lines = [];
|
|
537
548
|
yield* collectLines(gen, lines);
|
|
538
|
-
mkdirSync(
|
|
549
|
+
mkdirSync(dirname2(task.output), { recursive: true });
|
|
539
550
|
writeFileSync(task.output, lines.join("\n"), "utf8");
|
|
540
551
|
} else {
|
|
541
552
|
yield* gen;
|
|
@@ -691,7 +702,7 @@ async function* collectLines(gen, lines) {
|
|
|
691
702
|
}
|
|
692
703
|
function readContextFile(filePath2) {
|
|
693
704
|
try {
|
|
694
|
-
return
|
|
705
|
+
return readFileSync3(filePath2, "utf8");
|
|
695
706
|
} catch (err) {
|
|
696
707
|
const msg = err instanceof Error ? err.message : String(err);
|
|
697
708
|
throw new Error(`Context file "${filePath2}" could not be read: ${msg}`);
|
|
@@ -707,10 +718,6 @@ ${readContextFile(fp)}
|
|
|
707
718
|
|
|
708
719
|
${task.prompt}` };
|
|
709
720
|
}
|
|
710
|
-
function loadPrompt(name) {
|
|
711
|
-
const raw = readFileSync2(join(PROMPTS_DIR, `${name}.txt`), "utf8");
|
|
712
|
-
return raw.replace(/^(#[^\n]*\n)+\n?/, "");
|
|
713
|
-
}
|
|
714
721
|
function buildHealingPrompt(command, exitCode, output, attemptHistory) {
|
|
715
722
|
return SELF_HEALING_PROMPT.replaceAll("{{COMMAND}}", command).replaceAll("{{EXIT_CODE}}", String(exitCode)).replaceAll("{{OUTPUT}}", output).replaceAll("{{ATTEMPT_HISTORY}}", attemptHistory);
|
|
716
723
|
}
|
|
@@ -1136,20 +1143,18 @@ function App({ workflow: workflow2, events: events2, options: options2, updateCh
|
|
|
1136
1143
|
}
|
|
1137
1144
|
|
|
1138
1145
|
// src/plan.ts
|
|
1139
|
-
import { existsSync, mkdirSync as mkdirSync2, readFileSync as
|
|
1140
|
-
import {
|
|
1141
|
-
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1146
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync as readFileSync4, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1147
|
+
import { join as join2, resolve } from "node:path";
|
|
1142
1148
|
import { dump as dumpYaml } from "js-yaml";
|
|
1143
1149
|
import { z as z3 } from "zod";
|
|
1144
1150
|
import { zodToJsonSchema as zodToJsonSchema2 } from "zod-to-json-schema";
|
|
1145
|
-
var
|
|
1146
|
-
var
|
|
1147
|
-
var
|
|
1148
|
-
var
|
|
1149
|
-
var
|
|
1150
|
-
var
|
|
1151
|
-
var
|
|
1152
|
-
var PLAN_RETRY_JUDGE = readFileSync3(join2(PROMPTS_DIR2, "plan-retry-judge.txt"), "utf8").trim();
|
|
1151
|
+
var PLAN_RESEARCH_PROMPT = loadPrompt("plan-research");
|
|
1152
|
+
var PLAN_DECOMPOSE_PROMPT = loadPrompt("plan-decompose");
|
|
1153
|
+
var PLAN_JUDGE_PROMPT = loadPrompt("plan-judge");
|
|
1154
|
+
var PLAN_SYSTEM_RULES = loadPrompt("plan-system-rules");
|
|
1155
|
+
var PLAN_RETRY_PARSE_ERROR = loadPrompt("plan-retry-parse-error");
|
|
1156
|
+
var PLAN_RETRY_SCHEMA_ERROR = loadPrompt("plan-retry-schema-error");
|
|
1157
|
+
var PLAN_RETRY_JUDGE = loadPrompt("plan-retry-judge");
|
|
1153
1158
|
var MAX_PLAN_RETRIES = 3;
|
|
1154
1159
|
var TOTAL_PLAN_STAGES = 3;
|
|
1155
1160
|
var StepSchema = z3.object({
|
|
@@ -1197,15 +1202,29 @@ function findProjectRoot(startDir) {
|
|
|
1197
1202
|
return existsSync(candidate) ? candidate : null;
|
|
1198
1203
|
});
|
|
1199
1204
|
}
|
|
1205
|
+
function isSimpleRequest(description) {
|
|
1206
|
+
if (/\b\d+\s+(times|iterations?|passes)\b/i.test(description)) return true;
|
|
1207
|
+
if (/\bfor\s+each\b/i.test(description)) return true;
|
|
1208
|
+
return false;
|
|
1209
|
+
}
|
|
1200
1210
|
function parsePlanArgs(rawArgs2) {
|
|
1201
1211
|
let description = "";
|
|
1202
|
-
|
|
1212
|
+
let fast = false;
|
|
1213
|
+
const args = rawArgs2.filter((a) => {
|
|
1214
|
+
if (a === "-q" || a === "--fast") {
|
|
1215
|
+
fast = true;
|
|
1216
|
+
return false;
|
|
1217
|
+
}
|
|
1218
|
+
return true;
|
|
1219
|
+
});
|
|
1220
|
+
if (args[0] === "-h" || args[0] === "--help") {
|
|
1203
1221
|
console.log(`Usage: executant plan [OPTIONS] [DESCRIPTION]
|
|
1204
1222
|
|
|
1205
1223
|
Generate a task plan from a description.
|
|
1206
1224
|
|
|
1207
1225
|
Options:
|
|
1208
1226
|
-f, --file <path> Read prompt from file
|
|
1227
|
+
-q, --fast Skip codebase research (auto-detected for simple tasks)
|
|
1209
1228
|
-h, --help Show this help message
|
|
1210
1229
|
|
|
1211
1230
|
Examples:
|
|
@@ -1214,8 +1233,8 @@ Examples:
|
|
|
1214
1233
|
cat prompt.txt | executant plan`);
|
|
1215
1234
|
process.exit(0);
|
|
1216
1235
|
}
|
|
1217
|
-
if (
|
|
1218
|
-
const filePath2 =
|
|
1236
|
+
if (args[0] === "-f" || args[0] === "--file") {
|
|
1237
|
+
const filePath2 = args[1];
|
|
1219
1238
|
if (!filePath2) {
|
|
1220
1239
|
console.error("Error: -f/--file requires a file path argument");
|
|
1221
1240
|
process.exit(1);
|
|
@@ -1225,16 +1244,16 @@ Examples:
|
|
|
1225
1244
|
process.exit(1);
|
|
1226
1245
|
}
|
|
1227
1246
|
try {
|
|
1228
|
-
description =
|
|
1247
|
+
description = readFileSync4(filePath2, "utf8").trim();
|
|
1229
1248
|
} catch {
|
|
1230
1249
|
console.error(`Error: Cannot read file: ${filePath2}`);
|
|
1231
1250
|
process.exit(1);
|
|
1232
1251
|
}
|
|
1233
|
-
} else if (
|
|
1234
|
-
description =
|
|
1252
|
+
} else if (args.length > 0) {
|
|
1253
|
+
description = args.join(" ").trim();
|
|
1235
1254
|
} else if (!process.stdin.isTTY) {
|
|
1236
1255
|
try {
|
|
1237
|
-
description =
|
|
1256
|
+
description = readFileSync4("/dev/stdin", "utf8").trim();
|
|
1238
1257
|
} catch {
|
|
1239
1258
|
}
|
|
1240
1259
|
}
|
|
@@ -1256,7 +1275,7 @@ Examples:
|
|
|
1256
1275
|
const slug = slugify(description);
|
|
1257
1276
|
const ts = timestamp();
|
|
1258
1277
|
const taskFile = join2(todoDir, `${ts}-${slug}.yaml`);
|
|
1259
|
-
return { description, taskFile, todoDir };
|
|
1278
|
+
return { description, taskFile, todoDir, fast };
|
|
1260
1279
|
}
|
|
1261
1280
|
async function runPass3Judge(description, workflow2) {
|
|
1262
1281
|
try {
|
|
@@ -1275,38 +1294,48 @@ async function runPass3Judge(description, workflow2) {
|
|
|
1275
1294
|
}
|
|
1276
1295
|
async function* streamPlan(args) {
|
|
1277
1296
|
const { description, taskFile } = args;
|
|
1297
|
+
const skipResearch = args.fast || isSimpleRequest(description);
|
|
1278
1298
|
yield { type: "plan:start", description };
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1299
|
+
let researchDoc;
|
|
1300
|
+
if (skipResearch) {
|
|
1301
|
+
yield { type: "plan:stages", names: ["Decompose to Steps", "Validate"] };
|
|
1302
|
+
researchDoc = "No codebase research performed \u2014 the task is self-contained. Work directly from the user's original goal.";
|
|
1303
|
+
} else {
|
|
1304
|
+
yield { type: "plan:stages", names: ["Research & Planning", "Decompose to Steps", "Validate"] };
|
|
1305
|
+
yield { type: "plan:stage", stage: 1, total: TOTAL_PLAN_STAGES, name: "Research & Planning" };
|
|
1306
|
+
const researchLines = [];
|
|
1307
|
+
try {
|
|
1308
|
+
const researchTask = {
|
|
1309
|
+
type: "claude",
|
|
1310
|
+
name: "plan:research",
|
|
1311
|
+
prompt: PLAN_RESEARCH_PROMPT.replace("{{DESCRIPTION}}", description),
|
|
1312
|
+
allowedTools: ["Read", "Glob", "Grep"],
|
|
1313
|
+
permissionMode: "bypassPermissions",
|
|
1314
|
+
model: "opus"
|
|
1315
|
+
};
|
|
1316
|
+
for await (const event of runClaude(researchTask)) {
|
|
1317
|
+
if (event.type === "output:tool") {
|
|
1318
|
+
yield { type: "plan:tool", tool: event.tool, input: event.input };
|
|
1319
|
+
} else if (event.type === "output:text") {
|
|
1320
|
+
researchLines.push(event.text);
|
|
1321
|
+
yield { type: "plan:text", text: event.text };
|
|
1322
|
+
}
|
|
1297
1323
|
}
|
|
1324
|
+
} catch (err) {
|
|
1325
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
1326
|
+
yield { type: "plan:error", message: `Research pass failed: ${msg}` };
|
|
1327
|
+
return;
|
|
1328
|
+
}
|
|
1329
|
+
researchDoc = researchLines.join("\n");
|
|
1330
|
+
if (!researchDoc.trim()) {
|
|
1331
|
+
yield { type: "plan:error", message: "Research pass produced no output \u2014 cannot decompose" };
|
|
1332
|
+
return;
|
|
1298
1333
|
}
|
|
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
1334
|
}
|
|
1309
|
-
|
|
1335
|
+
const decomposeStage = skipResearch ? 1 : 2;
|
|
1336
|
+
const validateStage = skipResearch ? 2 : 3;
|
|
1337
|
+
const totalStages = skipResearch ? 2 : TOTAL_PLAN_STAGES;
|
|
1338
|
+
yield { type: "plan:stage", stage: decomposeStage, total: totalStages, name: "Decompose to Steps" };
|
|
1310
1339
|
let retryPrefix = "";
|
|
1311
1340
|
for (let attempt = 0; attempt < MAX_PLAN_RETRIES; attempt++) {
|
|
1312
1341
|
if (attempt > 0) {
|
|
@@ -1316,7 +1345,7 @@ async function* streamPlan(args) {
|
|
|
1316
1345
|
maxAttempts: MAX_PLAN_RETRIES,
|
|
1317
1346
|
reason: retryPrefix.replace(/\n/g, " ")
|
|
1318
1347
|
};
|
|
1319
|
-
yield { type: "plan:stage", stage:
|
|
1348
|
+
yield { type: "plan:stage", stage: decomposeStage, total: totalStages, name: "Decompose to Steps" };
|
|
1320
1349
|
}
|
|
1321
1350
|
const basePrompt = PLAN_DECOMPOSE_PROMPT.replace("{{DESCRIPTION}}", description).replace("{{RESEARCH_DOC}}", researchDoc);
|
|
1322
1351
|
const decomposeTask = {
|
|
@@ -1373,7 +1402,7 @@ ${issues}` };
|
|
|
1373
1402
|
retryPrefix = PLAN_RETRY_SCHEMA_ERROR.replace("{{ISSUES}}", issues);
|
|
1374
1403
|
continue;
|
|
1375
1404
|
}
|
|
1376
|
-
yield { type: "plan:stage", stage:
|
|
1405
|
+
yield { type: "plan:stage", stage: validateStage, total: totalStages, name: "Validate" };
|
|
1377
1406
|
const judgeResult = await runPass3Judge(description, zodResult.data);
|
|
1378
1407
|
if (judgeResult.skipped) {
|
|
1379
1408
|
yield { type: "plan:warn", message: "Judge skipped due to error \u2014 proceeding without validation" };
|
|
@@ -1941,9 +1970,9 @@ async function* withLogger(gen, logger2) {
|
|
|
1941
1970
|
}
|
|
1942
1971
|
|
|
1943
1972
|
// src/retrospective.ts
|
|
1944
|
-
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as
|
|
1945
|
-
import { basename, dirname as dirname4, join as join4, resolve as resolve3 } from "node:path";
|
|
1946
|
-
import { fileURLToPath as
|
|
1973
|
+
import { existsSync as existsSync3, mkdirSync as mkdirSync4, readdirSync as readdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync4 } from "node:fs";
|
|
1974
|
+
import { basename as basename2, dirname as dirname4, join as join4, resolve as resolve3 } from "node:path";
|
|
1975
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1947
1976
|
import { spawnSync } from "node:child_process";
|
|
1948
1977
|
import { load as parseYaml2 } from "js-yaml";
|
|
1949
1978
|
import { z as z4 } from "zod";
|
|
@@ -1951,8 +1980,8 @@ var RetrospectiveOutputSchema = z4.object({
|
|
|
1951
1980
|
improved_yaml: z4.string(),
|
|
1952
1981
|
changelog: z4.string()
|
|
1953
1982
|
});
|
|
1954
|
-
var
|
|
1955
|
-
var RETROSPECTIVE_PROMPT =
|
|
1983
|
+
var PROMPTS_DIR2 = join4(dirname4(fileURLToPath2(import.meta.url)), "prompts");
|
|
1984
|
+
var RETROSPECTIVE_PROMPT = readFileSync5(join4(PROMPTS_DIR2, "retrospective-analysis.txt"), "utf8");
|
|
1956
1985
|
async function runRetrospective(workflowFilePath, workflow2, highlightsDir, runTimestamp) {
|
|
1957
1986
|
try {
|
|
1958
1987
|
await doRetrospective(workflowFilePath, workflow2, highlightsDir, runTimestamp);
|
|
@@ -1996,13 +2025,13 @@ ${metrics}
|
|
|
1996
2025
|
`);
|
|
1997
2026
|
console.log("Analyzing execution and generating improvements...\n");
|
|
1998
2027
|
const highlightContents = runHighlights.map((f) => {
|
|
1999
|
-
const content =
|
|
2028
|
+
const content = readFileSync5(join4(highlightsDir, f), "utf8");
|
|
2000
2029
|
return `### ${f}
|
|
2001
2030
|
|
|
2002
2031
|
${content}`;
|
|
2003
2032
|
}).join("\n\n---\n\n");
|
|
2004
|
-
const originalYaml =
|
|
2005
|
-
const taskName =
|
|
2033
|
+
const originalYaml = readFileSync5(workflowFilePath, "utf8");
|
|
2034
|
+
const taskName = basename2(workflowFilePath, ".yaml");
|
|
2006
2035
|
const prompt = RETROSPECTIVE_PROMPT.replaceAll("{{TASK_NAME}}", taskName).replaceAll("{{ORIGINAL_GOAL}}", workflow2.goal).replaceAll("{{ORIGINAL_YAML}}", originalYaml).replaceAll("{{HIGHLIGHTS}}", highlightContents).replaceAll("{{METRICS}}", metrics);
|
|
2007
2036
|
const result = spawnSync(
|
|
2008
2037
|
"claude",
|
|
@@ -2078,7 +2107,7 @@ function extractJson(text) {
|
|
|
2078
2107
|
|
|
2079
2108
|
// src/index.ts
|
|
2080
2109
|
var CURRENT_VERSION = JSON.parse(
|
|
2081
|
-
|
|
2110
|
+
readFileSync6(join5(dirname5(fileURLToPath3(import.meta.url)), "../package.json"), "utf-8")
|
|
2082
2111
|
).version;
|
|
2083
2112
|
var rawArgs = process.argv.slice(2);
|
|
2084
2113
|
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
|
-
|
|
14
|
-
|
|
15
|
-
|
|
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
|
-
|
|
20
|
-
|
|
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.
|
|
4
|
-
"description": "
|
|
3
|
+
"version": "1.4.2",
|
|
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": "
|
|
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":
|
|
66
|
+
"npmPublish": false
|
|
66
67
|
}
|
|
67
68
|
],
|
|
68
69
|
[
|