agent-bios 0.19.1 → 0.19.2

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