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/herdrhost.py
ADDED
|
@@ -0,0 +1,373 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""codereview herdr pane hosting (Epic K3a) — an OPTIONAL
|
|
3
|
+
transport for agent seats the user already configured.
|
|
4
|
+
|
|
5
|
+
Herdr (github.com/herdrdev/herdr) is a terminal runtime that
|
|
6
|
+
hosts coding-agent CLIs in supervised panes. A seat whose name
|
|
7
|
+
is listed under `[hosting] herdr = [...]` in the saved user
|
|
8
|
+
config may run inside a herdr pane instead of a headless
|
|
9
|
+
subprocess, so the user can watch (and intervene in) the run
|
|
10
|
+
from herdr's UI. Nothing else changes: same spec, same prompt
|
|
11
|
+
contract, same record discipline, same gate. Herdr never
|
|
12
|
+
becomes a hard dependency — with herdr absent, too old, or
|
|
13
|
+
schema-mismatched, the adapter is inert, the seats run
|
|
14
|
+
headless exactly as before, and the run record carries one
|
|
15
|
+
line saying why (the CLI puts it in `herdr_hosting` via
|
|
16
|
+
record_extra).
|
|
17
|
+
|
|
18
|
+
Binding layer: herdr's CLI wrappers only (`herdr workspace
|
|
19
|
+
create`, `herdr pane run`, `herdr pane get`, `herdr api
|
|
20
|
+
snapshot`, ...). Raw socket access is a separate later
|
|
21
|
+
decision, per the K spec. Two surface choices worth stating:
|
|
22
|
+
|
|
23
|
+
* The seat is launched with `pane run`, not `agent start`.
|
|
24
|
+
`agent start` launches a RECOGNISED agent kind for
|
|
25
|
+
interactive prompting; a CodeReview seat is a one-shot
|
|
26
|
+
command with a file/stdin/stdout contract, which is
|
|
27
|
+
exactly what herdr's own automation docs assign to pane
|
|
28
|
+
commands ("shells, tests, servers, CI watchers, and other
|
|
29
|
+
ordinary terminal processes"). The packet itself is never
|
|
30
|
+
typed: the pane command line redirects stdin FROM the
|
|
31
|
+
prompt file and names it in $CODEREVIEW_PROMPT (passed
|
|
32
|
+
via `workspace create --env`, never typed either) — the
|
|
33
|
+
same contract the headless runner gives the seat.
|
|
34
|
+
* There is no `events` CLI in herdr: events.subscribe /
|
|
35
|
+
events.wait exist only on the socket API. The wrapper-
|
|
36
|
+
layer subscription is therefore a supervised watch —
|
|
37
|
+
repeated `pane get` polls standing in for
|
|
38
|
+
pane.agent_status_changed / pane.exited — and any break
|
|
39
|
+
in that watch (a failed poll, a vanished pane) is treated
|
|
40
|
+
as the socket layer's `events_lost`: reconcile ONCE via
|
|
41
|
+
`herdr api snapshot` (session.snapshot), then continue
|
|
42
|
+
only if the snapshot shows the same pane with the same
|
|
43
|
+
terminal_id. A pane that is gone, or whose terminal_id
|
|
44
|
+
changed (herdr restarted and restored layout — a resumed
|
|
45
|
+
session whose in-flight context cannot be proven
|
|
46
|
+
identical, spec U5), is a recorded seat failure. There is
|
|
47
|
+
no silent resume, ever: the adapter never re-creates a
|
|
48
|
+
pane or re-runs a command inside an attempt.
|
|
49
|
+
|
|
50
|
+
Truth seniority (K spec §5.1, K2 §4.2): the seat's outcome is
|
|
51
|
+
decided by OUR files — the capture files and the exit-code
|
|
52
|
+
file the pane command writes — exactly as the headless runner
|
|
53
|
+
decides from its subprocess. Herdr's agent_status is signal
|
|
54
|
+
#4, advisory only: it is recorded in the run's hosting
|
|
55
|
+
receipt and may surface an advisory blocked:input episode in
|
|
56
|
+
the seat-state trace while the seat runs; it never raises,
|
|
57
|
+
clears, or re-weights a finding, and when it disagrees with
|
|
58
|
+
the files, the files win and the disagreement stays in the
|
|
59
|
+
receipt's observations.
|
|
60
|
+
|
|
61
|
+
Environment discipline (spec U7): the pane is the user's own
|
|
62
|
+
terminal session — herdr injects its HERDR_* variables and
|
|
63
|
+
the user's shell environment applies. We add exactly one
|
|
64
|
+
variable of our own, CODEREVIEW_PROMPT (a path, not a
|
|
65
|
+
secret), plus HOME when the seat spec says home=private.
|
|
66
|
+
Operator API keys are never added to a pane: the headless
|
|
67
|
+
allow-list exists so keys stay out of agent processes, and
|
|
68
|
+
hosting does not smuggle them back in through the terminal.
|
|
69
|
+
|
|
70
|
+
Version discipline: the adapter feature-detects against
|
|
71
|
+
`herdr api schema --json` (the method and event names below
|
|
72
|
+
must all be present) and enforces MIN_HERDR_VERSION. The
|
|
73
|
+
floor and the name list are set from the herdr tree inspected
|
|
74
|
+
at build time (0.9.5, protocol 22); U8's cross-release schema
|
|
75
|
+
diff could not be run from a single clone, so the floor is
|
|
76
|
+
the oldest 0.9.x line and the schema check does the real
|
|
77
|
+
work on every run.
|
|
78
|
+
|
|
79
|
+
Live-smoke status: UNVERIFIED on the build machine — no real
|
|
80
|
+
herdr is installed there (and no Rust toolchain to build
|
|
81
|
+
one). The full lifecycle is tested against a stub `herdr`
|
|
82
|
+
executable implementing the used CLI surface; a real-herdr
|
|
83
|
+
smoke remains an open verification item, recorded as such in
|
|
84
|
+
the changelog.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
from __future__ import annotations
|
|
88
|
+
|
|
89
|
+
import contextvars
|
|
90
|
+
import json
|
|
91
|
+
import os
|
|
92
|
+
import re
|
|
93
|
+
import shutil
|
|
94
|
+
import subprocess
|
|
95
|
+
|
|
96
|
+
# Oldest herdr the adapter will drive (see module docstring).
|
|
97
|
+
MIN_HERDR_VERSION = (0, 9, 0)
|
|
98
|
+
|
|
99
|
+
# The socket methods the used CLI wrappers stand on, and the
|
|
100
|
+
# event types the wrapper-layer watch stands in for. All must
|
|
101
|
+
# appear in `herdr api schema --json`; a herdr missing any of
|
|
102
|
+
# them is a schema mismatch and the adapter stays inert.
|
|
103
|
+
REQUIRED_METHODS = (
|
|
104
|
+
"workspace.create", "workspace.close",
|
|
105
|
+
"pane.get", "pane.close", "pane.send_input",
|
|
106
|
+
"agent.get", "session.snapshot",
|
|
107
|
+
)
|
|
108
|
+
REQUIRED_EVENTS = ("pane.agent_status_changed", "pane.exited")
|
|
109
|
+
|
|
110
|
+
# Bound on a single herdr CLI call (seconds). The seat's own
|
|
111
|
+
# timeout bounds the run; these calls are control-plane only.
|
|
112
|
+
CLI_TIMEOUT = 15
|
|
113
|
+
|
|
114
|
+
# The current job's hosting-receipt box (a list), installed by
|
|
115
|
+
# core._review_job exactly as seatstate.CURRENT is: a hosted
|
|
116
|
+
# seat callable appends one receipt per attempt, and the job
|
|
117
|
+
# owner attaches the LAST one (the determining attempt's) to
|
|
118
|
+
# the result as `hosting`. ContextVar so concurrent jobs on
|
|
119
|
+
# pooled threads never share a box. Default None: no box, no
|
|
120
|
+
# receipts — headless runs are untouched.
|
|
121
|
+
RECEIPTS = contextvars.ContextVar(
|
|
122
|
+
"codereview_herdr_receipts", default=None)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class HerdrError(Exception):
|
|
126
|
+
"""One herdr CLI call failed (exit != 0, timeout, or
|
|
127
|
+
unparseable output). The message carries the herdr stderr
|
|
128
|
+
tail when there is one — recorded, never invented."""
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
def parse_version(text):
|
|
132
|
+
"""'herdr 0.9.5' / '0.9.5-preview.123' -> (0, 9, 5);
|
|
133
|
+
None when no dotted triple is present."""
|
|
134
|
+
m = re.search(r"(\d+)\.(\d+)\.(\d+)", text or "")
|
|
135
|
+
if not m:
|
|
136
|
+
return None
|
|
137
|
+
return tuple(int(g) for g in m.groups())
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def version_str(version):
|
|
141
|
+
return ".".join(str(n) for n in version)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def schema_names(schema_text):
|
|
145
|
+
"""All method/event names a schema document mentions.
|
|
146
|
+
Walks every string in the parsed JSON (method names live
|
|
147
|
+
under `schemas.request`, event names under
|
|
148
|
+
`schemas.event` / `schemas.subscription_event`, but the
|
|
149
|
+
walk does not depend on that layout — a schema that
|
|
150
|
+
reorganises its sections still feature-detects)."""
|
|
151
|
+
try:
|
|
152
|
+
doc = json.loads(schema_text)
|
|
153
|
+
except (ValueError, TypeError, RecursionError):
|
|
154
|
+
return set()
|
|
155
|
+
names = set()
|
|
156
|
+
stack = [doc]
|
|
157
|
+
while stack:
|
|
158
|
+
node = stack.pop()
|
|
159
|
+
if isinstance(node, dict):
|
|
160
|
+
stack.extend(node.values())
|
|
161
|
+
elif isinstance(node, list):
|
|
162
|
+
stack.extend(node)
|
|
163
|
+
elif isinstance(node, str) and re.fullmatch(
|
|
164
|
+
r"[a-z_]+\.[a-z_]+", node):
|
|
165
|
+
names.add(node)
|
|
166
|
+
return names
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def _default_run(argv, timeout):
|
|
170
|
+
"""(returncode, stdout, stderr) for one herdr CLI call.
|
|
171
|
+
A timeout or a missing binary is returncode -1 with the
|
|
172
|
+
reason on stderr — callers turn it into HerdrError."""
|
|
173
|
+
try:
|
|
174
|
+
proc = subprocess.run(
|
|
175
|
+
argv, capture_output=True, text=True, timeout=timeout)
|
|
176
|
+
except subprocess.TimeoutExpired:
|
|
177
|
+
return -1, "", f"timed out after {timeout}s"
|
|
178
|
+
except OSError as exc:
|
|
179
|
+
return -1, "", f"could not run: {exc}"
|
|
180
|
+
return proc.returncode, proc.stdout or "", proc.stderr or ""
|
|
181
|
+
|
|
182
|
+
|
|
183
|
+
class HerdrHost:
|
|
184
|
+
"""A detected, feature-checked herdr session, driven
|
|
185
|
+
through its CLI. One instance serves a whole run; every
|
|
186
|
+
hosted seat attempt creates and closes its own workspace
|
|
187
|
+
(labelled, so leftovers are recognisable)."""
|
|
188
|
+
|
|
189
|
+
def __init__(self, binary, version, run_fn=None):
|
|
190
|
+
self.binary = binary
|
|
191
|
+
self.version = version # tuple, e.g. (0, 9, 5)
|
|
192
|
+
self._run = run_fn or _default_run
|
|
193
|
+
|
|
194
|
+
@property
|
|
195
|
+
def version_text(self):
|
|
196
|
+
return version_str(self.version)
|
|
197
|
+
|
|
198
|
+
def _cli(self, args, timeout=CLI_TIMEOUT):
|
|
199
|
+
rc, out, err = self._run([self.binary, *args], timeout)
|
|
200
|
+
if rc != 0:
|
|
201
|
+
tail = (err or out).strip()[-300:]
|
|
202
|
+
raise HerdrError(
|
|
203
|
+
f"herdr {' '.join(args[:2])} exited {rc}"
|
|
204
|
+
+ (f": {tail}" if tail else ""))
|
|
205
|
+
return out
|
|
206
|
+
|
|
207
|
+
def _cli_json(self, args, timeout=CLI_TIMEOUT):
|
|
208
|
+
out = self._cli(args, timeout=timeout)
|
|
209
|
+
try:
|
|
210
|
+
doc = json.loads(out)
|
|
211
|
+
except (ValueError, RecursionError) as exc:
|
|
212
|
+
# RecursionError: output nested past the
|
|
213
|
+
# interpreter limit is unparseable for our
|
|
214
|
+
# purposes (P3-08), not a crash in the caller.
|
|
215
|
+
raise HerdrError(
|
|
216
|
+
f"herdr {' '.join(args[:2])} returned "
|
|
217
|
+
f"unparseable output: {exc}") from exc
|
|
218
|
+
if not isinstance(doc, dict):
|
|
219
|
+
# Every consumer of this helper calls .get on the
|
|
220
|
+
# result; a bare list/null/scalar is a malformed
|
|
221
|
+
# envelope and must fail as HerdrError (which
|
|
222
|
+
# detect() turns inert), never as AttributeError
|
|
223
|
+
# in snapshot()/create_workspace()/pane_get().
|
|
224
|
+
raise HerdrError(
|
|
225
|
+
f"herdr {' '.join(args[:2])} returned a "
|
|
226
|
+
f"non-object document "
|
|
227
|
+
f"({type(doc).__name__})")
|
|
228
|
+
if "error" in doc:
|
|
229
|
+
raise HerdrError(
|
|
230
|
+
f"herdr {' '.join(args[:2])} reported "
|
|
231
|
+
f"{str(doc['error'])[:300]}")
|
|
232
|
+
# CLI responses are envelopes ({"id":..., "result":{...}});
|
|
233
|
+
# accept a bare payload too, so a wrapper that prints the
|
|
234
|
+
# result directly still binds.
|
|
235
|
+
if isinstance(doc.get("result"), dict):
|
|
236
|
+
return doc["result"]
|
|
237
|
+
return doc
|
|
238
|
+
|
|
239
|
+
# -- session ------------------------------------------------
|
|
240
|
+
def snapshot(self):
|
|
241
|
+
"""session.snapshot -> the snapshot dict (workspaces,
|
|
242
|
+
tabs, panes, agents). Reconciliation source of truth
|
|
243
|
+
after any watch break."""
|
|
244
|
+
result = self._cli_json(["api", "snapshot"])
|
|
245
|
+
snap = result.get("snapshot")
|
|
246
|
+
return snap if isinstance(snap, dict) else result
|
|
247
|
+
|
|
248
|
+
# -- panes --------------------------------------------------
|
|
249
|
+
def create_workspace(self, cwd, label, env=None):
|
|
250
|
+
"""-> (workspace_id, pane_id) for a fresh CodeReview
|
|
251
|
+
workspace whose root pane sits in `cwd`. `env` rides
|
|
252
|
+
herdr's own --env (the launched process's environment)
|
|
253
|
+
— values are never typed into the pane."""
|
|
254
|
+
args = ["workspace", "create", "--cwd", cwd,
|
|
255
|
+
"--label", label, "--no-focus"]
|
|
256
|
+
for key in sorted((env or {})):
|
|
257
|
+
args += ["--env", f"{key}={env[key]}"]
|
|
258
|
+
result = self._cli_json(args)
|
|
259
|
+
pane = result.get("root_pane") or {}
|
|
260
|
+
workspace = result.get("workspace") or {}
|
|
261
|
+
pane_id = pane.get("pane_id")
|
|
262
|
+
workspace_id = workspace.get("workspace_id")
|
|
263
|
+
if not pane_id or not workspace_id:
|
|
264
|
+
if workspace_id:
|
|
265
|
+
# Half-created: the workspace exists server-
|
|
266
|
+
# side and this id is the only handle anyone
|
|
267
|
+
# will ever have on it — close it now rather
|
|
268
|
+
# than leak it.
|
|
269
|
+
try:
|
|
270
|
+
self.close_workspace(workspace_id)
|
|
271
|
+
except HerdrError:
|
|
272
|
+
pass
|
|
273
|
+
raise HerdrError(
|
|
274
|
+
"herdr workspace create returned no "
|
|
275
|
+
"root_pane/workspace ids")
|
|
276
|
+
return workspace_id, pane_id
|
|
277
|
+
|
|
278
|
+
def pane_run(self, pane_id, command_line):
|
|
279
|
+
"""Submit one command line to the pane's shell
|
|
280
|
+
(pane.send_input under the wrapper; quiet on success).
|
|
281
|
+
The line is a launcher for the seat command — the
|
|
282
|
+
review packet itself is never in it (it travels by
|
|
283
|
+
file; see the module docstring)."""
|
|
284
|
+
self._cli(["pane", "run", pane_id, command_line])
|
|
285
|
+
|
|
286
|
+
def pane_get(self, pane_id):
|
|
287
|
+
"""-> the PaneInfo dict for `pane_id`, or None when
|
|
288
|
+
herdr answers cleanly that no such pane exists.
|
|
289
|
+
Transport failures raise HerdrError."""
|
|
290
|
+
result = self._cli_json(["pane", "get", pane_id])
|
|
291
|
+
pane = result.get("pane")
|
|
292
|
+
return pane if isinstance(pane, dict) else result
|
|
293
|
+
|
|
294
|
+
def close_workspace(self, workspace_id):
|
|
295
|
+
self._cli(["workspace", "close", workspace_id])
|
|
296
|
+
|
|
297
|
+
def close_pane(self, pane_id):
|
|
298
|
+
self._cli(["pane", "close", pane_id])
|
|
299
|
+
|
|
300
|
+
|
|
301
|
+
def detect(environ=None, which_fn=None, run_fn=None):
|
|
302
|
+
"""Probe the machine for a usable herdr, in the spec's
|
|
303
|
+
order: on PATH -> version floor -> schema feature-detect
|
|
304
|
+
-> reachable session. Returns a plan dict:
|
|
305
|
+
|
|
306
|
+
{"active": True, "host": HerdrHost, "version": (x,y,z),
|
|
307
|
+
"reason": None}
|
|
308
|
+
{"active": False, "host": None, "version": ... or None,
|
|
309
|
+
"reason": "<one line, recorded verbatim in the record>"}
|
|
310
|
+
|
|
311
|
+
`environ` is accepted for symmetry with userconfig (the
|
|
312
|
+
probe reads HERDR_SOCKET_PATH's presence from it for the
|
|
313
|
+
reason line); which_fn/run_fn are injectable for tests."""
|
|
314
|
+
environ = os.environ if environ is None else environ
|
|
315
|
+
which_fn = shutil.which if which_fn is None else which_fn
|
|
316
|
+
run_fn = _default_run if run_fn is None else run_fn
|
|
317
|
+
binary = which_fn("herdr")
|
|
318
|
+
if not binary:
|
|
319
|
+
return {"active": False, "host": None, "version": None,
|
|
320
|
+
"reason": "herdr not found on PATH"}
|
|
321
|
+
rc, out, err = run_fn([binary, "--version"], CLI_TIMEOUT)
|
|
322
|
+
if rc != 0:
|
|
323
|
+
tail = (err or out).strip()[-200:]
|
|
324
|
+
return {"active": False, "host": None, "version": None,
|
|
325
|
+
"reason": "herdr --version failed"
|
|
326
|
+
+ (f": {tail}" if tail else "")}
|
|
327
|
+
version = parse_version(out)
|
|
328
|
+
if version is None:
|
|
329
|
+
return {"active": False, "host": None, "version": None,
|
|
330
|
+
"reason": "herdr --version printed no version: "
|
|
331
|
+
+ (out.strip()[:120] or "(empty)")}
|
|
332
|
+
if version < MIN_HERDR_VERSION:
|
|
333
|
+
return {"active": False, "host": None,
|
|
334
|
+
"version": version,
|
|
335
|
+
"reason": f"herdr {version_str(version)} is "
|
|
336
|
+
f"below the adapter minimum "
|
|
337
|
+
f"{version_str(MIN_HERDR_VERSION)}"}
|
|
338
|
+
host = HerdrHost(binary, version, run_fn=run_fn)
|
|
339
|
+
rc, out, err = run_fn([binary, "api", "schema", "--json"],
|
|
340
|
+
CLI_TIMEOUT)
|
|
341
|
+
if rc != 0:
|
|
342
|
+
tail = (err or out).strip()[-200:]
|
|
343
|
+
return {"active": False, "host": None,
|
|
344
|
+
"version": version,
|
|
345
|
+
"reason": "herdr api schema --json failed"
|
|
346
|
+
+ (f": {tail}" if tail else "")}
|
|
347
|
+
names = schema_names(out)
|
|
348
|
+
missing = [n for n in REQUIRED_METHODS + REQUIRED_EVENTS
|
|
349
|
+
if n not in names]
|
|
350
|
+
if missing:
|
|
351
|
+
return {"active": False, "host": None,
|
|
352
|
+
"version": version,
|
|
353
|
+
"reason": "herdr schema mismatch: missing "
|
|
354
|
+
+ ", ".join(missing)}
|
|
355
|
+
try:
|
|
356
|
+
host.snapshot()
|
|
357
|
+
except HerdrError as exc:
|
|
358
|
+
where = ("HERDR_SOCKET_PATH is set"
|
|
359
|
+
if environ.get("HERDR_SOCKET_PATH")
|
|
360
|
+
else "no HERDR_SOCKET_PATH set")
|
|
361
|
+
return {"active": False, "host": None,
|
|
362
|
+
"version": version,
|
|
363
|
+
"reason": f"no reachable herdr session "
|
|
364
|
+
f"({where}): {exc}"}
|
|
365
|
+
return {"active": True, "host": host, "version": version,
|
|
366
|
+
"reason": None}
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def inert_line(plan):
|
|
370
|
+
"""The ONE line the run record carries when hosting was
|
|
371
|
+
configured but the adapter stayed inert (record_extra key
|
|
372
|
+
`herdr_hosting`, written by the CLI)."""
|
|
373
|
+
return f"inert: {plan['reason']}"
|
codereview/hooks.py
ADDED
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Lifecycle hooks — Epic L3 of the adopted roadmap.
|
|
3
|
+
|
|
4
|
+
A project may declare shell commands in the `[hooks]` table of
|
|
5
|
+
its `.codereview.toml` (validated by policy.py in the B1 style)
|
|
6
|
+
that fire at three points in a review run's lifecycle:
|
|
7
|
+
|
|
8
|
+
pre-run before any seat call — fired by the CLI once
|
|
9
|
+
every setup step has succeeded and the run is
|
|
10
|
+
committed;
|
|
11
|
+
on-blocked after the record is written, when the run's
|
|
12
|
+
first seat (in record order) ended in a K2
|
|
13
|
+
blocked class (quota / auth / input / stalled).
|
|
14
|
+
The name means a SEAT blocked, not that the
|
|
15
|
+
gate verdict is BLOCKED (P6-09): a PASS run
|
|
16
|
+
with a blocked seat fires it, a BLOCKED run
|
|
17
|
+
with no blocked seat does not. The [hooks]
|
|
18
|
+
policy key `on_seat_blocked` is accepted as
|
|
19
|
+
an alias for `on_blocked` (policy.py folds
|
|
20
|
+
it into the same slot);
|
|
21
|
+
post-verdict after the record is written, on every run that
|
|
22
|
+
configures it.
|
|
23
|
+
|
|
24
|
+
A hook receives the event name and — for the two post-record
|
|
25
|
+
events — the run record's path, in the CODEREVIEW_HOOK_EVENT
|
|
26
|
+
/ CODEREVIEW_HOOK_RECORD environment variables, and as the
|
|
27
|
+
positional parameters $1 / $2 OF THE COMMAND STRING: the
|
|
28
|
+
command runs under `sh -c` with those values as its $1 / $2,
|
|
29
|
+
so a shell-string command (`hook.sh "$1" "$2"`) forwards them
|
|
30
|
+
to the script, while a bare script path receives no arguments
|
|
31
|
+
and must read the environment variables (P5-10, pinned by
|
|
32
|
+
tests/test_phase5_fixes.py).
|
|
33
|
+
|
|
34
|
+
Execution is the Epic C evidence sandbox, reused whole: a hook
|
|
35
|
+
runs as a single-command `evidence.collect` config, so it gets
|
|
36
|
+
the C5 confinement verbatim — a throwaway copy of the tree as
|
|
37
|
+
cwd (.git excluded, deleted afterwards), an environment built
|
|
38
|
+
from the allow-list (no provider keys, no tokens), no network
|
|
39
|
+
by default, a wall-clock timeout enforced by killing the whole
|
|
40
|
+
process group, and output stored as sha256 + a redacted tail.
|
|
41
|
+
There is deliberately no second executor in this module.
|
|
42
|
+
|
|
43
|
+
Timing contract for the post-record events: the record is
|
|
44
|
+
written first (so the path handed to the hooks exists and the
|
|
45
|
+
file already carries the verdict and the pre-run result), the
|
|
46
|
+
hooks fire, and then the CLI appends their results to the
|
|
47
|
+
record's `hooks` section and rewrites the record once at the
|
|
48
|
+
same path. The file a hook reads is therefore the file that
|
|
49
|
+
ends up carrying the complete hooks ledger.
|
|
50
|
+
|
|
51
|
+
Enforcement flows one way only. A hook's result is data for
|
|
52
|
+
the record; a hook that fails, times out, or names a missing
|
|
53
|
+
command is recorded and the run continues. Nothing in this
|
|
54
|
+
module returns a value the gate reads — there is no path by
|
|
55
|
+
which a hook can change a verdict, a finding, or any other
|
|
56
|
+
gate input, and `fire_hook` never raises.
|
|
57
|
+
"""
|
|
58
|
+
|
|
59
|
+
from __future__ import annotations
|
|
60
|
+
|
|
61
|
+
import hashlib
|
|
62
|
+
import shlex
|
|
63
|
+
|
|
64
|
+
from . import evidence as _evidence
|
|
65
|
+
from .core import redact_secrets
|
|
66
|
+
|
|
67
|
+
SCHEMA = "codereview.hooks/1"
|
|
68
|
+
|
|
69
|
+
# Event names, in lifecycle order, mapped to their [hooks]
|
|
70
|
+
# policy keys. Mirrors policy._HOOK_COMMAND_KEYS; a test pins
|
|
71
|
+
# the two together so they cannot drift.
|
|
72
|
+
EVENTS = ("pre-run", "on-blocked", "post-verdict")
|
|
73
|
+
_EVENT_KEYS = {"pre-run": "pre_run", "on-blocked": "on_blocked",
|
|
74
|
+
"post-verdict": "post_verdict"}
|
|
75
|
+
|
|
76
|
+
# A hook with no configured timeout gets the evidence runner's
|
|
77
|
+
# own default bound — one timeout discipline for every command
|
|
78
|
+
# the sandbox executes (C5).
|
|
79
|
+
DEFAULT_TIMEOUT = _evidence.DEFAULT_TIMEOUT
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def hooks_from_policy(policy):
|
|
83
|
+
"""The hooks config carried by a loaded policy dict, or
|
|
84
|
+
None when the policy configures no hook command (no
|
|
85
|
+
[hooks] table, or a table with only a timeout). The
|
|
86
|
+
returned dict is keyed by policy key (pre_run / on_blocked
|
|
87
|
+
/ post_verdict) plus an optional "timeout"."""
|
|
88
|
+
if not isinstance(policy, dict):
|
|
89
|
+
return None
|
|
90
|
+
hooks = policy.get("hooks") or {}
|
|
91
|
+
config = {}
|
|
92
|
+
for event in EVENTS:
|
|
93
|
+
command = hooks.get(_EVENT_KEYS[event])
|
|
94
|
+
if isinstance(command, str) and command.strip():
|
|
95
|
+
config[_EVENT_KEYS[event]] = command
|
|
96
|
+
if not config:
|
|
97
|
+
return None
|
|
98
|
+
if hooks.get("timeout") is not None:
|
|
99
|
+
config["timeout"] = hooks["timeout"]
|
|
100
|
+
return config
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def first_blocked_trigger(reviewer_reports):
|
|
104
|
+
"""The on-blocked trigger for a finished round: the first
|
|
105
|
+
reviewer report (in record order) whose seat ended in a K2
|
|
106
|
+
blocked class, as {"seat", "lens", "class", "state_source"}
|
|
107
|
+
— or None when no seat blocked.
|
|
108
|
+
|
|
109
|
+
Both seat-state slots are read: `seat_state` is the seat
|
|
110
|
+
that finally served (or failed), and `seat_state_primary`
|
|
111
|
+
is the primary seat's own terminal state when a fallback
|
|
112
|
+
served in its place — a primary that blocked on quota is
|
|
113
|
+
the run's first blocked seat even though the fallback's
|
|
114
|
+
report row reads done. `state_source` names which slot
|
|
115
|
+
matched, so the record says exactly what was read."""
|
|
116
|
+
for report in reviewer_reports or []:
|
|
117
|
+
if not isinstance(report, dict):
|
|
118
|
+
continue
|
|
119
|
+
for key in ("seat_state", "seat_state_primary"):
|
|
120
|
+
state = report.get(key)
|
|
121
|
+
if isinstance(state, dict) \
|
|
122
|
+
and state.get("state") == "blocked":
|
|
123
|
+
return {"seat": report.get("provider"),
|
|
124
|
+
"lens": report.get("lens"),
|
|
125
|
+
"class": state.get("class"),
|
|
126
|
+
"state_source": key}
|
|
127
|
+
return None
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _wrapped_command(command, event, record_path):
|
|
131
|
+
"""The shell string the sandbox executes for a hook: the
|
|
132
|
+
event and record path exported as environment variables,
|
|
133
|
+
then the user's command run by a nested shell with the
|
|
134
|
+
same values as positional arguments $1 / $2 ($0 is a fixed
|
|
135
|
+
label). Every interpolated value is shlex-quoted; the user
|
|
136
|
+
command itself is data to the outer shell, quoted whole."""
|
|
137
|
+
record = record_path or ""
|
|
138
|
+
return (
|
|
139
|
+
f"CODEREVIEW_HOOK_EVENT={shlex.quote(event)} "
|
|
140
|
+
f"CODEREVIEW_HOOK_RECORD={shlex.quote(record)} "
|
|
141
|
+
f"sh -c {shlex.quote(command)} codereview-hook "
|
|
142
|
+
f"{shlex.quote(event)} {shlex.quote(record)}")
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def fire_hook(command, event, source_dir, record_path=None,
|
|
146
|
+
timeout=None, environ=None):
|
|
147
|
+
"""Run one hook command through the evidence sandbox and
|
|
148
|
+
return its ledger entry. Never raises: a hook that cannot
|
|
149
|
+
even start (the sandbox itself failed) is an "error" entry,
|
|
150
|
+
exactly like an evidence item that could not run.
|
|
151
|
+
|
|
152
|
+
The entry: {"event", "command", "record_path", "status",
|
|
153
|
+
"exit_code", "duration_s", "output_sha256", "output_tail",
|
|
154
|
+
"network_mode", "detail"} — status/exit/duration/output
|
|
155
|
+
are the evidence item's own fields, so a hook's outcome is
|
|
156
|
+
recorded with the same fidelity as required evidence.
|
|
157
|
+
|
|
158
|
+
The stored "command" is redacted (P6-13): a hook command
|
|
159
|
+
is operator text that can embed a credential (a curl
|
|
160
|
+
header, a token argument), and the record is a durable
|
|
161
|
+
artefact that travels. The EXECUTED command is the raw
|
|
162
|
+
string, unchanged — only the ledger copy passes through
|
|
163
|
+
the same redact_secrets the evidence tails get."""
|
|
164
|
+
if timeout is None:
|
|
165
|
+
timeout = DEFAULT_TIMEOUT
|
|
166
|
+
entry = {"event": event, "command": redact_secrets(command),
|
|
167
|
+
"record_path": record_path}
|
|
168
|
+
config = {
|
|
169
|
+
"commands": [{
|
|
170
|
+
"name": f"hook:{event}",
|
|
171
|
+
"command": _wrapped_command(command, event,
|
|
172
|
+
record_path),
|
|
173
|
+
"required": False,
|
|
174
|
+
"timeout": timeout}],
|
|
175
|
+
"timeout": timeout,
|
|
176
|
+
}
|
|
177
|
+
try:
|
|
178
|
+
record = _evidence.collect(config, source_dir,
|
|
179
|
+
environ=environ)
|
|
180
|
+
items = record.get("items") or []
|
|
181
|
+
item = items[0] if items else {}
|
|
182
|
+
except Exception as exc: # noqa: BLE001 — recorded, not raised
|
|
183
|
+
item = {"status": "error", "exit_code": None,
|
|
184
|
+
"duration_s": 0.0,
|
|
185
|
+
"output_sha256": hashlib.sha256(b"").hexdigest(),
|
|
186
|
+
"output_tail": "", "network_mode": None,
|
|
187
|
+
"detail": f"hook could not run: {exc}"}
|
|
188
|
+
entry.update({
|
|
189
|
+
"status": item.get("status"),
|
|
190
|
+
"exit_code": item.get("exit_code"),
|
|
191
|
+
"duration_s": item.get("duration_s"),
|
|
192
|
+
"output_sha256": item.get("output_sha256"),
|
|
193
|
+
"output_tail": item.get("output_tail") or "",
|
|
194
|
+
"network_mode": item.get("network_mode"),
|
|
195
|
+
"detail": item.get("detail") or "",
|
|
196
|
+
})
|
|
197
|
+
return entry
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
class HookRunner:
|
|
201
|
+
"""One run's hook ledger: fires each configured event at
|
|
202
|
+
most once, in the order the CLI calls them, and accumulates
|
|
203
|
+
the results the record's `hooks` section carries.
|
|
204
|
+
|
|
205
|
+
`blocked_trigger` is set by the CLI (from
|
|
206
|
+
first_blocked_trigger) before on-blocked fires; the
|
|
207
|
+
on-blocked result carries it as its "trigger"."""
|
|
208
|
+
|
|
209
|
+
def __init__(self, config, source_dir, environ=None):
|
|
210
|
+
self.config = dict(config or {})
|
|
211
|
+
self.source_dir = source_dir
|
|
212
|
+
self.environ = environ
|
|
213
|
+
self.results = []
|
|
214
|
+
self.blocked_trigger = None
|
|
215
|
+
self._fired = set()
|
|
216
|
+
|
|
217
|
+
def configured_events(self):
|
|
218
|
+
"""The configured event names, in lifecycle order."""
|
|
219
|
+
return [event for event in EVENTS
|
|
220
|
+
if self.config.get(_EVENT_KEYS[event])]
|
|
221
|
+
|
|
222
|
+
def configured(self, event):
|
|
223
|
+
return event in self.configured_events()
|
|
224
|
+
|
|
225
|
+
def fire(self, event, record_path=None):
|
|
226
|
+
"""Fire `event` if it is configured and has not fired
|
|
227
|
+
yet; return its ledger entry, or None when the event
|
|
228
|
+
does not fire (unconfigured, or already fired — each
|
|
229
|
+
event fires exactly once per run)."""
|
|
230
|
+
if event in self._fired or not self.configured(event):
|
|
231
|
+
return None
|
|
232
|
+
self._fired.add(event)
|
|
233
|
+
entry = fire_hook(
|
|
234
|
+
self.config[_EVENT_KEYS[event]], event,
|
|
235
|
+
self.source_dir, record_path=record_path,
|
|
236
|
+
timeout=self.config.get("timeout"),
|
|
237
|
+
environ=self.environ)
|
|
238
|
+
if event == "on-blocked" \
|
|
239
|
+
and self.blocked_trigger is not None:
|
|
240
|
+
entry["trigger"] = dict(self.blocked_trigger)
|
|
241
|
+
self.results.append(entry)
|
|
242
|
+
return entry
|
|
243
|
+
|
|
244
|
+
def section(self):
|
|
245
|
+
"""The record's `hooks` section, or None when no hook
|
|
246
|
+
is configured (a hookless run's record carries no hooks
|
|
247
|
+
key at all — byte-identical to before Epic L3)."""
|
|
248
|
+
if not self.configured_events():
|
|
249
|
+
return None
|
|
250
|
+
return {
|
|
251
|
+
"schema": SCHEMA,
|
|
252
|
+
# The configured commands are recorded redacted
|
|
253
|
+
# (P6-13, same rule as the results entries): the
|
|
254
|
+
# config itself keeps the raw text for execution.
|
|
255
|
+
"events": [{"event": event,
|
|
256
|
+
"command": redact_secrets(
|
|
257
|
+
self.config[_EVENT_KEYS[event]])}
|
|
258
|
+
for event in self.configured_events()],
|
|
259
|
+
"blocked_trigger": self.blocked_trigger,
|
|
260
|
+
"results": list(self.results),
|
|
261
|
+
}
|