@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.
- package/README.md +339 -0
- package/bin/kraftwerk.js +24 -0
- package/dist/agent.d.ts +49 -0
- package/dist/agent.js +3 -0
- package/dist/cli/create-brief.d.ts +4 -0
- package/dist/cli/create-brief.js +146 -0
- package/dist/cli/doctor.d.ts +1 -0
- package/dist/cli/doctor.js +87 -0
- package/dist/cli/init.d.ts +1 -0
- package/dist/cli/init.js +81 -0
- package/dist/cli/kraftwerk.d.ts +1 -0
- package/dist/cli/kraftwerk.js +342 -0
- package/dist/cli/runs.d.ts +6 -0
- package/dist/cli/runs.js +120 -0
- package/dist/cli.d.ts +13 -0
- package/dist/cli.js +47 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +97 -0
- package/dist/discover.d.ts +21 -0
- package/dist/discover.js +38 -0
- package/dist/envelope.d.ts +21 -0
- package/dist/envelope.js +61 -0
- package/dist/gates.d.ts +18 -0
- package/dist/gates.js +34 -0
- package/dist/harness.d.ts +66 -0
- package/dist/harness.js +14 -0
- package/dist/harnesses/claude.d.ts +2 -0
- package/dist/harnesses/claude.js +117 -0
- package/dist/harnesses/codex.d.ts +2 -0
- package/dist/harnesses/codex.js +158 -0
- package/dist/harnesses/pi.d.ts +2 -0
- package/dist/harnesses/pi.js +151 -0
- package/dist/harnesses/registry.d.ts +2 -0
- package/dist/harnesses/registry.js +19 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +22 -0
- package/dist/remote.d.ts +23 -0
- package/dist/remote.js +57 -0
- package/dist/run.d.ts +66 -0
- package/dist/run.js +278 -0
- package/dist/runner/docker.d.ts +38 -0
- package/dist/runner/docker.js +166 -0
- package/dist/stats.d.ts +46 -0
- package/dist/stats.js +65 -0
- package/dist/validate.d.ts +8 -0
- package/dist/validate.js +33 -0
- package/dist/workflow.d.ts +24 -0
- package/dist/workflow.js +11 -0
- package/dist/yaml.d.ts +21 -0
- package/dist/yaml.js +324 -0
- package/package.json +52 -0
- package/runner/Dockerfile +43 -0
- package/schema/workflow.schema.json +244 -0
|
@@ -0,0 +1,151 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
/**
|
|
4
|
+
* pi harness (badlogic/pi-mono): a thin coding agent over raw vendor APIs —
|
|
5
|
+
* Anthropic (also via the Claude subscription OAuth), OpenAI, DeepSeek,
|
|
6
|
+
* Groq, OpenRouter, and many more. This is the "direct API call" runtime:
|
|
7
|
+
* same file tools and sessions as claude -p, any provider behind it.
|
|
8
|
+
*
|
|
9
|
+
* Model ids use pi's `provider/id` form, e.g. "anthropic/claude-haiku-4-5"
|
|
10
|
+
* or "deepseek/deepseek-chat" (vendor API key in env for non-OAuth
|
|
11
|
+
* providers; check with `pi auth check --provider <name>`).
|
|
12
|
+
*
|
|
13
|
+
* Session handling: `--session-id` is create-or-continue, so the adapter
|
|
14
|
+
* generates the id on the first call and simply reuses it to resume —
|
|
15
|
+
* nothing to parse back. `--no-context-files` keeps the run hermetic
|
|
16
|
+
* (no AGENTS.md/CLAUDE.md discovery). The effort scale maps 1:1 onto
|
|
17
|
+
* `--thinking` (low..max).
|
|
18
|
+
*
|
|
19
|
+
* JSONL events (verified against pi 0.84.1):
|
|
20
|
+
* session {id, cwd}
|
|
21
|
+
* tool_execution_start {toolName, args: {path|command|...}}
|
|
22
|
+
* message_end {message: {role, content[{type,text}],
|
|
23
|
+
* usage: {input, output, cacheRead, cacheWrite,
|
|
24
|
+
* cost: {total}}}}
|
|
25
|
+
* agent_end / agent_settled
|
|
26
|
+
*/
|
|
27
|
+
/** Framework tool names → pi built-in tool names. Unmapped tools (e.g. WebFetch) are dropped. */
|
|
28
|
+
const TOOL_MAP = {
|
|
29
|
+
Read: "read",
|
|
30
|
+
Write: "write",
|
|
31
|
+
Edit: "edit",
|
|
32
|
+
Bash: "bash",
|
|
33
|
+
Grep: "grep",
|
|
34
|
+
Glob: "find",
|
|
35
|
+
LS: "ls",
|
|
36
|
+
};
|
|
37
|
+
const clip = (raw) => {
|
|
38
|
+
const oneLine = raw.replace(/\s+/g, " ").trim();
|
|
39
|
+
return oneLine.length > 160 ? `${oneLine.slice(0, 160)}…` : oneLine;
|
|
40
|
+
};
|
|
41
|
+
function invokePi(inv) {
|
|
42
|
+
if (Object.keys(inv.mcpServers ?? {}).length > 0) {
|
|
43
|
+
throw new Error("pi harness has no MCP support (pi uses its own extension system) — run this agent on claude or codex");
|
|
44
|
+
}
|
|
45
|
+
const sessionId = inv.resume ?? randomUUID();
|
|
46
|
+
const tools = inv.tools.map((t) => TOOL_MAP[t]).filter(Boolean);
|
|
47
|
+
// pi has no per-command allowlist: a CLI grant enables the plain bash
|
|
48
|
+
// tool; the scoping lives only in the persona hints.
|
|
49
|
+
if ((inv.clis?.length ?? 0) > 0 && !tools.includes("bash"))
|
|
50
|
+
tools.push("bash");
|
|
51
|
+
const args = [
|
|
52
|
+
"-p",
|
|
53
|
+
"--mode", "json",
|
|
54
|
+
"--model", inv.model,
|
|
55
|
+
"--system-prompt", inv.systemPrompt,
|
|
56
|
+
"--tools", tools.join(","),
|
|
57
|
+
"--no-context-files",
|
|
58
|
+
"--session-id", sessionId,
|
|
59
|
+
];
|
|
60
|
+
if (inv.effort)
|
|
61
|
+
args.push("--thinking", inv.effort);
|
|
62
|
+
const started = Date.now();
|
|
63
|
+
return new Promise((resolve, reject) => {
|
|
64
|
+
const child = spawn("pi", args, {
|
|
65
|
+
cwd: inv.cwd,
|
|
66
|
+
stdio: ["pipe", "pipe", "pipe"],
|
|
67
|
+
});
|
|
68
|
+
child.stdin.write(inv.prompt);
|
|
69
|
+
child.stdin.end();
|
|
70
|
+
const usage = {
|
|
71
|
+
inputTokens: 0,
|
|
72
|
+
outputTokens: 0,
|
|
73
|
+
cacheReadTokens: 0,
|
|
74
|
+
cacheCreationTokens: 0,
|
|
75
|
+
};
|
|
76
|
+
let costUsd = 0;
|
|
77
|
+
let sawAssistant = false;
|
|
78
|
+
const texts = [];
|
|
79
|
+
let failure;
|
|
80
|
+
let stderr = "";
|
|
81
|
+
let buffer = "";
|
|
82
|
+
const handleLine = (line) => {
|
|
83
|
+
if (!line.trim())
|
|
84
|
+
return;
|
|
85
|
+
let event;
|
|
86
|
+
try {
|
|
87
|
+
event = JSON.parse(line);
|
|
88
|
+
}
|
|
89
|
+
catch {
|
|
90
|
+
return; // non-JSON noise on stdout
|
|
91
|
+
}
|
|
92
|
+
if (event.type === "tool_execution_start" && event.toolName) {
|
|
93
|
+
const a = event.args ?? {};
|
|
94
|
+
const raw = a.path ?? a.file_path ?? a.command ?? a.pattern ?? "";
|
|
95
|
+
inv.onToolUse?.(event.toolName, clip(String(raw)));
|
|
96
|
+
}
|
|
97
|
+
if (event.type === "message_end" && event.message?.role === "assistant") {
|
|
98
|
+
sawAssistant = true;
|
|
99
|
+
for (const block of event.message.content ?? []) {
|
|
100
|
+
if (block?.type === "text" && block.text?.trim()) {
|
|
101
|
+
texts.push(block.text.trim());
|
|
102
|
+
inv.onText?.(block.text.trim());
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
const u = event.message.usage;
|
|
106
|
+
if (u) {
|
|
107
|
+
usage.inputTokens += u.input ?? 0;
|
|
108
|
+
usage.outputTokens += u.output ?? 0;
|
|
109
|
+
usage.cacheReadTokens += u.cacheRead ?? 0;
|
|
110
|
+
usage.cacheCreationTokens += u.cacheWrite ?? 0;
|
|
111
|
+
costUsd += u.cost?.total ?? 0;
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
if (event.type === "error") {
|
|
115
|
+
failure = typeof event.error === "string" ? event.error : line;
|
|
116
|
+
}
|
|
117
|
+
};
|
|
118
|
+
child.stdout.on("data", (chunk) => {
|
|
119
|
+
buffer += chunk.toString("utf8");
|
|
120
|
+
const lines = buffer.split("\n");
|
|
121
|
+
buffer = lines.pop() ?? "";
|
|
122
|
+
lines.forEach(handleLine);
|
|
123
|
+
});
|
|
124
|
+
child.stderr.on("data", (chunk) => {
|
|
125
|
+
stderr += chunk.toString("utf8");
|
|
126
|
+
});
|
|
127
|
+
child.on("error", (err) => reject(new Error(`could not spawn pi: ${err.message}`)));
|
|
128
|
+
child.on("close", (code) => {
|
|
129
|
+
handleLine(buffer);
|
|
130
|
+
if (failure || code !== 0 || !sawAssistant) {
|
|
131
|
+
reject(new Error(`pi failed (exit ${code})` +
|
|
132
|
+
(failure ? `: ${failure}` : "") +
|
|
133
|
+
(stderr.trim() ? `\nstderr: ${stderr.trim().slice(-2000)}` : "")));
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
resolve({
|
|
137
|
+
sessionId,
|
|
138
|
+
// The envelope sits in the last message; parseEnvelope takes the
|
|
139
|
+
// last fenced block, so joining all messages is safe.
|
|
140
|
+
text: texts.join("\n\n"),
|
|
141
|
+
durationMs: Date.now() - started,
|
|
142
|
+
costUsd,
|
|
143
|
+
usage,
|
|
144
|
+
});
|
|
145
|
+
});
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
export const piHarness = {
|
|
149
|
+
id: "pi",
|
|
150
|
+
invoke: invokePi,
|
|
151
|
+
};
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { claudeHarness } from "./claude.js";
|
|
2
|
+
import { codexHarness } from "./codex.js";
|
|
3
|
+
import { piHarness } from "./pi.js";
|
|
4
|
+
/**
|
|
5
|
+
* Maps an agent's `harness` field to the adapter that runs it.
|
|
6
|
+
* Default is claude — an agent without a harness runs on `claude -p`.
|
|
7
|
+
*/
|
|
8
|
+
const HARNESSES = {
|
|
9
|
+
claude: claudeHarness,
|
|
10
|
+
codex: codexHarness,
|
|
11
|
+
pi: piHarness,
|
|
12
|
+
};
|
|
13
|
+
export function harnessFor(id = "claude") {
|
|
14
|
+
const harness = HARNESSES[id];
|
|
15
|
+
if (!harness) {
|
|
16
|
+
throw new Error(`harness "${id}" is not registered (available: ${Object.keys(HARNESSES).join(", ")})`);
|
|
17
|
+
}
|
|
18
|
+
return harness;
|
|
19
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kraftwerk — deterministic workflow-as-code over headless agent
|
|
3
|
+
* harnesses. "Agent proposes, code disposes."
|
|
4
|
+
*
|
|
5
|
+
* An agent is persona + model/effort + tools + harness (defineAgent); the
|
|
6
|
+
* Run executes it inside bounded phases (agentPhase/codePhase), judges the
|
|
7
|
+
* result (envelope + gates), corrects in the same session, and accounts for
|
|
8
|
+
* time/tokens/cost. Harnesses: claude -p (default), codex exec, pi.
|
|
9
|
+
*/
|
|
10
|
+
export { defineAgent, type AgentDefinition, type EffortLevel } from "./agent.js";
|
|
11
|
+
export { type AgentInvocation, type AgentResult, type Harness, type HarnessId, type McpServerConfig, type TokenUsage, } from "./harness.js";
|
|
12
|
+
export { harnessFor } from "./harnesses/registry.js";
|
|
13
|
+
export { Run, type RunOptions } from "./run.js";
|
|
14
|
+
export { correctionPrompt, envelopeContract, parseEnvelope, type Envelope, } from "./envelope.js";
|
|
15
|
+
export { containsText, fileNonEmpty, slotsFilled, type Gate } from "./gates.js";
|
|
16
|
+
export { fmtDuration, fmtTokens, phaseStatsLine, summaryTable, totalIn, type PhaseStats, type RunTotals, } from "./stats.js";
|
|
17
|
+
export { runStamp, type RunResult, type WorkflowDefinition, type WorkflowRunOptions, } from "./workflow.js";
|
|
18
|
+
export { runCli } from "./cli.js";
|
|
19
|
+
export { loadWorkflow, loadWorkflowYaml, missingEnv, type LoadedWorkflow } from "./yaml.js";
|
|
20
|
+
export { discoverWorkflows, findWorkflowsRoot, type DiscoveredWorkflow } from "./discover.js";
|
|
21
|
+
export { validateWorkflows } from "./validate.js";
|
|
22
|
+
export { resolveProject, SCHEMA_URL, type Project, type ProjectConfig } from "./config.js";
|
|
23
|
+
export { isRemoteSpec, resolveRemote, type RemoteSource } from "./remote.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* kraftwerk — deterministic workflow-as-code over headless agent
|
|
3
|
+
* harnesses. "Agent proposes, code disposes."
|
|
4
|
+
*
|
|
5
|
+
* An agent is persona + model/effort + tools + harness (defineAgent); the
|
|
6
|
+
* Run executes it inside bounded phases (agentPhase/codePhase), judges the
|
|
7
|
+
* result (envelope + gates), corrects in the same session, and accounts for
|
|
8
|
+
* time/tokens/cost. Harnesses: claude -p (default), codex exec, pi.
|
|
9
|
+
*/
|
|
10
|
+
export { defineAgent } from "./agent.js";
|
|
11
|
+
export { harnessFor } from "./harnesses/registry.js";
|
|
12
|
+
export { Run } from "./run.js";
|
|
13
|
+
export { correctionPrompt, envelopeContract, parseEnvelope, } from "./envelope.js";
|
|
14
|
+
export { containsText, fileNonEmpty, slotsFilled } from "./gates.js";
|
|
15
|
+
export { fmtDuration, fmtTokens, phaseStatsLine, summaryTable, totalIn, } from "./stats.js";
|
|
16
|
+
export { runStamp, } from "./workflow.js";
|
|
17
|
+
export { runCli } from "./cli.js";
|
|
18
|
+
export { loadWorkflow, loadWorkflowYaml, missingEnv } from "./yaml.js";
|
|
19
|
+
export { discoverWorkflows, findWorkflowsRoot } from "./discover.js";
|
|
20
|
+
export { validateWorkflows } from "./validate.js";
|
|
21
|
+
export { resolveProject, SCHEMA_URL } from "./config.js";
|
|
22
|
+
export { isRemoteSpec, resolveRemote } from "./remote.js";
|
package/dist/remote.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Remote workflow sources: run workflows straight from a git repository
|
|
3
|
+
* without vendoring them ("kraftwerk run --from github:org/repo tagline ...").
|
|
4
|
+
*
|
|
5
|
+
* Spec forms:
|
|
6
|
+
* github:org/repo shorthand for https://github.com/org/repo.git
|
|
7
|
+
* github:org/repo@ref branch, tag, or commit
|
|
8
|
+
* https://.../repo.git any https git remote (also with @ref)
|
|
9
|
+
* git@host:org/repo.git any ssh git remote (also with @ref)
|
|
10
|
+
*
|
|
11
|
+
* Clones land shallow in ~/.cache/kraftwerk/remotes/<slug>; an existing
|
|
12
|
+
* clone is refreshed with fetch + reset. Offline with an existing clone
|
|
13
|
+
* degrades to a warning and uses the cached state.
|
|
14
|
+
*/
|
|
15
|
+
export interface RemoteSource {
|
|
16
|
+
/** Absolute directory of the (refreshed) clone — the project root to discover in. */
|
|
17
|
+
dir: string;
|
|
18
|
+
url: string;
|
|
19
|
+
ref?: string;
|
|
20
|
+
}
|
|
21
|
+
export declare function isRemoteSpec(spec: string): boolean;
|
|
22
|
+
/** Clone or refresh the spec's repository; returns the local project dir. */
|
|
23
|
+
export declare function resolveRemote(spec: string): Promise<RemoteSource>;
|
package/dist/remote.js
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { mkdir, stat } from "node:fs/promises";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import path from "node:path";
|
|
5
|
+
export function isRemoteSpec(spec) {
|
|
6
|
+
return /^github:|^https:\/\/|^git@/.test(spec);
|
|
7
|
+
}
|
|
8
|
+
function parseSpec(spec) {
|
|
9
|
+
// @ref suffix: split on the last @ that isn't part of git@host.
|
|
10
|
+
let rest = spec;
|
|
11
|
+
let ref;
|
|
12
|
+
const at = rest.lastIndexOf("@");
|
|
13
|
+
if (at > rest.indexOf(":") && at > 0) {
|
|
14
|
+
ref = rest.slice(at + 1);
|
|
15
|
+
rest = rest.slice(0, at);
|
|
16
|
+
}
|
|
17
|
+
if (rest.startsWith("github:")) {
|
|
18
|
+
const orgRepo = rest.slice("github:".length).replace(/\/+$/, "");
|
|
19
|
+
if (!/^[\w.-]+\/[\w.-]+$/.test(orgRepo)) {
|
|
20
|
+
throw new Error(`Invalid github: source "${spec}" — expected github:org/repo[@ref]`);
|
|
21
|
+
}
|
|
22
|
+
return { url: `https://github.com/${orgRepo}.git`, ref };
|
|
23
|
+
}
|
|
24
|
+
return { url: rest, ref };
|
|
25
|
+
}
|
|
26
|
+
const slugify = (s) => s.replace(/^https:\/\/|^git@|\.git$/g, "").replace(/[^A-Za-z0-9._-]+/g, "-");
|
|
27
|
+
function git(args, cwd) {
|
|
28
|
+
const r = spawnSync("git", args, { cwd, encoding: "utf8" });
|
|
29
|
+
return { ok: r.status === 0, out: (r.stderr || r.stdout || "").trim() };
|
|
30
|
+
}
|
|
31
|
+
/** Clone or refresh the spec's repository; returns the local project dir. */
|
|
32
|
+
export async function resolveRemote(spec) {
|
|
33
|
+
const { url, ref } = parseSpec(spec);
|
|
34
|
+
const cacheRoot = path.join(os.homedir(), ".cache", "kraftwerk", "remotes");
|
|
35
|
+
const dir = path.join(cacheRoot, slugify(url) + (ref ? `-${slugify(ref)}` : ""));
|
|
36
|
+
await mkdir(cacheRoot, { recursive: true });
|
|
37
|
+
const cloned = (await stat(path.join(dir, ".git")).catch(() => null)) !== null;
|
|
38
|
+
if (!cloned) {
|
|
39
|
+
const args = ["clone", "--depth", "1", ...(ref ? ["--branch", ref] : []), url, dir];
|
|
40
|
+
const r = git(args);
|
|
41
|
+
if (!r.ok)
|
|
42
|
+
throw new Error(`git clone ${url} failed:\n${r.out}`);
|
|
43
|
+
}
|
|
44
|
+
else {
|
|
45
|
+
const fetched = git(["fetch", "--depth", "1", "origin", ...(ref ? [ref] : [])], dir);
|
|
46
|
+
if (fetched.ok) {
|
|
47
|
+
const target = ref ? "FETCH_HEAD" : "origin/HEAD";
|
|
48
|
+
const reset = git(["reset", "--hard", target], dir);
|
|
49
|
+
if (!reset.ok)
|
|
50
|
+
git(["reset", "--hard", "FETCH_HEAD"], dir);
|
|
51
|
+
}
|
|
52
|
+
else {
|
|
53
|
+
console.error(`Warning: ${url} not reachable — using the cached state.`);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
return { dir, url, ref };
|
|
57
|
+
}
|
package/dist/run.d.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { AgentDefinition } from "./agent.js";
|
|
2
|
+
import { type Envelope } from "./envelope.js";
|
|
3
|
+
import type { Gate } from "./gates.js";
|
|
4
|
+
import { type PhaseStats } from "./stats.js";
|
|
5
|
+
/**
|
|
6
|
+
* The phase runner: agent proposes, code disposes. An agent phase spawns one
|
|
7
|
+
* headless `claude -p` process, then the ORCHESTRATOR judges the result
|
|
8
|
+
* (envelope parse + gates). Failures produce a correction prompt into the
|
|
9
|
+
* same session, bounded by maxGateRetries. Agents never control retries
|
|
10
|
+
* or phase transitions.
|
|
11
|
+
*
|
|
12
|
+
* The runner is workflow-agnostic: agents, prompts, and gates are injected
|
|
13
|
+
* per phase by the concrete workflow. Per phase, the agent's persona is
|
|
14
|
+
* composed with the run's workspace context into the system prompt; one
|
|
15
|
+
* session PER HARNESS is shared across phases via resume, so a phase sees
|
|
16
|
+
* the prior conversation on its harness but always speaks with its own
|
|
17
|
+
* agent's voice. Across harnesses, context travels through the run files.
|
|
18
|
+
*
|
|
19
|
+
* Every event is appended to <runDir>/trace.jsonl for observability.
|
|
20
|
+
*/
|
|
21
|
+
export interface RunOptions {
|
|
22
|
+
runDir: string;
|
|
23
|
+
/**
|
|
24
|
+
* Workflow-level context appended to every agent's persona: the run
|
|
25
|
+
* directory, the file layout, how the orchestrator works.
|
|
26
|
+
*/
|
|
27
|
+
workspaceContext: string;
|
|
28
|
+
verbose?: boolean;
|
|
29
|
+
/** Corrections in the same session before the run aborts. Default 2. */
|
|
30
|
+
maxGateRetries?: number;
|
|
31
|
+
}
|
|
32
|
+
export declare class Run {
|
|
33
|
+
private readonly sessions;
|
|
34
|
+
/** Per-phase stats in execution order; repeated phases appear once per execution. */
|
|
35
|
+
readonly stats: PhaseStats[];
|
|
36
|
+
readonly runDir: string;
|
|
37
|
+
private readonly workspaceContext;
|
|
38
|
+
private readonly verbose;
|
|
39
|
+
private readonly maxGateRetries;
|
|
40
|
+
constructor(options: RunOptions);
|
|
41
|
+
trace(event: string, data?: Record<string, unknown>): Promise<void>;
|
|
42
|
+
agentPhase(params: {
|
|
43
|
+
name: string;
|
|
44
|
+
agent: AgentDefinition;
|
|
45
|
+
prompt: string;
|
|
46
|
+
gates: Gate[];
|
|
47
|
+
}): Promise<Envelope>;
|
|
48
|
+
/**
|
|
49
|
+
* Deterministic script phase: run a bash script in the run directory —
|
|
50
|
+
* no agent, no LLM. The script sees REQUEST, RUN_DIR, PHASE, and (in
|
|
51
|
+
* folder mode) WORKFLOW_DIR as env vars. It hands over the same envelope as an agent phase: either it
|
|
52
|
+
* prints the fenced ```json envelope itself (last block on stdout wins),
|
|
53
|
+
* or the runner synthesizes one from the exit code (status ok, summary =
|
|
54
|
+
* last stdout line). Gates run afterwards, but there is no correction
|
|
55
|
+
* loop — a script is deterministic, so a failing gate fails the run.
|
|
56
|
+
*/
|
|
57
|
+
scriptPhase(params: {
|
|
58
|
+
name: string;
|
|
59
|
+
script: string;
|
|
60
|
+
gates: Gate[];
|
|
61
|
+
env?: Record<string, string>;
|
|
62
|
+
}): Promise<Envelope>;
|
|
63
|
+
codePhase<T>(name: string, fn: () => Promise<T>): Promise<T>;
|
|
64
|
+
/** Per-phase table (time, tokens, cost) plus totals; also traced as run_summary. */
|
|
65
|
+
printSummary(): Promise<void>;
|
|
66
|
+
}
|
package/dist/run.js
ADDED
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
import { spawn } from "node:child_process";
|
|
2
|
+
import { appendFile } from "node:fs/promises";
|
|
3
|
+
import path from "node:path";
|
|
4
|
+
import { harnessFor } from "./harnesses/registry.js";
|
|
5
|
+
import { correctionPrompt, parseEnvelope } from "./envelope.js";
|
|
6
|
+
import { fmtDuration, phaseStatsLine, summaryTable } from "./stats.js";
|
|
7
|
+
export class Run {
|
|
8
|
+
sessions = new Map();
|
|
9
|
+
/** Per-phase stats in execution order; repeated phases appear once per execution. */
|
|
10
|
+
stats = [];
|
|
11
|
+
runDir;
|
|
12
|
+
workspaceContext;
|
|
13
|
+
verbose;
|
|
14
|
+
maxGateRetries;
|
|
15
|
+
constructor(options) {
|
|
16
|
+
this.runDir = options.runDir;
|
|
17
|
+
this.workspaceContext = options.workspaceContext;
|
|
18
|
+
this.verbose = options.verbose ?? false;
|
|
19
|
+
this.maxGateRetries = options.maxGateRetries ?? 2;
|
|
20
|
+
}
|
|
21
|
+
async trace(event, data = {}) {
|
|
22
|
+
await appendFile(path.join(this.runDir, "trace.jsonl"), JSON.stringify({ ts: new Date().toISOString(), event, ...data }) + "\n");
|
|
23
|
+
}
|
|
24
|
+
async agentPhase(params) {
|
|
25
|
+
const { agent } = params;
|
|
26
|
+
const harness = harnessFor(agent.harness);
|
|
27
|
+
const modelName = harness.id === "claude" ? agent.model : `${harness.id}:${agent.model}`;
|
|
28
|
+
const modelLabel = agent.effort ? `${modelName}, effort ${agent.effort}` : modelName;
|
|
29
|
+
console.log(`\n▸ Phase "${params.name}" — ${agent.name} [${agent.id}] (${modelLabel})`);
|
|
30
|
+
await this.trace("phase_start", {
|
|
31
|
+
phase: params.name,
|
|
32
|
+
kind: "agent",
|
|
33
|
+
agent: agent.id,
|
|
34
|
+
harness: harness.id,
|
|
35
|
+
model: agent.model,
|
|
36
|
+
effort: agent.effort ?? null,
|
|
37
|
+
clis: agent.clis ? Object.keys(agent.clis) : [],
|
|
38
|
+
mcp: agent.mcp ? Object.keys(agent.mcp) : [],
|
|
39
|
+
resume: this.sessions.get(harness.id) ?? null,
|
|
40
|
+
});
|
|
41
|
+
const phaseStats = {
|
|
42
|
+
phase: params.name,
|
|
43
|
+
kind: "agent",
|
|
44
|
+
agent: agent.id,
|
|
45
|
+
harness: harness.id,
|
|
46
|
+
model: agent.model,
|
|
47
|
+
effort: agent.effort,
|
|
48
|
+
attempts: 0,
|
|
49
|
+
durationMs: 0,
|
|
50
|
+
inputTokens: 0,
|
|
51
|
+
outputTokens: 0,
|
|
52
|
+
cacheReadTokens: 0,
|
|
53
|
+
cacheCreationTokens: 0,
|
|
54
|
+
costUsd: 0,
|
|
55
|
+
};
|
|
56
|
+
const started = Date.now();
|
|
57
|
+
// CLI grants are persona-level knowledge: inject the usage hints once
|
|
58
|
+
// here instead of repeating them in every step prompt.
|
|
59
|
+
const cliEntries = Object.entries(agent.clis ?? {});
|
|
60
|
+
const cliBlock = cliEntries.length
|
|
61
|
+
? "\n\nThese CLIs are available to you via Bash (pre-approved, usable without asking):\n" +
|
|
62
|
+
cliEntries
|
|
63
|
+
.map(([name, hint]) => `- ${name}${hint.trim() ? ` — ${hint.trim()}` : ""}`)
|
|
64
|
+
.join("\n")
|
|
65
|
+
: "";
|
|
66
|
+
let prompt = params.prompt;
|
|
67
|
+
for (let attempt = 0; attempt <= this.maxGateRetries; attempt++) {
|
|
68
|
+
const result = await harness.invoke({
|
|
69
|
+
prompt,
|
|
70
|
+
systemPrompt: `${agent.persona.trim()}${cliBlock}\n\n${this.workspaceContext.trim()}`,
|
|
71
|
+
cwd: this.runDir,
|
|
72
|
+
model: agent.model,
|
|
73
|
+
effort: agent.effort,
|
|
74
|
+
tools: agent.tools,
|
|
75
|
+
clis: cliEntries.map(([name]) => name),
|
|
76
|
+
mcpServers: agent.mcp,
|
|
77
|
+
resume: this.sessions.get(harness.id),
|
|
78
|
+
onToolUse: (tool, target) => {
|
|
79
|
+
console.log(` ⚙ ${tool} ${target}`.trimEnd());
|
|
80
|
+
void this.trace("tool_use", { phase: params.name, attempt, tool, target });
|
|
81
|
+
},
|
|
82
|
+
onText: this.verbose ? (text) => console.log(` 💬 ${text}`) : undefined,
|
|
83
|
+
});
|
|
84
|
+
if (result.sessionId)
|
|
85
|
+
this.sessions.set(harness.id, result.sessionId);
|
|
86
|
+
phaseStats.attempts = attempt + 1;
|
|
87
|
+
phaseStats.durationMs = Date.now() - started;
|
|
88
|
+
phaseStats.costUsd += result.costUsd ?? 0;
|
|
89
|
+
if (result.usage) {
|
|
90
|
+
phaseStats.inputTokens += result.usage.inputTokens;
|
|
91
|
+
phaseStats.outputTokens += result.usage.outputTokens;
|
|
92
|
+
phaseStats.cacheReadTokens += result.usage.cacheReadTokens;
|
|
93
|
+
phaseStats.cacheCreationTokens += result.usage.cacheCreationTokens;
|
|
94
|
+
}
|
|
95
|
+
await this.trace("agent_result", {
|
|
96
|
+
phase: params.name,
|
|
97
|
+
attempt,
|
|
98
|
+
harness: harness.id,
|
|
99
|
+
sessionId: result.sessionId,
|
|
100
|
+
numTurns: result.numTurns,
|
|
101
|
+
durationMs: result.durationMs,
|
|
102
|
+
costUsd: result.costUsd,
|
|
103
|
+
usage: result.usage,
|
|
104
|
+
});
|
|
105
|
+
const failures = [];
|
|
106
|
+
let envelope;
|
|
107
|
+
try {
|
|
108
|
+
envelope = parseEnvelope(result.text, params.name);
|
|
109
|
+
await this.trace("envelope", { phase: params.name, attempt, envelope });
|
|
110
|
+
}
|
|
111
|
+
catch (err) {
|
|
112
|
+
failures.push(err.message);
|
|
113
|
+
}
|
|
114
|
+
if (envelope?.status === "blocked") {
|
|
115
|
+
await this.trace("phase_end", { phase: params.name, status: "blocked" });
|
|
116
|
+
throw new Error(`Phase "${params.name}" blocked by agent: ${envelope.reason ?? "no reason given"}`);
|
|
117
|
+
}
|
|
118
|
+
for (const gate of params.gates) {
|
|
119
|
+
const failure = await gate.check(this.runDir);
|
|
120
|
+
await this.trace("gate_result", {
|
|
121
|
+
phase: params.name,
|
|
122
|
+
attempt,
|
|
123
|
+
gate: gate.name,
|
|
124
|
+
passed: failure === null,
|
|
125
|
+
failure,
|
|
126
|
+
});
|
|
127
|
+
if (failure)
|
|
128
|
+
failures.push(`${gate.name}: ${failure}`);
|
|
129
|
+
}
|
|
130
|
+
if (failures.length === 0) {
|
|
131
|
+
this.stats.push(phaseStats);
|
|
132
|
+
console.log(` ✔ envelope + ${params.gates.length} gates passed (${phaseStatsLine(phaseStats, result.numTurns)})`);
|
|
133
|
+
await this.trace("phase_end", { phase: params.name, status: "ok", stats: phaseStats });
|
|
134
|
+
return envelope;
|
|
135
|
+
}
|
|
136
|
+
const retrying = attempt < this.maxGateRetries;
|
|
137
|
+
console.log(` ✖ ${failures.length} check(s) failed${retrying ? ", correcting in the same session" : ""}`);
|
|
138
|
+
for (const failure of failures)
|
|
139
|
+
console.log(` - ${failure}`);
|
|
140
|
+
prompt = correctionPrompt(params.name, failures);
|
|
141
|
+
}
|
|
142
|
+
this.stats.push(phaseStats);
|
|
143
|
+
await this.trace("phase_end", { phase: params.name, status: "failed", stats: phaseStats });
|
|
144
|
+
throw new Error(`Phase "${params.name}" failed after ${this.maxGateRetries + 1} attempts`);
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Deterministic script phase: run a bash script in the run directory —
|
|
148
|
+
* no agent, no LLM. The script sees REQUEST, RUN_DIR, PHASE, and (in
|
|
149
|
+
* folder mode) WORKFLOW_DIR as env vars. It hands over the same envelope as an agent phase: either it
|
|
150
|
+
* prints the fenced ```json envelope itself (last block on stdout wins),
|
|
151
|
+
* or the runner synthesizes one from the exit code (status ok, summary =
|
|
152
|
+
* last stdout line). Gates run afterwards, but there is no correction
|
|
153
|
+
* loop — a script is deterministic, so a failing gate fails the run.
|
|
154
|
+
*/
|
|
155
|
+
async scriptPhase(params) {
|
|
156
|
+
console.log(`\n▸ Phase "${params.name}" — script (bash)`);
|
|
157
|
+
await this.trace("phase_start", { phase: params.name, kind: "script" });
|
|
158
|
+
const started = Date.now();
|
|
159
|
+
const result = await new Promise((resolve, reject) => {
|
|
160
|
+
const child = spawn("bash", ["-c", params.script], {
|
|
161
|
+
cwd: this.runDir,
|
|
162
|
+
env: { ...process.env, RUN_DIR: this.runDir, PHASE: params.name, ...params.env },
|
|
163
|
+
});
|
|
164
|
+
let stdout = "";
|
|
165
|
+
let stderr = "";
|
|
166
|
+
child.stdout.on("data", (chunk) => {
|
|
167
|
+
const text = String(chunk);
|
|
168
|
+
stdout += text;
|
|
169
|
+
if (this.verbose) {
|
|
170
|
+
for (const line of text.split("\n")) {
|
|
171
|
+
if (line.trim())
|
|
172
|
+
console.log(` 💬 ${line}`);
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
});
|
|
176
|
+
child.stderr.on("data", (chunk) => (stderr += String(chunk)));
|
|
177
|
+
child.on("error", reject);
|
|
178
|
+
child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
|
|
179
|
+
});
|
|
180
|
+
const phaseStats = {
|
|
181
|
+
phase: params.name,
|
|
182
|
+
kind: "script",
|
|
183
|
+
attempts: 1,
|
|
184
|
+
durationMs: Date.now() - started,
|
|
185
|
+
inputTokens: 0,
|
|
186
|
+
outputTokens: 0,
|
|
187
|
+
cacheReadTokens: 0,
|
|
188
|
+
cacheCreationTokens: 0,
|
|
189
|
+
costUsd: 0,
|
|
190
|
+
};
|
|
191
|
+
await this.trace("script_result", {
|
|
192
|
+
phase: params.name,
|
|
193
|
+
exitCode: result.code,
|
|
194
|
+
stdout: result.stdout,
|
|
195
|
+
stderr: result.stderr,
|
|
196
|
+
});
|
|
197
|
+
const fail = async (message) => {
|
|
198
|
+
this.stats.push(phaseStats);
|
|
199
|
+
await this.trace("phase_end", { phase: params.name, status: "failed", stats: phaseStats });
|
|
200
|
+
throw new Error(`Phase "${params.name}" (script) failed: ${message}`);
|
|
201
|
+
};
|
|
202
|
+
if (result.code !== 0) {
|
|
203
|
+
const tail = result.stderr.trim().split("\n").slice(-5).join("\n");
|
|
204
|
+
return await fail(`exit code ${result.code}${tail ? `\n${tail}` : ""}`);
|
|
205
|
+
}
|
|
206
|
+
let envelope;
|
|
207
|
+
if (result.stdout.includes("```json")) {
|
|
208
|
+
try {
|
|
209
|
+
envelope = parseEnvelope(result.stdout, params.name);
|
|
210
|
+
}
|
|
211
|
+
catch (err) {
|
|
212
|
+
return await fail(err.message);
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
else {
|
|
216
|
+
const lines = result.stdout.trim().split("\n").filter((l) => l.trim());
|
|
217
|
+
envelope = {
|
|
218
|
+
phase: params.name,
|
|
219
|
+
status: "ok",
|
|
220
|
+
artifacts: [],
|
|
221
|
+
summary: lines.at(-1),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
await this.trace("envelope", { phase: params.name, envelope });
|
|
225
|
+
if (envelope.status === "blocked") {
|
|
226
|
+
return await fail(`blocked: ${envelope.reason ?? "no reason given"}`);
|
|
227
|
+
}
|
|
228
|
+
const failures = [];
|
|
229
|
+
for (const gate of params.gates) {
|
|
230
|
+
const failure = await gate.check(this.runDir);
|
|
231
|
+
await this.trace("gate_result", {
|
|
232
|
+
phase: params.name,
|
|
233
|
+
gate: gate.name,
|
|
234
|
+
passed: failure === null,
|
|
235
|
+
failure,
|
|
236
|
+
});
|
|
237
|
+
if (failure)
|
|
238
|
+
failures.push(`${gate.name}: ${failure}`);
|
|
239
|
+
}
|
|
240
|
+
if (failures.length > 0) {
|
|
241
|
+
for (const failure of failures)
|
|
242
|
+
console.log(` - ${failure}`);
|
|
243
|
+
return await fail(`${failures.length} gate(s) failed — script steps are deterministic, no correction loop`);
|
|
244
|
+
}
|
|
245
|
+
this.stats.push(phaseStats);
|
|
246
|
+
console.log(` ✔ envelope + ${params.gates.length} gates passed (exit 0 | ${fmtDuration(phaseStats.durationMs)})`);
|
|
247
|
+
await this.trace("phase_end", { phase: params.name, status: "ok", stats: phaseStats });
|
|
248
|
+
return envelope;
|
|
249
|
+
}
|
|
250
|
+
async codePhase(name, fn) {
|
|
251
|
+
console.log(`\n▸ Phase "${name}" — code`);
|
|
252
|
+
await this.trace("phase_start", { phase: name, kind: "code" });
|
|
253
|
+
const started = Date.now();
|
|
254
|
+
const value = await fn();
|
|
255
|
+
const phaseStats = {
|
|
256
|
+
phase: name,
|
|
257
|
+
kind: "code",
|
|
258
|
+
attempts: 1,
|
|
259
|
+
durationMs: Date.now() - started,
|
|
260
|
+
inputTokens: 0,
|
|
261
|
+
outputTokens: 0,
|
|
262
|
+
cacheReadTokens: 0,
|
|
263
|
+
cacheCreationTokens: 0,
|
|
264
|
+
costUsd: 0,
|
|
265
|
+
};
|
|
266
|
+
this.stats.push(phaseStats);
|
|
267
|
+
await this.trace("phase_end", { phase: name, status: "ok", stats: phaseStats });
|
|
268
|
+
return value;
|
|
269
|
+
}
|
|
270
|
+
/** Per-phase table (time, tokens, cost) plus totals; also traced as run_summary. */
|
|
271
|
+
async printSummary() {
|
|
272
|
+
const { lines, total } = summaryTable(this.stats);
|
|
273
|
+
console.log(`\n▸ Run summary`);
|
|
274
|
+
for (const line of lines)
|
|
275
|
+
console.log(line);
|
|
276
|
+
await this.trace("run_summary", { phases: this.stats, total });
|
|
277
|
+
}
|
|
278
|
+
}
|