agent-bios 0.1.0 → 0.2.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.
@@ -40,6 +40,15 @@ EFFORT_DESCRIPTIONS = {
40
40
  }
41
41
  CUSTOM_PRESET = "__custom__"
42
42
  OTHER_MODEL = "__other_model__"
43
+ # User-saved presets live beside the deployed config, in a file the installer
44
+ # neither deploys nor verifies, so they survive `agent-bios install`.
45
+ USER_PRESETS_NAME = "presets.local.toml"
46
+ USER_PRESETS_HEADER = (
47
+ "# agent-launch user presets, written by the launcher's save action.\n"
48
+ "# agent-bios install never deploys or verifies this file, so presets here\n"
49
+ "# survive upgrades. Shipped presets live in the deployed profiles.toml and\n"
50
+ "# are overridden by a preset of the same name here.\n"
51
+ )
43
52
 
44
53
 
45
54
  @dataclass(frozen=True)
@@ -55,21 +64,55 @@ REVIEW_SETUPS = {
55
64
  "none": {
56
65
  "label": "None",
57
66
  "requirements": {"codex": (), "claude": ()},
67
+ "description": "No review route. Fastest; you review it yourself.",
58
68
  "contract": "No additional review route requested.",
59
69
  },
60
70
  "native-panel": {
61
71
  "label": "Native panel",
62
72
  "requirements": {"codex": (), "claude": ()},
73
+ "description": (
74
+ "Subagents review from different angles. Broad coverage, no dependency; "
75
+ "same-model reviewers share the main's blind spots."
76
+ ),
63
77
  "contract": "Use native multi-perspective subagents when review gates fire.",
64
78
  },
79
+ "slash-review": {
80
+ "label": "Slash review",
81
+ "requirements": {"codex": (), "claude": ()},
82
+ "description": {
83
+ "claude": (
84
+ "The built-in /code-review command (add `ultra` for the deep cloud pass). "
85
+ "Cheapest good diff review; same-family, so it is a weaker check than cross."
86
+ ),
87
+ "codex": (
88
+ "The built-in /review command. Cheapest good diff review; same-family, "
89
+ "so it is a weaker check than cross."
90
+ ),
91
+ },
92
+ "contract": {
93
+ "claude": (
94
+ "Use Claude Code's built-in /code-review when review gates fire; use "
95
+ "/code-review ultra for a deep multi-agent pass on high-risk changes."
96
+ ),
97
+ "codex": "Use Codex's built-in /review when review gates fire.",
98
+ },
99
+ },
65
100
  "onto": {
66
101
  "label": "onto-mcp",
67
102
  "requirements": {"codex": ("onto",), "claude": ("onto",)},
103
+ "description": (
104
+ "Structured multi-lens review through onto-mcp. Best for concept/ontology "
105
+ "and design work; needs onto installed."
106
+ ),
68
107
  "contract": "Use onto-mcp as the structured review lens when review gates fire.",
69
108
  },
70
109
  "ultracode": {
71
110
  "label": "Ultracode",
72
111
  "requirements": {"codex": ("ultracode",), "claude": ("ultracode",)},
112
+ "description": (
113
+ "Workflow-orchestrated review fanned out over many agents. Best for "
114
+ "exhaustive audits; slowest and most expensive."
115
+ ),
73
116
  "contract": (
74
117
  "Run the configured Ultracode CLI as a Codex-backed review route when "
75
118
  "review gates fire."
@@ -81,6 +124,10 @@ REVIEW_SETUPS = {
81
124
  "codex": ("onto", "ultracode"),
82
125
  "claude": ("onto", "ultracode"),
83
126
  },
127
+ "description": (
128
+ "onto + native + Ultracode together, acting on their union. Widest net for "
129
+ "risky work; missing routes degrade rather than fail."
130
+ ),
84
131
  "contract": {
85
132
  "codex": "Use onto plus native and Codex-backed Ultracode review kinds; act on their union.",
86
133
  "claude": "Use onto plus native multi-perspective and Codex-backed Ultracode review kinds; act on their union.",
@@ -96,12 +143,18 @@ REVIEW_SETUPS = {
96
143
  REVIEW_ROUTES = {
97
144
  "none": (),
98
145
  "native-panel": ("native",),
146
+ "slash-review": ("slash",),
99
147
  "onto": ("onto",),
100
148
  "ultracode": ("ultracode",),
101
149
  "hybrid": ("onto", "native", "ultracode"),
102
150
  }
151
+ # Routes that are host-native review commands: they always resolve (no capability
152
+ # to install) but run on the main's own family, so cross-family mode cannot dispatch
153
+ # them to the opposite family and labels them PROPOSED instead.
154
+ SAME_FAMILY_ROUTES = {"slash"}
103
155
  ROUTE_LABELS = {
104
156
  "native": "native same-model multi-perspective review",
157
+ "slash": "host-native slash-command review",
105
158
  "onto": "onto structured-lens review",
106
159
  "ultracode": "Codex-backed Ultracode review",
107
160
  }
@@ -153,6 +206,28 @@ def default_config_path() -> pathlib.Path:
153
206
  return pathlib.Path(__file__).resolve().parent.parent / "config/agent-launch.toml"
154
207
 
155
208
 
209
+ def user_presets_path(config_path: pathlib.Path) -> pathlib.Path:
210
+ return config_path.with_name(USER_PRESETS_NAME)
211
+
212
+
213
+ def load_user_presets(path: pathlib.Path) -> dict[str, Any]:
214
+ if not path.is_file():
215
+ return {}
216
+ try:
217
+ data = tomllib.loads(path.read_text())
218
+ except (OSError, tomllib.TOMLDecodeError) as exc:
219
+ raise LaunchError(f"cannot load user presets {path}: {exc}") from exc
220
+ unexpected = sorted(set(data) - {"presets"})
221
+ if unexpected:
222
+ raise LaunchError(
223
+ f"{path} may only define [presets.*]; found: {', '.join(unexpected)}"
224
+ )
225
+ presets = data.get("presets", {})
226
+ if not isinstance(presets, dict):
227
+ raise LaunchError(f"[presets] must be a table in {path}")
228
+ return presets
229
+
230
+
156
231
  def load_config(path: pathlib.Path) -> dict[str, Any]:
157
232
  try:
158
233
  data = tomllib.loads(path.read_text())
@@ -163,12 +238,17 @@ def load_config(path: pathlib.Path) -> dict[str, Any]:
163
238
  for key in ("backends", "hosts", "presets"):
164
239
  if not isinstance(data.get(key), dict) or not data[key]:
165
240
  raise LaunchError(f"config requires non-empty [{key}]")
241
+ # Merged before the preset checks below so user presets face the same validation.
242
+ data["presets"].update(load_user_presets(user_presets_path(path)))
166
243
  capabilities = data.get("capabilities", {})
167
244
  if not isinstance(capabilities, dict):
168
245
  raise LaunchError("[capabilities] must be a table")
169
246
  for name, capability in capabilities.items():
170
247
  if not isinstance(capability, dict):
171
248
  raise LaunchError(f"capability must be a table: {name}")
249
+ hint = capability.get("install")
250
+ if hint is not None and (not isinstance(hint, str) or not hint):
251
+ raise LaunchError(f"capabilities.{name}.install must be a non-empty string")
172
252
  onto = capabilities.get("onto")
173
253
  if onto is not None and (
174
254
  not isinstance(onto.get("command"), str) or not onto["command"]
@@ -304,7 +384,7 @@ def route_availability(plan: dict[str, Any]) -> dict[str, bool]:
304
384
  a mounted onto plus a review_onto pin to flip its family."""
305
385
  capabilities = plan.get("capabilities", {})
306
386
  if plan.get("review_family", "cross") == "same":
307
- available = {"native": bool(plan["delegation"])}
387
+ available = {"native": bool(plan["delegation"]), "slash": True}
308
388
  for route, capability in (("onto", "onto"), ("ultracode", "ultracode")):
309
389
  available[route] = _resolves(capabilities.get(capability, {}).get("command", ""))
310
390
  return available
@@ -313,11 +393,26 @@ def route_availability(plan: dict[str, Any]) -> dict[str, bool]:
313
393
  )
314
394
  return {
315
395
  "native": cross_native_command(plan) is not None,
396
+ # The host's own review command exists, but only for its own family; the
397
+ # cross branch of effective_review routes it to the PROPOSED floor.
398
+ "slash": True,
316
399
  "onto": onto_ok,
317
400
  "ultracode": cross_ultracode_command(plan) is not None,
318
401
  }
319
402
 
320
403
 
404
+ def install_hint(plan: dict[str, Any], routes: list[str]) -> str:
405
+ """One-line install guidance for the capability-backed routes that are missing.
406
+ Empty when a route has no capability, no configured install, or is already there."""
407
+ capabilities = plan.get("capabilities", {})
408
+ hints = []
409
+ for route in routes:
410
+ hint = capabilities.get(route, {}).get("install")
411
+ if hint and hint not in hints:
412
+ hints.append(hint)
413
+ return f"; install: {' && '.join(hints)}" if hints else ""
414
+
415
+
321
416
  def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str | None]:
322
417
  """Resolve the requested review setup to the routes that can actually run.
323
418
 
@@ -325,9 +420,11 @@ def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str |
325
420
  reproduces the earlier degrade-to-native behavior (floor None; a dropped
326
421
  external route degrades to native in effective; native requires delegation).
327
422
  In cross-family mode the effective routes run on the opposite family; when no
328
- cross route resolves and delegation is on, floor='native' marks a same-family
329
- PROPOSED fallback, and a requested non-none setup with no cross route and no
330
- fallback (delegation off) fails closed."""
423
+ cross route resolves the floor names the same-family route that runs instead,
424
+ labeled PROPOSED a SAME_FAMILY_ROUTES route floors as itself (it needs no
425
+ delegation, being the host's own command), otherwise 'native' when delegation
426
+ is on. A requested non-none setup with no cross route and no fallback
427
+ (delegation off) fails closed."""
331
428
  requested = plan["review_setup"]
332
429
  if requested not in REVIEW_ROUTES:
333
430
  raise LaunchError(f"unknown review setup: {requested!r}")
@@ -341,11 +438,24 @@ def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str |
341
438
  if dropped and "native" not in effective and plan["delegation"]:
342
439
  effective.append("native")
343
440
  return effective, dropped, None
344
- effective = [route for route in wanted if available[route]]
441
+ # A same-family route cannot be dispatched cross-family, so it is never
442
+ # "effective" here; it becomes the PROPOSED floor below instead.
443
+ effective = [
444
+ route
445
+ for route in wanted
446
+ if available[route] and route not in SAME_FAMILY_ROUTES
447
+ ]
345
448
  dropped = [route for route in wanted if not available[route]]
346
449
  floor = None
347
450
  if not effective and requested != "none":
348
- if plan["delegation"]:
451
+ same_family = [
452
+ route
453
+ for route in wanted
454
+ if route in SAME_FAMILY_ROUTES and available[route]
455
+ ]
456
+ if same_family:
457
+ floor = same_family[0]
458
+ elif plan["delegation"]:
349
459
  floor = "native"
350
460
  else:
351
461
  raise LaunchError(
@@ -882,8 +992,10 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
882
992
 
883
993
 
884
994
  def review_description(host: str, name: str) -> str:
885
- contract = REVIEW_SETUPS[name]["contract"]
886
- return contract[host] if isinstance(contract, dict) else contract
995
+ """The user-facing one-liner for the menu: what the setup is for and what it
996
+ costs. Distinct from ["contract"], which is the instruction sent to the agent."""
997
+ description = REVIEW_SETUPS[name]["description"]
998
+ return description[host] if isinstance(description, dict) else description
887
999
 
888
1000
 
889
1001
  def effort_options(host: str, model: str) -> list[MenuOption]:
@@ -1001,13 +1113,19 @@ def save_preset(
1001
1113
  )
1002
1114
  fields, tier_overrides = preset_from_plan(plan, config, name)
1003
1115
  block = render_preset_block(name, fields, tier_overrides)
1004
- text = remove_preset_block(config_path.read_text(), name).rstrip("\n") + "\n\n" + block
1005
- temporary = config_path.with_name(f".{config_path.name}.{os.getpid()}.tmp")
1116
+ target = user_presets_path(config_path)
1006
1117
  try:
1118
+ kept = remove_preset_block(target.read_text(), name).rstrip("\n")
1119
+ except FileNotFoundError:
1120
+ kept = USER_PRESETS_HEADER.rstrip("\n")
1121
+ text = kept + "\n\n" + block
1122
+ temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
1123
+ try:
1124
+ target.parent.mkdir(parents=True, exist_ok=True)
1007
1125
  temporary.write_text(text)
1008
- os.replace(temporary, config_path)
1126
+ os.replace(temporary, target)
1009
1127
  except OSError as exc:
1010
- raise LaunchError(f"cannot save preset to {config_path}: {exc}") from exc
1128
+ raise LaunchError(f"cannot save preset to {target}: {exc}") from exc
1011
1129
 
1012
1130
 
1013
1131
  def customize(
@@ -1033,7 +1151,10 @@ def customize(
1033
1151
  ]
1034
1152
  if missing:
1035
1153
  note = "degrades to same-family native (PROPOSED)" if cross else "degrades to native"
1036
- description = f"{description} ({', '.join(missing)} unavailable now; {note})"
1154
+ description = (
1155
+ f"{description} ({', '.join(missing)} unavailable now; {note}"
1156
+ f"{install_hint(plan, missing)})"
1157
+ )
1037
1158
  review_options.append(MenuOption(name, spec["label"], description))
1038
1159
  if plan["host"] == "codex":
1039
1160
  policies = [
@@ -1471,6 +1592,15 @@ def _cross_review_route(
1471
1592
  "No cross-family route resolved at launch; using same-family native "
1472
1593
  "subagent review labeled PROPOSED (family collapse)."
1473
1594
  )
1595
+ elif floor:
1596
+ setup_contract = REVIEW_SETUPS[plan["review_setup"]]["contract"]
1597
+ if isinstance(setup_contract, dict):
1598
+ setup_contract = setup_contract[plan["host"]]
1599
+ parts.append(
1600
+ f"This review route runs on this main's own family, so it cannot be "
1601
+ f"dispatched cross-family; its verdicts are PROPOSED (family collapse). "
1602
+ f"{setup_contract}"
1603
+ )
1474
1604
  parts.append(
1475
1605
  "Cross-family reviewers are dispatched as read-only subprocesses, not "
1476
1606
  "CLI-native subagents; spawning them needs this main's execution policy to "
@@ -1615,13 +1745,13 @@ def print_summary(
1615
1745
  if effective:
1616
1746
  review_display += f" via {'+'.join(effective)}"
1617
1747
  if dropped:
1618
- review_display += f" (dropped {','.join(dropped)})"
1619
- if floor == "native":
1620
- review_display += " → same-family native PROPOSED"
1748
+ review_display += f" (dropped {','.join(dropped)}{install_hint(plan, dropped)})"
1749
+ if floor:
1750
+ review_display += f" → same-family {floor} PROPOSED"
1621
1751
  elif dropped:
1622
1752
  review_display += (
1623
1753
  f" → effective {'+'.join(effective) or 'none'} "
1624
- f"({','.join(dropped)} unavailable)"
1754
+ f"({','.join(dropped)} unavailable{install_hint(plan, dropped)})"
1625
1755
  )
1626
1756
  print(
1627
1757
  f" Review setup {review_display} · "
@@ -1585,7 +1585,8 @@ if launcher.is_file() and launch_profile:
1585
1585
  unavailable,
1586
1586
  "agent-launch codex degrade-to-native review",
1587
1587
  (
1588
- "Review setup hybrid → effective native (onto,ultracode unavailable)",
1588
+ "Review setup hybrid → effective native (onto,ultracode unavailable;"
1589
+ " install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
1589
1590
  "degraded to native same-model",
1590
1591
  "fall back to native same-model subagent review",
1591
1592
  ),
@@ -1600,7 +1601,8 @@ if launcher.is_file() and launch_profile:
1600
1601
  unavailable_claude,
1601
1602
  "agent-launch claude degrade-to-native review",
1602
1603
  (
1603
- "Review setup hybrid → effective native (onto,ultracode unavailable)",
1604
+ "Review setup hybrid → effective native (onto,ultracode unavailable;"
1605
+ " install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
1604
1606
  "fall back to native same-model subagent review",
1605
1607
  ),
1606
1608
  ("--mcp-config",),
@@ -1637,8 +1639,11 @@ if launcher.is_file() and launch_profile:
1637
1639
  ):
1638
1640
  mark_fail("saved codex tier override leaked into the claude host")
1639
1641
  launcher_module.save_preset(round_trip, reloaded, save_target, "mysetup")
1640
- if save_target.read_text().count("[presets.mysetup]") != 1:
1642
+ saved_file = launcher_module.user_presets_path(save_target)
1643
+ if saved_file.read_text().count("[presets.mysetup]") != 1:
1641
1644
  mark_fail("re-saving a preset duplicated its block")
1645
+ if "[presets.mysetup]" in save_target.read_text():
1646
+ mark_fail("save_preset wrote a user preset into the installer-owned config")
1642
1647
 
1643
1648
  # The Custom hub 'save' action persists the named preset then launches.
1644
1649
  ui_save_target = tmp / "ui-save.toml"
@@ -0,0 +1,62 @@
1
+ #!/usr/bin/env bash
2
+ # Prompting guides are model-version-bound: unlike the rest of the guide set,
3
+ # their rules change when the model changes (the current generation inverted the
4
+ # previous one's advice). So they name concrete models, and this gate keeps that
5
+ # naming honest — it fails when the launch config binds a model no prompting
6
+ # guide covers, which is exactly when the guidance needs re-deriving from the
7
+ # vendor's current docs.
8
+ set -u
9
+ cd "$(dirname "$0")/.." || exit 2
10
+
11
+ python3 - "$@" <<'PY'
12
+ import pathlib, re, sys, tomllib
13
+
14
+ config = pathlib.Path("config/agent-launch.toml")
15
+ hosts = tomllib.loads(config.read_text())["hosts"]
16
+
17
+ # host -> the guide that owns prompting guidance for that host's model family
18
+ OWNERS = {"codex": "gpt-prompting", "claude": "claude-prompting"}
19
+
20
+ def targets(guide_id):
21
+ path = pathlib.Path(f"codex/guides/{guide_id}.md")
22
+ if not path.is_file():
23
+ sys.exit(f"FAIL: missing prompting guide: {path}")
24
+ front = re.match(r"---\n(.*?)\n---\n", path.read_text(), re.S)
25
+ if not front:
26
+ sys.exit(f"FAIL: missing YAML frontmatter: {path}")
27
+ block = re.search(r"^targets:\n((?:\s+-\s+\S+\n)+)", front.group(1), re.M)
28
+ if not block:
29
+ sys.exit(f"FAIL: {path} declares no targets:")
30
+ return {line.strip().lstrip("- ").strip() for line in block.group(1).splitlines()}
31
+
32
+ fail = 0
33
+ for host, guide_id in OWNERS.items():
34
+ configured = set(hosts.get(host, {}).get("models", []))
35
+ for tier in hosts.get(host, {}).get("tiers", {}).values():
36
+ if tier.get("model"):
37
+ configured.add(tier["model"])
38
+ if not configured:
39
+ print(f"FAIL: no models configured for host {host}; nothing to check")
40
+ fail = 1
41
+ continue
42
+ declared = targets(guide_id)
43
+ stale = sorted(configured - declared)
44
+ if stale:
45
+ print(
46
+ f"FAIL: {guide_id}.md does not cover configured {host} model(s): "
47
+ f"{', '.join(stale)} — re-derive it from current vendor guidance and "
48
+ f"update its targets:"
49
+ )
50
+ fail = 1
51
+ else:
52
+ print(f" {guide_id}: covers all {len(configured)} configured {host} model(s)")
53
+ unused = sorted(declared - configured)
54
+ if unused:
55
+ print(f" note: {guide_id} also targets unbound model(s): {', '.join(unused)}")
56
+
57
+ sys.exit(fail)
58
+ PY
59
+ status=$?
60
+ [ $status -eq 0 ] && echo "PROMPTING TARGETS OK: every configured model is covered" \
61
+ || echo "PROMPTING TARGETS FAILED"
62
+ exit $status
@@ -44,6 +44,7 @@ REPO="$(cd "$SELF/.." && pwd)"
44
44
  CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
45
45
  CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
46
46
  LAUNCH_DIR="$HOME/.config/agent-launch"
47
+ USER_PRESETS_NAME="presets.local.toml" # user-owned; never deployed or verified
47
48
  BIN_DIR="$HOME/.local/bin"
48
49
  STATE_DIR="$HOME/.local/share/agent-bios"
49
50
  LEGACY_STATE_DIR="$HOME/.local/share/agent-dotfiles" # pre-rename state; migrated on first run
@@ -125,6 +126,113 @@ migrate_state() {
125
126
  mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
126
127
  }
127
128
 
129
+ # ---- optional dependencies -----------------------------------------------
130
+ # Capabilities are optional: a missing one only degrades the review routes that
131
+ # need it. config/agent-launch.toml is the single source for both the command
132
+ # that gates a route and the install line offered here.
133
+ capability_table() {
134
+ python3 - "$REPO/config/agent-launch.toml" <<'PY'
135
+ import sys, tomllib
136
+ for name, cap in tomllib.load(open(sys.argv[1], "rb")).get("capabilities", {}).items():
137
+ print("\t".join((name, cap.get("command", ""), cap.get("install", ""))))
138
+ PY
139
+ }
140
+
141
+ install_capability() {
142
+ local name="$1" line="$2"
143
+ log "Installing optional dependency $name: $line"
144
+ if [ "$DRY_RUN" = 1 ]; then info "[dry-run] $line"; return 0; fi
145
+ if sh -c "$line"; then info "installed $name"; else log "warning: installing $name failed; routes needing it stay degraded"; fi
146
+ }
147
+
148
+ handle_capabilities() {
149
+ local requested="$1" name command line
150
+ # Fail on a typo rather than silently installing nothing.
151
+ local known; known=$(capability_table | cut -f1)
152
+ local want
153
+ for want in ${requested//,/ }; do
154
+ printf '%s\n' "$known" | grep -qx "$want" || {
155
+ log "unknown --with capability: $want (known: $(printf '%s' "$known" | tr '\n' ' '))"; return 1; }
156
+ done
157
+ while IFS=$'\t' read -r name command line; do
158
+ [ -n "$name" ] || continue
159
+ if command -v "$command" >/dev/null 2>&1; then
160
+ info "capability present $name ($command)"
161
+ continue
162
+ fi
163
+ if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
164
+ [ -n "$line" ] && install_capability "$name" "$line" \
165
+ || log "note: $name has no configured install line"
166
+ elif [ -n "$line" ] && [ -t 0 ] && [ "$DRY_RUN" != 1 ]; then
167
+ printf ' Install optional dependency %s? (%s) [y/N] ' "$name" "$line"
168
+ local answer=""; read -r answer </dev/tty || answer=""
169
+ case "$answer" in
170
+ [yY]*) install_capability "$name" "$line" ;;
171
+ *) info "skipped $name — install later: $line" ;;
172
+ esac
173
+ else
174
+ info "optional $name unavailable; routes needing it degrade${line:+ — install: $line}"
175
+ fi
176
+ done <<EOF
177
+ $(capability_table)
178
+ EOF
179
+ }
180
+
181
+ # Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
182
+ # would be lost to the cp below; move them into the user-owned presets file first.
183
+ migrate_user_presets() {
184
+ local src="$REPO/config/agent-launch.toml"
185
+ local dst="$LAUNCH_DIR/profiles.toml"
186
+ local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
187
+ [ -f "$dst" ] || return 0
188
+ python3 - "$src" "$dst" "$user" "$DRY_RUN" <<'PY' || { log "user preset migration failed; not overwriting $LAUNCH_DIR/profiles.toml"; return 1; }
189
+ import os, pathlib, re, sys, tomllib
190
+
191
+ src, dst, user, dry_run = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), sys.argv[4] == "1"
192
+ dst_text = dst.read_text()
193
+ shipped = set(tomllib.loads(src.read_text()).get("presets", {}))
194
+ extra = set(tomllib.loads(dst_text).get("presets", {})) - shipped
195
+ if not extra:
196
+ sys.exit(0)
197
+ existing_text = user.read_text() if user.is_file() else ""
198
+ already = set(tomllib.loads(existing_text).get("presets", {})) if existing_text else set()
199
+ move = sorted(extra - already)
200
+ for name in sorted(extra & already):
201
+ print(f" kept in {user.name} {name} (already saved there)")
202
+ if not move:
203
+ sys.exit(0)
204
+
205
+ header = re.compile(r'^\[presets\.(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9_-]*))(?:[.\]])')
206
+ blocks, current = {}, None
207
+ for line in dst_text.splitlines(keepends=True):
208
+ stripped = line.strip()
209
+ if stripped.startswith("["):
210
+ found = header.match(stripped)
211
+ current = (found.group(1) or found.group(2)) if found else None
212
+ if current in move:
213
+ blocks.setdefault(current, []).append(line)
214
+ # Refuse to deploy over a preset we could not carry across, rather than drop it.
215
+ missing = [name for name in move if name not in blocks]
216
+ if missing:
217
+ sys.exit(f"cannot extract preset block(s) from {dst}: {', '.join(missing)}")
218
+
219
+ out = existing_text.rstrip("\n") or (
220
+ "# agent-launch user presets, moved out of profiles.toml by agent-bios install.\n"
221
+ "# The installer never deploys or verifies this file, so presets here survive upgrades."
222
+ )
223
+ for name in move:
224
+ out += "\n\n" + "".join(blocks[name]).strip("\n")
225
+ for name in move:
226
+ print(f" {'[dry-run] ' if dry_run else ''}moved preset {name} -> {user}")
227
+ if dry_run:
228
+ sys.exit(0)
229
+ user.parent.mkdir(parents=True, exist_ok=True)
230
+ temporary = user.with_name(f".{user.name}.{os.getpid()}.tmp")
231
+ temporary.write_text(out + "\n")
232
+ os.replace(temporary, user)
233
+ PY
234
+ }
235
+
128
236
  remove_zsh_hook() {
129
237
  if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
130
238
  info "no zsh hook to remove"
@@ -152,9 +260,14 @@ cmd_install() {
152
260
  deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
153
261
  deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
154
262
  deploy_file "$REPO/scripts/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
263
+ migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
155
264
  deploy_file "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
156
265
  deploy_file "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
157
266
  deploy_file "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
267
+ log ""
268
+ log "Optional dependencies (missing ones only degrade the routes that need them)..."
269
+ handle_capabilities "$WITH" || exit 1
270
+ log ""
158
271
  if [ "$DRY_RUN" = 1 ]; then
159
272
  info "[dry-run] provision managed Textual venv"
160
273
  else
@@ -212,6 +325,16 @@ PY
212
325
  if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
213
326
  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
214
327
  fi
328
+ # Prompting guides name concrete models, so they go stale on a model change
329
+ # rather than degrading quietly; this checks them against the launch config.
330
+ if [ -x "$REPO/scripts/check-prompting-targets.sh" ]; then
331
+ if "$REPO/scripts/check-prompting-targets.sh" >/dev/null 2>&1; then
332
+ info "prompting targets OK"
333
+ else
334
+ log "prompting guides do not cover a configured model; run scripts/check-prompting-targets.sh"
335
+ fail=1
336
+ fi
337
+ fi
215
338
  return $fail
216
339
  }
217
340
 
@@ -284,7 +407,12 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
284
407
  agent-bios uninstall remove deployed files and the zsh hook
285
408
  agent-bios help
286
409
 
287
- Flags: --dry-run print actions without changing anything
410
+ Flags: --dry-run print actions without changing anything
411
+ --with a,b also install the named optional dependencies (install only).
412
+ Without it, install offers each missing one when the terminal
413
+ is interactive, and otherwise just prints its install line.
414
+ Known: onto, ultracode. Missing ones are not fatal — they only
415
+ degrade the review routes that need them.
288
416
  Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
289
417
  EOF
290
418
  }
@@ -292,11 +420,15 @@ EOF
292
420
  # ---- dispatch ------------------------------------------------------------
293
421
  CMD="${1:-help}"
294
422
  if [ $# -gt 0 ]; then shift; fi
295
- for a in "$@"; do
296
- case "$a" in
423
+ WITH=""
424
+ while [ $# -gt 0 ]; do
425
+ case "$1" in
297
426
  --dry-run) DRY_RUN=1 ;;
298
- *) log "unknown flag: $a"; exit 2 ;;
427
+ --with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
428
+ --with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
429
+ *) log "unknown flag: $1"; exit 2 ;;
299
430
  esac
431
+ shift
300
432
  done
301
433
 
302
434
  case "$CMD" in