dirtywork 0.2.0__py3-none-any.whl

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/__init__.py ADDED
@@ -0,0 +1 @@
1
+ __version__ = "0.2.0"
dirtywork/__main__.py ADDED
@@ -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())
@@ -0,0 +1,67 @@
1
+ from __future__ import annotations
2
+
3
+ import os
4
+ import re
5
+ from pathlib import Path
6
+
7
+
8
+ class GuardrailError(Exception):
9
+ """Raised when a tool call violates a containment rule."""
10
+
11
+
12
+ def resolve_in_worktree(path_str: str, worktree: Path, writing: bool = False) -> Path:
13
+ """Resolve a tool-supplied path and require it to land inside the worktree.
14
+
15
+ Symlinks are followed (Path.resolve), so a link pointing outside is caught.
16
+ For not-yet-existing paths, resolve() still normalizes .. components.
17
+ """
18
+ wt = worktree.resolve()
19
+ raw = Path(path_str)
20
+ candidate = raw if raw.is_absolute() else wt / raw
21
+ resolved = candidate.resolve()
22
+
23
+ if not (resolved == wt or wt in resolved.parents):
24
+ raise GuardrailError(
25
+ f"Path '{path_str}' resolves outside the worktree ({resolved}). "
26
+ f"Use paths relative to the worktree root."
27
+ )
28
+ if writing:
29
+ rel_parts = resolved.relative_to(wt).parts
30
+ if rel_parts and rel_parts[0] == ".git":
31
+ raise GuardrailError(
32
+ f"Writing inside .git/ is not allowed (got '{path_str}')."
33
+ )
34
+ return resolved
35
+
36
+ # (reason, pattern) — case-insensitive. Blocks accidents, not adversaries.
37
+ _DENYLIST: list[tuple[str, str]] = [
38
+ ("sudo is not allowed", r"\bsudo\b"),
39
+ ("git push is not allowed — leave changes uncommitted for review",
40
+ r"\bgit\s+push\b"),
41
+ ("destructive command targeting an absolute or home path",
42
+ r"\b(rm|mv|chmod|chown)\b[^|;&]*\s['\"]?(/|~)"),
43
+ ("piping a download into a shell is not allowed",
44
+ r"\b(curl|wget)\b[^|;&]*\|\s*['\"]?\w*\s*(ba|z|da)?sh\b"),
45
+ ("system-control commands are not allowed",
46
+ r"\b(osascript|launchctl|shutdown|reboot|killall)\b"),
47
+ ("redirecting output to an absolute or home path outside the worktree",
48
+ r">>?\s*['\"]?(?!/dev/null)(/|~)"),
49
+ ("changing directory out of the worktree is not allowed",
50
+ r"\b(cd|pushd)\s+['\"]?(/|~|\.\.)"),
51
+ ]
52
+
53
+ _COMPILED = [(reason, re.compile(pat, re.IGNORECASE)) for reason, pat in _DENYLIST]
54
+
55
+
56
+ def check_bash_command(command: str) -> str | None:
57
+ """Return a rejection reason if the command matches the denylist, else None."""
58
+ for reason, rx in _COMPILED:
59
+ if rx.search(command):
60
+ return f"BLOCKED: {reason}. Rework the command to stay inside the worktree."
61
+ return None
62
+
63
+
64
+ def build_env() -> dict:
65
+ """Minimal env for bash subprocesses — parent shell secrets are not inherited."""
66
+ keep = ("PATH", "HOME", "TERM", "LANG", "TMPDIR")
67
+ return {k: os.environ[k] for k in keep if k in os.environ}
dirtywork/llm.py ADDED
@@ -0,0 +1,77 @@
1
+ from __future__ import annotations
2
+
3
+ import http.client
4
+ import json
5
+ import socket
6
+ import urllib.error
7
+ import urllib.request
8
+
9
+
10
+ class LLMError(Exception):
11
+ """Raised when the LM Studio server is unreachable or returns garbage."""
12
+
13
+
14
+ class LLMTimeout(LLMError):
15
+ """Raised when a request to LM Studio times out."""
16
+
17
+
18
+ class LMStudioClient:
19
+ def __init__(self, base_url: str = "http://localhost:1234/v1", timeout: int = 600):
20
+ self.base_url = base_url.rstrip("/")
21
+ self.timeout = timeout
22
+
23
+ def _request(self, path: str, payload: dict | None = None,
24
+ timeout: float | None = None) -> dict:
25
+ url = f"{self.base_url}{path}"
26
+ try:
27
+ data = json.dumps(payload).encode() if payload is not None else None
28
+ req = urllib.request.Request(
29
+ url, data=data, headers={"Content-Type": "application/json"}
30
+ )
31
+ except (ValueError, TypeError) as e:
32
+ raise LLMError(f"invalid request for LM Studio at {self.base_url!r}: {e}")
33
+ effective_timeout = timeout if timeout is not None else self.timeout
34
+ try:
35
+ with urllib.request.urlopen(req, timeout=effective_timeout) as resp:
36
+ body = resp.read()
37
+ except urllib.error.HTTPError as e:
38
+ try:
39
+ detail = e.read()[:500]
40
+ except Exception:
41
+ detail = b"<unreadable error body>"
42
+ raise LLMError(f"LM Studio HTTP {e.code} on {path}: {detail!r}")
43
+ except (urllib.error.URLError, OSError, http.client.HTTPException, ValueError) as e:
44
+ if isinstance(e, socket.timeout) or isinstance(getattr(e, "reason", None), socket.timeout):
45
+ raise LLMTimeout(f"request to {path} timed out after {effective_timeout}s")
46
+ raise LLMError(f"cannot reach LM Studio at {self.base_url}: {e}")
47
+ try:
48
+ return json.loads(body.decode())
49
+ except (json.JSONDecodeError, UnicodeDecodeError) as e:
50
+ raise LLMError(f"invalid JSON from LM Studio on {path}: {e}")
51
+
52
+ def list_models(self) -> list[str]:
53
+ body = self._request("/models")
54
+ if not isinstance(body, dict) or not isinstance(body.get("data"), list):
55
+ raise LLMError("unexpected /models response shape from LM Studio")
56
+ ids = []
57
+ for m in body["data"]:
58
+ if not isinstance(m, dict) or not isinstance(m.get("id"), str):
59
+ raise LLMError("unexpected /models entry shape from LM Studio")
60
+ ids.append(m["id"])
61
+ return ids
62
+
63
+ def chat(
64
+ self,
65
+ model: str,
66
+ messages: list,
67
+ tools: list,
68
+ temperature: float | None = None,
69
+ max_tokens: int = 4096,
70
+ timeout: float | None = None,
71
+ ) -> dict:
72
+ payload = {"model": model, "messages": messages, "max_tokens": max_tokens}
73
+ if tools:
74
+ payload["tools"] = tools
75
+ if temperature is not None:
76
+ payload["temperature"] = temperature
77
+ return self._request("/chat/completions", payload, timeout=timeout)
dirtywork/runner.py ADDED
@@ -0,0 +1,220 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import time
5
+ from dataclasses import dataclass, field
6
+
7
+ from .llm import LLMTimeout
8
+
9
+ CONTEXT_WINDOWS = {
10
+ "qwen/qwen3-coder-next": 65536,
11
+ "mistralai/devstral-small-2-2512": 32768,
12
+ }
13
+ DEFAULT_WINDOW = 32768
14
+ TRIM_MARKER = "[result trimmed — re-run the tool if needed]"
15
+ CHARS_PER_TOKEN = 4
16
+ BUDGET_FRACTION = 0.75
17
+ MAX_CONSECUTIVE_FAILURES = 3
18
+
19
+
20
+ def _valid_tool_call(tc) -> bool:
21
+ """Structurally valid OpenAI tool call: non-empty string id, function object
22
+ with non-empty string name, arguments absent/None or a string."""
23
+ if not isinstance(tc, dict):
24
+ return False
25
+ if not isinstance(tc.get("id"), str) or not tc["id"]:
26
+ return False
27
+ fn = tc.get("function")
28
+ if not isinstance(fn, dict):
29
+ return False
30
+ if not isinstance(fn.get("name"), str) or not fn["name"]:
31
+ return False
32
+ args = fn.get("arguments")
33
+ return args is None or isinstance(args, str)
34
+
35
+
36
+ def _canonical_tool_call(tc: dict) -> dict:
37
+ """Rebuild an accepted call in canonical OpenAI wire shape."""
38
+ fn = tc["function"]
39
+ return {"id": tc["id"], "type": "function",
40
+ "function": {"name": fn["name"], "arguments": fn.get("arguments") or "{}"}}
41
+
42
+
43
+ def _total_chars(messages: list) -> int:
44
+ total = 0
45
+ for m in messages:
46
+ total += len(m.get("content") or "")
47
+ for tc in m.get("tool_calls") or []:
48
+ if not isinstance(tc, dict):
49
+ continue
50
+ total += len((tc.get("function") or {}).get("arguments") or "")
51
+ return total
52
+
53
+
54
+ def trim_messages(messages: list, char_budget: int) -> bool:
55
+ """Replace oldest tool results with TRIM_MARKER until under budget."""
56
+ for m in messages:
57
+ if _total_chars(messages) <= char_budget:
58
+ return True
59
+ if m.get("role") == "tool" and m.get("content") != TRIM_MARKER:
60
+ m["content"] = TRIM_MARKER
61
+ return _total_chars(messages) <= char_budget
62
+
63
+
64
+ @dataclass
65
+ class RunResult:
66
+ status: str
67
+ turns: int
68
+ final_message: str
69
+ usage: dict = field(default_factory=dict)
70
+
71
+
72
+ class Runner:
73
+ def __init__(self, client, executor, transcript, model,
74
+ max_turns: int = 40, timeout: int = 1800,
75
+ temperature: float | None = None,
76
+ run_info: dict | None = None):
77
+ self.client = client
78
+ self.executor = executor
79
+ self.transcript = transcript
80
+ self.model = model
81
+ self.max_turns = max_turns
82
+ self.timeout = timeout
83
+ self.temperature = temperature
84
+ self.run_info = run_info
85
+ window = CONTEXT_WINDOWS.get(model, DEFAULT_WINDOW)
86
+ self.char_budget = int(window * BUDGET_FRACTION * CHARS_PER_TOKEN)
87
+
88
+ def run(self, system_prompt: str, task: str) -> RunResult:
89
+ from .tools import TOOL_SCHEMAS
90
+
91
+ messages = [
92
+ {"role": "system", "content": system_prompt},
93
+ {"role": "user", "content": task},
94
+ ]
95
+ self.transcript.write("run_start", task=task, model=self.model,
96
+ max_turns=self.max_turns, timeout=self.timeout,
97
+ **(self.run_info or {}))
98
+ usage = {"prompt_tokens": 0, "completion_tokens": 0}
99
+ turns = 0
100
+ failures = 0
101
+ start = time.monotonic()
102
+ deadline = start + self.timeout
103
+ self.executor.deadline = deadline
104
+
105
+ def finish(status: str, final: str) -> RunResult:
106
+ self.transcript.write("run_end", status=status, turns=turns,
107
+ duration_s=round(time.monotonic() - start, 1),
108
+ usage=usage)
109
+ return RunResult(status, turns, final, usage)
110
+
111
+ try:
112
+ while True:
113
+ if turns >= self.max_turns:
114
+ return finish("max_turns", "")
115
+ remaining = deadline - time.monotonic()
116
+ if remaining <= 0:
117
+ return finish("timeout", "")
118
+ if not trim_messages(messages, self.char_budget):
119
+ return finish("context_exhausted", "")
120
+
121
+ try:
122
+ resp = self.client.chat(self.model, messages, tools=TOOL_SCHEMAS,
123
+ temperature=self.temperature,
124
+ timeout=max(1.0, remaining))
125
+ except LLMTimeout:
126
+ if time.monotonic() >= deadline - 0.5:
127
+ return finish("timeout", "")
128
+ else:
129
+ return finish("model_error",
130
+ "model request timed out before the run deadline")
131
+ turns += 1
132
+ try:
133
+ msg = resp["choices"][0]["message"]
134
+ if not isinstance(msg, dict):
135
+ raise TypeError("message is not an object")
136
+ except (KeyError, IndexError, TypeError):
137
+ return finish("model_error",
138
+ "malformed response from server (no choices[0].message)")
139
+ finish_reason = resp["choices"][0].get("finish_reason")
140
+ resp_usage = resp.get("usage") or {}
141
+ for k in usage:
142
+ usage[k] += resp_usage.get(k, 0) or 0
143
+ raw = msg.get("tool_calls") or []
144
+ if not isinstance(raw, list):
145
+ raw = []
146
+ tool_calls = [_canonical_tool_call(tc) for tc in raw if _valid_tool_call(tc)]
147
+ malformed_count = len(raw) - len(tool_calls)
148
+ self.transcript.write(
149
+ "assistant", text=msg.get("content"),
150
+ tool_calls=[{"name": (tc.get("function") or {}).get("name"),
151
+ "arguments": ((tc.get("function") or {}).get("arguments") or "")[:2000]}
152
+ for tc in tool_calls])
153
+ if raw:
154
+ # Rebuild in canonical wire shape (id, type: "function", function
155
+ # with a string arguments) so the history we send back stays
156
+ # protocol-valid for strict OpenAI-compatible servers, on every
157
+ # path that resends tool calls -- not just when malformed.
158
+ clean_msg = {"role": "assistant", "content": msg.get("content") or ""}
159
+ if tool_calls:
160
+ clean_msg["tool_calls"] = tool_calls
161
+ messages.append(clean_msg)
162
+ else:
163
+ messages.append(msg)
164
+ return finish("completed", msg.get("content") or "")
165
+
166
+ for _ in range(malformed_count):
167
+ failures += 1
168
+ result = "ERROR: malformed tool call entry (missing or invalid id/function fields)"
169
+ self.transcript.write("tool_result", tool="", args="", result=result)
170
+ if malformed_count > 0 and failures >= MAX_CONSECUTIVE_FAILURES:
171
+ return finish("model_error",
172
+ "aborted after repeated malformed tool calls")
173
+
174
+ for tc in tool_calls:
175
+ fn_info = tc.get("function") or {}
176
+ name = fn_info.get("name") or ""
177
+ raw_args = fn_info.get("arguments") or "{}"
178
+ call_id = tc.get("id", "")
179
+ try:
180
+ args = json.loads(raw_args)
181
+ if not isinstance(args, dict):
182
+ raise ValueError("arguments must be a JSON object")
183
+ result = self.executor.execute(name, args)
184
+ failures = 0
185
+ except (json.JSONDecodeError, ValueError) as e:
186
+ failures += 1
187
+ if finish_reason == "length":
188
+ result = (
189
+ "ERROR: your reply was cut off at the token limit before "
190
+ "the tool call completed. Emit smaller tool calls — e.g. "
191
+ "write the file in pieces using multiple write_file/"
192
+ "edit_file calls."
193
+ )
194
+ else:
195
+ result = f"ERROR: malformed tool arguments: {e}"
196
+ except KeyError:
197
+ failures += 1
198
+ available_tools = ', '.join(s['function']['name'] for s in TOOL_SCHEMAS)
199
+ result = f"ERROR: unknown tool '{name}'. Available: {available_tools}."
200
+ except TypeError as e:
201
+ failures += 1
202
+ result = f"ERROR: bad arguments for {name}: {e}"
203
+ self.transcript.write("tool_result", tool=name,
204
+ args=raw_args[:500],
205
+ result=result[:2000])
206
+ messages.append({"role": "tool", "tool_call_id": call_id,
207
+ "content": result})
208
+ if failures >= MAX_CONSECUTIVE_FAILURES:
209
+ return finish("model_error",
210
+ "aborted after repeated malformed tool calls")
211
+
212
+ if malformed_count > 0:
213
+ messages.append({
214
+ "role": "user",
215
+ "content": (f"{malformed_count} of your tool calls were malformed "
216
+ "(missing or invalid id/function fields) and were "
217
+ "discarded. Re-issue them as valid tool calls."),
218
+ })
219
+ except KeyboardInterrupt:
220
+ return finish("interrupted", "")
dirtywork/tools.py ADDED
@@ -0,0 +1,237 @@
1
+ from __future__ import annotations
2
+
3
+ import shutil
4
+ import subprocess
5
+ import time
6
+ from pathlib import Path
7
+
8
+ from .guardrails import GuardrailError, build_env, check_bash_command, resolve_in_worktree
9
+
10
+ MAX_RESULT_CHARS = 8000
11
+
12
+
13
+ def _cap(text: str, cap: int = MAX_RESULT_CHARS, note: str = "") -> str:
14
+ if len(text) <= cap:
15
+ return text
16
+ suffix = f"\n[output truncated at {cap} chars{note}]"
17
+ return text[:cap] + suffix
18
+
19
+
20
+ def read_file(worktree: Path, path: str, offset: int = 0, limit: int = 400) -> str:
21
+ try:
22
+ p = resolve_in_worktree(path, worktree)
23
+ lines = p.read_text(encoding="utf-8", errors="replace").splitlines()
24
+ except GuardrailError as e:
25
+ return f"ERROR: {e}"
26
+ except OSError as e:
27
+ return f"ERROR: cannot read '{path}': {e}"
28
+ window = lines[offset : offset + limit]
29
+ numbered = "\n".join(f"{i:6}\t{line}" for i, line in enumerate(window, offset + 1))
30
+ if offset + limit < len(lines):
31
+ numbered += (
32
+ f"\n[showing lines {offset + 1}-{offset + len(window)} of {len(lines)}; "
33
+ f"re-run with offset={offset + limit} for more]"
34
+ )
35
+ return _cap(numbered, note=" — re-run with offset/limit to see more")
36
+
37
+
38
+ def write_file(worktree: Path, path: str, content: str) -> str:
39
+ try:
40
+ p = resolve_in_worktree(path, worktree, writing=True)
41
+ p.parent.mkdir(parents=True, exist_ok=True)
42
+ p.write_text(content, encoding="utf-8")
43
+ except GuardrailError as e:
44
+ return f"ERROR: {e}"
45
+ except OSError as e:
46
+ return f"ERROR: cannot write '{path}': {e}"
47
+ return f"Wrote {len(content.encode('utf-8'))} bytes to {path}"
48
+
49
+
50
+ def edit_file(worktree: Path, path: str, old_string: str, new_string: str) -> str:
51
+ try:
52
+ p = resolve_in_worktree(path, worktree, writing=True)
53
+ text = p.read_text(encoding="utf-8")
54
+ except GuardrailError as e:
55
+ return f"ERROR: {e}"
56
+ except UnicodeDecodeError:
57
+ return f"ERROR: {path} is not valid UTF-8 text; edit_file only works on text files"
58
+ except OSError as e:
59
+ return f"ERROR: cannot read '{path}': {e}"
60
+ count = text.count(old_string)
61
+ if count != 1:
62
+ return (
63
+ f"ERROR: old_string occurs {count} times in {path}; it must occur exactly "
64
+ f"once. Include more surrounding context to make it unique."
65
+ )
66
+ try:
67
+ p.write_text(text.replace(old_string, new_string, 1), encoding="utf-8")
68
+ except OSError as e:
69
+ return f"ERROR: cannot write '{path}': {e}"
70
+ return f"Edited {path}"
71
+
72
+
73
+ def list_dir(worktree: Path, path: str = ".") -> str:
74
+ try:
75
+ p = resolve_in_worktree(path, worktree)
76
+ entries = sorted(p.iterdir(), key=lambda e: e.name)
77
+ except GuardrailError as e:
78
+ return f"ERROR: {e}"
79
+ except OSError as e:
80
+ return f"ERROR: cannot list '{path}': {e}"
81
+ rows = []
82
+ for e in entries:
83
+ try:
84
+ if e.is_dir():
85
+ rows.append(f"{e.name}/")
86
+ else:
87
+ rows.append(f"{e.name} ({e.stat().st_size} bytes)")
88
+ except OSError:
89
+ rows.append(f"{e.name} (broken symlink)")
90
+ return _cap("\n".join(rows) or "(empty directory)")
91
+
92
+
93
+ def grep(worktree: Path, pattern: str, path: str = ".", glob: str | None = None,
94
+ timeout: int = 30) -> str:
95
+ try:
96
+ p = resolve_in_worktree(path, worktree)
97
+ except GuardrailError as e:
98
+ return f"ERROR: {e}"
99
+ except OSError as e:
100
+ return f"ERROR: cannot access '{path}': {e}"
101
+ rg = shutil.which("rg")
102
+ if rg:
103
+ cmd = [rg, "-n", "--no-heading", "-M", "300", "-e", pattern]
104
+ if glob:
105
+ cmd += ["-g", glob]
106
+ cmd.append(str(p))
107
+ else:
108
+ cmd = ["grep", "-rn", "-e", pattern]
109
+ if glob:
110
+ cmd += [f"--include={glob}"]
111
+ cmd.append(str(p))
112
+ try:
113
+ res = subprocess.run(cmd, capture_output=True, text=True, timeout=timeout)
114
+ except subprocess.TimeoutExpired:
115
+ return f"ERROR: grep timed out after {timeout}s — narrow the pattern or path."
116
+ except OSError as e:
117
+ return f"ERROR: grep failed: {e}"
118
+ if res.returncode not in (0, 1):
119
+ return f"ERROR: grep failed: {res.stderr.strip()[:500]}"
120
+ if not res.stdout.strip():
121
+ return "No matches found."
122
+ # strip the worktree prefix so results read as relative paths
123
+ out = res.stdout.replace(str(worktree.resolve()) + "/", "")
124
+ return _cap(out, note=" — narrow the pattern or path for full results")
125
+
126
+
127
+ MAX_BASH_CHARS = 10000
128
+
129
+
130
+ def bash(worktree: Path, command: str, timeout: int = 120) -> str:
131
+ reason = check_bash_command(command)
132
+ if reason:
133
+ return reason # starts with "BLOCKED:"
134
+ timeout = max(1, min(int(timeout), 600))
135
+ try:
136
+ res = subprocess.run(
137
+ ["bash", "-c", command],
138
+ cwd=str(worktree),
139
+ env=build_env(),
140
+ capture_output=True,
141
+ text=True,
142
+ timeout=timeout,
143
+ )
144
+ except subprocess.TimeoutExpired:
145
+ return f"ERROR: command timed out after {timeout}s."
146
+ except OSError as e:
147
+ return f"ERROR: bash failed: {e}"
148
+ combined = (res.stdout + res.stderr).strip()
149
+ return _cap(f"exit code: {res.returncode}\n{combined}", cap=MAX_BASH_CHARS)
150
+
151
+
152
+ def _param(props: dict, required: list) -> dict:
153
+ return {"type": "object", "properties": props, "required": required}
154
+
155
+
156
+ TOOL_SCHEMAS = [
157
+ {"type": "function", "function": {
158
+ "name": "read_file",
159
+ "description": "Read a file, returning numbered lines. Large files are "
160
+ "windowed; use offset/limit to page through.",
161
+ "parameters": _param({
162
+ "path": {"type": "string", "description": "Path relative to worktree root"},
163
+ "offset": {"type": "integer", "description": "0-based first line, default 0"},
164
+ "limit": {"type": "integer", "description": "Max lines, default 400"},
165
+ }, ["path"])}},
166
+ {"type": "function", "function": {
167
+ "name": "write_file",
168
+ "description": "Create or overwrite a file. Parent directories are created.",
169
+ "parameters": _param({
170
+ "path": {"type": "string"},
171
+ "content": {"type": "string"},
172
+ }, ["path", "content"])}},
173
+ {"type": "function", "function": {
174
+ "name": "edit_file",
175
+ "description": "Replace old_string with new_string in a file. old_string "
176
+ "must occur exactly once — include surrounding context.",
177
+ "parameters": _param({
178
+ "path": {"type": "string"},
179
+ "old_string": {"type": "string"},
180
+ "new_string": {"type": "string"},
181
+ }, ["path", "old_string", "new_string"])}},
182
+ {"type": "function", "function": {
183
+ "name": "list_dir",
184
+ "description": "List a directory's entries (dirs end with /).",
185
+ "parameters": _param({"path": {"type": "string", "description": "Default '.'"}}, [])}},
186
+ {"type": "function", "function": {
187
+ "name": "grep",
188
+ "description": "Search file contents with a regex. Optional glob filter "
189
+ "like '*.cs' or '*.tsx'.",
190
+ "parameters": _param({
191
+ "pattern": {"type": "string"},
192
+ "path": {"type": "string", "description": "Default '.'"},
193
+ "glob": {"type": "string"},
194
+ }, ["pattern"])}},
195
+ {"type": "function", "function": {
196
+ "name": "bash",
197
+ "description": "Run a shell command in the worktree (cwd is the worktree "
198
+ "root). Use for builds/tests/git-status, NEVER for editing "
199
+ "files. 120s default timeout, 600s max.",
200
+ "parameters": _param({
201
+ "command": {"type": "string"},
202
+ "timeout": {"type": "integer", "description": "Seconds, default 120, max 600"},
203
+ }, ["command"])}},
204
+ ]
205
+
206
+
207
+ class ToolExecutor:
208
+ """Dispatches validated tool calls. Unknown names raise KeyError."""
209
+
210
+ def __init__(self, worktree: Path, transcript=None):
211
+ self.worktree = worktree
212
+ self.transcript = transcript
213
+ self.deadline = None
214
+ self._table = {
215
+ "read_file": read_file,
216
+ "write_file": write_file,
217
+ "edit_file": edit_file,
218
+ "list_dir": list_dir,
219
+ "grep": grep,
220
+ "bash": bash,
221
+ }
222
+
223
+ def execute(self, name: str, args: dict) -> str:
224
+ fn = self._table[name] # KeyError → runner counts a model failure
225
+ if self.deadline is not None:
226
+ remaining = self.deadline - time.monotonic()
227
+ if remaining <= 0:
228
+ return ("ERROR: run deadline exceeded; stop calling tools and "
229
+ "summarize what you have done.")
230
+ if name in ("bash", "grep"):
231
+ args = dict(args)
232
+ default = 120 if name == "bash" else 30
233
+ args["timeout"] = min(int(args.get("timeout", default)), max(1, int(remaining)))
234
+ result = fn(self.worktree, **args)
235
+ if result.startswith("BLOCKED:") and self.transcript is not None:
236
+ self.transcript.write("guardrail_block", tool=name, args=args, reason=result)
237
+ return result
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from datetime import datetime, timezone
5
+ from pathlib import Path
6
+
7
+
8
+ class Transcript:
9
+ """Append-only JSONL event log, flushed per line so `tail -f` works."""
10
+
11
+ def __init__(self, path: Path):
12
+ self.path = Path(path)
13
+ self.path.parent.mkdir(parents=True, exist_ok=True)
14
+ self._fh = open(self.path, "a", encoding="utf-8")
15
+
16
+ def write(self, event: str, **fields) -> None:
17
+ record = {"ts": datetime.now(timezone.utc).isoformat(), "event": event}
18
+ record.update(fields)
19
+ self._fh.write(json.dumps(record, ensure_ascii=False) + "\n")
20
+ self._fh.flush()
21
+
22
+ def close(self) -> None:
23
+ self._fh.close()
dirtywork/workspace.py ADDED
@@ -0,0 +1,76 @@
1
+ from __future__ import annotations
2
+
3
+ import re
4
+ import secrets
5
+ import subprocess
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+
10
+ class WorkspaceError(Exception):
11
+ """Raised when the target repo or worktree operation is unusable."""
12
+
13
+
14
+ def _git(repo: Path, *args: str) -> subprocess.CompletedProcess:
15
+ return subprocess.run(
16
+ ["git", "-C", str(repo), *args], capture_output=True, text=True
17
+ )
18
+
19
+
20
+ def preflight_repo(repo: Path) -> None:
21
+ if not repo.is_dir():
22
+ raise WorkspaceError(f"{repo} is not a directory")
23
+ if _git(repo, "rev-parse", "--is-inside-work-tree").returncode != 0:
24
+ raise WorkspaceError(f"{repo} is not a git repository")
25
+ if _git(repo, "rev-parse", "HEAD").returncode != 0:
26
+ raise WorkspaceError(f"{repo} has no commits (worktrees need a base ref)")
27
+
28
+
29
+ def make_slug(task: str, now: datetime, salt: str | None = None) -> str:
30
+ words = re.sub(r"[^a-z0-9\s-]", "", task.lower()).split()[:5]
31
+ base = re.sub(r"-+", "-", "-".join(words))[:40].strip("-") or "task"
32
+ if salt is None:
33
+ salt = secrets.token_hex(2)
34
+ return f"{base}-{now.strftime('%m%d%H%M%S')}-{salt}"
35
+
36
+
37
+ def create_worktree(repo: Path, slug: str, branch_from: str | None) -> Path:
38
+ rel = Path(".worktrees") / f"dw-{slug}"
39
+ ref = branch_from or "HEAD"
40
+ branch = f"dirtywork/{slug}"
41
+ existed = _git(repo, "rev-parse", "--verify", "--quiet",
42
+ f"refs/heads/{branch}").returncode == 0
43
+ res = _git(repo, "worktree", "add", "-b", branch, str(rel), ref)
44
+ if res.returncode != 0:
45
+ if not existed:
46
+ _git(repo, "branch", "-D", branch) # best-effort cleanup; ignore result
47
+ raise WorkspaceError(f"git worktree add failed: {res.stderr.strip()}")
48
+ return repo / rel
49
+
50
+
51
+ def ensure_worktrees_excluded(repo: Path) -> None:
52
+ # Use --git-path (not --git-dir) so this resolves to the shared repository's
53
+ # info/exclude even when `repo` is itself a linked worktree — a linked
54
+ # worktree's --git-dir is its private .git/worktrees/<name> dir, but git only
55
+ # ever consults the common/shared info/exclude for status/ignore purposes.
56
+ res = _git(repo, "rev-parse", "--git-path", "info/exclude")
57
+ if res.returncode != 0:
58
+ raise WorkspaceError(f"cannot locate git dir for {repo}")
59
+ exclude = Path(res.stdout.strip())
60
+ if not exclude.is_absolute():
61
+ exclude = repo / exclude
62
+ exclude.parent.mkdir(parents=True, exist_ok=True)
63
+ existing = exclude.read_text() if exclude.exists() else ""
64
+ if ".worktrees/" not in existing:
65
+ with open(exclude, "a", encoding="utf-8") as fh:
66
+ if existing and not existing.endswith("\n"):
67
+ fh.write("\n")
68
+ fh.write(".worktrees/\n")
69
+
70
+
71
+ def load_repo_context(repo: Path) -> str | None:
72
+ for name in ("CLAUDE.md", "AGENTS.md"):
73
+ p = repo / name
74
+ if p.is_file():
75
+ return p.read_text(encoding="utf-8", errors="replace")
76
+ return None
@@ -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,14 @@
1
+ dirtywork/__init__.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
2
+ dirtywork/__main__.py,sha256=g9E4aj4OGdN9mxkYfrpvmqpGNBGiLGde70oac4a-ClE,5371
3
+ dirtywork/guardrails.py,sha256=XBpP4sa4h_6C97wVmCQOBPuTWhkP9rI8QeJp0Jqoc1g,2588
4
+ dirtywork/llm.py,sha256=sHJ4RLau_INaYTXjRLhTYz8dGWSA9m5sE9oY5t_lTJc,3067
5
+ dirtywork/runner.py,sha256=9oSbg3Sfm9oij7U4TR_tnJaBWY4ioeGx3Zs40gftILM,9802
6
+ dirtywork/tools.py,sha256=5j-UVKuGNKtLB7-cy9Qk17c-l16TDxiSmOukLs0lBoA,9167
7
+ dirtywork/transcript.py,sha256=rHQGGVK_Pd5EKcsycSQH0w7P31QrpPhw5B6VW169SDM,707
8
+ dirtywork/workspace.py,sha256=2SBfalZcQa-atTDCFZEQlESASyTIX-rarWKffVJwgbg,2961
9
+ dirtywork-0.2.0.dist-info/licenses/LICENSE,sha256=g6jZ9mmdV6YHbQjPgxGgk8Iqv-vaJRWoh4RY9Wy0srQ,1083
10
+ dirtywork-0.2.0.dist-info/METADATA,sha256=k_J3QstKRVoAPmHq-URQupBkEVdJqIpXJuJHWmHAqos,10596
11
+ dirtywork-0.2.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
12
+ dirtywork-0.2.0.dist-info/entry_points.txt,sha256=JO9-O2lDQWfLkCuYpgtap3xe2cw1CFp_RSfMv2IrF4k,54
13
+ dirtywork-0.2.0.dist-info/top_level.txt,sha256=wlayGLP6ddtjpfsABOphDSBrBb4lp7dcY84tH3Ra6s8,10
14
+ dirtywork-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ dirtywork = dirtywork.__main__:main
@@ -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 @@
1
+ dirtywork