agent-bios 0.2.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.
@@ -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"},
@@ -539,6 +543,7 @@ _UI_CANCEL = "\x00cancel"
539
543
  def _build_app_class():
540
544
  """Import textual lazily and build the App/Screen classes, so importing this
541
545
  module and every non-interactive path stays free of the textual dependency."""
546
+ from rich.text import Text
542
547
  from textual import work
543
548
  from textual.app import App
544
549
  from textual.binding import Binding
@@ -569,7 +574,7 @@ def _build_app_class():
569
574
 
570
575
  app_css = """
571
576
  Screen { background: $surface; }
572
- #al-title { color: $accent; text-style: bold; padding: 0 1; }
577
+ #al-title { background: $primary; color: black; text-style: bold; padding: 0 1; }
573
578
  #al-setup {
574
579
  border: round $primary; border-title-color: $primary;
575
580
  border-title-style: bold; padding: 0 1; height: auto;
@@ -578,6 +583,10 @@ def _build_app_class():
578
583
  border: round $secondary; border-title-color: $secondary;
579
584
  border-title-style: bold; padding: 0 1; height: 5;
580
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
+ }
581
590
  #al-hdr { color: $text-muted; text-style: bold; padding: 0 1; }
582
591
  OptionList { height: 1fr; border: none; padding: 0 1; }
583
592
  #al-footer { color: $text-muted; dock: bottom; padding: 0 1; background: $panel; }
@@ -596,7 +605,9 @@ def _build_app_class():
596
605
  Binding("ctrl+c", "cancel", "cancel", priority=True),
597
606
  ]
598
607
 
599
- 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
+ ):
600
611
  super().__init__()
601
612
  self._title = title
602
613
  self._options = options
@@ -604,10 +615,14 @@ def _build_app_class():
604
615
  self._allow_back = allow_back
605
616
  self._plan = plan
606
617
  self._preview = preview
618
+ self._learning = learning
607
619
 
608
620
  def compose(self):
609
621
  yield Static(self._title, id="al-title")
610
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")
611
626
  detail = Static("", id="al-detail")
612
627
  detail.border_title = "About highlighted option"
613
628
  yield detail
@@ -617,6 +632,19 @@ def _build_app_class():
617
632
  option_list = OptionList()
618
633
  for option in self._options:
619
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
620
648
  option_list.add_option(
621
649
  Option(label, id=option.value, disabled=not option.enabled)
622
650
  )
@@ -775,10 +803,13 @@ class TextualUI:
775
803
  default: str,
776
804
  allow_back: bool,
777
805
  preview=None,
806
+ learning_lines: list[str] | None = None,
778
807
  ) -> str:
779
808
  result = self.app.call_from_thread(
780
809
  self.app.push_screen_wait,
781
- 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
+ ),
782
813
  )
783
814
  if result == _UI_BACK:
784
815
  raise BackRequested
@@ -814,13 +845,26 @@ def run_textual_flow(
814
845
 
815
846
 
816
847
  def choose_lines(
817
- 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,
818
853
  ) -> str:
819
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(" --")
820
860
  for index, option in enumerate(options, 1):
821
861
  marker = "" if option.enabled else " [unavailable]"
822
862
  selected = " *" if option.value == default and option.enabled else ""
823
- 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}")
824
868
  if not option.enabled:
825
869
  print(f" Unavailable: {option.unavailable_reason}")
826
870
  while True:
@@ -854,12 +898,13 @@ def choose(
854
898
  ui: TextualUI | None = None,
855
899
  allow_back: bool = False,
856
900
  preview=None,
901
+ learning_lines: list[str] | None = None,
857
902
  ) -> str:
858
903
  if not any(option.enabled for option in options):
859
904
  raise LaunchError(f"no available options for {title}")
860
905
  if ui is not None:
861
- return ui.choose(title, options, default, allow_back, preview)
862
- 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)
863
908
 
864
909
 
865
910
  def read_input(prompt: str) -> str:
@@ -950,6 +995,12 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
950
995
  label = preset.get("label", preset_name)
951
996
  if not isinstance(label, str) or not label:
952
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")
953
1004
  review_family = preset.get("review_family", "cross")
954
1005
  review_host = REVIEW_HOST[host]
955
1006
  opposite = config.get("hosts", {}).get(review_host)
@@ -988,6 +1039,8 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
988
1039
  "review_backend": review_backend,
989
1040
  "agent_templates": copy.deepcopy(config["hosts"][host].get("agent_templates")),
990
1041
  "capabilities": copy.deepcopy(config.get("capabilities", {})),
1042
+ "mission": mission,
1043
+ "trigger": trigger,
991
1044
  }
992
1045
 
993
1046
 
@@ -1386,6 +1439,215 @@ def customize(
1386
1439
  raise KeyboardInterrupt
1387
1440
 
1388
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
+
1389
1651
  def select_plan(
1390
1652
  config: dict[str, Any],
1391
1653
  host: str,
@@ -1416,21 +1678,44 @@ def select_plan(
1416
1678
  "Open a settings hub for tiers, review setup, policy, and final confirmation.",
1417
1679
  )
1418
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
+ )
1419
1690
  default = "balanced" if "balanced" in presets else next(iter(presets))
1420
1691
  if ui is not None:
1421
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
+
1422
1701
  selected = choose(
1423
1702
  "Preset",
1424
1703
  options,
1425
1704
  default,
1426
1705
  ui,
1427
- preview=lambda value: build_plan(
1428
- config, host, default if value == CUSTOM_PRESET else value
1429
- ),
1706
+ preview=lambda value: build_plan(config, host, preview_name(value)),
1707
+ learning_lines=learning_summary_lines(load_learning_status()),
1430
1708
  )
1431
1709
  if selected == CUSTOM_PRESET:
1432
1710
  selected_name = default
1433
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
1434
1719
  else:
1435
1720
  selected_name = selected
1436
1721
  if selected_name not in presets:
@@ -1468,7 +1753,7 @@ def codex_agent_configs(
1468
1753
  if not isinstance(templates, dict):
1469
1754
  raise LaunchError("Codex delegation requires [hosts.codex.agent_templates]")
1470
1755
  rendered: dict[str, tuple[str, str]] = {}
1471
- for tier in ("frontier", "workhorse", "sweep"):
1756
+ for tier in SPAWNABLE_TIERS:
1472
1757
  source_value = templates.get(tier)
1473
1758
  if not isinstance(source_value, str) or not source_value:
1474
1759
  raise LaunchError(f"Codex agent template missing: {tier}")
@@ -1630,7 +1915,12 @@ def run_contract(plan: dict[str, Any]) -> str:
1630
1915
  if plan["host"] == "codex"
1631
1916
  else "Main and child model/effort defaults are CLI-projected."
1632
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 ""
1633
1922
  return (
1923
+ f"{mission_prefix}"
1634
1924
  f"LaunchPlan: main={plan['main_tier']} ({plan['tiers'][plan['main_tier']]['model']}/"
1635
1925
  f"{tier_effort(plan, plan['main_tier'])}); tiers: {bindings}. "
1636
1926
  f"Delegation={'on' if plan['delegation'] else 'off'}. Review family={family}. "
@@ -1643,7 +1933,7 @@ def run_contract(plan: dict[str, Any]) -> str:
1643
1933
 
1644
1934
  def claude_agents(plan: dict[str, Any]) -> str:
1645
1935
  roles = {}
1646
- for tier in TIER_ORDER:
1936
+ for tier in SPAWNABLE_TIERS:
1647
1937
  binding = plan["tiers"][tier]
1648
1938
  roles[tier] = {
1649
1939
  "description": f"{tier.upper()} tier: {binding['model']} at {tier_effort(plan, tier)}",
@@ -1782,6 +2072,49 @@ def print_summary(
1782
2072
  print(" Argv " + json.dumps([command, *args]), file=stream)
1783
2073
 
1784
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
+
1785
2118
  def parse_args(argv: list[str]) -> argparse.Namespace:
1786
2119
  parser = argparse.ArgumentParser(description=__doc__)
1787
2120
  parser.add_argument("--config", type=pathlib.Path, default=default_config_path())
@@ -1811,6 +2144,12 @@ def main(argv: list[str]) -> int:
1811
2144
  config_path = args.config.expanduser()
1812
2145
  config = load_config(config_path)
1813
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()
1814
2153
  tty = sys.stdin.isatty() and sys.stdout.isatty()
1815
2154
  if not tty and args.dry_run and not args.preset and not args.custom:
1816
2155
  if "balanced" not in config["presets"]:
@@ -1848,6 +2187,12 @@ def main(argv: list[str]) -> int:
1848
2187
  projected = [*project_args(plan, materialize_agents=not args.dry_run), *args.forward]
1849
2188
  summary_stream = sys.stdout if tty or args.dry_run else sys.stderr
1850
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)
1851
2196
  if args.dry_run:
1852
2197
  print(json.dumps([command, *projected], ensure_ascii=False))
1853
2198
  return 0
@@ -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}")
@@ -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=()