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.
- package/DEPENDENCIES.md +5 -4
- package/README.md +2 -2
- package/claude/CLAUDE.md +14 -5
- package/claude/agents/frontier.md +9 -0
- package/claude/agents/sweep.md +9 -0
- package/claude/agents/workhorse.md +8 -0
- package/claude/guides/claude-prompting.md +123 -0
- package/claude/guides/cli-multi-model-workflow.md +11 -4
- package/claude/guides/coding-staged-workflow.md +8 -0
- package/claude/guides/gpt-prompting.md +118 -0
- package/claude/guides/llm-capability-boundary-patterns.md +5 -0
- package/claude/guides/mock-realization-boundary.md +9 -0
- package/claude/guides/review-request.md +274 -0
- package/claude/guides/session-learning-workflow.md +100 -0
- package/claude/guides/tooling-gotchas.md +155 -0
- package/claude/hooks/tooling-gotchas-hook.py +79 -0
- package/codex/AGENTS.md +14 -5
- package/codex/config-additions.toml +22 -0
- package/codex/guides/claude-prompting.md +123 -0
- package/codex/guides/cli-multi-model-workflow.md +11 -4
- package/codex/guides/coding-staged-workflow.md +8 -0
- package/codex/guides/gpt-prompting.md +118 -0
- package/codex/guides/llm-capability-boundary-patterns.md +5 -0
- package/codex/guides/mock-realization-boundary.md +9 -0
- package/codex/guides/review-request.md +274 -0
- package/codex/guides/session-learning-workflow.md +100 -0
- package/codex/guides/tooling-gotchas.md +155 -0
- package/config/agent-launch.toml +23 -0
- package/package.json +6 -2
- package/scripts/agent-launch.py +504 -29
- package/scripts/check-parity.sh +73 -6
- package/scripts/check-prompting-targets.sh +62 -0
- package/scripts/codex-helm.sh +19 -1
- package/scripts/codex-run.sh +19 -0
- package/scripts/install.sh +298 -6
package/scripts/agent-launch.py
CHANGED
|
@@ -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
|
|
329
|
-
PROPOSED
|
|
330
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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__(
|
|
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(
|
|
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,
|
|
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
|
-
|
|
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
|
-
|
|
886
|
-
|
|
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
|
-
|
|
1005
|
-
|
|
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,
|
|
1179
|
+
os.replace(temporary, target)
|
|
1009
1180
|
except OSError as exc:
|
|
1010
|
-
raise LaunchError(f"cannot save preset to {
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
1620
|
-
review_display += " → same-family
|
|
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
|