dirtywork 0.2.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Dirt Simple Solutions, LLC
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,239 @@
1
+ Metadata-Version: 2.4
2
+ Name: dirtywork
3
+ Version: 0.2.0
4
+ Summary: Agentic tool-use loop harness for local LM Studio models — an expensive-model orchestrator's free local workforce
5
+ Author: Dirt Simple Solutions, LLC
6
+ License: MIT
7
+ Project-URL: Repository, https://github.com/JimboSchneider/dirtywork
8
+ Project-URL: Homepage, https://dirtywork.run
9
+ Keywords: llm,agent,lm-studio,local-llm,coding-agent
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: License :: OSI Approved :: MIT License
14
+ Classifier: Programming Language :: Python :: 3
15
+ Classifier: Programming Language :: Python :: 3 :: Only
16
+ Classifier: Topic :: Software Development :: Code Generators
17
+ Requires-Python: >=3.9
18
+ Description-Content-Type: text/markdown
19
+ License-File: LICENSE
20
+ Dynamic: license-file
21
+
22
+ # dirtywork
23
+
24
+ [![CI](https://github.com/JimboSchneider/dirtywork/actions/workflows/ci.yml/badge.svg)](https://github.com/JimboSchneider/dirtywork/actions/workflows/ci.yml)
25
+ [![PyPI](https://img.shields.io/pypi/v/dirtywork.svg)](https://pypi.org/project/dirtywork/)
26
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/JimboSchneider/dirtywork/blob/main/LICENSE)
27
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://github.com/JimboSchneider/dirtywork/blob/main/pyproject.toml)
28
+
29
+ *Frontier models do the thinking. Local models do the dirty work.*
30
+
31
+ Runs one coding task against a local LM Studio model in an agentic tool-use
32
+ loop, inside an isolated git worktree. Built to be driven by an orchestrating
33
+ agent (Claude Code, in our case) — the expensive frontier model orchestrates
34
+ and reviews, the free local model grinds. Humans watch with `tail -f`.
35
+
36
+ **The division of labor:**
37
+
38
+ | | Role |
39
+ |---|---|
40
+ | 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 |
41
+ | Worker (local model, via dirtywork) | Explores the repo, edits files, runs builds/tests — inside a worktree it cannot escape |
42
+
43
+ Nothing the local model does touches your main checkout, and nothing it
44
+ produces merges without review. Parallelism comes from launching multiple
45
+ processes — LM Studio serves 4 concurrent requests per model.
46
+
47
+ ## Requirements
48
+
49
+ - macOS/Linux, Python 3.9+ (stdlib only — no venv, no pip deps)
50
+ - [LM Studio](https://lmstudio.ai) serving its OpenAI-compatible API at
51
+ `localhost:1234` with a tool-calling-capable model loaded. Verified
52
+ working: `qwen/qwen3-coder-next` (65k context, default) and
53
+ `mistralai/devstral-small-2-2512` (32k context)
54
+ - The target repo must be a git repo with at least one commit
55
+
56
+ **Other servers:** anything speaking the OpenAI chat-completions API with tool
57
+ calling should work via `--base-url` (e.g. Ollama at
58
+ `http://localhost:11434/v1`) — but only LM Studio is tested today. Reports
59
+ welcome.
60
+
61
+ ## Install
62
+
63
+ **pipx (PyPI):**
64
+
65
+ pipx install dirtywork
66
+
67
+ **pipx (straight from GitHub):**
68
+
69
+ pipx install git+https://github.com/JimboSchneider/dirtywork
70
+
71
+ **From source:**
72
+
73
+ git clone https://github.com/JimboSchneider/dirtywork
74
+ cd dirtywork
75
+ chmod +x bin/dirtywork
76
+ ln -sf "$PWD/bin/dirtywork" ~/.local/bin/dirtywork
77
+
78
+ The launcher is self-locating, so this works from any clone location.
79
+
80
+ ## Use
81
+
82
+ dirtywork run --repo ~/repos/someproject "Add a unit test for X"
83
+
84
+ - **Watch a run:** `tail -f` the transcript path printed on stderr.
85
+ - **Review a run:** `git -C <worktree> diff`, read the transcript, run the
86
+ repo's tests — then commit the branch or discard it.
87
+ - **Discard a run:**
88
+ `git -C <repo> worktree remove --force <worktree> && git -C <repo> branch -D dirtywork/<slug>`
89
+
90
+ ## How a run works
91
+
92
+ 1. **Preflight** — LM Studio reachable, model loaded, repo valid. Any
93
+ failure exits 2 with nothing created.
94
+ 2. **Worktree** — a fresh worktree at `<repo>/.worktrees/dw-<slug>` on new
95
+ branch `dirtywork/<slug>`, branched from `--branch-from` (default:
96
+ repo HEAD). `.worktrees/` is added to the repo's local
97
+ `.git/info/exclude` automatically. If the repo has a `CLAUDE.md` or
98
+ `AGENTS.md` at its root, its content is injected into the worker's
99
+ system prompt so it inherits your conventions.
100
+ 3. **The loop** — the model gets six tools (`read_file`, `write_file`,
101
+ `edit_file`, `list_dir`, `grep`, `bash`) via OpenAI function-calling and
102
+ works until it replies without calling a tool. Context is budgeted per
103
+ model (oldest tool results get trimmed first); three consecutive
104
+ malformed tool calls abort the run.
105
+ 4. **No auto-commit** — changes stay uncommitted in the worktree; the
106
+ transcript lands at `~/.dirtywork/runs/<slug>/transcript.jsonl`
107
+ (outside the worktree, so it can never pollute the diff).
108
+
109
+ ## Safety model
110
+
111
+ Guardrails block **accidents, not adversaries** — the post-run review is
112
+ the real gate:
113
+
114
+ - All file tools are path-confined to the worktree (symlink-safe realpath
115
+ checks; `.git/` is write-protected against hook injection).
116
+ - `bash` runs cwd-pinned in the worktree with a minimal environment (your
117
+ shell's tokens/keys are not inherited) and a regex denylist: `sudo`,
118
+ `git push`, `rm`/`mv`/`chmod`/`chown` on absolute or `~` paths,
119
+ `cd`/`pushd` escapes, downloads piped to a shell, system-control
120
+ commands, redirects outside the worktree.
121
+ - Every denylist rejection is logged to the transcript as a
122
+ `guardrail_block` event, so attempted escapes are visible at review time.
123
+ - Network is allowed (package restores need it); per-command timeout 120s
124
+ default, 600s max.
125
+
126
+ Plainly: this is **not a sandbox**. Run it against repos where you'd trust
127
+ yourself to review the diff — because that review is the actual gate.
128
+
129
+ ## Development
130
+
131
+ python3 -m pytest # unit suite (no LM Studio needed)
132
+ python3 -m pytest -m live -v # live suite (requires LM Studio running;
133
+ # includes a real end-to-end agent run)
134
+
135
+ Design docs: `docs/superpowers/specs/2026-08-13-localagent-design.md`
136
+ (architecture and contracts) and
137
+ `docs/superpowers/plans/2026-08-14-localagent.md` (implementation plan).
138
+
139
+ ## The story
140
+
141
+ dirtywork was designed, built, reviewed, and shipped in one day — by the
142
+ exact orchestrator/worker pattern it implements — and its first production
143
+ run surfaced a real cent-level rounding bug in the invoicing app it was
144
+ pointed at. The full postmortem, including a build-one-yourself recipe:
145
+ [the postmortem](https://github.com/JimboSchneider/dirtywork/blob/main/docs/2026-08-14-building-localagent.md)
146
+ (or read [the designed HTML edition](https://dirtywork.run/building-localagent.html)
147
+ served via Pages).
148
+
149
+ In August 2026 the project was renamed **dirtywork** — same tool, a name that says what it does.
150
+
151
+ ## Troubleshooting
152
+
153
+ - **exit 2, "cannot reach LM Studio"** — server not running; check `lms ps`
154
+ and `curl -s localhost:1234/v1/models`.
155
+ - **exit 2, "model not loaded"** — `lms load <model>` (the error names the
156
+ loaded models).
157
+ - **status `max_turns` / `timeout`** — the worktree is kept; read the
158
+ transcript to see where it stalled, salvage what's useful, or re-run with
159
+ higher limits.
160
+ - **status `context_exhausted`** — the task needed more context than the
161
+ model's window; split the task or use the larger-context model.
162
+
163
+ ## Machine contract
164
+
165
+ `dirtywork` is built to be driven by another agent (Claude Code) rather than
166
+ read by a human — the primary consumer parses stdout, not the terminal.
167
+
168
+ **Flags:**
169
+
170
+ ```
171
+ dirtywork run --repo <path> "<task>"
172
+ [--model qwen/qwen3-coder-next] # or mistralai/devstral-small-2-2512
173
+ [--branch-from <ref>] # default: repo HEAD
174
+ [--max-turns 40]
175
+ [--timeout 1800] # whole-run wall clock, seconds
176
+ [--temperature <f>] # omitted by default → server preset
177
+ [--base-url http://localhost:1234/v1] # LM Studio's OpenAI-compatible endpoint
178
+ ```
179
+
180
+ **stdout:** on any run that gets past preflight, exactly one JSON object is
181
+ printed to stdout (nothing else goes to stdout):
182
+
183
+ ```json
184
+ {
185
+ "status": "completed",
186
+ "worktree": "/path/to/repo/.worktrees/dw-<slug>",
187
+ "branch": "dirtywork/<slug>",
188
+ "transcript": "/path/to/transcript.jsonl",
189
+ "turns": 7,
190
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0},
191
+ "final_message": "..."
192
+ }
193
+ ```
194
+
195
+ `status` is one of: `completed`, `max_turns`, `timeout`,
196
+ `context_exhausted`, `model_error`, `interrupted`. When the run fails before
197
+ a `RunResult` exists — the LLM client raises, post-worktree setup fails (e.g.
198
+ the transcript can't be created), or any other exception escapes the run
199
+ (status `model_error` in every case) — `turns` is `null` and `usage` is `{}`,
200
+ but `status`, `worktree`, `branch`, and `transcript` are still populated so
201
+ the worktree can be located for salvage.
202
+
203
+ **Exit codes:**
204
+
205
+ - `0` — `completed`.
206
+ - `1` — run ended abnormally (`max_turns`, `timeout`, `context_exhausted`,
207
+ `model_error`, `interrupted`); the worktree and branch are kept for
208
+ salvage/review. `main` catches every `Exception` the run raises (not just
209
+ ones the runner itself converts to a status) and reports it as
210
+ `model_error` via the same JSON contract, so a post-preflight run never
211
+ tracebacks. (Ctrl-C is a `KeyboardInterrupt`, a `BaseException`, not caught
212
+ here — but the run loop itself already converts in-loop Ctrl-C to status
213
+ `interrupted` before it would reach this point.)
214
+ - `2` — preflight or environment error (LM Studio unreachable, model not
215
+ loaded, `--repo` not a git repo, etc.); nothing is created.
216
+
217
+ All progress (transcript path, worktree path, `error:`-prefixed messages) is
218
+ written to stderr; watch a live run with `tail -f` on the transcript path.
219
+
220
+ **Transcript events** (JSONL, one per line): `run_start` (task, repo, model,
221
+ config), `assistant` (text + tool calls), `tool_result` (truncated),
222
+ `guardrail_block`, `run_end` (status, turns, duration, cumulative usage).
223
+
224
+ ## Contributing
225
+
226
+ Issues and PRs welcome. Ground rules:
227
+
228
+ - Runtime stays **stdlib-only** — that zero-dependency install is a feature,
229
+ not an accident. Dev-only dependencies (pytest) are fine.
230
+ - `python3 -m pytest` must be green; if your change touches the model-facing
231
+ path, run the live suite too (`python3 -m pytest -m live -v`, needs a
232
+ running LM Studio).
233
+ - Tool functions never raise; the client raises `LLMError` only; stdout is
234
+ exactly one JSON object post-preflight. These contracts have tests — keep
235
+ them green.
236
+
237
+ ## License
238
+
239
+ [MIT](https://github.com/JimboSchneider/dirtywork/blob/main/LICENSE) © 2026 Dirt Simple Solutions, LLC
@@ -0,0 +1,218 @@
1
+ # dirtywork
2
+
3
+ [![CI](https://github.com/JimboSchneider/dirtywork/actions/workflows/ci.yml/badge.svg)](https://github.com/JimboSchneider/dirtywork/actions/workflows/ci.yml)
4
+ [![PyPI](https://img.shields.io/pypi/v/dirtywork.svg)](https://pypi.org/project/dirtywork/)
5
+ [![License: MIT](https://img.shields.io/badge/license-MIT-green.svg)](https://github.com/JimboSchneider/dirtywork/blob/main/LICENSE)
6
+ [![Python 3.9+](https://img.shields.io/badge/python-3.9%2B-blue.svg)](https://github.com/JimboSchneider/dirtywork/blob/main/pyproject.toml)
7
+
8
+ *Frontier models do the thinking. Local models do the dirty work.*
9
+
10
+ Runs one coding task against a local LM Studio model in an agentic tool-use
11
+ loop, inside an isolated git worktree. Built to be driven by an orchestrating
12
+ agent (Claude Code, in our case) — the expensive frontier model orchestrates
13
+ and reviews, the free local model grinds. Humans watch with `tail -f`.
14
+
15
+ **The division of labor:**
16
+
17
+ | | Role |
18
+ |---|---|
19
+ | 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 |
20
+ | Worker (local model, via dirtywork) | Explores the repo, edits files, runs builds/tests — inside a worktree it cannot escape |
21
+
22
+ Nothing the local model does touches your main checkout, and nothing it
23
+ produces merges without review. Parallelism comes from launching multiple
24
+ processes — LM Studio serves 4 concurrent requests per model.
25
+
26
+ ## Requirements
27
+
28
+ - macOS/Linux, Python 3.9+ (stdlib only — no venv, no pip deps)
29
+ - [LM Studio](https://lmstudio.ai) serving its OpenAI-compatible API at
30
+ `localhost:1234` with a tool-calling-capable model loaded. Verified
31
+ working: `qwen/qwen3-coder-next` (65k context, default) and
32
+ `mistralai/devstral-small-2-2512` (32k context)
33
+ - The target repo must be a git repo with at least one commit
34
+
35
+ **Other servers:** anything speaking the OpenAI chat-completions API with tool
36
+ calling should work via `--base-url` (e.g. Ollama at
37
+ `http://localhost:11434/v1`) — but only LM Studio is tested today. Reports
38
+ welcome.
39
+
40
+ ## Install
41
+
42
+ **pipx (PyPI):**
43
+
44
+ pipx install dirtywork
45
+
46
+ **pipx (straight from GitHub):**
47
+
48
+ pipx install git+https://github.com/JimboSchneider/dirtywork
49
+
50
+ **From source:**
51
+
52
+ git clone https://github.com/JimboSchneider/dirtywork
53
+ cd dirtywork
54
+ chmod +x bin/dirtywork
55
+ ln -sf "$PWD/bin/dirtywork" ~/.local/bin/dirtywork
56
+
57
+ The launcher is self-locating, so this works from any clone location.
58
+
59
+ ## Use
60
+
61
+ dirtywork run --repo ~/repos/someproject "Add a unit test for X"
62
+
63
+ - **Watch a run:** `tail -f` the transcript path printed on stderr.
64
+ - **Review a run:** `git -C <worktree> diff`, read the transcript, run the
65
+ repo's tests — then commit the branch or discard it.
66
+ - **Discard a run:**
67
+ `git -C <repo> worktree remove --force <worktree> && git -C <repo> branch -D dirtywork/<slug>`
68
+
69
+ ## How a run works
70
+
71
+ 1. **Preflight** — LM Studio reachable, model loaded, repo valid. Any
72
+ failure exits 2 with nothing created.
73
+ 2. **Worktree** — a fresh worktree at `<repo>/.worktrees/dw-<slug>` on new
74
+ branch `dirtywork/<slug>`, branched from `--branch-from` (default:
75
+ repo HEAD). `.worktrees/` is added to the repo's local
76
+ `.git/info/exclude` automatically. If the repo has a `CLAUDE.md` or
77
+ `AGENTS.md` at its root, its content is injected into the worker's
78
+ system prompt so it inherits your conventions.
79
+ 3. **The loop** — the model gets six tools (`read_file`, `write_file`,
80
+ `edit_file`, `list_dir`, `grep`, `bash`) via OpenAI function-calling and
81
+ works until it replies without calling a tool. Context is budgeted per
82
+ model (oldest tool results get trimmed first); three consecutive
83
+ malformed tool calls abort the run.
84
+ 4. **No auto-commit** — changes stay uncommitted in the worktree; the
85
+ transcript lands at `~/.dirtywork/runs/<slug>/transcript.jsonl`
86
+ (outside the worktree, so it can never pollute the diff).
87
+
88
+ ## Safety model
89
+
90
+ Guardrails block **accidents, not adversaries** — the post-run review is
91
+ the real gate:
92
+
93
+ - All file tools are path-confined to the worktree (symlink-safe realpath
94
+ checks; `.git/` is write-protected against hook injection).
95
+ - `bash` runs cwd-pinned in the worktree with a minimal environment (your
96
+ shell's tokens/keys are not inherited) and a regex denylist: `sudo`,
97
+ `git push`, `rm`/`mv`/`chmod`/`chown` on absolute or `~` paths,
98
+ `cd`/`pushd` escapes, downloads piped to a shell, system-control
99
+ commands, redirects outside the worktree.
100
+ - Every denylist rejection is logged to the transcript as a
101
+ `guardrail_block` event, so attempted escapes are visible at review time.
102
+ - Network is allowed (package restores need it); per-command timeout 120s
103
+ default, 600s max.
104
+
105
+ Plainly: this is **not a sandbox**. Run it against repos where you'd trust
106
+ yourself to review the diff — because that review is the actual gate.
107
+
108
+ ## Development
109
+
110
+ python3 -m pytest # unit suite (no LM Studio needed)
111
+ python3 -m pytest -m live -v # live suite (requires LM Studio running;
112
+ # includes a real end-to-end agent run)
113
+
114
+ Design docs: `docs/superpowers/specs/2026-08-13-localagent-design.md`
115
+ (architecture and contracts) and
116
+ `docs/superpowers/plans/2026-08-14-localagent.md` (implementation plan).
117
+
118
+ ## The story
119
+
120
+ dirtywork was designed, built, reviewed, and shipped in one day — by the
121
+ exact orchestrator/worker pattern it implements — and its first production
122
+ run surfaced a real cent-level rounding bug in the invoicing app it was
123
+ pointed at. The full postmortem, including a build-one-yourself recipe:
124
+ [the postmortem](https://github.com/JimboSchneider/dirtywork/blob/main/docs/2026-08-14-building-localagent.md)
125
+ (or read [the designed HTML edition](https://dirtywork.run/building-localagent.html)
126
+ served via Pages).
127
+
128
+ In August 2026 the project was renamed **dirtywork** — same tool, a name that says what it does.
129
+
130
+ ## Troubleshooting
131
+
132
+ - **exit 2, "cannot reach LM Studio"** — server not running; check `lms ps`
133
+ and `curl -s localhost:1234/v1/models`.
134
+ - **exit 2, "model not loaded"** — `lms load <model>` (the error names the
135
+ loaded models).
136
+ - **status `max_turns` / `timeout`** — the worktree is kept; read the
137
+ transcript to see where it stalled, salvage what's useful, or re-run with
138
+ higher limits.
139
+ - **status `context_exhausted`** — the task needed more context than the
140
+ model's window; split the task or use the larger-context model.
141
+
142
+ ## Machine contract
143
+
144
+ `dirtywork` is built to be driven by another agent (Claude Code) rather than
145
+ read by a human — the primary consumer parses stdout, not the terminal.
146
+
147
+ **Flags:**
148
+
149
+ ```
150
+ dirtywork run --repo <path> "<task>"
151
+ [--model qwen/qwen3-coder-next] # or mistralai/devstral-small-2-2512
152
+ [--branch-from <ref>] # default: repo HEAD
153
+ [--max-turns 40]
154
+ [--timeout 1800] # whole-run wall clock, seconds
155
+ [--temperature <f>] # omitted by default → server preset
156
+ [--base-url http://localhost:1234/v1] # LM Studio's OpenAI-compatible endpoint
157
+ ```
158
+
159
+ **stdout:** on any run that gets past preflight, exactly one JSON object is
160
+ printed to stdout (nothing else goes to stdout):
161
+
162
+ ```json
163
+ {
164
+ "status": "completed",
165
+ "worktree": "/path/to/repo/.worktrees/dw-<slug>",
166
+ "branch": "dirtywork/<slug>",
167
+ "transcript": "/path/to/transcript.jsonl",
168
+ "turns": 7,
169
+ "usage": {"prompt_tokens": 0, "completion_tokens": 0},
170
+ "final_message": "..."
171
+ }
172
+ ```
173
+
174
+ `status` is one of: `completed`, `max_turns`, `timeout`,
175
+ `context_exhausted`, `model_error`, `interrupted`. When the run fails before
176
+ a `RunResult` exists — the LLM client raises, post-worktree setup fails (e.g.
177
+ the transcript can't be created), or any other exception escapes the run
178
+ (status `model_error` in every case) — `turns` is `null` and `usage` is `{}`,
179
+ but `status`, `worktree`, `branch`, and `transcript` are still populated so
180
+ the worktree can be located for salvage.
181
+
182
+ **Exit codes:**
183
+
184
+ - `0` — `completed`.
185
+ - `1` — run ended abnormally (`max_turns`, `timeout`, `context_exhausted`,
186
+ `model_error`, `interrupted`); the worktree and branch are kept for
187
+ salvage/review. `main` catches every `Exception` the run raises (not just
188
+ ones the runner itself converts to a status) and reports it as
189
+ `model_error` via the same JSON contract, so a post-preflight run never
190
+ tracebacks. (Ctrl-C is a `KeyboardInterrupt`, a `BaseException`, not caught
191
+ here — but the run loop itself already converts in-loop Ctrl-C to status
192
+ `interrupted` before it would reach this point.)
193
+ - `2` — preflight or environment error (LM Studio unreachable, model not
194
+ loaded, `--repo` not a git repo, etc.); nothing is created.
195
+
196
+ All progress (transcript path, worktree path, `error:`-prefixed messages) is
197
+ written to stderr; watch a live run with `tail -f` on the transcript path.
198
+
199
+ **Transcript events** (JSONL, one per line): `run_start` (task, repo, model,
200
+ config), `assistant` (text + tool calls), `tool_result` (truncated),
201
+ `guardrail_block`, `run_end` (status, turns, duration, cumulative usage).
202
+
203
+ ## Contributing
204
+
205
+ Issues and PRs welcome. Ground rules:
206
+
207
+ - Runtime stays **stdlib-only** — that zero-dependency install is a feature,
208
+ not an accident. Dev-only dependencies (pytest) are fine.
209
+ - `python3 -m pytest` must be green; if your change touches the model-facing
210
+ path, run the live suite too (`python3 -m pytest -m live -v`, needs a
211
+ running LM Studio).
212
+ - Tool functions never raise; the client raises `LLMError` only; stdout is
213
+ exactly one JSON object post-preflight. These contracts have tests — keep
214
+ them green.
215
+
216
+ ## License
217
+
218
+ [MIT](https://github.com/JimboSchneider/dirtywork/blob/main/LICENSE) © 2026 Dirt Simple Solutions, LLC
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
@@ -0,0 +1,144 @@
1
+ from __future__ import annotations
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ from .llm import LLMError, LMStudioClient
10
+ from .runner import Runner
11
+ from .tools import ToolExecutor
12
+ from .transcript import Transcript
13
+ from .workspace import (
14
+ WorkspaceError,
15
+ create_worktree,
16
+ ensure_worktrees_excluded,
17
+ load_repo_context,
18
+ make_slug,
19
+ preflight_repo,
20
+ )
21
+
22
+ RUNS_DIR = Path.home() / ".dirtywork" / "runs"
23
+ DEFAULT_MODEL = "qwen/qwen3-coder-next"
24
+
25
+
26
+ def build_system_prompt(worktree: Path, repo_context: str | None) -> str:
27
+ prompt = f"""You are a coding agent working in a git worktree at {worktree}.
28
+ Complete the task, then reply with a plain-text summary of what you changed and what commands you ran.
29
+
30
+ Rules:
31
+ - Use edit_file or write_file for ALL file changes. Never modify files via bash (no sed -i, no echo redirects, no heredocs).
32
+ - Paths are relative to the worktree root.
33
+ - Explore before editing: use list_dir, grep, and read_file to understand the code first.
34
+ - Verify your work: run the repo's tests or build via bash before declaring the task complete.
35
+ - Do not run git commit or git branch commands; leave all changes uncommitted for review.
36
+ - When the task is complete, reply WITHOUT calling any tools — that final plain reply ends the run."""
37
+ if repo_context:
38
+ prompt += f"\n\nRepository conventions (from the repo's own docs):\n\n{repo_context}"
39
+ return prompt
40
+
41
+
42
+ def _err(msg: str) -> None:
43
+ print(f"error: {msg}", file=sys.stderr)
44
+
45
+
46
+ def main(argv: list | None = None) -> int:
47
+ parser = argparse.ArgumentParser(prog="dirtywork")
48
+ sub = parser.add_subparsers(dest="cmd", required=True)
49
+ run_p = sub.add_parser("run", help="run one task in an isolated worktree")
50
+ run_p.add_argument("task")
51
+ run_p.add_argument("--repo", required=True, type=Path)
52
+ run_p.add_argument("--model", default=DEFAULT_MODEL)
53
+ run_p.add_argument("--branch-from", default=None)
54
+ run_p.add_argument("--max-turns", type=int, default=40)
55
+ run_p.add_argument("--timeout", type=int, default=1800)
56
+ run_p.add_argument("--temperature", type=float, default=None)
57
+ run_p.add_argument("--base-url", default="http://localhost:1234/v1")
58
+ args = parser.parse_args(argv)
59
+
60
+ repo = args.repo.expanduser().resolve()
61
+
62
+ # ---- preflight (exit 2, create nothing) ----
63
+ client = LMStudioClient(base_url=args.base_url)
64
+ try:
65
+ preflight_repo(repo)
66
+ models = client.list_models()
67
+ except WorkspaceError as e:
68
+ _err(str(e))
69
+ return 2
70
+ except LLMError as e:
71
+ _err(f"{e}\nIs LM Studio running? Try: lms ps")
72
+ return 2
73
+ if args.model not in models:
74
+ _err(f"model '{args.model}' not loaded (loaded: {', '.join(models) or 'none'}). "
75
+ f"Load it with: lms load {args.model}")
76
+ return 2
77
+
78
+ # ---- workspace ----
79
+ slug = make_slug(args.task, datetime.now())
80
+ try:
81
+ ensure_worktrees_excluded(repo)
82
+ worktree = create_worktree(repo, slug, args.branch_from)
83
+ except WorkspaceError as e:
84
+ _err(str(e))
85
+ return 2
86
+
87
+ transcript_path = RUNS_DIR / slug / "transcript.jsonl"
88
+ print(f"transcript: {transcript_path}", file=sys.stderr)
89
+ print(f"worktree: {worktree}", file=sys.stderr)
90
+
91
+ # ---- run ----
92
+ # Everything from here on -- Transcript/ToolExecutor/Runner construction,
93
+ # system-prompt assembly, and the run itself -- is wrapped in one boundary so
94
+ # the machine contract (exactly one JSON object on stdout, post-preflight)
95
+ # holds even if a component other than runner.run() blows up.
96
+ transcript = None
97
+ try:
98
+ transcript = Transcript(transcript_path)
99
+ executor = ToolExecutor(worktree, transcript=transcript)
100
+ runner = Runner(client, executor, transcript, model=args.model,
101
+ max_turns=args.max_turns, timeout=args.timeout,
102
+ temperature=args.temperature,
103
+ run_info={"repo": str(repo), "worktree": str(worktree)})
104
+ system_prompt = build_system_prompt(worktree, load_repo_context(worktree))
105
+ result = runner.run(system_prompt, args.task)
106
+ except Exception as e:
107
+ message = str(e) if isinstance(e, LLMError) else f"unexpected error: {e!r}"
108
+ if transcript is not None:
109
+ try:
110
+ transcript.write("run_end", status="model_error", error=message)
111
+ except Exception:
112
+ pass
113
+ _err(message)
114
+ print(json.dumps({
115
+ "status": "model_error",
116
+ "worktree": str(worktree),
117
+ "branch": f"dirtywork/{slug}",
118
+ "transcript": str(transcript_path),
119
+ "turns": None,
120
+ "usage": {},
121
+ "final_message": message,
122
+ }, indent=2))
123
+ return 1
124
+ finally:
125
+ if transcript is not None:
126
+ try:
127
+ transcript.close()
128
+ except Exception:
129
+ pass
130
+
131
+ print(json.dumps({
132
+ "status": result.status,
133
+ "worktree": str(worktree),
134
+ "branch": f"dirtywork/{slug}",
135
+ "transcript": str(transcript_path),
136
+ "turns": result.turns,
137
+ "usage": result.usage,
138
+ "final_message": result.final_message,
139
+ }, indent=2))
140
+ return 0 if result.status == "completed" else 1
141
+
142
+
143
+ if __name__ == "__main__":
144
+ sys.exit(main())