roadmodel 0.2.82__tar.gz → 0.2.84__tar.gz

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.
Files changed (72) hide show
  1. {roadmodel-0.2.82 → roadmodel-0.2.84}/PKG-INFO +2 -1
  2. {roadmodel-0.2.82 → roadmodel-0.2.84}/README.md +1 -0
  3. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/claude-commands/roadmap-refresh.md +7 -1
  4. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/templates/prompt-phase-roadmap.md +7 -1
  5. {roadmodel-0.2.82 → roadmodel-0.2.84}/pyproject.toml +1 -1
  6. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/prompt-phase-roadmap.md +7 -1
  7. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/roadmap-refresh.md +7 -1
  8. {roadmodel-0.2.82 → roadmodel-0.2.84}/scripts/update_projects.py +132 -29
  9. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/__init__.py +1 -1
  10. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/cli.py +94 -11
  11. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/scoring.py +187 -14
  12. {roadmodel-0.2.82 → roadmodel-0.2.84}/.github/README.md +0 -0
  13. {roadmodel-0.2.82 → roadmodel-0.2.84}/.gitignore +0 -0
  14. {roadmodel-0.2.82 → roadmodel-0.2.84}/LICENSE +0 -0
  15. {roadmodel-0.2.82 → roadmodel-0.2.84}/NOTICE +0 -0
  16. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/benchmarks.json +0 -0
  17. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/catalog.json +0 -0
  18. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/claude-commands/roadmap-phase.md +0 -0
  19. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/claude-commands/roadmap-project.md +0 -0
  20. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/claude-commands/roadmap-step.md +0 -0
  21. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/claude-commands/roadmodel-upgrade.md +0 -0
  22. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/context-windows.json +0 -0
  23. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/effort-tokens.json +0 -0
  24. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/model-selector.txt +0 -0
  25. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/model-tier-cost-scale.md +0 -0
  26. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/settings-display.md +0 -0
  27. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/templates/phase-roadmap-template.md +0 -0
  28. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/templates/planning-kit-how-to-use.md +0 -0
  29. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/templates/project-roadmap-template.md +0 -0
  30. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/templates/prompt-project-roadmap.md +0 -0
  31. {roadmodel-0.2.82 → roadmodel-0.2.84}/docs/user-context.example.md +0 -0
  32. {roadmodel-0.2.82 → roadmodel-0.2.84}/hatch_build.py +0 -0
  33. {roadmodel-0.2.82 → roadmodel-0.2.84}/infra/README.md +0 -0
  34. {roadmodel-0.2.82 → roadmodel-0.2.84}/infra/gcp-killswitch/README.md +0 -0
  35. {roadmodel-0.2.82 → roadmodel-0.2.84}/infra/supabase/README.md +0 -0
  36. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/benchmarks.json +0 -0
  37. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/catalog.json +0 -0
  38. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/context-windows.json +0 -0
  39. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/effort-tokens.json +0 -0
  40. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/model-selector.txt +0 -0
  41. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/model-tier-cost-scale.md +0 -0
  42. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/phase-roadmap-template.md +0 -0
  43. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/planning-kit-how-to-use.md +0 -0
  44. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/project-roadmap-template.md +0 -0
  45. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/prompt-project-roadmap.md +0 -0
  46. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/roadmap-phase.md +0 -0
  47. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/roadmap-project.md +0 -0
  48. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/roadmap-step.md +0 -0
  49. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/roadmodel-upgrade.md +0 -0
  50. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/settings-display.md +0 -0
  51. {roadmodel-0.2.82 → roadmodel-0.2.84}/roadmodel/data/user-context.example.md +0 -0
  52. {roadmodel-0.2.82 → roadmodel-0.2.84}/scripts/agent_context.py +0 -0
  53. {roadmodel-0.2.82 → roadmodel-0.2.84}/scripts/agent_rules.py +0 -0
  54. {roadmodel-0.2.82 → roadmodel-0.2.84}/scripts/updater_lock.py +0 -0
  55. {roadmodel-0.2.82 → roadmodel-0.2.84}/scripts/upgrade_fleet.py +0 -0
  56. {roadmodel-0.2.82 → roadmodel-0.2.84}/scripts/worktree_memory.py +0 -0
  57. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/__main__.py +0 -0
  58. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/config.py +0 -0
  59. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/cost.py +0 -0
  60. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/errors.py +0 -0
  61. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/mcp_server.py +0 -0
  62. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/private_files.py +0 -0
  63. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/providers/__init__.py +0 -0
  64. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/providers/_safety.py +0 -0
  65. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/providers/anthropic.py +0 -0
  66. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/providers/google.py +0 -0
  67. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/providers/openai.py +0 -0
  68. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/providers/openai_compatible.py +0 -0
  69. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/providers/registry.py +0 -0
  70. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/recommend.py +0 -0
  71. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/usage.py +0 -0
  72. {roadmodel-0.2.82 → roadmodel-0.2.84}/src/roadmodel/user_context.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: roadmodel
3
- Version: 0.2.82
3
+ Version: 0.2.84
4
4
  Summary: BYO-key CLI that recommends the right AI model, platform, and settings for a prompt.
5
5
  Project-URL: Homepage, https://roadmodel.ai
6
6
  Project-URL: Repository, https://github.com/nathanramoscfa/roadmodel
@@ -607,6 +607,7 @@ server and the reasoning-effort default in parity automatically.
607
607
  | `roadmodel recommend --model` | Override the specific model ID on the chosen provider. |
608
608
  | `roadmodel recommend --user-context PATH` | Override the user-context.md location for this invocation. |
609
609
  | `roadmodel score --category C --complexity X` | Rank (model, platform, effort) candidates in code for a classified task — no engine call. See [docs/scoring-model.md](docs/scoring-model.md). |
610
+ | `roadmodel backup --category C --complexity X --model M --effort E` | Name the cross-maker backup for a pick, by the rule /recommend uses — no engine call. Prints the roadmap Settings Backup cell. |
610
611
  | `roadmodel catalog show` | Print the bundled `model-selector.txt` (use `--doc tier-cost-scale` for the price doc). |
611
612
  | `roadmodel catalog path` | Print the on-disk path of the bundled catalog document (same `--doc` flag). |
612
613
  | `roadmodel context show` | Print the resolved `user-context.md` file. |
@@ -356,6 +356,7 @@ server and the reasoning-effort default in parity automatically.
356
356
  | `roadmodel recommend --model` | Override the specific model ID on the chosen provider. |
357
357
  | `roadmodel recommend --user-context PATH` | Override the user-context.md location for this invocation. |
358
358
  | `roadmodel score --category C --complexity X` | Rank (model, platform, effort) candidates in code for a classified task — no engine call. See [docs/scoring-model.md](docs/scoring-model.md). |
359
+ | `roadmodel backup --category C --complexity X --model M --effort E` | Name the cross-maker backup for a pick, by the rule /recommend uses — no engine call. Prints the roadmap Settings Backup cell. |
359
360
  | `roadmodel catalog show` | Print the bundled `model-selector.txt` (use `--doc tier-cost-scale` for the price doc). |
360
361
  | `roadmodel catalog path` | Print the on-disk path of the bundled catalog document (same `--doc` flag). |
361
362
  | `roadmodel context show` | Print the resolved `user-context.md` file. |
@@ -180,7 +180,13 @@ exclusion in the selector, and include a backup model.
180
180
  A changed backup alone counts as a difference; name it in that line.
181
181
  - Every table carries a `Backup` row directly under `Model`: the
182
182
  selector's BACKUP with its own platform and dial in one cell
183
- (`GPT-5.6 Terra — Codex · Intelligence Medium`), or `None`. A table
183
+ (`GPT-5.6 Terra — Codex · Intelligence Medium`), or `None`. Where
184
+ `roadmodel` is installed, the cell is the one `roadmodel backup
185
+ --category <c> --complexity <x> [--novel] --model <the step's model>
186
+ --effort <the step's effort>` prints for the step's classification —
187
+ the rule /recommend backs a pick up by, which takes the cheaper of two
188
+ substitutes the benchmarks cannot tell apart — and it outranks the
189
+ selector's BACKUP. A table
184
190
  written before roadmodel 0.2.41 has no such row: add it from the
185
191
  backup the step already names (its Model rationale's closing
186
192
  sentence, or its entry in the Model selection blocks) — a layout
@@ -103,7 +103,13 @@ selector in `@planning/model-selector.txt` (prices from
103
103
  You are the engine — do not call any external API. Honor every
104
104
  availability exclusion in the selector, and write each step's backup
105
105
  model into its Settings table's Backup row, with the backup's own
106
- platform and dial.
106
+ platform and dial. Where `roadmodel` is installed, the Backup row is
107
+ the cell `roadmodel backup --category <c> --complexity <x> [--novel]
108
+ --model <the step's model> --effort <the step's effort>` prints for the
109
+ step's own classification: the rule /recommend backs a pick up by, in
110
+ code, which takes the cheaper of two substitutes the benchmarks cannot
111
+ tell apart. The selector's BACKUP stands only where it is not
112
+ installed.
107
113
 
108
114
  Honor the template's style rules: 80-column prose, zero `{{...}}`
109
115
  tokens left, `**Status:** Not started` directly under the title and
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "roadmodel"
7
- version = "0.2.82"
7
+ version = "0.2.84"
8
8
  description = "BYO-key CLI that recommends the right AI model, platform, and settings for a prompt."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -103,7 +103,13 @@ selector in `@planning/model-selector.txt` (prices from
103
103
  You are the engine — do not call any external API. Honor every
104
104
  availability exclusion in the selector, and write each step's backup
105
105
  model into its Settings table's Backup row, with the backup's own
106
- platform and dial.
106
+ platform and dial. Where `roadmodel` is installed, the Backup row is
107
+ the cell `roadmodel backup --category <c> --complexity <x> [--novel]
108
+ --model <the step's model> --effort <the step's effort>` prints for the
109
+ step's own classification: the rule /recommend backs a pick up by, in
110
+ code, which takes the cheaper of two substitutes the benchmarks cannot
111
+ tell apart. The selector's BACKUP stands only where it is not
112
+ installed.
107
113
 
108
114
  Honor the template's style rules: 80-column prose, zero `{{...}}`
109
115
  tokens left, `**Status:** Not started` directly under the title and
@@ -180,7 +180,13 @@ exclusion in the selector, and include a backup model.
180
180
  A changed backup alone counts as a difference; name it in that line.
181
181
  - Every table carries a `Backup` row directly under `Model`: the
182
182
  selector's BACKUP with its own platform and dial in one cell
183
- (`GPT-5.6 Terra — Codex · Intelligence Medium`), or `None`. A table
183
+ (`GPT-5.6 Terra — Codex · Intelligence Medium`), or `None`. Where
184
+ `roadmodel` is installed, the cell is the one `roadmodel backup
185
+ --category <c> --complexity <x> [--novel] --model <the step's model>
186
+ --effort <the step's effort>` prints for the step's classification —
187
+ the rule /recommend backs a pick up by, which takes the cheaper of two
188
+ substitutes the benchmarks cannot tell apart — and it outranks the
189
+ selector's BACKUP. A table
184
190
  written before roadmodel 0.2.41 has no such row: add it from the
185
191
  backup the step already names (its Model rationale's closing
186
192
  sentence, or its entry in the Model selection blocks) — a layout
@@ -49,6 +49,7 @@ from concurrent.futures import ThreadPoolExecutor, as_completed
49
49
  from dataclasses import dataclass, field
50
50
  from pathlib import Path
51
51
  from typing import Optional
52
+ from urllib.parse import urlsplit
52
53
  from xml.sax.saxutils import escape
53
54
 
54
55
  COMMANDS = (
@@ -1975,7 +1976,8 @@ def _parse_time(value: str) -> tuple[int, int]:
1975
1976
  def launchd_plist(python: Path, script: Path, hour: int, minute: int, log: Path) -> str:
1976
1977
  # stdout goes nowhere: `--log` already writes the run to the log, and
1977
1978
  # launchd capturing stdout into the same file recorded every run twice.
1978
- # stderr stays captured, so a crash before the log opens still lands there.
1979
+ # --log also captures stderr after its safe open. launchd must not pre-open
1980
+ # a path outside that validation; failures before log setup are discarded.
1979
1981
  return f"""<?xml version="1.0" encoding="UTF-8"?>
1980
1982
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
1981
1983
  <plist version="1.0">
@@ -1994,7 +1996,7 @@ def launchd_plist(python: Path, script: Path, hour: int, minute: int, log: Path)
1994
1996
  <dict><key>Hour</key><integer>{hour}</integer><key>Minute</key><integer>{minute}</integer></dict>
1995
1997
  <key>RunAtLoad</key><false/>
1996
1998
  <key>StandardOutPath</key><string>/dev/null</string>
1997
- <key>StandardErrorPath</key><string>{escape(str(log))}</string>
1999
+ <key>StandardErrorPath</key><string>/dev/null</string>
1998
2000
  <key>EnvironmentVariables</key>
1999
2001
  <dict><key>PATH</key><string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string></dict>
2000
2002
  </dict>
@@ -2248,7 +2250,90 @@ CONTEXT_SYNC_CONFIG = CONFIG_DIR / "context-sync.json"
2248
2250
  CONTEXT_CLONE = CONFIG_DIR / "context-repo"
2249
2251
  USER_CONTEXT = CONFIG_DIR / "user-context.md"
2250
2252
  CONTEXT_FILE = "user-context.md"
2251
- _REPO_SLUG_RE = re.compile(r"^[A-Za-z0-9_.-]+/[A-Za-z0-9_.-]+$")
2253
+ _REPO_SLUG_RE = re.compile(r"[A-Za-z0-9][A-Za-z0-9-]{0,38}/[A-Za-z0-9_][A-Za-z0-9_.-]{0,99}")
2254
+
2255
+
2256
+ def _context_repo(value: str) -> str:
2257
+ """One GitHub repository identity, with no local paths or URL credentials."""
2258
+ if not isinstance(value, str) or any(ord(c) < 33 or ord(c) == 127 for c in value):
2259
+ raise ValueError("Unsupported context repository.")
2260
+ candidate = value
2261
+ if value.startswith("git@github.com:"):
2262
+ candidate = value[len("git@github.com:") :]
2263
+ elif "://" in value:
2264
+ parsed = urlsplit(value)
2265
+ if (
2266
+ parsed.hostname != "github.com"
2267
+ or parsed.port is not None
2268
+ or parsed.query
2269
+ or parsed.fragment
2270
+ ):
2271
+ raise ValueError("Unsupported context repository.")
2272
+ if parsed.scheme == "https":
2273
+ if parsed.username is not None or parsed.password is not None:
2274
+ raise ValueError("Unsupported context repository.")
2275
+ elif parsed.scheme == "ssh":
2276
+ if parsed.username != "git" or parsed.password is not None:
2277
+ raise ValueError("Unsupported context repository.")
2278
+ else:
2279
+ raise ValueError("Unsupported context repository.")
2280
+ candidate = parsed.path.removeprefix("/")
2281
+ if candidate.endswith(".git"):
2282
+ candidate = candidate[:-4]
2283
+ if not _REPO_SLUG_RE.fullmatch(candidate):
2284
+ raise ValueError("Unsupported context repository.")
2285
+ return candidate.lower()
2286
+
2287
+
2288
+ def _context_visibility(repo: str) -> bool:
2289
+ """Require authenticated, exact-identity PRIVATE metadata before carrying bytes."""
2290
+ try:
2291
+ gh = shutil.which("gh")
2292
+ if not gh:
2293
+ return False
2294
+ result = subprocess.run( # noqa: S603 — fixed gh API, canonical repository
2295
+ [gh, "api", "--hostname", "github.com", f"repos/{repo}"],
2296
+ capture_output=True,
2297
+ text=True,
2298
+ timeout=30,
2299
+ )
2300
+ if result.returncode != 0:
2301
+ return False
2302
+ data = json.loads(result.stdout)
2303
+ return (
2304
+ isinstance(data, dict)
2305
+ and data.get("private") is True
2306
+ and data.get("visibility") == "private"
2307
+ and isinstance(data.get("full_name"), str)
2308
+ and data["full_name"].lower() == repo
2309
+ )
2310
+ except (OSError, ValueError, subprocess.SubprocessError):
2311
+ return False
2312
+
2313
+
2314
+ def _context_destination(repo: str) -> Optional[str]:
2315
+ """Validate effective Git URL rewrites, configured transports and current branch."""
2316
+ for args in (
2317
+ ("remote", "get-url", "--all", "origin"),
2318
+ ("remote", "get-url", "--push", "--all", "origin"),
2319
+ ):
2320
+ result = _git(*args, cwd=CONTEXT_CLONE)
2321
+ values = result.stdout.splitlines()
2322
+ if result.returncode or len(values) != 1 or _context_repo(values[0]) != repo:
2323
+ return None
2324
+ result = _git("symbolic-ref", "--quiet", "--short", "HEAD", cwd=CONTEXT_CLONE)
2325
+ branch = result.stdout.strip()
2326
+ if result.returncode or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._/-]*", branch):
2327
+ return None
2328
+ if _git("check-ref-format", "--branch", branch, cwd=CONTEXT_CLONE).returncode:
2329
+ return None
2330
+ for name in (f"branch.{branch}.remote", f"branch.{branch}.pushRemote", "remote.pushDefault"):
2331
+ result = _git("config", "--get-all", name, cwd=CONTEXT_CLONE)
2332
+ if result.returncode not in (0, 1) or any(
2333
+ v != "origin" for v in result.stdout.splitlines()
2334
+ ):
2335
+ return None
2336
+ return branch
2252
2337
 
2253
2338
 
2254
2339
  def _git(*args: str, cwd: Path) -> subprocess.CompletedProcess[str]:
@@ -2270,42 +2355,37 @@ def _read_context_config() -> Optional[dict[str, str]]:
2270
2355
 
2271
2356
  def configure_context_sync(repo: str, source: Optional[str], dry_run: bool = False) -> str:
2272
2357
  """Record this machine's role. `source` given → it publishes that file;
2273
- omitted → it pulls. `repo` is `owner/name` (or a git URL / path)."""
2274
- if not (_REPO_SLUG_RE.match(repo) or "://" in repo or repo.startswith(("git@", "/"))):
2275
- raise SystemExit(f"--context-sync: expected OWNER/REPO, got {repo!r}")
2358
+ omitted → it pulls. `repo` identifies a GitHub repository."""
2359
+ try:
2360
+ repo = _context_repo(repo)
2361
+ except ValueError:
2362
+ raise SystemExit("--context-sync: expected a canonical GitHub repository.") from None
2276
2363
  cfg: dict[str, str] = {"repo": repo, "role": "replica"}
2277
2364
  if source:
2278
2365
  src = Path(source).expanduser().resolve()
2279
2366
  if not src.is_file():
2280
- raise SystemExit(f"--context-source: no such file {src}")
2367
+ raise SystemExit("--context-source: source file is unavailable.")
2281
2368
  cfg.update(role="source", source=str(src))
2282
2369
  if not dry_run:
2283
2370
  _private_dir(CONFIG_DIR)
2284
2371
  _replace_bytes(CONTEXT_SYNC_CONFIG, (json.dumps(cfg, indent=2) + "\n").encode())
2285
- where = f"publishing {cfg['source']}" if source else f"pulling into {USER_CONTEXT}"
2286
- return f"this machine is the user-context {cfg['role'].upper()}, {where}, via {repo}"
2372
+ return f"this machine is the user-context {cfg['role'].upper()}; privacy verification required at each sync"
2287
2373
 
2288
2374
 
2289
2375
  def _ensure_clone(repo: str) -> Optional[str]:
2290
- """None on success, else why it failed. A slug clones through `gh` (which
2291
- carries the operator's GitHub auth for a PRIVATE repo); a URL or path
2292
- clones with plain git."""
2376
+ """Clone only the independently verified GitHub repository through gh."""
2293
2377
  if (CONTEXT_CLONE / ".git").is_dir():
2294
2378
  _private_dir(CONTEXT_CLONE)
2295
2379
  return None
2296
2380
  _private_dir(CONTEXT_CLONE.parent)
2297
- if _REPO_SLUG_RE.match(repo) and shutil.which("gh"):
2298
- cmd = ["gh", "repo", "clone", repo, str(CONTEXT_CLONE)]
2299
- else:
2300
- url = f"https://github.com/{repo}.git" if _REPO_SLUG_RE.match(repo) else repo
2301
- cmd = ["git", "clone", url, str(CONTEXT_CLONE)]
2381
+ cmd = ["gh", "repo", "clone", repo, str(CONTEXT_CLONE)]
2302
2382
  done = subprocess.run( # noqa: S603 — literal argv built above from a validated repo, no shell
2303
2383
  cmd, capture_output=True, text=True, timeout=180
2304
2384
  )
2305
2385
  if done.returncode == 0:
2306
2386
  _private_dir(CONTEXT_CLONE)
2307
2387
  return None
2308
- return _tail(done.stderr or done.stdout, 2)
2388
+ return "clone failed"
2309
2389
 
2310
2390
 
2311
2391
  def _digest(data: bytes) -> str:
@@ -2318,33 +2398,49 @@ def sync_user_context(dry_run: bool = False) -> list[str]:
2318
2398
  """Publish (source) or pull (replica) the user-context. Silent when this
2319
2399
  machine was never configured, so an operator with one machine sees
2320
2400
  nothing new."""
2401
+ try:
2402
+ return _sync_user_context(dry_run)
2403
+ except (OSError, ValueError, KeyError, subprocess.SubprocessError):
2404
+ return ["user-context sync: SKIPPED (private destination could not be verified)"]
2405
+
2406
+
2407
+ def _sync_user_context(dry_run: bool) -> list[str]:
2321
2408
  cfg = _read_context_config()
2322
2409
  if cfg is None:
2323
2410
  return []
2324
2411
  if dry_run:
2325
- return [f"user-context sync: {cfg['role']} via {cfg['repo']} (dry run: nothing sent)"]
2326
- failed = _ensure_clone(cfg["repo"])
2412
+ return ["user-context sync: dry run; privacy unverified, nothing sent"]
2413
+ repo = _context_repo(cfg["repo"])
2414
+ if not _context_visibility(repo):
2415
+ return ["user-context sync: SKIPPED (private destination could not be verified)"]
2416
+ if CONTEXT_CLONE.exists() and (
2417
+ not (CONTEXT_CLONE / ".git").is_dir() or not _context_destination(repo)
2418
+ ):
2419
+ return ["user-context sync: SKIPPED (clone destination could not be verified)"]
2420
+ failed = _ensure_clone(repo)
2327
2421
  if failed:
2328
- return [f"user-context sync: SKIPPED (clone of {cfg['repo']} failed: {failed})"]
2422
+ return ["user-context sync: SKIPPED (clone failed)"]
2423
+ branch = _context_destination(repo)
2424
+ if branch is None:
2425
+ return ["user-context sync: SKIPPED (clone destination could not be verified)"]
2329
2426
  has_history = _git("rev-parse", "--verify", "HEAD", cwd=CONTEXT_CLONE).returncode == 0
2330
2427
  if has_history:
2331
- pulled = _git("pull", "--ff-only", "--quiet", cwd=CONTEXT_CLONE)
2428
+ pulled = _git("pull", "--ff-only", "--quiet", "origin", branch, cwd=CONTEXT_CLONE)
2332
2429
  if pulled.returncode != 0:
2333
- return [f"user-context sync: SKIPPED (pull failed: {_tail(pulled.stderr, 2)})"]
2430
+ return ["user-context sync: SKIPPED (pull failed)"]
2334
2431
  carried = CONTEXT_CLONE / CONTEXT_FILE
2335
2432
 
2336
2433
  if cfg["role"] == "source":
2337
2434
  src = Path(cfg["source"])
2338
2435
  if not src.is_file():
2339
- return [f"user-context sync: SKIPPED (source {src} is missing)"]
2436
+ return ["user-context sync: SKIPPED (source file is unavailable)"]
2340
2437
  out: list[str] = []
2341
2438
  # An edit to a separate copy at the default path would never publish.
2342
2439
  if USER_CONTEXT.exists() and not USER_CONTEXT.is_symlink():
2343
2440
  if USER_CONTEXT.read_bytes() != src.read_bytes():
2344
2441
  out.append(
2345
- f"user-context sync: WARNING {USER_CONTEXT} is a separate copy that "
2346
- f"differs from the source; edits there are NOT published. Point it at "
2347
- f"{src}."
2442
+ "user-context sync: WARNING default context differs from the source; "
2443
+ "edits to that separate copy are NOT published."
2348
2444
  )
2349
2445
  data = src.read_bytes()
2350
2446
  if carried.exists() and carried.read_bytes() == data:
@@ -2354,14 +2450,21 @@ def sync_user_context(dry_run: bool = False) -> list[str]:
2354
2450
  steps = (
2355
2451
  ("add", CONTEXT_FILE),
2356
2452
  ("commit", "--quiet", "-m", f"sync user-context {_digest(data)}"),
2357
- ("push", "--quiet", "-u", "origin", "HEAD"),
2453
+ ("push", "--quiet", "-u", "origin", f"HEAD:refs/heads/{branch}"),
2358
2454
  )
2359
2455
  for step in steps:
2456
+ if step[0] == "push" and (
2457
+ not _context_visibility(repo) or _context_destination(repo) != branch
2458
+ ):
2459
+ return [
2460
+ *out,
2461
+ "user-context sync: SKIPPED (private destination changed before push)",
2462
+ ]
2360
2463
  done = _git(*step, cwd=CONTEXT_CLONE)
2361
2464
  if done.returncode != 0:
2362
2465
  return [
2363
2466
  *out,
2364
- f"user-context sync: FAILED at git {step[0]}: {_tail(done.stderr, 2)}",
2467
+ f"user-context sync: FAILED at git {step[0]}",
2365
2468
  ]
2366
2469
  return [*out, f"user-context sync: published ({_digest(data)})"]
2367
2470
 
@@ -1,2 +1,2 @@
1
1
  # src/roadmodel/__init__.py
2
- __version__ = "0.2.82"
2
+ __version__ = "0.2.84"
@@ -418,6 +418,23 @@ def recommend(
418
418
  click.echo(_format_structured_text(structured), nl=False)
419
419
 
420
420
 
421
+ def _scoring_user_context(user_context_path: Path | None) -> str:
422
+ """The user-context text the scorer reads: ``--user-context``, else
423
+ ROADMODEL_USER_CONTEXT, else the default path; empty when none exists. A
424
+ path named explicitly that does not exist is an error."""
425
+ if user_context_path is not None and not user_context_path.expanduser().exists():
426
+ raise UserContextNotFoundError(user_context_path.expanduser())
427
+ env_user_context = os.environ.get("ROADMODEL_USER_CONTEXT")
428
+ if (
429
+ user_context_path is None
430
+ and env_user_context
431
+ and not Path(env_user_context).expanduser().exists()
432
+ ):
433
+ raise UserContextNotFoundError(Path(env_user_context).expanduser())
434
+ resolved = user_context.resolve(cli_path=user_context_path)
435
+ return user_context.read(resolved) if resolved.exists() else ""
436
+
437
+
421
438
  @cli.command("score")
422
439
  @click.option(
423
440
  "--category",
@@ -474,17 +491,7 @@ def score_command(
474
491
  funding, consumption headroom and Usage-pool status from user-context.md.
475
492
  Every term is printed so a pick can be audited line by line.
476
493
  """
477
- if user_context_path is not None and not user_context_path.expanduser().exists():
478
- raise UserContextNotFoundError(user_context_path.expanduser())
479
- env_user_context = os.environ.get("ROADMODEL_USER_CONTEXT")
480
- if (
481
- user_context_path is None
482
- and env_user_context
483
- and not Path(env_user_context).expanduser().exists()
484
- ):
485
- raise UserContextNotFoundError(Path(env_user_context).expanduser())
486
- resolved = user_context.resolve(cli_path=user_context_path)
487
- text = user_context.read(resolved) if resolved.exists() else ""
494
+ text = _scoring_user_context(user_context_path)
488
495
  posture = (budget or scoring.declared_budget(text)).lower()
489
496
  try:
490
497
  task = scoring.Task(
@@ -504,6 +511,82 @@ def score_command(
504
511
  click.echo(scoring.render_text(ranking, limit=top))
505
512
 
506
513
 
514
+ @cli.command("backup")
515
+ @click.option(
516
+ "--category",
517
+ required=True,
518
+ type=click.Choice(list(scoring.CATEGORIES), case_sensitive=False),
519
+ help="PRIMARY task category of the pick.",
520
+ )
521
+ @click.option(
522
+ "--complexity",
523
+ required=True,
524
+ type=click.Choice(list(scoring.COMPLEXITIES), case_sensitive=False),
525
+ help="Overall complexity of the pick's task.",
526
+ )
527
+ @click.option("--novel", is_flag=True, default=False, help="High complexity AND novel.")
528
+ @click.option("--model", required=True, help="The pick's model: catalog id or name.")
529
+ @click.option("--effort", required=True, help="The pick's effort, as its dial names it.")
530
+ @click.option(
531
+ "--budget",
532
+ type=click.Choice(list(scoring.BUDGETS), case_sensitive=False),
533
+ default=None,
534
+ help="Budget posture; defaults to the user-context's declared Budget priority.",
535
+ )
536
+ @click.option(
537
+ "--output",
538
+ type=click.Choice(["text", "json"], case_sensitive=False),
539
+ default="text",
540
+ show_default=True,
541
+ )
542
+ @click.option(
543
+ "--user-context",
544
+ "user_context_path",
545
+ type=click.Path(path_type=Path),
546
+ default=None,
547
+ help="Path to user-context.md override.",
548
+ )
549
+ def backup_command(
550
+ category: str,
551
+ complexity: str,
552
+ novel: bool,
553
+ model: str,
554
+ effort: str,
555
+ budget: str | None,
556
+ output: str,
557
+ user_context_path: Path | None,
558
+ ) -> None:
559
+ """Name the cross-maker backup for a pick, in code — no engine call.
560
+
561
+ The rule /recommend backs each pick up by: another maker's model that does
562
+ the job as well, for the least; where the benchmarks cannot tell two
563
+ substitutes apart, the cheaper. Prints the roadmap Settings Backup cell.
564
+ """
565
+ text = _scoring_user_context(user_context_path)
566
+ posture = (budget or scoring.declared_budget(text)).lower()
567
+ try:
568
+ task = scoring.Task(category.lower(), complexity.lower(), novel, posture)
569
+ backup = scoring.backup_for_pick(task, text, model, effort)
570
+ except ValueError as exc:
571
+ raise click.ClickException(str(exc)) from exc
572
+ cell = scoring.backup_cell(backup) if backup is not None else "None"
573
+ if output.lower() == "json":
574
+ payload: dict[str, Any] = {"backup": None, "cell": cell}
575
+ if backup is not None:
576
+ c = backup.candidate
577
+ payload["backup"] = {
578
+ "model_id": c.model_id,
579
+ "model": c.model_name,
580
+ "platform_id": c.platform_id,
581
+ "platform": c.platform_name,
582
+ "effort": backup.effort,
583
+ "native_effort": backup.native,
584
+ }
585
+ click.echo(json.dumps(payload, indent=2, ensure_ascii=False))
586
+ return
587
+ click.echo(f"BACKUP {cell}")
588
+
589
+
507
590
  @cli.command("cost")
508
591
  @click.option("--model", required=True, help="Catalog model id or display name.")
509
592
  @click.option("--platform", required=True, help="Access method id or display name.")
@@ -137,8 +137,19 @@ _RANK_SCALED_KEYS: Final[frozenset[str]] = frozenset(
137
137
 
138
138
  # Editorial letter → quality points. Midpoints of five equal bands.
139
139
  LETTER_QUALITY: Final[dict[str, float]] = {"S": 90.0, "A": 70.0, "B": 50.0, "C": 30.0, "D": 10.0}
140
- # When AA has measured the model: weight on the scaled evidence vs the letter.
140
+ # When AA has measured the model in speed: weight on the scaled evidence vs
141
+ # the letter (speed's letter is an estimate, not derived from tokens/s).
141
142
  EVIDENCE_WEIGHT: Final[float] = 0.7
143
+ # The five derived categories: update/derive_ratings.py letters them from the
144
+ # very benchmark CATEGORY_EVIDENCE names, so a measured model's letter there
145
+ # is a staircase of evidence already counted. Blending it in adds no
146
+ # information, only a cliff at each band edge: GPT-6 Astra's 52.7 on the AA
147
+ # Index reads S and GPT-6.1 Sol's 51.8 reads A, six quality points for 0.9
148
+ # of index. A model measured there is scored on its evidence alone; its
149
+ # letter stands in only where AA has not measured it.
150
+ DERIVED_LETTER_CATEGORIES: Final[frozenset[str]] = frozenset(
151
+ {"coding", "planning", "agentic", "long-context", "knowledge"}
152
+ )
142
153
  # A letter with no AA measurement behind it is an editorial prior, not
143
154
  # evidence: discount it so a measured model wins an otherwise-tied letter.
144
155
  UNMEASURED_DISCOUNT: Final[float] = 5.0
@@ -886,7 +897,8 @@ def _quality(
886
897
  level: str | None = None,
887
898
  ) -> tuple[float, str, str]:
888
899
  """The model's quality for the task's category: its headline evidence, or
889
- with ``level`` its evidence at that effort, blended with its letter."""
900
+ with ``level`` its evidence at that effort; in speed blended with its
901
+ letter, in a derived category (:data:`DERIVED_LETTER_CATEGORIES`) alone."""
890
902
  raw_tiers = model.get("tiers")
891
903
  tiers: dict[str, Any] = raw_tiers if isinstance(raw_tiers, dict) else {}
892
904
  letter = str(tiers.get(task.category, "C")).upper()
@@ -898,6 +910,8 @@ def _quality(
898
910
  points = _evidence_points(str(model.get("id", "")), task, bench, scale, level)
899
911
  if points is None:
900
912
  return max(0.0, letter_q - UNMEASURED_DISCOUNT), "letter", letter
913
+ if task.category in DERIVED_LETTER_CATEGORIES:
914
+ return points, f"aa:{CATEGORY_EVIDENCE[task.category]}", letter
901
915
  q = EVIDENCE_WEIGHT * points + (1.0 - EVIDENCE_WEIGHT) * letter_q
902
916
  return q, f"aa:{CATEGORY_EVIDENCE[task.category]}+letter", letter
903
917
 
@@ -1244,10 +1258,24 @@ def rank(
1244
1258
  backup: Candidate | None = None
1245
1259
  warning: str | None = None
1246
1260
  if primary is not None:
1247
- for c in candidates[1:]:
1248
- if c.provider != primary.provider and c.funding != "unfunded":
1249
- backup = c
1250
- break
1261
+ others = [
1262
+ c for c in candidates[1:] if c.provider != primary.provider and c.funding != "unfunded"
1263
+ ]
1264
+ if others:
1265
+ # The best-scoring other maker's model, else the cheapest one the
1266
+ # evidence cannot tell from it (:func:`_near_tied`), whatever the
1267
+ # posture: at budget best the score barely prices a model, and a
1268
+ # fraction of an AA point within noise would buy a dearer backup.
1269
+ ref = others[0]
1270
+ band = _aa_band(bench, cat)
1271
+
1272
+ def aa(c: Candidate) -> float | None:
1273
+ return _evidence(bench, c.model_id, FRONTIER_INDEX, c.evidence_level)
1274
+
1275
+ backup = min(
1276
+ (c for c in others if _near_tied(c, aa(c), ref, aa(ref), task.category, band)),
1277
+ key=lambda c: (c.effective_cost_usd, -c.score, c.model_id),
1278
+ )
1251
1279
  if backup is None:
1252
1280
  funded_providers = sorted({c.provider for c in candidates if c.funding != "unfunded"})
1253
1281
  warning = (
@@ -1452,6 +1480,33 @@ def _level_with(a: Candidate, b: Candidate, category: str) -> bool:
1452
1480
  return a.evidence_points >= b.evidence_points - TIE_BAND.get(category, DEFAULT_TIE_BAND)
1453
1481
 
1454
1482
 
1483
+ def _near_tied(
1484
+ a: Candidate,
1485
+ a_aa: float | None,
1486
+ b: Candidate,
1487
+ b_aa: float | None,
1488
+ category: str,
1489
+ aa_band: float,
1490
+ ) -> bool:
1491
+ """Whether the evidence cannot tell ``a`` from ``b``: each level with the
1492
+ other in the category (:func:`_level_with`, both ways), and their AA
1493
+ Indexes (``a_aa``, ``b_aa``) no more than ``aa_band`` apart where both are
1494
+ measured. One-way levelness is not enough: every stronger model is level
1495
+ with a weaker one."""
1496
+ return (
1497
+ _level_with(a, b, category)
1498
+ and _level_with(b, a, category)
1499
+ and (a_aa is None or b_aa is None or abs(a_aa - b_aa) <= aa_band)
1500
+ )
1501
+
1502
+
1503
+ def _aa_band(bench: dict[str, Any], catalog: dict[str, Any]) -> float:
1504
+ """The AA Index's noise band in its own points: DEFAULT_TIE_BAND on the
1505
+ 0–100 scale it is read on, mapped back onto the index's measured range."""
1506
+ gscale = _evidence_scale(catalog, bench, FRONTIER_INDEX)
1507
+ return 0.0 if gscale is None else DEFAULT_TIE_BAND * (gscale.hi - gscale.lo) / 100.0
1508
+
1509
+
1455
1510
  def _tie_break(p: FrontierPoint, category: str) -> tuple[float, float]:
1456
1511
  """Between points level in the category: the higher AA Index, then the
1457
1512
  cheaper. Speed work too: QUALITY is the ladder's top, and COST and
@@ -1680,8 +1735,9 @@ def ladder(
1680
1735
  adequate point above COST.
1681
1736
  - Each rung carries a BACKUP from another maker (:func:`_backup_for`):
1682
1737
  the cheapest point on the other makers' frontier that does the job as
1683
- well as the rung, else the nearest below it. ``backup_warning`` says why
1684
- a rung has none.
1738
+ well as the rung, else the strongest substitute, giving way to a
1739
+ cheaper one the evidence cannot tell from it. ``backup_warning`` says
1740
+ why a rung has none.
1685
1741
 
1686
1742
  None when no pool model carries an AA Index (no frontier to read)."""
1687
1743
  cat = catalog if catalog is not None else _cost._load_catalog()
@@ -1826,8 +1882,7 @@ def ladder(
1826
1882
  cost_rung = settle(_below(cost_rung, balanced_rung, cat))
1827
1883
  # General capability is level within the same noise band, read on the
1828
1884
  # AA Index's own 0–100 scale (the planning evidence).
1829
- gscale = _evidence_scale(cat, bench, FRONTIER_INDEX)
1830
- aa_band = 0.0 if gscale is None else DEFAULT_TIE_BAND * (gscale.hi - gscale.lo) / 100.0
1885
+ aa_band = _aa_band(bench, cat)
1831
1886
  rungs = {
1832
1887
  r.tier: replace(r, backup=_backup_for(r, settings, task, headroom, bench, cat, aa_band))
1833
1888
  for r in (cost_rung, balanced_rung, quality_rung)
@@ -1894,10 +1949,13 @@ def _backup_for(
1894
1949
  those already paid for ahead of per-token ones. A point matches the rung
1895
1950
  when it stands level with it in the task's category
1896
1951
  (:func:`_level_with`) and in general capability (an AA Index no more than
1897
- ``aa_band`` below). The backup is the cheapest match; with none, the
1952
+ ``aa_band`` below). The reference is the cheapest match; with none, the
1898
1953
  substitute the QUALITY rule would pick (:func:`_top`: strongest in the
1899
1954
  category, a near tie going to the higher AA Index); with no adequate
1900
- point, the one that falls shortest of the bar. It runs at its
1955
+ point, the one that falls shortest of the bar. The backup is the cheapest
1956
+ of the points within the noise bands of the reference both ways
1957
+ (:func:`_near_tied`): the evidence cannot tell them apart, so paying more
1958
+ buys nothing it can show. It runs at its
1901
1959
  point's measured effort, else at the rung's posture's effort, on its own
1902
1960
  platform. None when the pool holds no other
1903
1961
  maker's model."""
@@ -1924,13 +1982,26 @@ def _backup_for(
1924
1982
  if _level_with(p.candidate, r.candidate, task.category)
1925
1983
  and (r.aa_index is None or aa(p) >= r.aa_index - aa_band)
1926
1984
  ]
1927
- chosen = (
1985
+ ref = (
1928
1986
  min(matches, key=lambda p: (p.price_usd, -aa(p)))
1929
1987
  if matches
1930
1988
  else _top(near, task.category)
1931
1989
  )
1932
1990
  else:
1933
- chosen = max(front, key=_nearest)
1991
+ ref = max(front, key=_nearest)
1992
+ # Of the points the evidence cannot tell from the reference, the cheapest:
1993
+ # a near-identical model at a quarter of the price is not passed over for
1994
+ # a fraction of an AA point. A peer may fall short of the bar where the
1995
+ # reference clears it, by less than the noise: a shortfall the evidence
1996
+ # cannot show. A prepaid reference keeps a prepaid backup.
1997
+ prepaid = ref.candidate.scarcity < 1.0
1998
+ peers = [
1999
+ p
2000
+ for p in front
2001
+ if (p.candidate.scarcity < 1.0 or not prepaid)
2002
+ and _near_tied(p.candidate, p.aa_index, ref.candidate, ref.aa_index, task.category, aa_band)
2003
+ ]
2004
+ chosen = min(peers, key=lambda p: (p.price_usd, -p.candidate.score))
1934
2005
  c = chosen.candidate
1935
2006
  if c.evidence_level is not None:
1936
2007
  effort = c.evidence_level
@@ -1940,6 +2011,108 @@ def _backup_for(
1940
2011
  return Backup(chosen, effort, native_level(c.platform_id, c.model_id, effort, catalog=catalog))
1941
2012
 
1942
2013
 
2014
+ def _model_ref_id(ref: str, catalog: dict[str, Any]) -> str:
2015
+ """The catalog id ``ref`` names, matched spelling-free as
2016
+ recommend._model_key does ("Claude Opus 5.5", "Opus 5.5" and
2017
+ "claude-opus-5-5" are one model); ``ref`` itself on a miss."""
2018
+
2019
+ def key(text: str) -> str:
2020
+ words = re.split(r"[\s_.-]+", text.lower())
2021
+ return " ".join(sorted(w for w in words if w and w != "claude"))
2022
+
2023
+ want = key(ref)
2024
+ for m in catalog.get("models", []):
2025
+ if isinstance(m, dict) and want in {key(str(m.get("id", ""))), key(str(m.get("name", "")))}:
2026
+ return str(m.get("id", ""))
2027
+ return ref
2028
+
2029
+
2030
+ def backup_for_pick(
2031
+ task: Task,
2032
+ user_context_text: str,
2033
+ model: str,
2034
+ effort: str,
2035
+ *,
2036
+ unavailable_models: list[str] | None = None,
2037
+ catalog: dict[str, Any] | None = None,
2038
+ benchmarks: dict[str, Any] | None = None,
2039
+ ) -> Backup | None:
2040
+ """The backup for a pick made outside the ladder (a roadmap step's
2041
+ Settings): ``model`` (catalog id or name) run at ``effort`` (a scorer or
2042
+ native level), backed up by the rule a /recommend rung follows
2043
+ (:func:`_backup_for`), over the same pool, so the two never disagree.
2044
+ ``task.budget`` sets the posture an unmeasured backup's effort follows.
2045
+ None when the pool holds no other maker's model. ValueError when the
2046
+ model is not one this user-context can reach."""
2047
+ cat = catalog if catalog is not None else _cost._load_catalog()
2048
+ bench = benchmarks if benchmarks is not None else _load_benchmarks()
2049
+ model_id = _model_ref_id(model, cat)
2050
+ base = Task(task.category, task.complexity, task.novel, "balanced")
2051
+ ranking = rank(
2052
+ base,
2053
+ user_context_text,
2054
+ unavailable_models=unavailable_models,
2055
+ catalog=cat,
2056
+ benchmarks=bench,
2057
+ effort_aware=False,
2058
+ )
2059
+ picked = next((c for c in ranking.candidates if c.model_id == model_id), None)
2060
+ models = {str(m.get("id", "")): m for m in cat.get("models", []) if isinstance(m, dict)}
2061
+ if picked is None or model_id not in models:
2062
+ raise ValueError(f"{model!r} is not a model this user-context can reach")
2063
+ funded = [c for c in ranking.candidates if c.funding != "unfunded"]
2064
+ pool = funded or ranking.candidates
2065
+ headroom = consumption_headroom(user_context_text or "")
2066
+ lam, k = ranking.lam, ranking.k_points_per_decade
2067
+ settings = effort_settings(pool, base, headroom, lam=lam, k=k, catalog=cat, benchmarks=bench)
2068
+ composite = with_composites(bench)
2069
+ scales = (
2070
+ _evidence_scale(cat, composite, CATEGORY_EVIDENCE[base.category]),
2071
+ _evidence_scale(cat, composite, CATEGORY_EVIDENCE["planning"]),
2072
+ )
2073
+ level = _scorer_level(effort)
2074
+ measured = level in measured_levels(bench, model_id)
2075
+ at = _at_level(
2076
+ picked, level if measured else None, models[model_id], base, composite, scales, lam, k
2077
+ )
2078
+ point = FrontierPoint(
2079
+ at, _evidence(bench, model_id, FRONTIER_INDEX, at.evidence_level), frontier_price(at)
2080
+ )
2081
+ tier = next(t for t, b in TIER_BUDGET.items() if b == task.budget)
2082
+ return _backup_for(
2083
+ Rung(tier, point, level), settings, task, headroom, bench, cat, _aa_band(bench, cat)
2084
+ )
2085
+
2086
+
2087
+ # A platform's effort dial as a Settings table names it (docs/settings-display.md):
2088
+ # Intelligence on OpenAI's reasoning surfaces, Effort elsewhere; Cursor has none.
2089
+ _DIAL_NAMES: Final[dict[str, str | None]] = {
2090
+ "codex-cli": "Intelligence",
2091
+ "codex-api": "Intelligence",
2092
+ "openai-api": "Intelligence",
2093
+ "cursor": None,
2094
+ }
2095
+ _LEVEL_WORDS: Final[dict[str, str]] = {
2096
+ "minimal": "Minimal",
2097
+ "low": "Low",
2098
+ "medium": "Medium",
2099
+ "high": "High",
2100
+ "xhigh": "XHigh",
2101
+ "max": "Max",
2102
+ "ultra": "Ultra",
2103
+ }
2104
+
2105
+
2106
+ def backup_cell(backup: Backup) -> str:
2107
+ """``backup`` as a roadmap Settings table's Backup cell:
2108
+ ``GPT-6.1 Sol — Codex · Intelligence XHigh``."""
2109
+ c = backup.candidate
2110
+ dial = _DIAL_NAMES.get(c.platform_id, "Effort")
2111
+ level = _LEVEL_WORDS.get(normalize_level(backup.level), backup.level)
2112
+ head = f"{c.model_name} — {c.platform_name}"
2113
+ return head if dial is None else f"{head} · {dial} {level}"
2114
+
2115
+
1943
2116
  def _below(lower: Rung, upper: Rung, catalog: dict[str, Any]) -> Rung:
1944
2117
  """``lower`` run at least one effort level below ``upper`` when both name
1945
2118
  the same model on the same platform and that path costs something
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes