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/__init__.py +6 -0
- codereview/__main__.py +4 -0
- codereview/acceptance.py +649 -0
- codereview/agentsmd.py +259 -0
- codereview/bench.py +881 -0
- codereview/claims.py +182 -0
- codereview/cli.py +2927 -0
- codereview/core.py +9070 -0
- codereview/crash.py +247 -0
- codereview/discovery.py +289 -0
- codereview/disputes.py +749 -0
- codereview/diversity.py +183 -0
- codereview/evidence.py +1023 -0
- codereview/feedback.py +416 -0
- codereview/github.py +286 -0
- codereview/hardening.py +45 -0
- codereview/herdrhost.py +373 -0
- codereview/hooks.py +261 -0
- codereview/intake.py +208 -0
- codereview/mcp_tools.py +455 -0
- codereview/mcp_transport.py +272 -0
- codereview/notify.py +237 -0
- codereview/pilot.py +549 -0
- codereview/policy.py +1086 -0
- codereview/probe.py +534 -0
- codereview/providers.py +1681 -0
- codereview/record_schema.py +904 -0
- codereview/registry.py +294 -0
- codereview/risk.py +368 -0
- codereview/sarif.py +309 -0
- codereview/seatstate.py +398 -0
- codereview/setup_wizard.py +355 -0
- codereview/support.py +328 -0
- codereview/techlead.py +1041 -0
- codereview/tiering.py +265 -0
- codereview/userconfig.py +307 -0
- codereview_loop-0.13.0.dist-info/METADATA +502 -0
- codereview_loop-0.13.0.dist-info/RECORD +44 -0
- codereview_loop-0.13.0.dist-info/WHEEL +5 -0
- codereview_loop-0.13.0.dist-info/entry_points.txt +3 -0
- codereview_loop-0.13.0.dist-info/licenses/LICENSE +21 -0
- codereview_loop-0.13.0.dist-info/top_level.txt +2 -0
- reviewloop/__init__.py +6 -0
- reviewloop/__main__.py +6 -0
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
|
codereview/userconfig.py
ADDED
|
@@ -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.")
|