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
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -4,3 +4,4 @@ workspaces/
*.log
.DS_Store
artifacts/
.worktrees/
24 changes: 24 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
# Changelog

## v2.3.0

Honest foundation: what the README and labels promise now matches what the code does.

- `/factory cancel` closes the issue and any PR, removes the labels and the worktree, from every waiting
state (needs-info, awaiting-approval, needs-human, failed, in-review). Before, it only worked at plan
approval and never closed the issue, and in needs-info it was recorded as the answer.
- `.factory/config.json` is validated at boot: unknown or mistyped keys and wrong types refuse to start,
and every problem is listed at once. `riskCriteria` and `_comment` keys stay allowed.
- New `stage_runs` table: one row per stage attempt (duration, tokens, cost, exit, kill reason), so retries
keep their history. Forward-only migration; existing databases are upgraded in place.
- `--json` on `run`, `tick`, `watch --once` and `doctor`, with a documented exit-code table (2 run failed,
3 paused, 4 doctor failed). Pattern from jiractrl.
- The Claude Code pin moves from 2.0.5 to 2.1.281 (Dockerfile and CI template); every flag the executor passes
is present in that version's `--help`. A test keeps the two pins equal.
- Docs: the `factory:failed` label text, the dashboard bind comment and the README no longer claim a
`factory:monitor` stage (that label marks issues filed by `factory scan`).
- Fix: `claim` and the runner's commits failed on a host with no git identity (a fresh CI runner or
container). The runner now supplies a fallback identity only when none is configured. `make test` runs
with no identity so this cannot hide on a developer machine again.

Not in this release: a monitor stage (v2.8), any agent besides Claude (v2.5).
2 changes: 1 addition & 1 deletion Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ FROM oven/bun:1.3.4-slim

ARG GH_CLI_VERSION=2.63.2
ARG NODE_MAJOR=20
ARG CLAUDE_CODE_VERSION=2.0.5
ARG CLAUDE_CODE_VERSION=2.1.281
ARG UV_VERSION=0.5.11

RUN apt-get update && apt-get install -y --no-install-recommends \
Expand Down
4 changes: 3 additions & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -12,8 +12,10 @@ check: typecheck test skills-check
typecheck:
bun x tsc --noEmit

# Run with no git identity, like a fresh CI runner or container, so a code path
# that needs the developer's own identity fails here and not only on GitHub.
test:
bun test
GIT_CONFIG_GLOBAL=/dev/null GIT_CONFIG_SYSTEM=/dev/null GIT_CONFIG_COUNT=1 GIT_CONFIG_KEY_0=user.useConfigOnly GIT_CONFIG_VALUE_0=true bun test

skills-check:
@command -v uvx >/dev/null 2>&1 || { \
Expand Down
26 changes: 20 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,15 +1,15 @@
# Software Factory

A GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR, monitor. You watch
A GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR. You watch
it happen in issues, comments, and a dashboard, instead of an agent's private terminal.

Point it at any repo, label an issue `factory:ready`, and it runs the loop end to end: a PR
lands with a test that proves it, or a human gets asked exactly what's missing.

## The loop

A human labels an issue `factory:ready`. The runner claims it, then drives six stages, each a
Claude Code skill working in its own git worktree:
A human labels an issue `factory:ready`. The runner claims it, then drives five stages, each a
Claude Code skill working in its own git worktree, then the runner opens the PR:

1. **triage** classifies risk and posts a triage comment.
2. **plan** researches the code (via `factory-explorer`) and posts a plan comment with acceptance
Expand Down Expand Up @@ -77,14 +77,14 @@ The issue's label is the state. Everything else is derived from the thread.
| Label | Meaning | Moves on when |
|---|---|---|
| `factory:ready` | a human queued it | the runner claims it |
| `factory:triaging` / `planning` | the agent is working | the stage finishes |
| `factory:triaging` / `factory:planning` | the agent is working | the stage finishes |
| `factory:needs-info` | the agent asked a question (max two rounds, then `needs-human`) | a trusted comment answers it |
| `factory:awaiting-approval` | plan posted, waiting | `/factory approve`, or auto for low risk |
| `factory:building` / `verifying` | building, or proving the build | gates and verifier finish; a reject goes back to build twice, then `needs-human` |
| `factory:building` / `factory:verifying` | building, or proving the build | gates and verifier finish; a reject goes back to build twice, then `needs-human` |
| `factory:in-review` | PR ready for review | a human merges, or asks `/factory revise` |
| `factory:failed` | a stage broke (timeout, red gates, boundary, denied tool) | `/factory retry` |
| `factory:needs-human` | refused or out of rounds | `/factory retry` |
| `factory:monitor` | merged, being watched | the monitor closes it |
| `factory:monitor` | filed by `factory scan` from a production signal; a provenance label, not a stage | nothing, it stays on the issue |

## Where a human acts

Expand All @@ -109,6 +109,20 @@ Always through GitHub state, which the runner polls every `pollIntervalSeconds`.
Every runner comment carries a hidden `<!-- factory:` marker, so the runner never mistakes its own
comments for an answer.

## Scripting the CLI

`run`, `tick`, `watch --once` and `doctor` take `--json`: success is `{"ok":true,"data":...}` on
stdout, failure is `{"ok":false,"error":{"kind":"config|usage|error","message":...}}` on stderr.
Progress lines go to stderr, so stdout stays parseable.

| Exit | Meaning |
|---:|---|
| 0 | success |
| 1 | usage, invalid config, `gh` or `git` failure, or anything unexpected |
| 2 | `run`: the issue ended in `factory:failed` |
| 3 | `tick` or `watch --once`: paused by `maxOpenFactoryPrs`, no new pickups |
| 4 | `doctor`: at least one check failed |

## Proving every scenario

You do not run every path live. `tests/scenarios.test.ts` replays each branch of the state machine
Expand Down
57 changes: 25 additions & 32 deletions bin/factory
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ 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 { EXIT, UsageError, failureJson, successJson } from "../src/cli-output";
import { advanceIssue, pollOnce, recoverInFlight, startWatch, type WatchDeps } from "../src/watch";
import { ShellGateRunner } from "../src/gates";
import { scan } from "../src/scan";
Expand Down Expand Up @@ -53,11 +54,10 @@ async function resolveCloneDir(): Promise<string> {
const repoSlug = flag("repo");
if (repoSlug) {
const dir = await ensureRepoClone(new GitCommandRunner(), repoSlug);
console.log(`factory: --repo ${repoSlug} resolved to ${dir}`);
console.error(`factory: --repo ${repoSlug} resolved to ${dir}`);
return dir;
}
console.error("factory: --repo-dir <path-to-clone> or --repo <owner/name> is required");
process.exit(1);
throw new UsageError("--repo-dir <path-to-clone> or --repo <owner/name> is required");
}

// Shared by watch/run/tick so all three modes (long-lived poll, one-shot CI
Expand Down Expand Up @@ -88,7 +88,8 @@ async function cmdWatch(): Promise<void> {
if (recovered.length) console.log(`factory watch: recovered #${recovered.join(", #")}`);
if (has("once")) {
const result = await pollOnce(deps, config);
console.log(JSON.stringify(result, null, 2));
console.log(has("json") ? successJson(result, !result.paused) : JSON.stringify(result, null, 2));
if (result.paused) process.exit(EXIT.paused);
return;
}
startWatch(deps, config, (r) => {
Expand All @@ -106,15 +107,13 @@ async function cmdRun(): Promise<void> {
const cloneDir = await resolveCloneDir();
const config = await loadConfig(cloneDir);
const issueNumber = Number(flag("issue"));
if (!issueNumber) {
console.error("factory run: --issue <N> is required");
process.exit(1);
}
if (!issueNumber) throw new UsageError("run: --issue <N> is required");
const deps = buildWatchDeps(cloneDir);
const issue = await deps.github.getIssue(config.repo, issueNumber);
const outcome = await advanceIssue(deps, config, issue);
console.log(`factory run #${issueNumber}: ${outcome}`);
if (outcome === "failed") process.exit(1);
if (has("json")) console.log(successJson({ issue: issueNumber, outcome }, outcome !== "failed"));
else console.log(`factory run #${issueNumber}: ${outcome}`);
if (outcome === "failed") process.exit(EXIT.runFailed);
}

// A single poll pass across every open, factory-owned label, then exit —
Expand All @@ -125,8 +124,8 @@ async function cmdTick(): Promise<void> {
const config = await loadConfig(cloneDir);
const deps = buildWatchDeps(cloneDir);
const result = await pollOnce(deps, config);
console.log(JSON.stringify(result, null, 2));
if (result.paused) process.exit(1);
console.log(has("json") ? successJson(result, !result.paused) : JSON.stringify(result, null, 2));
if (result.paused) process.exit(EXIT.paused);
}

// Called from a CI failure step: the workflow itself already knows the run
Expand All @@ -138,10 +137,7 @@ async function cmdPark(): Promise<void> {
const config = await loadConfig(cloneDir);
const issueNumber = Number(flag("issue"));
const reason = flag("reason") ?? "parked by CI (no reason given)";
if (!issueNumber) {
console.error("factory park: --issue <N> is required");
process.exit(1);
}
if (!issueNumber) throw new UsageError("park: --issue <N> is required");
const github = new GitHub();
const issue = await github.getIssue(config.repo, issueNumber);
const runningLabels = [LABEL.triaging, LABEL.planning, LABEL.building, LABEL.verifying, LABEL.inReview];
Expand Down Expand Up @@ -214,8 +210,9 @@ async function cmdDoctor(): Promise<void> {
try {
config = await loadConfig(cloneDir);
} catch (err) {
console.log(` [FAIL] .factory/config.json is usable — ${err instanceof Error ? err.message : err}`);
process.exit(1);
if (has("json")) console.log(successJson({ checks: [{ name: ".factory/config.json is usable", ok: false, detail: err instanceof Error ? err.message : String(err), fixable: false }] }, false));
else console.log(` [FAIL] .factory/config.json is usable — ${err instanceof Error ? err.message : err}`);
process.exit(EXIT.checksFailed);
}
const github = new GitHub();
const checks = await runDoctor(
Expand All @@ -236,16 +233,14 @@ async function cmdDoctor(): Promise<void> {
},
{ repo: config.repo, cloneDir, baselineTag: config.baselineTag, factoryMode: process.env.FACTORY_MODE },
);
let allOk = true;
for (const c of checks) {
console.log(` [${c.ok ? "ok" : "FAIL"}] ${c.name} — ${c.detail}`);
if (!c.ok) allOk = false;
}
const allOk = checks.every((c) => c.ok);
if (has("json")) console.log(successJson({ checks }, allOk));
else for (const c of checks) console.log(` [${c.ok ? "ok" : "FAIL"}] ${c.name} — ${c.detail}`);
if (!allOk && has("fix")) {
console.log("factory doctor --fix: creating missing labels");
console.error("factory doctor --fix: creating missing labels");
await fixDoctor(github, config.repo);
}
if (!allOk && !has("fix")) process.exit(1);
if (!allOk && !has("fix")) process.exit(EXIT.checksFailed);
}

// `dashboard/server.ts`'s own `if (import.meta.main)` block never fires
Expand Down Expand Up @@ -299,10 +294,7 @@ async function cmdUp(): Promise<void> {

async function cmdInstall(): Promise<void> {
const target = args[1];
if (!target) {
console.error("factory install: usage: factory install <target-dir> [--dry-run] [--update] [--ci]");
process.exit(1);
}
if (!target) throw new UsageError("install: usage: factory install <target-dir> [--dry-run] [--update] [--ci]");
const passthrough = [
...(has("dry-run") ? ["--dry-run"] : []),
...(has("update") ? ["--update"] : []),
Expand Down Expand Up @@ -340,13 +332,14 @@ async function main(): Promise<void> {
return cmdInstall();
default:
console.log(helpText());
if (command && !["help", "--help", "-h"].includes(command)) process.exit(1);
if (command && !["help", "--help", "-h"].includes(command)) process.exit(EXIT.error);
}
}

try {
await main();
} catch (err) {
console.error(`factory: ${err instanceof Error ? err.message : err}`);
process.exit(1);
if (has("json")) console.error(failureJson(err));
else console.error(`factory: ${err instanceof Error ? err.message : err}`);
process.exit(EXIT.error);
}
12 changes: 4 additions & 8 deletions dashboard/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,14 +19,10 @@ const here = dirname(fileURLToPath(import.meta.url));
const PORT = Number(process.env.FACTORY_DASHBOARD_PORT ?? 4100);
const DB_PATH = process.env.FACTORY_DB_PATH ?? DEFAULT_DB_PATH;
const REPO = process.env.FACTORY_REPO ?? "";
// Audit finding #16: the dashboard binds 0.0.0.0 with no auth today and is
// CSRF-able via a plain `text/plain` POST. Read once at module scope (not a
// createDashboard param) so both real entry points — this file's own
// `import.meta.main` block and bin/factory's cmdDashboard — pick it up from
// the same process env without either having to be changed to pass it
// through. Binding to a non-loopback host without setting this is refused
// below; wiring `Bun.serve`'s own `hostname` default to 127.0.0.1 is a
// separate, still-open change in bin/factory's cmdDashboard (see README).
// Audit finding #16: the dashboard binds 127.0.0.1 by default (see the bottom
// of this file) and refuses a non-loopback caller unless this token is set.
// Read at module scope so createDashboard and bin/factory's cmdDashboard
// both pick it up from the process env.
const DASHBOARD_TOKEN = process.env.FACTORY_DASHBOARD_TOKEN ?? "";
const BOARD_CACHE_MS = 10_000;

Expand Down
4 changes: 2 additions & 2 deletions package.json
Original file line number Diff line number Diff line change
@@ -1,9 +1,9 @@
{
"name": "software-factory",
"version": "2.2.0",
"version": "2.3.0",
"private": true,
"type": "module",
"description": "GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR, monitor.",
"description": "GitHub-native SDLC loop for coding agents: triage, plan, build, verify, PR.",
"engines": { "bun": ">=1.3" },
"bin": {
"factory": "./bin/factory"
Expand Down
34 changes: 34 additions & 0 deletions src/cli-output.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// Machine-readable CLI contract, after jiractrl's SPEC.md "JSON and exit
// contract" (owainlewis/jiractrl@c5ee78c, MIT): with `--json`, success is
// {"ok":true,"data":...} on stdout and failure is {"ok":false,"error":{kind,message}}
// on stderr. Exit codes are stable and listed in the README; a test keeps both in step.
// Deviations: the codes are the factory's own, and 1 keeps meaning "failed" so CI steps still fail.

import { ConfigError } from "./config";

export const EXIT = {
ok: 0,
error: 1, // usage, config, gh/git failure, anything unexpected
runFailed: 2, // `run`: the issue ended in factory:failed
paused: 3, // `tick`/`watch --once`: STOP_IF paused new pickups
checksFailed: 4, // `doctor`: at least one check failed
} as const;

export type ErrorKind = "config" | "usage" | "error";

export class UsageError extends Error {}

export function errorKind(err: unknown): ErrorKind {
if (err instanceof ConfigError) return "config";
if (err instanceof UsageError) return "usage";
return "error";
}

export function successJson(data: unknown, ok = true): string {
return JSON.stringify({ ok, data }, null, 2);
}

export function failureJson(err: unknown): string {
const message = err instanceof Error ? err.message : String(err);
return JSON.stringify({ ok: false, error: { kind: errorKind(err), message } }, null, 2);
}
Loading
Loading