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/ship/identity.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""The GitHub identity python-vibe adds when it is used.
|
|
2
|
+
|
|
3
|
+
The person who ran the tool stays the commit author. When the harness
|
|
4
|
+
writes a commit or opens a pull request, it adds this account as
|
|
5
|
+
co-author so GitHub shows it on that change. The account is created
|
|
6
|
+
once at github.com/signup; this module only writes the trailer.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
|
|
14
|
+
CO_AUTHOR_LOGIN = "python-vibe"
|
|
15
|
+
CO_AUTHOR_NAME = "python-vibe"
|
|
16
|
+
# github.com/python-vibe, created 29 Aug 2026. The numbered noreply form
|
|
17
|
+
# is what GitHub links on a Co-authored-by trailer.
|
|
18
|
+
CO_AUTHOR_ID = 322567521
|
|
19
|
+
CO_AUTHOR_EMAIL = f"{CO_AUTHOR_ID}+{CO_AUTHOR_LOGIN}@users.noreply.github.com"
|
|
20
|
+
CO_AUTHOR_URL = "https://github.com/python-vibe"
|
|
21
|
+
# Override only if the account's noreply address changes.
|
|
22
|
+
# export PYTHON_VIBE_COAUTHOR="python-vibe <322567521+python-vibe@users.noreply.github.com>"
|
|
23
|
+
COAUTHOR_ENV = "PYTHON_VIBE_COAUTHOR"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def co_author_line() -> str:
|
|
27
|
+
"""The trailer written on a commit python-vibe made.
|
|
28
|
+
|
|
29
|
+
Casing does not decide whether GitHub links it; owning the address
|
|
30
|
+
does. The default address is the live github.com/python-vibe account.
|
|
31
|
+
"""
|
|
32
|
+
identity = os.environ.get(COAUTHOR_ENV, "").strip()
|
|
33
|
+
if identity:
|
|
34
|
+
return f"Co-authored-by: {identity}"
|
|
35
|
+
return f"Co-authored-by: {CO_AUTHOR_NAME} <{CO_AUTHOR_EMAIL}>"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def co_author_email() -> str:
|
|
39
|
+
identity = os.environ.get(COAUTHOR_ENV, "").strip()
|
|
40
|
+
match = re.search(r"<([^>]+)>", identity) if identity else None
|
|
41
|
+
return match.group(1) if match else CO_AUTHOR_EMAIL
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def co_author_links() -> bool:
|
|
45
|
+
"""Whether GitHub can turn the trailer into a linked account.
|
|
46
|
+
|
|
47
|
+
A `users.noreply.github.com` address links only with the account's
|
|
48
|
+
numeric id in front of the name. The bare form was issued to older
|
|
49
|
+
accounts and links only if that account still holds it.
|
|
50
|
+
"""
|
|
51
|
+
email = co_author_email()
|
|
52
|
+
if not email.endswith("@users.noreply.github.com"):
|
|
53
|
+
return "@" in email and "." in email.split("@")[-1]
|
|
54
|
+
return bool(re.match(r"\d+\+", email))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def with_co_author(message: str) -> str:
|
|
58
|
+
"""Append the trailer once, when python-vibe made the commit."""
|
|
59
|
+
text = message.rstrip()
|
|
60
|
+
if "co-authored-by: python-vibe" in text.lower():
|
|
61
|
+
return text
|
|
62
|
+
return f"{text}\n\n{co_author_line()}\n"
|
harness/ship/ticket.py
ADDED
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
"""Turn a GitHub issue or pull request into something to act on.
|
|
2
|
+
|
|
3
|
+
Reading a ticket is not the same as knowing what to do with it. A ticket
|
|
4
|
+
names files, functions and a list of things to finish, and all three are
|
|
5
|
+
buried in prose. This pulls out the parts that point at code, checks them
|
|
6
|
+
against the project in front of the agent, and quotes comments from other
|
|
7
|
+
GitHub users on the same thread — using the signed-in `gh` user, the same
|
|
8
|
+
account that can see those comments.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import json
|
|
14
|
+
import re
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
|
|
18
|
+
MAX_BODY = 1600
|
|
19
|
+
MAX_ITEMS = 8
|
|
20
|
+
MAX_PEER = 400
|
|
21
|
+
_PATH = re.compile(r"[\w./-]+\.(?:py|pyi|md|toml|yml|yaml|json|cfg|ini)\b")
|
|
22
|
+
_SYMBOL = re.compile(
|
|
23
|
+
r"`([A-Za-z_]\w*)(?:\(\))?`|\b(?:def|function|class)\s+([A-Za-z_]\w*)"
|
|
24
|
+
)
|
|
25
|
+
_CHECK = re.compile(r"^\s*[-*]\s*\[( |x|X)\]\s*(.+)$", re.MULTILINE)
|
|
26
|
+
_BULLET = re.compile(r"^\s*[-*]\s+(?!\[)(.+)$", re.MULTILINE)
|
|
27
|
+
_DONE_WHEN = re.compile(r"^#+\s*(done when|acceptance|tasks?|todo)\b", re.I | re.MULTILINE)
|
|
28
|
+
|
|
29
|
+
# github.com/claude comments on tickets when that product is used on a repo.
|
|
30
|
+
# The login is a GitHub username, not a product name in the brief.
|
|
31
|
+
WATCHED_LOGINS = frozenset({"claude", "claude[bot]"})
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
@dataclass(frozen=True)
|
|
35
|
+
class Ticket:
|
|
36
|
+
"""A ticket, and where in this project it points."""
|
|
37
|
+
|
|
38
|
+
number: str
|
|
39
|
+
kind: str
|
|
40
|
+
title: str
|
|
41
|
+
body: str = ""
|
|
42
|
+
state: str = ""
|
|
43
|
+
files: tuple[str, ...] = ()
|
|
44
|
+
missing: tuple[str, ...] = ()
|
|
45
|
+
symbols: tuple[str, ...] = ()
|
|
46
|
+
todo: tuple[str, ...] = field(default_factory=tuple)
|
|
47
|
+
job: str = ""
|
|
48
|
+
viewer: str = ""
|
|
49
|
+
peers: tuple[tuple[str, str], ...] = ()
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def named_paths(text: str) -> list[str]:
|
|
53
|
+
"""File paths the text mentions, in order, without repeats."""
|
|
54
|
+
seen: list[str] = []
|
|
55
|
+
for hit in _PATH.findall(text):
|
|
56
|
+
cleaned = hit.strip("./`,;:()[]'\"")
|
|
57
|
+
if cleaned and cleaned not in seen:
|
|
58
|
+
seen.append(cleaned)
|
|
59
|
+
return seen
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def named_symbols(text: str) -> list[str]:
|
|
63
|
+
"""Functions or classes named in backticks or after def/class."""
|
|
64
|
+
seen: list[str] = []
|
|
65
|
+
for backticked, declared in _SYMBOL.findall(text):
|
|
66
|
+
name = backticked or declared
|
|
67
|
+
if not name or name in seen or len(name) < 3:
|
|
68
|
+
continue
|
|
69
|
+
if "." in name or name.endswith(".py"):
|
|
70
|
+
continue
|
|
71
|
+
seen.append(name)
|
|
72
|
+
return seen
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def unfinished_items(text: str) -> list[str]:
|
|
76
|
+
"""What the ticket still asks for."""
|
|
77
|
+
boxes = _CHECK.findall(text)
|
|
78
|
+
if boxes:
|
|
79
|
+
return [item.strip() for mark, item in boxes if mark == " "][:MAX_ITEMS]
|
|
80
|
+
match = _DONE_WHEN.search(text)
|
|
81
|
+
if not match:
|
|
82
|
+
return []
|
|
83
|
+
section = text[match.end() :]
|
|
84
|
+
end = re.search(r"^#+\s", section, re.MULTILINE)
|
|
85
|
+
if end:
|
|
86
|
+
section = section[: end.start()]
|
|
87
|
+
return [item.strip() for item in _BULLET.findall(section)][:MAX_ITEMS]
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def ticket_job(text: str) -> str:
|
|
91
|
+
"""Which write skill the ticket body is asking for."""
|
|
92
|
+
from harness.task import (
|
|
93
|
+
looks_like_add_feature,
|
|
94
|
+
looks_like_bugfix,
|
|
95
|
+
looks_like_fix_smell,
|
|
96
|
+
looks_like_ops,
|
|
97
|
+
looks_like_platform,
|
|
98
|
+
looks_like_write_tests,
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
if looks_like_write_tests(text):
|
|
102
|
+
return "write-tests"
|
|
103
|
+
if looks_like_ops(text):
|
|
104
|
+
return "write-workflow"
|
|
105
|
+
if looks_like_platform(text):
|
|
106
|
+
return "write-paths"
|
|
107
|
+
if looks_like_fix_smell(text):
|
|
108
|
+
return "fix-smell"
|
|
109
|
+
if looks_like_bugfix(text):
|
|
110
|
+
return "bugfix"
|
|
111
|
+
if looks_like_add_feature(text):
|
|
112
|
+
return "add-feature"
|
|
113
|
+
return ""
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def comments_from(data: dict) -> list[tuple[str, str]]:
|
|
117
|
+
"""(login, body) from gh issue/pr JSON comments and reviews."""
|
|
118
|
+
found: list[tuple[str, str]] = []
|
|
119
|
+
blobs: list = []
|
|
120
|
+
for key in ("comments", "reviews"):
|
|
121
|
+
raw = data.get(key) or []
|
|
122
|
+
if isinstance(raw, dict):
|
|
123
|
+
raw = raw.get("nodes") or raw.get("comments") or []
|
|
124
|
+
if isinstance(raw, list):
|
|
125
|
+
blobs.extend(item for item in raw if isinstance(item, dict))
|
|
126
|
+
for item in blobs:
|
|
127
|
+
author = item.get("author")
|
|
128
|
+
if isinstance(author, dict):
|
|
129
|
+
login = str(author.get("login") or "")
|
|
130
|
+
else:
|
|
131
|
+
login = str(author or "")
|
|
132
|
+
body = str(item.get("body") or "").strip()
|
|
133
|
+
if login and body:
|
|
134
|
+
found.append((login, body))
|
|
135
|
+
return found
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def identity_from_user_json(payload: str) -> tuple[str, str, str]:
|
|
139
|
+
"""login, name, email from `gh api user` JSON."""
|
|
140
|
+
try:
|
|
141
|
+
data = json.loads(payload)
|
|
142
|
+
except (json.JSONDecodeError, TypeError):
|
|
143
|
+
return "", "", ""
|
|
144
|
+
if not isinstance(data, dict):
|
|
145
|
+
return "", "", ""
|
|
146
|
+
login = str(data.get("login") or "").strip()
|
|
147
|
+
name = str(data.get("name") or login or "").strip()
|
|
148
|
+
email = str(data.get("email") or "").strip()
|
|
149
|
+
if login and not email:
|
|
150
|
+
email = f"{login}@users.noreply.github.com"
|
|
151
|
+
return login, name, email
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def parse_ticket(
|
|
155
|
+
payload: str,
|
|
156
|
+
project: Path,
|
|
157
|
+
*,
|
|
158
|
+
kind: str = "issue",
|
|
159
|
+
viewer: str = "",
|
|
160
|
+
) -> Ticket | None:
|
|
161
|
+
"""Read what `gh ... view --json` returned, and locate it in the project."""
|
|
162
|
+
try:
|
|
163
|
+
data = json.loads(payload)
|
|
164
|
+
except (json.JSONDecodeError, TypeError):
|
|
165
|
+
return None
|
|
166
|
+
if not isinstance(data, dict):
|
|
167
|
+
return None
|
|
168
|
+
body = str(data.get("body") or "")
|
|
169
|
+
title = str(data.get("title") or "")
|
|
170
|
+
text = f"{title}\n{body}"
|
|
171
|
+
root = Path(project).resolve()
|
|
172
|
+
named = named_paths(text)
|
|
173
|
+
for item in data.get("files") or []:
|
|
174
|
+
if not isinstance(item, dict):
|
|
175
|
+
continue
|
|
176
|
+
rel = str(item.get("path") or item.get("filename") or "").strip()
|
|
177
|
+
if rel and rel not in named:
|
|
178
|
+
named.append(rel)
|
|
179
|
+
here = [rel for rel in named if (root / rel).is_file()]
|
|
180
|
+
peers = [
|
|
181
|
+
(login, body[:MAX_PEER])
|
|
182
|
+
for login, body in comments_from(data)
|
|
183
|
+
if login.lower() in WATCHED_LOGINS
|
|
184
|
+
]
|
|
185
|
+
return Ticket(
|
|
186
|
+
number=str(data.get("number") or ""),
|
|
187
|
+
kind=kind,
|
|
188
|
+
title=title,
|
|
189
|
+
body=body[:MAX_BODY],
|
|
190
|
+
state=str(data.get("state") or ""),
|
|
191
|
+
files=tuple(here),
|
|
192
|
+
missing=tuple(rel for rel in named if rel not in here),
|
|
193
|
+
symbols=tuple(named_symbols(text)),
|
|
194
|
+
todo=tuple(unfinished_items(body)),
|
|
195
|
+
job=ticket_job(text),
|
|
196
|
+
viewer=viewer,
|
|
197
|
+
peers=tuple(peers),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def next_ticket_action(ticket: Ticket) -> str:
|
|
202
|
+
"""The one Action the 8B should take after reading the ticket."""
|
|
203
|
+
if ticket.files:
|
|
204
|
+
return f"Action: read Path: {ticket.files[0]}"
|
|
205
|
+
if ticket.symbols:
|
|
206
|
+
return f"Action: locate Query: {ticket.symbols[0]}"
|
|
207
|
+
words = " ".join(ticket.title.split()[:4]) or "the ask"
|
|
208
|
+
return f"Action: locate Query: {words}"
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def render_ticket(ticket: Ticket) -> str:
|
|
212
|
+
"""What the agent is shown: the ask, then where it points."""
|
|
213
|
+
lines = [f"{ticket.kind} #{ticket.number}: {ticket.title}"]
|
|
214
|
+
if ticket.state:
|
|
215
|
+
lines.append(f"state: {ticket.state}")
|
|
216
|
+
if ticket.viewer:
|
|
217
|
+
lines.append(f"viewing as @{ticket.viewer} (signed-in gh user)")
|
|
218
|
+
else:
|
|
219
|
+
lines.append("viewing as: not signed in to gh")
|
|
220
|
+
if ticket.job:
|
|
221
|
+
lines.append(f"Job: {ticket.job}")
|
|
222
|
+
if ticket.todo:
|
|
223
|
+
lines.append("")
|
|
224
|
+
lines.append("Still to do:")
|
|
225
|
+
lines.extend(f" - {item}" for item in ticket.todo)
|
|
226
|
+
if ticket.files:
|
|
227
|
+
lines.append("")
|
|
228
|
+
lines.append("Where (files in this project):")
|
|
229
|
+
lines.extend(f" {rel}" for rel in ticket.files)
|
|
230
|
+
if ticket.missing:
|
|
231
|
+
lines.append("")
|
|
232
|
+
lines.append(
|
|
233
|
+
"Named but not here (do not create these blindly): "
|
|
234
|
+
+ ", ".join(ticket.missing[:5])
|
|
235
|
+
)
|
|
236
|
+
if ticket.symbols:
|
|
237
|
+
lines.append("")
|
|
238
|
+
lines.append("Functions it names: " + ", ".join(ticket.symbols[:8]))
|
|
239
|
+
if ticket.peers:
|
|
240
|
+
lines.append("")
|
|
241
|
+
lines.append("Also on this ticket:")
|
|
242
|
+
for login, excerpt in ticket.peers:
|
|
243
|
+
one = " ".join(excerpt.split())
|
|
244
|
+
lines.append(f" @{login}: {one}")
|
|
245
|
+
nxt = next_ticket_action(ticket)
|
|
246
|
+
lines.append("")
|
|
247
|
+
lines.append(f"Next: {nxt}")
|
|
248
|
+
if ticket.body:
|
|
249
|
+
lines.append("")
|
|
250
|
+
lines.append(ticket.body)
|
|
251
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Load SKILL.md files. Deterministic. No model.
|
|
2
|
+
|
|
3
|
+
Looks in the project `skills/` folder and the py-harness kit
|
|
4
|
+
(`<repo>/skills`). Same name: project wins.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import re
|
|
10
|
+
from dataclasses import dataclass
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from harness.paths import KIT_SKILLS_DIR
|
|
14
|
+
from harness.task import (
|
|
15
|
+
looks_like_add_feature,
|
|
16
|
+
looks_like_algorithm,
|
|
17
|
+
looks_like_analytics,
|
|
18
|
+
looks_like_design_loop,
|
|
19
|
+
looks_like_fix_smell,
|
|
20
|
+
looks_like_http_client,
|
|
21
|
+
looks_like_app_loop,
|
|
22
|
+
looks_like_app_overflow,
|
|
23
|
+
looks_like_new_package,
|
|
24
|
+
looks_like_ops,
|
|
25
|
+
looks_like_platform,
|
|
26
|
+
looks_like_question,
|
|
27
|
+
looks_like_script,
|
|
28
|
+
mentions_cli,
|
|
29
|
+
mentions_http,
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
_FRONT = re.compile(r"^---\n(.*?)\n---\n(.*)\Z", re.DOTALL)
|
|
33
|
+
_NAME = re.compile(r"^name:\s*(.+)$", re.MULTILINE)
|
|
34
|
+
_DESC = re.compile(r"^description:\s*(.+)$", re.MULTILINE)
|
|
35
|
+
MAX_SKILL_CHARS = 2500
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
@dataclass(frozen=True)
|
|
39
|
+
class Skill:
|
|
40
|
+
name: str
|
|
41
|
+
description: str
|
|
42
|
+
body: str
|
|
43
|
+
path: Path
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def kit_skills_dir() -> Path:
|
|
47
|
+
return KIT_SKILLS_DIR
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _parse_skill(path: Path) -> Skill | None:
|
|
51
|
+
text = path.read_text(encoding="utf-8")
|
|
52
|
+
match = _FRONT.match(text)
|
|
53
|
+
if not match:
|
|
54
|
+
return None
|
|
55
|
+
meta, body = match.group(1), match.group(2).strip()
|
|
56
|
+
name = _NAME.search(meta)
|
|
57
|
+
desc = _DESC.search(meta)
|
|
58
|
+
raw_name = name.group(1).strip() if name else path.parent.name
|
|
59
|
+
if not raw_name or not desc:
|
|
60
|
+
return None
|
|
61
|
+
return Skill(
|
|
62
|
+
name=raw_name,
|
|
63
|
+
description=desc.group(1).strip(),
|
|
64
|
+
body=body,
|
|
65
|
+
path=path,
|
|
66
|
+
)
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _scan_dir(root: Path) -> dict[str, Skill]:
|
|
70
|
+
found: dict[str, Skill] = {}
|
|
71
|
+
if not root.is_dir():
|
|
72
|
+
return found
|
|
73
|
+
for skill_md in sorted(root.glob("*/SKILL.md")):
|
|
74
|
+
parsed = _parse_skill(skill_md)
|
|
75
|
+
if parsed:
|
|
76
|
+
found[parsed.name] = parsed
|
|
77
|
+
return found
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
def list_skills(project: Path | None = None, extra: Path | None = None) -> list[Skill]:
|
|
81
|
+
merged: dict[str, Skill] = {}
|
|
82
|
+
merged.update(_scan_dir(kit_skills_dir()))
|
|
83
|
+
if extra:
|
|
84
|
+
merged.update(_scan_dir(extra))
|
|
85
|
+
if project:
|
|
86
|
+
merged.update(_scan_dir(project.resolve() / "skills"))
|
|
87
|
+
return sorted(merged.values(), key=lambda item: item.name)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def get_skill(name: str, project: Path | None = None) -> Skill | None:
|
|
91
|
+
key = name.strip().lower()
|
|
92
|
+
if not key:
|
|
93
|
+
return None
|
|
94
|
+
for skill in list_skills(project):
|
|
95
|
+
if skill.name.lower() == key:
|
|
96
|
+
return skill
|
|
97
|
+
return None
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def skill_from_action(
|
|
101
|
+
action: str, name: str = "", path: str = "", project: Path | None = None
|
|
102
|
+
) -> Skill | None:
|
|
103
|
+
"""Action: skill + Name:, or Action: write-tests as a shortcut."""
|
|
104
|
+
if action == "skill":
|
|
105
|
+
return get_skill(name or path, project)
|
|
106
|
+
return get_skill(action, project)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def pick_skills(task: str, catalog: list[Skill]) -> list[Skill]:
|
|
110
|
+
picked: list[Skill] = []
|
|
111
|
+
if looks_like_question(task):
|
|
112
|
+
picked.extend(s for s in catalog if s.name == "answer-question")
|
|
113
|
+
from harness.task import (
|
|
114
|
+
looks_like_merge,
|
|
115
|
+
looks_like_ship,
|
|
116
|
+
looks_like_ticket,
|
|
117
|
+
looks_like_ticket_work,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if looks_like_merge(task):
|
|
121
|
+
picked.extend(s for s in catalog if s.name == "merge-pr")
|
|
122
|
+
return picked
|
|
123
|
+
if looks_like_ship(task) and not looks_like_ticket_work(task):
|
|
124
|
+
picked.extend(s for s in catalog if s.name == "open-pr")
|
|
125
|
+
return picked
|
|
126
|
+
if looks_like_ticket(task):
|
|
127
|
+
picked.extend(s for s in catalog if s.name == "read-issue")
|
|
128
|
+
if not looks_like_ticket_work(task) and not looks_like_ship(task):
|
|
129
|
+
return picked
|
|
130
|
+
if looks_like_new_package(task):
|
|
131
|
+
picked.extend(s for s in catalog if s.name == "new-package")
|
|
132
|
+
if looks_like_app_loop(task):
|
|
133
|
+
picked.extend(s for s in catalog if s.name == "write-cli-app")
|
|
134
|
+
if mentions_http(task):
|
|
135
|
+
picked.extend(s for s in catalog if s.name == "call-http")
|
|
136
|
+
elif mentions_cli(task):
|
|
137
|
+
picked.extend(s for s in catalog if s.name == "write-script")
|
|
138
|
+
if mentions_cli(task) or mentions_http(task):
|
|
139
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
140
|
+
return picked
|
|
141
|
+
if looks_like_app_overflow(task):
|
|
142
|
+
picked.extend(s for s in catalog if s.name == "write-cli-app")
|
|
143
|
+
picked.extend(s for s in catalog if s.name == "call-http")
|
|
144
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
145
|
+
return picked
|
|
146
|
+
if looks_like_fix_smell(task):
|
|
147
|
+
picked.extend(s for s in catalog if s.name == "fix-smell")
|
|
148
|
+
return picked
|
|
149
|
+
if looks_like_design_loop(task):
|
|
150
|
+
from harness.task import looks_like_review_code, task_paths
|
|
151
|
+
|
|
152
|
+
# "review src/app.py" is a review of one file; "review the project
|
|
153
|
+
# structure" is a review of the tree. The named path decides.
|
|
154
|
+
if task_paths(task) and looks_like_review_code(task):
|
|
155
|
+
picked.extend(s for s in catalog if s.name == "review-code")
|
|
156
|
+
return picked
|
|
157
|
+
picked.extend(
|
|
158
|
+
s
|
|
159
|
+
for s in catalog
|
|
160
|
+
if s.name in {"review-design", "refactor-split", "readable-layout"}
|
|
161
|
+
)
|
|
162
|
+
return picked
|
|
163
|
+
if looks_like_ops(task):
|
|
164
|
+
picked.extend(s for s in catalog if s.name == "write-workflow")
|
|
165
|
+
return picked
|
|
166
|
+
if looks_like_platform(task):
|
|
167
|
+
picked.extend(s for s in catalog if s.name == "write-paths")
|
|
168
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
169
|
+
return picked
|
|
170
|
+
if looks_like_http_client(task):
|
|
171
|
+
picked.extend(s for s in catalog if s.name == "call-http")
|
|
172
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
173
|
+
return picked
|
|
174
|
+
if looks_like_analytics(task):
|
|
175
|
+
picked.extend(s for s in catalog if s.name == "analyze-data")
|
|
176
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
177
|
+
return picked
|
|
178
|
+
if looks_like_algorithm(task):
|
|
179
|
+
picked.extend(s for s in catalog if s.name == "write-algorithm")
|
|
180
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
181
|
+
return picked
|
|
182
|
+
if looks_like_script(task):
|
|
183
|
+
picked.extend(s for s in catalog if s.name == "write-script")
|
|
184
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
185
|
+
return picked
|
|
186
|
+
if looks_like_add_feature(task):
|
|
187
|
+
picked.extend(s for s in catalog if s.name == "add-feature")
|
|
188
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
189
|
+
from harness.task import (
|
|
190
|
+
looks_like_archive,
|
|
191
|
+
looks_like_compare,
|
|
192
|
+
looks_like_review_code,
|
|
193
|
+
looks_like_walk_files,
|
|
194
|
+
looks_unclear,
|
|
195
|
+
)
|
|
196
|
+
|
|
197
|
+
# Scripting jobs: the model reaches for the shallow primitive on its own
|
|
198
|
+
# (os.listdir for "under a folder", a shell call for a zip), so the skill
|
|
199
|
+
# is what supplies the right one.
|
|
200
|
+
for matches, skill_name in (
|
|
201
|
+
(looks_like_archive, "use-archive"),
|
|
202
|
+
(looks_like_compare, "compare-things"),
|
|
203
|
+
(looks_like_walk_files, "walk-files"),
|
|
204
|
+
):
|
|
205
|
+
if matches(task) and not any(s.name == skill_name for s in picked):
|
|
206
|
+
picked.extend(s for s in catalog if s.name == skill_name)
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
if looks_unclear(task):
|
|
210
|
+
picked.extend(s for s in catalog if s.name == "ask-when-unclear")
|
|
211
|
+
if looks_like_review_code(task) and not picked:
|
|
212
|
+
picked.extend(s for s in catalog if s.name == "review-code")
|
|
213
|
+
lowered = task.strip().lower()
|
|
214
|
+
if "test" in lowered and not any(s.name == "write-tests" for s in picked):
|
|
215
|
+
picked.extend(s for s in catalog if s.name == "write-tests")
|
|
216
|
+
return picked
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def render_catalog(catalog: list[Skill]) -> str:
|
|
220
|
+
if not catalog:
|
|
221
|
+
return "Skills: (none)"
|
|
222
|
+
lines = ["Skills (Action: skill + Name: …, or --skill):"]
|
|
223
|
+
for skill in catalog:
|
|
224
|
+
lines.append(f" {skill.name} — {skill.description}")
|
|
225
|
+
return "\n".join(lines)
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def render_skill(skill: Skill, target=None, project: Path | None = None) -> str:
|
|
229
|
+
"""Render one skill, repointed at `project` when a target is given.
|
|
230
|
+
|
|
231
|
+
Without a target the kit paths stay literal, which is only right for the
|
|
232
|
+
eval fixtures. See harness.skillkit.target.
|
|
233
|
+
"""
|
|
234
|
+
body = skill.body
|
|
235
|
+
if target is not None:
|
|
236
|
+
from harness.skillkit.target import retarget
|
|
237
|
+
|
|
238
|
+
body = retarget(body, target, project)
|
|
239
|
+
if len(body) > MAX_SKILL_CHARS:
|
|
240
|
+
body = body[:MAX_SKILL_CHARS] + "\n# … skill truncated\n"
|
|
241
|
+
return f"# skill {skill.name}\n{skill.description}\n\n{body}"
|