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,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,6 @@
1
+ """Load skills and prepare them for a specific project.
2
+
3
+ Reads `SKILL.md` files, selects the ones that apply to a task, and replaces
4
+ the file paths inside them with paths that exist in the project being
5
+ worked on.
6
+ """
@@ -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}"