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
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import pathlib
12
12
  import shutil
13
+ import subprocess
13
14
  import sys
14
15
  import textwrap
15
16
  import tomllib
@@ -18,6 +19,9 @@ from typing import Any, NoReturn
18
19
 
19
20
 
20
21
  TIER_ORDER = ("frontier", "helm", "workhorse", "sweep")
22
+ # Tiers projected as spawnable subagents. HELM is the main role, never a
23
+ # spawnable worker — registering it invited helm-as-subagent misuse.
24
+ SPAWNABLE_TIERS = ("frontier", "workhorse", "sweep")
21
25
  EFFORT_ORDER = ("low", "medium", "high", "xhigh", "max", "ultra")
22
26
  HOST_EFFORTS = {
23
27
  "codex": {"low", "medium", "high", "xhigh", "max", "ultra"},
@@ -40,6 +44,15 @@ EFFORT_DESCRIPTIONS = {
40
44
  }
41
45
  CUSTOM_PRESET = "__custom__"
42
46
  OTHER_MODEL = "__other_model__"
47
+ # User-saved presets live beside the deployed config, in a file the installer
48
+ # neither deploys nor verifies, so they survive `agent-bios install`.
49
+ USER_PRESETS_NAME = "presets.local.toml"
50
+ USER_PRESETS_HEADER = (
51
+ "# agent-launch user presets, written by the launcher's save action.\n"
52
+ "# agent-bios install never deploys or verifies this file, so presets here\n"
53
+ "# survive upgrades. Shipped presets live in the deployed profiles.toml and\n"
54
+ "# are overridden by a preset of the same name here.\n"
55
+ )
43
56
 
44
57
 
45
58
  @dataclass(frozen=True)
@@ -55,21 +68,55 @@ REVIEW_SETUPS = {
55
68
  "none": {
56
69
  "label": "None",
57
70
  "requirements": {"codex": (), "claude": ()},
71
+ "description": "No review route. Fastest; you review it yourself.",
58
72
  "contract": "No additional review route requested.",
59
73
  },
60
74
  "native-panel": {
61
75
  "label": "Native panel",
62
76
  "requirements": {"codex": (), "claude": ()},
77
+ "description": (
78
+ "Subagents review from different angles. Broad coverage, no dependency; "
79
+ "same-model reviewers share the main's blind spots."
80
+ ),
63
81
  "contract": "Use native multi-perspective subagents when review gates fire.",
64
82
  },
83
+ "slash-review": {
84
+ "label": "Slash review",
85
+ "requirements": {"codex": (), "claude": ()},
86
+ "description": {
87
+ "claude": (
88
+ "The built-in /code-review command (add `ultra` for the deep cloud pass). "
89
+ "Cheapest good diff review; same-family, so it is a weaker check than cross."
90
+ ),
91
+ "codex": (
92
+ "The built-in /review command. Cheapest good diff review; same-family, "
93
+ "so it is a weaker check than cross."
94
+ ),
95
+ },
96
+ "contract": {
97
+ "claude": (
98
+ "Use Claude Code's built-in /code-review when review gates fire; use "
99
+ "/code-review ultra for a deep multi-agent pass on high-risk changes."
100
+ ),
101
+ "codex": "Use Codex's built-in /review when review gates fire.",
102
+ },
103
+ },
65
104
  "onto": {
66
105
  "label": "onto-mcp",
67
106
  "requirements": {"codex": ("onto",), "claude": ("onto",)},
107
+ "description": (
108
+ "Structured multi-lens review through onto-mcp. Best for concept/ontology "
109
+ "and design work; needs onto installed."
110
+ ),
68
111
  "contract": "Use onto-mcp as the structured review lens when review gates fire.",
69
112
  },
70
113
  "ultracode": {
71
114
  "label": "Ultracode",
72
115
  "requirements": {"codex": ("ultracode",), "claude": ("ultracode",)},
116
+ "description": (
117
+ "Workflow-orchestrated review fanned out over many agents. Best for "
118
+ "exhaustive audits; slowest and most expensive."
119
+ ),
73
120
  "contract": (
74
121
  "Run the configured Ultracode CLI as a Codex-backed review route when "
75
122
  "review gates fire."
@@ -81,6 +128,10 @@ REVIEW_SETUPS = {
81
128
  "codex": ("onto", "ultracode"),
82
129
  "claude": ("onto", "ultracode"),
83
130
  },
131
+ "description": (
132
+ "onto + native + Ultracode together, acting on their union. Widest net for "
133
+ "risky work; missing routes degrade rather than fail."
134
+ ),
84
135
  "contract": {
85
136
  "codex": "Use onto plus native and Codex-backed Ultracode review kinds; act on their union.",
86
137
  "claude": "Use onto plus native multi-perspective and Codex-backed Ultracode review kinds; act on their union.",
@@ -96,12 +147,18 @@ REVIEW_SETUPS = {
96
147
  REVIEW_ROUTES = {
97
148
  "none": (),
98
149
  "native-panel": ("native",),
150
+ "slash-review": ("slash",),
99
151
  "onto": ("onto",),
100
152
  "ultracode": ("ultracode",),
101
153
  "hybrid": ("onto", "native", "ultracode"),
102
154
  }
155
+ # Routes that are host-native review commands: they always resolve (no capability
156
+ # to install) but run on the main's own family, so cross-family mode cannot dispatch
157
+ # them to the opposite family and labels them PROPOSED instead.
158
+ SAME_FAMILY_ROUTES = {"slash"}
103
159
  ROUTE_LABELS = {
104
160
  "native": "native same-model multi-perspective review",
161
+ "slash": "host-native slash-command review",
105
162
  "onto": "onto structured-lens review",
106
163
  "ultracode": "Codex-backed Ultracode review",
107
164
  }
@@ -153,6 +210,28 @@ def default_config_path() -> pathlib.Path:
153
210
  return pathlib.Path(__file__).resolve().parent.parent / "config/agent-launch.toml"
154
211
 
155
212
 
213
+ def user_presets_path(config_path: pathlib.Path) -> pathlib.Path:
214
+ return config_path.with_name(USER_PRESETS_NAME)
215
+
216
+
217
+ def load_user_presets(path: pathlib.Path) -> dict[str, Any]:
218
+ if not path.is_file():
219
+ return {}
220
+ try:
221
+ data = tomllib.loads(path.read_text())
222
+ except (OSError, tomllib.TOMLDecodeError) as exc:
223
+ raise LaunchError(f"cannot load user presets {path}: {exc}") from exc
224
+ unexpected = sorted(set(data) - {"presets"})
225
+ if unexpected:
226
+ raise LaunchError(
227
+ f"{path} may only define [presets.*]; found: {', '.join(unexpected)}"
228
+ )
229
+ presets = data.get("presets", {})
230
+ if not isinstance(presets, dict):
231
+ raise LaunchError(f"[presets] must be a table in {path}")
232
+ return presets
233
+
234
+
156
235
  def load_config(path: pathlib.Path) -> dict[str, Any]:
157
236
  try:
158
237
  data = tomllib.loads(path.read_text())
@@ -163,12 +242,17 @@ def load_config(path: pathlib.Path) -> dict[str, Any]:
163
242
  for key in ("backends", "hosts", "presets"):
164
243
  if not isinstance(data.get(key), dict) or not data[key]:
165
244
  raise LaunchError(f"config requires non-empty [{key}]")
245
+ # Merged before the preset checks below so user presets face the same validation.
246
+ data["presets"].update(load_user_presets(user_presets_path(path)))
166
247
  capabilities = data.get("capabilities", {})
167
248
  if not isinstance(capabilities, dict):
168
249
  raise LaunchError("[capabilities] must be a table")
169
250
  for name, capability in capabilities.items():
170
251
  if not isinstance(capability, dict):
171
252
  raise LaunchError(f"capability must be a table: {name}")
253
+ hint = capability.get("install")
254
+ if hint is not None and (not isinstance(hint, str) or not hint):
255
+ raise LaunchError(f"capabilities.{name}.install must be a non-empty string")
172
256
  onto = capabilities.get("onto")
173
257
  if onto is not None and (
174
258
  not isinstance(onto.get("command"), str) or not onto["command"]
@@ -304,7 +388,7 @@ def route_availability(plan: dict[str, Any]) -> dict[str, bool]:
304
388
  a mounted onto plus a review_onto pin to flip its family."""
305
389
  capabilities = plan.get("capabilities", {})
306
390
  if plan.get("review_family", "cross") == "same":
307
- available = {"native": bool(plan["delegation"])}
391
+ available = {"native": bool(plan["delegation"]), "slash": True}
308
392
  for route, capability in (("onto", "onto"), ("ultracode", "ultracode")):
309
393
  available[route] = _resolves(capabilities.get(capability, {}).get("command", ""))
310
394
  return available
@@ -313,11 +397,26 @@ def route_availability(plan: dict[str, Any]) -> dict[str, bool]:
313
397
  )
314
398
  return {
315
399
  "native": cross_native_command(plan) is not None,
400
+ # The host's own review command exists, but only for its own family; the
401
+ # cross branch of effective_review routes it to the PROPOSED floor.
402
+ "slash": True,
316
403
  "onto": onto_ok,
317
404
  "ultracode": cross_ultracode_command(plan) is not None,
318
405
  }
319
406
 
320
407
 
408
+ def install_hint(plan: dict[str, Any], routes: list[str]) -> str:
409
+ """One-line install guidance for the capability-backed routes that are missing.
410
+ Empty when a route has no capability, no configured install, or is already there."""
411
+ capabilities = plan.get("capabilities", {})
412
+ hints = []
413
+ for route in routes:
414
+ hint = capabilities.get(route, {}).get("install")
415
+ if hint and hint not in hints:
416
+ hints.append(hint)
417
+ return f"; install: {' && '.join(hints)}" if hints else ""
418
+
419
+
321
420
  def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str | None]:
322
421
  """Resolve the requested review setup to the routes that can actually run.
323
422
 
@@ -325,9 +424,11 @@ def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str |
325
424
  reproduces the earlier degrade-to-native behavior (floor None; a dropped
326
425
  external route degrades to native in effective; native requires delegation).
327
426
  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."""
427
+ cross route resolves the floor names the same-family route that runs instead,
428
+ labeled PROPOSED a SAME_FAMILY_ROUTES route floors as itself (it needs no
429
+ delegation, being the host's own command), otherwise 'native' when delegation
430
+ is on. A requested non-none setup with no cross route and no fallback
431
+ (delegation off) fails closed."""
331
432
  requested = plan["review_setup"]
332
433
  if requested not in REVIEW_ROUTES:
333
434
  raise LaunchError(f"unknown review setup: {requested!r}")
@@ -341,11 +442,24 @@ def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str |
341
442
  if dropped and "native" not in effective and plan["delegation"]:
342
443
  effective.append("native")
343
444
  return effective, dropped, None
344
- effective = [route for route in wanted if available[route]]
445
+ # A same-family route cannot be dispatched cross-family, so it is never
446
+ # "effective" here; it becomes the PROPOSED floor below instead.
447
+ effective = [
448
+ route
449
+ for route in wanted
450
+ if available[route] and route not in SAME_FAMILY_ROUTES
451
+ ]
345
452
  dropped = [route for route in wanted if not available[route]]
346
453
  floor = None
347
454
  if not effective and requested != "none":
348
- if plan["delegation"]:
455
+ same_family = [
456
+ route
457
+ for route in wanted
458
+ if route in SAME_FAMILY_ROUTES and available[route]
459
+ ]
460
+ if same_family:
461
+ floor = same_family[0]
462
+ elif plan["delegation"]:
349
463
  floor = "native"
350
464
  else:
351
465
  raise LaunchError(
@@ -429,6 +543,7 @@ _UI_CANCEL = "\x00cancel"
429
543
  def _build_app_class():
430
544
  """Import textual lazily and build the App/Screen classes, so importing this
431
545
  module and every non-interactive path stays free of the textual dependency."""
546
+ from rich.text import Text
432
547
  from textual import work
433
548
  from textual.app import App
434
549
  from textual.binding import Binding
@@ -459,7 +574,7 @@ def _build_app_class():
459
574
 
460
575
  app_css = """
461
576
  Screen { background: $surface; }
462
- #al-title { color: $accent; text-style: bold; padding: 0 1; }
577
+ #al-title { background: $primary; color: black; text-style: bold; padding: 0 1; }
463
578
  #al-setup {
464
579
  border: round $primary; border-title-color: $primary;
465
580
  border-title-style: bold; padding: 0 1; height: auto;
@@ -468,6 +583,10 @@ def _build_app_class():
468
583
  border: round $secondary; border-title-color: $secondary;
469
584
  border-title-style: bold; padding: 0 1; height: 5;
470
585
  }
586
+ #al-learning-title { background: $warning; color: black; text-style: bold; padding: 0 1; }
587
+ #al-learning {
588
+ border: round $warning; padding: 0 1; height: auto;
589
+ }
471
590
  #al-hdr { color: $text-muted; text-style: bold; padding: 0 1; }
472
591
  OptionList { height: 1fr; border: none; padding: 0 1; }
473
592
  #al-footer { color: $text-muted; dock: bottom; padding: 0 1; background: $panel; }
@@ -486,7 +605,9 @@ def _build_app_class():
486
605
  Binding("ctrl+c", "cancel", "cancel", priority=True),
487
606
  ]
488
607
 
489
- def __init__(self, title, options, default, allow_back, plan, preview=None):
608
+ def __init__(
609
+ self, title, options, default, allow_back, plan, preview=None, learning=None
610
+ ):
490
611
  super().__init__()
491
612
  self._title = title
492
613
  self._options = options
@@ -494,10 +615,14 @@ def _build_app_class():
494
615
  self._allow_back = allow_back
495
616
  self._plan = plan
496
617
  self._preview = preview
618
+ self._learning = learning
497
619
 
498
620
  def compose(self):
499
621
  yield Static(self._title, id="al-title")
500
622
  yield setup_panel(self._plan)
623
+ if self._learning:
624
+ yield Static("Session Learning", id="al-learning-title")
625
+ yield Static("\n".join(self._learning), id="al-learning")
501
626
  detail = Static("", id="al-detail")
502
627
  detail.border_title = "About highlighted option"
503
628
  yield detail
@@ -507,6 +632,19 @@ def _build_app_class():
507
632
  option_list = OptionList()
508
633
  for option in self._options:
509
634
  label = option.label + ("" if option.enabled else " [unavailable]")
635
+ if option.value == LEARNING_HUB:
636
+ # The learning area is a different kind of destination than
637
+ # the launch presets: separated, marked, and warning-toned
638
+ # to match its status panel.
639
+ option_list.add_option(None)
640
+ option_list.add_option(
641
+ Option(
642
+ Text(f"◆ {label}", style="bold yellow"),
643
+ id=option.value,
644
+ disabled=not option.enabled,
645
+ )
646
+ )
647
+ continue
510
648
  option_list.add_option(
511
649
  Option(label, id=option.value, disabled=not option.enabled)
512
650
  )
@@ -665,10 +803,13 @@ class TextualUI:
665
803
  default: str,
666
804
  allow_back: bool,
667
805
  preview=None,
806
+ learning_lines: list[str] | None = None,
668
807
  ) -> str:
669
808
  result = self.app.call_from_thread(
670
809
  self.app.push_screen_wait,
671
- self._menu_screen(title, options, default, allow_back, self.plan, preview),
810
+ self._menu_screen(
811
+ title, options, default, allow_back, self.plan, preview, learning_lines
812
+ ),
672
813
  )
673
814
  if result == _UI_BACK:
674
815
  raise BackRequested
@@ -704,13 +845,26 @@ def run_textual_flow(
704
845
 
705
846
 
706
847
  def choose_lines(
707
- title: str, options: list[MenuOption], default: str, allow_back: bool
848
+ title: str,
849
+ options: list[MenuOption],
850
+ default: str,
851
+ allow_back: bool,
852
+ learning_lines: list[str] | None = None,
708
853
  ) -> str:
709
854
  print(f"\n{title}")
855
+ if learning_lines:
856
+ print(" -- Session Learning --")
857
+ for line in learning_lines:
858
+ print(f" {line}")
859
+ print(" --")
710
860
  for index, option in enumerate(options, 1):
711
861
  marker = "" if option.enabled else " [unavailable]"
712
862
  selected = " *" if option.value == default and option.enabled else ""
713
- print(f" {index}. {option.label}{marker}{selected} - {option.description}")
863
+ label = option.label
864
+ if option.value == LEARNING_HUB:
865
+ print(" " + "-" * 30)
866
+ label = f"◆ {label}"
867
+ print(f" {index}. {label}{marker}{selected} - {option.description}")
714
868
  if not option.enabled:
715
869
  print(f" Unavailable: {option.unavailable_reason}")
716
870
  while True:
@@ -744,12 +898,13 @@ def choose(
744
898
  ui: TextualUI | None = None,
745
899
  allow_back: bool = False,
746
900
  preview=None,
901
+ learning_lines: list[str] | None = None,
747
902
  ) -> str:
748
903
  if not any(option.enabled for option in options):
749
904
  raise LaunchError(f"no available options for {title}")
750
905
  if ui is not None:
751
- return ui.choose(title, options, default, allow_back, preview)
752
- return choose_lines(title, options, default, allow_back)
906
+ return ui.choose(title, options, default, allow_back, preview, learning_lines)
907
+ return choose_lines(title, options, default, allow_back, learning_lines)
753
908
 
754
909
 
755
910
  def read_input(prompt: str) -> str:
@@ -840,6 +995,12 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
840
995
  label = preset.get("label", preset_name)
841
996
  if not isinstance(label, str) or not label:
842
997
  raise LaunchError(f"invalid label in preset {preset_name}")
998
+ mission = preset.get("mission")
999
+ if mission is not None and (not isinstance(mission, str) or not mission):
1000
+ raise LaunchError(f"presets.{preset_name}.mission must be a non-empty string")
1001
+ trigger = preset.get("trigger")
1002
+ if trigger is not None and (not isinstance(trigger, str) or not trigger):
1003
+ raise LaunchError(f"presets.{preset_name}.trigger must be a non-empty string")
843
1004
  review_family = preset.get("review_family", "cross")
844
1005
  review_host = REVIEW_HOST[host]
845
1006
  opposite = config.get("hosts", {}).get(review_host)
@@ -878,12 +1039,16 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
878
1039
  "review_backend": review_backend,
879
1040
  "agent_templates": copy.deepcopy(config["hosts"][host].get("agent_templates")),
880
1041
  "capabilities": copy.deepcopy(config.get("capabilities", {})),
1042
+ "mission": mission,
1043
+ "trigger": trigger,
881
1044
  }
882
1045
 
883
1046
 
884
1047
  def review_description(host: str, name: str) -> str:
885
- contract = REVIEW_SETUPS[name]["contract"]
886
- return contract[host] if isinstance(contract, dict) else contract
1048
+ """The user-facing one-liner for the menu: what the setup is for and what it
1049
+ costs. Distinct from ["contract"], which is the instruction sent to the agent."""
1050
+ description = REVIEW_SETUPS[name]["description"]
1051
+ return description[host] if isinstance(description, dict) else description
887
1052
 
888
1053
 
889
1054
  def effort_options(host: str, model: str) -> list[MenuOption]:
@@ -1001,13 +1166,19 @@ def save_preset(
1001
1166
  )
1002
1167
  fields, tier_overrides = preset_from_plan(plan, config, name)
1003
1168
  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")
1169
+ target = user_presets_path(config_path)
1170
+ try:
1171
+ kept = remove_preset_block(target.read_text(), name).rstrip("\n")
1172
+ except FileNotFoundError:
1173
+ kept = USER_PRESETS_HEADER.rstrip("\n")
1174
+ text = kept + "\n\n" + block
1175
+ temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
1006
1176
  try:
1177
+ target.parent.mkdir(parents=True, exist_ok=True)
1007
1178
  temporary.write_text(text)
1008
- os.replace(temporary, config_path)
1179
+ os.replace(temporary, target)
1009
1180
  except OSError as exc:
1010
- raise LaunchError(f"cannot save preset to {config_path}: {exc}") from exc
1181
+ raise LaunchError(f"cannot save preset to {target}: {exc}") from exc
1011
1182
 
1012
1183
 
1013
1184
  def customize(
@@ -1033,7 +1204,10 @@ def customize(
1033
1204
  ]
1034
1205
  if missing:
1035
1206
  note = "degrades to same-family native (PROPOSED)" if cross else "degrades to native"
1036
- description = f"{description} ({', '.join(missing)} unavailable now; {note})"
1207
+ description = (
1208
+ f"{description} ({', '.join(missing)} unavailable now; {note}"
1209
+ f"{install_hint(plan, missing)})"
1210
+ )
1037
1211
  review_options.append(MenuOption(name, spec["label"], description))
1038
1212
  if plan["host"] == "codex":
1039
1213
  policies = [
@@ -1265,6 +1439,215 @@ def customize(
1265
1439
  raise KeyboardInterrupt
1266
1440
 
1267
1441
 
1442
+ LEARNING_HUB = "__learning__"
1443
+ LEARNING_PRESET = "session-learning"
1444
+ LEARNING_STATUS_PATH = pathlib.Path(
1445
+ os.environ.get(
1446
+ "AGENT_BIOS_LEARNING_STATUS",
1447
+ str(pathlib.Path.home() / ".local/share/agent-bios/learning-status.json"),
1448
+ )
1449
+ )
1450
+
1451
+
1452
+ def load_learning_status() -> dict[str, Any] | None:
1453
+ try:
1454
+ return json.loads(LEARNING_STATUS_PATH.read_text())
1455
+ except (OSError, ValueError):
1456
+ return None
1457
+
1458
+
1459
+ def learning_summary_lines(status: dict[str, Any] | None) -> list[str]:
1460
+ """Panel body for the Session Learning area: which learning content is
1461
+ live, through which mechanisms, and whether the corpus is rolled back."""
1462
+ if status is None:
1463
+ return ["learning status not projected yet — run: agent-bios install"]
1464
+ current = status.get("current_version", "?")
1465
+ latest = status.get("latest_version", "?")
1466
+ head = f"Applied version {current}"
1467
+ if status.get("rolled_back_to"):
1468
+ head += f" (ROLLED BACK; latest is {latest})"
1469
+ layers = status.get("summary", {}).get("placed_by_layer", {})
1470
+ order = ("global", "guide", "hook", "enforcement", "gate")
1471
+ layer_text = " · ".join(
1472
+ f"{name} {layers[name]}" for name in order if layers.get(name)
1473
+ ) or "none"
1474
+ by_status = status.get("summary", {}).get("by_status", {})
1475
+ return [
1476
+ head,
1477
+ f"Mechanisms {layer_text}",
1478
+ f"Ledger placed {by_status.get('placed', 0)} · "
1479
+ f"incubating {by_status.get('incubating', 0) + by_status.get('incubating-G', 0)} · "
1480
+ f"versions {len(status.get('versions', []))}",
1481
+ ]
1482
+
1483
+
1484
+ def _learning_info(ui: TextualUI | None, title: str, lines: list[str]) -> None:
1485
+ """Info screen in both UIs: options carry the content; only exit is back."""
1486
+ options = [MenuOption("back", "Back", "Return to Session Learning.")]
1487
+ try:
1488
+ choose(title, options, "back", ui, allow_back=True, learning_lines=lines)
1489
+ except BackRequested:
1490
+ pass
1491
+
1492
+
1493
+ def _learning_rollback(status: dict[str, Any], version: str, ui: TextualUI | None) -> None:
1494
+ confirm = choose(
1495
+ f"Roll back learning corpus to {version}?",
1496
+ [
1497
+ MenuOption(
1498
+ "cancel", "Cancel", "Keep the currently deployed learning content."
1499
+ ),
1500
+ MenuOption(
1501
+ "rollback",
1502
+ f"Roll back to {version}",
1503
+ "Re-deploys globals/guides/hooks as of that learning version. "
1504
+ "System deployment (launcher, wrappers) stays current. Roll "
1505
+ "forward again by selecting the latest version.",
1506
+ ),
1507
+ ],
1508
+ "cancel",
1509
+ ui,
1510
+ allow_back=True,
1511
+ )
1512
+ if confirm != "rollback":
1513
+ return
1514
+ script = pathlib.Path(status["repo"]) / "scripts/session-learning/learning-state.py"
1515
+ result = subprocess.run(
1516
+ [sys.executable, str(script), "rollback", "--version", version],
1517
+ capture_output=True,
1518
+ text=True,
1519
+ )
1520
+ output = (result.stdout + result.stderr).strip().splitlines()
1521
+ tail = output[-1] if output else ""
1522
+ verdict = "Rollback complete." if result.returncode == 0 else "Rollback FAILED."
1523
+ _learning_info(ui, verdict, [tail] if tail else [])
1524
+
1525
+
1526
+ def _learning_versions(ui: TextualUI | None) -> None:
1527
+ while True:
1528
+ status = load_learning_status()
1529
+ if status is None:
1530
+ _learning_info(ui, "Versions & rollback", learning_summary_lines(None))
1531
+ return
1532
+ current = status.get("current_version")
1533
+ options = []
1534
+ for v in reversed(status.get("versions", [])):
1535
+ name = v["version"]
1536
+ label = f"{name} (current)" if name == current else name
1537
+ options.append(
1538
+ MenuOption(
1539
+ name,
1540
+ label,
1541
+ f"closed {v.get('closed', '?')} · commit {v.get('commit', '')[:12]} · "
1542
+ f"{v.get('summary', '')}",
1543
+ )
1544
+ )
1545
+ options.append(MenuOption("back", "Back", "Return to Session Learning."))
1546
+ try:
1547
+ selected = choose(
1548
+ "Versions & rollback",
1549
+ options,
1550
+ options[0].value,
1551
+ ui,
1552
+ allow_back=True,
1553
+ learning_lines=learning_summary_lines(status),
1554
+ )
1555
+ except BackRequested:
1556
+ return
1557
+ if selected == "back":
1558
+ return
1559
+ if selected == current:
1560
+ _learning_info(
1561
+ ui,
1562
+ f"{selected} is the currently applied version",
1563
+ ["Select a different version to roll back or forward."],
1564
+ )
1565
+ continue
1566
+ _learning_rollback(status, selected, ui)
1567
+
1568
+
1569
+ def _learning_packages(ui: TextualUI | None) -> None:
1570
+ """v1: the corpus ships as a single core package; the list shape is ready
1571
+ for the domain-packaging backlog to populate with real packages."""
1572
+ status = load_learning_status()
1573
+ if status is None:
1574
+ _learning_info(ui, "Learning packages", learning_summary_lines(None))
1575
+ return
1576
+ layers = status.get("summary", {}).get("placed_by_layer", {})
1577
+ options = [
1578
+ MenuOption(
1579
+ "core",
1580
+ f"core corpus @ {status.get('current_version', '?')}",
1581
+ "All learning domains in one package until domain packaging "
1582
+ f"lands (backlog). Layers: {json.dumps(layers, separators=(', ', ' '))}. "
1583
+ "Per-domain selection, versions, and rollback will appear here.",
1584
+ ),
1585
+ MenuOption("back", "Back", "Return to Session Learning."),
1586
+ ]
1587
+ try:
1588
+ choose(
1589
+ "Learning packages",
1590
+ options,
1591
+ "back",
1592
+ ui,
1593
+ allow_back=True,
1594
+ learning_lines=learning_summary_lines(status),
1595
+ )
1596
+ except BackRequested:
1597
+ pass
1598
+
1599
+
1600
+ def learning_hub(config: dict[str, Any], ui: TextualUI | None) -> str:
1601
+ """Session Learning area: status, packages, versions/rollback, session start.
1602
+
1603
+ Returns "start" to launch the learning-session preset, "back" otherwise.
1604
+ """
1605
+ while True:
1606
+ status = load_learning_status()
1607
+ options = [
1608
+ MenuOption(
1609
+ "start",
1610
+ "Start a learning session",
1611
+ "Launch with the Session learning preset: the mission line "
1612
+ "directs the session to the workflow guide and the SSOT.",
1613
+ enabled=LEARNING_PRESET in config["presets"],
1614
+ unavailable_reason=f"preset {LEARNING_PRESET!r} not configured",
1615
+ ),
1616
+ MenuOption(
1617
+ "packages",
1618
+ "Learning packages",
1619
+ "What learning content is applied, as installable packages "
1620
+ "(v1: single core corpus).",
1621
+ ),
1622
+ MenuOption(
1623
+ "versions",
1624
+ "Versions & rollback",
1625
+ "Learning content versions (distinct from system deployment); "
1626
+ "roll the corpus back or forward.",
1627
+ ),
1628
+ MenuOption("back", "Back", "Return to the launch menu."),
1629
+ ]
1630
+ try:
1631
+ selected = choose(
1632
+ "Session Learning",
1633
+ options,
1634
+ "start",
1635
+ ui,
1636
+ allow_back=True,
1637
+ learning_lines=learning_summary_lines(status),
1638
+ )
1639
+ except BackRequested:
1640
+ return "back"
1641
+ if selected == "back":
1642
+ return "back"
1643
+ if selected == "start":
1644
+ return "start"
1645
+ if selected == "packages":
1646
+ _learning_packages(ui)
1647
+ elif selected == "versions":
1648
+ _learning_versions(ui)
1649
+
1650
+
1268
1651
  def select_plan(
1269
1652
  config: dict[str, Any],
1270
1653
  host: str,
@@ -1295,21 +1678,44 @@ def select_plan(
1295
1678
  "Open a settings hub for tiers, review setup, policy, and final confirmation.",
1296
1679
  )
1297
1680
  )
1681
+ options.append(
1682
+ MenuOption(
1683
+ LEARNING_HUB,
1684
+ "Enter Session Learning Mode",
1685
+ "Learning-refinement mode: applied versions and mechanisms, "
1686
+ "packages, rollback — and start a dedicated learning session "
1687
+ "(preselected on entry).",
1688
+ )
1689
+ )
1298
1690
  default = "balanced" if "balanced" in presets else next(iter(presets))
1299
1691
  if ui is not None:
1300
1692
  ui.set_plan(build_plan(config, host, default))
1693
+
1694
+ def preview_name(value: str) -> str:
1695
+ if value == CUSTOM_PRESET:
1696
+ return default
1697
+ if value == LEARNING_HUB:
1698
+ return LEARNING_PRESET if LEARNING_PRESET in presets else default
1699
+ return value
1700
+
1301
1701
  selected = choose(
1302
1702
  "Preset",
1303
1703
  options,
1304
1704
  default,
1305
1705
  ui,
1306
- preview=lambda value: build_plan(
1307
- config, host, default if value == CUSTOM_PRESET else value
1308
- ),
1706
+ preview=lambda value: build_plan(config, host, preview_name(value)),
1707
+ learning_lines=learning_summary_lines(load_learning_status()),
1309
1708
  )
1310
1709
  if selected == CUSTOM_PRESET:
1311
1710
  selected_name = default
1312
1711
  selected_custom = True
1712
+ elif selected == LEARNING_HUB:
1713
+ if learning_hub(config, ui) != "start":
1714
+ continue
1715
+ if LEARNING_PRESET not in presets:
1716
+ raise LaunchError(f"preset {LEARNING_PRESET!r} missing from config")
1717
+ selected_name = LEARNING_PRESET
1718
+ selected_custom = False
1313
1719
  else:
1314
1720
  selected_name = selected
1315
1721
  if selected_name not in presets:
@@ -1347,7 +1753,7 @@ def codex_agent_configs(
1347
1753
  if not isinstance(templates, dict):
1348
1754
  raise LaunchError("Codex delegation requires [hosts.codex.agent_templates]")
1349
1755
  rendered: dict[str, tuple[str, str]] = {}
1350
- for tier in ("frontier", "workhorse", "sweep"):
1756
+ for tier in SPAWNABLE_TIERS:
1351
1757
  source_value = templates.get(tier)
1352
1758
  if not isinstance(source_value, str) or not source_value:
1353
1759
  raise LaunchError(f"Codex agent template missing: {tier}")
@@ -1471,6 +1877,15 @@ def _cross_review_route(
1471
1877
  "No cross-family route resolved at launch; using same-family native "
1472
1878
  "subagent review labeled PROPOSED (family collapse)."
1473
1879
  )
1880
+ elif floor:
1881
+ setup_contract = REVIEW_SETUPS[plan["review_setup"]]["contract"]
1882
+ if isinstance(setup_contract, dict):
1883
+ setup_contract = setup_contract[plan["host"]]
1884
+ parts.append(
1885
+ f"This review route runs on this main's own family, so it cannot be "
1886
+ f"dispatched cross-family; its verdicts are PROPOSED (family collapse). "
1887
+ f"{setup_contract}"
1888
+ )
1474
1889
  parts.append(
1475
1890
  "Cross-family reviewers are dispatched as read-only subprocesses, not "
1476
1891
  "CLI-native subagents; spawning them needs this main's execution policy to "
@@ -1500,7 +1915,12 @@ def run_contract(plan: dict[str, Any]) -> str:
1500
1915
  if plan["host"] == "codex"
1501
1916
  else "Main and child model/effort defaults are CLI-projected."
1502
1917
  )
1918
+ mission = plan.get("mission")
1919
+ if mission and plan.get("trigger"):
1920
+ mission = mission.replace("{trigger}", plan["trigger"])
1921
+ mission_prefix = f"Mission: {mission} " if mission else ""
1503
1922
  return (
1923
+ f"{mission_prefix}"
1504
1924
  f"LaunchPlan: main={plan['main_tier']} ({plan['tiers'][plan['main_tier']]['model']}/"
1505
1925
  f"{tier_effort(plan, plan['main_tier'])}); tiers: {bindings}. "
1506
1926
  f"Delegation={'on' if plan['delegation'] else 'off'}. Review family={family}. "
@@ -1513,7 +1933,7 @@ def run_contract(plan: dict[str, Any]) -> str:
1513
1933
 
1514
1934
  def claude_agents(plan: dict[str, Any]) -> str:
1515
1935
  roles = {}
1516
- for tier in TIER_ORDER:
1936
+ for tier in SPAWNABLE_TIERS:
1517
1937
  binding = plan["tiers"][tier]
1518
1938
  roles[tier] = {
1519
1939
  "description": f"{tier.upper()} tier: {binding['model']} at {tier_effort(plan, tier)}",
@@ -1615,13 +2035,13 @@ def print_summary(
1615
2035
  if effective:
1616
2036
  review_display += f" via {'+'.join(effective)}"
1617
2037
  if dropped:
1618
- review_display += f" (dropped {','.join(dropped)})"
1619
- if floor == "native":
1620
- review_display += " → same-family native PROPOSED"
2038
+ review_display += f" (dropped {','.join(dropped)}{install_hint(plan, dropped)})"
2039
+ if floor:
2040
+ review_display += f" → same-family {floor} PROPOSED"
1621
2041
  elif dropped:
1622
2042
  review_display += (
1623
2043
  f" → effective {'+'.join(effective) or 'none'} "
1624
- f"({','.join(dropped)} unavailable)"
2044
+ f"({','.join(dropped)} unavailable{install_hint(plan, dropped)})"
1625
2045
  )
1626
2046
  print(
1627
2047
  f" Review setup {review_display} · "
@@ -1652,6 +2072,49 @@ def print_summary(
1652
2072
  print(" Argv " + json.dumps([command, *args]), file=stream)
1653
2073
 
1654
2074
 
2075
+ SESSION_LEARNING_STATE = pathlib.Path(
2076
+ os.environ.get(
2077
+ "AGENT_BIOS_SESSION_LEARNING_STATE",
2078
+ str(pathlib.Path.home() / ".local/share/agent-bios/session-learning-state.json"),
2079
+ )
2080
+ )
2081
+
2082
+
2083
+ def _line_count(path: pathlib.Path) -> int:
2084
+ try:
2085
+ with path.open("rb") as fh:
2086
+ return sum(chunk.count(b"\n") for chunk in iter(lambda: fh.read(1 << 20), b""))
2087
+ except OSError:
2088
+ return 0
2089
+
2090
+
2091
+ def session_learning_nudge(config: dict[str, Any]) -> str | None:
2092
+ """Nudge when enough sessions accumulated since the last mining window.
2093
+
2094
+ The baseline is written by scripts/session-learning/update-state.py at
2095
+ window close; provider history line counts are a cheap proxy for new
2096
+ sessions. No state file means no nudge.
2097
+ """
2098
+ try:
2099
+ state = json.loads(SESSION_LEARNING_STATE.read_text())
2100
+ baseline = int(state["history_lines_total"])
2101
+ except (OSError, ValueError, KeyError, TypeError):
2102
+ return None
2103
+ settings = config.get("session_learning", {})
2104
+ threshold = settings.get("nudge_after", 250) if isinstance(settings, dict) else 250
2105
+ current = _line_count(pathlib.Path.home() / ".claude/history.jsonl") + _line_count(
2106
+ pathlib.Path.home() / ".codex/history.jsonl"
2107
+ )
2108
+ delta = current - baseline
2109
+ if delta < threshold:
2110
+ return None
2111
+ return (
2112
+ f"session-learning due: ~{delta} new session entries since "
2113
+ f"{state.get('window_end', '?')} (threshold {threshold}) — launch the "
2114
+ "Session learning preset to run the next mining window"
2115
+ )
2116
+
2117
+
1655
2118
  def parse_args(argv: list[str]) -> argparse.Namespace:
1656
2119
  parser = argparse.ArgumentParser(description=__doc__)
1657
2120
  parser.add_argument("--config", type=pathlib.Path, default=default_config_path())
@@ -1681,6 +2144,12 @@ def main(argv: list[str]) -> int:
1681
2144
  config_path = args.config.expanduser()
1682
2145
  config = load_config(config_path)
1683
2146
  command, passthrough = resolve_backend(config, args.host)
2147
+ nudge = session_learning_nudge(config)
2148
+ if nudge:
2149
+ print(f"agent-launch: {nudge}", file=sys.stderr)
2150
+ nudged = config["presets"].get("session-learning")
2151
+ if isinstance(nudged, dict):
2152
+ nudged["description"] = f"{nudged.get('description', '')} ⚠ {nudge}".strip()
1684
2153
  tty = sys.stdin.isatty() and sys.stdout.isatty()
1685
2154
  if not tty and args.dry_run and not args.preset and not args.custom:
1686
2155
  if "balanced" not in config["presets"]:
@@ -1718,6 +2187,12 @@ def main(argv: list[str]) -> int:
1718
2187
  projected = [*project_args(plan, materialize_agents=not args.dry_run), *args.forward]
1719
2188
  summary_stream = sys.stdout if tty or args.dry_run else sys.stderr
1720
2189
  print_summary(plan, command, projected, summary_stream, bool(args.forward))
2190
+ trigger = plan.get("trigger")
2191
+ if trigger:
2192
+ # Black-on-yellow to match the Session Learning identity; the session
2193
+ # waits for this exact phrase before starting the workflow.
2194
+ line = f' Once the session is up, type "{trigger}" to begin the learning workflow. '
2195
+ print(f"\n\x1b[1;30;43m{line}\x1b[0m", file=summary_stream)
1721
2196
  if args.dry_run:
1722
2197
  print(json.dumps([command, *projected], ensure_ascii=False))
1723
2198
  return 0