dirtywork
Frontier models do the thinking. Local models do the dirty work.
Runs one coding task against a local model (LM Studio or Ollama) in an agentic
tool-use loop, inside an isolated git worktree. Built to be driven by an orchestrating
agent (Claude Code, in our case) — the expensive frontier model orchestrates
and reviews, the free local model grinds. Humans watch with tail -f.
The division of labor:
| Role | |
|---|---|
| Orchestrator (a frontier model, e.g. Claude Code — or you) | Picks the task, invokes dirtywork, reviews the worktree diff and transcript, commits/PRs what survives review |
| Worker (local model, via dirtywork) | Explores the repo, edits files, runs builds/tests — file tools are confined to the worktree; bash is a real shell (see Security & trust) |
File edits go through path confinement into an isolated git worktree, and nothing
the worker produces merges without your review. But the worker can run bash, and
a shell is a shell — read Security & trust before pointing this
at a model or repo you don't trust. Parallelism comes from launching multiple
processes — LM Studio serves 4 concurrent requests per model.
Developed and benchmarked on macOS/Apple Silicon; CI-tested on Linux and macOS (Windows unsupported — see below).
Documentation
- Orchestrator setup — put Claude Code (or any
agent) in the driver's seat: LM Studio, Docker,
pipx install,dirtywork init, the review loop. - Operating guide — running a task, resuming,
reviewing, the
runssubcommands, benchmarking, troubleshooting. - Machine contract — every flag, the stdout
JSON schema, exit codes, transcript events;
dirtywork contractprints the installed version's copy. - Security — the Docker containment model, known
exposures, and
--sandbox none's host-mode caveats. - Transcript schema — the full
field-by-field JSONL/
run.jsonreference. - Worker image — building, publishing, and deriving the sandbox's Docker image.
Security & trust
Docker is the default sandbox as of 0.4 — a breaking change from 0.2.
Every tool call (read_file/write_file/append_file/edit_file/apply_edits/
insert_before/insert_after/list_dir/grep/bash) runs inside a
locked-down container: --network none by default,
--read-only root filesystem, --cap-drop ALL, kernel-enforced memory/CPU/
process-count/per-file-size limits, and no host path mounted in except the
parent repository's read-only git object store.
Full model, known exposures and the host-mode caveats: docs/security.md.
Platform support
| Tier | Platform | What it means |
|---|---|---|
| Developed & benchmarked | macOS on Apple Silicon (M-series, unified memory) with LM Studio | all worker/bench numbers and model-sizing guidance in docs/superpowers/bench/ were measured here |
| CI-tested | Linux x86_64 (Ubuntu, Python 3.9 + 3.13) and macOS | unit suite on every push; the Docker sandbox live tests run on Linux in CI |
| Unsupported | Windows | the unit suite runs on windows-latest in CI as an advisory (allowed-to-fail) job with a per-file pass/fail/error/skip table and, since #96 tier 1, runs to completion — the three POSIX-only crashes (killpg, O_NOFOLLOW, os.kill(pid, 0)) have Windows branches; Windows remains unsupported until an integration suite passes (Docker mode there is untested) (see the note in Security & trust); the plan is #96; WSL2 is the way in — note below |
| Windows: use WSL2 (one report so far, 2026-08-27: runs cleanly — more welcome). Native | ||
| Windows no longer crashes on the three POSIX-only calls (0.13.0, #96 tier 1), but the unit suite | ||
| still fails there in ~158 places — the tier-2 list on | ||
| #96 — so it stays unsupported. Inside | ||
| WSL2 it's the CI-tested Linux path. What to get right — the first report hit the third and fourth: |
- Keep the repo and
~/.dirtyworkon the distro's own filesystem (~/repos/…), not/mnt/c/…— drvfs is slow for git and its permission and symlink semantics differ. - Your model server is on the Windows side. Either enable
mirrored networking
(
networkingMode=mirroredunder[wsl2]in%UserProfile%\.wslconfig, Windows 11 22H2+) solocalhostreaches it, or pass--base-urlwith the Windows host's IP on the WSL network — and make the server listen on all interfaces (LM Studio: enable serving on the local network in its server settings; Ollama:OLLAMA_HOST=0.0.0.0). - Docker: Docker Desktop with WSL integration turned on for that distro, or Docker Engine
installed inside it.
docker infomust succeed inside the distro before you start; if it doesn't, that is the whole problem. - Load the model with enough context. dirtywork reads the loaded context length from the server and refuses to start (exit 2) when it is too small for a run — reload the model with a larger context in LM Studio rather than working around the check.
- Run Claude Code inside WSL too, so
dirtywork init, the skill and thedirtyworkcommand are all on the same side.
Pick a worker with a record. qwen/qwen3-coder-next (the default) and
mistralai/devstral-small-2-2512 are the benchmarked workers. gpt-oss models on LM Studio have
known tool-call parser issues — empty replies and partial file writes are the symptom, not a scope
problem — see the
local-model research note.
dirtywork bench measures any model before you trust it with a task. And run with --verify: the
worker's summary is never the receipt; the verify command and the diff are.
Other OpenAI-compatible servers (vLLM, llama.cpp) should work via
--base-url/--provider. LM Studio (--provider openai) and Ollama
(--provider ollama) are both exercised — recorded-fixture contract tests for
each, plus an opt-in live smoke per server; the Anthropic API adapter
(--provider anthropic) has recorded-fixture tests and no live tests. Parallel
tool calls are unverified on Ollama.
Requirements
- macOS/Linux, Python 3.9+ (stdlib only — no venv, no pip deps)
- Docker Desktop or dockerd (default sandbox as of 0.4) —
docker versionmust succeed. Missing/unreachable Docker is a preflight error with a hint; pass--sandbox noneto skip this requirement and run unsandboxed on the host instead. The worker image (ghcr.io/jimboschneider/dirtywork-worker:0.13, pulled on the first run) ships git, bash, Python 3, Node 22 with npm, .NET SDK 8 and 10, ripgrep, jq, curl and shellcheck. Nothing can be installed at run time — the sandbox is offline — so anything else means a derived image (see the worker image guide). - LM Studio serving its OpenAI-compatible API at
localhost:1234with a tool-calling-capable model loaded. Verified working:qwen/qwen3-coder-next(65k context, default) andmistralai/devstral-small-2-2512(32k context). One slot loaded with the largest context your machine holds beats several smaller ones — see Sizing the context window for the measured numbers --provider anthropicneeds theANTHROPIC_API_KEYenvironment variable set; the default (--provider openai, LM Studio or any OpenAI-compatible server) and--provider ollamaneed no key. If the OpenAI-compatible server does require one, setOPENAI_API_KEYand it is sent as a Bearer token; unset, nothing is sent.--provider ollamatalks tohttp://localhost:11434/v1and asksGET /api/pswhat context length the model is actually loaded with. Runollama run <model>first — Ollama lists pulled models, not resident ones, so an unloaded model passes preflight and then gets whatevernum_ctxOllama picks. Model ids include the tag (gemma4:latest).- The target repo must be a git repo with at least one commit
Other servers: anything speaking the OpenAI chat-completions API with tool
calling should work via --base-url. Ollama has its own --provider ollama
as of 0.10 (default base URL http://localhost:11434/v1, with a real
loaded-context probe) — see Platform support for what's
actually exercised by the test suites. Reports welcome.
Install
Recommended — pipx (PyPI):
pipx install dirtywork
Update later with pipx upgrade dirtywork, then run dirtywork init again
(why: below). pipx keeps dirtywork in its own venv and puts a dirtywork
shim on your PATH, so it can't collide with your projects' dependencies. If
you don't have pipx, see the
pipx install docs (macOS:
brew install pipx && pipx ensurepath).
Use it from Claude Code — or Codex
dirtywork init --repo .
installs the dirtywork skill for Claude Code — in your home (~/.claude/skills/dirtywork/) and,
with --repo, in the project — so Claude knows how to brief a run, read its JSON, review the
worktree, resume with feedback and record a verdict. Ask it to delegate a task to dirtywork. Codex
CLI, Gemini CLI, Cursor and Copilot read the same file from one shared directory:
dirtywork init --agent codex --repo . writes it to .agents/skills/dirtywork/ (home and project),
and --agent gemini|cursor|copilot write those same two files. Codex CLI has driven a real run this
way (one report, 2026-08-29: brief → run → host verify → verdict on a Vite project; the worker image
needed Node 22 for it, hence 0.13). Gemini CLI, Cursor and Copilot read the same file — not yet
tried here; reports welcome. The full reference is dirtywork contract. The skill's run template
carries --provider/--base-url, so Ollama and other endpoints need no hand edits. The
walkthrough, from LM Studio to the first delegated task:
Orchestrator setup.
init configures nothing. It writes one Markdown file that Claude Code loads by name;
contract only prints. Which model, provider, sandbox and image a run uses are flags on
dirtywork run, every time — there is no config file. If both a personal and a project copy exist,
Claude Code loads the personal one, so put your own tweaks (a default --model, say) in
~/.claude/skills/dirtywork/SKILL.md, not the committed copy.
Re-run dirtywork init after every upgrade. The skill is a file on disk, written by the
version that installed it. pipx upgrade replaces the wheel and leaves that file alone, so until
you re-run init, Claude is driving the new release with the old release's instructions — flags,
JSON fields and exit codes it describes may no longer match. init is safe to repeat: it refreshes
a copy you haven't edited, leaves a current one alone, and refuses to overwrite one you changed
unless you pass --force; it prints one line per file saying which.
pipx (straight from GitHub, unreleased main):
pipx install git+https://github.com/JimboSchneider/dirtywork
From source (for hacking on dirtywork itself):
git clone https://github.com/JimboSchneider/dirtywork
cd dirtywork
chmod +x bin/dirtywork
ln -sf "$PWD/bin/dirtywork" ~/.local/bin/dirtywork
The launcher is self-locating, so this works from any clone location. Note
that whatever branch or working tree is checked out becomes "the tool" —
prefer the pipx install for day-to-day use and run bin/dirtywork from the
clone explicitly when testing unreleased changes.
Use
dirtywork run --repo ~/repos/someproject "Add a unit test for X"
- Watch a run:
tail -fthe transcript path printed on stderr. - Review a run:
git -C <worktree> diff, read the transcript, run the repo's tests — then commit the branch or discard it. - Clean up a run:
dirtywork runs clean <slug>.
The review→fix loop
dirtywork resume <slug> --feedback "…"— send the same worktree back to the worker with your review notes.dirtywork run --branch-from @<slug> "…"— start a fresh run from what an earlier run produced.dirtywork runs snapshot <slug>— commit a run's worktree onto its own branch by hand, without a full resume.--verify "<cmd>"— make your gate the harness's gate; a failing command comes back to the worker as a fix round before the run ends.
Everything else — resuming, the runs subcommands, benchmarking,
troubleshooting: docs/operating.md; every flag, the
stdout JSON and exit codes: the machine contract.
How a run works
- Preflight — model server reachable, model loaded, repo valid. Any failure exits 2 with nothing created.
- Worktree — a fresh worktree at
<repo>/.worktrees/dw-<slug>on new branchdirtywork/<slug>, branched from--branch-from(default: repo HEAD). In docker mode (the default) the worktree stays empty (only its.gitfile) for the whole run — the worker's tree lives on a Docker volume and reaches the worktree only via the validated export after the run ends..worktrees/is added to the repo's local.git/info/excludeautomatically. If the repo has aCLAUDE.mdorAGENTS.mdat its base commit, its content is injected into the worker's system prompt so it inherits your conventions. - The loop — the model gets eleven tools (
read_file,write_file,append_file,edit_file,apply_edits,insert_before,insert_after,list_dir,grep,bash,finish) via OpenAI function-calling.append_fileadds text verbatim to the end of an existing file, so a file larger than one reply iswrite_filefor the first part andappend_filefor each part after it — the recovery a truncatedwrite_fileis now told to use by name, in a message that states the--max-tokenscap and a chunk size to stay under (1.0, #65).insert_before/insert_afteradd whole lines around a unique anchor without touching the anchor's own line — the primitive for "add a line here", whichedit_filecould only express as a replace.apply_editstakes a brief's whole numbered list of exact replacements to one file in a single call, applied in order, all-or-nothing: if anyolddoes not match exactly once at its turn, nothing is written and the result names the first failure. Since 0.10 every file write is staged in a temp file and promoted atomically, so a run killed mid-write leaves the file byte-identical instead of truncated. Every successfuledit_file/apply_edits/write_file/append_file/insert_*result echoes a capped unified diff of what actually changed, so a replace that silently deleted a line is visible to the worker in the same turn — exceptwrite_fileon a NEW file, which has nothing to diff against and reports its byte and line count instead. Context is budgeted per model — dirtywork asks the server what window it actually loaded and reports both the value and its source, and the payload'strimmed_turnssays on how many turns the oldest tool results had to be dropped to fit. Three consecutive tool failures of one kind (malformed call, malformed arguments, unknown tool, bad arguments, empty reply) or six in total abort the run, or six cut-off replies at--max-tokens(1.0, #65). The model ends a run by calling thefinish(summary=...)tool (a plain reply with no tool call also ends it); an empty, think-only, or truncated reply, or a tool call written as text, is sent back with a one-line nudge instead of being treated as completion. - No auto-commit — changes stay uncommitted in the worktree; the
transcript lands at
~/.dirtywork/runs/<slug>/transcript.jsonl(outside the worktree, so it can never pollute the diff).
Development
python3 -m pytest # unit suite (no LM Studio or Docker needed)
python3 -m pytest -m live -v # live suite (requires LM Studio running;
# includes a real end-to-end agent run)
python3 -m pytest -m docker -v # docker suite (requires a running Docker
# daemon; host-sentinel and lifecycle tests)
python3 -m pytest -m ollama -v # ollama suite (requires a running Ollama
# server; opt-in, same shape as -m live)
Design docs: docs/superpowers/specs/2026-08-13-localagent-design.md
(architecture and contracts) and
docs/superpowers/plans/2026-08-14-localagent.md (implementation plan).
The story
dirtywork's first version was designed, built, reviewed, and shipped in a
single day — by the exact orchestrator/worker pattern it implements — and
its first production run surfaced a real cent-level rounding bug in the
invoicing app it was pointed at. That was v0.1. Since then the work has been
the unglamorous kind: hardening the host mode (0.3), putting the worker in a
container (0.4), and running the tool against its own security plans, task
by task, with a frontier model planning and reviewing, a local model doing
the typing, and every decision on the record — reviews, ledgers, a
scoreboard, and a release gate that runs on real Docker. The postmortems:
building localagent,
the tool renamed itself,
and the process record for the sandbox work in
docs/superpowers/bench/.
In August 2026 the project was renamed dirtywork — same tool, a name that says what it does.
Contributing
Issues and PRs welcome. Ground rules:
- Runtime stays stdlib-only — that zero-dependency install is a feature, not an accident. Dev-only dependencies (pytest) are fine.
python3 -m pytestmust be green; if your change touches the model-facing path, run the live suite too (python3 -m pytest -m live -v, needs a running LM Studio).- Tool functions never raise; the client raises
LLMErroronly; stdout is exactly one JSON object post-preflight. These contracts have tests — keep them green.
License
MIT © 2026 Dirt Simple Solutions, LLC
Metadata
Release files for dirtywork 0.13.2
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| dirtywork-0.13.2.tar.gz | 450.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dirtywork-0.13.2-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 676.1 kB
Release files / dirtywork-0.13.2.tar.gz
| Download URL | dirtywork-0.13.2.tar.gz |
|---|---|
| Size | 450.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
ba8e97d821eedb16cd2789107789d89d28ea27d939f059ea1f442c05a69be0ea
|
|
BLAKE2b-256 checksum How to use checksums |
c388c5a97390f26dd6b5b024df0493b0348c4cfd259d3584c7b67cbce8176912
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency logRelease files / dirtywork-0.13.2-py3-none-any.whl
| Download URL | dirtywork-0.13.2-py3-none-any.whl |
|---|---|
| Size | 225.7 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
aed53a61ca2ed828e72b6d7a9d85824a9556e972750fbe5efcc16adfd7901a60
|
|
BLAKE2b-256 checksum How to use checksums |
632a7d5338f4a3c51a548efb81fa35cf99b063f0afc713efb1d3d0ad947706fe
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 9, 2026.
Transparency log