Skip to content

Config

Every config.toml table the factory reads, with a commented example of each. A ticket that adds a config table edits this file; one that adds a mode edits the README's usage block. Back to the README.

Config

LINEAR_API_KEY — an env var or .env next to linear_provider.py. Which Linear project a target is driven from is the [board] table of that target's config.toml, below.

Per-target behavior lives in ~/.holophyte/<slug>/config.toml. Everything the factory keeps about a target sits in that one directory — the store at store.db, the supervisor lock — created on first need; only <repo>.worktrees keeps a sibling address of its own.

The directory is host state, not repo state: it holds this host's agent routes, leases and heartbeats, so it belongs to the host rather than to a checkout that gets cloned, moved and deleted. <slug> is the target's basename plus the first eight hex digits of the SHA-1 of its absolute path, so /a/repo and /b/repo — two repositories with two histories — never share a store. Set HOLOPHYTE_HOME to put the whole tree somewhere other than ~/.holophyte; the tests use it, and nothing else reads a home path.

Older layouts are adopted once, on the first retarget that finds them: a <repo>.holophyte/ directory beside the checkout, or the dotted siblings that preceded it (<repo>.holophyte.db with its -wal/-shm sidecars and <repo>.holophyte.toml), are moved into the state directory with one [holo2] adopted <from> -> <to> line per file. What ends adoption is the store at the new address, not the directory holding it, so writing config.toml there by hand first does not strand a legacy history — the move merges into the directory that is already there. If a store is already at the new address and another is still at an old one, the factory exits non-zero naming both and moves nothing: which history is the real one is an operator's decision, not a guess. A single file already sitting at a landing address — that hand-written config.toml, say, with a legacy <repo>.holophyte.toml still beside the checkout — stops the move the same way rather than being overwritten.

Adoption runs for the target the command line names, once cli() has named it. Importing the module or asking for --help derives paths and moves nothing.

The file is optional: absent means every default below stays in place, which is how the factory runs against itself. A file that exists but does not parse is a startup error naming the file and the line — a config the operator wrote is never silently ignored. Tables this version does not know are left alone. Inside a table it does read ([agents], [worktree], [supervisor], [loop], [report], [board], [merge]), a key it does not read is a startup error naming the file, the table, the key and the keys the table accepts: setup_timeout_min is a typo, not a timeout, and a typo the factory ignored would leave a knob believed set that is not. The accepted keys are listed with each table below.

[agents]
# Each role's harness command. The task goal is appended as the last argument,
# so end the command where its prompt goes (`-p` for Claude Code, nothing for
# `codex exec`). Omit a role to keep its default.
implementer = "claude --model opus --effort high -p"   # default route
reviewer    = "my-reviewer --diff"                     # see the caveat below
adjudicator = "my-reviewer --final"

Accepted keys: implementer, reviewer, adjudicator.

Defaults, in place whenever the key is absent: claude -p <goal> --model opus --effort high implements; review and adjudicate go through the hardened container described in Reviewing. A reviewer or adjudicator override is also an opt-out of that container — the configured command runs directly in the task worktree. Overriding the implementer has no such effect; it already runs there.

What an override keeps is the pair the round is about. Before the command runs, the task worktree's refs/review/base and refs/review/candidate are pointed at the round's two commits — the same names the staged checkout uses, and the names the reviewer prompt tells the command to read. Both must be full commit SHAs the worktree has, with the base an ancestor of the candidate, or the round is refused rather than run against whatever HEAD happens to be.

Every configured command is resolved at startup, before the run claims a ticket: the string has to split to an argv, and its program has to be an executable found on PATH or named by an absolute path. A name that resolves nowhere is an error while nothing is in flight, rather than a FileNotFoundError in the middle of a round holding the project's run lease. Startup does not run the command — a route is an agent turn, not a probe. Relative paths with a directory in them (./review.sh) are refused: rounds run in a task worktree that does not exist yet, so the name would resolve somewhere neither startup nor the operator named.

[worktree]
# Shell commands that prepare a freshly cut task worktree, run in order.
setup = [
  "python3 -m venv .venv",
  ".venv/bin/pip install -q -e '.[dev]'",
]
# Wall-clock cap per setup command, in seconds. Optional; the default is the
# verify gate's 300-second cap.
setup_timeout_sec = 300
# The segment ahead of the slash in a task branch name. Optional; `task` when
# absent, so branches are `task/ko-7000-the-title-slug`.
branch_prefix = "task"

Accepted keys: setup, setup_timeout_sec, branch_prefix.

They run in the worktree, right after its branch is cut and before the first agent turn — the moment that decides what the implementer and the verify gate have to work with. Without them a worktree silently borrows the main checkout's environment (its .venv, its module cache), so a task that changes a dependency is tested against the old one. Each command goes through the same machinery as a ticket's verify command: shell, one command per entry, a per-command cap (setup_timeout_sec, a positive number of seconds; 300 when absent), and a fail-loud report that names the failing command, the cap when it is the cap that fired, and its output, attributing a top-level && chain clause by clause.

A failing command stops the setup — step two of a setup assumes step one worked — and fails the run before an agent turn is dispatched, so a target whose toolchain will not install costs no tokens. The branch and worktree are discarded rather than preserved: no agent ran, so there is nothing on them to keep, and the reason goes to the ticket as a comment. The table's shape is checked at startup with the [agents] commands; the commands themselves are not run there, since the worktree they are written against does not exist yet.

What setup writes into the worktree is untracked, and the implementer is asked to commit its work: keep build artifacts (.venv/, caches) in the target's .gitignore, or a task's git add -A will sweep them into the branch.

branch_prefix names the segment before the slash in every branch the loop cuts, so a repository with its own convention (factory/, ko/, bot/) keeps it. Everything after the slash is unchanged — the lowercased ticket identifier, then the title slug — because the identifier is what makes a preserved branch traceable from git branch alone. The worktree directory name does not carry the prefix and does not change. A prefix that is empty, contains a slash or whitespace, starts with -, or uses a character git refuses in a ref name (~ ^ : ? * [ \) is a startup error naming the key, before anything is claimed. Branches already preserved under an older prefix are not renamed; a run that reuses one starts from the name the new prefix gives it.

[supervisor]
# The sweep's thresholds. Every key is optional; the values shown are the
# defaults, in place whenever the key (or the whole table) is absent.
heartbeat_stale_min      = 5    # a heartbeat older than this is a silent sighting
stale_strikes            = 2    # consecutive silent sightings that trip a run
budget_grace             = 1.5  # multiple of the ticket's estimate that blows the box
review_overlap_threshold = 0.5  # findings shared by two rounds that reads as stuck
sweep_interval_sec       = 60   # sleep between two --supervise passes
restart_grace_sec        = 120  # how long a self-merge re-exec may take to come back

Accepted keys: the six above.

Different targets want different patience — a Go build's worktree setup is slower than stdlib Python's — and these are the knobs --sweep and --supervise read. Each value is checked at startup, for every mode: the thresholds and the interval must be positive numbers, stale_strikes a positive integer, and the overlap a fraction in (0, 1]. A value outside its constraint is an error naming the key and the constraint, like malformed TOML, rather than a default quietly used in its place. A key this version does not know is refused the same way. The config is read once at startup; a running supervisor does not pick up an edit.

[loop]
# What the claim loop does after a run it closed out as failed. Optional; the
# value shown is the default.
stop_on_failure = true   # false: record the failure and claim the next ticket
# Whether the loop starts a detached --supervise for the target at startup
# when no live supervisor holds its lock. Optional; the default is true.
spawn_supervisor = true  # false: a service manager runs the supervisor

Accepted keys: stop_on_failure, order, spawn_supervisor.

By default one failed run ends the process after its close-out, with a nonzero exit, and an operator relaunches the loop — the right call while the loop is still being watched. With stop_on_failure = false the run is closed out exactly as before (released, escalated if it was one failure too many, the FINDINGS.md window regenerated) and the loop goes on to the next ready ticket in the same process, for an unattended night. Escalation is untouched: a ticket that fails twice still parks itself for a human; the knob only decides whether one failure stops the whole queue. The exit status is still nonzero once the queue is empty if any run failed. The value must be a boolean, true or false; a string such as "yes" is a startup error naming the key, like a [supervisor] threshold outside its constraint.

With spawn_supervisor = true (the default) the loop checks the target's supervisor.lock at startup, after the config and route checks and before its first claim, and when no live pid holds it starts factory.py --supervise for the same target as a detached process, logging to supervisor.log in the state directory; when a live supervisor holds the lock it names that pid and carries on. spawn_supervisor = false skips the check and the spawn, for an operator whose service manager runs the supervisor as a unit of its own; the explicit --supervise command is unchanged either way. A boolean, checked like stop_on_failure.

[board]
# The Linear project this target claims from and the team whose workflow
# states its tickets move through. Required for the loop and --supervise.
project_id = "00000000-0000-0000-0000-000000000000"
team = "Example Team"

Accepted keys: project_id, team.

The board is a per-target setting: two targets on one host driven from one process-wide variable would both claim from the same project, and the second would silently work the first's queue. Both values must be non-empty strings. --report, --serve and a read-only --sweep need no board and run without the table; the loop and --supervise exit at startup naming [board] project_id when it is absent. Nothing in the environment stands in for the table.

[report]
# What the factory prints where it would print the machine's hostname.
# Optional; absent, the hostname is printed as recorded.
host_label = "writer-1"

Accepted keys: host_label.

The host column of --report and --sweep and the supervisor's startup and refusal lines show the label in place of the hostname when it is set. The FINDINGS.md window the loop commits renders no host: its run and round entries never carried one, so there is nothing there to relabel. The column of the report and sweep exists so a reader can tell which writer produced a run when there is more than one; a stable label does that job without naming a personal machine in a public repository. The store keeps recording the real hostname (runs.host, supervisorHeartbeats.host, the lock file), which the supervisor compares against its own, so the label can be renamed later without a migration. The value must be a non-empty string; anything else is a startup error naming the key.

[merge]
# Who says "merge" once the reviewer has approved and the pre-merge verify
# has passed. Optional; the value shown is the default.
approve = "auto"   # "human": park the approved run for an operator to release

Accepted keys: approve.

With approve = "auto" a clean merge gate merges, as it always has. With approve = "human" the loop stops there instead: the run's phase becomes awaiting_merge_approval, its ticket goes blocked_on_operator with the question merge? (which /attention lists under blocked, and the drawer shows), a ledger comment names the branch and the candidate sha, and the branch and worktree are preserved exactly as after a refused merge. The lease is released, so the loop claims the next ready ticket, but the run itself is not ended: it stays open in awaiting_merge_approval with no outcome, the supervisor sweep leaves it alone as it does a run blocked on an operator, and the park is not a failure -- it neither stops the pass under stop_on_failure nor counts toward the escalation that blocks a ticket, and the exit status is not spent on it. Nothing merges until the operator says so; releasing a parked run is the next ticket's --approve. The value must be "auto" or "human"; anything else is a startup error naming the key.