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.
Files changed (60) hide show
  1. package/DEPENDENCIES.md +35 -12
  2. package/README.md +346 -31
  3. package/claude/CLAUDE.md +2 -2
  4. package/claude/agents/frontier.md +1 -1
  5. package/claude/agents/sweep.md +3 -3
  6. package/claude/agents/workhorse.md +2 -2
  7. package/claude/guides/claude-prompting.md +119 -34
  8. package/claude/guides/cli-multi-model-workflow.md +33 -15
  9. package/claude/guides/gpt-prompting.md +148 -28
  10. package/claude/guides/review-request.md +27 -0
  11. package/claude/guides/session-distill-workflow.md +54 -2
  12. package/claude/guides/slide-writing/RUNBOOK.md +137 -0
  13. package/claude/guides/slide-writing/scripts/pair.py +979 -0
  14. package/claude/guides/slide-writing/scripts/render.mjs +82 -0
  15. package/claude/guides/slide-writing.md +195 -0
  16. package/claude/guides/svg-visualization-guide.md +9 -0
  17. package/claude/guides/verification-discipline.md +5 -1
  18. package/claude/hooks/tooling-gotchas-hook.py +7 -5
  19. package/codex/AGENTS.md +2 -2
  20. package/codex/agents/frontier.toml +2 -1
  21. package/codex/agents/reviewer.toml +1 -1
  22. package/codex/agents/sweep.toml +3 -3
  23. package/codex/agents/workhorse.toml +1 -1
  24. package/codex/config-additions.toml +1 -1
  25. package/codex/guides/claude-prompting.md +119 -34
  26. package/codex/guides/cli-multi-model-workflow.md +33 -15
  27. package/codex/guides/gpt-prompting.md +148 -28
  28. package/codex/guides/review-request.md +27 -0
  29. package/codex/guides/session-distill-workflow.md +54 -2
  30. package/codex/guides/slide-writing/RUNBOOK.md +137 -0
  31. package/codex/guides/slide-writing/scripts/pair.py +979 -0
  32. package/codex/guides/slide-writing/scripts/render.mjs +82 -0
  33. package/codex/guides/slide-writing.md +195 -0
  34. package/codex/guides/svg-visualization-guide.md +9 -0
  35. package/codex/guides/verification-discipline.md +5 -1
  36. package/compose/assemble.py +290 -14
  37. package/compose/bootstrap/SKILL.md +119 -0
  38. package/compose/check-domains.py +102 -9
  39. package/compose/corpus-state.py +1174 -0
  40. package/compose/corpus.py +387 -0
  41. package/compose/corpus_catalog.py +882 -0
  42. package/compose/corpus_install.py +1617 -0
  43. package/compose/corpus_session.py +726 -0
  44. package/compose/corpus_store.py +1414 -0
  45. package/compose/corpus_transaction.py +236 -0
  46. package/compose/corpus_ui.py +644 -0
  47. package/compose/domains.json +101 -100
  48. package/compose/write-update-cache.py +53 -0
  49. package/install.sh +174 -24
  50. package/launch/agent-launch.py +1327 -184
  51. package/launch/agent-launch.toml +12 -16
  52. package/launch/i18n/en.toml +113 -7
  53. package/launch/i18n/ja.toml +113 -7
  54. package/launch/i18n/ko.toml +113 -7
  55. package/learn/collect-learning.py +46 -19
  56. package/learn/migrate-learnings.py +10 -1
  57. package/package.json +13 -3
  58. package/provenance.json +1 -1
  59. package/session-cost.py +22 -2
  60. 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
+ }