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.

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

Per-project behavior lives in ~/.holophyte/<slug>/config.toml. Everything the factory keeps about a project 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 project'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 project 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], [verify], [worktree], [supervisor], [loop], [report], [board], [merge], [console], [serve], [story], [trim]), 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]

A configured writer uses the same host-command treatment as a configured adjudicator: its wrapper is responsible for enforcing read-only access. The writing prompt asks for text only, leads with behaviour and reasons, and applies the project's pr_style afterwards. Without a writer, writing keeps the implementer's command and isolation settings.

Key Default Allowed values and when to change
implementer Default: Claude Code / Opus, high effort Non-empty command string, or the table [agents.implementer] with harness ("claude" or "codex") and optional model and effort (claude: default "opus", "high"; codex: default "gpt-6-astra", "high", effort one of "low", "medium", "high", "xhigh"), or harness "devin" with a required model and no effort; override to select another implementer harness. A table's adapter builds the argv, records the session id (claude at dispatch, codex from its banner after the turn, devin from devin list --format json in the task worktree after the turn) and builds the resume argv. A claude table implementer or trimmer runs with --output-format json: the factory keeps the reply's result text, and each turn's agent_turn event records its usage (input_tokens, output_tokens, cost_usd, num_turns).
reviewer Default: Hardened Codex review container Non-empty command string, or the table [agents.reviewer] with harness "codex" and optional model and effort (default "gpt-6-astra", "high"; effort one of "low", "medium", "high", "xhigh"), or harness "cursor" or "devin" with a required model and no effort; override only to supply an independent review route outside the container.
adjudicator Default: Hardened Codex review container Non-empty command string, or the table [agents.adjudicator] as for reviewer; change to supply a separate adjudication route.
adversary Default: Hardened Codex review container on the reviewer's model pair, with Codex multi-agent on Non-empty command string, or the table [agents.adversary] with harness "codex", "cursor" or "devin" as for reviewer; change to run the adversarial pass ([review] adversary below) on another route. Its event records family "configured" when set; unset, the default route alternates between Codex and Claude Opus when adversary_credential is set and runs Codex otherwise.
adversary_credential Default: Absent (every default-route adversary pass runs Codex) Only { env = "NAME" }, NAME a variable in the factory's environment such as CLAUDE_CODE_OAUTH_TOKEN; a file form, a string or an invalid name is a startup error. Set it to alternate the default-route adversary between Codex and Claude Opus (opus, effort high) in the review container: pass k of run R runs Claude when R + k is odd. The container gets the variable as --env=NAME, so its value is in no argument and no script; a Claude pass while the variable is unset or empty fails the run as review_route, naming it. With adversary_fallback set, or with the reviewer's container fallback pair set while [review] adversary is on, startup probes the adversary seat and the Claude route, and a failed probe starts the adversary on its fallback. The variable's value is registered for redaction once the key is read.
consolidator Default: Hardened Codex review container on the reviewer's model pair Non-empty command string; change to run the consolidator on another route. On a round with an adversarial pass that goes to a fix turn, it merges near-duplicate findings and orders the list the fix turn gets (see Reviewing). It has no fallback key and no startup probe: a failed turn, or a reply without CONSOLIDATED, hands the fix turn the exact merge and the run goes on.
writer Default: Active implementer route Non-empty command string for PR titles, descriptions and fix-round refreshes. Probed at startup; a failed probe is reported and writing uses the implementer.
trimmer Default: Active implementer route Non-empty command string, or the table [agents.trimmer] with harness "claude", "codex" or "devin" and the same model and effort rules as [agents.implementer] (no orchestration); set it to run the trim turn ([trim] below) on a cheaper model or another harness. The turn launches like an implementer turn, in the task worktree and under implementer_isolation, with the trim goal as the last argument, and records no session. Probed at startup; when it and trimmer_fallback both fail, startup prints "trimmer route down; runs skip trim" and each run's trim records skipped, without stopping the loop.
critic Default: Absent (no critic) Only the table [agents.critic], with optional harness ("codex", the default, or "claude"), model and effort (codex: default "gpt-6-luna", "medium", effort one of "low", "medium", "high", "xhigh"; claude: default "opus", "high"); a command string is refused. Set it to give a cheap model a seat for judging whether a queued ticket is still relevant. Probed at startup in a throwaway detached checkout of main; a failed probe is reported and turns the critic off for the loop's life without stopping it.
review_model Default: "gpt-6-astra" Non-empty Codex model ID; change for a different container review model.
review_effort Default: "high" "low", "medium", "high", "xhigh"; change the container review reasoning effort.
review_fallback_model Default: Absent (disabled) Non-empty Codex model ID, set together with review_fallback_effort; the container reviewer's fallback model, run in the same container.
review_fallback_effort Default: Absent (disabled) "low", "medium", "high", "xhigh", set together with review_fallback_model; the fallback model's reasoning effort.
review_service_tier Default: Absent (no tier) Non-empty Codex service tier such as "priority"; the container reviewer passes it to Codex as -c service_tier="TIER", an argument to the container script rather than text spelled into it, and the review route probe runs with it. Each container review turn's agent_turn run event records it as service_tier, or null when none is set. Absent, Codex runs exactly as without the key.
review_fallback_service_tier Default: Absent (no tier) Non-empty Codex service tier for the fallback pair alone; needs review_fallback_model beside it. The primary's review_service_tier never reaches the fallback route.
review_mode Default: "single" "single" or "verified"; "verified" asks the reviewer to review from three angles (correctness against the ticket, the tests, scope and regressions) and to have an independent verifier check each finding, reporting only confirmed findings. Refuted findings go between a REFUTED FINDINGS (non-blocking) line and an END REFUTED FINDINGS line; the factory removes that section before it parses findings, fingerprints the round or briefs the fix turn, and an opening line without its closing line removes nothing. Each review round records a review_mode run event naming the mode and the round.
implementer_isolation Default: "none" "container" isolates turns and live probes. Optional table form: { backend = "container", memory = "4g", writable = true }; memory is a positive integer with m or g suffix; writable controls the workspace mount. A writable implementer turn also mounts a per-task-worktree directory (mode 0700, under the project's state directory) at the home's .claude, so Claude session files survive into the next turn in that worktree; a Claude table implementer's session id is recorded at dispatch, so its fix round resumes that session in the container (a Codex or Devin implementer's fix round starts fresh); a read-only launch, such as the live probe, and the verify and capture containers get none. Every container launch in a project, read-only, setup, verify and capture included, mounts the project's tool cache (mode 0700, under the project's state directory) at the home's .cache, with GOPATH, GOMODCACHE, GOCACHE, GOTMPDIR, npm_config_cache and BUN_INSTALL_CACHE_DIR pointed into it and TMPDIR at the container's /tmp; every task of the project shares it, so one task's cache is the next task's, the same trust the host's shared caches carry under "none"; a launch outside any project, such as the implementer live probe's empty scratch directory, gets neither.
implementer_image Default: reviewer image (review_runner.IMAGE) Image containing the exact configured implementer CLI and the project's toolchain. The reviewer image carries a pinned Claude CLI whose managed settings default to bypassPermissions (see reviewing.md); its credential still comes from implementer_credential. The image needs no Codex: when the implementer or implementer_fallback runs Codex (a command string whose program is codex, or harness = "codex"), every container launch in the project bind-mounts the host's Codex release, the review_runner.CODEX_FILES beside the codex found on the factory's PATH, read-only at /opt/codex/bin and puts that directory first on the command's PATH; a host missing either file fails the launch naming it. When implementer_fallback runs Codex, every container launch also bind-mounts the host's Codex login, auth.json in $CODEX_HOME (default ~/.codex), read-only at /opt/codex/login/auth.json, and a Codex command links it into its Codex home, the worktree environment's CODEX_HOME or /home/implementer/.codex (or, where that directory is not writable, into a fresh CODEX_HOME under /tmp), unless that file is absent or implementer_credential already supplies auth.json in that home. A launch whose command is codex gets the same mounts on its own, so a Codex trimmer or trimmer_fallback beside a Claude implementer adds them to its turns and probes only, and a host without Codex turns trim off instead of failing every launch. Startup refuses a missing image and prints its build command.
implementer_credential Default: {} (no credential) Either { env = "AGENT_API_KEY" } to pass one named host variable, or { file = "~/.agent/auth.json", destination = "/home/implementer/.agent/auth.json" } to give the turn a private copy of one regular file under the temporary home. Each launch copies the file, mode 0600, into a scratch directory in the project's state directory (in the system temporary directory for a launch outside any repository, such as the startup probe) and mounts that directory read-write at the destination's parent (or, when the parent is the home or lies in the session or tool-cache directory, mounts the copy itself read-write, so their existing files stay visible), so the CLI can write beside it and rewrite it; where the copy itself is mounted, the CLI can rewrite it in place but cannot replace it by renaming another file over it, so a CLI that refreshes its credential that way fails the refresh there, and its destination should instead sit in a directory of its own; the copy, and anything written beside it outside those two directories, is removed when the launch ends, and the host file never changes.
implementer_resume Default: absent (disabled) Command string containing {session}; the findings prompt is appended as the last argv element. Refused beside a table implementer, whose adapter builds the resume.
implementer_session Default: absent (disabled) Regular expression string with exactly one capture group containing the session id. Refused beside a table implementer, whose adapter records the session.
budget_scale Default: 1.0 Finite number from 1.0 to 3.0; increase for a slower implementer harness.
turn_cap_min Default: 90 minutes Integer of at least 1; the ceiling on any one implement, fix or babysit turn, scaled by budget_scale. An implement turn is armed with its ticket's box less the run's agent work so far, floored at ten minutes (or the box, if smaller), and never past this ceiling. A timed-out turn names the limit that fired, this cap or the ticket's box, and records a turn_timeout run event with its role, limit (turn_cap or time_box) and armed seconds.
turn_quiet_sec Default: 900 seconds Integer of at least 0, not scaled by budget_scale; 0 turns the watchdog off. A Claude harness implement or trim turn ([agents.implementer], implementer_profiles or [agents.trimmer] with harness = "claude") runs with --output-format stream-json --verbose, and one that prints no stream line for this long is killed as stalled. It records a turn_stalled run event with its role, quiet_s, lines and last_tool, keeps its edits as a WIP: implementer stalled mid-edit commit, and fails the run as infra, with no fallback. While it runs, such a turn records a narrative turn_progress run event with its tool-call count and the latest call's label, on its first tool call and then at most once a minute. Opaque command strings get no watchdog.
implementer_fallback Default: Absent (disabled) Non-empty command string distinct from the primary; set for a probed backup implementer.
reviewer_fallback Default: Absent (disabled) Non-empty command string distinct from the primary, or a non-empty list of them; set for a probed backup reviewer. A list is probed in order when the primary fails, and the first entry that passes is recorded and used.
adjudicator_fallback Default: Absent (disabled) Non-empty command string distinct from the primary; set for a probed backup adjudicator.
adversary_fallback Default: Absent (disabled) Non-empty command string distinct from adversary; set for a probed backup adversary. When it is set startup probes the adversary seat, and an adversary turn whose output carries an outage line probes it and switches to it, as reviewer_fallback does.
trimmer_fallback Default: Absent (disabled) Non-empty command string distinct from trimmer; set for a probed backup trimmer. Startup activates it when the trimmer's probe fails and its own passes, and a trim turn whose output carries an outage line switches to it; a trimmer that fails with no fallback, or a fallback that fails its probe, reverts that run's trim.
implementer_profiles Default: Absent (every ticket runs on [agents.implementer]) Only tables [agents.implementer_profiles.NAME], NAME of letters, digits, _ and -, each with harness, model, effort and orchestration under the rules of [agents.implementer]; startup refuses a non-table, a command string, an unknown key, a harness that cannot implement, a bad effort or a bad name, naming [agents.implementer_profiles.NAME]. A ticket's Implementer: NAME line in "Estimate & dependencies" runs that run's implement and fix turns on the profile in place of [agents.implementer], which stays the default (see below).
implementer_shadow Default: Absent (no shadow) Only the table [agents.implementer_shadow], with harness, model and effort under the same rules as [agents.implementer] (no orchestration); startup refuses a command string, an unknown key, a harness that cannot implement or a bad effort, naming [agents.implementer_shadow]. Set it to run a second implementer on a claimed run's ticket for comparison: the shadow probes its route, implements the ticket once in its own worktree SLUG.shadow on the local branch shadow/SLUG, cut from the run's base commit, runs the ticket's verify commands and the baseline checks, and records one shadow_result run event (see below). It never pushes, opens a pull request, merges, writes to the board, changes the run's phase or adds to its working time, and a down shadow route switches no route. --report compares each shadowed run's primary and shadow implementations side by side.
[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"
# The Codex model and reasoning effort the review container runs, for the
# reviewer and the adjudicator alike. Optional; the values shown are the
# defaults. The effort is one of low, medium, high, xhigh.
review_model  = "gpt-6-astra"
review_effort = "high"
# A second pair the same container switches to when the first cannot run.
# Optional; both keys or neither.
review_fallback_model  = "gpt-5.6-sol"
review_fallback_effort = "high"
# Multiplier on the implementer turn's wall-clock budget: the ticket's
# estimate times this is the cap each implementer turn is armed with, the
# box the sweep and /status count the run against, and the turn_cap_min
# ceiling scaled the same way. Optional; 1.0 when absent, a number from
# 1.0 to 3.0 otherwise. Set it for a slower harness, not for a bigger task.
budget_scale = 1.5

A role can instead be a table naming a harness adapter in holophyte/agents/harness.py. Only implementer, reviewer, adjudicator, critic, trimmer and adversary may be tables, and only for a role the harness supports; today that is claude for implementer, trimmer and critic, codex for implementer, trimmer, reviewer, adjudicator, critic and adversary, devin for implementer, trimmer, reviewer, adjudicator and adversary, and cursor for reviewer, adjudicator and adversary. Unknown keys, an unknown harness, a role the harness does not serve, or an option the harness requires or refuses are startup errors. [agents.implementer] harness = "claude" runs claude -p --session-id U --model M --effort E PROMPT with a fresh UUID U, records U on the run before launch (a turn the budget kills keeps it), and resumes with claude -p --resume U --model M --effort E PROMPT. The binary is claude on PATH, or the absolute path in the top-level [harnesses] table. Under container isolation the image supplies the bare claude and [harnesses] is ignored; U is still recorded at dispatch, and the fix round's resume turn runs in the container against the session files kept for that worktree.

[agents.implementer]
harness = "claude"
model   = "opus"    # optional; passed to --model
effort  = "high"    # optional; passed to --effort as written

[harnesses]
claude = "/opt/claude/bin/claude"   # optional; absolute path only

[agents.implementer_shadow] names a second implementer route to compare with the primary on the same ticket:

[agents.implementer_shadow]
harness = "claude"
model = "sonnet"
effort = "high"

The shadow's turn gets the primary's brief, isolation and time cap. Its shadow_result event records the route, branch, base_sha, head_sha, commits, lines_changed, the turn's seconds, exit_status, timed_out and usage, the verify result (ok and failed_command, or null when no turn ran), the outcome (route_down, branch_exists, setup_failed, crashed, timed_out, no_commits, verify_failed, verified or error) and a redacted detail. A verified or verify_failed shadow then gets one review, blind to which implementer wrote it: the primary reviewer route reads round 1's prompt, less the visual evidence the shadow never captures or publishes, and with the shadow's worktree name and branch shown as the primary's; the event records its verdict (APPROVE, REQUEST_CHANGES, MALFORMED or error with its error text), the findings count by severity, the unwitnessed criteria count, the reviewer profile and the review's seconds. No adversary, fix round or fallback reviewer runs, a configured [agents] reviewer records verdict skipped, and any other shadow records review null. The worktree is removed with its uncommitted edits once the event is recorded, but a shadow's commits stay on a local shadow/SLUG branch: list them with git branch --list 'shadow/*' in the project repository and remove one with git branch -D shadow/SLUG once its diff is no longer needed. The factory never pushes or deletes them, and an existing shadow/SLUG branch or SLUG.shadow path is left untouched: the shadow records branch_exists and stops.

[agents.implementer_profiles.NAME] names an implementer route a ticket can pick with an Implementer: NAME line beside its estimate:

[agents.implementer]
harness = "claude"
model = "opus"

[agents.implementer_profiles.sonnet]
harness = "claude"
model = "sonnet"
effort = "high"

Before a run's first agent turn the claim resolves its profile. A name the config defines is probed once; when the probe passes, every implement and fix turn of the run uses the profile, its orchestration, isolation mounts and resume included, and implementer_fallback keeps its outage role. A name the config does not define, or a profile whose probe fails, runs on [agents.implementer]. Each run records one implementer_profile run event with the profile used (or null), its source (ticket, classifier, escalation or default), the requested name (or null), why it fell back (null, unknown profile or the probe's diagnostic) and the route label its turns record. The order is an escalation, then the ticket's line, then a routing [questions.complexity] verdict, then the default. A continuation of a paused run reuses the recorded profile without a new probe or event, and fails as an infra failure if that profile is no longer configured.

A run on a profile that fails on its merits (a verify failure, a fix round that made no progress, or a terminal adjudication FAIL) is escalated once: after its close-out the factory requeues the ticket with a factory interventions row whose note starts escalation:, and records an escalation run event with the profile, the failure_kind, whether it was requeued and why. Every later claim of the ticket runs on [agents.implementer] with source escalation, reusing the preserved branch. The escalated implement turn is told the profile, the failure reason and the branch head, decides whether to build on the earlier commits or restart from main with a forward commit that returns the tree to main's, and declares, before any closing outcome line, ESCALATION: build — reason or ESCALATION: restart — reason; an escalation_choice run event records the choice (build, restart or undeclared) and its reason. Other failure kinds, runs on the default implementer and an escalated run's own failure are not escalated; [questions.failures] triage then finds the ticket already requeued and does not requeue it again.

[agents.reviewer] harness = "codex" (and the same for adjudicator) does what a host wrapper script used to. Each turn runs in a throwaway detached worktree of refs/review/RUN/candidate inside the review scratch directory, removed with it on every exit, as codex exec -m M -c model_reasoning_effort=E --dangerously-bypass-approvals-and-sandbox PROMPT: Codex's read-only sandbox cannot start under a systemd user unit with PrivateTmp, so the throwaway checkout is the write boundary. The id from Codex's first session id: line is written to $HOLOPHYTE_REVIEW_SCRATCH/session, and when HOLOPHYTE_REVIEW_RESUME is set (see [loop] review_session) the turn is codex exec resume with the same options, the id and the prompt; a resume answered with "no rollout found" runs once more fresh and records a review_session event with that reason. review_model and review_effort beside a table reviewer are refused like beside a command.

[agents.reviewer]
harness = "codex"
model   = "gpt-5.6-sol"   # optional; passed to -m
effort  = "medium"        # optional; low, medium, high or xhigh

[agents.implementer] harness = "codex" replaces the three hand-matched strings implementer, implementer_session and implementer_resume. Each turn runs in the task worktree as codex exec --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -m M -c model_reasoning_effort=E PROMPT, with M and E defaulting to the review defaults above. Codex chooses the session id, so after every implement and fix turn, a timed-out one included, the UUID on the first session id: line of the combined output is recorded on the run as for implementer_session. A fix round under [loop] fix_session = "resume" runs codex exec resume U --dangerously-bypass-approvals-and-sandbox --skip-git-repo-check -m M -c model_reasoning_effort=E PROMPT, keeping the turn's model and effort.

[agents.implementer]
harness = "codex"
model   = "gpt-5.6-sol"   # optional; passed to -m
effort  = "medium"        # optional; low, medium, high or xhigh

[agents.reviewer] harness = "devin" (and the same for adjudicator) runs in the same throwaway candidate checkout as devin --model M --permission-mode dangerous --respect-workspace-trust false -p PROMPT. Print mode fails in a directory Devin has never trusted, and every throwaway checkout is one; dangerous lets the reviewer run git and tests without a prompt, the checkout staying the write boundary. After the turn, devin list --format json in that checkout, which holds only this turn's session, supplies the id written to $HOLOPHYTE_REVIEW_SCRATCH/session; any other answer records none. A resume adds -r ID before -p. model is required, since the factory has no Devin default, and effort is refused, since the CLI has no effort flag.

[agents.reviewer]
harness = "devin"
model   = "opus"          # required; passed to --model

[agents.implementer] harness = "devin" runs each turn in the task worktree as devin --respect-workspace-trust false --permission-mode dangerous --model M -p -- PROMPT; the -- keeps a prompt that starts with - from being read as a flag. Devin prints no session id, so after every implement and fix turn, a timed-out one included, devin list --format json in the task worktree (under its own 60-second cap, inside the turn's heartbeat and killed by the sweep that would kill the turn) supplies it: the session with the newest last_activity_at, since a worktree holds a second session once a fix round started fresh. A list that fails, times out, names no session or ties two sessions for newest records nothing and leaves the turn as it was. A fix round under [loop] fix_session = "resume" runs devin --respect-workspace-trust false --permission-mode dangerous --model M -r ID -p -- PROMPT. model is required and effort refused, as for the reviewer.

[agents.implementer]
harness = "devin"
model   = "opus"          # required; passed to --model

[agents.implementer] orchestration (default "off") is one of "off", "subagents" and "workflow", and a key of the implementer table only. "subagents" adds a paragraph to the implement brief inviting the agent to split independent pieces of the ticket across subagents it starts, keep the one worktree and branch, and have an independent subagent review the change before handing in; claude, codex and devin support it. "workflow" (Claude only) also carries the maintainer's opt-in to a Claude Code multi-agent workflow, with subagents when that tool is unavailable; startup refuses it beside an implementer_fallback that is not a claude command, since an outage switch reuses the brief. A ticket's Orchestration: MODE line in "Estimate & dependencies" overrides the key. A mode the active route cannot run is lowered: "workflow" runs as "subagents" on a Codex, Devin or command-string route, and beside an implementer_fallback that is not a claude command. Every implement turn records an orchestration run event with the mode the brief carried, the requested mode and its source (ticket, project or default). The key never reaches the harness command line, and the turn's time budget is unchanged.

[agents.implementer]
harness       = "claude"
orchestration = "workflow"   # optional; "off", "subagents" or "workflow"

[agents.reviewer] harness = "cursor" (and the same for adjudicator) runs the Cursor CLI in the same throwaway candidate checkout, as cursor-agent -p --model M --force --trust PROMPT. model is required and effort is refused: the CLI has no effort flag. Cursor cannot resume here, so on a re-review under [loop] review_session = "resume" or "alternate" no HOLOPHYTE_REVIEW_RESUME is set and the review_session event records reason: "harness cannot resume".

[agents.reviewer]
harness = "cursor"
model   = "grok-4.7-high"   # required; passed to --model

[agents.critic] is table-only and may leave out harness, which means "codex". Its turn runs as codex exec -m M -c model_reasoning_effort=E --dangerously-bypass-approvals-and-sandbox PROMPT, M and E defaulting to gpt-6-luna and medium, with its cwd a detached worktree of main under a temporary directory that is removed on every exit. With harness = "claude" the turn is claude -p --session-id U --model M --effort E PROMPT in a fresh session, M and E defaulting to opus and high, in the same detached worktree; its reply is read as a Codex reply is. Startup probes it after the writer; a failed probe prints "critic route down; claims skip the relevance check" and the loop carries on without the critic. Under [loop] workers > 1 only the scheduler probes it; each worker inherits that outcome rather than probing again.

[agents.critic]
model  = "gpt-6-luna"   # optional; passed to -m
effort = "medium"       # optional; low, medium, high or xhigh
[agents.critic]
harness = "claude"
model   = "sonnet"   # optional; passed to --model
effort  = "high"     # optional; passed to --effort as written

Container implementation uses the reviewer hardening flags, a 4 GiB memory cap, bridge networking, the factory user's non-root UID/GID, a temporary home and /workspace mounted read-write. Only [worktree] env_allow values and the declared credential enter the agent environment, alongside fixed runtime and Git identity settings. With no worktree environment configured, no host environment variables are inherited. Git author identity comes from the project's configured user.name and user.email; host Git configuration and hooks are not mounted. A self-contained Git directory permits commits in linked worktrees; objects, HEAD and index return to the host after the container has been removed. Verify and capture commands still run on the host. The image must supply the CLI; host executables are not mounted. The reviewer image alone may need extending for the configured implementer. none preserves existing host behavior.

Set [loop] fix_session = "resume" to reuse the recorded session for review fix rounds, with e.g. implementer_resume = "codex exec resume {session}" in [agents] (include the model and sandbox flags for your implementer), or with a table implementer, whose adapter builds the resume argv itself (for codex, with the model and effort of the turn it resumes). The resumed prompt contains reviewer findings and adjudication instructions; the original ticket is already in the session. alternate assigns odd store run ids to resume and even ids to fresh, consistently across their fix rounds. Resume requires this run's session id, a configured template (or a table implementer) and the primary implementer route. Otherwise the ordinary fresh prompt is used. A nonzero resume exit without a new commit gets one fresh retry, subject to the run budget; a timeout remains a budget failure. Resume launch errors also retry fresh once. With either experimental setting, each fix round records a fix_session run event with arm, resumed, and a reason when skipped or failed. The default fresh setting records no such events. These settings apply to review fix rounds only; pull request babysitting is unchanged.

Set implementer_session to extract a session handle from captured implementer output (stdout and stderr), for example the Codex banner:

[agents]
implementer_session = 'session id: ([0-9a-f-]{36})'

After each implement or fix turn, including a timed-out turn, the first match updates runs.providerSessionId and appends an agent_session detail event. Its payload contains session_id, role (implement) and route (primary or fallback). Later ids replace the column while the events retain history in order. With no pattern or no match, nothing is recorded. Invalid regular expressions or a capture-group count other than one are refused at startup. Writer, reviewer, adjudicator and container-isolated turns are excluded. This records handles only; it does not resume sessions.

budget_scale exists because the budget stops runaway turns, not because it selects a harness: an implementer that reads more and edits later can hit its time box on work it had nearly finished, and the answer is more wall clock, not a smaller ticket. The estimate, the ticket and the template's ceiling are untouched — the scale multiplies them only where the clock is armed, so a budget_scale = 1.5 project arms a 45-minute turn for a 30-minute ticket, the timeout line names both figures, and the supervisor's time-box sweep allows for the scaled box. The turn_cap_min ceiling, the ninety minutes by default, scales with it. A value under 1.0 or over 3.0 — or a non-number — is a startup error naming the key and the range.

A configured implementer is probed before each pass claims a ticket: the loop runs the exact command a turn would, with the goal Reply with the single word: ready, in an empty temporary directory under a 90 s cap, and the pass proceeds only when it exits 0 with ready in its output. A route that exits nonzero, answers something else or does not answer in time ends the pass nonzero with the command, the exit code or the timeout, and the last lines it printed -- a typo or a stale CLI is found here, not by a failed implement turn later. The default route is also probed when it has a fallback. Review seats with a fallback are also probed, in a temporary checkout so review wrappers can resolve refs. The probe runs when a pass starts and again when the daemon's PUT /config (behind [serve] config_edit and the write token, never the read token alone: the probe executes whatever command the key names) changes this key: the write lands either way, and the reply's probe carries the same verdict, command, exit code and last lines the loop would print, so a route that does not answer is known at the write and not at the next factory.py start.

Each seat may name a fallback command, for example:

[agents]
implementer = "codex exec --model gpt-5.6-sol"
implementer_fallback = "devin -p"

Fallbacks use the same command-string grammar and receive the goal as the last argument. A fallback must differ from its primary. The loop probes the fallback when the primary probe fails, or when a turn emits its route's quota signature. Only a successful fallback probe activates the route; the interrupted turn is then dispatched once on it. The seat stays on fallback for the process's remaining turns and retries its primary at the next start. Each switch prints its reason and command, records a route_fallback project intervention and run event (a startup switch attaches to the first affected run), and adds a fallback chip to the console's project header. A failed fallback probe stops the loop without recording a switch. review_model and review_effort may sit beside fallback keys, which run only when the container route fails, but not beside reviewer.

review_fallback_model and review_fallback_effort give the container reviewer a fallback that never leaves the container. Both are required together, and like the primary pair they are refused beside a reviewer command but not beside a fallback command. With the pair set, startup probes the container reviewer; when that probe fails it probes the fallback pair in the same container and, if it passes, records and activates the switch as for a fallback command. A review or adjudication turn whose container cannot stage, start or read the reviewer on the primary pair probes the fallback pair and runs the same round once on it; a second failure stops the turn as a review_route failure.

review_model and review_effort choose what runs inside the hardened container when neither review role is overridden by a command. Both reach the container script as arguments, never as text spelled into it. The profile a review round records is codex-TAIL-EFFORT, where TAIL is the model id's last dash-separated segment: the default records codex-astra-high, and gpt-5.6-sol at medium records codex-sol-medium, so the row names what actually ran. An effort outside the four above or an empty model is a startup error naming the key. Either key beside a reviewer command is refused as contradictory: the override opts the reviewer out of the container the pair routes, so one of the two lines is not doing what it says.

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. Executable resolution is separate from the live route probes described above. 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.

[harnesses]

Where a harness adapter finds its binary when a role in [agents] is written as a table. Keys are registered harness names; each value is an absolute path. Absent, the adapter runs the harness's own name from PATH. Ignored for the implementer under implementer_isolation = "container", where the image supplies the binary; review roles run on the host and keep their path.

Key Default Allowed values and when to change
claude Default: claude on PATH Absolute path to the Claude CLI; set when the binary the factory should run is not the first claude on PATH. A relative path is refused.
codex Default: codex on PATH Absolute path to the Codex CLI for a table reviewer or adjudicator; set when the binary the factory should run is not the first codex on PATH. A relative path is refused.
cursor Default: cursor-agent on PATH Absolute path to the Cursor CLI for a table reviewer or adjudicator; set when the binary the factory should run is not the first cursor-agent on PATH. A relative path is refused.
devin Default: devin on PATH Absolute path to the Devin CLI for a table implementer, reviewer or adjudicator; set when the binary the factory should run is not the first devin on PATH. A relative path is refused.

[loop]

Key Default Allowed values and when to change
stop_on_failure Default: true Boolean; set false to continue the queue after a failed run.
order Default: "identifier" "identifier" or "priority"; choose priority to claim urgent Linear tickets first.
spawn_supervisor Default: true Boolean; disable when a service manager owns the supervisor. A project in the host registry spawns none whatever it says: the host sweep watches it.
review_rounds Default: 2 Integer at least 1; change the base independent review allowance.
review_rounds_per_lines Default: 800 Integer at least 0; change the diff-size scaling interval, or use 0 to disable scaling.
review_rounds_max Default: 4 Integer at least 1 and at least review_rounds; change the scaled round ceiling.
workers Default: 1 Integer at least 1; increase to work multiple claimable tickets concurrently.
review_session Default: "fresh" fresh, resume, or alternate; alternate requests reviewer resume on odd run ids, fresh on even run ids.
fix_session Default: "fresh" fresh, resume, or alternate; alternate resumes odd run ids and starts even run ids fresh.
tick_sec Default: 120 seconds Integer at least 10; change how soon a pool with spare slots notices new work, and how often a pool re-checks its parked pull requests.
critic_after_hours Default: 12 hours Integer at least 0; a ticket filed longer ago than this, or one whose named files changed on main since it was filed, is put to [agents.critic] before it is claimed. Lower it to ask sooner; 0 asks of every ticket. Without a critic seat it does nothing.

Configured reviewer wrappers may write their session id to $HOLOPHYTE_REVIEW_SCRATCH/session before exiting. The file must contain an opaque non-empty string of at most 200 characters with no whitespace (including no trailing newline). Missing, unreadable or invalid ids are silently ignored. The factory reads it before removing the scratch directory and records an agent_session event with session_id, role, route, and review round. It does not replace the implementer's recorded session.

With review_session = "resume", round two and later on the primary route receive round one's primary reviewer id in HOLOPHYTE_REVIEW_RESUME. The wrapper is responsible for resuming that session, including across its throwaway checkout directories. The full review prompt is still passed. The first round, fresh arm, missing id, and fallback route receive no resume variable. Each re-review in the experiment records a review_session event with arm, requested, and a reason when no resume is requested. The default fresh setting emits no such experiment event. The default container reviewer, PR thread reviews, covering review, and adjudicator are outside this experiment. The reviewer remains independent of the implementer in every arm.

[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
# Which ready ticket the loop claims first. Optional; the default is the
# lowest identifier.
order = "identifier"     # "priority": most urgent Linear priority first
# Whether the loop starts a detached --supervise for the project at startup
# when no live supervisor holds its lock. Optional; the default is true.
spawn_supervisor = true  # false: a service manager runs the supervisor
# The review-round cap, computed per run from the candidate's diff. Optional;
# the values shown are the defaults.
review_rounds = 2            # the base every run gets
review_rounds_per_lines = 800  # one extra round per this many changed lines; 0 never scales
review_rounds_max = 4        # the ceiling
# How many worker processes the loop keeps running at most, one per claimable
# ticket. Optional; the default is one process working one ticket at a time.
workers = 1                  # 3: up to three tickets worked at once
# How often, in seconds, the scheduler recounts the queue while fewer than
# `workers` are running. Optional; the default is two minutes.
tick_sec = 120
# How old a ticket may be before the critic seat is asked whether it is still
# relevant. Optional; the default is twelve hours.
critic_after_hours = 12

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 under [report] findings = "repo") 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.

order is which ready ticket the loop claims first. "identifier" (the default) is the loop as it has always been: lowest identifier first. "priority" claims the most urgent Linear priority first (1 before 2 before 3 before 4, then unprioritised), identifier ascending within a priority -- the policy for a queue with more than one author, where a P1 filed after ten P3s should not wait behind all of them. The file board has no priority and orders by identifier under either value. Anything but one of the two strings is a startup error naming the key.

With spawn_supervisor = true (the default) the loop checks the project'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 project 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.

The three review_rounds* keys set how many review rounds a run may take before its terminal adjudication. Before the first review the loop measures the candidate against its merge base with main (git diff --numstat, insertions plus deletions, so a preserved branch that already merged main is not charged for main's own lines) and computes

cap = min(review_rounds_max, review_rounds + changed_lines // review_rounds_per_lines)

It prints [holo2] review cap N for M changed lines, records the same line as a note in the run's ledger, and runs up to N rounds; the adjudication after the last round is unchanged. Under the defaults a 300-line candidate gets two rounds, a 1,700-line one four, and a 9,000-line one four. With review_rounds_per_lines = 0 every run gets exactly review_rounds. Each key must be an integer: review_rounds and review_rounds_max at least 1, review_rounds_per_lines at least 0, and review_rounds_max no less than review_rounds; anything else is a startup error naming the key.

workers is the ceiling on the pool of worker processes the loop keeps running. With 1 (the default) the loop is one process working one ticket at a time, exactly as before the key. Above 1 the process that ran factory.py PROJECT becomes a scheduler: it runs the startup checks and the sweep once, then keeps min(claimable, workers) children running, each a factory.py PROJECT --worker that claims one ticket and works it to merge or park -- a queue of one ticket is one worker, a queue of five under workers = 3 is three (see The loop). Merges into main still serialise under the merge lock. stop_on_failure keeps its meaning per pool: a failed worker stops the spawning and the running workers are waited for. An integer of at least 1; "3" (a string) or 0 (a pool that could work nothing) is a startup error naming the key.

tick_sec is how often the scheduler recounts the queue while the pool is below workers: with a slot free, its wait on the children times out after this many seconds and the ready listing and the claimable count are run again, so a ticket filed while the pool was busy starts within a tick rather than at the next exit. The wait carries it with the pool full too: that tick re-checks the parked pull requests (their checks, threads and CI wake) but reads no listing and spawns nothing. The timer itself prints nothing; only a spawn or what the parked-pull-request check reports shows. An integer of at least 10; "120" (a string) or 5 is a startup error naming the key. Only the scheduler reads it: under workers = 1 there is no pool to tick.

[board]

Key Default Allowed values and when to change
project_id Default: None; required for a Linear board Non-empty string naming the Linear project UUID; set to choose the project's queue. Refused on a native board.
team Default: None; required for a Linear board, "native:PREFIX" on a native one Non-empty string naming the Linear team; set to resolve that team's workflow states. It is also the store's key for the project, so a project that moves from Linear to a native board keeps its team.
label Default: Absent (no filter) Non-empty string; set to claim only ready issues with this label. Refused on a native board.
prefix Default: None; required for a native board An uppercase letter then up to nine uppercase letters or digits, such as "HOLO"; the PREFIX of a native board's PREFIX-n ticket identifiers. Refused on a Linear board, whose team names its tickets.
key Default: None; deprecated alias of prefix Read as prefix when prefix is not set; set to the same value as prefix it is ignored, set to a different one it is a startup error naming both. Each command's startup prints one notice while the table carries it: rename it to prefix. Refused on a Linear board.
mode Default: "mirror"; "store" on a native board "mirror" or "store"; where the project's tickets are kept. "mirror" claims from the board's ready listing; "store" claims from the store's queue, at the revision admission judged. Flip one project at a time, held and drained, and only while --board-diff is empty.
kind Default: "linear" "linear" or "native"; which board the project uses. A native board is the store: it takes prefix, defaults team to "native:PREFIX" and mode to "store", and refuses mode = "mirror". Every board member answers from the project's store and nothing asks Linear.
[board]
# The Linear project, or board, this project 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"
# The label a ready issue must carry for the loop to see it. Optional;
# absent, every ready issue in the Linear project is the loop's.
label = "holophyte"

The board is a per-project setting: two projects on one host driven from one process-wide variable would both claim from the same Linear project, and the second would silently work the first's queue. Both values must be non-empty strings. --report, --serve, --repoint 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.

label is the opt-in for a board people also work in: when it is set, the loop's ready listing keeps only issues carrying that label by name (case-sensitive, as Linear shows it), so a ticket a person has decided to take, or one that is a plan rather than a contract, is invisible to the factory however ready it looks. The claim, the queue the console's Board mirrors and the supervisor's board fallback all read through the same filter, so the label decides the whole queue rather than only the claim. The key is read once when the process builds its provider: set, unset or changed on a live project it takes effect at the next restart, not the next pass. The board side is live -- the loop asks the board fresh each claim, so a ticket that gains the label joins the listing, and a mirror row whose ticket lost it waits on the board at blocked_on_deps until it carries it again. In mode = "store" the board is read once a pass instead -- the loop's listing at most once a [loop] tick_sec, the host sweep's once a board_ask_sec while no loop is live -- and the claim reads its one candidate back from the board before it is taken. A ticket then leaves the queue when its column changes (moved to Backlog, or the label removed), not when it disappears from the listing, and nothing waits at blocked_on_deps for the listing to name it again. project_id and team are required when the table is present; label is the one key that may be absent, but when written it must be a non-empty string, and anything else is a startup error naming the key.

mode and kind stage the move to boards kept in the store: "store" is accepted but changes nothing in this release, and "native" builds the native board, whose reads, filing and notes are the project's store. Any other value is refused when a command resolves the board -- the loop, --supervise, a --sweep read-only or acting, and the operator verbs -- which then exits at startup naming the key and the values it accepts. --report, --status, --serve and --import-store --dry-run never resolve the board, so one of them succeeding says nothing about these two keys.

[worktree]

Key Default Allowed values and when to change
setup Default: [] List of entries, each a non-empty shell command string or a table { run = "...", inputs = [...], keep = [...] }; set to install the project's dependencies before agent turns. A table's run is a non-empty command, inputs a non-empty list of repository-relative files or directories, and optional keep a list of repository-relative paths the step leaves behind; any other key is refused. Task worktrees and the main-side checkout run every entry's command; a loop witness pass reuses a table step in its warm tree while its inputs at the commit match its last successful run. Setup runs where verify commands run: under implementer_isolation = "container" it runs in the image, so commands name tools by the names the image puts on PATH, not by host paths.
setup_timeout_sec Default: 300 seconds Finite positive number; increase for slower dependency installation.
branch_prefix Default: "task" Legal single git branch segment (constraints below); change to follow the project's branch naming convention.
env_source Default: absent Source dotenv path, with ~ expanded; relative paths resolve beside config.toml. Requires env_allow.
env_allow Default: absent List of names matching [A-Za-z_][A-Za-z0-9_]*; requires env_source. Missing names refuse startup. Writes exactly these assignments to a mode-0600 .env before setup commands. An empty list writes an empty file.
carry Default: [] List of non-empty repository-relative directory paths without ..; set for ignored dependencies the reviewer needs. A writable container launch (setup, verify, implementer turns and capture) leaves each listed directory out of its disposable clone in both directions and bind-mounts the worktree's directory read-write at the same path under /workspace, creating a missing one in the worktree first; a listed path that is tracked, or leaves the repository, fails the launch naming it; a listed path that is a link is mounted only when it is untracked and points at the same path in another worktree of the repository, as the main-side verify checkout links it.
[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"
# Ignored install directories the review stage copies from the task worktree,
# read-only, so the reviewer can run the ticket's verify commands. Optional;
# empty when absent.
carry = ["console/node_modules"]

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 project 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 project's .gitignore, or a task's git add -A will sweep them into the branch.

A setup entry may be a table that declares what the step reads and leaves behind:

[worktree]
setup = [
  { run = "npm ci --no-audit --no-fund", inputs = ["package-lock.json"], keep = ["node_modules", "prisma/generated"] },
  { run = "npx prisma generate", inputs = ["package-lock.json", "prisma"], keep = ["prisma/generated"] },
  "npx next typegen",
]

Task worktrees and the main-side checkout run every entry's run in order, as for a string. A loop witness pass keeps one warm tree per project, witness-tree/ in the project's state directory, beside its lock witness-tree.lock and its stamps witness-tree.json. Each pass checks the commit out there and runs git clean -ffdx, keeping every keep path; the same clean runs between witnesses. A table step is reused, printing worktree setup N/M reused, while a key over its run and its inputs at the commit matches the stamp of its last successful run, every keep path exists, and no earlier table step ran; a string runs every time. A failed setup clears every stamp. A pass that finds the warm tree held by another, or cannot check it out or clean it, runs in a scratch tree with full setup, as a hand-run --witness-pass and the approval baseline always do. Inputs outside the repository cannot be declared: a step that reads one stays a string.

env_source accepts NAME=value lines, blank lines, comments and an optional export prefix. Values (including quotes) are kept verbatim; no shell expansion runs. Duplicate names use the last assignment. Invalid assignments are refused by line number without revealing values. Source values are redacted from loop output, events, ledger narratives, run failure reasons and review verification results. CRLF line endings are accepted. Setup refuses a tracked .env and adds /.env to Git’s local info/exclude if needed. Factory recovery staging excludes it independently of ignore rules; a candidate with .env in its tree or new history cannot be pushed. With neither key, setup writes no environment file.

carry lists the repository-relative directories, among what setup wrote and git ignores, that the review stage receives a copy of: the reviewer judges a fresh checkout of the candidate commit, which holds none of them, and a console ticket's bun --cwd=console test reports zero tests in a stage with no console/node_modules. Each listed directory is copied into the stage at the same path with its write bits cleared, after the checkout and before the reviewer starts; the stage's identity check runs before and after the copy with --ignored=no, so a carried directory neither dirties the stage nor counts in its fingerprint. A listed path that is tracked in git, absent from the worktree, or escapes the repository (..) fails the stage naming the path rather than skipping it. Startup checks the list is a list of relative paths; nothing is carried that the worktree does not already hold.

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.

[verify]

Key Default Allowed values and when to change
always Default: [] List of non-empty shell command strings; set fast baseline checks required at every verify gate.
before_merge Default: [] List of non-empty shell command strings; set additional, expensive checks needed at the merge gate.
timeout_sec Default: 300 seconds Finite positive number; increase the per-command timeout of the baseline and of every run of a ticket's verify commands (review, trim, adjudication, merge gate, babysitter) for slower checks; reproduction keeps its own 300-second cap.

The default empty tiers add no checks. Ticket verify commands run first, then always; at the merge gate before_merge runs last. Commands run in order in the task worktree and stop at the first failure. These baselines supplement the ticket's exact commands; timeout_sec caps each baseline command and each run of the ticket's verify commands in review, trim, adjudication, the merge gate and the babysitter. The reproduce turn keeps its own 300-second cap, and worktree setup has its own timeout.

Every line of an instrumented simple newline verify block must pass, and the run stops at the first failure. Parseable top-level && chains also stop at the first failing clause; both forms report the failed clause and exit status. This applies to ticket commands and to blocks in both always and before_merge. Lines share one shell, preserving exported variables and cd; append || true to a line whose failure is explicitly allowed. Complex shell programs (including semicolon-separated command lists) execute verbatim, with their own shell exit semantics and without clause-level reporting or the instrumented first-failure guarantee.

The implementer's brief lists the always commands after the ticket's verify commands, so a check that runs at every verify gate is run before the candidate is committed too. To catch a repository-wide test module before a pull request's unit check does, name it in always through tests/run_modules.py, the runner that check uses: each argument is a module path or glob matched by file name, only the matched modules run, each in its own process and HOLOPHYTE_HOME, a pattern that matches no module fails the run, and the closing summary line names every module that failed.

[verify]
always = [
  "ruff check holophyte tests store",
  "python3 tests/run_modules.py tests/test_file_sizes.py tests/test_comment_budget.py 'tests/test_docs*.py'",
]
before_merge = ["python3 -m unittest discover -s tests"]
timeout_sec = 300

See the loop for verify points and automatic .githooks enablement.

[merge]

Before pushing a task branch or merging it locally, the factory removes matching attribution lines and their leftover blank lines from unpublished commits. The defaults match AI Co-Authored-By: trailers naming Claude, Devin, Codex, Copilot or Cursor, or containing noreply@anthropic.com or devin-ai-integration; Generated with lines linking to those agents' sites; and the 🤖 Generated with form. Human co-author trailers remain. Before a rewrite, remote refs are refreshed; commits reachable from origin or main are never rewritten. The rewrite preserves trees, authors and dates, leaves the working tree and index alone, and checks the tip tree before atomically moving the branch. Signatures on rewritten commits are removed because they no longer validate. Invalid regular expressions refuse startup, naming strip_attribution. Use TOML literal strings for custom patterns, for example strip_attribution = ['(?i)^Generated by internal bot\b'].

private_patterns lists Python regular expressions for text a project must never publish, such as private hostnames. Set it only in the project's own config.toml in the host state directory, which no checkout commits. When it is set, the factory scans the lines each unpublished commit adds (a merge commit through its combined diff) and each commit message: a review round cannot approve a candidate that matches and hands its fix turn the location, and a push of a matching branch is refused before git push, so the remote is unchanged. Pull request titles and bodies are scanned before GitHub is called, and --file-ticket, --file-ticket --update and ticket_template.py --repo refuse a matching ticket body. A refusal, and its private_match run event, names only the location and [merge] private_patterns #N, where N is the pattern's zero-based position in the list; pattern text and matched text are blanked from the factory's output. A value that is not a list of strings, or an invalid expression, refuses startup naming the key and the position, never the text. Removing a matching line is never refused.

Key Default Allowed values and when to change
approve Default: "auto" "auto" or "human"; choose human to require an explicit operator approval before merging.
mode Default: "local" "local" or "pr"; choose pr to send the candidate through GitHub checks and review.
pr_rounds Default: 5 Integer at least 1; change the maximum babysit passes before parking.
pr_main_refreshes Default: 10 Integer at least 1; change the maximum babysit passes that only merge main into the pull request before parking. These passes do not count toward pr_rounds.
pr_merge_method Default: "merge" "merge", "squash", or "rebase"; match the repository's merge rules.
pr_poll_sec Default: 180 seconds Integer at least 10; change the minimum interval between automatic babysit resumes.
pr_quiet_sec Default: 300 seconds Integer at least 0; change the quiet period after GitHub activity, or use 0 for immediate green merges.
strip_attribution Default: agent attribution patterns List of Python regular expressions searched per commit-message line; [] disables cleanup. Replaces the default patterns when set.
private_patterns Default: [] (no scan) List of Python regular expressions searched per line of what the factory publishes: added lines and messages of unpublished commits, pull request titles and bodies, and filed ticket bodies. A match is refused naming its location and #N.
check_wait_sec Default: 1800 seconds Integer at least 1; increase the pending-check and quiet-period wait cap for slow CI.
missing_check_sec Default: 600 seconds Integer at least 1; how long a status check the base branch's ruleset requires may report nothing on the pull request's head before the babysitter stops waiting for it. Checks that reported and are merely slow wait for check_wait_sec instead.
route_retry_sec Default: 900 seconds Integer at least 1; how long a run parked as a route outage waits before the host wakes it to ask the adjudicator again, whatever pr_poll_sec says. A babysit thread pass parks that way when its adjudicator command exits non-zero (a provider limit line is quoted in the reason) or gives no verdict for any thread; nothing is posted on the judged threads. A turn that fails again parks again.
check_fix_rounds Default: 2 Integer at least 1; the most check fix turns one babysit pass runs on red checks. After a fix turn's commit goes red again, another round runs only when the failure is new: its failure lines (test failure headers and error lines read from the whole job logs, normalized for timestamps, durations, retries and digests) are not all lines an earlier round was handed. A repeated failure, or a new one once the rounds are used up, parks the run. 1 runs one fix turn, then parks.
capture_fix_rounds Default: 1 Integer at least 0; the most capture fix rounds one run runs. On a project with ui_capture_local = true, when the UI capture at a babysit fix head exits nonzero, times out, writes no media or saves fewer views than the ticket declares, a capture fix turn runs before the covering review, handed the failure, the views not captured, the failing Playwright lines and the end of the capture log, and the capture runs again at the turn's head. If that capture still fails, the run parks without a covering review. A capture that fails to publish (upload, credentials) gets no round. 0 sends a failed capture straight to the covering review.
retrigger_missing_checks Default: false Boolean; set to true to push one empty commit ("Retrigger missing checks: NAMES") to the candidate branch when a required check has reported nothing for missing_check_sec, then keep waiting on the new head. Otherwise, or when the check still reports nothing on that new head, the run parks naming the missing checks.
require_up_to_date Default: true Boolean; under mode = "pr" the factory refuses to merge a candidate that does not hold the fetched tip of main, and parks it as behind main. Set to false only on a repository that does not require branches to be up to date before merging: the refusal is skipped and GitHub's own merge rules decide, so a conflicting merge is still refused. Non-boolean values are a startup error naming the key.
pr_changes_log Default: false Boolean; set to true to retain an ordered "Changes since first review" list across approved fix rounds. Otherwise the next successful description refresh removes any existing list, preserving Evidence, the Linear line and appended blocks. Non-boolean values are a startup error naming the key.
review_fixes Default: false Boolean; with approve = "human", set to true so fix commits a babysit pass pushes after the release (bot-thread answers, merges of main) get the covering review of that range before the run parks, and the park names the reviewed range and sha. A rejection with findings no earlier round raised gets an automatic fix round and another covering review (see covering_fix_rounds); a repeated rejection, or one past those rounds, parks with the findings and no approval. Non-boolean values are a startup error naming the key.
covering_fix_rounds Default: 2 Integer at least 0; the most automatic fix rounds per run after a babysit covering review of a fix asks for changes no earlier round asked for. The review's findings become the fix note and a covering review of that fix follows; findings that repeat an earlier round's (by [sweep] review_overlap_threshold) park instead. Like the operator's note allowance they run past reviewRoundCap. Set to 0 to park on every rejection of a fix.
pr_draft Default: false true, false or "ui"; under mode = "pr", set to true to open the factory's pull request as a draft. The babysitter marks it ready for review once, at the point where it would merge or park it ready to merge, then waits for checks again; it never merges a draft. With pr_draft = "ui" the pull request opens as a draft only when the candidate's diff touches ui_paths (otherwise it opens ready, as with false), and the factory never marks it ready: at that point the run records a pr_draft_held event and parks for the maintainer, whose send-back and steer notes drive fix rounds that park held again. When the maintainer marks it ready for review on GitHub, the reconcile wakes the run (a ci_wake event, at most once per pr_poll_sec) and the babysitter carries it on as any ready pull request. "ui" needs ui_paths; any other value, or "ui" without ui_paths, is a startup error naming the key.
pr_style Default: "" String; set instructions for the title and description writer to follow repository conventions.
ui_paths Default: [] List of non-empty repository-relative globs without ..; set with ui_capture to identify changes needing visual evidence.
ui_capture_dir Default: "e2e/capture" Directory named in the implementer brief for ticket capture scripts; the capture command receives it as HOLOPHYTE_CAPTURE_DIR.
ui_capture_local Default: false Boolean; true keeps capture specs out of the project's repository. ui_capture_dir must then be a repository-relative directory without ...
ui_capture_video Default: false Boolean; true sets HOLOPHYTE_CAPTURE_VIDEO=1 for the capture command, so the bundled Playwright runner publishes the videos a capture saves as video-01.webm, video-02.webm and so on; false removes an inherited HOLOPHYTE_CAPTURE_VIDEO. Turning it on or off captures again at the same commit.
ui_capture Default: "" Command string with shell-style quoting but no shell evaluation; set with ui_paths to capture evidence non-interactively.
capture_env_source Default: absent Source dotenv path for the capture command only, with ~ expanded; relative paths resolve beside config.toml. Requires capture_env_allow.
capture_env_allow Default: absent List of names matching [A-Za-z_][A-Za-z0-9_]*; requires capture_env_source. Missing names refuse startup, naming the variable. Exactly these values are added to the ui_capture command's environment, on the host and in a container; they are never written to the worktree and never reach agent turns or verify commands. Source values are redacted from output.
media_repo Default: "" (project repository) Empty string or GitHub owner/name; set a separate repository to keep evidence out of the project's git storage.
media_bucket Default: Absent (git publishing) Table described under merge.media_bucket below; set to publish evidence in S3-compatible object storage instead.
media_max_file_mb Default: 10 MB Finite positive number; change the largest permitted individual evidence file.
media_max_total_mb Default: 20 MB Finite positive number; change the total evidence budget per capture.
human_threads Default: "park" "park" or "act"; choose act to judge and fix concrete human requests while leaving their threads unresolved.
bot_threads Default: "act" "act" or "advisory"; choose advisory to record unmentioned bot findings without blocking the merge.
bot_logins Default: [] List of login strings; set to classify additional bot accounts for advisory routing and human-reply escalation.
mention_accounts Default: [] List of GitHub login strings, matched case-insensitively; only these accounts may give mention instructions. Empty or absent keeps mentions open to any account; startup logs this once when human_threads = "act".
mention_handle Default: "holophyte" String, written without @; change the handle used for direct instructions in threads and the PR conversation.
after Default: [] List of shell command strings; set post-merge builds or other commands needed in the main checkout after a local merge.
bot_authors Default: ["devin-ai-integration", "coderabbitai", "greptile-apps", "github-actions"] List of login strings replacing these defaults; change which bots' declined threads are resolved (the [bot] suffix still qualifies).
[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
# Where an approved, verified candidate goes. Optional; the value shown is
# the default.
mode = "local"     # "pr": push the branch to origin and open a pull request
# How many babysit passes over an open pull request before the run parks
# for the operator. Optional; the value shown is the default.
pr_rounds = 5
# How many babysit passes that only merge main into the pull request (it
# conflicted, or a merge was refused as behind main) before the run parks;
# they do not count toward pr_rounds. Optional; the value shown is the default.
pr_main_refreshes = 10
# How the babysitter merges a green, quiet pull request: "merge", "squash" or
# "rebase". Optional; the value shown is the default.
pr_merge_method = "merge"
# The least seconds between two babysit rounds the loop itself starts on
# one parked pull request when it sees new review activity. Optional; the
# value shown is the default.
pr_poll_sec = 180
# The least seconds a pull request must have been green and untouched --
# no comment, review, push or check -- before the babysitter merges it.
# Optional; the value shown is the default. 0 merges as soon as it is
# green.
pr_quiet_sec = 300
# Instructions the written turn is given, in the repository's own words.
# Optional; default empty.
pr_style = ""
# Shell commands run in order in the main checkout once a local merge has
# landed, before the run is marked merged: the console build, so the daemon
# serves the bundle the merge changed. Optional; default empty. The first
# failing command stops the list and parks the run for the operator with
# its output; the merge stays. Not run under mode = "pr".
after = ["bun --cwd=console run build"]

With a non-empty mention_accounts list, unlisted mentions receive one reply per thread: "Only listed maintainers may instruct the factory here", with the standard comment header. Review threads retain normal bot or human routing; conversation comments yield no instruction. Set mention_accounts = ["maintainer-login"] to restrict instructions in both places. A bare string or a non-string list entry is rejected at startup.

mention_handle defaults to "holophyte" (without @). A review thread's latest comment mentioning @holophyte, case-insensitively, is an instruction: the factory fixes it without adjudication, replies with the commit SHA, and resolves it. Set mention_handle = "factory-bot" to use @factory-bot instead. Earlier mentions do not make a thread an instruction. Unmentioned threads retain the human_threads and bot_threads policies.

bot_authors is a list of login strings, defaulting to ["devin-ai-integration", "coderabbitai", "greptile-apps", "github-actions"]. A declined thread whose opening author is listed or whose login ends in [bot] is replied to with the reason and then resolved. Other declines stay open and park the run. Setting the list replaces the defaults; [] keeps only the suffix rule. A bare string or a non-string entry fails startup naming bot_authors. This key controls decline resolution; the existing GitHub author-type policy still determines which threads are adjudicated.

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.

With mode = "local" a clean merge gate lands the candidate on main with a --no-ff merge, as it always has. With mode = "pr" the loop pushes the task branch to origin instead and opens a pull request against main with a title and body written from the change, so the repository's own review bots and CI see the change before it lands (design note 7). The loop then babysits the pull request -- reads its unresolved review threads and its checks, verdicts each thread, fixes and replies, waits for CI -- for at most pr_rounds passes; see PR rounds for the pass. A pull request that comes up green with no unresolved thread is merged through the PR's merge API under approve = "auto", and under approve = "human" the run parks exactly as above -- phase awaiting_merge_approval, ticket blocked_on_operator, branch and worktree preserved, lease released -- with the PR's URL recorded on the run (runs.prUrl), in the ticket's question (PR open: URL, with the open threads listed) and in the ledger comment, until --approve KO-n releases it: the resumed claim babysits the PR once more and merges it through the API when it is green and quiet. A declined thread left open for its author, a thread only a person can answer, red checks, or the cap park the run the same way; --babysit KO-n sends such a run back for another round of passes without saying "merge". The factory never moves local main under this mode: the merge is GitHub's, and the writer host's checkout tracks origin by the operator's hand. The pull request is opened through gh when it is on PATH, and otherwise through the GitHub API with a token read from GH_TOKEN or GITHUB_TOKEN in the environment; the babysitter's calls (gh api, GraphQL for the threads) take the same route, and the token is never written to the config, the store or a log. Startup checks the route before anything is claimed: a project with no origin remote, a gh whose gh auth status fails, or neither gh nor a token is a startup error naming [merge] mode. At the gate, a refused push, a PR create that fails, or a route that has gone missing since startup ends the run as an infra failure -- no strike against the ticket, branch and worktree preserved, and no pull request recorded that was not opened. The value must be "local" or "pr"; anything else is a startup error naming the key. "The factory never pushes" is, under this mode, "the factory never pushes main".

pr_rounds is an integer of at least 1 (default 5): the number of babysitter passes the loop makes over one pull request in one run before it parks the run for the operator naming the cap, with the threads still open listed -- the cap that keeps the loop from arguing with a review bot forever. Every pass is a reviewRounds row with route github:LOGIN, so the count is visible in FINDINGS. Anything that is not such an integer (0, true, "5") is a startup error naming the key.

pr_main_refreshes is an integer of at least 1 (default 10): the number of babysitter passes that only merge origin/main into the branch -- GitHub reported the pull request conflicting, or refused the merge because the branch is behind main -- before the run parks naming this cap. Such a pass does not count toward pr_rounds, so a reviewed, green pull request is not parked for keeping up with a busy main, while one that can never catch up still parks. Anything that is not such an integer is a startup error naming the key.

pr_merge_method is the merge_method the babysitter sends GitHub's merge API when it lands a green, quiet pull request under mode = "pr": "merge" (the default) asks for a merge commit, like the local --no-ff merge; "squash" and "rebase" are for a repository whose branch ruleset allows squash merges only or requires linear history, which refuses a merge commit after every gate has passed. The sha recorded on the run and in the ledger is the one GitHub answers, which for "squash" and "rebase" is the new commit on main. The key is validated whatever the mode; anything but the three strings is a startup error naming the key. The local mode's --no-ff merge is unaffected. For "squash" and "merge", the commit subject is the pull request title followed by (#N) and the commit body is its first Summary paragraph (empty when absent). "rebase" sends neither override.

pr_poll_sec is the least time, in seconds, between two babysit rounds the loop itself starts on one parked pull request (KO-362). Every tick already reads each parked pull request once to notice a merge; the same read carries GitHub's updatedAt and the review-thread count, and a pull request that has moved past what the last babysit pass recorded on the run is sent back to the babysitter as --babysit KO-n would send it (see Loop) -- no more often than this per pull request, measured from the park, so a reviewer typing three comments in a minute gets one round rather than three. The default is 180; an integer of at least 10, and anything else (5, "180", true) is a startup error naming the key. The reads back off on their own when the token's GraphQL budget runs low, whatever the value.

pr_quiet_sec is the least time, in seconds, a pull request must have been green with no unresolved thread before the babysitter merges it (KO-429) -- the "quiet" of "green and quiet". It is measured from GitHub's updatedAt, which moves on every comment, review, push and check: a reviewer still typing, a bot's second pass not yet posted, or a commit pushed a minute after the checks went green all restart the count. Until then the pass re-reads the pull request on the cadence it uses for pending checks and prints how long it has been quiet of the quiet required, and pr_rounds still caps a pull request that never goes quiet. The default is 300; an integer of at least 0, and anything else (-1, "300", true) is a startup error naming the key. 0 is the merge-as-soon-as-green the babysitter had before.

Pull request bodies are always written. After the candidate is approved and verified and before the branch is pushed, the loop runs one more turn on the implementer route in the task worktree, given the diff against main (capped, with a note when cut), the ticket body, the repository's AGENTS.md and CLAUDE.md when the repository root has them, the repository's pull request template -- .github/pull_request_template.md, or PULL_REQUEST_TEMPLATE.md under .github/ or at the root -- when the worktree has one, with the instruction to fill its sections, and pr_style. The turn answers with one line TITLE: ... and the description in Markdown after it; the loop takes the title as given, appends one line Linear: KO-n with the issue's URL to the body, and opens the pull request with them. No FINDINGS entry is appended, and the branch keeps its identifier. A reply with no TITLE: line, an empty title or a title over 120 characters, an empty body, or a turn that runs out of its budget (a few minutes of the run's remaining box), falls back to the ticket title and a short stub. The stub contains the first paragraph of the ticket's Summary section (up to 600 characters), one line saying The description could not be written: REASON, and the Linear: KO-n line with the issue URL. The loop prints the failure reason. The description is rewritten after each approved fix round from the current diff, ticket and repository conventions; see PR rounds.

pr_style is an optional string of instructions the written turn is given verbatim, for the repository's own pull request conventions -- for example, "Title starts with [Feature Name], the feature read from the diff. No ticket identifier in the title. Describe what changed and why in a few short paragraphs; no testing plan." Anything but a string is a startup error naming the key.

Visual evidence is captured when the diff matches ui_paths. Configure it together with ui_capture; leaving both absent leaves PRs unchanged. The capture command receives one output-directory argument and has five minutes to write PNG, WebM or MP4 files. Evidence is shared with review prompts and PR descriptions. A ticket may list up to six states under optional ## Evidence, one per line. With capture configured, the implementer is told to add or update a script under ui_capture_dir, producing 01-slug.png, 02-slug.png, etc. in state order, plus a recording when the states describe a flow. The command receives HOLOPHYTE_TICKET, HOLOPHYTE_CAPTURE_DIR set to ui_capture_dir and, only when states are listed, HOLOPHYTE_EVIDENCE_STATES joined with newlines, plus any capture_env_allow values, and HOLOPHYTE_CAPTURE_VIDEO=1 when ui_capture_video = true. The project's own harness selects and runs that ticket's script. With ui_capture_local = true, every worktree setup writes a .gitignore holding * into ui_capture_dir, so the directory ignores itself (in the worktree and in a container turn's clone), and the brief names the spec <ui_capture_dir>/<ticket key>.capture.ts, which stays in the worktree and is never committed. A change to the local spec the capture would run reruns the capture at the same commit and counts as fix-round progress. A state that names image files in backticks (`03-panel.png`) captions each published one it names; a state naming none captions the NN- image at its position. A state with no image is marked "not captured" in the PR and reviewer prompt. A capture fails when its command exits nonzero or times out, when it writes no media, or, for a ticket that lists states, when it exits 0 without an image for every state; its section then opens with "The UI capture saved N of M views; not captured: …", and its media is published beside the ticket's earlier media (pr-media/<ticket>-<head>, or <ticket>-<head>/ under media_repo) rather than over it. A ticket without states succeeds with any media file. The PR's Evidence section is rewritten when the pull request opens, when a review of its candidate approves, and before the covering review of each babysit fix that passed verify and touched ui_paths since the section's "Captured at" commit, whatever that review then decides. When the capture at the new head fails, the earlier images stay under a notice that they show an older commit. Tickets without the section keep the default capture. On a project that sets ui_paths, a ticket whose +++ header declares ui_change = "major" must carry a [mockup] table linking the approved mock-up (in the magic-line form still accepted, a **UI change:** major line under What / Why / How needs a ## Mock-up section); --file-ticket refuses it and the claim leaves it unclaimed until it does. A project without ui_paths never needs the mock-up, and a ticket declaring minor, or nothing, is unaffected.

A Playwright project can name the factory's own runner as ui_capture instead of shipping a capture script: python3 HOLOPHYTE/holophyte/capture_playwright.py with the options below (or python3 -m holophyte.capture_playwright). It runs the spec DIR/<HOLOPHYTE_TICKET>.capture.ts, which lives outside the project's test tree, and refuses when the spec is missing, unless the ticket lists no evidence states and --default SPEC names an existing spec, which then runs in its place (a ticket that lists states and wrote no spec is still refused, and a --default naming a missing file is refused naming it). A default outside the working directory, such as one kept beside the factory's config, runs from a temporary copy in the capture directory, so it resolves the project's modules and path aliases; the copy is removed after the run, and a missing capture directory is refused naming it. A generated config in the spec's directory imports the project's config and points every test project at that spec. Projects named in another project's dependencies keep their own testMatch, so a setup project still runs. The boot command runs with that config and the spec's path appended, regex-escaped because Playwright reads it as a file filter, and with CAPTURE_OUT set to the absolute output directory. After the command exits 0, the run fails unless at least one NN-slug.png was written. With HOLOPHYTE_CAPTURE_VIDEO=1, the projects that run the capture spec, never a helper project, record each page into a fresh temporary outputDir. After the command exits 0, each video saved by name there is copied into the output directory as video-01.webm, video-02.webm and so on, in the order they were written: Playwright's own video.webm and video-N.webm from the page and context fixtures, and any file the spec saved with video.saveAs(). The per-page scratch recordings of a context the spec created with recordVideo (page@ and hex digits) are skipped, and stderr counts them, so a spec saves the page that shows the states and never a sign-in or warm-up page. The temporary directory is then removed, and a run that published no video says so on stderr without failing. With HOLOPHYTE_CAPTURE_VIDEO=1, each capture project's use, or the config's own use when it has no projects, also gets launchOptions.slowMo set to --slow-mo, beside the project's other launch options, which slows every action of the browser, including in a context the spec creates, and video: { mode: 'on', show: { actions: { cursor: 'pointer' }, test: { level: 'step' } } }, which draws a cursor, each action's title and the current test.step title on the video of Playwright's own page and context fixtures (a Playwright before the show option ignores it). A context the spec creates passes recordVideo.showActions and calls page.screencast.showChapter() itself. --slow-mo 0 leaves slowMo out, and without video the generated config is unchanged. The options are --boot COMMAND (default npx playwright test), --env NAME=VALUE (repeatable; {key} in a value becomes the ticket key lowercased without its hyphen, ABC-12 giving abc12), --dir DIR (default HOLOPHYTE_CAPTURE_DIR, else .holophyte-capture), --config FILE (default playwright.config.ts), --default SPEC (default none) and --slow-mo MS (an integer of at least 0, default 400; any other value refuses before booting). For example: ui_capture = "python3 HOLOPHYTE/holophyte/capture_playwright.py --boot 'bash BOOT_SCRIPT' --env HANDLE=-capture-{key} --env WORKERS=1".

capture_env_source and capture_env_allow supply credentials the capture needs and the implementer does not, such as a server's API keys. They keep those values out of the implementer's .env, environment and prompt, not out of reach: with implementer_isolation = "none" the implementer runs as the same host user and could read the source file, and the capture command runs spec code the candidate wrote, which sees the values while it runs.

MB means 1,048,576 bytes: oversized files are omitted, then videos are dropped first to fit the total cap, with each omission listed in Evidence. Without a bucket, media_repo selects a separate GitHub repository; otherwise evidence goes to the project repository on pr-media/KO-n. Image links reflect the destination repository's visibility; videos use blob links.

A human mention in the pull request conversation tab is also an instruction, bypassing adjudication. The factory answers with a conversation comment quoting the request and naming the fix SHA; conversation comments have no review thread to resolve. Unmentioned conversation comments are ignored.

[merge.media_bucket]

Key Default Allowed values and when to change
endpoint Default: None; required when the table is present HTTP(S) URL without credentials, query, fragment or whitespace; set the S3-compatible API endpoint.
bucket Default: None; required when the table is present Non-empty S3 bucket name using lowercase letters, digits, dots and hyphens; set the destination bucket.
public_base Default: None; required when the table is present HTTP(S) URL without credentials, query, fragment or whitespace; set the public URL prefix from which readers fetch objects.
retention_days Default: Absent (no expiry) Positive integer when set; set to describe the lifecycle expiry you configured on the bucket, and leave it absent when the bucket has none.

Bucket publishing takes precedence over both git publishers. Set HOLOPHYTE_MEDIA_ACCESS_KEY_ID and HOLOPHYTE_MEDIA_SECRET_ACCESS_KEY in the writer's environment, never in this file. The operator must configure public reads, and any lifecycle expiry, on the bucket; retention_days is display metadata and does not install an expiry policy. The validator uses 1 as its validation fallback when the key is omitted, but the publisher then names no expiry in the pull request.

A ticket's ## Mock-up snapshots (.png) and bundles (.zip) are read from public_base too: filing refuses a media link outside it, and the run downloads each one into the task worktree for the implementer. A lifecycle expiry on the bucket expires them as well, so a ticket filed long before its run may reach the implementer without its design.

[merge.media_bucket]
endpoint = "https://objects.example.com"
bucket = "review-evidence"
public_base = "https://media.example.com"
retention_days = 7

[supervisor]

Key Default Allowed values and when to change
heartbeat_stale_min Default: 5 minutes Finite positive number; increase for a project whose healthy heartbeat can be delayed.
stale_strikes Default: 2 Positive integer; increase to require more consecutive silent sightings before acting.
budget_grace Default: 1.5 Finite positive number; change the grace multiplier on the run's per-turn allowance.
run_cap Default: 3.0 Finite number from 1.5 to 5.0; change the hard ceiling in scaled ticket boxes.
review_overlap_threshold Default: 0.5 Finite number in (0, 1]; change the shared-findings fraction that signals a stuck review (review_stuck). Only code findings are compared: a criterion restated as unmet is left out, and the same criterion restated in three consecutive rounds trips criterion_stuck instead.
sweep_interval_sec Default: 60 seconds Finite positive number; change how often a project's own supervisor sweeps. The host sweep's interval is host.toml's [supervisor] sweep_sec instead.
restart_grace_sec Default: 120 seconds Finite positive number; increase for slower self-merge restarts.
board_ask_sec Default: 600 seconds Integer at least 60; change the minimum interval between fallback board listings.
[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
run_cap                  = 3.0  # the run's hard ceiling, in boxes: the loop refuses a turn past it
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
board_ask_sec            = 600  # least wait between two fallback asks of the board

board_ask_sec bounds how often the supervisor's board fallback may list the board's ready tickets. The fallback runs when the mirror is empty and no loop is live — a ticket filed while the loop was down has no mirror row, so the pass asks the board itself — and a ready listing is Linear's most expensive query here, thousands of the key's hourly complexity points. The last ask is stamped on the project's store row, so the interval holds across passes and across supervisor restarts whatever sweep_interval_sec is. The value is an integer of at least 60; under a minute the fallback is the polling that emptied the key.

Separately, every Linear answer carries the key's complexity-budget headers and the provider shares low-budget deadlines per API key under HOLOPHYTE_HOME/linear-budget (default ~/.holophyte/linear-budget). When under a tenth of the limit remains, the fallback and the loop's idle relisting both wait for the reset instead of spending the points to be refused: one [holo2] board not asked: budget resets at HH:MM line per refill, and no calls until it. Supervisor loop starts and pool worker claims honor the same deadline, including after a process restart; the pool also checks after mirroring before spawning workers. If a low reading omits the reset, the factory waits one hour from that reading before probing again. A refused answer (HTTP 429) lands the same way, as a LinearBudgetExhausted naming the reset.

The box is counted per turn: a run's allowance is the ticket's estimate once for its first implementer turn and once more for each review round it has recorded, up to the run's review cap, all under budget_grace -- the same budget the loop gives each turn, so a fix round after a review is not swept as overtime. A run with no review round yet is judged against the single box.

Whatever that allowance grows to, run_cap times the box is the ceiling: the loop refuses to arm a turn whose budget would carry the run past it -- the run fails there, candidate preserved, instead of the turn being killed mid-edit -- and a run that slips past anyway is swept. The ceiling exists for the run that keeps earning turns by failing review, the case the per-turn budget cannot bound. run_cap is a number from 1.5 to 5.0; /status carries it in thresholds so the console's time-box bar can draw it.

Different projects 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, board_ask_sec an integer of at least 60, the overlap a fraction in (0, 1], and run_cap a number from 1.5 to 5.0. 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. Under the sweep timer every host sweep run is a new process that reads each registered project's thresholds, so an edit applies at the next run (the --supervise loop without a timer re-reads a project's config when host.toml changes); its interval is not a project's but the host's, [supervisor] sweep_sec in host.toml (The host registry), and it judges a project's watcher beat stale after two of those intervals.

[serve]

Key Default Allowed values and when to change
transcripts Default: [] Allowed transcript roots (a path or list of paths), relative to the config directory or absolute, with home expansion. Empty disables transcript reads; turn metadata remains available. Codex roots contain rollout JSONL files; Claude roots contain SESSION.jsonl files, as Claude Code’s projects directory does; durable Devin exports belong below a directory named for the session id. The operator must preserve review exports before scratch cleanup. A container review copies its Codex session into the transcripts directory in the project's holophyte directory, beside config.toml; list "transcripts" here to open review turns.
token_file Default: Absent Non-empty path string, relative to the config directory or absolute, with home expansion; set for non-loopback reads or any enabled write routes.
machine_token_file Default: Absent Non-empty path string, resolved as token_file is; set to accept one machine-wide token beside the project's own wherever token_file is demanded.
actions Default: false Boolean; enable to expose authenticated daemon action routes.
config_edit Default: false Boolean; enable to read and edit config through authenticated daemon routes.
name Default: Project directory name Non-empty string without /; change to match the deployed systemd instance.
[serve]
# The file whose contents every JSON request to a non-loopback bind must
# present as `Authorization: Bearer ...`. Required when `--serve` names a
# host other than loopback; ignored when it binds loopback.
token_file = "~/.holophyte/holophyte/serve.token"
# A second file whose contents are accepted wherever `token_file`'s are:
# one token for every daemon on this machine. Optional.
machine_token_file = "~/.holophyte/machine.token"
# Answer `POST /actions/restart-supervisor`, `/actions/launch-loop` and
# `/actions/requeue` behind the token, on every bind (so `token_file` is
# required with this on). Off, every `/actions/` path is 404.
actions = false
# Answer `GET /config` (this file, token and key values redacted) and
# `PUT /config` (a replacement, validated as startup validates, written
# beside a `config.toml.bak-STAMP`) behind the token, on every bind. Off
# by default: whoever can write this file writes `[worktree] setup` and
# `[agents]`, which the next loop start runs as commands on this host.
config_edit = false
# The systemd instance those actions address: `holophyte-supervise@NAME`,
# `holophyte-loop@NAME`. The project directory's name when absent.
name = "holophyte"

The daemon's bind address is its only boundary, and once the bind is anything but loopback that is not enough. With --serve HOST:PORT where HOST is not loopback (127.0.0.1, any 127.x address, localhost, ::1), the daemon reads token_file at startup and answers 401 with an empty JSON body, before touching the store, to every request that does not carry the file's exact contents as a bearer token; /, the console's built files and /peers stay open so the page can load and learn where its peers are. A non-loopback bind with no token_file is a startup error naming the key. The file must be a regular, non-empty file that is not group- or world-readable (chmod 600); anything else is a startup error naming the file and its mode. The token is the file's contents with surrounding whitespace stripped and is never printed or logged. ~ is expanded and a relative path is taken against the config's directory. A loopback bind ignores the key for its reads: --serve 7710 is as open as it always was, unless actions is on (below). One token per project, no rotation: to change it, write the file and restart the unit.

machine_token_file names a second token file, read wherever token_file is read and held to the same rules; a missing, empty or group- or world-readable file is the same startup error, naming [serve] machine_token_file. Every route that demands the project's token accepts this one too, each compared in constant time, so the daemons on one machine can share a single token that is rotated in one place and kept in one copy on the operator's machine, while token_file stays the token to hand out for one project alone. It does not stand in for token_file: the binds and routes that need that key still need it. Absent, the daemon accepts the project's token alone, as before.

actions opts the daemon into the three POST /actions/... routes, off by default: restart-supervisor and launch-loop run systemctl --user against the deploy units, requeue is the store's requeue as --requeue KO-n --note TEXT does it, each an interventions row written before it runs and not run when it cannot be recorded. The routes are behind the bearer token on every bind, loopback included -- the bind address guards reads, not a hand on the units -- so actions = true needs token_file whatever the bind, and a bind without it is a startup error naming the key. name is the instance name the unit actions append -- the slug the deploy templates were enabled under -- a non-empty string with no /, the project directory's name when absent. config_edit opens this file itself to the console: GET /config is its text with the value of every key named ...token or ...key replaced by [redacted], wherever and however the key is written (token_file, a path, stays; every value under a table so named is replaced too), and PUT /config is a replacement the daemon holds to the same checks startup runs -- a refused document is 400 naming the key and nothing is written -- then writes beside a timestamped backup and records as a config_edit intervention; a [redacted] sent back is the current value, so a round trip never blanks a secret. A write that changes [agents] implementer runs the startup probe on it ([agents] above) and reports the verdict as probe beside the write, which lands regardless. A PUT /config may also carry {"patch": {"loop.workers": 3, ...}}, dotted keys the daemon sets in this file with tomlkit -- comments and layout kept -- and holds to the same checks; GET /config carries the redacted text parsed as values beside it, so the console never parses TOML. The change applies at the next loop start, not to a running loop. It needs token_file on every bind as actions does, and is off by default because the file is command execution on the writer host ([worktree] setup, [agents]). All three are read once at bind. The routes, their bodies and replies are in The daemon's actions.

On a host daemon (factory.py --serve with no project) the keys split. name is the project's route name, /projects/NAME/..., as well as its unit instance, and must be unique across the registry. config_edit stays per project, read when the registry is (re)read. token_file is accepted under the project's own prefix only, beside the machine token, for one release. The bind, machine_token_file and actions are the host's, in host.toml's [serve] table (The host registry): a project's actions is not read, and a project's machine_token_file is named once at start and ignored.

[console]

Key Default Allowed values and when to change
daemons Default: [] List of unique HOST:PORT strings with non-empty host and decimal port; set to show other daemons' projects in the console.
[console]
# The other daemons the console fans out to, as HOST:PORT strings. Optional;
# absent, the page shows this daemon's project alone.
daemons = ["writer-2:7710", "writer-3:7710"]

One daemon serves one project; the console shows every project on every host, so the page has to be told where the others are, and the daemon it was loaded from tells it: GET /peers answers this list as peers beside the address the daemon itself bound as self. The entries are strings the page fetches from the browser; the daemon never connects to them. Each is checked at startup like --serve's address -- a non-empty host, a decimal port -- and none may appear twice; a bad entry is a startup error naming [console] daemons and the entry, before anything is served. A peer beyond loopback is behind its own [serve] token_file; the page presents the token the operator gives it. A host daemon serves every registered project, so its peers are other hosts' daemons, listed in host.toml's [console] daemons; a project's own list is not read there.

[report]

Key Default Allowed values and when to change
host_label Default: Absent (hostname) Non-empty string; set a stable writer role label for reports and board leases.
findings Default: "none" "none" or "repo"; choose repo to render and commit a bounded FINDINGS.md window.
[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"
# Whether the loop renders FINDINGS.md into the project: `none` renders and
# commits nothing, `repo` renders and commits the bounded window at every
# close-out. Optional; the default is `none`.
findings = "none"

The store is the run record, read through the console or --report; FINDINGS.md is a second copy of it that can drift, so by default (findings = "none") no close-out writes or commits the file, the merge path makes no findings commit, and the checkout of a project that merges by pull request gains no untracked file. findings = "repo" is for a project that wants the evidence beside its code: the bounded window is rendered and committed at every close-out, one commit per merge. Any other value fails startup naming [report] findings. Switching a project from repo to none leaves the FINDINGS.md already in its repository exactly as it is, not deleted; the operator removes it by hand. --report prints the mode in effect below the table.

host_label also names this writer's board lease: the claim labels the Linear issue holo: plus the label (holo:writer-1 above) for as long as the run holds the ticket, and another writer skips a ready issue carrying a holo: label that is not its own (the loop, step 1). Two writer hosts sharing one board therefore need two distinct labels; a host with no host_label leases under its hostname.

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.

[questions]

Typed triage of bare PR mentions, answered by TypeSafe's Jev or by the Claude or Codex CLI.

Key Default Meaning
url Default: https://api.typesafe.ai/v1/systemone Typed-question HTTP(S) endpoint.
key_env Default: TYPESAFE_API_KEY Environment variable holding the bearer key.
min_confidence Default: 0.6 Confidence floor, a number from 0 to 1.
backend Default: jev jev (the HTTP service), claude (claude -p) or codex (codex exec).
model Default: haiku for claude, gpt-6-luna for codex Model of a CLI backend; refused beside jev.
effort Default: high for claude, low for codex Effort of a CLI backend: low, medium, high, xhigh, or for claude also max; refused beside jev.
backend_fallback Default: none Backend used when a claude or codex backend fails its probe; any of the three. Refused beside jev.
fallback_model Default: the fallback backend's own model default Model of a CLI fallback.
fallback_effort Default: the fallback backend's own effort default Effort of a CLI fallback.
failures Default: Absent (failed runs are not classified) Table described under questions.failures below; set to classify each failed run.
complexity Default: Absent (claimed tickets are not classified) Table described under questions.complexity below; set to record each claimed run's complexity.
[questions]
# jev (the HTTP service), claude or codex. Optional; the default is jev.
backend = "claude"
# Model and effort of a CLI backend; refused beside jev.
model = "haiku"
effort = "high"
# Probed only when the backend fails its own probe. Optional.
backend_fallback = "codex"
fallback_model = "gpt-6-luna"
fallback_effort = "low"
# Answers below this confidence take the read-only answer path.
min_confidence = 0.6

The key is read from that environment variable for each request. Missing keys, service failures, unclear answers and answers below the floor use the read-only answer path. Only a confident fix requests implementation; explicit ask: and fix: markers bypass triage.

A CLI backend runs in an empty temporary directory with stdin closed: claude with no tools, MCP servers or settings, codex in a read-only sandbox with its shell tool disabled. It sends the same instructions and options, then the redacted thread as a JSON record framed as data, not instructions, and reads one choice and a confidence from the CLI's structured output. The record is capped at 96 KiB, the oldest earlier comments dropped first, so the prompt fits one command-line argument. That confidence is the model's own rating from 0 to 1 that its choice is right, not a calibrated probability like Jev's; min_confidence gates it the same way.

The first CLI question in a process probes the backend with a fixed question. When the probe fails, backend_fallback is probed; if it passes, the switch is printed and recorded as a route_fallback intervention for seat questions. When no route passes, every question in that process fails safe as route_down without launching anything. A CLI that fails after its probe passed fails that one question; it does not switch.

Each question asked during a run records a detail question event with the backend, model, effort, outcome, latency, tokens and, where the backend reports it (claude), the cost. --report totals them on a questions: line.

Run python3 scripts/eval_triage.py --config PATH --min-accuracy 0.8 to replay the labelled fixture against the configured backend. It prints counts, accuracy and misses, and exits nonzero below the floor. Unit tests replace the service and the CLIs. Run HOLOPHYTE_LIVE_QUESTIONS=claude (or codex) with tests/test_questions_live.py on a host where that CLI is signed in before a project opts in.

[questions.failures]

The cause question a failed loop run gets right after close-out: infra (a tool, network, vendor, CI-host or factory-configuration problem outside the change), code (the change is wrong) or spec (the ticket is wrong). With the table absent, nothing is asked. A run that parks or that the supervisor sweeps is never asked.

Key Default Meaning
backend Default: claude jev, claude or codex, as in [questions]; not inherited from it.
model Default: sonnet for claude, gpt-6-luna for codex Model of a CLI backend; refused beside jev.
effort Default: medium for claude, low for codex Effort of a CLI backend; refused beside jev.
backend_fallback Default: none Backend used when the backend fails its probe, as in [questions].
fallback_model Default: the fallback backend's own model default Model of a CLI fallback.
fallback_effort Default: the fallback backend's own effort default Effort of a CLI fallback.
requeue Default: false Requeue a confident infra failure once, on its preserved branch.
requeue_confidence Default: 0.85 Confidence floor for that requeue, a number from 0 to 1.
[questions.failures]
backend = "claude"
model = "sonnet"
effort = "medium"
# Off by default: the answer is recorded and shown, never acted on.
requeue = true
requeue_confidence = 0.85

A jev backend reads url and key_env from [questions]. The question sees the ticket's title, the run's failure reason (its first 2000 characters), failure kind, outcome class and attempt, and the run's last review verdict and findings (their first 700 characters), each redacted. Its usage lands in the run's question event, and the decision in one detail failure_triage event whose payload holds choice, confidence, backend, model, requeued and why.

The factory requeues only when requeue is true, the answer is infra at or above requeue_confidence, no earlier run of the ticket was requeued this way, and close-out left the ticket in_flight with no live run. The requeue is the operator's --requeue with a factory intervention whose note starts failure triage:; the next claim reuses the leftover worktree, so the branch survives. A factory requeue is not a human intervention, so a second failure escalates to blocked_on_operator under the usual strike rule. Every other answer, and a question that fails, leaves the run stranded as before, and /attention shows the classification on its failed item.

[questions.complexity]

The complexity question a claimed run gets before its implementer profile is resolved: small, medium or large, with a confidence and a one-line reason. With the table absent, nothing is asked. By default the verdict is only recorded; with route = true a confident verdict picks a profile.

Key Default Meaning
backend Default: claude jev, claude or codex, as in [questions]; not inherited from it.
model Default: haiku for claude, gpt-6-luna for codex Model of a CLI backend; refused beside jev.
effort Default: high for claude, low for codex Effort of a CLI backend; refused beside jev.
backend_fallback Default: none Backend used when the backend fails its probe, as in [questions].
fallback_model Default: the fallback backend's own model default Model of a CLI fallback.
fallback_effort Default: the fallback backend's own effort default Effort of a CLI fallback.
route Default: false Let a confident verdict pick the implementer profile mapped to it in profiles.
route_confidence Default: 0.8 Confidence floor for that pick, a number from 0 to 1.
profiles Default: empty Table mapping any of small, medium and large to a profile name [agents.implementer_profiles] defines.
[questions.complexity]
backend = "claude"
model = "haiku"
effort = "high"
# route defaults to false: the verdict is then only recorded.
route = true
route_confidence = 0.8
profiles = { small = "sonnet" }

Startup refuses route = true without profiles, and a profiles entry naming a profile [agents.implementer_profiles] does not define, naming [questions.complexity]. A jev backend reads url and key_env from [questions] and returns no reason. The question sees the ticket's redacted title and body (its first 24000 characters, fewer when escaping would push the record past 96 KiB), its estimate and its number of acceptance criteria. Each run asks once; a continuation of a paused run that already has a verdict does not ask again. The run records one detail complexity event whose payload holds verdict (null when the question failed), confidence, reason (its first line, at most 200 characters), backend, model and why (the failure, else null). A failed question leaves the run on its usual route.

With route = true, a run with no escalation and no Implementer: line runs on the profile mapped to a verdict at or above route_confidence. The profile is probed as a ticket's would be, and its implementer_profile event has source classifier; a failed probe runs on [agents.implementer].

[story]

A story's witness runs and how many of its children run at once.

Key Default Meaning
witness_sec Default: 600 seconds Positive integer; the most one witness run at a commit of main may take, all of the story's witness commands together. A witness still waiting when it is spent is recorded error.
max_parallel Default: 2 Positive integer; the most children of the open story holding a live run at once. The claim refuses another child, naming the cap, until one ends.
dependency_ready Default: merged When a child's approved dependencies count as ready. merged, the only value, waits until each one has merged.
witness_nightly_hour Default: 3 Hour from 0 to 23, host local time. A loop pass runs an open story's whole witness set when its last real run is older than the most recent such hour, even when no child merged and none of its ## Witness paths changed since; otherwise it carries the last verdicts.
[story]
witness_sec = 600
max_parallel = 2
dependency_ready = "merged"
witness_nightly_hour = 3

A witness_sec or max_parallel that is not a positive integer, a dependency_ready other than merged, or a witness_nightly_hour that is not a whole hour from 0 to 23, is a startup error naming the key.

[review]

Each review round records a blast-radius tier, high, medium or low, in a blast_radius detail event, and --report counts each run's newest one. The tier is set from the round's diff (git diff --name-only --no-renames BASE..SHA, so a rename counts both paths): a changed path matching the built-in high list, BASE_HIGH_PATHS in holophyte/review/blast_radius.py (CI workflows, deploy and security config, lockfiles and dependency manifests, migration directories), or high_paths makes it high; otherwise one of the medium signals below makes it medium. A pattern is matched with fnmatch.fnmatchcase() against the repository-relative path, so * also crosses /, and a pattern with no / is matched against the basename too. A ticket's **Blast radius:** high or medium, the implementer's BLAST RADIUS: line and the run's previous round can raise the tier and never lower it.

Key Default Meaning
high_paths Default: [] List of repository-relative globs without ..; a changed path matching one makes the round high. It adds to the built-in high list and never removes from it.
medium_paths Default: [] List of repository-relative globs without ..; a changed path matching one makes a round that is not high medium.
fan_in Default: 10 Integer of at least 0; a changed Python module imported by at least this many other tracked .py files at the candidate makes the round medium. A module inside a package is also named from its top-most package, so src/pkg/core.py counts importers of pkg.core. 0 turns the signal off.
packages Default: 3 Integer of at least 0; changed paths spanning at least this many first path segments make the round medium. Paths under tests/, test/ or docs/, and paths ending .md, do not count; a root-level file counts as .. 0 turns the signal off.
adversary Default: false true or false; true runs the adversarial pass described in Reviewing beside the primary review on medium and high rounds, on the [agents] adversary seat. While it is false no adversary turn runs.
[review]
high_paths = ["app/auth/*"]
medium_paths = ["src/parse/*"]
fan_in = 10
packages = 3
adversary = true

A high_paths or medium_paths that is not a list of such globs, a fan_in or packages that is not an integer of at least 0 (a boolean included), an adversary that is not a boolean, or an unknown key is a startup error naming the key.

[trim]

The trim step: one agent turn after the implement turn and before the first review round that makes the run's diff smaller. It runs the vendored trim procedure (holophyte/loop/trim_brief.py) on the [agents] trimmer seat, or the active implementer route when none is set, and the factory keeps only commits whose subjects are trim: delete, trim: merge, trim: flatten, trim: comments or trim: tests, and only while verify stays green.

Key Default Meaning
enabled Default: true Boolean; false turns the step off for the project, with no turn and no trim event.
budget_min Default: 15 minutes Integer of at least 1; the trim turn's budget, scaled by budget_scale like every turn. A run cap that would refuse a turn this long skips the step.
[trim]
enabled = true
budget_min = 15

An enabled that is not a boolean, a budget_min that is not an integer of at least 1, or any other key is a startup error naming the key.