acumen 0.0.1.dev0__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.
acumen/ship.py ADDED
@@ -0,0 +1,298 @@
1
+ """The shipping agent: make a benchmarked skill installable *into* the target package.
2
+
3
+ Once a maintainer has benchmarked a skill and knows which version is best, ``acumen ship
4
+ --skill vN`` packages that version into the target so the package gains a ``<dist>-install-skills``
5
+ console script — one command drops the skill into the skills directory of whichever agentic
6
+ framework the user names (``--agent {claude,codex,agents,claude-science}`` or an explicit
7
+ ``--dest``) for the package's users. For a GitHub URL the change arrives as a pull request the
8
+ agent opens itself; for a local path it is written straight into the working tree.
9
+
10
+ The whole point is **generalization**: the agent reasons about the repo — distribution vs import
11
+ name, src-vs-flat layout, build backend — rather than pattern-matching decoupler's shape. So, like
12
+ draft/improve/taskgen, this is an autonomous agent, not a template stamper. The one thing acumen
13
+ stages for it is a clean copy of the skill content (``SKILL.md`` + ``references/``, with acumen's
14
+ ``meta.json`` bookkeeping stripped) to be copied verbatim into the wheel.
15
+
16
+ **Deliberately NOT isolated.** Unlike every other agent, the shipper runs in the user's
17
+ real environment — real network, real git/``gh`` credentials, real ``uv`` — because it builds,
18
+ installs, pushes, and opens a PR. There is nothing to keep honest here: its output is a PR the
19
+ maintainer reviews (or a working-tree diff), so isolation would only stop it doing its job. The
20
+ correctness gate is therefore not env scrubbing but the **build-verify** the agent performs:
21
+ build the wheel in a fresh venv, install it, run the entry point, confirm ``SKILL.md`` ships from
22
+ the installed location — the only thing that catches a mis-wired backend shipping a wheel with no
23
+ skill data. That gate lives in the agent's own turns; acumen trusts the agent's report.
24
+ """
25
+
26
+ from __future__ import annotations
27
+
28
+ import os
29
+ import shutil
30
+ import tempfile
31
+ import tomllib
32
+ from dataclasses import dataclass
33
+ from pathlib import Path
34
+
35
+ from claude_agent_sdk import ClaudeAgentOptions, ResultMessage, query
36
+
37
+ from acumen.config import Config
38
+ from acumen.env import API_AUTH_ENV_VARS, SESSION_AUTH_ENV_VAR, AuthMode, Target, claude_cli_dir
39
+ from acumen.logs import LiveLog
40
+ from acumen.procs import label_env, reap
41
+ from acumen.prompts import ship_prompt
42
+ from acumen.skills import Skill, content_files, load_skill
43
+
44
+ #: A package "already ships an installer" if any console script is named like this.
45
+ _INSTALLER_SUFFIX = "-install-skills"
46
+
47
+
48
+ class ShipError(RuntimeError):
49
+ """Raised when a skill could not be shipped."""
50
+
51
+
52
+ @dataclass(frozen=True)
53
+ class ShipResult:
54
+ """The outcome of a shipping run: what was delivered and what it cost."""
55
+
56
+ skill: Skill
57
+ #: ``"github"`` (a PR was opened) or ``"local"`` (the working tree was edited).
58
+ mode: str
59
+ #: The agent's final message — for a GitHub target it carries the PR URL.
60
+ summary: str
61
+ cost_usd: float
62
+ turns: int
63
+ #: Live log paths for this run, when a :class:`LiveLog` was attached.
64
+ log_jsonl: Path | None = None
65
+ log_html: Path | None = None
66
+
67
+
68
+ def _script_tables(pyproject: dict) -> list[dict]:
69
+ """Return every table that can declare console scripts across build backends."""
70
+ tables: list[dict] = []
71
+ scripts = pyproject.get("project", {}).get("scripts")
72
+ if isinstance(scripts, dict):
73
+ tables.append(scripts)
74
+ # poetry keeps its scripts under [tool.poetry.scripts]; check it too so a poetry package
75
+ # that already ships an installer is not clobbered.
76
+ poetry = pyproject.get("tool", {}).get("poetry", {}).get("scripts")
77
+ if isinstance(poetry, dict):
78
+ tables.append(poetry)
79
+ return tables
80
+
81
+
82
+ def installer_exists(src_dir: Path) -> bool:
83
+ """Whether the target already ships a skills installer.
84
+
85
+ True if any declared console script is named ``<something>-install-skills`` (the contract
86
+ this command creates), or a ``_skills/install.py`` already exists under the source tree.
87
+ Used as a pre-flight so ``acumen ship`` refuses to clobber an existing installer without
88
+ ``--force``, before the (costly, real-env) agent runs.
89
+
90
+ Parameters
91
+ ----------
92
+ src_dir
93
+ The target package checkout.
94
+
95
+ Returns
96
+ -------
97
+ Whether an installer is already present.
98
+ """
99
+ pyproject = src_dir / "pyproject.toml"
100
+ if pyproject.is_file():
101
+ try:
102
+ data = tomllib.loads(pyproject.read_text())
103
+ except (OSError, tomllib.TOMLDecodeError):
104
+ data = {}
105
+ for table in _script_tables(data):
106
+ if any(str(name).endswith(_INSTALLER_SUFFIX) for name in table):
107
+ return True
108
+ return any(p.name == "install.py" and p.parent.name == "_skills" for p in src_dir.rglob("_skills/install.py"))
109
+
110
+
111
+ def _stage_skill_payload(skill: Skill, dest: Path) -> Path:
112
+ """Copy the skill's content files (no ``meta.json``) into ``dest`` for verbatim shipping.
113
+
114
+ :func:`acumen.skills.content_files` already excludes acumen's ``meta.json`` bookkeeping, so
115
+ what lands in ``dest`` is exactly what should ship in the wheel — ``SKILL.md`` plus any
116
+ ``references/`` tree, structure preserved.
117
+ """
118
+ dest.mkdir(parents=True, exist_ok=True)
119
+ for path in content_files(skill.directory):
120
+ rel = path.relative_to(skill.directory)
121
+ target = dest / rel
122
+ target.parent.mkdir(parents=True, exist_ok=True)
123
+ shutil.copyfile(path, target)
124
+ return dest
125
+
126
+
127
+ def _ship_env(target: Target, auth_mode: AuthMode) -> dict[str, str]:
128
+ """The environment for the (unisolated) ship agent: the real env, target venv on PATH.
129
+
130
+ Unlike the benchmark and meta-agents this is NOT scrubbed — the agent needs real
131
+ git/``gh`` credentials, network, and ``uv``. The target venv's ``bin`` is prepended so
132
+ ``python`` resolves to the interpreter with the package installed, matching the other agents;
133
+ everything else the user has (PATH, HOME, credentials, tokens) is carried through untouched.
134
+
135
+ The one exception is the model credential: to bill the chosen ``auth_mode`` deterministically
136
+ we neutralize the *other* mode's variables — ``"session"`` clears the metered API/cloud keys
137
+ (the user's real ``~/.claude`` OAuth login then authenticates), ``"api"`` clears the
138
+ subscription OAuth token (the API key then wins). Everything non-credential is still carried
139
+ through.
140
+
141
+ The cleared variables are set to ``""``, not deleted: the SDK merges this mapping over
142
+ ``os.environ`` (``{**os.environ, **options.env}``), so a key we merely drop from the dict
143
+ falls back through from the inherited environment and the run bills the wrong credential. An
144
+ explicit empty value overrides the inherited one, which the CLI reads as unset.
145
+ """
146
+ env = dict(os.environ)
147
+ drop = API_AUTH_ENV_VARS if auth_mode == "session" else (SESSION_AUTH_ENV_VAR,)
148
+ for var in drop:
149
+ env[var] = ""
150
+ parts = [str(target.bin_dir)]
151
+ cli_dir = claude_cli_dir()
152
+ if cli_dir is not None:
153
+ parts.append(str(cli_dir))
154
+ existing = env.get("PATH", "")
155
+ if existing:
156
+ parts.append(existing)
157
+ env["PATH"] = os.pathsep.join(parts)
158
+ return env
159
+
160
+
161
+ def _config_dir(env: dict[str, str]) -> Path:
162
+ """The ``CLAUDE_CONFIG_DIR`` the ship agent's transcript lands under.
163
+
164
+ The ship agent is unisolated, so it uses the user's real config dir — ``CLAUDE_CONFIG_DIR``
165
+ if set, else ``$HOME/.claude`` — never a throwaway.
166
+ """
167
+ explicit = env.get("CLAUDE_CONFIG_DIR")
168
+ if explicit:
169
+ return Path(explicit)
170
+ home = env.get("HOME") or str(Path.home())
171
+ return Path(home) / ".claude"
172
+
173
+
174
+ async def ship_skill(
175
+ *,
176
+ cfg: Config,
177
+ target: Target,
178
+ skills_root: Path,
179
+ version: str,
180
+ auth_mode: AuthMode = "session",
181
+ model: str | None = None,
182
+ max_turns: int | None = None,
183
+ max_usd: float | None = None,
184
+ force: bool = False,
185
+ log: LiveLog | None = None,
186
+ ) -> ShipResult:
187
+ """Make skill ``version`` installable into the target package.
188
+
189
+ Parameters
190
+ ----------
191
+ cfg
192
+ The pass config; supplies ``skill_name`` and ``ship_model``, and ``is_local`` decides
193
+ whether delivery is a PR (GitHub URL) or a working-tree edit (local path).
194
+ target
195
+ The prepared target, supplying the checkout the agent modifies (its ``src_dir``) and the
196
+ venv interpreter. For a URL this is acumen's clone; for a local path it is the given dir.
197
+ skills_root
198
+ The ``skills/`` root; ``version`` is loaded and validated from it.
199
+ version
200
+ The skill version to ship, e.g. ``v2``. Required — there is no implicit "latest"/"best".
201
+ auth_mode
202
+ Which credential the ship agent authenticates with — ``"session"`` (the Claude
203
+ subscription) or ``"api"``. Only the model credential is affected; the agent still runs
204
+ in the user's real environment with real git/``gh``/network access.
205
+ model
206
+ Override for the ship model; defaults to ``cfg.ship_model``.
207
+ max_turns, max_usd
208
+ Caps for the agent. **Unbounded by default**: shipping builds and installs the
209
+ package iteratively and runs git/``gh``, so no default budget is imposed.
210
+ force
211
+ Proceed even if the target already ships a skills installer (otherwise refused).
212
+ log
213
+ A :class:`LiveLog` to stream the agent's messages to and render an HTML log from.
214
+
215
+ Returns
216
+ -------
217
+ The ship result: the version shipped, the delivery mode, the agent's summary, and cost.
218
+ """
219
+ skill = load_skill(skills_root, version, expect_name=cfg.skill_name)
220
+
221
+ # Pre-flight, before the costly real-env agent: refuse to clobber an existing installer.
222
+ if installer_exists(target.src_dir) and not force:
223
+ raise ShipError(
224
+ f"{target.pkg_name} already ships a skills installer — pass force=True to ship anyway. "
225
+ "Shipping over an existing installer risks overwriting a maintainer's own setup."
226
+ )
227
+
228
+ mode = "local" if cfg.is_local else "github"
229
+
230
+ holder = Path(tempfile.mkdtemp(prefix="acumen-ship-"))
231
+ try:
232
+ skill_src = _stage_skill_payload(skill, holder / "skill")
233
+
234
+ prompt = ship_prompt(
235
+ package=target.pkg_name,
236
+ skill_name=cfg.skill_name,
237
+ version=skill.version,
238
+ checkout=target.src_dir,
239
+ skill_src=skill_src,
240
+ mode=mode,
241
+ )
242
+ options = ClaudeAgentOptions(
243
+ # The agent edits the checkout in place, so its cwd IS the checkout.
244
+ cwd=str(target.src_dir),
245
+ # Real environment — NOT scrubbed: git/gh creds + network + uv. Marked with the
246
+ # holder so the teardown below can find whatever the agent leaves running; the
247
+ # holder path appears nowhere else in this agent's environment.
248
+ env=label_env(_ship_env(target, auth_mode), holder),
249
+ model=model or cfg.ship_model,
250
+ # No default budget cap: only bound the agent if the caller asked.
251
+ max_turns=max_turns,
252
+ max_budget_usd=max_usd,
253
+ # It reads the staged skill payload to copy into the wheel.
254
+ add_dirs=[str(skill_src)],
255
+ # No skill discovery: the shipper packages a skill, it does not consume one.
256
+ setting_sources=[],
257
+ permission_mode="bypassPermissions",
258
+ system_prompt={"type": "preset", "preset": "claude_code"},
259
+ )
260
+
261
+ result: ResultMessage | None = None
262
+ agent_error: Exception | None = None
263
+ try:
264
+ async for message in query(prompt=prompt, options=options):
265
+ if log is not None:
266
+ log.append(message)
267
+ if isinstance(message, ResultMessage):
268
+ result = message
269
+ except Exception as err: # noqa: BLE001 - a failed ship is an error to report, re-raised below
270
+ agent_error = err
271
+ finally:
272
+ # The ship agent is unisolated, so its transcript lands under the user's real config
273
+ # dir (not a throwaway). Render the HTML log in a finally so an aborted run (the SDK
274
+ # raises on a cap breach, after yielding the result) is still inspectable.
275
+ if log is not None:
276
+ log.finalize(config_dir=_config_dir(options.env), work_dir=target.src_dir, result=result)
277
+
278
+ if agent_error is not None:
279
+ raise ShipError(f"the shipping agent failed: {type(agent_error).__name__}: {agent_error}") from agent_error
280
+ if result is None:
281
+ raise ShipError("the shipping agent produced no result message")
282
+ if result.is_error:
283
+ raise ShipError(f"the shipping agent errored: {result.subtype} {result.errors or ''}".strip())
284
+
285
+ return ShipResult(
286
+ skill=skill,
287
+ mode=mode,
288
+ summary=result.result or "",
289
+ cost_usd=result.total_cost_usd or 0.0,
290
+ turns=result.num_turns,
291
+ log_jsonl=log.jsonl_path if log is not None else None,
292
+ log_html=log.html_path if log is not None and log.html_rendered else None,
293
+ )
294
+ finally:
295
+ # Kill anything the agent left running before removing the directory it runs in.
296
+ # ship's agent works in the target checkout, but its env still points at the holder.
297
+ reap(holder)
298
+ shutil.rmtree(holder, ignore_errors=True)
acumen/skills.py ADDED
@@ -0,0 +1,298 @@
1
+ """Skill directories: loading, validation, and a stable content hash.
2
+
3
+ A skill is a directory — ``SKILL.md`` plus an optional ``references/`` tree — and
4
+ versions are immutable: ``improve`` writes ``skills/v{n+1}/``, never mutates ``v{n}``.
5
+
6
+ ``meta.json`` is acumen's own bookkeeping (version, parent, rationale, hash), not part of
7
+ the skill. It is therefore excluded from :func:`skill_hash` — it *contains* the hash, so
8
+ including it would be circular — and excluded from what gets copied to an agent, which
9
+ has no business reading the rationale for its own skill.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import hashlib
15
+ import json
16
+ import re
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+ from typing import Any
20
+
21
+ import yaml
22
+
23
+ SKILL_FILE = "SKILL.md"
24
+ META_FILE = "meta.json"
25
+ REFERENCES_DIR = "references"
26
+
27
+ #: Files that are acumen bookkeeping rather than skill content.
28
+ _NOT_CONTENT = {META_FILE}
29
+
30
+ _VERSION_RE = re.compile(r"^v(\d+)$")
31
+ _FRONTMATTER_RE = re.compile(r"\A---\r?\n(?P<body>.*?)\r?\n---\s*\r?\n?", re.DOTALL)
32
+
33
+
34
+ class SkillError(ValueError):
35
+ """Raised when a skill directory is missing, malformed, or inconsistent."""
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class SkillMeta:
40
+ """Provenance for one skill version, as stored in ``meta.json``."""
41
+
42
+ version: int
43
+ parent: str | None
44
+ rationale: str
45
+ hash: str
46
+ #: Maintainer ``--feedback`` that shaped this version, or ``None`` if none was given.
47
+ feedback: str | None = None
48
+
49
+ def to_dict(self) -> dict[str, Any]:
50
+ """Return the ``meta.json`` payload.
51
+
52
+ ``feedback`` is omitted when absent, so a version drafted/improved without ``--feedback``
53
+ writes the same ``meta.json`` it did without the field.
54
+ """
55
+ payload: dict[str, Any] = {
56
+ "version": self.version,
57
+ "parent": self.parent,
58
+ "rationale": self.rationale,
59
+ "hash": self.hash,
60
+ }
61
+ if self.feedback:
62
+ payload["feedback"] = self.feedback
63
+ return payload
64
+
65
+
66
+ @dataclass(frozen=True)
67
+ class Skill:
68
+ """A loaded, validated skill version."""
69
+
70
+ version: str
71
+ directory: Path
72
+ name: str
73
+ description: str
74
+ hash: str
75
+
76
+ @property
77
+ def number(self) -> int:
78
+ """The numeric version, e.g. ``1`` for ``"v1"``."""
79
+ return version_number(self.version)
80
+
81
+
82
+ def version_name(number: int) -> str:
83
+ """Return the directory name for a numeric version, e.g. ``1`` -> ``"v1"``."""
84
+ if number < 1:
85
+ raise SkillError(f"skill version must be >= 1, got {number}")
86
+ return f"v{number}"
87
+
88
+
89
+ def version_number(version: str) -> int:
90
+ """Parse a version directory name, e.g. ``"v1"`` -> ``1``."""
91
+ match = _VERSION_RE.match(version)
92
+ if not match:
93
+ raise SkillError(f"skill version must look like 'v1', got {version!r}")
94
+ number = int(match.group(1))
95
+ if number < 1:
96
+ raise SkillError(f"skill version must be >= 1, got {version!r}")
97
+ return number
98
+
99
+
100
+ def skill_dir(skills_root: Path, version: str | int) -> Path:
101
+ """Return the directory holding a skill version."""
102
+ name = version_name(version) if isinstance(version, int) else version
103
+ version_number(name) # validate
104
+ return skills_root / name
105
+
106
+
107
+ def content_files(directory: Path) -> list[Path]:
108
+ """Return the skill's content files, sorted, excluding acumen bookkeeping.
109
+
110
+ Sorting by POSIX-relative path keeps the order stable across filesystems, which is
111
+ what makes :func:`skill_hash` reproducible.
112
+ """
113
+ files = [
114
+ path
115
+ for path in directory.rglob("*")
116
+ if path.is_file() and not (path.parent == directory and path.name in _NOT_CONTENT)
117
+ ]
118
+ return sorted(files, key=lambda p: p.relative_to(directory).as_posix())
119
+
120
+
121
+ def skill_content(directory: Path) -> dict[str, str]:
122
+ """Return the skill's text content as ``{posix-relpath: text}``, excluding bookkeeping.
123
+
124
+ Used by the report to diff one version against its parent. Files that do not decode as
125
+ UTF-8 (there should be none in a skill) are represented by a short placeholder so a diff
126
+ still lists them without choking on bytes.
127
+
128
+ Returns
129
+ -------
130
+ A mapping from each content file's POSIX-relative path to its decoded text.
131
+ """
132
+ content: dict[str, str] = {}
133
+ for path in content_files(directory):
134
+ rel = path.relative_to(directory).as_posix()
135
+ try:
136
+ content[rel] = path.read_text(encoding="utf-8")
137
+ except UnicodeDecodeError:
138
+ content[rel] = "<binary file>\n"
139
+ return content
140
+
141
+
142
+ def skill_hash(directory: Path) -> str:
143
+ """Hash a skill directory's content — the value recorded in every ``result.json``.
144
+
145
+ Both the relative path and the bytes of each file are folded in, with lengths, so
146
+ that renaming a file or moving text between files changes the hash. Byte content is
147
+ used rather than text, so a line-ending change is a real change.
148
+
149
+ Returns
150
+ -------
151
+ ``"sha256:<hex>"``.
152
+ """
153
+ if not directory.is_dir():
154
+ raise SkillError(f"skill directory does not exist: {directory}")
155
+ digest = hashlib.sha256()
156
+ for path in content_files(directory):
157
+ rel = path.relative_to(directory).as_posix().encode()
158
+ data = path.read_bytes()
159
+ # Length-prefix both parts so no concatenation of names and bodies can collide.
160
+ digest.update(f"{len(rel)}:".encode())
161
+ digest.update(rel)
162
+ digest.update(f"{len(data)}:".encode())
163
+ digest.update(data)
164
+ return f"sha256:{digest.hexdigest()}"
165
+
166
+
167
+ def parse_frontmatter(text: str) -> dict[str, Any]:
168
+ """Parse the YAML frontmatter of a ``SKILL.md``.
169
+
170
+ Returns
171
+ -------
172
+ The frontmatter mapping; empty if the file has none.
173
+ """
174
+ match = _FRONTMATTER_RE.match(text)
175
+ if not match:
176
+ return {}
177
+ try:
178
+ data = yaml.safe_load(match.group("body"))
179
+ except yaml.YAMLError as err:
180
+ raise SkillError(f"SKILL.md frontmatter is not valid YAML: {err}") from err
181
+ if data is None:
182
+ return {}
183
+ if not isinstance(data, dict):
184
+ raise SkillError(f"SKILL.md frontmatter must be a mapping, got {type(data).__name__}")
185
+ return data
186
+
187
+
188
+ def load_skill(skills_root: Path, version: str | int, *, expect_name: str | None = None) -> Skill:
189
+ """Load and validate one skill version.
190
+
191
+ Parameters
192
+ ----------
193
+ skills_root
194
+ The ``skills/`` root directory.
195
+ version
196
+ ``"v1"`` or ``1``.
197
+ expect_name
198
+ If given, the skill's frontmatter ``name`` must match it — this is
199
+ ``config.skill_name``, and a mismatch means the skill would be injected under a
200
+ directory name the agent's own frontmatter disagrees with.
201
+
202
+ Returns
203
+ -------
204
+ The loaded skill, with its content hash computed.
205
+ """
206
+ directory = skill_dir(skills_root, version)
207
+ if not directory.is_dir():
208
+ raise SkillError(f"no such skill version: {directory}")
209
+ skill_md = directory / SKILL_FILE
210
+ if not skill_md.is_file():
211
+ raise SkillError(f"{directory} has no {SKILL_FILE} — a skill is a directory containing one")
212
+ try:
213
+ text = skill_md.read_text()
214
+ except OSError as err:
215
+ raise SkillError(f"cannot read {skill_md}: {err}") from err
216
+
217
+ front = parse_frontmatter(text)
218
+ name = front.get("name")
219
+ description = front.get("description")
220
+ if not isinstance(name, str) or not name.strip():
221
+ raise SkillError(f"{skill_md} frontmatter is missing a non-empty 'name'")
222
+ if not isinstance(description, str) or not description.strip():
223
+ raise SkillError(
224
+ f"{skill_md} frontmatter is missing a non-empty 'description' — "
225
+ "the description is how the agent decides whether to load the skill at all"
226
+ )
227
+ if expect_name is not None and name.strip() != expect_name:
228
+ raise SkillError(
229
+ f"{skill_md} declares name {name.strip()!r} but config.skill_name is {expect_name!r} — "
230
+ "they must match, or the skill is injected under a name its own frontmatter disagrees with"
231
+ )
232
+
233
+ return Skill(
234
+ version=directory.name,
235
+ directory=directory,
236
+ name=name.strip(),
237
+ description=description.strip(),
238
+ hash=skill_hash(directory),
239
+ )
240
+
241
+
242
+ def available_versions(skills_root: Path) -> list[str]:
243
+ """Return the skill versions present, in numeric order."""
244
+ if not skills_root.is_dir():
245
+ return []
246
+ found = [p.name for p in skills_root.iterdir() if p.is_dir() and _VERSION_RE.match(p.name)]
247
+ return sorted(found, key=version_number)
248
+
249
+
250
+ def latest_version(skills_root: Path) -> str | None:
251
+ """Return the highest skill version present, or ``None`` if there are none."""
252
+ versions = available_versions(skills_root)
253
+ return versions[-1] if versions else None
254
+
255
+
256
+ def next_version(skills_root: Path) -> str:
257
+ """Return the version a new skill should be written as."""
258
+ latest = latest_version(skills_root)
259
+ return version_name(version_number(latest) + 1 if latest else 1)
260
+
261
+
262
+ def read_meta(directory: Path) -> SkillMeta | None:
263
+ """Read a skill version's ``meta.json``, or ``None`` if it has none."""
264
+ path = directory / META_FILE
265
+ if not path.is_file():
266
+ return None
267
+ try:
268
+ raw = json.loads(path.read_text())
269
+ except (OSError, json.JSONDecodeError) as err:
270
+ raise SkillError(f"cannot read {path}: {err}") from err
271
+ feedback = raw.get("feedback")
272
+ return SkillMeta(
273
+ version=int(raw.get("version", 0)),
274
+ parent=raw.get("parent"),
275
+ rationale=str(raw.get("rationale", "")),
276
+ hash=str(raw.get("hash", "")),
277
+ feedback=str(feedback) if feedback else None,
278
+ )
279
+
280
+
281
+ def write_meta(directory: Path, *, parent: str | None, rationale: str, feedback: str | None = None) -> SkillMeta:
282
+ """Write ``meta.json`` for a skill version, hashing its content as it stands.
283
+
284
+ Call this only once the skill's content files are final — the hash is computed here.
285
+
286
+ ``feedback`` records the maintainer ``--feedback`` that shaped this version, for the
287
+ report's skill-versions provenance; it is omitted from ``meta.json`` when empty.
288
+ """
289
+ text = (feedback or "").strip()
290
+ meta = SkillMeta(
291
+ version=version_number(directory.name),
292
+ parent=parent,
293
+ rationale=rationale.strip(),
294
+ hash=skill_hash(directory),
295
+ feedback=text or None,
296
+ )
297
+ (directory / META_FILE).write_text(json.dumps(meta.to_dict(), indent=2) + "\n")
298
+ return meta