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
harness/locate.py ADDED
@@ -0,0 +1,767 @@
1
+ """Deterministic first steps for a small everyday model. No LLM."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from pathlib import Path
7
+
8
+ from harness.act.tools import grep_py, read_py
9
+ from harness.scan.names import undefined_names
10
+ from harness.task import looks_like_question, question_symbol
11
+ from harness.task import looks_like_add_feature
12
+ from harness.task import (
13
+ covered_symbol,
14
+ everyday_example_path,
15
+ everyday_skill_name,
16
+ named_project_file,
17
+ looks_like_bugfix,
18
+ looks_like_design_loop,
19
+ looks_like_everyday_code,
20
+ looks_like_fix_smell,
21
+ looks_like_new_package,
22
+ looks_like_refactor,
23
+ looks_like_review,
24
+ looks_like_write_tests,
25
+ smell_symbol,
26
+ )
27
+
28
+ _DEF = re.compile(r"\b(?:def|class)\s+([A-Za-z_][A-Za-z0-9_]*)")
29
+
30
+
31
+ def subject_of(task: str) -> str:
32
+ """The longest dotted name in the task, or "".
33
+
34
+ A name like `result.stopped` is the strongest hint a task gives
35
+ about *where* in a file the work is, and it is what an excerpt
36
+ should be centred on. Without it the model was shown the first
37
+ 3,500 characters and the last 800 of a 13,476-character file, and
38
+ the dict it had been asked to change was in neither.
39
+ """
40
+ words = re.findall(r"[A-Za-z_][A-Za-z0-9_]*(?:\.[A-Za-z_][A-Za-z0-9_]*)+", task)
41
+ dotted = [word for word in words if not word.endswith((".py", ".md", ".txt"))]
42
+ return max(dotted, key=len) if dotted else ""
43
+
44
+
45
+ def signature_line(text: str, symbol: str) -> str:
46
+ if not symbol:
47
+ return ""
48
+ needle = f"def {symbol}("
49
+ for raw in text.splitlines():
50
+ line = raw.strip()
51
+ if line.startswith("#"):
52
+ continue
53
+ if raw.count(":") >= 2 and not raw.lstrip().startswith(("def ", "class ", "async ")):
54
+ line = raw.split(":", 2)[-1].strip()
55
+ if needle in line:
56
+ return line.rstrip()
57
+ return ""
58
+
59
+
60
+ def return_annotation(signature: str) -> str:
61
+ if "->" not in signature:
62
+ return ""
63
+ return signature.split("->", 1)[1].rstrip(":").strip()
64
+
65
+
66
+ def _compact(text: str) -> str:
67
+ return re.sub(r"\s+", "", text).lower()
68
+
69
+
70
+ _ASKS_RETURN = re.compile(r"\b(return|returns|returned|type|give back|output)\b", re.I)
71
+ MIN_DESCRIPTION_WORDS = 4
72
+ # Words a return-type answer must add beyond the type itself. The
73
+ # bare answer to "what does compute_total return?" was `"int"`, which
74
+ # is the annotation read back, not what the function does. Two extra
75
+ # words is enough for "compute_total sums int"; asking for four
76
+ # rejected answers a person would accept, and the loop then spent
77
+ # every remaining step asking again.
78
+ MIN_EXTRA_WORDS = 2
79
+
80
+
81
+ def asks_what_it_returns(task: str) -> bool:
82
+ """True for "what does X return?", false for "what does X do?"."""
83
+ return bool(_ASKS_RETURN.search(task))
84
+
85
+
86
+ def refuse_shallow_done(task: str, summary: str, signature: str) -> str:
87
+ """Refuse an answer that is thinner than the question asked for.
88
+
89
+ Quoting the return type was added because the model answered "a tuple".
90
+ It was applied to every question, so "what does apply_discount do?" was
91
+ refused for the answer "it reduces a total by a whole percentage" —
92
+ the harness insisting on a worse reply than the one it was given.
93
+ """
94
+ if not looks_like_question(task):
95
+ return ""
96
+ if not asks_what_it_returns(task):
97
+ # A question about behaviour wants a sentence, not a type name.
98
+ if len((summary or "").split()) >= MIN_DESCRIPTION_WORDS:
99
+ return ""
100
+ return (
101
+ "too thin. Action: done Summary: say in a sentence what it does, "
102
+ "from the code you read."
103
+ )
104
+ wanted = return_annotation(signature)
105
+ if not wanted:
106
+ return ""
107
+ compact = _compact(summary or "")
108
+ if _compact(wanted) not in compact:
109
+ return (
110
+ f"too thin. Action: done Summary: must quote {wanted} "
111
+ f"from {signature} and say what it computes."
112
+ )
113
+ extra = [
114
+ word
115
+ for word in re.findall(r"[A-Za-z0-9_]+", summary or "")
116
+ if _compact(word) not in _compact(wanted)
117
+ ]
118
+ if len(extra) < MIN_EXTRA_WORDS:
119
+ return (
120
+ f"too thin. Action: done Summary: quote {wanted} and say in a "
121
+ "sentence what it computes, from the code you read."
122
+ )
123
+ return ""
124
+
125
+
126
+ def def_hit_path(grep_text: str, symbol: str) -> str:
127
+ wanted = symbol.strip()
128
+ if not wanted or grep_text.startswith(("(no hits)", "bad regex")):
129
+ return ""
130
+ fallback = ""
131
+ for line in grep_text.splitlines():
132
+ if line.startswith("#") or line.count(":") < 2:
133
+ continue
134
+ path, _ln, content = line.split(":", 2)
135
+ if not fallback:
136
+ fallback = path
137
+ if re.search(rf"\b(?:def|class)\s+{re.escape(wanted)}\b", content):
138
+ return path
139
+ return fallback
140
+
141
+
142
+ def locate_py(project: Path, query: str, scope: str = "") -> tuple[str, str]:
143
+ if not query.strip():
144
+ return "locate needs Query:", ""
145
+ hits = grep_py(project, query, scope=scope)
146
+ symbol = query.removeprefix("def ").removeprefix("class ").split()[0]
147
+ path = def_hit_path(hits, symbol)
148
+ if not path:
149
+ return hits, ""
150
+ body = read_py(project, path)
151
+ return f"{hits}\n\n# auto-read {path}\n{body}", path
152
+
153
+
154
+ def prelude(project: Path, task: str, scope: str = "") -> tuple[str, str]:
155
+ """Search before the model runs, and say what to do with what was found.
156
+
157
+ Small models skip the first grep, so the harness does it for them and
158
+ hands over the file with an instruction attached.
159
+
160
+ Each kind of task needs a different opening, so each one is its own
161
+ function below and this only chooses between them. They were a single
162
+ if-chain of a hundred and forty lines, which made the shared tail at
163
+ the end read as if it belonged to whichever branch you had just
164
+ finished reading.
165
+ """
166
+ if looks_like_new_package(task):
167
+ return "", ""
168
+ from harness.task import looks_like_app_overflow, package_noun
169
+
170
+ if looks_like_app_overflow(task):
171
+ from harness.scan.app_spec import overflow_edit_line
172
+
173
+ line = overflow_edit_line(task, project).rstrip(".")
174
+ dest = (
175
+ "pkg/config.py"
176
+ if "pkg/config.py" in line
177
+ else f"pkg/{package_noun(task)}.py"
178
+ )
179
+ return (f"{line}. Do not grep.\n", dest)
180
+ for opening in (
181
+ _opening_for_design_loop,
182
+ _opening_for_write_tests,
183
+ _opening_for_a_named_file,
184
+ ):
185
+ found = opening(project, task, scope)
186
+ if found is not None:
187
+ return found
188
+ return _opening_found_by_symbol(project, task, scope)
189
+
190
+
191
+ def _opening_for_design_loop(
192
+ project: Path, task: str, scope: str
193
+ ) -> tuple[str, str] | None:
194
+ """A review or refactor starts from the structure report, not a file."""
195
+ if not looks_like_design_loop(task):
196
+ return None
197
+ from harness.scan.design import design_is_clean, render_design_review
198
+
199
+ report = render_design_review(project, scope)
200
+ kind = "refactor" if looks_like_refactor(task) and not looks_like_review(task) else "review"
201
+ if design_is_clean(report):
202
+ next_line = (
203
+ "Next Action must be done. Summary: quote no structure findings."
204
+ )
205
+ else:
206
+ next_line = (
207
+ "Next Action must be edit Path: pkg/<new_concern>.py with one function."
208
+ )
209
+ return f"Harness design review ({kind})\n{next_line}\n\n{report}", ""
210
+
211
+
212
+ def _opening_for_write_tests(
213
+ project: Path, task: str, scope: str
214
+ ) -> tuple[str, str] | None:
215
+ """Writing a test needs the subject located and a destination chosen."""
216
+ if not looks_like_write_tests(task):
217
+ return None
218
+ symbol = covered_symbol(task) or question_symbol(task)
219
+ dest = named_project_file(task, project)
220
+ rel = dest.replace("\\", "/").lower()
221
+ if dest and "test" not in rel and not rel.split("/")[-1].startswith("test_"):
222
+ dest = ""
223
+ if not dest and symbol:
224
+ dest = f"tests/test_{symbol.split('.')[-1]}.py"
225
+ if not dest:
226
+ dest = "tests/test_module.py"
227
+ text, path = locate_py(project, symbol, scope) if symbol else ("", "")
228
+ header = (
229
+ f"Harness locate (write-tests) Query: {symbol or 'the function'}\n"
230
+ f"Next Action must be patch Path: {dest} Append: one AAA "
231
+ f"test_<unit>_<result> that calls {symbol or 'the function'}.\n"
232
+ "Do not edit the implementation. Do not ask."
233
+ )
234
+ return f"{header}\n\n{text}", path
235
+
236
+
237
+ def _opening_for_a_named_file(
238
+ project: Path, task: str, scope: str
239
+ ) -> tuple[str, str] | None:
240
+ """Open the file the task names, and say what may be done to it.
241
+
242
+ A task that names a file has already said which file to open.
243
+ Looking up a word out of that path instead found every file in the
244
+ project: "src/harness/model/engine.py" was searched for as
245
+ "harness".
246
+
247
+ `scope` is unused here and kept so every opening has one shape and
248
+ the caller can try them in turn.
249
+ """
250
+ named = named_project_file(task, project)
251
+ if named:
252
+ try:
253
+ body = read_py(project, named, about=subject_of(task))
254
+ except (OSError, ValueError):
255
+ body = ""
256
+ if body:
257
+ if looks_like_question(task):
258
+ next_line = (
259
+ "Next Action must be done. Quote what this file does. "
260
+ "Do not grep, read, or edit."
261
+ )
262
+ elif reviews_one_named_file(task):
263
+ findings = named_file_review_summary(project, task)
264
+ extra = f"\n{findings}" if findings else ""
265
+ next_line = (
266
+ "Next Action must be done. Quote a defect from the "
267
+ "findings below. Do not patch, edit, or run."
268
+ f"{extra}"
269
+ )
270
+ else:
271
+ next_line = (
272
+ f"Next Action must be patch Path: {named} with a Find: "
273
+ "line copied whole from the file below, and a Replace:. "
274
+ "Do not map or grep."
275
+ )
276
+ return (
277
+ f"Harness opened the file named in the task: {named}\n"
278
+ f"{next_line}\n"
279
+ f"Only {named} may be changed.\n\n"
280
+ f"# auto-read {named}\n{body}",
281
+ named,
282
+ )
283
+ return None
284
+
285
+
286
+ def _opening_found_by_symbol(
287
+ project: Path, task: str, scope: str
288
+ ) -> tuple[str, str]:
289
+ """Nothing named a file, so find the symbol and say what to do with it."""
290
+ symbol = smell_symbol(task) if looks_like_fix_smell(task) else question_symbol(task)
291
+ if not symbol and looks_like_add_feature(task):
292
+ symbol = question_symbol(task) or ""
293
+ if not symbol:
294
+ return "", ""
295
+ text, path = locate_py(project, symbol, scope)
296
+ if looks_like_question(task):
297
+ kind = "question"
298
+ elif looks_like_fix_smell(task):
299
+ kind = "fix-smell"
300
+ elif looks_like_everyday_code(task):
301
+ kind = everyday_skill_name(task) or "everyday"
302
+ else:
303
+ kind = "add-feature"
304
+ header = f"Harness locate ({kind}) Query: {symbol}"
305
+ header += _what_to_do_next(project, task, symbol, text, path)
306
+ return f"{header}\n\n{text}", path
307
+
308
+
309
+ def _what_to_do_next(
310
+ project: Path, task: str, symbol: str, text: str, path: str
311
+ ) -> str:
312
+ """The instruction attached to what was found, or "" for none.
313
+
314
+ One line per kind of task. An 8B follows the first instruction it
315
+ sees, so there is exactly one and it names the action, the path and
316
+ the shape of the edit.
317
+ """
318
+ header = ""
319
+ if looks_like_question(task) and path:
320
+ header += (
321
+ "\nNext Action must be done. Do not locate, grep, or read."
322
+ )
323
+ sig = signature_line(text, symbol)
324
+ if return_annotation(sig):
325
+ header += (
326
+ f"\nSummary must quote the -> type from: {sig} "
327
+ "and say what the function computes."
328
+ )
329
+ elif looks_like_fix_smell(task) and path:
330
+ header += (
331
+ "\nNext Action must be patch Find: the old def line "
332
+ "Replace: a readable snake_case name. Do not grep."
333
+ )
334
+ elif looks_like_everyday_code(task):
335
+ example = everyday_example_path(task)
336
+ header += (
337
+ f"\nNext Action must be edit Path: {example} with one function. "
338
+ "Do not grep. Do not emit curl."
339
+ )
340
+ elif looks_like_add_feature(task):
341
+ from harness.skillkit.target import pick_module
342
+
343
+ dest = path or pick_module(project, path, task)
344
+ header += (
345
+ f"\nNext Action must be patch Path: {dest} "
346
+ f"Append: def {symbol}(...). Do not grep. Do not create a second "
347
+ f"{Path(dest).stem}.py."
348
+ )
349
+ dest_path = Path(project) / dest
350
+ try:
351
+ dest_body = dest_path.read_text(encoding="utf-8")
352
+ except OSError:
353
+ dest_body = ""
354
+ names = [
355
+ name
356
+ for name in re.findall(r"^def \w+\((\w+)", dest_body, re.M)
357
+ if name not in {"self", "cls"}
358
+ ]
359
+ # Only when the task left the argument open. `read_env_file(path)`
360
+ # has already said what it takes, and telling the model to use the
361
+ # neighbours' `prices` instead sent it round the loop until the
362
+ # steps ran out.
363
+ from harness.skillkit.refuse_change import task_names_arguments
364
+
365
+ if names and not task_names_arguments(task):
366
+ neighbor = max(set(names), key=names.count)
367
+ header += (
368
+ f" Neighbor functions take `{neighbor}`. Use the same "
369
+ "argument unless the task says otherwise."
370
+ )
371
+ return header
372
+
373
+
374
+ _QUESTION_WRITE = frozenset({"patch", "edit", "run"})
375
+ _QUESTION_REEXPLORE = frozenset({"read", "locate", "grep"})
376
+
377
+
378
+ def refuse_redundant_locate(
379
+ task: str, action: str, prelude_ran: bool, project: Path | None = None
380
+ ) -> str:
381
+ if action != "locate":
382
+ return ""
383
+ from harness.task import looks_like_app_overflow
384
+
385
+ if looks_like_app_overflow(task):
386
+ from harness.scan.app_spec import overflow_edit_line
387
+
388
+ return "already located. " + overflow_edit_line(task, project)
389
+ if looks_like_new_package(task):
390
+ from harness.task import looks_like_app_loop, package_noun
391
+
392
+ noun = package_noun(task)
393
+ if looks_like_app_loop(task):
394
+ return (
395
+ f"already located. Action: edit Path: pkg/{noun}.py with "
396
+ "urllib.request and argparse subcommand list. No curl. "
397
+ "Do not ask."
398
+ )
399
+ return (
400
+ f"already located. Action: edit Path: pkg/{noun}.py with one "
401
+ "function. Do not ask."
402
+ )
403
+ if not prelude_ran:
404
+ return ""
405
+ if looks_like_question(task):
406
+ return (
407
+ "already located. Action: done Summary: quote the -> type."
408
+ )
409
+ if looks_like_everyday_code(task):
410
+ example = everyday_example_path(task)
411
+ return (
412
+ f"already located. Action: edit Path: {example} with one function."
413
+ )
414
+ if looks_like_add_feature(task):
415
+ dest = ""
416
+ if project is not None:
417
+ from harness.skillkit.target import pick_module
418
+
419
+ dest = pick_module(project, "", task)
420
+ where = f" Path: {dest}" if dest else ""
421
+ return (
422
+ f"already located. Action: patch{where} Append: the new function."
423
+ )
424
+ return ""
425
+
426
+
427
+ def refuse_app_overflow_explore(task: str, action: str) -> str:
428
+ """Overflow already names pkg/pr_review.py. Live 8B grepped comment for 20 steps."""
429
+ from harness.task import looks_like_app_overflow
430
+
431
+ if not looks_like_app_overflow(task) or action not in {"grep", "locate", "map"}:
432
+ return ""
433
+ from harness.scan.app_spec import overflow_edit_line
434
+
435
+ return f"Do not grep. {overflow_edit_line(task)}"
436
+
437
+
438
+ def refuse_app_ask(task: str, action: str) -> str:
439
+ """A greenfield CLI is not a clarifying question. Live 8B asked and stopped."""
440
+ from harness.task import looks_like_app_loop, package_noun
441
+
442
+ if action != "ask":
443
+ return ""
444
+ from harness.task import looks_like_app_overflow
445
+
446
+ if looks_like_app_overflow(task):
447
+ from harness.scan.app_spec import overflow_edit_line
448
+
449
+ return f"Do not ask. {overflow_edit_line(task)}"
450
+ if not looks_like_app_loop(task):
451
+ return ""
452
+ noun = package_noun(task)
453
+ return (
454
+ f"Do not ask. Action: edit Path: pkg/{noun}.py with urllib.request "
455
+ "and argparse subcommand list."
456
+ )
457
+
458
+
459
+ def refuse_app_tests_first(task: str, project: Path | None, action: str, path: str) -> str:
460
+ """Tests come after list/show exist. The live demo patched tests that were not there."""
461
+ from harness.task import looks_like_app_loop, package_noun
462
+
463
+ if not looks_like_app_loop(task) or action not in {"edit", "patch"}:
464
+ return ""
465
+ rel = (path or "").replace("\\", "/").lower()
466
+ if "test" not in rel:
467
+ return ""
468
+ if project is None:
469
+ return ""
470
+ from harness.scan.app_spec import required_gaps
471
+
472
+ keys = {gap.key for gap in required_gaps(project, task)}
473
+ if keys & {"http", "list", "show"}:
474
+ noun = package_noun(task)
475
+ return f"Implementation first. Action: edit Path: pkg/{noun}.py"
476
+ return ""
477
+
478
+
479
+ def refuse_bugfix_tests_first(
480
+ task: str, project: Path | None, action: str, path: str
481
+ ) -> str:
482
+ """A named-file bugfix whose symbol already has a test is not a write-tests job.
483
+
484
+ Live 8B rewrote tests/test_util_stats.py on the everyday-ready fixture
485
+ and never patched compute_total.
486
+ """
487
+ if not looks_like_bugfix(task) or action not in {"edit", "patch"}:
488
+ return ""
489
+ if project is None:
490
+ return ""
491
+ named = named_project_file(task, project)
492
+ if not named:
493
+ return ""
494
+ rel = (path or "").replace("\\", "/")
495
+ parts = [part for part in rel.split("/") if part]
496
+ if "tests" not in parts and not (parts and parts[-1].startswith("test_")):
497
+ return ""
498
+ from harness.skillkit.refuse_finish import tests_call
499
+
500
+ symbol = covered_symbol(task) or question_symbol(task)
501
+ if not symbol or not tests_call(project, symbol):
502
+ return ""
503
+ return (
504
+ f"The test already covers {symbol}. "
505
+ f"Action: patch Path: {named} with a Find: line copied whole from the file."
506
+ )
507
+
508
+
509
+ def refuse_bugfix_explore(
510
+ task: str, project: Path | None, action: str, located_path: str = ""
511
+ ) -> str:
512
+ """The named impl is already open. Live 8B explored 12 steps and wrote nothing."""
513
+ if not looks_like_bugfix(task) or action not in {
514
+ "grep",
515
+ "locate",
516
+ "map",
517
+ "ask",
518
+ "read",
519
+ }:
520
+ return ""
521
+ if project is None:
522
+ return ""
523
+ named = named_project_file(task, project)
524
+ if not named:
525
+ return ""
526
+ if action == "read" and not located_path:
527
+ return ""
528
+ return (
529
+ f"Do not {action}. Action: patch Path: {named} with a Find: line "
530
+ "copied whole from the file."
531
+ )
532
+
533
+
534
+ def refuse_app_wrong_path(task: str, action: str, path: str) -> str:
535
+ """Live 8B wrote pkg/pull_viewer.py and pkg.py after the hint named pr_review."""
536
+ from harness.task import looks_like_app_loop, looks_like_app_overflow, package_noun
537
+
538
+ if action not in {"edit", "patch"}:
539
+ return ""
540
+ if not looks_like_app_loop(task) and not looks_like_app_overflow(task):
541
+ return ""
542
+ rel = (path or "").replace("\\", "/").lstrip("./")
543
+ if not rel:
544
+ return ""
545
+ noun = package_noun(task)
546
+ allowed = (
547
+ "pkg/__init__.py",
548
+ f"pkg/{noun}.py",
549
+ f"tests/test_{noun}.py",
550
+ "pkg/config.py",
551
+ )
552
+ if rel in allowed:
553
+ return ""
554
+ return f"The module is pkg/{noun}.py. Action: edit Path: pkg/{noun}.py"
555
+
556
+
557
+ def refuse_write_tests_ask(task: str, action: str) -> str:
558
+ """Cover-test jobs name the symbol. Asking where tests live wastes the step."""
559
+ if action != "ask" or not looks_like_write_tests(task):
560
+ return ""
561
+ symbol = covered_symbol(task)
562
+ dest = f"tests/test_{symbol}.py" if symbol else "tests/test_<unit>.py"
563
+ return (
564
+ "Do not ask. Action: patch Path: "
565
+ f"{dest} Append: one AAA test_<unit>_<result> method."
566
+ )
567
+
568
+
569
+ _INVENTED = re.compile(r"\b([A-Za-z_][A-Za-z0-9_]{5,})\b")
570
+ _INVENTED_SKIP = frozenset(
571
+ {
572
+ "function",
573
+ "returns",
574
+ "return",
575
+ "empty",
576
+ "input",
577
+ "output",
578
+ "should",
579
+ "because",
580
+ "however",
581
+ "potential",
582
+ "defects",
583
+ "defect",
584
+ "errors",
585
+ "found",
586
+ "change",
587
+ "would",
588
+ "summary",
589
+ "action",
590
+ "module",
591
+ "caller",
592
+ "callers",
593
+ "formatted",
594
+ "present",
595
+ "values",
596
+ "counts",
597
+ "measured",
598
+ "estimated",
599
+ }
600
+ )
601
+
602
+
603
+ def refuse_invented_review(task: str, summary: str, body: str) -> str:
604
+ """Refuse a review that names a function the file does not contain.
605
+
606
+ Live 8B on a real tree invented compute_total and estimate_tokens after
607
+ reading an unrelated OpenSRE file. Demo-task prior, not a finding.
608
+ """
609
+ from harness.task import looks_like_review_code
610
+
611
+ if not looks_like_review_code(task):
612
+ return ""
613
+ if not (summary or "").strip() or not (body or "").strip():
614
+ return ""
615
+ invented: list[str] = []
616
+ for name in _INVENTED.findall(summary):
617
+ if name.lower() in _INVENTED_SKIP:
618
+ continue
619
+ if name.lower() in task.lower():
620
+ continue
621
+ if "_" not in name:
622
+ continue
623
+ if name in body or f"def {name}" in body:
624
+ continue
625
+ invented.append(name)
626
+ if not invented:
627
+ return ""
628
+ return (
629
+ f"{invented[0]} is not in the file you read. "
630
+ "Action: done Summary: quote a name that is in # auto-read, or say "
631
+ "no defects found."
632
+ )
633
+
634
+
635
+ def refuse_question_ask(task: str, action: str, located_path: str) -> str:
636
+ if action != "ask" or not looks_like_question(task):
637
+ return ""
638
+ if not located_path:
639
+ return ""
640
+ return (
641
+ "already located. Action: done Summary: quote the -> type from "
642
+ "# auto-read and say what the function computes."
643
+ )
644
+
645
+
646
+ def reviews_one_named_file(task: str) -> bool:
647
+ """True for "review src/orders.py for bugs", false for the design loop.
648
+
649
+ A structure review is allowed to edit, because the loop it drives moves
650
+ on to splitting a module. A review of one named file is not: it was
651
+ asked to report.
652
+ """
653
+ from harness.task import looks_like_review_code, task_paths
654
+
655
+ return bool(task_paths(task)) and looks_like_review_code(task)
656
+
657
+
658
+ def named_file_review_summary(project: Path, task: str) -> str:
659
+ """Quote compiler findings in a named file. Empty when there are none.
660
+
661
+ Live 8B on `review src/orders.py for bugs` was told to patch, then
662
+ refused, then burned the step budget. A hosted agent reads the file
663
+ once and names `subtotl`. This is that read, without a model turn.
664
+ """
665
+ if not reviews_one_named_file(task):
666
+ return ""
667
+ named = named_project_file(task, project)
668
+ if not named:
669
+ return ""
670
+ try:
671
+ body = read_py(project, named, about=subject_of(task))
672
+ except (OSError, ValueError):
673
+ return ""
674
+ leftover = undefined_names(body)
675
+ if not leftover:
676
+ return ""
677
+ shown = ", ".join(leftover)
678
+ return f"Compiler findings: undefined name {shown} in {named} (used, never bound)."
679
+
680
+
681
+ def refuse_question_write(task: str, action: str) -> str:
682
+ if reviews_one_named_file(task) and action in _QUESTION_WRITE:
683
+ return (
684
+ "Reviews do not edit. Action: done Summary: name the defect and "
685
+ "quote the line it is on."
686
+ )
687
+ if looks_like_design_loop(task):
688
+ return ""
689
+ if looks_like_question(task) and action in _QUESTION_WRITE:
690
+ return (
691
+ "Questions do not edit. "
692
+ "Action: done Summary: quote return or refuse from # auto-read."
693
+ )
694
+ return ""
695
+
696
+
697
+ def refuse_thin_review(task: str, summary: str, report: str) -> str:
698
+ if not looks_like_review(task):
699
+ return ""
700
+ from harness.scan.design import design_is_clean
701
+
702
+ if design_is_clean(report):
703
+ if "no structure findings" in (summary or "").lower():
704
+ return ""
705
+ return (
706
+ "too thin. Action: done Summary: quote no structure findings"
707
+ )
708
+ keys = [
709
+ word
710
+ for word in ("SoC", "god", "outsized", "tests", "scripts", "split", "__init__")
711
+ if word.lower() in report.lower()
712
+ ]
713
+ if not keys:
714
+ return ""
715
+ text = (summary or "").lower()
716
+ if any(key.lower() in text for key in keys):
717
+ return ""
718
+ return (
719
+ "too thin. Action: done Summary: quote one finding "
720
+ f"({', '.join(keys[:3])})"
721
+ )
722
+
723
+
724
+ def refuse_design_dirty(task: str, report: str) -> str:
725
+ if not looks_like_design_loop(task):
726
+ return ""
727
+ from harness.scan.design import design_is_clean
728
+
729
+ if design_is_clean(report):
730
+ return ""
731
+ return (
732
+ "not done. Structure findings remain. "
733
+ "Action: edit Path: pkg/<new_concern>.py with one function. "
734
+ "Then the harness will re-scan."
735
+ )
736
+
737
+
738
+ def refuse_redundant_explore(
739
+ task: str, action: str, path: str, located_path: str
740
+ ) -> str:
741
+ if not looks_like_question(task) or not located_path:
742
+ return ""
743
+ if action not in _QUESTION_REEXPLORE:
744
+ return ""
745
+ rel = path.replace("\\", "/").lstrip("./")
746
+ located = located_path.replace("\\", "/").lstrip("./")
747
+ same = (not rel) or rel == located or located.endswith(rel) or rel.endswith(located)
748
+ if not same:
749
+ return ""
750
+ return (
751
+ f"already have # auto-read {located}. "
752
+ "Action: done Summary: quote return or refuse from that file."
753
+ )
754
+
755
+
756
+ def refuse_early_done(task: str, last_path: str, located_path: str) -> str:
757
+ if not looks_like_question(task):
758
+ return ""
759
+ symbol = question_symbol(task)
760
+ if not symbol:
761
+ return ""
762
+ if located_path or (last_path and symbol.replace("_", "") in last_path.replace("_", "").lower()):
763
+ return ""
764
+ return (
765
+ f"not done. Harness or you must locate {symbol} first. "
766
+ f"Action: locate Query: {symbol}"
767
+ )