agent-bios 0.1.0 → 0.2.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 +2 -0
- package/claude/guides/claude-prompting.md +123 -0
- package/claude/guides/gpt-prompting.md +118 -0
- package/claude/guides/review-request.md +266 -0
- package/codex/AGENTS.md +2 -0
- package/codex/guides/claude-prompting.md +123 -0
- package/codex/guides/gpt-prompting.md +118 -0
- package/codex/guides/review-request.md +266 -0
- package/config/agent-launch.toml +6 -0
- package/package.json +3 -2
- package/scripts/agent-launch.py +147 -17
- package/scripts/check-parity.sh +8 -3
- package/scripts/check-prompting-targets.sh +62 -0
- package/scripts/install.sh +136 -4
package/scripts/agent-launch.py
CHANGED
|
@@ -40,6 +40,15 @@ EFFORT_DESCRIPTIONS = {
|
|
|
40
40
|
}
|
|
41
41
|
CUSTOM_PRESET = "__custom__"
|
|
42
42
|
OTHER_MODEL = "__other_model__"
|
|
43
|
+
# User-saved presets live beside the deployed config, in a file the installer
|
|
44
|
+
# neither deploys nor verifies, so they survive `agent-bios install`.
|
|
45
|
+
USER_PRESETS_NAME = "presets.local.toml"
|
|
46
|
+
USER_PRESETS_HEADER = (
|
|
47
|
+
"# agent-launch user presets, written by the launcher's save action.\n"
|
|
48
|
+
"# agent-bios install never deploys or verifies this file, so presets here\n"
|
|
49
|
+
"# survive upgrades. Shipped presets live in the deployed profiles.toml and\n"
|
|
50
|
+
"# are overridden by a preset of the same name here.\n"
|
|
51
|
+
)
|
|
43
52
|
|
|
44
53
|
|
|
45
54
|
@dataclass(frozen=True)
|
|
@@ -55,21 +64,55 @@ REVIEW_SETUPS = {
|
|
|
55
64
|
"none": {
|
|
56
65
|
"label": "None",
|
|
57
66
|
"requirements": {"codex": (), "claude": ()},
|
|
67
|
+
"description": "No review route. Fastest; you review it yourself.",
|
|
58
68
|
"contract": "No additional review route requested.",
|
|
59
69
|
},
|
|
60
70
|
"native-panel": {
|
|
61
71
|
"label": "Native panel",
|
|
62
72
|
"requirements": {"codex": (), "claude": ()},
|
|
73
|
+
"description": (
|
|
74
|
+
"Subagents review from different angles. Broad coverage, no dependency; "
|
|
75
|
+
"same-model reviewers share the main's blind spots."
|
|
76
|
+
),
|
|
63
77
|
"contract": "Use native multi-perspective subagents when review gates fire.",
|
|
64
78
|
},
|
|
79
|
+
"slash-review": {
|
|
80
|
+
"label": "Slash review",
|
|
81
|
+
"requirements": {"codex": (), "claude": ()},
|
|
82
|
+
"description": {
|
|
83
|
+
"claude": (
|
|
84
|
+
"The built-in /code-review command (add `ultra` for the deep cloud pass). "
|
|
85
|
+
"Cheapest good diff review; same-family, so it is a weaker check than cross."
|
|
86
|
+
),
|
|
87
|
+
"codex": (
|
|
88
|
+
"The built-in /review command. Cheapest good diff review; same-family, "
|
|
89
|
+
"so it is a weaker check than cross."
|
|
90
|
+
),
|
|
91
|
+
},
|
|
92
|
+
"contract": {
|
|
93
|
+
"claude": (
|
|
94
|
+
"Use Claude Code's built-in /code-review when review gates fire; use "
|
|
95
|
+
"/code-review ultra for a deep multi-agent pass on high-risk changes."
|
|
96
|
+
),
|
|
97
|
+
"codex": "Use Codex's built-in /review when review gates fire.",
|
|
98
|
+
},
|
|
99
|
+
},
|
|
65
100
|
"onto": {
|
|
66
101
|
"label": "onto-mcp",
|
|
67
102
|
"requirements": {"codex": ("onto",), "claude": ("onto",)},
|
|
103
|
+
"description": (
|
|
104
|
+
"Structured multi-lens review through onto-mcp. Best for concept/ontology "
|
|
105
|
+
"and design work; needs onto installed."
|
|
106
|
+
),
|
|
68
107
|
"contract": "Use onto-mcp as the structured review lens when review gates fire.",
|
|
69
108
|
},
|
|
70
109
|
"ultracode": {
|
|
71
110
|
"label": "Ultracode",
|
|
72
111
|
"requirements": {"codex": ("ultracode",), "claude": ("ultracode",)},
|
|
112
|
+
"description": (
|
|
113
|
+
"Workflow-orchestrated review fanned out over many agents. Best for "
|
|
114
|
+
"exhaustive audits; slowest and most expensive."
|
|
115
|
+
),
|
|
73
116
|
"contract": (
|
|
74
117
|
"Run the configured Ultracode CLI as a Codex-backed review route when "
|
|
75
118
|
"review gates fire."
|
|
@@ -81,6 +124,10 @@ REVIEW_SETUPS = {
|
|
|
81
124
|
"codex": ("onto", "ultracode"),
|
|
82
125
|
"claude": ("onto", "ultracode"),
|
|
83
126
|
},
|
|
127
|
+
"description": (
|
|
128
|
+
"onto + native + Ultracode together, acting on their union. Widest net for "
|
|
129
|
+
"risky work; missing routes degrade rather than fail."
|
|
130
|
+
),
|
|
84
131
|
"contract": {
|
|
85
132
|
"codex": "Use onto plus native and Codex-backed Ultracode review kinds; act on their union.",
|
|
86
133
|
"claude": "Use onto plus native multi-perspective and Codex-backed Ultracode review kinds; act on their union.",
|
|
@@ -96,12 +143,18 @@ REVIEW_SETUPS = {
|
|
|
96
143
|
REVIEW_ROUTES = {
|
|
97
144
|
"none": (),
|
|
98
145
|
"native-panel": ("native",),
|
|
146
|
+
"slash-review": ("slash",),
|
|
99
147
|
"onto": ("onto",),
|
|
100
148
|
"ultracode": ("ultracode",),
|
|
101
149
|
"hybrid": ("onto", "native", "ultracode"),
|
|
102
150
|
}
|
|
151
|
+
# Routes that are host-native review commands: they always resolve (no capability
|
|
152
|
+
# to install) but run on the main's own family, so cross-family mode cannot dispatch
|
|
153
|
+
# them to the opposite family and labels them PROPOSED instead.
|
|
154
|
+
SAME_FAMILY_ROUTES = {"slash"}
|
|
103
155
|
ROUTE_LABELS = {
|
|
104
156
|
"native": "native same-model multi-perspective review",
|
|
157
|
+
"slash": "host-native slash-command review",
|
|
105
158
|
"onto": "onto structured-lens review",
|
|
106
159
|
"ultracode": "Codex-backed Ultracode review",
|
|
107
160
|
}
|
|
@@ -153,6 +206,28 @@ def default_config_path() -> pathlib.Path:
|
|
|
153
206
|
return pathlib.Path(__file__).resolve().parent.parent / "config/agent-launch.toml"
|
|
154
207
|
|
|
155
208
|
|
|
209
|
+
def user_presets_path(config_path: pathlib.Path) -> pathlib.Path:
|
|
210
|
+
return config_path.with_name(USER_PRESETS_NAME)
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def load_user_presets(path: pathlib.Path) -> dict[str, Any]:
|
|
214
|
+
if not path.is_file():
|
|
215
|
+
return {}
|
|
216
|
+
try:
|
|
217
|
+
data = tomllib.loads(path.read_text())
|
|
218
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
219
|
+
raise LaunchError(f"cannot load user presets {path}: {exc}") from exc
|
|
220
|
+
unexpected = sorted(set(data) - {"presets"})
|
|
221
|
+
if unexpected:
|
|
222
|
+
raise LaunchError(
|
|
223
|
+
f"{path} may only define [presets.*]; found: {', '.join(unexpected)}"
|
|
224
|
+
)
|
|
225
|
+
presets = data.get("presets", {})
|
|
226
|
+
if not isinstance(presets, dict):
|
|
227
|
+
raise LaunchError(f"[presets] must be a table in {path}")
|
|
228
|
+
return presets
|
|
229
|
+
|
|
230
|
+
|
|
156
231
|
def load_config(path: pathlib.Path) -> dict[str, Any]:
|
|
157
232
|
try:
|
|
158
233
|
data = tomllib.loads(path.read_text())
|
|
@@ -163,12 +238,17 @@ def load_config(path: pathlib.Path) -> dict[str, Any]:
|
|
|
163
238
|
for key in ("backends", "hosts", "presets"):
|
|
164
239
|
if not isinstance(data.get(key), dict) or not data[key]:
|
|
165
240
|
raise LaunchError(f"config requires non-empty [{key}]")
|
|
241
|
+
# Merged before the preset checks below so user presets face the same validation.
|
|
242
|
+
data["presets"].update(load_user_presets(user_presets_path(path)))
|
|
166
243
|
capabilities = data.get("capabilities", {})
|
|
167
244
|
if not isinstance(capabilities, dict):
|
|
168
245
|
raise LaunchError("[capabilities] must be a table")
|
|
169
246
|
for name, capability in capabilities.items():
|
|
170
247
|
if not isinstance(capability, dict):
|
|
171
248
|
raise LaunchError(f"capability must be a table: {name}")
|
|
249
|
+
hint = capability.get("install")
|
|
250
|
+
if hint is not None and (not isinstance(hint, str) or not hint):
|
|
251
|
+
raise LaunchError(f"capabilities.{name}.install must be a non-empty string")
|
|
172
252
|
onto = capabilities.get("onto")
|
|
173
253
|
if onto is not None and (
|
|
174
254
|
not isinstance(onto.get("command"), str) or not onto["command"]
|
|
@@ -304,7 +384,7 @@ def route_availability(plan: dict[str, Any]) -> dict[str, bool]:
|
|
|
304
384
|
a mounted onto plus a review_onto pin to flip its family."""
|
|
305
385
|
capabilities = plan.get("capabilities", {})
|
|
306
386
|
if plan.get("review_family", "cross") == "same":
|
|
307
|
-
available = {"native": bool(plan["delegation"])}
|
|
387
|
+
available = {"native": bool(plan["delegation"]), "slash": True}
|
|
308
388
|
for route, capability in (("onto", "onto"), ("ultracode", "ultracode")):
|
|
309
389
|
available[route] = _resolves(capabilities.get(capability, {}).get("command", ""))
|
|
310
390
|
return available
|
|
@@ -313,11 +393,26 @@ def route_availability(plan: dict[str, Any]) -> dict[str, bool]:
|
|
|
313
393
|
)
|
|
314
394
|
return {
|
|
315
395
|
"native": cross_native_command(plan) is not None,
|
|
396
|
+
# The host's own review command exists, but only for its own family; the
|
|
397
|
+
# cross branch of effective_review routes it to the PROPOSED floor.
|
|
398
|
+
"slash": True,
|
|
316
399
|
"onto": onto_ok,
|
|
317
400
|
"ultracode": cross_ultracode_command(plan) is not None,
|
|
318
401
|
}
|
|
319
402
|
|
|
320
403
|
|
|
404
|
+
def install_hint(plan: dict[str, Any], routes: list[str]) -> str:
|
|
405
|
+
"""One-line install guidance for the capability-backed routes that are missing.
|
|
406
|
+
Empty when a route has no capability, no configured install, or is already there."""
|
|
407
|
+
capabilities = plan.get("capabilities", {})
|
|
408
|
+
hints = []
|
|
409
|
+
for route in routes:
|
|
410
|
+
hint = capabilities.get(route, {}).get("install")
|
|
411
|
+
if hint and hint not in hints:
|
|
412
|
+
hints.append(hint)
|
|
413
|
+
return f"; install: {' && '.join(hints)}" if hints else ""
|
|
414
|
+
|
|
415
|
+
|
|
321
416
|
def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str | None]:
|
|
322
417
|
"""Resolve the requested review setup to the routes that can actually run.
|
|
323
418
|
|
|
@@ -325,9 +420,11 @@ def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str |
|
|
|
325
420
|
reproduces the earlier degrade-to-native behavior (floor None; a dropped
|
|
326
421
|
external route degrades to native in effective; native requires delegation).
|
|
327
422
|
In cross-family mode the effective routes run on the opposite family; when no
|
|
328
|
-
cross route resolves
|
|
329
|
-
PROPOSED
|
|
330
|
-
|
|
423
|
+
cross route resolves the floor names the same-family route that runs instead,
|
|
424
|
+
labeled PROPOSED — a SAME_FAMILY_ROUTES route floors as itself (it needs no
|
|
425
|
+
delegation, being the host's own command), otherwise 'native' when delegation
|
|
426
|
+
is on. A requested non-none setup with no cross route and no fallback
|
|
427
|
+
(delegation off) fails closed."""
|
|
331
428
|
requested = plan["review_setup"]
|
|
332
429
|
if requested not in REVIEW_ROUTES:
|
|
333
430
|
raise LaunchError(f"unknown review setup: {requested!r}")
|
|
@@ -341,11 +438,24 @@ def effective_review(plan: dict[str, Any]) -> tuple[list[str], list[str], str |
|
|
|
341
438
|
if dropped and "native" not in effective and plan["delegation"]:
|
|
342
439
|
effective.append("native")
|
|
343
440
|
return effective, dropped, None
|
|
344
|
-
|
|
441
|
+
# A same-family route cannot be dispatched cross-family, so it is never
|
|
442
|
+
# "effective" here; it becomes the PROPOSED floor below instead.
|
|
443
|
+
effective = [
|
|
444
|
+
route
|
|
445
|
+
for route in wanted
|
|
446
|
+
if available[route] and route not in SAME_FAMILY_ROUTES
|
|
447
|
+
]
|
|
345
448
|
dropped = [route for route in wanted if not available[route]]
|
|
346
449
|
floor = None
|
|
347
450
|
if not effective and requested != "none":
|
|
348
|
-
|
|
451
|
+
same_family = [
|
|
452
|
+
route
|
|
453
|
+
for route in wanted
|
|
454
|
+
if route in SAME_FAMILY_ROUTES and available[route]
|
|
455
|
+
]
|
|
456
|
+
if same_family:
|
|
457
|
+
floor = same_family[0]
|
|
458
|
+
elif plan["delegation"]:
|
|
349
459
|
floor = "native"
|
|
350
460
|
else:
|
|
351
461
|
raise LaunchError(
|
|
@@ -882,8 +992,10 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
|
|
|
882
992
|
|
|
883
993
|
|
|
884
994
|
def review_description(host: str, name: str) -> str:
|
|
885
|
-
|
|
886
|
-
|
|
995
|
+
"""The user-facing one-liner for the menu: what the setup is for and what it
|
|
996
|
+
costs. Distinct from ["contract"], which is the instruction sent to the agent."""
|
|
997
|
+
description = REVIEW_SETUPS[name]["description"]
|
|
998
|
+
return description[host] if isinstance(description, dict) else description
|
|
887
999
|
|
|
888
1000
|
|
|
889
1001
|
def effort_options(host: str, model: str) -> list[MenuOption]:
|
|
@@ -1001,13 +1113,19 @@ def save_preset(
|
|
|
1001
1113
|
)
|
|
1002
1114
|
fields, tier_overrides = preset_from_plan(plan, config, name)
|
|
1003
1115
|
block = render_preset_block(name, fields, tier_overrides)
|
|
1004
|
-
|
|
1005
|
-
temporary = config_path.with_name(f".{config_path.name}.{os.getpid()}.tmp")
|
|
1116
|
+
target = user_presets_path(config_path)
|
|
1006
1117
|
try:
|
|
1118
|
+
kept = remove_preset_block(target.read_text(), name).rstrip("\n")
|
|
1119
|
+
except FileNotFoundError:
|
|
1120
|
+
kept = USER_PRESETS_HEADER.rstrip("\n")
|
|
1121
|
+
text = kept + "\n\n" + block
|
|
1122
|
+
temporary = target.with_name(f".{target.name}.{os.getpid()}.tmp")
|
|
1123
|
+
try:
|
|
1124
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
1007
1125
|
temporary.write_text(text)
|
|
1008
|
-
os.replace(temporary,
|
|
1126
|
+
os.replace(temporary, target)
|
|
1009
1127
|
except OSError as exc:
|
|
1010
|
-
raise LaunchError(f"cannot save preset to {
|
|
1128
|
+
raise LaunchError(f"cannot save preset to {target}: {exc}") from exc
|
|
1011
1129
|
|
|
1012
1130
|
|
|
1013
1131
|
def customize(
|
|
@@ -1033,7 +1151,10 @@ def customize(
|
|
|
1033
1151
|
]
|
|
1034
1152
|
if missing:
|
|
1035
1153
|
note = "degrades to same-family native (PROPOSED)" if cross else "degrades to native"
|
|
1036
|
-
description =
|
|
1154
|
+
description = (
|
|
1155
|
+
f"{description} ({', '.join(missing)} unavailable now; {note}"
|
|
1156
|
+
f"{install_hint(plan, missing)})"
|
|
1157
|
+
)
|
|
1037
1158
|
review_options.append(MenuOption(name, spec["label"], description))
|
|
1038
1159
|
if plan["host"] == "codex":
|
|
1039
1160
|
policies = [
|
|
@@ -1471,6 +1592,15 @@ def _cross_review_route(
|
|
|
1471
1592
|
"No cross-family route resolved at launch; using same-family native "
|
|
1472
1593
|
"subagent review labeled PROPOSED (family collapse)."
|
|
1473
1594
|
)
|
|
1595
|
+
elif floor:
|
|
1596
|
+
setup_contract = REVIEW_SETUPS[plan["review_setup"]]["contract"]
|
|
1597
|
+
if isinstance(setup_contract, dict):
|
|
1598
|
+
setup_contract = setup_contract[plan["host"]]
|
|
1599
|
+
parts.append(
|
|
1600
|
+
f"This review route runs on this main's own family, so it cannot be "
|
|
1601
|
+
f"dispatched cross-family; its verdicts are PROPOSED (family collapse). "
|
|
1602
|
+
f"{setup_contract}"
|
|
1603
|
+
)
|
|
1474
1604
|
parts.append(
|
|
1475
1605
|
"Cross-family reviewers are dispatched as read-only subprocesses, not "
|
|
1476
1606
|
"CLI-native subagents; spawning them needs this main's execution policy to "
|
|
@@ -1615,13 +1745,13 @@ def print_summary(
|
|
|
1615
1745
|
if effective:
|
|
1616
1746
|
review_display += f" via {'+'.join(effective)}"
|
|
1617
1747
|
if dropped:
|
|
1618
|
-
review_display += f" (dropped {','.join(dropped)})"
|
|
1619
|
-
if floor
|
|
1620
|
-
review_display += " → same-family
|
|
1748
|
+
review_display += f" (dropped {','.join(dropped)}{install_hint(plan, dropped)})"
|
|
1749
|
+
if floor:
|
|
1750
|
+
review_display += f" → same-family {floor} PROPOSED"
|
|
1621
1751
|
elif dropped:
|
|
1622
1752
|
review_display += (
|
|
1623
1753
|
f" → effective {'+'.join(effective) or 'none'} "
|
|
1624
|
-
f"({','.join(dropped)} unavailable)"
|
|
1754
|
+
f"({','.join(dropped)} unavailable{install_hint(plan, dropped)})"
|
|
1625
1755
|
)
|
|
1626
1756
|
print(
|
|
1627
1757
|
f" Review setup {review_display} · "
|
package/scripts/check-parity.sh
CHANGED
|
@@ -1585,7 +1585,8 @@ if launcher.is_file() and launch_profile:
|
|
|
1585
1585
|
unavailable,
|
|
1586
1586
|
"agent-launch codex degrade-to-native review",
|
|
1587
1587
|
(
|
|
1588
|
-
"Review setup hybrid → effective native (onto,ultracode unavailable
|
|
1588
|
+
"Review setup hybrid → effective native (onto,ultracode unavailable;"
|
|
1589
|
+
" install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
|
|
1589
1590
|
"degraded to native same-model",
|
|
1590
1591
|
"fall back to native same-model subagent review",
|
|
1591
1592
|
),
|
|
@@ -1600,7 +1601,8 @@ if launcher.is_file() and launch_profile:
|
|
|
1600
1601
|
unavailable_claude,
|
|
1601
1602
|
"agent-launch claude degrade-to-native review",
|
|
1602
1603
|
(
|
|
1603
|
-
"Review setup hybrid → effective native (onto,ultracode unavailable
|
|
1604
|
+
"Review setup hybrid → effective native (onto,ultracode unavailable;"
|
|
1605
|
+
" install: npm i -g onto-mcp && npm i -g ultracode-for-codex)",
|
|
1604
1606
|
"fall back to native same-model subagent review",
|
|
1605
1607
|
),
|
|
1606
1608
|
("--mcp-config",),
|
|
@@ -1637,8 +1639,11 @@ if launcher.is_file() and launch_profile:
|
|
|
1637
1639
|
):
|
|
1638
1640
|
mark_fail("saved codex tier override leaked into the claude host")
|
|
1639
1641
|
launcher_module.save_preset(round_trip, reloaded, save_target, "mysetup")
|
|
1640
|
-
|
|
1642
|
+
saved_file = launcher_module.user_presets_path(save_target)
|
|
1643
|
+
if saved_file.read_text().count("[presets.mysetup]") != 1:
|
|
1641
1644
|
mark_fail("re-saving a preset duplicated its block")
|
|
1645
|
+
if "[presets.mysetup]" in save_target.read_text():
|
|
1646
|
+
mark_fail("save_preset wrote a user preset into the installer-owned config")
|
|
1642
1647
|
|
|
1643
1648
|
# The Custom hub 'save' action persists the named preset then launches.
|
|
1644
1649
|
ui_save_target = tmp / "ui-save.toml"
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# Prompting guides are model-version-bound: unlike the rest of the guide set,
|
|
3
|
+
# their rules change when the model changes (the current generation inverted the
|
|
4
|
+
# previous one's advice). So they name concrete models, and this gate keeps that
|
|
5
|
+
# naming honest — it fails when the launch config binds a model no prompting
|
|
6
|
+
# guide covers, which is exactly when the guidance needs re-deriving from the
|
|
7
|
+
# vendor's current docs.
|
|
8
|
+
set -u
|
|
9
|
+
cd "$(dirname "$0")/.." || exit 2
|
|
10
|
+
|
|
11
|
+
python3 - "$@" <<'PY'
|
|
12
|
+
import pathlib, re, sys, tomllib
|
|
13
|
+
|
|
14
|
+
config = pathlib.Path("config/agent-launch.toml")
|
|
15
|
+
hosts = tomllib.loads(config.read_text())["hosts"]
|
|
16
|
+
|
|
17
|
+
# host -> the guide that owns prompting guidance for that host's model family
|
|
18
|
+
OWNERS = {"codex": "gpt-prompting", "claude": "claude-prompting"}
|
|
19
|
+
|
|
20
|
+
def targets(guide_id):
|
|
21
|
+
path = pathlib.Path(f"codex/guides/{guide_id}.md")
|
|
22
|
+
if not path.is_file():
|
|
23
|
+
sys.exit(f"FAIL: missing prompting guide: {path}")
|
|
24
|
+
front = re.match(r"---\n(.*?)\n---\n", path.read_text(), re.S)
|
|
25
|
+
if not front:
|
|
26
|
+
sys.exit(f"FAIL: missing YAML frontmatter: {path}")
|
|
27
|
+
block = re.search(r"^targets:\n((?:\s+-\s+\S+\n)+)", front.group(1), re.M)
|
|
28
|
+
if not block:
|
|
29
|
+
sys.exit(f"FAIL: {path} declares no targets:")
|
|
30
|
+
return {line.strip().lstrip("- ").strip() for line in block.group(1).splitlines()}
|
|
31
|
+
|
|
32
|
+
fail = 0
|
|
33
|
+
for host, guide_id in OWNERS.items():
|
|
34
|
+
configured = set(hosts.get(host, {}).get("models", []))
|
|
35
|
+
for tier in hosts.get(host, {}).get("tiers", {}).values():
|
|
36
|
+
if tier.get("model"):
|
|
37
|
+
configured.add(tier["model"])
|
|
38
|
+
if not configured:
|
|
39
|
+
print(f"FAIL: no models configured for host {host}; nothing to check")
|
|
40
|
+
fail = 1
|
|
41
|
+
continue
|
|
42
|
+
declared = targets(guide_id)
|
|
43
|
+
stale = sorted(configured - declared)
|
|
44
|
+
if stale:
|
|
45
|
+
print(
|
|
46
|
+
f"FAIL: {guide_id}.md does not cover configured {host} model(s): "
|
|
47
|
+
f"{', '.join(stale)} — re-derive it from current vendor guidance and "
|
|
48
|
+
f"update its targets:"
|
|
49
|
+
)
|
|
50
|
+
fail = 1
|
|
51
|
+
else:
|
|
52
|
+
print(f" {guide_id}: covers all {len(configured)} configured {host} model(s)")
|
|
53
|
+
unused = sorted(declared - configured)
|
|
54
|
+
if unused:
|
|
55
|
+
print(f" note: {guide_id} also targets unbound model(s): {', '.join(unused)}")
|
|
56
|
+
|
|
57
|
+
sys.exit(fail)
|
|
58
|
+
PY
|
|
59
|
+
status=$?
|
|
60
|
+
[ $status -eq 0 ] && echo "PROMPTING TARGETS OK: every configured model is covered" \
|
|
61
|
+
|| echo "PROMPTING TARGETS FAILED"
|
|
62
|
+
exit $status
|
package/scripts/install.sh
CHANGED
|
@@ -44,6 +44,7 @@ REPO="$(cd "$SELF/.." && pwd)"
|
|
|
44
44
|
CLAUDE_DIR="${CLAUDE_CONFIG_DIR:-$HOME/.claude}"
|
|
45
45
|
CODEX_DIR="${CODEX_HOME:-$HOME/.codex}"
|
|
46
46
|
LAUNCH_DIR="$HOME/.config/agent-launch"
|
|
47
|
+
USER_PRESETS_NAME="presets.local.toml" # user-owned; never deployed or verified
|
|
47
48
|
BIN_DIR="$HOME/.local/bin"
|
|
48
49
|
STATE_DIR="$HOME/.local/share/agent-bios"
|
|
49
50
|
LEGACY_STATE_DIR="$HOME/.local/share/agent-dotfiles" # pre-rename state; migrated on first run
|
|
@@ -125,6 +126,113 @@ migrate_state() {
|
|
|
125
126
|
mv "$LEGACY_STATE_DIR" "$STATE_DIR" && info "migrated state $LEGACY_STATE_DIR -> $STATE_DIR"
|
|
126
127
|
}
|
|
127
128
|
|
|
129
|
+
# ---- optional dependencies -----------------------------------------------
|
|
130
|
+
# Capabilities are optional: a missing one only degrades the review routes that
|
|
131
|
+
# need it. config/agent-launch.toml is the single source for both the command
|
|
132
|
+
# that gates a route and the install line offered here.
|
|
133
|
+
capability_table() {
|
|
134
|
+
python3 - "$REPO/config/agent-launch.toml" <<'PY'
|
|
135
|
+
import sys, tomllib
|
|
136
|
+
for name, cap in tomllib.load(open(sys.argv[1], "rb")).get("capabilities", {}).items():
|
|
137
|
+
print("\t".join((name, cap.get("command", ""), cap.get("install", ""))))
|
|
138
|
+
PY
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
install_capability() {
|
|
142
|
+
local name="$1" line="$2"
|
|
143
|
+
log "Installing optional dependency $name: $line"
|
|
144
|
+
if [ "$DRY_RUN" = 1 ]; then info "[dry-run] $line"; return 0; fi
|
|
145
|
+
if sh -c "$line"; then info "installed $name"; else log "warning: installing $name failed; routes needing it stay degraded"; fi
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
handle_capabilities() {
|
|
149
|
+
local requested="$1" name command line
|
|
150
|
+
# Fail on a typo rather than silently installing nothing.
|
|
151
|
+
local known; known=$(capability_table | cut -f1)
|
|
152
|
+
local want
|
|
153
|
+
for want in ${requested//,/ }; do
|
|
154
|
+
printf '%s\n' "$known" | grep -qx "$want" || {
|
|
155
|
+
log "unknown --with capability: $want (known: $(printf '%s' "$known" | tr '\n' ' '))"; return 1; }
|
|
156
|
+
done
|
|
157
|
+
while IFS=$'\t' read -r name command line; do
|
|
158
|
+
[ -n "$name" ] || continue
|
|
159
|
+
if command -v "$command" >/dev/null 2>&1; then
|
|
160
|
+
info "capability present $name ($command)"
|
|
161
|
+
continue
|
|
162
|
+
fi
|
|
163
|
+
if printf '%s\n' "${requested//,/ }" | tr ' ' '\n' | grep -qx "$name"; then
|
|
164
|
+
[ -n "$line" ] && install_capability "$name" "$line" \
|
|
165
|
+
|| log "note: $name has no configured install line"
|
|
166
|
+
elif [ -n "$line" ] && [ -t 0 ] && [ "$DRY_RUN" != 1 ]; then
|
|
167
|
+
printf ' Install optional dependency %s? (%s) [y/N] ' "$name" "$line"
|
|
168
|
+
local answer=""; read -r answer </dev/tty || answer=""
|
|
169
|
+
case "$answer" in
|
|
170
|
+
[yY]*) install_capability "$name" "$line" ;;
|
|
171
|
+
*) info "skipped $name — install later: $line" ;;
|
|
172
|
+
esac
|
|
173
|
+
else
|
|
174
|
+
info "optional $name unavailable; routes needing it degrade${line:+ — install: $line}"
|
|
175
|
+
fi
|
|
176
|
+
done <<EOF
|
|
177
|
+
$(capability_table)
|
|
178
|
+
EOF
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
# Presets the launcher saved into the deployed profiles.toml (pre-split, or by hand)
|
|
182
|
+
# would be lost to the cp below; move them into the user-owned presets file first.
|
|
183
|
+
migrate_user_presets() {
|
|
184
|
+
local src="$REPO/config/agent-launch.toml"
|
|
185
|
+
local dst="$LAUNCH_DIR/profiles.toml"
|
|
186
|
+
local user="$LAUNCH_DIR/$USER_PRESETS_NAME"
|
|
187
|
+
[ -f "$dst" ] || return 0
|
|
188
|
+
python3 - "$src" "$dst" "$user" "$DRY_RUN" <<'PY' || { log "user preset migration failed; not overwriting $LAUNCH_DIR/profiles.toml"; return 1; }
|
|
189
|
+
import os, pathlib, re, sys, tomllib
|
|
190
|
+
|
|
191
|
+
src, dst, user, dry_run = pathlib.Path(sys.argv[1]), pathlib.Path(sys.argv[2]), pathlib.Path(sys.argv[3]), sys.argv[4] == "1"
|
|
192
|
+
dst_text = dst.read_text()
|
|
193
|
+
shipped = set(tomllib.loads(src.read_text()).get("presets", {}))
|
|
194
|
+
extra = set(tomllib.loads(dst_text).get("presets", {})) - shipped
|
|
195
|
+
if not extra:
|
|
196
|
+
sys.exit(0)
|
|
197
|
+
existing_text = user.read_text() if user.is_file() else ""
|
|
198
|
+
already = set(tomllib.loads(existing_text).get("presets", {})) if existing_text else set()
|
|
199
|
+
move = sorted(extra - already)
|
|
200
|
+
for name in sorted(extra & already):
|
|
201
|
+
print(f" kept in {user.name} {name} (already saved there)")
|
|
202
|
+
if not move:
|
|
203
|
+
sys.exit(0)
|
|
204
|
+
|
|
205
|
+
header = re.compile(r'^\[presets\.(?:"([^"]+)"|([A-Za-z0-9][A-Za-z0-9_-]*))(?:[.\]])')
|
|
206
|
+
blocks, current = {}, None
|
|
207
|
+
for line in dst_text.splitlines(keepends=True):
|
|
208
|
+
stripped = line.strip()
|
|
209
|
+
if stripped.startswith("["):
|
|
210
|
+
found = header.match(stripped)
|
|
211
|
+
current = (found.group(1) or found.group(2)) if found else None
|
|
212
|
+
if current in move:
|
|
213
|
+
blocks.setdefault(current, []).append(line)
|
|
214
|
+
# Refuse to deploy over a preset we could not carry across, rather than drop it.
|
|
215
|
+
missing = [name for name in move if name not in blocks]
|
|
216
|
+
if missing:
|
|
217
|
+
sys.exit(f"cannot extract preset block(s) from {dst}: {', '.join(missing)}")
|
|
218
|
+
|
|
219
|
+
out = existing_text.rstrip("\n") or (
|
|
220
|
+
"# agent-launch user presets, moved out of profiles.toml by agent-bios install.\n"
|
|
221
|
+
"# The installer never deploys or verifies this file, so presets here survive upgrades."
|
|
222
|
+
)
|
|
223
|
+
for name in move:
|
|
224
|
+
out += "\n\n" + "".join(blocks[name]).strip("\n")
|
|
225
|
+
for name in move:
|
|
226
|
+
print(f" {'[dry-run] ' if dry_run else ''}moved preset {name} -> {user}")
|
|
227
|
+
if dry_run:
|
|
228
|
+
sys.exit(0)
|
|
229
|
+
user.parent.mkdir(parents=True, exist_ok=True)
|
|
230
|
+
temporary = user.with_name(f".{user.name}.{os.getpid()}.tmp")
|
|
231
|
+
temporary.write_text(out + "\n")
|
|
232
|
+
os.replace(temporary, user)
|
|
233
|
+
PY
|
|
234
|
+
}
|
|
235
|
+
|
|
128
236
|
remove_zsh_hook() {
|
|
129
237
|
if [ ! -f "$ZSHRC" ] || ! grep -qF "$HOOK_MARK" "$ZSHRC"; then
|
|
130
238
|
info "no zsh hook to remove"
|
|
@@ -152,9 +260,14 @@ cmd_install() {
|
|
|
152
260
|
deploy_glob "$REPO/codex/agents" "*.toml" "$CODEX_DIR/agents"
|
|
153
261
|
deploy_file "$REPO/scripts/codex-run.sh" "$CODEX_DIR/bin/codex-run" "+x"
|
|
154
262
|
deploy_file "$REPO/scripts/codex-helm.sh" "$CODEX_DIR/bin/codex-helm" "+x"
|
|
263
|
+
migrate_user_presets || exit 1 # must precede the deploy below, which overwrites profiles.toml
|
|
155
264
|
deploy_file "$REPO/config/agent-launch.toml" "$LAUNCH_DIR/profiles.toml"
|
|
156
265
|
deploy_file "$REPO/shell/agent-launch.zsh" "$LAUNCH_DIR/shell.zsh"
|
|
157
266
|
deploy_file "$REPO/scripts/agent-launch.py" "$BIN_DIR/agent-launch" "+x"
|
|
267
|
+
log ""
|
|
268
|
+
log "Optional dependencies (missing ones only degrade the routes that need them)..."
|
|
269
|
+
handle_capabilities "$WITH" || exit 1
|
|
270
|
+
log ""
|
|
158
271
|
if [ "$DRY_RUN" = 1 ]; then
|
|
159
272
|
info "[dry-run] provision managed Textual venv"
|
|
160
273
|
else
|
|
@@ -212,6 +325,16 @@ PY
|
|
|
212
325
|
if [ -d "$REPO/ko" ] && [ -x "$REPO/scripts/check-parity.sh" ]; then
|
|
213
326
|
if "$REPO/scripts/check-parity.sh" >/dev/null 2>&1; then info "repo mirror parity OK"; else log "repo mirror parity FAILED"; fail=1; fi
|
|
214
327
|
fi
|
|
328
|
+
# Prompting guides name concrete models, so they go stale on a model change
|
|
329
|
+
# rather than degrading quietly; this checks them against the launch config.
|
|
330
|
+
if [ -x "$REPO/scripts/check-prompting-targets.sh" ]; then
|
|
331
|
+
if "$REPO/scripts/check-prompting-targets.sh" >/dev/null 2>&1; then
|
|
332
|
+
info "prompting targets OK"
|
|
333
|
+
else
|
|
334
|
+
log "prompting guides do not cover a configured model; run scripts/check-prompting-targets.sh"
|
|
335
|
+
fail=1
|
|
336
|
+
fi
|
|
337
|
+
fi
|
|
215
338
|
return $fail
|
|
216
339
|
}
|
|
217
340
|
|
|
@@ -284,7 +407,12 @@ agent-bios — deploy the Claude/Codex instruction SSOT into $HOME (by copy).
|
|
|
284
407
|
agent-bios uninstall remove deployed files and the zsh hook
|
|
285
408
|
agent-bios help
|
|
286
409
|
|
|
287
|
-
Flags: --dry-run
|
|
410
|
+
Flags: --dry-run print actions without changing anything
|
|
411
|
+
--with a,b also install the named optional dependencies (install only).
|
|
412
|
+
Without it, install offers each missing one when the terminal
|
|
413
|
+
is interactive, and otherwise just prints its install line.
|
|
414
|
+
Known: onto, ultracode. Missing ones are not fatal — they only
|
|
415
|
+
degrade the review routes that need them.
|
|
288
416
|
Env: CLAUDE_CONFIG_DIR, CODEX_HOME, AGENT_LAUNCH_VENV, ZDOTDIR
|
|
289
417
|
EOF
|
|
290
418
|
}
|
|
@@ -292,11 +420,15 @@ EOF
|
|
|
292
420
|
# ---- dispatch ------------------------------------------------------------
|
|
293
421
|
CMD="${1:-help}"
|
|
294
422
|
if [ $# -gt 0 ]; then shift; fi
|
|
295
|
-
|
|
296
|
-
|
|
423
|
+
WITH=""
|
|
424
|
+
while [ $# -gt 0 ]; do
|
|
425
|
+
case "$1" in
|
|
297
426
|
--dry-run) DRY_RUN=1 ;;
|
|
298
|
-
|
|
427
|
+
--with) shift; WITH="${1:-}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
|
|
428
|
+
--with=*) WITH="${1#--with=}"; [ -n "$WITH" ] || { log "--with needs a comma-separated capability list"; exit 2; } ;;
|
|
429
|
+
*) log "unknown flag: $1"; exit 2 ;;
|
|
299
430
|
esac
|
|
431
|
+
shift
|
|
300
432
|
done
|
|
301
433
|
|
|
302
434
|
case "$CMD" in
|