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
36 changes: 36 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,41 @@
# Changelog

## v2.5.1

Finishes v2.5: the pieces it promised and did not ship.

- Cost meter: `src/pricing.ts` holds a dated per-model price table. An unknown model is "not reported",
never $0. `stage_runs` gains `tokens_cached` and `usage_complete` (guarded, idempotent `ALTER`), and the
dashboard shows "Not reported" for incomplete usage.
- Stage policy: triage, plan and verify run Codex with `-s read-only` and return their artifact as the final
message; the runner validates it (`src/agents/reply.ts`) and writes the file. Build and pr keep
`workspace-write`. Opt in to `--output-schema` with `outputSchema: true` on an agent.
- Stage artifacts get a JSON Schema built from the validators (`src/schemas.ts`), included in the artifact
contract. Every step artifact accepts `outcome: complete|blocked|failed` and rejects unknown fields;
`blocked` goes to needs-human with the reason.
- Event log has a 32 MiB byte budget per run, with a `process.output_truncated` marker.
- A `command` agent that is really `codex exec` or `claude -p`, even behind `env`, `mise` or `direnv`, gets
its JSON flag and preset parser.
- Verify refuses stale evidence: if `gate.json` names a different tree than HEAD, the gates re-run first.
Triage refuses an issue another open PR already closes, before any tokens are spent.
- Skills: `outcome` is taught, verdict comments render `pass|fail|unverified` per criterion, AC ids are never
renumbered, build stops after 3 failed gate runs.
- Dashboard: the session cookie is a random id, not the token. Thread, run titles, artifact previews and CLI
errors pass through `plain()`. `InboxChannel` interface, inbox argument parsing and every ChatOps
command are tested.
- Provenance: the test now checks one ported test per row and Markdown ports.

Not in this release:

- No live Codex run (#18). Claude gets no `--json-schema`; `outputSchema` is opt-in.
- `gpt-5.6-terra` is unpriced, so its cost is not reported. Incomplete usage stores `cost_usd = 0` with
`usage_complete = 0`, not NULL.
- A read-only reply's artifact is capped at 16 KiB.
- The tool-free finding re-check call (P40) is still prompt text only.
- Sub-minute durations keep the upstream `42.5s` format.
- Upstream `runs-view.test.js`, `artifacts_test.go` and the auth tests are not ported.
- Real Claude fixtures per stage are not recorded; they spend tokens.

## v2.5.0

Any coding agent: the factory no longer knows Claude by name. An agent is config.
Expand Down
35 changes: 20 additions & 15 deletions THIRD_PARTY_NOTICES.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,26 +8,31 @@ in step. Only MIT-licensed code is copied.

Source: https://github.com/owainlewis/machinist (MIT, Copyright (c) 2026 Owain Lewis)

- `dashboard/public/styles.css` from `internal/controlplane/web/src/styles.css`
- `dashboard/public/lib/run-metrics.js` from `internal/controlplane/web/src/run-metrics.js`
- `dashboard/public/lib/runs-board.js` from `internal/controlplane/web/src/runs-board.js`
- `dashboard/public/lib/status-loader.js` from `internal/controlplane/web/src/status-loader.js`
- `dashboard/public/lib/analytics-state.js` from `internal/controlplane/web/src/analytics-state.js`
- `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`
- `dashboard/public/styles.css` from `internal/controlplane/web/src/styles.css` [no upstream test]
- `dashboard/public/lib/run-metrics.js` from `internal/controlplane/web/src/run-metrics.js` [tested by `tests/ported/machinist/run-metrics.test.ts`]
- `dashboard/public/lib/runs-board.js` from `internal/controlplane/web/src/runs-board.js` [tested by `tests/ported/machinist/runs-board.test.ts`]
- `dashboard/public/lib/status-loader.js` from `internal/controlplane/web/src/status-loader.js` [tested by `tests/ported/machinist/status-ui.test.ts`]
- `dashboard/public/lib/analytics-state.js` from `internal/controlplane/web/src/analytics-state.js` [no upstream test]
- `dashboard/public/lib/task-presentation.js` from `internal/controlplane/web/src/task-presentation.js` [tested by `tests/ported/machinist/task-presentation.test.ts`]
- `dashboard/public/lib/routes.js` from `internal/controlplane/web/src/routes.js` [tested by `tests/ported/machinist/routes.test.ts`]
- `src/revision.ts` from `internal/runner/revision.go` (shape from `internal/protocol/revision.go`) [tested by `tests/ported/machinist/revision.test.ts`]
- `src/agents/executor.ts` from `internal/runner/runner.go` (process group kill from `process_unix.go`) [tested by `tests/ported/machinist/runner.test.ts`]
- `src/agents/env.ts` from `internal/runner/runner.go` [tested by `tests/ported/machinist/runner.test.ts`]
- `src/event-budget.ts` from `internal/runner/events.go` [tested by `tests/ported/machinist/events.test.ts`]
- `src/agents/structured.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/structured.test.ts`]
- `src/agents/final-message.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/final-message.test.ts`]
- `src/agents/presets/codex.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/usage.test.ts`]
- `src/agents/usage.ts` from `internal/runner/codex_usage.go` [tested by `tests/ported/machinist/usage.test.ts`]
- `src/artifacts.ts` from `internal/protocol/workflow.go` [tested by `tests/ported/machinist/workflow.test.ts`]

## owainlewis/assembler@7cac671

Source: https://github.com/owainlewis/assembler (MIT, Copyright (c) 2026 Owain Lewis)

- `src/display.ts` from `src/display.ts`
- `src/logs.ts` from `src/runs.ts`
- `src/display.ts` from `src/display.ts` [tested by `tests/ported/assembler/display.test.ts`]
- `src/logs.ts` from `src/runs.ts` [tested by `tests/ported/assembler/logs.test.ts`]
- `src/config.ts` from `src/index.ts` [tested by `tests/ported/assembler/config.test.ts`]
- `src/agents/reply.ts` from `src/index.ts` [tested by `tests/ported/assembler/outputs.test.ts`]

## License text (both projects)

Expand Down
13 changes: 4 additions & 9 deletions bin/factory
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@ import { advanceIssue, pollOnce, recoverInFlight, startWatch, type WatchDeps } f
import { ShellGateRunner } from "../src/gates";
import { scan } from "../src/scan";
import { streamLogs } from "../src/logs";
import { act, buildInbox, type InboxAction } from "../src/inbox";
import { plain } from "../src/display";
import { act, buildInbox, inboxPositionals, type InboxAction } from "../src/inbox";
import { reset, rebaseline } from "../src/reset";
import { runDoctor, fixDoctor } from "../src/doctor";
import { createDashboard } from "../dashboard/server";
Expand Down Expand Up @@ -164,13 +165,7 @@ async function cmdInbox(): Promise<void> {
if (!repo) throw new UsageError("inbox: --repo <owner/name> (or FACTORY_REPO) is required");
const github = new GitHub();
const items = buildInbox(await github.listOpenIssues(repo));
const positional: string[] = [];
for (let i = 1; i < args.length; i++) {
if (args[i] === "--json") continue;
if (args[i]!.startsWith("--")) i++; // a flag and its value
else positional.push(args[i]!);
}
const [issueArg, actionArg] = positional;
const [issueArg, actionArg] = inboxPositionals(args);
if (issueArg) {
const item = items.find((i) => i.issue === Number(issueArg));
if (!item) throw new UsageError(`inbox: #${issueArg} is not waiting for you`);
Expand Down Expand Up @@ -382,6 +377,6 @@ try {
await main();
} catch (err) {
if (has("json")) console.error(failureJson(err));
else console.error(`factory: ${err instanceof Error ? err.message : err}`);
else console.error(`factory: ${plain(err instanceof Error ? err.message : String(err))}`);
process.exit(EXIT.error);
}
2 changes: 1 addition & 1 deletion dashboard/public/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -215,7 +215,7 @@ function renderSheet() {
h("h2", null, "Stages"),
!sheet.stages ? h("p", { class: "muted" }, "Loading") : sheet.stages.length ? h("div", { class: "table-wrap" }, h("table", null, h("thead", null, h("tr", null, ["Stage", "Agent", "Took", "Tokens", "Cost"].map((c, i) => h("th", { class: i >= 2 ? "num" : "" }, c)))),
h("tbody", null, sheet.stages.map((s) => h("tr", null, h("td", null, h("span", { class: "status", "data-tone": s.exit_code === 0 && !s.killed_reason ? "ok" : "bad" }, s.stage)), h("td", null, s.agent), h("td", { class: "num" }, formatDurationMillis(s.duration_ms)),
h("td", { class: "num" }, s.tokens_in + s.tokens_out ? compact(s.tokens_in + s.tokens_out) : "Not reported"), h("td", { class: "num" }, money(s.cost_usd))))))) : h("p", { class: "muted" }, "No stage has finished yet."),
h("td", { class: "num" }, s.usage_complete !== 0 && s.tokens_in + s.tokens_out ? compact(s.tokens_in + s.tokens_out) : "Not reported"), h("td", { class: "num" }, s.usage_complete === 0 ? "Not reported" : money(s.cost_usd))))))) : h("p", { class: "muted" }, "No stage has finished yet."),
h("h2", { style: "margin-top:1.5rem" }, "Files from the run"),
!sheet.artifacts ? h("p", { class: "muted" }, "Loading") : sheet.artifacts.length ? h("div", { class: "actions" }, sheet.artifacts.map((f) => h("button", { class: "btn", type: "button", onclick: () => preview(f.name) }, `${f.name} (${compact(f.size)}B)`))) : h("p", { class: "muted" }, "This run left no files on this machine."),
sheet.preview && [h("p", { class: "muted" }, `${sheet.preview.name}${sheet.preview.truncated ? " (first 1 MiB)" : ""} `, h("a", { href: `/api/issues/${sheet.issue}/artifacts?file=${encodeURIComponent(sheet.preview.name)}&download=1` }, "Download")), h("pre", { class: "log" }, sheet.preview.text)],
Expand Down
37 changes: 28 additions & 9 deletions dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
// works with no local SQLite at all (a CI/VM run with no watcher on this
// machine).

import { createHash, timingSafeEqual } from "node:crypto";
import { createHash, randomBytes, timingSafeEqual } from "node:crypto";
import { readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
import { dirname, join, sep } from "node:path";
import { fileURLToPath } from "node:url";
Expand Down Expand Up @@ -44,6 +44,8 @@ export function tokenMatches(presented: string, expected: string): boolean {
}

const SESSION_COOKIE = "factory_session";
const SESSION_TTL_MS = 8 * 60 * 60 * 1000;
const MAX_SESSIONS = 1000;

function cookie(req: Request, name: string): string {
for (const part of (req.headers.get("cookie") ?? "").split(";")) {
Expand All @@ -61,9 +63,18 @@ const ASSET_TYPES: Record<string, string> = { css: "text/css; charset=utf-8", js

export const ARTIFACT_LIMIT = 1024 * 1024;

function plainRun<T extends { title: string }>(run: T): T {
return { ...run, title: plain(run.title) };
}

function plainIssue<T extends { title: string; body: string; comments: { body: string }[] }>(issue: T): T {
return { ...issue, title: plain(issue.title), body: plain(issue.body), comments: issue.comments.map((c) => ({ ...c, body: plain(c.body) })) };
}

export function createDashboard(state: FactoryState, github: GitHub, repo: string, autoApproveDefault = false, workspaces = workspacesDir()) {
const indexHtml = readFileSync(join(here, "public", "index.html"), "utf8");

const sessions = new Map<string, number>();
let boardCache: { at: number; issues: Awaited<ReturnType<GitHub["listOpenIssues"]>> } | null = null;
let boardInflight: Promise<Awaited<ReturnType<GitHub["listOpenIssues"]>>> | null = null;

Expand Down Expand Up @@ -152,7 +163,8 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin
"x-artifact-truncated": String(truncated),
};
if (url.searchParams.get("download")) headers["content-disposition"] = `attachment; filename="${name.replace(/[^\w.-]/g, "_")}"`;
return new Response(body, { headers });
// A preview is text for a person; a download stays byte for byte.
return new Response(url.searchParams.get("download") ? body : plain(Buffer.from(body).toString("utf8")), { headers });
}

type Handler = (req: Request, url: URL, m: RegExpMatchArray) => Response | Promise<Response>;
Expand Down Expand Up @@ -191,10 +203,16 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin
if (!DASHBOARD_TOKEN || typeof body.token !== "string" || !tokenMatches(body.token, DASHBOARD_TOKEN)) {
return json({ error: "unauthorized" }, { status: 401 });
}
return json({ ok: true }, { headers: { "set-cookie": `${SESSION_COOKIE}=${DASHBOARD_TOKEN}; HttpOnly; SameSite=Strict; Path=/` } });
// The cookie is a random id, never the token, so a leaked cookie cannot be replayed as a Bearer token.
const id = randomBytes(32).toString("hex");
const now = Date.now();
for (const [k, expires] of sessions) if (expires <= now) sessions.delete(k);
if (sessions.size >= MAX_SESSIONS) sessions.delete(sessions.keys().next().value!);
sessions.set(id, now + SESSION_TTL_MS);
return json({ ok: true }, { headers: { "set-cookie": `${SESSION_COOKIE}=${id}; HttpOnly; SameSite=Strict; Path=/; Max-Age=${SESSION_TTL_MS / 1000}` } });
},
},
{ method: "GET", pattern: /^\/api\/runs$/, label: "GET /api/runs", handler: () => json({ repo, runs: state.listRuns(repo || undefined) }) },
{ method: "GET", pattern: /^\/api\/runs$/, label: "GET /api/runs", handler: () => json({ repo, runs: state.listRuns(repo || undefined).map(plainRun) }) },
{
method: "GET",
pattern: /^\/api\/board$/,
Expand All @@ -219,7 +237,7 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin
method: "GET",
pattern: /^\/api\/issues\/(\d+)\/thread$/,
label: "GET /api/issues/:n/thread",
handler: async (_req, _url, m) => needRepo() ?? json({ issue: await github.getIssue(repo, Number(m[1])) }),
handler: async (_req, _url, m) => needRepo() ?? json({ issue: plainIssue(await github.getIssue(repo, Number(m[1]))) }),
},
{
method: "GET",
Expand All @@ -243,7 +261,7 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin
handler: (_req, _url, m) => {
const run = state.listRuns(repo || undefined).find((r) => r.id === Number(m[1]));
if (!run) return json({ error: "no such run" }, { status: 404 });
return json({ run, stages: state.listStageRuns(run.repo, { issue: run.issue }) });
return json({ run: plainRun(run), stages: state.listStageRuns(run.repo, { issue: run.issue }) });
},
},
{
Expand All @@ -257,7 +275,7 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin
return json({
repo,
rows: runs.map((run) => ({
run,
run: plainRun(run),
stages: attempts
.filter((a) => a.issue === run.issue)
.map((a) => ({ stage: a.stage, agent: a.agent, duration_ms: a.duration_ms, cost_usd: a.cost_usd, ok: a.exit_code === 0 && !a.killed_reason })),
Expand Down Expand Up @@ -375,8 +393,9 @@ export function createDashboard(state: FactoryState, github: GitHub, repo: strin

function authorized(req: Request): boolean {
const header = req.headers.get("authorization") ?? "";
const presented = header.startsWith("Bearer ") ? header.slice(7) : cookie(req, SESSION_COOKIE);
return tokenMatches(presented, DASHBOARD_TOKEN);
if (header.startsWith("Bearer ")) return tokenMatches(header.slice(7), DASHBOARD_TOKEN);
const expires = sessions.get(cookie(req, SESSION_COOKIE));
return expires !== undefined && expires > Date.now();
}

// `remoteAddress` comes from `server.requestIP(req)` at the real Bun.serve
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.5.0",
"version": "2.5.1",
"private": true,
"type": "module",
"description": "GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR.",
Expand Down
24 changes: 21 additions & 3 deletions src/agents/executor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,10 @@ import { aggregateStageEvents, type Executor, type StageEvent, type StageRunOpti
import { sanitizeEnv } from "./env";
import { renderPrompt } from "./prompt";
import { PRESETS } from "./presets";
import type { AgentConfig, AgentPreset, StageAgents } from "./types";
import { structuredCommand } from "./structured";
import { replySchema } from "../schemas";
import { writeReply } from "./reply";
import { type AgentConfig, type AgentPreset, type StageAgents, stagePolicy } from "./types";

const DEFAULT_TIMEOUT_MINUTES = 15;
const STDERR_KEEP_BYTES = 64 * 1024;
Expand All @@ -30,6 +33,9 @@ export function resolveAgent(agents: Record<string, AgentConfig>, stages: StageA
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}"`);
// A bare `codex exec` or `claude -p` command still gets its JSON flag and parser.
const found = !preset && config.command ? structuredCommand(name, config.command) : undefined;
if (found) return { name, config: { ...config, command: found.command }, preset: PRESETS[found.preset] };
return { name, config, preset };
}

Expand Down Expand Up @@ -65,20 +71,27 @@ export class CommandExecutor implements Executor {
const artifactDir = join(opts.cwd, runDir(opts.issue));
mkdirSync(artifactDir, { recursive: true });

// A read-only stage on a preset that cannot write files returns them instead.
const readOnly = !stagePolicy(opts.stage).write && agent.preset?.returnsArtifact === true && !agent.config.command;
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);
const prompt = await renderPrompt(opts, readOnly);
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));
let schemaFile: string | undefined;
if (readOnly && agent.config.outputSchema) {
schemaFile = join(scratch, "reply.schema.json");
writeFileSync(schemaFile, JSON.stringify(replySchema(opts.stage)));
}
({ argv, stdin } = agent.preset.command(opts, agent.config, prompt, { schemaFile }));
} else {
throw new Error(`agent "${agent.name}" has neither a preset nor a command`);
}
Expand Down Expand Up @@ -185,6 +198,11 @@ export class CommandExecutor implements Executor {
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 };
if (readOnly && exitCode === 0 && !killedReason) {
const problem = await writeReply(opts.cwd, opts.issue, opts.stage, base.finalMessage);
// No file is left behind, so the runner reports "no valid <stage>.json".
if (problem) base.events.push({ kind: "text", text: `read-only reply rejected: ${problem}` });
}
return killedReason ? { ...base, exitCode: exitCode || 1, killedReason } : base;
}
}
Loading
Loading