@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/config.js ADDED
@@ -0,0 +1,97 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { parse } from "yaml";
4
+ /**
5
+ * Project resolution + optional project config.
6
+ *
7
+ * `kraftwerk.yml` at the project root is both the config file and the
8
+ * root marker, so every CLI command works from any subdirectory. Without
9
+ * it, the walk-up falls back to the first ancestor containing a workflows
10
+ * root (src/workflows/ or workflows/), then to the first .git directory,
11
+ * then to the starting cwd.
12
+ *
13
+ * All fields are optional — a valid kraftwerk.yml may be empty:
14
+ *
15
+ * workflows: src/workflows # workflows root, relative to the file
16
+ * output: output # run-artifact directory, relative to the file
17
+ */
18
+ /** Stable, versionless URL of the workflow JSON schema (editor validation). */
19
+ export const SCHEMA_URL = "https://raw.githubusercontent.com/NETNODEAG/kraftwerk/main/kraftwerk/schema/workflow.schema.json";
20
+ export const CONFIG_FILENAMES = ["kraftwerk.yml", "kraftwerk.yaml"];
21
+ const WORKFLOW_ROOT_CANDIDATES = ["src/workflows", "workflows"];
22
+ const exists = async (p) => !!(await stat(p).catch(() => null));
23
+ const isDir = async (p) => (await stat(p).catch(() => null))?.isDirectory() ?? false;
24
+ async function findConfigFile(dir) {
25
+ for (const name of CONFIG_FILENAMES) {
26
+ const candidate = path.join(dir, name);
27
+ if (await exists(candidate))
28
+ return candidate;
29
+ }
30
+ return undefined;
31
+ }
32
+ async function findWorkflowsDir(dir, configured) {
33
+ const candidates = configured ? [configured] : WORKFLOW_ROOT_CANDIDATES;
34
+ for (const candidate of candidates) {
35
+ const abs = path.resolve(dir, candidate);
36
+ if (await isDir(abs))
37
+ return abs;
38
+ }
39
+ return undefined;
40
+ }
41
+ /**
42
+ * Resolve the project for a cwd: walk up until a kraftwerk.yml or a
43
+ * workflows root appears; a .git directory is the fallback root, the cwd
44
+ * itself the last resort.
45
+ */
46
+ export async function resolveProject(cwd) {
47
+ const start = path.resolve(cwd);
48
+ let gitFallback;
49
+ for (let dir = start;; dir = path.dirname(dir)) {
50
+ const configPath = await findConfigFile(dir);
51
+ if (configPath) {
52
+ const config = await loadConfig(configPath);
53
+ return {
54
+ root: dir,
55
+ config,
56
+ configPath,
57
+ workflowsRoot: await findWorkflowsDir(dir, config.workflows),
58
+ outputDir: path.resolve(dir, config.output ?? "output"),
59
+ };
60
+ }
61
+ const workflowsRoot = await findWorkflowsDir(dir);
62
+ if (workflowsRoot) {
63
+ return { root: dir, config: {}, workflowsRoot, outputDir: path.join(dir, "output") };
64
+ }
65
+ if (!gitFallback && (await exists(path.join(dir, ".git"))))
66
+ gitFallback = dir;
67
+ if (dir === path.dirname(dir))
68
+ break;
69
+ }
70
+ const root = gitFallback ?? start;
71
+ return { root, config: {}, outputDir: path.join(root, "output") };
72
+ }
73
+ async function loadConfig(configPath) {
74
+ let raw;
75
+ try {
76
+ raw = parse(await readFile(configPath, "utf8"));
77
+ }
78
+ catch (err) {
79
+ throw new Error(`${path.basename(configPath)}: unreadable YAML: ${err.message}`);
80
+ }
81
+ if (raw === null || raw === undefined)
82
+ return {};
83
+ if (typeof raw !== "object" || Array.isArray(raw)) {
84
+ throw new Error(`${path.basename(configPath)}: expected a mapping (workflows, output)`);
85
+ }
86
+ const config = raw;
87
+ const known = ["workflows", "output"];
88
+ for (const key of Object.keys(config)) {
89
+ if (!known.includes(key)) {
90
+ throw new Error(`${path.basename(configPath)}: unknown key "${key}" (allowed: ${known.join(", ")})`);
91
+ }
92
+ if (typeof config[key] !== "string") {
93
+ throw new Error(`${path.basename(configPath)}: ${key} must be a string`);
94
+ }
95
+ }
96
+ return config;
97
+ }
@@ -0,0 +1,21 @@
1
+ import { type LoadedWorkflow } from "./yaml.js";
2
+ /**
3
+ * Workflow auto-discovery for the kraftwerk CLI: a consumer needs no entry
4
+ * file at all — every workflow folder (containing workflow.yml) and every
5
+ * top-level .yml file under the workflows root is picked up automatically.
6
+ *
7
+ * The project root is resolved by walking up from cwd (kraftwerk.yml or a
8
+ * workflows root marks it — see config.ts), so discovery works from any
9
+ * subdirectory. Roots tried in order: src/workflows/, workflows/ (first
10
+ * existing wins), overridable via `workflows:` in kraftwerk.yml.
11
+ * Load failures are carried per entry instead of thrown, so `list` can show
12
+ * broken workflows alongside valid ones.
13
+ */
14
+ export interface DiscoveredWorkflow {
15
+ path: string;
16
+ workflow?: LoadedWorkflow;
17
+ error?: string;
18
+ }
19
+ /** Workflows root for a cwd (walk-up + kraftwerk.yml aware). */
20
+ export declare function findWorkflowsRoot(cwd: string): Promise<string | undefined>;
21
+ export declare function discoverWorkflows(cwd: string): Promise<DiscoveredWorkflow[]>;
@@ -0,0 +1,38 @@
1
+ import { readdir, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ import { resolveProject } from "./config.js";
4
+ import { loadWorkflow } from "./yaml.js";
5
+ /** Workflows root for a cwd (walk-up + kraftwerk.yml aware). */
6
+ export async function findWorkflowsRoot(cwd) {
7
+ return (await resolveProject(cwd)).workflowsRoot;
8
+ }
9
+ export async function discoverWorkflows(cwd) {
10
+ const root = await findWorkflowsRoot(cwd);
11
+ if (!root)
12
+ return [];
13
+ const found = [];
14
+ for (const entry of await readdir(root, { withFileTypes: true })) {
15
+ const entryPath = path.join(root, entry.name);
16
+ let candidate;
17
+ if (entry.isDirectory()) {
18
+ for (const f of ["workflow.yml", "workflow.yaml"]) {
19
+ if (await stat(path.join(entryPath, f)).catch(() => null)) {
20
+ candidate = entryPath;
21
+ break;
22
+ }
23
+ }
24
+ }
25
+ else if (/\.ya?ml$/.test(entry.name)) {
26
+ candidate = entryPath;
27
+ }
28
+ if (!candidate)
29
+ continue;
30
+ try {
31
+ found.push({ path: candidate, workflow: await loadWorkflow(candidate) });
32
+ }
33
+ catch (err) {
34
+ found.push({ path: candidate, error: err.message });
35
+ }
36
+ }
37
+ return found.sort((a, b) => a.path.localeCompare(b.path));
38
+ }
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Typed JSON envelope: every agent phase must end its final message with one
3
+ * fenced ```json block reporting what it did. The orchestrator parses and
4
+ * validates it — the agent's prose is ignored, only the envelope (plus the
5
+ * file gates) decides whether the phase passed.
6
+ */
7
+ export interface Envelope {
8
+ phase: string;
9
+ status: "ok" | "blocked";
10
+ artifacts: string[];
11
+ summary?: string;
12
+ reason?: string;
13
+ }
14
+ export declare function parseEnvelope(text: string, expectedPhase: string): Envelope;
15
+ /**
16
+ * The output contract appended to every agent phase prompt. Owned by the
17
+ * framework because `parseEnvelope` is what enforces it.
18
+ */
19
+ export declare const envelopeContract: (phase: string) => string;
20
+ /** Gate failed: correct in the SAME session instead of a cold restart. */
21
+ export declare const correctionPrompt: (phase: string, failures: string[]) => string;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * Typed JSON envelope: every agent phase must end its final message with one
3
+ * fenced ```json block reporting what it did. The orchestrator parses and
4
+ * validates it — the agent's prose is ignored, only the envelope (plus the
5
+ * file gates) decides whether the phase passed.
6
+ */
7
+ export function parseEnvelope(text, expectedPhase) {
8
+ const fences = [...text.matchAll(/```json\s*([\s\S]*?)```/g)];
9
+ if (fences.length === 0) {
10
+ throw new Error("Envelope missing: no ```json code block in the final answer");
11
+ }
12
+ let raw;
13
+ try {
14
+ raw = JSON.parse(fences[fences.length - 1][1]);
15
+ }
16
+ catch (err) {
17
+ throw new Error(`Envelope is not valid JSON: ${err.message}`);
18
+ }
19
+ if (raw.phase !== expectedPhase) {
20
+ throw new Error(`Envelope reports phase "${raw.phase}", expected "${expectedPhase}"`);
21
+ }
22
+ if (raw.status !== "ok" && raw.status !== "blocked") {
23
+ throw new Error(`Envelope has invalid status "${raw.status}" (allowed: ok | blocked)`);
24
+ }
25
+ if (!Array.isArray(raw.artifacts) ||
26
+ raw.artifacts.some((a) => typeof a !== "string")) {
27
+ throw new Error('Envelope field "artifacts" must be an array of strings');
28
+ }
29
+ return {
30
+ phase: raw.phase,
31
+ status: raw.status,
32
+ artifacts: raw.artifacts,
33
+ summary: typeof raw.summary === "string" ? raw.summary : undefined,
34
+ reason: typeof raw.reason === "string" ? raw.reason : undefined,
35
+ };
36
+ }
37
+ /**
38
+ * The output contract appended to every agent phase prompt. Owned by the
39
+ * framework because `parseEnvelope` is what enforces it.
40
+ */
41
+ export const envelopeContract = (phase) => `
42
+ End your final answer with EXACTLY ONE json code block (the envelope):
43
+
44
+ \`\`\`json
45
+ {"phase": "${phase}", "status": "ok", "artifacts": ["<files written>"], "summary": "<one sentence>"}
46
+ \`\`\`
47
+
48
+ If you cannot complete the task, set "status": "blocked" and add a
49
+ "reason" field. At most one sentence of text before the code block.
50
+ `.trim();
51
+ /** Gate failed: correct in the SAME session instead of a cold restart. */
52
+ export const correctionPrompt = (phase, failures) => `
53
+ The orchestrator checked your phase "${phase}". The following checks
54
+ failed:
55
+
56
+ ${failures.map((f) => `- ${f}`).join("\n")}
57
+
58
+ Fix exactly these points (edit the files directly).
59
+
60
+ ${envelopeContract(phase)}
61
+ `.trim();
@@ -0,0 +1,18 @@
1
+ /**
2
+ * Gates verify post-execution claims, never predictions: they run AFTER the
3
+ * agent finishes and look only at the files it left behind. A gate returns
4
+ * null (passed) or a human-readable failure that goes verbatim into the
5
+ * correction prompt.
6
+ *
7
+ * Only workflow-agnostic gates live here; workflow-specific ones (schema
8
+ * checks etc.) live next to their workflow and implement the same interface.
9
+ */
10
+ export interface Gate {
11
+ name: string;
12
+ check(runDir: string): Promise<string | null>;
13
+ }
14
+ export declare const fileNonEmpty: (file: string) => Gate;
15
+ /** No {{...}} template slots may survive a fill step. */
16
+ export declare const slotsFilled: (file: string) => Gate;
17
+ /** Fixed template parts chosen by code must survive byte-identically. */
18
+ export declare const containsText: (file: string, needle: string, label: string) => Gate;
package/dist/gates.js ADDED
@@ -0,0 +1,34 @@
1
+ import { readFile, stat } from "node:fs/promises";
2
+ import path from "node:path";
3
+ export const fileNonEmpty = (file) => ({
4
+ name: `file_non_empty(${file})`,
5
+ async check(runDir) {
6
+ try {
7
+ const stats = await stat(path.join(runDir, file));
8
+ return stats.size > 0 ? null : `${file} exists but is empty`;
9
+ }
10
+ catch {
11
+ return `${file} was not written`;
12
+ }
13
+ },
14
+ });
15
+ /** No {{...}} template slots may survive a fill step. */
16
+ export const slotsFilled = (file) => ({
17
+ name: `slots_filled(${file})`,
18
+ async check(runDir) {
19
+ const content = await readFile(path.join(runDir, file), "utf8").catch(() => "");
20
+ return content.includes("{{")
21
+ ? `${file} still contains unfilled {{...}} slots`
22
+ : null;
23
+ },
24
+ });
25
+ /** Fixed template parts chosen by code must survive byte-identically. */
26
+ export const containsText = (file, needle, label) => ({
27
+ name: `contains(${file}, ${label})`,
28
+ async check(runDir) {
29
+ const content = await readFile(path.join(runDir, file), "utf8").catch(() => "");
30
+ return content.includes(needle)
31
+ ? null
32
+ : `${file} no longer contains "${needle}" — the fixed template part was changed`;
33
+ },
34
+ });
@@ -0,0 +1,66 @@
1
+ /**
2
+ * The harness port: WHICH runtime executes an agent phase.
3
+ *
4
+ * A harness is a complete headless agent runtime (claude -p, codex exec,
5
+ * pi). All of them share the same contract — spawn one short-lived process,
6
+ * stream JSONL events, resume a session by id — so the framework talks to
7
+ * a single interface and one adapter per CLI normalizes the flags and the
8
+ * event schema (see harnesses/).
9
+ *
10
+ * Sessions are per harness: two phases on the same harness share context
11
+ * via resume; across harnesses, context travels through the files in the
12
+ * run directory (the workspace-context contract).
13
+ */
14
+ export type HarnessId = "claude" | "codex" | "pi";
15
+ /**
16
+ * One MCP server an agent may use: either a local stdio server (spawned by
17
+ * the harness, e.g. `node multiply-server.ts`) or a remote streamable-HTTP
18
+ * server (`url`). Names must be [A-Za-z0-9_-]. Supported by the claude and
19
+ * codex harnesses; pi has no MCP support and rejects the invocation.
20
+ */
21
+ export type McpServerConfig = {
22
+ command: string;
23
+ args?: string[];
24
+ env?: Record<string, string>;
25
+ } | {
26
+ url: string;
27
+ };
28
+ export interface AgentInvocation {
29
+ prompt: string;
30
+ systemPrompt: string;
31
+ cwd: string;
32
+ model: string;
33
+ effort?: string;
34
+ tools: string[];
35
+ /**
36
+ * CLI command prefixes granted for this phase (e.g. "git", "npm run").
37
+ * claude: each becomes a scoped `Bash(<name>:*)` allowlist entry.
38
+ * codex: the sandbox runs commands anyway — nothing to do.
39
+ * pi: no per-command scoping — enables the plain bash tool.
40
+ */
41
+ clis?: string[];
42
+ /** MCP servers available in this phase, keyed by server name. */
43
+ mcpServers?: Record<string, McpServerConfig>;
44
+ resume?: string;
45
+ onToolUse?: (tool: string, target: string) => void;
46
+ onText?: (text: string) => void;
47
+ }
48
+ export interface TokenUsage {
49
+ /** Fresh (uncached) input tokens. */
50
+ inputTokens: number;
51
+ outputTokens: number;
52
+ cacheReadTokens: number;
53
+ cacheCreationTokens: number;
54
+ }
55
+ export interface AgentResult {
56
+ sessionId: string;
57
+ text: string;
58
+ numTurns?: number;
59
+ durationMs?: number;
60
+ costUsd?: number;
61
+ usage?: TokenUsage;
62
+ }
63
+ export interface Harness {
64
+ id: HarnessId;
65
+ invoke(inv: AgentInvocation): Promise<AgentResult>;
66
+ }
@@ -0,0 +1,14 @@
1
+ /**
2
+ * The harness port: WHICH runtime executes an agent phase.
3
+ *
4
+ * A harness is a complete headless agent runtime (claude -p, codex exec,
5
+ * pi). All of them share the same contract — spawn one short-lived process,
6
+ * stream JSONL events, resume a session by id — so the framework talks to
7
+ * a single interface and one adapter per CLI normalizes the flags and the
8
+ * event schema (see harnesses/).
9
+ *
10
+ * Sessions are per harness: two phases on the same harness share context
11
+ * via resume; across harnesses, context travels through the files in the
12
+ * run directory (the workspace-context contract).
13
+ */
14
+ export {};
@@ -0,0 +1,2 @@
1
+ import type { Harness } from "../harness.js";
2
+ export declare const claudeHarness: Harness;
@@ -0,0 +1,117 @@
1
+ import { spawn } from "node:child_process";
2
+ function invokeClaude(inv) {
3
+ const mcpNames = Object.keys(inv.mcpServers ?? {});
4
+ const allowedTools = [
5
+ ...inv.tools,
6
+ ...(inv.clis ?? []).map((name) => `Bash(${name}:*)`),
7
+ ...mcpNames.map((name) => `mcp__${name}`),
8
+ ];
9
+ const args = [
10
+ "-p",
11
+ "--chrome",
12
+ "--output-format", "stream-json",
13
+ "--verbose",
14
+ "--model", inv.model,
15
+ "--system-prompt", inv.systemPrompt,
16
+ "--allowed-tools", allowedTools.join(","),
17
+ "--permission-mode", "acceptEdits",
18
+ // Keep the run hermetic: no user/project settings, hooks, or CLAUDE.md.
19
+ "--setting-sources", "",
20
+ ];
21
+ if (mcpNames.length > 0) {
22
+ const mcpServers = Object.fromEntries(Object.entries(inv.mcpServers).map(([name, cfg]) => [
23
+ name,
24
+ "url" in cfg ? { type: "http", url: cfg.url } : cfg,
25
+ ]));
26
+ args.push("--mcp-config", JSON.stringify({ mcpServers }), "--strict-mcp-config");
27
+ }
28
+ if (inv.effort)
29
+ args.push("--effort", inv.effort);
30
+ if (inv.resume)
31
+ args.push("--resume", inv.resume);
32
+ return new Promise((resolve, reject) => {
33
+ const child = spawn("claude", args, {
34
+ cwd: inv.cwd,
35
+ stdio: ["pipe", "pipe", "pipe"],
36
+ });
37
+ child.stdin.write(inv.prompt);
38
+ child.stdin.end();
39
+ let sessionId;
40
+ let final;
41
+ let stderr = "";
42
+ let buffer = "";
43
+ const handleLine = (line) => {
44
+ if (!line.trim())
45
+ return;
46
+ let msg;
47
+ try {
48
+ msg = JSON.parse(line);
49
+ }
50
+ catch {
51
+ return; // non-JSON noise on stdout
52
+ }
53
+ if (msg.type === "system" && msg.subtype === "init" && msg.session_id) {
54
+ sessionId = msg.session_id;
55
+ }
56
+ if (msg.type === "assistant" && Array.isArray(msg.message?.content)) {
57
+ for (const block of msg.message.content) {
58
+ if (block?.type === "tool_use") {
59
+ const input = block.input ?? {};
60
+ // Bash: show the actual command (single line, clipped).
61
+ const raw = input.file_path ?? input.url ?? input.path ?? input.command ?? "";
62
+ const target = String(raw).replace(/\s+/g, " ").trim();
63
+ inv.onToolUse?.(block.name, target.length > 160 ? `${target.slice(0, 160)}…` : target);
64
+ }
65
+ if (block?.type === "text" && block.text?.trim()) {
66
+ inv.onText?.(block.text.trim());
67
+ }
68
+ }
69
+ }
70
+ if (msg.type === "result") {
71
+ if (msg.is_error || msg.subtype !== "success") {
72
+ reject(new Error(`claude -p failed: ${msg.subtype} ${msg.result ?? ""}`));
73
+ return;
74
+ }
75
+ final = {
76
+ sessionId: msg.session_id ?? sessionId ?? "",
77
+ text: msg.result ?? "",
78
+ numTurns: msg.num_turns,
79
+ durationMs: msg.duration_ms,
80
+ costUsd: msg.total_cost_usd,
81
+ usage: msg.usage
82
+ ? {
83
+ inputTokens: msg.usage.input_tokens ?? 0,
84
+ outputTokens: msg.usage.output_tokens ?? 0,
85
+ cacheReadTokens: msg.usage.cache_read_input_tokens ?? 0,
86
+ cacheCreationTokens: msg.usage.cache_creation_input_tokens ?? 0,
87
+ }
88
+ : undefined,
89
+ };
90
+ }
91
+ };
92
+ child.stdout.on("data", (chunk) => {
93
+ buffer += chunk.toString("utf8");
94
+ const lines = buffer.split("\n");
95
+ buffer = lines.pop() ?? "";
96
+ lines.forEach(handleLine);
97
+ });
98
+ child.stderr.on("data", (chunk) => {
99
+ stderr += chunk.toString("utf8");
100
+ });
101
+ child.on("error", (err) => reject(new Error(`could not spawn claude: ${err.message}`)));
102
+ child.on("close", (code) => {
103
+ handleLine(buffer);
104
+ if (final?.sessionId) {
105
+ resolve(final);
106
+ }
107
+ else {
108
+ reject(new Error(`claude -p exited with code ${code} without a result message` +
109
+ (stderr.trim() ? `\nstderr: ${stderr.trim().slice(-2000)}` : "")));
110
+ }
111
+ });
112
+ });
113
+ }
114
+ export const claudeHarness = {
115
+ id: "claude",
116
+ invoke: invokeClaude,
117
+ };
@@ -0,0 +1,2 @@
1
+ import type { Harness } from "../harness.js";
2
+ export declare const codexHarness: Harness;
@@ -0,0 +1,158 @@
1
+ import { spawn } from "node:child_process";
2
+ const clip = (raw) => {
3
+ const oneLine = raw.replace(/\s+/g, " ").trim();
4
+ return oneLine.length > 160 ? `${oneLine.slice(0, 160)}…` : oneLine;
5
+ };
6
+ /** JSON string escaping is valid TOML basic-string escaping. */
7
+ const toml = (value) => JSON.stringify(value);
8
+ function mcpOverrides(servers) {
9
+ const args = [];
10
+ for (const [name, cfg] of Object.entries(servers)) {
11
+ if (!/^[A-Za-z0-9_-]+$/.test(name)) {
12
+ throw new Error(`codex: MCP server name "${name}" must match [A-Za-z0-9_-]+`);
13
+ }
14
+ const key = `mcp_servers.${name}`;
15
+ if ("url" in cfg) {
16
+ args.push("-c", `${key}.url=${toml(cfg.url)}`);
17
+ continue;
18
+ }
19
+ args.push("-c", `${key}.command=${toml(cfg.command)}`);
20
+ if (cfg.args?.length) {
21
+ args.push("-c", `${key}.args=[${cfg.args.map(toml).join(",")}]`);
22
+ }
23
+ if (cfg.env && Object.keys(cfg.env).length > 0) {
24
+ const table = Object.entries(cfg.env)
25
+ .map(([k, v]) => `${k}=${toml(v)}`)
26
+ .join(",");
27
+ args.push("-c", `${key}.env={${table}}`);
28
+ }
29
+ }
30
+ return args;
31
+ }
32
+ function invokeCodex(inv) {
33
+ const hasMcp = Object.keys(inv.mcpServers ?? {}).length > 0;
34
+ const args = [
35
+ "exec",
36
+ "--json",
37
+ "-m", inv.model,
38
+ // --approve-for-me implies the workspace-write sandbox and lets codex's
39
+ // automatic reviewer answer MCP tool approvals (headless exec would
40
+ // otherwise auto-cancel them).
41
+ ...(hasMcp ? ["--approve-for-me"] : ["--sandbox", "workspace-write"]),
42
+ "-C", inv.cwd,
43
+ "--skip-git-repo-check",
44
+ "--ignore-user-config",
45
+ ];
46
+ if (hasMcp)
47
+ args.push(...mcpOverrides(inv.mcpServers));
48
+ if (inv.effort)
49
+ args.push("-c", `model_reasoning_effort=${inv.effort}`);
50
+ // Governance mapping: a web-tool grant means the sandbox may reach the network.
51
+ if (inv.tools.some((t) => t === "WebFetch" || t === "WebSearch")) {
52
+ args.push("-c", "sandbox_workspace_write.network_access=true");
53
+ }
54
+ if (inv.resume)
55
+ args.push("resume", inv.resume);
56
+ args.push("-"); // read the prompt from stdin
57
+ // No system-prompt flag on codex: carry persona + context in the prompt.
58
+ const prompt = `# Role and workspace context for this phase\n\n${inv.systemPrompt}\n\n` +
59
+ `# Task\n\n${inv.prompt}`;
60
+ const started = Date.now();
61
+ return new Promise((resolve, reject) => {
62
+ const child = spawn("codex", args, {
63
+ cwd: inv.cwd,
64
+ stdio: ["pipe", "pipe", "pipe"],
65
+ });
66
+ child.stdin.write(prompt);
67
+ child.stdin.end();
68
+ let sessionId;
69
+ let usage;
70
+ const texts = [];
71
+ let failure;
72
+ let stderr = "";
73
+ let buffer = "";
74
+ const handleLine = (line) => {
75
+ if (!line.trim())
76
+ return;
77
+ let event;
78
+ try {
79
+ event = JSON.parse(line);
80
+ }
81
+ catch {
82
+ return; // non-JSON noise on stdout
83
+ }
84
+ if (event.type === "thread.started" && event.thread_id) {
85
+ sessionId = event.thread_id;
86
+ }
87
+ if (event.type === "item.started" && event.item) {
88
+ const item = event.item;
89
+ if (item.type === "command_execution" && item.command) {
90
+ inv.onToolUse?.("Bash", clip(item.command));
91
+ }
92
+ if (item.type === "file_change") {
93
+ for (const change of item.changes ?? []) {
94
+ const tool = change.kind === "add" ? "Write" : "Edit";
95
+ inv.onToolUse?.(tool, clip(change.path ?? ""));
96
+ }
97
+ }
98
+ if (item.type === "mcp_tool_call" && item.server && item.tool) {
99
+ inv.onToolUse?.(`mcp__${item.server}__${item.tool}`, clip(item.arguments !== undefined ? JSON.stringify(item.arguments) : ""));
100
+ }
101
+ }
102
+ if (event.type === "item.completed" && event.item?.type === "agent_message") {
103
+ const text = event.item.text?.trim();
104
+ if (text) {
105
+ texts.push(text);
106
+ inv.onText?.(text);
107
+ }
108
+ }
109
+ if (event.type === "turn.completed" && event.usage) {
110
+ const u = event.usage;
111
+ const total = u.input_tokens ?? 0;
112
+ const cached = u.cached_input_tokens ?? 0;
113
+ usage = {
114
+ // codex reports input_tokens as the total including cache reads.
115
+ inputTokens: Math.max(0, total - cached),
116
+ outputTokens: u.output_tokens ?? 0,
117
+ cacheReadTokens: cached,
118
+ cacheCreationTokens: u.cache_write_input_tokens ?? 0,
119
+ };
120
+ }
121
+ if (event.type === "turn.failed" || event.type === "error") {
122
+ failure = event.message ?? line;
123
+ }
124
+ };
125
+ child.stdout.on("data", (chunk) => {
126
+ buffer += chunk.toString("utf8");
127
+ const lines = buffer.split("\n");
128
+ buffer = lines.pop() ?? "";
129
+ lines.forEach(handleLine);
130
+ });
131
+ child.stderr.on("data", (chunk) => {
132
+ stderr += chunk.toString("utf8");
133
+ });
134
+ child.on("error", (err) => reject(new Error(`could not spawn codex: ${err.message}`)));
135
+ child.on("close", (code) => {
136
+ handleLine(buffer);
137
+ if (failure || code !== 0 || !sessionId) {
138
+ reject(new Error(`codex exec failed (exit ${code})` +
139
+ (failure ? `: ${failure}` : "") +
140
+ (stderr.trim() ? `\nstderr: ${stderr.trim().slice(-2000)}` : "")));
141
+ return;
142
+ }
143
+ resolve({
144
+ sessionId,
145
+ // The envelope sits in the last message; parseEnvelope takes the
146
+ // last fenced block, so joining all messages is safe.
147
+ text: texts.join("\n\n"),
148
+ durationMs: Date.now() - started,
149
+ usage,
150
+ // No cost in codex events (subscription auth) — left undefined.
151
+ });
152
+ });
153
+ });
154
+ }
155
+ export const codexHarness = {
156
+ id: "codex",
157
+ invoke: invokeCodex,
158
+ };
@@ -0,0 +1,2 @@
1
+ import type { Harness } from "../harness.js";
2
+ export declare const piHarness: Harness;