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,419 @@
1
+ """Writing one test for a function, and choosing what to call it with."""
2
+
3
+ from __future__ import annotations
4
+
5
+ """Mechanical fixes the 8B fails to express. Deterministic. No model.
6
+
7
+ Live 8B (29 Aug 2026): left `subtotal` unbound after a NameError task, and
8
+ spent twelve `Find:` turns that never matched `def calc(x: int, ...)`.
9
+ Those are compiler jobs. The harness does them, then runs the suite,
10
+ before the first generate. A green suite ends the run without a model.
11
+ """
12
+ import ast
13
+ import importlib.util
14
+ import os
15
+ import re
16
+ import sys
17
+ from pathlib import Path
18
+ from harness.act.code import apply_source
19
+ from harness.task import (
20
+ looks_like_file_operation,
21
+ covered_symbol,
22
+ looks_like_add_feature,
23
+ named_project_file,
24
+ question_symbol,
25
+ )
26
+
27
+
28
+
29
+ def _imports(source: str, name: str) -> bool:
30
+ """Whether `source` brings `name` in by an import."""
31
+ try:
32
+ tree = ast.parse(source)
33
+ except (SyntaxError, ValueError):
34
+ return False
35
+ for node in ast.walk(tree):
36
+ if isinstance(node, (ast.Import, ast.ImportFrom)):
37
+ for alias in node.names:
38
+ if (alias.asname or alias.name.split(".")[-1]) == name:
39
+ return True
40
+ return False
41
+
42
+
43
+ def _test_file_for(
44
+ task: str, project: Path, name: str, dests: list[Path]
45
+ ) -> Path:
46
+ """Which test file a new test for `name` belongs in.
47
+
48
+ This used to be whichever file sorted first, so covering
49
+ `ticket_job` from `ship/ticket.py` appended the test to
50
+ `tests/test_agent_api.py`. It ran, it passed, and it was filed under
51
+ something it has nothing to do with.
52
+
53
+ In order: the file that already tests this symbol, the one named
54
+ after the module the task points at, and only then the first.
55
+ """
56
+ for path in dests:
57
+ try:
58
+ body = path.read_text(encoding="utf-8")
59
+ except OSError:
60
+ continue
61
+ # A test named after it, or a file that imports it. Merely
62
+ # mentioning the name is not enough — a docstring in an unrelated
63
+ # test file matched and sent the new test there — and a regex is
64
+ # not enough either, because most of these imports are written
65
+ # across several lines inside brackets.
66
+ if re.search(rf"\bdef test_\w*{re.escape(name)}", body) or _imports(
67
+ body, name
68
+ ):
69
+ return path
70
+ named = named_project_file(task, project)
71
+ if named:
72
+ stem = Path(named).stem
73
+ for path in dests:
74
+ if path.stem in (f"test_{stem}", stem):
75
+ return path
76
+ return dests[0]
77
+
78
+
79
+ def _already_exercised(body: str, name: str, safe: str) -> bool:
80
+ """Does this test file actually put the name to work?
81
+
82
+ The word appearing somewhere is not the same as the name being
83
+ tested. `name in body` matched prose: asked to "add a check", the
84
+ harness took `check` for a symbol, found the English word in a
85
+ docstring, and finished the run saying a test already existed. The
86
+ word is in 17 of this project's test files; it is called in 5.
87
+
88
+ A call or a test named after it is the difference between a file
89
+ that mentions something and a file that exercises it.
90
+ """
91
+ return (
92
+ f"{name}(" in body
93
+ or f"{safe}(" in body
94
+ or f"def test_{safe}_" in body
95
+ or f"def test_{safe}(" in body
96
+ )
97
+
98
+
99
+ def apply_cover_test(project: Path, task: str, *, write: bool = True) -> str:
100
+ """Add one AAA test for the named function.
101
+
102
+ Returns a note when a test already names the function, so the run can
103
+ finish without the model appending a dead copy after `if __name__`.
104
+ """
105
+ if looks_like_file_operation(task):
106
+ # Nothing here is a symbol. Guessing one and finding it in some
107
+ # test file is how "already has a test for create" happened.
108
+ return ""
109
+ name = covered_symbol(task) or (
110
+ question_symbol(task) if looks_like_add_feature(task) else ""
111
+ )
112
+ if not name:
113
+ return ""
114
+ tests = Path(project) / "tests"
115
+ dests = sorted(tests.glob("test_*.py")) if tests.is_dir() else []
116
+ if not dests:
117
+ return ""
118
+ dest = _test_file_for(task, project, name, dests)
119
+ body = dest.read_text(encoding="utf-8")
120
+ safe = name.replace(".", "_")
121
+ if _already_exercised(body, name, safe):
122
+ return f"already has a test for {name}"
123
+ impl = named_project_file(task, project)
124
+ if not impl:
125
+ from harness.skillkit.target import pick_module
126
+
127
+ impl = pick_module(project, "", task)
128
+ impl_path = Path(project) / impl if impl else None
129
+ if impl_path is None or not impl_path.is_file():
130
+ return ""
131
+ if f"def {name}" not in impl_path.read_text(encoding="utf-8"):
132
+ return ""
133
+ sample = _sample_values(impl_path, name, project=Path(project))
134
+ if sample is None:
135
+ return ""
136
+ args, expected, class_name, func_name = sample
137
+ module = impl.replace("\\", "/").removesuffix(".py").replace("/", ".")
138
+ imported = class_name or func_name
139
+ names = ", ".join(key for key, _value in args)
140
+ values = ", ".join(repr(value) for _key, value in args)
141
+ assigns = f"{names} = {values}" if args else ""
142
+ if class_name:
143
+ holder = class_name[0].lower() + class_name[1:]
144
+ act = (
145
+ f" {holder} = {class_name}()\n"
146
+ + (f" {assigns}\n" if assigns else "")
147
+ + f" got = {holder}.{func_name}({names})\n"
148
+ )
149
+ else:
150
+ act = (
151
+ (f" {assigns}\n" if assigns else "")
152
+ + f" got = {func_name}({names})\n"
153
+ )
154
+ method = (
155
+ f" def test_{safe}_returns_the_expected_result(self) -> None:\n"
156
+ f"{act}"
157
+ f" self.assertEqual(got, {expected!r})\n"
158
+ )
159
+ merged = _add_import_symbol(body, module, imported)
160
+ merged = _append_class_method(merged, method)
161
+ try:
162
+ ast.parse(merged)
163
+ except SyntaxError:
164
+ return ""
165
+ if write:
166
+ apply_source(dest, merged, original=body)
167
+ try:
168
+ rel = dest.resolve().relative_to(Path(project).resolve()).as_posix()
169
+ except ValueError:
170
+ rel = dest.as_posix()
171
+ return f"AAA test for {name} in {rel}"
172
+
173
+
174
+ def _find_callable(
175
+ tree: ast.AST, name: str
176
+ ) -> tuple[str, ast.FunctionDef] | None:
177
+ """(class_name or "", function). Empty class_name is a module function."""
178
+ cls_name, meth = name, ""
179
+ if "." in name:
180
+ cls_name, meth = name.split(".", 1)
181
+ for node in tree.body:
182
+ if isinstance(node, ast.FunctionDef) and node.name == name and not meth:
183
+ return "", node
184
+ if not isinstance(node, ast.ClassDef):
185
+ continue
186
+ if meth and node.name != cls_name:
187
+ continue
188
+ if not meth and node.name != name:
189
+ if any(
190
+ isinstance(item, ast.FunctionDef) and item.name == name
191
+ for item in node.body
192
+ ):
193
+ item = next(
194
+ item
195
+ for item in node.body
196
+ if isinstance(item, ast.FunctionDef) and item.name == name
197
+ )
198
+ return node.name, item
199
+ continue
200
+ for item in node.body:
201
+ if not isinstance(item, ast.FunctionDef):
202
+ continue
203
+ if meth and item.name == meth:
204
+ return node.name, item
205
+ if not meth and not item.name.startswith("_"):
206
+ return node.name, item
207
+ return None
208
+
209
+
210
+ MIN_SHARE_REACHED = 0.5
211
+
212
+
213
+ def _lines_reached(call, path: Path, first: int, last: int) -> int:
214
+ """How many lines between `first` and `last` the call actually runs.
215
+
216
+ A test built from an argument that returns on the first guard is a
217
+ test of the guard. Counting what ran is the only way to tell that
218
+ apart from a test that exercised the function.
219
+ """
220
+ seen: set[int] = set()
221
+ # Compare real paths: a module loaded from /tmp reports /private/tmp
222
+ # on macOS, and the tracer then matched nothing at all.
223
+ target = os.path.realpath(path)
224
+
225
+ def trace(frame, event, _arg):
226
+ if os.path.realpath(frame.f_code.co_filename) != target:
227
+ return None
228
+ if event == "line" and first <= frame.f_lineno <= last:
229
+ seen.add(frame.f_lineno)
230
+ return trace
231
+
232
+ previous = sys.gettrace()
233
+ sys.settrace(trace)
234
+ try:
235
+ call()
236
+ finally:
237
+ sys.settrace(previous)
238
+ return len(seen)
239
+
240
+
241
+ def _body_lines(func) -> int:
242
+ """Executable lines in a function, not counting its docstring."""
243
+ body = list(func.body)
244
+ if (
245
+ body
246
+ and isinstance(body[0], ast.Expr)
247
+ and isinstance(body[0].value, ast.Constant)
248
+ and isinstance(body[0].value.value, str)
249
+ ):
250
+ body = body[1:]
251
+ lines = set()
252
+ for statement in body:
253
+ for node in ast.walk(statement):
254
+ if hasattr(node, "lineno"):
255
+ lines.add(node.lineno)
256
+ return len(lines) or 1
257
+
258
+
259
+ def _candidates(hint: str, arg_name: str, source: str) -> list[object]:
260
+ """Values worth trying for one argument, best guess first.
261
+
262
+ The string literals the module compares against are the ones that
263
+ reach a branch: a function that checks `text == "yes"` is only
264
+ exercised by "yes". A placeholder reaches the first return.
265
+ """
266
+ if "list" in hint:
267
+ return [[10, 20], [], [1]]
268
+ if "dict" in hint:
269
+ return [{"prices": [10, 20], "percent": 10}, {}]
270
+ if "float" in hint:
271
+ return [1.5, 0.0]
272
+ if "bool" in hint:
273
+ return [True, False]
274
+ if "int" in hint:
275
+ return [2, 0, 100]
276
+ if "str" in hint or not hint:
277
+ literals = [
278
+ node.value
279
+ for node in ast.walk(ast.parse(source))
280
+ if isinstance(node, ast.Constant)
281
+ and isinstance(node.value, str)
282
+ and 0 < len(node.value) <= 40
283
+ and "\n" not in node.value
284
+ ]
285
+ seen, ordered = set(), []
286
+ for value in literals:
287
+ if value not in seen:
288
+ seen.add(value)
289
+ ordered.append(value)
290
+ # Plain values first, so a literal only wins when it genuinely
291
+ # reaches further into the function. A test reading `shout("x")`
292
+ # is easier to follow than one reading `shout("!")`.
293
+ return ["x", "", *ordered[:12]]
294
+ return [2, 0]
295
+
296
+
297
+ def _sample_values(
298
+ path: Path, name: str, *, project: Path | None = None
299
+ ) -> tuple[list[tuple[str, object]], object, str, str] | None:
300
+ """Call the function with simple args. None when that is not safe."""
301
+ try:
302
+ tree = ast.parse(path.read_text(encoding="utf-8"))
303
+ except (OSError, SyntaxError):
304
+ return None
305
+ found = _find_callable(tree, name)
306
+ if found is None:
307
+ return None
308
+ class_name, func = found
309
+ if func.args.kwonlyargs or func.args.vararg or func.args.kwarg:
310
+ return None
311
+ source = path.read_text(encoding="utf-8")
312
+ choices: list[tuple[str, list[object]]] = []
313
+ for arg in func.args.args:
314
+ if arg.arg in {"self", "cls"}:
315
+ continue
316
+ hint = ast.unparse(arg.annotation) if arg.annotation else ""
317
+ choices.append((arg.arg, _candidates(hint, arg.arg, source)))
318
+ args: list[tuple[str, object]] = [
319
+ (name, values[0]) for name, values in choices
320
+ ]
321
+ token = name.replace(".", "_")
322
+ module_name = f"_vibe_cover_{token}"
323
+ spec = importlib.util.spec_from_file_location(module_name, path)
324
+ if spec is None or spec.loader is None:
325
+ return None
326
+ module = importlib.util.module_from_spec(spec)
327
+ sys.modules[module_name] = module
328
+ inserted = ""
329
+ if project is not None:
330
+ inserted = str(Path(project).resolve())
331
+ sys.path.insert(0, inserted)
332
+ try:
333
+ spec.loader.exec_module(module)
334
+ if class_name:
335
+ cls = getattr(module, class_name, None)
336
+ if cls is None:
337
+ return None
338
+ target = getattr(cls(), func.name)
339
+ else:
340
+ target = getattr(module, func.name, None)
341
+ if target is None:
342
+ return None
343
+ first = func.lineno
344
+ last = func.end_lineno or func.lineno
345
+ best_reach, best_args, expected = -1, args, None
346
+ # Try one argument at a time against the first working set, and
347
+ # keep whichever call runs the most of the function.
348
+ for index, (arg_name, values) in enumerate(choices):
349
+ for value in values[:8]:
350
+ trial = list(args)
351
+ trial[index] = (arg_name, value)
352
+ shot = tuple(v for _k, v in trial)
353
+ try:
354
+ reach = _lines_reached(
355
+ lambda: target(*shot), path, first, last
356
+ )
357
+ outcome = target(*shot)
358
+ except Exception:
359
+ continue
360
+ if reach > best_reach:
361
+ best_reach, best_args, expected = reach, trial, outcome
362
+ if best_reach > 0:
363
+ args = list(best_args)
364
+ if best_reach < max(1, int(_body_lines(func) * MIN_SHARE_REACHED)):
365
+ # Every value tried returned on a guard, so a test built from
366
+ # this would asserts the guard rather than the function. Say
367
+ # nothing rather than write a test that proves nothing.
368
+ return None
369
+ except Exception:
370
+ return None
371
+ finally:
372
+ sys.modules.pop(module_name, None)
373
+ if inserted and sys.path and sys.path[0] == inserted:
374
+ sys.path.pop(0)
375
+ return list(best_args), expected, class_name, func.name
376
+
377
+
378
+ def _add_import_symbol(text: str, module: str, name: str) -> str:
379
+ if re.search(
380
+ rf"from\s+{re.escape(module)}\s+import\s+.*\b{re.escape(name)}\b", text
381
+ ):
382
+ return text
383
+ pattern = re.compile(rf"^(from\s+{re.escape(module)}\s+import\s+)(.+)$", re.M)
384
+ match = pattern.search(text)
385
+ if match:
386
+ imported = match.group(2)
387
+ if re.search(rf"\b{re.escape(name)}\b", imported):
388
+ return text
389
+ return text.replace(
390
+ match.group(0), f"{match.group(1)}{imported.rstrip()}, {name}", 1
391
+ )
392
+ line = f"from {module} import {name}\n"
393
+ if "import " in text:
394
+ last = 0
395
+ for hit in re.finditer(r"^(?:from\s+\S+\s+)?import\s+.+$", text, re.M):
396
+ last = hit.end()
397
+ return text[:last] + "\n" + line + text[last:]
398
+ return line + text
399
+
400
+
401
+ def _append_class_method(text: str, method: str) -> str:
402
+ try:
403
+ tree = ast.parse(text)
404
+ except SyntaxError:
405
+ return text
406
+ classes = [node for node in tree.body if isinstance(node, ast.ClassDef)]
407
+ if not classes:
408
+ return text.rstrip() + (
409
+ "\n\nclass TestGenerated(unittest.TestCase):\n" + method + "\n"
410
+ )
411
+ last = classes[-1]
412
+ methods = [node for node in last.body if isinstance(node, ast.FunctionDef)]
413
+ end = methods[-1].end_lineno if methods else last.end_lineno
414
+ lines = text.splitlines(keepends=True)
415
+ insert = method if method.startswith("\n") else "\n" + method
416
+ if not insert.endswith("\n"):
417
+ insert += "\n"
418
+ lines.insert(end, insert)
419
+ return "".join(lines)
@@ -0,0 +1,151 @@
1
+ """The repairs that run before any model turn, in order."""
2
+
3
+ from __future__ import annotations
4
+
5
+ """Mechanical fixes the 8B fails to express. Deterministic. No model.
6
+
7
+ Live 8B (29 Aug 2026): left `subtotal` unbound after a NameError task, and
8
+ spent twelve `Find:` turns that never matched `def calc(x: int, ...)`.
9
+ Those are compiler jobs. The harness does them, then runs the suite,
10
+ before the first generate. A green suite ends the run without a model.
11
+ """
12
+ from pathlib import Path
13
+ from harness.act.code import apply_source
14
+ from harness.task import (
15
+ looks_like_add_feature,
16
+ looks_like_app_overflow,
17
+ looks_like_bugfix,
18
+ looks_like_fix_smell,
19
+ looks_like_write_tests,
20
+ named_project_file,
21
+ rename_pair,
22
+ )
23
+
24
+ from harness.act.autofix.additions import (
25
+ _impl_py,
26
+ apply_add_function,
27
+ apply_function_rename,
28
+ )
29
+ from harness.act.autofix.moves import apply_file_move, apply_function_move
30
+ from harness.act.autofix.conflicts import _resolve_conflict, looks_like_conflict
31
+ from harness.act.autofix.cover import apply_cover_test
32
+ from harness.act.autofix.names import (
33
+ apply_typo_fixes,
34
+ apply_zero_return_sum,
35
+ typo_pairs,
36
+ )
37
+ from harness.act.autofix.scaffold import apply_home_config, apply_list_page_query
38
+
39
+
40
+ def _repair_in_place(
41
+ project: Path, task: str, rel: str, *, write: bool
42
+ ) -> list[str]:
43
+ """Rewrite the named file, or the one file that holds the typo.
44
+
45
+ Split out of `apply_mechanical` when it crossed eighty lines. It is
46
+ one job: change a file that is already there. Everything after it in
47
+ the caller adds something new instead.
48
+ """
49
+ notes: list[str] = []
50
+ path = Path(project) / rel if rel else None
51
+ text = original = ""
52
+ if path is not None and path.is_file():
53
+ try:
54
+ original = path.read_text(encoding="utf-8")
55
+ except OSError:
56
+ original = ""
57
+ text = original
58
+ if looks_like_fix_smell(task):
59
+ old, new = rename_pair(task)
60
+ if old and new:
61
+ renamed = apply_function_rename(text, old, new)
62
+ if renamed != text:
63
+ text = renamed
64
+ notes.append(f"renamed def {old} → def {new} in {rel}")
65
+ if looks_like_bugfix(task):
66
+ fixed = apply_typo_fixes(text)
67
+ if fixed != text:
68
+ pairs = typo_pairs(original)
69
+ text = fixed
70
+ shown = ", ".join(f"{bad} → {good}" for bad, good in pairs)
71
+ notes.append(f"bound unique NameError typo ({shown}) in {rel}")
72
+ summed = apply_zero_return_sum(text, task)
73
+ if summed != text:
74
+ text = summed
75
+ notes.append(f"bound a zero return to a sum in {rel}")
76
+ if text != original and notes and write:
77
+ apply_source(path, text, original=original)
78
+ return notes
79
+ if looks_like_bugfix(task):
80
+ for dest, dest_rel in _impl_py(project):
81
+ try:
82
+ original = dest.read_text(encoding="utf-8")
83
+ except OSError:
84
+ continue
85
+ fixed = apply_typo_fixes(original)
86
+ if fixed == original:
87
+ continue
88
+ pairs = typo_pairs(original)
89
+ if write:
90
+ apply_source(dest, fixed, original=original)
91
+ shown = ", ".join(f"{bad} → {good}" for bad, good in pairs)
92
+ notes.append(f"bound unique NameError typo ({shown}) in {dest_rel}")
93
+ return notes
94
+
95
+
96
+ def apply_mechanical(
97
+ project: Path, task: str, rel: str, *, write: bool = True
98
+ ) -> str:
99
+ """Write a rename, unique typo, or missing AAA test. Return a note, or empty."""
100
+ if not rel:
101
+ rel = named_project_file(task, project)
102
+ notes: list[str] = _repair_in_place(project, task, rel, write=write)
103
+ path = Path(project) / rel if rel else None
104
+ if looks_like_add_feature(task):
105
+ added = apply_add_function(project, task, write=write)
106
+ if added:
107
+ notes.append(added)
108
+ cover = apply_cover_test(project, task, write=write)
109
+ if cover:
110
+ notes.append(cover)
111
+ moved = apply_file_move(project, task, write=write)
112
+ if moved:
113
+ notes.append(moved)
114
+ moved_one = apply_function_move(project, task, write=write)
115
+ if moved_one:
116
+ notes.append(moved_one)
117
+ if path is not None and path.is_file() and looks_like_conflict(task):
118
+ # Read again rather than carry the text out of the repair above:
119
+ # that repair may have rewritten the file, and a conflict has to
120
+ # be resolved against what is on disk now.
121
+ try:
122
+ current = path.read_text(encoding="utf-8")
123
+ except OSError:
124
+ current = ""
125
+ note = _resolve_conflict(path, rel, current, write=write)
126
+ if note:
127
+ notes.append(note)
128
+ if looks_like_write_tests(task):
129
+ cover = apply_cover_test(project, task, write=write)
130
+ if cover:
131
+ notes.append(cover)
132
+ if looks_like_app_overflow(task):
133
+ paged = apply_list_page_query(project, task, write=write)
134
+ if paged:
135
+ notes.append(paged)
136
+ home = apply_home_config(project, task, write=write)
137
+ if home:
138
+ notes.append(home)
139
+ if not notes:
140
+ return ""
141
+ verb = "applied" if write else "would apply (read-only)"
142
+ return (
143
+ f"Harness {verb} a mechanical fix (no model):\n"
144
+ + "\n".join(f"- {item}" for item in notes)
145
+ + (
146
+ "\nNext Action must be run Argv: -m unittest discover -s tests -q. "
147
+ "Do not patch this file again."
148
+ if write
149
+ else "\nAction: done Summary: say what you would change and why."
150
+ )
151
+ )
@@ -0,0 +1,50 @@
1
+ """Adding the import line for a name used without one."""
2
+
3
+ from __future__ import annotations
4
+
5
+ """Mechanical fixes the 8B fails to express. Deterministic. No model.
6
+
7
+ Live 8B (29 Aug 2026): left `subtotal` unbound after a NameError task, and
8
+ spent twelve `Find:` turns that never matched `def calc(x: int, ...)`.
9
+ Those are compiler jobs. The harness does them, then runs the suite,
10
+ before the first generate. A green suite ends the run without a model.
11
+ """
12
+ from harness.scan.names import undefined_names
13
+
14
+
15
+
16
+
17
+ def apply_missing_imports(source: str) -> str:
18
+ """Add the import line for a well-known name used without one.
19
+
20
+ A model writes `Path` and forgets `from pathlib import Path`. Refusing
21
+ that and asking for a rename is wrong twice over: the name is right,
22
+ and the repair is mechanical. Only names on a fixed list are handled,
23
+ so nothing is guessed.
24
+ """
25
+ from harness.scan.names import import_for, undefined_names
26
+
27
+ wanted = [
28
+ line for line in (import_for(name) for name in undefined_names(source)) if line
29
+ ]
30
+ if not wanted:
31
+ return source
32
+ lines = source.splitlines()
33
+ present = {line.strip() for line in lines}
34
+ missing = [line for line in dict.fromkeys(wanted) if line not in present]
35
+ if not missing:
36
+ return source
37
+ insert_at = 0
38
+ if lines and lines[0].lstrip()[:3] in {'"""', "'''"}:
39
+ quote = lines[0].lstrip()[:3]
40
+ rest = lines[0].lstrip()[3:]
41
+ if quote in rest:
42
+ insert_at = 1
43
+ else:
44
+ for index, line in enumerate(lines[1:], 1):
45
+ if quote in line:
46
+ insert_at = index + 1
47
+ break
48
+ while insert_at < len(lines) and not lines[insert_at].strip():
49
+ insert_at += 1
50
+ return "\n".join(lines[:insert_at] + missing + [""] + lines[insert_at:]).rstrip() + "\n"