A control-plane daemon that turns GitHub issues into pull requests by driving herdr (the execution substrate) through a deterministic state-graph engine that reads a declarative YAML workflow.
Phase 1 + 2a — issue to merged. The engine drives the loop end to end: one issue → spawn an implementer in an isolated git worktree via herdr → the agent opens a PR → spawn a reviewer → the
reviewdecision branches (approve / request_changes / escalate) → on approve, the merge gate (CI + mergeable, plus approvals if configured) is polled over GitHub →merge_prsquash-merges →merged. The real merge is gated onpolicies.dry_run(default-on), so the shipped config halts atmergingand logs the intended merge until you setdry_run: false. Triage/intake and the concurrent scheduler daemon now ship (R2), and the daemon exposes an optional loopback MCP control surface (below). Cross-task memory remains deferred — tracked in ROADMAP.md.
You don't drive the orchestrator by hand — you hand it to a coding agent. The
repo ships everything the agent needs: RUNBOOK.md is the
end-to-end operator guide, and the
operate-orchestrator skill is
the supervision loop.
Prerequisites: Go 1.26+ · herdr running ·
gh authenticated for your repo ·
Claude Code (claude) installed.
-
Clone this repo and, from inside a herdr pane, start a Claude session in the checkout:
git clone https://github.com/sean1588/herdr-orchestrator cd herdr-orchestrator claude -
Tell it what to operate:
Read RUNBOOK.md and operate the orchestrator against
<owner>/<repo>.The agent takes it from there: builds the binary, scaffolds a config wired to your repo (
orchestratord init), creates the source label, starts the daemon, and supervises the pipeline — restarting it if it dies, diagnosing stuck tasks, and surfacing anything that needs you. -
Feed it work: label a small, well-specified issue in your repo
agent-ready. The next poll picks it up and runs it through triage → implement → review → merge-gate.
The shipped default is dry_run: true — the pipeline stops just short of the
real merge and halts each task at merging as a success. Watch one dry pass,
then have the agent set dry_run: false and restart the daemon.
Not using Claude? Any capable coding agent works — RUNBOOK.md §3.0 and the
skill file are plain markdown, and the control surface is plain JSON-RPC over
HTTP. Prefer to drive it yourself? The Usage section below has every
command, and TUTORIAL.md is the human-paced walkthrough.
A fixed engine (mechanism) interprets a per-team workflow (policy) supplied as
YAML. A task is a token moving through a directed state graph; the engine —
never a model — owns every transition. Judgment enters only at constrained
decision points; irreversible side effects (merge) are reachable only through
gate evaluations over authoritative sources. GitHub is the source of
truth for artifacts; an agent's done status is only a trigger to go check
GitHub. The engine is the single writer of durable task state (SQLite).
cmd/orchestratord/ CLI: validate | plan | run | recover | daemon | version
internal/config/ workflow types, JSON-Schema validation, the 7 safety invariants
internal/engine/ the state-graph executor
internal/scheduler/ the daemon loop: one poller, N workers, single-writer store
internal/store/ SQLite task store + per-transition audit log (single writer)
internal/exec/ ExecutionBackend interface + herdr implementation
internal/github/ Client interface + gh CLI implementation (PR detection)
internal/mcp/ loopback MCP control server (read state + cancel/enqueue)
internal/notify/ out-of-band escalation/alert notifier (webhook)
internal/proc/ mockable os/exec runner (the seam under herdr + gh)
The engine depends only on small interfaces (exec.ExecutionBackend,
github.Client, *store.Store), never on herdr or gh concretely — so the
backend can later be swapped for a headless/container implementation.
Past pr_open the engine runs the rest of the pipeline:
- Review (a
decision). Enteringpr_openspawns thereviewerrole with a task file built from the decision's rubric (e.g.prompts/review.md, resolved relative to the config file) plus a pointer to the PR. The reviewer writes a verdict file —{"verdict": "...", "feedback": "..."}— and onagent.donethe engine reads it, validates the verdict against the decision's declaredverdicts, and branches. The engine reads a verdict; it never judges. - Changes requested.
changes_requestedresumes the implementer carrying the reviewer'sfeedback, loops back topr_openon a new push, and gives up toescalatedoncepolicies.retry_caps.changes_requestedis exceeded. - Merge gate.
approvedevaluates the merge gate (github_checks+github_reviews+github_mergeable) over one authoritativePRStatusread. If not yet green it parks inblocked_on_gate, which evaluates the gate once and, while it neither passes nor has timed out, suspends — the drive returns and frees its worker slot instead of pinning it for the whole wait, and the scheduler re-drives the task each poll to re-check the gate (status.changedhas no push source). The state timeout is measured from the audit-recorded entry time, so it survives suspend/resume and daemon restarts; past it, the task escalates. - Merge.
mergingruns themerge_praction — gated onpolicies.dry_run(default-on). A dry run logs the intended merge and halts atmerging; withdry_run: falseitgh pr merge --squash, verifies the PR isMERGED(authoritative), and reachesmerged. Merge is reachable only through a gate (a safety invariant) and the side effect itself is gated again bydry_run. Once the merge is confirmed the engine settles the task's bookkeeping: it closes the source issue (the default kickoff'sgh pr create --fillwrites noCloses #Ntrailer, so nothing else would) and deletes the remote branch. Both are best-effort — the merge is already irreversible, so a bookkeeping failure is logged rather than re-driven as a failed merge.
Requires Go 1.26+. The binary is pure Go (no cgo) — a single static binary.
go build ./... # compile everything
go test ./... # run the test suite
go vet ./...
gofmt -l .
# build the CLI into a runnable binary
go build -o orchestratord ./cmd/orchestratordThe commands below assume ./orchestratord is on your PATH; otherwise run them
through the toolchain, e.g. go run ./cmd/orchestratord validate <config>.
Scaffold a working config for your repo — writes pipeline.yaml + prompts/
from the shipped default (readable copy in examples/):
orchestratord init --repo <owner>/<name> [--label agent-ready] [--dir .]Validate a workflow config (JSON Schema + the safety invariants) — no external dependencies, safe to run anywhere:
orchestratord validate examples/default-pipeline.yamlrun and recover drive a real agent and touch GitHub, so they need:
- herdr running, and the process able to reach it — run from inside a herdr
pane, or with
HERDR_SOCKET_PATHpointing at the server socket (echo $HERDR_ENVshould be1inside a pane;echo $HERDR_SOCKET_PATH). ghauthenticated for the target repo — verify withgh auth status. (Confirm it from inside a herdr pane too; PR creation fails silently otherwise.)- A local checkout of the repo the agent will work in, passed as
--repo(absolute path). The engine creates per-task worktrees beside it. - The agent CLI named in the workflow's
roles.*.launchonPATH(defaultclaude). Agents run non-root with no--dangerously-skip-permissions; on a brand-new worktree the agent TUI may prompt to trust the folder. - An issue to work — in the repo your config's
sourcesblock names (set byinit --repo).rundrives the--issuenumber you pass directly; thedaemoninstead polls the sourceselect:label (agent-ready).
Drive one issue through the pipeline (to merged, or merging under the shipped
dry_run: true):
orchestratord run \
--config pipeline.yaml \
--repo /abs/path/to/checkout \
--base main \
--issue 123 \
--db ./orchestrator.db # optional; defaults to ./orchestrator.dbExit code is 0 when the task reaches merged (a real merge) or halts at
merging (a dry run withheld the merge), non-zero otherwise (e.g. escalated).
Task state and a per-transition audit log persist in the --db SQLite file. Two
more optional flags are accepted: --worktrees-dir (parent dir for the per-task
git worktrees; defaults to the repo's sibling) and --task-dir (where task
context files are written; defaults to the system temp dir).
Reconcile and resume in-flight tasks after a restart (crash recovery) — keys on
the deterministic agent/issue-<n> branch and the durable task id, never the
volatile herdr pane id:
orchestratord recover --config <c> --repo /abs/path/to/checkoutThe daemon can expose an optional MCP server so an operator or a
supervising agent can observe its tasks and intervene per-task. It is
off by default; enable it with --mcp-listen:
orchestratord daemon --config <c> --repo /abs/path/to/checkout \
--mcp-listen 127.0.0.1:7777The server runs in-process (sharing the daemon's single store handle and its
scheduler), speaks hand-rolled JSON-RPC 2.0 over HTTP (the request/response
subset of MCP's Streamable HTTP transport) on a single /mcp endpoint, and adds
zero dependencies.
Posture: bind loopback only. There is no auth — the bind address is the trust boundary, so any local process can drive it (including the control tools). Run it only where you trust every local process; do not bind a non-loopback address.
Tools:
| Tool | Args | Does |
|---|---|---|
list_tasks |
— | list every task with its state, branch, PR, retries |
get_task |
issue |
one task's current view |
get_audit |
issue |
a task's state-transition history |
cancel_task |
issue |
cancel the running drive; it settles to cancelled |
enqueue_task |
issue |
(re-)drive an issue by number (idempotent) |
Control semantics. cancel_task / enqueue_task are
dispatch-acknowledged, not completion-acknowledged: the tool confirms the
command reached the scheduler and was actionable, then returns. Observe the
result — a cancelled state, a new PR — via get_task / get_audit. A
cancelled task settles to the reserved cancelled terminal (its worktree is
left in place for inspection, not torn down) and is neither re-driven nor
re-listed. cancel_task on an issue with no active drive is a tool error.
Smoke-test the endpoint with curl:
curl -s 127.0.0.1:7777/mcp -d \
'{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"list_tasks","arguments":{}}}'A workflow is a versioned YAML document — the policy the fixed engine
interprets. It is validated in two stages before anything runs: a JSON Schema
for shape, then seven semantic safety invariants. validate reports both;
run and recover refuse to start on any error.
| File | Role |
|---|---|
internal/config/workflow.schema.json |
JSON Schema (Draft 2020-12) for the config shape; embedded in the binary via go:embed and applied first. The authoritative shape contract. |
internal/config/validate.go |
The runtime validator: applies the schema, then the seven invariants, returning errors + warnings. |
validate_workflow.py (repo root) |
Reference spec for the invariants, kept behaviorally equivalent to validate.go. Runs standalone: python3 validate_workflow.py <config> [--schema workflow.schema.json]. |
examples/default-pipeline.yaml |
The shipped default pipeline (with prompts/ beside it) — what orchestratord init scaffolds, and the starting point when authoring your own. |
internal/config/testdata/broken-pipeline.yaml |
A structurally-valid config that violates the invariants (merge reachable without a gate; an unbounded loop) — used to prove the validator bites. |
spike0.sh (repo root) |
The proven herdr + gh command sequence the herdr backend wraps. |
Top-level keys (version, name, and states are required; unknown keys are
rejected):
| Key | Meaning |
|---|---|
version |
Schema version (integer ≥ 0). |
name |
Workflow name (non-empty). |
entry_state |
The state a new task starts in (used for reachability checks). |
policies |
Workflow-wide knobs (below). |
sources |
Where work originates — github_issues, polled by the daemon on the select: label. |
roles |
Agent profiles a state can spawn/resume. |
gates |
Deterministic predicates over authoritative sources (GitHub). |
decisions |
Constrained judgment hooks with a closed set of verdicts. |
states |
The nodes of the state graph (below). |
policies — max_concurrent_tasks, dry_run, circuit_breaker,
retry_caps (a per-state cap map, state_name: N), blocked_timeout, and
execution (backend: herdr|local|container, run_as: root|non_root,
sandbox: bool). The engine reads these: retry_caps bounds per-state retries
and is validated, dry_run gates the real merge, blocked_timeout bounds
blocking (below), and max_concurrent_tasks bounds the daemon's concurrency
(R2). circuit_breaker and the finer execution knobs (sandbox) are parsed
but not yet enforced.
blocked_timeout (a duration, e.g. 10m; absent ⇒ unbounded) caps how long
an agent may sit continuously blocked before the engine takes the state's
timeout transition, with trigger blocked_timeout in the audit. It exists
because a blocked agent is parked on an interactive prompt nobody will answer —
it will never report done — yet blocking does not change state, and a state
timeout is anchored to state entry. Without this the only lever is shortening
the whole state timeout, which would also kill legitimately long runs. Any
working event clears the clock, so a prompt the agent resolves itself never
counts; idle deliberately does not clear it, because a pane parked at an
unanswerable prompt can report idle. The state timeout remains the hard backstop.
roles — each has launch (argv, required, e.g. ["claude"]),
task_delivery (context_file | inline), workspace (per_task | shared),
and an optional kickoff string.
gates — type is one of github_pr, github_checks, github_reviews,
github_mergeable (the only authoritative sources accepted). Type-specific
fields (head, all_passing, min_approved, require) are allowed alongside.
decisions — impl.type is llm (with a rubric path) or exec (with a
command argv); verdicts is the closed, unique set of outcomes it may return.
states — each state may declare:
entry— an action on arrival:spawn/resumea role (optionallywitha named input), oraction: merge_pr(the only side-effecting entry action).transitions— outgoing edges (below).terminal—success|rejected|needs_human(a leaf; takes no transitions).wait_for— an event the state parks on (e.g.status.changed).alert— surface the state to a human.
A transition carries a when trigger (exactly one of event, timeout
— matching ^[0-9]+(s|m|h)$, decision, or gate), an optional secondary
evaluate (decision or gate, run after an event), and exactly one outcome:
to: <state>— unconditional move;branch: { <key>: <state>, … }— keys are the decision's verdicts, or exactly{pass, fail}for a gate;action: { alert: <name> }— a side action that does not change state.
A gate reference is a single name or a list (every gate must pass).
- Refs resolve — every
spawn/resumerole,decision/gate, andto/branchtarget names a declared entity. - Decisions are total — a transition's branch keys exactly equal the referenced decision's declared verdicts.
- Gate branches are
{pass, fail}. - Gates read authoritative sources only —
github_pr,github_checks,github_reviews,github_mergeable. - Merge is gate-only — entering a side-effecting (
merge_pr) state must be gate-evaluated, never decided by a model or raw event. - Loops terminate — every cycle has a retry cap or a timeout.
- Every non-terminal state has an exit.
Copy default-pipeline.yaml, edit it, and check it — no external services
needed, so it is safe in CI or a pre-commit hook:
orchestratord validate path/to/your-workflow.yaml
# OK: "your-workflow" valid (N warning(s)) -> exit 0
# FAIL: K error(s), N warning(s) -> exit 1 (warnings alone pass)The trigger key is
when, neveron— a bareon:is coerced to the YAML booleantrueand would silently drop the trigger. The schema rejects it.
- Branch names are deterministic:
agent/issue-<n>(the durable reconcile key). - herdr pane ids are volatile — parsed from output, re-resolved on restart, never persisted as a durable key.
- Agents are never launched with
--dangerously-skip-permissions; honorrun_as: non_root+sandbox. - Task handoff is a context file + single-line kickoff, never an inline multi-line prompt typed through the pane.