agent-bios 0.9.8 → 0.9.9

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 (49) hide show
  1. package/DEPENDENCIES.md +19 -19
  2. package/README.md +34 -11
  3. package/claude/CLAUDE.md +2 -1
  4. package/claude/guides/claude-prompting.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +19 -1
  6. package/claude/guides/coding-staged-workflow.md +32 -0
  7. package/claude/guides/gpt-prompting.md +1 -1
  8. package/claude/guides/learning-flow.md +5 -5
  9. package/claude/guides/llm-capability-boundary.md +6 -1
  10. package/claude/guides/session-distill-workflow.md +19 -9
  11. package/claude/guides/tooling-gotchas.md +16 -0
  12. package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
  13. package/claude/hooks/tooling-gotchas-hook.py +7 -0
  14. package/codex/AGENTS.md +2 -1
  15. package/codex/guides/claude-prompting.md +1 -1
  16. package/codex/guides/cli-multi-model-workflow.md +19 -1
  17. package/codex/guides/coding-staged-workflow.md +32 -0
  18. package/codex/guides/gpt-prompting.md +1 -1
  19. package/codex/guides/learning-flow.md +5 -5
  20. package/codex/guides/llm-capability-boundary.md +6 -1
  21. package/codex/guides/session-distill-workflow.md +19 -9
  22. package/codex/guides/tooling-gotchas.md +16 -0
  23. package/{scripts → compose}/assemble.py +184 -16
  24. package/{scripts → compose}/canary.sh +14 -5
  25. package/{scripts → compose}/check-domains.py +9 -3
  26. package/{config → compose}/domains.json +1 -0
  27. package/{scripts → compose}/pkgid.py +8 -1
  28. package/compose/prune-backups.py +204 -0
  29. package/{scripts → compose}/register-hooks.py +3 -3
  30. package/{scripts/install.sh → install.sh} +401 -104
  31. package/launch/agent-launch.py +5294 -0
  32. package/launch/agent-launch.toml +376 -0
  33. package/{scripts → launch}/check-prompting-targets.sh +1 -1
  34. package/{scripts → launch}/provision-venv.sh +1 -1
  35. package/{scripts → learn}/check-learning.py +7 -7
  36. package/{scripts → learn}/collect-learning.py +10 -10
  37. package/{config → learn}/learning.schema.json +3 -3
  38. package/{scripts → learn}/migrate-learnings.py +95 -54
  39. package/{scripts → learn}/redact.py +4 -4
  40. package/package.json +25 -24
  41. package/wrappers/claude-run.sh +162 -0
  42. package/{scripts → wrappers}/codex-run.sh +62 -6
  43. package/config/agent-launch.toml +0 -143
  44. package/scripts/agent-launch.py +0 -2350
  45. package/scripts/check-parity.sh +0 -2003
  46. /package/{shell → launch}/agent-launch.zsh +0 -0
  47. /package/{config → learn}/promotions.json +0 -0
  48. /package/{scripts/session-cost.py → session-cost.py} +0 -0
  49. /package/{scripts → wrappers}/codex-helm.sh +0 -0
@@ -25,7 +25,14 @@ set -euo pipefail
25
25
  # CI, pipes, and background runs, where stdin stays open but never delivers.
26
26
  # `learn` is the one subcommand whose payload IS stdin, so keep the caller's on
27
27
  # fd 3 first and hand it back only there; every other path still sees /dev/null.
28
- exec 3<&0 2>/dev/null || exec 3</dev/null # tolerate a caller that closed fd 0
28
+ # The braces matter. `exec` with redirections and no command applies them to the
29
+ # SHELL, permanently — so the bare `exec 3<&0 2>/dev/null` this used to be sent
30
+ # every later error message on this script's stderr to /dev/null: bash's own
31
+ # set -e diagnostics, python tracebacks from deployed steps, and any `>&2` an
32
+ # author writes. Only stdout survived, which is why a failing install could stop
33
+ # with no reason on screen. The group scopes the suppression to the one command
34
+ # whose error is expected when a caller closed fd 0.
35
+ { exec 3<&0; } 2>/dev/null || exec 3</dev/null # tolerate a caller that closed fd 0
29
36
  exec </dev/null
30
37
 
31
38
  # Resolve this script through symlinks before locating the package: npm links the
@@ -42,7 +49,7 @@ while [ -L "$SOURCE" ]; do
42
49
  esac
43
50
  done
44
51
  SELF="$(cd -P "$(dirname "$SOURCE")" && pwd)"
45
- REPO="$(cd "$SELF/.." && pwd)"
52
+ REPO="$SELF"
46
53
 
47
54
  CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
48
55
  CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
@@ -52,6 +59,11 @@ BIN_DIR="$HOME/.local/bin"
52
59
  STATE_DIR="$HOME/.local/share/agent-bios"
53
60
  LEGACY_STATE_DIR="$HOME/.local/share/agent-dotfiles" # pre-rename state; migrated on first run
54
61
  MANIFEST="$STATE_DIR/manifest.txt"
62
+ # The manifest is truncated at the start of every install, so what the PREVIOUS one deployed
63
+ # must be preserved before that happens. It is the only record that a file now on disk was
64
+ # written by us rather than authored by the user, and seed_entry needs exactly that to tell a
65
+ # previous release's deployed entry from someone's own CLAUDE.md.
66
+ PRIOR_MANIFEST="$STATE_DIR/manifest.prev.txt"
55
67
  ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"
56
68
  ZSH_HOOK='[ -r "$HOME/.config/agent-launch/shell.zsh" ] && source "$HOME/.config/agent-launch/shell.zsh"'
57
69
  HOOK_MARK='agent-launch/shell.zsh'
@@ -105,35 +117,129 @@ deploy_glob() {
105
117
  done
106
118
  }
107
119
 
108
- # ---- packaged (domain-selected) corpus deploy ----------------------------
109
- # Active when --domains is passed or a prior selection exists in STATE_DIR.
110
- # assemble.py owns the corpus surfaces (central tree, entry seeding, codex
111
- # marker region, settings merge); the entry CLAUDE.md and AGENTS.md are NOT
112
- # manifested — the entry is user-owned after seeding, AGENTS.md holds a
113
- # personal region so uninstall never deletes them.
114
- # Full mode never runs the assembler, so nothing registered the hooks it
115
- # deployed the files landed and never fired. Reuse the assembler's own merge
116
- # so both install paths register identically, under one ownership rule.
117
- register_hooks_full() {
118
- [ "$DRY_RUN" = 1 ] && { info "[dry-run] register central hooks"; return 0; }
119
- python3 "$REPO/scripts/register-hooks.py" "$REPO" "$CLAUDE_DIR" \
120
- || log "note: hook registration failed; the deployed hooks will not fire"
120
+ # Backups are made on every install and every uninstall, and nothing used to remove them — 23
121
+ # directories in two weeks on the first machine measured, plus loose `.bak-*` files sitting in
122
+ # the user's own config dirs. Pruning runs AFTER the new copy exists, never before, so a failed
123
+ # run cannot leave the user with neither the change nor its backup.
124
+ prune_backups() {
125
+ [ -f "$REPO/compose/prune-backups.py" ] || return 0
126
+ python3 "$REPO/compose/prune-backups.py" --state-dir "$STATE_DIR" \
127
+ --claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" \
128
+ $([ "$DRY_RUN" = 1 ] && echo --dry-run) || log "note: backup pruning skipped"
121
129
  }
122
130
 
123
- packaged_mode() { [ "${DOMAINS_SET:-0}" = 1 ] || [ -f "$STATE_DIR/selection.json" ]; }
131
+ # Guides whose frontmatter declares `audience: author`. They document steps only
132
+ # the corpus author can perform and name paths that exist in a checkout and
133
+ # nowhere else, so they are never installed. compose/assemble.py owns the
134
+ # declaration and withholds them from the destinations it writes; this asks it
135
+ # rather than keeping a second parser. Both callers below need that same answer
136
+ # for ground the assembler never writes — the sweep of the pre-unification
137
+ # $CLAUDE_DIR/guides, and verify's check that the absence holds in all three
138
+ # directories. Absence of the assembler degrades to withholding nothing.
139
+ # Fails LOUD, never open. An empty answer here means "withhold nothing", so
140
+ # swallowing an unreadable declaration would deploy exactly the guides this rule
141
+ # exists to hold back — the fail-open shape of every other degrade-on-absence
142
+ # fallback in this file, but inverted. compose/assemble.py ships, so its absence
143
+ # is a broken payload rather than a configuration a user might have.
144
+ author_only_guides() {
145
+ local out
146
+ if ! out=$(python3 - "$REPO" <<'PY'
147
+ import pathlib, sys
148
+ root = pathlib.Path(sys.argv[1])
149
+ sys.path.insert(0, str(root / "compose"))
150
+ try:
151
+ from assemble import author_only
152
+ except ImportError as exc:
153
+ raise SystemExit(f"compose/assemble.py owns the audience declaration and "
154
+ f"could not be imported ({exc})")
155
+ guides = root / "claude" / "guides"
156
+ if not guides.is_dir():
157
+ raise SystemExit(f"{guides} is missing; the payload is incomplete")
158
+ for p in sorted(guides.glob("*.md")):
159
+ if author_only(p):
160
+ print(p.name)
161
+ PY
162
+ ); then
163
+ # stderr, not stdout: every caller reads this function through a command
164
+ # substitution, so anything printed normally would be captured INTO the
165
+ # variable instead of reaching the operator — and `exit` here would leave
166
+ # only the subshell, letting the caller proceed with the error text as its
167
+ # list of withheld guides. The caller checks the status and exits itself.
168
+ log "cannot read which guides are author-only — refusing to deploy rather than" >&2
169
+ log " shipping them by default. compose/assemble.py owns that declaration and" >&2
170
+ log " must be present; reinstall the package." >&2
171
+ return 1
172
+ fi
173
+ printf '%s\n' "$out"
174
+ }
124
175
 
125
- assemble_packaged() {
176
+ # Remove a withheld guide from a directory this installer deploys into, backing it
177
+ # up first exactly as deploy_file backs up a file it is about to overwrite. An
178
+ # unconditional rm would delete a same-named file this installer never wrote, and
179
+ # a shared or symlinked guides directory makes that somebody's own file.
180
+ prune_withheld() {
181
+ local dstdir="$1" base
182
+ printf '%s\n' "$WITHHELD_GUIDES" | while IFS= read -r base; do
183
+ [ -n "$base" ] || continue
184
+ [ -f "$dstdir/$base" ] || continue
185
+ if [ -n "$BACKUP_DIR" ]; then
186
+ run mkdir -p "$(dirname "$BACKUP_DIR$dstdir/$base")"
187
+ run cp "$dstdir/$base" "$BACKUP_DIR$dstdir/$base"
188
+ fi
189
+ run rm -f "$dstdir/$base"
190
+ [ "$DRY_RUN" = 1 ] || info "withheld (audience: author), removed stale copy: $dstdir/$base"
191
+ done
192
+ }
193
+
194
+ # ---- corpus deploy -------------------------------------------------------
195
+ # assemble.py owns the corpus surfaces (central tree, entry seeding, codex marker region,
196
+ # settings merge). The entry CLAUDE.md and AGENTS.md are NOT manifested — the entry is
197
+ # user-owned after seeding and AGENTS.md holds a personal region — so uninstall removes our
198
+ # central tree and marked regions and leaves the user's file itself alone.
199
+ # There is ONE install shape. There used to be two: a "full" deploy that wrote the corpus into
200
+ # the entry file, and a packaged one that assembled selected domains under `central/` and left the
201
+ # entry file to the user. They differed in the thing that matters most — who owns the entry file —
202
+ # so the same path was ours in one mode and theirs in the other, and no rule about user-owned
203
+ # content could be true of both. Full mode is now "every domain selected": the same assembly, the
204
+ # same ownership, one set of answers.
205
+ #
206
+ # What this buys, in the order the questions were asked: the user's own additions live in a file
207
+ # we never rewrite, so they are separately versioned by construction; a package can be added or
208
+ # dropped later by re-assembling, with no need to know which bytes came from where; and uninstall
209
+ # can take everything of ours because nothing of theirs is mixed into it.
210
+ assemble_corpus() {
126
211
  local args=(--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR") rc=0
127
- [ "${DOMAINS_SET:-0}" = 1 ] && args+=(--domains "$DOMAINS_ARG")
212
+ if [ "${DOMAINS_SET:-0}" = 1 ]; then
213
+ args+=(--domains "$DOMAINS_ARG")
214
+ elif [ ! -f "$STATE_DIR/selection.json" ]; then
215
+ # No flag and no saved selection: install everything. This is what "full" meant, expressed as
216
+ # a selection so it goes down the same path as every other one.
217
+ args+=(--domains "$(python3 -c "import json,sys;print(','.join(sorted(json.load(open(sys.argv[1]))['domains'])))" "$REPO/compose/domains.json")")
218
+ fi
128
219
  [ "$DRY_RUN" = 1 ] && args+=(--dry-run)
129
- python3 "$REPO/scripts/assemble.py" "${args[@]}" || rc=$?
220
+ # A dry run never truncated the manifest, so the live one still describes the last install.
221
+ local prior="$PRIOR_MANIFEST"
222
+ [ "$DRY_RUN" = 1 ] && prior="$MANIFEST"
223
+ [ -f "$prior" ] && args+=(--prior-manifest "$prior")
224
+ python3 "$REPO/compose/assemble.py" "${args[@]}" || rc=$?
130
225
  if [ "$rc" = 2 ]; then
131
- log "packaged: entry file needs user action (import line missing); central content will not load until it is added"
226
+ log "entry file needs user action (import line missing); central content will not load until it is added"
132
227
  elif [ "$rc" != 0 ]; then
133
228
  return 1
134
229
  fi
135
230
  if [ "$DRY_RUN" != 1 ]; then
136
- find "$CLAUDE_DIR/central" "$CODEX_DIR/guides" -type f 2>/dev/null >> "$MANIFEST"
231
+ # `central/` is ours whole, so scanning it is right. `$CODEX_DIR/guides` is SHARED the
232
+ # user and other tools keep files there, and the assembler deliberately leaves any name it
233
+ # did not deploy alone. Scanning that directory claimed those files anyway, and uninstall
234
+ # removes whatever the manifest names, so the protection was undone one step later. The
235
+ # ownership rule is the assembler's: the name exists in our source tree.
236
+ find "$CLAUDE_DIR/central" -type f 2>/dev/null >> "$MANIFEST"
237
+ local g dst
238
+ for g in "$REPO"/codex/guides/*.md; do
239
+ [ -f "$g" ] || continue
240
+ dst="$CODEX_DIR/guides/$(basename "$g")"
241
+ [ -f "$dst" ] && printf '%s\n' "$dst" >> "$MANIFEST"
242
+ done
137
243
  fi
138
244
  }
139
245
 
@@ -309,13 +415,25 @@ PY
309
415
 
310
416
  # ---- optional dependencies -----------------------------------------------
311
417
  # Capabilities are optional: a missing one only degrades the review routes that
312
- # need it. config/agent-launch.toml is the single source for both the command
418
+ # need it. launch/agent-launch.toml is the single source for both the command
313
419
  # that gates a route and the install line offered here.
314
420
  capability_table() {
315
- python3 - "$REPO/config/agent-launch.toml" <<'PY'
421
+ python3 - "$REPO/launch/agent-launch.toml" <<'PY'
316
422
  import sys, tomllib
317
- for name, cap in tomllib.load(open(sys.argv[1], "rb")).get("capabilities", {}).items():
318
- print("\t".join((name, cap.get("command", ""), cap.get("install", ""))))
423
+ # `${backend}` means "the CLI of the host this capability offers on". Resolved HERE because
424
+ # the probe below runs `command -v` on whatever this prints, and the literal sentinel is not
425
+ # a command: the built-in workflow capability reported as unavailable while the launcher ran
426
+ # it perfectly well.
427
+ data = tomllib.load(open(sys.argv[1], "rb"))
428
+ backends = data.get("backends", {})
429
+ for name, cap in data.get("capabilities", {}).items():
430
+ command = cap.get("command", "")
431
+ if command == "${backend}":
432
+ hosts = sorted({h for offer in cap.get("offers", []) for h in offer.get("hosts", [])})
433
+ command = next(
434
+ (backends[h]["command"] for h in hosts if backends.get(h, {}).get("command")), ""
435
+ )
436
+ print("\t".join((name, command, cap.get("install", ""))))
319
437
  PY
320
438
  }
321
439
 
@@ -339,6 +457,16 @@ handle_capabilities() {
339
457
  [ -n "$name" ] || continue
340
458
  if command -v "$command" >/dev/null 2>&1; then
341
459
  info "capability present $name ($command)"
460
+ # Explicitly requested, already there, and nothing to install: say so, because after
461
+ # the rename `--with ultracode` names the claude-backed reviewer — which needs no
462
+ # install — while the tool the user probably meant stays missing and is only hinted
463
+ # at further down. The request silently no-opped and install still exited 0.
464
+ if [ -z "$line" ] && printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
465
+ log "note: $name needs no install (it is the host CLI); nothing was installed for it"
466
+ local installable
467
+ installable=$(capability_table | awk -F'\t' -v me="$name" '$1 != me && $3 != "" { print $1 }' | tr '\n' ' ')
468
+ [ -n "$installable" ] && log " capabilities that DO install: ${installable% }"
469
+ fi
342
470
  continue
343
471
  fi
344
472
  if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
@@ -362,7 +490,7 @@ EOF
362
490
  # Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
363
491
  # would be lost to the cp below; move them into the user-owned presets file first.
364
492
  migrate_user_presets() {
365
- local src="$REPO/config/agent-launch.toml"
493
+ local src="$REPO/launch/agent-launch.toml"
366
494
  local dst="$LAUNCH_DIR/profiles.toml"
367
495
  local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
368
496
  [ -f "$dst" ] || return 0
@@ -429,10 +557,10 @@ remove_zsh_hook() {
429
557
  # corpus AND are in this user's assembled bundle. Best-effort: a prune failure
430
558
  # (or an absent manifest/script) never fails the install. Runs per host.
431
559
  migrate_learnings() {
432
- local script="$REPO/scripts/migrate-learnings.py"
433
- { [ -f "$script" ] && [ -f "$REPO/config/promotions.json" ]; } || return 0
560
+ local script="$REPO/learn/migrate-learnings.py"
561
+ { [ -f "$script" ] && [ -f "$REPO/learn/promotions.json" ]; } || return 0
434
562
  local -a sel dry
435
- if packaged_mode; then sel=(--selection-file "$STATE_DIR/selection.json"); else sel=(--full); fi
563
+ sel=(--selection-file "$STATE_DIR/selection.json")
436
564
  [ "$DRY_RUN" = 1 ] && dry=(--dry-run) || dry=()
437
565
  # ${dry[@]+...}: expanding an empty array as "${dry[@]}" is an unbound-variable
438
566
  # error under `set -u` on bash 3.2 (macOS default) and would abort the install.
@@ -449,32 +577,31 @@ cmd_install() {
449
577
  if [ "$DRY_RUN" != 1 ]; then
450
578
  mkdir -p "$STATE_DIR"
451
579
  BACKUP_DIR="$STATE_DIR/backups/$(date +%Y%m%d-%H%M%S)"
580
+ [ -f "$MANIFEST" ] && cp "$MANIFEST" "$PRIOR_MANIFEST"
452
581
  : > "$MANIFEST"
453
582
  fi
454
583
  log "Deploying agent-bios from $REPO"
455
- if packaged_mode; then
456
- log "packaged mode: assembling selected domains (state: $STATE_DIR/selection.json)"
457
- assemble_packaged || exit 1
458
- else
459
- deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
460
- deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
461
- deploy_glob "$REPO/claude/agents" "*.md" "$CLAUDE_DIR/agents"
462
- # Our hooks go under central/: <claude>/hooks is shared with other tools
463
- # files, caches, and state, and we must not own a directory we share.
464
- deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/central/hooks" "+x"
465
- register_hooks_full
466
- deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
467
- deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
468
- fi
584
+ # Refuse loudly before doing any work: an unreadable declaration must not degrade
585
+ # into withholding nothing, which is the fail-open shape this rule exists to avoid.
586
+ WITHHELD_GUIDES="$(author_only_guides)" || exit 1
587
+ assemble_corpus || exit 1
588
+ # assemble.py withholds author-only guides and prunes the destinations it writes.
589
+ # $CLAUDE_DIR/guides is not one of them — it is where the pre-unification full
590
+ # install put guides, so a machine that installed then would keep its copy for good.
591
+ prune_withheld "$CLAUDE_DIR/guides"
469
592
  migrate_learnings # Phase 4: clear personal copies now absorbed by the corpus
470
593
  deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
471
594
  codex_config_additions merge || exit 1
472
- deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
473
- deploy_file "$REPO/scripts/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
595
+ deploy_file "$REPO/wrappers/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
596
+ deploy_file "$REPO/wrappers/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
597
+ # The claude-side adapter lands under CLAUDE_DIR for the same reason its codex twin
598
+ # lands under CODEX_DIR: each is that host's, and a single shared bin would make the
599
+ # two families' review routes indistinguishable from their install paths.
600
+ deploy_file "$REPO/wrappers/claude-run.sh" "$CLAUDE_DIR/bin/claude-run" "+x"
474
601
  migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
475
- deploy_file "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
476
- deploy_file "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
477
- deploy_file "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
602
+ deploy_file "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
603
+ deploy_file "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
604
+ deploy_file "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
478
605
  log ""
479
606
  log "Optional dependencies (missing ones only degrade the routes that need them)..."
480
607
  handle_capabilities "$WITH" || exit 1
@@ -482,11 +609,11 @@ cmd_install() {
482
609
  if [ "$DRY_RUN" = 1 ]; then
483
610
  info "[dry-run] provision managed Textual venv"
484
611
  else
485
- bash "$REPO/scripts/provision-venv.sh" && info "managed venv OK" \
612
+ bash "$REPO/launch/provision-venv.sh" && info "managed venv OK" \
486
613
  || log "warning: venv provisioning failed (numbered-prompt fallback applies)"
487
614
  fi
488
615
  add_zsh_hook
489
- if python3 "$REPO/scripts/session-distill/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
616
+ if python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
490
617
  info "corpus-status projected"
491
618
  else
492
619
  log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
@@ -517,6 +644,7 @@ PY
517
644
  # An untouched backup dir means nothing was replaced; that healthy state
518
645
  # must not become a nonzero exit under set -e.
519
646
  { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
647
+ prune_backups
520
648
  else
521
649
  log "VERIFY FAILED after install — see messages above"
522
650
  exit 1
@@ -527,37 +655,60 @@ verify_match() { if cmp -s "$1" "$2"; then info "match $2"; else log "MISMA
527
655
  verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return 1; fi; }
528
656
 
529
657
  cmd_verify() {
530
- local fail=0 gp
658
+ local fail=0 gp gb
531
659
  if [ ! -d "$REPO/.git" ] && [ "$(drift_state)" = "drift" ]; then
532
660
  log "deploy drift: deployed $(deployed_version), package $(source_version) — run: agent-bios install"
533
661
  fail=1
534
662
  fi
535
- if packaged_mode; then
536
- # Packaged: corpus surfaces are selection-derived, not repo-identical.
537
- # The entry file is user-owned — READ-check the import line, never rewrite.
538
- python3 "$REPO/scripts/check-domains.py" >/dev/null 2>&1 && info "domains gate OK" || { log "domains gate FAILED"; fail=1; }
539
- verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
540
- if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
541
- info "entry import line present"
542
- else
543
- log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
544
- fi
545
- if grep -qF 'agent-bios:central:start' "$CODEX_DIR/AGENTS.md" 2>/dev/null; then
546
- info "codex central region present"
547
- else
548
- log "codex AGENTS.md central region missing"; fail=1
549
- fi
663
+ # Not mode-specific: an author-only guide must be absent from EVERY directory any
664
+ # install writes, including the one the pre-unification full install owned.
665
+ local verify_withheld
666
+ verify_withheld="$(author_only_guides)" || return 1
667
+ for gb in $verify_withheld; do
668
+ for gp in "$CLAUDE_DIR/guides/$gb" "$CLAUDE_DIR/central/guides/$gb" "$CODEX_DIR/guides/$gb"; do
669
+ if [ -f "$gp" ]; then
670
+ log "author-only guide is still installed: $gp"; fail=1
671
+ fi
672
+ done
673
+ done
674
+ [ -n "$verify_withheld" ] && info "author-only guides withheld: $(printf '%s' "$verify_withheld" | tr '\n' ' ')"
675
+ # Corpus surfaces are selection-derived, not repo-identical, so verify reads the assembled
676
+ # shape rather than byte-comparing against the repo. The entry file is user-owned — READ-check
677
+ # the import line, never rewrite it.
678
+ python3 "$REPO/compose/check-domains.py" >/dev/null 2>&1 && info "domains gate OK" || { log "domains gate FAILED"; fail=1; }
679
+ verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
680
+ if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
681
+ info "entry import line present"
682
+ else
683
+ log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
684
+ fi
685
+ if grep -qF 'agent-bios:central:start' "$CODEX_DIR/AGENTS.md" 2>/dev/null; then
686
+ info "codex central region present"
687
+ else
688
+ log "codex AGENTS.md central region missing"; fail=1
689
+ fi
690
+ # Every capability must probe as a real command. `${backend}` reaching this table means
691
+ # the sentinel was not resolved, and the probe below would then report a capability the
692
+ # launcher can run perfectly well as unavailable.
693
+ if capability_table | awk -F'\t' '$2 == "" || $2 == "${backend}" { print; found=1 } END { exit !found }' >/dev/null 2>&1; then
694
+ log "capability table has an unresolved or empty command — the installer would misreport it"; fail=1
550
695
  else
551
- verify_match "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md" || fail=1
552
- verify_match "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md" || fail=1
553
- for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
554
- for gp in "$REPO"/claude/agents/*.md; do verify_present "$CLAUDE_DIR/agents/$(basename "$gp")" || fail=1; done
555
- for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/central/hooks/$(basename "$gp")" || fail=1; done
556
- for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
557
- fi
558
- verify_match "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
559
- verify_match "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
560
- verify_match "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
696
+ info "capability commands resolve"
697
+ fi
698
+ # The help's --with list must BE the capability table, not a copy of it: the copy went
699
+ # stale on the first rename and advertised a name that selects a different capability
700
+ # than the one carrying the install line.
701
+ local advertised table
702
+ advertised=$(usage 2>/dev/null | sed -n 's/^ *--with names: //p')
703
+ table=$(capability_table 2>/dev/null | cut -f1 | tr '\n' ' ')
704
+ if [ "$advertised" = "${table% }" ]; then
705
+ info "--with help matches the capability table"
706
+ else
707
+ log "--with help advertises '$advertised' but the capabilities are '${table% }'"; fail=1
708
+ fi
709
+ verify_match "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
710
+ verify_match "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
711
+ verify_match "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
561
712
  python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
562
713
  import sys, pathlib, tomllib
563
714
  root = pathlib.Path(sys.argv[1])
@@ -575,6 +726,24 @@ PY
575
726
  log "codex-helm dry-run FAILED"; fail=1
576
727
  fi
577
728
  fi
729
+ # Existence first, because the guard below is written as `if executable` and a MISSING
730
+ # adapter would satisfy it by never running — a deploy target whose only check skips
731
+ # itself when the deploy failed is not checked at all.
732
+ verify_present "$CLAUDE_DIR/bin/claude-run" || fail=1
733
+ # Then WHICH version landed, because this file is on the panel's dispatch path now and
734
+ # an older copy would dispatch reviews that quietly emit nothing. The earlier check
735
+ # here asserted that an unpinned dispatch is refused; that guard was deliberately
736
+ # removed when the adapter went live — refusing turned "the review ran unpinned" into
737
+ # "the review did not run" — so asserting it would now fail against correct behaviour.
738
+ # `--help` is the only probe that reaches no network: an unpinned run warns and then
739
+ # dispatches for real.
740
+ if [ -x "$CLAUDE_DIR/bin/claude-run" ]; then
741
+ if "$CLAUDE_DIR/bin/claude-run" --help 2>/dev/null | grep -q 'REVIEW_RECEIPT_DIR'; then
742
+ info "claude-run is receipt-aware"
743
+ else
744
+ log "claude-run predates the receipt contract — reviews through it emit nothing"; fail=1
745
+ fi
746
+ fi
578
747
  local vpy="${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch/venv}/bin/python"
579
748
  if [ -x "$vpy" ] && "$vpy" -c 'import textual' 2>/dev/null; then
580
749
  info "managed venv (textual) OK"
@@ -583,25 +752,25 @@ PY
583
752
  fi
584
753
  # A file this installer executes but never ships is invisible from a clone and
585
754
  # fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
586
- if [ -x "$REPO/scripts/check-package.sh" ]; then
587
- if "$REPO/scripts/check-package.sh" >/dev/null 2>&1; then
755
+ if [ -x "$REPO/gates/check-package.sh" ]; then
756
+ if "$REPO/gates/check-package.sh" >/dev/null 2>&1; then
588
757
  info "npm payload OK"
589
758
  else
590
- log "npm payload incomplete; run scripts/check-package.sh"
759
+ log "npm payload incomplete; run gates/check-package.sh"
591
760
  fail=1
592
761
  fi
593
762
  fi
594
763
  # Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
595
- if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
596
- if "$REPO/scripts/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
764
+ if [ -d "$REPO/ko" ] && [ -x "$REPO/gates/check-parity.sh" ]; then
765
+ if "$REPO/gates/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
597
766
  fi
598
767
  # Prompting guides name concrete models, so they go stale on a model change
599
768
  # rather than degrading quietly; this checks them against the launch config.
600
- if [ -x "$REPO/scripts/check-prompting-targets.sh" ]; then
601
- if "$REPO/scripts/check-prompting-targets.sh" >/dev/null 2>&1; then
769
+ if [ -x "$REPO/launch/check-prompting-targets.sh" ]; then
770
+ if "$REPO/launch/check-prompting-targets.sh" >/dev/null 2>&1; then
602
771
  info "prompting targets OK"
603
772
  else
604
- log "prompting guides do not cover a configured model; run scripts/check-prompting-targets.sh"
773
+ log "prompting guides do not cover a configured model; run launch/check-prompting-targets.sh"
605
774
  fail=1
606
775
  fi
607
776
  fi
@@ -611,38 +780,149 @@ PY
611
780
  cmd_uninstall() {
612
781
  migrate_state
613
782
  codex_config_additions remove || log "warning: could not remove codex config additions"
783
+ # The assembler's two spans in files we do not own — settings registrations and the AGENTS.md
784
+ # central region — are merged in and were never removed here, so uninstall used to leave hooks
785
+ # invoking deleted files and instructions pointing at deleted guides. It reads ownership the
786
+ # same way the merge wrote it, so it cannot reach past what we put there.
787
+ if [ -f "$REPO/compose/assemble.py" ]; then
788
+ python3 "$REPO/compose/assemble.py" --remove-owned --claude-dir "$CLAUDE_DIR" \
789
+ --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR" ${DRY_RUN:+} \
790
+ $([ "$DRY_RUN" = 1 ] && echo --dry-run) \
791
+ || log "warning: could not remove assembler-owned regions"
792
+ fi
614
793
  if [ -f "$MANIFEST" ]; then
794
+ # Back up before deleting, the way install backs up before overwriting. Full mode deploys the
795
+ # entry CLAUDE.md/AGENTS.md as ordinary targets, so they are manifested and removed here —
796
+ # correct, since in that mode the entry file IS the corpus. What was wrong is that anything a
797
+ # user added to it disappeared with no copy, while the same file overwritten during install
798
+ # would have been backed up. Removal is symmetric with deployment; recoverability now is too.
799
+ [ "$DRY_RUN" = 1 ] || { mkdir -p "$STATE_DIR"; BACKUP_DIR="$STATE_DIR/backups/uninstall-$(date +%Y%m%d-%H%M%S)"; }
615
800
  local f
616
801
  while IFS= read -r f; do
617
802
  [ -n "$f" ] || continue
803
+ if [ -f "$f" ] && [ -n "$BACKUP_DIR" ] && [ "$DRY_RUN" != 1 ]; then
804
+ mkdir -p "$(dirname "$BACKUP_DIR$f")" && cp "$f" "$BACKUP_DIR$f"
805
+ fi
618
806
  [ -f "$f" ] && { run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed $f"; }
619
807
  done < "$MANIFEST"
808
+ { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && info "staged for the archive: $BACKUP_DIR"; } || true
620
809
  [ "$DRY_RUN" = 1 ] || rm -f "$MANIFEST"
621
810
  else
622
811
  log "no manifest at $MANIFEST; removing known deploy targets"
812
+ # The two entry files are deliberately NOT in this list. `$CLAUDE_DIR/CLAUDE.md` is seeded
813
+ # once and the user's thereafter, and `$CODEX_DIR/AGENTS.md` is ours only between the central
814
+ # markers, which --remove-owned above already took. This branch runs precisely when state is
815
+ # missing or agent-bios was never installed here, so nothing says we wrote either file —
816
+ # and deleting them whole destroyed personal instructions with no copy at all, since
817
+ # BACKUP_DIR is set only on the manifest path above.
623
818
  local p
624
- for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" \
625
- "$CODEX_DIR/bin/codex-run" "$CODEX_DIR/bin/codex-helm" \
819
+ for p in "$CODEX_DIR/bin/codex-run" "$CODEX_DIR/bin/codex-helm" \
820
+ "$CLAUDE_DIR/bin/claude-run" \
626
821
  "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh" "$BIN_DIR/agent-launch"; do
627
822
  [ -f "$p" ] && { run rm -f "$p"; [ "$DRY_RUN" = 1 ] || info "removed $p"; }
628
823
  done
629
824
  fi
630
825
  local d
631
- for d in "$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
826
+ # `central/` and its subdirectories are entirely ours the assembler creates them and the
827
+ # manifest covers every file inside — so they belong in this sweep. They were missing from it,
828
+ # which left three empty directories in the user's config dir after a "leave no trace" removal.
829
+ # rmdir, never rm -rf: a directory that is not empty is one we did not fully account for, and
830
+ # the right answer then is to leave it and be visibly incomplete.
831
+ for d in "$CLAUDE_DIR/central/guides" "$CLAUDE_DIR/central/hooks" "$CLAUDE_DIR/central/agents" \
832
+ "$CLAUDE_DIR/central" \
833
+ "$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CLAUDE_DIR/bin" \
834
+ "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
632
835
  [ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
633
836
  done
634
837
  remove_zsh_hook
838
+ archive_and_purge
635
839
  log ""
636
- log "Uninstalled deployed files and the zsh hook."
637
- log "Kept: managed venv + backups under $STATE_DIR (rm -rf \"$STATE_DIR\" to purge)."
840
+ log "Uninstalled: deployed files, the zsh hook, state, backups, cache, and the managed venv."
841
+ }
842
+
843
+ # Uninstall is a SECURITY operation — nothing of ours may survive it on the machine. That
844
+ # conflicts with never destroying what a user added, because full mode writes the corpus into an
845
+ # entry file they then edit, so removal takes their work with it. One artifact settles both:
846
+ # everything removed leaves as a single archive that can be handed off or deleted in one act,
847
+ # and every managed location is then purged. The archive lands in $HOME, outside every path we
848
+ # manage, because a copy inside a directory we are about to delete is not a copy.
849
+ #
850
+ # Order is the safety property: archive first, verify the archive exists, purge only then. A
851
+ # failed archive leaves the machine untouched rather than clean and empty-handed.
852
+ archive_and_purge() {
853
+ local ts stage rel out list staged skipped
854
+ ts="$(date +%Y%m%d-%H%M%S)"
855
+ out="${AGENT_BIOS_UNINSTALL_ARCHIVE:-$HOME}/agent-bios-uninstall-$ts.tar.gz"
856
+ if [ "$DRY_RUN" = 1 ]; then
857
+ info "[dry-run] archive removed content to $out, then purge $STATE_DIR, the cache and the venv"
858
+ return 0
859
+ fi
860
+ stage="$(mktemp -d)" || { log "warning: no temp dir; leaving state in place"; return 0; }
861
+ list="$(mktemp)" || { rm -rf "$stage"; log "warning: no temp file; leaving state in place"; return 0; }
862
+ [ -d "$STATE_DIR" ] && cp -R "$STATE_DIR" "$stage/state" 2>/dev/null
863
+ # WHICH loose copies are ours is compose/prune-backups.py's question — it deletes them too, on
864
+ # the retention path, and a rule written in both places drifts on one side. `*.bak-*` was the
865
+ # match here, which also claims a `notes.bak-old` the user saved by hand; these sit in
866
+ # directories we share with them and with other tools, so the name must carry the ownership.
867
+ if [ -f "$REPO/compose/prune-backups.py" ]; then
868
+ python3 "$REPO/compose/prune-backups.py" --list-owned \
869
+ --claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" >"$list" 2>/dev/null || : >"$list"
870
+ else
871
+ log "note: compose/prune-backups.py is absent, so loose backup copies are left where they are"
872
+ fi
873
+ # Staged UNDER their absolute path, the way deploy_file backs up to "$BACKUP_DIR$dst".
874
+ # Flattening them into one directory let two same-named copies from the two host trees
875
+ # collide, and the loser was then deleted with nothing in the archive to restore it from.
876
+ # Only what verifiably reached the stage may be deleted later. Every failure mode here is
877
+ # silent — a full temp filesystem, an unreadable source, a directory that cannot be created —
878
+ # and purging from the ORIGINAL list removed files the archive does not contain. Size equality
879
+ # rather than cp's exit status alone, because the question is whether the archive can restore
880
+ # it, not whether the copy was attempted.
881
+ staged="$(mktemp)" || { rm -f "$list"; rm -rf "$stage"
882
+ log "warning: no temp file; leaving state in place"; return 0; }
883
+ skipped=0
884
+ while IFS= read -r -d '' rel; do
885
+ if mkdir -p "$(dirname "$stage/loose$rel")" 2>/dev/null \
886
+ && cp "$rel" "$stage/loose$rel" 2>/dev/null \
887
+ && [ "$(wc -c <"$rel" 2>/dev/null)" = "$(wc -c <"$stage/loose$rel" 2>/dev/null)" ]; then
888
+ printf '%s\0' "$rel" >> "$staged"
889
+ else
890
+ skipped=$((skipped + 1))
891
+ rm -f "$stage/loose$rel" 2>/dev/null # a partial copy must not look archived
892
+ log "warning: could not stage $rel — leaving it in place"
893
+ fi
894
+ done <"$list"
895
+ if tar czf "$out" -C "$stage" . 2>/dev/null && [ -s "$out" ]; then
896
+ # Only here, with the archive written and non-empty, is removing the originals recoverable.
897
+ # Deleting them before the tar meant a failed archive took the backups with it — while the
898
+ # warning below told the operator they had been left in place.
899
+ while IFS= read -r -d '' rel; do rm -f "$rel" 2>/dev/null; done <"$staged"
900
+ rm -f "$list" "$staged"
901
+ rm -rf "$stage"
902
+ rm -rf "$STATE_DIR" "$HOME/.cache/agent-launch" \
903
+ "${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch}"
904
+ log ""
905
+ log "Everything removed is in ONE archive: $out"
906
+ if [ "$skipped" -gt 0 ]; then
907
+ log "Move it somewhere central or delete it. $skipped backup copy(ies) could not be staged,"
908
+ log "so they were LEFT ON DISK rather than deleted with nothing to restore them from."
909
+ else
910
+ log "Move it somewhere central or delete it — nothing of ours is left on this machine."
911
+ fi
912
+ else
913
+ rm -f "$list" "$staged"
914
+ rm -rf "$stage"
915
+ log "warning: could not write $out — state, backups and the venv were left in place rather"
916
+ log " than deleted with no copy. Re-run once the archive path is writable."
917
+ fi
638
918
  }
639
919
 
640
920
  cmd_onboard() {
641
921
  log "agent-bios onboarding — pick your domain packages (core + infra always install)"
642
922
  local names=() line i=1 choice sel="" n picks
643
923
  while IFS= read -r line; do names+=("$line"); done \
644
- < <(python3 -c "import json;print('\n'.join(sorted(json.load(open('$REPO/config/domains.json'))['domains'])))")
645
- [ "${#names[@]}" -ge 1 ] || { log "no domains found in config/domains.json"; exit 1; }
924
+ < <(python3 -c "import json;print('\n'.join(sorted(json.load(open('$REPO/compose/domains.json'))['domains'])))")
925
+ [ "${#names[@]}" -ge 1 ] || { log "no domains found in compose/domains.json"; exit 1; }
646
926
  if [ "$DOMAINS_SET" = 1 ]; then
647
927
  # Non-interactive path, per this installer's input contract (stdin is
648
928
  # detached at the top of the script): selection arrives as domain names.
@@ -674,10 +954,13 @@ cmd_onboard() {
674
954
  log ""
675
955
  log "Activation canary (proves the bundle loads in a live session)..."
676
956
  if [ "$DRY_RUN" = 1 ]; then info "[dry-run] skip canary probe"; return; fi
677
- bash "$REPO/scripts/canary.sh" || {
957
+ bash "$REPO/compose/canary.sh" || {
678
958
  log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
679
959
  exit 1
680
960
  }
961
+ # The prune is authorized by the canary's proof, and cmd_install ran before the canary existed
962
+ # for this bundle rev — so it kept everything. Now that loading is proven, run it for real.
963
+ migrate_learnings
681
964
  }
682
965
 
683
966
  # ---- deploy-chain drift ---------------------------------------------------
@@ -750,7 +1033,7 @@ usage() {
750
1033
  agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
751
1034
 
752
1035
  agent-bios install deploy into this environment (backs up + verifies)
753
- agent-bios onboard interactive domain selection + packaged install + activation canary
1036
+ agent-bios onboard interactive domain selection + install + activation canary
754
1037
  agent-bios verify check the deployed state matches the source
755
1038
  agent-bios learn submit a session learning (reads the JSON record on
756
1039
  stdin; this is what the learn! flow calls, and it
@@ -761,20 +1044,34 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
761
1044
  agent-bios help
762
1045
 
763
1046
  Flags: --dry-run print actions without changing anything
764
- --domains a,b packaged mode (install/onboard): assemble ONLY the named
765
- domain packages (plus core+infra) instead of the full
766
- corpus; with onboard, 'none' means core+infra only. The
767
- selection persists in the state dir, so later
768
- installs/updates stay packaged until the selection file
769
- is removed. Default (no flag, no saved selection) keeps
770
- today's full-corpus deploy.
1047
+ --domains a,b assemble ONLY the named domain packages (plus core+infra);
1048
+ with onboard, 'none' means core+infra only. The selection
1049
+ persists in the state dir and later installs/updates reuse
1050
+ it. Default (no flag, no saved selection) selects every
1051
+ domain one install shape, "full" is just everything
1052
+ selected.
771
1053
  --with a,b also install the named optional dependencies (install only).
772
1054
  Without it, install offers each missing one when the terminal
773
1055
  is interactive, and otherwise just prints its install line.
774
- Known: onto, ultracode. Missing ones are not fatal — they only
775
- degrade the review routes that need them.
1056
+ Missing ones are not fatal — they only degrade the review
1057
+ routes that need them.
776
1058
  Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
777
1059
  EOF
1060
+ # DERIVED, not typed: the hardcoded pair went stale the moment a capability was renamed,
1061
+ # and the name it still advertised selected a different capability than the one that
1062
+ # carries the install line.
1063
+ local known; known=$(capability_table 2>/dev/null | cut -f1 | tr '\n' ' ')
1064
+ [ -n "$known" ] && printf ' --with names: %s\n' "${known% }"
1065
+
1066
+ # Recovery exists in both modes at different granularity, and naming the wrong one is worse
1067
+ # than naming none: a clone can roll the corpus back to a registered mining window, while an
1068
+ # npm install has no git history to read and rolls the whole package back by version instead.
1069
+ # Derived from which install this is, for the same reason --with names is.
1070
+ if [ -d "$REPO/.git" ]; then
1071
+ printf '\nRecover: python3 %s/compose/corpus-state.py list, then rollback --version V\n' "$REPO"
1072
+ else
1073
+ printf '\nRecover: npm install -g agent-bios@<older-version> && agent-bios install\n'
1074
+ fi
778
1075
  }
779
1076
 
780
1077
  # ---- dispatch ------------------------------------------------------------
@@ -784,10 +1081,10 @@ if [ $# -gt 0 ]; then shift; fi
784
1081
  # `learn` forwards its arguments and stdin straight to the collector, so it must
785
1082
  # bypass the flag parser below (which rejects anything it does not know). This
786
1083
  # subcommand is the only PATH-reachable entry to capture: the corpus guide used
787
- # to invoke scripts/collect-learning.py relative to the cwd, which works from a
1084
+ # to invoke learn/collect-learning.py relative to the cwd, which works from a
788
1085
  # clone and silently fails for every other install.
789
1086
  if [ "$CMD" = "learn" ]; then
790
- collector="$REPO/scripts/collect-learning.py"
1087
+ collector="$REPO/learn/collect-learning.py"
791
1088
  [ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
792
1089
  exec python3 "$collector" "$@" <&3
793
1090
  fi