| name | mill-go |
| description | In a spawned worktree with an approved plan, sequentially execute every batch in the plan's DAG. Per batch spawn one implementer Sonnet, run code review, loop with receive-review on REQUEST_CHANGES, halt on stuck. Hand off to mill-merge. |
mill-go
You are the Builder — a lean orchestrator. You coordinate per-batch implementation but never read card bodies or diffs yourself. The Implementer (spawned per batch) reads its own batch file, implements cards, runs verify:, and fixes on receive-review. You read only status.md, the Batch Index DAG in 00-overview.md, and the fenced yaml verdict block of each code review. Keeping your context lean is the whole point — Builder cost is a rounding error next to the Implementer and code-reviewer calls.
Entry
-
Read the task slug: slug = _active.read_slug(Path(".millhouse")). Missing → halt with "this worktree was not created by mill-spawn".
signature: _active.read_slug(mill_dir: Path) -> str
-
Resolve the wiki path: wiki_path = _paths.resolve_wiki_path(_paths.resolve_git_root()). Sync the wiki clone: _wiki.sync_pull(wiki_path, slug=slug).
signature: _wiki.sync_pull(wiki_path: Path, *, slug: str) -> None
-
Load config — deep-merge <wiki_path>/config.yaml with .millhouse/config.local.yaml via _review_common.load_config(wiki_path, Path(".millhouse")). Read these keys:
pipeline.auto_merge — whether to invoke mill-merge after success.
pipeline.auto_report — whether to auto-fire mill-self-report at end-of-work. mill-go fires it at Handoff step 6, AFTER any /mill-merge invocation in step 5 — including after PR-pending halts. See step 6 for the explicit "do not treat PR-pending as termination" rule.
review.code.rounds — max review rounds per batch.
review.code.self_fix_rounds — passed to the implementer brief.
review.code.holistic — if true, run one holistic code review after all batches approve.
review.code.holistic_rounds — max holistic fix rounds (default 1).
review.code.per_batch — if false (missing key defaults to true), skip per-batch code review for all batches.
-
Acquire the builder lock:
uv run --project "$CLAUDE_PLUGIN_ROOT" "$CLAUDE_PLUGIN_ROOT/scripts/millpy-builder-lock.py" acquire <slug>
On exit code 1: surface the stderr message and halt — a second mill-go will corrupt state.
-
Entry phase gate. Set status_path = Path("task/status.md").resolve() and inspect the phase:
status = _status.read_full(status_path)
phase = status["yaml"]["phase"]
blocked_reason = status["yaml"].get("blocked_reason")
signature: _status.read_full(status_path: Path) -> {"yaml": dict, "timeline": list[str]}
| phase | action |
|---|
planned | fresh run — continue to Prepare |
implementing / reviewing / fixing | resume (see Resume) |
blocked | surface blocked_reason from status.md and halt |
discussed / discussing / planning | tell user to finish mill-plan and halt |
done | tell user the task is complete; suggest /mill-merge if auto-merge was off |
| any other | surface + halt |
-
Read the plan overview: overview_path = Path("task/plan/00-overview.md").resolve(). Confirm approved: true in the frontmatter. Extract the Batch Index via _plan_dag.extract_batch_index(overview_text), validate via _plan_dag.validate(batches, sorted(p.name for p in plan_dir.glob("??-*.md") if p.name != "00-overview.md")) where plan_dir = Path("task/plan/").resolve(), then compute order = _plan_dag.topo_order(batches).
signature: _plan_dag.extract_batch_index(overview_text: str) -> list[dict]
signature: _plan_dag.validate(batches: list[dict], batch_files: list[str]) -> None
signature: _plan_dag.topo_order(batches: list[dict]) -> list[str]
If mill-go is interrupted mid-run, re-run /mill-go — it will auto-reclaim the builder lock for the same task (stale-self-lock detection is built in).
Prepare
On a fresh run only (no ## Batches section in status.md):
_status.init_batches(status_path, order) — seeds every batch at state: pending.
signature: _status.init_batches(status_path: Path, names: list[str]) -> None
_status.append_phase(status_path, "implementing", _timestamp.now_utc_iso()).
signature: _status.append_phase(status_path: Path, phase: str, timestamp: str) -> None
signature: _timestamp.now_utc_iso() -> str
- Commit on the task branch:
git -C <worktree> add task/status.md && git -C <worktree> commit -m "mill-go: prepare for {slug}".
Execute — sequential loop
For each batch in order:
1. Implement
2. Parse implementer report
The implementer's last output line must be JSON:
{"status":"success|stuck","commit_sha":"...","session_id":"...", ...}
status: success → continue to Code Review.
status: stuck, stuck_type: transient → auto-retry ONCE by re-invoking millpy-implement.py <batch_name> (no --resume flag — a fresh batch start). Record review_round: 0, do not change batch state. If the second invocation also reports stuck_type: transient → escalate per Stuck escalation below.
status: stuck, stuck_type: verify | logic → ask user per Stuck escalation.
- Malformed / missing JSON line → treat as
stuck_type: logic reason "no structured report".
Record commit_sha from a successful report on the batch entry.
2b. Cleanliness gate
After a success report: compute new dirt via _cleanliness.compute_new_dirt(<worktree>, <worktree>/task/.cleanliness-snapshot-<batch_name>.txt). If the returned list is non-empty (genuine implementer-introduced dirt that did not pre-date the batch):
_status.set_batch_field(status_path, batch_name, "state", "blocked")
_status.set_batch_field(status_path, batch_name, "blocked_reason", "uncommitted working tree after implementer report")
_status.append_phase(status_path, "blocked", _timestamp.now_utc_iso())
- Commit on the task branch:
git -C <worktree> add task/status.md && git -C <worktree> commit -m "mill-go: blocked on <batch_name> — dirty tree"
- Go to Blocked.
signature: _cleanliness.compute_new_dirt(worktree: Path, snapshot_path: Path) -> list[str]
If the returned list is empty, continue to "3. Code Review loop" as normal.
3. Code Review loop
If review.code.per_batch is false: set batch state → approved, _status.append_phase(status_path, f"approved-{batch_name}", _timestamp.now_utc_iso()), commit on the task branch: git -C <worktree> add status.md && git -C <worktree> commit -m "mill-go: approve batch {batch_name} (per-batch review disabled)", and continue to the next batch. Skip the rest of this section.
- Set batch state →
reviewing, review_round: 1.
_status.append_phase(status_path, f"reviewing-{batch_name}-r1", _timestamp.now_utc_iso()).
extra_files = [].
For each round N from 1 to review.code.rounds:
-
Crash-recovery check. Before firing the CLI, scan Path("task/reviews").resolve() for a file matching *-code-review-{batch_name}-r{N}.md. If found, treat it as this round's review file — parse its verdict from the fenced yaml block via _review_common.parse_verdict(file_content) and skip to step 4 below. This covers the case where mill-go crashed after writing the review but before committing state.
signature: _review_common.parse_verdict(text: str) -> str
-
Background via millpy-bg:
uv run --project "$CLAUDE_PLUGIN_ROOT" "$CLAUDE_PLUGIN_ROOT/scripts/millpy-bg.py" \
--slug review-code-<batch_name>-r<N> -- \
uv run --project "$CLAUDE_PLUGIN_ROOT" "$CLAUDE_PLUGIN_ROOT/scripts/millpy-review-code.py" \
--batch <batch_name> [--extra-file <p> ...]
Returns immediately with pid=<N> log=<abs-path>. Do not use run_in_background: true. Poll cat <log-path> until [mill-bg] EXIT appears, then extract the JSON summary line (last non-empty, non-sentinel line). The CLI prints one JSON line {"type":"code","round":N,"verdict":"...","reviews":[...]}.
-
Before reading any review file, load the mill-receiving-review skill. Non-negotiable.
-
Branch on verdict:
-
Max-rounds exhaustion. After review.code.rounds rounds without APPROVE: _notify.notify("mill-go.review-exhausted", f"batch {batch_name}", slug=slug, rounds=N), set batch state → blocked, blocked_reason: "review rounds exhausted", _status.append_phase(status_path, "blocked", _timestamp.now_utc_iso()), commit on the task branch: git -C <worktree> add task/status.md && git -C <worktree> commit -m "mill-go: blocked on {batch_name} after {N} rounds". Go to Blocked below.
Stuck escalation
If the deep-merged config has pipeline.autonomous_mode: true: for any stuck_type (transient already-retried, verify, logic): skip the user prompt; set batch state → blocked, blocked_reason: "autonomous-mode stuck: {stuck_type}", _status.append_phase(status_path, "blocked", _timestamp.now_utc_iso()); commit git -C <worktree> add task/status.md && git -C <worktree> commit -m "mill-go: blocked on {batch_name} (autonomous-mode)" and push; go to Blocked.
- CLI emits
stuck_type: transient (LLM-layer failure surfaced as the synthetic stuck JSON described in Implement step 2; the CLI exits 1 in that case but stdout carries the JSON) → apply the one-retry policy: re-invoke millpy-implement.py <batch_name> once with no --resume flag (a fresh session). If the second invocation also reports stuck_type: transient, escalate to user with the regular transient three-option prompt (retry fresh, edit plan and retry, block).
transient (already retried once) → surface to user with three options: retry fresh, edit plan and retry, block. User picks.
verify / logic → surface to user with three options: edit plan to clarify then retry fresh, skip this batch (block the task), block the task. User picks.
- On user-chosen block: set batch state →
blocked, blocked_reason: <reason>, _status.append_phase(status_path, "blocked", _timestamp.now_utc_iso()), commit on the task branch: git -C <worktree> add task/status.md && git -C <worktree> commit -m "mill-go: blocked on {batch_name}". Go to Blocked.
Blocked
Holistic code review
Guard: Only execute this section if cfg.get("review", {}).get("code", {}).get("holistic", True) is truthy.
max_holistic_rounds = cfg.get("review", {}).get("code", {}).get("holistic_rounds", 1). Loop variable H starts at 1. extra_files = [].
For each round H from 1 to max_holistic_rounds:
-
Crash-recovery. Scan reviews/ for a file matching *-code-review-r{H}.md (holistic code review files have format {ts}-code-review-r{N}.md — no batch-name segment, no -holistic- substring; per-batch files embed {batch_name} so the glob never collides). If found, skip the CLI and use that file's verdict directly.
-
_status.append_phase(status_path, "holistic-reviewing", _timestamp.now_utc_iso()). Commit: git -C <worktree> add status.md && git -C <worktree> commit -m "mill-go: holistic reviewing round {H}".
-
Background via millpy-bg:
uv run --project "${CLAUDE_PLUGIN_ROOT}" "${CLAUDE_PLUGIN_ROOT}/scripts/millpy-bg.py" \
--slug review-code-holistic-r{H} -- \
uv run --project "${CLAUDE_PLUGIN_ROOT}" \
"${CLAUDE_PLUGIN_ROOT}/scripts/millpy-review-code.py" \
[--extra-file <p> ...]
Include any accumulated extra_files from prior NEED_CONTEXT rounds via --extra-file <p> (one flag per path). Poll and extract JSON as per the per-batch pattern.
-
On APPROVE: _status.append_phase(status_path, "holistic-approved", _timestamp.now_utc_iso()). Commit status. Proceed to Handoff.
-
On REQUEST_CHANGES: Load mill-receiving-review before reading any finding. Dispatch:
uv run --project "${CLAUDE_PLUGIN_ROOT}" \
"${CLAUDE_PLUGIN_ROOT}/scripts/millpy-implement-holistic.py" \
--review-file <abs-path-to-holistic-review-file> --round {H}
Parse stdout JSON (same last-{"status":...}-line pattern as per-batch). The CLI handles holistic-fixing phase + commit + push itself.
stuck_type: transient: one-retry policy (re-invoke once). If still transient: surface to user — retry fresh / skip holistic / block task.
stuck_type: verify or logic: surface to user — edit plan and retry / skip holistic and proceed to Handoff / block task.
- On success: increment H and loop.
-
On NEED_CONTEXT: apply the same extra-files / notify path as per-batch.
-
Rounds exhausted (H > max_holistic_rounds, REQUEST_CHANGES still returned): If the deep-merged config has pipeline.autonomous_mode: true: _status.append_phase(status_path, "blocked", _timestamp.now_utc_iso()); _status.update_field(status_path, "blocked_reason", f"holistic review exhausted {max_holistic_rounds} round(s) (autonomous-mode)"); commit git -C <worktree> add task/status.md && git -C <worktree> commit -m "mill-go: blocked on holistic review (autonomous-mode)" and push; halt with "Autonomous mode: holistic review exhausted. Task left as [active]." surface to user with a blocked-task halt (not blocked-batch):
Holistic review exhausted {max_holistic_rounds} round(s). Task is blocked.
- Rethink — revise discussion and re-run mill-plan.
- Skip holistic — accept remaining findings and proceed to Handoff.
- Block — halt and leave for manual resolution.
Wait for user choice before proceeding.
Handoff
_status.append_phase(status_path, "done", _timestamp.now_utc_iso()). Commit on the task branch: git -C <worktree> add task/status.md && git -C <worktree> commit -m "mill-go: done {slug}".
- Flip Home.md's task line to
[done]:
home_path = wiki_path / "Home.md"
with _wiki.wiki_lock(wiki_path, slug):
_tasks_md.set_phase_at(home_path, slug, "done")
_wiki.write_commit_push(wiki_path, ["Home.md"], f"task: complete {slug}", slug=slug)
signature: _tasks_md.set_phase_at(path: Path, slug: str, phase: str | None) -> None
signature: _wiki.wiki_lock(wiki_path: Path, slug: str) -> ContextManager[None]
signature: _wiki.write_commit_push(wiki_path: Path, paths: list[str], msg: str, *, slug: str) -> None
The lock-context wraps the read-modify-write atomically; set_phase_at does the read+transform+write itself; write_commit_push acquires the lock internally but the counter from wiki_lock makes that a no-op.
_notify.notify("mill-go.done", f"task {slug} complete", slug=slug).
- Release the builder lock immediately:
uv run --project "$CLAUDE_PLUGIN_ROOT" "$CLAUDE_PLUGIN_ROOT/scripts/millpy-builder-lock.py" release
- If
pipeline.auto_merge: true → invoke /mill-merge. Otherwise tell the user: "Task complete. Run /mill-merge to merge the task branch back to parent." mill-merge may halt on pr-pending in PR mode (git.require-pr-to-base: true) — that is a skill-level halt and is expected; treat it as completion of step 5 and continue to step 6.
- If
pipeline.auto_report: true → invoke /mill-self-report --auto. Always fires at the end of Handoff, including after a pr-pending halt in step 5 — do NOT treat the PR-pending message as task termination. The skill checks gh auth itself and bails cleanly if absent. mill-merge itself does not self-report — only the orchestrator (mill-go) does. Cross-thread merges and post-PR teardowns are not auto-reflected; user can run /mill-self-report manually if wanted.
Principles
- Lean Builder. You never read card bodies, diffs, or source files unless responding to a stuck-logic event on a specific batch. Your context stays small by design — this is what lets Opus be a legitimate Builder choice.
- Implementer owns receive-review. On
REQUEST_CHANGES the implementer (not Builder) loads mill-receiving-review and applies findings. Builder passes a pointer to the review file; the implementer's warm session already knows the code.
- Commits go through
git-commit. implementer-brief.md already instructs this, but enforce it if the implementer asks for confirmation: every per-card commit invokes the git-commit skill so lint + codeguide-update run per-commit. Batch N+1's implementer then reads a codeguide that already reflects batch N's additions.
- One task per worktree. The builder lock enforces this at runtime. Do not attempt to relax it.
- Never guess when stuck. Surface to the user with concrete options; don't invent a recovery.
- Review files are the ground truth. Verdict parsing reads only the fenced yaml block; the
## Findings body is the implementer's job to read, not yours.
- Helper signatures are documented inline. Every helper this skill names has an explicit one-line signature in the section that calls it. Never Read or Grep the helper source — the signature is here, and any failure surfaces as an exception. (See
mill:workflow for the project-wide rule.)
Board discipline
task/status.md, task/reviews/<file>, and task/plan/<file> writes are committed on the task branch via git -C <worktree> add ... && git -C <worktree> commit. millpy-implement.py pushes its own task-branch state commits (batch-start, fix-cycle) to origin/<task-branch> immediately after each git commit. The Builder's own state commits (Prepare, Approve, blocked, done) and per-card implementer commits do not push — mill-merge pushes the full task branch at task end. Adding push to the Builder's own commits is a follow-up task; this PR scopes the push policy to CLI commits only.
- Home.md writes (the Handoff
[done] flip) go through _wiki.write_commit_push(..., slug=...) inside a with _wiki.wiki_lock(wiki_path, slug): block. The wiki helpers acquire the lock internally; the context manager makes the read-modify-write atomic.
- Phase transitions via
_status.append_phase; batch-state mutations via _status.set_batch_field. Hand-editing either yaml block is banned.
- The path-invariant rule from CLAUDE.md is load-bearing: working state never goes to the wiki — only Home.md / _Sidebar.md do.