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 +1 -0
- dirtywork/__main__.py +144 -0
- dirtywork/guardrails.py +67 -0
- dirtywork/llm.py +77 -0
- dirtywork/runner.py +220 -0
- dirtywork/tools.py +237 -0
- dirtywork/transcript.py +23 -0
- dirtywork/workspace.py +76 -0
- dirtywork-0.2.0.dist-info/METADATA +239 -0
- dirtywork-0.2.0.dist-info/RECORD +14 -0
- dirtywork-0.2.0.dist-info/WHEEL +5 -0
- dirtywork-0.2.0.dist-info/entry_points.txt +2 -0
- dirtywork-0.2.0.dist-info/licenses/LICENSE +21 -0
- dirtywork-0.2.0.dist-info/top_level.txt +1 -0
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())
|
dirtywork/guardrails.py
ADDED
|
@@ -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
|
dirtywork/transcript.py
ADDED
|
@@ -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
|
+
[](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,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,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
|