agent-bios 0.9.7 → 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 +209 -25
  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/compose/register-hooks.py +44 -0
  30. package/{scripts/install.sh → install.sh} +403 -94
  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 -23
  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,26 +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
- packaged_mode() { [ "${DOMAINS_SET:-0}" = 1 ] || [ -f "$STATE_DIR/selection.json" ]; }
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"
129
+ }
115
130
 
116
- assemble_packaged() {
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
+ }
175
+
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() {
117
211
  local args=(--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR") rc=0
118
- [ "${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
119
219
  [ "$DRY_RUN" = 1 ] && args+=(--dry-run)
120
- 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=$?
121
225
  if [ "$rc" = 2 ]; then
122
- 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"
123
227
  elif [ "$rc" != 0 ]; then
124
228
  return 1
125
229
  fi
126
230
  if [ "$DRY_RUN" != 1 ]; then
127
- 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
128
243
  fi
129
244
  }
130
245
 
@@ -300,13 +415,25 @@ PY
300
415
 
301
416
  # ---- optional dependencies -----------------------------------------------
302
417
  # Capabilities are optional: a missing one only degrades the review routes that
303
- # 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
304
419
  # that gates a route and the install line offered here.
305
420
  capability_table() {
306
- python3 - "$REPO/config/agent-launch.toml" <<'PY'
421
+ python3 - "$REPO/launch/agent-launch.toml" <<'PY'
307
422
  import sys, tomllib
308
- for name, cap in tomllib.load(open(sys.argv[1], "rb")).get("capabilities", {}).items():
309
- 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", ""))))
310
437
  PY
311
438
  }
312
439
 
@@ -330,6 +457,16 @@ handle_capabilities() {
330
457
  [ -n "$name" ] || continue
331
458
  if command -v "$command" >/dev/null 2>&1; then
332
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
333
470
  continue
334
471
  fi
335
472
  if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
@@ -353,7 +490,7 @@ EOF
353
490
  # Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
354
491
  # would be lost to the cp below; move them into the user-owned presets file first.
355
492
  migrate_user_presets() {
356
- local src="$REPO/config/agent-launch.toml"
493
+ local src="$REPO/launch/agent-launch.toml"
357
494
  local dst="$LAUNCH_DIR/profiles.toml"
358
495
  local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
359
496
  [ -f "$dst" ] || return 0
@@ -420,10 +557,10 @@ remove_zsh_hook() {
420
557
  # corpus AND are in this user's assembled bundle. Best-effort: a prune failure
421
558
  # (or an absent manifest/script) never fails the install. Runs per host.
422
559
  migrate_learnings() {
423
- local script="$REPO/scripts/migrate-learnings.py"
424
- { [ -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
425
562
  local -a sel dry
426
- if packaged_mode; then sel=(--selection-file "$STATE_DIR/selection.json"); else sel=(--full); fi
563
+ sel=(--selection-file "$STATE_DIR/selection.json")
427
564
  [ "$DRY_RUN" = 1 ] && dry=(--dry-run) || dry=()
428
565
  # ${dry[@]+...}: expanding an empty array as "${dry[@]}" is an unbound-variable
429
566
  # error under `set -u` on bash 3.2 (macOS default) and would abort the install.
@@ -440,29 +577,31 @@ cmd_install() {
440
577
  if [ "$DRY_RUN" != 1 ]; then
441
578
  mkdir -p "$STATE_DIR"
442
579
  BACKUP_DIR="$STATE_DIR/backups/$(date +%Y%m%d-%H%M%S)"
580
+ [ -f "$MANIFEST" ] && cp "$MANIFEST" "$PRIOR_MANIFEST"
443
581
  : > "$MANIFEST"
444
582
  fi
445
583
  log "Deploying agent-bios from $REPO"
446
- if packaged_mode; then
447
- log "packaged mode: assembling selected domains (state: $STATE_DIR/selection.json)"
448
- assemble_packaged || exit 1
449
- else
450
- deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
451
- deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
452
- deploy_glob "$REPO/claude/agents" "*.md" "$CLAUDE_DIR/agents"
453
- deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/hooks" "+x"
454
- deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
455
- deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
456
- 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"
457
592
  migrate_learnings # Phase 4: clear personal copies now absorbed by the corpus
458
593
  deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
459
594
  codex_config_additions merge || exit 1
460
- deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
461
- 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"
462
601
  migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
463
- deploy_file "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
464
- deploy_file "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
465
- 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"
466
605
  log ""
467
606
  log "Optional dependencies (missing ones only degrade the routes that need them)..."
468
607
  handle_capabilities "$WITH" || exit 1
@@ -470,11 +609,11 @@ cmd_install() {
470
609
  if [ "$DRY_RUN" = 1 ]; then
471
610
  info "[dry-run] provision managed Textual venv"
472
611
  else
473
- bash "$REPO/scripts/provision-venv.sh" && info "managed venv OK" \
612
+ bash "$REPO/launch/provision-venv.sh" && info "managed venv OK" \
474
613
  || log "warning: venv provisioning failed (numbered-prompt fallback applies)"
475
614
  fi
476
615
  add_zsh_hook
477
- 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
478
617
  info "corpus-status projected"
479
618
  else
480
619
  log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
@@ -505,6 +644,7 @@ PY
505
644
  # An untouched backup dir means nothing was replaced; that healthy state
506
645
  # must not become a nonzero exit under set -e.
507
646
  { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
647
+ prune_backups
508
648
  else
509
649
  log "VERIFY FAILED after install — see messages above"
510
650
  exit 1
@@ -515,37 +655,60 @@ verify_match() { if cmp -s "$1" "$2"; then info "match $2"; else log "MISMA
515
655
  verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return 1; fi; }
516
656
 
517
657
  cmd_verify() {
518
- local fail=0 gp
658
+ local fail=0 gp gb
519
659
  if [ ! -d "$REPO/.git" ] && [ "$(drift_state)" = "drift" ]; then
520
660
  log "deploy drift: deployed $(deployed_version), package $(source_version) — run: agent-bios install"
521
661
  fail=1
522
662
  fi
523
- if packaged_mode; then
524
- # Packaged: corpus surfaces are selection-derived, not repo-identical.
525
- # The entry file is user-owned — READ-check the import line, never rewrite.
526
- python3 "$REPO/scripts/check-domains.py" >/dev/null 2>&1 && info "domains gate OK" || { log "domains gate FAILED"; fail=1; }
527
- verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
528
- if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
529
- info "entry import line present"
530
- else
531
- log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
532
- fi
533
- if grep -qF 'agent-bios:central:start' "$CODEX_DIR/AGENTS.md" 2>/dev/null; then
534
- info "codex central region present"
535
- else
536
- log "codex AGENTS.md central region missing"; fail=1
537
- 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
695
+ else
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"
538
706
  else
539
- verify_match "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md" || fail=1
540
- verify_match "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md" || fail=1
541
- for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
542
- for gp in "$REPO"/claude/agents/*.md; do verify_present "$CLAUDE_DIR/agents/$(basename "$gp")" || fail=1; done
543
- for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/hooks/$(basename "$gp")" || fail=1; done
544
- for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
545
- fi
546
- verify_match "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
547
- verify_match "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
548
- verify_match "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
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
549
712
  python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
550
713
  import sys, pathlib, tomllib
551
714
  root = pathlib.Path(sys.argv[1])
@@ -563,6 +726,24 @@ PY
563
726
  log "codex-helm dry-run FAILED"; fail=1
564
727
  fi
565
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
566
747
  local vpy="${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch/venv}/bin/python"
567
748
  if [ -x "$vpy" ] && "$vpy" -c 'import textual' 2>/dev/null; then
568
749
  info "managed venv (textual) OK"
@@ -571,25 +752,25 @@ PY
571
752
  fi
572
753
  # A file this installer executes but never ships is invisible from a clone and
573
754
  # fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
574
- if [ -x "$REPO/scripts/check-package.sh" ]; then
575
- 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
576
757
  info "npm payload OK"
577
758
  else
578
- log "npm payload incomplete; run scripts/check-package.sh"
759
+ log "npm payload incomplete; run gates/check-package.sh"
579
760
  fail=1
580
761
  fi
581
762
  fi
582
763
  # Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
583
- if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
584
- 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
585
766
  fi
586
767
  # Prompting guides name concrete models, so they go stale on a model change
587
768
  # rather than degrading quietly; this checks them against the launch config.
588
- if [ -x "$REPO/scripts/check-prompting-targets.sh" ]; then
589
- 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
590
771
  info "prompting targets OK"
591
772
  else
592
- 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"
593
774
  fail=1
594
775
  fi
595
776
  fi
@@ -599,38 +780,149 @@ PY
599
780
  cmd_uninstall() {
600
781
  migrate_state
601
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
602
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)"; }
603
800
  local f
604
801
  while IFS= read -r f; do
605
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
606
806
  [ -f "$f" ] && { run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed $f"; }
607
807
  done < "$MANIFEST"
808
+ { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && info "staged for the archive: $BACKUP_DIR"; } || true
608
809
  [ "$DRY_RUN" = 1 ] || rm -f "$MANIFEST"
609
810
  else
610
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.
611
818
  local p
612
- for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" \
613
- "$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" \
614
821
  "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh" "$BIN_DIR/agent-launch"; do
615
822
  [ -f "$p" ] && { run rm -f "$p"; [ "$DRY_RUN" = 1 ] || info "removed $p"; }
616
823
  done
617
824
  fi
618
825
  local d
619
- 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
620
835
  [ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
621
836
  done
622
837
  remove_zsh_hook
838
+ archive_and_purge
623
839
  log ""
624
- log "Uninstalled deployed files and the zsh hook."
625
- 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
626
918
  }
627
919
 
628
920
  cmd_onboard() {
629
921
  log "agent-bios onboarding — pick your domain packages (core + infra always install)"
630
922
  local names=() line i=1 choice sel="" n picks
631
923
  while IFS= read -r line; do names+=("$line"); done \
632
- < <(python3 -c "import json;print('\n'.join(sorted(json.load(open('$REPO/config/domains.json'))['domains'])))")
633
- [ "${#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; }
634
926
  if [ "$DOMAINS_SET" = 1 ]; then
635
927
  # Non-interactive path, per this installer's input contract (stdin is
636
928
  # detached at the top of the script): selection arrives as domain names.
@@ -662,10 +954,13 @@ cmd_onboard() {
662
954
  log ""
663
955
  log "Activation canary (proves the bundle loads in a live session)..."
664
956
  if [ "$DRY_RUN" = 1 ]; then info "[dry-run] skip canary probe"; return; fi
665
- bash "$REPO/scripts/canary.sh" || {
957
+ bash "$REPO/compose/canary.sh" || {
666
958
  log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
667
959
  exit 1
668
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
669
964
  }
670
965
 
671
966
  # ---- deploy-chain drift ---------------------------------------------------
@@ -738,7 +1033,7 @@ usage() {
738
1033
  agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
739
1034
 
740
1035
  agent-bios install deploy into this environment (backs up + verifies)
741
- agent-bios onboard interactive domain selection + packaged install + activation canary
1036
+ agent-bios onboard interactive domain selection + install + activation canary
742
1037
  agent-bios verify check the deployed state matches the source
743
1038
  agent-bios learn submit a session learning (reads the JSON record on
744
1039
  stdin; this is what the learn! flow calls, and it
@@ -749,20 +1044,34 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
749
1044
  agent-bios help
750
1045
 
751
1046
  Flags: --dry-run print actions without changing anything
752
- --domains a,b packaged mode (install/onboard): assemble ONLY the named
753
- domain packages (plus core+infra) instead of the full
754
- corpus; with onboard, 'none' means core+infra only. The
755
- selection persists in the state dir, so later
756
- installs/updates stay packaged until the selection file
757
- is removed. Default (no flag, no saved selection) keeps
758
- 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.
759
1053
  --with a,b also install the named optional dependencies (install only).
760
1054
  Without it, install offers each missing one when the terminal
761
1055
  is interactive, and otherwise just prints its install line.
762
- Known: onto, ultracode. Missing ones are not fatal — they only
763
- degrade the review routes that need them.
1056
+ Missing ones are not fatal — they only degrade the review
1057
+ routes that need them.
764
1058
  Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
765
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
766
1075
  }
767
1076
 
768
1077
  # ---- dispatch ------------------------------------------------------------
@@ -772,10 +1081,10 @@ if [ $# -gt 0 ]; then shift; fi
772
1081
  # `learn` forwards its arguments and stdin straight to the collector, so it must
773
1082
  # bypass the flag parser below (which rejects anything it does not know). This
774
1083
  # subcommand is the only PATH-reachable entry to capture: the corpus guide used
775
- # 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
776
1085
  # clone and silently fails for every other install.
777
1086
  if [ "$CMD" = "learn" ]; then
778
- collector="$REPO/scripts/collect-learning.py"
1087
+ collector="$REPO/learn/collect-learning.py"
779
1088
  [ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
780
1089
  exec python3 "$collector" "$@" <&3
781
1090
  fi