agent-bios 0.9.9 → 0.11.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 +3 -3
- package/README.md +10 -2
- package/claude/CLAUDE.md +4 -41
- package/claude/guides/cli-multi-model-workflow.md +7 -7
- package/claude/guides/coding-staged-workflow.md +51 -49
- package/claude/guides/concept-economy.md +187 -0
- package/claude/guides/documentation-hygiene.md +112 -0
- package/claude/guides/review-request.md +9 -7
- package/claude/guides/session-distill-workflow.md +3 -3
- package/claude/guides/tooling-gotchas.md +10 -0
- package/claude/guides/verification-discipline.md +166 -0
- package/claude/hooks/tooling-gotchas-hook.py +323 -13
- package/codex/AGENTS.md +4 -41
- package/codex/guides/cli-multi-model-workflow.md +7 -7
- package/codex/guides/coding-staged-workflow.md +51 -49
- package/codex/guides/concept-economy.md +187 -0
- package/codex/guides/documentation-hygiene.md +112 -0
- package/codex/guides/review-request.md +9 -7
- package/codex/guides/session-distill-workflow.md +3 -3
- package/codex/guides/tooling-gotchas.md +10 -0
- package/codex/guides/verification-discipline.md +166 -0
- package/compose/assemble.py +10 -1
- package/compose/check-domains.py +882 -6
- package/compose/domains.json +10 -44
- package/install.sh +176 -16
- package/launch/agent-launch.py +790 -173
- package/launch/agent-launch.toml +56 -107
- package/launch/i18n/en.toml +66 -0
- package/launch/i18n/ja.toml +63 -0
- package/launch/i18n/ko.toml +63 -0
- package/package.json +9 -4
- package/provenance.json +1 -0
- package/wrappers/codex-run.sh +1 -1
- package/claude/hooks/__pycache__/tooling-gotchas-hook.cpython-314.pyc +0 -0
package/launch/agent-launch.py
CHANGED
|
@@ -5,6 +5,7 @@ from __future__ import annotations
|
|
|
5
5
|
|
|
6
6
|
import argparse
|
|
7
7
|
import copy
|
|
8
|
+
import fcntl
|
|
8
9
|
import hashlib
|
|
9
10
|
import json
|
|
10
11
|
import os
|
|
@@ -87,6 +88,176 @@ USER_PRESETS_HEADER = (
|
|
|
87
88
|
"# survive upgrades. Shipped presets live in the deployed profiles.toml and\n"
|
|
88
89
|
"# are overridden by a preset of the same name here.\n"
|
|
89
90
|
)
|
|
91
|
+
# ── UI language (interface text only) ─────────────────────────────────────────
|
|
92
|
+
# Deploy-managed catalogs, sibling directory of the config: launch/i18n/ in a
|
|
93
|
+
# checkout, ~/.config/agent-launch/i18n/ deployed. `en` is the reference
|
|
94
|
+
# key-set; the parity gate holds ko/ja to exactly it, both directions, so an
|
|
95
|
+
# incomplete catalog is unshippable rather than silently hybrid. Chrome
|
|
96
|
+
# language never reaches a rendered launch contract — a golden scenario pins
|
|
97
|
+
# that. Internal codes only here; "JP" is a display label for `ja`.
|
|
98
|
+
I18N_DIR_NAME = "i18n"
|
|
99
|
+
I18N_LANGUAGES = ("en", "ko", "ja")
|
|
100
|
+
I18N_ENV = "AGENT_LAUNCH_LANG"
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _module_catalog(language: str) -> dict[str, str]:
|
|
104
|
+
"""The catalog shipped beside this module, or empty. The module-adjacent set
|
|
105
|
+
is the default for in-process consumers that never reach main() (gate legs
|
|
106
|
+
import the module and drive flows directly); load_catalogs() replaces it
|
|
107
|
+
with the config-sibling deployment when one exists."""
|
|
108
|
+
path = pathlib.Path(__file__).resolve().parent / I18N_DIR_NAME / f"{language}.toml"
|
|
109
|
+
try:
|
|
110
|
+
raw = tomllib.loads(path.read_text())
|
|
111
|
+
except (OSError, tomllib.TOMLDecodeError):
|
|
112
|
+
return {}
|
|
113
|
+
return {key: value for key, value in raw.items() if isinstance(value, str)}
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
_CATALOG_EN: dict[str, str] = _module_catalog("en")
|
|
117
|
+
_CATALOG: dict[str, str] = _CATALOG_EN
|
|
118
|
+
# Keys asked for but absent from the active catalog. The completeness gate makes
|
|
119
|
+
# this state unshippable; the set is the runtime tripwire for the state the gate
|
|
120
|
+
# should have made impossible (surfaced by the Stage-2 banner).
|
|
121
|
+
_CATALOG_MISSING: set[str] = set()
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def i18n_dir(config_path: pathlib.Path) -> pathlib.Path:
|
|
125
|
+
return config_path.with_name(I18N_DIR_NAME)
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
# User-owned launcher preferences, sibling to the other two `.local.toml` files
|
|
129
|
+
# and owned the same way: written by the launcher on an explicit selection only,
|
|
130
|
+
# never deployed, never overwritten by an install. One table, one key, until
|
|
131
|
+
# another preference earns its place.
|
|
132
|
+
USER_LAUNCHER_NAME = "launcher.local.toml"
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def user_launcher_path(config_path: pathlib.Path) -> pathlib.Path:
|
|
136
|
+
return config_path.with_name(USER_LAUNCHER_NAME)
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def saved_language(config_path: pathlib.Path) -> str | None:
|
|
140
|
+
"""The persisted UI language, or None. A malformed file or value is a
|
|
141
|
+
loud notice and None — a preference must never block launching."""
|
|
142
|
+
path = user_launcher_path(config_path)
|
|
143
|
+
if not path.is_file():
|
|
144
|
+
return None
|
|
145
|
+
try:
|
|
146
|
+
value = tomllib.loads(path.read_text()).get("ui", {}).get("language")
|
|
147
|
+
except (OSError, tomllib.TOMLDecodeError) as exc:
|
|
148
|
+
print(f"agent-launch: cannot read {path}: {exc}; using en", file=sys.stderr)
|
|
149
|
+
return None
|
|
150
|
+
if value in I18N_LANGUAGES:
|
|
151
|
+
return value
|
|
152
|
+
if value is not None:
|
|
153
|
+
print(
|
|
154
|
+
f"agent-launch: {path} names language {value!r}, not one of "
|
|
155
|
+
f"{'/'.join(I18N_LANGUAGES)}; using en", file=sys.stderr,
|
|
156
|
+
)
|
|
157
|
+
return None
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
def save_language(config_path: pathlib.Path, language: str) -> None:
|
|
161
|
+
"""Persist the UI language atomically. The whole file is launcher-authored
|
|
162
|
+
(one table, one key), so a full rewrite loses nothing user-written."""
|
|
163
|
+
path = user_launcher_path(config_path)
|
|
164
|
+
content = (
|
|
165
|
+
"# agent-launch user preferences, written by the launcher.\n"
|
|
166
|
+
"# agent-bios install never deploys or verifies this file.\n"
|
|
167
|
+
f'\n[ui]\nlanguage = "{language}"\n'
|
|
168
|
+
)
|
|
169
|
+
temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
|
|
170
|
+
temporary.write_text(content)
|
|
171
|
+
os.replace(temporary, path)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def ui_language(config_path: pathlib.Path | None = None) -> str:
|
|
175
|
+
"""The active UI language. Env override first (what gates pin), then the
|
|
176
|
+
user preference file, then English."""
|
|
177
|
+
explicit = os.environ.get(I18N_ENV, "")
|
|
178
|
+
if explicit in I18N_LANGUAGES:
|
|
179
|
+
return explicit
|
|
180
|
+
if explicit:
|
|
181
|
+
print(
|
|
182
|
+
f"agent-launch: {I18N_ENV}={explicit!r} is not one of "
|
|
183
|
+
f"{'/'.join(I18N_LANGUAGES)}; using en", file=sys.stderr,
|
|
184
|
+
)
|
|
185
|
+
return "en"
|
|
186
|
+
if config_path is not None:
|
|
187
|
+
preferred = saved_language(config_path)
|
|
188
|
+
if preferred:
|
|
189
|
+
return preferred
|
|
190
|
+
return "en"
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def load_catalogs(config_path: pathlib.Path) -> None:
|
|
194
|
+
"""Load the English reference UI text catalog and the active language's catalog from
|
|
195
|
+
the config's sibling i18n directory, falling back to the module-adjacent set.
|
|
196
|
+
|
|
197
|
+
A selected language whose catalog fails to LOAD falls to English with a loud
|
|
198
|
+
notice — never silently. English missing from BOTH homes leaves keys
|
|
199
|
+
rendering as themselves, which the completeness gate makes unreachable on a
|
|
200
|
+
shipped tree."""
|
|
201
|
+
global _CATALOG, _CATALOG_EN
|
|
202
|
+
directory = i18n_dir(config_path)
|
|
203
|
+
|
|
204
|
+
def read(language: str) -> dict[str, str] | None:
|
|
205
|
+
path = directory / f"{language}.toml"
|
|
206
|
+
try:
|
|
207
|
+
raw = tomllib.loads(path.read_text())
|
|
208
|
+
except OSError:
|
|
209
|
+
return None
|
|
210
|
+
except tomllib.TOMLDecodeError as exc:
|
|
211
|
+
print(
|
|
212
|
+
f"agent-launch: UI text catalog {path} does not parse: {exc}",
|
|
213
|
+
file=sys.stderr,
|
|
214
|
+
)
|
|
215
|
+
return None
|
|
216
|
+
return {key: value for key, value in raw.items() if isinstance(value, str)}
|
|
217
|
+
|
|
218
|
+
deployed_en = read("en")
|
|
219
|
+
if deployed_en is not None:
|
|
220
|
+
_CATALOG_EN = deployed_en
|
|
221
|
+
elif not _CATALOG_EN:
|
|
222
|
+
print(
|
|
223
|
+
f"agent-launch: no UI text catalog for en under {directory} or beside "
|
|
224
|
+
"the launcher; UI text keys will render as themselves", file=sys.stderr,
|
|
225
|
+
)
|
|
226
|
+
language = ui_language(config_path)
|
|
227
|
+
if language == "en":
|
|
228
|
+
_CATALOG = _CATALOG_EN
|
|
229
|
+
return
|
|
230
|
+
selected = read(language)
|
|
231
|
+
if selected is None:
|
|
232
|
+
selected = _module_catalog(language)
|
|
233
|
+
if selected:
|
|
234
|
+
# A deployed catalog older than the launcher is invisible to the
|
|
235
|
+
# source-tree completeness gate; the skew is loud here instead of
|
|
236
|
+
# surfacing as silently-English strings one key at a time.
|
|
237
|
+
skew = len(set(_CATALOG_EN) - set(selected))
|
|
238
|
+
if skew:
|
|
239
|
+
print(
|
|
240
|
+
f"agent-launch: UI text catalog {language} is missing {skew} "
|
|
241
|
+
"key(s) vs en (deployed version skew?); those strings render "
|
|
242
|
+
"in English", file=sys.stderr,
|
|
243
|
+
)
|
|
244
|
+
_CATALOG = selected
|
|
245
|
+
return
|
|
246
|
+
print(
|
|
247
|
+
f"agent-launch: no UI text catalog for {language}; showing English",
|
|
248
|
+
file=sys.stderr,
|
|
249
|
+
)
|
|
250
|
+
_CATALOG = _CATALOG_EN
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def t(key: str) -> str:
|
|
254
|
+
"""One UI text string. Missing keys fall to English and trip the tripwire;
|
|
255
|
+
a key missing from English too renders as itself rather than crashing."""
|
|
256
|
+
value = _CATALOG.get(key)
|
|
257
|
+
if value is not None:
|
|
258
|
+
return value
|
|
259
|
+
_CATALOG_MISSING.add(key)
|
|
260
|
+
return _CATALOG_EN.get(key, key)
|
|
90
261
|
|
|
91
262
|
|
|
92
263
|
@dataclass(frozen=True)
|
|
@@ -136,66 +307,41 @@ REVIEW_SETUPS = {
|
|
|
136
307
|
"codex": "Use Codex's built-in /review when review gates fire.",
|
|
137
308
|
},
|
|
138
309
|
},
|
|
139
|
-
"onto": {
|
|
140
|
-
"label": "onto-mcp",
|
|
141
|
-
"requirements": {"codex": ("onto",), "claude": ("onto",)},
|
|
142
|
-
"description": (
|
|
143
|
-
"Structured multi-lens review through onto-mcp. Best for concept/ontology "
|
|
144
|
-
"and design work; needs onto installed."
|
|
145
|
-
),
|
|
146
|
-
"contract": "Use onto-mcp as the structured review lens when review gates fire.",
|
|
147
|
-
},
|
|
148
310
|
"ultracode": {
|
|
149
|
-
"label": "
|
|
150
|
-
"requirements": {"codex": ("
|
|
311
|
+
"label": "Deep review",
|
|
312
|
+
"requirements": {"codex": ("codex-exec",), "claude": ("codex-exec",)},
|
|
151
313
|
"description": (
|
|
152
|
-
"
|
|
153
|
-
"
|
|
314
|
+
"The review family's deep mechanism: plain codex exec at ultra effort on "
|
|
315
|
+
"a Codex seat, the Claude workflow when review lands on a Claude seat."
|
|
154
316
|
),
|
|
155
317
|
"contract": (
|
|
156
|
-
"Run the
|
|
157
|
-
"
|
|
158
|
-
|
|
159
|
-
},
|
|
160
|
-
"hybrid": {
|
|
161
|
-
"label": "Hybrid",
|
|
162
|
-
"requirements": {
|
|
163
|
-
"codex": ("onto", "ultracode-for-codex"),
|
|
164
|
-
"claude": ("onto", "ultracode-for-codex"),
|
|
165
|
-
},
|
|
166
|
-
"description": (
|
|
167
|
-
"onto + native + Ultracode together, acting on their union. Widest net for "
|
|
168
|
-
"risky work; missing routes degrade rather than fail."
|
|
318
|
+
"Run the Codex CLI in non-interactive exec mode (codex exec -s read-only -m "
|
|
319
|
+
'<frontier-tier model> -c model_reasoning_effort="ultra", self-contained packet '
|
|
320
|
+
"on stdin) as a Codex-backed deep review route when review gates fire."
|
|
169
321
|
),
|
|
170
|
-
"contract": {
|
|
171
|
-
"codex": "Use onto plus native and Codex-backed Ultracode review kinds; act on their union.",
|
|
172
|
-
"claude": "Use onto plus native multi-perspective and Codex-backed Ultracode review kinds; act on their union.",
|
|
173
|
-
},
|
|
174
322
|
},
|
|
175
323
|
}
|
|
176
324
|
|
|
177
325
|
# Each review setup decomposes into review routes. "native" is same-model
|
|
178
|
-
# multi-perspective subagent review (needs delegation); "
|
|
179
|
-
# external
|
|
326
|
+
# multi-perspective subagent review (needs delegation); "ultracode" is an
|
|
327
|
+
# external route gated on a resolvable capability command. A missing external
|
|
180
328
|
# route degrades to native instead of failing closed; only the delegation
|
|
181
329
|
# contradiction (native required with delegation off) stays fail-closed.
|
|
182
330
|
# The legacy route token "ultracode" is SETUP vocabulary, not a capability id — it was
|
|
183
331
|
# used as one, so renaming the tool silently repointed the legacy route at whatever else
|
|
184
332
|
# held the bare name. Bound explicitly here: the token stays frozen, the tool it resolves
|
|
185
333
|
# to is stated once, and the two can differ.
|
|
186
|
-
LEGACY_ULTRACODE_CAPABILITY = "
|
|
334
|
+
LEGACY_ULTRACODE_CAPABILITY = "codex-exec"
|
|
187
335
|
# Every legacy route that gates on an installed tool, and which capability that is. The
|
|
188
336
|
# token and the id were the same string in four places — availability, the install hint,
|
|
189
337
|
# the executable line and the lowering — so each was a separate way for a rename to
|
|
190
338
|
# repoint a frozen route at the wrong tool. One map, one place to be right.
|
|
191
|
-
LEGACY_ROUTE_CAPABILITY = {"
|
|
339
|
+
LEGACY_ROUTE_CAPABILITY = {"ultracode": LEGACY_ULTRACODE_CAPABILITY}
|
|
192
340
|
REVIEW_ROUTES = {
|
|
193
341
|
"none": (),
|
|
194
342
|
"native-panel": ("native",),
|
|
195
343
|
"slash-review": ("slash",),
|
|
196
|
-
"onto": ("onto",),
|
|
197
344
|
"ultracode": ("ultracode",),
|
|
198
|
-
"hybrid": ("onto", "native", "ultracode"),
|
|
199
345
|
}
|
|
200
346
|
# Routes that are host-native review commands: they always resolve (no capability
|
|
201
347
|
# to install) but run on the main's own family, so cross-family mode cannot dispatch
|
|
@@ -204,14 +350,11 @@ SAME_FAMILY_ROUTES = {"slash"}
|
|
|
204
350
|
ROUTE_LABELS = {
|
|
205
351
|
"native": "native same-model multi-perspective review",
|
|
206
352
|
"slash": "host-native slash-command review",
|
|
207
|
-
"
|
|
208
|
-
"ultracode": "Codex-backed Ultracode review",
|
|
353
|
+
"ultracode": "Codex-backed deep exec review",
|
|
209
354
|
}
|
|
210
355
|
# review_family selects whether review runs on the opposite model family (cross,
|
|
211
356
|
# the default) or the same family as the main (same, today's projection).
|
|
212
357
|
REVIEW_FAMILIES = {"cross", "same"}
|
|
213
|
-
# onto llmOverride provider enum (onto MCP tool schema, settings-chain).
|
|
214
|
-
ONTO_PROVIDERS = {"openai", "anthropic", "grok", "lmstudio"}
|
|
215
358
|
# The opposite model family for cross review, keyed on the launch host.
|
|
216
359
|
REVIEW_HOST = {"codex": "claude", "claude": "codex"}
|
|
217
360
|
# The family a legacy cross contract ADVERTISES for each host, and the provider that
|
|
@@ -251,9 +394,7 @@ LEGACY_REVIEW_LOWERING = {
|
|
|
251
394
|
"none": (False, ()),
|
|
252
395
|
"native-panel": (True, ()),
|
|
253
396
|
"slash-review": (True, ("host-review",)),
|
|
254
|
-
"
|
|
255
|
-
"ultracode": (True, ("ultracode-for-codex",)),
|
|
256
|
-
"hybrid": (True, ("onto", "ultracode-for-codex")),
|
|
397
|
+
"ultracode": (True, ("codex-exec",)),
|
|
257
398
|
}
|
|
258
399
|
|
|
259
400
|
|
|
@@ -625,7 +766,7 @@ def legacy_review_plan(setup: str, family: str) -> ReviewPlan:
|
|
|
625
766
|
# core and never configured — configuration names no MCP, CLI, API, or subagent.
|
|
626
767
|
#
|
|
627
768
|
# Stage 3 is the falsifying stage: if a shipped method needed an `if method ==
|
|
628
|
-
# "
|
|
769
|
+
# "codex-exec"` branch anywhere below, the seam would be wrong. There is deliberately no
|
|
629
770
|
# such branch, and gates/check_parity.py proves it by projecting a method whose name
|
|
630
771
|
# is generated at runtime and appears in no source file.
|
|
631
772
|
|
|
@@ -746,10 +887,10 @@ def parse_review_method(method_id: str, raw: Any, context: str) -> ReviewMethod:
|
|
|
746
887
|
# Two separate facts, and the gap between them is where a wrong map hid. `emits` is a
|
|
747
888
|
# claim about the WORLD — what this tool actually reports — and only someone who knows
|
|
748
889
|
# the tool can supply it. `severity_map` is OUR decision about how to translate it.
|
|
749
|
-
# With the map alone, "is this complete?" is undecidable:
|
|
750
|
-
#
|
|
751
|
-
#
|
|
752
|
-
# identity map
|
|
890
|
+
# With the map alone, "is this complete?" is undecidable: a shipped descriptor once
|
|
891
|
+
# carried the canonical ladder copied from the panel, mapping not one of the P0..P3
|
|
892
|
+
# values its tool really emitted, and nothing could tell that from a correct
|
|
893
|
+
# identity map whose vocabulary genuinely IS the ladder.
|
|
753
894
|
severity_emits = raw.get("severity_emits")
|
|
754
895
|
if (
|
|
755
896
|
not isinstance(severity_emits, list)
|
|
@@ -2028,16 +2169,11 @@ def load_config(path: pathlib.Path) -> dict[str, Any]:
|
|
|
2028
2169
|
hint = capability.get("install")
|
|
2029
2170
|
if hint is not None and (not isinstance(hint, str) or not hint):
|
|
2030
2171
|
raise LaunchError(f"capabilities.{name}.install must be a non-empty string")
|
|
2031
|
-
|
|
2032
|
-
if
|
|
2033
|
-
not isinstance(
|
|
2034
|
-
):
|
|
2035
|
-
raise LaunchError("capabilities.onto.command must be a non-empty string")
|
|
2036
|
-
ultracode = capabilities.get("ultracode-for-codex")
|
|
2037
|
-
if ultracode is not None and (
|
|
2038
|
-
not isinstance(ultracode.get("command"), str) or not ultracode["command"]
|
|
2172
|
+
codex_exec = capabilities.get("codex-exec")
|
|
2173
|
+
if codex_exec is not None and (
|
|
2174
|
+
not isinstance(codex_exec.get("command"), str) or not codex_exec["command"]
|
|
2039
2175
|
):
|
|
2040
|
-
raise LaunchError("capabilities.
|
|
2176
|
+
raise LaunchError("capabilities.codex-exec.command must be a non-empty string")
|
|
2041
2177
|
for host_name, host_data in data["hosts"].items():
|
|
2042
2178
|
if not isinstance(host_data, dict):
|
|
2043
2179
|
raise LaunchError(f"host must be a table: {host_name}")
|
|
@@ -2050,40 +2186,7 @@ def load_config(path: pathlib.Path) -> dict[str, Any]:
|
|
|
2050
2186
|
raise LaunchError(
|
|
2051
2187
|
f"hosts.{host_name}.models must be a non-empty list of strings"
|
|
2052
2188
|
)
|
|
2053
|
-
|
|
2054
|
-
if onto_review is not None:
|
|
2055
|
-
if not isinstance(onto_review, dict):
|
|
2056
|
-
raise LaunchError(f"hosts.{host_name}.onto_review must be a table")
|
|
2057
|
-
if onto_review.get("provider") not in ONTO_PROVIDERS:
|
|
2058
|
-
choices = ", ".join(sorted(ONTO_PROVIDERS))
|
|
2059
|
-
raise LaunchError(
|
|
2060
|
-
f"hosts.{host_name}.onto_review.provider must be one of: {choices}"
|
|
2061
|
-
)
|
|
2062
|
-
if not isinstance(onto_review.get("model"), str) or not onto_review["model"]:
|
|
2063
|
-
raise LaunchError(
|
|
2064
|
-
f"hosts.{host_name}.onto_review.model must be a non-empty string"
|
|
2065
|
-
)
|
|
2066
|
-
# The cross contract names the family of the review HOST while the override
|
|
2067
|
-
# takes its provider from this pin, so pinning another provider makes the
|
|
2068
|
-
# contract contradict itself — it says "run EVERY review route on
|
|
2069
|
-
# Anthropic/Claude" and hands the reviewer a grok seat. Fail closed rather
|
|
2070
|
-
# than advertise a family the review does not run on. Conditional because
|
|
2071
|
-
# hosts.<h>.provider is optional on purpose (see below): a profile that never
|
|
2072
|
-
# declares one is not asked to prove a match it cannot express.
|
|
2073
|
-
host_provider = host_data.get("provider")
|
|
2074
|
-
if (
|
|
2075
|
-
isinstance(host_provider, str)
|
|
2076
|
-
and host_provider
|
|
2077
|
-
and onto_review["provider"] != host_provider
|
|
2078
|
-
):
|
|
2079
|
-
raise LaunchError(
|
|
2080
|
-
f"hosts.{host_name}.onto_review.provider is {onto_review['provider']!r} "
|
|
2081
|
-
f"but that host speaks for {host_provider!r}; a cross-family contract "
|
|
2082
|
-
f"names the host's family, so an onto seat on another provider would "
|
|
2083
|
-
f"advertise a family it does not run on"
|
|
2084
|
-
)
|
|
2085
|
-
# OPTIONAL on purpose. Distinct from onto_review.provider (which pins what onto
|
|
2086
|
-
# is *told* to run), this is the host's own family identity. Every schema-v1
|
|
2189
|
+
# OPTIONAL on purpose. This is the host's own family identity. Every schema-v1
|
|
2087
2190
|
# profile written before it existed omits it, and requiring it would reject
|
|
2088
2191
|
# those profiles during load — before any preset is even selected, so a
|
|
2089
2192
|
# legacy-only config would stop launching at all. A host without a provider
|
|
@@ -2093,12 +2196,11 @@ def load_config(path: pathlib.Path) -> dict[str, Any]:
|
|
|
2093
2196
|
if provider is not None and (not isinstance(provider, str) or not provider):
|
|
2094
2197
|
raise LaunchError(f"hosts.{host_name}.provider must be a non-empty string")
|
|
2095
2198
|
# A host whose legacy family is hardcoded may only declare the provider that
|
|
2096
|
-
# family IS
|
|
2097
|
-
#
|
|
2098
|
-
#
|
|
2099
|
-
#
|
|
2100
|
-
#
|
|
2101
|
-
# only when declared — the field stays optional for schema-v1 profiles.
|
|
2199
|
+
# family IS: declare `hosts.claude.provider = "grok"` and the cross contract
|
|
2200
|
+
# still prints "run EVERY review route on Anthropic/Claude" over a grok seat.
|
|
2201
|
+
# Anchoring to LEGACY_HOST_FAMILY closes that, because the sentence and the
|
|
2202
|
+
# provider now read from one place. Still only when declared — the field stays
|
|
2203
|
+
# optional for schema-v1 profiles.
|
|
2102
2204
|
if provider is not None and host_name in LEGACY_HOST_FAMILY:
|
|
2103
2205
|
expected = LEGACY_HOST_FAMILY[host_name][1]
|
|
2104
2206
|
if provider != expected:
|
|
@@ -2210,11 +2312,15 @@ def cross_helm_command(plan: dict[str, Any]) -> str | None:
|
|
|
2210
2312
|
|
|
2211
2313
|
|
|
2212
2314
|
def cross_ultracode_command(plan: dict[str, Any]) -> str | None:
|
|
2213
|
-
"""The cross-family
|
|
2214
|
-
|
|
2215
|
-
|
|
2315
|
+
"""The cross-family deep reviewer command, or None. For a claude main it is
|
|
2316
|
+
the Codex CLI itself (codex-exec, non-interactive exec mode); for a codex
|
|
2317
|
+
main it is the claude backend (Claude Code /workflows ultracode)."""
|
|
2216
2318
|
if plan["review_host"] == "codex":
|
|
2217
|
-
|
|
2319
|
+
# Through capability_command, because the shipped codex-exec command is
|
|
2320
|
+
# `${backend}` — rereading the raw value would resolve the literal token.
|
|
2321
|
+
value = capability_command(
|
|
2322
|
+
plan.get("capabilities", {}).get(LEGACY_ULTRACODE_CAPABILITY, {}), plan, "codex"
|
|
2323
|
+
)
|
|
2218
2324
|
else:
|
|
2219
2325
|
value = plan["review_backend"]
|
|
2220
2326
|
try:
|
|
@@ -2225,24 +2331,24 @@ def cross_ultracode_command(plan: dict[str, Any]) -> str | None:
|
|
|
2225
2331
|
|
|
2226
2332
|
def route_availability(plan: dict[str, Any]) -> dict[str, bool]:
|
|
2227
2333
|
"""Which review routes can run right now. In same-family mode native gates on
|
|
2228
|
-
delegation and
|
|
2229
|
-
native/ultracode gate on the opposite-family dispatcher resolving
|
|
2230
|
-
a mounted onto plus a review_onto pin to flip its family."""
|
|
2334
|
+
delegation and ultracode on a resolvable capability. In cross-family mode
|
|
2335
|
+
native/ultracode gate on the opposite-family dispatcher resolving."""
|
|
2231
2336
|
capabilities = plan.get("capabilities", {})
|
|
2232
2337
|
if plan.get("review_family", "cross") == "same":
|
|
2233
2338
|
available = {"native": bool(plan["delegation"]), "slash": True}
|
|
2234
|
-
for route,
|
|
2235
|
-
|
|
2339
|
+
for route, capability_id in LEGACY_ROUTE_CAPABILITY.items():
|
|
2340
|
+
# Through capability_commands, because the shipped codex-exec command is
|
|
2341
|
+
# `${backend}`: the raw value is a token, not something PATH can answer.
|
|
2342
|
+
available[route] = any(
|
|
2343
|
+
_resolves(command)
|
|
2344
|
+
for command in capability_commands(capabilities.get(capability_id, {}), plan)
|
|
2345
|
+
)
|
|
2236
2346
|
return available
|
|
2237
|
-
onto_ok = _resolves(capabilities.get("onto", {}).get("command", "")) and bool(
|
|
2238
|
-
plan.get("review_onto")
|
|
2239
|
-
)
|
|
2240
2347
|
return {
|
|
2241
2348
|
"native": cross_native_command(plan) is not None,
|
|
2242
2349
|
# The host's own review command exists, but only for its own family; the
|
|
2243
2350
|
# cross branch of effective_review routes it to the PROPOSED floor.
|
|
2244
2351
|
"slash": True,
|
|
2245
|
-
"onto": onto_ok,
|
|
2246
2352
|
"ultracode": cross_ultracode_command(plan) is not None,
|
|
2247
2353
|
}
|
|
2248
2354
|
|
|
@@ -2960,10 +3066,9 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
|
|
|
2960
3066
|
# Cross review needs the opposite host + backend; without them fall back to
|
|
2961
3067
|
# same-family review (surfaced as review_family=same) rather than crashing.
|
|
2962
3068
|
review_family = "same"
|
|
2963
|
-
review_tiers,
|
|
3069
|
+
review_tiers, review_backend = {}, ""
|
|
2964
3070
|
else:
|
|
2965
3071
|
review_tiers = copy.deepcopy(opposite["tiers"])
|
|
2966
|
-
review_onto = copy.deepcopy(opposite.get("onto_review"))
|
|
2967
3072
|
review_backend = opposite_backend["command"]
|
|
2968
3073
|
return {
|
|
2969
3074
|
"host": host,
|
|
@@ -2991,7 +3096,6 @@ def build_plan(config: dict[str, Any], host: str, preset_name: str) -> dict[str,
|
|
|
2991
3096
|
"review_family": review_family,
|
|
2992
3097
|
"review_host": review_host,
|
|
2993
3098
|
"review_tiers": review_tiers,
|
|
2994
|
-
"review_onto": review_onto,
|
|
2995
3099
|
"review_backend": review_backend,
|
|
2996
3100
|
"agent_templates": copy.deepcopy(config["hosts"][host].get("agent_templates")),
|
|
2997
3101
|
"capabilities": copy.deepcopy(config.get("capabilities", {})),
|
|
@@ -3335,6 +3439,336 @@ def method_availability(method: ReviewMethod, config: dict[str, Any]) -> str:
|
|
|
3335
3439
|
return method.description
|
|
3336
3440
|
|
|
3337
3441
|
|
|
3442
|
+
USER_METHODS_HEADER = (
|
|
3443
|
+
"# agent-launch user review methods (and the capabilities they drive).\n"
|
|
3444
|
+
"# Merged at launch and validated exactly like shipped entries; never\n"
|
|
3445
|
+
"# deployed or overwritten by agent-bios install. A name that collides\n"
|
|
3446
|
+
"# with a shipped one is refused at launch rather than preferred.\n"
|
|
3447
|
+
)
|
|
3448
|
+
|
|
3449
|
+
|
|
3450
|
+
def _toml_str(value: str) -> str:
|
|
3451
|
+
"""Deterministic TOML string: JSON escaping is a valid TOML basic string."""
|
|
3452
|
+
return json.dumps(value, ensure_ascii=False)
|
|
3453
|
+
|
|
3454
|
+
|
|
3455
|
+
def _toml_str_list(values: list[str]) -> str:
|
|
3456
|
+
return "[" + ", ".join(_toml_str(v) for v in values) + "]"
|
|
3457
|
+
|
|
3458
|
+
|
|
3459
|
+
def _render_registration(answers: dict[str, Any]) -> str:
|
|
3460
|
+
"""The candidate's TOML block. Serialization only — every judgement about the
|
|
3461
|
+
content belongs to the reader that will accept or refuse it."""
|
|
3462
|
+
lines = [""]
|
|
3463
|
+
if answers.get("capability"):
|
|
3464
|
+
cap = answers["capability"]
|
|
3465
|
+
lines += [f"[capabilities.{cap['name']}]", f"command = {_toml_str(cap['command'])}"]
|
|
3466
|
+
if cap.get("install"):
|
|
3467
|
+
lines.append(f"install = {_toml_str(cap['install'])}")
|
|
3468
|
+
lines.append(
|
|
3469
|
+
"offers = [{ operation = " + _toml_str(cap["operation"])
|
|
3470
|
+
+ ", adapter = " + _toml_str(cap["adapter"])
|
|
3471
|
+
+ ", hosts = " + _toml_str_list(cap["hosts"]) + " }]"
|
|
3472
|
+
)
|
|
3473
|
+
lines.append("")
|
|
3474
|
+
lines += [
|
|
3475
|
+
f"[review_methods.{answers['id']}]",
|
|
3476
|
+
f"label = {_toml_str(answers['label'])}",
|
|
3477
|
+
f"description = {_toml_str(answers['description'])}",
|
|
3478
|
+
]
|
|
3479
|
+
if answers.get("capability"):
|
|
3480
|
+
lines += [
|
|
3481
|
+
f"capability = {_toml_str(answers['capability']['name'])}",
|
|
3482
|
+
f"operation = {_toml_str(answers['capability']['operation'])}",
|
|
3483
|
+
]
|
|
3484
|
+
lines += [
|
|
3485
|
+
f"instructions = {_toml_str(answers['instructions'])}",
|
|
3486
|
+
'output = "review-v1"',
|
|
3487
|
+
f"perspectives = {_toml_str_list(answers['perspectives'])}",
|
|
3488
|
+
f"trials = {answers['trials']}",
|
|
3489
|
+
'order = "fixed"',
|
|
3490
|
+
"swap_augmentation = false",
|
|
3491
|
+
'aggregation = "union"',
|
|
3492
|
+
f"severity_emits = {_toml_str_list(answers['severity_emits'])}",
|
|
3493
|
+
"severity_map = { "
|
|
3494
|
+
+ ", ".join(
|
|
3495
|
+
f"{_toml_str(k)} = {_toml_str(v)}" for k, v in answers["severity_map"].items()
|
|
3496
|
+
)
|
|
3497
|
+
+ " }",
|
|
3498
|
+
"",
|
|
3499
|
+
]
|
|
3500
|
+
return "\n".join(lines)
|
|
3501
|
+
|
|
3502
|
+
|
|
3503
|
+
def _trial_registration(
|
|
3504
|
+
config_path: pathlib.Path, block: str
|
|
3505
|
+
) -> str | None:
|
|
3506
|
+
"""Prove the candidate through the REAL pipeline: a temp dir holding a copy of
|
|
3507
|
+
the live config plus the user's local files with the block appended, run
|
|
3508
|
+
through the genuine load_config — same merge order, same validation, same
|
|
3509
|
+
collision refusal. Returns None on acceptance, the reader's message on refusal."""
|
|
3510
|
+
trial = pathlib.Path(tempfile.mkdtemp(prefix="agent-launch-register-"))
|
|
3511
|
+
shutil.copy(config_path, trial / config_path.name)
|
|
3512
|
+
for sibling in (USER_PRESETS_NAME, USER_LAUNCHER_NAME):
|
|
3513
|
+
source = config_path.with_name(sibling)
|
|
3514
|
+
if source.is_file():
|
|
3515
|
+
shutil.copy(source, trial / sibling)
|
|
3516
|
+
methods_source = user_methods_path(config_path)
|
|
3517
|
+
existing = methods_source.read_text() if methods_source.is_file() else USER_METHODS_HEADER
|
|
3518
|
+
candidate = trial / USER_METHODS_NAME
|
|
3519
|
+
candidate.write_text(existing + block)
|
|
3520
|
+
candidate.chmod(0o600)
|
|
3521
|
+
try:
|
|
3522
|
+
load_config(trial / config_path.name)
|
|
3523
|
+
except LaunchError as exc:
|
|
3524
|
+
return str(exc)
|
|
3525
|
+
return None
|
|
3526
|
+
|
|
3527
|
+
|
|
3528
|
+
def register_reviewer_wizard(
|
|
3529
|
+
ui: TextualUI | None,
|
|
3530
|
+
config_path: pathlib.Path,
|
|
3531
|
+
config: dict[str, Any],
|
|
3532
|
+
registry: dict[str, Any],
|
|
3533
|
+
) -> bool:
|
|
3534
|
+
"""Guided, ADD-ONLY registration into the user-owned methods file.
|
|
3535
|
+
|
|
3536
|
+
The wizard owns questions and serialization and nothing else: the only
|
|
3537
|
+
validity oracle is the real reader, run over a trial copy before a byte is
|
|
3538
|
+
written. Refusal shows the reader's own message and keeps the answers for
|
|
3539
|
+
another pass; nothing lands until the trial is green, and the write is an
|
|
3540
|
+
append through an atomic replace so the user's own formatting survives.
|
|
3541
|
+
Editing or removing entries stays a hand edit of the named file."""
|
|
3542
|
+
answers: dict[str, Any] = {}
|
|
3543
|
+
target = user_methods_path(config_path)
|
|
3544
|
+
while True:
|
|
3545
|
+
try:
|
|
3546
|
+
answers["id"] = prompt_text(
|
|
3547
|
+
t("wizard.id.label"), answers.get("id", "my-reviewer"), ui
|
|
3548
|
+
)
|
|
3549
|
+
answers["label"] = prompt_text(
|
|
3550
|
+
t("wizard.label.label"), answers.get("label", "My reviewer"), ui
|
|
3551
|
+
)
|
|
3552
|
+
answers["description"] = prompt_text(
|
|
3553
|
+
t("wizard.description.label"),
|
|
3554
|
+
answers.get("description", "Registered by the guided flow."), ui,
|
|
3555
|
+
)
|
|
3556
|
+
drives = choose(
|
|
3557
|
+
t("wizard.drives.title"),
|
|
3558
|
+
[
|
|
3559
|
+
MenuOption("tool", t("wizard.drives.tool.label"),
|
|
3560
|
+
t("wizard.drives.tool.description")),
|
|
3561
|
+
MenuOption("panel", t("wizard.drives.panel.label"),
|
|
3562
|
+
t("wizard.drives.panel.description")),
|
|
3563
|
+
],
|
|
3564
|
+
"tool" if answers.get("capability") else "panel",
|
|
3565
|
+
ui, allow_back=True,
|
|
3566
|
+
)
|
|
3567
|
+
if drives == "tool":
|
|
3568
|
+
cap = answers.get("capability") or {}
|
|
3569
|
+
cap["name"] = prompt_text(
|
|
3570
|
+
t("wizard.cap.name.label"), cap.get("name", answers["id"] + "-kit"), ui
|
|
3571
|
+
)
|
|
3572
|
+
cap["command"] = prompt_text(
|
|
3573
|
+
t("wizard.cap.command.label"), cap.get("command", cap["name"]), ui
|
|
3574
|
+
)
|
|
3575
|
+
install_line = prompt_text(
|
|
3576
|
+
t("wizard.cap.install.label"), cap.get("install") or "-", ui
|
|
3577
|
+
)
|
|
3578
|
+
cap["install"] = "" if install_line == "-" else install_line
|
|
3579
|
+
cap["operation"] = prompt_text(
|
|
3580
|
+
t("wizard.cap.operation.label"), cap.get("operation", "vendor-review"), ui
|
|
3581
|
+
)
|
|
3582
|
+
cap["adapter"] = choose(
|
|
3583
|
+
t("wizard.cap.adapter.title"),
|
|
3584
|
+
[
|
|
3585
|
+
MenuOption(name, name, REVIEW_ADAPTERS[name])
|
|
3586
|
+
for name in sorted(REVIEW_ADAPTERS)
|
|
3587
|
+
],
|
|
3588
|
+
cap.get("adapter", "exec-stdio-v1"), ui, allow_back=True,
|
|
3589
|
+
)
|
|
3590
|
+
hosts_pick = choose(
|
|
3591
|
+
t("wizard.cap.hosts.title"),
|
|
3592
|
+
[
|
|
3593
|
+
MenuOption("both", t("wizard.cap.hosts.both"), ""),
|
|
3594
|
+
MenuOption("codex", "codex", ""),
|
|
3595
|
+
MenuOption("claude", "claude", ""),
|
|
3596
|
+
],
|
|
3597
|
+
"both", ui, allow_back=True,
|
|
3598
|
+
)
|
|
3599
|
+
cap["hosts"] = (
|
|
3600
|
+
["codex", "claude"] if hosts_pick == "both" else [hosts_pick]
|
|
3601
|
+
)
|
|
3602
|
+
answers["capability"] = cap
|
|
3603
|
+
answers.setdefault("perspectives", ["refutation"])
|
|
3604
|
+
answers.setdefault("trials", 1)
|
|
3605
|
+
default_instructions = "run {command} on {model}/{effort}"
|
|
3606
|
+
else:
|
|
3607
|
+
answers["capability"] = None
|
|
3608
|
+
raw = prompt_text(
|
|
3609
|
+
t("wizard.perspectives.label"),
|
|
3610
|
+
", ".join(answers.get("perspectives") or ["correctness", "refutation"]),
|
|
3611
|
+
ui,
|
|
3612
|
+
)
|
|
3613
|
+
answers["perspectives"] = [
|
|
3614
|
+
item.strip() for item in raw.split(",") if item.strip()
|
|
3615
|
+
]
|
|
3616
|
+
while True:
|
|
3617
|
+
trials_raw = prompt_text(
|
|
3618
|
+
t("wizard.trials.label"), str(answers.get("trials", 2)), ui
|
|
3619
|
+
)
|
|
3620
|
+
try:
|
|
3621
|
+
answers["trials"] = int(trials_raw)
|
|
3622
|
+
break
|
|
3623
|
+
except ValueError:
|
|
3624
|
+
continue
|
|
3625
|
+
default_instructions = (
|
|
3626
|
+
"dispatch {command} as a fresh read-only process for {trials} "
|
|
3627
|
+
"isolated passes over {perspectives} on {model}/{effort}"
|
|
3628
|
+
)
|
|
3629
|
+
answers["instructions"] = prompt_text(
|
|
3630
|
+
t("wizard.instructions.label"),
|
|
3631
|
+
answers.get("instructions", default_instructions), ui,
|
|
3632
|
+
)
|
|
3633
|
+
raw = prompt_text(
|
|
3634
|
+
t("wizard.emits.label"),
|
|
3635
|
+
", ".join(answers.get("severity_emits") or SEVERITY_LADDER), ui,
|
|
3636
|
+
)
|
|
3637
|
+
answers["severity_emits"] = [
|
|
3638
|
+
item.strip() for item in raw.split(",") if item.strip()
|
|
3639
|
+
]
|
|
3640
|
+
mapping = {}
|
|
3641
|
+
for value in answers["severity_emits"]:
|
|
3642
|
+
if value in SEVERITY_LADDER:
|
|
3643
|
+
mapping[value] = value
|
|
3644
|
+
continue
|
|
3645
|
+
mapping[value] = choose(
|
|
3646
|
+
t("wizard.map.title") + f" — {value}",
|
|
3647
|
+
[MenuOption(level, level, "") for level in SEVERITY_LADDER],
|
|
3648
|
+
"medium", ui, allow_back=True,
|
|
3649
|
+
)
|
|
3650
|
+
answers["severity_map"] = mapping
|
|
3651
|
+
except BackRequested:
|
|
3652
|
+
return False
|
|
3653
|
+
|
|
3654
|
+
block = _render_registration(answers)
|
|
3655
|
+
# A symlinked methods file is written THROUGH, never replaced: os.replace
|
|
3656
|
+
# on the link path would swap the link for a regular file and strand the
|
|
3657
|
+
# canonical target. Resolved once; every later read/compare/write uses it.
|
|
3658
|
+
write_target = (
|
|
3659
|
+
target.resolve() if (target.exists() or target.is_symlink()) else target
|
|
3660
|
+
)
|
|
3661
|
+
existed = write_target.is_file()
|
|
3662
|
+
# BYTES, captured BEFORE the trial: the write-time comparison against this
|
|
3663
|
+
# is what catches a concurrent hand edit between proof and landing, and
|
|
3664
|
+
# text-mode round-trips would silently normalize a CRLF file.
|
|
3665
|
+
before = write_target.read_bytes() if existed else USER_METHODS_HEADER.encode()
|
|
3666
|
+
verdict = _trial_registration(config_path, block)
|
|
3667
|
+
if verdict is None:
|
|
3668
|
+
confirm = choose(
|
|
3669
|
+
t("wizard.confirm.title"),
|
|
3670
|
+
[
|
|
3671
|
+
MenuOption("write", t("wizard.confirm.write.label"),
|
|
3672
|
+
t("wizard.confirm.write.description")),
|
|
3673
|
+
MenuOption("edit", t("wizard.confirm.edit.label"), ""),
|
|
3674
|
+
],
|
|
3675
|
+
"write", ui, allow_back=True,
|
|
3676
|
+
corpus_lines=[t("wizard.confirm.target") + f" {target}", *block.splitlines()],
|
|
3677
|
+
)
|
|
3678
|
+
if confirm == "edit":
|
|
3679
|
+
continue
|
|
3680
|
+
# The compare-and-replace is atomic under an exclusive lock, closing
|
|
3681
|
+
# the recheck→replace window a bare comparison leaves open. The lock
|
|
3682
|
+
# file is advisory and launcher-owned; hand edits do not take it, so
|
|
3683
|
+
# the byte comparison inside the lock remains the real guard.
|
|
3684
|
+
lock_path = write_target.with_name(write_target.name + ".lock")
|
|
3685
|
+
# O_NOFOLLOW and no truncation: opening a lock path with "w" follows
|
|
3686
|
+
# a planted symlink and truncates whatever it points at, merely by
|
|
3687
|
+
# opening the wizard.
|
|
3688
|
+
lock_fd = os.open(
|
|
3689
|
+
lock_path, os.O_CREAT | os.O_WRONLY | os.O_NOFOLLOW, 0o600
|
|
3690
|
+
)
|
|
3691
|
+
with os.fdopen(lock_fd, "w") as lock_handle:
|
|
3692
|
+
fcntl.flock(lock_handle, fcntl.LOCK_EX)
|
|
3693
|
+
# Existence is part of the snapshot: a file created (or removed)
|
|
3694
|
+
# since the trial must read as a race even when its bytes happen
|
|
3695
|
+
# to equal the header the wizard would have invented.
|
|
3696
|
+
if write_target.is_file() != existed:
|
|
3697
|
+
_corpus_info(ui, t("wizard.title"), [t("wizard.raced.line")],
|
|
3698
|
+
back_hint=t("corpus.back.hint"))
|
|
3699
|
+
return False
|
|
3700
|
+
current = (
|
|
3701
|
+
write_target.read_bytes() if write_target.is_file()
|
|
3702
|
+
else USER_METHODS_HEADER.encode()
|
|
3703
|
+
)
|
|
3704
|
+
if current != before:
|
|
3705
|
+
_corpus_info(ui, t("wizard.title"), [t("wizard.raced.line")],
|
|
3706
|
+
back_hint=t("corpus.back.hint"))
|
|
3707
|
+
return False
|
|
3708
|
+
mode = (write_target.stat().st_mode & 0o777) if existed else 0o600
|
|
3709
|
+
temporary = write_target.with_name(
|
|
3710
|
+
f".{write_target.name}.{os.getpid()}.tmp"
|
|
3711
|
+
)
|
|
3712
|
+
temporary.write_bytes(before + block.encode())
|
|
3713
|
+
temporary.chmod(mode)
|
|
3714
|
+
os.replace(temporary, write_target)
|
|
3715
|
+
# Unreachable while the trial is the real pipeline — and
|
|
3716
|
+
# load-bearing exactly when it is not: a post-write failure must
|
|
3717
|
+
# restore the pre-write state (bytes, mode, or ABSENCE) and say
|
|
3718
|
+
# so, never leave a corrupt registry behind a green screen.
|
|
3719
|
+
try:
|
|
3720
|
+
fresh = load_config(config_path)
|
|
3721
|
+
except LaunchError:
|
|
3722
|
+
fresh = None
|
|
3723
|
+
if fresh is None or answers["id"] not in load_review_methods(fresh):
|
|
3724
|
+
# Restore ONLY when the file still holds exactly our write:
|
|
3725
|
+
# unconditional rollback would destroy an edit that landed in
|
|
3726
|
+
# the meantime; if the bytes moved, the human owns the merge.
|
|
3727
|
+
current = (
|
|
3728
|
+
write_target.read_bytes() if write_target.is_file() else b""
|
|
3729
|
+
)
|
|
3730
|
+
if current != before + block.encode():
|
|
3731
|
+
_corpus_info(
|
|
3732
|
+
ui, t("wizard.title"), [t("wizard.postwrite.line")],
|
|
3733
|
+
back_hint=t("corpus.back.hint"),
|
|
3734
|
+
)
|
|
3735
|
+
return False
|
|
3736
|
+
if existed:
|
|
3737
|
+
restore = write_target.with_name(
|
|
3738
|
+
f".{write_target.name}.{os.getpid()}.restore"
|
|
3739
|
+
)
|
|
3740
|
+
restore.write_bytes(before)
|
|
3741
|
+
restore.chmod(mode)
|
|
3742
|
+
os.replace(restore, write_target)
|
|
3743
|
+
else:
|
|
3744
|
+
write_target.unlink(missing_ok=True)
|
|
3745
|
+
_corpus_info(ui, t("wizard.title"), [t("wizard.postwrite.line")],
|
|
3746
|
+
back_hint=t("corpus.back.hint"))
|
|
3747
|
+
return False # pre-write state restored above
|
|
3748
|
+
config.clear()
|
|
3749
|
+
config.update(fresh)
|
|
3750
|
+
registry.clear()
|
|
3751
|
+
registry.update(load_review_methods(config))
|
|
3752
|
+
_corpus_info(
|
|
3753
|
+
ui, t("wizard.title"),
|
|
3754
|
+
[t("wizard.done.line") + f" {answers['id']}"],
|
|
3755
|
+
back_hint=t("corpus.back.hint"),
|
|
3756
|
+
)
|
|
3757
|
+
return True
|
|
3758
|
+
action = choose(
|
|
3759
|
+
t("wizard.refused.title"),
|
|
3760
|
+
[
|
|
3761
|
+
MenuOption("edit", t("wizard.refused.edit.label"),
|
|
3762
|
+
t("wizard.refused.edit.description")),
|
|
3763
|
+
MenuOption("contract", t("wizard.refused.contract.label"), ""),
|
|
3764
|
+
],
|
|
3765
|
+
"edit", ui, allow_back=True,
|
|
3766
|
+
corpus_lines=[t("wizard.refused.header"), "", *verdict.splitlines()],
|
|
3767
|
+
)
|
|
3768
|
+
if action == "contract":
|
|
3769
|
+
register_reviewer_info(ui, config_path)
|
|
3770
|
+
|
|
3771
|
+
|
|
3338
3772
|
def register_reviewer_info(ui: TextualUI | None, config_path: pathlib.Path) -> None:
|
|
3339
3773
|
"""Where a reviewer this launcher has never seen comes from.
|
|
3340
3774
|
|
|
@@ -3524,7 +3958,7 @@ def review_editor(
|
|
|
3524
3958
|
addable[0], ui, allow_back=True,
|
|
3525
3959
|
)
|
|
3526
3960
|
if method_id == REGISTER_REVIEWER:
|
|
3527
|
-
|
|
3961
|
+
register_reviewer_wizard(ui, config_path, config, registry)
|
|
3528
3962
|
continue
|
|
3529
3963
|
except BackRequested:
|
|
3530
3964
|
continue
|
|
@@ -3859,7 +4293,7 @@ def customize(
|
|
|
3859
4293
|
missing = [
|
|
3860
4294
|
route
|
|
3861
4295
|
for route in REVIEW_ROUTES[name]
|
|
3862
|
-
if route in
|
|
4296
|
+
if route in LEGACY_ROUTE_CAPABILITY and not available_routes[route]
|
|
3863
4297
|
]
|
|
3864
4298
|
if missing:
|
|
3865
4299
|
note = "degrades to same-family native (PROPOSED)" if cross else "degrades to native"
|
|
@@ -4186,13 +4620,29 @@ def corpus_summary_lines(status: dict[str, Any] | None) -> list[str]:
|
|
|
4186
4620
|
f"{name} {layers[name]}" for name in order if layers.get(name)
|
|
4187
4621
|
) or "none"
|
|
4188
4622
|
by_status = status.get("summary", {}).get("by_status", {})
|
|
4189
|
-
|
|
4623
|
+
lines = [
|
|
4190
4624
|
head,
|
|
4191
4625
|
f"Mechanisms {layer_text}",
|
|
4192
4626
|
f"Ledger placed {by_status.get('placed', 0)} · "
|
|
4193
4627
|
f"incubating {by_status.get('incubating', 0) + by_status.get('incubating-G', 0)} · "
|
|
4194
4628
|
f"versions {len(status.get('versions', []))}",
|
|
4195
4629
|
]
|
|
4630
|
+
domains = status.get("domains")
|
|
4631
|
+
if isinstance(domains, dict):
|
|
4632
|
+
applied = domains.get("applied")
|
|
4633
|
+
lines.append(
|
|
4634
|
+
"Domains "
|
|
4635
|
+
+ ("(none selected yet)" if applied is None else ", ".join(applied) or "core+infra only")
|
|
4636
|
+
)
|
|
4637
|
+
last_apply = status.get("last_apply")
|
|
4638
|
+
if isinstance(last_apply, dict) and last_apply.get("outcome") not in (None, "applied"):
|
|
4639
|
+
# A failed apply must be loud on the panel, not a fact buried in a log:
|
|
4640
|
+
# the deployed corpus and the requested selection disagree right now.
|
|
4641
|
+
lines.append(
|
|
4642
|
+
f"⚠ LAST APPLY {last_apply.get('outcome')} at {last_apply.get('at')} — "
|
|
4643
|
+
f"requested: {', '.join(last_apply.get('requested') or []) or 'core+infra only'}"
|
|
4644
|
+
)
|
|
4645
|
+
return lines
|
|
4196
4646
|
|
|
4197
4647
|
|
|
4198
4648
|
def _corpus_info(
|
|
@@ -4384,11 +4834,161 @@ def mode_default_preset(presets: dict[str, Any], mode: str) -> str | None:
|
|
|
4384
4834
|
return names[0] if names else None
|
|
4385
4835
|
|
|
4386
4836
|
|
|
4837
|
+
class CorpusApplyRequested(Exception):
|
|
4838
|
+
"""Raised out of the interactive flow when the user confirms a corpus
|
|
4839
|
+
selection: the Textual app must be torn down before the installer owns the
|
|
4840
|
+
terminal, so the request travels as control flow and main() runs the apply
|
|
4841
|
+
in the plain terminal, then re-enters the picker."""
|
|
4842
|
+
|
|
4843
|
+
def __init__(self, selection: list[str]):
|
|
4844
|
+
super().__init__(",".join(selection))
|
|
4845
|
+
self.selection = selection
|
|
4846
|
+
|
|
4847
|
+
|
|
4848
|
+
CORPUS_OPTION = "__corpus__"
|
|
4849
|
+
# A plain value, because the numbered prompt prints it as the default label.
|
|
4850
|
+
# Collision with a domain id is structurally impossible: domain slugs come from
|
|
4851
|
+
# compose/domains.json, which this repo owns and which carries no "apply".
|
|
4852
|
+
CORPUS_APPLY = "apply"
|
|
4853
|
+
|
|
4854
|
+
|
|
4855
|
+
def run_corpus_apply(selection: list[str]) -> None:
|
|
4856
|
+
"""Run the installer's onboard loop for the selection, in the caller's
|
|
4857
|
+
terminal. The installer alone deploys, canaries, and records the outcome;
|
|
4858
|
+
this function only streams it and reports the exit."""
|
|
4859
|
+
status = load_corpus_status() or {}
|
|
4860
|
+
domains = ",".join(selection) if selection else "none"
|
|
4861
|
+
front = shutil.which("agent-bios")
|
|
4862
|
+
if front:
|
|
4863
|
+
argv = [front, "onboard", "--domains", domains]
|
|
4864
|
+
else:
|
|
4865
|
+
repo = status.get("repo") or ""
|
|
4866
|
+
installer = pathlib.Path(repo) / "install.sh" if repo else None
|
|
4867
|
+
if installer is None or not installer.is_file():
|
|
4868
|
+
print(
|
|
4869
|
+
"agent-launch: no agent-bios on PATH and no usable repo in "
|
|
4870
|
+
f"corpus-status.json; run manually: agent-bios onboard --domains {domains}",
|
|
4871
|
+
file=sys.stderr,
|
|
4872
|
+
)
|
|
4873
|
+
return
|
|
4874
|
+
argv = ["bash", str(installer), "onboard", "--domains", domains]
|
|
4875
|
+
print(f"\nagent-launch: applying corpus selection: {' '.join(argv)}\n", flush=True)
|
|
4876
|
+
proc = subprocess.run(argv)
|
|
4877
|
+
if proc.returncode != 0:
|
|
4878
|
+
print(
|
|
4879
|
+
f"\nagent-launch: corpus apply FAILED (exit {proc.returncode}); the "
|
|
4880
|
+
"corpus panel shows the recorded outcome.", file=sys.stderr,
|
|
4881
|
+
)
|
|
4882
|
+
else:
|
|
4883
|
+
print("\nagent-launch: corpus selection applied.", flush=True)
|
|
4884
|
+
|
|
4885
|
+
|
|
4886
|
+
def corpus_checklist(ui: "TextualUI | None") -> None:
|
|
4887
|
+
"""Toggle-and-apply loop over the optional domain packages.
|
|
4888
|
+
|
|
4889
|
+
The list and the applied set come from corpus-status.json — the installer's
|
|
4890
|
+
projection — never from repo paths this launcher cannot know. Selection state
|
|
4891
|
+
lives only on this screen; Apply hands the exact set to the installer
|
|
4892
|
+
(raising through the Textual app so the terminal is free), and Esc leaves the
|
|
4893
|
+
deployed corpus untouched."""
|
|
4894
|
+
status = load_corpus_status()
|
|
4895
|
+
domains = (status or {}).get("domains")
|
|
4896
|
+
if not isinstance(domains, dict) or not isinstance(domains.get("available"), list):
|
|
4897
|
+
_corpus_info(
|
|
4898
|
+
ui, t("corpus.title"),
|
|
4899
|
+
[t("corpus.unavailable.line1"), t("corpus.unavailable.line2")],
|
|
4900
|
+
back_hint=t("corpus.back.hint"),
|
|
4901
|
+
)
|
|
4902
|
+
return
|
|
4903
|
+
available = domains["available"]
|
|
4904
|
+
applied_raw = domains.get("applied")
|
|
4905
|
+
# None is "never selected", [] is a verified core+infra-only selection —
|
|
4906
|
+
# the projection keeps them distinct, so the Apply rule must too: a
|
|
4907
|
+
# first-time user applying core+infra only IS a change.
|
|
4908
|
+
applied = set(applied_raw or [])
|
|
4909
|
+
never_applied = applied_raw is None
|
|
4910
|
+
toggles = set(applied)
|
|
4911
|
+
while True:
|
|
4912
|
+
options = [
|
|
4913
|
+
MenuOption(
|
|
4914
|
+
name,
|
|
4915
|
+
f"[{'x' if name in toggles else ' '}] {name}",
|
|
4916
|
+
t("corpus.toggle.description"),
|
|
4917
|
+
)
|
|
4918
|
+
for name in available
|
|
4919
|
+
]
|
|
4920
|
+
changed = toggles != applied or never_applied
|
|
4921
|
+
pending = "" if not changed else (
|
|
4922
|
+
" → " + (", ".join(sorted(toggles)) or "core+infra only")
|
|
4923
|
+
)
|
|
4924
|
+
options.append(
|
|
4925
|
+
MenuOption(
|
|
4926
|
+
CORPUS_APPLY,
|
|
4927
|
+
t("corpus.apply.label") + pending,
|
|
4928
|
+
t("corpus.apply.description"),
|
|
4929
|
+
enabled=changed,
|
|
4930
|
+
unavailable_reason=t("corpus.apply.unchanged"),
|
|
4931
|
+
)
|
|
4932
|
+
)
|
|
4933
|
+
try:
|
|
4934
|
+
choice = choose(
|
|
4935
|
+
t("corpus.title"), options, CORPUS_APPLY, ui, allow_back=True,
|
|
4936
|
+
corpus_lines=[t("corpus.core.line"), *corpus_summary_lines(status)],
|
|
4937
|
+
)
|
|
4938
|
+
except BackRequested:
|
|
4939
|
+
return
|
|
4940
|
+
if choice == CORPUS_APPLY:
|
|
4941
|
+
selection = sorted(toggles)
|
|
4942
|
+
if ui is None:
|
|
4943
|
+
run_corpus_apply(selection)
|
|
4944
|
+
return
|
|
4945
|
+
raise CorpusApplyRequested(selection)
|
|
4946
|
+
if choice in toggles:
|
|
4947
|
+
toggles.discard(choice)
|
|
4948
|
+
else:
|
|
4949
|
+
toggles.add(choice)
|
|
4950
|
+
|
|
4951
|
+
|
|
4952
|
+
LANGUAGE_OPTION = "__language__"
|
|
4953
|
+
# Language names render in their own language BY DESIGN — a reader hunting for
|
|
4954
|
+
# their language must be able to recognise it whatever UI language is active — so
|
|
4955
|
+
# these labels are deliberately catalog-independent.
|
|
4956
|
+
I18N_DISPLAY = {"en": "English", "ko": "한국어", "ja": "日本語"}
|
|
4957
|
+
|
|
4958
|
+
|
|
4959
|
+
def choose_language(
|
|
4960
|
+
config_path: pathlib.Path | None, ui: "TextualUI | None"
|
|
4961
|
+
) -> None:
|
|
4962
|
+
"""Pick and persist the UI language, then reload the catalogs so the
|
|
4963
|
+
menu the user returns to already speaks the choice."""
|
|
4964
|
+
if config_path is None:
|
|
4965
|
+
return
|
|
4966
|
+
descriptions = {
|
|
4967
|
+
"en": t("language.en.description"),
|
|
4968
|
+
"ko": t("language.ko.description"),
|
|
4969
|
+
"ja": t("language.ja.description"),
|
|
4970
|
+
}
|
|
4971
|
+
options = [
|
|
4972
|
+
MenuOption(code, I18N_DISPLAY[code], descriptions[code])
|
|
4973
|
+
for code in I18N_LANGUAGES
|
|
4974
|
+
]
|
|
4975
|
+
try:
|
|
4976
|
+
picked = choose(
|
|
4977
|
+
t("language.title"), options, ui_language(config_path), ui,
|
|
4978
|
+
allow_back=True,
|
|
4979
|
+
)
|
|
4980
|
+
except BackRequested:
|
|
4981
|
+
return
|
|
4982
|
+
save_language(config_path, picked)
|
|
4983
|
+
load_catalogs(config_path)
|
|
4984
|
+
|
|
4985
|
+
|
|
4387
4986
|
def pick_mode_and_preset(
|
|
4388
4987
|
config: dict[str, Any],
|
|
4389
4988
|
host: str,
|
|
4390
4989
|
ui: TextualUI | None,
|
|
4391
4990
|
resume_mode: str | None,
|
|
4991
|
+
config_path: pathlib.Path | None = None,
|
|
4392
4992
|
) -> tuple[str, bool, str]:
|
|
4393
4993
|
"""Root menu: a 3-way mode picker (Software Engineer / Builder / Session
|
|
4394
4994
|
distill), then that mode's preset submenu (Software Engineer and Builder
|
|
@@ -4405,26 +5005,31 @@ def pick_mode_and_preset(
|
|
|
4405
5005
|
while True:
|
|
4406
5006
|
if mode is None:
|
|
4407
5007
|
mode_options = [
|
|
4408
|
-
MenuOption(
|
|
4409
|
-
SWE_MODE,
|
|
4410
|
-
"Software Engineer",
|
|
4411
|
-
"Repo-scoped work that defers to the project's own "
|
|
4412
|
-
"AGENTS.md/CLAUDE.md: a bare Vanilla session, or Custom to "
|
|
4413
|
-
"configure one.",
|
|
4414
|
-
),
|
|
5008
|
+
MenuOption(SWE_MODE, t("mode.swe.label"), t("mode.swe.description")),
|
|
4415
5009
|
MenuOption(
|
|
4416
5010
|
DEFAULT_PRESET_MODE,
|
|
4417
|
-
"
|
|
4418
|
-
"
|
|
4419
|
-
"presets; includes Custom.",
|
|
5011
|
+
t("mode.builder.label"),
|
|
5012
|
+
t("mode.builder.description"),
|
|
4420
5013
|
),
|
|
4421
5014
|
MenuOption(
|
|
4422
5015
|
DISTILL_MODE,
|
|
4423
|
-
"
|
|
4424
|
-
"
|
|
4425
|
-
"packages, versions, and session start.",
|
|
5016
|
+
t("mode.distill.label"),
|
|
5017
|
+
t("mode.distill.description"),
|
|
4426
5018
|
),
|
|
4427
5019
|
]
|
|
5020
|
+
mode_options.append(
|
|
5021
|
+
MenuOption(
|
|
5022
|
+
CORPUS_OPTION, t("corpus.label"), t("corpus.description")
|
|
5023
|
+
)
|
|
5024
|
+
)
|
|
5025
|
+
if config_path is not None:
|
|
5026
|
+
mode_options.append(
|
|
5027
|
+
MenuOption(
|
|
5028
|
+
LANGUAGE_OPTION,
|
|
5029
|
+
t("language.label"),
|
|
5030
|
+
t("language.description"),
|
|
5031
|
+
)
|
|
5032
|
+
)
|
|
4428
5033
|
|
|
4429
5034
|
def preview_mode(value: str) -> dict[str, Any] | None:
|
|
4430
5035
|
target = (
|
|
@@ -4438,13 +5043,21 @@ def pick_mode_and_preset(
|
|
|
4438
5043
|
if ui is not None and initial is not None:
|
|
4439
5044
|
ui.set_plan(initial)
|
|
4440
5045
|
mode = choose(
|
|
4441
|
-
"
|
|
5046
|
+
t("mode.title"),
|
|
4442
5047
|
mode_options,
|
|
4443
5048
|
DEFAULT_PRESET_MODE,
|
|
4444
5049
|
ui,
|
|
4445
5050
|
preview=preview_mode,
|
|
4446
5051
|
corpus_lines=corpus_summary_lines(load_corpus_status()),
|
|
4447
5052
|
)
|
|
5053
|
+
if mode == CORPUS_OPTION:
|
|
5054
|
+
corpus_checklist(ui)
|
|
5055
|
+
mode = None
|
|
5056
|
+
continue
|
|
5057
|
+
if mode == LANGUAGE_OPTION:
|
|
5058
|
+
choose_language(config_path, ui)
|
|
5059
|
+
mode = None
|
|
5060
|
+
continue
|
|
4448
5061
|
if mode == DISTILL_MODE:
|
|
4449
5062
|
if distill_hub(config, ui) != "start":
|
|
4450
5063
|
mode = None
|
|
@@ -4511,7 +5124,7 @@ def select_plan(
|
|
|
4511
5124
|
selected_custom = custom_requested
|
|
4512
5125
|
if show_picker:
|
|
4513
5126
|
selected_name, selected_custom, resume_mode = pick_mode_and_preset(
|
|
4514
|
-
config, host, ui, resume_mode
|
|
5127
|
+
config, host, ui, resume_mode, config_path
|
|
4515
5128
|
)
|
|
4516
5129
|
if selected_name not in presets:
|
|
4517
5130
|
raise LaunchError(f"unknown preset: {selected_name}")
|
|
@@ -4605,7 +5218,16 @@ def _same_review_route(plan: dict[str, Any], effective: list[str], dropped: list
|
|
|
4605
5218
|
if isinstance(route, dict):
|
|
4606
5219
|
route = route[plan["host"]]
|
|
4607
5220
|
if "ultracode" in effective:
|
|
4608
|
-
|
|
5221
|
+
# Through capability_commands: the shipped codex-exec command is `${backend}`,
|
|
5222
|
+
# and route_availability has already proven one of these resolves.
|
|
5223
|
+
executable = next(
|
|
5224
|
+
resolve_command(command)
|
|
5225
|
+
for command in capability_commands(
|
|
5226
|
+
plan["capabilities"].get(LEGACY_ULTRACODE_CAPABILITY, {}), plan
|
|
5227
|
+
)
|
|
5228
|
+
if _resolves(command)
|
|
5229
|
+
)
|
|
5230
|
+
route = f"{route} Deep-exec executable: {executable}."
|
|
4609
5231
|
if "ultracode" in REVIEW_ROUTES[requested]:
|
|
4610
5232
|
route = (
|
|
4611
5233
|
f"{route} If the Codex-backed route is unavailable or unauthenticated "
|
|
@@ -4635,7 +5257,7 @@ def _cross_review_route(
|
|
|
4635
5257
|
native = cross_native_command(plan)
|
|
4636
5258
|
if review_host == "codex":
|
|
4637
5259
|
helm = cross_helm_command(plan)
|
|
4638
|
-
fanout = f" (or {helm} --mode review for
|
|
5260
|
+
fanout = f" (or {helm} --mode review for review fan-out)" if helm else ""
|
|
4639
5261
|
parts.append(
|
|
4640
5262
|
f"native: dispatch {native} --profile hermetic --model <review tier> "
|
|
4641
5263
|
f"--effort <e> --sandbox read-only{fanout}, self-contained packet on stdin, "
|
|
@@ -4647,31 +5269,19 @@ def _cross_review_route(
|
|
|
4647
5269
|
"--permission-mode plan --append-system-prompt <read-only reviewer role>, "
|
|
4648
5270
|
"self-contained packet, bounded report."
|
|
4649
5271
|
)
|
|
4650
|
-
if "onto" in effective:
|
|
4651
|
-
onto = plan["review_onto"]
|
|
4652
|
-
# Neither `auth` nor `effort`, both deliberately. onto's per-call override
|
|
4653
|
-
# REPLACES a seat whose route differs and OVERLAYs one whose route matches,
|
|
4654
|
-
# so a field stated here wins over the user's own onto settings.
|
|
4655
|
-
#
|
|
4656
|
-
# auth: onto selects the metered route only when a seat says so in writing —
|
|
4657
|
-
# either `auth = "api_key"` or an `api_key_env` naming the variable to call —
|
|
4658
|
-
# and defaults to the subscription worker otherwise. So an omitted auth cannot
|
|
4659
|
-
# land on metered, and stating one only overrides a user who chose it: the
|
|
4660
|
-
# switch to oauth is a route change, which drops their api_key_env with it.
|
|
4661
|
-
#
|
|
4662
|
-
# effort: a legacy setup names no reviewer effort at all (the enum hands the
|
|
4663
|
-
# reader the whole tier table), so there is none to state, and inventing one
|
|
4664
|
-
# would flatten every per-seat effort the user tuned.
|
|
4665
|
-
parts.append(
|
|
4666
|
-
f'onto: call onto_review/onto_prepare_review with llmOverride='
|
|
4667
|
-
f'{{"provider":"{onto["provider"]}","model":"{onto["model"]}"}} so onto runs {review_family}.'
|
|
4668
|
-
)
|
|
4669
5272
|
if "ultracode" in effective:
|
|
4670
5273
|
ultracode = cross_ultracode_command(plan)
|
|
4671
5274
|
if review_host == "codex":
|
|
5275
|
+
# `ultra` is the mechanism, not a seat binding: the whole point of this
|
|
5276
|
+
# route is the deepest single pass, so the effort is stated as a literal
|
|
5277
|
+
# the way the claude arm states its keyword. The model stays the review
|
|
5278
|
+
# host's own frontier tier — the seat table remains the model authority.
|
|
5279
|
+
deep_model = plan["review_tiers"].get("frontier", {}).get("model", "<frontier model>")
|
|
4672
5280
|
parts.append(
|
|
4673
|
-
f
|
|
4674
|
-
f"
|
|
5281
|
+
f'deep exec: run {ultracode} exec -s read-only -m {deep_model} '
|
|
5282
|
+
f'-c model_reasoning_effort="ultra" with a self-contained review packet '
|
|
5283
|
+
f"on stdin for {review_family} deep review (append -c "
|
|
5284
|
+
f'service_tier="fast" only when a faster, shallower pass is explicitly wanted).'
|
|
4675
5285
|
)
|
|
4676
5286
|
else:
|
|
4677
5287
|
parts.append(
|
|
@@ -4680,13 +5290,7 @@ def _cross_review_route(
|
|
|
4680
5290
|
"workflow-orchestration review."
|
|
4681
5291
|
)
|
|
4682
5292
|
if dropped:
|
|
4683
|
-
|
|
4684
|
-
f"onto (add a [hosts.{review_host}].onto_review pin)"
|
|
4685
|
-
if route == "onto" and not plan.get("review_onto")
|
|
4686
|
-
else route
|
|
4687
|
-
for route in dropped
|
|
4688
|
-
]
|
|
4689
|
-
parts.append(f"Unavailable cross-family route(s) at launch: {', '.join(detail)}.")
|
|
5293
|
+
parts.append(f"Unavailable cross-family route(s) at launch: {', '.join(dropped)}.")
|
|
4690
5294
|
if floor == "native":
|
|
4691
5295
|
parts.append(
|
|
4692
5296
|
"No cross-family route resolved at launch; using same-family native "
|
|
@@ -4808,10 +5412,9 @@ def review_mcp_servers(plan: dict[str, Any]) -> list[tuple[str, str]]:
|
|
|
4808
5412
|
a method's identity, so a third-party method reusing the tag is wired too."""
|
|
4809
5413
|
report = plan.get("review_report")
|
|
4810
5414
|
if report is None:
|
|
4811
|
-
|
|
4812
|
-
|
|
4813
|
-
|
|
4814
|
-
return [("onto", resolve_command(plan["capabilities"]["onto"]["command"]))]
|
|
5415
|
+
# No legacy route is MCP-backed: both deep routes are host CLIs dispatched
|
|
5416
|
+
# as subprocesses, so a legacy plan registers nothing.
|
|
5417
|
+
return []
|
|
4815
5418
|
# One entry per capability AND HOST, in first-selected order. Two methods may share a
|
|
4816
5419
|
# capability, and appending per row registered it twice — visible on codex as
|
|
4817
5420
|
# duplicated -c overrides and invisible on claude, whose dict collapsed them. Keying on
|
|
@@ -5239,6 +5842,11 @@ def main(argv: list[str]) -> int:
|
|
|
5239
5842
|
exec_backend(command, [*passthrough, *args.forward])
|
|
5240
5843
|
|
|
5241
5844
|
interactive_setup = not args.preset or args.custom
|
|
5845
|
+
if interactive_setup:
|
|
5846
|
+
# UI text catalogs feed only interactive screens; headless runs (dry-run,
|
|
5847
|
+
# --preset) render no interface text and must not gain a stderr notice from a
|
|
5848
|
+
# fixture directory that ships no catalogs.
|
|
5849
|
+
load_catalogs(config_path)
|
|
5242
5850
|
use_textual = (
|
|
5243
5851
|
interactive_setup
|
|
5244
5852
|
and tty
|
|
@@ -5252,14 +5860,23 @@ def main(argv: list[str]) -> int:
|
|
|
5252
5860
|
"agent-launch: rich terminal UI unavailable; using numbered prompts.",
|
|
5253
5861
|
file=sys.stderr,
|
|
5254
5862
|
)
|
|
5255
|
-
|
|
5256
|
-
|
|
5257
|
-
|
|
5258
|
-
|
|
5259
|
-
|
|
5260
|
-
|
|
5261
|
-
|
|
5262
|
-
|
|
5863
|
+
while True:
|
|
5864
|
+
try:
|
|
5865
|
+
if use_textual:
|
|
5866
|
+
plan = run_textual_flow(
|
|
5867
|
+
config, args.host, args.preset, args.custom, config_path
|
|
5868
|
+
)
|
|
5869
|
+
else:
|
|
5870
|
+
plan = select_plan(
|
|
5871
|
+
config, args.host, args.preset, args.custom, config_path=config_path
|
|
5872
|
+
)
|
|
5873
|
+
break
|
|
5874
|
+
except CorpusApplyRequested as request:
|
|
5875
|
+
# The Textual app is already torn down; the installer owns the
|
|
5876
|
+
# terminal for the duration, and the picker re-opens on a fresh
|
|
5877
|
+
# status projection afterwards.
|
|
5878
|
+
run_corpus_apply(request.selection)
|
|
5879
|
+
continue
|
|
5263
5880
|
validate_review_setup(plan)
|
|
5264
5881
|
projected = [*project_args(plan, materialize_agents=not args.dry_run), *args.forward]
|
|
5265
5882
|
summary_stream = sys.stdout if tty or args.dry_run else sys.stderr
|