agent-bios 0.1.0 → 0.3.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 (35) hide show
  1. package/DEPENDENCIES.md +5 -4
  2. package/README.md +2 -2
  3. package/claude/CLAUDE.md +14 -5
  4. package/claude/agents/frontier.md +9 -0
  5. package/claude/agents/sweep.md +9 -0
  6. package/claude/agents/workhorse.md +8 -0
  7. package/claude/guides/claude-prompting.md +123 -0
  8. package/claude/guides/cli-multi-model-workflow.md +11 -4
  9. package/claude/guides/coding-staged-workflow.md +8 -0
  10. package/claude/guides/gpt-prompting.md +118 -0
  11. package/claude/guides/llm-capability-boundary-patterns.md +5 -0
  12. package/claude/guides/mock-realization-boundary.md +9 -0
  13. package/claude/guides/review-request.md +274 -0
  14. package/claude/guides/session-learning-workflow.md +100 -0
  15. package/claude/guides/tooling-gotchas.md +155 -0
  16. package/claude/hooks/tooling-gotchas-hook.py +79 -0
  17. package/codex/AGENTS.md +14 -5
  18. package/codex/config-additions.toml +22 -0
  19. package/codex/guides/claude-prompting.md +123 -0
  20. package/codex/guides/cli-multi-model-workflow.md +11 -4
  21. package/codex/guides/coding-staged-workflow.md +8 -0
  22. package/codex/guides/gpt-prompting.md +118 -0
  23. package/codex/guides/llm-capability-boundary-patterns.md +5 -0
  24. package/codex/guides/mock-realization-boundary.md +9 -0
  25. package/codex/guides/review-request.md +274 -0
  26. package/codex/guides/session-learning-workflow.md +100 -0
  27. package/codex/guides/tooling-gotchas.md +155 -0
  28. package/config/agent-launch.toml +23 -0
  29. package/package.json +6 -2
  30. package/scripts/agent-launch.py +504 -29
  31. package/scripts/check-parity.sh +73 -6
  32. package/scripts/check-prompting-targets.sh +62 -0
  33. package/scripts/codex-helm.sh +19 -1
  34. package/scripts/codex-run.sh +19 -0
  35. package/scripts/install.sh +298 -6
@@ -111,6 +111,8 @@ current-state dashboard|claude/CLAUDE.md|claude/guides/implementation-map.md
111
111
  Verification Menus|claude/CLAUDE.md|claude/guides/coding-staged-workflow.md
112
112
  real Microsoft Excel engine|claude/CLAUDE.md|claude/guides/coding-staged-workflow.md
113
113
  severity contract|README.md|claude/guides/coding-staged-workflow.md
114
+ Ambient state|claude/CLAUDE.md|claude/guides/tooling-gotchas.md
115
+ the full lifecycle of what you create|claude/CLAUDE.md|claude/guides/tooling-gotchas.md
114
116
  ANCHORS
115
117
 
116
118
  # The launcher's Textual preflight UI tests need the managed venv (textual).
@@ -264,6 +266,33 @@ for filename, (expected_name, expected_model, expected_effort) in expected_agent
264
266
  f"want {expected_effort!r}"
265
267
  )
266
268
 
269
+ # codex/config-additions.toml is the additive fragment install merges into the
270
+ # live ~/.codex/config.toml; its agent entries are projections of the canonical
271
+ # codex/agents/*.toml templates and must not drift from them.
272
+ fragment_path = pathlib.Path("codex/config-additions.toml")
273
+ if not fragment_path.is_file():
274
+ mark_fail("required file missing: codex/config-additions.toml")
275
+ else:
276
+ fragment = tomllib.loads(fragment_path.read_text())
277
+ if fragment.get("features", {}).get("multi_agent") is not True:
278
+ mark_fail("config-additions must set features.multi_agent = true")
279
+ fragment_agents = fragment.get("agents", {})
280
+ if set(fragment_agents) != {"frontier", "workhorse", "sweep"}:
281
+ mark_fail(
282
+ "config-additions agents must be exactly the spawnable tiers "
283
+ f"(no helm): {sorted(fragment_agents)}"
284
+ )
285
+ for tier, spec in fragment_agents.items():
286
+ template_path = agent_dir / f"{tier}.toml"
287
+ template = tomllib.loads(template_path.read_text()) if template_path.is_file() else {}
288
+ if spec.get("description") != template.get("description"):
289
+ mark_fail(f"config-additions {tier} description drifted from {template_path}")
290
+ if spec.get("config_file") != f"${{CODEX_HOME}}/agents/{tier}.toml":
291
+ mark_fail(
292
+ f"config-additions {tier} config_file must be "
293
+ f"${{CODEX_HOME}}/agents/{tier}.toml"
294
+ )
295
+
267
296
 
268
297
  def table_row(path, slot):
269
298
  for raw_line in path.read_text().splitlines():
@@ -522,7 +551,7 @@ if launcher.is_file() and launch_profile:
522
551
  def set_plan(self, plan):
523
552
  self.plan = plan
524
553
 
525
- def choose(self, title, options, default, allow_back, preview=None):
554
+ def choose(self, title, options, default, allow_back, preview=None, learning_lines=None):
526
555
  if title == "Preset":
527
556
  if preview is not None:
528
557
  balanced = launcher_module.setup_summary_lines(preview("balanced"))
@@ -748,6 +777,7 @@ if launcher.is_file() and launch_profile:
748
777
  (b"Esc cancel | q cancel", b"\x1b[B"),
749
778
  (b"hybrid onto", b"\x1b[B"),
750
779
  (b"high-volume", b"\x1b[B"),
780
+ (b"learning-refinement", b"\x1b[B"),
751
781
  (b"Open a settings hub", b"\r"),
752
782
  )
753
783
  down = b"\x1b[B"
@@ -1098,10 +1128,11 @@ if launcher.is_file() and launch_profile:
1098
1128
  # TERM=dumb routes to numbered prompts (textual renders on any usable
1099
1129
  # terminal, so the numbered fallback is gated on TERM/non-TTY/textual
1100
1130
  # availability, not a terminfo probe). 'b' is the numbered back command.
1131
+ custom_number = str(len(fake_data["presets"]) + 1).encode()
1101
1132
  transcript, picker_status = run_picker_scenario(
1102
1133
  "numbered fallback (TERM=dumb)",
1103
1134
  (
1104
- (b"Open a settings hub", b"4\n"),
1135
+ (b"Open a settings hub", custom_number + b"\n"),
1105
1136
  (b"Custom settings", b"b\n"),
1106
1137
  (b"HELM default for everyday work", b"1\n"),
1107
1138
  ),
@@ -1114,6 +1145,28 @@ if launcher.is_file() and launch_profile:
1114
1145
  ):
1115
1146
  mark_fail("agent-launch numbered fallback (TERM=dumb) did not preserve numbered back")
1116
1147
 
1148
+ # Session Learning hub: enter, render status, back out, launch balanced.
1149
+ learning_number = str(len(fake_data["presets"]) + 2).encode()
1150
+ transcript, picker_status = run_picker_scenario(
1151
+ "numbered learning hub (TERM=dumb)",
1152
+ (
1153
+ (b"Session Learning", learning_number + b"\n"),
1154
+ (b"Versions & rollback", b"4\n"),
1155
+ (b"HELM default for everyday work", b"1\n"),
1156
+ ),
1157
+ term="dumb",
1158
+ )
1159
+ learning_panel_rendered = (
1160
+ b"Applied version" in transcript or b"not projected yet" in transcript
1161
+ )
1162
+ if (
1163
+ picker_status != 0
1164
+ or b"Traceback" in transcript
1165
+ or not learning_panel_rendered
1166
+ or b"Preset Balanced" not in transcript
1167
+ ):
1168
+ mark_fail("agent-launch numbered learning hub did not render or return")
1169
+
1117
1170
  passed = invoke([
1118
1171
  sys.executable, str(launcher), "--no-tui", "codex", "--", "exec", "--json", "probe"
1119
1172
  ], env=env)
@@ -1421,7 +1474,16 @@ if launcher.is_file() and launch_profile:
1421
1474
  except (ValueError, IndexError, json.JSONDecodeError) as exc:
1422
1475
  mark_fail(f"agent-launch Claude --agents projection is not valid JSON: {exc}")
1423
1476
  else:
1424
- for tier, (model, effort) in expected_claude_tiers.items():
1477
+ expected_spawnable = {
1478
+ tier: expected_claude_tiers[tier]
1479
+ for tier in ("frontier", "workhorse", "sweep")
1480
+ }
1481
+ if set(agents_arg) != set(expected_spawnable):
1482
+ mark_fail(
1483
+ "Claude --agents must project exactly the spawnable tiers "
1484
+ f"(HELM is the main, never a subagent): {sorted(agents_arg)}"
1485
+ )
1486
+ for tier, (model, effort) in expected_spawnable.items():
1425
1487
  role = agents_arg.get(tier, {})
1426
1488
  if role.get("model") != model or role.get("effort") != effort:
1427
1489
  mark_fail(f"agent-launch Claude {tier} role projection drifted: {role!r}")
@@ -1585,7 +1647,8 @@ if launcher.is_file() and launch_profile:
1585
1647
  unavailable,
1586
1648
  "agent-launch codex degrade-to-native review",
1587
1649
  (
1588
- "Review setup hybrid → effective native (onto,ultracode unavailable)",
1650
+ "Review setup hybrid → effective native (onto,ultracode unavailable;"
1651
+ " install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
1589
1652
  "degraded to native same-model",
1590
1653
  "fall back to native same-model subagent review",
1591
1654
  ),
@@ -1600,7 +1663,8 @@ if launcher.is_file() and launch_profile:
1600
1663
  unavailable_claude,
1601
1664
  "agent-launch claude degrade-to-native review",
1602
1665
  (
1603
- "Review setup hybrid → effective native (onto,ultracode unavailable)",
1666
+ "Review setup hybrid → effective native (onto,ultracode unavailable;"
1667
+ " install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
1604
1668
  "fall back to native same-model subagent review",
1605
1669
  ),
1606
1670
  ("--mcp-config",),
@@ -1637,8 +1701,11 @@ if launcher.is_file() and launch_profile:
1637
1701
  ):
1638
1702
  mark_fail("saved codex tier override leaked into the claude host")
1639
1703
  launcher_module.save_preset(round_trip, reloaded, save_target, "mysetup")
1640
- if save_target.read_text().count("[presets.mysetup]") != 1:
1704
+ saved_file = launcher_module.user_presets_path(save_target)
1705
+ if saved_file.read_text().count("[presets.mysetup]") != 1:
1641
1706
  mark_fail("re-saving a preset duplicated its block")
1707
+ if "[presets.mysetup]" in save_target.read_text():
1708
+ mark_fail("save_preset wrote a user preset into the installer-owned config")
1642
1709
 
1643
1710
  # The Custom hub 'save' action persists the named preset then launches.
1644
1711
  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
@@ -268,9 +268,27 @@ EOF
268
268
  EOF
269
269
  }
270
270
 
271
+ # Review packets must carry the whole subject: range-based diffs silently omit
272
+ # staged-but-uncommitted changes and untracked files, so the dispatcher itself
273
+ # appends the subject tree's actual state to the packet.
274
+ scope_note=""
275
+ if [ "$mode" = "review" ] && { [ -n "$cd_dir" ] || [ "$reach" != "hermetic" ]; }; then
276
+ scope_root="${cd_dir:-$PWD}"
277
+ if git -C "$scope_root" rev-parse --is-inside-work-tree >/dev/null 2>&1; then
278
+ scope_status="$(git -C "$scope_root" status --porcelain 2>/dev/null || true)"
279
+ if [ -n "$scope_status" ]; then
280
+ scope_note="Review scope manifest (dispatcher-generated \`git status --porcelain\` of the subject tree; uncommitted/untracked entries are part of the review subject unless the task says otherwise):
281
+ $scope_status"
282
+ echo "codex-helm: subject tree has uncommitted/untracked entries; scope manifest appended to the packet" >&2
283
+ fi
284
+ fi
285
+ fi
286
+
271
287
  final_prompt="$(build_preamble)
272
288
 
273
- User task:
289
+ ${scope_note:+$scope_note
290
+
291
+ }User task:
274
292
  $user_prompt"
275
293
 
276
294
  cleanup_dirs=()
@@ -167,6 +167,25 @@ if [ "${#extra_c[@]}" -gt 0 ]; then
167
167
  for kv in "${extra_c[@]}"; do args+=(-c "$kv"); done
168
168
  fi
169
169
 
170
+ # Dispatch audit: verifier diversity is only as real as the pinned backing
171
+ # model — an unpinned dispatch inherits config defaults and can silently
172
+ # collapse two "different" reviewers onto one backend. The audit line goes to
173
+ # the log file only; stdout/stderr stay reserved for the codex channels.
174
+ sandbox_label="$sandbox"
175
+ if [ "$bypass_sandbox" -eq 1 ]; then sandbox_label="bypass"; fi
176
+ cmodel=""
177
+ if [ "${#extra_c[@]}" -gt 0 ]; then
178
+ for kv in "${extra_c[@]}"; do
179
+ case "$kv" in model=*) cmodel="${kv#model=}" ;; esac
180
+ done
181
+ fi
182
+ dispatch_note="dispatch profile=$profile model=${model:-INHERITED_DEFAULT}${cmodel:+ c-model-override=$cmodel} effort=${effort:-config-default} sandbox=$sandbox_label"
183
+ if [ -z "$model" ] && [ -z "$cmodel" ]; then
184
+ echo "codex-run: WARNING: no --model pin; the backing model inherits the active config default" >&2
185
+ fi
186
+ mkdir -p "$real_home/log" 2>/dev/null || true
187
+ printf '%s %s\n' "$(date +%Y-%m-%dT%H:%M:%S%z)" "$dispatch_note" >> "$real_home/log/codex-run-dispatch.log" 2>/dev/null || true
188
+
170
189
  # stdin, stdout, and stderr already match this adapter's channel contract.
171
190
  set +e
172
191
  CODEX_HOME="$run_home" codex "${args[@]}"
@@ -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,259 @@ migrate_state() {
125
126
  mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
126
127
  }
127
128
 
129
+ # ---- codex live-config additions -----------------------------------------
130
+ # The live ~/.codex/config.toml is user/runtime-owned; agent-bios never
131
+ # deploys or overwrites it. codex/config-additions.toml declares the only
132
+ # content agent-bios manages there — one marked [agents.*] block plus a tagged
133
+ # features.multi_agent line — and this helper merges (install), checks
134
+ # (verify), or removes (uninstall) exactly that content, backed up and
135
+ # tomllib-validated before any write. Modes: merge | check | remove.
136
+ codex_config_additions() {
137
+ AB_MODE="$1" AB_CODEX_DIR="$CODEX_DIR" AB_FRAGMENT="$REPO/codex/config-additions.toml" \
138
+ AB_BACKUP="${BACKUP_DIR:-}" AB_DRY="$DRY_RUN" python3 - <<'PY'
139
+ import os, pathlib, sys, tomllib
140
+
141
+ mode = os.environ["AB_MODE"]
142
+ codex_dir = pathlib.Path(os.environ["AB_CODEX_DIR"])
143
+ fragment_path = pathlib.Path(os.environ["AB_FRAGMENT"])
144
+ backup_root = os.environ.get("AB_BACKUP", "")
145
+ dry = os.environ.get("AB_DRY") == "1"
146
+ target = codex_dir / "config.toml"
147
+ BEGIN = "# >>> agent-bios additions >>>"
148
+ END = "# <<< agent-bios additions <<<"
149
+ TAG = "# agent-bios"
150
+
151
+ def info(msg): print(f" {msg}")
152
+ def fail(msg): print(msg); sys.exit(1)
153
+
154
+ frag_text = fragment_path.read_text().replace("${CODEX_HOME}", str(codex_dir))
155
+ want_agents = tomllib.loads(frag_text)["agents"]
156
+ live_text = target.read_text() if target.is_file() else ""
157
+ try:
158
+ live = tomllib.loads(live_text) if live_text else {}
159
+ except Exception as exc:
160
+ fail(f"live codex config does not parse; not touching it: {target} ({exc})")
161
+
162
+ def state_ok():
163
+ if live.get("features", {}).get("multi_agent") is not True:
164
+ return False
165
+ return all(
166
+ live.get("agents", {}).get(name, {}).get(key) == spec[key]
167
+ for name, spec in want_agents.items()
168
+ for key in ("description", "config_file")
169
+ )
170
+
171
+ if mode == "check":
172
+ problems = []
173
+ if live.get("features", {}).get("multi_agent") is not True:
174
+ problems.append("features.multi_agent is not true")
175
+ for name, spec in want_agents.items():
176
+ if live.get("agents", {}).get(name, {}).get("config_file") != spec["config_file"]:
177
+ problems.append(f"agents.{name}.config_file drifted or missing")
178
+ elif not pathlib.Path(spec["config_file"]).is_file():
179
+ problems.append(f"agents.{name} template missing: {spec['config_file']}")
180
+ if problems:
181
+ fail(f"codex config additions: {'; '.join(problems)} ({target})")
182
+ info("codex config additions OK")
183
+ sys.exit(0)
184
+
185
+ def strip_managed(text):
186
+ out, skipping = [], False
187
+ for line in text.splitlines(keepends=True):
188
+ s = line.strip()
189
+ if s == BEGIN: skipping = True; continue
190
+ if s == END: skipping = False; continue
191
+ if skipping or s.endswith(TAG): continue
192
+ out.append(line)
193
+ return "".join(out)
194
+
195
+ if mode == "remove":
196
+ if not target.is_file():
197
+ sys.exit(0)
198
+ stripped = strip_managed(live_text)
199
+ if stripped == live_text:
200
+ info(f"no agent-bios additions in {target}")
201
+ sys.exit(0)
202
+ try:
203
+ tomllib.loads(stripped)
204
+ except Exception as exc:
205
+ fail(f"refusing removal; result would not parse: {exc}")
206
+ if dry:
207
+ info(f"[dry-run] remove agent-bios additions from {target}")
208
+ sys.exit(0)
209
+ backup = target.with_name(target.name + ".bak-agent-bios-uninstall")
210
+ backup.write_text(live_text)
211
+ target.write_text(stripped)
212
+ info(f"removed additions {target} (backup: {backup.name})")
213
+ sys.exit(0)
214
+
215
+ # mode == merge
216
+ if state_ok():
217
+ info(f"unchanged {target} (additions present)")
218
+ sys.exit(0)
219
+
220
+ # A drifted [agents.<tier>] outside our markers would become a duplicate
221
+ # table if we appended ours; that conflict needs the user, not a clobber.
222
+ base = strip_managed(live_text)
223
+ base_data = tomllib.loads(base) if base.strip() else {}
224
+ clash = [name for name in want_agents if name in base_data.get("agents", {})]
225
+ if clash:
226
+ fail(
227
+ f"unmanaged [agents.{'/'.join(clash)}] with drifted content in {target}; "
228
+ "align or remove them, then rerun install"
229
+ )
230
+
231
+ block_lines = [BEGIN]
232
+ if "features" not in base_data:
233
+ block_lines += ["[features]", f"multi_agent = true {TAG}"]
234
+ for name, spec in want_agents.items():
235
+ block_lines += [
236
+ f"[agents.{name}]",
237
+ f'description = "{spec["description"]}"',
238
+ f'config_file = "{spec["config_file"]}"',
239
+ ]
240
+ block_lines.append(END)
241
+ block = "\n".join(block_lines) + "\n"
242
+
243
+ new_text = base
244
+ if "features" in base_data:
245
+ if base_data["features"].get("multi_agent") is None:
246
+ lines = new_text.splitlines(keepends=True)
247
+ for i, line in enumerate(lines):
248
+ if line.strip() == "[features]":
249
+ lines.insert(i + 1, f"multi_agent = true {TAG}\n")
250
+ break
251
+ new_text = "".join(lines)
252
+ elif base_data["features"].get("multi_agent") is not True:
253
+ info(f"note: features.multi_agent explicitly set in {target}; leaving it")
254
+ if new_text and not new_text.endswith("\n"):
255
+ new_text += "\n"
256
+ new_text += ("\n" if new_text else "") + block
257
+
258
+ try:
259
+ tomllib.loads(new_text)
260
+ except Exception as exc:
261
+ fail(f"merge result would not parse; live config untouched ({exc})")
262
+ if dry:
263
+ info(f"[dry-run] merge agent-bios additions into {target}")
264
+ sys.exit(0)
265
+ if live_text and backup_root:
266
+ bpath = pathlib.Path(backup_root + str(target))
267
+ bpath.parent.mkdir(parents=True, exist_ok=True)
268
+ bpath.write_text(live_text)
269
+ target.parent.mkdir(parents=True, exist_ok=True)
270
+ target.write_text(new_text)
271
+ info(f"merged additions {target}")
272
+ PY
273
+ }
274
+
275
+ # ---- optional dependencies -----------------------------------------------
276
+ # Capabilities are optional: a missing one only degrades the review routes that
277
+ # need it. config/agent-launch.toml is the single source for both the command
278
+ # that gates a route and the install line offered here.
279
+ capability_table() {
280
+ python3 - "$REPO/config/agent-launch.toml" <<'PY'
281
+ import sys, tomllib
282
+ for name, cap in tomllib.load(open(sys.argv[1], "rb")).get("capabilities", {}).items():
283
+ print("\t".join((name, cap.get("command", ""), cap.get("install", ""))))
284
+ PY
285
+ }
286
+
287
+ install_capability() {
288
+ local name="$1" line="$2"
289
+ log "Installing optional dependency $name: $line"
290
+ if [ "$DRY_RUN" = 1 ]; then info "[dry-run] $line"; return 0; fi
291
+ if sh -c "$line"; then info "installed $name"; else log "warning: installing $name failed; routes needing it stay degraded"; fi
292
+ }
293
+
294
+ handle_capabilities() {
295
+ local requested="$1" name command line
296
+ # Fail on a typo rather than silently installing nothing.
297
+ local known; known=$(capability_table | cut -f1)
298
+ local want
299
+ for want in ${requested//,/ }; do
300
+ printf '%s\n' "$known" | grep -qx "$want" || {
301
+ log "unknown --with capability: $want (known: $(printf '%s' "$known" | tr '\n' ' '))"; return 1; }
302
+ done
303
+ while IFS=$'\t' read -r name command line; do
304
+ [ -n "$name" ] || continue
305
+ if command -v "$command" >/dev/null 2>&1; then
306
+ info "capability present $name ($command)"
307
+ continue
308
+ fi
309
+ if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
310
+ [ -n "$line" ] && install_capability "$name" "$line" \
311
+ || log "note: $name has no configured install line"
312
+ elif [ -n "$line" ] && [ -t 0 ] && [ "$DRY_RUN" != 1 ]; then
313
+ printf ' Install optional dependency %s? (%s) [y/N] ' "$name" "$line"
314
+ local answer=""; read -r answer </dev/tty || answer=""
315
+ case "$answer" in
316
+ [yY]*) install_capability "$name" "$line" ;;
317
+ *) info "skipped $name — install later: $line" ;;
318
+ esac
319
+ else
320
+ info "optional $name unavailable; routes needing it degrade${line:+ — install: $line}"
321
+ fi
322
+ done <<EOF
323
+ $(capability_table)
324
+ EOF
325
+ }
326
+
327
+ # Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
328
+ # would be lost to the cp below; move them into the user-owned presets file first.
329
+ migrate_user_presets() {
330
+ local src="$REPO/config/agent-launch.toml"
331
+ local dst="$LAUNCH_DIR/profiles.toml"
332
+ local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
333
+ [ -f "$dst" ] || return 0
334
+ python3 - "$src" "$dst" "$user" "$DRY_RUN" <<'PY' || { log "user preset migration failed; not overwriting $LAUNCH_DIR/profiles.toml"; return 1; }
335
+ import os, pathlib, re, sys, tomllib
336
+
337
+ src, dst, user, dry_run = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), sys.argv[4] == "1"
338
+ dst_text = dst.read_text()
339
+ shipped = set(tomllib.loads(src.read_text()).get("presets", {}))
340
+ extra = set(tomllib.loads(dst_text).get("presets", {})) - shipped
341
+ if not extra:
342
+ sys.exit(0)
343
+ existing_text = user.read_text() if user.is_file() else ""
344
+ already = set(tomllib.loads(existing_text).get("presets", {})) if existing_text else set()
345
+ move = sorted(extra - already)
346
+ for name in sorted(extra & already):
347
+ print(f" kept in {user.name} {name} (already saved there)")
348
+ if not move:
349
+ sys.exit(0)
350
+
351
+ header = re.compile(r'^\[presets\.(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9_-]*))(?:[.\]])')
352
+ blocks, current = {}, None
353
+ for line in dst_text.splitlines(keepends=True):
354
+ stripped = line.strip()
355
+ if stripped.startswith("["):
356
+ found = header.match(stripped)
357
+ current = (found.group(1) or found.group(2)) if found else None
358
+ if current in move:
359
+ blocks.setdefault(current, []).append(line)
360
+ # Refuse to deploy over a preset we could not carry across, rather than drop it.
361
+ missing = [name for name in move if name not in blocks]
362
+ if missing:
363
+ sys.exit(f"cannot extract preset block(s) from {dst}: {', '.join(missing)}")
364
+
365
+ out = existing_text.rstrip("\n") or (
366
+ "# agent-launch user presets, moved out of profiles.toml by agent-bios install.\n"
367
+ "# The installer never deploys or verifies this file, so presets here survive upgrades."
368
+ )
369
+ for name in move:
370
+ out += "\n\n" + "".join(blocks[name]).strip("\n")
371
+ for name in move:
372
+ print(f" {'[dry-run] ' if dry_run else ''}moved preset {name} -> {user}")
373
+ if dry_run:
374
+ sys.exit(0)
375
+ user.parent.mkdir(parents=True, exist_ok=True)
376
+ temporary = user.with_name(f".{user.name}.{os.getpid()}.tmp")
377
+ temporary.write_text(out + "\n")
378
+ os.replace(temporary, user)
379
+ PY
380
+ }
381
+
128
382
  remove_zsh_hook() {
129
383
  if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
130
384
  info "no zsh hook to remove"
@@ -147,14 +401,22 @@ cmd_install() {
147
401
  log "Deploying agent-bios from $REPO"
148
402
  deploy_file "$REPO/claude/CLAUDE.md" "$CLAUDE_DIR/CLAUDE.md"
149
403
  deploy_glob "$REPO/claude/guides" "*.md" "$CLAUDE_DIR/guides"
404
+ deploy_glob "$REPO/claude/agents" "*.md" "$CLAUDE_DIR/agents"
405
+ deploy_glob "$REPO/claude/hooks" "*.py" "$CLAUDE_DIR/hooks" "+x"
150
406
  deploy_file "$REPO/codex/AGENTS.md" "$CODEX_DIR/AGENTS.md"
151
407
  deploy_glob "$REPO/codex/guides" "*.md" "$CODEX_DIR/guides"
152
408
  deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
409
+ codex_config_additions merge || exit 1
153
410
  deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
154
411
  deploy_file "$REPO/scripts/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
412
+ migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
155
413
  deploy_file "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
156
414
  deploy_file "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
157
415
  deploy_file "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
416
+ log ""
417
+ log "Optional dependencies (missing ones only degrade the routes that need them)..."
418
+ handle_capabilities "$WITH" || exit 1
419
+ log ""
158
420
  if [ "$DRY_RUN" = 1 ]; then
159
421
  info "[dry-run] provision managed Textual venv"
160
422
  else
@@ -162,12 +424,19 @@ cmd_install() {
162
424
  || log "warning: venv provisioning failed (numbered-prompt fallback applies)"
163
425
  fi
164
426
  add_zsh_hook
427
+ if python3 "$REPO/scripts/session-learning/learning-state.py" project --repo "$REPO" >/dev/null 2>&1; then
428
+ info "learning-status projected"
429
+ else
430
+ log "note: learning-status projection unavailable (versions.json/ledger missing?)"
431
+ fi
165
432
  log ""
166
433
  log "Verifying deployment..."
167
434
  if cmd_verify; then
168
435
  log ""
169
436
  log "Done. Open a new shell (or: source \"$ZSHRC\") to activate the zero-arg launcher."
170
- [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"
437
+ # An untouched backup dir means nothing was replaced; that healthy state
438
+ # must not become a nonzero exit under set -e.
439
+ { [ -n "$BACKUP_DIR" ] && [ -d "$BACKUP_DIR" ] && log "Replaced files were backed up under $BACKUP_DIR"; } || true
171
440
  else
172
441
  log "VERIFY FAILED after install — see messages above"
173
442
  exit 1
@@ -185,6 +454,8 @@ cmd_verify() {
185
454
  verify_match "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml" || fail=1
186
455
  verify_match "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh" || fail=1
187
456
  for gp in "$REPO"/claude/guides/*.md; do verify_present "$CLAUDE_DIR/guides/$(basename "$gp")" || fail=1; done
457
+ for gp in "$REPO"/claude/agents/*.md; do verify_present "$CLAUDE_DIR/agents/$(basename "$gp")" || fail=1; done
458
+ for gp in "$REPO"/claude/hooks/*.py; do verify_present "$CLAUDE_DIR/hooks/$(basename "$gp")" || fail=1; done
188
459
  for gp in "$REPO"/codex/guides/*.md; do verify_present "$CODEX_DIR/guides/$(basename "$gp")" || fail=1; done
189
460
  python3 - "$CODEX_DIR/agents" <<'PY' && info "agent TOMLs OK" || fail=1
190
461
  import sys, pathlib, tomllib
@@ -195,6 +466,7 @@ assert not missing, f"missing agent TOMLs in {root}: {sorted(missing)}"
195
466
  for p in sorted(root.glob("*.toml")):
196
467
  tomllib.loads(p.read_text())
197
468
  PY
469
+ codex_config_additions check || fail=1
198
470
  if command -v codex >/dev/null 2>&1 && [ -x "$CODEX_DIR/bin/codex-helm" ]; then
199
471
  if "$CODEX_DIR/bin/codex-helm" --dry-run --mode review "probe" >/dev/null 2>&1; then
200
472
  info "codex-helm dry-run OK"
@@ -212,11 +484,22 @@ PY
212
484
  if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
213
485
  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
486
  fi
487
+ # Prompting guides name concrete models, so they go stale on a model change
488
+ # rather than degrading quietly; this checks them against the launch config.
489
+ if [ -x "$REPO/scripts/check-prompting-targets.sh" ]; then
490
+ if "$REPO/scripts/check-prompting-targets.sh" >/dev/null 2>&1; then
491
+ info "prompting targets OK"
492
+ else
493
+ log "prompting guides do not cover a configured model; run scripts/check-prompting-targets.sh"
494
+ fail=1
495
+ fi
496
+ fi
215
497
  return $fail
216
498
  }
217
499
 
218
500
  cmd_uninstall() {
219
501
  migrate_state
502
+ codex_config_additions remove || log "warning: could not remove codex config additions"
220
503
  if [ -f "$MANIFEST" ]; then
221
504
  local f
222
505
  while IFS= read -r f; do
@@ -234,7 +517,7 @@ cmd_uninstall() {
234
517
  done
235
518
  fi
236
519
  local d
237
- for d in "$CLAUDE_DIR/guides" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
520
+ for d in "$CLAUDE_DIR/guides" "$CLAUDE_DIR/agents" "$CODEX_DIR/guides" "$CODEX_DIR/agents" "$CODEX_DIR/bin" "$LAUNCH_DIR"; do
238
521
  [ -d "$d" ] && rmdir "$d" 2>/dev/null && info "removed empty $d" || true
239
522
  done
240
523
  remove_zsh_hook
@@ -284,7 +567,12 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
284
567
  agent-bios uninstall remove deployed files and the zsh hook
285
568
  agent-bios help
286
569
 
287
- Flags: --dry-run print actions without changing anything
570
+ Flags: --dry-run print actions without changing anything
571
+ --with a,b also install the named optional dependencies (install only).
572
+ Without it, install offers each missing one when the terminal
573
+ is interactive, and otherwise just prints its install line.
574
+ Known: onto, ultracode. Missing ones are not fatal — they only
575
+ degrade the review routes that need them.
288
576
  Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
289
577
  EOF
290
578
  }
@@ -292,11 +580,15 @@ EOF
292
580
  # ---- dispatch ------------------------------------------------------------
293
581
  CMD="${1:-help}"
294
582
  if [ $# -gt 0 ]; then shift; fi
295
- for a in "$@"; do
296
- case "$a" in
583
+ WITH=""
584
+ while [ $# -gt 0 ]; do
585
+ case "$1" in
297
586
  --dry-run) DRY_RUN=1 ;;
298
- *) log "unknown flag: $a"; exit 2 ;;
587
+ --with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
588
+ --with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
589
+ *) log "unknown flag: $1"; exit 2 ;;
299
590
  esac
591
+ shift
300
592
  done
301
593
 
302
594
  case "$CMD" in