py-harness-cli 0.3.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.
Files changed (92) hide show
  1. finetune/__init__.py +1 -0
  2. finetune/agent_system.py +41 -0
  3. finetune/agent_traces.py +157 -0
  4. finetune/everyday.py +30 -0
  5. finetune/hf_ollama.py +158 -0
  6. finetune/huggingface_store.py +144 -0
  7. finetune/models.py +74 -0
  8. finetune/paths.py +11 -0
  9. finetune/python_vibe.py +788 -0
  10. finetune/splits.py +54 -0
  11. finetune/systems.py +9 -0
  12. harness/__init__.py +42 -0
  13. harness/__main__.py +8 -0
  14. harness/act/__init__.py +6 -0
  15. harness/act/autofix/__init__.py +110 -0
  16. harness/act/autofix/additions.py +217 -0
  17. harness/act/autofix/conflicts.py +124 -0
  18. harness/act/autofix/cover.py +419 -0
  19. harness/act/autofix/mechanical.py +151 -0
  20. harness/act/autofix/missing_imports.py +50 -0
  21. harness/act/autofix/moves.py +439 -0
  22. harness/act/autofix/names.py +339 -0
  23. harness/act/autofix/scaffold.py +224 -0
  24. harness/act/code.py +157 -0
  25. harness/act/gate.py +229 -0
  26. harness/act/parse.py +247 -0
  27. harness/act/patch_fix.py +138 -0
  28. harness/act/tools.py +244 -0
  29. harness/agent/__init__.py +11 -0
  30. harness/agent/dispatch.py +235 -0
  31. harness/agent/loop.py +699 -0
  32. harness/agent/options.py +144 -0
  33. harness/agent/policy.py +856 -0
  34. harness/agent/prompt.py +170 -0
  35. harness/cli.py +393 -0
  36. harness/editor_kit.py +265 -0
  37. harness/guard/__init__.py +6 -0
  38. harness/guard/fallbacks.py +6 -0
  39. harness/guard/loop_guard.py +57 -0
  40. harness/guard/python_vibe.py +68 -0
  41. harness/guard/run.py +41 -0
  42. harness/guard/types.py +19 -0
  43. harness/locate.py +767 -0
  44. harness/mcp_stdio.py +306 -0
  45. harness/memory/__init__.py +5 -0
  46. harness/memory/conversation.py +104 -0
  47. harness/model/__init__.py +6 -0
  48. harness/model/chat_backend.py +100 -0
  49. harness/model/engine.py +165 -0
  50. harness/model/ollama_generate.py +60 -0
  51. harness/model/openai_generate.py +156 -0
  52. harness/model/outbound.py +83 -0
  53. harness/model/route.py +90 -0
  54. harness/observe/__init__.py +6 -0
  55. harness/observe/eval_gate.py +80 -0
  56. harness/observe/eval_loop.py +185 -0
  57. harness/observe/eval_tasks.py +399 -0
  58. harness/observe/report_md.py +102 -0
  59. harness/observe/trace_record.py +79 -0
  60. harness/openai_api.py +81 -0
  61. harness/paths.py +88 -0
  62. harness/py.typed +0 -0
  63. harness/scan/__init__.py +6 -0
  64. harness/scan/app_spec.py +338 -0
  65. harness/scan/design.py +112 -0
  66. harness/scan/existing.py +131 -0
  67. harness/scan/layout.py +254 -0
  68. harness/scan/names.py +308 -0
  69. harness/scan/project_brief.py +287 -0
  70. harness/scan/project_docs.py +42 -0
  71. harness/scan/project_scan.py +49 -0
  72. harness/scan/repo_map.py +101 -0
  73. harness/secrets.py +39 -0
  74. harness/server.py +199 -0
  75. harness/ship/__init__.py +1 -0
  76. harness/ship/bot_pr.py +221 -0
  77. harness/ship/git_ship.py +262 -0
  78. harness/ship/identity.py +62 -0
  79. harness/ship/ticket.py +251 -0
  80. harness/skillkit/__init__.py +6 -0
  81. harness/skillkit/catalog.py +241 -0
  82. harness/skillkit/refuse_change.py +640 -0
  83. harness/skillkit/refuse_finish.py +295 -0
  84. harness/skillkit/target.py +238 -0
  85. harness/task.py +717 -0
  86. py_harness_cli-0.3.0.dist-info/METADATA +177 -0
  87. py_harness_cli-0.3.0.dist-info/RECORD +92 -0
  88. py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
  89. py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
  90. py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
  91. py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
  92. py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
@@ -0,0 +1,138 @@
1
+ """Match a `Find:` block against a file, and explain a failed match.
2
+
3
+ `patch` replaces an exact substring. Exact matching is deliberate: it fails
4
+ instead of editing a line the user did not mean. The cost is that a small
5
+ model, which often reproduces the words of a line but not its indentation,
6
+ gets no way forward when the match fails.
7
+
8
+ These functions add two recoveries that do not weaken the guarantee:
9
+
10
+ * Retry the match ignoring differences in whitespace only. If that matches
11
+ in exactly one place, use it. If it matches in more than one place, the
12
+ match is rejected rather than guessed.
13
+ * When there is still no match, return the lines in the file that are most
14
+ similar, so the next attempt can copy a real line.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import difflib
20
+ import re
21
+ from dataclasses import dataclass
22
+
23
+ MAX_SUGGESTIONS = 3
24
+ MIN_RATIO = 0.6
25
+
26
+
27
+ def normalize(line: str) -> str:
28
+ return re.sub(r"\s+", " ", line).strip()
29
+
30
+
31
+ @dataclass(frozen=True)
32
+ class Match:
33
+ text: str
34
+ exact: bool
35
+
36
+
37
+ def _windows(lines: list[str], size: int) -> list[tuple[int, int]]:
38
+ return [(i, i + size) for i in range(0, len(lines) - size + 1)]
39
+
40
+
41
+ def find_match(text: str, find: str) -> Match | None:
42
+ """Find where `find` occurs in `text`.
43
+
44
+ Returns the exact text to replace, and whether it matched exactly.
45
+ Returns None when there is no match, or when a whitespace-insensitive
46
+ match occurs in more than one place.
47
+ """
48
+ if not find:
49
+ return None
50
+ hits = text.count(find)
51
+ if hits == 1:
52
+ return Match(find, True)
53
+ if hits > 1:
54
+ return None
55
+ wanted = [normalize(line) for line in find.strip("\n").splitlines()]
56
+ if not wanted or not any(wanted):
57
+ return None
58
+ lines = text.splitlines()
59
+ if len(wanted) > len(lines):
60
+ return None
61
+ found: list[str] = []
62
+ for start, end in _windows(lines, len(wanted)):
63
+ if [normalize(line) for line in lines[start:end]] == wanted:
64
+ found.append("\n".join(lines[start:end]))
65
+ if len(found) > 1:
66
+ return None
67
+ if len(found) != 1:
68
+ return None
69
+ return Match(found[0], False)
70
+
71
+
72
+ def suggestions(text: str, find: str, *, limit: int = MAX_SUGGESTIONS) -> list[str]:
73
+ """Return the lines in `text` most similar to `find`, for an error message.
74
+
75
+ Each entry is formatted as "line number: line content".
76
+ """
77
+ head = normalize(find.strip("\n").splitlines()[0]) if find.strip() else ""
78
+ if not head:
79
+ return []
80
+ scored: list[tuple[float, int, str]] = []
81
+ for number, line in enumerate(text.splitlines(), 1):
82
+ candidate = normalize(line)
83
+ if not candidate:
84
+ continue
85
+ ratio = difflib.SequenceMatcher(None, head, candidate).ratio()
86
+ if ratio >= MIN_RATIO:
87
+ scored.append((ratio, number, line.rstrip()))
88
+ scored.sort(key=lambda item: (-item[0], item[1]))
89
+ if scored:
90
+ return [f"{number}: {line}" for _ratio, number, line in scored[:limit]]
91
+ return _same_opener(text, head, limit)
92
+
93
+
94
+ def _same_opener(text: str, head: str, limit: int) -> list[str]:
95
+ """Nothing scored. Show lines that at least start the same way."""
96
+ first = head.split(" ", 1)[0]
97
+ if not first:
98
+ return []
99
+ out: list[str] = []
100
+ for number, line in enumerate(text.splitlines(), 1):
101
+ if normalize(line).startswith(first):
102
+ out.append(f"{number}: {line.rstrip()}")
103
+ if len(out) >= limit:
104
+ break
105
+ return out
106
+
107
+
108
+ def miss_message(text: str, find: str) -> str:
109
+ close = suggestions(text, find)
110
+ if not close:
111
+ return (
112
+ "Find: string not in file. Action: read that Path: first, "
113
+ "then copy one whole line from it."
114
+ )
115
+ listed = "\n".join(f" {item}" for item in close)
116
+ return (
117
+ "Find: string not in file. Closest lines in this file — copy one "
118
+ f"whole line verbatim:\n{listed}"
119
+ )
120
+
121
+
122
+ def align_indent(matched: str, replace: str) -> str:
123
+ """Give `replace` the leading whitespace of the line it will replace.
124
+
125
+ A small model often reproduces a line without its indentation. If the
126
+ replacement has no leading whitespace and the matched text does, the
127
+ replacement is indented to match.
128
+ """
129
+ if not matched or not replace.strip():
130
+ return replace
131
+ first = matched.splitlines()[0]
132
+ indent = first[: len(first) - len(first.lstrip())]
133
+ if not indent:
134
+ return replace
135
+ lines = replace.splitlines()
136
+ if lines[0].startswith((" ", "\t")):
137
+ return replace
138
+ return "\n".join(indent + line if line.strip() else line for line in lines)
harness/act/tools.py ADDED
@@ -0,0 +1,244 @@
1
+ """The seven things the agent can do to a project.
2
+
3
+ Look for a file, search inside files, read one, run one, change one, and
4
+ draw the shape of the whole. Nothing here decides whether a change is
5
+ allowed — `act.gate` answers that, and `patch_py` and `edit_py` ask it
6
+ before they write.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from collections.abc import Callable
12
+ from dataclasses import dataclass
13
+
14
+ import re
15
+ import subprocess
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ from harness.act.autofix import (
20
+ append_instead_of_replacing,
21
+ apply_function_rename,
22
+ apply_missing_imports,
23
+ apply_typo_fixes,
24
+ )
25
+ from harness.act.code import apply_source, read_project_file, resolve_project_file
26
+ from harness.act.gate import (
27
+ already_defined,
28
+ refuse_duplicate_module,
29
+ refuse_missing_import_target,
30
+ repair_unittest_append,
31
+ first_refusal,
32
+ )
33
+ from harness.paths import is_secret_name, rel_posix, suffix_globs
34
+ from harness.act.patch_fix import align_indent, find_match, miss_message
35
+ from harness.skillkit.refuse_change import (
36
+ refuse_add_opens_file,
37
+ refuse_layout,
38
+ refuse_ops_draft,
39
+ refuse_platform_draft,
40
+ refuse_rename_incomplete,
41
+ refuse_shell_fetch,
42
+ refuse_stdlib_shadow,
43
+ refuse_stub_body,
44
+ refuse_test_in_impl,
45
+ refuse_undefined_draft,
46
+ refuse_weak_test,
47
+ )
48
+
49
+ from harness.task import looks_like_bugfix, looks_like_fix_smell, rename_pair
50
+ from harness.scan.project_brief import render_map, resolve_scope
51
+ from harness.scan.project_scan import SKIP_DIR
52
+ from harness.scan.repo_map import render_outline
53
+
54
+ MAX_HITS = 30
55
+ _TRUNC = "\n# … truncated. Narrow Query or pass --scope"
56
+
57
+
58
+ def glob_py(project: Path, pattern: str, scope: str = "") -> str:
59
+ root = project.resolve()
60
+ base = resolve_scope(project, scope) if scope else root
61
+ hits: list[str] = []
62
+ for path in base.glob(pattern):
63
+ if any(part in SKIP_DIR for part in path.parts):
64
+ continue
65
+ if is_secret_name(path.name):
66
+ continue
67
+ if path.is_file():
68
+ hits.append(rel_posix(path, root))
69
+ if len(hits) >= MAX_HITS:
70
+ return "\n".join(hits) + _TRUNC
71
+ if not hits:
72
+ # rglob if user passed **/...
73
+ for path in base.rglob(pattern.removeprefix("**/")):
74
+ if any(part in SKIP_DIR for part in path.parts):
75
+ continue
76
+ if is_secret_name(path.name):
77
+ continue
78
+ if path.is_file():
79
+ hits.append(rel_posix(path, root))
80
+ if len(hits) >= MAX_HITS:
81
+ return "\n".join(hits) + _TRUNC
82
+ return "\n".join(hits) or "(no files)"
83
+
84
+
85
+ def grep_py(project: Path, query: str, scope: str = "") -> str:
86
+ root = project.resolve()
87
+ base = resolve_scope(project, scope) if scope else root
88
+ try:
89
+ wanted = re.compile(query)
90
+ except re.error as exc:
91
+ return f"bad regex: {exc}"
92
+ lines: list[str] = []
93
+ for pattern in suffix_globs():
94
+ for path in base.rglob(pattern):
95
+ if any(part in SKIP_DIR for part in path.parts):
96
+ continue
97
+ if is_secret_name(path.name):
98
+ continue
99
+ try:
100
+ text = path.read_text(encoding="utf-8")
101
+ except OSError:
102
+ continue
103
+ for i, line in enumerate(text.splitlines(), 1):
104
+ if wanted.search(line):
105
+ rel = rel_posix(path, root)
106
+ lines.append(f"{rel}:{i}:{line.strip()[:160]}")
107
+ if len(lines) >= MAX_HITS:
108
+ return "\n".join(lines) + _TRUNC
109
+ return "\n".join(lines) or "(no hits)"
110
+
111
+
112
+ def map_py(project: Path, scope: str = "") -> str:
113
+ """File list plus a signature outline. Sizes do not tell it where to look."""
114
+ return f"{render_map(project, scope)}\n\n{render_outline(project, scope)}"
115
+
116
+
117
+ def read_py(project: Path, rel: str, about: str = "") -> str:
118
+ """The file. `about` names what the read is for, so that a file too
119
+ long to send whole keeps the part being asked about."""
120
+ path = resolve_project_file(project, rel)
121
+ return read_project_file(path, about=about)
122
+
123
+
124
+ def patch_py(
125
+ project: Path,
126
+ rel: str,
127
+ find: str,
128
+ replace: str,
129
+ append: str = "",
130
+ task: str = "",
131
+ ) -> str:
132
+ path = resolve_project_file(project, rel)
133
+ original = path.read_text(encoding="utf-8") if path.is_file() else ""
134
+ text = original
135
+ note = ""
136
+ if looks_like_fix_smell(task):
137
+ old, new = rename_pair(task)
138
+ renamed = apply_function_rename(original, old, new)
139
+ if renamed != original:
140
+ text = renamed
141
+ note = " (harness renamed the def)"
142
+ find = ""
143
+ if find:
144
+ if len(find) < 8:
145
+ return (
146
+ "Find: must be at least 8 characters. "
147
+ "Use a unique full line such as: Find: return tota"
148
+ )
149
+ hits = text.count(find)
150
+ if hits > 1:
151
+ return f"Find: matches {hits} times — use a longer unique snippet"
152
+ match = find_match(text, find)
153
+ if match is None:
154
+ return miss_message(text, find)
155
+ text = text.replace(
156
+ match.text,
157
+ replace if match.exact else align_indent(match.text, replace),
158
+ 1,
159
+ )
160
+ if not match.exact:
161
+ note = " (Find: matched after whitespace normalisation)"
162
+ else:
163
+ note = ""
164
+ elif text == original and not append:
165
+ return "patch needs Find: or Append:"
166
+ if append:
167
+ already = already_defined(original, append, rel)
168
+ if already:
169
+ return already
170
+ repaired = repair_unittest_append(text, append)
171
+ text = (
172
+ repaired
173
+ if repaired is not None
174
+ else text.rstrip() + "\n\n" + append.rstrip() + "\n"
175
+ )
176
+ if looks_like_bugfix(task):
177
+ bound = apply_typo_fixes(text)
178
+ if bound != text:
179
+ text = bound
180
+ note = (note + " (harness bound unique NameError typo)").strip()
181
+ repaired = apply_missing_imports(text)
182
+ if repaired != text:
183
+ text = repaired
184
+ note = (note + " (harness added the missing import)").strip()
185
+ blocked = refuse_duplicate_module(project, rel, original)
186
+ if not blocked:
187
+ blocked = refuse_missing_import_target(project, rel, text)
188
+ if not blocked:
189
+ blocked = first_refusal(task, rel, original, text, fragment=append or replace)
190
+ if blocked:
191
+ return blocked
192
+ apply_source(path, text, original=original)
193
+ return (
194
+ f"patched {rel_posix(path, project.resolve())} "
195
+ f"(backup {path.name}.bak){note}"
196
+ )
197
+
198
+
199
+ def edit_py(project: Path, rel: str, source: str, task: str = "") -> str:
200
+ path = resolve_project_file(project, rel)
201
+ original = path.read_text(encoding="utf-8") if path.is_file() else ""
202
+ source = apply_missing_imports(source)
203
+ blocked = refuse_duplicate_module(project, rel, original)
204
+ if not blocked:
205
+ blocked = refuse_missing_import_target(project, rel, source)
206
+ if not blocked:
207
+ blocked = first_refusal(task, rel, original, source)
208
+ if blocked:
209
+ return blocked
210
+ # A short draft of only-new definitions is an addition, not a rewrite.
211
+ merged = append_instead_of_replacing(original, source)
212
+ if merged:
213
+ apply_source(path, merged, original=original)
214
+ return (
215
+ f"appended to {rel_posix(path, project.resolve())} "
216
+ f"(backup {path.name}.bak) — the draft added new definitions "
217
+ "rather than replacing the file"
218
+ )
219
+ apply_source(path, source, original=original)
220
+ return f"wrote {rel_posix(path, project.resolve())} (backup {path.name}.bak)"
221
+
222
+
223
+ def run_python(project: Path, argv: tuple[str, ...]) -> str:
224
+ if not argv:
225
+ return "Argv required, e.g. -m unittest discover -s tests -q"
226
+ blocked = {"-c", "-m pip", "http.server"}
227
+ joined = " ".join(argv)
228
+ if any(tok in joined for tok in blocked) or "|" in joined or ";" in joined:
229
+ return "refusing that argv"
230
+ if "unittest" in joined and "tests" in joined and not (project / "tests").is_dir():
231
+ return "no tests/ directory in this project — add tests/test_*.py first"
232
+ try:
233
+ proc = subprocess.run(
234
+ [sys.executable, *argv],
235
+ cwd=project,
236
+ capture_output=True,
237
+ text=True,
238
+ timeout=60,
239
+ check=False,
240
+ )
241
+ except subprocess.TimeoutExpired:
242
+ return "timed out (60s)"
243
+ out = (proc.stdout or "") + (proc.stderr or "")
244
+ return f"exit {proc.returncode}\n{out[-4000:]}"
@@ -0,0 +1,11 @@
1
+ """The agent loop and its public types.
2
+
3
+ `Agent` runs one task. `AgentOptions` describes how to run it and
4
+ `AgentResult` describes what happened. Every layer below this one works
5
+ without a model, which is what allows the harness to be tested offline.
6
+ """
7
+
8
+ from harness.agent.loop import Agent, Question
9
+ from harness.agent.options import AgentOptions, AgentResult, Step
10
+
11
+ __all__ = ["Agent", "AgentOptions", "AgentResult", "Question", "Step"]
@@ -0,0 +1,235 @@
1
+ """Carry out one action against the project.
2
+
3
+ Each branch returns the text to show the model and the file path the action
4
+ applied to. The path is kept by the loop so that a later action which does
5
+ not name a file still refers to the file most recently used.
6
+
7
+ This module makes no decisions about whether an action should run. Those
8
+ decisions are in `harness.agent.policy`.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ from collections.abc import Callable
14
+ from dataclasses import dataclass
15
+
16
+ from pathlib import Path
17
+
18
+ from harness.act.tools import (
19
+ edit_py,
20
+ glob_py,
21
+ grep_py,
22
+ map_py,
23
+ patch_py,
24
+ read_py,
25
+ run_python,
26
+ )
27
+ from harness.guard.python_vibe import PythonVibeGuard
28
+ from harness.locate import locate_py
29
+ from harness.scan.layout import render_layout
30
+ from harness.scan.project_brief import resolve_scope
31
+ from harness.ship.git_ship import (
32
+ commit_changes,
33
+ create_pr,
34
+ make_branch,
35
+ merge_pr,
36
+ push_branch,
37
+ read_issue,
38
+ read_pr,
39
+ )
40
+ from harness.skillkit.catalog import (
41
+ list_skills,
42
+ render_catalog,
43
+ render_skill,
44
+ skill_from_action,
45
+ )
46
+
47
+ ACTIONS = (
48
+ "glob|grep|read|edit|patch|run|map|plan|skill|locate|layout|ask|done|"
49
+ "issue|branch|commit|push|pr|merge"
50
+ )
51
+ WRITE_ACTIONS = frozenset({"edit", "patch", "run"})
52
+ SHIP_ACTIONS = frozenset({"issue", "branch", "commit", "push", "pr", "merge"})
53
+
54
+
55
+ @dataclass(frozen=True)
56
+ class Ask:
57
+ """One action the model asked for, and everything it may act on.
58
+
59
+ Fields:
60
+ project: the folder the run may read and write inside.
61
+ turn: the parsed action, with whichever fields it carried.
62
+ path: the file this action lands on — the one it named, or the
63
+ last one touched.
64
+ last_path: what to report when the action changes no file.
65
+ scope: the folder to stay inside, if the run was given one.
66
+ target: the skill target, when a skill is being rendered.
67
+ task: what the user asked for, in their own words.
68
+ """
69
+
70
+ project: Path
71
+ turn: object
72
+ path: str
73
+ last_path: str
74
+ scope: str
75
+ target: object
76
+ task: str
77
+
78
+
79
+ def _locate(ask: Ask) -> tuple[str, str]:
80
+ return locate_py(ask.project, ask.turn.query or ask.turn.name, ask.scope)
81
+
82
+
83
+ def _map(ask: Ask) -> tuple[str, str]:
84
+ return map_py(ask.project, ask.scope), ask.last_path
85
+
86
+
87
+ def _layout(ask: Ask) -> tuple[str, str]:
88
+ base = resolve_scope(ask.project, ask.scope) if ask.scope else ask.project
89
+ return render_layout(base), ask.last_path
90
+
91
+
92
+ def _plan(ask: Ask) -> tuple[str, str]:
93
+ return (
94
+ f"plan noted:\n{ask.turn.summary or '(empty plan)'}\n"
95
+ "Take the first explore action now."
96
+ ), ask.last_path
97
+
98
+
99
+ def _glob(ask: Ask) -> tuple[str, str]:
100
+ pattern = ask.turn.pattern or "**/*.py"
101
+ return glob_py(ask.project, pattern, scope=ask.scope), ask.last_path
102
+
103
+
104
+ def _grep(ask: Ask) -> tuple[str, str]:
105
+ return grep_py(ask.project, ask.turn.query, scope=ask.scope), ask.last_path
106
+
107
+
108
+ def _read(ask: Ask) -> tuple[str, str]:
109
+ if not ask.path:
110
+ return "read needs Path:", ask.last_path
111
+ return read_py(ask.project, ask.path), ask.path
112
+
113
+
114
+ def _edit(ask: Ask) -> tuple[str, str]:
115
+ if not ask.path:
116
+ return "edit needs Path:", ask.last_path
117
+ if not ask.turn.source:
118
+ return "edit needs a ```python block", ask.path
119
+ blocked = PythonVibeGuard().check(ask.turn.source)
120
+ if blocked.verdict != "pass":
121
+ return (
122
+ f"harness blocked: {[f.rule_id for f in blocked.findings]}",
123
+ ask.path,
124
+ )
125
+ return edit_py(ask.project, ask.path, ask.turn.source, task=ask.task), ask.path
126
+
127
+
128
+ def _patch(ask: Ask) -> tuple[str, str]:
129
+ if not ask.path:
130
+ return "patch needs Path: (or read that file first)", ask.last_path
131
+ return patch_py(
132
+ ask.project,
133
+ ask.path,
134
+ ask.turn.find,
135
+ ask.turn.replace,
136
+ ask.turn.append,
137
+ task=ask.task,
138
+ ), ask.path
139
+
140
+
141
+ def _run(ask: Ask) -> tuple[str, str]:
142
+ return run_python(ask.project, ask.turn.argv), ask.last_path
143
+
144
+
145
+ def _skill(ask: Ask) -> tuple[str, str]:
146
+ return (
147
+ f"skill needs Name:. {render_catalog(list_skills(ask.project))}",
148
+ ask.last_path,
149
+ )
150
+
151
+
152
+ def _named(ask: Ask) -> str:
153
+ """The issue or pull request number an action carried."""
154
+ return (ask.turn.number or ask.turn.name or ask.turn.query or "").strip()
155
+
156
+
157
+ def _issue(ask: Ask) -> tuple[str, str]:
158
+ return read_issue(ask.project, _named(ask)), ask.last_path
159
+
160
+
161
+ def _branch(ask: Ask) -> tuple[str, str]:
162
+ return make_branch(ask.project, ask.turn.name or ask.turn.summary), ask.last_path
163
+
164
+
165
+ def _commit(ask: Ask) -> tuple[str, str]:
166
+ return commit_changes(ask.project, ask.turn.summary), ask.last_path
167
+
168
+
169
+ def _push(ask: Ask) -> tuple[str, str]:
170
+ return push_branch(ask.project), ask.last_path
171
+
172
+
173
+ def _pr(ask: Ask) -> tuple[str, str]:
174
+ """A number reads that pull request; anything else opens one."""
175
+ number = _named(ask)
176
+ if number.isdigit():
177
+ return read_pr(ask.project, number), ask.last_path
178
+ return create_pr(
179
+ ask.project,
180
+ ask.turn.title or ask.turn.summary,
181
+ ask.turn.body or ask.turn.append,
182
+ ), ask.last_path
183
+
184
+
185
+ def _merge(ask: Ask) -> tuple[str, str]:
186
+ return merge_pr(ask.project, _named(ask), allowed=True), ask.last_path
187
+
188
+
189
+ # One entry per action the model may take. This was nineteen
190
+ # `if turn.action == ...` tests in a row, which hid both the list and
191
+ # the fact that a new action has to be added to it: forgetting meant a
192
+ # silent "unknown Action" rather than an error anyone would notice.
193
+ # `ask` and `done` are answered by the loop before it gets here.
194
+ HANDLERS: dict[str, Callable[[Ask], tuple[str, str]]] = {
195
+ "locate": _locate,
196
+ "map": _map,
197
+ "layout": _layout,
198
+ "plan": _plan,
199
+ "glob": _glob,
200
+ "grep": _grep,
201
+ "read": _read,
202
+ "edit": _edit,
203
+ "patch": _patch,
204
+ "run": _run,
205
+ "skill": _skill,
206
+ "issue": _issue,
207
+ "branch": _branch,
208
+ "commit": _commit,
209
+ "push": _push,
210
+ "pr": _pr,
211
+ "merge": _merge,
212
+ }
213
+
214
+
215
+ def run_action(
216
+ project: Path, turn, last_path: str, scope: str, target=None, task: str = ""
217
+ ) -> tuple[str, str]:
218
+ """Carry out one action, and say which file it left the run on."""
219
+ loaded = skill_from_action(turn.action, turn.name, turn.path, project)
220
+ if loaded is not None:
221
+ return render_skill(loaded, target, project), last_path
222
+ handler = HANDLERS.get(turn.action)
223
+ if handler is None:
224
+ return f"unknown Action {turn.action}. Use {ACTIONS}.", last_path
225
+ return handler(
226
+ Ask(
227
+ project=project,
228
+ turn=turn,
229
+ path=turn.path or last_path,
230
+ last_path=last_path,
231
+ scope=turn.scope or scope,
232
+ target=target,
233
+ task=task,
234
+ )
235
+ )