codereview-loop 0.13.0__py3-none-any.whl

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.
codereview/tiering.py ADDED
@@ -0,0 +1,265 @@
1
+ #!/usr/bin/env python3
2
+ """Tiered panels and escalation — Epic E2 + E4 of the adopted
3
+ roadmap.
4
+
5
+ E1's risk scorer describes a change; this module ACTS on the
6
+ description to size the panel that reviews it:
7
+
8
+ * band "low" → a designated pre-screen seat (or seats)
9
+ only;
10
+ * band "medium" → a reduced panel;
11
+ * band "high" → the full panel, and evidence is required
12
+ (the Epic C rule: required evidence that
13
+ is missing BLOCKS the gate). "Missing"
14
+ includes the run where the caller supplied
15
+ no evidence record at all — the tiering
16
+ policy is itself the requirement. A supplied
17
+ record whose required item is UNAVAILABLE
18
+ (tool not installed) is the other C3 branch
19
+ and makes the gate INCONCLUSIVE instead
20
+ (P6-11: the two statuses are per-item, and
21
+ an absent record is not an item).
22
+
23
+ E4 is the safety valve on the smaller tiers: if the
24
+ pre-screen / reduced panel returns uncertainty or any
25
+ blocking finding, the run escalates to the full panel WITHIN
26
+ the same run. The triggers are stated in the product's
27
+ existing vocabulary, never a parallel one:
28
+
29
+ * "blocking finding" — a stage-1 reviewer report carries a
30
+ finding whose severity is in the gate's blocking set
31
+ (the same set core._gate applies, allow_major included);
32
+ * "reviewer failure" — a stage-1 reviewer job ended in
33
+ error after its fallbacks: the seat could not answer, so
34
+ the small panel's answer is incomplete by the run's own
35
+ degraded vocabulary;
36
+ * "lens unserved: <lens>" — a lens the full panel tasks has
37
+ no successful stage-1 report while stage-2 seats remain
38
+ to serve it: the small panel structurally did not cover
39
+ the change (the S1/coverage vocabulary).
40
+
41
+ Escalation never removes anything: stage-1 reports and
42
+ findings stay in the run, and the final verdict is computed
43
+ on the full evidence of the run by the normal gate rules.
44
+ A finding the pre-screen raised is neither withdrawn nor
45
+ discounted because the full panel also served.
46
+
47
+ Configuration lives in the [risk] table of .codereview.toml
48
+ (policy.py validates it in the B1 style): `tiering = true`
49
+ turns the machinery on (it consumes the band, so [risk]
50
+ `enabled` must be true too — policy load rejects the
51
+ combination otherwise). `prescreen_seats` names the low
52
+ tier's seats (default: the first seat of the composed
53
+ panel); `medium_seats` names the medium tier's seats
54
+ (default: the first tasked seat per lens). A configured name
55
+ that is not on the composed panel is recorded in the run's
56
+ `tiering` section as a missing seat — never silently
57
+ dropped — and if filtering would leave a tier with NO seats
58
+ at all, the run falls back to the full panel with the
59
+ reason recorded: a configuration accident must never produce
60
+ an unreviewed run.
61
+
62
+ Seat–lens binding (S1) is untouched: under an explicit
63
+ binding, tiering filters the derived task list by seat, so
64
+ a seat serves exactly its bound lens(es) at every tier — a
65
+ reduced panel is a subset of seats with their bound lenses,
66
+ never a re-mapping. Under the legacy seat-order assignment
67
+ there is no binding to violate: the tier's seats form a
68
+ smaller panel and are tasked by the ordinary derivation
69
+ rule (a lone pre-screen seat is called once per lens,
70
+ exactly as a one-seat panel is today).
71
+
72
+ Deterministic control flow: no model calls in this module.
73
+ The plan and the trigger evaluation are pure functions of
74
+ the risk section, the derived tasks, the resolved config,
75
+ and the stage-1 reports.
76
+
77
+ With tiering unconfigured — no policy, [risk] disabled, or
78
+ `tiering` absent/false — core.run_review never consults
79
+ this module: no record section, no staged execution, the
80
+ run is byte-identical to the pre-E2 construction, which the
81
+ bench and the registered confirmation runs rely on: they
82
+ never enable it. Verification rounds are out of scope,
83
+ exactly as for E1/E3: run_verification carries no project
84
+ policy today, so there is no tiering surface there either.
85
+ """
86
+
87
+ from __future__ import annotations
88
+
89
+ SCHEMA = "codereview.tiering/1"
90
+
91
+ # The escalation trigger kinds, in the order they are
92
+ # evaluated and recorded.
93
+ TRIGGER_BLOCKING_FINDING = "blocking finding"
94
+ TRIGGER_REVIEWER_FAILURE = "reviewer failure"
95
+ TRIGGER_LENS_UNSERVED = "lens unserved"
96
+
97
+
98
+ def tiering_config(policy):
99
+ """The resolved tiering config carried by a loaded policy
100
+ dict, or None when tiering is not enabled (no policy, no
101
+ [risk] table, scoring disabled, or tiering = false) — the
102
+ caller then runs exactly as it did before Epic E2.
103
+
104
+ Defensive by construction, mirroring risk.risk_config:
105
+ a hand-built policy dict with missing or mistyped values
106
+ resolves to the defaults rather than raising mid-run; the
107
+ validating layer is policy.load_policy (which also
108
+ rejects tiering = true with enabled = false)."""
109
+ if not isinstance(policy, dict):
110
+ return None
111
+ risk = policy.get("risk") or {}
112
+ if not isinstance(risk, dict) \
113
+ or not risk.get("enabled") \
114
+ or not risk.get("tiering"):
115
+ return None
116
+
117
+ def _seats(key):
118
+ value = risk.get(key)
119
+ if not isinstance(value, (list, tuple)):
120
+ return []
121
+ return [name for name in value
122
+ if isinstance(name, str) and name]
123
+
124
+ return {
125
+ "prescreen_seats": _seats("prescreen_seats"),
126
+ "medium_seats": _seats("medium_seats"),
127
+ }
128
+
129
+
130
+ def plan_stages(band, tasks, config):
131
+ """Choose the tier's seats — the seat-level plan E4's
132
+ escalation rule runs under.
133
+
134
+ `tasks` is core._reviewer_tasks' output — (seat, fn,
135
+ lens) triples in derivation order, including any
136
+ fallback tasks run_review appended for binding-unserved
137
+ lenses; it fixes the panel's seat order and, for the
138
+ default medium tier, which seat first serves each lens.
139
+ `config` is a resolved config from tiering_config. The
140
+ caller turns the seat choice into stage task lists (a
141
+ binding-filtered subset of `tasks`, or a fresh
142
+ derivation for the tier's smaller panel under the
143
+ legacy assignment — see the module docstring).
144
+
145
+ Returns {"stage1_seats": [...], "prescreen_seats":
146
+ [...], "medium_seats": [...] or None,
147
+ "missing_seats": [...], "fallback_to_full": str or
148
+ None}. On a full-panel fallback, stage1_seats is the
149
+ whole panel and the caller runs no second stage.
150
+ """
151
+ config = config or {}
152
+ panel_seats = []
153
+ for name, _fn, _lens in tasks:
154
+ if name not in panel_seats:
155
+ panel_seats.append(name)
156
+
157
+ missing = []
158
+ fallback_to_full = None
159
+ prescreen = list(config.get("prescreen_seats") or [])
160
+ medium = list(config.get("medium_seats") or [])
161
+ if band == "high":
162
+ stage1_seats = list(panel_seats)
163
+ elif band == "medium":
164
+ if medium:
165
+ for name in medium:
166
+ if name not in panel_seats \
167
+ and name not in missing:
168
+ missing.append(name)
169
+ stage1_seats = [n for n in panel_seats
170
+ if n in set(medium)]
171
+ if not stage1_seats:
172
+ fallback_to_full = (
173
+ "none of the configured medium_seats "
174
+ "is on the composed panel")
175
+ else:
176
+ # Default reduced panel: the first tasked seat
177
+ # per lens, in task order — each lens the full
178
+ # panel serves is served once.
179
+ stage1_seats = []
180
+ seen_lenses = set()
181
+ for name, _fn, lens in tasks:
182
+ if lens in seen_lenses:
183
+ continue
184
+ seen_lenses.add(lens)
185
+ if name not in stage1_seats:
186
+ stage1_seats.append(name)
187
+ else: # "low" (and any unexpected band: the smallest tier)
188
+ if prescreen:
189
+ for name in prescreen:
190
+ if name not in panel_seats \
191
+ and name not in missing:
192
+ missing.append(name)
193
+ stage1_seats = [n for n in panel_seats
194
+ if n in set(prescreen)]
195
+ if not stage1_seats:
196
+ fallback_to_full = (
197
+ "none of the configured prescreen_seats "
198
+ "is on the composed panel")
199
+ else:
200
+ stage1_seats = panel_seats[:1]
201
+ if fallback_to_full is not None:
202
+ stage1_seats = list(panel_seats)
203
+ return {
204
+ "stage1_seats": stage1_seats,
205
+ "prescreen_seats": prescreen,
206
+ "medium_seats": medium or None,
207
+ "missing_seats": missing,
208
+ "fallback_to_full": fallback_to_full,
209
+ }
210
+
211
+
212
+ def escalation_triggers(stage1_reports, stage2_tasks,
213
+ blocking_sevs):
214
+ """E4's escalation evaluation over stage 1's results.
215
+
216
+ `stage1_reports` is the slice of the run's
217
+ reviewer_reports the first stage produced (each a report
218
+ dict with provider/lens/status/findings); `stage2_tasks`
219
+ is the plan's stage-2 task list — with nothing left to
220
+ escalate TO, no trigger can fire. `blocking_sevs` is the
221
+ gate's blocking severity set for this run (core passes
222
+ ("blocker",) under allow_major, else BLOCKING_DEFAULT).
223
+
224
+ Returns a list of trigger entries {"kind", "detail"} in
225
+ the fixed order: blocking finding, reviewer failure,
226
+ lens unserved. Empty ⇒ the small panel's answer stands
227
+ and stage 2 never runs. Pure: reads report dicts only.
228
+ """
229
+ if not stage2_tasks:
230
+ return []
231
+ reports = list(stage1_reports or [])
232
+ triggers = []
233
+
234
+ blocking = [f for r in reports
235
+ for f in (r.get("findings") or [])
236
+ if f.get("severity") in blocking_sevs]
237
+ if blocking:
238
+ triggers.append({
239
+ "kind": TRIGGER_BLOCKING_FINDING,
240
+ "detail": f"{len(blocking)} blocking finding(s) "
241
+ f"from the reduced panel "
242
+ f"({', '.join(sorted({f['severity'] for f in blocking}))})"})
243
+
244
+ failed = [r for r in reports if r.get("status") == "error"]
245
+ if failed:
246
+ triggers.append({
247
+ "kind": TRIGGER_REVIEWER_FAILURE,
248
+ "detail": f"{len(failed)} reduced-panel reviewer "
249
+ f"job(s) ended in error "
250
+ f"({', '.join(sorted({r.get('provider') or '?' for r in failed}))})"})
251
+
252
+ covered = {r.get("lens") for r in reports
253
+ if r.get("status") == "ok"}
254
+ stage2_lenses = []
255
+ for _name, _fn, lens in stage2_tasks:
256
+ if lens not in stage2_lenses:
257
+ stage2_lenses.append(lens)
258
+ for lens in stage2_lenses:
259
+ if lens not in covered:
260
+ triggers.append({
261
+ "kind": TRIGGER_LENS_UNSERVED,
262
+ "detail": f"lens unserved: {lens} — no "
263
+ f"successful reduced-panel review "
264
+ f"of that lens"})
265
+ return triggers
@@ -0,0 +1,307 @@
1
+ #!/usr/bin/env python3
2
+ """codereview user setup — the per-user config the FIRST RUN
3
+ creates (owner directive 2026-11-04: "we don't choose the LLM
4
+ model for them").
5
+
6
+ The product ships NO default panel. On first open the user runs
7
+ the setup wizard (`codereview setup`), picks their providers,
8
+ points at their own keys, assigns a model per role, and saves
9
+ THAT as their defaults. This module is the storage half:
10
+
11
+ path $CODEREVIEW_CONFIG, else $XDG_CONFIG_HOME/codereview/
12
+ config.toml, else ~/.config/codereview/config.toml
13
+ content provider SPECS and role -> spec assignments only.
14
+ A spec names a key ENVIRONMENT VARIABLE (the custom
15
+ spec grammar) or a registry preset (whose key env the
16
+ registry names) — key VALUES never appear here, are
17
+ never prompted into the file, and are never echoed.
18
+ The wizard also saves the seat metadata it derived
19
+ for the panel (P6-06: [seat_meta], seat name ->
20
+ vendor family) so a saved panel's families are the
21
+ ones setup computed, not a later re-derivation.
22
+
23
+ Resolution order at run time (cli): explicit --provider/--role
24
+ flags, then $CODEREVIEW_PROVIDERS, then this config. Nothing
25
+ else. A run with none of those is "not set up" and stops with
26
+ instructions — it never falls back to a built-in panel.
27
+ """
28
+
29
+ from __future__ import annotations
30
+
31
+ import json
32
+ import os
33
+ import re
34
+ import tomllib
35
+
36
+
37
+ def config_path(environ=None):
38
+ environ = os.environ if environ is None else environ
39
+ override = environ.get("CODEREVIEW_CONFIG", "").strip()
40
+ if override:
41
+ return override
42
+ xdg = environ.get("XDG_CONFIG_HOME", "").strip()
43
+ base = xdg or os.path.join(environ.get("HOME", "~"), ".config")
44
+ return os.path.join(base, "codereview", "config.toml")
45
+
46
+
47
+ def load_user_config(environ=None):
48
+ """-> {"providers": [...], "roles": {role: spec}} or None
49
+ when no setup exists. A malformed config raises ValueError —
50
+ a broken setup must be loud, never silently ignored."""
51
+ path = config_path(environ)
52
+ if not os.path.exists(path):
53
+ return None
54
+ with open(path, "rb") as fh:
55
+ try:
56
+ data = tomllib.load(fh)
57
+ except tomllib.TOMLDecodeError as exc:
58
+ raise ValueError(
59
+ f"user config {path} is not valid TOML: {exc}")
60
+ # Type-validate before iterating (open-refutations
61
+ # hardening, 9a4415ddafa6): a non-list `providers` was
62
+ # iterated element-wise (a string silently became one
63
+ # "provider" per character) and a non-table `roles` raised
64
+ # an uncaught AttributeError from .items() — both break
65
+ # this function's documented contract that a malformed
66
+ # config raises ValueError naming the path.
67
+ raw_providers = data.get("providers")
68
+ if raw_providers is None:
69
+ raw_providers = []
70
+ if not isinstance(raw_providers, list):
71
+ raise ValueError(
72
+ f"user config {path}: 'providers' must be a list "
73
+ f"of provider specs, got "
74
+ f"{type(raw_providers).__name__}")
75
+ raw_roles = data.get("roles")
76
+ if raw_roles is None:
77
+ raw_roles = {}
78
+ if not isinstance(raw_roles, dict):
79
+ raise ValueError(
80
+ f"user config {path}: 'roles' must be a table of "
81
+ f"role = \"spec\" entries, got "
82
+ f"{type(raw_roles).__name__}")
83
+ providers = [str(p) for p in raw_providers]
84
+ roles = {str(k): str(v) for k, v in raw_roles.items()}
85
+ # Epic K3a: the optional [hosting] table — today one key,
86
+ # `herdr`, a list of SEAT NAMES whose agent seats run
87
+ # inside herdr panes when herdr is present (see
88
+ # herdrhost.py). It is a hosting hint in the saved config
89
+ # only: never part of the agent: spec grammar, so records
90
+ # written before hosting existed stay valid. Type-
91
+ # validated like the rest — a malformed table is a loud
92
+ # ValueError, never a silently ignored hint.
93
+ raw_hosting = data.get("hosting")
94
+ if raw_hosting is None:
95
+ raw_hosting = {}
96
+ if not isinstance(raw_hosting, dict):
97
+ raise ValueError(
98
+ f"user config {path}: 'hosting' must be a table "
99
+ f"of hosting hints, got "
100
+ f"{type(raw_hosting).__name__}")
101
+ raw_herdr = raw_hosting.get("herdr")
102
+ if raw_herdr is None:
103
+ raw_herdr = []
104
+ if not isinstance(raw_herdr, list):
105
+ raise ValueError(
106
+ f"user config {path}: 'hosting.herdr' must be a "
107
+ f"list of seat names, got "
108
+ f"{type(raw_herdr).__name__}")
109
+ hosting = {"herdr": [str(s) for s in raw_herdr]}
110
+ # Epic L4: the optional [notify] table — one key,
111
+ # `command`, the argv of the operator's local attention
112
+ # command (see notify.py). It lives HERE, in the user
113
+ # setup, by design: notifications are operator preference,
114
+ # never project contract, so the project policy has no
115
+ # notify key (policy.load_policy rejects the table).
116
+ # Type-validated like the rest — a malformed table is a
117
+ # loud ValueError, never a silently ignored command.
118
+ raw_notify = data.get("notify")
119
+ if raw_notify is None:
120
+ raw_notify = {}
121
+ if not isinstance(raw_notify, dict):
122
+ raise ValueError(
123
+ f"user config {path}: 'notify' must be a table "
124
+ f"of notification settings, got "
125
+ f"{type(raw_notify).__name__}")
126
+ raw_command = raw_notify.get("command")
127
+ if raw_command is None:
128
+ raw_command = []
129
+ if not isinstance(raw_command, list) or not all(
130
+ isinstance(c, str) for c in raw_command):
131
+ raise ValueError(
132
+ f"user config {path}: 'notify.command' must be a "
133
+ f"list of strings (the notify command's argv), "
134
+ f"got {raw_command!r}")
135
+ notify = {"command": [str(c) for c in raw_command]}
136
+ # P6-06: the optional [seat_meta] table — seat name ->
137
+ # vendor family, written by the setup wizard from the
138
+ # metadata it derived for the confirmed panel. The CLI
139
+ # applies it over the derived baseline (explicit
140
+ # --seat-meta still wins). Type-validated like the rest.
141
+ raw_seat_meta = data.get("seat_meta")
142
+ if raw_seat_meta is None:
143
+ raw_seat_meta = {}
144
+ if not isinstance(raw_seat_meta, dict) or not all(
145
+ isinstance(k, str) and isinstance(v, str)
146
+ for k, v in raw_seat_meta.items()):
147
+ raise ValueError(
148
+ f"user config {path}: 'seat_meta' must be a "
149
+ f"table of seat name = \"family\" entries, got "
150
+ f"{raw_seat_meta!r}")
151
+ seat_meta = {str(k): str(v)
152
+ for k, v in raw_seat_meta.items()}
153
+ return {"providers": providers, "roles": roles,
154
+ "hosting": hosting, "notify": notify,
155
+ "seat_meta": seat_meta, "path": path}
156
+
157
+
158
+ def _toml_str(value):
159
+ # JSON strings are valid TOML basic strings for the content
160
+ # specs contain (URLs, names, commands without control chars).
161
+ return json.dumps(str(value), ensure_ascii=False)
162
+
163
+
164
+ def _toml_key(name):
165
+ # Bare TOML keys are [A-Za-z0-9_-]+; anything else is
166
+ # written as a quoted key.
167
+ if re.fullmatch(r"[A-Za-z0-9_-]+", str(name)):
168
+ return str(name)
169
+ return _toml_str(name)
170
+
171
+
172
+ def save_user_config(providers, roles, environ=None,
173
+ hosting=None, notify=None, seat_meta=None):
174
+ """Write the setup file (specs only — never a key value) and
175
+ return its path. `hosting` ({"herdr": [seat names]}) adds
176
+ the [hosting] table; `notify` ({"command": [argv...]})
177
+ adds the [notify] table (Epic L4); `seat_meta`
178
+ ({seat name: family}) adds the [seat_meta] table (P6-06);
179
+ the defaults write exactly the bytes earlier versions
180
+ wrote, so an unchanged setup stays byte-identical."""
181
+ path = config_path(environ)
182
+ os.makedirs(os.path.dirname(path) or ".", exist_ok=True)
183
+ lines = [
184
+ "# CodeReview user setup — created by `codereview setup`.",
185
+ "# Provider specs and role assignments only: keys live in",
186
+ "# the environment variables the specs name, never here.",
187
+ "providers = [" + ", ".join(
188
+ _toml_str(p) for p in providers) + "]",
189
+ "",
190
+ "[roles]",
191
+ ]
192
+ for role in sorted(roles):
193
+ lines.append(f"{role} = {_toml_str(roles[role])}")
194
+ herdr_seats = list((hosting or {}).get("herdr") or [])
195
+ if herdr_seats:
196
+ lines += [
197
+ "",
198
+ "[hosting]",
199
+ "# Agent seats listed here run inside herdr panes "
200
+ "when herdr",
201
+ "# is present (Epic K3a); without herdr they run "
202
+ "headless.",
203
+ "herdr = [" + ", ".join(
204
+ _toml_str(s) for s in herdr_seats) + "]",
205
+ ]
206
+ notify_command = list((notify or {}).get("command") or [])
207
+ if notify_command:
208
+ lines += [
209
+ "",
210
+ "[notify]",
211
+ "# Attention notifications (Epic L4): this local "
212
+ "command is",
213
+ "# run with the event name and a one-line summary "
214
+ "appended",
215
+ "# when a seat blocks or the gate reaches a "
216
+ "verdict.",
217
+ "command = [" + ", ".join(
218
+ _toml_str(c) for c in notify_command) + "]",
219
+ ]
220
+ seat_meta = dict(seat_meta or {})
221
+ if seat_meta:
222
+ lines += [
223
+ "",
224
+ "[seat_meta]",
225
+ "# Seat metadata the setup wizard derived for "
226
+ "this panel",
227
+ "# (P6-06): each seat's vendor family. Runs apply "
228
+ "it over",
229
+ "# the derived baseline; --seat-meta overrides it "
230
+ "per seat.",
231
+ ]
232
+ for name in sorted(seat_meta):
233
+ lines.append(
234
+ f"{_toml_key(name)} = "
235
+ f"{_toml_str(seat_meta[name])}")
236
+ with open(path, "w", encoding="utf-8") as fh:
237
+ fh.write("\n".join(lines) + "\n")
238
+ return path
239
+
240
+
241
+ def env_provider_specs(environ=None):
242
+ environ = os.environ if environ is None else environ
243
+ env = (environ.get("CODEREVIEW_PROVIDERS")
244
+ or environ.get("REVIEWLOOP_PROVIDERS", ""))
245
+ return [s.strip() for s in env.split(",") if s.strip()]
246
+
247
+
248
+ def default_provider_specs(environ=None):
249
+ """The run's provider specs when the user passed none:
250
+ environment first, then the saved setup. Empty list means
251
+ NOT SET UP — the caller stops with instructions rather than
252
+ inventing a panel."""
253
+ specs = env_provider_specs(environ)
254
+ if specs:
255
+ return specs
256
+ cfg = load_user_config(environ)
257
+ if cfg and cfg["providers"]:
258
+ return list(cfg["providers"])
259
+ return []
260
+
261
+
262
+ def default_role_assignments(environ=None):
263
+ """Saved role -> spec assignments from the user config
264
+ ({} when no setup exists). Explicit --role flags override
265
+ these per role at the CLI seam."""
266
+ cfg = load_user_config(environ)
267
+ return dict(cfg["roles"]) if cfg else {}
268
+
269
+
270
+ def default_hosting_herdr_seats(environ=None):
271
+ """Seat names the saved setup marks for herdr pane hosting
272
+ (Epic K3a; [] when no setup or no [hosting] table)."""
273
+ cfg = load_user_config(environ)
274
+ if not cfg:
275
+ return []
276
+ return list((cfg.get("hosting") or {}).get("herdr") or [])
277
+
278
+
279
+ def default_seat_meta(environ=None):
280
+ """The saved setup's [seat_meta] table ({seat name:
281
+ family}; {} when no setup or no table)."""
282
+ cfg = load_user_config(environ)
283
+ if not cfg:
284
+ return {}
285
+ return dict(cfg.get("seat_meta") or {})
286
+
287
+
288
+ def default_notify_command(environ=None):
289
+ """The saved setup's [notify] command argv (Epic L4; []
290
+ when no setup or no [notify] table). The ONLY source of a
291
+ notify command: the project policy cannot set one."""
292
+ cfg = load_user_config(environ)
293
+ if not cfg:
294
+ return []
295
+ return list((cfg.get("notify") or {}).get("command") or [])
296
+
297
+
298
+ NOT_SET_UP = (
299
+ "codereview is not set up yet: no providers were given and "
300
+ "no saved setup exists.\n"
301
+ " Interactive: run `codereview setup` and pick your "
302
+ "providers, keys, and per-role models.\n"
303
+ " Scripted: pass --provider <spec> (repeatable), or set "
304
+ "$CODEREVIEW_PROVIDERS, or run `codereview setup` once on "
305
+ "this machine.\n"
306
+ "CodeReview ships no default panel — the models are yours "
307
+ "to choose.")