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.
@@ -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
+ }