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 +27 -17
- package/dist/index.js +113 -85
- 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,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(
|
|
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
|
|
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
|
|
1140
|
-
import {
|
|
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
|
|
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();
|
|
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
|
-
|
|
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 (
|
|
1218
|
-
const filePath2 =
|
|
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 =
|
|
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 (
|
|
1234
|
-
description =
|
|
1251
|
+
} else if (args.length > 0) {
|
|
1252
|
+
description = args.join(" ").trim();
|
|
1235
1253
|
} else if (!process.stdin.isTTY) {
|
|
1236
1254
|
try {
|
|
1237
|
-
description =
|
|
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
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
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
|
-
|
|
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:
|
|
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:
|
|
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
|
|
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
|
|
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
|
|
1955
|
-
var RETROSPECTIVE_PROMPT =
|
|
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 =
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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.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": "
|
|
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
|
[
|