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,1414 @@
1
+ #!/usr/bin/env python3
2
+ """Private, revision-checked storage for an activated corpus.
3
+
4
+ The store deliberately owns source state and immutable projections only. It
5
+ does not alter a host configuration or claim that a snapshot was loaded by a
6
+ host; that is the launch adapter's job.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import copy
11
+ import contextlib
12
+ import datetime as dt
13
+ import hashlib
14
+ import importlib
15
+ import json
16
+ import os
17
+ from pathlib import Path, PurePosixPath
18
+ import re
19
+ import tempfile
20
+ import uuid
21
+ from typing import Any, Iterator
22
+
23
+ try:
24
+ from corpus_transaction import transaction_lock, guard_pending, pending_operations, operation_scope_active
25
+ except ImportError:
26
+ from .corpus_transaction import transaction_lock, guard_pending, pending_operations, operation_scope_active
27
+
28
+ SCHEMA_VERSION = 1
29
+ LOCAL_PACKAGE = "@local/personal"
30
+ SURFACES = {"always", "relevant", "requested", "event", "delegated"}
31
+ KINDS = {"rule", "guide", "skill", "hook", "agent"}
32
+ ITEM_FIELDS = {
33
+ "ref", "package_id", "item_id", "title", "body", "surface", "tier",
34
+ "domains", "kind", "members", "origin", "routes", "dependencies",
35
+ "active", "learning_source", "hook", "primary_member",
36
+ }
37
+ RUNTIME_FIELDS = {
38
+ "digest", "revision", "content_ref", "path", "state", "created_at",
39
+ "updated_at", "baseline_ref", "plan_id", "history_id",
40
+ }
41
+ LEARNING_FIELDS = {"schema_version", "learning_id", "lesson", "domain", "created", "supporting_sessions",
42
+ "criteria", "classification", "proposed_domain", "context"}
43
+ LEARNING_ID_RE = re.compile(r"^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$")
44
+
45
+
46
+ class CorpusStoreError(RuntimeError):
47
+ pass
48
+
49
+
50
+ class StaleRevision(CorpusStoreError):
51
+ pass
52
+
53
+
54
+ class ValidationError(CorpusStoreError):
55
+ pass
56
+
57
+
58
+ def _canonical(value: Any) -> bytes:
59
+ return json.dumps(value, ensure_ascii=False, sort_keys=True, separators=(",", ":")).encode("utf-8")
60
+
61
+
62
+ def _digest(value: Any) -> str:
63
+ if isinstance(value, bytes):
64
+ return hashlib.sha256(value).hexdigest()
65
+ return hashlib.sha256(_canonical(value)).hexdigest()
66
+
67
+
68
+ def _utcnow() -> str:
69
+ return dt.datetime.now(dt.timezone.utc).isoformat(timespec="microseconds")
70
+
71
+
72
+ def _safe_part(value: str, label: str = "path") -> str:
73
+ if not isinstance(value, str) or not value or "\x00" in value:
74
+ raise ValidationError(f"invalid {label}")
75
+ path = PurePosixPath(value)
76
+ if path.is_absolute() or ".." in path.parts or path == PurePosixPath("."):
77
+ raise ValidationError(f"unowned {label}: {value!r}")
78
+ return value
79
+
80
+
81
+ def _host(value: str) -> str:
82
+ if value not in {"claude", "codex"}:
83
+ raise ValidationError(f"unsupported host: {value!r}")
84
+ return value
85
+
86
+
87
+ def _reject_symlink_path(path: Path, *, leaf: bool = True) -> None:
88
+ """Refuse a symlink at the store-owned path boundary.
89
+
90
+ Platform temporary directories commonly sit below system symlink aliases
91
+ (macOS `/var` is one), so the boundary deliberately stops at the configured
92
+ store path or its direct parent. Every store-created nested root is checked
93
+ when it becomes the direct parent of a write; the lock itself also uses
94
+ ``O_NOFOLLOW`` against a final-component substitution race.
95
+ """
96
+ target = path if leaf else path.parent
97
+ if target.exists() or target.is_symlink():
98
+ if target.is_symlink():
99
+ raise CorpusStoreError(f"symlink is not a corpus-store path: {target}")
100
+
101
+
102
+ def _json_read(path: Path, default: Any = None) -> Any:
103
+ _reject_symlink_path(path)
104
+ if not path.is_file():
105
+ return copy.deepcopy(default)
106
+ try:
107
+ return json.loads(path.read_text(encoding="utf-8"))
108
+ except (OSError, ValueError) as exc:
109
+ raise CorpusStoreError(f"unreadable state at {path}: {exc}") from exc
110
+
111
+
112
+ def _atomic_write(path: Path, value: Any) -> None:
113
+ _reject_symlink_path(path)
114
+ _reject_symlink_path(path, leaf=False)
115
+ path.parent.mkdir(parents=True, exist_ok=True)
116
+ body = _canonical(value) + b"\n"
117
+ fd, tmp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
118
+ tmp = Path(tmp_name)
119
+ try:
120
+ with os.fdopen(fd, "wb") as handle:
121
+ handle.write(body)
122
+ handle.flush()
123
+ os.fsync(handle.fileno())
124
+ if path.exists():
125
+ tmp.chmod(path.stat().st_mode & 0o777)
126
+ os.replace(tmp, path)
127
+ # Persist the directory entry as well as the file bytes.
128
+ directory = os.open(path.parent, os.O_RDONLY)
129
+ try:
130
+ os.fsync(directory)
131
+ finally:
132
+ os.close(directory)
133
+ finally:
134
+ with contextlib.suppress(FileNotFoundError):
135
+ tmp.unlink()
136
+
137
+
138
+ def _copy_json(value: Any) -> Any:
139
+ return json.loads(_canonical(value))
140
+
141
+
142
+ def _rewrite_staged_paths(staging: Path, destination: Path) -> None:
143
+ """Replace compiler-internal absolute staging links before publication."""
144
+ old, new = str(staging), str(destination)
145
+ for path in staging.rglob("*"):
146
+ if not path.is_file():
147
+ continue
148
+ try:
149
+ text = path.read_text(encoding="utf-8")
150
+ except UnicodeDecodeError as exc:
151
+ raise CorpusStoreError(f"snapshot compiler emitted non-text member: {path}") from exc
152
+ if old in text:
153
+ path.write_text(text.replace(old, new), encoding="utf-8")
154
+
155
+
156
+ def _snapshot_relative_paths(files: Any) -> list[str]:
157
+ if not isinstance(files, list) or not files or not all(isinstance(path, str) for path in files):
158
+ raise ValidationError("snapshot compiler returned an empty or invalid file set")
159
+ normalized = [_safe_part(path, "snapshot file") for path in files]
160
+ if len(set(normalized)) != len(normalized):
161
+ raise ValidationError("snapshot compiler returned duplicate files")
162
+ return sorted(normalized)
163
+
164
+
165
+ def _snapshot_file_digests(root: Path, files: list[str]) -> dict[str, str]:
166
+ expected = sorted(set(files) | {"output.json"})
167
+ result: dict[str, str] = {}
168
+ for relative in expected:
169
+ path = root / _safe_part(relative, "snapshot file")
170
+ if path.is_symlink() or not path.is_file():
171
+ raise CorpusStoreError(f"snapshot output is missing or symlinked: {relative}")
172
+ result[relative] = _digest(path.read_bytes())
173
+ return result
174
+
175
+
176
+ def _snapshot_assets(value: Any, files: list[str]) -> dict[str, Any]:
177
+ if not isinstance(value, dict) or set(value) - {"claude_plugins"}:
178
+ raise ValidationError("invalid snapshot native assets")
179
+ plugins = value.get("claude_plugins", [])
180
+ if not isinstance(plugins, list) or not all(isinstance(path, str) for path in plugins):
181
+ raise ValidationError("snapshot plugins must be relative paths")
182
+ if len(plugins) != len(set(plugins)):
183
+ raise ValidationError("duplicate snapshot plugin")
184
+ for relative in plugins:
185
+ _safe_part(relative, "snapshot plugin")
186
+ if PurePosixPath(relative).as_posix() != relative:
187
+ raise ValidationError("snapshot plugin path is not canonical")
188
+ if f"{relative}/.claude-plugin/plugin.json" not in files:
189
+ raise ValidationError("snapshot plugin has no inventoried manifest")
190
+ return _copy_json(value)
191
+
192
+
193
+ def verify_snapshot(path: Path, expected_content_ref: str) -> dict[str, Any]:
194
+ """Validate a persisted snapshot without consulting current authoring state."""
195
+ root = Path(path)
196
+ _safe_part(expected_content_ref, "content ref")
197
+ if root.is_symlink() or not root.is_dir():
198
+ raise ValidationError(f"snapshot is missing or symlinked: {root}")
199
+ inventory = _json_read(root / "inventory.json")
200
+ output = _json_read(root / "output.json")
201
+ if not isinstance(inventory, dict) or not isinstance(output, dict):
202
+ raise ValidationError("snapshot metadata is unreadable")
203
+ inputs = inventory.get("inputs")
204
+ if not isinstance(inputs, dict) or _digest(inputs) != expected_content_ref:
205
+ raise ValidationError("snapshot content ref does not match canonical inputs")
206
+ files = _snapshot_relative_paths(output.get("files"))
207
+ _snapshot_assets(output.get("assets", {}), files)
208
+ digests = inventory.get("file_digests")
209
+ if not isinstance(digests, dict) or not digests:
210
+ raise ValidationError("snapshot has no file digest inventory")
211
+ expected = set(files) | {"output.json"}
212
+ if set(digests) != expected:
213
+ raise ValidationError("snapshot digest file set is not exact")
214
+ actual_files: set[str] = set()
215
+ for candidate in root.rglob("*"):
216
+ if candidate.is_symlink():
217
+ raise ValidationError(f"snapshot contains symlink: {candidate.relative_to(root)}")
218
+ if candidate.is_file() and candidate != root / "inventory.json":
219
+ actual_files.add(candidate.relative_to(root).as_posix())
220
+ if actual_files != expected:
221
+ raise ValidationError("snapshot persisted file set is not exact")
222
+ for relative, digest in digests.items():
223
+ if not isinstance(digest, str) or _digest((root / relative).read_bytes()) != digest:
224
+ raise ValidationError(f"snapshot file digest mismatch: {relative}")
225
+ return {"content_ref": expected_content_ref, "path": str(root), "inputs": inputs,
226
+ "items": inventory.get("items"), "file_digests": digests, "output": output}
227
+
228
+
229
+ class CorpusStore:
230
+ """The private corpus source, transaction journal, and snapshot compiler.
231
+
232
+ ``state_root`` is deploy/runtime-owned. ``user_root`` is deliberately a
233
+ separate authority: install never overwrites it.
234
+ """
235
+
236
+ def __init__(self, repo: Path, state_root: Path | None = None, user_root: Path | None = None):
237
+ self.repo = Path(repo).resolve()
238
+ self.state_root = Path(state_root or os.environ.get(
239
+ "AGENT_BIOS_STATE_DIR", str(Path.home() / ".local/share/agent-bios")
240
+ )).expanduser()
241
+ self.user_root = Path(user_root or os.environ.get(
242
+ "AGENT_BIOS_CORPUS_DIR", str(Path.home() / ".config/agent-bios/corpus")
243
+ )).expanduser()
244
+ self.runtime = self.state_root / "runtime"
245
+ self.sessions = self.state_root / "sessions"
246
+
247
+ # ---- layout and locking -------------------------------------------------
248
+
249
+ @property
250
+ def _runtime_state_path(self) -> Path:
251
+ return self.runtime / "state.json"
252
+
253
+ @property
254
+ def _user_state_path(self) -> Path:
255
+ return self.user_root / "state.json"
256
+
257
+ @contextlib.contextmanager
258
+ def _lock(self) -> Iterator[None]:
259
+ _reject_symlink_path(self.state_root, leaf=False)
260
+ _reject_symlink_path(self.state_root)
261
+ _reject_symlink_path(self.user_root, leaf=False)
262
+ _reject_symlink_path(self.user_root)
263
+ self.state_root.mkdir(parents=True, exist_ok=True)
264
+ with transaction_lock(self.state_root):
265
+ yield
266
+
267
+ def _empty_runtime(self) -> dict[str, Any]:
268
+ return {
269
+ "schema_version": SCHEMA_VERSION,
270
+ "last_successful_install_ref": None,
271
+ "selected_baseline_ref": None,
272
+ }
273
+
274
+ def _empty_user(self) -> dict[str, Any]:
275
+ return {
276
+ "schema_version": SCHEMA_VERSION,
277
+ "items": {},
278
+ "overrides": {},
279
+ "tombstones": {},
280
+ "selection": None,
281
+ "learning_suppressions": {"claude": [], "codex": []},
282
+ }
283
+
284
+ def _runtime_state(self) -> dict[str, Any]:
285
+ state = _json_read(self._runtime_state_path, self._empty_runtime())
286
+ if state.get("schema_version") != SCHEMA_VERSION:
287
+ raise CorpusStoreError("runtime state schema mismatch")
288
+ return state
289
+
290
+ def _user_state(self) -> dict[str, Any]:
291
+ state = _json_read(self._user_state_path, self._empty_user())
292
+ if state.get("schema_version") != SCHEMA_VERSION:
293
+ raise CorpusStoreError("personal state schema mismatch")
294
+ # v1 sources created before reset acquired this projection field. It
295
+ # has a deterministic empty meaning and is persisted on the next
296
+ # authoring transaction rather than rewritten by a read.
297
+ state.setdefault("learning_suppressions", {"claude": [], "codex": []})
298
+ for field, typ in (("items", dict), ("overrides", dict), ("tombstones", dict), ("learning_suppressions", dict)):
299
+ if not isinstance(state.get(field), typ):
300
+ raise CorpusStoreError(f"personal state has invalid {field}")
301
+ return state
302
+
303
+ def _write_transaction(self, tx_id: str, record: dict[str, Any]) -> None:
304
+ _atomic_write(self.runtime / "transactions" / tx_id / "journal.json", record)
305
+
306
+ def _recover_locked(self) -> None:
307
+ """Finish a source publication interrupted after its PREPARED journal.
308
+
309
+ User and runtime sources have separate ownership roots and cannot share
310
+ one rename. The journal therefore records both candidate documents
311
+ before either pointer moves. Recovery accepts only an exact prior/next
312
+ pair and finishes the known transaction; any third value is evidence of
313
+ an out-of-band writer and remains a fail-loud recovery record.
314
+ """
315
+ guard_pending(self.state_root)
316
+ root = self.runtime / "transactions"
317
+ if not root.is_dir():
318
+ return
319
+ for journal_path in sorted(root.glob("*/journal.json")):
320
+ journal = _json_read(journal_path)
321
+ if isinstance(journal, dict) and journal.get("state") == "NEEDS_RECOVERY":
322
+ raise CorpusStoreError(f"transaction {journal_path.parent.name} needs manual recovery")
323
+ if not isinstance(journal, dict) or journal.get("state") != "PREPARED":
324
+ continue
325
+ plan = journal.get("plan")
326
+ if not isinstance(plan, dict) or not isinstance(plan.get("before"), dict) or not isinstance(plan.get("after"), dict):
327
+ raise CorpusStoreError(f"invalid prepared transaction {journal_path.parent.name}")
328
+ current_runtime = _json_read(self._runtime_state_path, self._empty_runtime())
329
+ current_user = _json_read(self._user_state_path, self._empty_user())
330
+ prior, candidate = plan["before"], plan["after"]
331
+ known_runtime = current_runtime in (prior.get("runtime"), candidate.get("runtime"))
332
+ known_user = current_user in (prior.get("user"), candidate.get("user"))
333
+ if not known_runtime or not known_user:
334
+ journal["state"] = "NEEDS_RECOVERY"
335
+ journal["recovery_error"] = "source differs from prepared transaction prior and candidate"
336
+ _atomic_write(journal_path, journal)
337
+ raise CorpusStoreError(f"transaction {journal_path.parent.name} needs manual recovery")
338
+ history_id = journal.get("history_id")
339
+ if history_id is None:
340
+ instant = plan.get("created_at", journal.get("prepared_at", _utcnow()))
341
+ history_id = f"{instant.replace(':', '').replace('+00:00', 'Z')}-{journal_path.parent.name[:12]}"
342
+ journal["history_id"] = history_id
343
+ _atomic_write(journal_path, journal)
344
+ history = {"runtime": prior["runtime"], "user": prior["user"],
345
+ "revision": plan.get("expected_revision")}
346
+ history_path = self.user_root / "history" / history_id / "state.json"
347
+ if not history_path.exists():
348
+ _atomic_write(history_path, history)
349
+ if plan.get("details", {}).get("operation") == "reset":
350
+ trash_path = self.user_root / "trash" / history_id / "state.json"
351
+ if not trash_path.exists():
352
+ _atomic_write(trash_path, history)
353
+ _atomic_write(self._user_state_path, candidate["user"])
354
+ _atomic_write(self._runtime_state_path, candidate["runtime"])
355
+ journal["state"] = "RECOVERED_COMMITTED"
356
+ journal["recovered_at"] = _utcnow()
357
+ _atomic_write(journal_path, journal)
358
+
359
+ # ---- baseline and source resolution ------------------------------------
360
+
361
+ def _catalog_module(self):
362
+ try:
363
+ return importlib.import_module("corpus_catalog")
364
+ except ModuleNotFoundError:
365
+ # Package execution is useful in tests and is harmless in a checkout.
366
+ import sys
367
+ sys.path.insert(0, str(self.repo / "compose"))
368
+ try:
369
+ return importlib.import_module("corpus_catalog")
370
+ except ModuleNotFoundError as exc:
371
+ raise CorpusStoreError("compose/corpus_catalog.py is required") from exc
372
+
373
+ def _baseline_dir(self, ref: str) -> Path:
374
+ _safe_part(ref, "baseline reference")
375
+ return self.runtime / "baselines" / ref
376
+
377
+ def _normalize_content(self, item: dict[str, Any], *, allow_legacy: bool = False) -> dict[str, Any]:
378
+ return self._catalog_module().normalize_content(item, allow_legacy=allow_legacy)
379
+
380
+ def _read_baseline(self, ref: str) -> tuple[dict[str, Any], dict[str, Any]]:
381
+ root = self._baseline_dir(ref)
382
+ inventory = _json_read(root / "inventory.json")
383
+ defaults = _json_read(root / "defaults.json")
384
+ if not isinstance(inventory, dict) or not isinstance(defaults, dict):
385
+ raise CorpusStoreError(f"missing baseline tuple {ref}")
386
+ return inventory, defaults
387
+
388
+ def _baseline_promotions(self, ref: str) -> dict[str, Any]:
389
+ data = _json_read(self._baseline_dir(ref) / "promotions.json", {"version": 0, "promotions": []})
390
+ if not isinstance(data, dict) or not isinstance(data.get("promotions"), list):
391
+ raise CorpusStoreError(f"invalid promotion data in baseline {ref}")
392
+ return data
393
+
394
+ def _selected_baseline(self, runtime: dict[str, Any]) -> tuple[str, dict[str, Any], dict[str, Any]]:
395
+ ref = runtime.get("selected_baseline_ref")
396
+ if not isinstance(ref, str):
397
+ raise CorpusStoreError("no installed baseline; run install first")
398
+ inventory, defaults = self._read_baseline(ref)
399
+ return ref, inventory, defaults
400
+
401
+ def _learning_events(self, host: str) -> list[dict[str, Any]]:
402
+ _host(host)
403
+ _reject_symlink_path(self.user_root)
404
+ _reject_symlink_path(self.user_root / "learnings")
405
+ _reject_symlink_path(self.user_root / "learnings" / host)
406
+ path = self.user_root / "learnings" / host / "events.jsonl"
407
+ _reject_symlink_path(path)
408
+ if not path.is_file():
409
+ return []
410
+ events: list[dict[str, Any]] = []
411
+ for line_no, raw in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
412
+ if not raw.strip():
413
+ continue
414
+ try:
415
+ event = json.loads(raw)
416
+ except ValueError as exc:
417
+ raise CorpusStoreError(f"invalid learning event {path}:{line_no}") from exc
418
+ if not isinstance(event, dict):
419
+ raise CorpusStoreError(f"invalid learning event {path}:{line_no}")
420
+ events.append(event)
421
+ return events
422
+
423
+ def _learning_item(self, host: str, event: dict[str, Any]) -> dict[str, Any] | None:
424
+ learning_id = event["learning_id"]
425
+ body = event["lesson"]
426
+ return {
427
+ "ref": f"@local/learnings-{host}:{learning_id}",
428
+ "package_id": f"@local/learnings-{host}", "item_id": learning_id,
429
+ "title": event["domain"],
430
+ "body": body, "surface": event.get("surface", "always"),
431
+ "tier": event.get("tier", "env-personal"), "domains": ["personal"], "kind": "rule",
432
+ "members": {"learning.md": body}, "origin": {"type": "learning", "host": host},
433
+ "learning_source": True,
434
+ }
435
+
436
+ @staticmethod
437
+ def _learning_host_from_ref(ref: str) -> str | None:
438
+ prefix = "@local/learnings-"
439
+ if not ref.startswith(prefix) or ":" not in ref:
440
+ return None
441
+ host = ref[len(prefix):].split(":", 1)[0]
442
+ return host if host in {"claude", "codex"} else None
443
+
444
+ def _effective_items(
445
+ self, runtime: dict[str, Any], user: dict[str, Any], host: str | None = None, include_suppressed: bool = False
446
+ ) -> tuple[list[dict[str, Any]], dict[str, Any], dict[str, Any], str]:
447
+ baseline_ref, inventory, defaults = self._selected_baseline(runtime)
448
+ source = inventory.get("items")
449
+ if not isinstance(source, list):
450
+ raise CorpusStoreError(f"baseline {baseline_ref} has no item inventory")
451
+ items: dict[str, dict[str, Any]] = {}
452
+ for raw in source:
453
+ item = self._validate_item(raw, allow_origin=True)
454
+ items[item["ref"]] = item
455
+ for ref, raw in user["items"].items():
456
+ item = self._validate_item(raw, allow_origin=True)
457
+ if item["ref"] != ref or item["package_id"] != LOCAL_PACKAGE:
458
+ raise CorpusStoreError("personal item identity mismatch")
459
+ items[ref] = item
460
+ if host:
461
+ suppressed = set(user.get("learning_suppressions", {}).get(host, []))
462
+ for event in self._learning_events(host):
463
+ suppressed_event = _digest(event) in suppressed
464
+ if suppressed_event and not include_suppressed:
465
+ continue
466
+ item = self._learning_item(host, event)
467
+ if item is not None:
468
+ if suppressed_event:
469
+ item["active"] = False
470
+ items[item["ref"]] = self._validate_item(item, allow_origin=True)
471
+ for ref, override in user["overrides"].items():
472
+ if ref not in items:
473
+ continue
474
+ base_digest = override.get("base_digest")
475
+ patch = override.get("patch")
476
+ if not isinstance(base_digest, str) or not isinstance(patch, dict):
477
+ raise CorpusStoreError(f"invalid override for {ref}")
478
+ # An upstream change to the same base is not silently merged.
479
+ if _digest(items[ref]) != base_digest:
480
+ items[ref]["conflict"] = {"base_digest": base_digest, "current_digest": _digest(items[ref])}
481
+ continue
482
+ merged = {**items[ref], **patch}
483
+ items[ref] = self._validate_item(merged, allow_origin=True)
484
+ for ref in user["tombstones"]:
485
+ if include_suppressed and self._learning_host_from_ref(ref) == host and ref in items:
486
+ items[ref]["active"] = False
487
+ else:
488
+ items.pop(ref, None)
489
+ return [self._normalize_content(item, allow_legacy=True) for item in items.values()], inventory, defaults, baseline_ref
490
+
491
+ def _resolve_promotions(
492
+ self, selected: list[dict[str, Any]], user: dict[str, Any], host: str, baseline_ref: str
493
+ ) -> tuple[list[dict[str, Any]], list[dict[str, Any]]]:
494
+ """Suppress only a source event proven replaced in this exact snapshot."""
495
+ promotions = self._baseline_promotions(baseline_ref)
496
+ by_id: dict[str, list[dict[str, Any]]] = {}
497
+ warnings: list[dict[str, Any]] = []
498
+ for row in promotions["promotions"]:
499
+ if not isinstance(row, dict):
500
+ warnings.append({"reason": "invalid_promotion_row"})
501
+ continue
502
+ required = {"learning_id", "host", "source_digest", "target_ref", "target_digest"}
503
+ if not required <= set(row):
504
+ # v2 rows are audience claims only. They must never cause a
505
+ # source deletion merely because an anchor happens to match.
506
+ if isinstance(row.get("learning_id"), str):
507
+ warnings.append({"ref": f"@local/learnings-{host}:{row['learning_id']}",
508
+ "reason": "promotion_mapping_incomplete"})
509
+ continue
510
+ if all(isinstance(row.get(field), str) for field in required):
511
+ by_id.setdefault(row["learning_id"], []).append(row)
512
+ else:
513
+ warnings.append({"reason": "invalid_promotion_row"})
514
+ chosen = {item["ref"]: item for item in selected}
515
+ retained: list[dict[str, Any]] = []
516
+ for item in selected:
517
+ if item.get("origin", {}).get("type") != "learning" or item.get("origin", {}).get("host") != host:
518
+ retained.append(item)
519
+ continue
520
+ learning_id = item["item_id"]
521
+ event = next((event for event in self._learning_events(host)
522
+ if (event.get("learning_id") or event.get("id")) == learning_id), None)
523
+ candidates = [row for row in by_id.get(learning_id, [])
524
+ if row["host"] == host and event is not None and row["source_digest"] == _digest(event)]
525
+ if not candidates:
526
+ retained.append(item)
527
+ continue
528
+ row = candidates[0]
529
+ target = chosen.get(row["target_ref"])
530
+ if target is None:
531
+ retained.append(item)
532
+ continue
533
+ if row["target_digest"] != _digest(target):
534
+ warnings.append({"ref": item["ref"], "reason": "promotion_target_digest_mismatch"})
535
+ retained.append(item)
536
+ continue
537
+ member = row.get("target_member")
538
+ if row.get("exclusive") is True and target["body"] == item["body"]:
539
+ continue
540
+ if isinstance(member, str) and target.get("members", {}).get(member) == item["body"]:
541
+ baseline_inventory, _defaults = self._read_baseline(baseline_ref)
542
+ original = next((raw for raw in baseline_inventory["items"] if raw.get("ref") == row["target_ref"]), None)
543
+ if not isinstance(original, dict) or not isinstance(original.get("members"), dict):
544
+ raise ValidationError(f"learning_rebase_conflict: {item['ref']}")
545
+ if set(original["members"]) - set(target["members"]):
546
+ raise ValidationError(f"learning_rebase_conflict: {item['ref']}")
547
+ continue
548
+ raise ValidationError(f"learning_rebase_conflict: {item['ref']}")
549
+ return retained, warnings
550
+
551
+ def _authoring_revision(self, runtime: dict[str, Any], user: dict[str, Any]) -> str:
552
+ learning = {}
553
+ for host in ("claude", "codex"):
554
+ events = self._learning_events(host)
555
+ learning[host] = _digest(events)
556
+ return _digest({
557
+ "selected_baseline_ref": runtime.get("selected_baseline_ref"),
558
+ "last_successful_install_ref": runtime.get("last_successful_install_ref"),
559
+ "user": user, "learning": learning,
560
+ })
561
+
562
+ # ---- validation ---------------------------------------------------------
563
+
564
+ def _validate_item(self, raw: Any, *, allow_origin: bool = False) -> dict[str, Any]:
565
+ if not isinstance(raw, dict):
566
+ raise ValidationError("item must be an object")
567
+ unknown = set(raw) - ITEM_FIELDS - {"conflict", "content_conflict"}
568
+ if unknown:
569
+ raise ValidationError(f"unknown item fields: {', '.join(sorted(unknown))}")
570
+ runtime = set(raw) & RUNTIME_FIELDS
571
+ if runtime:
572
+ raise ValidationError(f"runtime-owned item fields: {', '.join(sorted(runtime))}")
573
+ item = _copy_json(raw)
574
+ required = ("ref", "package_id", "item_id", "title", "body", "surface", "tier", "domains", "kind", "members")
575
+ missing = [field for field in required if field not in item]
576
+ if missing:
577
+ raise ValidationError(f"item missing fields: {', '.join(missing)}")
578
+ for field in ("ref", "package_id", "item_id", "title", "tier"):
579
+ if not isinstance(item[field], str) or not item[field]:
580
+ raise ValidationError(f"invalid item {field}")
581
+ if not isinstance(item["body"], str):
582
+ raise ValidationError("invalid item body")
583
+ if item["ref"] != f"{item['package_id']}:{item['item_id']}":
584
+ raise ValidationError("item ref does not match package_id and item_id")
585
+ catalog = self._catalog_module()
586
+ surfaces = getattr(catalog, "SURFACES", SURFACES)
587
+ kinds = getattr(catalog, "KINDS", KINDS)
588
+ if item["surface"] not in surfaces or item["kind"] not in kinds:
589
+ raise ValidationError("invalid consumption surface or kind")
590
+ if "hook" in item:
591
+ try:
592
+ catalog.validate_hook_binding(item["hook"])
593
+ except (ValueError, AttributeError) as exc:
594
+ raise ValidationError(f"invalid hook binding: {exc}") from exc
595
+ if not isinstance(item["domains"], list) or not all(isinstance(x, str) for x in item["domains"]):
596
+ raise ValidationError("invalid item domains")
597
+ if not isinstance(item["members"], dict):
598
+ raise ValidationError("invalid item members")
599
+ for path, body in item["members"].items():
600
+ _safe_part(path, "member path")
601
+ if not isinstance(body, str):
602
+ raise ValidationError("member body must be text")
603
+ if "origin" in item and not isinstance(item["origin"], dict):
604
+ raise ValidationError("invalid item origin")
605
+ if not allow_origin and "origin" in item:
606
+ raise ValidationError("origin is catalog-owned")
607
+ return item
608
+
609
+ def _validate_patch(self, patch: Any) -> dict[str, Any]:
610
+ if not isinstance(patch, dict) or not patch:
611
+ raise ValidationError("update needs a non-empty patch")
612
+ unknown = set(patch) - (ITEM_FIELDS - {"ref", "package_id", "item_id", "origin", "active", "learning_source"})
613
+ if unknown:
614
+ raise ValidationError(f"unknown or immutable patch fields: {', '.join(sorted(unknown))}")
615
+ if set(patch) & RUNTIME_FIELDS:
616
+ raise ValidationError("runtime-owned field in patch")
617
+ return _copy_json(patch)
618
+
619
+ def _validate_selection(self, selection: Any, inventory: dict[str, Any]) -> list[str] | None:
620
+ if selection is None:
621
+ return None
622
+ if not isinstance(selection, list) or not all(isinstance(x, str) for x in selection):
623
+ raise ValidationError("selection must be a list of qualified refs/domains")
624
+ packages = {p.get("package_id") for p in inventory.get("packages", []) if isinstance(p, dict)}
625
+ for value in selection:
626
+ if value == "all":
627
+ continue
628
+ if ":" in value:
629
+ continue
630
+ if value.startswith("@local/"):
631
+ continue
632
+ if "/" not in value or not value.startswith("@"):
633
+ raise ValidationError(f"selection must be fully qualified: {value!r}")
634
+ package = value.rsplit("/", 1)[0]
635
+ if package not in packages and package != LOCAL_PACKAGE and not package.startswith("@local/"):
636
+ raise ValidationError(f"unknown package in selection: {value!r}")
637
+ return sorted(set(selection))
638
+
639
+ def _normalized_install_selection(self, domains: list[str] | None, catalog: dict[str, Any]) -> list[str]:
640
+ """Translate legacy bare domain inputs at the installer boundary once."""
641
+ if domains is None:
642
+ return []
643
+ if not isinstance(domains, list) or not all(isinstance(domain, str) for domain in domains):
644
+ raise ValidationError("install domains must be a list of strings")
645
+ packages = catalog.get("packages", [])
646
+ if not packages or not isinstance(packages[0], dict) or not isinstance(packages[0].get("package_id"), str):
647
+ raise ValidationError("catalog lacks a core package")
648
+ core_package = packages[0]["package_id"]
649
+ normalized = [domain if domain == "all" or domain.startswith("@") else f"{core_package}/{domain}" for domain in domains]
650
+ return self._validate_selection(normalized, catalog) or []
651
+
652
+ @staticmethod
653
+ def _effective_selection(user: dict[str, Any], defaults: dict[str, Any], requested: list[str] | None) -> list[str] | None:
654
+ if requested is not None:
655
+ return requested
656
+ if user.get("selection") is not None:
657
+ return user["selection"]
658
+ return defaults.get("selection")
659
+
660
+ def _validate_snapshot_selection(self, selection: list[str] | None, inventory: dict[str, Any], items: list[dict[str, Any]]) -> None:
661
+ if selection is None:
662
+ return
663
+ if not isinstance(selection, list) or not all(isinstance(value, str) for value in selection):
664
+ raise ValidationError("selection must be a list")
665
+ packages = {entry["package_id"]: set((entry.get("domains") or {}).keys())
666
+ for entry in inventory.get("packages", []) if isinstance(entry, dict) and isinstance(entry.get("package_id"), str)}
667
+ packages.update({LOCAL_PACKAGE: {"personal"}, "@local/learnings-claude": {"personal"},
668
+ "@local/learnings-codex": {"personal"}})
669
+ refs = {item["ref"] for item in items}
670
+ for value in selection:
671
+ if value == "all":
672
+ continue
673
+ if ":" in value:
674
+ if value not in refs:
675
+ raise ValidationError(f"unknown corpus selection ref: {value}")
676
+ continue
677
+ if value in packages:
678
+ continue
679
+ if not value.startswith("@") or "/" not in value:
680
+ raise ValidationError(f"selection must be package/domain-qualified: {value!r}")
681
+ package, domain = value.rsplit("/", 1)
682
+ if package not in packages or domain not in packages[package]:
683
+ raise ValidationError(f"unknown corpus selection domain: {value}")
684
+
685
+ def _validate_candidate_projection(self, runtime: dict[str, Any], user: dict[str, Any]) -> None:
686
+ """Use the real compiler as a plan validator without publishing output."""
687
+ catalog = self._catalog_module()
688
+ try:
689
+ with tempfile.TemporaryDirectory(prefix="agent-bios-plan-") as temp:
690
+ for host in ("claude", "codex"):
691
+ items, _inventory, defaults, _baseline = self._effective_items(runtime, user, host=host)
692
+ active = [item for item in items if item.get("active", True) is not False]
693
+ selection = self._effective_selection(user, defaults, None)
694
+ self._validate_snapshot_selection(selection, _inventory, active)
695
+ selected = self._selected_items(active, selection)
696
+ self._require_resolved(selected)
697
+ catalog.compile_items(_copy_json(selected), Path(temp) / host, host)
698
+ except Exception as exc:
699
+ # The catalog names the concrete member/ref; retain that actionable
700
+ # evidence but keep the manager's public validation contract stable.
701
+ raise ValidationError(f"candidate projection is invalid: {exc}") from exc
702
+
703
+ @staticmethod
704
+ def _require_resolved(items: list[dict[str, Any]]) -> None:
705
+ conflicts = [item["ref"] for item in items if item.get("conflict") or item.get("content_conflict")]
706
+ if conflicts:
707
+ raise ValidationError("unresolved corpus conflict: " + ", ".join(sorted(conflicts)))
708
+
709
+ def _rebase_overlays(self, old_inventory: dict[str, Any], new_inventory: dict[str, Any], user: dict[str, Any]) -> dict[str, Any]:
710
+ """Three-way field/member comparison shared by forward and reverse switches."""
711
+ result = _copy_json(user)
712
+ old_items = {item["ref"]: item for item in old_inventory.get("items", []) if isinstance(item, dict)}
713
+ new_items = {item["ref"]: item for item in new_inventory.get("items", []) if isinstance(item, dict)}
714
+ conflicts: list[str] = []
715
+ for ref, override in list(result["overrides"].items()):
716
+ learning_host = self._learning_host_from_ref(ref)
717
+ if learning_host is not None:
718
+ event = next((event for event in self._learning_events(learning_host)
719
+ if ref == f"@local/learnings-{learning_host}:{event['learning_id']}"), None)
720
+ if event is None or override.get("base_digest") != _digest(self._learning_item(learning_host, event)):
721
+ conflicts.append(ref)
722
+ continue
723
+ old, new = old_items.get(ref), new_items.get(ref)
724
+ if old is None or not isinstance(override, dict):
725
+ conflicts.append(ref)
726
+ continue
727
+ if override.get("base_digest") != _digest(old) or not isinstance(override.get("patch"), dict):
728
+ conflicts.append(ref)
729
+ continue
730
+ base = self._normalize_content(old, allow_legacy=True)
731
+ personal = self._normalize_content({**old, **override["patch"]}, allow_legacy=True)
732
+ if personal == base:
733
+ result["overrides"].pop(ref, None)
734
+ continue
735
+ if new is None:
736
+ conflicts.append(ref)
737
+ continue
738
+ target = self._normalize_content(new, allow_legacy=True)
739
+ self._require_resolved([base, target, personal])
740
+ merged = _copy_json(target)
741
+ missing = object()
742
+ for field in ITEM_FIELDS - {"body"}:
743
+ if field == "members":
744
+ output = dict(target["members"])
745
+ for member in set(base["members"]) | set(personal["members"]) | set(target["members"]):
746
+ prior = base["members"].get(member, missing)
747
+ mine = personal["members"].get(member, missing)
748
+ theirs = target["members"].get(member, missing)
749
+ if mine == prior:
750
+ continue
751
+ if theirs != prior and mine != theirs:
752
+ conflicts.append(f"{ref} members/{member}")
753
+ elif mine is missing:
754
+ output.pop(member, None)
755
+ else:
756
+ output[member] = mine
757
+ merged[field] = output
758
+ else:
759
+ prior, mine, theirs = base.get(field, missing), personal.get(field, missing), target.get(field, missing)
760
+ if mine == prior:
761
+ continue
762
+ if theirs != prior and mine != theirs:
763
+ conflicts.append(f"{ref} {field}")
764
+ elif mine is missing:
765
+ merged.pop(field, None)
766
+ else:
767
+ merged[field] = mine
768
+ # body is a view of the merged primary member, not a second merge input.
769
+ merged.pop("body", None)
770
+ merged = self._normalize_content(merged)
771
+ patch = {field: value for field, value in merged.items()
772
+ if field in ITEM_FIELDS and value != new.get(field)}
773
+ if patch:
774
+ result["overrides"][ref] = {"base_digest": _digest(new), "patch": patch}
775
+ else:
776
+ result["overrides"].pop(ref, None)
777
+ if conflicts:
778
+ raise ValidationError("baseline_update_conflict: " + ", ".join(sorted(conflicts)))
779
+ return result
780
+
781
+ # ---- public read API ----------------------------------------------------
782
+
783
+ def install(self, domains: list[str] | None = None) -> dict[str, Any]:
784
+ """Install one immutable validated baseline tuple without touching user data."""
785
+ with self._lock():
786
+ return self.commit_install(self.prepare_install(domains))
787
+
788
+ def prepare_install(self, domains: list[str] | None = None) -> dict[str, Any]:
789
+ """Stage a validated baseline and source plan without advancing any pointer."""
790
+ with self._lock():
791
+ self._recover_locked()
792
+ catalog = self._catalog_module().load_catalog(self.repo)
793
+ if not isinstance(catalog, dict) or catalog.get("schema_version") != SCHEMA_VERSION:
794
+ raise ValidationError("catalog schema mismatch")
795
+ raw_items = catalog.get("items")
796
+ if not isinstance(raw_items, list) or not raw_items:
797
+ raise ValidationError("catalog inventory is empty")
798
+ items = [self._validate_item(item, allow_origin=True) for item in raw_items]
799
+ if len({item["ref"] for item in items}) != len(items):
800
+ raise ValidationError("catalog has duplicate corpus refs")
801
+ defaults = {"schema_version": SCHEMA_VERSION,
802
+ "selection": self._normalized_install_selection(domains, catalog) +
803
+ [LOCAL_PACKAGE, "@local/learnings-claude", "@local/learnings-codex"]}
804
+ promotion_path = self.repo / "learn" / "promotions.json"
805
+ promotions = _json_read(promotion_path, {"version": 0, "promotions": []})
806
+ if not isinstance(promotions, dict) or not isinstance(promotions.get("promotions"), list):
807
+ raise ValidationError("promotion manifest is invalid")
808
+ compiler = self.repo / "compose" / "corpus_catalog.py"
809
+ tuple_data = {
810
+ "schema_version": SCHEMA_VERSION, "catalog": catalog, "defaults": defaults,
811
+ "promotions": promotions,
812
+ "compiler_digest": _digest(compiler.read_bytes()) if compiler.is_file() else None,
813
+ }
814
+ baseline_ref = _digest(tuple_data)
815
+ root = self._baseline_dir(baseline_ref)
816
+ existing = root / "inventory.json"
817
+ if existing.exists() and _json_read(existing) != catalog:
818
+ raise CorpusStoreError(f"immutable baseline collision: {baseline_ref}")
819
+ runtime = self._runtime_state()
820
+ user = self._user_state()
821
+ prior_last = runtime.get("last_successful_install_ref")
822
+ next_user = user
823
+ adopt = runtime.get("selected_baseline_ref") == prior_last
824
+ if adopt and isinstance(prior_last, str) and prior_last != baseline_ref:
825
+ old_inventory, _old_defaults = self._read_baseline(prior_last)
826
+ # A conflict refuses before either the baseline pointer or the
827
+ # successful-install record changes.
828
+ next_user = self._rebase_overlays(old_inventory, catalog, user)
829
+ before = {"runtime": _copy_json(runtime), "user": _copy_json(user)}
830
+ _atomic_write(root / "inventory.json", catalog)
831
+ _atomic_write(root / "defaults.json", defaults)
832
+ _atomic_write(root / "promotions.json", promotions)
833
+ runtime["last_successful_install_ref"] = baseline_ref
834
+ if runtime.get("selected_baseline_ref") is None or adopt:
835
+ runtime["selected_baseline_ref"] = baseline_ref
836
+ self._validate_candidate_projection(runtime, next_user)
837
+ transaction_id = uuid.uuid4().hex
838
+ details = {"baseline_ref": baseline_ref, "selected_baseline_ref": runtime["selected_baseline_ref"], "items": len(items)}
839
+ candidate = {"transaction_id": transaction_id, "before": before,
840
+ "after": {"runtime": runtime, "user": next_user}, "details": details,
841
+ "expected_revision": self._authoring_revision(before["runtime"], before["user"])}
842
+ self._write_transaction(transaction_id, {"state": "PLANNED", "kind": "install", "plan": candidate})
843
+ return _copy_json(candidate)
844
+
845
+ def commit_install(self, candidate: dict[str, Any]) -> dict[str, Any]:
846
+ """Publish a staged installation; repeated recovery consumes the same journal."""
847
+ transaction_id = _safe_part(candidate.get("transaction_id"), "transaction id")
848
+ with self._lock():
849
+ self._recover_locked()
850
+ record = _json_read(self.runtime / "transactions" / transaction_id / "journal.json")
851
+ if (self.runtime / "private-install.json").exists() and not operation_scope_active(self.state_root):
852
+ raise ValidationError("managed installations must publish through agent-bios install")
853
+ if not isinstance(record, dict) or record.get("kind") != "install":
854
+ raise ValidationError("unknown installation candidate")
855
+ plan = record["plan"]
856
+ if len(candidate) > 1 and candidate != plan:
857
+ raise ValidationError("installation candidate differs from its recorded plan")
858
+ if record["state"] in {"COMMITTED", "RECOVERED_COMMITTED"}:
859
+ return _copy_json(plan["details"])
860
+ runtime, user = self._runtime_state(), self._user_state()
861
+ if record["state"] != "PLANNED" or self._authoring_revision(runtime, user) != plan["expected_revision"]:
862
+ raise StaleRevision("installation candidate is stale")
863
+ self._validate_candidate_projection(plan["after"]["runtime"], plan["after"]["user"])
864
+ record["state"] = "PREPARED"
865
+ self._write_transaction(transaction_id, record)
866
+ self._recover_locked()
867
+ return _copy_json(plan["details"])
868
+
869
+ def status(self) -> dict[str, Any]:
870
+ with self._lock():
871
+ pending = [entry for entry in pending_operations(self.state_root)
872
+ if entry["state"] == "NEEDS_RECOVERY"
873
+ or "resets" in Path(entry["path"]).parts
874
+ or _json_read(Path(entry["path"])).get("owner") == "installer"]
875
+ if pending:
876
+ return {"schema_version": SCHEMA_VERSION, "needs_recovery": pending,
877
+ "installed": self._runtime_state().get("last_successful_install_ref") is not None}
878
+ self._recover_locked()
879
+ runtime, user = self._runtime_state(), self._user_state()
880
+ revision = self._authoring_revision(runtime, user)
881
+ refs = list((self.runtime / "baselines").glob("*/inventory.json"))
882
+ defaults = self._selected_baseline(runtime)[2] if runtime.get("selected_baseline_ref") else {}
883
+ return {
884
+ "schema_version": SCHEMA_VERSION, "installed": runtime.get("last_successful_install_ref") is not None,
885
+ "last_successful_install_ref": runtime.get("last_successful_install_ref"),
886
+ "selected_baseline_ref": runtime.get("selected_baseline_ref"),
887
+ "revision": revision, "baseline_count": len(refs),
888
+ "personal_items": len(user["items"]), "overrides": len(user["overrides"]),
889
+ "tombstones": len(user["tombstones"]), "selection": self._effective_selection(user, defaults, None),
890
+ }
891
+
892
+ def list_items(self, include_removed: bool = True) -> list[dict[str, Any]]:
893
+ with self._lock():
894
+ self._recover_locked()
895
+ runtime, user = self._runtime_state(), self._user_state()
896
+ selected_ref, inventory, _defaults = self._selected_baseline(runtime)
897
+ all_items, _, _, _ = self._effective_items(runtime, user)
898
+ effective = {item["ref"]: item for item in all_items}
899
+ for host in ("claude", "codex"):
900
+ effective.update({item["ref"]: item for item in self._effective_items(runtime, user, host=host, include_suppressed=True)[0]})
901
+ source = {item["ref"]: self._validate_item(item, allow_origin=True) for item in inventory["items"]}
902
+ rows: list[dict[str, Any]] = []
903
+ for ref in sorted(set(source) | set(user["items"]) | set(effective)):
904
+ base = source.get(ref)
905
+ item = effective.get(ref)
906
+ removed = ref in user["tombstones"] or (item is not None and item.get("active", True) is False)
907
+ if removed and not include_removed:
908
+ continue
909
+ row = _copy_json(item or base or user["items"][ref])
910
+ row["state"] = "removed" if removed else ("conflict" if item and (item.get("conflict") or item.get("content_conflict")) else "active")
911
+ row["digest"] = _digest(item or base or user["items"][ref])
912
+ row["baseline_ref"] = selected_ref if base else None
913
+ rows.append(row)
914
+ return rows
915
+
916
+ def show(self, ref: str, view: str = "effective") -> dict[str, Any]:
917
+ if view not in {"effective", "installed", "change", "diff", "history"}:
918
+ raise ValidationError("unknown corpus view")
919
+ with self._lock():
920
+ self._recover_locked()
921
+ runtime, user = self._runtime_state(), self._user_state()
922
+ baseline_ref, inventory, _defaults = self._selected_baseline(runtime)
923
+ base = next((self._validate_item(x, allow_origin=True) for x in inventory["items"] if x.get("ref") == ref), None)
924
+ learning_host = self._learning_host_from_ref(ref)
925
+ effective = {x["ref"]: x for x in self._effective_items(runtime, user, host=learning_host, include_suppressed=learning_host is not None)[0]}.get(ref)
926
+ if base is None and ref not in user["items"] and effective is None:
927
+ raise ValidationError(f"unknown corpus ref: {ref}")
928
+ if view == "installed":
929
+ return {"ref": ref, "baseline_ref": baseline_ref, "item": base}
930
+ if view == "change":
931
+ return {"ref": ref, "override": user["overrides"].get(ref), "tombstone": user["tombstones"].get(ref), "personal": user["items"].get(ref)}
932
+ if view == "diff":
933
+ return {"ref": ref, "installed": base, "effective": effective, "change": user["overrides"].get(ref), "removed": ref in user["tombstones"]}
934
+ if view == "history":
935
+ return {"ref": ref, "history": self.history(ref)}
936
+ removed = ref in user["tombstones"] or (effective is not None and effective.get("active", True) is False)
937
+ state = "removed" if removed else ("conflict" if effective and (effective.get("conflict") or effective.get("content_conflict")) else "active")
938
+ return {"ref": ref, "item": effective, "digest": _digest(effective) if effective else None, "state": state}
939
+
940
+ # ---- plans --------------------------------------------------------------
941
+
942
+ def _next_personal_id(self, user: dict[str, Any]) -> str:
943
+ """Allocate against retained sources and plans, including retired identities."""
944
+ issued = set(user["items"])
945
+ for path in (self.user_root / "history").glob("*/state.json"):
946
+ record = _json_read(path)
947
+ issued.update((record.get("user") or {}).get("items", {}))
948
+ for path in (self.runtime / "transactions").glob("*/journal.json"):
949
+ record = _json_read(path)
950
+ plan = record.get("plan") or {}
951
+ issued.update(((plan.get("after") or {}).get("user") or {}).get("items", {}))
952
+ if ref := (plan.get("details") or {}).get("ref"):
953
+ issued.add(ref)
954
+ for _attempt in range(16):
955
+ item_id = f"personal-{uuid.uuid4().hex}"
956
+ if f"{LOCAL_PACKAGE}:{item_id}" not in issued:
957
+ return item_id
958
+ raise CorpusStoreError("could not allocate an unused personal identity")
959
+
960
+ def _prepare_operation(self, payload: dict[str, Any], runtime: dict[str, Any], user: dict[str, Any],
961
+ *, allocated_item_id: str | None = None) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]:
962
+ if not isinstance(payload, dict):
963
+ raise ValidationError("plan payload must be an object")
964
+ op = payload.get("operation", payload.get("op"))
965
+ if op not in {"create", "update", "remove", "restore", "recover", "reset", "rollback", "select"}:
966
+ raise ValidationError("unknown corpus operation")
967
+ allowed = {
968
+ "create": {"operation", "op", "item", "package_id", "expected_revision"},
969
+ "update": {"operation", "op", "ref", "patch", "item_digest", "expected_revision"},
970
+ "remove": {"operation", "op", "ref", "item_digest", "expected_revision"},
971
+ "restore": {"operation", "op", "ref", "expected_revision"},
972
+ "recover": {"operation", "op", "ref", "expected_revision"},
973
+ "reset": {"operation", "op", "expected_revision"},
974
+ "rollback": {"operation", "op", "baseline_ref", "history_id", "expected_revision"},
975
+ "select": {"operation", "op", "selection", "expected_revision"},
976
+ }[op]
977
+ unknown = set(payload) - allowed
978
+ if unknown:
979
+ raise ValidationError(f"unknown plan fields: {', '.join(sorted(unknown))}")
980
+ current = self._authoring_revision(runtime, user)
981
+ given = payload.get("expected_revision")
982
+ if given is not None and given != current:
983
+ raise StaleRevision(f"expected revision {given} is stale; current is {current}")
984
+ next_runtime, next_user = _copy_json(runtime), _copy_json(user)
985
+ items, inventory, defaults, _baseline_ref = self._effective_items(runtime, user)
986
+ effective = {item["ref"]: item for item in items}
987
+ details: dict[str, Any] = {"operation": op}
988
+ if op == "create":
989
+ raw = _copy_json(payload.get("item"))
990
+ if not isinstance(raw, dict):
991
+ raise ValidationError("create needs item")
992
+ if set(raw) & {"item_id", "ref", "content_conflict", "conflict"}:
993
+ raise ValidationError("creation identity and conflict metadata are runtime-owned")
994
+ if allocated_item_id is None:
995
+ raise ValidationError("creation needs a recorded runtime-owned identity")
996
+ package = payload.get("package_id", raw.get("package_id", LOCAL_PACKAGE))
997
+ if package != LOCAL_PACKAGE:
998
+ raise ValidationError("V1 creation targets @local/personal")
999
+ raw["package_id"] = LOCAL_PACKAGE
1000
+ raw["item_id"] = allocated_item_id
1001
+ raw["ref"] = f"{LOCAL_PACKAGE}:{raw['item_id']}"
1002
+ raw.setdefault("surface", "requested")
1003
+ raw.setdefault("tier", "env-personal")
1004
+ raw.setdefault("domains", ["personal"])
1005
+ raw.setdefault("kind", "rule")
1006
+ if "members" not in raw:
1007
+ raw.setdefault("body", "")
1008
+ raw["members"] = {"content.md": raw["body"]}
1009
+ raw["primary_member"] = "content.md"
1010
+ elif "body" not in raw:
1011
+ primary = raw.get("primary_member")
1012
+ if not isinstance(raw["members"], dict) or not isinstance(primary, str) or primary not in raw["members"]:
1013
+ raise ValidationError("members-only creation needs primary_member")
1014
+ raw["body"] = raw["members"][primary]
1015
+ try:
1016
+ item = self._validate_item(self._normalize_content(raw, allow_legacy=True))
1017
+ self._require_resolved([item])
1018
+ if "body" in payload["item"] and item["body"] != raw["body"]:
1019
+ raise ValidationError("body conflicts with primary_member member content")
1020
+ except ValueError as exc:
1021
+ raise ValidationError(str(exc)) from exc
1022
+ if item["kind"] not in {"rule", "guide", "skill"}:
1023
+ raise ValidationError("V1 personal creation supports rule, guide, and skill items")
1024
+ if item["ref"] in effective or item["ref"] in next_user["items"]:
1025
+ raise ValidationError(f"corpus ref already exists: {item['ref']}")
1026
+ next_user["items"][item["ref"]] = item
1027
+ details.update({"ref": item["ref"], "item_digest": _digest(item)})
1028
+ elif op == "update":
1029
+ ref = payload.get("ref")
1030
+ if isinstance(ref, str) and ref not in effective:
1031
+ learning_host = self._learning_host_from_ref(ref)
1032
+ if learning_host is not None:
1033
+ effective = {item["ref"]: item for item in self._effective_items(runtime, user, host=learning_host, include_suppressed=True)[0]}
1034
+ if not isinstance(ref, str) or ref not in effective:
1035
+ raise ValidationError("update needs an active corpus ref")
1036
+ item = effective[ref]
1037
+ supplied_digest = payload.get("item_digest")
1038
+ item_digest = _digest(item)
1039
+ if not isinstance(supplied_digest, str) or supplied_digest != item_digest:
1040
+ raise StaleRevision(f"item digest for {ref} is stale")
1041
+ patch = self._validate_patch(payload.get("patch"))
1042
+ try:
1043
+ candidate = self._validate_item(self._catalog_module().update_content(item, patch), allow_origin=True)
1044
+ except ValueError as exc:
1045
+ raise ValidationError(str(exc)) from exc
1046
+ self._require_resolved([candidate])
1047
+ if ref in next_user["items"]:
1048
+ next_user["items"][ref] = candidate
1049
+ else:
1050
+ base = next((row for row in inventory["items"] if row["ref"] == ref), None)
1051
+ if base is None:
1052
+ host = self._learning_host_from_ref(ref)
1053
+ event = next(event for event in self._learning_events(host)
1054
+ if f"@local/learnings-{host}:{event['learning_id']}" == ref)
1055
+ base = self._learning_item(host, event)
1056
+ normalized_base = self._normalize_content(base, allow_legacy=True)
1057
+ delta = {field: value for field, value in candidate.items()
1058
+ if field in ITEM_FIELDS and value != normalized_base.get(field)}
1059
+ if delta:
1060
+ next_user["overrides"][ref] = {"base_digest": _digest(base), "patch": delta}
1061
+ else:
1062
+ next_user["overrides"].pop(ref, None)
1063
+ details.update({"ref": ref, "prior_item_digest": item_digest, "item_digest": _digest(candidate)})
1064
+ elif op == "remove":
1065
+ ref = payload.get("ref")
1066
+ if isinstance(ref, str) and ref not in effective:
1067
+ learning_host = self._learning_host_from_ref(ref)
1068
+ if learning_host is not None:
1069
+ effective = {item["ref"]: item for item in self._effective_items(runtime, user, host=learning_host, include_suppressed=True)[0]}
1070
+ if not isinstance(ref, str) or ref not in effective:
1071
+ raise ValidationError("remove needs an active corpus ref")
1072
+ supplied_digest = payload.get("item_digest")
1073
+ if supplied_digest is not None and supplied_digest != _digest(effective[ref]):
1074
+ raise StaleRevision(f"item digest for {ref} is stale")
1075
+ if ref in next_user["items"]:
1076
+ next_user["items"][ref]["active"] = False
1077
+ else:
1078
+ # The plan must have a stable result digest. Audit time belongs in
1079
+ # its journal, not in authoring state that is recomputed at Apply.
1080
+ next_user["tombstones"][ref] = {"base_digest": _digest(effective[ref])}
1081
+ details["ref"] = ref
1082
+ elif op == "restore":
1083
+ ref = payload.get("ref")
1084
+ if not isinstance(ref, str):
1085
+ raise ValidationError("restore needs corpus ref")
1086
+ if ref not in {x.get("ref") for x in inventory.get("items", [])}:
1087
+ raise ValidationError("restore is available only for selected installed baseline items")
1088
+ next_user["overrides"].pop(ref, None)
1089
+ next_user["tombstones"].pop(ref, None)
1090
+ details["ref"] = ref
1091
+ elif op == "recover":
1092
+ ref = payload.get("ref")
1093
+ learning_host = self._learning_host_from_ref(ref) if isinstance(ref, str) else None
1094
+ if learning_host is not None:
1095
+ event = next((event for event in self._learning_events(learning_host)
1096
+ if f"@local/learnings-{learning_host}:{event['learning_id']}" == ref), None)
1097
+ if event is None:
1098
+ raise ValidationError("recover needs personal corpus ref")
1099
+ next_user["tombstones"].pop(ref, None)
1100
+ suppressions = next_user.setdefault("learning_suppressions", {}).setdefault(learning_host, [])
1101
+ if _digest(event) in suppressions:
1102
+ suppressions.remove(_digest(event))
1103
+ elif not isinstance(ref, str) or ref not in next_user["items"]:
1104
+ raise ValidationError("recover needs personal corpus ref")
1105
+ else:
1106
+ next_user["items"][ref].pop("active", None)
1107
+ details["ref"] = ref
1108
+ elif op == "select":
1109
+ selection = self._validate_selection(payload.get("selection"), inventory)
1110
+ next_user["selection"] = selection
1111
+ details["selection"] = selection
1112
+ elif op == "reset":
1113
+ suppressions = {host: [_digest(event) for event in self._learning_events(host)]
1114
+ for host in ("claude", "codex")}
1115
+ next_user = self._empty_user()
1116
+ next_user["learning_suppressions"] = suppressions
1117
+ latest = next_runtime.get("last_successful_install_ref")
1118
+ if not latest:
1119
+ raise CorpusStoreError("cannot reset before install")
1120
+ next_runtime["selected_baseline_ref"] = latest
1121
+ _inv, latest_defaults = self._read_baseline(latest)
1122
+ next_user["selection"] = latest_defaults.get("selection")
1123
+ details["baseline_ref"] = latest
1124
+ elif op == "rollback":
1125
+ history_id = payload.get("history_id")
1126
+ baseline_ref = payload.get("baseline_ref")
1127
+ if history_id is not None:
1128
+ _safe_part(history_id, "history id")
1129
+ record = _json_read(self.user_root / "history" / str(history_id) / "state.json")
1130
+ if not isinstance(record, dict) or "user" not in record or "runtime" not in record:
1131
+ raise ValidationError("unknown rollback history")
1132
+ next_user = record["user"]
1133
+ # History rollback replays authoring state but preserves the current successful-install pointer.
1134
+ next_runtime["selected_baseline_ref"] = record["runtime"].get("selected_baseline_ref")
1135
+ self._read_baseline(next_runtime["selected_baseline_ref"])
1136
+ details["history_id"] = history_id
1137
+ else:
1138
+ if not isinstance(baseline_ref, str):
1139
+ raise ValidationError("rollback needs baseline_ref or history_id")
1140
+ target, _target_defaults = self._read_baseline(baseline_ref)
1141
+ next_user = self._rebase_overlays(inventory, target, next_user)
1142
+ next_runtime["selected_baseline_ref"] = baseline_ref
1143
+ details["baseline_ref"] = baseline_ref
1144
+ return next_runtime, next_user, details
1145
+
1146
+ def plan(self, payload: dict[str, Any]) -> dict[str, Any]:
1147
+ with self._lock():
1148
+ self._recover_locked()
1149
+ runtime, user = self._runtime_state(), self._user_state()
1150
+ before = self._authoring_revision(runtime, user)
1151
+ allocated = self._next_personal_id(user) if isinstance(payload, dict) and payload.get("operation", payload.get("op")) == "create" else None
1152
+ next_runtime, next_user, details = self._prepare_operation(payload, runtime, user, allocated_item_id=allocated)
1153
+ self._validate_candidate_projection(next_runtime, next_user)
1154
+ after = self._authoring_revision(next_runtime, next_user)
1155
+ plan_id = uuid.uuid4().hex
1156
+ plan = {
1157
+ "schema_version": SCHEMA_VERSION, "plan_id": plan_id, "created_at": _utcnow(),
1158
+ "expected_revision": before, "result_revision": after, "payload": _copy_json(payload),
1159
+ "details": details, "before": {"runtime": runtime, "user": user},
1160
+ "after": {"runtime": next_runtime, "user": next_user},
1161
+ }
1162
+ if allocated is not None:
1163
+ plan["allocated_item_id"] = allocated
1164
+ self._write_transaction(plan_id, {"state": "PLANNED", "plan": plan})
1165
+ return {key: plan[key] for key in ("schema_version", "plan_id", "expected_revision", "result_revision", "details")}
1166
+
1167
+ def apply(self, plan_id: str, expected_revision: str | None = None) -> dict[str, Any]:
1168
+ _safe_part(plan_id, "plan id")
1169
+ with self._lock():
1170
+ self._recover_locked()
1171
+ journal_path = self.runtime / "transactions" / plan_id / "journal.json"
1172
+ journal = _json_read(journal_path)
1173
+ if not isinstance(journal, dict):
1174
+ raise ValidationError("plan is unknown")
1175
+ if journal.get("state") in {"COMMITTED", "RECOVERED_COMMITTED"} and journal.get("kind") != "install":
1176
+ return self._applied_result(journal)
1177
+ if journal.get("state") != "PLANNED" or journal.get("kind") == "install":
1178
+ raise ValidationError("plan is not ready for apply")
1179
+ plan = journal.get("plan")
1180
+ if not isinstance(plan, dict):
1181
+ raise CorpusStoreError("invalid plan journal")
1182
+ runtime, user = self._runtime_state(), self._user_state()
1183
+ current = self._authoring_revision(runtime, user)
1184
+ expected = expected_revision if expected_revision is not None else plan.get("expected_revision")
1185
+ if expected != current or plan.get("expected_revision") != current:
1186
+ raise StaleRevision(f"plan {plan_id} is stale; current revision is {current}")
1187
+ # Recalculate from payload under the lock; journal after-state is a preview, not authority.
1188
+ allocated = plan.get("allocated_item_id")
1189
+ if allocated is None and plan["payload"].get("operation", plan["payload"].get("op")) == "create":
1190
+ # Persisted pre-allocation plans already resolved their identity in after-state.
1191
+ ref = plan.get("details", {}).get("ref")
1192
+ allocated = ref.split(":", 1)[1] if isinstance(ref, str) and ref.startswith(LOCAL_PACKAGE + ":") else None
1193
+ next_runtime, next_user, details = self._prepare_operation(plan["payload"], runtime, user, allocated_item_id=allocated)
1194
+ self._validate_candidate_projection(next_runtime, next_user)
1195
+ result = self._authoring_revision(next_runtime, next_user)
1196
+ if result != plan.get("result_revision"):
1197
+ raise CorpusStoreError("plan result changed during apply")
1198
+ prepared = {"state": "PREPARED", "plan": plan, "prior_revision": current, "prepared_at": _utcnow()}
1199
+ history_id = f"{prepared['prepared_at'].replace(':', '').replace('+00:00', 'Z')}-{plan_id[:12]}"
1200
+ prepared["history_id"] = history_id
1201
+ self._write_transaction(plan_id, prepared)
1202
+ _atomic_write(self.user_root / "history" / history_id / "state.json", {"runtime": runtime, "user": user, "revision": current})
1203
+ if details.get("operation") == "reset":
1204
+ _atomic_write(self.user_root / "trash" / history_id / "state.json", {"runtime": runtime, "user": user, "revision": current})
1205
+ _atomic_write(self._user_state_path, next_user)
1206
+ _atomic_write(self._runtime_state_path, next_runtime)
1207
+ committed = {"state": "COMMITTED", "plan": plan, "history_id": history_id, "revision": result, "committed_at": _utcnow()}
1208
+ self._write_transaction(plan_id, committed)
1209
+ return {"plan_id": plan_id, "history_id": history_id, "revision": result, "details": details}
1210
+
1211
+ @staticmethod
1212
+ def _applied_result(journal: dict[str, Any]) -> dict[str, Any]:
1213
+ plan = journal["plan"]
1214
+ return {"plan_id": plan["plan_id"], "history_id": journal["history_id"],
1215
+ "revision": plan["result_revision"], "details": plan["details"]}
1216
+
1217
+ # ---- immutable snapshots and history -----------------------------------
1218
+
1219
+ def _selected_items(self, items: list[dict[str, Any]], selection: list[str] | None) -> list[dict[str, Any]]:
1220
+ if selection and "all" in selection:
1221
+ return items
1222
+ selected: list[dict[str, Any]] = []
1223
+ for item in items:
1224
+ if item.get("tier") in {"core", "infra"}:
1225
+ selected.append(item)
1226
+ continue
1227
+ if not selection:
1228
+ continue
1229
+ if item["ref"] in selection or item["package_id"] in selection:
1230
+ selected.append(item)
1231
+ continue
1232
+ if any(f"{item['package_id']}/{domain}" in selection for domain in item.get("domains", [])):
1233
+ selected.append(item)
1234
+ return selected
1235
+
1236
+ def snapshot(self, host: str, selection: list[str] | None = None, dry_run: bool = False,
1237
+ native: bool = False) -> dict[str, Any]:
1238
+ """Compose an immutable activated-session snapshot.
1239
+
1240
+ A dry run has no durable write path: it compiles in an OS temporary
1241
+ directory and rewrites only the returned private paths to their future
1242
+ content-addressed destination. The ContentRef is therefore the same
1243
+ value Apply/launch will later publish.
1244
+ """
1245
+ _host(host)
1246
+ if not isinstance(native, bool):
1247
+ raise ValidationError("native activation must be boolean")
1248
+ if dry_run and not self._runtime_state_path.is_file():
1249
+ raise CorpusStoreError("no installed baseline; run install first")
1250
+ lock = transaction_lock(self.state_root) if dry_run else self._lock()
1251
+ with lock:
1252
+ guard_pending(self.state_root)
1253
+ if dry_run and pending_operations(self.state_root):
1254
+ raise CorpusStoreError("source transaction needs recovery before snapshot preview")
1255
+ if not dry_run:
1256
+ self._recover_locked()
1257
+ runtime, user = self._runtime_state(), self._user_state()
1258
+ items, inventory, defaults, baseline_ref = self._effective_items(runtime, user, host=host)
1259
+ active = [item for item in items if item.get("active", True) is not False]
1260
+ effective_selection = self._effective_selection(user, defaults, selection)
1261
+ self._validate_snapshot_selection(effective_selection, inventory, active)
1262
+ selected = self._selected_items(active, effective_selection)
1263
+ self._require_resolved(selected)
1264
+ selected, promotion_warnings = self._resolve_promotions(selected, user, host, baseline_ref)
1265
+ bootstrap_path = self.repo / "compose" / "bootstrap" / "SKILL.md"
1266
+ if not bootstrap_path.is_file():
1267
+ raise CorpusStoreError("private management bootstrap is missing")
1268
+ catalog_path = self.repo / "compose" / "corpus_catalog.py"
1269
+ inputs = {
1270
+ "schema_version": SCHEMA_VERSION, "host": host, "baseline_ref": baseline_ref,
1271
+ "selection": effective_selection,
1272
+ "selection_digest": _digest(effective_selection),
1273
+ "authoring_revision": self._authoring_revision(runtime, user),
1274
+ "item_digests": {item["ref"]: _digest(item) for item in sorted(selected, key=lambda x: x["ref"])},
1275
+ "learning_digest": _digest(self._learning_events(host)),
1276
+ "promotion_digest": _digest(self._baseline_promotions(baseline_ref)),
1277
+ "compiler_digest": _digest(catalog_path.read_bytes()) if catalog_path.is_file() else None,
1278
+ "store_schema_digest": _digest(Path(__file__).read_bytes()),
1279
+ "bootstrap_digest": _digest(bootstrap_path.read_bytes()),
1280
+ }
1281
+ if native:
1282
+ import sys
1283
+ inputs["native"] = True
1284
+ inputs["native_python"] = sys.executable
1285
+ content_ref = _digest(inputs)
1286
+ root = self.sessions / "snapshots" / content_ref
1287
+ _reject_symlink_path(self.sessions)
1288
+ _reject_symlink_path(self.sessions / "snapshots")
1289
+ catalog = self._catalog_module()
1290
+ if not hasattr(catalog, "compile_items"):
1291
+ raise CorpusStoreError("corpus catalog has no compile_items")
1292
+ if not dry_run and (root / "inventory.json").exists():
1293
+ verified = verify_snapshot(root, content_ref)
1294
+ if verified["inputs"] != inputs:
1295
+ raise CorpusStoreError(f"immutable snapshot collision: {content_ref}")
1296
+ output = verified["output"]
1297
+ else:
1298
+ if dry_run:
1299
+ temp = tempfile.TemporaryDirectory(prefix="agent-bios-snapshot-")
1300
+ staging = Path(temp.name) / "snapshot"
1301
+ staging.mkdir()
1302
+ else:
1303
+ temp = None
1304
+ staging = root.with_name(f".{root.name}.staging-{uuid.uuid4().hex}")
1305
+ staging.mkdir(parents=True, exist_ok=False)
1306
+ try:
1307
+ compiled = catalog.compile_items(_copy_json(selected), staging, host, native=True) if native else catalog.compile_items(_copy_json(selected), staging, host)
1308
+ if not isinstance(compiled, dict) or not isinstance(compiled.get("instruction_text"), str):
1309
+ raise CorpusStoreError("catalog compiler returned invalid output")
1310
+ files = _snapshot_relative_paths(compiled.get("files"))
1311
+ output = {
1312
+ "instruction_text": compiled.get("instruction_text", ""),
1313
+ "files": sorted(set(files) | {"bootstrap/SKILL.md"}), "item_refs": compiled.get("item_refs", []),
1314
+ "unavailable": compiled.get("unavailable", []) + promotion_warnings,
1315
+ }
1316
+ assets = _snapshot_assets(compiled.get("assets", {}), files)
1317
+ if assets:
1318
+ output["assets"] = assets
1319
+ invocation = f"Corpus management: invoke $corpus using {root / 'bootstrap' / 'SKILL.md'}."
1320
+ output["instruction_text"] = output["instruction_text"].rstrip() + "\n\n" + invocation + "\n"
1321
+ for plugin in assets.get("claude_plugins", []):
1322
+ for agent_path in (staging / plugin / "agents").glob("*.md"):
1323
+ with agent_path.open("a", encoding="utf-8") as agent_file:
1324
+ agent_file.write("\n\n" + invocation + "\n")
1325
+ if dry_run:
1326
+ output["instruction_text"] = output["instruction_text"].replace(str(staging), str(root))
1327
+ else:
1328
+ bootstrap_target = staging / "bootstrap" / "SKILL.md"
1329
+ bootstrap_target.parent.mkdir(parents=True, exist_ok=True)
1330
+ bootstrap_target.write_bytes(bootstrap_path.read_bytes())
1331
+ _rewrite_staged_paths(staging, root)
1332
+ output["instruction_text"] = output["instruction_text"].replace(str(staging), str(root))
1333
+ _atomic_write(staging / "inventory.json", {"inputs": inputs, "items": selected})
1334
+ _atomic_write(staging / "output.json", output)
1335
+ digests = _snapshot_file_digests(staging, output["files"])
1336
+ _atomic_write(staging / "inventory.json", {"inputs": inputs, "items": selected, "file_digests": digests})
1337
+ # A directory rename is the publication point; no consumer sees a partial snapshot.
1338
+ if not dry_run:
1339
+ root.parent.mkdir(parents=True, exist_ok=True)
1340
+ try:
1341
+ os.replace(staging, root)
1342
+ except FileExistsError:
1343
+ if verify_snapshot(root, content_ref)["inputs"] != inputs:
1344
+ raise CorpusStoreError(f"immutable snapshot collision: {content_ref}")
1345
+ finally:
1346
+ if temp is not None:
1347
+ temp.cleanup()
1348
+ elif staging.exists():
1349
+ import shutil
1350
+ shutil.rmtree(staging)
1351
+ return {"content_ref": content_ref, "path": str(root), "instruction_text": output["instruction_text"],
1352
+ "revision": inputs["authoring_revision"], "unavailable": output.get("unavailable", []),
1353
+ "assets": output.get("assets", {})}
1354
+
1355
+ def history(self, ref: str | None = None) -> list[dict[str, Any]]:
1356
+ root = self.user_root / "history"
1357
+ if not root.is_dir():
1358
+ return []
1359
+ rows: list[dict[str, Any]] = []
1360
+ for path in sorted(root.glob("*/state.json"), reverse=True):
1361
+ record = _json_read(path)
1362
+ if not isinstance(record, dict):
1363
+ continue
1364
+ if ref is not None:
1365
+ user = record.get("user", {})
1366
+ if ref not in user.get("items", {}) and ref not in user.get("overrides", {}) and ref not in user.get("tombstones", {}):
1367
+ continue
1368
+ rows.append({"history_id": path.parent.name, "revision": record.get("revision"), "path": str(path.parent)})
1369
+ return rows
1370
+
1371
+ def snapshot_inventory(self, content_ref: str) -> dict[str, Any]:
1372
+ """Read a pinned snapshot by ContentRef without resolving current state."""
1373
+ _safe_part(content_ref, "content ref")
1374
+ with self._lock():
1375
+ self._recover_locked()
1376
+ root = self.sessions / "snapshots" / content_ref
1377
+ verified = verify_snapshot(root, content_ref)
1378
+ output = verified["output"]
1379
+ return {"content_ref": content_ref, "path": str(root), "inputs": verified["inputs"],
1380
+ "items": verified["items"], "files": output.get("files"),
1381
+ "item_refs": output.get("item_refs"), "instruction_text": output.get("instruction_text"),
1382
+ "unavailable": output.get("unavailable"), "assets": output.get("assets", {})}
1383
+
1384
+ def capture_learning(self, host: str, record: dict[str, Any]) -> dict[str, Any]:
1385
+ """Append exact captured bytes to a host-private immutable source journal."""
1386
+ _host(host)
1387
+ if not isinstance(record, dict):
1388
+ raise ValidationError("learning capture must be an object")
1389
+ event = _copy_json(record)
1390
+ unknown = set(event) - LEARNING_FIELDS
1391
+ required = {"schema_version", "learning_id", "lesson", "domain", "created", "supporting_sessions"}
1392
+ if unknown or not required <= set(event):
1393
+ raise ValidationError("learning record must use the collector v1 schema exactly")
1394
+ if event["schema_version"] != 1 or not isinstance(event["learning_id"], str) or not LEARNING_ID_RE.fullmatch(event["learning_id"]):
1395
+ raise ValidationError("invalid collector learning identity")
1396
+ if not isinstance(event["lesson"], str) or not isinstance(event["domain"], str) or not isinstance(event["created"], str):
1397
+ raise ValidationError("invalid collector learning content")
1398
+ sessions = event["supporting_sessions"]
1399
+ if not isinstance(sessions, list) or not sessions or not all(isinstance(value, str) for value in sessions):
1400
+ raise ValidationError("invalid collector learning provenance")
1401
+ path = self.user_root / "learnings" / host / "events.jsonl"
1402
+ with self._lock():
1403
+ self._recover_locked()
1404
+ prior = self._learning_events(host)
1405
+ if any(x.get("learning_id") == event["learning_id"] for x in prior):
1406
+ raise ValidationError("learning_id already captured")
1407
+ _reject_symlink_path(path, leaf=False)
1408
+ path.parent.mkdir(parents=True, exist_ok=True)
1409
+ # Append is atomic under the store lock and fsync makes the capture durable before return.
1410
+ with path.open("a", encoding="utf-8") as handle:
1411
+ handle.write(_canonical(event).decode("utf-8") + "\n")
1412
+ handle.flush()
1413
+ os.fsync(handle.fileno())
1414
+ return {"host": host, "learning_id": event["learning_id"], "digest": _digest(event), "revision": self._authoring_revision(self._runtime_state(), self._user_state())}