agent-bios 0.3.0 → 0.4.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.
@@ -44,6 +44,17 @@ EFFORT_DESCRIPTIONS = {
44
44
  }
45
45
  CUSTOM_PRESET = "__custom__"
46
46
  OTHER_MODEL = "__other_model__"
47
+ # Root-menu grouping for presets: "builder" (tunable, includes Custom), "general"
48
+ # (plain CLI presets like Vanilla), "distill" (opens the Session Distill hub).
49
+ # A preset with a missing/unknown mode defaults to "builder" so presets written
50
+ # before this field existed (including saved/local user presets) keep working.
51
+ GENERAL_MODE = "general"
52
+ DEFAULT_PRESET_MODE = "builder"
53
+ # The mode value that opens the Session Distill hub instead of a preset
54
+ # submenu; doubles as the option value on the root Mode menu so selecting it
55
+ # needs no translation.
56
+ DISTILL_MODE = "distill"
57
+ PRESET_MODES = (GENERAL_MODE, DEFAULT_PRESET_MODE, DISTILL_MODE)
47
58
  # User-saved presets live beside the deployed config, in a file the installer
48
59
  # neither deploys nor verifies, so they survive `agent-bios install`.
49
60
  USER_PRESETS_NAME = "presets.local.toml"
@@ -62,8 +73,9 @@ class MenuOption:
62
73
  description: str
63
74
  enabled: bool = True
64
75
  unavailable_reason: str = ""
65
- CODEX_POLICIES = {"bypass", "workspace-write", "read-only"}
66
- CLAUDE_POLICIES = {"acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan"}
76
+ STANDARD_POLICY = "standard"
77
+ CODEX_POLICIES = {"bypass", "workspace-write", "read-only", STANDARD_POLICY}
78
+ CLAUDE_POLICIES = {"acceptEdits", "auto", "bypassPermissions", "manual", "dontAsk", "plan", STANDARD_POLICY}
67
79
  REVIEW_SETUPS = {
68
80
  "none": {
69
81
  "label": "None",
@@ -543,7 +555,6 @@ _UI_CANCEL = "\x00cancel"
543
555
  def _build_app_class():
544
556
  """Import textual lazily and build the App/Screen classes, so importing this
545
557
  module and every non-interactive path stays free of the textual dependency."""
546
- from rich.text import Text
547
558
  from textual import work
548
559
  from textual.app import App
549
560
  from textual.binding import Binding
@@ -583,8 +594,8 @@ def _build_app_class():
583
594
  border: round $secondary; border-title-color: $secondary;
584
595
  border-title-style: bold; padding: 0 1; height: 5;
585
596
  }
586
- #al-learning-title { background: $warning; color: black; text-style: bold; padding: 0 1; }
587
- #al-learning {
597
+ #al-corpus-title { background: $warning; color: black; text-style: bold; padding: 0 1; }
598
+ #al-corpus {
588
599
  border: round $warning; padding: 0 1; height: auto;
589
600
  }
590
601
  #al-hdr { color: $text-muted; text-style: bold; padding: 0 1; }
@@ -606,7 +617,7 @@ def _build_app_class():
606
617
  ]
607
618
 
608
619
  def __init__(
609
- self, title, options, default, allow_back, plan, preview=None, learning=None
620
+ self, title, options, default, allow_back, plan, preview=None, corpus=None
610
621
  ):
611
622
  super().__init__()
612
623
  self._title = title
@@ -615,14 +626,14 @@ def _build_app_class():
615
626
  self._allow_back = allow_back
616
627
  self._plan = plan
617
628
  self._preview = preview
618
- self._learning = learning
629
+ self._corpus = corpus
619
630
 
620
631
  def compose(self):
621
632
  yield Static(self._title, id="al-title")
622
633
  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")
634
+ if self._corpus:
635
+ yield Static("Corpus status", id="al-corpus-title")
636
+ yield Static("\n".join(self._corpus), id="al-corpus")
626
637
  detail = Static("", id="al-detail")
627
638
  detail.border_title = "About highlighted option"
628
639
  yield detail
@@ -632,19 +643,6 @@ def _build_app_class():
632
643
  option_list = OptionList()
633
644
  for option in self._options:
634
645
  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
648
646
  option_list.add_option(
649
647
  Option(label, id=option.value, disabled=not option.enabled)
650
648
  )
@@ -803,12 +801,12 @@ class TextualUI:
803
801
  default: str,
804
802
  allow_back: bool,
805
803
  preview=None,
806
- learning_lines: list[str] | None = None,
804
+ corpus_lines: list[str] | None = None,
807
805
  ) -> str:
808
806
  result = self.app.call_from_thread(
809
807
  self.app.push_screen_wait,
810
808
  self._menu_screen(
811
- title, options, default, allow_back, self.plan, preview, learning_lines
809
+ title, options, default, allow_back, self.plan, preview, corpus_lines
812
810
  ),
813
811
  )
814
812
  if result == _UI_BACK:
@@ -849,21 +847,18 @@ def choose_lines(
849
847
  options: list[MenuOption],
850
848
  default: str,
851
849
  allow_back: bool,
852
- learning_lines: list[str] | None = None,
850
+ corpus_lines: list[str] | None = None,
853
851
  ) -> str:
854
852
  print(f"\n{title}")
855
- if learning_lines:
856
- print(" -- Session Learning --")
857
- for line in learning_lines:
853
+ if corpus_lines:
854
+ print(" -- Corpus status --")
855
+ for line in corpus_lines:
858
856
  print(f" {line}")
859
857
  print(" --")
860
858
  for index, option in enumerate(options, 1):
861
859
  marker = "" if option.enabled else " [unavailable]"
862
860
  selected = " *" if option.value == default and option.enabled else ""
863
861
  label = option.label
864
- if option.value == LEARNING_HUB:
865
- print(" " + "-" * 30)
866
- label = f"◆ {label}"
867
862
  print(f" {index}. {label}{marker}{selected} - {option.description}")
868
863
  if not option.enabled:
869
864
  print(f" Unavailable: {option.unavailable_reason}")
@@ -898,13 +893,13 @@ def choose(
898
893
  ui: TextualUI | None = None,
899
894
  allow_back: bool = False,
900
895
  preview=None,
901
- learning_lines: list[str] | None = None,
896
+ corpus_lines: list[str] | None = None,
902
897
  ) -> str:
903
898
  if not any(option.enabled for option in options):
904
899
  raise LaunchError(f"no available options for {title}")
905
900
  if ui is not None:
906
- return ui.choose(title, options, default, allow_back, preview, learning_lines)
907
- return choose_lines(title, options, default, allow_back, learning_lines)
901
+ return ui.choose(title, options, default, allow_back, preview, corpus_lines)
902
+ return choose_lines(title, options, default, allow_back, corpus_lines)
908
903
 
909
904
 
910
905
  def read_input(prompt: str) -> str:
@@ -995,6 +990,11 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
995
990
  label = preset.get("label", preset_name)
996
991
  if not isinstance(label, str) or not label:
997
992
  raise LaunchError(f"invalid label in preset {preset_name}")
993
+ mode = preset.get("mode")
994
+ if not isinstance(mode, str) or mode not in PRESET_MODES:
995
+ # Missing/unknown mode (older or user/local presets) defaults to builder
996
+ # rather than failing closed, so existing presets keep working unchanged.
997
+ mode = DEFAULT_PRESET_MODE
998
998
  mission = preset.get("mission")
999
999
  if mission is not None and (not isinstance(mission, str) or not mission):
1000
1000
  raise LaunchError(f"presets.{preset_name}.mission must be a non-empty string")
@@ -1024,6 +1024,7 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
1024
1024
  "preset": preset_name,
1025
1025
  "label": label,
1026
1026
  "description": preset.get("description", f"Launch the {label} preset."),
1027
+ "mode": mode,
1027
1028
  "main_tier": main_tier,
1028
1029
  "frontier_effort": frontier_effort,
1029
1030
  "review_setup": review_setup,
@@ -1226,6 +1227,11 @@ def customize(
1226
1227
  "Read-only sandbox",
1227
1228
  "Allow inspection while blocking file writes through the Codex sandbox.",
1228
1229
  ),
1230
+ MenuOption(
1231
+ STANDARD_POLICY,
1232
+ "Standard",
1233
+ "Pass no policy flag; use Codex's own default approval and sandbox behavior.",
1234
+ ),
1229
1235
  ]
1230
1236
  policy_field = "codex_execution_policy"
1231
1237
  policy_title = "Codex execution policy"
@@ -1261,6 +1267,11 @@ def customize(
1261
1267
  "Plan mode",
1262
1268
  "Start Claude in planning mode without direct implementation.",
1263
1269
  ),
1270
+ MenuOption(
1271
+ STANDARD_POLICY,
1272
+ "Standard",
1273
+ "Pass no policy flag; use Claude's own default permission prompts.",
1274
+ ),
1264
1275
  ]
1265
1276
  policy_field = "claude_permission_mode"
1266
1277
  policy_title = "Claude permission mode (not an OS sandbox)"
@@ -1439,28 +1450,27 @@ def customize(
1439
1450
  raise KeyboardInterrupt
1440
1451
 
1441
1452
 
1442
- LEARNING_HUB = "__learning__"
1443
- LEARNING_PRESET = "session-learning"
1444
- LEARNING_STATUS_PATH = pathlib.Path(
1453
+ DISTILL_PRESET = "session-distill"
1454
+ CORPUS_STATUS_PATH = pathlib.Path(
1445
1455
  os.environ.get(
1446
- "AGENT_BIOS_LEARNING_STATUS",
1447
- str(pathlib.Path.home() / ".local/share/agent-bios/learning-status.json"),
1456
+ "AGENT_BIOS_CORPUS_STATUS",
1457
+ str(pathlib.Path.home() / ".local/share/agent-bios/corpus-status.json"),
1448
1458
  )
1449
1459
  )
1450
1460
 
1451
1461
 
1452
- def load_learning_status() -> dict[str, Any] | None:
1462
+ def load_corpus_status() -> dict[str, Any] | None:
1453
1463
  try:
1454
- return json.loads(LEARNING_STATUS_PATH.read_text())
1464
+ return json.loads(CORPUS_STATUS_PATH.read_text())
1455
1465
  except (OSError, ValueError):
1456
1466
  return None
1457
1467
 
1458
1468
 
1459
- def learning_summary_lines(status: dict[str, Any] | None) -> list[str]:
1460
- """Panel body for the Session Learning area: which learning content is
1469
+ def corpus_summary_lines(status: dict[str, Any] | None) -> list[str]:
1470
+ """Panel body for the Session Distill area: which corpus content is
1461
1471
  live, through which mechanisms, and whether the corpus is rolled back."""
1462
1472
  if status is None:
1463
- return ["learning status not projected yet — run: agent-bios install"]
1473
+ return ["corpus status not projected yet — run: agent-bios install"]
1464
1474
  current = status.get("current_version", "?")
1465
1475
  latest = status.get("latest_version", "?")
1466
1476
  head = f"Applied version {current}"
@@ -1481,26 +1491,26 @@ def learning_summary_lines(status: dict[str, Any] | None) -> list[str]:
1481
1491
  ]
1482
1492
 
1483
1493
 
1484
- def _learning_info(ui: TextualUI | None, title: str, lines: list[str]) -> None:
1494
+ def _corpus_info(ui: TextualUI | None, title: str, lines: list[str]) -> None:
1485
1495
  """Info screen in both UIs: options carry the content; only exit is back."""
1486
- options = [MenuOption("back", "Back", "Return to Session Learning.")]
1496
+ options = [MenuOption("back", "Back", "Return to Session Distill.")]
1487
1497
  try:
1488
- choose(title, options, "back", ui, allow_back=True, learning_lines=lines)
1498
+ choose(title, options, "back", ui, allow_back=True, corpus_lines=lines)
1489
1499
  except BackRequested:
1490
1500
  pass
1491
1501
 
1492
1502
 
1493
- def _learning_rollback(status: dict[str, Any], version: str, ui: TextualUI | None) -> None:
1503
+ def _corpus_rollback(status: dict[str, Any], version: str, ui: TextualUI | None) -> None:
1494
1504
  confirm = choose(
1495
- f"Roll back learning corpus to {version}?",
1505
+ f"Roll back corpus to {version}?",
1496
1506
  [
1497
1507
  MenuOption(
1498
- "cancel", "Cancel", "Keep the currently deployed learning content."
1508
+ "cancel", "Cancel", "Keep the currently deployed corpus content."
1499
1509
  ),
1500
1510
  MenuOption(
1501
1511
  "rollback",
1502
1512
  f"Roll back to {version}",
1503
- "Re-deploys globals/guides/hooks as of that learning version. "
1513
+ "Re-deploys globals/guides/hooks as of that corpus version. "
1504
1514
  "System deployment (launcher, wrappers) stays current. Roll "
1505
1515
  "forward again by selecting the latest version.",
1506
1516
  ),
@@ -1511,7 +1521,7 @@ def _learning_rollback(status: dict[str, Any], version: str, ui: TextualUI | Non
1511
1521
  )
1512
1522
  if confirm != "rollback":
1513
1523
  return
1514
- script = pathlib.Path(status["repo"]) / "scripts/session-learning/learning-state.py"
1524
+ script = pathlib.Path(status["repo"]) / "scripts/session-distill/corpus-state.py"
1515
1525
  result = subprocess.run(
1516
1526
  [sys.executable, str(script), "rollback", "--version", version],
1517
1527
  capture_output=True,
@@ -1520,14 +1530,14 @@ def _learning_rollback(status: dict[str, Any], version: str, ui: TextualUI | Non
1520
1530
  output = (result.stdout + result.stderr).strip().splitlines()
1521
1531
  tail = output[-1] if output else ""
1522
1532
  verdict = "Rollback complete." if result.returncode == 0 else "Rollback FAILED."
1523
- _learning_info(ui, verdict, [tail] if tail else [])
1533
+ _corpus_info(ui, verdict, [tail] if tail else [])
1524
1534
 
1525
1535
 
1526
- def _learning_versions(ui: TextualUI | None) -> None:
1536
+ def _corpus_versions(ui: TextualUI | None) -> None:
1527
1537
  while True:
1528
- status = load_learning_status()
1538
+ status = load_corpus_status()
1529
1539
  if status is None:
1530
- _learning_info(ui, "Versions & rollback", learning_summary_lines(None))
1540
+ _corpus_info(ui, "Versions & rollback", corpus_summary_lines(None))
1531
1541
  return
1532
1542
  current = status.get("current_version")
1533
1543
  options = []
@@ -1542,7 +1552,7 @@ def _learning_versions(ui: TextualUI | None) -> None:
1542
1552
  f"{v.get('summary', '')}",
1543
1553
  )
1544
1554
  )
1545
- options.append(MenuOption("back", "Back", "Return to Session Learning."))
1555
+ options.append(MenuOption("back", "Back", "Return to Session Distill."))
1546
1556
  try:
1547
1557
  selected = choose(
1548
1558
  "Versions & rollback",
@@ -1550,91 +1560,91 @@ def _learning_versions(ui: TextualUI | None) -> None:
1550
1560
  options[0].value,
1551
1561
  ui,
1552
1562
  allow_back=True,
1553
- learning_lines=learning_summary_lines(status),
1563
+ corpus_lines=corpus_summary_lines(status),
1554
1564
  )
1555
1565
  except BackRequested:
1556
1566
  return
1557
1567
  if selected == "back":
1558
1568
  return
1559
1569
  if selected == current:
1560
- _learning_info(
1570
+ _corpus_info(
1561
1571
  ui,
1562
1572
  f"{selected} is the currently applied version",
1563
1573
  ["Select a different version to roll back or forward."],
1564
1574
  )
1565
1575
  continue
1566
- _learning_rollback(status, selected, ui)
1576
+ _corpus_rollback(status, selected, ui)
1567
1577
 
1568
1578
 
1569
- def _learning_packages(ui: TextualUI | None) -> None:
1579
+ def _corpus_packages(ui: TextualUI | None) -> None:
1570
1580
  """v1: the corpus ships as a single core package; the list shape is ready
1571
1581
  for the domain-packaging backlog to populate with real packages."""
1572
- status = load_learning_status()
1582
+ status = load_corpus_status()
1573
1583
  if status is None:
1574
- _learning_info(ui, "Learning packages", learning_summary_lines(None))
1584
+ _corpus_info(ui, "Corpus packages", corpus_summary_lines(None))
1575
1585
  return
1576
1586
  layers = status.get("summary", {}).get("placed_by_layer", {})
1577
1587
  options = [
1578
1588
  MenuOption(
1579
1589
  "core",
1580
1590
  f"core corpus @ {status.get('current_version', '?')}",
1581
- "All learning domains in one package until domain packaging "
1591
+ "All corpus domains in one package until domain packaging "
1582
1592
  f"lands (backlog). Layers: {json.dumps(layers, separators=(', ', ' '))}. "
1583
1593
  "Per-domain selection, versions, and rollback will appear here.",
1584
1594
  ),
1585
- MenuOption("back", "Back", "Return to Session Learning."),
1595
+ MenuOption("back", "Back", "Return to Session Distill."),
1586
1596
  ]
1587
1597
  try:
1588
1598
  choose(
1589
- "Learning packages",
1599
+ "Corpus packages",
1590
1600
  options,
1591
1601
  "back",
1592
1602
  ui,
1593
1603
  allow_back=True,
1594
- learning_lines=learning_summary_lines(status),
1604
+ corpus_lines=corpus_summary_lines(status),
1595
1605
  )
1596
1606
  except BackRequested:
1597
1607
  pass
1598
1608
 
1599
1609
 
1600
- def learning_hub(config: dict[str, Any], ui: TextualUI | None) -> str:
1601
- """Session Learning area: status, packages, versions/rollback, session start.
1610
+ def distill_hub(config: dict[str, Any], ui: TextualUI | None) -> str:
1611
+ """Session Distill area: status, packages, versions/rollback, session start.
1602
1612
 
1603
- Returns "start" to launch the learning-session preset, "back" otherwise.
1613
+ Returns "start" to launch the session-distill preset, "back" otherwise.
1604
1614
  """
1605
1615
  while True:
1606
- status = load_learning_status()
1616
+ status = load_corpus_status()
1607
1617
  options = [
1608
1618
  MenuOption(
1609
1619
  "start",
1610
- "Start a learning session",
1611
- "Launch with the Session learning preset: the mission line "
1620
+ "Start a session distill run",
1621
+ "Launch with the Session distill preset: the mission line "
1612
1622
  "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",
1623
+ enabled=DISTILL_PRESET in config["presets"],
1624
+ unavailable_reason=f"preset {DISTILL_PRESET!r} not configured",
1615
1625
  ),
1616
1626
  MenuOption(
1617
1627
  "packages",
1618
- "Learning packages",
1619
- "What learning content is applied, as installable packages "
1628
+ "Corpus packages",
1629
+ "What corpus content is applied, as installable packages "
1620
1630
  "(v1: single core corpus).",
1621
1631
  ),
1622
1632
  MenuOption(
1623
1633
  "versions",
1624
1634
  "Versions & rollback",
1625
- "Learning content versions (distinct from system deployment); "
1635
+ "Corpus content versions (distinct from system deployment); "
1626
1636
  "roll the corpus back or forward.",
1627
1637
  ),
1628
1638
  MenuOption("back", "Back", "Return to the launch menu."),
1629
1639
  ]
1630
1640
  try:
1631
1641
  selected = choose(
1632
- "Session Learning",
1642
+ "Session Distill",
1633
1643
  options,
1634
1644
  "start",
1635
1645
  ui,
1636
1646
  allow_back=True,
1637
- learning_lines=learning_summary_lines(status),
1647
+ corpus_lines=corpus_summary_lines(status),
1638
1648
  )
1639
1649
  except BackRequested:
1640
1650
  return "back"
@@ -1643,34 +1653,106 @@ def learning_hub(config: dict[str, Any], ui: TextualUI | None) -> str:
1643
1653
  if selected == "start":
1644
1654
  return "start"
1645
1655
  if selected == "packages":
1646
- _learning_packages(ui)
1656
+ _corpus_packages(ui)
1647
1657
  elif selected == "versions":
1648
- _learning_versions(ui)
1658
+ _corpus_versions(ui)
1649
1659
 
1650
1660
 
1651
- def select_plan(
1661
+ def preset_mode(data: dict[str, Any]) -> str:
1662
+ """A preset's root-menu group. Missing/unknown falls back to builder — the
1663
+ same default build_plan applies — so listing presets for a menu never
1664
+ raises on a not-yet-migrated user preset."""
1665
+ mode = data.get("mode")
1666
+ return mode if isinstance(mode, str) and mode in PRESET_MODES else DEFAULT_PRESET_MODE
1667
+
1668
+
1669
+ def mode_default_preset(presets: dict[str, Any], mode: str) -> str | None:
1670
+ """First configured preset in a mode; builder prefers 'balanced' so the
1671
+ long-standing everyday default stays the highlighted choice. None means
1672
+ the mode has no presets configured (only reachable via a stripped-down
1673
+ user config; Custom still covers builder in that case)."""
1674
+ names = [name for name, data in presets.items() if preset_mode(data) == mode]
1675
+ if mode == DEFAULT_PRESET_MODE and "balanced" in names:
1676
+ return "balanced"
1677
+ return names[0] if names else None
1678
+
1679
+
1680
+ def pick_mode_and_preset(
1652
1681
  config: dict[str, Any],
1653
1682
  host: str,
1654
- preset_name: str | None,
1655
- custom_requested: bool,
1656
- ui: TextualUI | None = None,
1657
- config_path: pathlib.Path | None = None,
1658
- ) -> dict[str, Any]:
1683
+ ui: TextualUI | None,
1684
+ resume_mode: str | None,
1685
+ ) -> tuple[str, bool, str]:
1686
+ """Root menu: a 3-way mode picker (General user / Builder / Session
1687
+ distill), then that mode's preset submenu (Builder also lists Custom).
1688
+
1689
+ Esc in the submenu returns to the mode picker; Esc at the mode picker
1690
+ cancels the launcher, matching the picker's prior root Esc/q semantics.
1691
+ Returns (preset_name, custom_requested, mode) — the mode is handed back so
1692
+ a later Esc out of the Custom hub resumes this same submenu instead of
1693
+ dropping all the way back to the top mode picker.
1694
+ """
1659
1695
  presets = config["presets"]
1660
- explicit_preset = preset_name
1661
- show_picker = explicit_preset is None
1696
+ mode = resume_mode
1662
1697
  while True:
1663
- selected_name = explicit_preset
1664
- selected_custom = custom_requested
1665
- if show_picker:
1666
- options = [
1698
+ if mode is None:
1699
+ mode_options = [
1667
1700
  MenuOption(
1668
- name,
1669
- data["label"],
1670
- data.get("description", f"Launch the {data['label']} preset."),
1671
- )
1672
- for name, data in presets.items()
1701
+ GENERAL_MODE,
1702
+ "General user",
1703
+ "Plain presets with nothing launch-specific applied, like "
1704
+ "Vanilla: no tier bindings or launch contract.",
1705
+ ),
1706
+ MenuOption(
1707
+ DEFAULT_PRESET_MODE,
1708
+ "Builder",
1709
+ "Tune tiers, review routes, and permissions across fixed "
1710
+ "presets; includes Custom.",
1711
+ ),
1712
+ MenuOption(
1713
+ DISTILL_MODE,
1714
+ "Session distill",
1715
+ "Enter the dedicated session-distill hub: status, "
1716
+ "packages, versions, and session start.",
1717
+ ),
1673
1718
  ]
1719
+
1720
+ def preview_mode(value: str) -> dict[str, Any] | None:
1721
+ target = (
1722
+ DISTILL_PRESET
1723
+ if value == DISTILL_MODE
1724
+ else mode_default_preset(presets, value)
1725
+ )
1726
+ return build_plan(config, host, target) if target else None
1727
+
1728
+ initial = preview_mode(DEFAULT_PRESET_MODE)
1729
+ if ui is not None and initial is not None:
1730
+ ui.set_plan(initial)
1731
+ mode = choose(
1732
+ "Mode",
1733
+ mode_options,
1734
+ DEFAULT_PRESET_MODE,
1735
+ ui,
1736
+ preview=preview_mode,
1737
+ corpus_lines=corpus_summary_lines(load_corpus_status()),
1738
+ )
1739
+ if mode == DISTILL_MODE:
1740
+ if distill_hub(config, ui) != "start":
1741
+ mode = None
1742
+ continue
1743
+ if DISTILL_PRESET not in presets:
1744
+ raise LaunchError(f"preset {DISTILL_PRESET!r} missing from config")
1745
+ return DISTILL_PRESET, False, mode
1746
+ options = [
1747
+ MenuOption(
1748
+ name,
1749
+ data["label"],
1750
+ data.get("description", f"Launch the {data['label']} preset."),
1751
+ )
1752
+ for name, data in presets.items()
1753
+ if preset_mode(data) == mode
1754
+ ]
1755
+ if mode == DEFAULT_PRESET_MODE:
1674
1756
  options.append(
1675
1757
  MenuOption(
1676
1758
  CUSTOM_PRESET,
@@ -1678,46 +1760,45 @@ def select_plan(
1678
1760
  "Open a settings hub for tiers, review setup, policy, and final confirmation.",
1679
1761
  )
1680
1762
  )
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
- )
1690
- default = "balanced" if "balanced" in presets else next(iter(presets))
1691
- if ui is not None:
1692
- ui.set_plan(build_plan(config, host, default))
1763
+ default = mode_default_preset(presets, mode) or CUSTOM_PRESET
1764
+ if ui is not None and default != CUSTOM_PRESET:
1765
+ ui.set_plan(build_plan(config, host, default))
1693
1766
 
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
1767
+ def preview_preset(value: str) -> dict[str, Any]:
1768
+ target = default if value == CUSTOM_PRESET else value
1769
+ return build_plan(config, host, target)
1700
1770
 
1771
+ try:
1701
1772
  selected = choose(
1702
- "Preset",
1703
- options,
1704
- default,
1705
- ui,
1706
- preview=lambda value: build_plan(config, host, preview_name(value)),
1707
- learning_lines=learning_summary_lines(load_learning_status()),
1773
+ "Preset", options, default, ui, allow_back=True, preview=preview_preset
1774
+ )
1775
+ except BackRequested:
1776
+ mode = None
1777
+ continue
1778
+ if selected == CUSTOM_PRESET:
1779
+ return default, True, mode
1780
+ return selected, False, mode
1781
+
1782
+
1783
+ def select_plan(
1784
+ config: dict[str, Any],
1785
+ host: str,
1786
+ preset_name: str | None,
1787
+ custom_requested: bool,
1788
+ ui: TextualUI | None = None,
1789
+ config_path: pathlib.Path | None = None,
1790
+ ) -> dict[str, Any]:
1791
+ presets = config["presets"]
1792
+ explicit_preset = preset_name
1793
+ show_picker = explicit_preset is None
1794
+ resume_mode: str | None = None
1795
+ while True:
1796
+ selected_name = explicit_preset
1797
+ selected_custom = custom_requested
1798
+ if show_picker:
1799
+ selected_name, selected_custom, resume_mode = pick_mode_and_preset(
1800
+ config, host, ui, resume_mode
1708
1801
  )
1709
- if selected == CUSTOM_PRESET:
1710
- selected_name = default
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
1719
- else:
1720
- selected_name = selected
1721
1802
  if selected_name not in presets:
1722
1803
  raise LaunchError(f"unknown preset: {selected_name}")
1723
1804
  plan = build_plan(config, host, selected_name)
@@ -1949,6 +2030,12 @@ def claude_agents(plan: dict[str, Any]) -> str:
1949
2030
 
1950
2031
  def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[str]:
1951
2032
  host = plan["host"]
2033
+ if plan.get("mode") == GENERAL_MODE:
2034
+ # General mode is defined as the plain backend with nothing applied: no
2035
+ # launch contract, no tier pinning, no agents, no permission/sandbox
2036
+ # flag. Short-circuit here so that invariant holds structurally instead
2037
+ # of depending on every general-mode preset being configured just so.
2038
+ return []
1952
2039
  main = plan["tiers"][plan["main_tier"]]
1953
2040
  main_effort = tier_effort(plan, plan["main_tier"])
1954
2041
  contract = run_contract(plan)
@@ -1961,11 +2048,12 @@ def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[
1961
2048
  "-c", f"features.multi_agent={'true' if plan['delegation'] else 'false'}",
1962
2049
  ]
1963
2050
  policy = plan["codex_execution_policy"]
1964
- policy_args = (
1965
- ["--dangerously-bypass-approvals-and-sandbox"]
1966
- if policy == "bypass"
1967
- else ["--sandbox", policy]
1968
- )
2051
+ if policy == "bypass":
2052
+ policy_args = ["--dangerously-bypass-approvals-and-sandbox"]
2053
+ elif policy == STANDARD_POLICY:
2054
+ policy_args = []
2055
+ else:
2056
+ policy_args = ["--sandbox", policy]
1969
2057
  args += policy_args
1970
2058
  if plan["delegation"]:
1971
2059
  for tier, (path, description) in codex_agent_configs(plan, materialize_agents).items():
@@ -1989,11 +2077,12 @@ def project_args(plan: dict[str, Any], materialize_agents: bool = True) -> list[
1989
2077
  if plan["delegation"]:
1990
2078
  args += ["--agents", claude_agents(plan)]
1991
2079
  policy = plan["claude_permission_mode"]
1992
- policy_args = (
1993
- ["--dangerously-skip-permissions"]
1994
- if policy == "bypassPermissions"
1995
- else ["--permission-mode", policy]
1996
- )
2080
+ if policy == "bypassPermissions":
2081
+ policy_args = ["--dangerously-skip-permissions"]
2082
+ elif policy == STANDARD_POLICY:
2083
+ policy_args = []
2084
+ else:
2085
+ policy_args = ["--permission-mode", policy]
1997
2086
  args += policy_args
1998
2087
  if "onto" in effective_routes:
1999
2088
  onto_command = resolve_command(plan["capabilities"]["onto"]["command"])
@@ -2072,10 +2161,10 @@ def print_summary(
2072
2161
  print(" Argv " + json.dumps([command, *args]), file=stream)
2073
2162
 
2074
2163
 
2075
- SESSION_LEARNING_STATE = pathlib.Path(
2164
+ SESSION_DISTILL_STATE = pathlib.Path(
2076
2165
  os.environ.get(
2077
- "AGENT_BIOS_SESSION_LEARNING_STATE",
2078
- str(pathlib.Path.home() / ".local/share/agent-bios/session-learning-state.json"),
2166
+ "AGENT_BIOS_SESSION_DISTILL_STATE",
2167
+ str(pathlib.Path.home() / ".local/share/agent-bios/session-distill-state.json"),
2079
2168
  )
2080
2169
  )
2081
2170
 
@@ -2088,19 +2177,19 @@ def _line_count(path: pathlib.Path) -> int:
2088
2177
  return 0
2089
2178
 
2090
2179
 
2091
- def session_learning_nudge(config: dict[str, Any]) -> str | None:
2180
+ def session_distill_nudge(config: dict[str, Any]) -> str | None:
2092
2181
  """Nudge when enough sessions accumulated since the last mining window.
2093
2182
 
2094
- The baseline is written by scripts/session-learning/update-state.py at
2183
+ The baseline is written by scripts/session-distill/update-state.py at
2095
2184
  window close; provider history line counts are a cheap proxy for new
2096
2185
  sessions. No state file means no nudge.
2097
2186
  """
2098
2187
  try:
2099
- state = json.loads(SESSION_LEARNING_STATE.read_text())
2188
+ state = json.loads(SESSION_DISTILL_STATE.read_text())
2100
2189
  baseline = int(state["history_lines_total"])
2101
2190
  except (OSError, ValueError, KeyError, TypeError):
2102
2191
  return None
2103
- settings = config.get("session_learning", {})
2192
+ settings = config.get("session_distill", {})
2104
2193
  threshold = settings.get("nudge_after", 250) if isinstance(settings, dict) else 250
2105
2194
  current = _line_count(pathlib.Path.home() / ".claude/history.jsonl") + _line_count(
2106
2195
  pathlib.Path.home() / ".codex/history.jsonl"
@@ -2109,9 +2198,9 @@ def session_learning_nudge(config: dict[str, Any]) -> str | None:
2109
2198
  if delta < threshold:
2110
2199
  return None
2111
2200
  return (
2112
- f"session-learning due: ~{delta} new session entries since "
2201
+ f"session-distill due: ~{delta} new session entries since "
2113
2202
  f"{state.get('window_end', '?')} (threshold {threshold}) — launch the "
2114
- "Session learning preset to run the next mining window"
2203
+ "Session distill preset to run the next mining window"
2115
2204
  )
2116
2205
 
2117
2206
 
@@ -2144,10 +2233,10 @@ def main(argv: list[str]) -> int:
2144
2233
  config_path = args.config.expanduser()
2145
2234
  config = load_config(config_path)
2146
2235
  command, passthrough = resolve_backend(config, args.host)
2147
- nudge = session_learning_nudge(config)
2236
+ nudge = session_distill_nudge(config)
2148
2237
  if nudge:
2149
2238
  print(f"agent-launch: {nudge}", file=sys.stderr)
2150
- nudged = config["presets"].get("session-learning")
2239
+ nudged = config["presets"].get("session-distill")
2151
2240
  if isinstance(nudged, dict):
2152
2241
  nudged["description"] = f"{nudged.get('description', '')} ⚠ {nudge}".strip()
2153
2242
  tty = sys.stdin.isatty() and sys.stdout.isatty()
@@ -2189,9 +2278,9 @@ def main(argv: list[str]) -> int:
2189
2278
  print_summary(plan, command, projected, summary_stream, bool(args.forward))
2190
2279
  trigger = plan.get("trigger")
2191
2280
  if trigger:
2192
- # Black-on-yellow to match the Session Learning identity; the session
2281
+ # Black-on-yellow to match the Session Distill identity; the session
2193
2282
  # waits for this exact phrase before starting the workflow.
2194
- line = f' Once the session is up, type "{trigger}" to begin the learning workflow. '
2283
+ line = f' Once the session is up, type "{trigger}" to begin the session-distill workflow. '
2195
2284
  print(f"\n\x1b[1;30;43m{line}\x1b[0m", file=summary_stream)
2196
2285
  if args.dry_run:
2197
2286
  print(json.dumps([command, *projected], ensure_ascii=False))