"Implement software from a PRD or task tree using the dark-factory orchestrator. Use this skill when the user wants to: run the orchestrator against a PRD, check orchestrator/task status, resolve blocked tasks, resume stalled work, or manage concurrent TDD workflows. Trigger on mentions of PRDs, orchestrating implementation, running tasks through the pipeline, checking task status, or resolving blocked/failed tasks — even if the user doesn't say 'orchestrator' explicitly."
Recursos
1Instal·la
npx skillscat add leodearden/dark-factory/orchestrate Instal·la des del registre de SkillsCat.
Dark Factory Orchestrator
The orchestrator implements tasks as concurrent TDD workflows — planning, coding, verifying, reviewing, and merging — driven by Claude Code agent instances with module-level locking to prevent conflicts.
Task decomposition happens here, in the interactive session, where you have full codebase context. The orchestrator handles concurrent execution. This separation exists because taskmaster's parse_prd uses a weaker model without codebase access, which produces decent but incomplete decompositions (missed integration tasks, wrong dependency graphs, no awareness of what's already implemented). Decomposing interactively produces better tasks with less cleanup.
Important: do not implement tasks directly
This skill is about driving the orchestrator — not about implementing tasks yourself in this session. When the user says "do task 7" or "run the tasks", they mean launch the orchestrator to execute them via its TDD workflow pipeline (worktrees, architect/implementer agents, verification, review, merge). Never implement task code directly in the interactive session unless the user explicitly asks you to bypass the orchestrator.
Critical: identify the target project FIRST
The orchestrator binary lives in /home/leo/src/dark-factory/orchestrator and is always invoked from there (because uv run --project orchestrator resolves the package path). But the target project — the codebase whose tasks the orchestrator operates on — is determined entirely by the config file, which sets project_root and fused_memory.project_id.
When the user invokes /orchestrate, they expect it to operate on the project they are currently in — not on dark-factory by default. The orchestrator binary now refuses to start without an explicit target (--config flag or ORCH_CONFIG_PATH env var) — there is no auto-discovery and no default. Identify the target project before any other action.
Step 1 — capture the user's cwd
Before any other action, run pwd and treat the result as the target project root for the entire session. Do not assume it is dark-factory.
pwd
# e.g. /home/leo/src/reify → TARGET_PROJECT = /home/leo/src/reifyStep 2 — find the target project's config (TARGET_CONFIG)
First, check whether ORCH_CONFIG_PATH is already set in the environment — if direnv loaded the project's .envrc, this is automatic and you're done:
echo "${ORCH_CONFIG_PATH:-unset}"
# If a path is shown: that IS your TARGET_CONFIG. Verify it matches TARGET_PROJECT.
# If "unset": continue below.If unset, the config is the target project's dark-factory-orchestrator.yaml — the
canonical, required filename for every factory-operated project (CLAUDE.md, "Repo Map";
it is what the dashboard's escalation-URL discovery keys on):
TARGET_CONFIG="$TARGET_PROJECT"/dark-factory-orchestrator.yaml
ls "$TARGET_CONFIG"As of 2026-09-29 every project under DASHBOARD_KNOWN_PROJECT_ROOTS carries that file.
The legacy spellings (orchestrator.yaml, orchestrator-config.yaml, config.yaml,orchestrator/config.yaml) are honoured only as a discovery fallback for a not-yet-migrated
project, never as a choice for a new one — and a stray legacy file can sit BESIDE the canonical
one (autopilot-video still has a config.yaml, pump-web-ui an orchestrator.yaml), so never let a
glob over the legacy names win over the canonical path when both exist.
Verify the file actually points at the target:
grep -E '^project_root|project_id' "$TARGET_CONFIG"
# Expect: project_root: "/home/leo/src/<target>"
# project_id: "<target>"If no config exists for the target, stop and ask the user — the project may need an orchestrator config created before it can be run. See references/project-setup.md for the schema.
Step 3 — use the target everywhere for the rest of the session
Once you've identified TARGET_PROJECT and TARGET_CONFIG, every command and MCP call below must use them — --config "$TARGET_CONFIG" (or ORCH_CONFIG_PATH="$TARGET_CONFIG") is required on every orchestrator invocation:
| Where | Use |
|---|---|
| Launch / status command | cd /home/leo/src/dark-factory (binary lives there) and --config "$TARGET_CONFIG" |
project_root="..." in fused-memory MCP calls (get_tasks, submit_task, resolve_ticket, set_task_status, add_dependency, update_task, etc.) |
"$TARGET_PROJECT" |
| Worktree inspection paths | "$TARGET_PROJECT"/.worktrees/<task-id> |
project_id in add_memory writes |
the project_id from TARGET_CONFIG (e.g. "reify", "dark_factory", "autopilot_video") |
| Manual git merge target | "$TARGET_PROJECT" (the target's main branch, not dark-factory's) |
There are no exceptions — even when the target is dark-factory, --config (or ORCH_CONFIG_PATH) must be set. See references/project-setup.md for .envrc/direnv ergonomics.
Determine what the user wants
The user will be in one of these situations. Read the request and jump to the matching section:
| Situation | Go to |
|---|---|
| "Implement this PRD" / hands you a .md file | Decompose PRD, then Execute Tasks |
| "Do task X" / "Run task 7" / "Execute the tasks" | Execute Tasks |
No arguments given (bare /orchestrate) |
Execute Tasks — run all pending tasks |
| "What's the status?" / "How are the tasks going?" | Check Status |
| "Task X is blocked" / "Something failed" | Resolve Blocks |
| Tasks already exist in Taskmaster, user wants to execute them | Execute Tasks (skip decomposition) |
| Docker/services aren't running, connection errors | Read references/infrastructure.md |
Decompose PRD
This is the critical step. You decompose the PRD into tasks interactively, using full codebase context, then write them to Taskmaster via submit_task + resolve_ticket. Do not use taskmaster's parse_prd — it lacks the context to do this well.
1. Read the PRD and the codebase
Read the PRD file. Then explore the codebase to understand:
- What already exists (don't create tasks for things already implemented)
- Module boundaries and directory structure (for accurate module tagging)
- Test infrastructure (what test frameworks, patterns, fixtures exist)
- Import graphs and dependencies between modules (for accurate task dependencies)
Use Read, Grep, Glob to build this understanding. Check git log for recent changes that might affect the plan.
2. Propose tasks
Present the user with a task table for review:
| # | Title | Modules | Dependencies | Description |
|---|---|---|---|---|
| 1 | ... | [dir1, dir2] | — | ... |
| 2 | ... | [dir1] | 1 | ... |
Each task should:
- Be scoped to a coherent unit of work — something one agent can plan, implement, and verify in a single TDD cycle
- Have accurate module tags — the actual directories it will modify (used for concurrency locking, so get this right)
- Have correct dependencies — based on real import relationships and build order, not guesses
- Include e2e/integration tasks — these are easy to forget but critical. If the PRD involves multiple components, add a task that wires them together and tests the full pipeline
- Skip what's already done — check the codebase. Don't create tasks for existing functionality
Keep descriptions concrete — the orchestrator's architect agent will read them to produce a TDD plan, so vague descriptions lead to vague plans that block at execution time.
3. Get user review
Present the table and dependency graph. Ask the user to review:
- Are tasks scoped correctly? (too big → split, too small → merge)
- Are dependencies right?
- Anything missing? (integration tests, config, docs)
- Any tasks that are already done?
Iterate until the user is satisfied.
4. Write tasks to Taskmaster
Use the TARGET_PROJECT you captured at the top of the session as project_root. (Fused-memory owns task state — there is no on-disk tasks.json to inspect.) Never hardcode /home/leo/src/dark-factory here unless the user is actually in dark-factory.
Write each task via fused-memory MCP tools using the two-phase pattern:
# Phase 1: submit — returns immediately with a ticket id
submit_result = submit_task(
title="<title>",
description="<detailed description>",
project_root="$TARGET_PROJECT",
priority="<medium|high>",
metadata={
"source": "prd-decomposition",
"spawn_context": "parse_prd",
# sparse is fine — the architect widens scope at plan time. File paths only (a directory is rejected); use [] to defer entirely.
"files": ["<path/to/file.py>"],
},
)
ticket = submit_result["ticket"]
# Phase 2: block until the curator decides
# "combined" is a normal outcome when PRD decomposition re-covers ground already in the task tree
resolve = resolve_ticket(ticket=ticket, project_root="$TARGET_PROJECT", timeout_seconds=<see _shared/ticket-failure-handling.md>)
if resolve["status"] == "created":
task_id = resolve["task_id"] # new task
elif resolve["status"] == "combined":
task_id = resolve["task_id"] # merged into existing task — normal, not an error
elif resolve["status"] == "refused":
# A deterministic guard rejected the candidate: no task was created and there
# is no task_id. Intended outcome — record resolve["reason"] and move on.
# Do NOT retry and do NOT record a task id.
note_refused(resolve["reason"])
elif resolve["status"] == "awaiting_curation":
# The curator could not judge it yet: the ticket is durably parked and
# retried until it gets a verdict. No task_id yet. Do NOT re-submit; see
# skills/_shared/ticket-failure-handling.md.
note_parked(ticket)
elif resolve["status"] == "failed":
# On `failed`: surface the reason to the user and skip this task (user can
# resubmit manually after investigating).
# See skills/_shared/ticket-failure-handling.md for the retryable/terminal reason
# matrix and R4 idempotency guidance. PRD-decomposition tasks don't natively set
# escalation_id/suggestion_hash; to opt into R4 de-duplication on retry, synthesize
# a stable pair per that doc's guidance.
handle_failure(resolve["reason"])Then set dependencies:
add_dependency(id="2", depends_on="1", project_root="$TARGET_PROJECT")After all tasks are written, verify with get_tasks(project_root="$TARGET_PROJECT") and show the user the final state.
5. Proceed to execution
Jump to Execute Tasks.
Execute Tasks
Pre-flight
Before launching, verify:
Target project is identified — you must already have run
pwdand locatedTARGET_CONFIGper the Critical: identify the target project FIRST section. If you haven't, do that now. If you skip it, the orchestrator will refuse to start and emit an educational error pointing here.Services are reachable — run a quick health check:
# FalkorDB speaks Redis protocol, not HTTP — use a raw PING (echo PING; sleep 0.5) | nc -w2 localhost 6379 2>&1 | grep -q PONG && echo "FalkorDB: ok" || echo "FalkorDB: DOWN" # Qdrant has an HTTP readiness endpoint curl -sf http://localhost:6333/readyz > /dev/null 2>&1 && echo "Qdrant: ok" || echo "Qdrant: DOWN"If either is down, read
references/infrastructure.mdfor startup instructions.Environment —
OPENAI_API_KEYmust be set (used for embeddings). The orchestrator's Claude agents authenticate via OAuth (Max subscription), not an API key —ANTHROPIC_API_KEYis not required.Tasks exist — verify with
get_tasks(project_root="$TARGET_PROJECT"). If the task tree is empty, go to Decompose PRD first. Confirm the returned tasks actually belong to the target project — if you see dark-factory task titles when the user is in reify, yourproject_rootis wrong; go fix step 3 above before launching.
Launch
The orchestrator binary is invoked from /home/leo/src/dark-factory (that's where uv run --project orchestrator resolves). --config (or ORCH_CONFIG_PATH) selects the target project and is required on every invocation — no exceptions, no auto-discovery, no defaults.
cd /home/leo/src/dark-factory
# Run existing tasks against the target project
uv run --project orchestrator orchestrator run --config "$TARGET_CONFIG"
# Or decompose a PRD first, then run
uv run --project orchestrator orchestrator run --config "$TARGET_CONFIG" --prd <path-to-prd>
# Equivalent form using ORCH_CONFIG_PATH (e.g. when direnv loaded the project's .envrc):
ORCH_CONFIG_PATH="$TARGET_CONFIG" uv run --project orchestrator orchestrator runIf you omit both --config and ORCH_CONFIG_PATH, the orchestrator exits 1 with an educational error message pointing at references/project-setup.md. There is no silent fallback — this is the hard guard against the cross-project execution incident that lost work.
Options:
--config <path>— required unlessORCH_CONFIG_PATHis set. Selects the target project (setsproject_rootandfused_memory.project_id). When both are set,--configwins.--prd <path>— path to PRD markdown file. If omitted, skips PRD parsing and runs existing pending tasks.--dry-run— verify task tree and module tags, but don't execute workflows. Useful for confirming you're pointed at the right project before committing to a real run.--verbose— debug-level logging.
The fused-memory HTTP server runs as a systemd service and must already be running before launching the orchestrator. Do not start, restart, or stop fused-memory without explicit user permission. Verify it's up: curl -sf http://localhost:8002/health.
The orchestrator also starts an escalation MCP server on port 8102 (configurable). Agents use this to escalate issues they can't resolve. Escalations are handled in a separate session — this session should ignore them entirely. Use the /escalation-watcher skill in a dedicated session to monitor and resolve escalations during a run.
What happens during a run
The orchestrator will:
- Start escalation MCP server (fused-memory must already be running as a systemd service)
- Check usage cap status (multi-account failover if cap hit at startup)
- Recover crashed tasks from surviving worktrees — if a prior run crashed mid-task, it resumes from the last completed plan step rather than starting over
- Call
parse_prd(no-op if tasks already exist in Taskmaster) - Tag tasks with code modules via a classifier agent (if not already tagged)
- Execute tasks concurrently (default 24, configurable via
max_concurrent_tasks), each following:
PLAN (architect) → EXECUTE (implementer, TDD) → VERIFY (pytest/ruff/pyright) → if verify fails: DEBUG (up to 5 cycles) → REVIEW (one comprehensive reviewer; a separate deep-review checkpoint runs across recently-merged modules every ~40 merges) → MERGE (to main, with post-merge verification) - If an agent encounters a problem outside its scope, it escalates rather than retrying.
Escalations are handled in a separate session — do not attempt to handle them here. - Print a summary report with per-task outcomes and costs
Each task gets its own git worktree and branch (task/<id>). Merges use --no-ff to preserve history.
That is one landing per verify, which is the stock pipeline. When merge_deep.chain_cap > 0 and the queue holds 2 or more mergeable items, a single verify instead covers a chain of queued items: the chain is built in one lane by merging them onto the head in submission order, only its tip is verified, and on a pass the whole verified prefix is CAS-landed in submission order — so one passing verify lands several tasks. The shipped default chain_cap=0 disables this entirely, leaving the pipeline exactly as described above. A tip failure lands nothing via the chain and leaves the queue untouched, and the next round halves its target depth (any pass resets it) — see OPERATIONS.md §5 "Deep merge-ahead chains" and plans/deep-merge-ahead-prd.md for the full contract.
The debugger is a distinct agent role invoked automatically on each verify failure. It receives the failure report (test output, lint errors, type errors) and makes targeted fixes. The verify→debug loop repeats up to max_verify_attempts times (default 5) before the task blocks.
After merge, post-merge verification re-runs the full verification suite on main. If it fails, the merge is automatically reverted and the task blocks — this catches integration issues that only appear after combining with other tasks' changes.
Interpreting the output
The final report shows:
Orchestrator run complete: 5/7 tasks done
Blocked: 2
Total cost: $47.32
Duration: 2025-03-16T10:00:00 → 2025-03-16T11:23:45If any tasks are blocked, jump to Resolve Blocks.
Stop Orchestrator
To stop a running orchestrator gracefully, send SIGTERM to the python3 orchestrator process — not the bash wrapper or the uv process.
Find the right PID
# List orchestrator processes with their configs
pgrep -af 'orchestrator run --config'Look for the python3 (or orchestrator) process whose --config matches your target. The output shows a chain like bash → uv → orchestrator; you want the innermost orchestrator PID.
Send SIGTERM
kill <orchestrator_pid>The orchestrator handles SIGTERM gracefully:
- The main loop is interrupted
- In-flight agent tasks are cancelled
- The
finallyblock runs: metrics are finalized, MCP server is stopped, merge worker and escalation server are shut down - Process exits
Task results are persisted incrementally as each task completes (not batched at the end), so completed work is never lost even on ungraceful termination.
Verify shutdown
# Confirm the process tree is gone
pgrep -af 'orchestrator run --config' | grep "$TARGET_CONFIG"If children (agent subprocesses) are orphaned, kill them by PID. Do not use pkill with broad patterns — it may hit other projects' orchestrators.
Reload Config (vs Restart)
Some config edits don't need a restart. mcp__escalation__reload_config hot-applies a safe, allowlisted subset of dark-factory-orchestrator.yaml changes to the already-running orchestrator process — no SIGTERM, no cold start, no in-flight agents or verify suites killed.
Why this exists
A full restart is expensive: graceful stop (SIGTERM, up to 90s before SIGKILL) cancels in-flight agent tasks and cargo verify suites, then cold start pays a warm-lane reseed, a module-tagger pass, and up to 280s waiting on fused-memory. For a pure tuning-knob change (bump a model, loosen a timeout), that cost buys nothing.
How to invoke
reload_config is operator-only — it is in no agent role's MCP allow-list (same convention as halt_scheduler/halt_merge_queue), so agents can never reload config on themselves mid-task. Call it from an interactive session or a dedicated /escalation-watcher session, against the target orchestrator's own escalation MCP server (reify 8100, dark-factory 8102):
mcp__escalation__reload_config()It takes no path argument — it always re-reads the process's own ORCH_CONFIG_PATH, the same cross-project guard that governs --config/ORCH_CONFIG_PATH at startup (a reload can never retarget a running orchestrator at a different project).
Reload vs restart — which one do I need?
| Edit | Action |
|---|---|
Per-role models / budgets / max_turns / effort / timeouts / backends |
Reload |
routing.* (allowed_models / ladder / per_model_daily_ceiling_usd / rules) |
Reload |
Steward grace (steward_completion_timeout, steward_lifetime_budget) |
Reload |
Scheduler + starvation-watchdog tuning, loop-pass thresholds (idle_poll_secs, orphan_l0_timeout_secs, watcher-rotation params) |
Reload |
review.* checkpoint knobs, unblock_auto.*, verify_env |
Reload |
git.offline_lane_* leaf tunables (test threads, poll interval, persist passes, recheck seconds, red-unlanded hours) |
Reload |
merge_deep.chain_cap — the deep merge-ahead chain cap; 0 is the shipped default and the kill switch (mechanism: OPERATIONS.md §5 "Deep merge-ahead chains") |
Reload |
max_concurrent_tasks, pool sizes / verify_runners, escalation bind host/port, sandbox.backend, project_root, merge-lane git.* structural fields (branch_prefix, main_branch, persistent_merge_worktree, …) |
Restart — these are startup-baked (semaphores, pool sizes, bound sockets, module globals); reload reports them in restart_required without touching the running process |
| Any code change (not just YAML) | Restart — reload only re-reads config, never code |
routing.* is green-tier hot-reloadable like the other per-role knobs — its
leaves are auto-covered by RELOADABLE_FIELDS throughconfig._submodel_leaf_paths (task 2535 step-16 confirmed this), matching
the existing per-role models/budgets/max_turns/effort/backends
reload semantics. An operator can install/retune a policy rule or bumpallowed_models/ceilings without a restart, then read applied /restart_required in the reload response as usual.
When unsure, reload first — it's cheap and non-destructive (fail-closed on bad YAML, never half-applies), then check the response for anything in restart_required and restart only if something you actually need is on that list.
Reading the response
{
reloaded: bool, # true iff validation passed and apply committed
config_path: str, # the ORCH_CONFIG_PATH re-read
applied: {path: {old, new}}, # allowlisted, changed, now live
restart_required: {path: {old, new}}, # changed but not allowlisted — still stale
unchanged: int, # count of equal fields (audit convenience)
error: str | null, # set iff reloaded=false; nothing was mutated
}reloaded: true ≠ "everything took effect." Always read applied and restart_required — a mixed edit can partially apply (allowlisted fields go live immediately) while non-allowlisted fields in the same edit sit untouched and stale until a restart. error is only set when the reload itself failed (bad YAML/validator failure); in that case the live config is untouched (fail-closed) and nothing was applied.
Every call — success or failure — appends a config_reload event row to runs.db and fires a WARNING journal line whenever restart_required is non-empty or reloaded=false, so an AFK operator can audit what happened after the fact.
See CLAUDE.md's "Orchestrator Config Reload" section for the quick-reference tier summary, and plans/config-hot-reload-prd.md for the full allowlist and invariants.
Model Routing
The orchestrator resolves (model, effort, budget_usd, max_turns) for every LLM invocation through a layered resolver (routing.resolve_route). See CLAUDE.md's "Model Routing" section for the routing.* config block, the closed match/set vocabulary, and the layered precedence — this section covers the operator-facing CLI and how to read what was actually decided.
Probing model availability
orchestrator probe-models exercises every configured pool account (config.usage_cap.accounts) × candidate model — default config.routing.allowed_models plus the fable candidate model (routing.FABLE_CANDIDATE_MODEL) — with a cheap 1-turn invocation, and writes a deterministic, committable YAML availability artifact:
cd /home/leo/src/dark-factory
uv run --project orchestrator orchestrator probe-models --config "$TARGET_CONFIG" \
[--models m1,m2] [--output PATH] [--budget-usd N]| Option | Default | Meaning |
|---|---|---|
--config |
required (or ORCH_CONFIG_PATH) |
Same target-project rule as every other subcommand — selects project_root and the probed account/model config |
--models |
routing.allowed_models + the fable candidate |
Comma-separated override for the probed model set |
--budget-usd |
routing.DEFAULT_PROBE_BUDGET_USD |
Per-invocation USD ceiling for each one-turn probe — must clear one turn of the most expensive probed model, or every pair reports budget_too_low |
--output |
routing.DEFAULT_PROBE_ARTIFACT_PATH (config/model-availability.yaml) |
Where to write the rendered artifact |
Per (account, model) pair, the artifact records one status (from routing.classify_probe_outcome, except no_token/invoke_error which the probe runner assigns directly around it):
| Status | Meaning |
|---|---|
available |
invocation succeeded |
unavailable |
model not found for this account |
auth_error |
account auth failed |
capped |
account is at or near its usage cap |
no_token |
account's OAuth token env var is unresolvable — the model was never invoked for it |
invoke_error |
the invocation call itself raised (network/subprocess) |
budget_too_low |
the probe turn aborted on the local --budget-usd ceiling. The API accepted the request and consumed real tokens, so the model DID resolve for this account — the turn simply never completed. This is a mis-sized budget, NOT unavailability: re-run with a higher --budget-usd |
error |
a classified failure outcome matching none of the rows above (not a raised exception — that's invoke_error; not a budget abort — that's budget_too_low) |
A mis-sized but positive --budget-usd clears the parse-time check and still produces an artifact that is uniformly and plausibly wrong, so the command reports budget aborts itself rather than leaving them for whoever opens the YAML: any budget_too_low rows raise a stderr warning naming how many pairs aborted and the ceiling in force, and a run in which every probed pair aborted exits non-zero — it produced no availability evidence at all, so it must not read as a successful probe. The artifact is written either way, before the non-zero exit; budget_too_low rows are honest evidence about the budget and are not discarded.
This artifact is the per-(account, model) availability evidence an admission decision consumes — including for routing.FABLE_CANDIDATE_MODEL, which the probe unions into its target set whether or not a config already admits it. Running the probe does not itself admit anything: admission is a per-config operator edit to that project's routing.allowed_models.
Reading routing decisions
Today's per-invocation routing visibility: every resolved invocation emits a routing_decision event, and each task's metadata.routing carries the latest decision (model, source_layer, rule_id, rejected reasons) plus bounded history. There's no dedicated CLI query yet — metadata.routing is the practical read path, via get_task/get_tasks (see "Check Status" below).
Forthcoming: a per-(model×role) rollup (done/blocked/cap-hit rates, $/done) in the digest and dashboard is not yet rendered — this section will be updated once that lands.
Reading scoped cap-hit / failover cost events
cap_hit, near_cap, and failover account events (fired by the UsageGate, see CLAUDE.md's "Model-scoped account caps") land in the account_events table of data/orchestrator/runs.db — the same SQLite file the CostStore shares with EventStore/RunStore. Each row's details column is a JSON blob:
sqlite3 data/orchestrator/runs.db \
"SELECT account_name, event_type, details, created_at FROM account_events
WHERE event_type IN ('cap_hit', 'near_cap', 'failover')
ORDER BY created_at DESC LIMIT 20;"When usage_cap.scoped_cap_models is non-empty, a scoped event's details carries a "scope" key set to the model name it was attributed to (e.g. "claude-fable-5"); a general (unscoped) event's details omits the scope key entirely. Reading a row:
cap_hit/near_capwithscopeindetails→ only that account's model-scope was affected; the account keeps serving general dispatches.cap_hit/near_capwithoutscope→ a full account-level cap; the account is excluded for every scope (general and every listed model).failover→detailscarriesfrom/toaccount names, plusscopeon a scoped failover (omitted on a general failover).
Check Status
Identify the target project first (see Critical: identify the target project FIRST), then query its status. The status subcommand also requires --config (or ORCH_CONFIG_PATH):
cd /home/leo/src/dark-factory
uv run --project orchestrator orchestrator status --config "$TARGET_CONFIG"This queries the fused-memory task tree and displays each task's status and module assignments:
[pending ] 1: Set up authentication module [backend, tests]
[in-progress ] 2: Add REST endpoints [backend, server]
[done ] 3: Create database schema [backend]
[blocked ] 4: Implement caching layer [backend]You can also query tasks directly via fused-memory MCP tools for more detail. Always pass project_root="$TARGET_PROJECT" — passing dark-factory's path here will return the wrong project's tasks:
get_tasks(project_root="$TARGET_PROJECT")
get_task(id="4", project_root="$TARGET_PROJECT")If the returned tasks don't look like the project the user is in (e.g. you see dark-factory titles when the user is in reify), your project_root is wrong — re-run step 1 of "identify the target project" before continuing.
Resolve Blocks
Tasks block at specific workflow stages. The approach depends on where it got stuck.
Identify the block
- Check status to find blocked tasks (using
--config "$TARGET_CONFIG") - Look at the task's worktree for artifacts — the orchestrator preserves worktrees for blocked tasks (cleaned up only on success). Worktrees live under the target project, not dark-factory:
ls "$TARGET_PROJECT"/.worktrees/ - Inside a blocked task's worktree, check
.task/for diagnostics:.task/plan.json— the TDD plan (shows which steps completed).task/iterations.jsonl— execution log.task/reviews/— reviewer feedback (if it reached review stage)
Common block patterns
| Stage | Symptom | Resolution |
|---|---|---|
| PLAN | "architect failed" or no plan.json |
Task description may be too vague. Update the task with more detail via update_task, reset to pending, and re-run. |
| EXECUTE | "Execution iterations exhausted" | Implementer couldn't complete all plan steps in 10 iterations. Check iterations.jsonl for where it got stuck. May need to simplify the task or split it. |
| VERIFY | "Verification attempts exhausted" | Tests/lint/typecheck fail persistently. Check the worktree for the actual failures: cd <worktree> && pytest / ruff check / pyright. Fix manually or update the task. Caveat: verification currently runs repo-wide commands by default — pre-existing errors in unrelated modules can block tasks. Check whether the failures are in the task's own modules or elsewhere. |
| REVIEW | "Review cycles exhausted" with blocking issues | Reviewers found design problems the architect couldn't resolve in 2 replan cycles. Read .task/reviews/*.json for the specific issues. May need architectural guidance written to memory. |
| MERGE | "Merge conflicts" / "Post-merge verification failed" | Another task modified the same code. Check git log --oneline main for recent merges. May need to rebase the task branch manually. |
| MERGE (halted) | "Merge queue halted" / wip_conflict, unmerged_state, or stash_failed escalation blocking all other merges / merge outcomes wip_halted, done_wip_recovery, wip_recovery_no_advance, unmerged_state, stash_failed |
Exactly one escalation owns the halt (tracked on the merge worker as _halt_owner_esc_id). Resolve that specific escalation via /escalation-watcher or mcp__escalation__resolve_issue to un-halt the queue. For wip_conflict, check the recovery branch named in the detail (wip/recovery-<task>-<ts>); for unmerged_state, inspect project_root with git status and resolve UU/AA/DD markers; for stash_failed, inspect and commit project_root's uncommitted WIP — do NOT use git stash (CLAUDE.md forbids it in any dark-factory checkout; refs/stash is shared across worktrees and the merge worker's advance path consumes the same stack, incident 13674d3c68). Do NOT manually toggle the halt flag — ownership is the single source of truth. |
Manual resolution workflow
All paths below operate on the target project ($TARGET_PROJECT), not dark-factory.
Navigate to the worktree:
cd "$TARGET_PROJECT"/.worktrees/<task-id>Diagnose: read
.task/plan.json, check test output, reviewgit logFix: make changes directly in the worktree
Verify: run the target project's verify commands (look these up in
$TARGET_CONFIG— Rust projects usecargo test/cargo clippy, Python projects usepytest/ruff/pyright, etc. — do not assume Python tooling)Merge manually (if the fix is good) — capture the merge sha at the moment of the merge, never by re-reading
HEADlater:cd "$TARGET_PROJECT" branch=$(git symbolic-ref --quiet --short HEAD); echo "on branch=$branch" [ "$branch" = "main" ] \ && git merge --no-ff task/<task-id> \ && MERGE_SHA=$(git rev-parse HEAD) \ && [ "$(git rev-parse -q --verify "$MERGE_SHA^2")" = "$(git rev-parse task/<task-id>)" ] \ && echo "MERGE_SHA=$MERGE_SHA"The
&&chain is the point, not style.$TARGET_PROJECTis a machine-operated checkout — the merge worker, the startup reconciler and git hooks all act on it directly — somaincan advance between this step and step 6, and agit rev-parse HEADread there can return an unrelated task's merge. Chaining short-circuits a conflicted merge, soMERGE_SHAis never set from a merge that failed — resolve the conflict first and re-run the chain. But two paths exit 0 without creating this task's merge commit, which is why the chain also checks the merge commit's second parent and opens with the branch guard. (a)Already up to date.— when the branch tip is already an ancestor ofmain,git merge --no-ffexits 0 and creates no merge commit at all, sogit rev-parse HEADreturns whatever is on main's tip, which under a live merge worker can be an unrelated task's merge; the second-parent check rejects that, because$MERGE_SHA^2is then some other branch's tip (or absent). (b) not onmain— the merge genuinely SUCCEEDS andMERGE_SHApoints at a real merge commit of this branch that simply is not on main (measured:git merge-base --is-ancestor "$MERGE_SHA" mainrc=1); the second-parent check cannot catch this, since it is a bona-fide merge of this branch, which is why the branch guard is the first link of the same&&chain — so a non-mainHEAD short-circuits the chain andgit mergenever runs. Merging onto the wrong branch is itself the damage, not just a bad capture, so the guard has to abort, not merely warn: a standalone[ ... ] || { echo "NOT ON main - stop"; }before an unchainedgit mergeprints its warning and then merges anyway (reproduced on git 2.43.0), and the chain still prints aMERGE_SHA=because the second-parent check passes. Theecho "on branch=$branch"runs unconditionally so the guard's verdict is visible either way rather than being silent short-circuit. Stamp only aMERGE_SHA=the chain actually printed.Update task status:
set_task_status(id="<task-id>", status="done", project_root="$TARGET_PROJECT", done_provenance={"kind": "merged", "commit": "<merge-commit-sha>"})kindis required —_validate_done_provenance(fused-memory/src/fused_memory/middleware/task_interceptor.py) rejects a kind-less blob withdone_provenance.kind is required, andDoneProvenance.kind(shared/src/shared/task_metadata.py) has no default. Use{"kind": "merged", "commit": "<sha>"}when this branch supplied the merge you just performed — the normal case here, since step 5 merged by hand; use{"kind": "found_on_main", "commit": "<sha>", "note": "<why>"}when the work was already on main.Hand-merge carve-out — read this before reaching for the ladder.
git merge --no-ffin step 5 writes the subjectMerge branch 'task/<task-id>', which the orchestrator-shaped--grep="Merge task/<task-id> into main"marker deliberately does not match. So on this path the marker search comes back empty on a merge that plainly landed — expected, not a signal. The commit to stamp is the one step 5 captured:{"kind": "merged", "commit": "$MERGE_SHA"}. It is attributable because step 5 proved its second parent is this branch's tip — that is what makes it a merge commit that brought this branch in, not whatever has landed on main since — and it was captured onmain. This path is explicitly not subject to the citation gate in the shared doc below, which governs the fast-forward path only. If step 5's chain printed noMERGE_SHA=, this carve-out does not apply: fall through to IfMERGE_SHAis not in hand below, which re-derives from the hand-merge subject, proves containment, and already bans a baregit rev-parse HEAD.If
MERGE_SHAis not in hand — step 6 reached in a fresh shell, a resumed session, or a merge performed earlier — do not substitutegit rev-parse HEAD. Re-derive by the hand-merge subject and prove containment first:S="Merge branch 'task/<task-id>'" sha=$(git log main --fixed-strings --grep="$S" --format='%H%x09%s' \ | awk -F'\t' -v s="$S" '$2==s && !seen {print $1; seen=1}') echo "hand-merge sha=$sha" [ -n "$sha" ] && { git merge-base --is-ancestor task/<task-id> "$sha"; echo "containment rc=$?"; }--fixed-stringsagainst the full quoted subject is substring-safe — the trailing'stopstask/1matching insideMerge branch 'task/10'. Theawkhalf selects the newest hit whose subject is exactly that, scanning past commits that merely quote it in a body; do not add--max-count=1, which would stop at the newest message match and discard a genuine merge shadowed behind it — see step 1 for the measured population. Stamp$shaonly when it is non-empty AND containment rc=0, which together prove that merge brought this branch in — the subject conjunct is already discharged by the selection. The subject conjunct is load-bearing and containment does not supply it:git log --grepmatches the whole commit message, so a commit that merely quotes the hand-merge subject in its body is matched too, and containment cannot reject it — containment exists to reject a stale marker from a previous incarnation (whose tip is a descendant, not an ancestor), but for a branch that genuinely landed earlier, any later commit on main has the branch as an ancestor and so passes rc=0. This is the same body-match hole step 1 closes for the orchestrator-shaped marker. rc=1 means it did not (a different or earlier merge), rc=128 means one of the two shas did not resolve, and an empty$shameans no commit on main carries the hand-merge subject: on any of those, stamp nothing here and fall through to the ladder below. Never stamp a baregit rev-parse HEAD— that is main's current tip, not necessarily your merge, and the server's only backstop (git merge-base --is-ancestor <sha> main) passes for every recent commit on main, so nothing downstream would catch the substitution.Otherwise — when the work was already on main and you are deriving rather than recording a merge you performed — derive the sha with the task-scoped ladder, never from main's current HEAD and never from an eyeballed listing: `skills/_shared/deriving-landed-sha.md`, the single normative copy (exact-subject marker search, ref-existence gate, containment, the group-merge candidate, the phantom-branch citation gate, and the
DoneProvenancecontract). Run it in full. Two adaptations for this call site: run every command in$TARGET_PROJECT, not dark-factory, and the shared doc writes the task id as<TASK_ID>where this workflow writes<task-id>— same value.On the ladder's genuine not-landed outcomes — rc=0's phantom-branch exit, rc=1 outside both the
coalesce-*and rebased-solo-landing arms, and rc=128 with an empty marker search outside thecoalesce-*arm — stamp nothing and report, rather than substituting a convenient sha. A citation gate that is un-evaluable (git.commit_citation_pattern: "") proves neither verdict, and nor does the ladder's landing-unattributable verdict: stamp nothing on either, and report each as such rather than as not-landed.A note-only
{"note": "<one-sentence explanation>"}payload is no longer accepted — the post-3092 hardening requires a commit on every kind. For a fast-forward merge, or when the work was covered by a sibling task, still cite a commit:{"kind": "found_on_main", "commit": "<the commit on main that cites this task>", "note": "<one-sentence explanation>"}, derived task-scoped as above.Clean up worktree (from inside
$TARGET_PROJECT):git worktree remove .worktrees/<task-id> git branch -d task/<task-id>
Writing context to help future runs
If the block was caused by missing architectural context, write it to memory so the orchestrator's agents can find it next time. Use the target project's project_id (read it from $TARGET_CONFIG under fused_memory.project_id — e.g. "reify", "dark_factory"), not a hardcoded value:
add_memory(
content="<the decision or convention that was missing>",
category="decisions_and_rationale", # or "preferences_and_norms" for conventions
project_id="<target project_id from TARGET_CONFIG>",
agent_id="claude-interactive"
)Resume Existing Tasks
If tasks already exist from a prior session, first assess their state before re-running. Make sure you've identified the target project (see top section) — querying with the wrong project_root will return a different project's task tree and silently lead you astray.
1. Audit the task tree
get_tasks(project_root="$TARGET_PROJECT")Review the task tree to understand the current state. Do not change the status of existing tasks — they may have been set by the user or another session. If a task's status looks wrong, ask the user before changing it.
You may:
- Add missing tasks (e.g., integration/e2e) →
submit_task(...)+resolve_ticket(...)and wire dependencies - Fix wrong dependencies →
remove_dependency/add_dependency
2. Execute
Once the task tree is accurate, jump to Execute Tasks. The orchestrator's scheduler only picks up pending tasks with all dependencies satisfied, so completed tasks are automatically skipped.
Configuration
The config file is selected via --config <path> or ORCH_CONFIG_PATH=<path> — there is no auto-discovery from cwd. The orchestrator binary refuses to start without one of these set, by design. See references/project-setup.md for the rationale, the schema for new project configs, and .envrc/direnv ergonomics.
YAML values in the loaded config support ${VAR_NAME} and ${VAR_NAME:default} environment variable expansion.
Core settings
| Setting | config.yaml | What it controls |
|---|---|---|
max_concurrent_tasks |
24 | Parallel task workflows |
max_per_module |
1 | Tasks per code module (serialization) |
max_execute_iterations |
10 | Implementer retries before blocking |
max_verify_attempts |
5 | Debug-fix cycles before blocking |
max_review_cycles |
2 | Review-replan loops before blocking |
lock_depth |
4 | Module path depth for lock normalization (projects commonly override; lower = coarser locks) |
test_command |
pytest | Verification test runner |
lint_command |
ruff check ... | Verification linter |
type_check_command |
pyright (per-package) | Verification type checker |
escalation.port |
8102 | Escalation MCP server port |
escalation.queue_dir |
data/escalations | Escalation queue directory |
sandbox.enabled |
false | Enable bwrap filesystem sandbox |
Per-role settings
Each role has model, budget, max turns, reasoning effort, and backend:
| Role | Model | Budget | Turns | Effort | Backend |
|---|---|---|---|---|---|
| architect | opus | $5 | 50 | max | claude |
| implementer | sonnet | $10 | 80 | max | claude |
| debugger | sonnet | $5 | 50 | max | claude |
| reviewer | sonnet | $2 | 30 | medium | claude |
| merger | opus | $5 | 50 | max | claude |
| module_tagger | (sonnet) | ($2) | (30) | medium | claude |
Module overrides
Subprojects can override verification commands by placing an orchestrator.yaml in their directory (e.g., fused-memory/orchestrator.yaml). This scopes test_command, lint_command, and type_check_command to that subproject when tasks touch its modules.
Multi-account failover
The usage_cap section configures automatic failover between Max subscription accounts when usage caps are hit. Accounts are tried in order; when one is capped, the orchestrator switches to the next and starts a background probe to detect when the capped account resets.
usage_cap.scoped_cap_models: list[str] (default ['claude-fable-5']) adds a model-scoped cap dimension on top of that per-account failover: a cap hit while running a listed model caps only that account's scope for that model — the account keeps serving general (non-listed-model) work untouched — and a general cap-hit always caps the whole account (every scope). scoped_cap_models: [] is the kill switch, disabling scope tracking entirely. See CLAUDE.md's "Model-scoped account caps" (under "Model Routing") for the full scope semantics and how they interact with the resolve_route fail-safe check.
usage_cap.*, including scoped_cap_models, is restart-tier — it is not in RELOADABLE_FIELDS, so mcp__escalation__reload_config won't apply a change to it; see "Reload Config (vs Restart)" above.
Override via YAML file (--config) or environment variables (ORCH_MAX_CONCURRENT_TASKS=5, ORCH_MODELS__ARCHITECT=sonnet).
Known Issues & Workarounds
These are active issues that may affect orchestrator runs. Check the task tree for fix status.
Verification runs repo-wide (task 45)
The verify step runs ruff check, pyright, and pytest with repo-wide scope by default. Pre-existing lint/type errors in modules outside a task's scope cause verification to fail even when the task's own code is clean. Workaround: ensure main is clean before starting a run (ruff check && pyright should pass). Module-scoped verification is planned but not yet implemented.
Worktree imports resolve from main tree (task 46)
Worktrees share the main tree's .venv. Python imports resolve from the installed (main) package, not the worktree's modified source. New methods or changed signatures in worktree code fail at test time with AttributeError or assertion mismatches. Workaround: if a task blocks at verify with import errors for code it just wrote, manually verify the code is correct, merge to main, and confirm tests pass post-merge.