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.
- dirtywork-0.2.0/LICENSE +21 -0
- dirtywork-0.2.0/PKG-INFO +239 -0
- dirtywork-0.2.0/README.md +218 -0
- dirtywork-0.2.0/dirtywork/__init__.py +1 -0
- dirtywork-0.2.0/dirtywork/__main__.py +144 -0
- dirtywork-0.2.0/dirtywork/guardrails.py +67 -0
- dirtywork-0.2.0/dirtywork/llm.py +77 -0
- dirtywork-0.2.0/dirtywork/runner.py +220 -0
- dirtywork-0.2.0/dirtywork/tools.py +237 -0
- dirtywork-0.2.0/dirtywork/transcript.py +23 -0
- dirtywork-0.2.0/dirtywork/workspace.py +76 -0
- dirtywork-0.2.0/dirtywork.egg-info/PKG-INFO +239 -0
- dirtywork-0.2.0/dirtywork.egg-info/SOURCES.txt +26 -0
- dirtywork-0.2.0/dirtywork.egg-info/dependency_links.txt +1 -0
- dirtywork-0.2.0/dirtywork.egg-info/entry_points.txt +2 -0
- dirtywork-0.2.0/dirtywork.egg-info/top_level.txt +1 -0
- dirtywork-0.2.0/pyproject.toml +36 -0
- dirtywork-0.2.0/setup.cfg +4 -0
- dirtywork-0.2.0/tests/test_guardrails_bash.py +61 -0
- dirtywork-0.2.0/tests/test_guardrails_paths.py +60 -0
- dirtywork-0.2.0/tests/test_live.py +68 -0
- dirtywork-0.2.0/tests/test_llm.py +154 -0
- dirtywork-0.2.0/tests/test_main.py +168 -0
- dirtywork-0.2.0/tests/test_runner.py +489 -0
- dirtywork-0.2.0/tests/test_tools_bash.py +117 -0
- dirtywork-0.2.0/tests/test_tools_files.py +105 -0
- dirtywork-0.2.0/tests/test_transcript.py +32 -0
- dirtywork-0.2.0/tests/test_workspace.py +164 -0
dirtywork-0.2.0/LICENSE
ADDED
|
@@ -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.
|
dirtywork-0.2.0/PKG-INFO
ADDED
|
@@ -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
|
+
[](https://github.com/JimboSchneider/dirtywork/actions/workflows/ci.yml)
|
|
25
|
+
[](https://pypi.org/project/dirtywork/)
|
|
26
|
+
[](https://github.com/JimboSchneider/dirtywork/blob/main/LICENSE)
|
|
27
|
+
[](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
|
+
[](https://github.com/JimboSchneider/dirtywork/actions/workflows/ci.yml)
|
|
4
|
+
[](https://pypi.org/project/dirtywork/)
|
|
5
|
+
[](https://github.com/JimboSchneider/dirtywork/blob/main/LICENSE)
|
|
6
|
+
[](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())
|