agent-bios 0.9.8 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/DEPENDENCIES.md +19 -19
  2. package/README.md +43 -12
  3. package/claude/CLAUDE.md +5 -41
  4. package/claude/guides/claude-prompting.md +1 -1
  5. package/claude/guides/cli-multi-model-workflow.md +22 -4
  6. package/claude/guides/coding-staged-workflow.md +49 -15
  7. package/claude/guides/concept-economy.md +187 -0
  8. package/claude/guides/documentation-hygiene.md +112 -0
  9. package/claude/guides/gpt-prompting.md +1 -1
  10. package/claude/guides/learning-flow.md +5 -5
  11. package/claude/guides/llm-capability-boundary.md +6 -1
  12. package/claude/guides/review-request.md +9 -7
  13. package/claude/guides/session-distill-workflow.md +19 -9
  14. package/claude/guides/tooling-gotchas.md +26 -0
  15. package/claude/guides/verification-discipline.md +166 -0
  16. package/claude/hooks/tooling-gotchas-hook.py +329 -12
  17. package/codex/AGENTS.md +5 -41
  18. package/codex/guides/claude-prompting.md +1 -1
  19. package/codex/guides/cli-multi-model-workflow.md +22 -4
  20. package/codex/guides/coding-staged-workflow.md +49 -15
  21. package/codex/guides/concept-economy.md +187 -0
  22. package/codex/guides/documentation-hygiene.md +112 -0
  23. package/codex/guides/gpt-prompting.md +1 -1
  24. package/codex/guides/learning-flow.md +5 -5
  25. package/codex/guides/llm-capability-boundary.md +6 -1
  26. package/codex/guides/review-request.md +9 -7
  27. package/codex/guides/session-distill-workflow.md +19 -9
  28. package/codex/guides/tooling-gotchas.md +26 -0
  29. package/codex/guides/verification-discipline.md +166 -0
  30. package/{scripts → compose}/assemble.py +194 -17
  31. package/{scripts → compose}/canary.sh +14 -5
  32. package/compose/check-domains.py +1178 -0
  33. package/{config → compose}/domains.json +11 -44
  34. package/{scripts → compose}/pkgid.py +8 -1
  35. package/compose/prune-backups.py +204 -0
  36. package/{scripts → compose}/register-hooks.py +3 -3
  37. package/install.sh +1233 -0
  38. package/launch/agent-launch.py +5294 -0
  39. package/launch/agent-launch.toml +376 -0
  40. package/{scripts → launch}/check-prompting-targets.sh +1 -1
  41. package/{scripts → launch}/provision-venv.sh +1 -1
  42. package/{scripts → learn}/check-learning.py +7 -7
  43. package/{scripts → learn}/collect-learning.py +10 -10
  44. package/{config → learn}/learning.schema.json +3 -3
  45. package/{scripts → learn}/migrate-learnings.py +95 -54
  46. package/{scripts → learn}/redact.py +4 -4
  47. package/package.json +32 -27
  48. package/provenance.json +1 -0
  49. package/wrappers/claude-run.sh +162 -0
  50. package/{scripts → wrappers}/codex-run.sh +62 -6
  51. package/config/agent-launch.toml +0 -143
  52. package/scripts/agent-launch.py +0 -2350
  53. package/scripts/check-domains.py +0 -296
  54. package/scripts/check-parity.sh +0 -2003
  55. package/scripts/install.sh +0 -819
  56. /package/{shell → launch}/agent-launch.zsh +0 -0
  57. /package/{config → learn}/promotions.json +0 -0
  58. /package/{scripts/session-cost.py → session-cost.py} +0 -0
  59. /package/{scripts → wrappers}/codex-helm.sh +0 -0
package/install.sh ADDED
@@ -0,0 +1,1233 @@
1
+ #!/usr/bin/env bash
2
+ # agent-bios installer.
3
+ #
4
+ # Deploys the single-source-of-truth (globals, scoped guides, Codex agent
5
+ # templates, Codex wrappers, the launch profile/shell/bin, a managed Textual
6
+ # venv, and the zsh hook) into $HOME by COPY — idempotent, backed up before
7
+ # overwrite, and reversible. Distributed as an npm bin; the actual $HOME
8
+ # deployment is this explicit command (never a postinstall side effect).
9
+ #
10
+ # Usage:
11
+ # agent-bios install deploy into this environment (backs up + verifies)
12
+ # agent-bios verify check the deployed state matches the source
13
+ # agent-bios status show what is installed and where
14
+ # agent-bios update git pull + reinstall (clone), or print the npm update line
15
+ # agent-bios uninstall remove deployed files and the zsh hook
16
+ # agent-bios help
17
+ #
18
+ # Flags: --dry-run (print actions, change nothing).
19
+ # Env overrides: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR.
20
+ set -euo pipefail
21
+
22
+ # This installer is non-interactive: every input arrives as a subcommand, flag,
23
+ # or env var. Detach stdin so no child (the codex-helm dry-run, pip, git) can
24
+ # block forever on an inherited idle stdin — that is what hangs an install under
25
+ # CI, pipes, and background runs, where stdin stays open but never delivers.
26
+ # `learn` is the one subcommand whose payload IS stdin, so keep the caller's on
27
+ # fd 3 first and hand it back only there; every other path still sees /dev/null.
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
36
+ exec </dev/null
37
+
38
+ # Resolve this script through symlinks before locating the package: npm links the
39
+ # bin into node_modules/.bin and the global bin dir, so $0 is a link and its
40
+ # dirname is the link's directory, not the package. Without this the source tree
41
+ # resolves to the bin dir's parent (e.g. /opt/homebrew) and every deploy fails.
42
+ SOURCE="${BASH_SOURCE[0]}"
43
+ while [ -L "$SOURCE" ]; do
44
+ LINKDIR="$(cd -P "$(dirname "$SOURCE")" && pwd)"
45
+ SOURCE="$(readlink "$SOURCE")"
46
+ case "$SOURCE" in
47
+ /*) ;;
48
+ *) SOURCE="$LINKDIR/$SOURCE" ;;
49
+ esac
50
+ done
51
+ SELF="$(cd -P "$(dirname "$SOURCE")" && pwd)"
52
+ REPO="$SELF"
53
+
54
+ CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
55
+ CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
56
+ LAUNCH_DIR="$HOME/.config/agent-launch"
57
+ USER_PRESETS_NAME="presets.local.toml" # user-owned; never deployed or verified
58
+ BIN_DIR="$HOME/.local/bin"
59
+ STATE_DIR="$HOME/.local/share/agent-bios"
60
+ LEGACY_STATE_DIR="$HOME/.local/share/agent-dotfiles" # pre-rename state; migrated on first run
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"
67
+ ZSHRC="${ZDOTDIR:-$HOME}/.zshrc"
68
+ ZSH_HOOK='[ -r "$HOME/.config/agent-launch/shell.zsh" ] && source "$HOME/.config/agent-launch/shell.zsh"'
69
+ HOOK_MARK='agent-launch/shell.zsh'
70
+
71
+ DRY_RUN=0
72
+ BACKUP_DIR=""
73
+
74
+ log() { printf '%s\n' "$*"; }
75
+ info() { printf ' %s\n' "$*"; }
76
+ run() { if [ "$DRY_RUN" = 1 ]; then printf ' [dry-run] %s\n' "$*"; else "$@"; fi; }
77
+
78
+ # ---- prerequisites -------------------------------------------------------
79
+ check_prereqs() {
80
+ local ok=0
81
+ command -v git >/dev/null 2>&1 || { log "missing prerequisite: git"; ok=1; }
82
+ command -v python3 >/dev/null 2>&1 || { log "missing prerequisite: python3"; ok=1; }
83
+ if command -v python3 >/dev/null 2>&1; then
84
+ python3 -c 'import sys; sys.exit(0 if sys.version_info>=(3,11) else 1)' 2>/dev/null \
85
+ || { log "python3 >= 3.11 required (tomllib)"; ok=1; }
86
+ fi
87
+ command -v zsh >/dev/null 2>&1 || log "note: zsh not found; the shell hook targets zsh"
88
+ command -v codex >/dev/null 2>&1 || log "note: codex CLI not found; Codex-side steps will be skipped"
89
+ command -v claude >/dev/null 2>&1 || log "note: claude CLI not found"
90
+ return $ok
91
+ }
92
+
93
+ # ---- copy with backup + manifest ----------------------------------------
94
+ deploy_file() {
95
+ local src="$1" dst="$2" mode="${3:-}"
96
+ [ -f "$src" ] || { log "source missing: $src"; return 1; }
97
+ if [ -f "$dst" ] && cmp -s "$src" "$dst"; then
98
+ info "unchanged $dst"
99
+ else
100
+ if [ -f "$dst" ] && [ -n "$BACKUP_DIR" ]; then
101
+ run mkdir -p "$(dirname "$BACKUP_DIR$dst")"
102
+ run cp "$dst" "$BACKUP_DIR$dst"
103
+ fi
104
+ run mkdir -p "$(dirname "$dst")"
105
+ run cp "$src" "$dst"
106
+ if [ -n "$mode" ]; then run chmod "$mode" "$dst"; fi
107
+ [ "$DRY_RUN" = 1 ] || info "installed $dst"
108
+ fi
109
+ [ "$DRY_RUN" = 1 ] || printf '%s\n' "$dst" >> "$MANIFEST"
110
+ }
111
+
112
+ deploy_glob() {
113
+ local srcdir="$1" pat="$2" dstdir="$3" mode="${4:-}" f
114
+ for f in "$srcdir"/$pat; do
115
+ [ -f "$f" ] || continue
116
+ deploy_file "$f" "$dstdir/$(basename "$f")" "$mode"
117
+ done
118
+ }
119
+
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
+ }
130
+
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() {
211
+ local args=(--claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR") rc=0
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
219
+ [ "$DRY_RUN" = 1 ] && args+=(--dry-run)
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=$?
225
+ if [ "$rc" = 2 ]; then
226
+ log "entry file needs user action (import line missing); central content will not load until it is added"
227
+ elif [ "$rc" != 0 ]; then
228
+ return 1
229
+ fi
230
+ if [ "$DRY_RUN" != 1 ]; then
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
243
+ fi
244
+ }
245
+
246
+ add_zsh_hook() {
247
+ if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then
248
+ info "zsh hook present $ZSHRC"
249
+ return
250
+ fi
251
+ if [ "$DRY_RUN" = 1 ]; then info "[dry-run] append zsh hook to $ZSHRC"; return; fi
252
+ printf '%s\n' "$ZSH_HOOK" >> "$ZSHRC"
253
+ info "added zsh hook $ZSHRC"
254
+ }
255
+
256
+ # Carry state written under the pre-rename directory so an existing install keeps
257
+ # its manifest and backups instead of stranding them.
258
+ migrate_state() {
259
+ if [ -d "$STATE_DIR" ] || [ ! -d "$LEGACY_STATE_DIR" ]; then
260
+ return
261
+ fi
262
+ if [ "$DRY_RUN" = 1 ]; then
263
+ info "[dry-run] migrate state $LEGACY_STATE_DIR -> $STATE_DIR"
264
+ return
265
+ fi
266
+ mkdir -p "$(dirname "$STATE_DIR")"
267
+ mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
268
+ }
269
+
270
+ # ---- codex live-config additions -----------------------------------------
271
+ # The live ~/.codex/config.toml is user/runtime-owned; agent-bios never
272
+ # deploys or overwrites it. codex/config-additions.toml declares the only
273
+ # content agent-bios manages there — one marked [agents.*] block plus a tagged
274
+ # features.multi_agent line — and this helper merges (install), checks
275
+ # (verify), or removes (uninstall) exactly that content, backed up and
276
+ # tomllib-validated before any write. Modes: merge | check | remove.
277
+ codex_config_additions() {
278
+ AB_MODE="$1" AB_CODEX_DIR="$CODEX_DIR" AB_FRAGMENT="$REPO/codex/config-additions.toml" \
279
+ AB_BACKUP="${BACKUP_DIR:-}" AB_DRY="$DRY_RUN" python3 - <<'PY'
280
+ import os, pathlib, sys, tomllib
281
+
282
+ mode = os.environ["AB_MODE"]
283
+ codex_dir = pathlib.Path(os.environ["AB_CODEX_DIR"])
284
+ fragment_path = pathlib.Path(os.environ["AB_FRAGMENT"])
285
+ backup_root = os.environ.get("AB_BACKUP", "")
286
+ dry = os.environ.get("AB_DRY") == "1"
287
+ target = codex_dir / "config.toml"
288
+ BEGIN = "# >>> agent-bios additions >>>"
289
+ END = "# <<< agent-bios additions <<<"
290
+ TAG = "# agent-bios"
291
+
292
+ def info(msg): print(f" {msg}")
293
+ def fail(msg): print(msg); sys.exit(1)
294
+
295
+ frag_text = fragment_path.read_text().replace("${CODEX_HOME}", str(codex_dir))
296
+ want_agents = tomllib.loads(frag_text)["agents"]
297
+ live_text = target.read_text() if target.is_file() else ""
298
+ try:
299
+ live = tomllib.loads(live_text) if live_text else {}
300
+ except Exception as exc:
301
+ fail(f"live codex config does not parse; not touching it: {target} ({exc})")
302
+
303
+ def state_ok():
304
+ if live.get("features", {}).get("multi_agent") is not True:
305
+ return False
306
+ return all(
307
+ live.get("agents", {}).get(name, {}).get(key) == spec[key]
308
+ for name, spec in want_agents.items()
309
+ for key in ("description", "config_file")
310
+ )
311
+
312
+ if mode == "check":
313
+ problems = []
314
+ if live.get("features", {}).get("multi_agent") is not True:
315
+ problems.append("features.multi_agent is not true")
316
+ for name, spec in want_agents.items():
317
+ if live.get("agents", {}).get(name, {}).get("config_file") != spec["config_file"]:
318
+ problems.append(f"agents.{name}.config_file drifted or missing")
319
+ elif not pathlib.Path(spec["config_file"]).is_file():
320
+ problems.append(f"agents.{name} template missing: {spec['config_file']}")
321
+ if problems:
322
+ fail(f"codex config additions: {'; '.join(problems)} ({target})")
323
+ info("codex config additions OK")
324
+ sys.exit(0)
325
+
326
+ def strip_managed(text):
327
+ out, skipping = [], False
328
+ for line in text.splitlines(keepends=True):
329
+ s = line.strip()
330
+ if s == BEGIN: skipping = True; continue
331
+ if s == END: skipping = False; continue
332
+ if skipping or s.endswith(TAG): continue
333
+ out.append(line)
334
+ return "".join(out)
335
+
336
+ if mode == "remove":
337
+ if not target.is_file():
338
+ sys.exit(0)
339
+ stripped = strip_managed(live_text)
340
+ if stripped == live_text:
341
+ info(f"no agent-bios additions in {target}")
342
+ sys.exit(0)
343
+ try:
344
+ tomllib.loads(stripped)
345
+ except Exception as exc:
346
+ fail(f"refusing removal; result would not parse: {exc}")
347
+ if dry:
348
+ info(f"[dry-run] remove agent-bios additions from {target}")
349
+ sys.exit(0)
350
+ backup = target.with_name(target.name + ".bak-agent-bios-uninstall")
351
+ backup.write_text(live_text)
352
+ target.write_text(stripped)
353
+ info(f"removed additions {target} (backup: {backup.name})")
354
+ sys.exit(0)
355
+
356
+ # mode == merge
357
+ if state_ok():
358
+ info(f"unchanged {target} (additions present)")
359
+ sys.exit(0)
360
+
361
+ # A drifted [agents.<tier>] outside our markers would become a duplicate
362
+ # table if we appended ours; that conflict needs the user, not a clobber.
363
+ base = strip_managed(live_text)
364
+ base_data = tomllib.loads(base) if base.strip() else {}
365
+ clash = [name for name in want_agents if name in base_data.get("agents", {})]
366
+ if clash:
367
+ fail(
368
+ f"unmanaged [agents.{'/'.join(clash)}] with drifted content in {target}; "
369
+ "align or remove them, then rerun install"
370
+ )
371
+
372
+ block_lines = [BEGIN]
373
+ if "features" not in base_data:
374
+ block_lines += ["[features]", f"multi_agent = true {TAG}"]
375
+ for name, spec in want_agents.items():
376
+ block_lines += [
377
+ f"[agents.{name}]",
378
+ f'description = "{spec["description"]}"',
379
+ f'config_file = "{spec["config_file"]}"',
380
+ ]
381
+ block_lines.append(END)
382
+ block = "\n".join(block_lines) + "\n"
383
+
384
+ new_text = base
385
+ if "features" in base_data:
386
+ if base_data["features"].get("multi_agent") is None:
387
+ lines = new_text.splitlines(keepends=True)
388
+ for i, line in enumerate(lines):
389
+ if line.strip() == "[features]":
390
+ lines.insert(i + 1, f"multi_agent = true {TAG}\n")
391
+ break
392
+ new_text = "".join(lines)
393
+ elif base_data["features"].get("multi_agent") is not True:
394
+ info(f"note: features.multi_agent explicitly set in {target}; leaving it")
395
+ if new_text and not new_text.endswith("\n"):
396
+ new_text += "\n"
397
+ new_text += ("\n" if new_text else "") + block
398
+
399
+ try:
400
+ tomllib.loads(new_text)
401
+ except Exception as exc:
402
+ fail(f"merge result would not parse; live config untouched ({exc})")
403
+ if dry:
404
+ info(f"[dry-run] merge agent-bios additions into {target}")
405
+ sys.exit(0)
406
+ if live_text and backup_root:
407
+ bpath = pathlib.Path(backup_root + str(target))
408
+ bpath.parent.mkdir(parents=True, exist_ok=True)
409
+ bpath.write_text(live_text)
410
+ target.parent.mkdir(parents=True, exist_ok=True)
411
+ target.write_text(new_text)
412
+ info(f"merged additions {target}")
413
+ PY
414
+ }
415
+
416
+ # ---- optional dependencies -----------------------------------------------
417
+ # Capabilities are optional: a missing one only degrades the review routes that
418
+ # need it. launch/agent-launch.toml is the single source for both the command
419
+ # that gates a route and the install line offered here.
420
+ capability_table() {
421
+ python3 - "$REPO/launch/agent-launch.toml" <<'PY'
422
+ import sys, tomllib
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", ""))))
437
+ PY
438
+ }
439
+
440
+ install_capability() {
441
+ local name="$1" line="$2"
442
+ log "Installing optional dependency $name: $line"
443
+ if [ "$DRY_RUN" = 1 ]; then info "[dry-run] $line"; return 0; fi
444
+ if sh -c "$line"; then info "installed $name"; else log "warning: installing $name failed; routes needing it stay degraded"; fi
445
+ }
446
+
447
+ handle_capabilities() {
448
+ local requested="$1" name command line
449
+ # Fail on a typo rather than silently installing nothing.
450
+ local known; known=$(capability_table | cut -f1)
451
+ local want
452
+ for want in ${requested//,/ }; do
453
+ printf '%s\n' "$known" | grep -qx "$want" || {
454
+ log "unknown --with capability: $want (known: $(printf '%s' "$known" | tr '\n' ' '))"; return 1; }
455
+ done
456
+ while IFS=$'\t' read -r name command line; do
457
+ [ -n "$name" ] || continue
458
+ if command -v "$command" >/dev/null 2>&1; then
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
470
+ continue
471
+ fi
472
+ if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
473
+ [ -n "$line" ] && install_capability "$name" "$line" \
474
+ || log "note: $name has no configured install line"
475
+ elif [ -n "$line" ] && [ -t 0 ] && [ "$DRY_RUN" != 1 ]; then
476
+ printf ' Install optional dependency %s? (%s) [y/N] ' "$name" "$line"
477
+ local answer=""; read -r answer </dev/tty || answer=""
478
+ case "$answer" in
479
+ [yY]*) install_capability "$name" "$line" ;;
480
+ *) info "skipped $name — install later: $line" ;;
481
+ esac
482
+ else
483
+ info "optional $name unavailable; routes needing it degrade${line:+ — install: $line}"
484
+ fi
485
+ done <<EOF
486
+ $(capability_table)
487
+ EOF
488
+ }
489
+
490
+ # Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
491
+ # would be lost to the cp below; move them into the user-owned presets file first.
492
+ migrate_user_presets() {
493
+ local src="$REPO/launch/agent-launch.toml"
494
+ local dst="$LAUNCH_DIR/profiles.toml"
495
+ local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
496
+ [ -f "$dst" ] || return 0
497
+ python3 - "$src" "$dst" "$user" "$DRY_RUN" <<'PY' || { log "user preset migration failed; not overwriting $LAUNCH_DIR/profiles.toml"; return 1; }
498
+ import os, pathlib, re, sys, tomllib
499
+
500
+ src, dst, user, dry_run = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), sys.argv[4] == "1"
501
+ dst_text = dst.read_text()
502
+ shipped = set(tomllib.loads(src.read_text()).get("presets", {}))
503
+ extra = set(tomllib.loads(dst_text).get("presets", {})) - shipped
504
+ if not extra:
505
+ sys.exit(0)
506
+ existing_text = user.read_text() if user.is_file() else ""
507
+ already = set(tomllib.loads(existing_text).get("presets", {})) if existing_text else set()
508
+ move = sorted(extra - already)
509
+ for name in sorted(extra & already):
510
+ print(f" kept in {user.name} {name} (already saved there)")
511
+ if not move:
512
+ sys.exit(0)
513
+
514
+ header = re.compile(r'^\[presets\.(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9_-]*))(?:[.\]])')
515
+ blocks, current = {}, None
516
+ for line in dst_text.splitlines(keepends=True):
517
+ stripped = line.strip()
518
+ if stripped.startswith("["):
519
+ found = header.match(stripped)
520
+ current = (found.group(1) or found.group(2)) if found else None
521
+ if current in move:
522
+ blocks.setdefault(current, []).append(line)
523
+ # Refuse to deploy over a preset we could not carry across, rather than drop it.
524
+ missing = [name for name in move if name not in blocks]
525
+ if missing:
526
+ sys.exit(f"cannot extract preset block(s) from {dst}: {', '.join(missing)}")
527
+
528
+ out = existing_text.rstrip("\n") or (
529
+ "# agent-launch user presets, moved out of profiles.toml by agent-bios install.\n"
530
+ "# The installer never deploys or verifies this file, so presets here survive upgrades."
531
+ )
532
+ for name in move:
533
+ out += "\n\n" + "".join(blocks[name]).strip("\n")
534
+ for name in move:
535
+ print(f" {'[dry-run] ' if dry_run else ''}moved preset {name} -> {user}")
536
+ if dry_run:
537
+ sys.exit(0)
538
+ user.parent.mkdir(parents=True, exist_ok=True)
539
+ temporary = user.with_name(f".{user.name}.{os.getpid()}.tmp")
540
+ temporary.write_text(out + "\n")
541
+ os.replace(temporary, user)
542
+ PY
543
+ }
544
+
545
+ remove_zsh_hook() {
546
+ if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
547
+ info "no zsh hook to remove"
548
+ return
549
+ fi
550
+ if [ "$DRY_RUN" = 1 ]; then info "[dry-run] remove zsh hook from $ZSHRC"; return; fi
551
+ grep -vF "$HOOK_MARK" "$ZSHRC" > "$ZSHRC.agent-tmp" && mv "$ZSHRC.agent-tmp" "$ZSHRC"
552
+ info "removed zsh hook $ZSHRC"
553
+ }
554
+
555
+ # Promote -> migrate (collection loop, Phase 4): after the corpus is deployed,
556
+ # clear personal copies of learnings that have been promoted into the shared
557
+ # corpus AND are in this user's assembled bundle. Best-effort: a prune failure
558
+ # (or an absent manifest/script) never fails the install. Runs per host.
559
+ migrate_learnings() {
560
+ local script="$REPO/learn/migrate-learnings.py"
561
+ { [ -f "$script" ] && [ -f "$REPO/learn/promotions.json" ]; } || return 0
562
+ local -a sel dry
563
+ sel=(--selection-file "$STATE_DIR/selection.json")
564
+ [ "$DRY_RUN" = 1 ] && dry=(--dry-run) || dry=()
565
+ # ${dry[@]+...}: expanding an empty array as "${dry[@]}" is an unbound-variable
566
+ # error under `set -u` on bash 3.2 (macOS default) and would abort the install.
567
+ python3 "$script" --host claude --config-dir "$CLAUDE_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
568
+ || info "learnings migrate (claude) skipped"
569
+ python3 "$script" --host codex --config-dir "$CODEX_DIR" "${sel[@]}" ${dry[@]+"${dry[@]}"} \
570
+ || info "learnings migrate (codex) skipped"
571
+ }
572
+
573
+ # ---- subcommands ---------------------------------------------------------
574
+ cmd_install() {
575
+ check_prereqs || { log "resolve the prerequisites above and retry"; exit 1; }
576
+ migrate_state
577
+ if [ "$DRY_RUN" != 1 ]; then
578
+ mkdir -p "$STATE_DIR"
579
+ BACKUP_DIR="$STATE_DIR/backups/$(date +%Y%m%d-%H%M%S)"
580
+ [ -f "$MANIFEST" ] && cp "$MANIFEST" "$PRIOR_MANIFEST"
581
+ : > "$MANIFEST"
582
+ fi
583
+ log "Deploying agent-bios from $REPO"
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"
592
+ migrate_learnings # Phase 4: clear personal copies now absorbed by the corpus
593
+ deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
594
+ codex_config_additions merge || exit 1
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"
601
+ migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
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"
605
+ log ""
606
+ log "Optional dependencies (missing ones only degrade the routes that need them)..."
607
+ handle_capabilities "$WITH" || exit 1
608
+ log ""
609
+ if [ "$DRY_RUN" = 1 ]; then
610
+ info "[dry-run] provision managed Textual venv"
611
+ else
612
+ bash "$REPO/launch/provision-venv.sh" && info "managed venv OK" \
613
+ || log "warning: venv provisioning failed (numbered-prompt fallback applies)"
614
+ fi
615
+ add_zsh_hook
616
+ if python3 "$REPO/compose/corpus-state.py" project --repo "$REPO" >/dev/null 2>&1; then
617
+ info "corpus-status projected"
618
+ else
619
+ log "note: corpus-status projection unavailable (versions.json/ledger missing?)"
620
+ fi
621
+ # Deploy/system version marker for the launcher's TUI version line, read from
622
+ # package.json (version + releaseDate) — distinct from the corpus content
623
+ # version. Best-effort: a failure here never fails the install.
624
+ if [ "$DRY_RUN" != 1 ]; then
625
+ if python3 - "$REPO/package.json" "$STATE_DIR/version.json" "$REPO/provenance.json" \
626
+ "$([ -e "$REPO/.git" ] && echo clone || echo package)" <<'PY' 2>/dev/null
627
+ import json, os, sys
628
+ pkg = json.load(open(sys.argv[1]))
629
+ out = {"version": pkg.get("version"), "releaseDate": pkg.get("releaseDate"),
630
+ "source": sys.argv[4]}
631
+ # Publication provenance rides along ONLY for a package-layout source: in a
632
+ # clone, git itself is the live provenance and a provenance.json on disk is by
633
+ # definition residue (a failed pack's leftover stamp) — ingesting it once let a
634
+ # clone deployment of commit B carry a stale label A into the state marker.
635
+ if sys.argv[4] == "package" and os.path.isfile(sys.argv[3]):
636
+ try:
637
+ prov = json.load(open(sys.argv[3]))
638
+ if prov.get("commit"):
639
+ out["commit"] = prov["commit"]
640
+ if prov.get("dirty"):
641
+ out["dirty"] = True
642
+ except Exception:
643
+ pass
644
+ with open(sys.argv[2], "w") as f:
645
+ json.dump(out, f)
646
+ f.write("\n")
647
+ PY
648
+ then
649
+ printf '%s\n' "$STATE_DIR/version.json" >> "$MANIFEST"
650
+ info "version marker written ($STATE_DIR/version.json)"
651
+ else
652
+ log "note: version marker not written (package.json unreadable)"
653
+ fi
654
+ fi
655
+ log ""
656
+ log "Verifying deployment..."
657
+ if cmd_verify; then
658
+ log ""
659
+ log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
660
+ # An untouched backup dir means nothing was replaced; that healthy state
661
+ # must not become a nonzero exit under set -e.
662
+ { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
663
+ prune_backups
664
+ else
665
+ log "VERIFY FAILED after install — see messages above"
666
+ exit 1
667
+ fi
668
+ }
669
+
670
+ verify_match() { if cmp -s "$1" "$2"; then info "match $2"; else log "MISMATCH/absent $2"; return 1; fi; }
671
+ verify_present() { if [ -f "$1" ]; then return 0; else log "missing $1"; return 1; fi; }
672
+
673
+ cmd_verify() {
674
+ local fail=0 gp gb
675
+ if [ ! -e "$REPO/.git" ] && [ "$(drift_state)" = "drift" ]; then
676
+ log "deploy drift: $(drift_detail) — run: agent-bios install"
677
+ fail=1
678
+ fi
679
+ # Not mode-specific: an author-only guide must be absent from EVERY directory any
680
+ # install writes, including the one the pre-unification full install owned.
681
+ local verify_withheld
682
+ verify_withheld="$(author_only_guides)" || return 1
683
+ for gb in $verify_withheld; do
684
+ for gp in "$CLAUDE_DIR/guides/$gb" "$CLAUDE_DIR/central/guides/$gb" "$CODEX_DIR/guides/$gb"; do
685
+ if [ -f "$gp" ]; then
686
+ log "author-only guide is still installed: $gp"; fail=1
687
+ fi
688
+ done
689
+ done
690
+ [ -n "$verify_withheld" ] && info "author-only guides withheld: $(printf '%s' "$verify_withheld" | tr '\n' ' ')"
691
+ # Corpus surfaces are selection-derived, not repo-identical, so verify reads the assembled
692
+ # shape rather than byte-comparing against the repo. The entry file is user-owned — READ-check
693
+ # the import line, never rewrite it.
694
+ python3 "$REPO/compose/check-domains.py" >/dev/null 2>&1 && info "domains gate OK" || { log "domains gate FAILED"; fail=1; }
695
+ verify_present "$CLAUDE_DIR/central/bundle.md" || fail=1
696
+ if grep -qF '@central/bundle.md' "$CLAUDE_DIR/CLAUDE.md" 2>/dev/null; then
697
+ info "entry import line present"
698
+ else
699
+ log "entry $CLAUDE_DIR/CLAUDE.md lacks '@central/bundle.md' — central corpus is NOT loading"; fail=1
700
+ fi
701
+ if grep -qF 'agent-bios:central:start' "$CODEX_DIR/AGENTS.md" 2>/dev/null; then
702
+ info "codex central region present"
703
+ else
704
+ log "codex AGENTS.md central region missing"; fail=1
705
+ fi
706
+ # Every capability must probe as a real command. `${backend}` reaching this table means
707
+ # the sentinel was not resolved, and the probe below would then report a capability the
708
+ # launcher can run perfectly well as unavailable.
709
+ if capability_table | awk -F'\t' '$2 == "" || $2 == "${backend}" { print; found=1 } END { exit !found }' >/dev/null 2>&1; then
710
+ log "capability table has an unresolved or empty command — the installer would misreport it"; fail=1
711
+ else
712
+ info "capability commands resolve"
713
+ fi
714
+ # The help's --with list must BE the capability table, not a copy of it: the copy went
715
+ # stale on the first rename and advertised a name that selects a different capability
716
+ # than the one carrying the install line.
717
+ local advertised table
718
+ advertised=$(usage 2>/dev/null | sed -n 's/^ *--with names: //p')
719
+ table=$(capability_table 2>/dev/null | cut -f1 | tr '\n' ' ')
720
+ if [ "$advertised" = "${table% }" ]; then
721
+ info "--with help matches the capability table"
722
+ else
723
+ log "--with help advertises '$advertised' but the capabilities are '${table% }'"; fail=1
724
+ fi
725
+ verify_match "$REPO/launch/agent-launch.py" "$BIN_DIR/agent-launch" || fail=1
726
+ verify_match "$REPO/launch/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
727
+ verify_match "$REPO/launch/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
728
+ python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
729
+ import sys, pathlib, tomllib
730
+ root = pathlib.Path(sys.argv[1])
731
+ required = {"frontier.toml", "workhorse.toml", "sweep.toml", "reviewer.toml"}
732
+ missing = required - {p.name for p in root.glob("*.toml")}
733
+ assert not missing, f"missing agent TOMLs in {root}: {sorted(missing)}"
734
+ for p in sorted(root.glob("*.toml")):
735
+ tomllib.loads(p.read_text())
736
+ PY
737
+ codex_config_additions check || fail=1
738
+ if command -v codex >/dev/null 2>&1 && [ -x "$CODEX_DIR/bin/codex-helm" ]; then
739
+ if "$CODEX_DIR/bin/codex-helm" --dry-run --mode review "probe" >/dev/null 2>&1; then
740
+ info "codex-helm dry-run OK"
741
+ else
742
+ log "codex-helm dry-run FAILED"; fail=1
743
+ fi
744
+ fi
745
+ # Existence first, because the guard below is written as `if executable` and a MISSING
746
+ # adapter would satisfy it by never running — a deploy target whose only check skips
747
+ # itself when the deploy failed is not checked at all.
748
+ verify_present "$CLAUDE_DIR/bin/claude-run" || fail=1
749
+ # Then WHICH version landed, because this file is on the panel's dispatch path now and
750
+ # an older copy would dispatch reviews that quietly emit nothing. The earlier check
751
+ # here asserted that an unpinned dispatch is refused; that guard was deliberately
752
+ # removed when the adapter went live — refusing turned "the review ran unpinned" into
753
+ # "the review did not run" — so asserting it would now fail against correct behaviour.
754
+ # `--help` is the only probe that reaches no network: an unpinned run warns and then
755
+ # dispatches for real.
756
+ if [ -x "$CLAUDE_DIR/bin/claude-run" ]; then
757
+ if "$CLAUDE_DIR/bin/claude-run" --help 2>/dev/null | grep -q 'REVIEW_RECEIPT_DIR'; then
758
+ info "claude-run is receipt-aware"
759
+ else
760
+ log "claude-run predates the receipt contract — reviews through it emit nothing"; fail=1
761
+ fi
762
+ fi
763
+ local vpy="${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch/venv}/bin/python"
764
+ if [ -x "$vpy" ] && "$vpy" -c 'import textual' 2>/dev/null; then
765
+ info "managed venv (textual) OK"
766
+ else
767
+ log "note: managed venv/textual unavailable (numbered-prompt fallback applies)"
768
+ fi
769
+ # A file this installer executes but never ships is invisible from a clone and
770
+ # fatal on npm, so the payload gate runs wherever it exists (maintainer-side).
771
+ if [ -x "$REPO/gates/check-package.sh" ]; then
772
+ if "$REPO/gates/check-package.sh" >/dev/null 2>&1; then
773
+ info "npm payload OK"
774
+ else
775
+ log "npm payload incomplete; run gates/check-package.sh"
776
+ fail=1
777
+ fi
778
+ fi
779
+ # Repo-internal mirror parity is a maintainer gate; only meaningful from a clone.
780
+ if [ -d "$REPO/ko" ] && [ -x "$REPO/gates/check-parity.sh" ]; then
781
+ 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
782
+ fi
783
+ # Prompting guides name concrete models, so they go stale on a model change
784
+ # rather than degrading quietly; this checks them against the launch config.
785
+ if [ -x "$REPO/launch/check-prompting-targets.sh" ]; then
786
+ if "$REPO/launch/check-prompting-targets.sh" >/dev/null 2>&1; then
787
+ info "prompting targets OK"
788
+ else
789
+ log "prompting guides do not cover a configured model; run launch/check-prompting-targets.sh"
790
+ fail=1
791
+ fi
792
+ fi
793
+ return $fail
794
+ }
795
+
796
+ cmd_uninstall() {
797
+ migrate_state
798
+ codex_config_additions remove || log "warning: could not remove codex config additions"
799
+ # The assembler's two spans in files we do not own — settings registrations and the AGENTS.md
800
+ # central region — are merged in and were never removed here, so uninstall used to leave hooks
801
+ # invoking deleted files and instructions pointing at deleted guides. It reads ownership the
802
+ # same way the merge wrote it, so it cannot reach past what we put there.
803
+ if [ -f "$REPO/compose/assemble.py" ]; then
804
+ python3 "$REPO/compose/assemble.py" --remove-owned --claude-dir "$CLAUDE_DIR" \
805
+ --codex-dir "$CODEX_DIR" --state-dir "$STATE_DIR" ${DRY_RUN:+} \
806
+ $([ "$DRY_RUN" = 1 ] && echo --dry-run) \
807
+ || log "warning: could not remove assembler-owned regions"
808
+ fi
809
+ if [ -f "$MANIFEST" ]; then
810
+ # Back up before deleting, the way install backs up before overwriting. Full mode deploys the
811
+ # entry CLAUDE.md/AGENTS.md as ordinary targets, so they are manifested and removed here —
812
+ # correct, since in that mode the entry file IS the corpus. What was wrong is that anything a
813
+ # user added to it disappeared with no copy, while the same file overwritten during install
814
+ # would have been backed up. Removal is symmetric with deployment; recoverability now is too.
815
+ [ "$DRY_RUN" = 1 ] || { mkdir -p "$STATE_DIR"; BACKUP_DIR="$STATE_DIR/backups/uninstall-$(date +%Y%m%d-%H%M%S)"; }
816
+ local f
817
+ while IFS= read -r f; do
818
+ [ -n "$f" ] || continue
819
+ if [ -f "$f" ] && [ -n "$BACKUP_DIR" ] && [ "$DRY_RUN" != 1 ]; then
820
+ mkdir -p "$(dirname "$BACKUP_DIR$f")" && cp "$f" "$BACKUP_DIR$f"
821
+ fi
822
+ [ -f "$f" ] && { run rm -f "$f"; [ "$DRY_RUN" = 1 ] || info "removed $f"; }
823
+ done < "$MANIFEST"
824
+ { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && info "staged for the archive: $BACKUP_DIR"; } || true
825
+ [ "$DRY_RUN" = 1 ] || rm -f "$MANIFEST"
826
+ else
827
+ log "no manifest at $MANIFEST; removing known deploy targets"
828
+ # The two entry files are deliberately NOT in this list. `$CLAUDE_DIR/CLAUDE.md` is seeded
829
+ # once and the user's thereafter, and `$CODEX_DIR/AGENTS.md` is ours only between the central
830
+ # markers, which --remove-owned above already took. This branch runs precisely when state is
831
+ # missing or agent-bios was never installed here, so nothing says we wrote either file —
832
+ # and deleting them whole destroyed personal instructions with no copy at all, since
833
+ # BACKUP_DIR is set only on the manifest path above.
834
+ local p
835
+ for p in "$CODEX_DIR/bin/codex-run" "$CODEX_DIR/bin/codex-helm" \
836
+ "$CLAUDE_DIR/bin/claude-run" \
837
+ "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh" "$BIN_DIR/agent-launch"; do
838
+ [ -f "$p" ] && { run rm -f "$p"; [ "$DRY_RUN" = 1 ] || info "removed $p"; }
839
+ done
840
+ fi
841
+ local d
842
+ # `central/` and its subdirectories are entirely ours — the assembler creates them and the
843
+ # manifest covers every file inside — so they belong in this sweep. They were missing from it,
844
+ # which left three empty directories in the user's config dir after a "leave no trace" removal.
845
+ # rmdir, never rm -rf: a directory that is not empty is one we did not fully account for, and
846
+ # the right answer then is to leave it and be visibly incomplete.
847
+ for d in "$CLAUDE_DIR/central/guides" "$CLAUDE_DIR/central/hooks" "$CLAUDE_DIR/central/agents" \
848
+ "$CLAUDE_DIR/central" \
849
+ "$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CLAUDE_DIR/bin" \
850
+ "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
851
+ [ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
852
+ done
853
+ remove_zsh_hook
854
+ archive_and_purge
855
+ log ""
856
+ log "Uninstalled: deployed files, the zsh hook, state, backups, cache, and the managed venv."
857
+ }
858
+
859
+ # Uninstall is a SECURITY operation — nothing of ours may survive it on the machine. That
860
+ # conflicts with never destroying what a user added, because full mode writes the corpus into an
861
+ # entry file they then edit, so removal takes their work with it. One artifact settles both:
862
+ # everything removed leaves as a single archive that can be handed off or deleted in one act,
863
+ # and every managed location is then purged. The archive lands in $HOME, outside every path we
864
+ # manage, because a copy inside a directory we are about to delete is not a copy.
865
+ #
866
+ # Order is the safety property: archive first, verify the archive exists, purge only then. A
867
+ # failed archive leaves the machine untouched rather than clean and empty-handed.
868
+ archive_and_purge() {
869
+ local ts stage rel out list staged skipped
870
+ ts="$(date +%Y%m%d-%H%M%S)"
871
+ out="${AGENT_BIOS_UNINSTALL_ARCHIVE:-$HOME}/agent-bios-uninstall-$ts.tar.gz"
872
+ if [ "$DRY_RUN" = 1 ]; then
873
+ info "[dry-run] archive removed content to $out, then purge $STATE_DIR, the cache and the venv"
874
+ return 0
875
+ fi
876
+ stage="$(mktemp -d)" || { log "warning: no temp dir; leaving state in place"; return 0; }
877
+ list="$(mktemp)" || { rm -rf "$stage"; log "warning: no temp file; leaving state in place"; return 0; }
878
+ [ -d "$STATE_DIR" ] && cp -R "$STATE_DIR" "$stage/state" 2>/dev/null
879
+ # WHICH loose copies are ours is compose/prune-backups.py's question — it deletes them too, on
880
+ # the retention path, and a rule written in both places drifts on one side. `*.bak-*` was the
881
+ # match here, which also claims a `notes.bak-old` the user saved by hand; these sit in
882
+ # directories we share with them and with other tools, so the name must carry the ownership.
883
+ if [ -f "$REPO/compose/prune-backups.py" ]; then
884
+ python3 "$REPO/compose/prune-backups.py" --list-owned \
885
+ --claude-dir "$CLAUDE_DIR" --codex-dir "$CODEX_DIR" >"$list" 2>/dev/null || : >"$list"
886
+ else
887
+ log "note: compose/prune-backups.py is absent, so loose backup copies are left where they are"
888
+ fi
889
+ # Staged UNDER their absolute path, the way deploy_file backs up to "$BACKUP_DIR$dst".
890
+ # Flattening them into one directory let two same-named copies from the two host trees
891
+ # collide, and the loser was then deleted with nothing in the archive to restore it from.
892
+ # Only what verifiably reached the stage may be deleted later. Every failure mode here is
893
+ # silent — a full temp filesystem, an unreadable source, a directory that cannot be created —
894
+ # and purging from the ORIGINAL list removed files the archive does not contain. Size equality
895
+ # rather than cp's exit status alone, because the question is whether the archive can restore
896
+ # it, not whether the copy was attempted.
897
+ staged="$(mktemp)" || { rm -f "$list"; rm -rf "$stage"
898
+ log "warning: no temp file; leaving state in place"; return 0; }
899
+ skipped=0
900
+ while IFS= read -r -d '' rel; do
901
+ if mkdir -p "$(dirname "$stage/loose$rel")" 2>/dev/null \
902
+ && cp "$rel" "$stage/loose$rel" 2>/dev/null \
903
+ && [ "$(wc -c <"$rel" 2>/dev/null)" = "$(wc -c <"$stage/loose$rel" 2>/dev/null)" ]; then
904
+ printf '%s\0' "$rel" >> "$staged"
905
+ else
906
+ skipped=$((skipped + 1))
907
+ rm -f "$stage/loose$rel" 2>/dev/null # a partial copy must not look archived
908
+ log "warning: could not stage $rel — leaving it in place"
909
+ fi
910
+ done <"$list"
911
+ if tar czf "$out" -C "$stage" . 2>/dev/null && [ -s "$out" ]; then
912
+ # Only here, with the archive written and non-empty, is removing the originals recoverable.
913
+ # Deleting them before the tar meant a failed archive took the backups with it — while the
914
+ # warning below told the operator they had been left in place.
915
+ while IFS= read -r -d '' rel; do rm -f "$rel" 2>/dev/null; done <"$staged"
916
+ rm -f "$list" "$staged"
917
+ rm -rf "$stage"
918
+ rm -rf "$STATE_DIR" "$HOME/.cache/agent-launch" \
919
+ "${AGENT_LAUNCH_VENV:-$HOME/.local/share/agent-launch}"
920
+ log ""
921
+ log "Everything removed is in ONE archive: $out"
922
+ if [ "$skipped" -gt 0 ]; then
923
+ log "Move it somewhere central or delete it. $skipped backup copy(ies) could not be staged,"
924
+ log "so they were LEFT ON DISK rather than deleted with nothing to restore them from."
925
+ else
926
+ log "Move it somewhere central or delete it — nothing of ours is left on this machine."
927
+ fi
928
+ else
929
+ rm -f "$list" "$staged"
930
+ rm -rf "$stage"
931
+ log "warning: could not write $out — state, backups and the venv were left in place rather"
932
+ log " than deleted with no copy. Re-run once the archive path is writable."
933
+ fi
934
+ }
935
+
936
+ cmd_onboard() {
937
+ log "agent-bios onboarding — pick your domain packages (core + infra always install)"
938
+ local names=() line i=1 choice sel="" n picks
939
+ while IFS= read -r line; do names+=("$line"); done \
940
+ < <(python3 -c "import json;print('\n'.join(sorted(json.load(open('$REPO/compose/domains.json'))['domains'])))")
941
+ [ "${#names[@]}" -ge 1 ] || { log "no domains found in compose/domains.json"; exit 1; }
942
+ if [ "$DOMAINS_SET" = 1 ]; then
943
+ # Non-interactive path, per this installer's input contract (stdin is
944
+ # detached at the top of the script): selection arrives as domain names.
945
+ [ "$DOMAINS_ARG" = none ] && DOMAINS_ARG=""
946
+ sel="$DOMAINS_ARG"
947
+ elif ( : </dev/tty ) 2>/dev/null; then
948
+ for line in "${names[@]}"; do info "$i) $line"; i=$((i+1)); done
949
+ printf 'Select by number, comma-separated (empty = core+infra only): '
950
+ read -r choice </dev/tty || choice=""
951
+ if [ -n "$choice" ]; then
952
+ # bash 3.2 + set -u: expanding an EMPTY array errors, so split only
953
+ # when there is input; empty input means core+infra only.
954
+ IFS=',' read -ra picks <<<"$choice"
955
+ for n in "${picks[@]}"; do
956
+ n="${n// /}"; [ -n "$n" ] || continue
957
+ case "$n" in (*[!0-9]*) log "invalid selection: $n"; exit 2 ;; esac
958
+ [ "$n" -ge 1 ] && [ "$n" -le "${#names[@]}" ] || { log "selection out of range: $n"; exit 2; }
959
+ sel="$sel${sel:+,}${names[$((n-1))]}"
960
+ done
961
+ fi
962
+ else
963
+ log "onboard needs a terminal or an explicit selection — run:"
964
+ log " agent-bios onboard --domains a,b (or --domains none for core+infra only)"
965
+ exit 2
966
+ fi
967
+ DOMAINS_ARG="$sel"; DOMAINS_SET=1
968
+ log "selection: ${sel:-<core+infra only>}"
969
+ cmd_install
970
+ log ""
971
+ log "Activation canary (proves the bundle loads in a live session)..."
972
+ if [ "$DRY_RUN" = 1 ]; then info "[dry-run] skip canary probe"; return; fi
973
+ bash "$REPO/compose/canary.sh" || {
974
+ log "ONBOARDING INCOMPLETE: the bundle is installed but not loading — fix the cause above and re-run: agent-bios verify"
975
+ exit 1
976
+ }
977
+ # The prune is authorized by the canary's proof, and cmd_install ran before the canary existed
978
+ # for this bundle rev — so it kept everything. Now that loading is proven, run it for real.
979
+ migrate_learnings
980
+ }
981
+
982
+ # ---- deploy-chain drift ---------------------------------------------------
983
+ # A repo edit is inert until it is published AND globally installed AND
984
+ # deployed. The middle two are checkable: the installer stamps the package
985
+ # version it deployed into the state dir, so a stamp older than the package now
986
+ # running means someone updated the package and never re-deployed. Reading the
987
+ # registry cannot see this, which is why it went unnoticed three times.
988
+ json_field() { # $1=file $2=key
989
+ [ -f "$1" ] || return 1
990
+ python3 -c 'import json,sys
991
+ try:
992
+ v=json.load(open(sys.argv[1])).get(sys.argv[2])
993
+ except Exception:
994
+ sys.exit(1)
995
+ sys.exit(0) if v is None else print(v)' "$1" "$2" 2>/dev/null
996
+ }
997
+
998
+ deployed_version() { json_field "$STATE_DIR/version.json" version; }
999
+ source_version() { json_field "$REPO/package.json" version; }
1000
+
1001
+ json_true() { # exit 0 iff $2 in $1 is JSON true — json.dump writes '"k": true'
1002
+ # with a space, so byte-matching a compact spelling read the real writer's
1003
+ # output as false forever.
1004
+ [ -f "$1" ] || return 1
1005
+ python3 -c 'import json,sys
1006
+ try:
1007
+ v=json.load(open(sys.argv[1])).get(sys.argv[2])
1008
+ except Exception:
1009
+ sys.exit(1)
1010
+ sys.exit(0 if v is True else 1)' "$1" "$2" 2>/dev/null
1011
+ }
1012
+
1013
+ current_commit() { # the CURRENT side's commit: git HEAD in a clone, the stamp in a package
1014
+ # A clone's provenance is its live HEAD — a provenance.json on its disk is a
1015
+ # failed pack's residue, and reading it once let a stale label A match a
1016
+ # checkout sitting at B.
1017
+ if [ -e "$REPO/.git" ]; then
1018
+ git -C "$REPO" rev-parse HEAD 2>/dev/null || true
1019
+ else
1020
+ json_field "$REPO/provenance.json" commit 2>/dev/null || true
1021
+ fi
1022
+ }
1023
+
1024
+ current_dirty() { # exit 0 iff the current side is a dirty-stamped PACKAGE artifact
1025
+ [ ! -e "$REPO/.git" ] && json_true "$REPO/provenance.json" dirty
1026
+ }
1027
+
1028
+ drift_state() { # prints: match | drift | unknown
1029
+ local d s dc sc
1030
+ d="$(deployed_version)" || { echo unknown; return; }
1031
+ s="$(source_version)" || { echo unknown; return; }
1032
+ [ -n "$d" ] && [ -n "$s" ] || { echo unknown; return; }
1033
+ [ "$d" = "$s" ] || { echo drift; return; }
1034
+ # Same VERSION is not same SOURCE: two locally packed tarballs keep one
1035
+ # version across different commits (npm pack is an accommodated flow), and
1036
+ # the commit was persisted into version.json exactly so this comparison
1037
+ # could read it. Judged only when both sides carry one — a clone deploy
1038
+ # stamps no commit, and half a comparison is none.
1039
+ dc="$(json_field "$STATE_DIR/version.json" commit 2>/dev/null || true)"
1040
+ sc="$(current_commit)"
1041
+ # Dirt is judged BEFORE the commit comparison: a dirty artifact's bytes are
1042
+ # described by no commit, so differing commit labels prove nothing about
1043
+ # differing payloads — unknown, not drift, or npm-layout verify fails on a
1044
+ # mismatch nobody proved.
1045
+ if json_true "$STATE_DIR/version.json" dirty || current_dirty; then
1046
+ echo unknown; return
1047
+ fi
1048
+ if [ -n "$dc" ] && [ -n "$sc" ] && [ "$dc" != "$sc" ]; then
1049
+ echo drift; return
1050
+ fi
1051
+ # UNPROVABLE is not drift: an unbound side (npm layout without a commit — a
1052
+ # lifecycle-disabled pack, or the hook's materialized tree) and a DIRTY side
1053
+ # (bytes no commit describes; parsed via json_true, never byte-matched,
1054
+ # because json.dump's spacing defeated the compact grep) both make the
1055
+ # correspondence unprovable. Drift is a PROVEN mismatch and makes verify
1056
+ # fail; branding the unprovable as drift made D-0050's accepted install path
1057
+ # fail its own verification. Clone deployments stay version-compared — git
1058
+ # itself is their live provenance, and they persist no commit by design.
1059
+ if [ "$(json_field "$STATE_DIR/version.json" source 2>/dev/null || true)" = "package" ] \
1060
+ && [ -z "$dc" ]; then
1061
+ echo unknown; return
1062
+ fi
1063
+ if [ ! -e "$REPO/.git" ] && [ -z "$sc" ]; then
1064
+ echo unknown; return
1065
+ fi
1066
+ echo match
1067
+ }
1068
+
1069
+ drift_detail() { # one line naming WHAT drifted: versions, or commits behind one version
1070
+ local d s dc sc
1071
+ d="$(deployed_version)"; s="$(source_version)"
1072
+ dc="$(json_field "$STATE_DIR/version.json" commit 2>/dev/null || true)"
1073
+ sc="$(current_commit)"
1074
+ if [ "$d" = "$s" ] && { json_true "$STATE_DIR/version.json" dirty || current_dirty; }; then
1075
+ printf 'a DIRTY-packed artifact is involved — its bytes are described by no commit; a clean-pack redeploy restores a provable match'
1076
+ elif [ "$d" = "$s" ] && [ -n "$dc" ] && [ -n "$sc" ] && [ "$dc" != "$sc" ]; then
1077
+ printf 'deployed commit %s but this source is at %s (both version %s)' "$dc" "$sc" "$s"
1078
+ elif [ "$(json_field "$STATE_DIR/version.json" source 2>/dev/null || true)" = "package" ] \
1079
+ && [ -z "$dc" ]; then
1080
+ printf 'the deployed artifact is UNBOUND (packed with lifecycle scripts disabled) — its bytes are proven by no commit; a bound redeploy restores a provable match'
1081
+ elif [ ! -e "$REPO/.git" ] && [ -z "$sc" ]; then
1082
+ printf 'this package is UNBOUND (no provenance) — its bytes are proven by no commit'
1083
+ else
1084
+ printf 'deployed %s but this package is %s' "$d" "$s"
1085
+ fi
1086
+ }
1087
+
1088
+ cmd_status() {
1089
+ local version p
1090
+ if [ -e "$REPO/.git" ]; then
1091
+ version="$(git -C "$REPO" describe --tags --always --dirty 2>/dev/null || git -C "$REPO" rev-parse --short HEAD 2>/dev/null || echo '?')"
1092
+ log "agent-bios (git clone: $version)"
1093
+ elif [ -f "$REPO/package.json" ]; then
1094
+ version="$(node -e "try{process.stdout.write(require('$REPO/package.json').version)}catch(e){process.stdout.write('?')}" 2>/dev/null || echo '?')"
1095
+ log "agent-bios (npm package: $version)"
1096
+ if [ -f "$REPO/provenance.json" ]; then
1097
+ local built_from
1098
+ # Tolerated, never trusted: a malformed stamp used to kill status via
1099
+ # set -e mid-output, and a well-formed one with no commit passed in
1100
+ # silence — both are UNBOUND states, not absences of the question.
1101
+ built_from="$(json_field "$REPO/provenance.json" commit 2>/dev/null || true)"
1102
+ if [ -n "$built_from" ]; then
1103
+ info "built from commit ${built_from}$(
1104
+ grep -q '"dirty":true' "$REPO/provenance.json" && printf ' (DIRTY tree at pack time)')"
1105
+ else
1106
+ info "UNBOUND provenance.json is unreadable or names no commit — this artifact is not usably bound"
1107
+ fi
1108
+ else
1109
+ # The absence is the finding: D-0050 accepts lifecycle-disabled packs as
1110
+ # residual BECAUSE this line makes an unbound artifact visible — a silent
1111
+ # skip here would unmake that decision's premise.
1112
+ info "UNBOUND no provenance stamp — packed with lifecycle scripts disabled, or a pre-provenance release; this artifact names no commit"
1113
+ fi
1114
+ else
1115
+ log "agent-bios"
1116
+ fi
1117
+ log " source: $REPO"
1118
+ case "$(drift_state)" in
1119
+ match) info "deployed version $(deployed_version) (matches this package)" ;;
1120
+ drift) log "DRIFT $(drift_detail) — run: agent-bios install" ;;
1121
+ unknown) if [ -f "$STATE_DIR/version.json" ]; then
1122
+ info "correspondence unprovable: $(drift_detail)"
1123
+ else
1124
+ info "deployed version unknown (no state marker yet)"
1125
+ fi ;;
1126
+ esac
1127
+ for p in "$CLAUDE_DIR/CLAUDE.md" "$CODEX_DIR/AGENTS.md" "$BIN_DIR/agent-launch" \
1128
+ "$LAUNCH_DIR/profiles.toml" "$LAUNCH_DIR/shell.zsh"; do
1129
+ if [ -e "$p" ]; then info "present $p"; else info "MISSING $p"; fi
1130
+ done
1131
+ if [ -f "$ZSHRC" ] && grep -qF "$HOOK_MARK" "$ZSHRC"; then info "zsh hook present"; else info "zsh hook absent"; fi
1132
+ }
1133
+
1134
+ cmd_update() {
1135
+ if [ -e "$REPO/.git" ]; then
1136
+ log "Updating from git..."
1137
+ run git -C "$REPO" pull --ff-only
1138
+ cmd_install
1139
+ else
1140
+ log "Installed as an npm package. Update with:"
1141
+ log " npm install -g agent-bios@latest && agent-bios install"
1142
+ log "Then confirm what actually landed — right after a publish the cached"
1143
+ log "packument can serve the PREVIOUS version at exit 0:"
1144
+ log " agent-bios status # must show the version you expected, and no DRIFT"
1145
+ fi
1146
+ }
1147
+
1148
+ usage() {
1149
+ cat <<'EOF'
1150
+ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
1151
+
1152
+ agent-bios install deploy into this environment (backs up + verifies)
1153
+ agent-bios onboard interactive domain selection + install + activation canary
1154
+ agent-bios verify check the deployed state matches the source
1155
+ agent-bios learn submit a session learning (reads the JSON record on
1156
+ stdin; this is what the learn! flow calls, and it
1157
+ works from any directory, unlike a repo-relative path)
1158
+ agent-bios status show what is installed and where
1159
+ agent-bios update git pull + reinstall (clone), or print the npm update line
1160
+ agent-bios uninstall remove deployed files and the zsh hook
1161
+ agent-bios help
1162
+
1163
+ Flags: --dry-run print actions without changing anything
1164
+ --domains a,b assemble ONLY the named domain packages (plus core+infra);
1165
+ with onboard, 'none' means core+infra only. The selection
1166
+ persists in the state dir and later installs/updates reuse
1167
+ it. Default (no flag, no saved selection) selects every
1168
+ domain — one install shape, "full" is just everything
1169
+ selected.
1170
+ --with a,b also install the named optional dependencies (install only).
1171
+ Without it, install offers each missing one when the terminal
1172
+ is interactive, and otherwise just prints its install line.
1173
+ Missing ones are not fatal — they only degrade the review
1174
+ routes that need them.
1175
+ Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
1176
+ EOF
1177
+ # DERIVED, not typed: the hardcoded pair went stale the moment a capability was renamed,
1178
+ # and the name it still advertised selected a different capability than the one that
1179
+ # carries the install line.
1180
+ local known; known=$(capability_table 2>/dev/null | cut -f1 | tr '\n' ' ')
1181
+ [ -n "$known" ] && printf ' --with names: %s\n' "${known% }"
1182
+
1183
+ # Recovery exists in both modes at different granularity, and naming the wrong one is worse
1184
+ # than naming none: a clone can roll the corpus back to a registered mining window, while an
1185
+ # npm install has no git history to read and rolls the whole package back by version instead.
1186
+ # Derived from which install this is, for the same reason --with names is.
1187
+ if [ -e "$REPO/.git" ]; then
1188
+ printf '\nRecover: python3 %s/compose/corpus-state.py list, then rollback --version V\n' "$REPO"
1189
+ else
1190
+ printf '\nRecover: npm install -g agent-bios@<older-version> && agent-bios install\n'
1191
+ fi
1192
+ }
1193
+
1194
+ # ---- dispatch ------------------------------------------------------------
1195
+ CMD="${1:-help}"
1196
+ if [ $# -gt 0 ]; then shift; fi
1197
+
1198
+ # `learn` forwards its arguments and stdin straight to the collector, so it must
1199
+ # bypass the flag parser below (which rejects anything it does not know). This
1200
+ # subcommand is the only PATH-reachable entry to capture: the corpus guide used
1201
+ # to invoke learn/collect-learning.py relative to the cwd, which works from a
1202
+ # clone and silently fails for every other install.
1203
+ if [ "$CMD" = "learn" ]; then
1204
+ collector="$REPO/learn/collect-learning.py"
1205
+ [ -f "$collector" ] || { log "learn: collector missing at $collector"; exit 1; }
1206
+ exec python3 "$collector" "$@" <&3
1207
+ fi
1208
+
1209
+ WITH=""
1210
+ DOMAINS_ARG=""
1211
+ DOMAINS_SET=0
1212
+ while [ $# -gt 0 ]; do
1213
+ case "$1" in
1214
+ --dry-run) DRY_RUN=1 ;;
1215
+ --with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
1216
+ --with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
1217
+ --domains) shift; DOMAINS_ARG="${1:-}"; DOMAINS_SET=1; [ -n "$DOMAINS_ARG" ] || { log "--domains needs a comma-separated domain list (use onboard for core-only)"; exit 2; } ;;
1218
+ --domains=*) DOMAINS_ARG="${1#--domains=}"; DOMAINS_SET=1; [ -n "$DOMAINS_ARG" ] || { log "--domains needs a comma-separated domain list (use onboard for core-only)"; exit 2; } ;;
1219
+ *) log "unknown flag: $1"; exit 2 ;;
1220
+ esac
1221
+ shift
1222
+ done
1223
+
1224
+ case "$CMD" in
1225
+ install) cmd_install ;;
1226
+ onboard) cmd_onboard ;;
1227
+ update) cmd_update ;;
1228
+ uninstall) cmd_uninstall ;;
1229
+ verify) if cmd_verify; then log "VERIFY OK"; else log "VERIFY FAILED"; exit 1; fi ;;
1230
+ status) cmd_status ;;
1231
+ help|-h|--help) usage ;;
1232
+ *) log "unknown command: $CMD"; usage; exit 2 ;;
1233
+ esac