omnilane 0.42.5 → 0.42.7

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.
@@ -3,11 +3,14 @@
3
3
 
4
4
  Runs one CLI invocation, captures raw stdout/stderr to files, and writes a
5
5
  descriptor with the original command/stream fields plus a vendor-specific
6
- verdict, its reason, and the observed model when the response proves it.
6
+ verdict, its reason, the observed model, and the tier of evidence that model
7
+ rests on.
7
8
  """
8
9
  import argparse
10
+ import glob
9
11
  import json
10
12
  import os
13
+ import re
11
14
  import subprocess
12
15
  import time
13
16
  from datetime import datetime, timezone
@@ -16,6 +19,88 @@ from pathlib import Path
16
19
  SWEEP_ID = os.environ.get("OMNILANE_TRANSPORT_SWEEP_ID", "overlay-reprobe-20260909")
17
20
  DEFAULT_ROOT = Path.home() / ".omnilane" / "transport-evidence" / SWEEP_ID
18
21
 
22
+ # Ordered strongest first. The tier is read off the evidence a run produced, not
23
+ # off the vendor: a CLI that starts reporting a billed model earns the higher
24
+ # tier with no change here.
25
+ TIER_BILLED = "billed-model" # the provider named the model it charged for
26
+ TIER_ECHO = "client-echo" # the CLI recorded the model it asked for
27
+ TIER_SELECTOR = "selector-only" # the CLI accepted the selector and said no more
28
+ EVIDENCE_TIERS = (TIER_BILLED, TIER_ECHO, TIER_SELECTOR)
29
+
30
+
31
+ def codex_failure(stdout_text: str) -> str:
32
+ """The last thing codex's event stream said went wrong, if anything.
33
+
34
+ Later events supersede earlier ones: a retry notice is progress, the message
35
+ on `turn.failed` is the outcome.
36
+ """
37
+ latest = ""
38
+ for line in stdout_text.splitlines():
39
+ line = line.strip()
40
+ if not line.startswith("{"):
41
+ continue
42
+ try:
43
+ event = json.loads(line)
44
+ except json.JSONDecodeError:
45
+ continue
46
+ if not isinstance(event, dict):
47
+ continue
48
+ if event.get("type") == "turn.failed":
49
+ message = (event.get("error") or {}).get("message")
50
+ elif event.get("type") == "error":
51
+ message = event.get("message")
52
+ else:
53
+ continue
54
+ if isinstance(message, str) and message:
55
+ latest = message
56
+ return f"; codex-event: {latest[:200]}" if latest else ""
57
+
58
+
59
+ def _requested_model(command: list) -> str | None:
60
+ requested = None
61
+ for index, argument in enumerate(command):
62
+ if not isinstance(argument, str):
63
+ continue
64
+ if argument in ("--model", "-m") and index + 1 < len(command):
65
+ requested = command[index + 1]
66
+ elif argument.startswith("--model="):
67
+ requested = argument.split("=", 1)[1]
68
+ return requested
69
+
70
+
71
+ def record_values(record_text: str) -> list[str]:
72
+ """Comparable values from a client record digest: `key<TAB>value` lines.
73
+
74
+ `#` lines carry provenance and display labels that must never be compared —
75
+ agy's backend label is "Gemini 3.8 Flash (Low)", not a model identifier.
76
+ """
77
+ values = []
78
+ for line in (record_text or "").splitlines():
79
+ if line.startswith("#") or "\t" not in line:
80
+ continue
81
+ value = line.split("\t", 1)[1].strip()
82
+ if value:
83
+ values.append(value)
84
+ return values
85
+
86
+
87
+ def _client_record(
88
+ evidence_json: dict,
89
+ record_text: str,
90
+ passed_reason: str,
91
+ ) -> tuple[str, str, str | None, str]:
92
+ """Judge a CLI's own on-disk record of the request it sent."""
93
+ values = record_values(record_text)
94
+ if not values:
95
+ return "pass", f"{passed_reason}; no-client-record", None, TIER_SELECTOR
96
+ observed_model = ", ".join(sorted(set(values)))
97
+ requested_model = _requested_model(evidence_json.get("command", []))
98
+ if not requested_model:
99
+ return "fail", "missing-requested-model", observed_model, TIER_SELECTOR
100
+ if requested_model not in values:
101
+ return "fail", "client-record-mismatch", observed_model, TIER_SELECTOR
102
+ return "pass", f"{passed_reason}-and-client-record-matched", observed_model, TIER_ECHO
103
+
19
104
 
20
105
  def verdict(
21
106
  evidence_json: dict,
@@ -23,25 +108,27 @@ def verdict(
23
108
  stderr_text: str,
24
109
  vendor: str,
25
110
  expected_token: str | None,
26
- ) -> tuple[str, str, str | None]:
111
+ extra: dict | None = None,
112
+ ) -> tuple[str, str, str | None, str]:
27
113
  """Judge raw evidence without reading files, running commands or mutating it.
28
114
 
29
- Only Claude's billed modelUsage keys currently prove the responding model.
30
- A requested selector (including the Codex banner) is not observed identity.
115
+ `extra` carries text the caller already gathered from disk, so this stays a
116
+ pure function that can re-judge an old sweep offline.
31
117
  """
118
+ extra = extra or {}
32
119
  if evidence_json.get("timed_out"):
33
- return "fail", "timeout", None
120
+ return "fail", "timeout", None, TIER_SELECTOR
34
121
  if not expected_token:
35
- return "fail", "missing-expected-token", None
122
+ return "fail", "missing-expected-token", None, TIER_SELECTOR
36
123
 
37
124
  exit_code = evidence_json.get("exit_code")
38
125
  if vendor == "claude":
39
126
  try:
40
127
  response = json.loads(stdout_text)
41
128
  except (json.JSONDecodeError, TypeError):
42
- return "fail", "invalid-json", None
129
+ return "fail", "invalid-json", None, TIER_SELECTOR
43
130
  if not isinstance(response, dict):
44
- return "fail", "invalid-json-result", None
131
+ return "fail", "invalid-json-result", None, TIER_SELECTOR
45
132
  usage = response.get("modelUsage")
46
133
  models = sorted(usage) if isinstance(usage, dict) else []
47
134
  observed_model = ", ".join(models) or None
@@ -56,48 +143,168 @@ def verdict(
56
143
  reason = "api-error"
57
144
  else:
58
145
  reason = "result-error"
59
- return "fail", f"{reason}: {result[:120]}", observed_model
146
+ return "fail", f"{reason}: {result[:120]}", observed_model, TIER_SELECTOR
60
147
  if not models:
61
- return "fail", "missing-model-usage", None
62
- command = evidence_json.get("command", [])
63
- requested_model = None
64
- for index, argument in enumerate(command):
65
- if argument == "--model" and index + 1 < len(command):
66
- requested_model = command[index + 1]
67
- elif isinstance(argument, str) and argument.startswith("--model="):
68
- requested_model = argument.split("=", 1)[1]
148
+ return "fail", "missing-model-usage", None, TIER_SELECTOR
149
+ requested_model = _requested_model(evidence_json.get("command", []))
69
150
  if not requested_model:
70
- return "fail", "missing-requested-model", observed_model
151
+ return "fail", "missing-requested-model", observed_model, TIER_SELECTOR
71
152
  if models != [requested_model]:
72
- return "fail", "model-mismatch", observed_model
153
+ return "fail", "model-mismatch", observed_model, TIER_SELECTOR
73
154
  if "unknown --effort" in stderr_text.lower():
74
- return "fail", "effort-silently-defaulted", observed_model
155
+ return "fail", "effort-silently-defaulted", observed_model, TIER_SELECTOR
75
156
  if exit_code != 0:
76
- return "fail", f"exit-code: {exit_code}", observed_model
157
+ return "fail", f"exit-code: {exit_code}", observed_model, TIER_SELECTOR
77
158
  if expected_token not in result:
78
- return "fail", "missing-expected-token", observed_model
79
- return "pass", "expected-token-and-model-matched", observed_model
159
+ return "fail", "missing-expected-token", observed_model, TIER_SELECTOR
160
+ return "pass", "expected-token-and-model-matched", observed_model, TIER_BILLED
161
+
162
+ if vendor == "grok":
163
+ if exit_code != 0:
164
+ return "fail", f"exit-code: {exit_code}: {stderr_text[:120]}", None, TIER_SELECTOR
165
+ if stderr_text:
166
+ return "fail", f"unexpected-stderr: {stderr_text[:120]}", None, TIER_SELECTOR
167
+ if expected_token not in stdout_text:
168
+ return "fail", "missing-expected-token", None, TIER_SELECTOR
169
+ try:
170
+ response = json.loads(stdout_text)
171
+ except (json.JSONDecodeError, TypeError):
172
+ response = None
173
+ usage = response.get("modelUsage") if isinstance(response, dict) else None
174
+ models = sorted(usage) if isinstance(usage, dict) else []
175
+ if not models:
176
+ return "pass", "expected-token-and-clean-stderr; no-billed-model", None, TIER_SELECTOR
177
+ observed_model = ", ".join(models)
178
+ requested_model = _requested_model(evidence_json.get("command", []))
179
+ if not requested_model:
180
+ return "fail", "missing-requested-model", observed_model, TIER_SELECTOR
181
+ # Grok bills `grok-4.6` as `grok-4.6-build`. Accept that one suffix and
182
+ # nothing else: a prefix test would let `grok-4.6-anything` pass.
183
+ if models not in ([requested_model], [f"{requested_model}-build"]):
184
+ return "fail", "model-mismatch", observed_model, TIER_SELECTOR
185
+ return "pass", "expected-token-and-billed-model-matched", observed_model, TIER_BILLED
80
186
 
81
- if vendor in ("grok", "agy"):
187
+ if vendor == "agy":
82
188
  if exit_code != 0:
83
- return "fail", f"exit-code: {exit_code}: {stderr_text[:120]}", None
189
+ return "fail", f"exit-code: {exit_code}: {stderr_text[:120]}", None, TIER_SELECTOR
84
190
  if stderr_text:
85
- return "fail", f"unexpected-stderr: {stderr_text[:120]}", None
191
+ return "fail", f"unexpected-stderr: {stderr_text[:120]}", None, TIER_SELECTOR
86
192
  if expected_token not in stdout_text:
87
- return "fail", "missing-expected-token", None
88
- return "pass", "expected-token-and-clean-stderr", None
193
+ return "fail", "missing-expected-token", None, TIER_SELECTOR
194
+ return _client_record(evidence_json, extra.get("cli_log", ""),
195
+ "expected-token-and-clean-stderr")
89
196
 
90
197
  if vendor == "codex":
91
198
  diagnostics = [line[:120] for line in stderr_text.splitlines()
92
199
  if "error" in line.lower() or "warning" in line.lower()]
93
200
  review = "; stderr-review: " + " | ".join(diagnostics) if diagnostics else ""
201
+ # Under `--json` the refusal that ended the run is an stdout event, not
202
+ # a stderr line, so a failure would otherwise be recorded as a bare exit
203
+ # code and leave unproven[] saying nothing a reader can act on.
204
+ why = codex_failure(stdout_text)
94
205
  if exit_code != 0:
95
- return "fail", f"exit-code: {exit_code}{review}", None
206
+ return "fail", f"exit-code: {exit_code}{why}{review}", None, TIER_SELECTOR
96
207
  if expected_token not in stdout_text:
97
- return "fail", f"missing-expected-token{review}", None
98
- return "pass", f"expected-token-matched{review}", None
208
+ return "fail", f"missing-expected-token{why}{review}", None, TIER_SELECTOR
209
+ result, reason, observed, tier = _client_record(
210
+ evidence_json, extra.get("rollout", ""), "expected-token-matched")
211
+ return result, f"{reason}{review}", observed, tier
212
+
213
+ return "fail", f"unsupported-vendor: {vendor}", None, TIER_SELECTOR
214
+
215
+
216
+ def _sha256(path: Path) -> str:
217
+ import hashlib
218
+
219
+ digest = hashlib.sha256()
220
+ with open(path, "rb") as stream:
221
+ for block in iter(lambda: stream.read(1 << 20), b""):
222
+ digest.update(block)
223
+ return digest.hexdigest()
224
+
225
+
226
+ def _walk_models(node, prefix: str, found: dict) -> None:
227
+ if isinstance(node, dict):
228
+ for key, value in node.items():
229
+ here = f"{prefix}.{key}" if prefix else key
230
+ if key == "model" and isinstance(value, str):
231
+ found.setdefault(here, value)
232
+ _walk_models(value, here, found)
233
+ elif isinstance(node, list):
234
+ for item in node:
235
+ _walk_models(item, f"{prefix}[]", found)
236
+
237
+
238
+ def codex_record(stdout_text: str, sessions_dir: Path | None = None) -> str:
239
+ """Digest the rollout codex persisted for the thread this run started.
240
+
241
+ Needs `codex exec --json` for the thread id and no `--ephemeral`, which
242
+ would suppress the rollout this reads.
243
+ """
244
+ thread_id = None
245
+ for line in stdout_text.splitlines():
246
+ line = line.strip()
247
+ if not line.startswith("{"):
248
+ continue
249
+ try:
250
+ event = json.loads(line)
251
+ except json.JSONDecodeError:
252
+ continue
253
+ if isinstance(event, dict) and isinstance(event.get("thread_id"), str):
254
+ thread_id = event["thread_id"]
255
+ break
256
+ if not thread_id:
257
+ return ""
258
+ root = sessions_dir or Path.home() / ".codex" / "sessions"
259
+ matches = sorted(glob.glob(str(root / "**" / f"rollout-*{thread_id}.jsonl"), recursive=True))
260
+ if not matches:
261
+ return ""
262
+ path = Path(matches[0])
263
+ found: dict[str, str] = {}
264
+ for line in path.read_text(errors="replace").splitlines():
265
+ line = line.strip()
266
+ if not line.startswith("{"):
267
+ continue
268
+ try:
269
+ event = json.loads(line)
270
+ except json.JSONDecodeError:
271
+ continue
272
+ _walk_models(event, "", found)
273
+ lines = [f"# thread_id\t{thread_id}", f"# source\t{path}", f"# sha256\t{_sha256(path)}"]
274
+ lines += [f"{key}\t{value}" for key, value in sorted(found.items())]
275
+ return "\n".join(lines) + "\n"
276
+
277
+
278
+ AGY_MODEL = re.compile(r"Resolving model (\S+)")
279
+ AGY_LABEL = re.compile(r'selected model override to backend: label="([^"]+)"')
280
+
99
281
 
100
- return "fail", f"unsupported-vendor: {vendor}", None
282
+ def agy_record(app_root: Path, since: float) -> str:
283
+ """Digest the model agy resolved in the logs this run wrote under app_root.
284
+
285
+ `since` rejects logs from an earlier run, so a reused app root cannot lend
286
+ its model string to a later probe.
287
+ """
288
+ app_root = Path(app_root).expanduser()
289
+ if not app_root.is_dir():
290
+ return ""
291
+ lines: list[str] = []
292
+ values: dict[str, str] = {}
293
+ for path in sorted(app_root.rglob("cli*.log")):
294
+ if not path.is_file() or path.stat().st_mtime < since - 2:
295
+ continue
296
+ text = path.read_text(errors="replace")
297
+ models = AGY_MODEL.findall(text)
298
+ if not models:
299
+ continue
300
+ lines += [f"# source\t{path}", f"# sha256\t{_sha256(path)}"]
301
+ lines += [f"# label\t{label}" for label in dict.fromkeys(AGY_LABEL.findall(text))]
302
+ for model in models:
303
+ values.setdefault(f"{path.name}:resolved-model:{model}", model)
304
+ if not values:
305
+ return ""
306
+ lines += [f"{key}\t{value}" for key, value in sorted(values.items())]
307
+ return "\n".join(lines) + "\n"
101
308
 
102
309
 
103
310
  def probe(
@@ -109,6 +316,7 @@ def probe(
109
316
  *,
110
317
  vendor: str | None = None,
111
318
  expected_token: str | None = None,
319
+ app_root: Path | None = None,
112
320
  ) -> dict:
113
321
  if not expected_token:
114
322
  raise ValueError("expected_token is required before running a probe")
@@ -148,10 +356,24 @@ def probe(
148
356
  "stdout": str(out_path),
149
357
  "stderr": str(err_path),
150
358
  }
151
- record["verdict"], record["verdict_reason"], record["observed_model"] = verdict(
152
- record, out_path.read_text(errors="replace"), err_path.read_text(errors="replace"),
153
- vendor, expected_token,
154
- )
359
+ stdout_text = out_path.read_text(errors="replace")
360
+ extra = {}
361
+ if vendor == "codex":
362
+ extra["rollout"] = codex_record(stdout_text)
363
+ elif vendor == "agy" and app_root is not None:
364
+ extra["cli_log"] = agy_record(app_root, started)
365
+ for key, text in extra.items():
366
+ if not text:
367
+ continue
368
+ # Stored so the sweep hashes it and verdict() can re-judge it offline.
369
+ record_path = evidence / f"{name}.{key}"
370
+ record_path.write_text(text)
371
+ record[key] = str(record_path)
372
+ record["verdict"], record["verdict_reason"], record["observed_model"], \
373
+ record["evidence_tier"] = verdict(
374
+ record, stdout_text, err_path.read_text(errors="replace"),
375
+ vendor, expected_token, extra,
376
+ )
155
377
  (evidence / f"{name}.json").write_text(json.dumps(record, indent=2) + "\n")
156
378
  return record
157
379
 
@@ -174,11 +396,13 @@ if __name__ == "__main__":
174
396
  parser.add_argument("--expect", required=True, help="expected response token")
175
397
  parser.add_argument("--vendor", choices=("claude", "grok", "agy", "codex"),
176
398
  help="defaults to the command executable's basename")
399
+ parser.add_argument("--app-root", type=Path,
400
+ help="agy app data root for this run, to read back its cli log")
177
401
  parser.add_argument("name")
178
402
  parser.add_argument("command", nargs=argparse.REMAINDER)
179
403
  args = parser.parse_args()
180
404
  if not args.command:
181
405
  parser.error("command is required")
182
- rec = probe(args.name, args.command, root=args.root,
406
+ rec = probe(args.name, args.command, root=args.root, app_root=args.app_root,
183
407
  vendor=args.vendor, expected_token=args.expect)
184
408
  print(json.dumps(rec, ensure_ascii=False))
@@ -19,10 +19,13 @@ You (the main loop) may be Claude, GPT, Grok, or Gemini. The procedure is identi
19
19
  Read-only work uses advise; edits require `--mode work --workdir <repo>`.
20
20
  `<repo>/scripts/dispatch.sh --caller-context FILE [--executor auto|native|cli] [--native-context FILE] [--vendor V] [--mode work] [--workdir DIR] <lane> "<task>"`
21
21
 
22
- A model caller MUST pass `--caller-context`; without it every dispatch is
23
- refused with `missing-caller-context` before a job exists. Build that file
24
- before the first dispatch — see **Frozen exact-AA downward gate** for the
25
- schema, a worked example, and what to do when your own effort is unverifiable.
22
+ A model caller needs a caller identity. Dispatch reads it from the CLI that
23
+ launched you when that CLI names its model and effort, so an ordinary session
24
+ passes nothing. When it cannot, dispatch is refused with
25
+ `missing-caller-context` before a job exists: run `omnilane whoami` (or
26
+ `<repo>/bin/omnilane whoami`) and pass the file it prints as
27
+ `--caller-context`. See **Frozen exact-AA downward gate** for the schema and
28
+ what to do when your effort is genuinely unverifiable.
26
29
 
27
30
  Add `--background` for long tasks; poll with `scripts/jobs.sh status|result <id>`.
28
31
  Use `--thread NAME` when later claude, codex, grok or gemini dispatches
@@ -355,15 +358,23 @@ and `snapshot_id` must equal the registry's own `snapshot.id`.
355
358
  Set `inherited_ceiling` to your own row's score when you are the root caller, or
356
359
  to the ceiling you were handed when you are a child.
357
360
 
358
- **When your harness names a model but no effort, look before you guess.** The
359
- launching process usually carries the exact flags. Walk your own ancestor chain
360
- (`ps -o ppid=,comm= -p <pid>` upward, then `ps -o args= -p <ancestor>`) and read
361
- its `--model` / `--effort`. Match the ancestor chain rather than the first
362
- matching process on the host — a second session of the same CLI is common and
363
- its flags are not yours. This is request-selector evidence, the same class the
364
- transport overlay carries, and it does not certify upstream identity.
365
-
366
- Only when that genuinely yields nothing: ask the operator, or declare the
361
+ **Your identity is read from the CLI that launched you.** When no
362
+ `--caller-context` is given, dispatch walks up the process tree to the nearest
363
+ vendor CLI and reads the selector it was started with: `--model` / `--effort`
364
+ for claude, `-m` and `-c model_reasoning_effort` for codex, `--reasoning-effort`
365
+ for grok, the effort-encoded model id for agy. `omnilane whoami` runs the same
366
+ walk and prints the resulting caller-context file, for a retry or to see what
367
+ you will be held to. Nearest wins, so a codex worker started by a Claude session
368
+ is a codex caller, and another session of the same CLI elsewhere on the host is
369
+ never consulted. It does not guess: a missing flag, a model alias, or a Claude
370
+ effort whose only scored row is non-reasoning is refused with the reason. This
371
+ is request-selector evidence, the same class the transport overlay carries; it
372
+ does not certify upstream identity, but unlike a hand-written file the model
373
+ cannot edit it. An explicit `--caller-context` or `--operator-asserted-human`
374
+ still wins, and `OMNILANE_AA_CALLER_FROM_PROCESS=0` restores the file-only
375
+ contract.
376
+
377
+ Only when `omnilane whoami` refuses: ask the operator, or declare the
367
378
  lowest-scoring row of your model and say so in your report. Understating only
368
379
  narrows what you may dispatch to, so it fails in the safe direction — but it is
369
380
  the fallback, not the first move, and an unnecessarily low ceiling silently
@@ -372,7 +383,9 @@ declared effort to unblock a refused target, and never assert
372
383
  `--operator-asserted-human` on your own behalf.
373
384
 
374
385
  Three refusal codes mean different things and need different fixes.
375
- `missing-caller-context` means you passed no file — write one.
386
+ `missing-caller-context` means no identity reached the gate: you passed no file
387
+ and dispatch could not read one from your launching CLI. Run `omnilane whoami`;
388
+ its refusal says exactly why, and that reason is what to fix or report.
376
389
  `runtime-mapping-unverified` means the file is fine but the *target* has no proven
377
390
  host-local request selector; that is fixed by a `--transport-overlay` entry backed
378
391
  by real evidence, never by editing the frozen registry (its sha256 is pinned in
@@ -388,9 +401,24 @@ selector evidence. Evidence entries carry a `vendor` tag: a tagged entry that
388
401
  drifts marks only its own vendor stale, and the other three keep dispatching.
389
402
  Untagged evidence — `probe-manifest.json`, and any overlay built before the tags
390
403
  existed — still fails the whole gate closed, which is what an unpatched host
391
- looks like. Codex and Claude evidence paths embed version directories
392
- (`releases/0.153.4-…`, `versions/2.1.263`), so their upgrades remove the file
393
- rather than change its digest; both are treated as staleness, not corruption.
404
+ looks like. Codex and Claude resolve through version directories
405
+ (`releases/0.153.4-…`, `versions/2.1.266`), so their upgrades remove the anchored
406
+ file rather than change its digest; both are treated as staleness, not corruption.
407
+
408
+ Do not expect these upgrades to be operator actions. agy and grok update
409
+ themselves in the background when invoked — agy's own `cli.log` records
410
+ `auto_updater.go: Spawned background update process`, and both binaries changed
411
+ under a probing session on 2026-09-10, minutes after their first call. Overlay
412
+ drift is therefore a routine consequence of using a vendor, not an occasional
413
+ maintenance event, which is why per-vendor degradation matters more than it
414
+ looks. It also means any test asserting a fixed number of verified live
415
+ mappings will go red on its own schedule.
416
+
417
+ Because of that, `build_overlay.py` anchors the executable `shutil.which` resolves
418
+ rather than a version written into the script. A pinned path drifts out of use
419
+ silently: before 0.42.6 the overlay hashed claude `2.1.263` while every dispatch
420
+ ran `2.1.266`, so eleven mappings were "verified" against a binary that had not
421
+ run for a day.
394
422
 
395
423
  Re-signing is a probe, a rebuild, and an install, in that order. Back up
396
424
  `~/.omnilane/transport-contracts.local.json` first; restoring it is the rollback.
@@ -408,6 +436,30 @@ Keep the sweep where its default `--root` puts it,
408
436
  inside a repository is one `git clean -fdx` away from taking every vendor down
409
437
  at once — the same global refusal a re-signing session is usually trying to end.
410
438
 
439
+ Every mapping carries an `evidence_tier` saying how strongly its probe pinned the
440
+ responder. `billed-model` means the provider named the model it charged for —
441
+ Claude's `modelUsage`, grok's under `--output-format json`. `client-echo` means
442
+ the CLI wrote down the model it asked for — codex's session rollout, agy's
443
+ `cli.log` resolver line. `selector-only` means the CLI accepted the selector and
444
+ said nothing more. Put plainly: `client-echo` is the CLI's copy of your order,
445
+ `billed-model` is the provider's receipt. Neither certifies upstream identity,
446
+ but only one of them was written by the party that answered.
447
+
448
+ The tier is reported, never enforced. Dispatch still turns on `runtime_verified`
449
+ alone, so a mapping that drops to `selector-only` keeps working and simply shows
450
+ up in doctor as worth re-probing. Do not add a tier check to the gate: that would
451
+ rebuild the failure 0.42.5 removed, where evidence quality could refuse a lane
452
+ that runs. The tier is read off the evidence a run produced rather than assigned
453
+ per vendor, so a sweep predating 0.42.6 re-judges as `selector-only` and a CLI
454
+ that starts reporting a billed model is promoted with no code change.
455
+
456
+ Two probe details follow from this. Codex needs `exec --json` (the thread id that
457
+ locates the rollout) and must *not* use `--ephemeral`, which suppresses the very
458
+ rollout the tier reads. agy needs its own app data directory, prepared exactly
459
+ the way `run-gemini.sh` does it — `prepare-agy-mode.py --mode advise` returns a
460
+ path relative to `~/.gemini` that is passed as `--app_data_dir=`; the environment
461
+ variables that look like they would do this are ignored.
462
+
411
463
  Never sign a probe you did not read. `probe.py` records a `verdict` because exit
412
464
  status alone is not evidence: the Claude CLI answers a quota refusal with a JSON
413
465
  body carrying `is_error`, and it accepts an unknown `--effort` by silently using