A local, scheduled software factory. Linear is the source of truth: label an issue and the
factory refines it, breaks it into sub-issues, builds it into a draft pull request, and then keeps
that PR healthy (fixes failing CI, resolves merge conflicts, answers review feedback, drafts the
reviews you were asked for). Everything runs on your own machine from an OS scheduler
(factory tick every 20 minutes), with one narrow Claude Agent SDK worker per action and a
deterministic Python engine around it.
The design generalises the "unattended PR routine" pattern: read external state (not notifications),
one worker per action, dedupe by (target, kind, version), push only when earned, flag a human when
not. The implementation contract lives in docs/architecture.md; the
build-vs-buy check for the issue -> PR path is docs/adr/0001-build-vs-buy.md.
Workers (Claude sessions) get no outward-write capability. git push, every gh write, gh api
with a non-GET method or the graphql endpoint, Linear/Slack HTTP, package publishing and all
mcp__* tools are denied by policy.py in both the SDK can_use_tool callback and a PreToolUse
hook. A worker edits, tests and commits inside a throwaway git worktree and returns a typed result
(push_ok, reasons, summary, dispositions, sub-issue specs...). The engine (effects.py) applies
deterministic policy and performs every outward write itself. Denials return a message, so the model
adapts instead of hanging on a permission prompt.
The tool policy is not the only wall. Below it, each worker:
- loads no filesystem settings (
setting_sources=[]): the repo's.claude/settings.json(its hooks,apiKeyHelper,env, plugins, allow rules) is never applied, because a PR head controls that file. The repo'sCLAUDE.md/AGENTS.mdare still followed: the engine appends them to the worker's system prompt. - has no
ghlogin:GH_CONFIG_DIRpoints at an empty file (yourhosts.yml/ keyring login is unreachable) andGH_TOKENis the optional read-onlyGH_WORKER_TOKENfrom the profile.env, or empty. Without that token workers runghunauthenticated by design; the engine already put the PR, CI and review context in the prompt. - runs git with every GitHub push URL rewritten to a transport that does not exist (a scripted
push fails too) and with git hooks disabled:
core.hooksPathpoints at a path under a regular file (%SystemRoot%\System32\drivers\etc\hosts\no-hookson Windows,/dev/null/no-hookselsewhere), where no process can ever create a hook. The repo'spre-commit/commit-msghooks do not run for worker commits; the worker prompt tells workers to run the repo's lint / format commands themselves when itsCLAUDE.mdasks for them, and a.pre-commit-config.yamlbecomes a verification command (pre-commit run --all-files --show-diff-on-failure). - may not set
GIT_*/GH_*/SSH_*/ editor / pager variables, change git config keys that run commands or redirect remotes and credentials, or write into a.gitdirectory or a git/gh/ssh config or credential file. A change to the clone's shared git config or hooks that slips through anyway is reverted after the session, flagged, and the run's output is discarded. - has its whole process tree killed when the session ends (finished, failed or timed out), so a dev server, watcher or background loop it started does not outlive it.
The engine's own git (worktree setup, every fetch -- including the evidence branch's and factory ready's -- and every push) runs with the same unplantable core.hooksPath, so a hook planted in the
clone's shared .git/hooks never executes with your credentials; when the host cannot provide that
path, git does not run at all. With hooks off, Git LFS's pre-push hook cannot upload LFS objects
either: the engine uploads the pushed change's LFS objects itself before pushing the branch, and
refuses the push (keeping the work for you) when it cannot.
Allowed outward writes (all in effects.py, each one audited):
- push branches with the refspec
HEAD:refs/heads/<branch>only -- never+, never--force*, never--no-verify(includes the orphanfactory/evidencebranch for screenshots) - open draft pull requests (with a configurable AI-disclosure footer)
- edit the
<!-- factory:evidence -->section of PRs the factory itself opened - Linear: state transitions, sub-issue creation (+ blocked-by relations), and -- for
refineonly -- issue title/description edits plus removal of thefactory:refinelabel
Never: comments, replies, reviews (GitHub or Linear), resolving threads, merge, close, ready-for-review, force-push, label creation.
Invariants the tests enforce:
- Workers propose, the engine acts (above).
- Every outward write goes through
effects.py. mode = "observe"performs zero outward writes to GitHub or the tracker: onlyrv(review drafts, written locally) runs for real; every other kind is recorded as "would run".--dry-rundoes the same for a single tick: scan, filter, print, start no worker, write no state or flags (and it needs no provider credentials).- State is recorded only on worker outcome
done/nothing; anything else is a failed attempt, andmax_attempts(default 3) per(target, kind, version)stops retries (flagged once). - Auth failures stop the wave: a precheck failure (gh, tracker, provider credentials) flags once and exits 2 without spending attempts; a mid-wave auth failure stops launching new actions. A model rate / usage limit (or an overloaded provider) stops the tick the same way: exit 2, no attempt spent, flagged once per day; the next tick simply retries.
- Profiles never cross: one process loads one profile; secrets live in
Profile.secrets, never inos.environ; state, worktrees and logs live under that profile's data dir. - Refine notifies before it edits: the original title/description is persisted and a toast/Slack DM delivered before the Linear issue is touched. No notification -> no edit.
Push policy on top of the worker's push_ok: at least one passing verification run; no changed file
matching the sensitive globs (auth, security, billing, migrations, workflows, per-repo additions); no
changed file that CODEOWNERS assigns to someone else -- evaluated per changed file with GitHub's
last-match-wins rule, so * @other-team followed by /docs/ @you lets a docs change through while
anything else stays refused (owners count as yours when they are your login or a team in
policy.my_teams); rc never pushes when a comment was skipped; conflict never pushes with
unresolved or unsafe hunks or leftover markers, and judges only its own conflict resolutions, not
every base-branch file the merge brought in. A refused push keeps the worktree for you and flags it.
| Kind | Trigger | Worker returns | Engine effect | Version key |
|---|---|---|---|---|
ci |
my open PR has >= 1 failing check on its current head | fix commit + classification (caused-by-PR / flaky / inherited / infra; the prompt carries the base branch's latest run of each failing workflow, fetched by the engine) | push if push_ok and policy passes; all-flaky with no commit -> handled |
head SHA |
conflict |
PR mergeable == CONFLICTING |
merge of origin/<base> + per-hunk classification |
push if every hunk resolved safely; else keep worktree + flag | {head}:{base} SHAs |
rc |
new reviewer/bot feedback since my last commit, or unresolved non-outdated threads | commit + per-comment disposition | push only if no comment was skipped | latest feedback timestamp |
rv |
my review requested directly, non-draft | review markdown | written to reviews/<repo>-<pr>.md + notification; never posted |
head SHA |
refine |
issue has factory:refine |
rewritten title + description (AC list), open questions, suggested split | save original -> notify -> edit issue -> remove the label (factory revert-refine KEY restores); nothing to refine -> the label is removed and you are told |
issue updated_at |
plan |
issue has factory:plan, no children |
sub-issue specs with repo + blocked-by indices | create sub-issues (with repo:<name> label when the parent's team can use it, else a description marker; without factory:build -- you approve by labelling), move parent to started, notify; nothing to plan -> flagged once (remove the plan label, then label factory:build) |
issue updated_at |
build |
factory:build, unstarted, blockers done, no open linked PR, no pending factory:refine / factory:plan label, repo resolvable |
commits on the issue's branch, tests run, push_ok |
push branch, open draft PR with Fixes KEY + evidence section + footer, move issue to started, notify; nothing to build -> flagged once |
issue id (attempt-capped) |
ready |
manual: factory ready <repo> |
commits improving agent readiness (CLAUDE.md verification section, scripts, deflaking) | push factory/ready-<date>, draft PR |
origin/<default> SHA |
Actions on one target run in a fixed order: ci -> conflict -> rc -> rv on a PR, refine -> plan -> build on an issue. A build waits, without spending an attempt and with one flag naming the label,
while a refine or plan label is pending: its kind is enabled (a disabled kind's label is ignored),
and for plan only while the issue has no sub-issues and plan has not said "nothing to plan" for
the issue as it is now. Different targets run in parallel up to max_parallel.
Repo inference for issues (resolve.py): repo:<name> label or planner marker -> the repo the
planner chose when it created the sub-issue -> GitHub attachments -> majority vote of sibling issues
-> repo names / paths in the text -> a model pick over candidates. Below resolve.min_confidence
(0.7) or multi-repo -> flag and skip.
Every factory-opened PR body carries a machine-maintained evidence section: changed files and
sensitive-adjacent paths (next to a sensitive-glob match or inside another team's CODEOWNERS area;
informational, the push policy decides per file), acceptance-criteria -> test mapping, verification commands run, screenshots
for visual = true repos (pushed to factory/evidence), and a readiness warning when the repo scores
below policy.min_readiness.
Prerequisites: Python 3.12+ (the repo pins 3.13 in .python-version), uv,
git, the GitHub CLI gh (logged in: gh auth login), and the Claude Code CLI. On Windows the Agent
SDK needs the native claude.exe (the installer from claude.ai); it refuses npm's claude.cmd shim.
The CLI must be at least the version the SDK was built against (Windows SDK wheels bundle none, so
the one on PATH is used): an older CLI rejects the configured model and every worker dies at
startup. factory doctor compares the two; claude update fixes it, or provider.cli_path points
the SDK at a newer claude.exe that is not on PATH.
git clone https://github.com/JonFermin/software-factory.git
cd software-factory
uv sync
uv run factory --help
uv run factory ... is the only entry point (factory is the console script; python -m software_factory works too). Nothing is installed globally.
A profile is a directory <profiles_dir>/<name>/ with factory.toml (config) and .env (secrets,
never committed). <profiles_dir> is --profiles-dir, else $SOFTWARE_FACTORY_PROFILES_DIR, else
the per-user config dir <config root>/software-factory/profiles when it exists (<config root> is
~/Library/Application Support on macOS, else $XDG_CONFIG_HOME or ~/.config on Linux and
Windows (not %APPDATA%: packaged apps such as the Claude desktop app virtualize AppData, so scheduled ticks would not see the same files), else <repo>/profiles. Real profiles live outside this repository: <repo>/profiles
only ships its README.md and profiles/example/factory.toml, and
everything else under it is gitignored. The default profile name is personal; create it by
copying the example:
# Linux and Windows (Git Bash: ~ is %USERPROFILE%); on macOS use
# ~/Library/Application Support/software-factory/profiles/personal
mkdir -p ~/.config/software-factory/profiles/personal
cp profiles/example/factory.toml ~/.config/software-factory/profiles/personal/factory.toml
# edit github.owners and repos.root, add the .env next to it, then:
uv run factory doctor
The example manages the clones under repos.root (~/code) whose GitHub owner is listed in
github.owners, and starts with the upkeep kinds. Every key is annotated in factory.example.toml;
the full profile story (isolation, employer profiles, rollout) is in
profiles/README.md.
.env keys (KEY=value per line; never exported to the environment):
| Key | Needed when |
|---|---|
CLAUDE_CODE_OAUTH_TOKEN |
provider.kind = "oauth" (from claude setup-token; personal profiles only) |
ANTHROPIC_API_KEY |
provider.kind = "api_key" |
AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_SESSION_TOKEN |
provider.kind = "bedrock" without ambient AWS credentials |
GOOGLE_APPLICATION_CREDENTIALS |
provider.kind = "vertex" without Application Default Credentials |
LINEAR_API_KEY |
linear.enabled = true and an issue kind (refine/plan/build) is enabled |
SLACK_BOT_TOKEN, SLACK_USER_ID |
notify.slack = true |
GH_WORKER_TOKEN |
optional: a fine-grained read-only GitHub PAT (repository permissions contents, pull requests and actions: read) that workers get as GH_TOKEN. Without it workers have no gh authentication at all, by design: your own gh login is never reachable from a worker, and the engine puts the PR / CI / review context in the prompt. doctor warns when it is absent |
Key factory.toml settings: mode (observe | full), enabled_kinds (the rollout ladder),
max_parallel, max_attempts, session_timeout_min, [provider] (kind, model, effort,
fallback_model, cli_path), [github] owners, [repos] root/include/exclude plus per-repo
[repos.overrides.<name>] (visual, dev_command, dev_port, verify, setup,
sensitive_globs, min_readiness, default_branch), [linear] labels, [policy] sensitive globs
and min_readiness, [notify], [schedule].
The factory never creates external resources, so these are yours:
- Claude credentials. For
provider.kind = "oauth"runclaude setup-tokenand paste the token into.envasCLAUDE_CODE_OAUTH_TOKEN. Caveat recorded in the ADR: Anthropic's terms allow individual Agent SDK use on a subscription but steer products that act for third parties to API keys; a single-user tool on your own machine is not the prohibited case, but it is not explicitly carved out either. Confirm the current terms allow unattended use on your plan; switching toapi_key/bedrock/vertexis one config line (employer profiles rejectoauthoutright). - Linear. A personal API key (
LINEAR_API_KEY), Linear's GitHub integration enabled (so branches named with the issue'sbranchNameauto-link and the factory never opens a second PR), and the labelsfactory:refine,factory:plan,factory:buildcreated in Linear. Optionalrepo:<name>labels pin an issue to a repo and let the planner label sub-issues.factory doctorreports missing labels; it cannot create them (label creation is not an allowed write). - Slack (for DMs to yourself, i.e. phone push). A Slack app with the
chat:writeandim:writebot scopes installed to your workspace; put its bot token inSLACK_BOT_TOKENand your member id inSLACK_USER_ID. Desktop toasts work without any setup (desktop-notifier). - Repos. Local clones under
repos.rootwhoseoriginis a GitHub repo owned by one ofgithub.owners(or listed inrepos.include). Workers run in linked worktrees of those clones, always on a detached HEAD: your checkouts are never modified and no local branch is created, reset or deleted in your clone (the engine pushesHEAD:refs/heads/<branch>straight from the worktree). - Optional: a read-only worker token. Create a fine-grained GitHub PAT limited to the managed
repos with read-only contents, pull requests and actions, and put it in
.envasGH_WORKER_TOKENif you want workers to be able to rungh pr view/gh run viewthemselves. Skip it and workers rely on the context the engine fetched (doctorshows awarnline, which is expected).
Then uv run factory doctor until it is green (Slack/Linear lines may stay warn until you enable
those features, and worker gh token stays warn without the optional GH_WORKER_TOKEN).
Global options: --profile/-p NAME (default personal), --profiles-dir PATH.
| Command | What it does | Exit code |
|---|---|---|
tick [--dry-run] |
One tick: lock -> precheck -> scan -> filter -> dispatch -> record. --dry-run prints the actions and skips (with reasons) and starts nothing; it needs no provider credentials and raises no flags. |
0 ok, 1 an action failed, 2 auth failure or provider rate/usage limit; "tick already running" exits 0 |
run <kind> <target> [--dry-run] |
One action by hand from the current external state. PR kinds take owner/name#123, name#123 or a PR URL; issue kinds take the key; ready takes a repo. Bypasses the dedupe/attempt/enabled-kind filters (the way to retry an exhausted build), still respects mode, and still refuses a target with a kept worktree until release. |
1 failed, 2 bad target / auth failure / provider rate/usage limit |
status |
Mode, enabled kinds, lock state, open flags, kept worktrees, factory-opened PRs, last 10 runs. | |
flags [--ack ID] [--all] |
Open flags (things waiting for you); --ack closes one. |
1 unknown id |
doctor [--repo NAME] |
Profile + employer rules, .env, provider credentials, Claude CLI (the binary the SDK will launch, incl. provider.cli_path, and that it is not older than the SDK's expected CLI), gh auth (and that the account gh is logged into matches github.login), the optional GH_WORKER_TOKEN (warn when absent: workers then run gh unauthenticated), managed repos, unique dev_ports, Linear reachability + factory labels (+ repo:<name> labels, warn only; checked per team when linear.team_keys is set), Slack auth.test, toast backend, data dir writable, scheduler jobs (and that they use this shell's data dir and may run on battery; a scheduler config that cannot be evaluated is a fail line, not a crash). --repo adds verification discovery and the static readiness checks (runtime checks show as n/a). |
1 if any check fails |
readiness [--repo NAME] [--no-run] [--force] |
Readiness score per managed repo. Full runs execute the discovered verification commands twice in a throwaway worktree at origin/<default> (flakiness, runtime); --no-run is static only. Cached per HEAD; informational, never blocks. |
|
ready <repo> [--dry-run] |
The manual ready action: worker improves readiness on factory/ready-<date>, engine pushes and opens a draft PR. |
as run |
report [--since 7d] [--digest] |
Refresh outcomes of every factory-touched PR, then: runs per kind, merged / closed / open, median hours to merge, rework rate, reverted pushes, cost, top flag reasons. --digest DMs the compact version to you on Slack. |
1 if the digest could not be sent |
revert-refine <KEY> |
Restore the original title/description of a refined issue (status reverted; the label is not re-added). |
1 no record |
schedule install [--interval N] [--no-digest] / uninstall / show |
OS scheduler jobs for this profile (below). | 1 scheduler error |
cleanup [--all] [--yes] |
Remove leftover worktrees that are not kept for you; --all also releases + removes the kept ones after confirmation. Refuses while a tick runs. |
|
release <target> [--remove] |
Release a kept worktree so the target is dispatched again; --remove deletes the directory (otherwise it stays until cleanup; being a detached HEAD it does not block the next run). |
1 unknown target |
Output is plain ASCII; stdout is switched to UTF-8 (with replacement) so a cp1252 Windows console never breaks a scheduled run.
uv run factory schedule install registers two jobs for the profile with the OS scheduler, running
<uv> run --project <repo> factory --profile <name> ... (with SOFTWARE_FACTORY_PROFILES_DIR set
to the profile's directory whenever it lives outside <repo>/profiles, the user config dir included, and SOFTWARE_FACTORY_DATA_DIR set when it was
set in the shell that ran install -- scheduled jobs inherit no shell environment, and without it
the scheduled ticks would use a different state.db, lock and logs than your CLI):
tickeveryschedule.interval_minutes(default 20)report --digest --since 7dweekly atschedule.digest_weekday/digest_time
Backends: Windows Task Scheduler (schtasks, each job a generated .cmd wrapper under the profile's
scripts/ dir because /TR is limited to 261 characters; stdout/stderr go to logs/scheduler.log;
tasks run only while you are logged on, which is what lets toasts appear; the tasks are allowed to
start and keep running on battery, which schtasks alone would not permit), launchd on macOS
(~/Library/LaunchAgents/com.software-factory.<profile>.<job>.plist), cron on Linux (a block
between # software-factory:<profile> begin/end markers). schedule show prints what the OS
reports (on Windows also the env the wrapper sets); schedule uninstall removes both jobs and the
wrappers. Re-run install after changing SOFTWARE_FACTORY_DATA_DIR or the profile location;
doctor warns when the installed jobs disagree with the current shell. Ticks never overlap: a
global file lock makes a second tick exit immediately.
Turn kinds on by config, not code, one rung at a time, and let factory report and the audit log
justify the next rung:
mode = "observe"-- onlyrvruns for real (local review drafts); every other kind is recorded as "would run" with zero outward writes. This is where an employer profile starts.mode = "full",enabled_kinds = ["ci", "conflict"]-- upkeep pushes on your own open PRs.- add
"rc"-- review-feedback commits (never when a comment was skipped). - add
"build"-- labelled issues become draft PRs. - add
"plan"(and"refine") -- the factory decomposes and rewrites issues.
The example profile (profiles/example/factory.toml) starts at rung 2-3 (ci, conflict, rc,
rv); give it about a week of upkeep metrics before adding build. Setting enabled_kinds to include an
issue kind is what makes LINEAR_API_KEY mandatory at precheck; until then a missing key only
skips the tracker.
Everything mutable lives outside the repo, per profile, under
<data_root>/software-factory/<profile>/ where data_root is
~/Library/Application Support (macOS) or $XDG_DATA_HOME / ~/.local/share (Linux and Windows; not %APPDATA%: packaged apps such as the Claude desktop app virtualize AppData, so scheduled ticks would not see the same files);
SOFTWARE_FACTORY_DATA_DIR overrides the base (schedule install embeds it in the jobs so
scheduled ticks and the CLI share one dir):
state.db SQLite: handled, attempts, kept worktrees, flags, factory PRs, pushes,
resolutions, refinements, runs, PR outcomes, caches
wt/ throwaway git worktrees (short name: Windows MAX_PATH)
logs/audit.jsonl every effect, notification, flag and tick boundary
logs/runs.jsonl one record per action run (kind, target, version, outcome, cost, duration)
logs/transcripts/ one JSONL transcript per worker session (secrets redacted)
logs/scheduler.log stdout/stderr of scheduled runs
reviews/ rv drafts: <repo-name>-<pr>.md
refinements/ refine records: <KEY>.md (original, new, diff, open questions)
scripts/ generated scheduler wrappers
scratch/ cwd for workers that have no worktree (refine without a repo, plan, resolver)
gh-noauth empty file: the workers' GH_CONFIG_DIR (so gh never finds your login)
tick.lock the global tick lock
The same checkout runs an employer profile next to a personal one without crossing streams:
separate config, secrets, repo set, tracker, state, worktrees and logs; a tick only ever sees one
profile. Employer profiles (employer = true) must use api_key, bedrock or vertex (never the
subscription token), require a disclosure footer, default to mode = "observe", and live outside
any git checkout like every real profile (in the user config dir, or a dedicated directory such as
SOFTWARE_FACTORY_PROFILES_DIR=~/factory-profiles; doctor warns when an employer profile sits
inside a git checkout). Workers only ever receive the one credential their
provider needs (providers.worker_env); inherited ANTHROPIC_* / CLAUDE_CODE_* / tracker / chat
variables are scrubbed from the process before any worker starts.
doctorsaysprovider credentials: CLAUDE_CODE_OAUTH_TOKEN missing-- create<profiles_dir>/<name>/.envnext to the profile'sfactory.tomlwith the token fromclaude setup-token(or switchprovider.kind).profile 'personal' not found: <path> does not exist-- no profile at the path the message names; copyprofiles/example/factory.tomlthere (see "Profile setup"), or point--profiles-dir/SOFTWARE_FACTORY_PROFILES_DIRat the directory that holds your profiles.gh auth: ... gh auth login-- rungh auth login; the engine uses yourghsession for every GitHub read and write (workers never do).worker gh token: GH_WORKER_TOKEN not in the profile .env-- expected unless you want workers to runghreads themselves: without the token they runghunauthenticated by design and use the PR / CI / review context the engine put in the prompt. To enable it, add a fine-grained read-only PAT (contents, pull requests, actions: read) asGH_WORKER_TOKEN; never put a token with write access there.- A worker said
ghis not logged in, or a repo's pre-commit hook did not run -- by design (see "Core principle" above). Workers run with git hooks disabled and without the repo's.claude/settings.json. A.pre-commit-config.yamlis picked up by verification discovery (pre-commit run --all-files --show-diff-on-failure); checks wired into hooks any other way belong in the repo'sCLAUDE.md(workers run what it asks for) or in a[repos.overrides.<name>] verifyentry. claude cli: ... script shim-- on Windows the SDK refusesclaude.cmd; install the nativeclaude.exeso it is found onPATH(the doctor shows which binary will be used and its version).claude cli: ... older than the CLI the SDK expects-- the Agent SDK launches that binary and it does not know the configured model (workers fail with "does not support this model"). Runclaude update, or setprovider.cli_pathto a newerclaude.exe(Windows: a native.exe, neverclaude.cmd);doctorreports the binarycli_pathnames, and fails when it is missing.gh auth: gh is logged in as X but github.login = Y-- the profile pins a login that is not the accountghuses; the scan filters your PRs and your own comments by that login, so fixgithub.login(or leave it empty to detect it).dev ports: dev_port shared across repos.overrides-- two visual repos on one port would screenshot each other's dev server when built in the same tick; pin unique ports (dev_command = "npm run dev -- --port N --strictPort").scheduler: jobs installed but they do not run from the current data dir / carry SOFTWARE_FACTORY_DATA_DIR-- the jobs were installed from a shell with a differentSOFTWARE_FACTORY_DATA_DIR, so scheduled ticks and your CLI use different state; re-runfactory schedule installfrom this shell.scheduler power(Windows) -- the tasks still carry the schtasks default of not starting on battery; re-runschedule install.linear: LINEAR_API_KEY missingis only a warning until an issue kind is enabled; withbuild/plan/refineon it is a precheck failure (exit 2, flagged once per day).tick already running-- another tick holdstick.lock; it exits 0 on purpose. If a crashed process left the lock, it is released automatically (the lock is an OS file lock, not a marker).- An action keeps being skipped with
kept worktree awaiting me-- a push was refused or a conflict left unresolved; the worktree path is infactory status/ the flag. The kept worktree is a detached HEAD (the engine never creates local branches): to continue by hand,cdinto it andgit switch -c <branch>-factoryfirst (the flag names it; the suffix keeps it clear of your own local<branch>, which may be checked out elsewhere), or your commits are easy to lose at the next checkout. For a PR you can instead review the kept commits andgit push origin HEAD:refs/heads/<branch>yourself. Thenfactory release <target>; without--removethe directory stays for you (untilfactory cleanup) and no longer blocks the next run, with--removeit is deleted. attempts exhausted--max_attempts(3) failed attempts at this version. The factory flags it once when the cap is hit (with how the last attempt ended), then skips the action silently. What re-arms it depends on the kind: PR kinds (ci,conflict,rc,rv) re-arm on a new head, base or review feedback;refine/planre-arm when the issue is edited (newupdated_at);buildis keyed by the issue id, so editing the issue does not re-arm it -- fix the cause, then retry withfactory run build KEY.factory run <kind> <target>retries any kind by hand.provider unavailable/ the tick exits 2 with a rate or usage limit -- the model provider refused the session (rate limit, usage limit, overloaded, billing). The tick stops launching new actions, spends no attempt and flags once per day; the next scheduled tick retries the same work (factory run/readyexit 2 the same way). Only a session that produced no result counts: one that hit a limit on the way (or in a subagent) but finished with valid output keeps its work. Lowermax_parallel, setprovider.fallback_model, or wait for the limit window to reset.- A push was refused with
changed files owned by another team per CODEOWNERS-- CODEOWNERS is evaluated per changed file with GitHub's last-match-wins rule; the file's owners are neither your login nor a team inpolicy.my_teams. Add the team tomy_teamsif it is yours, or push that change yourself. - Nothing is pushed in
observemode /--dry-run-- by design; look forwould_*records inlogs/audit.jsonl. - Garbled characters in a Windows console -- the CLI prints ASCII and reconfigures stdout to
UTF-8; worker output in transcripts is UTF-8. Use
type/ a UTF-8 capable viewer on the logs. - A tick ran for hours -- each worker session has
session_timeout_min(30); readiness full runs have a 120 s runtime budget and a 10 min per-command timeout.cleanupremoves orphaned worktrees once the tick is over.
uv run pytest # the whole suite (fakes everywhere: no network, no workers, tmp git repos)
uv run ruff check src tests
uv run factory doctor
uv run python scripts/e2e_local.py [--profile NAME] [--cli-path PATH] [--effort medium] [--keep]
Runs the real engine — scan, dispatch, handlers, Claude workers, policy, push policy, effects — against a
throwaway sandbox: a local bare repo stands in for GitHub (its "CI" really runs the sandbox tests on each
head), and in-process fakes stand in for the gh and Linear APIs. One tick exercises all seven kinds
(ci, rc, conflict, rv on four PRs; build, refine, plan on three issues), a second tick must
find nothing to do, then the refinement is reverted; 37 checks cover pushes, CI results, PR body
(Fixes / evidence / footer), notify-before-edit, and that every outward call is on the allowed list. It
spends real model tokens (about $1 at medium effort, ~1 minute). Without --profile the workers use
this machine's existing Claude Code login. Use it after changing prompts, policy, runner or effects.
See CLAUDE.md for the working rules for agents and docs/architecture.md
for the module contract.