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.
- finetune/__init__.py +1 -0
- finetune/agent_system.py +41 -0
- finetune/agent_traces.py +157 -0
- finetune/everyday.py +30 -0
- finetune/hf_ollama.py +158 -0
- finetune/huggingface_store.py +144 -0
- finetune/models.py +74 -0
- finetune/paths.py +11 -0
- finetune/python_vibe.py +788 -0
- finetune/splits.py +54 -0
- finetune/systems.py +9 -0
- harness/__init__.py +42 -0
- harness/__main__.py +8 -0
- harness/act/__init__.py +6 -0
- harness/act/autofix/__init__.py +110 -0
- harness/act/autofix/additions.py +217 -0
- harness/act/autofix/conflicts.py +124 -0
- harness/act/autofix/cover.py +419 -0
- harness/act/autofix/mechanical.py +151 -0
- harness/act/autofix/missing_imports.py +50 -0
- harness/act/autofix/moves.py +439 -0
- harness/act/autofix/names.py +339 -0
- harness/act/autofix/scaffold.py +224 -0
- harness/act/code.py +157 -0
- harness/act/gate.py +229 -0
- harness/act/parse.py +247 -0
- harness/act/patch_fix.py +138 -0
- harness/act/tools.py +244 -0
- harness/agent/__init__.py +11 -0
- harness/agent/dispatch.py +235 -0
- harness/agent/loop.py +699 -0
- harness/agent/options.py +144 -0
- harness/agent/policy.py +856 -0
- harness/agent/prompt.py +170 -0
- harness/cli.py +393 -0
- harness/editor_kit.py +265 -0
- harness/guard/__init__.py +6 -0
- harness/guard/fallbacks.py +6 -0
- harness/guard/loop_guard.py +57 -0
- harness/guard/python_vibe.py +68 -0
- harness/guard/run.py +41 -0
- harness/guard/types.py +19 -0
- harness/locate.py +767 -0
- harness/mcp_stdio.py +306 -0
- harness/memory/__init__.py +5 -0
- harness/memory/conversation.py +104 -0
- harness/model/__init__.py +6 -0
- harness/model/chat_backend.py +100 -0
- harness/model/engine.py +165 -0
- harness/model/ollama_generate.py +60 -0
- harness/model/openai_generate.py +156 -0
- harness/model/outbound.py +83 -0
- harness/model/route.py +90 -0
- harness/observe/__init__.py +6 -0
- harness/observe/eval_gate.py +80 -0
- harness/observe/eval_loop.py +185 -0
- harness/observe/eval_tasks.py +399 -0
- harness/observe/report_md.py +102 -0
- harness/observe/trace_record.py +79 -0
- harness/openai_api.py +81 -0
- harness/paths.py +88 -0
- harness/py.typed +0 -0
- harness/scan/__init__.py +6 -0
- harness/scan/app_spec.py +338 -0
- harness/scan/design.py +112 -0
- harness/scan/existing.py +131 -0
- harness/scan/layout.py +254 -0
- harness/scan/names.py +308 -0
- harness/scan/project_brief.py +287 -0
- harness/scan/project_docs.py +42 -0
- harness/scan/project_scan.py +49 -0
- harness/scan/repo_map.py +101 -0
- harness/secrets.py +39 -0
- harness/server.py +199 -0
- harness/ship/__init__.py +1 -0
- harness/ship/bot_pr.py +221 -0
- harness/ship/git_ship.py +262 -0
- harness/ship/identity.py +62 -0
- harness/ship/ticket.py +251 -0
- harness/skillkit/__init__.py +6 -0
- harness/skillkit/catalog.py +241 -0
- harness/skillkit/refuse_change.py +640 -0
- harness/skillkit/refuse_finish.py +295 -0
- harness/skillkit/target.py +238 -0
- harness/task.py +717 -0
- py_harness_cli-0.3.0.dist-info/METADATA +177 -0
- py_harness_cli-0.3.0.dist-info/RECORD +92 -0
- py_harness_cli-0.3.0.dist-info/WHEEL +5 -0
- py_harness_cli-0.3.0.dist-info/entry_points.txt +3 -0
- py_harness_cli-0.3.0.dist-info/licenses/LICENSE +202 -0
- py_harness_cli-0.3.0.dist-info/licenses/NOTICE +6 -0
- py_harness_cli-0.3.0.dist-info/top_level.txt +2 -0
harness/editor_kit.py
ADDED
|
@@ -0,0 +1,265 @@
|
|
|
1
|
+
"""Copy drop-in editor settings into a project. No model."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
from harness.paths import REPO_ROOT
|
|
12
|
+
|
|
13
|
+
KINDS = ("vscode", "continue", "cursor", "zed")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def kit_dir() -> Path:
|
|
17
|
+
packaged = Path(__file__).resolve().parent / "kit_editors"
|
|
18
|
+
if packaged.is_dir():
|
|
19
|
+
return packaged
|
|
20
|
+
return REPO_ROOT / "editors"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def looks_like_vibe_checkout(project: Path) -> bool:
|
|
24
|
+
return (project / "src" / "harness" / "cli.py").is_file()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def install_editors(
|
|
28
|
+
project: Path,
|
|
29
|
+
kind: str,
|
|
30
|
+
*,
|
|
31
|
+
allow_writes: bool = False,
|
|
32
|
+
user_wide: bool = False,
|
|
33
|
+
) -> list[Path]:
|
|
34
|
+
"""Write the drop-in files for `kind`. Returns written paths."""
|
|
35
|
+
if kind not in KINDS:
|
|
36
|
+
raise ValueError(f"kind must be one of {', '.join(KINDS)}")
|
|
37
|
+
if user_wide and kind != "cursor":
|
|
38
|
+
raise ValueError("--global is only for kind cursor")
|
|
39
|
+
root = project.expanduser().resolve()
|
|
40
|
+
if not user_wide:
|
|
41
|
+
root.mkdir(parents=True, exist_ok=True)
|
|
42
|
+
written: list[Path] = []
|
|
43
|
+
if kind == "vscode":
|
|
44
|
+
dest = root / ".vscode" / "tasks.json"
|
|
45
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
46
|
+
dest.write_text(_merge_vscode_tasks(dest), encoding="utf-8")
|
|
47
|
+
written.append(dest)
|
|
48
|
+
return written
|
|
49
|
+
if kind == "continue":
|
|
50
|
+
dest = root / ".continue" / "config.yaml"
|
|
51
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
52
|
+
dest.write_text(
|
|
53
|
+
(kit_dir() / "vscode" / "continue.yaml").read_text(encoding="utf-8"),
|
|
54
|
+
encoding="utf-8",
|
|
55
|
+
)
|
|
56
|
+
written.append(dest)
|
|
57
|
+
return written
|
|
58
|
+
if kind == "zed":
|
|
59
|
+
dest = root / ".zed" / "settings.json"
|
|
60
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
61
|
+
dest.write_text(_zed_settings(root, dest), encoding="utf-8")
|
|
62
|
+
written.append(dest)
|
|
63
|
+
return written
|
|
64
|
+
mcp_root = Path.home() if user_wide else root
|
|
65
|
+
dest = mcp_root / ".cursor" / "mcp.json"
|
|
66
|
+
dest.parent.mkdir(parents=True, exist_ok=True)
|
|
67
|
+
dest.write_text(
|
|
68
|
+
_merge_mcp(
|
|
69
|
+
dest,
|
|
70
|
+
_cursor_server(
|
|
71
|
+
root, allow_writes=allow_writes, user_wide=user_wide
|
|
72
|
+
),
|
|
73
|
+
),
|
|
74
|
+
encoding="utf-8",
|
|
75
|
+
)
|
|
76
|
+
written.append(dest)
|
|
77
|
+
if not user_wide:
|
|
78
|
+
tasks = root / ".vscode" / "tasks.json"
|
|
79
|
+
tasks.parent.mkdir(parents=True, exist_ok=True)
|
|
80
|
+
tasks.write_text(_merge_vscode_tasks(tasks), encoding="utf-8")
|
|
81
|
+
written.append(tasks)
|
|
82
|
+
return written
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def next_steps(kind: str, *, allow_writes: bool = False, user_wide: bool = False) -> str:
|
|
86
|
+
"""What a person does after the files are written. Printed by the CLI."""
|
|
87
|
+
if kind != "cursor":
|
|
88
|
+
return (
|
|
89
|
+
"Reload the window, then Command Palette → Tasks: Run Task → "
|
|
90
|
+
"py-harness: ask"
|
|
91
|
+
)
|
|
92
|
+
writes = (
|
|
93
|
+
"read-write"
|
|
94
|
+
if allow_writes
|
|
95
|
+
else "read-only — re-run with --allow-writes to edit files"
|
|
96
|
+
)
|
|
97
|
+
where = "every workspace (~/.cursor/mcp.json)" if user_wide else "this folder"
|
|
98
|
+
return (
|
|
99
|
+
f"py-harness is set up for {where} ({writes}).\n"
|
|
100
|
+
"1. ollama pull llama3.1:8b\n"
|
|
101
|
+
"2. Command Palette → Developer: Reload Window\n"
|
|
102
|
+
"3. Open Customize → MCP → enable py-harness\n"
|
|
103
|
+
"4. In chat: ask py-harness what compute_total returns\n"
|
|
104
|
+
" or Tasks: Run Task → py-harness: ask\n"
|
|
105
|
+
"Do not point Override OpenAI Base URL at 127.0.0.1. "
|
|
106
|
+
"That request often leaves this machine."
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _vscode_tasks() -> dict:
|
|
111
|
+
"""Task file that runs whichever interpreter has py-harness installed.
|
|
112
|
+
|
|
113
|
+
The tasks used to call a bare `py-harness`. An editor runs a task in a
|
|
114
|
+
plain shell, and that command is only there if the install put it on
|
|
115
|
+
PATH, which a virtual environment or a --user install often does not.
|
|
116
|
+
Naming the interpreter directly works in every case.
|
|
117
|
+
"""
|
|
118
|
+
template = json.loads(
|
|
119
|
+
(kit_dir() / "vscode" / "tasks.json").read_text(encoding="utf-8")
|
|
120
|
+
)
|
|
121
|
+
runner = f'"{Path(sys.executable).as_posix()}" -m harness'
|
|
122
|
+
env = None if _harness_is_importable() else {
|
|
123
|
+
"PYTHONPATH": (REPO_ROOT / "src").as_posix()
|
|
124
|
+
}
|
|
125
|
+
for task in template["tasks"]:
|
|
126
|
+
task["command"] = task["command"].replace("__RUNNER__", runner)
|
|
127
|
+
if env:
|
|
128
|
+
task["options"] = {"env": env}
|
|
129
|
+
return template
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def _merge_vscode_tasks(dest: Path) -> str:
|
|
133
|
+
incoming = _vscode_tasks()
|
|
134
|
+
if not dest.is_file():
|
|
135
|
+
return json.dumps(incoming, indent=2) + "\n"
|
|
136
|
+
try:
|
|
137
|
+
data = json.loads(dest.read_text(encoding="utf-8") or "{}")
|
|
138
|
+
except json.JSONDecodeError:
|
|
139
|
+
data = {}
|
|
140
|
+
if not isinstance(data, dict):
|
|
141
|
+
data = {}
|
|
142
|
+
labels = {task.get("label") for task in incoming.get("tasks", [])}
|
|
143
|
+
kept = [
|
|
144
|
+
task
|
|
145
|
+
for task in data.get("tasks", [])
|
|
146
|
+
if isinstance(task, dict) and task.get("label") not in labels
|
|
147
|
+
]
|
|
148
|
+
data["version"] = incoming.get("version", data.get("version", "2.0.0"))
|
|
149
|
+
data["tasks"] = kept + incoming["tasks"]
|
|
150
|
+
incoming_inputs = incoming.get("inputs", [])
|
|
151
|
+
incoming_ids = {item.get("id") for item in incoming_inputs}
|
|
152
|
+
kept_inputs = [
|
|
153
|
+
item
|
|
154
|
+
for item in data.get("inputs", [])
|
|
155
|
+
if isinstance(item, dict) and item.get("id") not in incoming_ids
|
|
156
|
+
]
|
|
157
|
+
if incoming_inputs or kept_inputs:
|
|
158
|
+
data["inputs"] = kept_inputs + incoming_inputs
|
|
159
|
+
return json.dumps(data, indent=2) + "\n"
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def _harness_is_importable() -> bool:
|
|
163
|
+
"""True when a bare interpreter can `import harness` with no help.
|
|
164
|
+
|
|
165
|
+
An editor starts the server as a plain subprocess, without whatever
|
|
166
|
+
PYTHONPATH the person had set when they generated the file.
|
|
167
|
+
"""
|
|
168
|
+
probe = subprocess.run(
|
|
169
|
+
[sys.executable, "-c", "import harness"],
|
|
170
|
+
capture_output=True,
|
|
171
|
+
env={"PATH": os.environ.get("PATH", "")},
|
|
172
|
+
check=False,
|
|
173
|
+
)
|
|
174
|
+
return probe.returncode == 0
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def _stdio_server(project: Path) -> dict:
|
|
178
|
+
"""Command an editor will spawn. Absolute interpreter + project."""
|
|
179
|
+
server = {
|
|
180
|
+
"command": Path(sys.executable).as_posix(),
|
|
181
|
+
"args": ["-m", "harness", "mcp", "--project", project.as_posix()],
|
|
182
|
+
}
|
|
183
|
+
if not _harness_is_importable():
|
|
184
|
+
server["env"] = {"PYTHONPATH": (REPO_ROOT / "src").as_posix()}
|
|
185
|
+
return server
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _cursor_server(
|
|
189
|
+
project: Path, *, allow_writes: bool, user_wide: bool = False
|
|
190
|
+
) -> dict:
|
|
191
|
+
"""Portable Cursor MCP. Uses ${workspaceFolder} so the file can be shared.
|
|
192
|
+
|
|
193
|
+
Cursor interpolates that variable to the folder the person has open.
|
|
194
|
+
An absolute --project would bake in one machine and one folder.
|
|
195
|
+
"""
|
|
196
|
+
args = ["-m", "harness", "mcp", "--project", "${workspaceFolder}"]
|
|
197
|
+
if allow_writes:
|
|
198
|
+
args.append("--allow-writes")
|
|
199
|
+
server: dict = {
|
|
200
|
+
"type": "stdio",
|
|
201
|
+
"command": _cursor_command(project, user_wide=user_wide),
|
|
202
|
+
"args": args,
|
|
203
|
+
}
|
|
204
|
+
env = _cursor_env(project, user_wide=user_wide)
|
|
205
|
+
if env:
|
|
206
|
+
server["env"] = env
|
|
207
|
+
return server
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def _cursor_command(project: Path, *, user_wide: bool = False) -> str:
|
|
211
|
+
"""A name on PATH when that is enough. Else this process's interpreter."""
|
|
212
|
+
if user_wide:
|
|
213
|
+
return Path(sys.executable).as_posix()
|
|
214
|
+
if _harness_is_importable() or looks_like_vibe_checkout(project):
|
|
215
|
+
return "python3"
|
|
216
|
+
return Path(sys.executable).as_posix()
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def _cursor_env(project: Path, *, user_wide: bool = False) -> dict[str, str]:
|
|
220
|
+
if user_wide:
|
|
221
|
+
if _harness_is_importable():
|
|
222
|
+
return {}
|
|
223
|
+
return {"PYTHONPATH": (REPO_ROOT / "src").as_posix()}
|
|
224
|
+
if looks_like_vibe_checkout(project):
|
|
225
|
+
return {"PYTHONPATH": "${workspaceFolder}/src"}
|
|
226
|
+
if not _harness_is_importable():
|
|
227
|
+
return {"PYTHONPATH": (REPO_ROOT / "src").as_posix()}
|
|
228
|
+
return {}
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def _merge_mcp(dest: Path, server: dict) -> str:
|
|
232
|
+
"""Put py-harness in mcp.json. Keep every other server."""
|
|
233
|
+
data: dict = {}
|
|
234
|
+
if dest.is_file():
|
|
235
|
+
try:
|
|
236
|
+
loaded = json.loads(dest.read_text(encoding="utf-8") or "{}")
|
|
237
|
+
except json.JSONDecodeError:
|
|
238
|
+
loaded = {}
|
|
239
|
+
if isinstance(loaded, dict):
|
|
240
|
+
data = loaded
|
|
241
|
+
servers = data.setdefault("mcpServers", {})
|
|
242
|
+
if not isinstance(servers, dict):
|
|
243
|
+
servers = {}
|
|
244
|
+
data["mcpServers"] = servers
|
|
245
|
+
servers["py-harness"] = server
|
|
246
|
+
return json.dumps(data, indent=2) + "\n"
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
def _zed_settings(project: Path, dest: Path) -> str:
|
|
250
|
+
"""Merge py-harness into .zed/settings.json. Do not drop other keys."""
|
|
251
|
+
incoming = _stdio_server(project)
|
|
252
|
+
data: dict = {}
|
|
253
|
+
if dest.is_file():
|
|
254
|
+
try:
|
|
255
|
+
loaded = json.loads(dest.read_text(encoding="utf-8") or "{}")
|
|
256
|
+
except json.JSONDecodeError:
|
|
257
|
+
loaded = {}
|
|
258
|
+
if isinstance(loaded, dict):
|
|
259
|
+
data = loaded
|
|
260
|
+
servers = data.setdefault("context_servers", {})
|
|
261
|
+
if not isinstance(servers, dict):
|
|
262
|
+
servers = {}
|
|
263
|
+
data["context_servers"] = servers
|
|
264
|
+
servers["py-harness"] = incoming
|
|
265
|
+
return json.dumps(data, indent=2) + "\n"
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
"""Reject an action that has already been run with the same arguments.
|
|
2
|
+
|
|
3
|
+
A model that is unsure what to do next will often repeat its last search.
|
|
4
|
+
The tool returns the same output, the model is no better informed, and the
|
|
5
|
+
step budget is spent.
|
|
6
|
+
|
|
7
|
+
Only read-only actions are checked. Running the tests again after a change
|
|
8
|
+
is progress, not repetition, so `run` and `patch` are never rejected here.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from dataclasses import dataclass, field
|
|
14
|
+
|
|
15
|
+
EXPLORE = frozenset({"glob", "grep", "read", "map", "locate", "plan", "skill"})
|
|
16
|
+
_NEXT = {
|
|
17
|
+
"grep": "Action: read Path: one file from those hits.",
|
|
18
|
+
"glob": "Action: read Path: one file from that list.",
|
|
19
|
+
"map": "Action: grep Query: a symbol from the task.",
|
|
20
|
+
"locate": "Action: read Path: the file it named, or Action: done.",
|
|
21
|
+
"read": "Action: done with the answer, or Action: patch with a fix.",
|
|
22
|
+
"plan": "Take the first explore action now.",
|
|
23
|
+
"skill": "Copy the Action: block from the skill.",
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def turn_key(turn) -> tuple[str, ...]:
|
|
28
|
+
return (
|
|
29
|
+
turn.action,
|
|
30
|
+
turn.path.strip(),
|
|
31
|
+
turn.query.strip(),
|
|
32
|
+
turn.pattern.strip(),
|
|
33
|
+
turn.scope.strip(),
|
|
34
|
+
turn.name.strip(),
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass
|
|
39
|
+
class LoopGuard:
|
|
40
|
+
"""Remembers explore keys already served in this run."""
|
|
41
|
+
|
|
42
|
+
seen: set[tuple[str, ...]] = field(default_factory=set)
|
|
43
|
+
|
|
44
|
+
def check(self, turn) -> str:
|
|
45
|
+
if turn is None or turn.action not in EXPLORE:
|
|
46
|
+
return ""
|
|
47
|
+
key = turn_key(turn)
|
|
48
|
+
if key in self.seen:
|
|
49
|
+
hint = _NEXT.get(turn.action, "Take a different action.")
|
|
50
|
+
detail = turn.path or turn.query or turn.pattern or turn.name
|
|
51
|
+
return (
|
|
52
|
+
f"already ran that exact {turn.action}"
|
|
53
|
+
+ (f" ({detail})" if detail else "")
|
|
54
|
+
+ f". The result has not changed. {hint}"
|
|
55
|
+
)
|
|
56
|
+
self.seen.add(key)
|
|
57
|
+
return ""
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Deterministic guard for python-vibe drafts. No model. No network.
|
|
2
|
+
|
|
3
|
+
The fine-tune owns style. This only stops a few classes of output that
|
|
4
|
+
must not ship: empty, leaked secrets, pipe-to-shell, or a lesion diagnosis
|
|
5
|
+
(wrong surface — this is a coding harness).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import re
|
|
11
|
+
|
|
12
|
+
from harness.guard.types import Finding, Outcome
|
|
13
|
+
from harness.secrets import SECRET_SHAPES
|
|
14
|
+
|
|
15
|
+
RULESET_VERSION = "python-vibe-harness@0.1.0"
|
|
16
|
+
MAX_CHARS = 8000
|
|
17
|
+
|
|
18
|
+
_FLAGS = re.IGNORECASE
|
|
19
|
+
|
|
20
|
+
_RULES: tuple[tuple[str, str, re.Pattern[str]], ...] = (
|
|
21
|
+
(
|
|
22
|
+
"PV001",
|
|
23
|
+
"block",
|
|
24
|
+
re.compile(r"^\s*$"),
|
|
25
|
+
),
|
|
26
|
+
(
|
|
27
|
+
"PV002",
|
|
28
|
+
"block",
|
|
29
|
+
re.compile(
|
|
30
|
+
"|".join(f"(?:{p.pattern})" for _name, p in SECRET_SHAPES)
|
|
31
|
+
),
|
|
32
|
+
),
|
|
33
|
+
(
|
|
34
|
+
"PV003",
|
|
35
|
+
"block",
|
|
36
|
+
re.compile(r"(curl|wget)\s+[^\n|]{0,200}\|\s*(?:ba)?sh\b", _FLAGS),
|
|
37
|
+
),
|
|
38
|
+
(
|
|
39
|
+
"PV004",
|
|
40
|
+
"block",
|
|
41
|
+
re.compile(
|
|
42
|
+
r"(\b(this|that|it)(?:'s|\s+is)\s+(a\s+|an\s+)?"
|
|
43
|
+
r"(melanoma|basal cell carcinoma|skin cancer)\b"
|
|
44
|
+
r"|\byou have\s+(a\s+|an\s+)?(melanoma|skin cancer)\b)",
|
|
45
|
+
_FLAGS,
|
|
46
|
+
),
|
|
47
|
+
),
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
class PythonVibeGuard:
|
|
52
|
+
def review(self, text: str) -> Outcome:
|
|
53
|
+
findings: list[Finding] = []
|
|
54
|
+
if len(text) > MAX_CHARS:
|
|
55
|
+
findings.append(
|
|
56
|
+
Finding("PV005", "block", f"output length {len(text)} > {MAX_CHARS}")
|
|
57
|
+
)
|
|
58
|
+
for rule_id, severity, pattern in _RULES:
|
|
59
|
+
match = pattern.search(text)
|
|
60
|
+
if match:
|
|
61
|
+
findings.append(Finding(rule_id, severity, match.group(0)[:80]))
|
|
62
|
+
if findings:
|
|
63
|
+
return Outcome("block", None, tuple(findings), RULESET_VERSION)
|
|
64
|
+
return Outcome("pass", text, (), RULESET_VERSION)
|
|
65
|
+
|
|
66
|
+
def check(self, text: str, red_flags: list[str] | None = None) -> Outcome:
|
|
67
|
+
del red_flags
|
|
68
|
+
return self.review(text)
|
harness/guard/run.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Generate → guard → regenerate once → fixed fallback.
|
|
2
|
+
|
|
3
|
+
The guard never edits its way out of a block.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
from collections.abc import Callable
|
|
9
|
+
from typing import Protocol
|
|
10
|
+
|
|
11
|
+
from harness.guard.types import Outcome
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Guard(Protocol):
|
|
15
|
+
def check(self, text: str, red_flags: list[str] | None = None) -> Outcome: ...
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def complete(
|
|
19
|
+
generate: Callable[[str], str],
|
|
20
|
+
guard: Guard,
|
|
21
|
+
fallback: str,
|
|
22
|
+
prompt: str,
|
|
23
|
+
*,
|
|
24
|
+
red_flags: list[str] | None = None,
|
|
25
|
+
attempts: int = 2,
|
|
26
|
+
) -> Outcome:
|
|
27
|
+
last: Outcome | None = None
|
|
28
|
+
for _ in range(attempts):
|
|
29
|
+
draft = generate(prompt)
|
|
30
|
+
outcome = guard.check(draft, red_flags)
|
|
31
|
+
if outcome.output is not None:
|
|
32
|
+
return outcome
|
|
33
|
+
last = outcome
|
|
34
|
+
assert last is not None
|
|
35
|
+
return Outcome(
|
|
36
|
+
verdict="block",
|
|
37
|
+
output=fallback,
|
|
38
|
+
findings=last.findings,
|
|
39
|
+
ruleset_version=last.ruleset_version,
|
|
40
|
+
fallback=True,
|
|
41
|
+
)
|
harness/guard/types.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
@dataclass(frozen=True)
|
|
7
|
+
class Finding:
|
|
8
|
+
rule_id: str
|
|
9
|
+
severity: str
|
|
10
|
+
excerpt: str
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class Outcome:
|
|
15
|
+
verdict: str
|
|
16
|
+
output: str | None
|
|
17
|
+
findings: tuple[Finding, ...]
|
|
18
|
+
ruleset_version: str
|
|
19
|
+
fallback: bool = False
|