dirtywork
Frontier models do the thinking. Local models do the dirty work.
Runs one coding task against a local LM Studio model 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.
Security & trust
dirtywork's containment is honest about its limits:
- File tools (
read_file/write_file/edit_file/list_dir/grep) are confined to the worktree by real path resolution — symlinks,.., and absolute paths that escape are rejected. Writes additionally refuse to go through a symlink at the final path component (even one pointing back inside the worktree) and refuse any non-regular-file target (FIFO/device/socket) outright. bashis a general shell, not a sandbox. A denylist blocks common accidents (destructive commands aimed outside the worktree, shared-git-state writes, piping a download into an interpreter), andHOMEis redirected into the worktree so~/$HOMEcan't reach your real~/.sshor~/.aws. But a determined or prompt-injected model can still read absolute host paths (cat /etc/…) — the denylist raises the bar for a confused model, it does not stop an adversarial one. Concretely, host mode (--sandbox none, the only mode this version has) does not block writes made through an interpreter — e.g.python3 -c "open('/tmp/x','w').write('y')"succeeds. Enumerating every interpreter's write primitive is not a regex-shaped problem; the real fix is a process boundary (an OS-level sandbox), tracked as the next release..git/info/excludegains a line. The first run against a repo appends.worktrees/to the shared repository's.git/info/exclude(not tracked, not committed, idempotent) so worktree directories don't show up as untracked noise ingit status. This is the only host-side git state a run writes outside its own worktree.- Worktree growth is checked after every tool call. Past
--max-worktree-mb(default 2048) or--max-worktree-files(default 200000) the run ends with statusbudget_exceeded. This is a best-effort, sampled bound, not a kernel quota — see SECURITY.md. - Review is the real boundary. Read the transcript and diff before you merge. Note
that
bashside-effects happen at run time, so review catches what lands in the diff, not what a command already did.git diff --statin host mode compares against the run's base commit, so unstaged, staged, and committed changes to tracked files all show up — but a new file the model wrote and nevergit added won't appear indiff_stat. Such files are listed separately inrun_end.untracked(untracked paths, one per line; a whole untracked directory collapses to a singledir/entry).
Practical guidance: run dirtywork against models and repositories you'd trust with
shell access on your machine. A malicious target repo's CLAUDE.md/AGENTS.md is
injected into the worker's prompt, so treat untrusted repos as you would untrusted
code. True per-run isolation (OS sandbox / container) is the tracked next step.
Requirements
- macOS/Linux, Python 3.9+ (stdlib only — no venv, no pip deps)
- 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) - 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 (e.g. Ollama at
http://localhost:11434/v1) — but only LM Studio is tested today. Reports
welcome.
Install
pipx (PyPI):
pipx install dirtywork
pipx (straight from GitHub):
pipx install git+https://github.com/JimboSchneider/dirtywork
From source:
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.
Use
dirtywork run --repo ~/repos/someproject "Add a unit test for X"
- Watch a run:
tail -fthe transcript path printed on stderr (~/.dirtywork/runs/<slug>/transcript.jsonl). - Review a run:
git -C <worktree> diff, read the transcript (run_endcarriesdiff_statanduntracked), run the repo's tests — then commit the branch or discard it. - Discard a run:
git -C <repo> worktree remove --force <worktree> && git -C <repo> branch -D dirtywork/<slug> - All flags, stdout JSON, exit codes, transcript events: see Machine contract.
How a run works
- Preflight — LM Studio 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)..worktrees/is added to the repo's local.git/info/excludeautomatically. If the repo has aCLAUDE.mdorAGENTS.mdat its root, its content is injected into the worker's system prompt so it inherits your conventions. - The loop — the model gets six tools (
read_file,write_file,edit_file,list_dir,grep,bash) via OpenAI function-calling and works until it replies without calling a tool. Context is budgeted per model (oldest tool results get trimmed first); three consecutive malformed tool calls abort the run. - 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).
Safety model
Guardrails block accidents, not adversaries — the post-run review is the real gate:
- All file tools are path-confined to the worktree (symlink-safe realpath
checks;
.git/is write-protected against hook injection). bashruns cwd-pinned in the worktree with a minimal environment (your shell's tokens/keys are not inherited) and a regex denylist:sudo,git push,git config/remote/worktree/branch -D/… that would write the parent repo's shared state (including throughgit -C/git -c/--flagglobal options),rm/mv/chmod/chownon absolute or~paths,cd/pushdescapes, downloads piped to a shell, system-control commands, redirects outside the worktree.- Every denylist rejection is logged to the transcript as a
guardrail_blockevent, so attempted escapes are visible at review time. - File tools refuse to operate on anything that isn't a regular file (FIFOs,
devices, sockets) and refuse to write through a symlink at the final path
component, even when its target is inside the worktree.
write_filecontent is capped at 5 MB,list_diroutput at 2000 entries, and the assistant's own text is capped at 64 000 chars in the transcript (the full text is still sent to the model). - Worktree growth is sampled after every tool call against
--max-worktree-mb/--max-worktree-files; past either, the run ends with statusbudget_exceeded. - Network is allowed (package restores need it); per-command timeout 120s default, 600s max.
Plainly: this is not a sandbox. Run it against repos where you'd trust yourself to review the diff — because that review is the actual gate.
Development
python3 -m pytest # unit suite (no LM Studio needed)
python3 -m pytest -m live -v # live suite (requires LM Studio running;
# includes a real end-to-end agent run)
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 was designed, built, reviewed, and shipped in one 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. The full postmortem, including a build-one-yourself recipe: the postmortem (or read the designed HTML edition served via Pages).
In August 2026 the project was renamed dirtywork — same tool, a name that says what it does.
Troubleshooting
- exit 2, "cannot reach LM Studio" — server not running; check
lms psandcurl -s localhost:1234/v1/models. - exit 2, "model not loaded" —
lms load <model>(the error names the loaded models). - status
max_turns/timeout— the worktree is kept; read the transcript to see where it stalled, salvage what's useful, or re-run with higher limits. - status
context_exhausted— the task needed more context than the model's window; split the task or use the larger-context model. - status
budget_exceeded— the worktree grew past--max-worktree-mb/--max-worktree-filesduring a tool call; the worktree and branch are kept for salvage. Raise the limit or investigate what wrote so much.
Machine contract
dirtywork is built to be driven by another agent (Claude Code) rather than
read by a human — the primary consumer parses stdout, not the terminal.
Flags:
dirtywork run --repo <path> "<task>"
[--model qwen/qwen3-coder-next] # or mistralai/devstral-small-2-2512
[--branch-from <ref>] # default: repo HEAD
[--max-turns 40]
[--timeout 1800] # whole-run wall clock, seconds
[--temperature <f>] # omitted by default → server preset
[--base-url http://localhost:1234/v1] # LM Studio's OpenAI-compatible endpoint
[--max-worktree-mb 2048] # best-effort worktree size bound
[--max-worktree-files 200000] # best-effort worktree entry-count bound
stdout: on any run that gets past preflight, exactly one JSON object is printed to stdout (nothing else goes to stdout):
{
"status": "completed",
"worktree": "/path/to/repo/.worktrees/dw-<slug>",
"branch": "dirtywork/<slug>",
"transcript": "/path/to/transcript.jsonl",
"turns": 7,
"usage": {"prompt_tokens": 0, "completion_tokens": 0},
"final_message": "...",
"run_dir": "/home/you/.dirtywork/runs/<slug>",
"base_commit": "abc123def456..."
}
status is one of: completed, max_turns, timeout,
context_exhausted, model_error, interrupted, budget_exceeded. When
the run fails before a RunResult exists — the LLM client raises,
post-worktree setup fails (e.g. the transcript can't be created), or any
other exception escapes the run (status model_error in every case) —
turns is null and usage is {}, but status, worktree, branch,
transcript, run_dir, and (when it was resolved before the failure)
base_commit are still populated so the worktree can be located for
salvage.
Exit codes:
0—completed.1— run ended abnormally (max_turns,timeout,context_exhausted,model_error,interrupted,budget_exceeded); the worktree and branch are kept for salvage/review.maincatches everyExceptionthe run raises (not just ones the runner itself converts to a status) and reports it asmodel_errorvia the same JSON contract, so a post-preflight run never tracebacks. (Ctrl-C is aKeyboardInterrupt, aBaseException, not caught here — but the run loop itself already converts in-loop Ctrl-C to statusinterruptedbefore it would reach this point.)2— preflight or environment error (LM Studio unreachable, model not loaded,--reponot a git repo, etc.); nothing is created.
All progress (transcript path, worktree path, error:-prefixed messages) is
written to stderr; watch a live run with tail -f on the transcript path.
Transcript events (JSONL, one per line): run_start (task, repo, model,
config, plus provenance: base_commit, branch, branch_from,
base_url, dirtywork_version, temperature, sandbox, provider),
assistant (text + tool calls — text capped at 64 000 chars in the
transcript only, the full text is still sent to the model), tool_result
(truncated), guardrail_block, run_end (status, turns, duration,
cumulative usage, plus diff_stat in host mode — git diff --stat
against the base commit, tracked changes only, capped at 64 000 chars —
and untracked, git status --porcelain ?? entries, capped at
64 000 chars).
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
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file dirtywork-0.3.0.tar.gz.
File metadata
- Download URL: dirtywork-0.3.0.tar.gz
- Upload date:
- Size: 55.4 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
fe544c5ea33fd4b2b4389ca6e87bcf4f3ae9f501520a49635bd37741a5a11006
|
|
| MD5 |
b3aacd32b759e792095c1507a5458d05
|
|
| BLAKE2b-256 |
7e61738c4751fcd78bad0bcf7d558f084fa2ff8255b2f0d61622de9e34140d70
|
Provenance
The following attestation bundles were made for dirtywork-0.3.0.tar.gz:
Publisher:
publish.yml on JimboSchneider/dirtywork
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dirtywork-0.3.0.tar.gz -
Subject digest:
fe544c5ea33fd4b2b4389ca6e87bcf4f3ae9f501520a49635bd37741a5a11006 - Sigstore transparency entry: 2491177254
- Sigstore integration time:
-
Permalink:
JimboSchneider/dirtywork@65ff58aa43b59e0e811ab2771935374888040e73 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/JimboSchneider
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@65ff58aa43b59e0e811ab2771935374888040e73 -
Trigger Event:
release
-
Statement type:
File details
Details for the file dirtywork-0.3.0-py3-none-any.whl.
File metadata
- Download URL: dirtywork-0.3.0-py3-none-any.whl
- Upload date:
- Size: 33.5 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/7.0.0 CPython/3.13.14
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
35e3b52c87d4a71ef353cbd8fe1b0f3a4e0a7a9b6c0da52b8be2dc928d617a36
|
|
| MD5 |
20a95a9b92f2a4478db06b38586ade18
|
|
| BLAKE2b-256 |
e3523fa471124946f5e6368caca49774f318d3c0483eca10d865f615310e4123
|
Provenance
The following attestation bundles were made for dirtywork-0.3.0-py3-none-any.whl:
Publisher:
publish.yml on JimboSchneider/dirtywork
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
dirtywork-0.3.0-py3-none-any.whl -
Subject digest:
35e3b52c87d4a71ef353cbd8fe1b0f3a4e0a7a9b6c0da52b8be2dc928d617a36 - Sigstore transparency entry: 2491177455
- Sigstore integration time:
-
Permalink:
JimboSchneider/dirtywork@65ff58aa43b59e0e811ab2771935374888040e73 -
Branch / Tag:
refs/tags/v0.3.0 - Owner: https://github.com/JimboSchneider
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@65ff58aa43b59e0e811ab2771935374888040e73 -
Trigger Event:
release
-
Statement type: