@netnodeag/kraftwerk 0.2.0

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 (53) hide show
  1. package/README.md +339 -0
  2. package/bin/kraftwerk.js +24 -0
  3. package/dist/agent.d.ts +49 -0
  4. package/dist/agent.js +3 -0
  5. package/dist/cli/create-brief.d.ts +4 -0
  6. package/dist/cli/create-brief.js +146 -0
  7. package/dist/cli/doctor.d.ts +1 -0
  8. package/dist/cli/doctor.js +87 -0
  9. package/dist/cli/init.d.ts +1 -0
  10. package/dist/cli/init.js +81 -0
  11. package/dist/cli/kraftwerk.d.ts +1 -0
  12. package/dist/cli/kraftwerk.js +342 -0
  13. package/dist/cli/runs.d.ts +6 -0
  14. package/dist/cli/runs.js +120 -0
  15. package/dist/cli.d.ts +13 -0
  16. package/dist/cli.js +47 -0
  17. package/dist/config.d.ts +41 -0
  18. package/dist/config.js +97 -0
  19. package/dist/discover.d.ts +21 -0
  20. package/dist/discover.js +38 -0
  21. package/dist/envelope.d.ts +21 -0
  22. package/dist/envelope.js +61 -0
  23. package/dist/gates.d.ts +18 -0
  24. package/dist/gates.js +34 -0
  25. package/dist/harness.d.ts +66 -0
  26. package/dist/harness.js +14 -0
  27. package/dist/harnesses/claude.d.ts +2 -0
  28. package/dist/harnesses/claude.js +117 -0
  29. package/dist/harnesses/codex.d.ts +2 -0
  30. package/dist/harnesses/codex.js +158 -0
  31. package/dist/harnesses/pi.d.ts +2 -0
  32. package/dist/harnesses/pi.js +151 -0
  33. package/dist/harnesses/registry.d.ts +2 -0
  34. package/dist/harnesses/registry.js +19 -0
  35. package/dist/index.d.ts +23 -0
  36. package/dist/index.js +22 -0
  37. package/dist/remote.d.ts +23 -0
  38. package/dist/remote.js +57 -0
  39. package/dist/run.d.ts +66 -0
  40. package/dist/run.js +278 -0
  41. package/dist/runner/docker.d.ts +38 -0
  42. package/dist/runner/docker.js +166 -0
  43. package/dist/stats.d.ts +46 -0
  44. package/dist/stats.js +65 -0
  45. package/dist/validate.d.ts +8 -0
  46. package/dist/validate.js +33 -0
  47. package/dist/workflow.d.ts +24 -0
  48. package/dist/workflow.js +11 -0
  49. package/dist/yaml.d.ts +21 -0
  50. package/dist/yaml.js +324 -0
  51. package/package.json +52 -0
  52. package/runner/Dockerfile +43 -0
  53. package/schema/workflow.schema.json +244 -0
package/dist/yaml.js ADDED
@@ -0,0 +1,324 @@
1
+ import { mkdir, readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { Ajv } from "ajv";
4
+ import { parse } from "yaml";
5
+ import { envelopeContract } from "./envelope.js";
6
+ import { containsText, fileNonEmpty, slotsFilled } from "./gates.js";
7
+ import { Run } from "./run.js";
8
+ import { summaryTable } from "./stats.js";
9
+ import { runStamp } from "./workflow.js";
10
+ /**
11
+ * YAML-configured workflows (v1: a linear sequence of gated agent steps).
12
+ * Vocabulary borrows from GitHub Actions: `steps`, `runs-on` (the harness),
13
+ * `${{ request }}` / `${{ agent }}` interpolation.
14
+ *
15
+ * Canonical form is a FOLDER:
16
+ *
17
+ * src/workflows/tagline/
18
+ * workflow.yml # agents inline + steps; validated against
19
+ * prompts/ # schema/workflow.schema.json
20
+ * analysieren.md # referenced from a step: `prompt: prompts/analysieren.md`
21
+ *
22
+ * In folder mode, single-line `prompt:`, `persona:`, and `workspace:` values
23
+ * are treated as file references inside the folder; multiline values stay
24
+ * inline. A single `.yml` file (everything inline) is still supported.
25
+ *
26
+ * Steps come in two kinds: agent steps (`agent` + `prompt`) and deterministic
27
+ * script steps (`run`: a bash script, single-line value = file reference).
28
+ *
29
+ * MCP servers live alongside the workflow: a top-level `mcp:` map (stdio
30
+ * command/args/env or remote url), agents opt in via `mcp: [names]` —
31
+ * governance like tools. Relative server files (e.g. mcp/multiply-server.ts)
32
+ * are resolved inside the folder.
33
+ *
34
+ * CLIs work the same way: a top-level `clis:` map (command prefix -> one-line
35
+ * usage hint), agents opt in via `clis: [names]`. The hint is injected into
36
+ * the agent's persona once — step prompts never repeat it.
37
+ *
38
+ * Validation: structural pass against the JSON Schema (ajv) first, then
39
+ * semantic checks (agent references, duplicate step names, variables,
40
+ * referenced files). The engine appends the envelope contract to every step
41
+ * prompt itself. Not in v1 (roadmap): approval loops,
42
+ * AGENTS.md-style context files, skills.
43
+ */
44
+ const GATE_HINT = "Gates: file_non_empty: <file> | slots_filled: <file> | contains: {file, text, label?}";
45
+ const VARIABLES = ["request", "agent"];
46
+ let compiledSchema;
47
+ async function schemaValidator() {
48
+ if (!compiledSchema) {
49
+ const schema = JSON.parse(await readFile(new URL("../schema/workflow.schema.json", import.meta.url), "utf8"));
50
+ compiledSchema = new Ajv({ allErrors: true }).compile(schema);
51
+ }
52
+ return compiledSchema;
53
+ }
54
+ const STEP_HINT = "Step: agent step {name, agent, prompt, gates?} OR script step {name, run, gates?}";
55
+ const MCP_HINT = "MCP servers: {command, args?, env?} (stdio) OR {url} (remote streamable HTTP)";
56
+ const CLI_HINT = "CLIs: <command prefix>: <one-line usage hint> — name e.g. git, ddev, npm run (no special characters)";
57
+ function formatAjvErrors(errors, file) {
58
+ // oneOf mismatches (gates, steps, mcp) explode into per-branch errors; keep it readable.
59
+ const relevant = errors.filter((e) => e.keyword !== "oneOf" || !/\/gates\/|\/steps\/\d+$|^\/mcp\//.test(e.instancePath));
60
+ const lines = relevant.slice(0, 4).map((e) => {
61
+ const where = e.instancePath || "/";
62
+ if (e.keyword === "additionalProperties") {
63
+ return `${where}: unknown key "${e.params.additionalProperty}"`;
64
+ }
65
+ if (e.keyword === "enum") {
66
+ return `${where}: allowed values are ${e.params.allowedValues?.join(", ")}`;
67
+ }
68
+ return `${where}: ${e.message}`;
69
+ });
70
+ const gateHint = errors.some((e) => e.instancePath.includes("/gates/")) ? `\n${GATE_HINT}` : "";
71
+ const stepHint = errors.some((e) => e.keyword === "oneOf" && /\/steps\/\d+$/.test(e.instancePath))
72
+ ? `\n${STEP_HINT}`
73
+ : "";
74
+ const mcpHint = errors.some((e) => e.instancePath.startsWith("/mcp/")) ? `\n${MCP_HINT}` : "";
75
+ const cliHint = errors.some((e) => e.instancePath.startsWith("/clis")) ? `\n${CLI_HINT}` : "";
76
+ return `${file}: schema validation failed\n${lines.map((l) => ` - ${l}`).join("\n")}${gateHint}${stepHint}${mcpHint}${cliHint}`;
77
+ }
78
+ /** Names from `requires:` that are missing/empty in the current environment. */
79
+ export const missingEnv = (requires) => requires.filter((name) => !process.env[name]);
80
+ /**
81
+ * Load a workflow from a folder (`<dir>/workflow.yml` + referenced files)
82
+ * or from a single `.yml`/`.yaml` file (everything inline).
83
+ */
84
+ export async function loadWorkflow(givenPath) {
85
+ const stats = await stat(givenPath).catch(() => null);
86
+ if (!stats)
87
+ throw new Error(`${givenPath}: not found`);
88
+ let yamlPath = givenPath;
89
+ let baseDir;
90
+ if (stats.isDirectory()) {
91
+ baseDir = givenPath;
92
+ const candidates = ["workflow.yml", "workflow.yaml"];
93
+ const found = [];
94
+ for (const c of candidates) {
95
+ if (await stat(path.join(givenPath, c)).catch(() => null))
96
+ found.push(c);
97
+ }
98
+ if (found.length === 0) {
99
+ throw new Error(`${path.basename(givenPath)}/: contains no workflow.yml`);
100
+ }
101
+ yamlPath = path.join(givenPath, found[0]);
102
+ }
103
+ const file = baseDir
104
+ ? `${path.basename(baseDir)}/${path.basename(yamlPath)}`
105
+ : path.basename(yamlPath);
106
+ const fail = (message) => {
107
+ throw new Error(`${file}: ${message}`);
108
+ };
109
+ let raw;
110
+ try {
111
+ raw = parse(await readFile(yamlPath, "utf8"));
112
+ }
113
+ catch (err) {
114
+ return fail(`unreadable YAML: ${err.message}`);
115
+ }
116
+ // 1) Structural validation against the JSON Schema.
117
+ const validate = await schemaValidator();
118
+ if (!validate(raw)) {
119
+ throw new Error(formatAjvErrors(validate.errors ?? [], file));
120
+ }
121
+ // Single-line values in folder mode reference files inside the folder.
122
+ const resolveText = async (value, field) => {
123
+ const line = value.trim();
124
+ if (!baseDir || line.includes("\n"))
125
+ return value.trim();
126
+ const candidate = path.resolve(baseDir, line);
127
+ if (!candidate.startsWith(path.resolve(baseDir) + path.sep)) {
128
+ return fail(`${field}: "${line}" is outside the workflow folder`);
129
+ }
130
+ const content = await readFile(candidate, "utf8").catch(() => null);
131
+ if (content !== null)
132
+ return content.trim();
133
+ if (line.includes("/") || /\.(md|txt|sh)$/.test(line)) {
134
+ return fail(`${field}: referenced file "${line}" not found`);
135
+ }
136
+ return value.trim();
137
+ };
138
+ // 2) Semantic checks + resolution.
139
+ const workspace = raw.workspace ? await resolveText(raw.workspace, "workspace") : "";
140
+ // MCP servers stored alongside the workflow. Stdio args that resolve to a
141
+ // file relative to the folder become absolute (the server process is later
142
+ // spawned from the run directory); absolute paths and URLs pass through —
143
+ // that's the external-MCP case.
144
+ const mcpBase = baseDir ?? path.dirname(yamlPath);
145
+ const mcpDefs = new Map();
146
+ for (const [name, cfg] of Object.entries(raw.mcp ?? {})) {
147
+ if ("url" in cfg) {
148
+ mcpDefs.set(name, { url: cfg.url });
149
+ continue;
150
+ }
151
+ const resolvedArgs = [];
152
+ for (const arg of (cfg.args ?? [])) {
153
+ if (path.isAbsolute(arg)) {
154
+ resolvedArgs.push(arg);
155
+ continue;
156
+ }
157
+ const candidate = path.resolve(mcpBase, arg);
158
+ if (await stat(candidate).catch(() => null)) {
159
+ resolvedArgs.push(candidate);
160
+ }
161
+ else if (/\.(ts|mts|cts|js|mjs|cjs|py|sh)$/.test(arg)) {
162
+ fail(`mcp.${name}: server file "${arg}" not found`);
163
+ }
164
+ else {
165
+ resolvedArgs.push(arg); // flags like -y, package names like tsx
166
+ }
167
+ }
168
+ mcpDefs.set(name, {
169
+ command: cfg.command,
170
+ ...(resolvedArgs.length > 0 ? { args: resolvedArgs } : {}),
171
+ ...(cfg.env ? { env: cfg.env } : {}),
172
+ });
173
+ }
174
+ // CLIs available to agents: command prefix -> one-line usage hint (may be
175
+ // empty). Purely declarative; agents opt in below.
176
+ const cliDefs = new Map(Object.entries(raw.clis ?? {}).map(([name, hint]) => [name, String(hint ?? "")]));
177
+ const agents = new Map();
178
+ for (const [id, a] of Object.entries(raw.agents ?? {})) {
179
+ const mcp = {};
180
+ for (const serverName of (a.mcp ?? [])) {
181
+ const def = mcpDefs.get(serverName);
182
+ if (!def) {
183
+ fail(`agents.${id}.mcp: server "${serverName}" is not defined under mcp` +
184
+ ` (available: ${[...mcpDefs.keys()].join(", ") || "—"})`);
185
+ }
186
+ mcp[serverName] = def;
187
+ }
188
+ if (Object.keys(mcp).length > 0 && a["runs-on"] === "pi") {
189
+ fail(`agents.${id}: runs-on "pi" does not support MCP — use claude or codex`);
190
+ }
191
+ const clis = {};
192
+ for (const cliName of (a.clis ?? [])) {
193
+ if (!cliDefs.has(cliName)) {
194
+ fail(`agents.${id}.clis: CLI "${cliName}" is not defined under clis` +
195
+ ` (available: ${[...cliDefs.keys()].join(", ") || "—"})`);
196
+ }
197
+ clis[cliName] = cliDefs.get(cliName);
198
+ }
199
+ agents.set(id, {
200
+ id,
201
+ name: a.name ?? id,
202
+ model: a.model,
203
+ effort: a.effort,
204
+ tools: a.tools,
205
+ persona: await resolveText(a.persona, `agents.${id}.persona`),
206
+ harness: a["runs-on"],
207
+ ...(Object.keys(clis).length > 0 ? { clis } : {}),
208
+ ...(Object.keys(mcp).length > 0 ? { mcp } : {}),
209
+ });
210
+ }
211
+ const seen = new Set();
212
+ const steps = [];
213
+ for (const [i, s] of raw.steps.entries()) {
214
+ const at = (message) => fail(`steps[${i}]: ${message}`);
215
+ if (seen.has(s.name))
216
+ at(`step name "${s.name}" is a duplicate`);
217
+ seen.add(s.name);
218
+ const gates = (s.gates ?? []).map((g, j) => buildGate(g, (message) => fail(`steps[${i}].gates[${j}]: ${message}`)));
219
+ if (s.run !== undefined) {
220
+ const script = await resolveText(s.run, `steps[${i}].run`);
221
+ for (const match of script.matchAll(/\$\{\{\s*(\w+)\s*\}\}/g)) {
222
+ if (match[1] !== "request") {
223
+ at(`unknown variable \${{ ${match[1]} }} — run steps only support \${{ request }} (plus env: REQUEST, RUN_DIR, PHASE, WORKFLOW_DIR)`);
224
+ }
225
+ }
226
+ steps.push({ kind: "script", name: s.name, script, gates });
227
+ continue;
228
+ }
229
+ const agent = agents.get(s.agent);
230
+ if (!agent) {
231
+ at(`agent "${s.agent}" is not defined under agents (available: ${[...agents.keys()].join(", ")})`);
232
+ }
233
+ const prompt = await resolveText(s.prompt, `steps[${i}].prompt`);
234
+ for (const match of prompt.matchAll(/\$\{\{\s*(\w+)\s*\}\}/g)) {
235
+ if (!VARIABLES.includes(match[1])) {
236
+ at(`unknown variable \${{ ${match[1]} }} — available: ${VARIABLES.join(", ")}`);
237
+ }
238
+ }
239
+ steps.push({ kind: "agent", name: s.name, agent: agent, prompt, gates });
240
+ }
241
+ const requires = raw.requires ?? [];
242
+ return {
243
+ name: raw.name,
244
+ description: raw.description,
245
+ meta: {
246
+ agents: [...agents.values()],
247
+ steps: steps.map((s) => s.name),
248
+ requires,
249
+ },
250
+ async run({ request, verbose }) {
251
+ const missing = missingEnv(requires);
252
+ if (missing.length > 0) {
253
+ throw new Error(`Workflow "${raw.name}" needs environment variables that are missing: ${missing.join(", ")}` +
254
+ ` (declared under requires: in ${file})`);
255
+ }
256
+ // KRAFTWERK_RUN_DIR lets an outer runner (sandbox, web trigger) pick
257
+ // the run directory upfront so it can mount/watch it by name.
258
+ const runDir = process.env.KRAFTWERK_RUN_DIR
259
+ ? path.resolve(process.env.KRAFTWERK_RUN_DIR)
260
+ : path.resolve("output", `run-${runStamp()}`);
261
+ await mkdir(runDir, { recursive: true });
262
+ const run = new Run({
263
+ runDir,
264
+ verbose,
265
+ workspaceContext: [
266
+ `Working directory (read and create all files here): ${runDir}`,
267
+ workspace,
268
+ ]
269
+ .filter(Boolean)
270
+ .join("\n\n"),
271
+ });
272
+ await run.trace("run_start", {
273
+ workflow: raw.name,
274
+ description: raw.description,
275
+ request,
276
+ steps: steps.map((s) => s.kind === "script"
277
+ ? { name: s.name, kind: "script" }
278
+ : { name: s.name, kind: "agent", agent: s.agent.id, model: s.agent.model }),
279
+ });
280
+ for (const step of steps) {
281
+ if (step.kind === "script") {
282
+ await run.scriptPhase({
283
+ name: step.name,
284
+ script: step.script.replace(/\$\{\{\s*request\s*\}\}/g, request),
285
+ gates: step.gates,
286
+ env: {
287
+ REQUEST: request,
288
+ ...(baseDir ? { WORKFLOW_DIR: path.resolve(baseDir) } : {}),
289
+ },
290
+ });
291
+ continue;
292
+ }
293
+ const prompt = step.prompt
294
+ .replace(/\$\{\{\s*request\s*\}\}/g, request)
295
+ .replace(/\$\{\{\s*agent\s*\}\}/g, step.agent.id);
296
+ await run.agentPhase({
297
+ name: step.name,
298
+ agent: step.agent,
299
+ prompt: `${prompt}\n\n${envelopeContract(step.name)}`,
300
+ gates: step.gates,
301
+ });
302
+ }
303
+ await run.printSummary();
304
+ console.log(`\nArtifacts: ${runDir}`);
305
+ return { runDir, phases: run.stats, total: summaryTable(run.stats).total };
306
+ },
307
+ };
308
+ }
309
+ /** Back-compat alias for the single-file entry point. */
310
+ export const loadWorkflowYaml = loadWorkflow;
311
+ /** One YAML gate entry (single-key mapping, schema-checked) -> a Gate. */
312
+ function buildGate(g, at) {
313
+ const [gateName, value] = Object.entries(g)[0];
314
+ switch (gateName) {
315
+ case "file_non_empty":
316
+ return fileNonEmpty(value);
317
+ case "slots_filled":
318
+ return slotsFilled(value);
319
+ case "contains":
320
+ return containsText(value.file, value.text, value.label ?? value.text);
321
+ default:
322
+ return at(`unknown gate "${gateName}" — ${GATE_HINT}`);
323
+ }
324
+ }
package/package.json ADDED
@@ -0,0 +1,52 @@
1
+ {
2
+ "name": "@netnodeag/kraftwerk",
3
+ "version": "0.2.0",
4
+ "description": "Deterministic workflow-as-code framework: agents (persona + model + tools + harness) run in bounded phases on headless CLI harnesses (claude -p, codex exec, pi); code owns the control flow, envelopes + gates judge the results",
5
+ "keywords": ["agents", "workflow", "orchestration", "llm", "claude", "codex", "automation", "cli"],
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/NETNODEAG/kraftwerk.git",
9
+ "directory": "kraftwerk"
10
+ },
11
+ "homepage": "https://github.com/NETNODEAG/kraftwerk#readme",
12
+ "bugs": { "url": "https://github.com/NETNODEAG/kraftwerk/issues" },
13
+ "engines": { "node": ">=20" },
14
+ "publishConfig": { "access": "public" },
15
+ "type": "module",
16
+ "files": [
17
+ "bin",
18
+ "dist",
19
+ "runner",
20
+ "schema"
21
+ ],
22
+ "exports": {
23
+ ".": {
24
+ "types": "./dist/index.d.ts",
25
+ "import": "./dist/index.js"
26
+ }
27
+ },
28
+ "scripts": {
29
+ "typecheck": "tsc --noEmit",
30
+ "validate": "tsx src/validate.ts",
31
+ "build": "rm -rf dist && tsc -p tsconfig.build.json",
32
+ "prepublishOnly": "npm run build"
33
+ },
34
+ "license": "MIT",
35
+ "devDependencies": {
36
+ "@types/node": "^26.2.0",
37
+ "tsx": "^4.23.12",
38
+ "typescript": "^7.0.2"
39
+ },
40
+ "dependencies": {
41
+ "@inquirer/prompts": "^8.5.2",
42
+ "ajv": "^8.20.0",
43
+ "chalk": "^6.0.0",
44
+ "cli-table3": "^0.6.5",
45
+ "commander": "^15.0.0",
46
+ "ora": "^9.4.1",
47
+ "yaml": "^2.9.0"
48
+ },
49
+ "bin": {
50
+ "kraftwerk": "bin/kraftwerk.js"
51
+ }
52
+ }
@@ -0,0 +1,43 @@
1
+ # kraftwerk-runner — sandbox image for isolated workflow runs.
2
+ #
3
+ # Build (from the framework directory):
4
+ # kraftwerk runner build # or:
5
+ # docker build -t kraftwerk-runner -f runner/Dockerfile .
6
+ #
7
+ # One container per run: the runner mounts the workflow folder read-only,
8
+ # bind-mounts the host run directory, injects env vars, and executes
9
+ # `kraftwerk run` inside. See src/runner/docker.ts.
10
+
11
+ # node >= 24: workflows may ship TypeScript MCP servers that node runs
12
+ # directly (native type stripping).
13
+ FROM node:24-slim
14
+
15
+ RUN apt-get update && apt-get install -y --no-install-recommends \
16
+ bash python3 curl ca-certificates openssh-client git procps jq \
17
+ && rm -rf /var/lib/apt/lists/*
18
+
19
+ # Agent harness CLIs used by workflows (headless: claude -p, codex exec).
20
+ RUN npm install -g @anthropic-ai/claude-code @openai/codex
21
+
22
+ # The framework itself, installed from the build context. Ships the
23
+ # compiled dist/ (tsx is a devDependency and not installed here) — run
24
+ # `npm run build` before building the image from a dev checkout.
25
+ WORKDIR /opt/kraftwerk
26
+ COPY package.json ./
27
+ RUN npm install --omit=dev
28
+ COPY bin ./bin
29
+ COPY schema ./schema
30
+ COPY dist ./dist
31
+ RUN ln -s /opt/kraftwerk/bin/kraftwerk.js /usr/local/bin/kraftwerk \
32
+ && chmod +x /opt/kraftwerk/bin/kraftwerk.js
33
+
34
+ # Consumer project skeleton: the runner mounts the workflow folder into
35
+ # src/workflows/<name> and the run dir into output/<run-id>. The MCP SDK +
36
+ # zod are preinstalled so workflow-local MCP servers (mcp/*.ts) resolve
37
+ # their imports, mirroring the consumer's node_modules.
38
+ WORKDIR /work
39
+ RUN mkdir -p src/workflows output \
40
+ && printf '{"name":"kraftwerk-sandbox","private":true}\n' > package.json \
41
+ && npm install --no-fund --no-audit @modelcontextprotocol/sdk zod
42
+
43
+ ENV HOME=/root
@@ -0,0 +1,244 @@
1
+ {
2
+ "$schema": "http://json-schema.org/draft-07/schema#",
3
+ "$id": "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json",
4
+ "title": "kraftwerk workflow",
5
+ "description": "A linear workflow of gated agent steps. Folder form: <name>/workflow.yml, where single-line prompt/persona/workspace values may reference files inside the folder (e.g. prompts/step.md).",
6
+ "type": "object",
7
+ "required": ["name", "description", "steps"],
8
+ "additionalProperties": false,
9
+ "properties": {
10
+ "name": {
11
+ "type": "string",
12
+ "minLength": 1,
13
+ "description": "Workflow id used on the CLI: npm start -- <name> \"<request>\""
14
+ },
15
+ "description": {
16
+ "type": "string",
17
+ "description": "One-liner shown in the CLI workflow listing"
18
+ },
19
+ "workspace": {
20
+ "type": "string",
21
+ "description": "Workspace context appended to every agent's system prompt (file layout etc.). Single-line value = path to a file inside the workflow folder."
22
+ },
23
+ "requires": {
24
+ "type": "array",
25
+ "items": { "type": "string", "pattern": "^[A-Za-z_][A-Za-z0-9_]*$" },
26
+ "description": "Environment variables the workflow needs (API tokens etc.). Checked before the run starts; missing variables abort with a clear error instead of failing mid-run."
27
+ },
28
+ "mcp": {
29
+ "type": "object",
30
+ "description": "MCP servers stored alongside the workflow, keyed by server name. Agents opt in via their mcp: [names] list.",
31
+ "propertyNames": { "pattern": "^[A-Za-z0-9_-]+$" },
32
+ "additionalProperties": { "$ref": "#/definitions/mcpServer" }
33
+ },
34
+ "clis": {
35
+ "type": "object",
36
+ "description": "CLIs agents may call via Bash, keyed by command prefix (e.g. git, ddev, npm run) with a one-line usage hint (may be empty). Agents opt in via their clis: [names] list; the hint is injected into the agent's persona once — never repeat it in step prompts. claude scopes its Bash allowlist to Bash(<name>:*); codex runs commands in its sandbox anyway; pi gets the plain bash tool (no scoping).",
37
+ "propertyNames": { "pattern": "^[A-Za-z0-9@_.][A-Za-z0-9@_. /-]*$" },
38
+ "additionalProperties": {
39
+ "type": ["string", "null"],
40
+ "description": "One-line usage hint injected into the granting agent's persona; empty/null = name only"
41
+ }
42
+ },
43
+ "agents": {
44
+ "type": "object",
45
+ "minProperties": 1,
46
+ "description": "Agent roster: id -> definition",
47
+ "additionalProperties": { "$ref": "#/definitions/agent" }
48
+ },
49
+ "steps": {
50
+ "type": "array",
51
+ "minItems": 1,
52
+ "description": "Linear sequence of gated steps: agent steps (agent + prompt) or deterministic script steps (run)",
53
+ "items": { "$ref": "#/definitions/step" }
54
+ }
55
+ },
56
+ "definitions": {
57
+ "agent": {
58
+ "type": "object",
59
+ "required": ["model", "tools", "persona"],
60
+ "additionalProperties": false,
61
+ "properties": {
62
+ "name": {
63
+ "type": "string",
64
+ "description": "Display name, defaults to the agent id"
65
+ },
66
+ "description": {
67
+ "type": "string",
68
+ "description": "Informational only"
69
+ },
70
+ "model": {
71
+ "type": "string",
72
+ "minLength": 1,
73
+ "description": "Model id in the harness's naming, e.g. haiku, claude-opus-5, gpt-5.6-sol, deepseek/deepseek-chat"
74
+ },
75
+ "effort": {
76
+ "enum": ["low", "medium", "high", "xhigh", "max"],
77
+ "description": "Reasoning effort; omit for the model default"
78
+ },
79
+ "runs-on": {
80
+ "enum": ["claude", "codex", "pi"],
81
+ "description": "Harness that executes this agent (default: claude)"
82
+ },
83
+ "tools": {
84
+ "type": "array",
85
+ "items": { "type": "string" },
86
+ "description": "Governance: the only tools the agent may use, e.g. [Read, Write, Edit, WebFetch]"
87
+ },
88
+ "persona": {
89
+ "type": "string",
90
+ "minLength": 1,
91
+ "description": "System prompt: role, voice, rules. Single-line value = path to a file inside the workflow folder."
92
+ },
93
+ "mcp": {
94
+ "type": "array",
95
+ "items": { "type": "string", "minLength": 1 },
96
+ "description": "MCP servers this agent may use (names from the top-level mcp map). Governance like tools. Not supported on runs-on: pi."
97
+ },
98
+ "clis": {
99
+ "type": "array",
100
+ "items": { "type": "string", "minLength": 1 },
101
+ "description": "CLIs this agent may use (names from the top-level clis map). Their usage hints are injected into the persona; claude additionally scopes the Bash allowlist to these commands."
102
+ }
103
+ }
104
+ },
105
+ "mcpServer": {
106
+ "oneOf": [
107
+ {
108
+ "type": "object",
109
+ "required": ["command"],
110
+ "additionalProperties": false,
111
+ "properties": {
112
+ "command": {
113
+ "type": "string",
114
+ "minLength": 1,
115
+ "description": "Executable that starts the stdio MCP server, e.g. node or npx"
116
+ },
117
+ "args": {
118
+ "type": "array",
119
+ "items": { "type": "string" },
120
+ "description": "Arguments; an arg that resolves to a file inside the workflow folder is made absolute (e.g. mcp/multiply-server.ts)"
121
+ },
122
+ "env": {
123
+ "type": "object",
124
+ "additionalProperties": { "type": "string" },
125
+ "description": "Extra environment variables for the server process"
126
+ }
127
+ }
128
+ },
129
+ {
130
+ "type": "object",
131
+ "required": ["url"],
132
+ "additionalProperties": false,
133
+ "properties": {
134
+ "url": {
135
+ "type": "string",
136
+ "minLength": 1,
137
+ "description": "Remote streamable-HTTP MCP server, e.g. https://mcp.linear.app/mcp"
138
+ }
139
+ }
140
+ }
141
+ ]
142
+ },
143
+ "step": {
144
+ "oneOf": [
145
+ { "$ref": "#/definitions/agentStep" },
146
+ { "$ref": "#/definitions/scriptStep" }
147
+ ]
148
+ },
149
+ "agentStep": {
150
+ "type": "object",
151
+ "required": ["name", "agent", "prompt"],
152
+ "additionalProperties": false,
153
+ "properties": {
154
+ "name": {
155
+ "type": "string",
156
+ "minLength": 1,
157
+ "description": "Step/phase name; also the envelope phase the agent must report"
158
+ },
159
+ "agent": {
160
+ "type": "string",
161
+ "minLength": 1,
162
+ "description": "Id of an agent defined under agents"
163
+ },
164
+ "prompt": {
165
+ "type": "string",
166
+ "minLength": 1,
167
+ "description": "Task prompt. Single-line value = path to a file inside the workflow folder. Variables: ${{ request }}, ${{ agent }}. The envelope contract is appended automatically."
168
+ },
169
+ "gates": {
170
+ "type": "array",
171
+ "description": "Post-execution file checks; failures trigger an in-session correction",
172
+ "items": { "$ref": "#/definitions/gate" }
173
+ }
174
+ }
175
+ },
176
+ "scriptStep": {
177
+ "type": "object",
178
+ "required": ["name", "run"],
179
+ "additionalProperties": false,
180
+ "properties": {
181
+ "name": {
182
+ "type": "string",
183
+ "minLength": 1,
184
+ "description": "Step/phase name; also the envelope phase of the script step"
185
+ },
186
+ "run": {
187
+ "type": "string",
188
+ "minLength": 1,
189
+ "description": "Deterministic step: bash script executed in the run directory (no agent, no LLM). Single-line value = script file inside the workflow folder (e.g. scripts/check.sh). Env vars: REQUEST, RUN_DIR, PHASE; ${{ request }} is interpolated. Non-zero exit fails the run. Optionally end stdout with the same fenced ```json envelope agents emit; otherwise the engine synthesizes one (status ok, summary = last stdout line)."
190
+ },
191
+ "gates": {
192
+ "type": "array",
193
+ "description": "Post-execution file checks; script steps have no correction loop — a failing gate fails the run (fix the script)",
194
+ "items": { "$ref": "#/definitions/gate" }
195
+ }
196
+ }
197
+ },
198
+ "gate": {
199
+ "oneOf": [
200
+ {
201
+ "type": "object",
202
+ "required": ["file_non_empty"],
203
+ "additionalProperties": false,
204
+ "properties": {
205
+ "file_non_empty": {
206
+ "type": "string",
207
+ "minLength": 1,
208
+ "description": "File (relative to the run directory) that must exist and be non-empty"
209
+ }
210
+ }
211
+ },
212
+ {
213
+ "type": "object",
214
+ "required": ["slots_filled"],
215
+ "additionalProperties": false,
216
+ "properties": {
217
+ "slots_filled": {
218
+ "type": "string",
219
+ "minLength": 1,
220
+ "description": "File that must not contain unfilled {{...}} template slots"
221
+ }
222
+ }
223
+ },
224
+ {
225
+ "type": "object",
226
+ "required": ["contains"],
227
+ "additionalProperties": false,
228
+ "properties": {
229
+ "contains": {
230
+ "type": "object",
231
+ "required": ["file", "text"],
232
+ "additionalProperties": false,
233
+ "properties": {
234
+ "file": { "type": "string", "minLength": 1 },
235
+ "text": { "type": "string", "minLength": 1 },
236
+ "label": { "type": "string", "description": "Short label used in the gate name; defaults to text" }
237
+ }
238
+ }
239
+ }
240
+ }
241
+ ]
242
+ }
243
+ }
244
+ }