Cookbook#
This cookbook shows common ways to use wastech-orchestrator. It is written for operators who run the orchestrator and for developers who want a practical path from "empty workspace" to "task processed into a Pull Request".
The canonical product reference remains the code and worc_architecture.md. The full CLI surface (install, preflight, validate-flow, telegram-test, run, promote, rerun, finalize, prs, merge-task, watch, stop, restart, status, top, shell, list, tasks, completion, clear, logs, runs, memory, upgrade-config, upgrade-docs) exists in the current codebase; this guide focuses on the everyday subset.
1. Install Into A Repository#
Install the package in an environment that has Python 3.12+, git, and the provider CLIs you plan to use (codex, claude) on PATH.
python -m venv .venv
source .venv/bin/activate
pip install -e ".[dev]"
On Windows PowerShell, activate the environment with:
.\.venv\Scripts\Activate.ps1
install is the single setup command. Run it from inside the target repository checkout — it detects settings, generates a validated config.yaml, scaffolds the runtime layout, and needs no later --config:
pipx install "git+https://github.com/VladimirMakarevich/wastech-orchestrator.git"
cd C:\projects\my-repo
worc install . # interactive wizard (same on macOS)
# non-interactive, e.g. for CI/automation:
worc install . --non-interactive --provider codex --no-create-pr
Everything the orchestrator generates lives under a single gitignored <repo>/.worc/ home: config.yaml, the agent task-authoring guide/ (the packaged guide/ docs copied to .worc/guide/), the editable flows/ copies (built-in flows + their per-flow prompt dirs), the tools/ copies (executables the packaged tool nodes resolve against), SQLite state.db (with -wal/-shm), orchestrator.pid, logs/, runs/ (per-task frozen bundles + sealed exchanges), memory/, and workspace/, plus the tasks/rejected quarantine — which also lives under .worc/, so a rejected task is never swept into the audit commit. (Check logs are not a top-level directory — they live under logs/<task-id>/checks/.) install appends the .worc/ and sibling .worc-io/ (exchange) ignore lines to the repo's tracked .gitignore, skipping either one an existing rule already covers. The only things outside .worc//.worc-io/ are the tasks/ lifecycle dirs (preparing//pending//done//failed/) at the repo root — deliberately, because they are the one root you may want in git. install gitignores them too by default (an anchored /tasks/ line); track them and the task file plus its <id>.summary.md (in done/ or failed/) become the audit trail the orchestrator commits — see How-To → Track your task files in git. preparing/ is a staging area the watch scanner never looks in, so a task file can be composed there without being picked up mid-write; promote moves it into pending/.
install also drops two reference files beside config.yaml: config.example.yaml (a fully commented copy, never read at runtime) and .env.example. Copy the latter to .worc/.env and fill it in — that is where secrets belong (the Telegram bot token and chat id today). The orchestrator auto-loads <repo>/.worc/.env at startup, an already-exported environment variable always wins over the file, and because the whole .worc/ home is gitignored the real .env is never committed.
Re-running is idempotent (existing files are skipped and config.yaml is never overwritten); --reconfigure backs up and regenerates; --dry-run writes nothing. See configuration.md for the discovery order and operations.md for the full wizard. The remaining recipes all run from inside the repo without --config.
2. Configure A Target Repository#
Edit config.yaml and point the repo block at the repository the orchestrator should modify:
repo:
url: "git@github.com:OWNER/REPO.git"
local_path: "./workspace/repo"
base_branch: "main"
branch_prefix: "worc"
The default branch name produced by the Git Manager follows:
worc/<epoch>-<task-id>-<slug>
<epoch> is the unix timestamp captured when the branch is prepared, so re-submitting the same task never collides with a leftover branch. The full auto-generated name is capped at 50 characters: the slug is truncated to whatever fits (or dropped entirely if the prefix already fills the budget). A task may set branch_name to override the full branch name when a project or customer requires a different convention; an override longer than 50 characters logs a warning and falls back to the auto-generated name.
This fresh-branch behavior is branch_mode: new — the default, and the only mode in which the orchestrator owns (and may reset/delete) the branch. Set repo.branch_mode (or a per-task branch_mode) to existing to work in a named already-existing branch (branch_ref), or current to work in the current checkout as-is — see task-authoring.md. A per-task publish: commit|push|pull_request caps how far publishing goes for one task (e.g. stop at a local commit) without switching flows.
Credentials are configured outside the orchestrator:
- authenticate git and
ghin the shell environment that runs the orchestrator; - authenticate Codex through the Codex CLI;
- authenticate Claude Code through the Claude CLI;
- do not put token values in
config.yaml, task files, orextra_args.
For all configuration fields, see configuration.md.
Two things install wrote that are worth knowing before you edit anything else#
The generated config.yaml is deliberately small. It carries what the install resolved (repo, the selected providers, git, the auto_mode answer), the security posture, and three affordances at their default so you can find them (paths.tasks_dir, an empty checks.command_sets, telegram.enabled). Every other block is absent and running at its documented default — the file's own footer lists them block by block. Add a block only to change something; the annotated config.example.yaml installed beside it is the complete reference.
It wrote security.strict_isolation: false, and that value is the advanced mode. Not a slightly relaxed sandbox: it forwards your shell's environment whole to every process run on the agent's behalf, hands every node a shell and the full tool surface (the skill tool included), lets the agent write wherever your own account can rather than only in the clone, raises no OS sandbox for Claude on any host, and puts every node online whatever its flow granted. What remains is a four-level floor, and only the first level is mechanical — and mechanical on Codex alone, which keeps its generated permission profile at either value of the key. Read Configuration → The advanced mode before your first real run and decide deliberately; setting strict_isolation: true (or deleting the key — an omitted key means true) is the fail-closed posture.
Two things the mode does not do, because they are the usual misreading: it unlocks no provider full-access mode (both selectors are refused at every value of the key), and it skips no proof — the per-provider capability probes run either way, and under false most of all, since there the generated permission profile is the whole local floor.
Self-host the orchestrator repository#
Do not point repo.local_path at the source checkout open in your IDE. Use one checkout to run a known-good orchestrator build and a separate clone as the target repository:
wastech-self/
.venv/ # control environment running the known-good orchestrator
.worc/ # orchestrator home (config.yaml, logs/, state.db, workspace/, ...)
tasks/ # task lifecycle dirs at the repo root (gitignored by default; --track-tasks to commit them)
workspace/
repo/ # separate clone modified by coding agents
Recommended preparation:
- Create the control checkout outside the source checkout and run
install .there. - Clone
wastech-orchestratorinto the target clone configured inrepo.local_path. - Confirm the target clone is clean and on
main. - Confirm the target clone keeps no orchestrator artifacts of its own — the runtime home lives under the control checkout's
.worc/. - Configure the real Python checks:
ruff check .,mypy src, andpytest. - Keep
orchestrator.auto_mode.enabled: falsefor the first run. - Use a unique task id and a small, fully specified task before attempting a larger backlog item.
Run the first experiment against a disposable fork or test remote. create_pull_request: false skips gh pr create, but the publishing pipeline still commits and pushes the task branch. To stop before the push as well, cap a single task with front-matter publish: commit — the per-task scope is downgrade-only (commit < push < pull_request), so it can shorten the flow's publishing policy but never extend it.
3. Run Preflight#
Before processing tasks, run:
python -m wastech_orchestrator preflight
Preflight checks each allowed provider (executable, version, and what its CLI says about its own stored credentials), the configured isolation policy, and — when git.create_pull_request is on — that gh is installed and logged in. It also echoes the configured check command sets, so an empty quality gate is visible before the first run rather than after it. A healthy run looks like:
env: OK — loaded 2 variable(s) from .worc/.env
claude: OK — claude 2.1.210 available (version=2.1.210, auth=logged_in (claude.ai))
claude-binary: /opt/homebrew/bin/claude -> /opt/homebrew/Cellar/claude/2.1.210/bin/claude
codex: OK — codex 0.144.4 available (version=0.144.4, auth=logged_in)
codex-binary: /Users/me/.codex/bin/codex (inside CODEX_HOME)
codex: isolation smoke OK — codex workspace-write sandbox: OS-enforced (empty MCP inventory)
isolation: OK (strict_isolation=false)
read-isolation: OFF (strict_isolation=false)
isolation-floor: NONE (claude: strict_isolation=false — no OS sandbox on any host)
advanced-mode: ON (security.strict_isolation=false) — full freedom for the agent under the operator's responsibility, except the floor; guide/config/security.md says what that floor holds and what it does not
git-evidence: ON (security.allow_git_evidence=true) — inert under strict_isolation=false
allowed-environment: OK — 9 name(s) after expansion (git/gh scope)
gh-repo-pin: OK — every gh call pinned to OWNER/REPO (from repo.url)
checks: 1 command set(s):
repo (paths: always): ruff check .; mypy src; .venv/bin/python -m pytest
gh: OK
telegram: SKIP (disabled)
preflight: ready
Three of those lines are new enough to call out. isolation: OK (strict_isolation=false) plus the advanced-mode: ON line is what a fresh install produces, because it wrote strict_isolation: false — see section 2. isolation-floor: answers a different question: whether an OS-enforced write floor exists for this run at all, and it is advisory — one isolation-floor: NONE (<provider>: <cause>) status line per provider without a floor, naming only the cause (what the missing floor costs is in .worc/guide/config/security.md), and the run continues; a host with a floor prints no such line. Two things print isolation-floor: NONE: a host that cannot sandbox (native Windows, or Linux/WSL2 without bubblewrap + socat), at either setting, and strict_isolation: false itself on every host, because the advanced mode raises no OS sandbox for Claude anywhere. So on a fresh install expect the Claude NONE line shown above on any host; Codex is unaffected, since it always gets a generated permission profile. gh-repo-pin: says whether every gh call is pinned to a repository; when repo.url names no hosted repo and the clone's origin cannot be parsed either, nothing is pinned and this fails (with create_pull_request on) rather than switching the guarantee off quietly.
worc preflight is the only caller that opts into the live no-model isolation capability smoke (the isolation smoke line above): Codex runs a real codex sandbox probe of the generated permission profile, so an old CLI, a missing sandbox helper, or a mis-generated policy surfaces here instead of mid-run. It runs at either value of strict_isolation — under false most of all, since there the profile is the whole local floor. The installer's own post-write preflight stays offline and prints no such line. A proven policy leak fails preflight unconditionally; an undemonstrable sandbox is fatal only when there is no fallback provider — and "nothing was written" is never a pass: a probe that cannot tell an enforced sandbox from a model that never tried reports NOT DEMONSTRATED.
One probe is deliberately not part of this and costs money:
worc preflight --paid-isolation-probe
Claude offers no way to run a command under its sandbox without the model, so this spends one real (billed) call per supporting provider, letting an agent try to write into .git and the control home; the verdict is read from the filesystem afterwards, never from what the model said it did. Nothing implies it — no run, and no plain preflight, spends it. The evidence lands at .worc/preflight/claude-paid-isolation-probe.json.
Preflight deliberately does not validate flows — that is worc validate-flow <name> (or --all), a separate read-only check over your .worc/flows/. Every dispatched flow is validated fatally at task time anyway, so a broken flow fails that task rather than the whole gate.
Exit code is 0 when ready and 1 when not. If preflight fails, fix the reported environment or configuration problem before running tasks. Do not work around failures by weakening sandbox or approval settings; the validator rejects known unsafe extra_args.
Run these diagnostics in the same shell that will start the orchestrator:
command -v codex
codex --version
codex exec --help
command -v claude
command -v gh
On Windows use where codex, where claude, and where gh. WSL, PowerShell, the IDE extension, and a global npm installation can resolve different Codex executables and report different versions. Configure agents.providers.codex.command for the executable visible to the actual orchestrator process; do not infer it from a different terminal. If codex --version and worc preflight disagree, follow How-To §5 to pin the intended native executable.
Only providers named in agents.allowed are required. If Claude Code is not installed, use Codex-only routing for every agent-driven stage. If gh is not installed, set git.create_pull_request: false until GitHub CLI is installed and authenticated.
4. Create A First Task#
Create tasks/preparing/task-001.md, then promote it into the queue with worc promote task-001:
---
id: task-001
title: "Add login form validation"
---
## Description
Add client-side validation to the login form. Show a validation error when the email is missing, when the email format is invalid, or when the password is empty.
## Acceptance criteria
- [ ] Empty email shows a validation error.
- [ ] Invalid email format shows a validation error.
- [ ] Empty password shows a validation error.
- [ ] Existing login behavior still works for valid input.
## Constraints
- Do not touch billing code.
- Add or update focused tests for the login form.
The validation gate requires front matter, a valid id, a non-empty title, and a non-empty Description section. Acceptance criteria are not a structural reject, but they make the task complete enough to skip autonomous refinement (refinement-skip is deterministic — driven by completeness, with no task flag). Constraints are still strongly recommended because they keep the implementation scope clear.
Task authoring details are in task-authoring.md.
5. Run One Task#
Process a single task end to end:
python -m wastech_orchestrator run tasks/pending/task-001.md
Useful variants:
python -m wastech_orchestrator --config ./config.yaml run tasks/pending/task-001.md
python -m wastech_orchestrator --log-level debug run tasks/pending/task-001.md
python -m wastech_orchestrator --log-file ./logs/orchestrator.log run tasks/pending/task-001.md
Exit codes:
| Exit | Meaning |
|---|---|
0 |
The task reached done. |
1 |
The task reached failed — or run refused to start: another executor owns the clone, or the file is a terminal task's own leftover (use rerun / finalize). |
2 |
The task reached manual_action_required — or the run was refused before it started (unreadable/too-new config, a dependency that is not merged yet). |
3 |
Paused, not terminal: every provider was transiently unavailable. The task stays resumable and the next run/watch/restart continues it from its checkpoint. Distinguishing this from 1 is the point — a CI job should retry, not triage. |
A successful task runs through validation, branch preparation, optional refinement, planning, implementation, checks (testing), review, fixing if needed, the whole-task documentation pass, then publishing — commit, push, and PR creation — followed by terminal cleanup back to repo.base_branch. The plain-language summary that becomes the PR body is not a separate stage: the supervisor layer writes it at task close (see supervisor) — and with that layer switched off, the deterministic report is the body instead.
Find a task to act on (list) + Tab-completion#
Before rerun/finalize/status you need the task id. worc list shows it without hand-scanning folders, and the completion script lets you Tab through ids instead of typing them:
worc list # active + pending queue + recent terminal tasks
worc list --format ids --scope rerun # bare ids a rerun would accept
source <(worc completion zsh) # then: worc rerun <Tab> completes rerun-eligible ids
Re-attempt a task that ended failed / manual_action_required#
A terminal task is frozen — watch/resume never pick it up again. rerun re-attempts it (stop the watch daemon first; it needs an idle slot):
worc rerun task-001 --dry-run # show the plan; write nothing
worc rerun task-001 --yes # fresh attempt from the current base_branch
worc rerun task-001 --continue --yes # infra failure you fixed: reuse the branch, re-enter at the failed stage
worc rerun task-001 --continue --reset-fix-budget --yes # fix loop exhausted: grant a fresh fix budget
worc rerun task-001 --continue --from implementation --yes # re-enter at a chosen node
worc rerun task-001 --force-reset-remote --yes # fresh: also delete the prior remote branch
Use fresh (default) for a quality failure or a clean redo (the branch is reset to base and prior .worc/logs/<id>/ is archived to .worc/logs/<id>/attempt-<N>/); use --continue when you fixed an environment/infra problem by hand (a missing tool, PATH, a dropped Telegram approval) and want to pick up where it stopped — on --continue the task's own uncommitted work is tolerated once it reached review/fixing/publish. Add --reset-fix-budget when the fix loop was exhausted (max_fix_cycles) — it grants a fresh fix budget: the consecutive fix counters and every non-blocking evaluator's max_rework_per_stage count start afresh, while the global max_total_fix_iterations backstop is kept, so termination stays bounded; if you resume into an already-exhausted budget without passing --reset-fix-budget/--no-reset-fix-budget, rerun asks interactively (never skipped by --yes). Pass it too when the plan carries a note: that max_rework_per_stage is spent for a non-blocking evaluator: without it that evaluator accepts on its next pass with no fix round — a full paid pass that changes nothing. While a rerun runs it holds the same orchestrator.run marker as run, so status/list/top show its task as running, not parked (no daemon). Add --from <node> to re-enter at a chosen node of the checkpoint's flow — it resumes through any flow drift since the checkpoint (the node just needs to exist today). Add --force-reset-remote (fresh mode only) when the prior attempt already pushed and opened a PR and you want that branch deleted — which closes the PR — instead of a second one racing it. Each re-attempt appends a ledger record linked to the prior one. In a script or CI, pass --non-interactive so any confirmation not already answered by --yes/--reset-fix-budget/--no-reset-fix-budget is refused with exit 1 rather than blocking on a prompt (worc shell passes it for the same reason). See operations.md "Re-attempting a terminal task" for the full rules.
Record a task you handled by hand (finalize)#
If you resolved a terminal task yourself (merged the PR, fixed it locally, or dropped it), finalize reconciles the bookkeeping — it records and tidies only, never running the pipeline or committing/pushing/PR-ing (daemon must be stopped):
worc finalize task-001 --as done --pr-url https://github.com/o/r/pull/42 # you merged it
worc finalize task-001 --as failed --note "superseded" # give up on it
worc finalize task-001 --as abandoned --note "obsolete" # drop it (audited)
It sets the terminal status, runs terminal cleanup (returns the working copy to base_branch, fail-closed on a dirty tree — unless the branch mode / repo.checkout_base_on_cleanup keeps it on the working branch), moves the task file, closes any waiting HITL prompt, and appends a manual ledger record. See operations.md "Finalize a task you handled by hand" for the full rules.
Merge a reviewed PR (prs / merge-task)#
With auto-merge off (the default), the orchestrator leaves its PR open for you to review on GitHub. After you approve it, let the orchestrator finish — pull base in, resolve any conflicts, and merge (daemon must be stopped):
worc prs # which orchestrator PRs are open and awaiting merge?
worc merge-task task-001 --dry-run # preview: PR, whether base merges cleanly
worc merge-task task-001 # go-ahead: update branch w/ base, resolve conflicts, merge
worc prs --sync --yes # record PRs you merged directly on GitHub instead
A clean base-merge is mechanical; a conflicting one runs the operator-editable merge flow (git.merge_flow) — an agent works from the conflict inventory ({conflicts_path}, which lists marker-less conflicts too), the checks re-run, then the orchestrator commits and merges. On any failure it aborts the merge and leaves the PR open; a conflicted path left byte-identical to what the merge produced is such a failure (exit 2, manual action required) — finish that path by hand and re-run merge-task. See operations.md "Merging a reviewed PR" for flags and the safety-only gate.
6. Use watch#
watch resumes an interrupted task first, then processes pending task files:
python -m wastech_orchestrator watch
python -m wastech_orchestrator watch --poll-seconds 0 # single pass (no loop), e.g. under cron
By default watch is a long-running loop: orchestrator.poll_interval_seconds (default 300) is how often it runs git fetch + pull --ff-only on base_branch and re-scans, so tasks committed and pushed to the repo after watch started are picked up without a manual pull. Stop it with Ctrl-C; set poll_interval_seconds: 0 (or --poll-seconds 0) for a single pass.
By default, orchestrator.auto_mode.enabled is false, so within each scan watch processes or resumes one task and then waits for the next tick:
orchestrator:
auto_mode:
enabled: false
Enable sequential queue processing only when you want the orchestrator to pick the next pending task after a successful terminal cleanup:
orchestrator:
auto_mode:
enabled: true
Auto mode does not introduce concurrency. There is a single active task slot, and terminal cleanup (checkout back to repo.base_branch, or staying on the working branch per repo.checkout_base_on_cleanup / the branch mode) must complete before the next task can start.
Run the queue overnight (worc-night-run)#
watch stops at the first task that needs a human. For a night where something will go wrong, hand the queue to an agent running the packaged worc-night-run skill instead — it drives the tasks one at a time with worc run, retries a stuck task through a bounded four-start ladder, abandons it on a security refusal, never publishes or merges anything itself, and leaves a write-up per task:
mkdir -p .claude/skills && cp -R .worc/guide/skills/worc-night-run .claude/skills/ # once; again after upgrade-docs
worc preflight && worc validate-flow --all # the skill runs this gate itself, but fix failures while you are awake
# then ask the agent: "run the pending queue overnight with worc-night-run"
ls .worc/night-runs/ # in the morning: <stamp>/report.md + one <task-id>.md per task
See operations → Unattended night runs for what it may and may not touch.
To run several worc instances against one git-distributed task pool without them colliding, give each one a queue: orchestrator.queue (default "default", overridable per launch with worc watch --queue NAME) is matched by plain string equality against a task's own queue front-matter field, and an instance only ever claims tasks that match. An untagged task carries "default", so a single untagged instance behaves exactly as it does with no queues at all.
Monitor a running task#
For a long run, write the safe operator trace to a rotating file:
python -m wastech_orchestrator \
--log-file ./logs/orchestrator.jsonl \
--log-format json \
--heartbeat-seconds 30 \
watch
Global options (--config, --env-file, --log-level, --log-format, --log-file, --heartbeat-seconds) must appear before the subcommand — worc watch --log-file … is an argparse error, not a working command. The file rotates at 10 MB and keeps five backups. Use --log-format logfmt for a human-readable key=value file, or json for ingestion by tools. --heartbeat-seconds (default 30) emits safe progress records while a provider, check, or Git command is still running; set it to 0 to disable heartbeats.
Follow the live trace from another terminal:
tail -f logs/orchestrator.jsonl
The trace includes start/completion/failure events and durations for stages, provider attempts, checks, branch preparation, commit, push, PR creation, and terminal cleanup. Heartbeats contain only safe metadata such as task, stage, provider, attempt, timeout, and elapsed time.
Read the persisted task snapshot without starting providers, checks, or Git operations:
python -m wastech_orchestrator --config ./config.yaml status
python -m wastech_orchestrator --config ./config.yaml status task-001
Without a task id, status shows active tasks or the most recently updated task. It reports the task id and title, the persisted status (a display label such as parked (no daemon) or done (recovered: <loop>)), the flow checkpoint node (where the engine would resume), branch, subtask, fix counter, last update time, elapsed time since that update, a cost= line with the task's recorded spend (every node attempt plus the supervisor layer — cost=$114.11 over 19 provider calls (Claude's reported cost includes its subagents), the same words as the PR body's ## Run cost section), a cost_not_accounted= line when some attempt carries no recorded cost (Codex reports none), and the last cleanup error if there is one — then the same configured-command-set summary preflight prints. It opens state.db read-only.
For a single attended surface instead of re-running status, use worc top — a live, read-only monitor that auto-refreshes the active task + flow node, a parked/gate-pending marker, the queue (filtered to the served queue and priority-sorted, exactly as the daemon runs it), recent terminal tasks, and a tail of the daemon log. Point it at the daemon's log file and quit with q:
worc --log-file ./logs/daemon.jsonl watch & # global flag, so it comes BEFORE `watch`
worc top --log-file ./logs/daemon.jsonl # subcommand flag: which log to tail; q (then Enter) to quit
The two --log-file flags are not the same option and sit on opposite sides of the subcommand. The global one (before watch) is a sink the daemon writes to; top's and shell's are the log to tail (shell defaults to .worc/logs/daemon.log). top never opens it for writing, so it cannot race the daemon's rotating handler. worc top --poll-seconds N sets the refresh interval (default 2) and --recent N how many terminal tasks to list (default 10).
worc top is stdlib-only (no extra). For an interactive console that also drives commands — enqueue a task, ps, logs, down the daemon — install the [shell] extra and run worc shell. Entry is passive: it attaches to a live daemon or opens idle (the queue is not served until you type up). up spawns the daemon and verifies it came up (surfacing the real error if it does not); quit detaches and leaves the daemon running:
pip install wastech-orchestrator[shell]
worc shell # attach-or-idle; up / enqueue / promote / ps / clear / down / quit
7. Choose Which Provider Runs a Node#
Provider routing is node-based — it lives on the flow, not the task. Each agent/evaluator node in the flow YAML may declare its own provider: (codex | claude); a node with no provider runs on the global primary (the one config.yaml provider marked primary: true, which must be in agents.allowed). The global primary is also the sole infrastructure-fallback target. A node that ran on the fallback is never silent about it: a console warning naming both providers and the failure (and that the flow pinned the provider, when it did), a ⚠️ ran on a fallback provider trace with telegram.trace on, and a line in the PR body's ## Provider fallbacks section — so a critic you pinned to a second model that ended up running on the first one does not read as the cross-check you planned.
# in an operator flow (.worc/flows/<task_type>.yaml) or a packaged flow node:
- id: review
kind: evaluator
role: review
role_file: implementation/review.md
provider: codex # this node runs on codex; omit to use the global primary
A node's declared provider/model/reasoning are the defaults; a task may overlay them per run with nodes.<node-id>.{model,reasoning,provider} (best-effort — an invalid value is warned and skipped at run time, never fatal; see task-authoring.md). This lets one default flow cover several model/effort/provider variants without a separate flow file. testing and publishing run no agent (Check Runner / Git Manager). A provider (node-declared or task-overridden) must be in agents.allowed, and neither the flow nor a task can change provider commands, credentials, sandbox settings, extra_args, or any security policy.
7a. Customize a Node's Prompt#
To add repository-specific engineering rules or a review rubric to a stage without editing Python, edit that node's role_file (see prompt templates). install delivers the built-in flows + their role files under .worc/flows/ (each flow's prompts in its own <task_type>/ subdir), and .worc/flows/ is the only copy the orchestrator reads, so edit the delivered role file (a custom operator flow likewise keeps its role files under its own .worc/flows/<task_type>/ subdir). The role file's content is the prompt template — edit it and the change takes effect on the next run.
For example, a review node's role file replaced with a security rubric:
<!-- implementation/review.md -->
Review for security first. Reject the change unless:
- all new inputs are validated and outputs encoded;
- no secret, token, or credential appears in the diff at {diff_path};
- the plan at {plan_path} is fully implemented.
Notes:
- Variables are metadata/paths only — e.g.
{repo_path},{diff_path},{plan_path}. Large content stays in the artifact files the agent reads by path. The set is a fixed allowlist (task_id,stage,repo_path/repo,task_path,plan_path,diff_path,change_overview_path,diff_base,checks_path,review_path,subtask_order,subtask_count,subtask_spec_path,memory_path,predecessor_context,conflicts_path,report_dir) plus one{<node-id>_path}per node in your flow, so a downstream node can name an upstream node's output — a nodestatic-scanpublishes{static-scan_path}. Unknown{...}and literal braces pass through unchanged, so a prompt containing JSON or code never breaks. - You do not have to point a node at the change: from the first edit on, every node's context footer already lists the change overview, the diff and
Change base: <sha>, and closes with a where-to-start sentence the orchestrator picks by whether the node can run commands (start from the overview andgit diff <base> -- <path>, or read the full diff). A rubric that must read the whole diff — like the secret check above — says so and names{diff_path}; see Flow authoring → What a node is handed about the change. - Wrap optional prose in
{?name}…{/name}: the block survives only whennameis an allowlisted variable with a non-empty value, otherwise the whole block (markers included) is dropped — no dangling empty placeholder when, say, there is no subtask. - The exact text sent each run is saved (redacted) to
.worc/logs/<task-id>/stages/<node-id>/run-<node-run-id>/rendered-prompt.md— one per node run, so a re-running node keeps every pass. - A template is prompt text only: it cannot change the provider, sandbox/approvals, denied commands, or enable
git/ghpublishing.
7b. Run A Different Flow (deep_research)#
A task selects its pipeline with one front-matter field. Nothing else changes — same engine, same gates, same supervisor layer:
---
id: task-042
title: "How does provider fallback actually decide?"
task_type: deep_research
---
task_type resolves to .worc/flows/<task_type>.yaml and nowhere else — an unknown value is a hard resolution error, not a silent fall back to the default. Omitting the field means implementation. install delivers these:
task_type |
Deliverable |
|---|---|
implementation (default) |
A code change, published as a pull request. |
deep_research |
A research report under docs/research/<task-id>/ — the policy's default home; a flow may move the base with report_dir: <base> (the engine still appends /<task-id>). |
security_audit |
A security review. It declares publishing: none, and its report lands in the gitignored .worc/security-reports/<task-id>/ — never in a commit. That too is a default a flow may move with report_dir; a private base outside .worc/ is yours to gitignore (the publish step refuses a git-trackable report, and the report directory is kept out of the code commit's staging set either way). |
blog_article / blog_article_revise |
A new authorial article written from scratch, or an improvement pass over one that already exists. |
content_chapter / content_translate |
A long-form chapter reworked in place, or a source-language chapter adapted into English. |
merge |
Not selected by a task: this is the conflict-resolution flow merge-task runs (git.merge_flow). |
They are ordinary YAML under .worc/flows/, so a copy with a new name and a matching task_type: field inside is a new flow. worc validate-flow --all checks them before a task does.
deep_research is worth knowing in outline because its shape differs from the coding flow in three ways an operator notices:
- The repository analysis is three sequential passes, not one, each with a fresh context window and a narrow mandatory remit (
analysis_core→analysis_surfaces→analysis_docs_tests). One node asked to walk everything self-triages: it goes deep on what it recognises and labels the rest "no findings". The remit is what forces even depth. For a narrow question, disable the passes you do not need per task (nodes.analysis_surfaces: { enabled: false }) rather than editing the graph. - A
coverage_gatemeasures the audit instead of reading it. Every subsystem the task declares must show a traced property, not a bare "no findings" label; its rework edge re-enters atanalysis_core, because a gap can sit in any of the three remits. That catches a thin pass beforesynthesiswrites a conclusion on top of it. - Two
checksnodes run before the expensive evaluators: the deterministiccitationchecker over the sources manifest (its report records themanifest_pathit validated, so the fact-verification evaluator can open the same file), and acommand_profilenode running yourchecks.command_setsover the Markdown about to be committed. That second one costs nothing until you configure a matching set — and once you do, it changes how the flow can fail (see the catch-all note in §8).
refinement here is a scoping pass and runs on every task: it decomposes the question into sub-questions and anchors each to where its evidence lives. (It used to be gated on derived.needs_refinement, which is only true for an ill-formed task file — so on any well-formed task the scoping pass never ran. A complete task file and a scoped question are different things.) Skip it per task when the question is narrow enough.
What the deliverable directory contains. deep_research declares output_policy: repository_document, so every write is confined to its report directory — docs/research/<task-id>/ by default, or <report_dir>/<task-id>/ once the flow names another base (its synthesis prompt reads the directory as {report_dir}, so moving it needs no prompt edit) — and the bundle must produce report.md + sources.json. The organizing pass before the writer (architecture_design) deliberately writes no file — it returns its blueprint as its output and the writer consumes it. An intermediate blueprint written into the report directory ships in the pull request next to the deliverable, and the run that proved this shipped two documents that disagreed about coverage. So the report directory holds the deliverable and nothing else.
8. Configure Checks#
Checks are operator-authored command sets — never auto-detected. The Check Runner runs each selected command (argv list, no shell) with a timeout and records output as artifacts; a launched check that exits non-zero is a quality failure that goes to fixing (no provider fallback), while a required toolchain that cannot launch leaves the gate incomplete and sends the task to manual_action_required (the agent cannot install host toolchains). install writes command_sets: {} (no gate); you author the gate.
Single-root repo. One set with no paths always runs (on any non-empty diff):
checks:
timeout_seconds: 7200 # global per-command default
command_sets:
repo:
paths: [] # no paths ⇒ always runs
commands:
- { name: lint, argv: ["ruff", "check", "."] }
- { name: types, argv: ["mypy", "src"] }
- { name: tests, argv: [".venv/bin/python", "-m", "pytest"] }
Polyglot monorepo. Sets are selected by the union of those whose paths match the task diff; a cwd runs the command in a subtree, and skip_if_unavailable skips (rather than fails) a set whose toolchain is absent off-host:
checks:
command_sets:
backend:
paths: ["backend/**"]
commands:
- { name: tests, argv: ["pytest"], cwd: "backend" }
docs:
paths: ["**/*.md"] # extension-anywhere: only when Markdown changes
commands:
- { name: prose, argv: ["npx", "prettier@3", "--check", "**/*.md"] }
ios:
paths: ["ios/**"]
skip_if_unavailable: true # xcodebuild absent off-macOS ⇒ skip, don't block
commands:
- { name: build, argv: ["xcodebuild", "build"], cwd: "ios" }
An empty diff runs nothing (the checks node passes vacuously); a changed path claimed by no set runs no set on its account (cover shared/root files with a no-paths catch-all set). A skipped skip_if_unavailable set is recorded loudly and blocks git.auto_merge even when the node passes. See checks for every field and operations → command-set diagnostics for the preflight/status command-set summary.
Two things about that catch-all worth knowing before you rely on it:
- It fires on a Markdown-only diff too. A no-
pathsset runs on any non-empty diff, so once you also run a document-producing flow (deep_research, or the content/blog flows), that research run pays for the whole code gate — and a command that rewrites files rather than checking them parks the run on the green-but-dirtying guard. Either scope the catch-all'spathsto code, or keep it and add a documents set running a format check:
yaml
checks:
command_sets:
code:
paths: ["src/**", "tests/**", "pyproject.toml"] # scoped, not catch-all
commands:
- { name: lint, argv: ["ruff", "check", "."] }
docs:
paths: ["**/*.md"]
commands: # a CHECK, never a formatter
- { name: prose, argv: ["npx", "prettier@3", "--check", "**/*.md"] }
skip_if_unavailableis not an escape hatch. It converts a launch failure into a loud skip, nothing more — and a set that was the only one the diff selected and is then skipped leaves the gate with nothing run, which parks the task atmanual_action_requiredexactly where the launch failure would have. When you actually want a gate not to run, disable the node per task:nodes.testing: { enabled: false }.
9. Configure The Audit Commit#
There is one canonical layout: the orchestrator's runtime home is the gitignored <repo>/.worc/ (config, logs/, state.db, workspace/, ...), and the tasks/ lifecycle dirs live at the repo root — gitignored by default, trackable on request. The code commit uses scoped staging and excludes .worc/ and the lifecycle tree; when that tree is tracked, a separate task-scoped audit commit records it — the task file moved to done//failed/ plus its <id>.summary.md (with the tree gitignored there is no audit commit at all). Everything under .worc/ (plan, review, diffs, summary.json) stays local and is never committed.
git.footprint has two settings:
git:
footprint:
audit_commit_message: "chore(worc): audit trail for {task_id}" # the default
audit_on_branch: task # task (default) | sibling
audit_on_branch controls where the audit commit lands:
| Value | Best for |
|---|---|
task (default) |
The task + its summary committed onto the task branch, beside the code change. |
sibling |
The audit commit goes onto a separate <branch>-audit branch, keeping the task branch limited to the code change. |
{task_id} is the only placeholder the message template takes.
The audit commit exists to answer a different question than the code commit:
- the code commit says what changed in the source tree;
- the audit commit says which task was completed and what its outcome was.
Typical history for a successful task with the lifecycle tree tracked:
feat(task-123): add rate limiting
chore(worc): audit trail for task-123
The first commit carries the source diff. The second carries only the task trail under tasks/: the moved task-123.md plus task-123.summary.md — and, if you had committed the task file in preparing/ or pending/ first, its removal from there, so the file never sits in two lifecycle folders at once. Under the gitignored default only the first commit exists. Everything under .worc/ stays local and is never committed.
10. Inspect Logs And Artifacts#
The --log-file operator trace is the best live view. Start artifact inspection with:
ls .worc/logs
The most useful files are:
.worc/logs/
completed.jsonl # the ledger: one record per terminal transition
daemon.log # the watch daemon's live stream (+ rotated backups)
daemon-startup.log # only the daemon's pre-configuration output
<task-id>/
validation_report.json
task.normalized.json
task.enriched.md
plan.md
current.diff
change-overview.txt # the bounded map of current.diff: changed paths, kinds, line counts
checks/
hitl/
memory/
stages/<node-id>/
history.jsonl # one line per run of this node
run-<node-run-id>/ # every re-run kept: rendered-prompt.md, findings.json, <id>.out.md, …
summary.md / summary.json
failure_report.json
stuck.md # recovered-* when the task still finished `done`
merge/conflicts.md # merge-task's conflict inventory
publish/
Use completed.jsonl as the index of terminal tasks. Use stuck.md and failure_report.json when the result is manual_action_required. Provider stdout/stderr/event files are redacted; the full process environment and secrets must not be stored.
Note that a node's per-run artifacts are not at a fixed path — an evaluator's findings live under stages/<node-id>/run-<node-run-id>/findings.json, one directory per run, so a review → fixing → testing → review loop keeps every pass instead of clobbering the last. Read stages/<node-id>/history.jsonl for the chronological index.
Per-task runtime state (frozen control + instruction bundles, sealed exchanges) is a separate tree under .worc/runs/, cleaned automatically for a successful task and on demand with worc runs clean; the log tree above is cleaned with worc logs clean (which also sweeps the daemon logs).
Do not live-tail provider stdout.log or stderr.log: provider output is finalized and redacted after the subprocess exits. Use the operator log and status while work is in progress, then inspect provider artifacts after the attempt completes.
11. Recover From manual_action_required#
manual_action_required means the orchestrator stopped safely and needs a human decision. Common causes:
| Cause | Action |
|---|---|
| Fix budget exhausted | Read stuck.md, inspect the final diff, and decide whether to fix manually, refine the task, or grant a fresh budget with worc rerun <id> --continue --reset-fix-budget. |
A loop stopped early (limit_exhausted: no_file_change or repeated_findings) |
no_file_change: the agent kept answering without editing — find the blocker in the latest fixing output. repeated_findings: the critic returned the same findings three times — read that finding first; it usually asks for something the node cannot change. Fix the cause, then rerun --continue. |
| Checks gate incomplete (required toolchain absent, or every selected set skipped) | Install the toolchain, narrow the selecting paths, or disable that checks node for the task. A fix loop cannot install toolchains, so this never routes to fixing. |
| A green check mutated the working tree | A passing check that rewrote commit-candidate files (an auto-formatter, a codegen step) fails closed rather than passing silently. Replace it with the tool's check-only mode (--check, --dry-run), then rerun --continue. |
| An evaluator could not run (infra or misconfiguration) | The already-green diff is deliberately preserved on the branch instead of being failed — review and publish it by hand, or fix the evaluator's node and rerun --continue. |
A Claude attempt stopped with background_work_lost (an agent node ends failed, an evaluator manual_action_required) |
The agent backgrounded subagents and ended its turn before they finished, so the CLI stopped them and the "result" was a promise. Keep agents.providers.claude.background_tasks: false (the default — subagents then run in the foreground), or, if you turned it on, make sure the node's role prompt never ends a turn waiting on background work; then rerun --continue. |
| Terminal cleanup unsafe | Inspect repo.local_path with git status, reconcile the branch, and return to repo.base_branch. |
| More than one active task on restart | Decide which task is authoritative, then repair state before rerunning. |
| Git control state drifted across an attempt (WRI-009) | Inspect git status plus the repo-local config/hooks, reconcile, then rerun --continue. On a read-only node holding the git-evidence grant this only warns and continues. |
After resolving the problem, run:
python -m wastech_orchestrator watch
Recovery is idempotent: reruns reconcile state and do not duplicate commit, push, or PR operations.
12. Common Setups#
Codex-only:
agents:
allowed:
- codex
providers:
codex:
command: "codex"
model: ""
timeout_seconds: 7200
permission_profile: "workspace-write" # the access level — there is no `sandbox` key here
extra_args: []
primary: true # the global primary (exactly one; must be in agents.allowed)
sandbox: read-only / sandbox: workspace-write is rejected at config load — the access level lives on permission_profile, and sandbox now only accepts the full-access escape danger-full-access (upgrade-config folds a legacy value across for you).
Run without the oversight layer (cheapest possible run; the PR body becomes the deterministic report):
supervisor:
enabled: false
memory:
enabled: false # otherwise it is forced false for the run, with a warning
No automatic PR creation:
git:
create_pull_request: false
pr_base: "main"
This disables PR creation only. A successful run still commits and pushes the task branch.
Long-running checks (a per-set timeout_seconds overrides the global default):
checks:
command_sets:
repo:
timeout_seconds: 3600
commands:
- { name: tests, argv: ["npm", "test"] }
- { name: lint, argv: ["npm", "run", "lint"] }
- { name: types, argv: ["npm", "run", "typecheck"] }
Smaller fix budget for conservative runs (the defaults are 3 / 15 / 30, so these are genuine reductions):
agents:
max_stage_attempts: 2 # provider attempts for ONE node before the stage fails
max_fix_cycles: 5 # fix rounds of ONE fix loop (review/test -> fixing -> back); 5 rounds run, the 6th fail parks
max_total_fix_iterations: 10 # global cap on fix rounds across all loops; rejected at load if < max_fix_cycles