Configuration reference

Every key the engine reads from .ai-conductor/config.yml, with its type, default, allowed values, the code that consumes it, and what a bad value does. Sections follow the loader’s own allow-list order (src/conductor/src/engine/config.ts:213-269), not alphabetical order.

File locations

Role Path Constant
Project config <project>/.ai-conductor/config.yml PROJECT_CONFIG_DIR / PROJECT_CONFIG_FILE, src/conductor/src/engine/config.ts:94-95
User config ~/.ai-conductor/config.yml src/conductor/src/engine/user-config.ts:13-19
Project rate card <project>/.ai-conductor/rate-card.json RATE_CARD_RELATIVE_PATH, src/conductor/src/execution/rate-card.ts
Global rate card (fallback) ~/.ai-conductor/rate-card.json globalRateCardPath, src/conductor/src/execution/rate-card.ts
Legacy project dir <project>/.harness/config.yml LEGACY_PROJECT_CONFIG_DIR, config.ts:96
Legacy user JSON seed ~/.claude/ai-conductor.config.json (flat camelCase) One-time migration input; after a successful seed it is renamed to ai-conductor.config.json.migrated

Both files use the same schema. Keep per-user state (conductor:, markdown_viewer:, and github_bot:) in the user file. Keep self-host settings such as harness_self_host, owner_gate_cutover, and auto_restart_on_stale_engine in the harness checkout’s project config, not in unrelated projects.

migrateLegacyProjectConfig() renames .harness/config.yml to .ai-conductor/config.yml on every loadConfig() call; it is idempotent, no-ops when the new path already exists, and returns false silently on any failure without touching either file (config.ts:112-123, called at :132).

ai-conductor create <name> writes a new repository’s project config from templates/project-config.yml.template. For an existing Git repository, run ai-conductor config init; it writes the same template when the file is absent, reports success without changing bytes when the file already exists, and refuses a non-Git directory. The missing-file error names this command as its remedy. Its --test-suite-command <command> option records a non-empty, single-line aggregate command in the generated test_suite block without executing it. bin/install and bin/migrate continue to write only the user file.

Rate card (.ai-conductor/rate-card.json)

A committed, project-scoped JSON file of per-model token prices. It is durable state read by name, not configuration: it has no keys in config.yml, no user-scoped counterpart, and no precedence rules. Maintain it with ai-conductor rate-card refresh (see the CLI reference) and commit the result. .github/workflows/rate-card-refresh.yml also runs that refresh daily and opens a bot pull request on automation/rate-card when the published rates change, so the card does not rot between manual refreshes. Review such a PR as a cost change: merging it alters every subsequent costUsd computed for a codex dispatch. Already-recorded costs are unaffected — a dispatch is priced at the rate in force when it ran and is never repriced retroactively, which is why the card is committed rather than fetched live.

The card’s as_of therefore tracks when the rates last changed, not when they were last checked: a refresh that finds identical rates leaves the committed file untouched.

The global card at ~/.ai-conductor/rate-card.json takes precedence, so every project prices codex from one current source — not just projects that ran their own refresh. bin/install and bin/update maintain it as a symlink to the harness checkout’s committed card (sync_global_rate_card, bin/lib/harness-common.sh) — the same idiom as skill installs — so a merged rate-card bot PR updates every project at once with no re-install. A regular file already at that path is treated as operator-owned and left alone. A committed project card applies only where no global card exists (e.g. an environment that never ran bin/install).

It exists because providers disagree about reporting cost. Claude Code returns total_cost_usd on every dispatch, so its TokenUsage.costUsd is provider truth. Codex returns token counts and no money — so without a rate card every codex dispatch classifies as cost-unmetered, contributes $0, and a mixed-provider feature reports all-provider token volume beside Claude-only dollars.

Pi reports usage per message. The adapter sums the usage of every assistant and tool-result message_end event in a successful run (exit 0) and prices the total in this order:

  1. Every token-bearing message carries a positive usage.cost.total: costUsd is their sum, costSource: provider.
  2. Otherwise, every token-bearing message is an assistant message naming its provider and model, and the card has an entry for the message’s model id (responseModel, else model): costUsd is the per-message card price summed, costSource: rate-card.
  3. Otherwise costUsd stays undefined and the dispatch is cost-unmetered.

A Pi run that reports all-zero token counts records no usage. TokenUsage.attributedModel records the last assistant message’s canonical provider/model.

{
  "as_of": "2026-08-24T23:13:15.091Z",
  "source": "https://raw.githubusercontent.com/BerriAI/litellm/main/model_prices_and_context_window.json",
  "models": {
    "gpt-5.6-terra": {
      "input_cost_per_token": 0.000002,
      "output_cost_per_token": 0.000012,
      "cache_read_input_token_cost": 2e-7
    }
  }
}
Field Meaning
as_of ISO-8601 instant the card was last pruned from upstream. Rewritten by every successful refresh
source Upstream catalog the rates were pruned from
models.<id> Per-token prices, LiteLLM field names verbatim, so an entry is a literal subset of the upstream record

Rules that govern how the card is used:

  • Priced at dispatch time. The adapter computes costUsd from the card as the dispatch completes and sets it on the existing TokenUsage, which rides the existing provider_attempt event. Every downstream consumer — the cost rollup, the finish usage line, the shipped record, OTel — works unchanged. History is never re-priced: the rate in force when a dispatch ran is what its event carries, so a later refresh cannot drift an old feature’s reported cost.
  • The formula. `input × input_cost_per_token + cacheRead × (cache_read_input_token_cost ?? input_cost_per_token)
    • cacheCreation × input_cost_per_token + output × output_cost_per_token. TokenUsage.input is fresh-only by contract (the codex adapter subtracts the cached share at parse time), so cached volume is never charged twice. reasoningOutput is **not** priced separately — providers already include it in output`.
  • Fail closed. A missing card, an unparseable card, a dispatch that pinned no model, or a model with no card entry leaves costUsd undefined and the dispatch cost-unmetered. Nothing invents a price.
  • Provenance is recorded. TokenUsage.costSource is provider for a provider-reported figure and rate-card for a harness estimate. A provider-reported cost is never overwritten.
  • Cost-unmetered dispatches are visible. The finish usage line names them explicitly (N cost-unmetered (tokens counted, cost not)), so a partial cost can never be read as a total.
  • The card is read once per process. Rates change rarely (a merged bot PR); a daemon or CLI restart picks up the new card.

Load order and precedence

loadMergedConfig() (config.ts:1707-1734) runs four steps in this order:

  1. Read and YAML-parse the project file, then validate its explicit values with absent defaults deferred (loadProjectConfig(..., false)). Project-source errors and normalizations still occur before merge.
  2. readUserConfig() — read ~/.ai-conductor/config.yml. No schema validation runs on the user file at this stage (user-config.ts:31-70).
  3. mergeConfigs(user, project) — deep merge with the explicit project values as the winner (config.ts:1682-1703).
  4. validateConfig(merged, root, { source: 'merged' }).

Merge semantics (deepMerge, config.ts:1687-1703): plain objects merge key by key, recursively. Scalars and arrays from the project config replace the user value outright — arrays never concatenate.

Validation normalizes a deep clone, never the caller-owned value. During step 1, defaults for absent project values are not materialized, so a user-level value survives whenever the project omits that key. Explicit project values remain authoritative, including their existing rejection, fallback, clamping, and warning behavior. Step 4 materializes runtime defaults once for values absent from both scopes. This applies uniformly to every defaulted key, including attribution_audit_sample_pct, auto_restart_on_stale_engine, engine_refresh_min_interval_seconds, build_review, ci_watch, build_progress_halt, kickback_escalation, gate_code_validity, and retry_routing.

Known limitation. loadMergedConfig’s own docstring says “User-config parse errors become warnings, not hard failures” (config.ts:1700-1705), but the code returns a hard parse_error (config.ts:1715-1724). A malformed ~/.ai-conductor/config.yml blocks every project on the machine. Tracked in #1026.

For how a step’s model, effort, and max_retries resolve across config, provider policy, and CLI overrides, see models.

Validation behavior

Validation is fail-closed at the top level: an unrecognized key is a hard load error, not a warning.

Condition Result
File absent { type: 'missing' }; the engine continues on defaults (src/conductor/src/index.ts:714), but the test_suite gate fails with missing_config
Unparseable YAML { type: 'parse_error' }; the message carries YAML parse error at line N: when js-yaml supplies a mark (config.ts:149-162)
Empty document / null Valid — resolves to {} with no warnings (config.ts:199-201)
Root is not an object { type: 'validation_error' }, Config must be an object
Unknown top-level key Hard error Unknown top-level key: "<k>" (config.ts:270-274)
harness_version mismatch { type: 'version_mismatch' } (only when loadConfig is passed a harnessVersion)
Malformed project config at run start process.exit(1)

Only eight sites ever emit a warning instead of an error: the attribution_audit_sample_pct clamp (:708), auto_restart_on_stale_engine (:778), engine_refresh_min_interval_seconds (:807), the deprecated step_heartbeat_stall_minutes compatibility normalizer (:852-859), provider_preparation_timeout_minutes (:875-878), teardown_timeout_seconds at resolution, and the build_review and ci_watch normalizers (:52,898-927,929-961).

Key index

56 top-level keys are allow-listed (plus the retired compatibility key wiring, which emits a deprecation warning and event; see build_review). Everything else fails the load.

Key Type Default Section
harness_version string none harness_version
defaults object none defaults
phases object none phases
steps object none steps
complexity object none complexity
conductor object none conductor
markdown_viewer object none markdown_viewer and mermaid_renderer
mermaid_renderer object none markdown_viewer and mermaid_renderer
assess object none assess
acceptance_spec_globs string[] [] acceptance_spec_globs
test_suite object none test_suite
llm_provider string | string[] ['claude'] llm_provider
llm_providers object unset; required for pi llm_providers
provider_substitution string allow provider_substitution
ui_renderer string terminal ui_renderer
visualizers string[] unset visualizers
memory_provider string local memory_provider
tracker object GitHub selection tracker
otel object disabled otel
build_progress object see section build_progress
provider_stream object { min_interval_ms: 5000 } provider_stream
github_bot object none github_bot
spec_owner string none spec_owner
owner_gate_cutover ISO-8601 string null owner_gate_cutover
attribution_audit_sample_pct number 10 attribution telemetry
rebase_resolution_attempts number 3 rebase_resolution_attempts
validation_concurrency number 4 validation_concurrency
daemon_concurrency number 1 daemon_concurrency
daemon_heap_limit_mb number 4096 daemon_heap_limit_mb
daemon_heap_dump_threshold_mb number 3072 Daemon heap dump threshold
daemon_heap_dump_retention number 3 Daemon heap dump threshold
harness_self_host object see section harness_self_host
model_fallback_ladder string[] provider policy model_fallback_ladder
auto_restart_on_stale_engine boolean false auto_restart_on_stale_engine
engine_refresh_min_interval_seconds number 300 engine_refresh_min_interval_seconds
codex_doctor_timeout_seconds number 10 codex_doctor_timeout_seconds
mergeable_autoresolve object disabled mergeable_autoresolve
stacked_prs object disabled stacked_prs
conflict_check object { adr_corpus: change_set } conflict_check
build_review object { enabled: true } build_review
prd_audit object see section prd_audit
architecture_review_as_built object see section architecture_review_as_built
ci_watch object { enabled: true } ci_watch
build_progress_halt object see section build_progress_halt
retry_routing object { enabled: true } retry_routing
coverage_binding object { judge: { enabled: false, batch_size: 8 } } coverage_binding
kickback_escalation object { enabled: true } kickback_escalation
cumulative_kickback_bound object { enabled: true } cumulative_kickback_bound
gate_code_validity object { enabled: true } gate_code_validity
daemon_verbose boolean false daemon_verbose
reconcile_parked_auto_cleanup boolean true reconcile_parked_auto_cleanup
reclaim_merged_worktrees boolean true reclaim_merged_worktrees
provider_preparation_timeout_minutes number 5 provider_preparation_timeout_minutes
teardown_timeout_seconds number 120 teardown_timeout_seconds
dispatch_start_timeout_seconds number 120 dispatch_start_timeout_seconds
step_heartbeat_stall_minutes number deprecated no-op step_heartbeat_stall_minutes
stale_claim_window_hours number 24 stale_claim_window_hours

harness_version

Minimum harness version this config requires. Optional string containing a valid semver range. An empty string or invalid range fails config loading. The CLI and daemon resolve the installed harness version from the harness installation and pass it to their config loads; a mismatch returns { type: 'version_mismatch' } before startup.

satisfiesVersion delegates to semver.satisfies. Comparator sets, caret and tilde ranges, hyphen ranges, and exact versions therefore use normal semver range semantics. When an installation’s version cannot be identified, the compatibility check is skipped rather than blocking startup.

templates/ai-conductor-config.yml.template:8 ships harness_version: ">=0.99.0", satisfiable by the repo’s current pre-1.0 VERSION. (Formerly shipped an unsatisfiable ">=1.0.0"; fixed in #1010.)

defaults

Baseline knobs applied to every step that does not override them. Validated by validateEffortAndModelBag (config.ts:1534-1569); an unknown key inside the block is a hard error.

Key Type Allowed values Default Effect
defaults.model string any string — not enum-checked provider policy per step Overrides the policy model for every step
defaults.effort string low, medium, high, xhigh, max provider policy per step Sets CLAUDE_CODE_EFFORT_LEVEL for the dispatch
defaults.max_retries number any number, no range check DEFAULT_STEP_RETRIES[step] Attempt budget before a step fails
defaults.escalate boolean true, false true Whether retries climb the escalation ladder

defaults.max_retries interacts with build_progress_halt.attempt_ceiling: raising it above an explicitly set ceiling makes the config fail to load.

Tier overrides belong on a phase or step; defaults.by_tier is rejected.

phases

Per-phase knobs, keyed by phase name. Valid keys are SETUP, UNDERSTAND, DECIDE, BUILD, SHIP (uppercase, VALID_PHASES at config.ts:42). An unknown phase is a hard error Unknown phase: "<p>" (config.ts:293-295).

Each phase accepts the same five keys as defaults (config.ts:297). Unlike defaults, phases.<PHASE>.by_tier is read during resolution (resolved-config.ts:237, 349).

phases:
  UNDERSTAND:
    effort: low
  DECIDE:
    by_tier:
      L:
        effort: xhigh

Note the vocabulary split: config phases: keys are uppercase, while a SKILL.md frontmatter phase: field is lowercase. See skills.

steps

Per-step overrides, keyed by step name. A key matching a built-in step name overrides that step; any other key declares a custom step. steps must be an object, and each value must be an object (config.ts:303-330).

“Built-in” here means a member of ALL_STEPS — the 21 sequential steps. The four out-of-band steps (bootstrap, assess, remediate, attribution_verify) live in OUT_OF_BAND_STEPS (src/conductor/src/engine/steps.ts:304-345) and are not part of that set. See steps.

Known limitation. builtInNames is built from ALL_STEPS alone (config.ts:311), so a steps: entry for any of the four out-of-band steps is classified as a custom step and rejected for missing the custom-step fields. steps: { bootstrap: { model: haiku } } fails the load with Custom step "bootstrap" requires 'after: <existing-step>', and the same happens for assess, remediate, and attribution_verify. Their model, effort, and retry values can only be changed through defaults or phases. (templates/ai-conductor-config.yml.template formerly shipped exactly this steps: bootstrap: { model: haiku } example, commented out, which broke the config if uncommented; the template now illustrates the steps: block with explore, a real ALL_STEPS entry. Fixed in #1010.)

Per-step keys

16 keys are allow-listed (knownStepKeys, config.ts). An unknown key is a hard error Unknown key in steps.<name>: "<k>".

Key Type Validation Default Consumer
llm_provider string | string[] Non-empty string, or a non-empty array of unique non-empty strings inherits the first top-level entry src/conductor/src/engine/provider-selection.ts:11-20
model string Must be a string; the value is not enum-checked precedence chain resolved-config.ts:246
effort string low|medium|high|xhigh|max, else hard error (config.ts:365-367) precedence chain resolved-config.ts:257
max_retries number Must be a number (config.ts:372-374) DEFAULT_STEP_RETRIES[step] resolved-config.ts:356
disable boolean Must be a boolean (config.ts:375-377); see Disabling a step false resolved-config.ts:386
escalate boolean Must be a boolean (config.ts:378-380) true resolved-config.ts:371
skill string Must be a string path (config.ts:384-386); for custom steps the file must exist on disk (config.ts:516-525) the built-in skill resolved-config.ts:381, src/conductor/src/engine/steps.ts:603
hooks object Object with optional string before / after paths (config.ts:398-408) none resolved-config.ts:382-385
by_tier object See by_tier none resolved-config.ts:236, 348
when string Grammar-checked at load time and limited by step enforcement; see when none src/conductor/src/engine/when-expression.ts:97-136
parallel array See parallel none config.ts:422-466
after string Custom steps only — a built-in step with after is a hard error (config.ts:529-531) required for custom steps steps.ts:561
enforcement string Custom steps only (config.ts:532-534); structural|advisory|gating advisory steps.ts:599
completion_artifact string Custom steps only (config.ts:535-537); 7 constraints below none src/conductor/src/engine/artifacts.ts:3086-3135
gate boolean Custom steps only; a built-in step is a hard error inherits the after target steps.ts:605
kickback_target boolean Custom steps only; a built-in step is a hard error false steps.ts:606

steps.<name>.hooks takes two sub-keys, before and after, each a project-relative script path.

by_tier

Tier-scoped overrides, validated by validateByTier (config.ts:1571-1620). Tier keys must be S, M, or L; anything else is a hard error. Each tier object accepts exactly three keys — model, effort, max_retries — and rejects the rest. escalate is deliberately not tier-scopable.

steps:
  plan:
    by_tier:
      L:
        effort: xhigh
        max_retries: 5

Tier overrides sit above the flat steps.<name> values in the precedence chain. See models.

when

A guard expression evaluated per run. It is accepted for advisory steps and for a built-in gating step that opts into configDisableAllowed (currently manual_test); it is rejected for every other gating step and for structural steps. A custom step is conditional only when its enforcement is advisory (including the default). When false, the step is skipped and the daemon renders and persists a when_skip event. If a state key is undefined, the rendered line identifies that key and records that it evaluated false. Syntax is validated at config-load time by validateWhenSyntax (src/conductor/src/engine/when-expression.ts:97-136), which never evaluates the expression.

Supported forms, exhaustively:

Form Example
tier in [<csv>] tier in [M, L]
tier == <literal> tier == L
phase == <literal> phase == BUILD
${<key>} == <value> ${track} == product
A && B tier == L && phase == DECIDE

There is no ||, no !=, no !, and no parentheses. An empty string fails with when expression must not be empty. Anything else fails with unsupported when expression: "<expr>" plus the list of supported forms.

when also applies to parallel groups. When it evaluates false, the step and every branch are skipped, and each branch’s synthetic state key is recorded as skipped.

parallel

Splits one step into named branches. parallel must be an array and is mutually exclusive with skill (config.ts:422-430).

Branch key Type Validation Default
name string Non-empty and unique within the group; a duplicate is a hard error (config.ts:447-451) required
skill string Required and must be a string none
model string Must be a string branch inherits the step’s resolution
effort string low|medium|high|xhigh|max branch inherits
advisory boolean Must be a boolean false

An unknown branch key is a hard error. With advisory: false a branch failure blocks the group; with advisory: true the failure is logged and the group still succeeds (src/conductor/src/types/config.ts:51-56).

Each branch writes a synthetic state key <step_name>__<branch_name> into .pipeline/conduct-state.json, valued done, skipped, or failed (src/conductor/src/types/config.ts:166-170). See artifacts.

Branch fan-out is bounded by validation_concurrency, clamped to the branch count (src/conductor/src/engine/conductor.ts:6357).

Disabling a step

disable: true is checked against the step’s enforcement level:

Step kind Disableable
Custom advisory Yes
Custom gating or structural No
Built-in advisory Yes
Built-in gating Only when the step definition sets configDisableAllowed: true
Built-in structural Never

manual_test is the only built-in step with configDisableAllowed: true (src/conductor/src/engine/steps.ts:214). Per-step enforcement values are listed in steps.

Rejections name the enforcement class: structural steps “can never be disabled”; gating steps may be disabled only when they explicitly allow configuration disabling. The corresponding when rejections use the same distinction and say whether the step may be conditional.

Custom step registry contract

Any steps.<name> key that is not a built-in step name declares a custom step (src/conductor/src/engine/steps.ts:538, 549). buildStepRegistry splices it into the sequence at indexOf(after) + 1 using an iterative fixed-point loop, so chains of custom steps resolve (steps.ts:578-620). Siblings sharing an after target keep config-file order.

Six fields are custom-step-only:

Field Required Effect
after Yes Insertion point. Must resolve to a built-in step name or a sibling custom step declared in the same file; self-reference does not count. Otherwise: Custom step "<n>" references unknown after target: "<t>" (config.ts:496-510)
skill Yes, unless parallel is a non-empty group Path to the SKILL.md to dispatch. Without a non-empty parallel group, missing it returns Custom step "<n>" requires 'skill: <path-to-SKILL.md>'. The file must exist relative to the project root, else Custom step "<n>" skill file not found: <path> (config.ts:511-525)
enforcement No structural, advisory, or gating. Defaults to advisory (steps.ts:563)
completion_artifact No Path the step must write to be considered done; see below
gate No Boolean. Overrides loop-gate membership; when omitted, inherits the after target’s membership
kickback_target No Boolean. Makes this step reopenable by a downstream kickback; defaults to false

disable, when, parallel, model, effort, max_retries, escalate, hooks, by_tier, and llm_provider use the same semantics as for built-in steps. A custom step’s own enforcement controls whether it may be disabled or conditional: advisory is allowed; gating and structural are rejected.

The YAML key is the step’s lifecycle identity. A serial step’s skill path, or each parallel branch’s required skill path, supplies its invocation identity. At dispatch, the engine reads the configured SKILL.md frontmatter name and invokes that name using the selected host’s native syntax: Claude Code uses /name; Codex uses $name. The two names may differ, which lets a configuration key describe its place in a workflow without requiring a same-named skill. If a configured file or its name field is unavailable when dispatch begins, that dispatch fails with a diagnostic instead of falling back to the YAML key.

The derived StepDefinition (steps.ts:595-609) sets label = name, inherits phase from the after target, sets prerequisites = [after], skippableForTiers = [], isCheckpoint = false, and takes loopGate from the target step. A custom step inserted after a loop-gate step therefore joins the gate-driven tail loop.

buildStepRegistry also records each definition it inserts so the step resolves by name alone. Several points on the dispatch path — the phase lookup, the gating check, skill resolution, the audit trail — resolve a step by name with no registry in scope, and getStepDefinition consults the built-in table first, then out-of-band steps, then recorded customs. A custom step can therefore never shadow a step the engine defines itself, and a name no assembled config declared still throws Unknown step: <name>, which the daemon turns into a .pipeline/HALT. A custom whose after target never resolved is not recorded either, so a broken chain stays unresolvable rather than becoming silently dispatchable.

Custom steps hold no slot in the static step index. Every ordering decision — remediation routing, the earliest-target search, the dispatch loop — is relative to the resolved registry, so a custom step’s position derives entirely from its after target. See steps for the built-in order.

completion_artifact carries seven constraints, each a hard error (config.ts:471-494):

  1. Non-empty string.
  2. Not absolute — <field> must be repository-relative.
  3. Starts with .pipeline/ — <field> must be under .pipeline/.
  4. No .. path segment — <field> must not contain traversal segments.
  5. No glob characters * ? [ ] { } — <field> must be an exact file path without glob syntax.
  6. Does not end with / — <field> must name a file under .pipeline/.
  7. Equals its own path.normalize() form — <field> must be normalized.

At completion time the artifact is checked in this order: custom predicate, configured completion_artifact, glob fallback (artifacts.ts:3086-3135). The artifact must be a regular file and its mtimeMs must be at or above the attempt or session freshness floor; a stale file reports … is stale — <step> must rewrite it during this attempt, and a missing floor reports that completion cannot be verified without an attempt or session freshness floor.

completion_artifact is completion evidence, not an artifact-review declaration. In default and interactive runs, the conductor opens an artifact-review prompt only for a step with built-in artifact contracts or configured extra artifact globs. A custom step that declares only a completion marker therefore completes without an empty review prompt; its marker is still checked fail-closed.

This repo’s own custom step is documented in self-hosting.

Pull-request template regions

To reserve PR-body content for a custom SHIP step, put one named region for that step in .github/pull_request_template.md:

<!-- ai-conductor:step compliance-attest -->
Attested-By: security-bot
<!-- /ai-conductor:step -->

The opening marker’s name must be a declared custom step that runs in the SHIP phase before finish; built-in, undeclared, BUILD-phase, and post-finish owners fail config loading. An owner may have one region only. Regions cannot be nested or unclosed, and their template content cannot contain engine-owned PR-body headings or markers.

The engine seeds the retained SHIP draft from the template. Before the owning step runs, it adds a missing region with the template bytes but never overwrites a region already on the draft. When the step succeeds, its non-empty region content is captured as opaque bytes. finish restores those captured bytes before making the PR ready, so PR-body authors must leave every region and its markers unchanged. A template without these markers keeps the existing PR-body behavior.

complexity

| Key | Type | Allowed | Default | Consumer | | — | — | — | — | — |

The block remains reserved; any key inside it is rejected.

For what does resolve a tier, see where the tier comes from.

conductor

The sole update-check configuration surface. bin/install and the update flow read and write the user-level conductor: block in ~/.ai-conductor/config.yml; it is validated by validateConductorBlock (config.ts:1133-1165), and an unknown key inside the block is a hard error. A project .ai-conductor/config.yml that contains conductor is rejected; move the block to the user configuration so one repository cannot override another operator’s update settings.

Key Type Allowed Written by
conductor.update_channel string stable, tagged, or main only; anything else is a hard error (config.ts:1146-1153) bin/install and the update flow
conductor.auto_check boolean — bin/install and the update flow
conductor.current_version string — bin/install and the update flow (machine state)
conductor.last_checked_at string ISO-8601 UTC bin/install and the update flow (machine state)

For an existing installation, ~/.claude/ai-conductor.config.json is a one-time seed, not a second live configuration source. Before the first access to conductor:, its recognized values are translated from camelCase and take precedence over any stale values already in the block. A successful seed renames the file to ai-conductor.config.json.migrated, making later accesses a no-op. A missing source is a no-op; malformed or non-object JSON is left in place so it can be repaired and retried. Invalid or absent individual legacy fields are skipped, while valid fields still seed the block before the source is renamed.

Because the seed is a migration convenience rather than a precondition, a seed that cannot complete does not block a read of conductor:. The failure is reported on stderr and its source file is kept for a later repair, but the read still proceeds and decides fail-closed on the schema-owned block alone. A write stays fail-closed on the seed, so an explicit value can never be replayed over by legacy JSON on a later run. Without this split, an unseedable ~/.claude file disabled the update check outright even with a perfectly readable config.yml — most visibly mid-update, where a ai-conductor build old enough to predate config set failed the seed’s write while config read still worked.

Fresh installs default to stable, whose branch advances only after release CI publishes the matching semver tag and GitHub Release. tagged retains semver tag checkout behavior, and main follows every merge. Existing configured channels and version pins are preserved by installer updates.

markdown_viewer and mermaid_renderer

Both blocks allow only preset, command, args, and mode; an unknown key is a hard error. Their required fields differ: markdown can name a preset alone, while mermaid must name a preset.

Key Type Validation
.preset string Type only. Names a catalog entry that pre-fills command, args, and mode
.command string Type only. May be "" for the html and none mermaid presets
.args string[] All entries must be strings and the array must contain the literal {file}, else hard error. {out} is also substituted for mermaid
.mode string inline, blocking, or external

Markdown viewer presets (src/conductor/src/engine/md-viewer-presets.ts:15-84):

Preset Command Mode
glow glow -p -w 80 {file} inline
bat bat --style=plain --paging=never {file} inline
mdcat mdcat {file} inline
cat cat {file} inline
code code --wait {file} blocking
typora typora --wait {file} blocking
marktext marktext {file} external
nvim nvim {file} blocking
obsidian obsidian {file} external

Mermaid renderer presets (src/conductor/src/engine/mermaid-renderer-presets.ts:22-54):

Preset Command Mode Notes
html "" external Self-contained HTML in the default browser; no native dependencies
mmdc-png mmdc -i {file} -o {out} external Needs Chromium
mmdc-svg mmdc -i {file} -o {out} external Needs Chromium
none "" external Rendering disabled

For markdown_viewer, a named non-custom preset may stand alone because the preset supplies the command, arguments, and mode. When preset is absent or is custom, command, args, and mode are all required. For mermaid_renderer, preset is always required; the other fields remain optional preset overrides.

assess

Staleness thresholds for the codebase assessment. Validated by validateAssessBlock (config.ts:1095-1118); an unknown key is a hard error.

Key Type Validation Default Consumer
assess.stale_after_days number Finite and >= 0, else hard error 90 src/conductor/src/engine/project-prelude.ts:317
assess.stale_after_commits number Finite and >= 0, else hard error 500 project-prelude.ts:318

Either threshold being exceeded marks the assessment stale, which prompts before a re-run (src/conductor/src/types/config.ts:236-241).

acceptance_spec_globs

Extra globs the acceptance_specs step counts as completion evidence. Optional string[], default []. Must be an array containing only strings (config.ts:608-615).

These globs are added to, never replace, the step’s built-in STEP_ARTIFACT_GLOBS entry, and they apply to acceptance_specs alone — src/conductor/src/engine/artifacts.ts:211 returns them only for that step name.

acceptance_spec_globs:
  - "*/spec/**"
  - "*/__tests__/**"

The leading */ is the monorepo idiom for “any immediate subdirectory.”

test_suite

The project-owned aggregate verification command run by the pre-SHIP test_suite gate. Validated by validateTestSuiteBlock (config.ts:1191-1290); an unknown key inside the block is a hard error.

Key Type Required Validation Default
test_suite.command string Yes, unless test_suite.commands or test_suite.scoped_command is configured Non-empty after trim. Mutually exclusive with test_suite.commands. —
test_suite.commands object[] Yes, unless test_suite.command or test_suite.scoped_command is configured Non-empty ordered list. Each entry requires command and may set working_directory and timeout_seconds; unknown entry keys are rejected. Entries run serially and stop at the first failure. —
test_suite.commands[].command string Yes Non-empty after trim. —
test_suite.commands[].working_directory string No Relative project-root-contained directory. Overrides test_suite.working_directory for that entry. shared setting, then project root
test_suite.commands[].timeout_seconds number No Finite and > 0. Overrides test_suite.timeout_seconds for that entry. shared setting, then 1800 s
test_suite.scoped_command string No Non-empty after trim and must contain {selectors}. ai-conductor scoped-run <selectors...> replaces that placeholder with the selected tests; it never falls back to command. (config.ts:1223-1236) none; scoped runs are unavailable
test_suite.changed_command string No Non-empty after trim and must contain {base}, which is replaced with the merge-base SHA against origin/<default>. Used only by verification.mode: changed. none
test_suite.working_directory string No Must be relative and resolve inside the project root. Absolute paths, .. escapes, and symlinks whose realpath escapes the root are hard errors. Applies to scalar aggregate and scoped commands, and is the shared fallback for list entries; ai-conductor scoped-run rebases project-root-relative selectors onto it. project root
test_suite.timeout_seconds number No Finite and > 0. Shared fallback for list entries. 1800 s (DEFAULT_FULL_SUITE_TIMEOUT_MS, src/conductor/src/engine/full-suite-executor.ts)
test_suite.inputs string[] No Array of strings (config.ts:1276-1287) none
test_suite.environment string[] No Array of strings none
test_suite.verification.mode aggregate | scoped | changed No scoped requires test_suite.scoped_command; changed requires test_suite.changed_command; unknown modes are rejected aggregate
test_suite.verification.full_suite before_publish | once | skip No Only valid with verification.mode: changed (rejected with any other mode); unknown values are rejected before_publish (in changed mode)
test_suite.verification.drift_budget category → none | positive integer | unlimited No Only budgetable fingerprint categories are accepted; dependency, migration, environment, and project-config drift always re-runs all categories none

environment holds environment variable names, not values. Each value is HMAC’d into the full-suite fingerprint (src/conductor/src/engine/full-suite-fingerprint.ts:209-228) so that changing it invalidates cached verification with reason environment_changed, and each is redacted from verifier output. See environment.

The block must configure at least one of command, commands, or scoped_command. Aggregate verification requires either command or commands; a scalar command and command list cannot be combined. A list keeps its declared order, runs every entry only when all earlier entries succeed, and records the failed entry and unexecuted remainder when it stops. Omitting the block entirely is a gating failure at SHIP: the verifier returns { status: 'FAILED', reason: 'missing_config' } (src/conductor/src/engine/full-suite-verifier.ts:717-724) and the run HALTs. The gate itself is described in gates.

verification.mode: scoped derives selectors from the feature’s changed test paths. A scoped PASS includes its command template and resolved selector set in the fingerprint, so either change makes the evidence stale. An empty scoped selection deliberately runs the aggregate command and records that basis in evidence and the existing verification event. drift_budget is cumulative from the attested PASS: a declared within-budget change preserves that PASS; every other mismatch re-runs the aggregate or scoped command as configured.

verification.mode: changed runs changed_command on each BUILD-lap test_suite dispatch and records executionBasis: changed. That PASS satisfies the BUILD gate but not publication: the FINISH validation fence (and the SHIP-phase completion re-check) require an aggregate-basis PASS, so a changed-basis PASS reads STALE (aggregate_required) there and the fence routes back to test_suite, which runs the aggregate command/commands exactly once. A lap fails closed to the aggregate command when the merge-base or changed set cannot be computed, when nothing changed, or when any changed path is not a plain source or test file (dependencies, project config, environment, migrations, declared inputs, test setup/helpers/fixtures, any *.config.* for a test runner, or anything under scripts/).

verification.full_suite controls that publication requirement in changed mode:

  • before_publish (default): as above — publication needs a current aggregate-basis PASS, re-run whenever later changes make it stale.
  • once: the aggregate must pass once per feature. The first aggregate-basis PASS is stamped into the test-suite evidence (aggregatePassedAt) and carried across every later evidence write, so restarts and re-kicks honour it; after that a changed-basis PASS satisfies FINISH/SHIP and the aggregate is not re-dispatched, even when later laps change code.
  • skip: the aggregate never runs for publication; the changed-basis PASS satisfies FINISH/SHIP.

In every case each BUILD lap still runs the changed-only command (or fails closed to the aggregate).

llm_provider

Which provider host runs each step. Optional string or string[]; absent resolves to ['claude'] (src/conductor/src/engine/provider-selection.ts:5-8).

Validation (config.ts:1690-1728): a non-empty string, or a non-empty array of non-empty unique strings. Duplicates are rejected. An unregistered provider name is not caught at load — it throws at run start with a list of available providers (provider-selection.ts:52-66).

An array is a fallback ladder, not a set. The first entry is inherited by every step that does not set its own steps.<n>.llm_provider (provider-selection.ts:10-20; src/conductor/src/index.ts:1001; src/conductor/src/daemon-cli.ts:808). claude and codex use harness model policies, optionally overridden by llm_providers. pi ships no model policy, so selecting it requires llm_providers.pi. Other registered providers warn and fall back to the Claude policy.

Procedure and trade-offs are in multiprovider; the per-provider model tables are in models.

llm_providers

Provider-native model policy overrides, keyed by built-in provider id (claude, codex, pi). Optional object; unset means each provider uses its built-in policy (resolveProviderModelPolicy, src/conductor/src/engine/provider-model-policy.ts).

Key Type Effect when set
llm_providers.<id>.model non-empty string Replaces the provider policy’s default model for every step
llm_providers.<id>.model_escalation_order array of non-empty strings Replaces the retry escalation order
llm_providers.<id>.model_fallback_ladder array of non-empty strings Replaces the availability fallback ladder; takes precedence over model_fallback_ladder
llm_providers.pi.trust_project_files boolean, default false pi only. true lets unattended Pi dispatches load the project’s .pi files (the adapter drops -na). Read-only review dispatches ignore it. Any other provider id fails with llm_providers.<id>.trust_project_files is not applicable to provider <id>

Pi requires all three model keys, non-empty, whenever pi appears in any provider selection: llm_provider, steps.<step>.llm_provider, or a build_review rubric’s llm_provider. Any authored model that reaches a Pi dispatch — for example steps.build.model when build’s first provider is pi — must also be a Pi id. Pi model ids use provider/model form split at the first /: cline/google/gemma-4-31b-it:free is provider cline, model google/gemma-4-31b-it:free. The adapter passes --provider <provider> --model <model> --thinking <effort>, with effort unchanged.

llm_provider: [claude, pi]
llm_providers:
  pi:
    model: anthropic/claude-sonnet-4-5
    model_escalation_order: [anthropic/claude-sonnet-4-5, anthropic/claude-opus-4-5]
    model_fallback_ladder: [anthropic/claude-opus-4-5, openai/gpt-5]

Validation (src/conductor/src/engine/config.ts) fails configuration loading with:

Condition Error
Key is not a catalog provider llm_providers names unknown provider "<id>". Available catalog providers: claude, codex, pi
Unknown sub-key Unknown key in llm_providers.<id>: "<key>"
Empty array entry llm_providers.<id>.<key>[<index>] must be a non-empty string
Pi selected, key missing or empty llm_providers.pi.<key> is required because pi is configured
Pi id without / <config path> model "<id>" must use the provider/model form
Pi id with empty segment or whitespace ... has an empty provider segment, ... has an empty model segment, ... must not contain whitespace

At the boot of a dispatching command, when pi is installed and has configured models, the engine runs pi --list-models (5 s timeout) and rejects any configured id it does not list (src/conductor/src/engine/provider-model-probe.ts):

unknown Pi model for configured id "anthropic/claude-nope" at llm_providers.pi.model

A provider segment absent from the listing reports unknown Pi provider instead. A non-zero exit or unparseable output reports Pi model listing is unparseable: "<first line>". Procedure: multiprovider.

provider_substitution

Whether an explicitly selected step may append run-level providers as fallbacks. Optional allow or disallow; any other value is a hard validation error naming this key.

When unset or allow, a step’s steps.<name>.llm_provider is followed by the run-level llm_provider entries, with duplicates removed in first-occurrence order. When disallow, an explicit step selection is its complete candidate list. A step without its own selection continues to use the run-level list.

Set the key at the top level to establish the default for explicit step selections. Set steps.<name>.provider_substitution to override it for one step. This key does not change usage-exhaustion suppression: unavailable candidates are skipped only after the effective candidate list is resolved. See multiprovider.

ui_renderer

Plugin name for the run UI. Optional string, default terminal (src/conductor/src/index.ts:1020-1023).

Not schema-validated — the key is allow-listed only. An unknown name makes registry.get throw PluginNotFoundError (src/conductor/src/engine/plugin-registry.ts:37-46), which is the opposite of memory_provider’s soft fallback.

visualizers

Names of kind: visualizer plugins to start for this run. Optional array of strings, unset by default (src/conductor/src/types/config.ts:439).

Schema-validated: a non-array value, or an array containing a non-string, fails validateConfig with visualizers must be an array of strings / visualizers must contain only strings (src/conductor/src/engine/config.ts:788-795).

Each name is resolved against the plugin registry by selectVisualizers (src/conductor/src/index.ts:236-274). Resolution is soft, unlike ui_renderer:

Input Result
Unset or [] No visualizer is selected from this key
A registered visualizer name Its factory is invoked with the run’s start context and the result is started
A name that is not registered Warn once per name, naming the registered visualizers, and skip it
otel Warn once and skip — see below

Project-local plugins shadow global plugins of the same kind and name, so a project install of a name wins over the global one (src/conductor/src/engine/plugin-loader.ts:144-167).

otel is not selected through this key. The OTel visualizer is a built-in configured by its own otel block and is attempted independently of visualizers for interactive runs and for each daemon-dispatched feature (src/conductor/src/engine/otel/wire.ts). Listing otel here warns visualizer "otel" is configured through the "otel:" block; remove it from "visualizers". and is otherwise ignored (src/conductor/src/index.ts:243-248).

memory_provider

Plugin name for the memory store. Optional string, default local. Resolved by resolveMemoryProvider (config.ts:1818-1852), called at src/conductor/src/daemon-cli.ts:835.

Input Result
Absent, empty, or non-string local, no warning
A valid, installed provider name That provider
A valid name that is not installed local plus one warning per bad name per run (config.ts:1841-1849)

Not schema-validated.

tracker

Per-project work-tracker selection for engineer intake and its write-backs. Put this block in the project’s .ai-conductor/config.yml; intake reads that project file directly and ignores an absent block, selecting GitHub in either case. This deliberately validates only tracker, so an unrelated configuration error does not change the selected backend.

Key Type Required Allowed Default
tracker object No github or jira backend block absent selects GitHub
tracker.backend string Yes when tracker exists github, jira —
tracker.transport string No; Jira only api, mcp absent
tracker.credentials string No; Jira only any string absent
tracker.site string No; Jira only HTTPS URL absent
tracker.project_key string No; Jira only any string absent
tracker:
  backend: github

The Jira shape reserves its connection fields for a future adapter:

tracker:
  backend: jira
  transport: api
  credentials: jira-work
  site: https://example.atlassian.net
  project_key: ENG

No Jira adapter is currently installed. Selecting jira, or supplying an unreadable or invalid tracker block, excludes that project from intake instead of falling back to GitHub. The engine emits the persisted and rendered tracker_backend_unavailable event for that exclusion. GitHub remains the selection for projects with no config file, no tracker block, or an invalid unrelated configuration key.

tracker rejects unknown nested keys. The GitHub block accepts only backend; Jira-only fields on a GitHub block are errors. tracker.site must use https:.

otel

OpenTelemetry export. validateConfig only warns about unknown keys in otel and otel.spool; all value handling lives in resolveOtelConfig (src/conductor/src/engine/otel/otel-config.ts), which never throws. When enabled, it exports interactive runs and each daemon-dispatched feature independently; the trace Resource identifies the feature, project, durable dispatch run id, branch, executing engine version, and released harness version. The release is exported as the standard service.version Resource attribute, so OTel backends can group traces from different engine builds of the same harness release. For daemon dispatches, the branch is the dispatched feature worktree’s branch rather than the primary checkout’s branch. Branch, engine-version, and harness-version identity use a non-empty resolved value; an explicitly attempted but unavailable value is unresolved, while a caller that did not supply the property is not-supplied. The metric Resource uses daemon-stable identity: service.name, service.instance.id (<project>/<worker>), conductor.project, conductor.worker, host.name, and service.version (the released harness version, resolved once at meter start). It has no feature, branch, run-id, or engine-version attributes. Metric data points carry project and worker; feature-scoped instruments also carry feature. A new dispatch therefore does not create a new metric Resource series for the same daemon worker; a new release adds one target_info series per worker. Join target_info on (job, instance) to group metrics by service_version.

Event coverage is checked separately for the shared metrics listener and the trace visualizer. The sink registry’s otel flag drives the metrics listener’s complete handler table; an otelTrace: false declaration marks a metrics-only event and excludes it from the visualizer. Other OTel-enabled events require a visualizer handler at compile time. Daemon backlog, dispatch, and shipment events, memory_setup, feature_usage_total, and feature_cost_snapshot are metrics-only. provider_attempt is also excluded from tracing; its metrics projection records the authoritative invoked dispatch. unattributed_progress is excluded from both OTel consumers. Missing visualizer handlers at runtime report the event type through its warning callback. Production visualizers continue exporting traces with their per-run meter disabled.

The conductor.step.duration and conductor.pipeline.closeout.duration histograms use explicit duration buckets through 8 hours; quantiles saturate above that largest finite bucket boundary.

Each started execution that reaches a terminal event contributes one conductor.step.outcomes counter point. Its outcome is success, failure, interrupted, or refusal; interruption and refusal are terminal states, not successful work or provider failures. For a configured validation-group member, the step attribute identifies the member as configured:<url-encoded-parent-group>/<url-encoded-member> rather than collapsing it into the group policy step. Its duration ends when that member settles, so sibling and group-join delay do not inflate the member’s duration. The same member label is used for its duration, retry, dispatch, and terminal-outcome metrics.

Build-review rubric and coverage-binding provider attempts carry the execution identity of their owning lifecycle step, including provider preparation and settlement events. Their auxiliary member labels remain available for dispatch attribution; these calls update the owning step span rather than opening separate rubric spans. Context-free callers retain legacy name-based correlation.

Daemon exports include backlog count and oldest state-residence age by state, busy and free slots, in-flight features, liveness, active dispatch blockers, discovery duration, and build stalls by reason. conductor.daemon.inflight is the only conductor.daemon.* instrument with a feature attribute. The other daemon instruments—including conductor.daemon.stalls—carry only daemon-stable project and worker identity plus their instrument-specific attributes. Parked features contribute to both conductor.daemon.backlog{state=parked} and its oldest-age series.

When a run opens a conductor.run root span, its terminal export carries conductor.run.outcome: complete after feature_complete, halted after loop_halt, or terminated when the visualizer force-closes without either terminal event. Halted roots also carry conductor.run.halt.reason and, when supplied, conductor.run.halt.step and conductor.run.halt.class. An in-progress root span is not exported until it reaches one of those terminal paths.

Trace provenance ties each run to its source change. A field is exported only when its otel.provenance toggle is enabled and a value resolves; an unresolved value is omitted, never defaulted.

Toggle Exported trace attributes
commit Root span vcs.head.sha (worktree HEAD at feature_complete or loop_halt) and vcs.base.sha (latest non-null rebase base, retained in conduct-state.json as rebase_base_sha). The rebase step span also carries vcs.base.sha.
pr Root span conductor.pr.url when a PR exists, and conductor.pr.disposition: opened (PR URL known), none (finish choice keep), or unrecorded (no record, including force-closed terminated runs).
issue Trace Resource conductor.source.ref: the originating intake Source-Ref (owner/repo#N), from the daemon backlog item or, for interactive runs, the feature’s .docs/intake/<plan-stem>.md.
feature Trace Resource conductor.feature, and service.instance.id as <project>/<feature>. When disabled, conductor.feature is omitted and service.instance.id becomes <project>/<run-id>.

Provenance toggles affect traces only; metric Resources and data points are unchanged.

The conductor.run.outcomes counter increments once when an opened root run reaches one of those terminal paths. Its outcome attribute uses the same complete, halted, and terminated taxonomy, so dashboards can chart terminal runs without deriving counts from trace-query metrics.

All nine D14 instruments carry an optional raw tier label when the producing event resolved one: conductor.feature.dispatches, conductor.feature.halts, conductor.run.outcomes, conductor.feature.shipped, conductor.feature.duration.wall, conductor.feature.duration.active, conductor.feature.cost, conductor.feature.step.cost, and conductor.feature.step.tokens. Unresolved tiers are omitted rather than defaulted. Re-tiering creates a new cumulative series while the earlier tier’s last value remains available. Query cumulative feature totals by both dimensions, for example max by (feature, tier) (...); summing those cross-tier series can double-count a feature that was re-tiered.

Dispatch metrics use the same projection as the shipped-record cost rollup. Every invoked provider_attempt contributes one conductor.step.dispatches point, including failed attempts; an unavailable provider that was never invoked does not. A successful attempt suppresses its matching step_completed compatibility record, while an unmatched completion remains a legacy fallback only when it carries token usage, provider attribution, or a model. Provider-free completions do not count as dispatches. This keeps OTel dispatch counts, token totals, and costs aligned with ## Cost in the shipped record.

Step duration and retry points carry model, effort, provider, and tier when each is resolved; undefined dimensions are omitted. Every authoritative dispatch emits conductor.step.dispatches with step, metering (fully-metered, cost-unmetered, or unmetered), and the same resolved dimensions. Dispatches also carry fallback=true or false when both the preferred and invoked providers are known; duration and retry points never carry fallback. A terminal step also emits a non-persisted feature_cost_snapshot from the feature’s durable event ledger. The snapshot records the cumulative conductor.feature.cost USD gauge, plus conductor.feature.step.cost by step, model when known, and source when known (provider or rate-card), and conductor.feature.step.tokens by step, model when known, and token kind (input, output, cacheRead, or cacheCreation). A missing, malformed, or incomplete ledger emits no snapshot, so dashboards do not treat a partial total as authoritative.

feature_usage_total at feature closeout also records conductor.feature.cost; it agrees with the last successful snapshot for the same ledger. Its cost_complete attribute is false when any dispatch lacks token evidence or finite cost evidence. These are cumulative gauges, not counters: use a last-value query (or max_over_time where a range query is required), never increase() or rate(). Prometheus commonly normalizes the instruments to conductor_feature_cost_usd, conductor_feature_step_cost_usd, and conductor_feature_step_tokens. For example:

max by (feature, tier) (
  last_over_time(conductor_feature_cost_usd{project=~"$project"}[$__range])
)

To measure spend over an interval, subtract each cost series’ last value at the interval start from its last value at the interval end, then sum the differences. Do not sum repeated snapshot samples: each sample already represents the whole feature total at that moment.

Key Type Required Allowed Default
otel object No — absent means { enabled: false }
otel.exporter string Yes, when the block exists otlp, file —
otel.endpoint string Yes, when exporter: otlp any URL —
otel.file string No any path <pipelineDir>/otel.jsonl
otel.protocol string No http/protobuf, grpc omitted uses the exporter default
otel.headers mapping No; non-empty mappings only with exporter: otlp and HTTP/protobuf header name to { env: <non-empty variable name> } absent; no headers are sent
otel.project_name string No any non-blank name project root basename
otel.worker_name string No any non-blank name OS hostname
otel.attributes mapping No At most 16 namespaced keys with non-empty literal string values; keys beginning service., conductor., or host. are reserved absent; no custom attributes
otel.provenance mapping No only boolean commit, pr, issue, and feature keys every key true
otel.spool mapping No; applies only to exporter: otlp only enabled and max_bytes keys see otel spool
otel.spool.enabled boolean No true, false; true is refused with protocol: grpc true for HTTP/protobuf; false for gRPC
otel.spool.max_bytes integer No positive integer number of bytes 536870912 (512 MiB)

The failure mode is silent-disable-with-an-error-string, not a halt. An unknown exporter yields { enabled: false, error: "Unknown otel exporter '<x>'. Valid options: otlp, file." }; otlp without an endpoint yields { enabled: false, error: "otel exporter='otlp' requires an 'endpoint' URL …" }. An unsupported otel.protocol also disables telemetry and reports the rejected value plus the accepted http/protobuf and grpc values. A non-mapping otel.provenance, an unknown provenance key, or a non-boolean provenance value also disables telemetry with an error naming the problem. Omitted provenance keys default to true:

otel:
  exporter: otlp
  endpoint: https://collector.example.test
  provenance:
    pr: false
    issue: false

otel.headers supplies HTTP OTLP credentials by reference, never by value. Its only supported credential source in this slice is the process environment, using this shape:

otel:
  exporter: otlp
  endpoint: https://collector.example.test
  headers:
    authorization:
      env: OTEL_EXPORT_TOKEN

At configuration load, the resolver reads the non-empty OTEL_EXPORT_TOKEN environment variable and uses its value for the authorization HTTP header. The configuration remains a header-name and variable-name reference; it does not contain the credential. A literal string is refused rather than treated as a credential in configuration.

The same silent-disable result names these header failures: a non-mapping headers value; an empty or control-character header name; a literal credential; a reference other than exactly { env: <non-empty variable name> }; and an unset or empty referenced environment variable. Header entries are also refused for gRPC and the file exporter with an error that names the configured header and, when the reference contains a string env field, its environment-variable name; it never includes the environment variable’s value.

Other credential sources are excluded from this slice. Headers are carried only by the HTTP/protobuf OTLP exporters: this configuration does not provide gRPC credential or metadata carriage. With the spool active, the drainer re-reads each referenced variable on every delivery attempt; credentials are never written to the spool.

otel.project_name is trimmed before use. An absent, blank, or whitespace-only value falls back to the basename of the absolute project root for metric data-point identity; it does not affect service.name (ai-conductor) or the Resource conductor.project attribute. It is the project half of service.instance.id as well.

otel.worker_name is trimmed before use. An absent or blank value falls back to the OS hostname, then unknown if hostname resolution fails. It is the worker half of metric service.instance.id.

otel.attributes adds static attributes to every exported trace Resource, metric Resource, and metric data point. Each key and value is trimmed. Keys must contain a dot and must not begin with service., conductor., or host.; values must be literal, non-empty strings. Invalid entries and entries after the first 16 are dropped without disabling export, and OTel reports the dropped entries as one warning. Conductor-owned Resource and metric identity attributes always take precedence over a supplied attribute with the same name.

otel:
  exporter: otlp
  endpoint: https://collector.example.test
  attributes:
    deployment.environment.name: staging
    team.name: platform

otel spool

With exporter: otlp and HTTP/protobuf, every trace and metric batch is first written to a durable spool at <main-root>/.daemon/otel-spool/ (see artifacts), then delivered by a background drainer. Telemetry produced while the collector is down, unreachable, or rejecting credentials is delivered after it recovers, including across daemon restarts. Interactive runs and every daemon dispatch in one checkout share the spool; one process at a time holds its lease and drains it.

otel:
  exporter: otlp
  endpoint: https://collector.example.test
  spool:
    max_bytes: 1073741824   # 1 GiB; omit for 512 MiB
Collector response Batch outcome
2xx with no rejected items Deleted
2xx with partial-success rejected items, 400, 413 Dropped; one otel_spool_drop event with reason: rejected
401/403 (auth), 404 (endpoint), 408/429 (throttled), 5xx or any other status (server), connection failure (network) Kept; retried after Retry-After when supplied, otherwise with jittered exponential backoff from 1 s to 60 s
  • Cap. A write that would exceed max_bytes evicts the oldest batches first and emits otel_spool_drop with reason: evicted. A single batch larger than the cap is not spooled and is reported the same way.
  • Health. The first failure of each class per signal emits one otel renderer_error (OTLP <signal> delivery failed: <class>); recovery emits OTLP <signal> delivery recovered. While a backlog exists, otel_spool_backlog (files, bytes, oldestAgeMs, lastFailureClass) is persisted every 30 s. Both spool events are persisted to events.jsonl only; they are neither rendered nor exported to OTel.
  • gRPC. The spool supports HTTP/protobuf only. protocol: grpc without spool.enabled exports unspooled with one warning; an explicit spool.enabled: true disables telemetry with an error naming protocol: http/protobuf as the remedy.
  • Disabled. spool.enabled: false exports directly and reports a non-empty existing spool once, leaving it untouched.
  • No main checkout. When git cannot resolve the main checkout, export falls back to direct delivery with one warning.
  • SIGHUP. The daemon flushes active dispatch and daemon OTel providers into the spool and releases the lease, bounded by 5 s, then re-raises SIGHUP.

A max_bytes that is not a positive integer disables telemetry with otel.spool.max_bytes must be a positive integer number of bytes.

build_progress

Intra-step progress-event cadence during a build. Validated by validateBuildProgressBlock (config.ts:1231-1288), which rejects nonsense outright rather than coercing it. An unknown key inside the block is a hard error.

Key Type Validation Default
build_progress.poll_seconds number Finite and > 0 30
build_progress.quiet_minutes number Finite and > 0 15
build_progress.heartbeat_minutes number Finite and > 0 5
build_progress.enabled boolean Boolean true
build_progress.active_stall_minutes number Finite and > 0 45
build_progress.active_stall_action string warn or end_attempt warn

Cross-field rule (config.ts:1274-1285): poll_seconds must not exceed quiet_minutes * 60. Violating it is a hard error naming both values — otherwise a step could be declared stalled before it was polled once. poll_seconds also must not exceed active_stall_minutes * 60, checked against the resolved 45 when active_stall_minutes is omitted.

Consumed by src/conductor/src/engine/build-progress-watcher.ts:206; .enabled gates the build step’s watcher at src/conductor/src/engine/conductor.ts:3712.

When the watcher emits a quiet warning, the daemon log also shows provider activity <age> ago when the current build dispatch has a valid step-heartbeat. The age is display-only: a missing, stale, malformed, or unreadable heartbeat leaves the existing quiet warning unchanged and never affects the running provider’s lifecycle.

Active stall. A build step is active without movement when its current-dispatch step-heartbeat is younger than quiet_minutes but neither the resolved/total task counts, the current task id, nor HEAD has moved. Evidence-counter updates are not movement. Once that state lasts active_stall_minutes since the last movement, the watcher emits one build_active_stall event per movement episode, then applies active_stall_action:

  • warn — report only; the provider keeps running.
  • end_attempt — abort the current provider attempt. The ended attempt is a failed attempt that spends the ordinary retry budget and records build_stall with reason active_stall. On attempt 2 or later with no resolved-task or HEAD movement, it routes through the existing no_task_progress breaker instead.

A provider with no fresh heartbeat is quiet, not active, and is never ended by this policy.

provider_stream

Cadence for live provider_stream_progress events on the conductor event spine. The engine attaches the observer only to non-interactive dispatches that use a machine-readable envelope; an interactive operator session emits no provider-stream observations. min_interval_ms is a finite number; its default is 5000 milliseconds when the block is absent or the configured value is zero or negative. It is a hard floor for change-driven emissions, not the heartbeat cadence: unchanged observations re-emit every five minutes. Unknown keys and non-finite values fail config loading.

Key Type Validation Default
provider_stream.min_interval_ms number Finite; zero and negative values select the default 5000 ms

build_progress_halt

Whether a build that stops making progress halts or parks. Validated at config.ts:1304-1346; the resolved block is written back into the config object (config.ts:908). An unknown key inside the block is a hard error.

Key Type Validation Default
build_progress_halt.enabled boolean Boolean true
build_progress_halt.attempt_ceiling integer Finite, positive integer, and >= resolvedMaxRetries (config.ts:1335-1343) 30
build_progress_halt.dispatch_ceiling integer Finite, positive integer 20

resolvedMaxRetries is defaults.max_retries when numeric, otherwise FALLBACK_RETRIES (3) (config.ts:902-905). Setting defaults.max_retries: 40 alongside attempt_ceiling: 30 fails the load with build_progress_halt.attempt_ceiling (30) must not be below the resolved max_retries (40).

Known limitation. The floor check fires only when attempt_ceiling is explicitly set. When the build_progress_halt block is absent, or is present but omits attempt_ceiling, validateBuildProgressHaltBlock returns early (config.ts:1310) and the resolver installs the default 30 without rechecking (config.ts:1348-1365). A config with defaults.max_retries: 40 and no build_progress_halt block loads clean and runs with a ceiling below its own retry budget — the exact state the check exists to prevent. Set attempt_ceiling explicitly whenever you raise defaults.max_retries. Tracked in #1026.

Consumed at src/conductor/src/daemon-cli.ts:429, 462 and src/conductor/src/engine/conductor.ts:4298. User-level values apply when the project omits this block; see Load order and precedence.

retry_routing

Kill-switch for classifying a retry as a rerun versus a route to another step. Validated at config.ts:1380-1396; the resolved block is written back (config.ts:941).

Key Type Validation Default
retry_routing.enabled boolean Boolean, else hard error true

enabled is the only allowed key; an unknown key inside the block is a hard error. This is stricter than kickback_escalation, which silently discards its block instead.

Consumed at src/conductor/src/engine/conductor.ts:4149.

coverage_binding

Opt-in pre-BUILD judge that confirms each criterion claim is asserted by the cited task’s Done when checks.

Key Type Validation Default
coverage_binding.judge.enabled boolean Boolean, else hard error false
coverage_binding.judge.batch_size integer Positive integer, else hard error 8

Only enabled and batch_size are accepted under coverage_binding.judge. Each fresh judge session evaluates an ordered batch of at most batch_size claims; a size of 1 uses one session per claim. The engine validates one verdict per claim and checkpoints accepted batches, so a later run reuses unchanged completed judgments and evaluates only pending claims.

gate_code_validity

Kill-switch for reusing a previously passing gate verdict when its stamped code surface is unchanged, even if the verdict artifact’s modification time is stale.

Key Type Validation Default
gate_code_validity.enabled boolean Boolean, else hard error true

enabled is the only allowed key; a non-object block or an unknown inner key is a hard error. Setting enabled: false restores pure modification-time freshness and prevents reuse through code stamps.

The resolved value is consumed by the build_review, prd_audit, architecture_review_as_built, and manual_test completion predicates, by stale-review-artifact sweeping, and by step-runner gate preservation.

harness_self_host

Guardrails that apply when the build target is the harness checkout itself. Validated by validateSelfHostBlock (config.ts:972-1019). An unknown key inside the block is a hard error — deliberately, so a typo’d gate name surfaces instead of silently leaving that gate enabled.

Resolution is safe-by-default (resolveSelfHostConfig, resolved-config.ts:550-575): an absent block, or any omitted field, yields auto-detection with every gate enabled.

Key Type Allowed Default Effect
activation string auto, force_on, force_off auto auto compares the build root’s realpath against the harness root; force_on treats any repo as a self-build; force_off never self-hosts
sandbox_build_env boolean — true Runs the self-build under a throwaway CLAUDE_CONFIG_DIR
live_containment boolean — true Proves the live checkout is read-only to each self-host dispatch with bwrap. If false, skips containment and restores fail-closed live-boundary behavior.
version_approval_gate boolean — true Halts for operator VERSION-bump approval before finish
release_artifact_gate boolean — true Halts on an integrity, CHANGELOG, or migration-block failure
version_freeze string Non-empty after trim, else hard error (config.ts:1056-1064) null While it resolves to the repo VERSION, the approval gate self-satisfies. Blank or whitespace normalizes to null. Besides a pinned semver string, accepts the literal "latest" (tracks the resolved base branch’s current VERSION) or "branch:<name>" (tracks an explicit branch’s VERSION) — see self-hosting.md
auth_park_timeout_minutes number Must be a number, else hard error (config.ts:1079-1084) 60 OAuth park-and-poll timeout. 0 means an immediate credentials-specific halt
build_auth.mode string daemon-token, api-key; empty string rejected (config.ts:1117-1131) daemon-token Selects the self-build auth source
build_auth.token_path string Must be a string (config.ts:1132-1137) ~/.ai-conductor/build-auth ~ is expanded; blank or whitespace falls back to the default

A declared version_freeze never approves an actual bump: any VERSION other than the frozen value still halts.

auth_park_timeout_minutes has a second contract in the resolver: a non-integer or negative value silently falls back to 60 (resolved-config.ts:554-558) even though the validator only rejects non-numbers.

sandbox_build_env: false does not merely relax the sandbox — it makes the self-build unrunnable, with { success: false, permissionDenied: true, output: 'Required safety protection unavailable: self-host-isolation' } (src/conductor/src/engine/conductor.ts:2049-2065).

live_containment: false is a temporary compatibility opt-out, not an exclusion. The dispatch runs without the bwrap read-only live-checkout proof, so any live-checkout drift again follows the existing fail-closed boundary path and writes a HALT. See the live-boundary runbook for recovery.

Likewise, containment is not considered active when bwrap is unavailable or its probe fails. The probe proves three things: the live checkout is read-only, the dispatched worktree is writable, and the wrap still lets the provider create its own nested sandbox namespace. Only a full pass permits live-checkout drift to be attributed to a concurrent operator; every unproven case remains fail-closed.

The nesting assertion exists because providers sandbox themselves inside the wrap — codex’s apply_patch helper and Claude Code’s sandboxed bash both spawn bwrap. On a host that refuses an unprivileged user namespace to an already-namespaced process (for example Ubuntu with kernel.apparmor_restrict_unprivileged_userns=1), that nested sandbox fails and the provider cannot write a single file, so the dispatch burns its whole budget making no progress. Containment refuses itself on such hosts with containment unavailable: the wrap denies the provider its own nested sandbox namespace rather than wrapping a dispatch that cannot work.

The skill relink preflight is always on.

Operating this repo under these guardrails is covered in self-hosting.

model_fallback_ladder

Ordered list of models to try when the resolved model is unavailable. Optional string[]; must be an array of non-empty strings, and an empty array is legal (config.ts:737-746).

selectFallbackLadder (src/conductor/src/engine/model-availability.ts) picks the ladder in this order: llm_providers.<id>.model_fallback_ladder, then this key (never for pi), then the provider policy’s own ladder. Policy defaults are ['fable','opus','sonnet'] for Claude and ['gpt-5.6-sol','gpt-5.6-terra','gpt-5.6-luna'] for Codex. See models.

auto_restart_on_stale_engine

Whether an idle daemon respawns itself when dist/ points at a newer engine than the one it is running. Optional boolean, default false — written back into the config object (config.ts:783-786).

Input Result
Absent or null false, no warning
Boolean As given
Anything else false plus one warning; never throws

Armed only when the build is also classified self-host: (config?.auto_restart_on_stale_engine ?? false) && isSelfHost (src/conductor/src/daemon-cli.ts:761; also :1799). Read at daemon startup, so a change requires a daemon restart.

User-level values apply when the project omits this key; see Load order and precedence.

engine_refresh_min_interval_seconds

Minimum seconds between engine-refresh (origin fetch) attempts. Optional number, default 300 — written back (config.ts:812-815).

Input Result
Absent or null 300, no warning
Finite and > 0 As given
Non-numeric, non-finite, zero, or negative 300 plus one warning; never throws

Consumed at src/conductor/src/daemon-cli.ts:1397, 1427 as (… ?? 300) * 1000. User-level values apply when the project omits this key.

codex_doctor_timeout_seconds

Maximum time, in seconds, to wait for the Codex readiness doctor command. Optional number, default 10. The value must be positive and finite after conversion to milliseconds; invalid values, including values that overflow when multiplied by 1,000, fail configuration validation. The resolved value is passed to the Codex provider as milliseconds.

mergeable_autoresolve

Automatic conflict resolution on open PRs. Validated at config.ts:1405-1443; an unknown key inside the block is a hard error.

Key Type Validation Default
mergeable_autoresolve.enabled boolean Boolean, else hard error false
mergeable_autoresolve.cooldownMinutes number Finite and non-negative, else hard error 60
mergeable_autoresolve.suiteCommand string String, else hard error unset

Defaults are injected only when the block is present (config.ts:817-829); an absent block stays absent and each consumer applies ?? false / ?? 60 inline (src/conductor/src/daemon-cli.ts:1555, 1588, 1657-1659, 1747). Both paths reach the same values.

Disabling it never halts — the sweep simply behaves as it did before the feature existed.

Draft PRs are never dispatched for auto-resolution. A CONFLICTING draft is logged as skipping resolve for <url> (draft PR) and left alone; its mergeable label handling is unchanged, and no attempt counter is burned.

For a non-draft conflicting PR, the sweep may dispatch the rebase resolver after its deterministic resolver leaves conflicts. The supersession exception is available only when every remaining conflict path is test-only. In that narrow case the resolver may declare one or more replayed test-only commits superseded; the engine rejects an empty rationale, an unknown choice, a SHA that was not replayed, or a supersession declaration for a mixed conflict set. It then runs the configured suite and the existing preservation checks before the lease-protected push. Any resolver, guard, suite, or push failure leaves the PR unrefreshed and escalates it for remediation. Finish-time and re-kick rebases never enable this exception.

When a needs-remediation label was recorded specifically for a prior merge conflict, a later MERGEABLE sweep removes it only if the PR has no halt-body marker. The sweep makes at most three removal attempts; other needs-remediation causes remain sticky.

stacked_prs

Reserved for #2724 build-loop slice checkpoints; it has no runtime consumer yet. The commented stacked_prs entry in templates/project-config.yml.template is inert, and the plan-slices land gate and coverage_binding slice layer run regardless of this flag.

Key Type Validation Default
stacked_prs.enabled boolean Boolean, else hard error false

stacked_prs must be an object. Unknown sub-keys and non-object values are hard errors. When the block is present but enabled is omitted, it resolves to false.

conflict_check

Sets the ADR corpus used by the DECIDE conflict_check step. The block accepts only adr_corpus; any other nested key is a configuration error.

Key Type Allowed values Default
conflict_check.adr_corpus string change_set, repo_wide change_set

change_set compares stories with the approved ADRs in the current spec’s change set. It does not narrow that corpus or parse ADR supersession status. repo_wide first considers all approved ADRs, then narrows them to subjects that overlap the current stories and records both the examined and narrowed-out ADRs in the conflict report. At that scope only, an unambiguously fully superseded ADR is excluded; partial or ambiguous supersession remains in scope.

build_review

An opt-in judgement gate at the build → downstream seam. It no longer judges plan conformance, outcome delivery, or mechanism soundness — those questions now belong to prd_audit and the as-built architecture review (architecture_review_as_built). The block is normalized in place; the resolved value is written back (config.ts:1111-1177).

Key Type Default Status
build_review.enabled boolean true Works
build_review.adjudication.enabled boolean true Strict nested rollout switch for post-join remediation adjudication
build_review.scopeContainmentEnforced boolean false Works
build_review.maxParallel integer 4 Must be between 1 and 4
build_review.rubrics object testQuality, security off Closed canonical map: testQuality and security. Every other id ever accepted — scope, completeness, rootCause, causalIntegrity, tautology, wiring — is retired: it warns and is silently ignored rather than rejecting the config
build_review.custom_rubrics object disabled Opt-in installed custom review policies (maximum 32 declarations)

Normalization contract:

Input Result
Absent or null { enabled: true }, no warning
Valid enabled and/or scopeContainmentEnforced keys Preserved; omitted enabled defaults to true
Absent adjudication or adjudication.enabled { enabled: true }, no warning
Non-object adjudication, non-boolean adjudication.enabled, or an unknown adjudication key Hard validation error naming the exact build_review.adjudication.* path
Non-object { enabled: true } plus one warning
Unknown or invalid inner key That key is omitted and warned by name; valid sibling keys are preserved
perTaskFloor (any value) Retired and ignored; a config_deprecated_key event is emitted naming build_review.perTaskFloor

Malformed input fails open to enabled by design — config.ts states the rule as never silently opting a project out of the replacement authority.

build_review is a gating built-in with no configDisableAllowed (src/conductor/src/engine/steps.ts:158-161), so steps.build_review.disable: true is a hard error. The config key is the only off switch. When disabled, the step is marked skipped and a config_skip event is emitted (src/conductor/src/engine/conductor.ts:6259, 6270-6276), resolved once per pass.

Each built-in or custom rubric accepts timeout_seconds, independently of test_suite.timeout_seconds. It defaults to 300 seconds, retaining the engine’s existing five-minute review budget for projects without an override. Set it explicitly for slower model/effort policies; for example, build_review.custom_rubrics.moneySafety.timeout_seconds: 3600 allows an hour. The value must be positive and fit a Node timer (1–2147483647 milliseconds). The budget covers candidate preparation, catalog discovery, waiting for the lap, and judgment across provider fallback. Discovery is aborted on expiry; an already-running provider may finish, but its late judgment is rejected as a timeout and its invocation usage is retained. This is an acceptance deadline, not a process kill timeout.

testQuality and security accept enabled, max_projection_bytes, llm_provider, model, effort, model_fallback_ladder, max_retries, escalate, and min_confidence. max_projection_bytes is a positive integer UTF-8 byte limit for the rubric’s canonical projection; it defaults to 1048576 and the limit itself is admitted. An oversized projection is not dispatched: the gate records projection-oversized with the measured and allowed byte counts, then halts for an operator without consuming the shared mechanical-fault allowance. min_confidence is an integer from 0 through 100 and defaults to 0; scored findings below it are reported as suppressed rather than failing the gate or remaining actionable through build-review findings / build-review accept. Unscored findings are never suppressed. Both rubrics are off by default. When enabled, testQuality derives a frozen, feature-local typed scope from the graded diff, the active plan and stories, and established Covers bindings; it does not admit every declaration in a marked file. A production-only refactor, move, or rename with neither an established target nor a concrete candidate is a valid empty-scope PASS and dispatches neither the test-quality reviewer nor counterfactual execution. Enabled security review still runs and participates in the joined verdict. Missing markers, absent plan test paths, and an abstract possibility of an unknown dependency do not turn that empty scope into a coverage failure. Under build_review.rubrics: Every other id ever accepted is retired: scope, completeness, rootCause, causalIntegrity, tautology, wiring. Any unknown rubric id is rejected as a configuration error. Each retired id above is accepted as a no-op with a one-time notice naming the retired setting; it never fails configuration loading or halts a run. security judges the whole frozen feature diff for concrete, changed-hunk-anchored security defects and has no test scope or counterfactual preflight. Both behaviors follow adr-2026-08-22-build-review-opt-in-rubric-container.

build_review.custom_rubrics

Each key is a stable custom rubric id: 1-64 ASCII characters, beginning with an ASCII letter (upper- or lowercase), followed by letters, digits, _, or -. A build may declare at most 32 custom rubrics. A declaration requires skill and question; it may set source (project, global, or plugin), resources, and the normal provider/model/retry fields. Custom policies are disabled unless their declaration sets enabled: true, and they require build_review.adjudication.enabled so their findings pass through the shared decision and repair path.

The selected skill must be installed for the actual provider candidate. Omitting source is accepted only when discovery finds one unambiguous installation; otherwise select the project, global, or plugin source explicitly. resources name policy-package material captured with the selected skill; they cannot grant checkout, credential, sibling-review, or network access.

Selecting a custom rubric adopts its skill as a read-only review policy: its criteria and captured resources inform findings for the declared question, but its standalone workflow and output format do not replace the engine’s bounded review contract or aggregate verdict.

Custom findings may cite evidenceLocations as path:line or inclusive path:start-end ranges. Every citation must fit entirely inside one admitted source region. The engine checks source-region paths, line bounds, and content hashes against the frozen changed input before stamping findings, for both Claude and Codex.

Custom review runs on every platform in each provider’s own read-only review mode (Claude restricted mode and the Codex read-only sandbox), in the ordinary provider environment. The host read-only capability result is reported at daemon start, interactive config load, and daemon status under READ-ONLY REVIEW CAPABILITY. A candidate without an available read-only mode is skipped; a member with none settles read-only-review-unavailable and halts needs-human immediately without spending mechanical allowance, recoverable with record-reduced-coverage. Any reviewer-visible input change discards the whole lap as retryable review-input-mutated. Reuse keys include the selected declaration and captured policy digest, frozen input, engine version, and actual provider/model/effort, so a fallback provider never borrows a preferred-provider result. Exact operator dispositions remain decision stops and are re-read before an effect is applied.

An ambiguous changed marker association or identified changed setup/helper that affects an opted-in test is a concrete candidate, not an automatically in-scope test. The existing test-quality reviewer resolves each candidate from pinned source in its normal review call as resolved, out-of-scope, or indeterminate. Candidate file selectors may be used for conservative counterfactual execution without authorizing unchanged siblings as review targets. An indeterminate result preserves any valid findings but yields the derived scope-incomplete coverage fault; see the stalled-feature runbook for recovery and the bounded reduced-coverage path.

scopeContainmentEnforced is resolved through the same block and read by the real ai-conductor scope-check command. It defaults to false, so verified violations are reported while the commit proceeds. Set it to true to make a verified violation return exit 2; the generated commit-msg hook converts that result to Git exit 1 and refuses the commit without changing the working tree or index.

The wiring top-level config key (distinct from build_review.rubrics.wiring) is retired. It remains accepted for compatibility, is removed from the resolved configuration, and emits a warning and one config_deprecated_key event naming adr-2026-08-14-retire-build-review-wiring-rubric.

prd_audit

Bounded remediation policy for the SHIP-phase prd_audit gate, which judges the shipped implementation against the feature’s stories’ acceptance criteria (PRD functional requirements are context, not the audit key, when a PRD exists). Runs on every feature, regardless of complexity tier or work track — the step carries no tier or track skip. A feature whose stories have no acceptance criteria to grade trivially passes.

Key Type Default Effect
prd_audit.max_remediation_laps positive integer 1 Caps the number of remediation laps a prd_audit FAIL can trigger for a feature
prd_audit.max_appended_tasks positive integer 5 Fixed cap on tasks appended by a prd_audit remediation lap
prd_audit.max_appended_ratio finite number in (0, 1] 0.25 Cap on appended tasks as a fraction of the authored task count
prd_audit.halt_on_any_plan_gap boolean false When true, every PLAN_GAP finding halts for the operator, not only happy-path ones

The effective append cap for a remediation lap is min(max_appended_tasks, ceil(authored_count * max_appended_ratio)). FIXABLE findings beyond that cap, or a FIXABLE finding once max_remediation_laps is exhausted, halt for the operator listing every finding instead of appending tasks. A merged spec whose plan predates prd_audit’s remediation caps (pre-existing appended remediation tasks) counts those tasks toward the authored baseline rather than the cap, so an old feature does not retroactively exceed a cap it was never measured against.

architecture_review_as_built

Per-check, per-tier policy for the as-built architecture review, which runs on every feature and issues one of APPROVED, PLAN_GAP, or BLOCKED.

Key Type Default Effect
architecture_review_as_built.checks.<name>.tiers array of S|M|L see below Restricts the named check to the listed complexity tiers; an explicit list always overrides the artifact-presence default
architecture_review_as_built.remediation.enabled boolean true Enables daemon-mode bounded remediation for an all-REMEDIABLE BLOCKED report; false keeps that report on the needs-human halt path
architecture_review_as_built.max_remediation_laps positive integer 1 Caps as-built remediation laps for the feature; exhaustion halts before another plan append

<name> is one of reachability, planGap, adrCompliance, diagramDrift. Without an explicit tiers override:

Check Runs when
reachability Every tier
planGap Every tier
adrCompliance Approved ADRs exist under .docs/decisions/
diagramDrift Architecture diagrams exist

PLAN_GAP means the code faithfully implements the approved design and the design itself is the limit. The outcome is judged against the sealed story criteria under .docs/stories/, never against superseded .docs/intake/ capture: the gap is recorded in the verdict and the shipped record and ships when those criteria are satisfied, and halts only when a sealed story criterion is unmet.

remediation accepts only the enabled boolean. max_remediation_laps must be an integer greater than zero; unknown keys and invalid values are configuration errors.

ci_watch

Post-merge CI watch and fix loop. Normalized in place; the resolved value is written back (config.ts:929-961).

Key Type Default Status
ci_watch.enabled boolean true Works (src/conductor/src/daemon-cli.ts:1678)
ci_watch.cooldownMinutes finite non-negative number 60 Works

Normalization contract:

Input Result
Absent or null { enabled: true }, no warning
Valid enabled and/or cooldownMinutes keys Preserved; omitted enabled defaults to true
Non-object { enabled: true } plus one warning
Unknown or invalid inner key That key is omitted and warned by name; valid sibling keys are preserved

Eligibility failures return { eligible: false, reason } and skip — they never halt (src/conductor/src/engine/ci-fix.ts:230-264).

Remediation waits for terminal CI. A rollup counts as failed as soon as one check fails, even while sibling checks are still queued or running, so the eligibility gate defers any PR that still has a non-terminal check — reason checks-not-terminal (src/conductor/src/engine/ci-fix.ts#nonTerminalCheckNames). A check is terminal once it reports a conclusion (SUCCESS, FAILURE, CANCELLED, TIMED_OUT, SKIPPED, …). Legacy commit-status entries are terminal when they report a completed state such as SUCCESS, FAILURE, or ERROR; QUEUED, IN_PROGRESS, PENDING, WAITING, REQUESTED, EXPECTED, and entries with neither a conclusion nor a state are not. A deferral burns no attempt — the next sweep tick re-reads the PR and dispatches once every check has finished. When the PR state carries no check-rollup detail at all, the gate does not block.

CI repair uses the same selected check rollup that made the PR eligible. It supplies failed check names or status contexts and available detail links, then optionally adds failed-job excerpts from at most three distinct workflow runs. The complete repair context is capped at 24,576 UTF-8 bytes; omitted metadata or logs are marked. An unavailable optional log degrades the context without blocking repair, but an unreadable, malformed, or empty required check context defers repair and does not consume an attempt.

Repair execution follows the effective build provider, model, effort, and configured fallback policy; it has no separate provider setting. The repair session diagnoses and commits only. The daemon runs the configured test_suite verifier in the repair worktree, including its working directory, timeout, and evidence policy, before publishing with lease protection. Missing or invalid verification configuration blocks publication; CI repair does not use mergeable_autoresolve.suiteCommand.

The sweep reserves an attempt before dispatch. It restores that reservation only when the execution boundary proves that no repair session started; no-change, failed, ambiguous, and locally published repairs retain it. A published repair means a changed commit passed preservation checks and the configured verifier, then passed the lease-protected push. It is not a GitHub-green result: only a later observed green rollup resets the attempt and failure-detection state. Preparation and repair diagnostics are rendered in the daemon log and persisted as bounded, credential-safe ci_repair_diagnostic events in .daemon/events.jsonl.

Draft PRs are never dispatched to the CI fix loop. The sweep may still reconcile their mergeable label, but logs skipping ci-fix for <url> (draft PR) instead of collecting them as candidates — a draft PR belongs to an in-flight build, and fixing its CI would fight the running build. GitHub’s native checks remain the CI-status authority; the sweep never applies the redundant ci-failed label and removes it when observed. Attempt counters are not burned for skipped drafts.

cooldownMinutes reaches the CI-fix cooldown calculation (src/conductor/src/engine/ci-fix.ts:250); 0 is valid and disables the delay.

kickback_escalation

Escalation when a kickback to build produces no change.

Key Type Default
kickback_escalation.enabled boolean true

Contract (src/conductor/src/engine/config.ts:934-957): absent or null yields { enabled: true }; a boolean is taken as given; anything malformed — non-object, unknown inner key, or non-boolean enabled — is replaced with { enabled: true } with no warning. The resolved block is written back.

Consumed at src/conductor/src/engine/conductor.ts:3362 (?? true). When enabled, the no-op escalation guard compares the pre- and post-build tree hashes (and resolved-task counts) for the kickback; an empty commit therefore does not count as progress. Setting enabled: false disables that tree-hash witness and reverts to re-kicking until the cap. It does not disable the durable per-gate cap, which still bounds unchanged cross-dispatch loops; the planRemediation guard is also not gated by this flag (src/conductor/src/types/config.ts:302-308).

The flag applies to active build kickbacks only. A leftover configuration entry using the removed step name is rejected as an ordinary unknown custom step (it lacks the required after: field). See .pipeline/build-outcome.json.

cumulative_kickback_bound

Kill-switch for the cumulative build_review convergence bound.

Key Type Default
cumulative_kickback_bound.enabled boolean true

Contract (src/conductor/src/engine/config.ts:1056-1079), mirroring kickback_escalation: absent or null yields { enabled: true }; a boolean is taken as given; anything malformed — non-object, unknown inner key, or non-boolean enabled — is replaced with { enabled: true } with no warning. A block carrying an unknown sibling key is replaced wholesale, so a sibling enabled: false in that block is NOT honored.

The per-gate kickback counter resets whenever the tree moves, so a feature that changes the tree every lap can re-earn a full budget indefinitely. The cumulative counter is incremented outside the progress branch and is therefore tree-movement-proof: after MAX_CUMULATIVE_KICKBACKS_BUILD_REVIEW laps (kickback-ledger.ts:35), build_review terminates in an operator-visible needs-human halt naming the cumulative count instead of looping. A passing build_review does not reset the counter. A rebase credits the convergence laps back only when it actually invalidates build_review, and does so once for that invalidation; a rebase that preserves the gate leaves the accumulated count intact. See adr-2026-08-18-rebase-invalidation-refunds-build-review-convergence.md.

Consumed at src/conductor/src/engine/conductor.ts:3703 (?? true). Setting enabled: false disables only the terminal halt; the counter is still maintained and still reported on the kickback event’s cumulativeCount, so the history stays observable.

daemon_verbose

Re-surfaces gated-spec skip notices (no-PR, terminal-PR, no-Source-Ref) on the daemon log. Optional boolean; a non-boolean is a hard error (config.ts:597-599). The false default is applied at the wiring sites, not written back: config?.daemon_verbose ?? false (src/conductor/src/daemon-cli.ts:1037, 1111, 1191).

reconcile_parked_auto_cleanup

Whether the daemon’s startup and idle-tick sweep automatically removes a merged, recorded parked feature’s worktree and branch and unparks it, versus only classifying and annotating it on the dashboard. Optional boolean; a non-boolean is a hard error (config.ts:607-609). Absent config resolves to true at validation time (unlike daemon_verbose, the default is written back into obj.reconcile_parked_auto_cleanup, not just applied at the wiring site).

Set to false to require an explicit ai-conductor daemon reconcile-parked <slug> (or manual cleanup) for every parked feature, even once it is merged and recorded — see park a feature before you touch its git state.

reclaim_merged_worktrees

Whether the daemon’s startup and idle-tick sweep reclaims eligible, merged feature worktrees that are registered directly under .worktrees/. Optional boolean; a non-boolean is a hard error. Absent config resolves to true at validation time.

Set to false for a report-only pass over registered worktrees. The daemon still evaluates those candidates and records them as retained with reason disabled, but does not remove their worktree or branch. This setting does not change reconcile_parked_auto_cleanup: parked features continue to follow that setting. See parked-feature reconciliation.

provider_preparation_timeout_minutes

Active pre-spawn deadline, in minutes, for provider candidate resolution, session setup, and self-host preparation. It is independent of heartbeat telemetry. Optional finite number; absent resolves to 5. A positive value enables the deadline; 0 or a negative value disables it. A non-numeric or non-finite value is ignored with a validation warning, then resolves to the default.

When an active deadline expires, the supervisor revokes the preparing attempt before it can spawn a provider and allows one replacement for that logical step. A second preparation timeout writes a needs-human HALT. See the stalled-feature runbook for recovery.

teardown_timeout_seconds

Maximum time, in seconds, for a project’s optional bin/teardown hook before an authorized feature worktree removal. Absent values resolve to 120; a finite positive value, including a fractional number, replaces that bound. For example:

teardown_timeout_seconds: 120

This timeout is deliberately non-disableable. 0, negative, non-numeric, non-finite, and null values produce one warning at resolution and fall back to 120, so a teardown hook always has a finite bound. It does not share the auth timeout contract: harness_self_host.auth_park_timeout_minutes: 0 requests an immediate authentication halt, while teardown_timeout_seconds: 0 cannot opt out of the bound.

The daemon passes this bound to post-ship reaping, ai-conductor daemon reclaim-worktree <slug>, and parked-feature reconciliation. A missing bin/teardown is silent. A timeout, non-zero exit, or an unrunnable script is logged and contained; removal continues once its normal safety proof has authorized it. See running the daemon for the hook contract and worktree recovery for recovery.

dispatch_start_timeout_seconds

Maximum time, in seconds, for a project’s optional bin/dispatch-start hook at the start of every daemon dispatch. Absent values resolve to 120; a finite positive value, including a fractional number, replaces that bound.

dispatch_start_timeout_seconds: 120

This timeout is deliberately non-disableable. 0, negative, non-numeric, non-finite, and null values produce one warning at resolution and fall back to 120. A missing hook is silent; a timeout, non-zero exit, or unrunnable script is logged and contained, so the dispatch continues.

step_heartbeat_stall_minutes

Deprecated accepted compatibility no-op. Finite legacy values, including 0 and negative values, continue to load with a warning; non-numeric or non-finite values are ignored with a warning. The key has no default behavior and is never read as provider_preparation_timeout_minutes.

.pipeline/step-heartbeat remains activity telemetry for daemon status. Neither heartbeat silence nor staleness terminates, retries, replaces, or completes a running provider, so this key grants no termination or lifecycle authority.

github_bot

Optional machine-user credential for authorized GitHub writes. Configure it only in ~/.ai-conductor/config.yml:

github_bot:
  token_file: ~/.config/ai-conductor/github-bot-token

token_file is required when the block is present and must be a non-empty string. ~ and ~/… expand from the current user’s home directory. The file contains the token itself; it is read at write time, trimmed, and never belongs in configuration or version control.

This key may live only in ~/.ai-conductor/config.yml. A project .ai-conductor/config.yml containing github_bot, even with an empty value, is rejected so a repository cannot distribute one operator’s credential location. Unknown keys in the block are also rejected.

When configured, authorized GitHub writes and HTTPS pushes first use the bot credential. Reads and operator identity resolution continue to use the ambient gh credential. If the token cannot be read, GitHub refuses it, or a write uses an SSH remote, the authorized operation logs a github_write_credential_fallback event and retries once with the ambient operator credential. Other failures do not trigger a fallback. The event records only the operation, target, and reason; it never includes the token, token-file path, or command output.

The daemon also reads this token once while preparing a worktree to resolve the bot’s GitHub identity. When that succeeds, daemon-created commits carry a Co-authored-by: trailer for that identity; the operator remains the commit author. An unavailable token or identity read simply omits the trailer and emits a secret-safe bot_co_author_skipped event. Operator-run commits are never stamped.

spec_owner

The daemon operator identity used by the owner gate. Optional string.

Set it only in user configuration with ai-conductor config set spec_owner <identity>. The command rejects an empty identity before writing; ai-conductor config read spec_owner shows the effective value.

This key may live only in ~/.ai-conductor/config.yml. On the source: 'project' path the key being merely present — blank or not — is a hard rejection naming the file and the fix (config.ts:633-641), because a committed spec_owner would leak one operator’s identity to everyone who pulls the repo. On the source: 'merged' path only the type is checked (config.ts:642-644).

Consumed at src/conductor/src/engine/owner-gate/identity.ts:56, owner-gate/machine-identity.ts:40, and engine/engineer/authoring.ts:603. With no spec_owner, identity resolves per machine via the gh login fallback.

owner_gate_cutover

Grandfather instant for the owner gate. Optional ISO-8601 instant string.

Validation (config.ts:652-662): must be a string and Date.parse must succeed. A malformed date is rejected, never silently defaulted — an un-owned spec must not be misclassified because of a fat-fingered date. The error names the value and shows the expected form.

Absent resolves to null at the wiring site (src/conductor/src/daemon-cli.ts:1229), so un-owned specs default-build. With a cutover, an un-owned spec whose plan first reached the default branch strictly before it is labeled grandfathered; specs merged on or after it also default-build.

Attribution telemetry

attribution_audit_sample_pct controls the percentage of attribution telemetry audit events sampled. It is an optional number: validation requires a number, clamps values outside [0,100] with a warning, and defaults an absent value to 10. Attribution telemetry consumes the resolved value at src/conductor/src/engine/attribution-telemetry.ts; a user-level value applies when the project omits the key. See Load order and precedence.

The retired attribution_enforcement_cutover and attribution_judge_cutover keys are not valid configuration keys. Remove either key before updating.

rebase_resolution_attempts

Cap on assisted conflict-resolution attempts inside the rebase step. Optional number, default 3 (DEFAULT_REBASE_RESOLUTION_ATTEMPTS, resolved-config.ts:411).

Not validated in validateConfig — it is allow-listed only, and all coercion happens in resolveRebaseResolutionAttempts (resolved-config.ts:424-433):

Input Result
Absent or null 3
A finite number >= 0 Used as-is; 0 disables auto-resolution and a conflict halts immediately
Negative, non-finite, or non-number 3, silently

Consumed at src/conductor/src/engine/autoresolve.ts:214, src/conductor/src/engine/conductor.ts:6548, and src/conductor/src/daemon-cli.ts:979, 1616.

validation_concurrency

Bounds the validation-phase fan-out. Optional number, default 4 (DEFAULT_VALIDATION_CONCURRENCY, config.ts:1998).

The default is 4 rather than the built-in group’s branch count (3) so the whole SHIP-tail group — manual_test, prd_audit, architecture_review_as_built — dispatches in one wave instead of leaving its third member queued behind the first two. Because the resolved width is always clamped to the branch count, a narrower group never spawns idle slots. Lower it to serialize the fan-out (for example on a constrained machine, or to make a run easier to follow); 1 degrades the group to the serial path.

A non-number is a hard error (config.ts:748-752). Zero, negative, and NaN pass validation, but resolveValidationConcurrency (config.ts:2009-2021) silently substitutes 4.

Consumed at src/conductor/src/engine/conductor.ts:1263, then clamped to the branch count at :6357.

daemon_concurrency

Sets the daemon feature-executor pool width. It is optional and defaults to 1, which preserves the serial daemon behavior. Set it to a positive integer to allow that many eligible features to run at once:

daemon_concurrency: 3

Only integers in [1, ∞) are valid. Zero, negative, fractional, non-finite, and non-numeric values make configuration loading fail before the daemon dispatches work; they are not silently clamped.

Warning: anything above 1 is a different operating mode. Every executor shares one repository and one .git, so the daemon prints a WARNING: daemon concurrency N line at startup whenever the effective width exceeds 1. Expect more rebase kickbacks (a sibling feature landing on main invalidates downstream gates for everyone still in flight), busier worktree lifecycle operations, and provider spend that scales with the pool width. Start at 2, watch the daemon log for @rebase halts, and drop back to 1 if the kickback churn outweighs the throughput.

For a daemon invocation, an explicit --concurrency <n> flag takes precedence over this setting; otherwise this setting takes precedence over the default of 1. This pool width is independent of validation_concurrency, which bounds validation-phase branch fan-out within one feature.

The legacy compatibility dispatch used when no ProviderExecutionContext is available mutates the process-global provider environment. To avoid concurrent environment mutation, it refuses an effective daemon concurrency above 1 before build dispatch and reports LEGACY_NO_PROVIDER_EXECUTION_CONCURRENCY_REFUSAL. That compatibility-path refusal is not a clamp on the daemon’s normal worker pool.

daemon_heap_limit_mb

Sets the V8 old-space heap cap for a continuous daemon. It defaults to 4096 MB and is passed to the daemon pane as NODE_OPTIONS=--max-old-space-size=<n> before the launcher command:

daemon_heap_limit_mb: 6144

Only integers in [256, ∞) are valid. Invalid values stop ai-conductor daemon start and ai-conductor daemon restart before tmux launches or respawns a daemon.

Daemon heap dump threshold

The daemon writes one heap snapshot per lifetime when a boundary memory sample’s RSS reaches daemon_heap_dump_threshold_mb (default 3072). Snapshots are stored under .daemon/heap/, and at most daemon_heap_dump_retention (default 3) are kept; the oldest is removed first:

daemon_heap_dump_threshold_mb: 2048
daemon_heap_dump_retention: 5

Both must be integers in [1, ∞). Keep the threshold below daemon_heap_limit_mb so the snapshot lands before the heap cap stops the daemon.

stale_claim_window_hours

Controls how long a claimed engineer-intake ledger entry may remain unfinished before it is treated as stranded. It governs claim-time auto-heal and the default window for ai-conductor compose requeue --stale; --older-than overrides it for one invocation.

The default is 24 hours. Non-positive and non-numeric values fall back to that default.

Full example

harness_version: ">=0.99.0"

llm_provider: claude

defaults:
  effort: medium

phases:
  UNDERSTAND:
    effort: low

steps:
  plan:
    by_tier:
      L:
        effort: xhigh

test_suite:
  commands:
    - command: npm test
      working_directory: src/conductor
    - command: bash test/test_harness_integrity.sh
  scoped_command: npx vitest run {selectors}
  working_directory: .
  timeout_seconds: 1800
  environment:
    - CI
  verification:
    mode: aggregate
    drift_budget:
      source: 5
      tests: 10

markdown_viewer:
  preset: glow
  command: glow
  args: ["-p", "-w", "80", "{file}"]
  mode: inline

templates/project-config.yml.template is the project seed used by ai-conductor create and ai-conductor config init. templates/ai-conductor-config.yml.template remains the user-level reference. The remaining allow-listed keys are documented only here.

See also

  • models — how a step’s model and effort resolve, and the full per-step tables.
  • steps — step names, order, phase, tier-skip, and enforcement values.
  • cli — every command and flag, including --model.
  • environment — every environment variable the harness reads or writes.
  • gates — what a gate is and how fail-closed rules work.

This site uses Just the Docs, a documentation theme for Jekyll.