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,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)