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.
Developed and benchmarked on macOS/Apple Silicon; CI-tested on Linux and macOS (Windows unsupported — see below).
Security & trust
Docker mode protects host integrity and host execution, not repository-history confidentiality. The worker can read the entire parent object store (all branches, other worktrees' objects, unreachable objects). Do not run it on a clone whose history holds secrets you would not show the worker.
Windows: designed for Docker Desktop on Windows; not supported until a Windows integration suite passes. Items that need real Windows testing: Git for Windows paths and
\\?\handling,dockerCLI behavior, uid1000:1000, symlink-as-file export, case-insensitivity, long paths,core.symlinks=false,core.longpaths.
Docker is the default sandbox as of 0.4 — a breaking change from 0.2.
Every tool call (read_file/write_file/edit_file/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. The worker's tree lives on
a Docker volume, never a bind mount, so host git never touches worker
content — a hostile .gitattributes plus a local git filter cannot execute
on the host. The tree reaches your worktree only after the run ends,
through a validated tar export (dirtywork/sandbox/export.py): file/dir/
symlink members only, path-escape and .git-named-entry checks, count and
byte caps, extraction that never calls tarfile.extract()/extractall().
Docker missing or the daemon down is a preflight error (exit 2, with a
hint) — there is no silent fallback to unsandboxed execution. The export
step itself (which computes the diff and extracts the final tree, in a
fresh container) always runs with --network none, even when
--allow-network was passed for the worker.
What Docker mode does not give you:
- Confidentiality of repository history (see the callout above).
- A portable disk quota. Total disk is a best-effort bound: worktree size is sampled during commands and a host free-space floor is polled, but a burst inside one sampling interval (0.5-5 s) can exceed the limit before the container is killed. The exported tree is hard-capped by the validator regardless.
- Git-ignored files in the exported worktree. Build outputs
(
node_modules,bin/obj,.venv) stay inside the container's volume and are never exported — only the git-visible tree is.--keep-volumeplusdocker runagainst the volume recovers them if you need to. Non-Windows.
--sandbox none is the explicit opt-in to pre-0.4 host-mode behavior:
tools are path-confined to the worktree (symlink-safe realpath checks,
.git/ write-protected), but bash is a general shell gated only by a
best-effort regex denylist and a HOME redirected into the worktree. A
determined or prompt-injected model can still read absolute host paths
(cat /etc/...). Use it only against models and repositories you would
trust with unconfined shell access on your machine — the same caveat 0.2
carried, unchanged.
Residual exposures (documented, accepted, both modes where relevant):
- Object-store confidentiality (docker mode) — see the callout above.
- Escaping symlinks (committed in the base tree, or created by the worker)
are created on the host inside the worktree; dirtywork never follows
them and lists them in
run_end.escaping_symlinks. Anything else you run in that worktree afterward must not follow symlinks blindly. - Host
git status/diff/add/mergethat you run afterward use your own git config; a worker-authored.gitattributescan trigger a configured filter (git-lfs and similar). Review~/.dirtywork/runs/<slug>/diff.patchinstead (the container-computed patch — no host git ever touches worker content for that path) or withGIT_CONFIG_GLOBAL=/dev/null. - A malicious target repo's
CLAUDE.md/AGENTS.md(read from the base commit via git, not the filesystem — symlinks and oversized files are rejected) is injected into the worker's prompt; treat untrusted repos' documentation as you would untrusted code.
Practical guidance: run dirtywork against models and repositories you'd trust with the equivalent of a locked-down container on your machine. Read the transcript and diff before you merge — that review is still the real gate for what a run produced, even though docker mode now also gates what a run could do to your host while producing it.
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 | until a Windows integration suite passes (see the note in Security & trust) |
Other OpenAI-compatible servers (Ollama, vLLM, llama.cpp) should work via
--base-url/--provider; only LM Studio and the Anthropic API adapter
(--provider anthropic, recorded-fixture tests, no live tests) are
exercised by the test suites.
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. - 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) --provider anthropicneeds theANTHROPIC_API_KEYenvironment variable set; the default (--provider openai, LM Studio or any OpenAI-compatible server) needs no key.- 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) — see Platform support for
what's actually exercised by the test suites. 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"
See Security & trust — docker mode does not protect repository-history confidentiality.
-
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. (The worktree is only populated after the run ends, once the export step completes.) -
Clean up a run:
dirtywork runs clean <slug>— see Inspecting, cleaning up and re-exporting runs for the safety rules and the rest of therunssubcommands. -
All flags, stdout JSON, exit codes, transcript events: see Machine contract.
Resuming a run
A run that ended early (max_turns, stalled, timeout, interrupted, a
crash) keeps its worktree. Continue it on the same worktree and branch:
dirtywork resume <slug> # slug from the run's stdout JSON / ~/.dirtywork/runs
dirtywork resume ~/.dirtywork/runs/<slug> --max-turns 60
A resume is a new run (new slug, transcript, run dir, and — in docker mode —
container and volume) whose task is the original task plus a summary of how
the earlier run ended and the tail of its transcript; the model is told to
git status/git diff first and continue rather than restart. The sandbox
mode is the original run's; --model and the other run flags may be
overridden. Refused (exit 2, nothing created) when the earlier run is still
running, its worktree is gone, or its base commit no longer exists. In docker
mode the prior work is moved aside during the final export and put back if that
export fails, so a failed resume leaves the worktree exactly as it was. If a docker
resume is killed mid-export, that stash (<worktree>.pre-resume-<slug>, next to the
worktree) still holds the pre-resume content; resume refuses to run again until you
move it back or delete it, and never deletes a stash it did not create.
Docker-mode limit: export stores files, not the worker's in-container
commits, so a resumed docker worker sees the earlier work as uncommitted
changes against the base commit — not as its old commit history. Host mode
(--sandbox none) keeps the real commits.
Inspecting, cleaning up and re-exporting runs
Every run leaves a directory under ~/.dirtywork/runs/<slug>/ (run.json,
transcript.jsonl, and in docker mode diff.patch); the runs subcommands
work from that directory (plus best-effort docker/git lookups) independently
of whether the run is still going:
dirtywork runs list [--json]— every run under~/.dirtywork/runs: slug, status, when it started, its place in a resume chain, branch, whether the worktree still exists, and container/volume state.dirtywork runs show <slug> [--diff]— the run's summary fields, its fullrun.json, and a timeline reconstructed from the transcript;--diffalso printsdiff.patch.dirtywork runs export <slug> [--max-worktree-mb 2048] [--max-worktree-files 200000] [--max-patch-mb 10] [--keep-volume]— re-runs the docker export into the worktree for a run whose volume still exists (afterexport_failed, or a crash before the export ran); refuses a non-empty worktree, a still-running run, or a non-docker-sandbox run.--max-worktree-mb/--max-worktree-filesdefault to the same limits asdirtywork run— raise them here to retry an export that failed because the tree was too big.dirtywork runs clean <slug>|--all [--force] [--keep-transcript]— remove a run's container, volume, worktree, branch, and run directory. Every refusal is printed and makes the command exit 1:- refuses a worktree with uncommitted changes, or a branch that has
commits beyond the run's recorded base commit — both are what make
--allow-commitruns safe to clean;--forceremoves them anyway. - refuses a run that's still marked
runningwith a live (or unconfirmable) host process — not overridable; once the process is confirmed dead,--forceis required to confirm the cleanup. - never deletes a branch unless it's still the one actually checked out in the worktree (protects against deleting the wrong branch if you checked out something else there by hand).
- a run whose worktree was taken over by a later
resume(resumed_byset) keeps its worktree and branch — they belong to the newest run in the chain; clean that run instead. - only ever removes the worktree dirtywork itself created for the run
(
<repo>/.worktrees/dw-<slug>, a linked worktree of the recorded repo) — an editedrun.jsoncannot point it at another worktree; a worktree that is already gone is not a refusal (git bookkeeping is pruned and the run's owndirtywork/<slug>branch removed if nothing has it checked out). - container/volume removal only ever touches a resource whose
dirtywork.run/dirtywork.repolabels match this exact run (the SP2 collision rule); anything else is left alone and reported. A resource that is already gone ("no such object") is fine; any otherdocker inspectfailure (daemon down, permission denied) is a refusal, and then the worktree, branch and run directory are left untouched too, so a retry can still finish. - if anything above was refused, the run directory is kept too, so
nothing it describes is lost before you can retry with
--force. - slugs are plain names (
[A-Za-z0-9._-]); paths are rejected. --keep-transcriptkeepsrun.json/transcript.jsonland removes the rest of the run directory.
- refuses a worktree with uncommitted changes, or a branch that has
commits beyond the run's recorded base commit — both are what make
dirtywork runs verdict <slug> accept|reject|cleanup [--note TEXT] [--review-seconds N]— record the operator's verdict on a run into itsrun.json.
Benchmarking
dirtywork bench --models 'model[@provider][=base_url],...' \
[--provider openai] [--base-url URL] [--tasks name1,name2] \
[--repeats N] [--out PATH] [--max-turns 40] [--timeout 1800]
dirtywork bench summarize <results.jsonl>
Runs every (model × task × repeat) combination through the normal
dirtywork run --sandbox docker --keep-volume path against the fixture
repos under bench/repos/ (each with a bench.json of expected file
hashes plus an acceptance command), then scores the result: a fresh
acceptance container mounts the run's volume at /work and the fixture's
own bench/repos/<task>/acceptance/ read-only at /acceptance, hashes the
worker's copy of acceptance/ to catch tampering (marked gamed), and
runs the acceptance command from the read-only mount to get pass/fail.
Each row also records harness-failure counts (nudges by kind, stalls,
max_turns, sandbox_error, aborts). --models takes comma-separated
model[@provider][=base_url] specs — anything a spec omits falls back to
the sweep-wide --provider/--base-url, which in turn fall back to
dirtywork run's own defaults. Results append to
~/.dirtywork/bench/<UTC-timestamp>.jsonl (or --out); dirtywork bench summarize <file> prints a per-case table plus a per-model summary
(completion/acceptance/verdict rates, gamed count, mean tokens/wall time,
median review seconds).
bench runs from a source checkout only — bench/ and its fixture repos
are not part of the installed package.
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). 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 seven tools (
read_file,write_file,edit_file,list_dir,grep,bash,finish) via OpenAI function-calling. Context is budgeted per model (oldest tool results get trimmed first); three consecutive tool failures of one kind (malformed call, malformed arguments, unknown tool, bad arguments, empty reply) or six in total abort the run. 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).
Safety model
Docker mode (default): the container is the real boundary —
--network none, --read-only root filesystem, --cap-drop ALL,
kernel-enforced memory/CPU/process/per-file-size limits, no host path
mounted in except a read-only bind mount of the parent repository's git
object store. The worktree reaches the host only through the validated
tar export. See "Security & trust" above for what this does and does not
cover.
bash in docker mode only enforces the mode-independent policy rules (no
git push, sudo, piping a download into a shell, or system-control
commands) — the host-filesystem/host-repo rules below don't apply, since
the container has no host filesystem or shared parent repo to escape into.
--sandbox none (host mode, pre-0.4 behavior): 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 (an absolute-pathcdthat lands inside the worktree is allowed — only paths that leave it are blocked), 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 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)
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.
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). - exit 2, "ANTHROPIC_API_KEY is not set" — set that environment variable
before running with
--provider anthropic. - 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
stalled— N turns (--stall-turns) passed with no file change and no new command output; the worktree is kept. Usually the work is done but the model never calledfinish— inspect the worktree, ordirtywork resume <slug>. - host mode (
--sandbox none): "No module named pytest", or aLibrary//.cache/directory appears in the worktree — bash runs withHOMEset to the worktree on purpose (so~/.sshand friends are out of reach), which is where$HOME-keyed caches andpip install --userland. The operator's own user-site packages stay importable (they are put onPYTHONPATH), and the roots of$HOME-keyed toolchain managers are carried over (VOLTA_HOME,RUSTUP_HOME,CARGO_HOME,NVM_DIR,PYENV_ROOT— kept when set in your shell, else defaulted to~/.volta-style directories that exist) sonode/cargoshims do not re-download toolchains into the worktree; if a tool still cannot be found, install it system-wide or in the project's virtualenv rather than withpip install --userfrom inside a run. - 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. - exit 2, "Docker is the default sandbox since 0.4..." — Docker
Desktop/dockerd isn't running or isn't reachable. Start it, or pass
--sandbox noneto run unsandboxed on the host. - exit 2, "Build or pull the worker image..." — the configured
--imagecouldn't be resolved (not pullable, or a digest mismatch againstPINNED_DIGEST). Build/pull it perdocker/README.md, pass a different--image, or use--sandbox none. - exit 2, "Check that the repository's git object store is valid..."
—
--repo's git object store failed validation (a symlinked or missingobjectsdirectory, or one that escapes the git common dir). Verify the repo withgit -C <repo> fsck, or use--sandbox none. - status
sandbox_error— a docker command failed or timed out mid-run (daemon hang, container killed unexpectedly twice in a row, etc.); the worktree may be partially or not exported. Checkrun_end.errorin the transcript anddocker ps -a --filter label=dirtywork.run=<slug>. - status
export_failed(inrun.json'sexport_status, and as the overallstatusif the agent loop itself otherwise completed) — the worker's tree could not be validated/exported (e.g. it exceeded--max-worktree-mb/--max-worktree-files). The Docker volumedw-<slug>-workis kept (unless it was already going to be removed). Retry the export in place withdirtywork runs export <slug> --max-worktree-mb <n> --max-worktree-files <n>after raising the limit that tripped it (see Inspecting, cleaning up and re-exporting runs). To salvage the tree by hand instead, inspect the volume directly (docker run --rm -v dw-<slug>-work:/work <image> ...), or discard it withdocker volume rm dw-<slug>-workonce you're done.
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]
[--stall-turns 12] # end as `stalled` after N no-progress turns; 0 disables
[--context-window <tokens>] # default: built-in table, else 32768 (+ stderr warning)
[--timeout 1800] # whole-run wall clock, seconds
[--temperature <f>] # omitted by default → server preset
[--provider openai|anthropic] # default: openai; anthropic needs ANTHROPIC_API_KEY
[--base-url <url>] # default depends on --provider (LM Studio for openai,
# https://api.anthropic.com for anthropic)
[--max-worktree-mb 2048]
[--max-worktree-files 200000]
[--sandbox docker|none] # default: docker
[--image ghcr.io/jimboschneider/dirtywork-worker:0.6] # docker mode only
[--allow-network] # docker mode only; default --network none
[--memory 4g] # docker mode only
[--cpus 2] # docker mode only
[--tmp-size 1g] # docker mode only
[--gitdir-size 512m] # docker mode only
[--min-free-mb 2048] # docker mode only; host free-space floor
[--keep-volume] # docker mode only; skip volume cleanup
[--max-patch-mb 10] # docker mode only; diff.patch cap
[--allow-commit] # host mode only; worker commits its own work
dirtywork resume <slug | run-dir> # same flags as run, minus --repo/--branch-from/--sandbox/<task>;
[--model <m>] # defaults to the earlier run's model; --image defaults to its image
-
--stall-turns N(default 12) — end the run with statusstalledafter N consecutive turns that changed no file and produced no new command output; the model gets one nudge halfway.0disables. -
--context-window TOKENS— the model's context window, used to size the transcript trimming budget. Precedence: flag, thenDIRTYWORK_CONTEXT_WINDOW, then a built-in table for the known LM Studio models, then 32768 (with a warning on stderr). -
--allow-commit(host mode only) — replaces the prompt's "leave all changes uncommitted for review" rule with "commit your work in small conventional commits as you go", so the run's branch comes back as real history instead of a dirty worktree. Rejected in preflight with--sandbox docker: the export carries files, not commits (its archive can never contain a.gitentry), so a container's commits could not reach the host anyway.dirtywork resumeinherits the setting from the run it continues.
stdout: on any run that gets past preflight, exactly one JSON object is printed to stdout (nothing else goes to stdout):
{
"schema_version": 2,
"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": "...",
"provider": "openai",
"run_dir": "/home/you/.dirtywork/runs/<slug>",
"base_commit": "abc123...",
"resumed_from": null,
"finalize_error": null,
"watchdog_violation": null,
"watchdog_violation_kind": null
}
status is one of: completed, max_turns, timeout, stalled,
context_exhausted, model_error, interrupted, budget_exceeded,
sandbox_error, export_failed. 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, and run_dir are
still populated so the worktree and run directory can be located for
salvage.
base_commit and provider ("openai" or "anthropic") are present on
every post-preflight payload. resumed_from is
the slug of the run this one continued, or null if this was a fresh run.
finalize_error, watchdog_violation, and watchdog_violation_kind are added on the normal
end-of-run path — i.e. whenever runner.run() returns a result, completed
or not — normally null; see run_end below for what each means. The two
paths where runner.run() never returns (sandbox setup fails before it
starts, or an exception escapes the loop and is caught in main()) report
base_commit and resumed_from only, plus export_status too if a docker finalize() ran
during that exception recovery.
Exit codes:
0—completed.1— any non-completedstatus (max_turns,timeout,stalled,context_exhausted,model_error,interrupted,budget_exceeded,sandbox_error,export_failed); 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.
Full field-by-field schema, including every v1→v2 addition and the
run.json field list: docs/transcript-schema.md.
Transcript events (JSONL, one per line): run_start (task, repo, model,
config, schema_version: 2, plus provenance: worktree, base_commit,
branch, branch_from, base_url, dirtywork_version, temperature,
sandbox — the docker settings dict, or "none" — and provider,
context_window, resumed_from),
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, nudge ({"event": "nudge", "kind": "truncated|empty|text_tool_call|stall", "turn": N}), sandbox_reset
(docker mode: the container was reset — reason), and run_end (status, turns,
duration, cumulative
usage, plus the run's artifacts: in host mode diff_stat — git diff --stat against the base commit, tracked changes only — and untracked —
git status --porcelain ?? entries — each capped at 64 000 chars; in
docker mode diff_stat (which already includes new files, since the
export stages everything first), untracked (always ""), patch_path,
worktree_bytes, worktree_files, escaping_symlinks,
dropped_git_entries, export_status, watchdog_violation (docker mode;
null unless the watchdog killed the container), watchdog_violation_kind
(set alongside watchdog_violation: "budget" for a worktree-size or
host-disk-floor breach, "sandbox_error" for the watchdog's own
worktree-sampling exec failing twice; otherwise null), and
finalize_error (set when the finalize/export step itself raised an
exception after the agent loop otherwise finished; null normally)).
A finish(summary=...) call appears in the transcript as an ordinary tool call in its assistant event followed by a tool_result event whose result is run finished; the summary becomes the run's final_message.
The docker settings dict (run_start's sandbox, and the same fields in
run.json) includes image (the --image argument as given),
image_digest (the registry digest from RepoDigests, or null for a
locally-built image that was never pushed/pulled) — provenance only — and
image_pinned (true only when --image was left at its default AND
PINNED_DIGEST was enforced against a pulled default image; false for a
custom --image — never pinned — or a locally built/loaded default image,
which only warns). run.json also records the run's key fields: task,
model, context_window, resumed_from, and turns (at the end); when a
run is resumed, the earlier run's resumed_by field records the slug of the new run that continued it.
The container itself always runs from the image's local
content-addressed Id, never a registry digest, so a run can never trigger a
network pull.
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.6.0
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.6.0.tar.gz | 226.4 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| dirtywork-0.6.0-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 345.8 kB
Release files / dirtywork-0.6.0.tar.gz
| Download URL | dirtywork-0.6.0.tar.gz |
|---|---|
| Size | 226.4 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
8fe93d06e34dd163e8c0cbfcc968195c0dd2f19221f91f49bb0b161b0c02c14b
|
|
BLAKE2b-256 checksum How to use checksums |
0d02d2cf1f786d90d6e5e239f08eed2c13b5948049e797dd72af365f5969e573
|
| 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 Aug 18, 2026.
Transparency logRelease files / dirtywork-0.6.0-py3-none-any.whl
| Download URL | dirtywork-0.6.0-py3-none-any.whl |
|---|---|
| Size | 119.4 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
2ed16f094bffe9e07b37b87d371d1bb952f3b77276f11e46d994536fde7d1052
|
|
BLAKE2b-256 checksum How to use checksums |
123d78c50c5588c5e12a1dcc7c2711e52b9b2fc3691aa3978cc43da18e9e6254
|
| 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 Aug 18, 2026.
Transparency log