Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
25 changes: 25 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,30 @@
# Changelog

## v2.5.0

Any coding agent: the factory no longer knows Claude by name. An agent is config.

- `src/agents/`: one `CommandExecutor` (spawn, prompt on stdin or in the command, timeout, process-group
kill, stderr tail, bounded event log, tool-call cap) and presets for `claude` and `codex`. Claude's
command is unchanged from v2.4.0 (a test pins it), plus `--model` when `agent.model` is set.
- `.factory/config.json` gains `agents` (a `preset`, or your own `command` with `{{prompt}}`,
`{{promptFile}}`, `{{model}}`) and `stages` (`default` plus per-stage overrides, so one agent can build and
another verify). Config is validated at boot, including an unknown preset or stage.
- Any agent that can write files works with no adapter: it gets the stage skill plus an artifact contract, and
`FACTORY_ARTIFACT_DIR`, `FACTORY_ISSUE`, `FACTORY_STAGE`, `FACTORY_SCRATCH_DIR`. Runner credentials and repo
`GIT_*` variables are stripped from its environment. Without a preset, tokens show as not reported.
- Token totals follow machinist: the last terminal event wins, malformed usage is "not reported", never zero.
`stage_runs` records which agent and model ran each stage.
- Verify: findings are structured (`must|should|could`, confidence 0-5, what/why/where/fix) and per-criterion
`pass|fail|unverified`. The runner sends a self-contradicting `pass` to a human. A step result must be one
JSON object under 16 KiB. The runner writes `gate.json` (gate line and git tree hash) so a read-only
verifier trusts current evidence instead of re-running the gates.
- After a timeout the runner stops waiting on pipes held by a descendant that left the process group.
- `factory doctor` checks the binary of each agent a stage uses and warns about one with no preset.

Not in this release: Gemini, OpenCode, Pi, omp, Mastra Code and Amp presets (v2.6.0). The Codex preset is
covered by recorded-shape tests only; a live run is pending.

## v2.4.0

The cockpit: a redesigned dashboard and one place for everything that waits on a human.
Expand Down
26 changes: 24 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,8 @@ Checkpoint branches in the example target repo (`learnwithparam/splitbill`) use

- [Bun](https://bun.sh) ≥ 1.3
- [`gh`](https://cli.github.com), authenticated (`gh auth status`) with write access to the target repo
- [`claude`](https://claude.com/claude-code) on `PATH`, logged in, each stage runs as `claude -p`
- an agent CLI on `PATH`, logged in: [`claude`](https://claude.com/claude-code) by default (each stage runs
as `claude -p`), or `codex`, or any CLI you configure under `agents`
- [`uv`](https://docs.astral.sh/uv/) (for `uvx`, used to validate skills against the
[agentskills.io](https://agentskills.io) spec)
- [Docker](https://www.docker.com) only if you're using VM mode
Expand Down Expand Up @@ -231,6 +232,27 @@ build). Stages get only what the config grants, through `--settings` on `claude
Unknown keys and a missing `repo` are errors, other missing fields take the defaults in
`src/config.ts`.

### Choosing the agent

The factory knows no agent by name. Each one is config, and `stages` says which agent runs which stage:

```json
{
"agents": {
"claude": { "preset": "claude" },
"codex": { "preset": "codex", "model": "gpt-5.6-terra" },
"aider": { "command": ["aider", "--yes-always", "--message-file", "{{promptFile}}"] }
},
"stages": { "default": "claude", "verify": "codex" }
}
```

Presets: `claude`, `codex`. Setting both `preset` and `command` keeps the preset's event parser but runs your command. A `command` agent gets the stage skill plus an artifact contract on stdin, or
where `{{prompt}}` / `{{promptFile}}` appears, and writes its results as files under
`$FACTORY_ARTIFACT_DIR`. It runs with no event parser: tokens show as "not reported" and the tool-call cap
cannot be enforced, so the timeout is the backstop and `factory doctor` says so. A shell as the executable may not take `{{prompt}}` as an argument. The guard hook and
`--settings` rules are Claude-only. A preset-less agent inherits your environment (minus `GH_TOKEN`, `GITHUB_TOKEN`, `FACTORY_*`, repo `GIT_*`), including model API keys and `~/.config/gh`: run it in a sandbox until v3.0. The runner's diff check, gates and commit apply to every agent.

Then bring the target repo up to speed and start the loop:

```bash
Expand All @@ -256,7 +278,7 @@ dependency bump), clone it to see the loop run against something real before wir
| Command | Does |
|---|---|
| `factory install <target-dir> [--dry-run] [--update] [--ci]` | install or update the template in a repo |
| `factory doctor --repo-dir <path> [--fix]` | check `gh`/`claude`/`python3`/`jq` on PATH, `gh auth status`, config, charter, gates.sh, baseline tag, labels |
| `factory doctor --repo-dir <path> [--fix]` | check `gh`, each agent's binary, `python3`, `jq` on PATH, `gh auth status`, config, charter, gates.sh, baseline tag, labels |
| `factory up [--repo-dir <path> \| --repo <owner/name>]` | watch + dashboard in one process, the Docker/VM entrypoint |
| `factory watch [--repo-dir <path> \| --repo <owner/name>] [--once]` | poll and drive the loop (local mode) |
| `factory run [--repo-dir <path> \| --repo <owner/name>] --issue <N>` | advance one issue once, then exit (CI mode) |
Expand Down
5 changes: 5 additions & 0 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,11 @@ Source: https://github.com/owainlewis/machinist (MIT, Copyright (c) 2026 Owain L
- `dashboard/public/lib/task-presentation.js` from `internal/controlplane/web/src/task-presentation.js`
- `dashboard/public/lib/routes.js` from `internal/controlplane/web/src/routes.js`
- `src/revision.ts` from `internal/runner/revision.go` (shape from `internal/protocol/revision.go`)
- `src/agents/executor.ts` from `internal/runner/runner.go` (process group kill from `process_unix.go`)
- `src/agents/env.ts` from `internal/runner/runner.go`
- `src/agents/final-message.ts` from `internal/runner/codex_usage.go`
- `src/agents/presets/codex.ts` from `internal/runner/codex_usage.go`
- `src/agents/usage.ts` from `internal/runner/codex_usage.go`

## owainlewis/assembler@7cac671

Expand Down
16 changes: 8 additions & 8 deletions bin/factory
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,8 @@ import { resolve } from "node:path";
import { GitHub } from "../src/github";
import { GitCommandRunner, Git } from "../src/git";
import { FactoryState, DEFAULT_DB_PATH } from "../src/state";
import { ClaudeExecutor } from "../src/executor";
import { loadConfig } from "../src/config";
import { CommandExecutor } from "../src/agents/executor";
import { loadConfig, type FactoryConfig } from "../src/config";
import { EXIT, UsageError, failureJson, successJson } from "../src/cli-output";
import { advanceIssue, pollOnce, recoverInFlight, startWatch, type WatchDeps } from "../src/watch";
import { ShellGateRunner } from "../src/gates";
Expand Down Expand Up @@ -70,7 +70,7 @@ function buildWatchDeps(cloneDir: string): WatchDeps {
github: new GitHub(),
git: new Git(new GitCommandRunner()),
state: new FactoryState(flag("db") ?? DEFAULT_DB_PATH),
executor: new ClaudeExecutor(),
executor: new CommandExecutor(config.agents, config.stages),
gateRunner: new ShellGateRunner(),
cloneDir,
workspacesDir: flag("workspaces") ?? defaultWorkspacesDir(),
Expand All @@ -80,7 +80,7 @@ function buildWatchDeps(cloneDir: string): WatchDeps {
async function cmdWatch(): Promise<void> {
const cloneDir = await resolveCloneDir();
const config = await loadConfig(cloneDir);
const deps = buildWatchDeps(cloneDir);
const deps = buildWatchDeps(cloneDir, config);
console.log(`factory watch: polling ${config.repo} every ${config.pollIntervalSeconds}s`);
// Re-drive anything a crashed or previously-killed process left sitting in
// a running label before the first poll — otherwise it just sits there,
Expand Down Expand Up @@ -110,7 +110,7 @@ async function cmdRun(): Promise<void> {
const config = await loadConfig(cloneDir);
const issueNumber = Number(flag("issue"));
if (!issueNumber) throw new UsageError("run: --issue <N> is required");
const deps = buildWatchDeps(cloneDir);
const deps = buildWatchDeps(cloneDir, config);
const issue = await deps.github.getIssue(config.repo, issueNumber);
const outcome = await advanceIssue(deps, config, issue);
if (has("json")) console.log(successJson({ issue: issueNumber, outcome }, outcome !== "failed"));
Expand All @@ -124,7 +124,7 @@ async function cmdRun(): Promise<void> {
async function cmdTick(): Promise<void> {
const cloneDir = await resolveCloneDir();
const config = await loadConfig(cloneDir);
const deps = buildWatchDeps(cloneDir);
const deps = buildWatchDeps(cloneDir, config);
const result = await pollOnce(deps, config);
console.log(has("json") ? successJson(result, !result.paused) : JSON.stringify(result, null, 2));
if (result.paused) process.exit(EXIT.paused);
Expand Down Expand Up @@ -268,7 +268,7 @@ async function cmdDoctor(): Promise<void> {
}
},
},
{ repo: config.repo, cloneDir, baselineTag: config.baselineTag, factoryMode: process.env.FACTORY_MODE },
{ repo: config.repo, cloneDir, baselineTag: config.baselineTag, factoryMode: process.env.FACTORY_MODE, agents: config.agents, stages: config.stages },
);
const allOk = checks.every((c) => c.ok);
if (has("json")) console.log(successJson({ checks }, allOk));
Expand Down Expand Up @@ -318,7 +318,7 @@ async function cmdDashboard(): Promise<void> {
async function cmdUp(): Promise<void> {
const cloneDir = await resolveCloneDir();
const config = await loadConfig(cloneDir);
const deps = buildWatchDeps(cloneDir);
const deps = buildWatchDeps(cloneDir, config);
const recovered = await recoverInFlight(deps, config);
if (recovered.length) console.log(`factory up: recovered #${recovered.join(", #")}`);
startWatch(deps, config, (r) => {
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "software-factory",
"version": "2.4.0",
"version": "2.5.0",
"private": true,
"type": "module",
"description": "GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR.",
Expand Down
39 changes: 39 additions & 0 deletions src/agents/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
// Ported from owainlewis/machinist@3943516 internal/runner/runner.go:669-706 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: also strips GH_TOKEN, GITHUB_TOKEN and FACTORY_* (the runner's own secrets, audit finding #14), which machinist has no equivalent of.

// The agent inherits everything else (PATH, HOME, its own model key): it needs
// a key to work, so the residual risk is a spend-capped key, documented in the README.
const STRIPPED_ENV_PREFIXES = ["GH_TOKEN", "GITHUB_TOKEN", "FACTORY_", "GIT_CONFIG_KEY_", "GIT_CONFIG_VALUE_"];

// Repository-pointing git variables. A leaked GIT_DIR or GIT_INDEX_FILE makes
// the agent's git commands act on the wrong repository.
const REPOSITORY_GIT_ENV = new Set([
"GIT_ALTERNATE_OBJECT_DIRECTORIES",
"GIT_CEILING_DIRECTORIES",
"GIT_COMMON_DIR",
"GIT_CONFIG",
"GIT_CONFIG_COUNT",
"GIT_CONFIG_PARAMETERS",
"GIT_DIR",
"GIT_DISCOVERY_ACROSS_FILESYSTEM",
"GIT_GRAFT_FILE",
"GIT_IMPLICIT_WORK_TREE",
"GIT_INDEX_FILE",
"GIT_INTERNAL_SUPER_PREFIX",
"GIT_NAMESPACE",
"GIT_NO_REPLACE_OBJECTS",
"GIT_OBJECT_DIRECTORY",
"GIT_PREFIX",
"GIT_REPLACE_REF_BASE",
"GIT_SHALLOW_FILE",
"GIT_WORK_TREE",
]);

export function sanitizeEnv(env: NodeJS.ProcessEnv): Record<string, string> {
const out: Record<string, string> = {};
for (const [key, value] of Object.entries(env)) {
if (value === undefined) continue;
if (REPOSITORY_GIT_ENV.has(key) || STRIPPED_ENV_PREFIXES.some((p) => key === p || key.startsWith(p))) continue;
out[key] = value;
}
return out;
}
190 changes: 190 additions & 0 deletions src/agents/executor.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,190 @@
// Ported from owainlewis/machinist@3943516 internal/runner/runner.go:190-240 (MIT, Copyright (c) 2026 Owain Lewis). Deviations: the process-group kill is process_unix.go, and TypeScript on Bun (`detached` is setsid); the event cap and 1 MiB line cap follow events.go and codex_usage.go; the tool-call cap and stderr tail are the factory's own (audit finding #15).
// The one executor: spawn any agent CLI, feed the prompt, watch its output
// through the preset's line parser, and kill the whole process group on a
// timeout or a runaway tool-call count.

import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { runDir } from "../artifacts";
import { aggregateStageEvents, type Executor, type StageEvent, type StageRunOptions, type StageRunResult } from "../executor";
import { sanitizeEnv } from "./env";
import { renderPrompt } from "./prompt";
import { PRESETS } from "./presets";
import type { AgentConfig, AgentPreset, StageAgents } from "./types";

const DEFAULT_TIMEOUT_MINUTES = 15;
const STDERR_KEEP_BYTES = 64 * 1024;
export const MAX_EVENT_LINE_BYTES = 1 << 20;
export const MAX_RECORDED_OUTPUT_BYTES = 64 << 20;

export interface ResolvedAgent {
readonly name: string;
readonly config: AgentConfig;
readonly preset?: AgentPreset;
}

export function resolveAgent(agents: Record<string, AgentConfig>, stages: StageAgents, stage: keyof StageAgents & string): ResolvedAgent {
const name = stages[stage] ?? stages.default ?? "claude";
const config = agents[name];
if (!config) throw new Error(`stage ${stage} uses agent "${name}", which is not in config.agents`);
const preset = config.preset ? PRESETS[config.preset] : undefined;
if (config.preset && !preset) throw new Error(`agent "${name}": unknown preset "${config.preset}"`);
return { name, config, preset };
}

// Placeholders in a config command. Anything else is passed through verbatim.
export function renderCommand(command: readonly string[], values: { prompt: string; promptFile: string; model: string }): { argv: string[]; usesStdin: boolean } {
let usesStdin = true;
const argv = command.map((part) =>
part.replace(/\{\{(prompt|promptFile|model)\}\}/g, (_m, key: "prompt" | "promptFile" | "model") => {
if (key !== "model") usesStdin = false;
return values[key];
}),
);
return { argv, usesStdin };
}

export class CommandExecutor implements Executor {
constructor(
private readonly agents: Record<string, AgentConfig>,
private readonly stages: StageAgents,
) {}

async runStage(opts: StageRunOptions): Promise<StageRunResult> {
const agent = resolveAgent(this.agents, this.stages, opts.stage);
const scratch = mkdtempSync(join(tmpdir(), `factory-scratch-${opts.issue}-`));
try {
return await this.spawnStage(opts, agent, scratch);
} finally {
rmSync(scratch, { recursive: true, force: true });
}
}

private async spawnStage(opts: StageRunOptions, agent: ResolvedAgent, scratch: string): Promise<StageRunResult> {
const artifactDir = join(opts.cwd, runDir(opts.issue));
mkdirSync(artifactDir, { recursive: true });

let argv: readonly string[];
let stdin: string | undefined;
if (agent.preset?.ownsPrompt) {
({ argv, stdin } = agent.preset.command(opts, agent.config, ""));
} else {
const prompt = await renderPrompt(opts);
if (agent.config.command) {
const promptFile = join(scratch, "prompt.md");
writeFileSync(promptFile, prompt);
const rendered = renderCommand(agent.config.command, { prompt, promptFile, model: agent.config.model ?? "" });
argv = rendered.argv;
stdin = rendered.usesStdin ? prompt : undefined;
} else if (agent.preset) {
({ argv, stdin } = agent.preset.command(opts, agent.config, prompt));
} else {
throw new Error(`agent "${agent.name}" has neither a preset nor a command`);
}
}

const proc = Bun.spawn([...argv], {
cwd: opts.cwd,
stdin: stdin === undefined ? "ignore" : new Blob([stdin]),
stdout: "pipe",
stderr: "pipe",
detached: true, // its own process group, so a kill reaches the agent's children too
env: {
...sanitizeEnv(process.env),
FACTORY_ARTIFACT_DIR: artifactDir,
FACTORY_ISSUE: String(opts.issue),
FACTORY_STAGE: opts.stage,
FACTORY_SCRATCH_DIR: scratch,
},
});
let cancelRead: (() => void) | undefined;
const killGroup = () => {
// ESRCH means it already exited; that is the goal.
try {
process.kill(-proc.pid, "SIGKILL");
} catch {
proc.kill();
}
// A descendant that left the group (setsid) can still hold the pipe open:
// give the reader a moment to drain, then stop waiting for it.
setTimeout(() => cancelRead?.(), 2000).unref();
};
const events: StageEvent[] = [];
let toolCalls = 0;
let killedReason: string | undefined;
let usageComplete = agent.preset !== undefined;
let recorded = 0;
// Read alongside stdout, not after: an unread pipe can fill its OS buffer
// and stall the child, and launch-time errors go to stderr only.
const errReader = proc.stderr.getReader();
const stderrPromise = (async () => {
const dec = new TextDecoder();
let text = "";
for (;;) {
const { done, value } = await errReader.read().catch(() => ({ done: true, value: undefined }));
if (done) return text + dec.decode();
text = (text + dec.decode(value, { stream: true })).slice(-STDERR_KEEP_BYTES);
}
})();

const timeoutMinutes = opts.timeoutMinutes ?? DEFAULT_TIMEOUT_MINUTES;
const timer = setTimeout(() => {
killedReason = `stage exceeded stageTimeoutMinutes=${timeoutMinutes}`;
killGroup();
}, timeoutMinutes * 60_000);

const handle = (line: string) => {
if (!agent.preset) return;
if (Buffer.byteLength(line) > MAX_EVENT_LINE_BYTES) {
// Dropped, not parsed; if it was the terminal usage event the count is gone.
if (agent.preset.isUsageCandidate(line.slice(0, 4096))) usageComplete = false;
return;
}
for (const e of agent.preset.parseLine(line)) {
recorded += Buffer.byteLength(e.text ?? "");
if (recorded > MAX_RECORDED_OUTPUT_BYTES) {
if (events.at(-1)?.kind !== "truncated") events.push({ kind: "truncated", text: `recording stopped after ${MAX_RECORDED_OUTPUT_BYTES} output bytes; the agent keeps running` });
} else events.push(e);
if (e.kind !== "tool_use") continue;
toolCalls += 1;
if (opts.maxToolCalls && toolCalls > opts.maxToolCalls && !killedReason) {
killedReason = `stage exceeded maxToolCalls=${opts.maxToolCalls}`;
killGroup();
}
}
};

try {
const reader = proc.stdout.getReader();
cancelRead = () => {
void reader.cancel().catch(() => {});
void errReader.cancel().catch(() => {});
};
const decoder = new TextDecoder();
let buffer = "";
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop() ?? "";
for (const line of lines) handle(line);
// An unterminated line past the cap is dropped now, not buffered without bound.
if (buffer.length > MAX_EVENT_LINE_BYTES) {
if (agent.preset?.isUsageCandidate(buffer.slice(0, 4096))) usageComplete = false;
buffer = "";
}
}
if (buffer.trim()) handle(buffer);
} finally {
clearTimeout(timer);
}

const [exitCode, stderr] = await Promise.all([proc.exited, stderrPromise]);
const stderrTail = stderr.trim().slice(-4000) || undefined;
const base0 = aggregateStageEvents(events, exitCode, stderrTail);
const base = { ...base0, agent: agent.name, model: agent.config.model ?? null, usageComplete: usageComplete && base0.usageComplete !== false };
return killedReason ? { ...base, exitCode: exitCode || 1, killedReason } : base;
}
}
Loading
Loading