@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
@@ -0,0 +1,38 @@
1
+ export interface SandboxOptions {
2
+ /** Consumer project root (where output/ lives). */
3
+ projectRoot: string;
4
+ /** Workflow folder (folder mode) or .yml file path. */
5
+ workflowPath: string;
6
+ /** Workflow name as declared in the YAML (used for `kraftwerk run <name>`). */
7
+ workflowName: string;
8
+ request: string;
9
+ /** Pre-chosen run id (run-...); generated when omitted. */
10
+ runId?: string;
11
+ /** Forward the host SSH agent + known_hosts into the container. */
12
+ ssh?: boolean;
13
+ /** "attach": inherit stdio, await completion (CLI). "detach": stdio to runner.log, survives the parent (web trigger). */
14
+ mode: "attach" | "detach";
15
+ memory?: string;
16
+ cpus?: string;
17
+ }
18
+ export interface SandboxHandle {
19
+ runId: string;
20
+ runDir: string;
21
+ containerName: string;
22
+ /** Resolves with the docker exit code (attach mode only awaits it). */
23
+ finished: Promise<number>;
24
+ }
25
+ export declare function dockerAvailable(): string | null;
26
+ export declare function imageExists(): boolean;
27
+ /** Build (or rebuild) the kraftwerk-runner image. Streams docker output. */
28
+ export declare function buildImage(): Promise<void>;
29
+ export declare function runSandboxed(opts: SandboxOptions): Promise<SandboxHandle>;
30
+ /** Running kraftwerk sandbox containers: [{ runId, workflow, container, status }]. */
31
+ export declare function listSandboxes(): Array<{
32
+ runId: string;
33
+ workflow: string;
34
+ container: string;
35
+ status: string;
36
+ }>;
37
+ /** Stop a sandboxed run by run id. Returns false when no such container. */
38
+ export declare function stopSandbox(runId: string): boolean;
@@ -0,0 +1,166 @@
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { mkdir, writeFile, readFile, open } from "node:fs/promises";
3
+ import { existsSync } from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+ import { fileURLToPath } from "node:url";
7
+ import { runStamp } from "../workflow.js";
8
+ /**
9
+ * Docker sandbox runner: one container per workflow run.
10
+ *
11
+ * The container gets
12
+ * - the workflow folder, mounted read-only at /work/src/workflows/<name>
13
+ * - the host run directory, bind-mounted at /work/output/<run-id> — so
14
+ * trace.jsonl and all artifacts land on the host LIVE while the run
15
+ * executes (no copy-back step needed; the inspector polls as usual)
16
+ * - env vars from <project>/runner.env (if present) plus pass-through of
17
+ * ANTHROPIC_API_KEY / OPENAI_API_KEY from the host environment
18
+ * - optionally the host SSH agent socket + known_hosts (`ssh: true`)
19
+ *
20
+ * Inside, plain `kraftwerk run --yes` executes; KRAFTWERK_RUN_DIR pins the
21
+ * run directory to the mount. Container name kw-<run-id> and label
22
+ * kraftwerk.run make runs discoverable and cancellable (`docker stop`).
23
+ */
24
+ const IMAGE = "kraftwerk-runner";
25
+ const ENV_FILE = "runner.env";
26
+ const PASSTHROUGH_ENV = ["ANTHROPIC_API_KEY", "OPENAI_API_KEY"];
27
+ /** Framework package root (contains runner/Dockerfile). */
28
+ const frameworkDir = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
29
+ export function dockerAvailable() {
30
+ const r = spawnSync("docker", ["version", "--format", "{{.Server.Version}}"], {
31
+ encoding: "utf8",
32
+ timeout: 10_000,
33
+ });
34
+ if (r.status !== 0)
35
+ return null;
36
+ return r.stdout.trim() || "unknown";
37
+ }
38
+ export function imageExists() {
39
+ return spawnSync("docker", ["image", "inspect", IMAGE], { stdio: "ignore" }).status === 0;
40
+ }
41
+ /** Build (or rebuild) the kraftwerk-runner image. Streams docker output. */
42
+ export async function buildImage() {
43
+ if (!existsSync(path.join(frameworkDir, "dist"))) {
44
+ throw new Error("dist/ missing — run `npm run build` in the framework checkout first, then build the image.");
45
+ }
46
+ const dockerfile = path.join(frameworkDir, "runner", "Dockerfile");
47
+ await new Promise((resolve, reject) => {
48
+ const child = spawn("docker", ["build", "-t", IMAGE, "-f", dockerfile, frameworkDir], { stdio: "inherit" });
49
+ child.on("error", reject);
50
+ child.on("close", (code) => code === 0 ? resolve() : reject(new Error(`docker build failed (exit ${code})`)));
51
+ });
52
+ }
53
+ export async function runSandboxed(opts) {
54
+ if (!dockerAvailable()) {
55
+ throw new Error("Docker daemon not reachable — is Docker running?");
56
+ }
57
+ if (!imageExists()) {
58
+ throw new Error(`Image "${IMAGE}" not found — run \`kraftwerk runner build\` first.`);
59
+ }
60
+ const runId = opts.runId ?? `run-${runStamp()}`;
61
+ const runDir = path.join(opts.projectRoot, "output", runId);
62
+ await mkdir(runDir, { recursive: true });
63
+ const containerName = `kw-${runId}`;
64
+ // Folder mode and single-file mode both mount under src/workflows/ so
65
+ // the in-container discovery finds exactly this one workflow.
66
+ const wfAbs = path.resolve(opts.workflowPath);
67
+ const wfTarget = `/work/src/workflows/${path.basename(wfAbs)}`;
68
+ const args = [
69
+ "run", "--rm",
70
+ "--name", containerName,
71
+ "--label", `kraftwerk.run=${runId}`,
72
+ "--label", `kraftwerk.workflow=${opts.workflowName}`,
73
+ "--memory", opts.memory ?? "2g",
74
+ "--cpus", opts.cpus ?? "2",
75
+ "-v", `${wfAbs}:${wfTarget}:ro`,
76
+ "-v", `${runDir}:/work/output/${runId}`,
77
+ "-e", `KRAFTWERK_RUN_DIR=/work/output/${runId}`,
78
+ ];
79
+ const envFile = path.join(opts.projectRoot, ENV_FILE);
80
+ if (existsSync(envFile))
81
+ args.push("--env-file", envFile);
82
+ for (const key of PASSTHROUGH_ENV) {
83
+ if (process.env[key])
84
+ args.push("-e", key);
85
+ }
86
+ if (opts.ssh) {
87
+ // Docker Desktop (macOS) exposes the host agent at a magic path; on
88
+ // Linux the socket path can be mounted directly.
89
+ const sock = os.platform() === "darwin" ? "/run/host-services/ssh-auth.sock" : process.env.SSH_AUTH_SOCK;
90
+ if (sock) {
91
+ args.push("-v", `${os.platform() === "darwin" ? "/run/host-services/ssh-auth.sock" : sock}:/ssh-agent.sock`, "-e", "SSH_AUTH_SOCK=/ssh-agent.sock");
92
+ }
93
+ const knownHosts = path.join(os.homedir(), ".ssh", "known_hosts");
94
+ if (existsSync(knownHosts)) {
95
+ args.push("-v", `${knownHosts}:/root/.ssh/known_hosts:ro`);
96
+ }
97
+ }
98
+ args.push(IMAGE, "kraftwerk", "run", "--yes", opts.workflowName, opts.request);
99
+ await writeFile(path.join(runDir, "runner.json"), JSON.stringify({
100
+ container: containerName,
101
+ image: IMAGE,
102
+ workflow: opts.workflowName,
103
+ request: opts.request,
104
+ ssh: !!opts.ssh,
105
+ startedAt: new Date().toISOString(),
106
+ }, null, 2) + "\n");
107
+ let exited;
108
+ if (opts.mode === "attach") {
109
+ const child = spawn("docker", args, { stdio: "inherit" });
110
+ exited = new Promise((resolve, reject) => {
111
+ child.on("error", reject);
112
+ child.on("close", (code) => resolve(code ?? 1));
113
+ });
114
+ }
115
+ else {
116
+ // Detached: docker client keeps running after the parent (e.g. the
117
+ // inspector dev server) exits; all output goes to runner.log.
118
+ const log = await open(path.join(runDir, "runner.log"), "a");
119
+ const child = spawn("docker", args, {
120
+ detached: true,
121
+ stdio: ["ignore", log.fd, log.fd],
122
+ });
123
+ exited = new Promise((resolve) => {
124
+ child.on("close", (code) => resolve(code ?? 1));
125
+ child.on("error", () => resolve(1));
126
+ });
127
+ child.unref();
128
+ await log.close();
129
+ }
130
+ // Record the outcome before anyone (e.g. the CLI's process.exit) can act
131
+ // on the resolved exit code. Best effort only in detach mode — the parent
132
+ // may be gone; trace.jsonl still tells the story.
133
+ const finished = exited.then(async (code) => {
134
+ try {
135
+ const p = path.join(runDir, "runner.json");
136
+ const meta = JSON.parse(await readFile(p, "utf8"));
137
+ meta.exitCode = code;
138
+ meta.finishedAt = new Date().toISOString();
139
+ await writeFile(p, JSON.stringify(meta, null, 2) + "\n");
140
+ }
141
+ catch {
142
+ /* ignore */
143
+ }
144
+ return code;
145
+ });
146
+ return { runId, runDir, containerName, finished };
147
+ }
148
+ /** Running kraftwerk sandbox containers: [{ runId, workflow, container, status }]. */
149
+ export function listSandboxes() {
150
+ const r = spawnSync("docker", ["ps", "--filter", "label=kraftwerk.run", "--format",
151
+ '{{.Label "kraftwerk.run"}}\t{{.Label "kraftwerk.workflow"}}\t{{.Names}}\t{{.Status}}'], { encoding: "utf8" });
152
+ if (r.status !== 0)
153
+ return [];
154
+ return r.stdout
155
+ .trim()
156
+ .split("\n")
157
+ .filter(Boolean)
158
+ .map((line) => {
159
+ const [runId, workflow, container, status] = line.split("\t");
160
+ return { runId, workflow, container, status };
161
+ });
162
+ }
163
+ /** Stop a sandboxed run by run id. Returns false when no such container. */
164
+ export function stopSandbox(runId) {
165
+ return spawnSync("docker", ["stop", `kw-${runId}`], { stdio: "ignore", timeout: 30_000 }).status === 0;
166
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * Per-phase resource accounting: wall-clock time, token usage, cost.
3
+ * Collected by Run, persisted to trace.jsonl, rendered as the run summary.
4
+ */
5
+ export interface PhaseStats {
6
+ phase: string;
7
+ kind: "agent" | "code" | "script";
8
+ /** Agent id, for agent phases. */
9
+ agent?: string;
10
+ /** Harness id, for agent phases. */
11
+ harness?: string;
12
+ /** Model id, for agent phases. */
13
+ model?: string;
14
+ effort?: string;
15
+ /** 1 + number of correction rounds. */
16
+ attempts: number;
17
+ /** Wall-clock time of the whole phase, corrections included. */
18
+ durationMs: number;
19
+ /** Fresh input tokens (uncached), summed over attempts. */
20
+ inputTokens: number;
21
+ outputTokens: number;
22
+ cacheReadTokens: number;
23
+ cacheCreationTokens: number;
24
+ costUsd: number;
25
+ }
26
+ export interface RunTotals {
27
+ durationMs: number;
28
+ in: number;
29
+ out: number;
30
+ costUsd: number;
31
+ }
32
+ /** Everything the API processed as input: fresh + cache read + cache creation. */
33
+ export declare const totalIn: (s: {
34
+ inputTokens: number;
35
+ cacheReadTokens: number;
36
+ cacheCreationTokens: number;
37
+ }) => number;
38
+ export declare const fmtTokens: (n: number) => string;
39
+ export declare const fmtDuration: (ms: number) => string;
40
+ /** One-line phase result, appended to the ✔ log line. */
41
+ export declare function phaseStatsLine(stats: PhaseStats, numTurns?: number): string;
42
+ /** Render the end-of-run table; returns the printable lines plus totals. */
43
+ export declare function summaryTable(stats: PhaseStats[]): {
44
+ lines: string[];
45
+ total: RunTotals;
46
+ };
package/dist/stats.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Per-phase resource accounting: wall-clock time, token usage, cost.
3
+ * Collected by Run, persisted to trace.jsonl, rendered as the run summary.
4
+ */
5
+ /** Everything the API processed as input: fresh + cache read + cache creation. */
6
+ export const totalIn = (s) => s.inputTokens + s.cacheReadTokens + s.cacheCreationTokens;
7
+ export const fmtTokens = (n) => n >= 1000 ? `${(n / 1000).toFixed(1)}k` : String(n);
8
+ export const fmtDuration = (ms) => {
9
+ const s = Math.round(ms / 1000);
10
+ return s >= 60 ? `${Math.floor(s / 60)}m${String(s % 60).padStart(2, "0")}s` : `${s}s`;
11
+ };
12
+ /** One-line phase result, appended to the ✔ log line. */
13
+ export function phaseStatsLine(stats, numTurns) {
14
+ return [
15
+ numTurns !== undefined ? `${numTurns} turns` : "",
16
+ fmtDuration(stats.durationMs),
17
+ `${fmtTokens(totalIn(stats))} in / ${fmtTokens(stats.outputTokens)} out`,
18
+ `$${stats.costUsd.toFixed(4)}`,
19
+ ]
20
+ .filter(Boolean)
21
+ .join(" | ");
22
+ }
23
+ /** Render the end-of-run table; returns the printable lines plus totals. */
24
+ export function summaryTable(stats) {
25
+ const rows = stats.map((s) => {
26
+ // Prefix the harness only when it is not the default claude runtime.
27
+ const modelName = s.harness && s.harness !== "claude" ? `${s.harness}:${s.model}` : s.model;
28
+ return {
29
+ phase: s.phase,
30
+ agent: s.kind === "agent" ? s.agent ?? "" : `(${s.kind})`,
31
+ model: modelName ? (s.effort ? `${modelName} (${s.effort})` : modelName) : "",
32
+ attempts: String(s.attempts),
33
+ time: fmtDuration(s.durationMs),
34
+ tokens: s.kind === "agent" ? `${fmtTokens(totalIn(s))} / ${fmtTokens(s.outputTokens)}` : "",
35
+ cost: s.kind === "agent" ? `$${s.costUsd.toFixed(4)}` : "",
36
+ };
37
+ });
38
+ const total = stats.reduce((acc, s) => ({
39
+ durationMs: acc.durationMs + s.durationMs,
40
+ in: acc.in + totalIn(s),
41
+ out: acc.out + s.outputTokens,
42
+ costUsd: acc.costUsd + s.costUsd,
43
+ }), { durationMs: 0, in: 0, out: 0, costUsd: 0 });
44
+ rows.push({
45
+ phase: "total",
46
+ agent: "",
47
+ model: "",
48
+ attempts: "",
49
+ time: fmtDuration(total.durationMs),
50
+ tokens: `${fmtTokens(total.in)} / ${fmtTokens(total.out)}`,
51
+ cost: `$${total.costUsd.toFixed(4)}`,
52
+ });
53
+ const header = { phase: "phase", agent: "agent", model: "model", attempts: "att", time: "time", tokens: "tokens in/out", cost: "cost" };
54
+ const cols = ["phase", "agent", "model", "attempts", "time", "tokens", "cost"];
55
+ const width = (c) => Math.max(header[c].length, ...rows.map((r) => r[c].length));
56
+ const line = (r) => " " + cols.map((c) => r[c].padEnd(width(c))).join(" ");
57
+ return {
58
+ lines: [
59
+ line(header),
60
+ " " + cols.map((c) => "-".repeat(width(c))).join(" "),
61
+ ...rows.map(line),
62
+ ],
63
+ total,
64
+ };
65
+ }
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Validate workflow files/folders without executing them: runs the full
3
+ * loader (JSON Schema + semantic checks + referenced files). Returns the
4
+ * number of failures. Used by the `validate` CLI subcommand
5
+ * (`npm start -- validate <path> ...`) and runnable standalone
6
+ * (`npm run validate -- <path> ...` in the framework).
7
+ */
8
+ export declare function validateWorkflows(paths: string[]): Promise<number>;
@@ -0,0 +1,33 @@
1
+ import path from "node:path";
2
+ import { loadWorkflow } from "./yaml.js";
3
+ /**
4
+ * Validate workflow files/folders without executing them: runs the full
5
+ * loader (JSON Schema + semantic checks + referenced files). Returns the
6
+ * number of failures. Used by the `validate` CLI subcommand
7
+ * (`npm start -- validate <path> ...`) and runnable standalone
8
+ * (`npm run validate -- <path> ...` in the framework).
9
+ */
10
+ export async function validateWorkflows(paths) {
11
+ let failures = 0;
12
+ for (const p of paths) {
13
+ try {
14
+ const workflow = await loadWorkflow(p);
15
+ console.log(`✔ ${p}: OK — workflow "${workflow.name}" (${workflow.description})`);
16
+ }
17
+ catch (err) {
18
+ failures += 1;
19
+ console.error(`✖ ${p}: ${err.message}`);
20
+ }
21
+ }
22
+ return failures;
23
+ }
24
+ // Standalone entry: tsx src/validate.ts <path> [<path> ...]
25
+ if (process.argv[1] && import.meta.filename === path.resolve(process.argv[1])) {
26
+ const paths = process.argv.slice(2);
27
+ if (paths.length === 0) {
28
+ console.error("Usage: npm run validate -- <workflow.yml | workflow-folder> ...");
29
+ process.exit(1);
30
+ }
31
+ const failures = await validateWorkflows(paths);
32
+ process.exit(failures > 0 ? 1 : 0);
33
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The contract between the CLI registry and a concrete workflow (ADW).
3
+ * A workflow owns its run directory, roster, prompts, and gates; the
4
+ * framework only provides the phase runner and the CLI dispatch.
5
+ */
6
+ import type { PhaseStats, RunTotals } from "./stats.js";
7
+ export interface WorkflowRunOptions {
8
+ request: string;
9
+ autoApprove: boolean;
10
+ verbose: boolean;
11
+ }
12
+ /** Machine-readable outcome of a run (also what `run --json` prints). */
13
+ export interface RunResult {
14
+ runDir: string;
15
+ phases: PhaseStats[];
16
+ total: RunTotals;
17
+ }
18
+ export interface WorkflowDefinition {
19
+ name: string;
20
+ description: string;
21
+ run(opts: WorkflowRunOptions): Promise<RunResult | void>;
22
+ }
23
+ /** Local-time run-folder stamp: "2026-08-13-1432-07" (sortable, no colons). */
24
+ export declare function runStamp(date?: Date): string;
@@ -0,0 +1,11 @@
1
+ /**
2
+ * The contract between the CLI registry and a concrete workflow (ADW).
3
+ * A workflow owns its run directory, roster, prompts, and gates; the
4
+ * framework only provides the phase runner and the CLI dispatch.
5
+ */
6
+ /** Local-time run-folder stamp: "2026-08-13-1432-07" (sortable, no colons). */
7
+ export function runStamp(date = new Date()) {
8
+ const pad = (n) => String(n).padStart(2, "0");
9
+ return (`${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}` +
10
+ `-${pad(date.getHours())}${pad(date.getMinutes())}-${pad(date.getSeconds())}`);
11
+ }
package/dist/yaml.d.ts ADDED
@@ -0,0 +1,21 @@
1
+ import type { AgentDefinition } from "./agent.js";
2
+ import { type RunResult, type WorkflowDefinition } from "./workflow.js";
3
+ /** A YAML-loaded workflow additionally exposes its roster/steps (for the CLI). */
4
+ export interface LoadedWorkflow extends WorkflowDefinition {
5
+ readonly meta: {
6
+ agents: AgentDefinition[];
7
+ steps: string[];
8
+ /** Environment variables the workflow declares under `requires:`. */
9
+ requires: string[];
10
+ };
11
+ run(opts: Parameters<WorkflowDefinition["run"]>[0]): Promise<RunResult>;
12
+ }
13
+ /** Names from `requires:` that are missing/empty in the current environment. */
14
+ export declare const missingEnv: (requires: string[]) => string[];
15
+ /**
16
+ * Load a workflow from a folder (`<dir>/workflow.yml` + referenced files)
17
+ * or from a single `.yml`/`.yaml` file (everything inline).
18
+ */
19
+ export declare function loadWorkflow(givenPath: string): Promise<LoadedWorkflow>;
20
+ /** Back-compat alias for the single-file entry point. */
21
+ export declare const loadWorkflowYaml: typeof loadWorkflow;