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/agent/prompt.py
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
"""Build the first prompt sent to the model.
|
|
2
|
+
|
|
3
|
+
The prompt is assembled from the project summary, the project's own
|
|
4
|
+
contributor instructions, the list of available skills, the skills selected
|
|
5
|
+
for this task, and anything the harness looked up before the model started.
|
|
6
|
+
|
|
7
|
+
There is one builder so that the agent loop, the skill probe and the HTTP
|
|
8
|
+
server all send the same prompt. These were previously written out
|
|
9
|
+
separately and had drifted apart, which meant measurements taken with the
|
|
10
|
+
probe did not describe the prompt the agent actually used.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
from harness.act.autofix import apply_mechanical, apply_package_scaffold
|
|
19
|
+
from harness.agent.options import AgentOptions
|
|
20
|
+
from harness.locate import prelude, signature_line
|
|
21
|
+
from harness.scan.existing import already_covers, existing_files
|
|
22
|
+
from harness.scan.project_brief import (
|
|
23
|
+
ProjectBrief,
|
|
24
|
+
classify_project,
|
|
25
|
+
render_brief,
|
|
26
|
+
start_hint,
|
|
27
|
+
)
|
|
28
|
+
from harness.scan.project_docs import render_house_rules
|
|
29
|
+
from harness.ship.git_ship import read_ticket
|
|
30
|
+
from harness.skillkit.catalog import (
|
|
31
|
+
Skill,
|
|
32
|
+
get_skill,
|
|
33
|
+
list_skills,
|
|
34
|
+
pick_skills,
|
|
35
|
+
render_catalog,
|
|
36
|
+
render_skill,
|
|
37
|
+
)
|
|
38
|
+
from harness.skillkit.target import Target, pick_target, retarget
|
|
39
|
+
from harness.task import issue_number, looks_like_pr_ref, question_symbol
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@dataclass(frozen=True)
|
|
43
|
+
class Preamble:
|
|
44
|
+
"""Everything the loop learned before the model's first turn."""
|
|
45
|
+
|
|
46
|
+
prompt: str
|
|
47
|
+
brief: ProjectBrief
|
|
48
|
+
target: Target
|
|
49
|
+
system: str = ""
|
|
50
|
+
catalog: tuple[Skill, ...] = ()
|
|
51
|
+
skills: tuple[Skill, ...] = ()
|
|
52
|
+
located_path: str = ""
|
|
53
|
+
located_signature: str = ""
|
|
54
|
+
pre_text: str = ""
|
|
55
|
+
autofix: str = ""
|
|
56
|
+
notes: tuple[str, ...] = field(default_factory=tuple)
|
|
57
|
+
existing_paths: tuple[str, ...] = ()
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def choose_skills(
|
|
61
|
+
project: Path, task: str, brief: ProjectBrief, names: tuple[str, ...]
|
|
62
|
+
) -> list[Skill]:
|
|
63
|
+
"""Explicit `--skill` names win; otherwise the task picks."""
|
|
64
|
+
catalog = list_skills(project)
|
|
65
|
+
if names:
|
|
66
|
+
chosen = []
|
|
67
|
+
for name in names:
|
|
68
|
+
found = get_skill(name, project)
|
|
69
|
+
if found is None:
|
|
70
|
+
raise ValueError(f"unknown skill: {name}")
|
|
71
|
+
chosen.append(found)
|
|
72
|
+
return chosen
|
|
73
|
+
chosen = pick_skills(task, catalog)
|
|
74
|
+
if brief.kind == "large":
|
|
75
|
+
extra = get_skill("stay-scoped", project)
|
|
76
|
+
if extra and extra.name not in {item.name for item in chosen}:
|
|
77
|
+
chosen.append(extra)
|
|
78
|
+
return chosen
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _append_note(pre_text: str, notes: list[str], extra: str) -> str:
|
|
82
|
+
if not extra:
|
|
83
|
+
return pre_text
|
|
84
|
+
notes.append(extra)
|
|
85
|
+
return f"{pre_text}\n\n{extra}" if pre_text else extra
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _first_look(
|
|
89
|
+
project: Path, task: str, scope: str, allow_writes: bool
|
|
90
|
+
) -> tuple[str, str, str, str]:
|
|
91
|
+
"""Scaffold, prelude, mechanical bind before the model starts."""
|
|
92
|
+
scaffold = apply_package_scaffold(project, task, write=allow_writes)
|
|
93
|
+
pre_text, located_path = prelude(project, task, scope)
|
|
94
|
+
autofix = apply_mechanical(project, task, located_path, write=allow_writes)
|
|
95
|
+
if autofix and allow_writes:
|
|
96
|
+
pre_text, located_path = prelude(project, task, scope)
|
|
97
|
+
for extra in (autofix, scaffold):
|
|
98
|
+
if extra:
|
|
99
|
+
pre_text = f"{pre_text}\n\n{extra}" if pre_text else extra
|
|
100
|
+
sig = signature_line(pre_text, question_symbol(task)) if pre_text else ""
|
|
101
|
+
return pre_text, located_path, sig, autofix
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def build_preamble(options: AgentOptions) -> Preamble:
|
|
105
|
+
project = options.resolved_project()
|
|
106
|
+
task = options.task
|
|
107
|
+
pre_text, located_path, located_signature, autofix = _first_look(
|
|
108
|
+
project, task, options.scope, options.allow_writes
|
|
109
|
+
)
|
|
110
|
+
brief = classify_project(project, options.scope)
|
|
111
|
+
catalog = list_skills(project)
|
|
112
|
+
notes: list[str] = []
|
|
113
|
+
if pre_text:
|
|
114
|
+
notes.append(pre_text)
|
|
115
|
+
|
|
116
|
+
# What the project already has for this. A run once wrote its own
|
|
117
|
+
# worse copy of a check that was three files away, because nothing
|
|
118
|
+
# said so.
|
|
119
|
+
covered = already_covers(project, task, skip=located_path)
|
|
120
|
+
named_existing = existing_files(project, task, skip=located_path)
|
|
121
|
+
pre_text = _append_note(pre_text, notes, covered)
|
|
122
|
+
|
|
123
|
+
ticket = issue_number(task)
|
|
124
|
+
if ticket:
|
|
125
|
+
prefer = "pr" if looks_like_pr_ref(task) else "issue"
|
|
126
|
+
block = (
|
|
127
|
+
f"Harness ticket #{ticket}\n"
|
|
128
|
+
f"{read_ticket(project, ticket, prefer=prefer)}"
|
|
129
|
+
)
|
|
130
|
+
pre_text = _append_note(pre_text, notes, block)
|
|
131
|
+
|
|
132
|
+
skills = choose_skills(project, task, brief, options.skills)
|
|
133
|
+
target = pick_target(project, task, options.scope, located_path)
|
|
134
|
+
skill_block = ""
|
|
135
|
+
if skills:
|
|
136
|
+
skill_block = (
|
|
137
|
+
"\n\n".join(render_skill(item, target, project) for item in skills)
|
|
138
|
+
+ "\n\n"
|
|
139
|
+
)
|
|
140
|
+
house = render_house_rules(project)
|
|
141
|
+
read_only = (
|
|
142
|
+
"" if options.allow_writes
|
|
143
|
+
else "This run is read-only. Do not patch, edit, or run. Answer instead.\n"
|
|
144
|
+
)
|
|
145
|
+
prompt = (
|
|
146
|
+
f"{render_brief(brief, scope=options.scope)}\n\n"
|
|
147
|
+
+ (f"{house}\n\n" if house else "")
|
|
148
|
+
+ f"{render_catalog(catalog)}\n\n"
|
|
149
|
+
+ skill_block
|
|
150
|
+
+ (f"{pre_text}\n\n" if pre_text else "")
|
|
151
|
+
+ f"Project root: {project}\n"
|
|
152
|
+
+ (f"Scope: {options.scope}\n" if options.scope else "")
|
|
153
|
+
+ read_only
|
|
154
|
+
+ f"Task: {task}\n"
|
|
155
|
+
+ start_hint(brief, task, located=bool(located_path))
|
|
156
|
+
)
|
|
157
|
+
return Preamble(
|
|
158
|
+
prompt=prompt,
|
|
159
|
+
system=retarget(options.system, target),
|
|
160
|
+
brief=brief,
|
|
161
|
+
target=target,
|
|
162
|
+
catalog=tuple(catalog),
|
|
163
|
+
skills=tuple(skills),
|
|
164
|
+
located_path=located_path,
|
|
165
|
+
located_signature=located_signature,
|
|
166
|
+
pre_text=pre_text,
|
|
167
|
+
autofix=autofix,
|
|
168
|
+
notes=tuple(notes),
|
|
169
|
+
existing_paths=named_existing,
|
|
170
|
+
)
|
harness/cli.py
ADDED
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
"""Command line interface for the harness.
|
|
2
|
+
|
|
3
|
+
One command with subcommands, so a user does not have to know which file in
|
|
4
|
+
`scripts/` to run:
|
|
5
|
+
|
|
6
|
+
python -m harness brief ~/app
|
|
7
|
+
python -m harness layout ~/app
|
|
8
|
+
python -m harness ask ~/app "what does compute_total return?"
|
|
9
|
+
python -m harness run ~/app "add multiply(a, b) and a test"
|
|
10
|
+
python -m harness serve --project ~/app
|
|
11
|
+
python -m harness mcp --project ~/app
|
|
12
|
+
python -m harness editors cursor --allow-writes
|
|
13
|
+
python -m harness commit ~/app "why the change landed"
|
|
14
|
+
python -m harness route "what does compute_total return?"
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import argparse
|
|
20
|
+
import json
|
|
21
|
+
import sys
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
from harness.agent import Agent, AgentOptions
|
|
25
|
+
from harness.agent.options import DEFAULT_MAX_TOKENS, DEFAULT_STEPS
|
|
26
|
+
from harness.scan.layout import render_layout
|
|
27
|
+
from harness.scan.project_brief import classify_project, render_brief_for_person
|
|
28
|
+
from harness.skillkit.catalog import list_skills
|
|
29
|
+
|
|
30
|
+
# The everyday jobs. Extra commands exist; this is what people should
|
|
31
|
+
# type first. {prog} is filled in from how the tool was started: the
|
|
32
|
+
# installed command is not always on PATH, and printing it when it is
|
|
33
|
+
# not sends a first-time user to a command that does not exist.
|
|
34
|
+
HOW_TO = """\
|
|
35
|
+
{prog} — four jobs, on this machine.
|
|
36
|
+
|
|
37
|
+
{prog} brief
|
|
38
|
+
{prog} ask "what does compute_total return?"
|
|
39
|
+
{prog} run "write tests for apply_discount"
|
|
40
|
+
{prog} run "find the NameError and fix it"
|
|
41
|
+
{prog} run "add a function total_lines and a test"
|
|
42
|
+
|
|
43
|
+
Run those inside your project folder. To point at another folder:
|
|
44
|
+
|
|
45
|
+
{prog} ask /path/to/project "what does compute_total return?"
|
|
46
|
+
|
|
47
|
+
ask never writes. Daily run writes, then runs the suite; a failing
|
|
48
|
+
traceback goes back to the model once. Unique-typo NameError and a
|
|
49
|
+
template add are harness demos on demo/orders — they finish with no model.
|
|
50
|
+
From this checkout: {prog} brief demo/orders
|
|
51
|
+
More commands: {prog} --help
|
|
52
|
+
"""
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def how_to() -> str:
|
|
56
|
+
"""The short list, naming the command this machine can actually run."""
|
|
57
|
+
return HOW_TO.format(prog=_program_name())
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def resolve_project_task(first: str, second: str | None) -> tuple[Path, str]:
|
|
61
|
+
"""`ask DIR TASK` or `ask TASK` (DIR is the current folder)."""
|
|
62
|
+
if second:
|
|
63
|
+
return Path(first).expanduser().resolve(), second
|
|
64
|
+
path = Path(first).expanduser()
|
|
65
|
+
if path.exists() and path.is_dir() and " " not in first.strip():
|
|
66
|
+
return path.resolve(), ""
|
|
67
|
+
return Path(".").resolve(), first
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def _printer(verbose: bool):
|
|
71
|
+
def emit(kind: str, text: str) -> None:
|
|
72
|
+
if not text:
|
|
73
|
+
return
|
|
74
|
+
if kind == "draft":
|
|
75
|
+
print(f"\n{text}\n", flush=True)
|
|
76
|
+
elif verbose or kind in {"refused", "engine"}:
|
|
77
|
+
print(text[:2000], file=sys.stderr)
|
|
78
|
+
|
|
79
|
+
return emit
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _prompt_user(question) -> str:
|
|
83
|
+
"""The agent asked. Put it to the person actually sitting here."""
|
|
84
|
+
print(f"\n{question.render()}", file=sys.stderr)
|
|
85
|
+
try:
|
|
86
|
+
answer = input("> ").strip()
|
|
87
|
+
except EOFError:
|
|
88
|
+
return ""
|
|
89
|
+
if question.options and answer.isdigit():
|
|
90
|
+
index = int(answer) - 1
|
|
91
|
+
if 0 <= index < len(question.options):
|
|
92
|
+
return question.options[index]
|
|
93
|
+
return answer
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def _options(args, *, interactive: bool) -> AgentOptions:
|
|
97
|
+
return AgentOptions(
|
|
98
|
+
project=args.project,
|
|
99
|
+
task=getattr(args, "task", "") or "",
|
|
100
|
+
model=args.model,
|
|
101
|
+
engine=args.engine,
|
|
102
|
+
scope=args.scope,
|
|
103
|
+
skills=tuple(args.skill or ()),
|
|
104
|
+
steps=args.steps,
|
|
105
|
+
max_tokens=args.max_tokens,
|
|
106
|
+
allow_writes=getattr(args, "allow_writes", True),
|
|
107
|
+
record=getattr(args, "record", None),
|
|
108
|
+
keep_no_record=getattr(args, "no_record", False),
|
|
109
|
+
on_event=_printer(args.verbose),
|
|
110
|
+
on_question=_prompt_user if interactive else None,
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def _add_agent_flags(parser: argparse.ArgumentParser) -> None:
|
|
115
|
+
parser.add_argument("--scope", default="", help="work only inside this folder")
|
|
116
|
+
parser.add_argument("--skill", action="append", default=[], metavar="NAME")
|
|
117
|
+
parser.add_argument("--model", default=AgentOptions(project=Path(".")).model)
|
|
118
|
+
parser.add_argument(
|
|
119
|
+
"--engine",
|
|
120
|
+
default="ollama",
|
|
121
|
+
help="ollama (local or OLLAMA_HOST), mlx, or openai (remote weights)",
|
|
122
|
+
)
|
|
123
|
+
parser.add_argument("--steps", type=int, default=DEFAULT_STEPS)
|
|
124
|
+
parser.add_argument("--max-tokens", type=int, default=DEFAULT_MAX_TOKENS)
|
|
125
|
+
parser.add_argument(
|
|
126
|
+
"--record",
|
|
127
|
+
type=Path,
|
|
128
|
+
help="write turns here instead of .python-vibe/traces.jsonl",
|
|
129
|
+
)
|
|
130
|
+
parser.add_argument(
|
|
131
|
+
"--no-record",
|
|
132
|
+
action="store_true",
|
|
133
|
+
help="write no trace of this run",
|
|
134
|
+
)
|
|
135
|
+
parser.add_argument("-v", "--verbose", action="store_true")
|
|
136
|
+
parser.add_argument("--json", action="store_true", help="print the result as JSON")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _program_name() -> str:
|
|
140
|
+
"""What to print in usage: the installed command, or the module form."""
|
|
141
|
+
name = Path(sys.argv[0]).name
|
|
142
|
+
if name.startswith("py-harness"):
|
|
143
|
+
return "py-harness"
|
|
144
|
+
if name.startswith("python-vibe"):
|
|
145
|
+
return "python-vibe"
|
|
146
|
+
return "python -m harness"
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
150
|
+
parser = argparse.ArgumentParser(
|
|
151
|
+
prog=_program_name(),
|
|
152
|
+
description="Four everyday Python jobs, on this machine.",
|
|
153
|
+
epilog="Run py-harness with no arguments for the short how-to.",
|
|
154
|
+
)
|
|
155
|
+
subs = parser.add_subparsers(dest="command", required=False)
|
|
156
|
+
|
|
157
|
+
brief = subs.add_parser("brief", help="summarise this folder. Needs no AI model.")
|
|
158
|
+
brief.add_argument("project", nargs="?", default=".", type=Path)
|
|
159
|
+
brief.add_argument("--scope", default="")
|
|
160
|
+
|
|
161
|
+
layout = subs.add_parser("layout", help="report what makes a project hard to read. Needs no AI model.")
|
|
162
|
+
layout.add_argument("project", nargs="?", default=".", type=Path)
|
|
163
|
+
|
|
164
|
+
route = subs.add_parser(
|
|
165
|
+
"route", help="which local model suits a task. Needs no AI model."
|
|
166
|
+
)
|
|
167
|
+
route.add_argument("task")
|
|
168
|
+
|
|
169
|
+
ask = subs.add_parser("ask", help="answer a question. Changes nothing.")
|
|
170
|
+
ask.add_argument("first", help="the question, or a folder then the question")
|
|
171
|
+
ask.add_argument("second", nargs="?", default="", help="the question, when the first argument is a folder")
|
|
172
|
+
_add_agent_flags(ask)
|
|
173
|
+
|
|
174
|
+
run = subs.add_parser("run", help="write tests, fix a bug, or add one small function")
|
|
175
|
+
run.add_argument("first", help="what to do, or a folder then what to do")
|
|
176
|
+
run.add_argument("second", nargs="?", default="", help="the task, when the first argument is a folder")
|
|
177
|
+
run.add_argument(
|
|
178
|
+
"--dry-run",
|
|
179
|
+
dest="allow_writes",
|
|
180
|
+
action="store_false",
|
|
181
|
+
help="say what it would change, without changing anything",
|
|
182
|
+
)
|
|
183
|
+
_add_agent_flags(run)
|
|
184
|
+
|
|
185
|
+
serve = subs.add_parser("serve", help="serve on this machine only. Changes nothing unless --allow-writes.")
|
|
186
|
+
serve.add_argument("--project", type=Path, required=True)
|
|
187
|
+
serve.add_argument("--port", type=int, default=8090)
|
|
188
|
+
serve.add_argument(
|
|
189
|
+
"--allow-writes",
|
|
190
|
+
action="store_true",
|
|
191
|
+
help="allow callers to change files inside --project",
|
|
192
|
+
)
|
|
193
|
+
serve.add_argument("--model", default=AgentOptions(project=Path(".")).model)
|
|
194
|
+
|
|
195
|
+
mcp = subs.add_parser(
|
|
196
|
+
"mcp",
|
|
197
|
+
help="let an editor call py-harness. Changes nothing unless --allow-writes.",
|
|
198
|
+
)
|
|
199
|
+
mcp.add_argument("--project", type=Path, required=True)
|
|
200
|
+
mcp.add_argument("--allow-writes", action="store_true")
|
|
201
|
+
mcp.add_argument("--model", default=AgentOptions(project=Path(".")).model)
|
|
202
|
+
|
|
203
|
+
last = subs.add_parser(
|
|
204
|
+
"last", help="show the latest recorded turns. Needs no AI model."
|
|
205
|
+
)
|
|
206
|
+
last.add_argument("project", nargs="?", default=".", type=Path)
|
|
207
|
+
|
|
208
|
+
commit = subs.add_parser(
|
|
209
|
+
"commit",
|
|
210
|
+
help="record current changes. You stay the author; python-vibe is co-author.",
|
|
211
|
+
)
|
|
212
|
+
commit.add_argument("project", type=Path)
|
|
213
|
+
commit.add_argument("summary", help="why, not what (at least 8 characters)")
|
|
214
|
+
|
|
215
|
+
editors = subs.add_parser(
|
|
216
|
+
"editors",
|
|
217
|
+
help="write ready-made editor settings into a project",
|
|
218
|
+
)
|
|
219
|
+
editors.add_argument("kind", choices=("vscode", "continue", "cursor", "zed"))
|
|
220
|
+
editors.add_argument(
|
|
221
|
+
"--project",
|
|
222
|
+
type=Path,
|
|
223
|
+
default=Path("."),
|
|
224
|
+
help="folder it may change (default: current directory)",
|
|
225
|
+
)
|
|
226
|
+
editors.add_argument(
|
|
227
|
+
"--allow-writes",
|
|
228
|
+
action="store_true",
|
|
229
|
+
help="let the editor's run tool change files (cursor MCP only)",
|
|
230
|
+
)
|
|
231
|
+
editors.add_argument(
|
|
232
|
+
"--global",
|
|
233
|
+
dest="user_wide",
|
|
234
|
+
action="store_true",
|
|
235
|
+
help="merge into ~/.cursor/mcp.json so every workspace can call it",
|
|
236
|
+
)
|
|
237
|
+
return parser
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def _run_brief(args) -> int:
|
|
241
|
+
project = args.project.expanduser().resolve()
|
|
242
|
+
print(
|
|
243
|
+
render_brief_for_person(
|
|
244
|
+
classify_project(project, args.scope), scope=args.scope
|
|
245
|
+
)
|
|
246
|
+
)
|
|
247
|
+
# The full catalogue is written for the model, in the model's own
|
|
248
|
+
# syntax. Printing it here buries the answer the person asked for.
|
|
249
|
+
count = len(list_skills(project))
|
|
250
|
+
print()
|
|
251
|
+
print(
|
|
252
|
+
f"py-harness has {count} skills it can apply. It picks them from "
|
|
253
|
+
"the wording of your task; you do not choose them."
|
|
254
|
+
)
|
|
255
|
+
return 0
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def _run_layout(args) -> int:
|
|
259
|
+
print(render_layout(args.project.expanduser().resolve()))
|
|
260
|
+
return 0
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _run_route(args) -> int:
|
|
264
|
+
from harness import route_advice
|
|
265
|
+
|
|
266
|
+
print(route_advice(args.task))
|
|
267
|
+
return 0
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def _run_serve(args) -> int:
|
|
271
|
+
from harness.server import serve
|
|
272
|
+
|
|
273
|
+
return serve(
|
|
274
|
+
args.project.expanduser().resolve(),
|
|
275
|
+
port=args.port,
|
|
276
|
+
allow_writes=args.allow_writes,
|
|
277
|
+
model=args.model,
|
|
278
|
+
)
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def _run_mcp(args) -> int:
|
|
282
|
+
from harness.mcp_stdio import serve_stdio
|
|
283
|
+
|
|
284
|
+
return serve_stdio(
|
|
285
|
+
args.project.expanduser().resolve(),
|
|
286
|
+
allow_writes=args.allow_writes,
|
|
287
|
+
model=args.model,
|
|
288
|
+
)
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _run_last(args) -> int:
|
|
292
|
+
from harness.observe.trace_record import render_last
|
|
293
|
+
|
|
294
|
+
print(render_last(args.project.expanduser().resolve()))
|
|
295
|
+
return 0
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _run_commit(args) -> int:
|
|
299
|
+
from harness.ship.git_ship import commit_changes
|
|
300
|
+
|
|
301
|
+
print(commit_changes(args.project.expanduser().resolve(), args.summary))
|
|
302
|
+
return 0
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _run_editors(args) -> int:
|
|
306
|
+
from harness.editor_kit import install_editors, next_steps
|
|
307
|
+
|
|
308
|
+
try:
|
|
309
|
+
written = install_editors(
|
|
310
|
+
args.project,
|
|
311
|
+
args.kind,
|
|
312
|
+
allow_writes=getattr(args, "allow_writes", False),
|
|
313
|
+
user_wide=getattr(args, "user_wide", False),
|
|
314
|
+
)
|
|
315
|
+
except ValueError as exc:
|
|
316
|
+
print(str(exc), file=sys.stderr)
|
|
317
|
+
return 2
|
|
318
|
+
for path in written:
|
|
319
|
+
print(path)
|
|
320
|
+
print()
|
|
321
|
+
print(
|
|
322
|
+
next_steps(
|
|
323
|
+
args.kind,
|
|
324
|
+
allow_writes=getattr(args, "allow_writes", False),
|
|
325
|
+
user_wide=getattr(args, "user_wide", False),
|
|
326
|
+
)
|
|
327
|
+
)
|
|
328
|
+
return 0
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def _run_agent(args) -> int:
|
|
332
|
+
"""ask and run: the two commands that call the model."""
|
|
333
|
+
interactive = sys.stdin.isatty()
|
|
334
|
+
if args.command == "ask":
|
|
335
|
+
args.allow_writes = False
|
|
336
|
+
try:
|
|
337
|
+
result = Agent(_options(args, interactive=interactive)).run()
|
|
338
|
+
except ValueError as exc:
|
|
339
|
+
print(str(exc), file=sys.stderr)
|
|
340
|
+
return 2
|
|
341
|
+
if args.json:
|
|
342
|
+
print(json.dumps(result.as_dict(), indent=2))
|
|
343
|
+
else:
|
|
344
|
+
print(result.summary)
|
|
345
|
+
if args.command == "run" and not getattr(args, "no_record", False):
|
|
346
|
+
from harness.observe.trace_record import default_trace_path
|
|
347
|
+
|
|
348
|
+
dest = getattr(args, "record", None) or default_trace_path(args.project)
|
|
349
|
+
print(f"recorded {len(result.steps)} turns in {dest}", file=sys.stderr)
|
|
350
|
+
return 0 if result.ok else 1
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# One entry per subcommand. A chain of nine `if args.command ==` tests
|
|
354
|
+
# said the same thing at three times the length, and adding a command
|
|
355
|
+
# meant finding the right place in the middle of it.
|
|
356
|
+
COMMANDS = {
|
|
357
|
+
"brief": _run_brief,
|
|
358
|
+
"layout": _run_layout,
|
|
359
|
+
"route": _run_route,
|
|
360
|
+
"serve": _run_serve,
|
|
361
|
+
"mcp": _run_mcp,
|
|
362
|
+
"last": _run_last,
|
|
363
|
+
"commit": _run_commit,
|
|
364
|
+
"editors": _run_editors,
|
|
365
|
+
"ask": _run_agent,
|
|
366
|
+
"run": _run_agent,
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def _missing_task_message(command: str) -> str:
|
|
371
|
+
"""What to print when ask or run was given no words to work on."""
|
|
372
|
+
wanted = "a question" if command == "ask" else "what to change"
|
|
373
|
+
return (
|
|
374
|
+
f"{command} needs {wanted}, for example:\n"
|
|
375
|
+
f' {_program_name()} {command} "what does add return?"'
|
|
376
|
+
)
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def main(argv: list[str] | None = None) -> int:
|
|
380
|
+
args = build_parser().parse_args(argv)
|
|
381
|
+
if not args.command:
|
|
382
|
+
print(how_to(), end="")
|
|
383
|
+
return 0
|
|
384
|
+
|
|
385
|
+
if args.command in {"ask", "run"}:
|
|
386
|
+
project, task = resolve_project_task(args.first, args.second or None)
|
|
387
|
+
if not task.strip():
|
|
388
|
+
print(_missing_task_message(args.command), file=sys.stderr)
|
|
389
|
+
return 2
|
|
390
|
+
args.project = project
|
|
391
|
+
args.task = task
|
|
392
|
+
|
|
393
|
+
return COMMANDS[args.command](args)
|