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,295 @@
1
+ """Whether a run may say it is finished.
2
+
3
+ A different question from whether a change is acceptable. These look
4
+ at the project after the work, not at the draft before it: is the
5
+ symbol actually there, did a test run, does a test call what it was
6
+ asked to cover.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ # The oracle reuses one draft rule: a half-finished rename is a
12
+ # reason to refuse a finish as well as a draft.
13
+ from harness.skillkit.refuse_change import refuse_rename_incomplete
14
+
15
+ """SoC / readable-name guards. Deterministic. No model."""
16
+ import ast
17
+ import re
18
+ from pathlib import Path
19
+ from harness.task import (
20
+ looks_like_add_feature,
21
+ looks_like_bugfix,
22
+ looks_like_design_loop,
23
+ covered_symbol,
24
+ looks_like_everyday_code,
25
+ looks_like_fix_smell,
26
+ looks_like_write_tests,
27
+ looks_like_app_loop,
28
+ looks_like_app_overflow,
29
+ looks_like_new_package,
30
+ looks_like_refactor,
31
+ question_symbol,
32
+ rename_pair,
33
+ )
34
+
35
+
36
+ def _a_test_uses(body: str, symbol: str) -> bool:
37
+ """True when some `def test_...` in `body` actually mentions `symbol`.
38
+
39
+ Asking only whether the name appears anywhere in the test files
40
+ accepted a file holding one import line and nothing else. On a real
41
+ module the run wrote exactly that, reported `done`, and `unittest
42
+ discover` found no tests at all. An import is not coverage.
43
+ """
44
+ if not body.strip() or not symbol:
45
+ return False
46
+ try:
47
+ tree = ast.parse(body)
48
+ except (SyntaxError, ValueError):
49
+ # Unparsable here means several files concatenated. Fall back to
50
+ # requiring the name somewhere after a test definition.
51
+ return bool(re.search(rf"def test_\w*[\s\S]*?\b{re.escape(symbol)}\b", body))
52
+ for node in ast.walk(tree):
53
+ if not isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef)):
54
+ continue
55
+ if not node.name.startswith("test"):
56
+ continue
57
+ for child in ast.walk(node):
58
+ if isinstance(child, ast.Name) and child.id == symbol:
59
+ return True
60
+ if isinstance(child, ast.Attribute) and child.attr == symbol:
61
+ return True
62
+ return False
63
+
64
+
65
+ def tests_call(project: Path, symbol: str) -> bool:
66
+ """True when some test in tests/ actually calls symbol."""
67
+ tests = Path(project) / "tests"
68
+ if not tests.is_dir() or not symbol:
69
+ return False
70
+ body = ""
71
+ for path in sorted(tests.glob("test_*.py")):
72
+ try:
73
+ body += "\n" + path.read_text(encoding="utf-8")
74
+ except OSError:
75
+ continue
76
+ return _a_test_uses(body, symbol)
77
+
78
+
79
+ def refuse_done_oracle(task: str, project: Path, last_path: str) -> str:
80
+ """Refuse done when the named file or last write still has an unbound name."""
81
+ from harness.scan.names import undefined_in_file
82
+ from harness.task import named_project_file, rename_pair
83
+
84
+ if looks_like_app_overflow(task):
85
+ return ""
86
+
87
+ paths: list[str] = []
88
+ named = named_project_file(task, project)
89
+ if named:
90
+ paths.append(named)
91
+ if last_path and last_path not in paths:
92
+ paths.append(last_path)
93
+ if looks_like_bugfix(task):
94
+ for rel in paths:
95
+ leftover = undefined_in_file(Path(project) / rel)
96
+ if leftover:
97
+ return (
98
+ f"undefined name {leftover[0]} in {rel}. "
99
+ f"Action: patch Path: {rel} Find: {leftover[0]} "
100
+ "Replace: the name you assigned."
101
+ )
102
+ if looks_like_add_feature(task):
103
+ symbol = question_symbol(task)
104
+ if symbol:
105
+ found = False
106
+ root = Path(project)
107
+ from harness.scan.project_brief import iter_text_files
108
+
109
+ for path, _size in iter_text_files(root):
110
+ if path.suffix != ".py":
111
+ continue
112
+ try:
113
+ body = path.read_text(encoding="utf-8")
114
+ except OSError:
115
+ continue
116
+ if re.search(rf"^def {re.escape(symbol)}\b", body, re.MULTILINE):
117
+ found = True
118
+ break
119
+ if not found:
120
+ from harness.skillkit.target import pick_module
121
+
122
+ dest = pick_module(root, last_path, task)
123
+ return (
124
+ f"def {symbol} is not in the project. "
125
+ f"Action: patch Path: {dest} Append: def {symbol}(...)."
126
+ )
127
+ if looks_like_write_tests(task):
128
+ symbol = covered_symbol(task)
129
+ if symbol:
130
+ tests = Path(project) / "tests"
131
+ body = ""
132
+ if tests.is_dir():
133
+ for path in sorted(tests.glob("test_*.py")):
134
+ try:
135
+ body += path.read_text(encoding="utf-8")
136
+ except OSError:
137
+ continue
138
+ if not _a_test_uses(body, symbol):
139
+ test_rel = "tests/test_module.py"
140
+ if named:
141
+ test_rel = f"tests/test_{Path(named).stem}.py"
142
+ return (
143
+ f"no test calls {symbol}. "
144
+ f"Action: patch Path: {test_rel} "
145
+ f"Append: one AAA test that calls {symbol}."
146
+ )
147
+ if looks_like_fix_smell(task) and named:
148
+ old, new = rename_pair(task)
149
+ try:
150
+ body = (Path(project) / named).read_text(encoding="utf-8")
151
+ except OSError:
152
+ body = ""
153
+ if old and new and body:
154
+ missed = refuse_rename_incomplete(task, named, body)
155
+ if missed:
156
+ return missed
157
+ return ""
158
+
159
+
160
+ def refuse_write_done(task: str, ran_tests: bool, *, wrote: bool = True) -> str:
161
+ """Write tasks are not finished until a passing unittest has run.
162
+
163
+ A task that has not written yet is handled by the empty-done refuse.
164
+ New-package still needs a run even if the model claims it is done.
165
+ """
166
+ if ran_tests:
167
+ return ""
168
+ if looks_like_new_package(task):
169
+ return "not done. Action: run Argv: -m unittest discover -s tests -q"
170
+ if not wrote:
171
+ return ""
172
+ needs_run = (
173
+ looks_like_add_feature(task)
174
+ or looks_like_everyday_code(task)
175
+ or looks_like_fix_smell(task)
176
+ or looks_like_bugfix(task)
177
+ or looks_like_refactor(task)
178
+ or looks_like_design_loop(task)
179
+ )
180
+ if not needs_run:
181
+ return ""
182
+ return "not done. Action: run Argv: -m unittest discover -s tests -q"
183
+
184
+
185
+ def refuse_package_done(task: str, ran_tests: bool, wrote: bool = True) -> str:
186
+ return refuse_write_done(task, ran_tests, wrote=wrote)
187
+
188
+
189
+ def refuse_app_done(task: str, project: Path) -> str:
190
+ """Refuse done while list/show/http/mocked tests are still missing."""
191
+ from harness.scan.app_spec import (
192
+ next_app_action,
193
+ next_overflow_action,
194
+ render_app_review,
195
+ required_gaps,
196
+ )
197
+
198
+ if looks_like_app_overflow(task):
199
+ leftover = next_overflow_action(project, task)
200
+ return ("not done. " + leftover.strip()) if leftover else ""
201
+ if not looks_like_app_loop(task):
202
+ return ""
203
+ if required_gaps(project, task):
204
+ return (
205
+ "not done. "
206
+ + next_app_action(project, task).strip()
207
+ + "\n"
208
+ + render_app_review(project, task)
209
+ )
210
+ return ""
211
+
212
+
213
+ # Names a framework calls, so nobody in this project has to.
214
+ _CALLED_BY_SOMETHING_ELSE = ("test", "_", "main")
215
+
216
+
217
+ def _added_functions(path: Path) -> list[str]:
218
+ """Top-level functions this run put in the file that were not there.
219
+
220
+ The original is on disk: every write leaves a `.bak` beside the file
221
+ it changed. Without it there is no way to tell a function the run
222
+ added from one that was already there, and refusing on the second
223
+ would be refusing somebody else's code.
224
+ """
225
+ backup = path.with_suffix(path.suffix + ".bak")
226
+ try:
227
+ now = ast.parse(path.read_text(encoding="utf-8"))
228
+ before = ast.parse(backup.read_text(encoding="utf-8"))
229
+ except (SyntaxError, ValueError, OSError):
230
+ return []
231
+ was = {
232
+ n.name for n in before.body
233
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
234
+ }
235
+ return [
236
+ n.name for n in now.body
237
+ if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
238
+ and n.name not in was
239
+ and not n.name.startswith(_CALLED_BY_SOMETHING_ELSE)
240
+ ]
241
+
242
+
243
+ def _anything_calls(project: Path, name: str, skip: Path) -> bool:
244
+ """Does any other file in the project call this by name?"""
245
+ called = re.compile(rf"\b{re.escape(name)}\s*\(")
246
+ for path in project.rglob("*.py"):
247
+ if path == skip or path.name.endswith(".bak") or ".git" in path.parts:
248
+ continue
249
+ try:
250
+ body = path.read_text(encoding="utf-8")
251
+ except OSError:
252
+ continue
253
+ if called.search(body) or _a_test_uses(body, name):
254
+ return True
255
+ return False
256
+
257
+
258
+ def refuse_unwired_addition(
259
+ project: Path, last_path: str, task: str = ""
260
+ ) -> str:
261
+ """A function added, and nobody left to call it.
262
+
263
+ Asked to add a check that refuses to send when a prompt carries a
264
+ token, a run wrote `reject_github_tokens_in_prompt` and left nothing
265
+ nothing. The file parsed and the suite stayed green, because a
266
+ function nobody calls cannot fail. It also cannot work.
267
+
268
+ Whether the body is any good needs a reader. Whether anything will
269
+ ever run it does not, so that much is checked here.
270
+
271
+ Overflow comment/pagination/config is a later typed run. The 8B
272
+ writes ``def comment_on`` and the unused-function guard then
273
+ burns the step budget. That cell is not this rule.
274
+ """
275
+ if looks_like_app_overflow(task):
276
+ return ""
277
+ if not last_path:
278
+ return ""
279
+ path = Path(project) / last_path
280
+ for name in _added_functions(path):
281
+ try:
282
+ here = path.read_text(encoding="utf-8")
283
+ except OSError:
284
+ return ""
285
+ if re.search(rf"\b{re.escape(name)}\s*\(", here.replace(f"def {name}(", "")):
286
+ continue
287
+ if _anything_calls(Path(project), name, path):
288
+ continue
289
+ return (
290
+ f"`{name}` was added to {last_path} and nothing calls it. A "
291
+ "function nobody calls cannot fail, and cannot work either. "
292
+ "Action: patch the place that should use it, or write a test "
293
+ "that calls it."
294
+ )
295
+ return ""
@@ -0,0 +1,238 @@
1
+ """Replace the file paths inside a skill with paths from this project.
2
+
3
+ A skill is written as a block of text for the model to copy, so any file
4
+ path inside it will be copied as well. A path that refers to this
5
+ repository's test fixtures does not exist in the user's project, and
6
+ writing to it creates a file the user did not ask for.
7
+
8
+ Two replacements are made before the model sees a skill:
9
+
10
+ * The placeholders `{{module}}`, `{{test}}`, `{{scope}}` and `{{symbol}}`
11
+ are filled in from the project.
12
+ * Any `Path:` or `Scope:` that does not exist in the project is replaced
13
+ with one that does.
14
+
15
+ A path that does exist in the project is left unchanged, which is what
16
+ keeps the fixture-based tests working. A path ending in `__init__.py` is
17
+ also left unchanged, because creating a package legitimately names a file
18
+ that does not exist yet.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import re
24
+ from dataclasses import dataclass
25
+ from pathlib import Path
26
+
27
+ from harness.task import looks_like_new_package, looks_like_ops, package_noun, question_symbol
28
+ from harness.paths import rel_posix
29
+ from harness.scan.project_brief import iter_text_files
30
+
31
+ _DEF_NAME = re.compile(r"^def\s+([A-Za-z_]\w*)", re.MULTILINE)
32
+ # HTTP/UI adapters are larger than the domain file beside them. Size-first
33
+ # pick put `total_lines` in `orders_controller.py` on the live 8B demo.
34
+ _ROLE_SUFFIX = ("_controller", "_service", "_view", "_router", "_handler")
35
+
36
+ _PLACEHOLDER = re.compile(r"\{\{(module|test|scope|symbol)\}\}")
37
+ _PATH_LINE = re.compile(r"^(Path|File):\s*(\S+)\s*$", re.MULTILINE)
38
+ _SCOPE_LINE = re.compile(r"^Scope:\s*(\S+)\s*$", re.MULTILINE)
39
+ _SYMBOL_TOKEN = "the_symbol_from_the_task"
40
+ # Scaffolding a package legitimately names a file that does not exist yet.
41
+ _KEEP_NAMES = frozenset({"__init__.py"})
42
+ # Used only in documentation and error text, never handed to the model.
43
+ FALLBACK_MODULE = "path/to/module.py"
44
+ DEFAULT_NEW_MODULE = "src/main.py"
45
+ FALLBACK_TEST = "tests/test_module.py"
46
+
47
+
48
+ @dataclass(frozen=True)
49
+ class Target:
50
+ """The files in this project that a skill should refer to.
51
+
52
+ Fields:
53
+ module: file where new code belongs.
54
+ test: file where new tests belong.
55
+ scope: directory to stay within.
56
+ symbol: name from the task, used to fill in a search query.
57
+ """
58
+
59
+ module: str
60
+ test: str
61
+ scope: str
62
+ symbol: str
63
+
64
+
65
+ def _is_test(rel: str) -> bool:
66
+ parts = rel.replace("\\", "/").split("/")
67
+ return "tests" in parts or parts[-1].startswith("test_")
68
+
69
+
70
+ def _rels(project: Path, suffix: str = ".py") -> list[tuple[str, int]]:
71
+ root = project.resolve()
72
+ return [
73
+ (rel_posix(path, root), size)
74
+ for path, size in iter_text_files(project)
75
+ if path.suffix == suffix
76
+ ]
77
+
78
+
79
+ def pick_module(project: Path, located_path: str = "", task: str = "") -> str:
80
+ """The file a new function most likely belongs in.
81
+
82
+ A project with no Python file in it still needs a real path. The
83
+ placeholder used to be handed straight to the model, which then created
84
+ a file literally at `path/to/module.py` — the same fault as shipping a
85
+ fixture path, arriving by a different route.
86
+ """
87
+ if looks_like_ops(task):
88
+ return pick_workflow(project)
89
+ if looks_like_new_package(task):
90
+ return f"pkg/{package_noun(task)}.py"
91
+ if located_path:
92
+ rel = located_path.replace("\\", "/").lstrip("./")
93
+ if rel.endswith(".py") and not _is_test(rel):
94
+ return rel
95
+ usable = [
96
+ (rel, size)
97
+ for rel, size in _rels(project)
98
+ if not _is_test(rel) and Path(rel).name != "__init__.py"
99
+ ]
100
+ if not usable:
101
+ return _new_module_name(task)
102
+ wanted = _name_tokens(question_symbol(task))
103
+ usable.sort(key=lambda item: _module_score(project, item[0], item[1], wanted))
104
+ return usable[0][0]
105
+
106
+
107
+ def _name_tokens(symbol: str) -> frozenset[str]:
108
+ return frozenset(part for part in symbol.lower().split("_") if len(part) >= 3)
109
+
110
+
111
+ def _module_score(
112
+ project: Path, rel: str, size: int, wanted: frozenset[str]
113
+ ) -> tuple[int, int, int, str]:
114
+ """Lower is better: more name overlap, not a role adapter, then smaller."""
115
+ stem = Path(rel).stem.lower()
116
+ role = int(any(stem.endswith(suffix) for suffix in _ROLE_SUFFIX))
117
+ overlap = 0
118
+ if wanted:
119
+ try:
120
+ body = (Path(project) / rel).read_text(encoding="utf-8")
121
+ except OSError:
122
+ body = ""
123
+ overlap = sum(
124
+ 1 for name in _DEF_NAME.findall(body) if _name_tokens(name) & wanted
125
+ )
126
+ return (-overlap, role, size, rel)
127
+
128
+
129
+ def pick_workflow(project: Path) -> str:
130
+ """Existing workflow YAML, else the path the skill creates."""
131
+ root = Path(project)
132
+ folder = root / ".github" / "workflows"
133
+ if folder.is_dir():
134
+ found = sorted(
135
+ rel_posix(path, root)
136
+ for path in folder.iterdir()
137
+ if path.suffix in {".yml", ".yaml"} and path.is_file()
138
+ )
139
+ if found:
140
+ return found[0]
141
+ return ".github/workflows/tests.yml"
142
+
143
+
144
+ def _new_module_name(task: str) -> str:
145
+ """Where the first module of an empty project should go."""
146
+ symbol = question_symbol(task)
147
+ if symbol and symbol != _SYMBOL_TOKEN:
148
+ return f"src/{symbol}.py"
149
+ return DEFAULT_NEW_MODULE
150
+
151
+
152
+ def pick_test(project: Path, module: str) -> str:
153
+ """The matching test file, else any test file, else the name to create."""
154
+ stem = Path(module).stem
155
+ tests = sorted(rel for rel, _size in _rels(project) if _is_test(rel))
156
+ if not tests:
157
+ return f"tests/test_{stem}.py" if stem else FALLBACK_TEST
158
+ for rel in tests:
159
+ if Path(rel).stem == f"test_{stem}":
160
+ return rel
161
+ return tests[0]
162
+
163
+
164
+ def pick_scope(scope: str, module: str) -> str:
165
+ if scope:
166
+ return scope
167
+ parts = module.split("/")
168
+ return parts[0] if len(parts) > 1 else "."
169
+
170
+
171
+ def pick_target(
172
+ project: Path, task: str = "", scope: str = "", located_path: str = ""
173
+ ) -> Target:
174
+ module = pick_module(project, located_path, task)
175
+ symbol = (
176
+ package_noun(task)
177
+ if looks_like_new_package(task)
178
+ else question_symbol(task) or _SYMBOL_TOKEN
179
+ )
180
+ return Target(
181
+ module=module,
182
+ test=pick_test(project, module),
183
+ scope=pick_scope(scope, module),
184
+ symbol=symbol,
185
+ )
186
+
187
+
188
+ def _writes_a_whole_module(body: str) -> bool:
189
+ """True when the skill hands over a file, not a change to one.
190
+
191
+ `Action: edit` with a module body creates a file. `Action: patch` with
192
+ Find or Append edits one that is already there, and only that case
193
+ should be pointed at an existing file.
194
+ """
195
+ return bool(re.search(r"^Action:\s*edit\s*$", body, re.MULTILINE))
196
+
197
+
198
+ def retarget(body: str, target: Target, project: Path | None = None) -> str:
199
+ """Fill placeholders, then repoint any path this project does not have."""
200
+ values = {
201
+ "module": target.module,
202
+ "test": target.test,
203
+ "scope": target.scope,
204
+ "symbol": target.symbol,
205
+ }
206
+ text = _PLACEHOLDER.sub(lambda m: values[m.group(1)], body)
207
+ if target.symbol != _SYMBOL_TOKEN:
208
+ text = text.replace(_SYMBOL_TOKEN, target.symbol)
209
+ if project is None:
210
+ return text
211
+ root = project.resolve()
212
+
213
+ creates_module = _writes_a_whole_module(text)
214
+
215
+ def _path(match: re.Match[str]) -> str:
216
+ key, rel = match.group(1), match.group(2)
217
+ if (root / rel).is_file() or Path(rel).name in _KEEP_NAMES:
218
+ return match.group(0)
219
+ if creates_module and not _is_test(rel):
220
+ # The skill writes a new module, not a change to an existing one.
221
+ # Repointing it onto a file that already exists made the model
222
+ # write the function twice: once where it was sent, once where
223
+ # the skill said. Keep the name, move it beside this project's
224
+ # own modules.
225
+ home = Path(target.module).parent
226
+ name = Path(rel).name
227
+ moved = (home / name).as_posix() if home != Path(".") else name
228
+ return f"{key}: {moved}"
229
+ return f"{key}: {target.test if _is_test(rel) else target.module}"
230
+
231
+ def _scope(match: re.Match[str]) -> str:
232
+ rel = match.group(1)
233
+ if (root / rel).is_dir():
234
+ return match.group(0)
235
+ return f"Scope: {target.scope}"
236
+
237
+ text = _PATH_LINE.sub(_path, text)
238
+ return _SCOPE_LINE.sub(_scope, text)