agent-bios 0.14.0 → 0.16.0
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.
- package/DEPENDENCIES.md +35 -12
- package/README.md +346 -31
- package/claude/CLAUDE.md +2 -2
- package/claude/agents/frontier.md +1 -1
- package/claude/agents/sweep.md +3 -3
- package/claude/agents/workhorse.md +2 -2
- package/claude/guides/claude-prompting.md +119 -34
- package/claude/guides/cli-multi-model-workflow.md +33 -15
- package/claude/guides/gpt-prompting.md +148 -28
- package/claude/guides/review-request.md +27 -0
- package/claude/guides/session-distill-workflow.md +54 -2
- package/claude/guides/slide-writing/RUNBOOK.md +137 -0
- package/claude/guides/slide-writing/scripts/pair.py +979 -0
- package/claude/guides/slide-writing/scripts/render.mjs +82 -0
- package/claude/guides/slide-writing.md +195 -0
- package/claude/guides/svg-visualization-guide.md +9 -0
- package/claude/guides/verification-discipline.md +5 -1
- package/claude/hooks/tooling-gotchas-hook.py +7 -5
- package/codex/AGENTS.md +2 -2
- package/codex/agents/frontier.toml +2 -1
- package/codex/agents/reviewer.toml +1 -1
- package/codex/agents/sweep.toml +3 -3
- package/codex/agents/workhorse.toml +1 -1
- package/codex/config-additions.toml +1 -1
- package/codex/guides/claude-prompting.md +119 -34
- package/codex/guides/cli-multi-model-workflow.md +33 -15
- package/codex/guides/gpt-prompting.md +148 -28
- package/codex/guides/review-request.md +27 -0
- package/codex/guides/session-distill-workflow.md +54 -2
- package/codex/guides/slide-writing/RUNBOOK.md +137 -0
- package/codex/guides/slide-writing/scripts/pair.py +979 -0
- package/codex/guides/slide-writing/scripts/render.mjs +82 -0
- package/codex/guides/slide-writing.md +195 -0
- package/codex/guides/svg-visualization-guide.md +9 -0
- package/codex/guides/verification-discipline.md +5 -1
- package/compose/assemble.py +290 -14
- package/compose/bootstrap/SKILL.md +119 -0
- package/compose/check-domains.py +102 -9
- package/compose/corpus-state.py +1174 -0
- package/compose/corpus.py +387 -0
- package/compose/corpus_catalog.py +882 -0
- package/compose/corpus_install.py +1617 -0
- package/compose/corpus_session.py +726 -0
- package/compose/corpus_store.py +1414 -0
- package/compose/corpus_transaction.py +236 -0
- package/compose/corpus_ui.py +644 -0
- package/compose/domains.json +101 -100
- package/compose/write-update-cache.py +53 -0
- package/install.sh +174 -24
- package/launch/agent-launch.py +1327 -184
- package/launch/agent-launch.toml +12 -16
- package/launch/i18n/en.toml +113 -7
- package/launch/i18n/ja.toml +113 -7
- package/launch/i18n/ko.toml +113 -7
- package/learn/collect-learning.py +46 -19
- package/learn/migrate-learnings.py +10 -1
- package/package.json +13 -3
- package/provenance.json +1 -1
- package/session-cost.py +22 -2
- package/wrappers/codex-helm.sh +3 -3
|
@@ -0,0 +1,882 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Canonical corpus inventory and private snapshot compiler.
|
|
3
|
+
|
|
4
|
+
The catalog is intentionally a read-only view over package manifests and the
|
|
5
|
+
canonical Claude source tree. It does not know configuration-home paths and
|
|
6
|
+
never writes outside the destination handed to :func:`compile_items`.
|
|
7
|
+
|
|
8
|
+
The module is the first consumer of the stable ``item_id`` values in
|
|
9
|
+
``domains.json``. Those ids identify authored items; emitted paths and content
|
|
10
|
+
are deliberately derived separately so a body or surface edit cannot change a
|
|
11
|
+
CorpusRef.
|
|
12
|
+
"""
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import ast
|
|
16
|
+
import json
|
|
17
|
+
import hashlib
|
|
18
|
+
import os
|
|
19
|
+
import pathlib
|
|
20
|
+
import re
|
|
21
|
+
import shlex
|
|
22
|
+
import sys
|
|
23
|
+
import tempfile
|
|
24
|
+
from collections.abc import Iterable
|
|
25
|
+
from typing import Any
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
from pkgid import CORE, is_valid as valid_package_id
|
|
29
|
+
except ImportError: # pragma: no cover - package import from repository root
|
|
30
|
+
from compose.pkgid import CORE, is_valid as valid_package_id
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
SCHEMA_VERSION = 1
|
|
34
|
+
SURFACES = frozenset(("always", "relevant", "requested", "event", "delegated"))
|
|
35
|
+
KINDS = frozenset(("rule", "guide", "skill", "hook", "agent"))
|
|
36
|
+
TIERS = frozenset(("core", "domain", "env-personal", "infra"))
|
|
37
|
+
_SAFE_COMPONENT = re.compile(r"^[A-Za-z0-9][A-Za-z0-9._-]*$")
|
|
38
|
+
CLAUDE_HOOK_EVENTS = frozenset((
|
|
39
|
+
"PreToolUse", "PostToolUse", "PostToolUseFailure", "Notification",
|
|
40
|
+
"UserPromptSubmit", "SessionStart", "SessionEnd", "Stop", "SubagentStart",
|
|
41
|
+
"SubagentStop", "PreCompact", "PermissionRequest", "TeammateIdle",
|
|
42
|
+
"TaskCompleted", "ConfigChange", "WorktreeCreate", "WorktreeRemove",
|
|
43
|
+
))
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class CatalogError(ValueError):
|
|
47
|
+
"""A catalog cannot be compiled safely."""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def validate_hook_binding(hook: Any) -> None:
|
|
51
|
+
if not isinstance(hook, dict) or set(hook) != {"event", "matcher"}:
|
|
52
|
+
raise CatalogError("hook binding must contain only event and matcher")
|
|
53
|
+
event, matcher = hook.get("event"), hook.get("matcher")
|
|
54
|
+
if not isinstance(event, str) or event not in CLAUDE_HOOK_EVENTS:
|
|
55
|
+
raise CatalogError(f"unsupported Claude hook event {event!r}")
|
|
56
|
+
if not isinstance(matcher, str) or not matcher or any(char in matcher for char in "\n\r\x00"):
|
|
57
|
+
raise CatalogError("hook matcher must be one non-empty line")
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _read_utf8(path: pathlib.Path) -> str:
|
|
61
|
+
if path.is_symlink():
|
|
62
|
+
raise CatalogError(f"symlink input is not a corpus member: {path}")
|
|
63
|
+
try:
|
|
64
|
+
return path.read_text(encoding="utf-8")
|
|
65
|
+
except UnicodeDecodeError as exc:
|
|
66
|
+
raise CatalogError(f"corpus member is not UTF-8: {path}") from exc
|
|
67
|
+
except OSError as exc:
|
|
68
|
+
raise CatalogError(f"cannot read corpus member: {path}") from exc
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _relative_path(value: str, context: str) -> str:
|
|
72
|
+
if not isinstance(value, str) or not value:
|
|
73
|
+
raise CatalogError(f"{context}: member path must be a non-empty string")
|
|
74
|
+
pure = pathlib.PurePosixPath(value)
|
|
75
|
+
if pure.is_absolute() or ".." in pure.parts or "." in pure.parts:
|
|
76
|
+
raise CatalogError(f"{context}: unsafe member path {value!r}")
|
|
77
|
+
if any(not _SAFE_COMPONENT.match(part) for part in pure.parts):
|
|
78
|
+
raise CatalogError(f"{context}: unsafe member path {value!r}")
|
|
79
|
+
return pure.as_posix()
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _item_ref(package_id: str, item_id: str) -> str:
|
|
83
|
+
return f"{package_id}:{item_id}"
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def _public_item(item: dict[str, Any]) -> dict[str, Any]:
|
|
87
|
+
"""Deep-copy through JSON to make the return value safe for UI callers."""
|
|
88
|
+
return json.loads(json.dumps(item, ensure_ascii=False))
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def _content_members(item: dict[str, Any]) -> dict[str, str]:
|
|
92
|
+
"""Validate and copy the member map used as the content authority."""
|
|
93
|
+
members = item.get("members")
|
|
94
|
+
if not isinstance(members, dict) or not members:
|
|
95
|
+
raise CatalogError("content members must be a non-empty object")
|
|
96
|
+
context = str(item.get("ref", "content"))
|
|
97
|
+
normalized: dict[str, str] = {}
|
|
98
|
+
for path, content in members.items():
|
|
99
|
+
path = _relative_path(path, context)
|
|
100
|
+
if path in normalized:
|
|
101
|
+
raise CatalogError(f"{context}: duplicate member path {path!r}")
|
|
102
|
+
if not isinstance(content, str):
|
|
103
|
+
raise CatalogError(f"{context}: member {path!r} must be UTF-8 text")
|
|
104
|
+
normalized[path] = content
|
|
105
|
+
return normalized
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _legacy_primary_member(body: str, members: dict[str, str]) -> str | None:
|
|
109
|
+
"""Resolve only deterministic legacy representations; never guess intent."""
|
|
110
|
+
matching = [path for path, content in members.items() if content == body]
|
|
111
|
+
if len(matching) == 1:
|
|
112
|
+
return matching[0]
|
|
113
|
+
skills = [path for path in members if path == "SKILL.md" or path.endswith("/SKILL.md")]
|
|
114
|
+
if len(skills) == 1:
|
|
115
|
+
return skills[0]
|
|
116
|
+
# Generated rule entries historically exposed the line without the file's
|
|
117
|
+
# one trailing newline. It is a known serialization difference, not intent.
|
|
118
|
+
rules = [path for path, content in members.items() if path == "rule.md" and content == body + "\n"]
|
|
119
|
+
if len(rules) == 1:
|
|
120
|
+
return rules[0]
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def _deterministic_primary_path(members: dict[str, str]) -> str | None:
|
|
125
|
+
"""Find a legacy path only where the path itself is unambiguous."""
|
|
126
|
+
skills = [path for path in members if path == "SKILL.md" or path.endswith("/SKILL.md")]
|
|
127
|
+
if len(skills) == 1:
|
|
128
|
+
return skills[0]
|
|
129
|
+
if len(members) == 1:
|
|
130
|
+
return next(iter(members))
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _content_conflict(body: str, members: dict[str, str]) -> dict[str, Any]:
|
|
135
|
+
return {
|
|
136
|
+
"reason": "legacy body has no unambiguous matching primary_member",
|
|
137
|
+
"members": sorted(members),
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def normalize_content(item: dict[str, Any], *, allow_legacy: bool = False) -> dict[str, Any]:
|
|
142
|
+
"""Return a copied item whose ``body`` is derived from ``primary_member``.
|
|
143
|
+
|
|
144
|
+
Old snapshots may lack ``primary_member``. They are only migrated in
|
|
145
|
+
memory when a deterministic source member exists. An unresolved snapshot
|
|
146
|
+
remains readable with ``content_conflict`` and its original body/members;
|
|
147
|
+
callers must not compile it until an explicit update reconciles it.
|
|
148
|
+
"""
|
|
149
|
+
if not isinstance(item, dict):
|
|
150
|
+
raise CatalogError("content item must be an object")
|
|
151
|
+
result = _public_item(item)
|
|
152
|
+
members = _content_members(result)
|
|
153
|
+
result["members"] = members
|
|
154
|
+
primary = result.get("primary_member")
|
|
155
|
+
if primary is not None:
|
|
156
|
+
if not isinstance(primary, str) or primary not in members:
|
|
157
|
+
raise CatalogError("primary_member must name an existing member")
|
|
158
|
+
body = result.get("body")
|
|
159
|
+
if body is not None and not isinstance(body, str):
|
|
160
|
+
raise CatalogError("content body must be UTF-8 text")
|
|
161
|
+
generated_rule_newline = isinstance(body, str) and primary == "rule.md" and members[primary] == body + "\n"
|
|
162
|
+
if body is not None and body != members[primary] and not generated_rule_newline:
|
|
163
|
+
if not allow_legacy:
|
|
164
|
+
raise CatalogError("body conflicts with primary_member member content")
|
|
165
|
+
result.pop("primary_member", None)
|
|
166
|
+
result["content_conflict"] = _content_conflict(body, members)
|
|
167
|
+
return result
|
|
168
|
+
result["primary_member"] = primary
|
|
169
|
+
result["body"] = members[primary]
|
|
170
|
+
result.pop("content_conflict", None)
|
|
171
|
+
return result
|
|
172
|
+
if not allow_legacy:
|
|
173
|
+
raise CatalogError("primary_member is required")
|
|
174
|
+
body = result.get("body")
|
|
175
|
+
if not isinstance(body, str):
|
|
176
|
+
raise CatalogError("content body must be UTF-8 text")
|
|
177
|
+
primary = _legacy_primary_member(body, members)
|
|
178
|
+
if primary is not None:
|
|
179
|
+
result["primary_member"] = primary
|
|
180
|
+
result["body"] = members[primary]
|
|
181
|
+
result.pop("content_conflict", None)
|
|
182
|
+
return result
|
|
183
|
+
result.pop("primary_member", None)
|
|
184
|
+
result["content_conflict"] = _content_conflict(body, members)
|
|
185
|
+
return result
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def update_content(item: dict[str, Any], patch: dict[str, Any]) -> dict[str, Any]:
|
|
189
|
+
"""Apply a content patch while keeping member content the only authority.
|
|
190
|
+
|
|
191
|
+
A body-only edit writes the established primary member. Member-only and
|
|
192
|
+
primary-member-only edits derive the returned body. Concurrent body and
|
|
193
|
+
primary-member edits must agree, so two representations cannot silently
|
|
194
|
+
select different text.
|
|
195
|
+
"""
|
|
196
|
+
if not isinstance(patch, dict):
|
|
197
|
+
raise CatalogError("content patch must be an object")
|
|
198
|
+
base = normalize_content(item, allow_legacy=True)
|
|
199
|
+
result = _public_item(base)
|
|
200
|
+
copied_patch = _public_item(patch)
|
|
201
|
+
result.update(copied_patch)
|
|
202
|
+
body_supplied = "body" in copied_patch
|
|
203
|
+
members_supplied = "members" in copied_patch
|
|
204
|
+
primary_supplied = "primary_member" in copied_patch
|
|
205
|
+
members = _content_members(result)
|
|
206
|
+
result["members"] = members
|
|
207
|
+
|
|
208
|
+
primary = result.get("primary_member")
|
|
209
|
+
if primary is not None and (not isinstance(primary, str) or primary not in members):
|
|
210
|
+
raise CatalogError("primary_member must name an existing member")
|
|
211
|
+
if body_supplied and not isinstance(copied_patch["body"], str):
|
|
212
|
+
raise CatalogError("content body must be UTF-8 text")
|
|
213
|
+
if primary is None and body_supplied:
|
|
214
|
+
matches = [path for path, content in members.items() if content == copied_patch["body"]]
|
|
215
|
+
primary = matches[0] if len(matches) == 1 else _deterministic_primary_path(members)
|
|
216
|
+
if primary is None:
|
|
217
|
+
raise CatalogError("content_conflict: body edit needs primary_member or one matching member")
|
|
218
|
+
if primary is None and members_supplied:
|
|
219
|
+
primary = _deterministic_primary_path(members)
|
|
220
|
+
if body_supplied and primary is not None:
|
|
221
|
+
member_body = members[primary]
|
|
222
|
+
if members_supplied and member_body != copied_patch["body"]:
|
|
223
|
+
raise CatalogError("body conflicts with primary_member member content")
|
|
224
|
+
if not members_supplied:
|
|
225
|
+
members[primary] = copied_patch["body"]
|
|
226
|
+
|
|
227
|
+
if primary is not None:
|
|
228
|
+
result["primary_member"] = primary
|
|
229
|
+
result["body"] = members[primary]
|
|
230
|
+
result.pop("content_conflict", None)
|
|
231
|
+
return result
|
|
232
|
+
if primary_supplied:
|
|
233
|
+
# The explicit key was present but invalid; keep this error distinct
|
|
234
|
+
# from a legacy conflict so callers can repair the named path.
|
|
235
|
+
raise CatalogError("primary_member must name an existing member")
|
|
236
|
+
return normalize_content(result, allow_legacy=True)
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
def _validate_item(item: dict[str, Any], package_id: str, seen: set[str]) -> dict[str, Any]:
|
|
240
|
+
if not isinstance(item, dict):
|
|
241
|
+
raise CatalogError("item must be an object")
|
|
242
|
+
if not valid_package_id(package_id):
|
|
243
|
+
raise CatalogError(f"invalid package_id for item: {package_id!r}")
|
|
244
|
+
result = _public_item(item)
|
|
245
|
+
item_id = result.get("item_id")
|
|
246
|
+
if not isinstance(item_id, str) or not _SAFE_COMPONENT.match(item_id):
|
|
247
|
+
raise CatalogError(f"{package_id}: item_id must be a safe stable identifier")
|
|
248
|
+
ref = _item_ref(package_id, item_id)
|
|
249
|
+
if ref in seen:
|
|
250
|
+
raise CatalogError(f"duplicate corpus ref: {ref}")
|
|
251
|
+
seen.add(ref)
|
|
252
|
+
result["package_id"] = package_id
|
|
253
|
+
result["ref"] = ref
|
|
254
|
+
if not isinstance(result.get("title"), str) or not result["title"].strip():
|
|
255
|
+
raise CatalogError(f"{ref}: title is required")
|
|
256
|
+
if not isinstance(result.get("body"), str):
|
|
257
|
+
raise CatalogError(f"{ref}: body must be UTF-8 text")
|
|
258
|
+
if result.get("surface") not in SURFACES:
|
|
259
|
+
raise CatalogError(f"{ref}: invalid surface {result.get('surface')!r}")
|
|
260
|
+
if result.get("tier") not in TIERS:
|
|
261
|
+
raise CatalogError(f"{ref}: invalid tier {result.get('tier')!r}")
|
|
262
|
+
if result.get("kind") not in KINDS:
|
|
263
|
+
raise CatalogError(f"{ref}: invalid kind {result.get('kind')!r}")
|
|
264
|
+
domains = result.get("domains", [])
|
|
265
|
+
if not isinstance(domains, list) or any(not isinstance(d, str) or not d for d in domains):
|
|
266
|
+
raise CatalogError(f"{ref}: domains must be a list of names")
|
|
267
|
+
result["domains"] = list(dict.fromkeys(domains))
|
|
268
|
+
try:
|
|
269
|
+
result = normalize_content(result, allow_legacy=True)
|
|
270
|
+
except CatalogError as exc:
|
|
271
|
+
raise CatalogError(f"{ref}: {exc}") from exc
|
|
272
|
+
for key in ("routes", "dependencies"):
|
|
273
|
+
values = result.get(key, [])
|
|
274
|
+
if not isinstance(values, list) or any(not isinstance(v, str) for v in values):
|
|
275
|
+
raise CatalogError(f"{ref}: {key} must be a list of refs")
|
|
276
|
+
result[key] = list(dict.fromkeys(values))
|
|
277
|
+
origin = result.get("origin", {})
|
|
278
|
+
if not isinstance(origin, dict):
|
|
279
|
+
raise CatalogError(f"{ref}: origin must be an object")
|
|
280
|
+
result["origin"] = origin
|
|
281
|
+
if "hook" in result:
|
|
282
|
+
try:
|
|
283
|
+
validate_hook_binding(result["hook"])
|
|
284
|
+
except CatalogError as exc:
|
|
285
|
+
raise CatalogError(f"{ref}: {exc}") from exc
|
|
286
|
+
return result
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
def _bullet_bodies(monolith: str, manifest: dict[str, Any]) -> dict[str, str]:
|
|
290
|
+
bullets = [line for line in monolith.splitlines() if line.startswith("- ")]
|
|
291
|
+
result: dict[str, str] = {}
|
|
292
|
+
for entry in manifest.get("bullets", []):
|
|
293
|
+
anchor = entry.get("anchor")
|
|
294
|
+
if not isinstance(anchor, str):
|
|
295
|
+
raise CatalogError("bullet without anchor")
|
|
296
|
+
matches = [line for line in bullets if anchor in line]
|
|
297
|
+
if len(matches) != 1:
|
|
298
|
+
raise CatalogError(f"bullet anchor {anchor!r} matches {len(matches)} source lines")
|
|
299
|
+
result[anchor] = matches[0]
|
|
300
|
+
return result
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
def _manifest_item(
|
|
304
|
+
*, package_id: str, item_id: str, title: str, body: str, surface: str,
|
|
305
|
+
tier: str, domains: list[str], kind: str, members: dict[str, str],
|
|
306
|
+
source_path: str, primary_member: str, anchor: str | None = None, dependencies: Iterable[str] = (),
|
|
307
|
+
) -> dict[str, Any]:
|
|
308
|
+
origin: dict[str, Any] = {"source_path": source_path}
|
|
309
|
+
if anchor is not None:
|
|
310
|
+
origin["anchor"] = anchor
|
|
311
|
+
return {
|
|
312
|
+
"ref": _item_ref(package_id, item_id),
|
|
313
|
+
"package_id": package_id,
|
|
314
|
+
"item_id": item_id,
|
|
315
|
+
"title": title,
|
|
316
|
+
"body": body,
|
|
317
|
+
"surface": surface,
|
|
318
|
+
"tier": tier,
|
|
319
|
+
"domains": list(domains),
|
|
320
|
+
"kind": kind,
|
|
321
|
+
"members": members,
|
|
322
|
+
"primary_member": primary_member,
|
|
323
|
+
"origin": origin,
|
|
324
|
+
"routes": list(dependencies),
|
|
325
|
+
"dependencies": list(dependencies),
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def _surface_for(kind: str) -> str:
|
|
330
|
+
return {
|
|
331
|
+
"rule": "always",
|
|
332
|
+
"guide": "relevant",
|
|
333
|
+
"skill": "requested",
|
|
334
|
+
"hook": "event",
|
|
335
|
+
"agent": "delegated",
|
|
336
|
+
}[kind]
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def _template_hook_bindings(repo: pathlib.Path) -> dict[str, dict[str, str]]:
|
|
340
|
+
"""Hook bindings are read from the actual Claude registration template.
|
|
341
|
+
|
|
342
|
+
The template is the registration authority. A hook source path appearing in
|
|
343
|
+
a manifest description is not a registration and must never make executable
|
|
344
|
+
behavior appear in a private snapshot.
|
|
345
|
+
"""
|
|
346
|
+
path = repo / "claude" / "settings.template.json"
|
|
347
|
+
try:
|
|
348
|
+
settings = json.loads(_read_utf8(path))
|
|
349
|
+
except json.JSONDecodeError as exc:
|
|
350
|
+
raise CatalogError(f"invalid Claude hook settings template: {path}") from exc
|
|
351
|
+
hooks = settings.get("hooks") if isinstance(settings, dict) else None
|
|
352
|
+
if not isinstance(hooks, dict):
|
|
353
|
+
raise CatalogError("Claude hook settings template has no hooks object")
|
|
354
|
+
result: dict[str, dict[str, str]] = {}
|
|
355
|
+
for event, entries in hooks.items():
|
|
356
|
+
if event not in CLAUDE_HOOK_EVENTS or not isinstance(entries, list):
|
|
357
|
+
continue
|
|
358
|
+
for entry in entries:
|
|
359
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("matcher"), str):
|
|
360
|
+
continue
|
|
361
|
+
for hook in entry.get("hooks", []):
|
|
362
|
+
if not isinstance(hook, dict) or hook.get("type") != "command":
|
|
363
|
+
continue
|
|
364
|
+
command = hook.get("command")
|
|
365
|
+
if not isinstance(command, str):
|
|
366
|
+
continue
|
|
367
|
+
try:
|
|
368
|
+
tokens = shlex.split(command)
|
|
369
|
+
except ValueError:
|
|
370
|
+
continue
|
|
371
|
+
for token in tokens:
|
|
372
|
+
match = re.fullmatch(r"(?:\.?/)?central/hooks/([A-Za-z0-9][A-Za-z0-9._-]*\.py)", token)
|
|
373
|
+
if not match:
|
|
374
|
+
continue
|
|
375
|
+
name = match.group(1)
|
|
376
|
+
if name in result:
|
|
377
|
+
raise CatalogError(f"Claude hook script registered more than once: {name}")
|
|
378
|
+
result[name] = {"event": event, "matcher": entry["matcher"]}
|
|
379
|
+
return result
|
|
380
|
+
|
|
381
|
+
|
|
382
|
+
def _core_catalog(repo: pathlib.Path) -> dict[str, Any]:
|
|
383
|
+
manifest_path = repo / "compose" / "domains.json"
|
|
384
|
+
manifest = json.loads(_read_utf8(manifest_path))
|
|
385
|
+
package_id = manifest.get("package_id", CORE)
|
|
386
|
+
if not valid_package_id(package_id):
|
|
387
|
+
raise CatalogError(f"invalid core package id: {package_id!r}")
|
|
388
|
+
if manifest.get("version") != 1:
|
|
389
|
+
raise CatalogError("unsupported core manifest version")
|
|
390
|
+
domains = manifest.get("domains")
|
|
391
|
+
if not isinstance(domains, dict) or not domains:
|
|
392
|
+
raise CatalogError("core manifest domains must be non-empty")
|
|
393
|
+
if any(not isinstance(k, str) or not isinstance(v, str) for k, v in domains.items()):
|
|
394
|
+
raise CatalogError("core manifest domains must map names to descriptions")
|
|
395
|
+
monolith_path = repo / "claude" / "CLAUDE.md"
|
|
396
|
+
monolith = _read_utf8(monolith_path)
|
|
397
|
+
bullet_bodies = _bullet_bodies(monolith, manifest)
|
|
398
|
+
items: list[dict[str, Any]] = []
|
|
399
|
+
seen: set[str] = set()
|
|
400
|
+
hook_bindings = _template_hook_bindings(repo)
|
|
401
|
+
for entry in manifest.get("bullets", []):
|
|
402
|
+
item_id = entry.get("item_id")
|
|
403
|
+
anchor = entry.get("anchor")
|
|
404
|
+
if not isinstance(item_id, str):
|
|
405
|
+
raise CatalogError(f"bullet {anchor!r} lacks persistent item_id")
|
|
406
|
+
body = bullet_bodies[anchor]
|
|
407
|
+
items.append(_validate_item(_manifest_item(
|
|
408
|
+
package_id=package_id, item_id=item_id, title=anchor, body=body,
|
|
409
|
+
surface=_surface_for("rule"), tier=entry["tier"], domains=entry.get("domains", []),
|
|
410
|
+
kind="rule", members={"rule.md": body + "\n"}, primary_member="rule.md",
|
|
411
|
+
source_path="claude/CLAUDE.md",
|
|
412
|
+
anchor=anchor,
|
|
413
|
+
), package_id, seen))
|
|
414
|
+
|
|
415
|
+
section_specs = (
|
|
416
|
+
("guides", "guide", "claude/guides"),
|
|
417
|
+
("hooks", "hook", "claude/hooks"),
|
|
418
|
+
("agents", "agent", "claude/agents"),
|
|
419
|
+
)
|
|
420
|
+
for section, kind, source_dir in section_specs:
|
|
421
|
+
entries = manifest.get(section, {})
|
|
422
|
+
if not isinstance(entries, dict):
|
|
423
|
+
raise CatalogError(f"core manifest {section} must be an object")
|
|
424
|
+
for name, entry in entries.items():
|
|
425
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("item_id"), str):
|
|
426
|
+
raise CatalogError(f"{section}/{name} lacks persistent item_id")
|
|
427
|
+
source = repo / source_dir / name
|
|
428
|
+
body = _read_utf8(source)
|
|
429
|
+
members = {f"{kind}s/{name}": body}
|
|
430
|
+
if kind == "guide":
|
|
431
|
+
try:
|
|
432
|
+
from assemble import guide_members
|
|
433
|
+
except ImportError:
|
|
434
|
+
from .assemble import guide_members
|
|
435
|
+
try:
|
|
436
|
+
members = {f"guides/{member}": _read_utf8(repo / source_dir / member)
|
|
437
|
+
for member in guide_members(repo / source_dir, name)}
|
|
438
|
+
except ValueError as exc:
|
|
439
|
+
raise CatalogError(f"invalid guide bundle {name}: {exc}") from exc
|
|
440
|
+
item = _manifest_item(
|
|
441
|
+
package_id=package_id, item_id=entry["item_id"], title=name, body=body,
|
|
442
|
+
surface=_surface_for(kind), tier=entry["tier"], domains=entry.get("domains", []),
|
|
443
|
+
kind=kind, members=members,
|
|
444
|
+
primary_member=f"{kind}s/{name}", source_path=f"{source_dir}/{name}",
|
|
445
|
+
)
|
|
446
|
+
if kind == "hook" and name in hook_bindings:
|
|
447
|
+
item["hook"] = hook_bindings[name]
|
|
448
|
+
items.append(_validate_item(item, package_id, seen))
|
|
449
|
+
|
|
450
|
+
skills = manifest.get("skills", {})
|
|
451
|
+
if not isinstance(skills, dict):
|
|
452
|
+
raise CatalogError("core manifest skills must be an object")
|
|
453
|
+
for name, entry in skills.items():
|
|
454
|
+
if not isinstance(entry, dict) or not isinstance(entry.get("item_id"), str):
|
|
455
|
+
raise CatalogError(f"skills/{name} lacks persistent item_id")
|
|
456
|
+
root = repo / "claude" / "skills" / name
|
|
457
|
+
if root.is_symlink() or not root.is_dir():
|
|
458
|
+
raise CatalogError(f"skill source is not a directory: {root}")
|
|
459
|
+
members: dict[str, str] = {}
|
|
460
|
+
for path in sorted(root.rglob("*")):
|
|
461
|
+
if path.is_symlink():
|
|
462
|
+
raise CatalogError(f"symlink input is not a corpus member: {path}")
|
|
463
|
+
if path.is_file():
|
|
464
|
+
relative = path.relative_to(root).as_posix()
|
|
465
|
+
members[f"skills/{name}/{relative}"] = _read_utf8(path)
|
|
466
|
+
if f"skills/{name}/SKILL.md" not in members:
|
|
467
|
+
raise CatalogError(f"skill source lacks SKILL.md: {root}")
|
|
468
|
+
items.append(_validate_item(_manifest_item(
|
|
469
|
+
package_id=package_id, item_id=entry["item_id"], title=name,
|
|
470
|
+
body=members[f"skills/{name}/SKILL.md"], surface=_surface_for("skill"),
|
|
471
|
+
tier=entry["tier"], domains=entry.get("domains", []), kind="skill",
|
|
472
|
+
members=members, primary_member=f"skills/{name}/SKILL.md", source_path=f"claude/skills/{name}",
|
|
473
|
+
), package_id, seen))
|
|
474
|
+
if not items:
|
|
475
|
+
raise CatalogError("core catalog is empty")
|
|
476
|
+
return {
|
|
477
|
+
"schema_version": SCHEMA_VERSION,
|
|
478
|
+
"packages": [{"package_id": package_id, "domains": domains}],
|
|
479
|
+
"items": items,
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
|
|
483
|
+
def _external_catalog(package_root: pathlib.Path) -> dict[str, Any]:
|
|
484
|
+
manifest_path = package_root / "manifest.json"
|
|
485
|
+
manifest = json.loads(_read_utf8(manifest_path))
|
|
486
|
+
if manifest.get("schema_version", SCHEMA_VERSION) != SCHEMA_VERSION:
|
|
487
|
+
raise CatalogError("unsupported external package schema")
|
|
488
|
+
package_id = manifest.get("package_id")
|
|
489
|
+
if not valid_package_id(package_id):
|
|
490
|
+
raise CatalogError(f"invalid package_id: {package_id!r}")
|
|
491
|
+
domains = manifest.get("domains", {})
|
|
492
|
+
if not isinstance(domains, dict) or any(not isinstance(k, str) or not isinstance(v, str)
|
|
493
|
+
for k, v in domains.items()):
|
|
494
|
+
raise CatalogError("external package domains must map names to descriptions")
|
|
495
|
+
raw_items = manifest.get("items")
|
|
496
|
+
if raw_items is None:
|
|
497
|
+
item_path = package_root / "items.json"
|
|
498
|
+
raw_items = json.loads(_read_utf8(item_path)).get("items") if item_path.is_file() else None
|
|
499
|
+
if not isinstance(raw_items, list) or not raw_items:
|
|
500
|
+
raise CatalogError("external package must contain non-empty items")
|
|
501
|
+
seen: set[str] = set()
|
|
502
|
+
items = [_validate_item(item, package_id, seen) for item in raw_items]
|
|
503
|
+
return {"schema_version": SCHEMA_VERSION,
|
|
504
|
+
"packages": [{"package_id": package_id, "domains": domains}], "items": items}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def load_catalog(repo: pathlib.Path) -> dict[str, Any]:
|
|
508
|
+
"""Load a core repository, or an external package root with ``manifest.json``.
|
|
509
|
+
|
|
510
|
+
An external manifest uses the normalized item shape returned here, keeping a
|
|
511
|
+
single validation authority for built-in, personal, and imported packages.
|
|
512
|
+
"""
|
|
513
|
+
root = pathlib.Path(repo).resolve()
|
|
514
|
+
if root.is_symlink():
|
|
515
|
+
raise CatalogError(f"catalog root must not be a symlink: {root}")
|
|
516
|
+
if (root / "compose" / "domains.json").is_file():
|
|
517
|
+
return _core_catalog(root)
|
|
518
|
+
if (root / "manifest.json").is_file():
|
|
519
|
+
return _external_catalog(root)
|
|
520
|
+
raise CatalogError(f"no core catalog or package manifest at {root}")
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _destination_path(destination: pathlib.Path, relative: str) -> pathlib.Path:
|
|
524
|
+
relative = _relative_path(relative, "compiler")
|
|
525
|
+
path = destination / relative
|
|
526
|
+
# ``relative`` has already excluded traversal. resolve() after joining still
|
|
527
|
+
# detects a pre-existing symlink in a caller-provided destination.
|
|
528
|
+
try:
|
|
529
|
+
path.resolve().relative_to(destination.resolve())
|
|
530
|
+
except ValueError as exc:
|
|
531
|
+
raise CatalogError(f"compiler destination escapes root: {relative!r}") from exc
|
|
532
|
+
return path
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _write_private(path: pathlib.Path, content: str) -> None:
|
|
536
|
+
if path.is_symlink():
|
|
537
|
+
raise CatalogError(f"compiler refuses symlink output: {path}")
|
|
538
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
539
|
+
try:
|
|
540
|
+
with path.open('x', encoding='utf-8') as output:
|
|
541
|
+
output.write(content)
|
|
542
|
+
except FileExistsError as exc:
|
|
543
|
+
raise CatalogError(f"compiler output already exists or aliases another member: {path}") from exc
|
|
544
|
+
|
|
545
|
+
|
|
546
|
+
def _replace_private_owned(path: pathlib.Path, content: str) -> None:
|
|
547
|
+
"""Atomically rewrite a member this compiler emitted earlier in this run."""
|
|
548
|
+
if path.is_symlink() or not path.is_file():
|
|
549
|
+
raise CatalogError(f"compiler cannot replace unowned native member: {path}")
|
|
550
|
+
descriptor, name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
|
|
551
|
+
temporary = pathlib.Path(name)
|
|
552
|
+
try:
|
|
553
|
+
with os.fdopen(descriptor, "w", encoding="utf-8") as output:
|
|
554
|
+
output.write(content)
|
|
555
|
+
output.flush()
|
|
556
|
+
os.fsync(output.fileno())
|
|
557
|
+
temporary.chmod(path.stat().st_mode & 0o777)
|
|
558
|
+
os.replace(temporary, path)
|
|
559
|
+
finally:
|
|
560
|
+
temporary.unlink(missing_ok=True)
|
|
561
|
+
|
|
562
|
+
|
|
563
|
+
def _resource_rewrites(items: list[dict[str, Any]], destination: pathlib.Path) -> dict[str, str]:
|
|
564
|
+
"""Known canonical source paths -> the corresponding private copied member.
|
|
565
|
+
|
|
566
|
+
We only rewrite source links which name a catalog member. Broad replacement
|
|
567
|
+
of config-home variables would mutate unrelated prose and reintroduce a
|
|
568
|
+
global-home dependency.
|
|
569
|
+
"""
|
|
570
|
+
rewrites: dict[str, str] = {}
|
|
571
|
+
for item in items:
|
|
572
|
+
source = item.get("origin", {}).get("source_path")
|
|
573
|
+
if not isinstance(source, str):
|
|
574
|
+
continue
|
|
575
|
+
members = item["members"]
|
|
576
|
+
if item["kind"] == "skill":
|
|
577
|
+
continue
|
|
578
|
+
primary = item.get("primary_member")
|
|
579
|
+
if primary not in members:
|
|
580
|
+
continue
|
|
581
|
+
emitted_root = destination / "items" / _safe_ref_path(item["ref"])
|
|
582
|
+
emitted = emitted_root / primary
|
|
583
|
+
rewrites[source] = str(emitted)
|
|
584
|
+
# Current corpus prose uses these two host config forms. The generated
|
|
585
|
+
# target is private and host-neutral; no generic variable substitution.
|
|
586
|
+
if source.startswith("claude/guides/"):
|
|
587
|
+
tail = source.removeprefix("claude/guides/")
|
|
588
|
+
rewrites[f"${{CLAUDE_CONFIG_DIR:-$HOME/.claude}}/guides/{tail}"] = str(emitted)
|
|
589
|
+
rewrites[f"${{CODEX_HOME:-$HOME/.codex}}/guides/{tail}"] = str(emitted)
|
|
590
|
+
primary_dir = pathlib.PurePosixPath(primary).parent
|
|
591
|
+
for member in members:
|
|
592
|
+
relative = pathlib.PurePosixPath(member)
|
|
593
|
+
if not relative.is_relative_to(primary_dir):
|
|
594
|
+
continue
|
|
595
|
+
suffix = relative.relative_to(primary_dir).as_posix()
|
|
596
|
+
member_source = pathlib.PurePosixPath(source).parent / suffix
|
|
597
|
+
member_target = str(emitted_root / member)
|
|
598
|
+
rewrites[member_source.as_posix()] = member_target
|
|
599
|
+
member_tail = member_source.as_posix().removeprefix("claude/guides/")
|
|
600
|
+
rewrites[f"${{CLAUDE_CONFIG_DIR:-$HOME/.claude}}/guides/{member_tail}"] = member_target
|
|
601
|
+
rewrites[f"${{CODEX_HOME:-$HOME/.codex}}/guides/{member_tail}"] = member_target
|
|
602
|
+
return rewrites
|
|
603
|
+
|
|
604
|
+
|
|
605
|
+
def _safe_ref_path(ref: str) -> str:
|
|
606
|
+
# package refs contain '@', '/', and ':'; preserve identity without allowing
|
|
607
|
+
# their separators to create destination hierarchy outside this item root.
|
|
608
|
+
return "item-" + re.sub(r"[^A-Za-z0-9._-]", "_", ref)
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _rewrite_resources(text: str, rewrites: dict[str, str]) -> str:
|
|
612
|
+
for source, replacement in sorted(rewrites.items(), key=lambda pair: len(pair[0]), reverse=True):
|
|
613
|
+
text = text.replace(source, replacement)
|
|
614
|
+
return text
|
|
615
|
+
|
|
616
|
+
|
|
617
|
+
def _rewrite_native_hook_guide(item: dict[str, Any], text: str, rewrites: dict[str, str]) -> str:
|
|
618
|
+
"""Resolve the hook's own relative guide constant only in a native plugin."""
|
|
619
|
+
source = item.get("origin", {}).get("source_path")
|
|
620
|
+
if item.get("kind") != "hook" or not isinstance(source, str):
|
|
621
|
+
return text
|
|
622
|
+
if not source.startswith("claude/hooks/"):
|
|
623
|
+
return text
|
|
624
|
+
match = re.search(r'(?m)^GUIDE = "guides/([A-Za-z0-9][A-Za-z0-9._-]*\.md)"$', text)
|
|
625
|
+
if not match:
|
|
626
|
+
return text
|
|
627
|
+
target = rewrites.get(f"claude/guides/{match.group(1)}")
|
|
628
|
+
if target is None:
|
|
629
|
+
return text
|
|
630
|
+
return text[:match.start()] + f'GUIDE = {target!r}' + text[match.end():]
|
|
631
|
+
|
|
632
|
+
|
|
633
|
+
def _procedure_description(item: dict[str, Any]) -> str:
|
|
634
|
+
"""A compact, authored-first summary for requested procedure access."""
|
|
635
|
+
explicit = item.get("description")
|
|
636
|
+
if isinstance(explicit, str) and explicit.strip():
|
|
637
|
+
return explicit.strip()
|
|
638
|
+
match = re.search(r"^description:\s*(.+)$", item["body"], flags=re.MULTILINE)
|
|
639
|
+
if match:
|
|
640
|
+
return match.group(1).strip().strip('"')
|
|
641
|
+
for line in item["body"].splitlines():
|
|
642
|
+
text = line.strip()
|
|
643
|
+
if text and text != "---" and not text.startswith("#"):
|
|
644
|
+
return text
|
|
645
|
+
return "Private procedure"
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
def _router_text(relevant: list[dict[str, Any]], destination: pathlib.Path) -> str:
|
|
649
|
+
lines = ["# Generated relevant-corpus router", "",
|
|
650
|
+
"Use a listed guide only when the task matches its domains.", ""]
|
|
651
|
+
for item in sorted(relevant, key=lambda x: x["ref"]):
|
|
652
|
+
primary = item["_emitted_members"][item["primary_member"]]
|
|
653
|
+
domains = ", ".join(item["domains"]) or "all selected environments"
|
|
654
|
+
front = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|\Z)", item["body"], flags=re.DOTALL)
|
|
655
|
+
description = re.search(r"^description:[ \t]*(.+)$", front.group(1), flags=re.MULTILINE) if front else None
|
|
656
|
+
condition = description.group(1).strip().strip('\"\'') if description else ""
|
|
657
|
+
detail = f"{condition} — {domains}" if condition else domains
|
|
658
|
+
lines.append(f"- [{item['title']}]({primary}) — {detail} ({item['ref']})")
|
|
659
|
+
return "\n".join(lines) + "\n"
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
def _plugin_namespace(ref: str) -> str:
|
|
663
|
+
"""A compact namespace derived from the complete, package-qualified ref."""
|
|
664
|
+
return "agent-bios-" + hashlib.sha256(ref.encode("utf-8")).hexdigest()[:24]
|
|
665
|
+
|
|
666
|
+
|
|
667
|
+
def _native_hook_carrier(item: dict[str, Any]) -> tuple[str, dict[str, str]]:
|
|
668
|
+
source = item.get("origin", {}).get("source_path")
|
|
669
|
+
if item.get("kind") != "hook" or not isinstance(source, str):
|
|
670
|
+
raise CatalogError("event item has no installed Claude hook carrier provenance")
|
|
671
|
+
matched = re.fullmatch(r"claude/hooks/([A-Za-z0-9][A-Za-z0-9._-]*\.py)", source)
|
|
672
|
+
if not matched:
|
|
673
|
+
raise CatalogError("event item origin is not an installed Claude hooks/*.py carrier")
|
|
674
|
+
member = f"hooks/{matched.group(1)}"
|
|
675
|
+
if member not in item["members"]:
|
|
676
|
+
raise CatalogError("event item no longer retains its installed hook entrypoint")
|
|
677
|
+
try:
|
|
678
|
+
ast.parse(item["members"][member], filename=member)
|
|
679
|
+
except SyntaxError as exc:
|
|
680
|
+
raise CatalogError(f"native hook entrypoint is not valid Python: {exc.msg}") from exc
|
|
681
|
+
binding = item.get("hook")
|
|
682
|
+
if not isinstance(binding, dict):
|
|
683
|
+
raise CatalogError("event item has no registered Claude hook binding")
|
|
684
|
+
return member, binding
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
def _native_agent_carrier(item: dict[str, Any]) -> tuple[str, str]:
|
|
688
|
+
source = item.get("origin", {}).get("source_path")
|
|
689
|
+
if item.get("kind") != "agent" or not isinstance(source, str):
|
|
690
|
+
raise CatalogError("delegated item has no installed Claude agent carrier provenance")
|
|
691
|
+
matched = re.fullmatch(r"claude/agents/([A-Za-z0-9][A-Za-z0-9._-]*\.md)", source)
|
|
692
|
+
if not matched:
|
|
693
|
+
raise CatalogError("delegated item origin is not an installed Claude agents/*.md carrier")
|
|
694
|
+
filename = matched.group(1)
|
|
695
|
+
member = f"agents/{filename}"
|
|
696
|
+
if member not in item["members"]:
|
|
697
|
+
raise CatalogError("delegated item no longer retains its installed agent entrypoint")
|
|
698
|
+
# Claude advertises the frontmatter name, not the filename. Read only the
|
|
699
|
+
# bounded routing slug; leave all other native YAML interpretation to Claude.
|
|
700
|
+
header = re.match(r"\A---\r?\n(.*?)\r?\n---(?:\r?\n|$)", item["members"][member], re.S)
|
|
701
|
+
names = re.findall(r"(?m)^name:[ \t]*([^\r\n]*)", header.group(1)) if header else []
|
|
702
|
+
if len(names) != 1:
|
|
703
|
+
raise CatalogError("native agent needs one top-level frontmatter name")
|
|
704
|
+
name = names[0].strip()
|
|
705
|
+
if len(name) >= 2 and name[0] == name[-1] and name[0] in "\"'":
|
|
706
|
+
name = name[1:-1]
|
|
707
|
+
if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]*", name):
|
|
708
|
+
raise CatalogError("native agent name must be a plain or quoted routing slug")
|
|
709
|
+
return member, name
|
|
710
|
+
|
|
711
|
+
|
|
712
|
+
def _assert_native_member_safety(item: dict[str, Any], carrier: str, surface: str) -> None:
|
|
713
|
+
"""A plugin root may not smuggle a second discovered capability into launch."""
|
|
714
|
+
agent_members = {member for member in item["members"] if re.fullmatch(r"agents/[^/]+\.md", member)}
|
|
715
|
+
if surface == "delegated" and agent_members != {carrier}:
|
|
716
|
+
raise CatalogError("delegated item must retain exactly its one agents/*.md carrier")
|
|
717
|
+
for member in item["members"]:
|
|
718
|
+
first = pathlib.PurePosixPath(member).parts[0]
|
|
719
|
+
if first in {"skills", "commands", ".claude-plugin"}:
|
|
720
|
+
raise CatalogError(f"native plugin member would auto-discover {member!r}")
|
|
721
|
+
if member == "hooks/hooks.json" or member == "settings.json":
|
|
722
|
+
raise CatalogError(f"native plugin member would override generated registration {member!r}")
|
|
723
|
+
if first == "agents" and member != carrier:
|
|
724
|
+
raise CatalogError(f"native plugin member adds an unselected agent carrier {member!r}")
|
|
725
|
+
# Hook code and ordinary reference/assets remain valid. The generated
|
|
726
|
+
# hooks.json calls only the proven carrier, so support files stay inert.
|
|
727
|
+
|
|
728
|
+
|
|
729
|
+
def _write_plugin_manifest(root: pathlib.Path, namespace: str, item: dict[str, Any]) -> str:
|
|
730
|
+
relative = pathlib.PurePosixPath(".claude-plugin") / "plugin.json"
|
|
731
|
+
_write_private(root / relative, json.dumps({
|
|
732
|
+
"name": namespace,
|
|
733
|
+
"version": "1.0.0",
|
|
734
|
+
"description": f"Private agent-bios corpus item {item['ref']}",
|
|
735
|
+
}, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
736
|
+
return relative.as_posix()
|
|
737
|
+
|
|
738
|
+
|
|
739
|
+
def _emit_native_claude_item(
|
|
740
|
+
item: dict[str, Any], destination: pathlib.Path, namespace: str, base_instruction_text: str,
|
|
741
|
+
) -> tuple[list[str], dict[str, Any] | None]:
|
|
742
|
+
root = destination / "items" / _safe_ref_path(item["ref"])
|
|
743
|
+
if item["surface"] == "event":
|
|
744
|
+
member, binding = _native_hook_carrier(item)
|
|
745
|
+
_assert_native_member_safety(item, member, "event")
|
|
746
|
+
emitted = [_write_plugin_manifest(root, namespace, item)]
|
|
747
|
+
command = f'{shlex.quote(sys.executable)} "${{CLAUDE_PLUGIN_ROOT}}/{member}"'
|
|
748
|
+
hooks = {"hooks": {binding["event"]: [{
|
|
749
|
+
"matcher": binding["matcher"],
|
|
750
|
+
"hooks": [{"type": "command", "command": command}],
|
|
751
|
+
}]}}
|
|
752
|
+
relative = pathlib.PurePosixPath("hooks") / "hooks.json"
|
|
753
|
+
_write_private(root / relative, json.dumps(hooks, ensure_ascii=False, separators=(",", ":")) + "\n")
|
|
754
|
+
emitted.append(relative.as_posix())
|
|
755
|
+
return emitted, {"ref": item["ref"], "plugin": namespace, "hook": binding, "entrypoint": member}
|
|
756
|
+
if item["surface"] == "delegated":
|
|
757
|
+
member, name = _native_agent_carrier(item)
|
|
758
|
+
_assert_native_member_safety(item, member, "delegated")
|
|
759
|
+
# Carrier validation happens before any plugin write. The child receives
|
|
760
|
+
# the ordinary compiled snapshot, while its qualified route is added to
|
|
761
|
+
# the parent instruction later so it cannot recurse into this body.
|
|
762
|
+
target = root / member
|
|
763
|
+
_replace_private_owned(target, _read_utf8(target) + "\n\n" + base_instruction_text)
|
|
764
|
+
emitted = [_write_plugin_manifest(root, namespace, item)]
|
|
765
|
+
return emitted, {"ref": item["ref"], "plugin": namespace, "agent_name": name,
|
|
766
|
+
"route": f"{namespace}:{name}"}
|
|
767
|
+
raise CatalogError(f"unsupported native Claude surface {item['surface']!r}")
|
|
768
|
+
|
|
769
|
+
|
|
770
|
+
def compile_items(items: list[dict[str, Any]], destination: pathlib.Path, host: str,
|
|
771
|
+
native: bool = False) -> dict[str, Any]:
|
|
772
|
+
"""Emit selected items into one private destination.
|
|
773
|
+
|
|
774
|
+
``host`` is currently a validation seam for adapters. Claude and Codex use
|
|
775
|
+
the same private file layout, while the caller injects ``instruction_text``
|
|
776
|
+
through the corresponding per-session adapter.
|
|
777
|
+
"""
|
|
778
|
+
if host not in {"claude", "codex"}:
|
|
779
|
+
raise CatalogError(f"unsupported host: {host!r}")
|
|
780
|
+
if not isinstance(native, bool):
|
|
781
|
+
raise CatalogError("native must be boolean")
|
|
782
|
+
if not isinstance(items, list):
|
|
783
|
+
raise CatalogError("compiler requires an item list")
|
|
784
|
+
destination = pathlib.Path(destination)
|
|
785
|
+
if destination.exists() and destination.is_symlink():
|
|
786
|
+
raise CatalogError(f"compiler destination must not be a symlink: {destination}")
|
|
787
|
+
destination.mkdir(parents=True, exist_ok=True)
|
|
788
|
+
seen: set[str] = set()
|
|
789
|
+
normalized = [_validate_item(item, item.get("package_id", ""), seen) for item in items]
|
|
790
|
+
unresolved = [item["ref"] for item in normalized if item.get("content_conflict")]
|
|
791
|
+
if unresolved:
|
|
792
|
+
raise CatalogError(f"content_conflict: selected item needs reconciliation: {', '.join(unresolved)}")
|
|
793
|
+
rewrites = _resource_rewrites(normalized, destination)
|
|
794
|
+
files: list[str] = []
|
|
795
|
+
for item in normalized:
|
|
796
|
+
emitted: dict[str, str] = {}
|
|
797
|
+
root = destination / "items" / _safe_ref_path(item["ref"])
|
|
798
|
+
for member, content in item["members"].items():
|
|
799
|
+
relative = pathlib.PurePosixPath("items") / _safe_ref_path(item["ref"]) / member
|
|
800
|
+
target = _destination_path(destination, relative.as_posix())
|
|
801
|
+
rewritten = _rewrite_resources(content, rewrites)
|
|
802
|
+
if native and host == "claude" and item["surface"] == "event":
|
|
803
|
+
rewritten = _rewrite_native_hook_guide(item, rewritten, rewrites)
|
|
804
|
+
_write_private(target, rewritten)
|
|
805
|
+
emitted[member] = str(target)
|
|
806
|
+
files.append(relative.as_posix())
|
|
807
|
+
item["_emitted_members"] = emitted
|
|
808
|
+
|
|
809
|
+
always = [item for item in normalized if item["surface"] == "always"]
|
|
810
|
+
relevant = [item for item in normalized if item["surface"] == "relevant"]
|
|
811
|
+
requested = [item for item in normalized if item["surface"] == "requested"]
|
|
812
|
+
router_path = None
|
|
813
|
+
if relevant:
|
|
814
|
+
router_relative = "router/relevant.md"
|
|
815
|
+
router_path = _destination_path(destination, router_relative)
|
|
816
|
+
_write_private(router_path, _router_text(relevant, destination))
|
|
817
|
+
files.append(router_relative)
|
|
818
|
+
instruction_parts = ["# Activated private corpus", ""]
|
|
819
|
+
if always:
|
|
820
|
+
instruction_parts.append("## Always in this environment")
|
|
821
|
+
instruction_parts.append("")
|
|
822
|
+
instruction_parts.extend(_rewrite_resources(item["body"], rewrites).rstrip("\n") for item in always)
|
|
823
|
+
instruction_parts.append("")
|
|
824
|
+
if router_path is not None:
|
|
825
|
+
instruction_parts.append(f"Relevant procedures: {router_path}")
|
|
826
|
+
if requested:
|
|
827
|
+
instruction_parts.append("Requested procedures:")
|
|
828
|
+
for item in requested:
|
|
829
|
+
primary = item["_emitted_members"][item["primary_member"]]
|
|
830
|
+
instruction_parts.append(
|
|
831
|
+
f"- {item['title']} — {_procedure_description(item)}: {primary} ({item['ref']})")
|
|
832
|
+
base_instruction_text = "\n".join(instruction_parts).rstrip() + "\n"
|
|
833
|
+
unavailable: list[dict[str, str]] = []
|
|
834
|
+
assets: dict[str, Any] = {}
|
|
835
|
+
plugin_names: dict[str, str] = {}
|
|
836
|
+
plugin_roots: list[str] = []
|
|
837
|
+
agent_routes: list[dict[str, str]] = []
|
|
838
|
+
for item in normalized:
|
|
839
|
+
if item["surface"] not in {"event", "delegated"}:
|
|
840
|
+
continue
|
|
841
|
+
if not native:
|
|
842
|
+
unavailable.append({"ref": item["ref"], "surface": item["surface"],
|
|
843
|
+
"reason": f"native {item['surface']} consumption is disabled; opt in with --corpus-native"})
|
|
844
|
+
continue
|
|
845
|
+
if host != "claude":
|
|
846
|
+
unavailable.append({"ref": item["ref"], "surface": item["surface"],
|
|
847
|
+
"reason": f"native {item['surface']} adapter is unsupported for {host}"})
|
|
848
|
+
continue
|
|
849
|
+
namespace = _plugin_namespace(item["ref"])
|
|
850
|
+
prior = plugin_names.get(namespace)
|
|
851
|
+
if prior is not None and prior != item["ref"]:
|
|
852
|
+
raise CatalogError(f"Claude plugin namespace collision: {prior} and {item['ref']}")
|
|
853
|
+
plugin_names[namespace] = item["ref"]
|
|
854
|
+
try:
|
|
855
|
+
emitted, route = _emit_native_claude_item(item, destination, namespace, base_instruction_text)
|
|
856
|
+
except CatalogError as exc:
|
|
857
|
+
unavailable.append({"ref": item["ref"], "surface": item["surface"], "reason": str(exc)})
|
|
858
|
+
continue
|
|
859
|
+
root_relative = (pathlib.PurePosixPath("items") / _safe_ref_path(item["ref"])).as_posix()
|
|
860
|
+
plugin_roots.append(root_relative)
|
|
861
|
+
files.extend((pathlib.PurePosixPath(root_relative) / path).as_posix() for path in emitted)
|
|
862
|
+
if route and item["surface"] == "delegated":
|
|
863
|
+
agent_routes.append(route)
|
|
864
|
+
if native and host == "claude":
|
|
865
|
+
assets["claude_plugins"] = sorted(plugin_roots)
|
|
866
|
+
instruction_text = base_instruction_text
|
|
867
|
+
if agent_routes:
|
|
868
|
+
instruction_text += "\nNative delegated agents:\n"
|
|
869
|
+
for route in sorted(agent_routes, key=lambda row: row["ref"]):
|
|
870
|
+
instruction_text += f"- {route['route']} ({route['ref']})\n"
|
|
871
|
+
instruction_relative = "launch-content/instructions.md"
|
|
872
|
+
_write_private(_destination_path(destination, instruction_relative), instruction_text)
|
|
873
|
+
files.append(instruction_relative)
|
|
874
|
+
for item in normalized:
|
|
875
|
+
item.pop("_emitted_members", None)
|
|
876
|
+
return {
|
|
877
|
+
"instruction_text": instruction_text,
|
|
878
|
+
"files": sorted(files),
|
|
879
|
+
"item_refs": [item["ref"] for item in normalized],
|
|
880
|
+
"unavailable": unavailable,
|
|
881
|
+
"assets": assets,
|
|
882
|
+
}
|