agent-bios 0.15.0 → 0.16.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/DEPENDENCIES.md +35 -12
  2. package/README.md +346 -31
  3. package/claude/CLAUDE.md +2 -2
  4. package/claude/agents/frontier.md +1 -1
  5. package/claude/agents/sweep.md +3 -3
  6. package/claude/agents/workhorse.md +2 -2
  7. package/claude/guides/claude-prompting.md +72 -39
  8. package/claude/guides/cli-multi-model-workflow.md +33 -15
  9. package/claude/guides/gpt-prompting.md +103 -39
  10. package/claude/guides/review-request.md +27 -0
  11. package/claude/guides/session-distill-workflow.md +54 -2
  12. package/claude/guides/slide-writing/RUNBOOK.md +137 -0
  13. package/claude/guides/slide-writing/scripts/pair.py +979 -0
  14. package/claude/guides/slide-writing/scripts/render.mjs +82 -0
  15. package/claude/guides/slide-writing.md +195 -0
  16. package/claude/guides/svg-visualization-guide.md +9 -0
  17. package/claude/guides/verification-discipline.md +5 -1
  18. package/claude/hooks/tooling-gotchas-hook.py +7 -5
  19. package/codex/AGENTS.md +2 -2
  20. package/codex/agents/frontier.toml +2 -1
  21. package/codex/agents/reviewer.toml +1 -1
  22. package/codex/agents/sweep.toml +3 -3
  23. package/codex/agents/workhorse.toml +1 -1
  24. package/codex/config-additions.toml +1 -1
  25. package/codex/guides/claude-prompting.md +72 -39
  26. package/codex/guides/cli-multi-model-workflow.md +33 -15
  27. package/codex/guides/gpt-prompting.md +103 -39
  28. package/codex/guides/review-request.md +27 -0
  29. package/codex/guides/session-distill-workflow.md +54 -2
  30. package/codex/guides/slide-writing/RUNBOOK.md +137 -0
  31. package/codex/guides/slide-writing/scripts/pair.py +979 -0
  32. package/codex/guides/slide-writing/scripts/render.mjs +82 -0
  33. package/codex/guides/slide-writing.md +195 -0
  34. package/codex/guides/svg-visualization-guide.md +9 -0
  35. package/codex/guides/verification-discipline.md +5 -1
  36. package/compose/assemble.py +290 -14
  37. package/compose/bootstrap/SKILL.md +119 -0
  38. package/compose/check-domains.py +102 -9
  39. package/compose/corpus-state.py +4 -0
  40. package/compose/corpus.py +387 -0
  41. package/compose/corpus_catalog.py +882 -0
  42. package/compose/corpus_install.py +1617 -0
  43. package/compose/corpus_session.py +726 -0
  44. package/compose/corpus_store.py +1414 -0
  45. package/compose/corpus_transaction.py +236 -0
  46. package/compose/corpus_ui.py +644 -0
  47. package/compose/domains.json +101 -100
  48. package/install.sh +63 -18
  49. package/launch/agent-launch.py +1216 -179
  50. package/launch/agent-launch.toml +12 -16
  51. package/launch/i18n/en.toml +112 -7
  52. package/launch/i18n/ja.toml +112 -7
  53. package/launch/i18n/ko.toml +112 -7
  54. package/learn/collect-learning.py +46 -19
  55. package/learn/migrate-learnings.py +10 -1
  56. package/package.json +11 -3
  57. package/provenance.json +1 -1
  58. package/session-cost.py +22 -2
  59. package/wrappers/codex-helm.sh +3 -3
@@ -206,6 +206,96 @@ def author_only(path):
206
206
  for ln in front.splitlines() if ln.startswith("audience:"))
207
207
 
208
208
 
209
+ class GuideMemberError(ValueError):
210
+ """A guide source tree does not have the primary-plus-companions shape."""
211
+
212
+
213
+ def _guide_primaries(root):
214
+ """Return the top-level primary Markdown files after validating `root`.
215
+
216
+ A guide is one manifest item even when it has resources. Its primary remains the
217
+ top-level ``<name>.md`` and its optional resource tree is exactly ``<name>/``. This
218
+ small structural contract lets delivery and every author-side consumer agree on the
219
+ member set without adding a second manifest entry for each resource.
220
+
221
+ The source is shipped verbatim for non-Markdown resources, so accepting a symlink
222
+ (or a FIFO/device) would make the shipped package's bytes depend on the checkout at
223
+ copy time. Refuse those shapes before a caller starts copying anything.
224
+ """
225
+ if root.is_symlink() or not root.is_dir():
226
+ raise GuideMemberError(f"guide root must be a real directory: {root}")
227
+ primaries = []
228
+ entries = sorted(root.iterdir(), key=lambda p: p.name)
229
+ for path in entries:
230
+ if path.is_symlink():
231
+ raise GuideMemberError(f"guide source must not contain symlinks: {path}")
232
+ if path.is_file():
233
+ if path.suffix != ".md":
234
+ raise GuideMemberError(
235
+ f"unsupported top-level guide resource {path}; companion resources belong "
236
+ f"under a same-stem directory")
237
+ primaries.append(path.name)
238
+ continue
239
+ if path.is_dir():
240
+ primary = root / f"{path.name}.md"
241
+ if not primary.is_file() or primary.is_symlink():
242
+ raise GuideMemberError(
243
+ f"orphan guide companion tree {path}; expected primary {primary.name}")
244
+ continue
245
+ raise GuideMemberError(f"unsupported guide source path: {path}")
246
+ if not primaries:
247
+ raise GuideMemberError(f"guide root has no primary Markdown files: {root}")
248
+ return primaries
249
+
250
+
251
+ def _tree_members(root):
252
+ """Return regular-file members under `root`, refusing every other entry type."""
253
+ out = []
254
+ for path in sorted(root.iterdir(), key=lambda p: p.name):
255
+ if path.is_symlink():
256
+ raise GuideMemberError(f"guide companion tree must not contain symlinks: {path}")
257
+ if path.is_file():
258
+ out.append(path)
259
+ elif path.is_dir():
260
+ children = _tree_members(path)
261
+ if not children:
262
+ raise GuideMemberError(f"empty guide companion directory: {path}")
263
+ out.extend(children)
264
+ else:
265
+ raise GuideMemberError(f"unsupported guide companion path: {path}")
266
+ return out
267
+
268
+
269
+ def guide_members(root: pathlib.Path, name: str) -> list[str]:
270
+ """Return one guide item's source-relative files in deterministic order.
271
+
272
+ ``name`` is the manifest's existing top-level ``*.md`` key. The primary is first;
273
+ optional companions live below ``Path(name).stem/`` and retain their relative path.
274
+ Validating all top-level entries on every call intentionally catches an orphan companion
275
+ tree even when the caller happens to ask about a different guide.
276
+ """
277
+ root = pathlib.Path(root)
278
+ primaries = _guide_primaries(root)
279
+ if name not in primaries or pathlib.PurePath(name).name != name or not name.endswith(".md"):
280
+ raise GuideMemberError(f"guide primary {name!r} is not a top-level Markdown file in {root}")
281
+ members = [name]
282
+ companion = root / pathlib.Path(name).stem
283
+ if companion.exists():
284
+ if companion.is_symlink() or not companion.is_dir():
285
+ raise GuideMemberError(f"guide companion {companion} must be a real directory")
286
+ companion_members = _tree_members(companion)
287
+ if not companion_members:
288
+ raise GuideMemberError(f"empty guide companion tree: {companion}")
289
+ members.extend(str(path.relative_to(root).as_posix()) for path in companion_members)
290
+ return members
291
+
292
+
293
+ def guide_member_map(root: pathlib.Path) -> dict[str, list[str]]:
294
+ """Every guide's exact source-relative member paths, keyed by its manifest key."""
295
+ root = pathlib.Path(root)
296
+ return {name: guide_members(root, name) for name in _guide_primaries(root)}
297
+
298
+
209
299
  def copy_filtered(src_dir, names, dest, rewrite=None, dry=False, backup=None):
210
300
  """Write the selected files, and remove the ones we deployed and no longer select.
211
301
 
@@ -260,6 +350,160 @@ def copy_filtered(src_dir, names, dest, rewrite=None, dry=False, backup=None):
260
350
  target.write_text(body, encoding="utf-8")
261
351
 
262
352
 
353
+ def _backup_member(path, backup, reason):
354
+ """Keep a recoverable copy of one exact deployed member before changing it."""
355
+ if backup is None:
356
+ return
357
+ kept = backup / reason / str(path).lstrip("/")
358
+ kept.parent.mkdir(parents=True, exist_ok=True)
359
+ shutil.copy2(path, kept)
360
+
361
+
362
+ def validate_guide_destination_members(dest, members):
363
+ """Refuse member paths that would traverse a symlink below the chosen guide root.
364
+
365
+ ``dest`` itself is deliberately outside this check: a user may configure the whole
366
+ guides root as a symlink, and that pre-existing top-level behavior is not claimed by a
367
+ bundle. A same-stem companion directory below it is different — following it would let
368
+ an install replace or deselect a file outside the configured root. Validate every
369
+ source-owned member up front, including currently deselected ones, so no earlier member
370
+ can be changed before a later nested path reveals the unsafe shape.
371
+ """
372
+ dest = pathlib.Path(dest)
373
+ for rel in sorted(set(members)):
374
+ target = dest / rel
375
+ if target.is_symlink():
376
+ die(f"refusing guide member destination symlink: {target}")
377
+ ancestor = target.parent
378
+ while ancestor != dest:
379
+ if ancestor.is_symlink():
380
+ die(f"refusing guide member ancestor symlink: {ancestor}")
381
+ if ancestor.exists() and not ancestor.is_dir():
382
+ die(f"guide member ancestor is not a directory: {ancestor}")
383
+ ancestor = ancestor.parent
384
+
385
+
386
+ def prior_guide_candidates(prior_deployed, dest, current_members):
387
+ """Former guide paths a prior manifest names but the current source does not.
388
+
389
+ Historic manifests over-claimed guide directories, so a prior row is not deletion
390
+ authority. This parser only bounds the warning to the configured guide root; current
391
+ source members retain the existing source-derived cleanup rule in ``copy_guide_members``.
392
+ """
393
+ dest = pathlib.Path(dest)
394
+ current = set(current_members)
395
+ candidates = set()
396
+ for raw in prior_deployed:
397
+ if not raw or "\0" in raw:
398
+ continue
399
+ try:
400
+ path = pathlib.Path(raw)
401
+ except (TypeError, ValueError):
402
+ continue
403
+ if not path.is_absolute():
404
+ continue
405
+ try:
406
+ rel = path.relative_to(dest)
407
+ except ValueError:
408
+ continue
409
+ # `relative_to` is lexical and preserves `..`; never allow a handcrafted manifest
410
+ # row to escape the configured root when the target path is joined below.
411
+ if not rel.parts or any(part in ("", ".", "..") for part in rel.parts):
412
+ continue
413
+ rel_text = rel.as_posix()
414
+ if rel_text not in current:
415
+ candidates.add(rel_text)
416
+ return sorted(candidates)
417
+
418
+
419
+ def disclose_prior_guide_remnants(dest, candidates, dry=False):
420
+ """Report, but never alter, prior-only guide paths that remain on disk.
421
+
422
+ The existing installer keeps the previous manifest as its recovery record. A user can
423
+ inspect that record and remove a remnant manually if it is known to be old product data;
424
+ this assembler cannot safely distinguish that case from a user-created same-named file.
425
+ """
426
+ dest = pathlib.Path(dest)
427
+ for rel in sorted(set(candidates)):
428
+ target = dest / rel
429
+ # Inspect existence only. A link is also a remnant for manual review; never open
430
+ # or modify its target as part of this disclosure.
431
+ if not target.exists() and not target.is_symlink():
432
+ continue
433
+ print(f" {'[dry] ' if dry else ''}prior guide remnant left in place: {target}")
434
+ print(" inspect it and remove it manually if it is an obsolete agent-bios resource")
435
+
436
+
437
+ def copy_guide_members(src_dir, names, dest, rewrite=None, dry=False, backup=None):
438
+ """Copy selected guide bundles and reconcile only their exact source members.
439
+
440
+ A companion directory is not an ownership boundary: it may contain a file a user added
441
+ after installation. Membership is therefore resolved from the source tree and every
442
+ replace/deselect action is addressed to one known file path. Empty directories are left
443
+ behind deliberately; an installer can remove them with ``rmdir`` only after all manifest
444
+ owned members are gone.
445
+
446
+ Markdown is rewritten only where the old file-only guide path already was. Other regular
447
+ files are copied byte-for-byte (and with their mode) so scripts and render assets remain
448
+ one source of truth rather than becoming text projections.
449
+ """
450
+ src_dir = pathlib.Path(src_dir)
451
+ dest = pathlib.Path(dest)
452
+ all_members = guide_member_map(src_dir)
453
+ unknown = set(names) - set(all_members)
454
+ if unknown:
455
+ die(f"selected guide(s) not found in source tree: {sorted(unknown)}")
456
+ owned = {member for members in all_members.values() for member in members}
457
+ selected = {member for name in names for member in all_members[name]}
458
+ validate_guide_destination_members(dest, owned)
459
+ if not dry:
460
+ dest.mkdir(parents=True, exist_ok=True)
461
+
462
+ # Deselecting a guide means deleting files, never a same-named directory wholesale.
463
+ # The latter would claim a user's nested note merely because it shares our guide stem.
464
+ for rel in sorted(owned - selected):
465
+ target = dest / rel
466
+ if not target.is_file():
467
+ continue
468
+ print(f" {'[dry] ' if dry else ''}deselected, removed {target}")
469
+ if dry:
470
+ continue
471
+ _backup_member(target, backup, "deselected")
472
+ target.unlink()
473
+
474
+ for rel in sorted(selected):
475
+ source = src_dir / rel
476
+ target = dest / rel
477
+ if dry:
478
+ print(f" [dry] copy {rel} -> {dest}")
479
+ continue
480
+ target.parent.mkdir(parents=True, exist_ok=True)
481
+ if source.suffix == ".md":
482
+ body = source.read_text(encoding="utf-8")
483
+ if rewrite:
484
+ body = body.replace(*rewrite)
485
+ changed = True
486
+ if target.is_file():
487
+ try:
488
+ changed = target.read_text(encoding="utf-8") != body
489
+ except (OSError, UnicodeDecodeError):
490
+ changed = True
491
+ if changed and target.is_file():
492
+ _backup_member(target, backup, "replaced")
493
+ if changed:
494
+ target.write_text(body, encoding="utf-8")
495
+ continue
496
+
497
+ # Read the bytes directly. `filecmp` caches stat signatures, which can report a
498
+ # same-size rewrite clean on filesystems whose mtime granularity is coarse; source
499
+ # code is small and byte identity is the contract here.
500
+ changed = not target.is_file() or source.read_bytes() != target.read_bytes()
501
+ if changed and target.is_file():
502
+ _backup_member(target, backup, "replaced")
503
+ if changed:
504
+ shutil.copy2(source, target)
505
+
506
+
263
507
  def replace_atomically(path, text):
264
508
  """Write via a temp file + os.replace, so a write that fails partway cannot truncate.
265
509
 
@@ -610,15 +854,46 @@ def main():
610
854
  if codex_only:
611
855
  codex_bundle = place_codex_only(codex_bundle, codex_only)
612
856
 
857
+ guide_source = REPO / "claude" / "guides"
858
+ guide_sources = guide_member_map(guide_source)
859
+ codex_guide_sources = guide_member_map(REPO / "codex" / "guides")
860
+ if set(guide_sources) != set(codex_guide_sources):
861
+ die("Claude and Codex guide primary sets differ; regenerate mirrors before assembling")
862
+ for name, members in guide_sources.items():
863
+ if members != codex_guide_sources[name]:
864
+ die(f"Claude and Codex guide members differ for {name}; regenerate mirrors before assembling")
865
+ prior_deployed = set()
866
+ if args.prior_manifest:
867
+ prior = pathlib.Path(args.prior_manifest)
868
+ if prior.is_file():
869
+ prior_deployed = {str(pathlib.Path(ln.strip())) for ln in
870
+ prior.read_text(encoding="utf-8").splitlines() if ln.strip()}
871
+ current_guide_members = [member for members in guide_sources.values() for member in members]
872
+ current_codex_guide_members = [member for members in codex_guide_sources.values() for member in members]
873
+ prior_claude_candidates = prior_guide_candidates(
874
+ prior_deployed, claude_dir / "central" / "guides", current_guide_members)
875
+ prior_codex_candidates = prior_guide_candidates(
876
+ prior_deployed, codex_dir / "guides", current_codex_guide_members)
877
+ # Do this before withheld cleanup, bundle.md, or any guide copy. A companion ancestor
878
+ # can be a symlink even when its leaf is an ordinary file; checking leaf targets only
879
+ # would then write outside the selected guides root before the unsafe path was noticed.
880
+ validate_guide_destination_members(
881
+ claude_dir / "central" / "guides",
882
+ current_guide_members,
883
+ )
884
+ validate_guide_destination_members(
885
+ codex_dir / "guides",
886
+ current_codex_guide_members,
887
+ )
613
888
  guides = filtered_files(manifest, "guides", selection)
614
- withheld = [n for n in guides if author_only(REPO / "claude" / "guides" / n)]
889
+ withheld = [n for n in guides if author_only(guide_source / n)]
615
890
  guides = [n for n in guides if n not in withheld]
616
891
  # Not writing it is not enough for anyone who installed before this rule: the
617
892
  # manifest is rebuilt from the current deploy, so a file that stops being
618
893
  # deployed stops being tracked and would sit there for good.
619
- stale = [d / n for n in withheld
894
+ stale = [d / member for n in withheld for member in guide_sources[n]
620
895
  for d in (claude_dir / "central" / "guides", codex_dir / "guides")
621
- if (d / n).is_file()]
896
+ if (d / member).is_file()]
622
897
  # Copy before removing, the way install.sh backs up a file it replaces. The
623
898
  # name matching ours does not prove we wrote it — a shared or symlinked guides
624
899
  # directory can hold somebody's own file under the same name, and a deleted
@@ -627,6 +902,13 @@ def main():
627
902
  # `withheld` is an audience decision, `deselected` is a selection change.
628
903
  run_backup = state_dir / "backups" / time.strftime("%Y%m%d-%H%M%S")
629
904
  backup = run_backup / "withheld"
905
+ # A source release can remove one resource while retaining its primary guide. Prior
906
+ # manifests have historically included user files below these roots, so they disclose
907
+ # candidates only; the source-derived member list remains the sole deletion authority.
908
+ disclose_prior_guide_remnants(claude_dir / "central" / "guides", prior_claude_candidates,
909
+ dry=args.dry_run)
910
+ disclose_prior_guide_remnants(codex_dir / "guides", prior_codex_candidates,
911
+ dry=args.dry_run)
630
912
  for path in stale:
631
913
  print(f" {'[dry] ' if args.dry_run else ''}remove withheld {path}")
632
914
  if not args.dry_run:
@@ -646,25 +928,19 @@ def main():
646
928
  else:
647
929
  central.mkdir(parents=True, exist_ok=True)
648
930
  (central / "bundle.md").write_text(bundle, encoding="utf-8")
649
- copy_filtered(REPO / "claude" / "guides", guides, central / "guides",
650
- rewrite=(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/"), dry=dry,
651
- backup=run_backup)
931
+ copy_guide_members(guide_source, guides, central / "guides",
932
+ rewrite=(f"{CLAUDE_VAR}/guides/", f"{CLAUDE_VAR}/central/guides/"),
933
+ dry=dry, backup=run_backup)
652
934
  copy_filtered(REPO / "claude" / "hooks", hooks, central / "hooks", dry=dry, backup=run_backup)
653
935
  copy_filtered(REPO / "claude" / "agents", agents, central / "agents", dry=dry,
654
936
  backup=run_backup)
655
937
  merge_settings(claude_dir, hooks, REPO / "claude" / "settings.template.json", dry=dry,
656
938
  owned_names=manifest.get("hooks", {})) # deselected hooks must drop too
657
- prior_deployed = set()
658
- if args.prior_manifest:
659
- prior = pathlib.Path(args.prior_manifest)
660
- if prior.is_file():
661
- prior_deployed = {str(pathlib.Path(ln.strip())) for ln in
662
- prior.read_text(encoding="utf-8").splitlines() if ln.strip()}
663
939
  entry_state = seed_entry(claude_dir, monolith, prior_deployed, dry=dry)
664
940
 
665
941
  merge_codex(codex_dir, codex_bundle, prior_deployed, dry=dry)
666
- copy_filtered(REPO / "codex" / "guides", guides, codex_dir / "guides", dry=dry,
667
- backup=run_backup)
942
+ copy_guide_members(REPO / "codex" / "guides", guides, codex_dir / "guides", dry=dry,
943
+ backup=run_backup)
668
944
 
669
945
  if not dry:
670
946
  state_dir.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,119 @@
1
+ ---
2
+ name: corpus
3
+ description: Inspect or change the private agent-bios corpus used by activated sessions, including item creation, edits, consumption placement, removal, restore, recovery, selection, reset, rollback, history, and current-vs-pinned explanation. Use for requests about the user's agent-bios instructions or personal learnings; it does not alter the running session or native global files.
4
+ ---
5
+
6
+ # Corpus management
7
+
8
+ Use the deterministic `agent-bios corpus` client. In an activated launch, invoke
9
+ it as `bash "$AGENT_BIOS_PACKAGE_ROOT/install.sh" corpus` (the examples below use
10
+ the shorter command name). The launcher supplies that package path, so a checkout
11
+ installation does not accidentally call an older global npm CLI. If the variable
12
+ is absent, resolve the installed `agent-bios` command before using the examples.
13
+ It reads and writes private
14
+ agent-bios state; it never edits native global `AGENTS.md` / `CLAUDE.md`, host
15
+ discovery directories, or the current conversation.
16
+
17
+ ## Establish the view
18
+
19
+ Start with:
20
+
21
+ ```bash
22
+ agent-bios corpus status --json
23
+ agent-bios corpus list --json
24
+ agent-bios corpus show '<CorpusRef>' --view effective --json
25
+ ```
26
+
27
+ The running session is pinned to the `ContentRef` stated by its launch contract.
28
+ `status`, `list`, and `show` describe current authoring unless a command explicitly
29
+ says otherwise. A mutation changes future activated launches only; it does not
30
+ rewrite this session's pin. `snapshot --host codex|claude` composes current
31
+ authoring and returns stored snapshot evidence—it does not prove a host loaded it.
32
+ Use `snapshot --content-ref '<ContentRef>' --json` to inspect the exact immutable
33
+ items and instruction text of the running or resumed session, independently of
34
+ current authoring.
35
+
36
+ Use `show --view installed|change|diff|history` to compare the selected installed
37
+ baseline, the personal layer, and effective authoring. Use `history [CorpusRef]
38
+ --json` for recoverable revisions.
39
+
40
+ ## Plan, preview, then apply
41
+
42
+ Send exactly one semantic operation as JSON. The runtime owns ids, paths, digests,
43
+ timestamps, serialization, revision calculation, and publication. Never invent or
44
+ edit those values. For writes, preserve the `digest` returned by `list` as
45
+ `item_digest`; preserve the plan's `expected_revision` when applying it.
46
+
47
+ ```bash
48
+ agent-bios corpus plan --input request.json --json
49
+ agent-bios corpus apply '<plan_id>' --expected-revision '<expected_revision>' --json
50
+ ```
51
+
52
+ Supported payloads:
53
+
54
+ ```json
55
+ {"operation":"create","item":{"title":"Review releases","body":"...","surface":"requested","tier":"env-personal","domains":["personal"],"kind":"rule"}}
56
+ {"operation":"update","ref":"@scope/name:item-id","item_digest":"<digest>","patch":{"body":"...","surface":"relevant"}}
57
+ {"operation":"remove","ref":"@scope/name:item-id","item_digest":"<digest>"}
58
+ {"operation":"restore","ref":"@scope/name:item-id"}
59
+ {"operation":"recover","ref":"@local/personal:item-id"}
60
+ {"operation":"select","selection":["@scope/name/domain"]}
61
+ {"operation":"reset"}
62
+ {"operation":"rollback","baseline_ref":"<baseline_ref>"}
63
+ {"operation":"rollback","history_id":"<history_id>"}
64
+ ```
65
+
66
+ For a body edit, submit `body`; the manager updates the item's `primary_member`
67
+ and derives every content view from that file. For a multi-file edit, submit
68
+ `members` retaining unchanged files. A conflicting simultaneous body/member edit
69
+ is refused. If `content_conflict` is shown, compare the preserved body and members
70
+ with the user; submit an explicit primary-member/content choice rather than
71
+ silently choosing a version. Personal IDs are issued once by the plan and remain
72
+ stable on retry; create payloads do not supply `item_id` or `ref`.
73
+ Do not turn a prose edit into an executable asset, hook,
74
+ permission, tool, or capability change. `always`, `relevant`, and `requested`
75
+ mean always in an activated selection, trigger-routed, and explicitly requested.
76
+ For existing Claude hook items, `hook: {"event":"PreToolUse","matcher":"Bash"}`
77
+ edits the binding through the same plan/apply path. Preserve the installed source
78
+ carrier and native agent frontmatter, including tool restrictions. Changing an
79
+ ordinary prose item's kind/surface does not turn it into executable hook code.
80
+
81
+ Native hook execution and corpus-agent registration are off by default. The user
82
+ opts in per launch with `agent-launch --corpus-native`; `snapshot --host claude
83
+ --native --json` previews/composes that selection without activating a host.
84
+ The snapshot supplies session-only local plugins; it does not install them into
85
+ global discovery. Check `unavailable` for unsupported carriers or hosts. Native
86
+ corpus agent names are qualified by their item plugin, distinct from the launcher's
87
+ bare tier names. Removing/restoring one item changes only future snapshots.
88
+
89
+ Show the plan and its consequences before Apply when the user has not already
90
+ approved that exact mutation. A stale revision or digest requires a fresh read and
91
+ new plan; never retry by dropping the revision check. Report after Apply that the
92
+ current session is unchanged and a new activated launch is needed.
93
+
94
+ ## Restore, recover, and reset
95
+
96
+ - `restore` removes an installed item's personal override/tombstone and reveals
97
+ the version in the selected baseline for future snapshots.
98
+ - `recover` reactivates a personal item or host learning from private history. It is not installed
99
+ restore.
100
+ - `reset` returns future authoring and selection to the last successful installed
101
+ tuple while preserving sources, history, and existing session pins.
102
+ - For all product settings as well, `agent-bios reset` previews the effect;
103
+ `agent-bios reset --apply --yes --expected-revision '<preview revision>'` also
104
+ resets launcher overrides and disables configured ingestion. It does not reset
105
+ the native host's settings. Retry the accepted revision to finish an interrupted
106
+ reset; if state has changed, show a fresh preview and obtain approval for its
107
+ revision. Token bytes are never archived or restored.
108
+ - `rollback` selects a validated baseline or replays one history record for future
109
+ snapshots; it never rewrites a running or resumable session.
110
+
111
+ ## Personal learnings
112
+
113
+ Claude and Codex learning sources are separate, host-qualified local authorities.
114
+ Captured events and their `learning_id` identify immutable captured bytes. Editing
115
+ their corpus presentation creates a local overlay for future snapshots; it does
116
+ not change an already uploaded record. Uploading revised wording is a new explicit
117
+ capture with a new id and provenance link. Never claim that local remove, reset,
118
+ promotion, or purge deleted an uploaded learning unless a real remote delete
119
+ protocol reports that outcome.
@@ -40,7 +40,12 @@ import sys
40
40
 
41
41
  sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent))
42
42
  import pkgid # noqa: E402 (sibling module in compose/)
43
- from assemble import author_only, hook_command_matches # noqa: E402 THE readers: audience frontmatter, and the settings-merge token rule the gate must agree with
43
+ from assemble import ( # noqa: E402 the shipped structural/audience/ownership readers
44
+ GuideMemberError,
45
+ author_only,
46
+ guide_member_map,
47
+ hook_command_matches,
48
+ )
44
49
 
45
50
  REPO = pathlib.Path(__file__).resolve().parent.parent
46
51
  MANIFEST = REPO / "compose" / "domains.json"
@@ -62,6 +67,11 @@ def units_on_disk(root, glob, key):
62
67
  directories that carry SKILL.md. Returns (names, malformed) — a skills subdirectory
63
68
  without the marker is neither claimable nor ignorable."""
64
69
  names, malformed = set(), []
70
+ if key == "guides":
71
+ try:
72
+ return set(guide_member_map(root)), malformed
73
+ except GuideMemberError as exc:
74
+ return names, [str(exc)]
65
75
  for p in root.glob(glob):
66
76
  if key == "skills":
67
77
  if p.is_dir():
@@ -291,8 +301,11 @@ def run_gate(manifest, bullets, repo=REPO):
291
301
  if not on_disk:
292
302
  errors.append(f"non-vacuity: no files on disk under {rel}")
293
303
  for name in malformed:
294
- errors.append(f"{key}: directory {name} in {rel} carries no {SKILL_MARKER} — "
295
- f"not a skill the hosts can load, and not a name the manifest can claim")
304
+ if key == "skills":
305
+ errors.append(f"{key}: directory {name} in {rel} carries no {SKILL_MARKER} — "
306
+ f"not a skill the hosts can load, and not a name the manifest can claim")
307
+ else:
308
+ errors.append(f"{key}: invalid bundle source shape — {name}")
296
309
  for name, entry in section.items():
297
310
  check_registry(entry, f"{key}/{name}")
298
311
  if name not in on_disk:
@@ -308,6 +321,22 @@ def run_gate(manifest, bullets, repo=REPO):
308
321
  # handle is only as good as its declaration: this catches phrasings someone wrote down, not
309
322
  # every paraphrase.
310
323
  guide_aud = {n: audience(e, errors, f"guides/{n}") for n, e in entries["guides"].items()}
324
+ guide_dir = repo / "claude" / "guides"
325
+ try:
326
+ guide_sources = guide_member_map(guide_dir)
327
+ except GuideMemberError:
328
+ # Rule 3 already reports the concrete shape error. Keep the router checks from
329
+ # raising so one malformed companion cannot hide the rest of the manifest errors.
330
+ guide_sources = {}
331
+ for name, members in guide_sources.items():
332
+ primary_is_author_only = author_only(guide_dir / name)
333
+ for member in members[1:]:
334
+ path = guide_dir / member
335
+ if path.suffix == ".md" and author_only(path) and not primary_is_author_only:
336
+ errors.append(
337
+ f"guides/{name}: companion {member} declares audience: author but its "
338
+ f"primary is delivered — author-only content cannot ride a public guide bundle"
339
+ )
311
340
  # A declared handle is a NAME, and a name resolving to two guides routes the reader
312
341
  # nowhere: refs_from() would credit both targets from one prose router, falsely
313
342
  # rooting and co-packaging a guide the sentence never meant. Duplicates fail here
@@ -380,7 +409,6 @@ def run_gate(manifest, bullets, repo=REPO):
380
409
  # exemption: a corpus bullet (by path or declared handle), a parent guide citing a child
381
410
  # as a depth chain, and a launch preset's mission. An exemption list would be the fourth,
382
411
  # and it is the one that lets a genuinely orphaned guide through.
383
- guide_dir = repo / "claude" / "guides"
384
412
  launch = repo / "launch" / "agent-launch.toml"
385
413
  launch_text, launch_missions = "", []
386
414
  if launch.is_file():
@@ -540,8 +568,13 @@ def run_gate(manifest, bullets, repo=REPO):
540
568
  prose = re.sub(r"<!--.*?-->", "", prose, flags=re.S)
541
569
  return re.sub(r"<!--.*", "", prose, flags=re.S)
542
570
 
543
- bodies = {p.name: _defenced(p.read_text(encoding="utf-8"))
544
- for p in sorted(guide_dir.glob("*.md"))}
571
+ bodies = {
572
+ name: "\n".join(
573
+ _defenced((guide_dir / member).read_text(encoding="utf-8"))
574
+ for member in members if member.endswith(".md")
575
+ )
576
+ for name, members in guide_sources.items()
577
+ }
545
578
  if not bodies:
546
579
  errors.append("non-vacuity: no guide bodies read, so consumers were not checked")
547
580
  # Reachability is TRANSITIVE FROM ROOTS (bullets and launch missions), not "any
@@ -617,9 +650,10 @@ def run_gate(manifest, bullets, repo=REPO):
617
650
  sizes[key] = sizes.get(key, 0) + len(hit) // 4
618
651
  for name, entry in entries["guides"].items():
619
652
  key = entry.get("tier") if entry.get("tier") != "domain" else ",".join(entry.get("domains", ["?"])[:1])
620
- p = repo / FILE_SECTIONS["guides"][0] / name
621
- if p.is_file():
622
- sizes[key] = sizes.get(key, 0) + len(p.read_text(encoding="utf-8")) // 4
653
+ for member in guide_sources.get(name, []):
654
+ p = repo / FILE_SECTIONS["guides"][0] / member
655
+ if p.is_file():
656
+ sizes[key] = sizes.get(key, 0) + p.stat().st_size // 4
623
657
  return errors, sizes
624
658
 
625
659
 
@@ -773,6 +807,57 @@ def self_test(manifest, bullets):
773
807
  print(f"self-test [{'CAUGHT' if never_ok else 'MISSED'}] an env-personal (never-"
774
808
  f"delivered) router bullet leaves {o_name} an orphan")
775
809
 
810
+ # Bundle shape is structural, not a second manifest class. These controls run against
811
+ # copies because the source is the input being judged: an orphan resource tree and a
812
+ # symlink must be named before router/coverage checks can pretend the guide is ordinary.
813
+ bundle_sources = guide_member_map(REPO / "claude" / "guides")
814
+ bundle_name = next((name for name, members in bundle_sources.items()
815
+ if len(members) > 1), None)
816
+ if bundle_name is None:
817
+ raise SystemExit("self-test: no guide bundle has a companion member, so bundle-shape "
818
+ "controls would pass vacuously")
819
+ companion_markdown = next((member for member in bundle_sources[bundle_name][1:]
820
+ if member.endswith(".md")), None)
821
+ if companion_markdown is None:
822
+ raise SystemExit("self-test: the companion bundle has no Markdown resource, so the "
823
+ "companion-router control would pass vacuously")
824
+ import shutil as _bundle_shutil, tempfile as _bundle_tempfile
825
+ with _bundle_tempfile.TemporaryDirectory() as _td:
826
+ _root = pathlib.Path(_td)
827
+ for _sub in ("claude", "ko", "launch"):
828
+ if (REPO / _sub).is_dir():
829
+ _bundle_shutil.copytree(REPO / _sub, _root / _sub)
830
+ _guide_root = _root / "claude" / "guides"
831
+ (_guide_root / "orphan-companion-control").mkdir()
832
+ _orphan_shape, _ = run_gate(manifest, bullets, repo=_root)
833
+ orphan_companion_ok = any("orphan guide companion tree" in e for e in _orphan_shape)
834
+ _bundle_shutil.rmtree(_guide_root / "orphan-companion-control")
835
+
836
+ _symlink = _guide_root / pathlib.Path(bundle_name).stem / "symlink-control"
837
+ _symlink.symlink_to(_guide_root / bundle_name)
838
+ _symlink_shape, _ = run_gate(manifest, bullets, repo=_root)
839
+ symlink_ok = any("must not contain symlinks" in e for e in _symlink_shape)
840
+ _symlink.unlink()
841
+
842
+ _companion = _guide_root / companion_markdown
843
+ _body = _companion.read_text(encoding="utf-8")
844
+ _companion.write_text(_body + f"\n\nSee `guides/{o_name}` for the control.\n",
845
+ encoding="utf-8")
846
+ _companion_router, _ = run_gate(m_orphan, [b for b in bullets if b != o_line], repo=_root)
847
+ companion_router_ok = not any(o_name in e and "orphan" in e for e in _companion_router)
848
+ _companion.write_text("---\naudience: author\n---\n" + _body, encoding="utf-8")
849
+ _companion_author, _ = run_gate(manifest, bullets, repo=_root)
850
+ companion_author_ok = any(bundle_name in e and companion_markdown in e
851
+ and "audience: author" in e for e in _companion_author)
852
+ print(f"self-test [{'CAUGHT' if orphan_companion_ok else 'MISSED'}] an orphan companion "
853
+ "tree is rejected without requiring a manifest item")
854
+ print(f"self-test [{'CAUGHT' if symlink_ok else 'MISSED'}] a symlink in a companion tree "
855
+ "is rejected")
856
+ print(f"self-test [{'CAUGHT' if companion_router_ok else 'MISSED'}] a Markdown companion "
857
+ "is scanned as its primary guide's router")
858
+ print(f"self-test [{'CAUGHT' if companion_author_ok else 'MISSED'}] an author-only companion "
859
+ "cannot ride a delivered primary")
860
+
776
861
  # A registry of the wrong TYPE must be listed like any other violation. `set(None)` is
777
862
  # not a diagnostic — it is a TypeError out of the gate before it can name anything, and
778
863
  # `"domains": null` is exactly what a bad hand-edit of this file leaves behind. The
@@ -1222,6 +1307,14 @@ def self_test(manifest, bullets):
1222
1307
  failed.append("real router in list-blockquoted prose still counts")
1223
1308
  if not never_ok:
1224
1309
  failed.append("never-delivered bullet does not root a guide")
1310
+ if not orphan_companion_ok:
1311
+ failed.append("orphan companion tree is rejected")
1312
+ if not symlink_ok:
1313
+ failed.append("companion-tree symlink is rejected")
1314
+ if not companion_router_ok:
1315
+ failed.append("Markdown companion prose is scanned as a router")
1316
+ if not companion_author_ok:
1317
+ failed.append("author-only companion cannot ride a delivered primary")
1225
1318
  if not island_ok:
1226
1319
  failed.append("reciprocal island stays orphaned")
1227
1320
  if not inert_hits:
@@ -356,6 +356,9 @@ def _assemble_at(repo: pathlib.Path, commit: str, domains: list[str]) -> tuple[b
356
356
 
357
357
 
358
358
  def cmd_rollback(args: argparse.Namespace) -> int:
359
+ if os.environ.get("AGENT_BIOS_LEGACY_INSTALL") != "1":
360
+ print("corpus-state rollback: global rollback is retired; use agent-bios corpus plan with op=rollback and a private baseline_ref", file=sys.stderr)
361
+ return 2
359
362
  repo = repo_root(args.repo)
360
363
  versions = require_versions(repo, "rollback")
361
364
  if versions is None:
@@ -1144,6 +1147,7 @@ def main() -> int:
1144
1147
  # Before argparse, because --self-test takes none of the subcommands' arguments and
1145
1148
  # every subcommand here is required.
1146
1149
  if "--self-test" in sys.argv[1:]:
1150
+ os.environ["AGENT_BIOS_LEGACY_INSTALL"] = "1"
1147
1151
  return self_test()
1148
1152
  parser = argparse.ArgumentParser(description=__doc__)
1149
1153
  sub = parser.add_subparsers(dest="cmd", required=True)