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,439 @@
1
+ """Moving a file, and repairing what pointed at it.
2
+
3
+ Splitting a module, moving a helper to where it belongs and renaming a
4
+ file are ordinary work, and the harness could not do any of it. Asked to
5
+ move something it spent twenty steps and changed nothing.
6
+
7
+ The decision is usually already made by whoever asked — this file goes
8
+ there — so the work is mechanical, and mechanical work is what this
9
+ harness is good at. The part worth doing carefully is the imports: a
10
+ moved module leaves every `from pkg.old import name` pointing at
11
+ nothing, and asking a model to remember them all is how they get missed.
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import ast
17
+ import builtins
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+
22
+ from harness.act.code import apply_source, resolve_project_file
23
+ from harness.paths import rel_posix
24
+
25
+ # "move a/b.py to c/d.py", "rename a/b.py to c/d.py"
26
+ _MOVE = re.compile(
27
+ r"\b(?:move|rename)\b[^\w/]*([\w./-]+\.py)\b.{0,20}?\b(?:to|into|as)\b[^\w/]*([\w./-]+\.py)\b",
28
+ re.I,
29
+ )
30
+
31
+
32
+ def move_targets(task: str) -> tuple[str, str] | None:
33
+ """The two paths a move names, or None when it names fewer than two."""
34
+ found = _MOVE.search(task)
35
+ if not found:
36
+ return None
37
+ source, destination = found.group(1), found.group(2)
38
+ if source == destination:
39
+ return None
40
+ return source, destination
41
+
42
+
43
+ # Folders a project puts its code under, which imports do not name.
44
+ # A file at src/pkg/mod.py is imported as pkg.mod, not src.pkg.mod.
45
+ IMPORT_ROOTS = ("src", "lib")
46
+
47
+
48
+ def module_names(rel: str) -> list[str]:
49
+ """Every dotted name an import might use for this path.
50
+
51
+ A file under `src/` is imported without the `src`, because that is
52
+ what ends up on the path. Computing only the name relative to the
53
+ project root meant a real move rewrote nothing: the file became
54
+ `src.harness.observe.report_md` while every import said
55
+ `harness.observe.report_md`.
56
+ """
57
+ parts = list(Path(rel).with_suffix("").parts)
58
+ if parts and parts[-1] == "__init__":
59
+ parts = parts[:-1]
60
+ if not parts:
61
+ return []
62
+ names = [".".join(parts)]
63
+ if parts[0] in IMPORT_ROOTS and len(parts) > 1:
64
+ names.append(".".join(parts[1:]))
65
+ return names
66
+
67
+
68
+ def module_name(rel: str) -> str:
69
+ """The name an import is most likely to use for this path."""
70
+ names = module_names(rel)
71
+ return names[-1] if names else ""
72
+
73
+
74
+ def _rewritten(source: str, old: str, new: str) -> str:
75
+ """`source` with every import of `old` pointing at `new` instead."""
76
+ if not old or not new:
77
+ return source
78
+ text = re.sub(rf"(?<![\w.]){re.escape(old)}(?![\w])", new, source)
79
+ return text
80
+
81
+
82
+ def importers(project: Path, old: str) -> list[Path]:
83
+ """Files that name the module being moved."""
84
+ root = Path(project).resolve()
85
+ hits = []
86
+ for path in sorted(root.rglob("*.py")):
87
+ if any(part.startswith(".") or part == "__pycache__" for part in path.parts):
88
+ continue
89
+ try:
90
+ body = path.read_text(encoding="utf-8")
91
+ except OSError:
92
+ continue
93
+ if re.search(rf"(?<![\w.]){re.escape(old)}(?![\w])", body):
94
+ hits.append(path)
95
+ return hits
96
+
97
+
98
+ def apply_file_move(project: Path, task: str, *, write: bool = True) -> str:
99
+ """Move the file the task names and repair the imports. "" if unsure.
100
+
101
+ Everything is checked before anything is written: both paths stay
102
+ inside the project, the source exists, the destination does not, and
103
+ every file that would be rewritten still parses afterwards. A move
104
+ that would leave the project unparseable is refused whole rather
105
+ than half-applied.
106
+ """
107
+ targets = move_targets(task)
108
+ if targets is None:
109
+ return ""
110
+ source_rel, destination_rel = targets
111
+ try:
112
+ source = resolve_project_file(project, source_rel)
113
+ destination = resolve_project_file(project, destination_rel)
114
+ except (ValueError, OSError):
115
+ return ""
116
+ if not source.is_file() or destination.exists():
117
+ return ""
118
+
119
+ root = Path(project).resolve()
120
+ old_names = module_names(rel_posix(source, root))
121
+ new_names = module_names(rel_posix(destination, root))
122
+ if not old_names or len(old_names) != len(new_names):
123
+ return ""
124
+
125
+ edits: list[tuple[Path, str, str]] = []
126
+ seen: set[Path] = set()
127
+ for old_module, new_module in zip(old_names, new_names, strict=True):
128
+ for path in importers(project, old_module):
129
+ if path == source or path in seen:
130
+ continue
131
+ original = path.read_text(encoding="utf-8")
132
+ changed = _rewritten(original, old_module, new_module)
133
+ if changed == original:
134
+ continue
135
+ seen.add(path)
136
+ try:
137
+ ast.parse(changed)
138
+ except SyntaxError:
139
+ return ""
140
+ edits.append((path, original, changed))
141
+
142
+ body = source.read_text(encoding="utf-8")
143
+ if not write:
144
+ return (
145
+ f"would move {source_rel} to {destination_rel} "
146
+ f"and repair {len(edits)} file(s)"
147
+ )
148
+ destination.parent.mkdir(parents=True, exist_ok=True)
149
+ destination.write_text(body, encoding="utf-8")
150
+ source.unlink()
151
+ for path, original, changed in edits:
152
+ apply_source(path, changed, original=original)
153
+ repaired = f", and repaired {len(edits)} import(s)" if edits else ""
154
+ return f"moved {source_rel} to {destination_rel}{repaired}"
155
+
156
+
157
+ # "move the function refuse_x out of a/b.py into a/c.py", and the same
158
+ # sentence with `from`/`to`. The function name comes first because that
159
+ # is how people write it; the two paths follow in source-then-
160
+ # destination order.
161
+ _MOVE_FUNCTION = re.compile(
162
+ r"\bmove\b[^\w]*(?:the\s+)?(?:function\s+|def\s+)?"
163
+ r"(?P<name>[A-Za-z_]\w*)\b(?!\.py)"
164
+ r"[^\w/]*(?:\bout\s+of\b|\bfrom\b|\bin\b)[^\w/]*(?P<source>[\w./-]+\.py)\b"
165
+ r".{0,24}?\b(?:into|to)\b[^\w/]*(?P<destination>[\w./-]+\.py)\b",
166
+ re.I,
167
+ )
168
+
169
+
170
+ def function_move_targets(task: str) -> tuple[str, str, str] | None:
171
+ """(name, source, destination) for a function move, or None."""
172
+ found = _MOVE_FUNCTION.search(task)
173
+ if not found:
174
+ return None
175
+ source, destination = found.group("source"), found.group("destination")
176
+ if source == destination:
177
+ return None
178
+ return found.group("name"), source, destination
179
+
180
+
181
+ def _definition(tree: ast.Module, name: str) -> ast.stmt | None:
182
+ """The top-level def or class of that name, decorators included."""
183
+ for node in tree.body:
184
+ if isinstance(
185
+ node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
186
+ ) and node.name == name:
187
+ return node
188
+ return None
189
+
190
+
191
+ def _span(node: ast.stmt, lines: list[str]) -> tuple[int, int]:
192
+ """The line range to cut, counting decorators and the blank lines after."""
193
+ start = node.lineno - 1
194
+ for decorator in getattr(node, "decorator_list", []):
195
+ start = min(start, decorator.lineno - 1)
196
+ end = node.end_lineno or node.lineno
197
+ while end < len(lines) and not lines[end].strip():
198
+ end += 1
199
+ return start, end
200
+
201
+
202
+ def _free_names(node: ast.stmt) -> set[str]:
203
+ """Names the definition reads but does not create for itself.
204
+
205
+ A function carried into another file keeps whatever it referred to,
206
+ and the destination may not have it. Reporting that is the whole
207
+ reason this refuses instead of writing half a move.
208
+ """
209
+ bound: set[str] = {node.name}
210
+ for child in ast.walk(node):
211
+ if isinstance(child, (ast.FunctionDef, ast.AsyncFunctionDef)):
212
+ bound.add(child.name)
213
+ for arg in [
214
+ *child.args.posonlyargs,
215
+ *child.args.args,
216
+ *child.args.kwonlyargs,
217
+ ]:
218
+ bound.add(arg.arg)
219
+ for extra in (child.args.vararg, child.args.kwarg):
220
+ if extra is not None:
221
+ bound.add(extra.arg)
222
+ elif isinstance(child, ast.Name) and isinstance(child.ctx, ast.Store):
223
+ bound.add(child.id)
224
+ elif isinstance(child, ast.alias):
225
+ bound.add((child.asname or child.name).split(".")[0])
226
+ elif isinstance(child, ast.ExceptHandler) and child.name:
227
+ bound.add(child.name)
228
+ used = {
229
+ child.id
230
+ for child in ast.walk(node)
231
+ if isinstance(child, ast.Name) and isinstance(child.ctx, ast.Load)
232
+ }
233
+ return {name for name in used - bound if not hasattr(builtins, name)}
234
+
235
+
236
+ def _already_there(tree: ast.Module, name: str) -> bool:
237
+ return _definition(tree, name) is not None
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class _PlannedMove:
242
+ """A function move that has passed every check and written nothing."""
243
+
244
+ name: str
245
+ source: Path
246
+ source_before: str
247
+ source_after: str
248
+ destination: Path
249
+ destination_before: str
250
+ destination_after: str
251
+ # (path, before, after) — `apply_source` keeps a .bak from the
252
+ # original, so every write carries the text it replaces.
253
+ edits: tuple[tuple[Path, str, str], ...]
254
+
255
+
256
+ def _plan_function_move(project: Path, task: str) -> _PlannedMove | None:
257
+ """Work the move out in full, or return None. Writes nothing.
258
+
259
+ Everything is decided here so that `apply_function_move` either
260
+ writes a whole move or writes nothing at all. A half-applied move
261
+ leaves a project that does not import.
262
+ """
263
+ targets = function_move_targets(task)
264
+ if targets is None:
265
+ return None
266
+ name, source_rel, destination_rel = targets
267
+ root = Path(project).resolve()
268
+ source = resolve_project_file(project, source_rel)
269
+ destination = resolve_project_file(project, destination_rel)
270
+ if source is None or destination is None or not source.is_file():
271
+ return None
272
+ try:
273
+ source_text = source.read_text(encoding="utf-8")
274
+ source_tree = ast.parse(source_text)
275
+ destination_text = (
276
+ destination.read_text(encoding="utf-8") if destination.is_file() else ""
277
+ )
278
+ destination_tree = ast.parse(destination_text)
279
+ except (OSError, SyntaxError):
280
+ return None
281
+
282
+ node = _definition(source_tree, name)
283
+ if node is None or _already_there(destination_tree, name):
284
+ return None
285
+ # What the definition reads has to exist where it lands, or the move
286
+ # produces a file that fails on first call.
287
+ if any(
288
+ free not in _module_level_names(destination_tree)
289
+ for free in _free_names(node)
290
+ ):
291
+ return None
292
+
293
+ lines = source_text.splitlines(keepends=True)
294
+ first, last = _span(node, lines)
295
+ body = "".join(lines[first:last]).rstrip("\n")
296
+ source_after = "".join(lines[:first] + lines[last:])
297
+ landed = destination_text.rstrip("\n")
298
+ destination_after = f"{landed}\n\n\n{body}\n" if landed else f"{body}\n"
299
+ for text in (source_after, destination_after):
300
+ try:
301
+ ast.parse(text)
302
+ except SyntaxError:
303
+ return None
304
+
305
+ edits = _repointed_callers(
306
+ project,
307
+ name,
308
+ module_name(rel_posix(source, root)),
309
+ module_name(rel_posix(destination, root)),
310
+ skip={source, destination},
311
+ )
312
+ if edits is None:
313
+ return None
314
+ return _PlannedMove(
315
+ name,
316
+ source,
317
+ source_text,
318
+ source_after,
319
+ destination,
320
+ destination_text,
321
+ destination_after,
322
+ edits,
323
+ )
324
+
325
+
326
+ def _import_repaired(text: str, old_module: str, new_module: str, name: str) -> str:
327
+ """Take one name out of an import and give it its own line. "" if absent.
328
+
329
+ A real project does not write `from pkg.mod import one_name`. It
330
+ writes a parenthesised list of ten, and a plain string replacement
331
+ matches none of them: on this repository the first version moved the
332
+ function and left every caller importing it from where it used to
333
+ be, and the suite stopped loading.
334
+ """
335
+ try:
336
+ tree = ast.parse(text)
337
+ except SyntaxError:
338
+ return ""
339
+ lines = text.splitlines(keepends=True)
340
+ for node in tree.body:
341
+ if not isinstance(node, ast.ImportFrom) or node.module != old_module:
342
+ continue
343
+ kept = [alias for alias in node.names if alias.name != name]
344
+ if len(kept) == len(node.names):
345
+ continue
346
+ first, last = node.lineno - 1, node.end_lineno or node.lineno
347
+ replacement = f"from {new_module} import {name}\n"
348
+ if kept:
349
+ spelled = ", ".join(
350
+ a.name if not a.asname else f"{a.name} as {a.asname}" for a in kept
351
+ )
352
+ one_line = f"from {old_module} import {spelled}\n"
353
+ if len(one_line) > 80:
354
+ joined = ",\n ".join(
355
+ a.name if not a.asname else f"{a.name} as {a.asname}" for a in kept
356
+ )
357
+ one_line = f"from {old_module} import (\n {joined},\n)\n"
358
+ replacement = one_line + replacement
359
+ return "".join(lines[:first]) + replacement + "".join(lines[last:])
360
+ return ""
361
+
362
+
363
+ def _repointed_callers(
364
+ project: Path, name: str, old_module: str, new_module: str, *, skip: set[Path]
365
+ ) -> tuple[tuple[Path, str, str], ...] | None:
366
+ """Files whose import of `name` must follow it. None if one breaks."""
367
+ edits: list[tuple[Path, str, str]] = []
368
+ for path in importers(project, old_module):
369
+ if path in skip:
370
+ continue
371
+ original = path.read_text(encoding="utf-8")
372
+ changed = _import_repaired(original, old_module, new_module, name)
373
+ if not changed or changed == original:
374
+ continue
375
+ try:
376
+ ast.parse(changed)
377
+ except SyntaxError:
378
+ return None
379
+ edits.append((path, original, changed))
380
+ return tuple(edits)
381
+
382
+
383
+ def _write_keeping_a_backup(path: Path, source: str) -> None:
384
+ """Write a file that is deliberately shorter, and keep the original.
385
+
386
+ `apply_source` refuses a draft two thirds the length of what it
387
+ replaces, which is right when a model hands over a whole file and
388
+ wrong here: taking a function out makes the source shorter on
389
+ purpose. The backup is kept the same way, so nothing is lost and
390
+ the honest-finish check can still see what changed.
391
+ """
392
+ ast.parse(source)
393
+ backup = path.with_suffix(path.suffix + ".bak")
394
+ if path.is_file():
395
+ backup.write_text(path.read_text(encoding="utf-8"), encoding="utf-8")
396
+ path.parent.mkdir(parents=True, exist_ok=True)
397
+ path.write_text(source.rstrip() + "\n", encoding="utf-8")
398
+
399
+
400
+ def apply_function_move(project: Path, task: str, *, write: bool = True) -> str:
401
+ """Move the one function the task names, and repair its callers.
402
+
403
+ Returns a note, or "" when the task asks for something else or the
404
+ move cannot be made whole. Moving part of a file is the job people
405
+ reach for once a module has grown too big, and doing it by hand
406
+ means finding every caller by eye.
407
+ """
408
+ planned = _plan_function_move(project, task)
409
+ if planned is None:
410
+ return ""
411
+ where = rel_posix(planned.destination, Path(project).resolve())
412
+ if not write:
413
+ return f"would move {planned.name} to {where}"
414
+ _write_keeping_a_backup(planned.destination, planned.destination_after)
415
+ _write_keeping_a_backup(planned.source, planned.source_after)
416
+ for path, original, changed in planned.edits:
417
+ apply_source(path, changed, original=original)
418
+ repaired = (
419
+ f", and repaired {len(planned.edits)} import(s)" if planned.edits else ""
420
+ )
421
+ return f"moved {planned.name} to {where}{repaired}"
422
+
423
+
424
+ def _module_level_names(tree: ast.Module) -> set[str]:
425
+ """Everything a file defines or imports at the top level."""
426
+ found: set[str] = set()
427
+ for node in tree.body:
428
+ if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
429
+ found.add(node.name)
430
+ elif isinstance(node, (ast.Import, ast.ImportFrom)):
431
+ for alias in node.names:
432
+ found.add((alias.asname or alias.name).split(".")[0])
433
+ elif isinstance(node, ast.Assign):
434
+ for target in node.targets:
435
+ if isinstance(target, ast.Name):
436
+ found.add(target.id)
437
+ elif isinstance(node, ast.AnnAssign) and isinstance(node.target, ast.Name):
438
+ found.add(node.target.id)
439
+ return found