Skip to main content

dirtywork

CI PyPI License: MIT Python 3.9+

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, docker CLI behavior, uid 1000: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-volume plus docker run against 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/merge that you run afterward use your own git config; a worker-authored .gitattributes can trigger a configured filter (git-lfs and similar). Review ~/.dirtywork/runs/<slug>/diff.patch instead (the container-computed patch — no host git ever touches worker content for that path) or with GIT_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 version must succeed. Missing/unreachable Docker is a preflight error with a hint; pass --sandbox none to skip this requirement and run unsandboxed on the host instead.
  • LM Studio serving its OpenAI-compatible API at localhost:1234 with a tool-calling-capable model loaded. Verified working: qwen/qwen3-coder-next (65k context, default) and mistralai/devstral-small-2-2512 (32k context)
  • --provider anthropic needs the ANTHROPIC_API_KEY environment 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 -f the 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 the runs subcommands.

  • 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 full run.json, and a timeline reconstructed from the transcript; --diff also prints diff.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 (after export_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-files default to the same limits as dirtywork 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-commit runs safe to clean; --force removes them anyway.
    • refuses a run that's still marked running with a live (or unconfirmable) host process — not overridable; once the process is confirmed dead, --force is 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_by set) 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 edited run.json cannot point it at another worktree; a worktree that is already gone is not a refusal (git bookkeeping is pruned and the run's own dirtywork/<slug> branch removed if nothing has it checked out).
    • container/volume removal only ever touches a resource whose dirtywork.run/dirtywork.repo labels 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 other docker inspect failure (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-transcript keeps run.json/transcript.jsonl and removes the rest of the run directory.
  • dirtywork runs verdict <slug> accept|reject|cleanup [--note TEXT] [--review-seconds N] — record the operator's verdict on a run into its run.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

  1. Preflight — LM Studio reachable, model loaded, repo valid. Any failure exits 2 with nothing created.
  2. Worktree — a fresh worktree at <repo>/.worktrees/dw-<slug> on new branch dirtywork/<slug>, branched from --branch-from (default: repo HEAD). In docker mode (the default) the worktree stays empty (only its .git file) 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/exclude automatically. If the repo has a CLAUDE.md or AGENTS.md at its base commit, its content is injected into the worker's system prompt so it inherits your conventions.
  3. 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 the finish(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.
  4. 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).
  • bash runs 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 through git -C/git -c/--flag global options), rm/mv/chmod/chown on absolute or ~ paths, cd/pushd escapes (an absolute-path cd that 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_block event, 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_file content is capped at 5 MB, list_dir output 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 status budget_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 ps and curl -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 called finish — inspect the worktree, or dirtywork resume <slug>.
  • host mode (--sandbox none): "No module named pytest", or a Library//.cache/ directory appears in the worktree — bash runs with HOME set to the worktree on purpose (so ~/.ssh and friends are out of reach), which is where $HOME-keyed caches and pip install --user land. The operator's own user-site packages stay importable (they are put on PYTHONPATH), 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) so node/cargo shims 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 with pip install --user from 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-files during 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 none to run unsandboxed on the host.
  • exit 2, "Build or pull the worker image..." — the configured --image couldn't be resolved (not pullable, or a digest mismatch against PINNED_DIGEST). Build/pull it per docker/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 missing objects directory, or one that escapes the git common dir). Verify the repo with git -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. Check run_end.error in the transcript and docker ps -a --filter label=dirtywork.run=<slug>.
  • status export_failed (in run.json's export_status, and as the overall status if 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 volume dw-<slug>-work is kept (unless it was already going to be removed). Retry the export in place with dirtywork 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 with docker volume rm dw-<slug>-work once 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 status stalled after N consecutive turns that changed no file and produced no new command output; the model gets one nudge halfway. 0 disables.

  • --context-window TOKENS — the model's context window, used to size the transcript trimming budget. Precedence: flag, then DIRTYWORK_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 .git entry), so a container's commits could not reach the host anyway. dirtywork resume inherits 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-completed status (max_turns, timeout, stalled, context_exhausted, model_error, interrupted, budget_exceeded, sandbox_error, export_failed); the worktree and branch are kept for salvage/review. main catches every Exception the run raises (not just ones the runner itself converts to a status) and reports it as model_error via the same JSON contract, so a post-preflight run never tracebacks. (Ctrl-C is a KeyboardInterrupt, a BaseException, not caught here — but the run loop itself already converts in-loop Ctrl-C to status interrupted before it would reach this point.)
  • 2 — preflight or environment error (LM Studio unreachable, model not loaded, --repo not 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 pytest must 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 LLMError only; 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)

Source distribution for dirtywork 0.6.0
File Size Uploaded
dirtywork-0.6.0.tar.gz 226.4 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for dirtywork 0.6.0
File Interpreter ABI Platform
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 log

Release 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

Release history Release notifications | RSS feed

0.13.1

2 release files

0.13.0

2 release files

0.12.1

2 release files

0.12.0

2 release files

0.11.1

2 release files

0.11.0

2 release files

0.10.1

2 release files

0.10.0

2 release files

0.9.1

2 release files

0.9.0

2 release files

0.8.1

2 release files

0.8.0

2 release files

0.7.0

2 release files

0.6.1

2 release files

This release

0.6.0 This release

2 release files

0.5.1

2 release files

0.5.0

2 release files

0.4.1

2 release files

0.4.0

2 release files

0.3.0

2 release files

0.2.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page