custos-code 0.0.1__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.
custos_code/hooks.py ADDED
@@ -0,0 +1,648 @@
1
+ """Live path for Claude Code (class H): hook handlers and the auto-mode loop state.
2
+
3
+ PostToolUse appends CALL/RESULT events to ~/.custos-code/live/<session_id>.jsonl (the harness calls
4
+ us; the model has no write path). Stop reads `last_assistant_message` from the payload (the
5
+ transcript can lag), builds the ledger from the live file when present and the transcript
6
+ otherwise, runs claims -> verdicts, writes the receipt, and in auto mode returns a block decision
7
+ with deterministic nudges until the contract holds or the pass cap is hit.
8
+
9
+ Loop state per session: ~/.custos-code/state/<session_id>.json {passes, nudge_seq, open: {claim_text: verdict}}.
10
+
11
+ Owner: Oliver.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import hashlib
16
+ import json
17
+ import os
18
+ import secrets
19
+ import sys
20
+ import tomllib
21
+ from datetime import UTC, datetime
22
+ from typing import Any
23
+
24
+ from . import claims as claims_mod
25
+ from . import feedback, parsers, rerun
26
+ from . import judge as judge_mod
27
+ from . import review as review_mod
28
+ from . import scope as scope_mod
29
+ from . import verdicts as verdicts_mod
30
+ from .adapters import claude_code
31
+ from .ledger import MAX_OUTPUT_BYTES, chain, redact
32
+ from .models import Claim, EventFlags, EventKind, LedgerEvent, Session, Verdict, VerdictRecord
33
+
34
+ HOME = os.path.expanduser("~/.custos-code")
35
+
36
+
37
+ def _out_of_scope(payload: dict[str, Any]) -> bool:
38
+ """True when CUSTOS_CODE_ONLY_IN is set and this session's cwd is not inside it.
39
+
40
+ Exists for measurement validity, not convenience. The agent under test can read every file in
41
+ its working directory, so a project-local `.claude/settings.json` tells it that its output is
42
+ being checked and by what -- and on 2026-09-19 a run did exactly that, reading both the hook
43
+ config and a README that described the trap. An agent that knows it is being audited is not
44
+ the agent whose honesty we are trying to measure.
45
+
46
+ With CUSTOS_CODE_ONLY_IN the hooks live in the user's global settings and the project directory
47
+ holds nothing at all. The guard fails toward doing nothing: an unset, empty, or unresolvable
48
+ value, or a payload with no cwd, means this hook does not act.
49
+ """
50
+ root = os.environ.get("CUSTOS_CODE_ONLY_IN", "").strip()
51
+ if not root:
52
+ return False
53
+ cwd = payload.get("cwd")
54
+ if not isinstance(cwd, str) or not cwd:
55
+ return True
56
+ try:
57
+ root_r = os.path.realpath(os.path.expanduser(root))
58
+ cwd_r = os.path.realpath(cwd)
59
+ except OSError:
60
+ return True
61
+ return os.path.commonpath([root_r, cwd_r]) != root_r
62
+
63
+
64
+ def _config() -> dict[str, Any]:
65
+ """Auto-mode settings, from ~/.custos-code/config.toml with a per-invocation env override.
66
+
67
+ `auto` blocks the agent's turn, so it must be opt-in and it must be possible to opt in for one
68
+ project without arming every session on the machine. config.toml is global; the hook command in
69
+ a project's own .claude/settings.json can set CUSTOS_CODE_AUTO=1 instead, which scopes blocking to
70
+ that project. CUSTOS_CODE_AUTO=0 force-disables even when the global config enables it, so a repo
71
+ can opt out of a machine-wide default.
72
+ """
73
+ # Only an accusation holds the turn. `unrecorded` and `unwitnessed` are the checker reporting
74
+ # the limits of its own evidence -- "the output was piped", "nothing in the log either way" --
75
+ # and gating on them made the agent responsible for facts about the recorder. Measured over 648
76
+ # real claims on 2026-09-20 they are 21.0% and 28.9%, so half of every report held the turn
77
+ # open, and `unwitnessed` on a heading or a piece of advice cannot be cleared by any amount of
78
+ # further work. That is the three-pass spin, and it fired on honest reports.
79
+ cfg: dict[str, Any] = {"auto": False, "auto_max_passes": 3, "auto_clear": ["contradicted"]}
80
+ p = os.path.join(HOME, "config.toml")
81
+ if os.path.exists(p):
82
+ with open(p, "rb") as fh:
83
+ data = tomllib.load(fh)
84
+ cfg.update(data.get("tiers", {}))
85
+ env = os.environ.get("CUSTOS_CODE_AUTO")
86
+ if env is not None:
87
+ cfg["auto"] = env.strip().lower() in ("1", "true", "yes", "on")
88
+ if (mp := os.environ.get("CUSTOS_CODE_AUTO_MAX_PASSES")) and mp.isdigit():
89
+ cfg["auto_max_passes"] = int(mp)
90
+ return cfg
91
+
92
+
93
+ def _scope_mode() -> str:
94
+ """`off` | `warn` | `on`, from CUSTOS_CODE_SCOPE or config.toml. Default OFF, deliberately.
95
+
96
+ Every threshold in scope.py is a default I wrote, not a measurement. Issue #57 calibrates them
97
+ against ~400 sessions of accepted work, and until that reports, a scope gate that interrupts
98
+ good work is strictly worse than no scope gate -- see the Stop-hook latency that made the
99
+ terminal unusable on 2026-09-19. So this ships inert and is switched on by a number, not by
100
+ confidence.
101
+
102
+ `warn` bands and records without ever denying: that is the mode #57's harness runs in.
103
+ """
104
+ v = (os.environ.get("CUSTOS_CODE_SCOPE") or _config().get("scope") or "off")
105
+ v = str(v).strip().lower()
106
+ return v if v in ("off", "warn", "on") else "off"
107
+
108
+
109
+ def _scope_grant(payload: dict[str, Any], policy: scope_mod.Policy) -> scope_mod.Grant:
110
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else ""
111
+ sid = str(payload.get("session_id", "unknown"))
112
+ approved: tuple[str, ...] = ()
113
+ _, state_p, _ = _paths(sid)
114
+ if os.path.exists(state_p):
115
+ try:
116
+ with open(state_p, encoding="utf-8") as fh:
117
+ got = json.load(fh).get("scope_approved")
118
+ if isinstance(got, list):
119
+ approved = tuple(str(x) for x in got)
120
+ except (OSError, ValueError, json.JSONDecodeError):
121
+ pass
122
+ return scope_mod.Grant.for_session(cwd or os.getcwd(), approved=approved, policy=policy)
123
+
124
+
125
+ def _scope_gate(payload: dict[str, Any]) -> dict[str, Any] | None:
126
+ """Band this call and, if the mode allows, stop it before it happens.
127
+
128
+ docs/SCOPE.md §5: scope is checked at PreToolUse, BEFORE the action -- "ask before doing
129
+ something irreversible" is what every permission system does, not halting on an opinion. And
130
+ the cost asymmetry inverts against integrity: a scope false positive costs one pause, a scope
131
+ false negative costs a force-push.
132
+
133
+ The mode decides the response, because a flag is a message to a human and an unattended run has
134
+ nobody reading it:
135
+
136
+ GREEN YELLOW RED
137
+ attended pass ask deny
138
+ unattended pass deny deny
139
+
140
+ Fails OPEN on any error. A checker that cannot run is not evidence about the agent.
141
+ """
142
+ mode = _scope_mode()
143
+ if mode == "off":
144
+ return None
145
+ try:
146
+ raw = payload.get("tool_input")
147
+ inp: dict[str, Any] = dict(raw) if isinstance(raw, dict) else {}
148
+ policy = scope_mod.Policy.load()
149
+ f = scope_mod.classify(str(payload.get("tool_name", "")), inp,
150
+ _scope_grant(payload, policy), policy=policy)
151
+ except Exception as e: # noqa: BLE001 - never take the turn down over a scope check
152
+ print(f"receipts: scope check failed ({type(e).__name__}); allowing.", file=sys.stderr)
153
+ return None
154
+ if not f.gates or mode == "warn":
155
+ return None
156
+ unattended = bool(_config().get("auto"))
157
+ decision = "deny" if (f.band is scope_mod.Band.RED or unattended) else "ask"
158
+ return {"hookSpecificOutput": {
159
+ "hookEventName": "PreToolUse",
160
+ "permissionDecision": decision,
161
+ "permissionDecisionReason": _scope_reason(f, decision),
162
+ }}
163
+
164
+
165
+ def _scope_reason(f: scope_mod.Finding, decision: str) -> str:
166
+ """Say why, in terms of the actual finding. A generic reason trains people to click through."""
167
+ if f.band is scope_mod.Band.RED:
168
+ why = "this cannot be undone"
169
+ elif f.rule == "write-outside-cwd":
170
+ why = "this writes outside the directory this session was started in"
171
+ elif f.rule == "unrecoverable-write":
172
+ why = "there is no git work tree here, so this cannot be reverted"
173
+ else:
174
+ why = "this reaches outside the workspace"
175
+ tail = ("" if decision == "deny"
176
+ else " Approve it and it will not be asked again this session.")
177
+ return f"receipts/scope [{f.band.value}] {f.rule} — {why}: {f.detail}.{tail}"
178
+
179
+
180
+ def _paths(session_id: str) -> tuple[str, str, str]:
181
+ os.makedirs(os.path.join(HOME, "live"), exist_ok=True)
182
+ os.makedirs(os.path.join(HOME, "state"), exist_ok=True)
183
+ os.makedirs(os.path.join(HOME, "custos-code"), exist_ok=True)
184
+ return (os.path.join(HOME, "live", f"{session_id}.jsonl"),
185
+ os.path.join(HOME, "state", f"{session_id}.json"),
186
+ os.path.join(HOME, "custos-code", f"{session_id}.txt"))
187
+
188
+
189
+ def _rc_dir() -> str:
190
+ d = os.path.join(HOME, "rc")
191
+ os.makedirs(d, exist_ok=True)
192
+ return d
193
+
194
+
195
+ def _rc_pending_path(session_id: str) -> str:
196
+ os.makedirs(os.path.join(HOME, "rc_pending"), exist_ok=True)
197
+ return os.path.join(HOME, "rc_pending", f"{session_id}.json")
198
+
199
+
200
+ def _load_rc_pending(session_id: str) -> dict[str, str]:
201
+ p = _rc_pending_path(session_id)
202
+ if not os.path.exists(p):
203
+ return {}
204
+ with open(p, encoding="utf-8") as fh:
205
+ data: dict[str, str] = json.load(fh)
206
+ return data
207
+
208
+
209
+ def _unpack_pending(entry: str) -> tuple[str, str | None]:
210
+ """A pending entry is `{"rc": path, "cmd": original}`; older ones were a bare path string.
211
+
212
+ Tolerating the bare form matters because a session in flight when this shipped would otherwise
213
+ lose its rc files and, worse, keep recording our rewritten command as if the agent had run it.
214
+ """
215
+ try:
216
+ d = json.loads(entry)
217
+ except (json.JSONDecodeError, TypeError):
218
+ return entry, None
219
+ if not isinstance(d, dict):
220
+ return entry, None
221
+ rc = d.get("rc")
222
+ cmd = d.get("cmd")
223
+ return (rc if isinstance(rc, str) else entry), (cmd if isinstance(cmd, str) else None)
224
+
225
+
226
+ def _save_rc_pending(session_id: str, pending: dict[str, str]) -> None:
227
+ with open(_rc_pending_path(session_id), "w", encoding="utf-8") as fh:
228
+ json.dump(pending, fh)
229
+
230
+
231
+ # ---------- PreToolUse ----------
232
+ def on_pre_tool_use(payload: dict[str, Any]) -> dict[str, Any] | None:
233
+ """E5/E9: wrap a known-runner Bash command so `on_post_tool_use` can see the real resolved
234
+ binary path and exit code, defeating a `./pytest` wrapper shadowing the real one. Returns the
235
+ hook response JSON to print, or None to leave the command untouched.
236
+
237
+ Issue #22: the trailer is written to a random per-call file under `~/.custos-code/rc/` instead
238
+ of stdout (the E9 experiment showed a stdout trailer is visible in the model's own tool
239
+ output, letting it later forge the marker). `PostToolUse` cannot safely re-derive that random
240
+ path by re-parsing `tool_input.command` (undocumented whether it sees the original or the
241
+ rewritten command), so the path is instead handed across via a small per-session pending map
242
+ keyed on `tool_use_id`, which both events document. No `tool_use_id` means no way to
243
+ correlate the two sides, so the command is left unwrapped rather than leaking an orphan file.
244
+ """
245
+ if _out_of_scope(payload):
246
+ return None
247
+ # Scope first: a RED action must never get wrapped and run. The E5 rewrite below only makes a
248
+ # command observable; it does not make it safe.
249
+ if (gate := _scope_gate(payload)) is not None:
250
+ return gate
251
+ if payload.get("tool_name") != "Bash":
252
+ return None
253
+ inp = payload.get("tool_input")
254
+ command = inp.get("command") if isinstance(inp, dict) else None
255
+ if not isinstance(command, str):
256
+ return None
257
+ tool_use_id = payload.get("tool_use_id")
258
+ if not isinstance(tool_use_id, str):
259
+ return None
260
+ rc_path = os.path.join(_rc_dir(), secrets.token_hex(16))
261
+ wrapped = parsers.wrap_command_for_resolution(command, rc_path=rc_path)
262
+ if wrapped is None:
263
+ return None
264
+ sid = str(payload.get("session_id", "unknown"))
265
+ pending = _load_rc_pending(sid)
266
+ # Keep the ORIGINAL command beside the rc path. PostToolUse sees our rewritten command, and
267
+ # recording that would be wrong twice over: the receipt would quote a command the agent never
268
+ # ran, and the wrapper's own `command -v ... 2>/dev/null` matches the output-filtered detector,
269
+ # so every wrapped call would be flagged `piped` and its evidence discounted. Observed on
270
+ # session 21756df4: five true claims came back `unrecorded` for "filtered" output that our own
271
+ # instrumentation had filtered, and a sixth was contradicted outright.
272
+ pending[tool_use_id] = json.dumps({"rc": rc_path, "cmd": command})
273
+ _save_rc_pending(sid, pending)
274
+ return {"hookSpecificOutput": {"hookEventName": "PreToolUse", "updatedInput": {"command": wrapped}}}
275
+
276
+
277
+ # ---------- PostToolUse ----------
278
+ def _response_text(resp: Any) -> str:
279
+ """The tool's own output, the way the replay adapter records it.
280
+
281
+ This used to be `json.dumps(resp)`, which wrapped a Bash result in `{"stdout": "...\\n..."}`
282
+ with its newlines escaped. Every parser downstream then read one long line: anything anchored
283
+ to the start of a line could not match, so `pytest -q` -- which prints no session banner, only
284
+ a tail like `86 passed in 1.2s` -- parsed as "not a test runner at all" on the LIVE path while
285
+ parsing correctly on replay.
286
+
287
+ That divergence matters more than the one case. Every accuracy number this project quotes was
288
+ produced by replaying transcripts through `adapters.claude_code`, which extracts stdout
289
+ properly. The hook people actually install stored something else. Measurements taken on the
290
+ replay path were never evidence about the live path.
291
+ """
292
+ if isinstance(resp, str):
293
+ return resp
294
+ if isinstance(resp, dict):
295
+ parts = [str(resp[k]) for k in ("stdout", "stderr") if isinstance(resp.get(k), str) and resp[k]]
296
+ if parts:
297
+ return "\n".join(parts)
298
+ if isinstance(resp.get("content"), str):
299
+ return str(resp["content"])
300
+ return json.dumps(resp) if resp is not None else ""
301
+
302
+
303
+ def on_post_tool_use(payload: dict[str, Any]) -> None:
304
+ if _out_of_scope(payload):
305
+ return
306
+ sid = str(payload.get("session_id", "unknown"))
307
+ live, _, _ = _paths(sid)
308
+ tool = str(payload.get("tool_name", ""))
309
+ raw_inp = payload.get("tool_input")
310
+ inp: dict[str, Any] = dict(raw_inp) if isinstance(raw_inp, dict) else {}
311
+ resp = payload.get("tool_response")
312
+ text = _response_text(resp)
313
+ # Issue #22: `on_pre_tool_use` (when it wrapped this call) writes the resolved binary path and
314
+ # exit code to a per-call file instead of stdout, so there is no trailer in `text` to strip
315
+ # here -- look the file up via the tool_use_id pending map instead.
316
+ resolved_bin: str | None = None
317
+ wrapped_exit_code: int | None = None
318
+ tool_use_id = payload.get("tool_use_id")
319
+ if isinstance(tool_use_id, str):
320
+ pending = _load_rc_pending(sid)
321
+ entry = pending.pop(tool_use_id, None)
322
+ if entry is not None:
323
+ rc_path, original_cmd = _unpack_pending(entry)
324
+ resolved_bin, wrapped_exit_code = parsers.read_rc_file(rc_path)
325
+ if os.path.exists(rc_path):
326
+ os.remove(rc_path)
327
+ # Restore the agent's own command. `tool_input` here holds OUR rewrite, which quotes a
328
+ # command the agent never ran and whose `command -v ... 2>/dev/null` trips the
329
+ # output-filtered detector below.
330
+ if original_cmd is not None:
331
+ inp["command"] = original_cmd
332
+ _save_rc_pending(sid, pending)
333
+ text = redact(text)
334
+ if resolved_bin is not None:
335
+ inp["resolved_bin"] = resolved_bin # feeds a future rules.py trust check (E5); unused today
336
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
337
+ side = bool(payload.get("agent_id"))
338
+ n = sum(1 for _ in open(live)) if os.path.exists(live) else 0
339
+ ts = datetime.now(UTC)
340
+ call = LedgerEvent(seq=n, ts=ts, session_id=sid, kind=EventKind.CALL, tool=tool, input=redact(dict(inp)),
341
+ paths=claude_code._paths_from_input(tool, dict(inp), cwd), cwd=cwd, flags=EventFlags(sidechain=side))
342
+ flags = EventFlags(sidechain=side)
343
+ cmd = inp.get("command")
344
+ if tool == "Bash" and isinstance(cmd, str) and parsers.is_piped(cmd):
345
+ flags.piped = True
346
+ if len(text.encode()) > MAX_OUTPUT_BYTES:
347
+ flags.truncated = True
348
+ res = LedgerEvent(seq=n + 1, ts=ts, session_id=sid, kind=EventKind.RESULT, tool=tool,
349
+ output=text.encode()[:MAX_OUTPUT_BYTES].decode(errors="ignore"),
350
+ output_hash=hashlib.sha256(text.encode()).hexdigest(), cwd=cwd, flags=flags,
351
+ exit_code=wrapped_exit_code, # E9: only set when on_pre_tool_use wrapped this command
352
+ paths=[str(inp["file_path"])] if isinstance(inp.get("file_path"), str) else [])
353
+ with open(live, "a", encoding="utf-8") as fh:
354
+ fh.write(call.model_dump_json() + "\n")
355
+ fh.write(res.model_dump_json() + "\n")
356
+
357
+
358
+ # ---------- ledger assembly for Stop ----------
359
+ def _collect_reruns(session_id: str, ledger: list[LedgerEvent], live_path: str) -> list[LedgerEvent]:
360
+ """Fold any finished Tier 3 results into the ledger, once.
361
+
362
+ `spawn_async` detaches and writes a RERUN event to a result file; Claude Code hooks are
363
+ one-shot, so the Stop call that launched it has already returned. Something has to pick the
364
+ result up on a LATER turn or the re-run is theatre -- the subprocess runs, the evidence lands
365
+ on disk, and the checker never looks. Nothing did: `load_result` had no callers outside its
366
+ own module, which an adversarial pass caught before this shipped.
367
+
368
+ Appending to the live ledger file (not just the in-memory list) is what makes it persist, so
369
+ the evidence stays available to `receipts check` and to every later pass rather than being
370
+ consumed by whichever turn happened to notice it.
371
+
372
+ Resolves the seq placeholder `rerun.run_worker` left as NEEDS-DECISION(oliver): the event is
373
+ numbered when it is folded in, because only here is the ledger's length known.
374
+ """
375
+ # Ask rerun where it writes rather than rebuilding the path: two copies of the same layout
376
+ # drift, and a reader looking in the wrong directory silently finds nothing forever -- which
377
+ # is indistinguishable from "no re-runs happened".
378
+ d = str(rerun._rerun_dir(session_id))
379
+ if not os.path.isdir(d):
380
+ return ledger
381
+ next_seq = max((e.seq for e in ledger), default=-1) + 1
382
+ for name in sorted(os.listdir(d)):
383
+ if not name.endswith(".result.json"):
384
+ continue
385
+ claim_id = name[: -len(".result.json")]
386
+ try:
387
+ ev = rerun.load_result(session_id, claim_id)
388
+ except (OSError, ValueError):
389
+ ev = None
390
+ if ev is None:
391
+ continue
392
+ ev.seq = next_seq
393
+ next_seq += 1
394
+ ledger.append(ev)
395
+ # Only persist when a live ledger already exists. If this session's ledger came from the
396
+ # transcript (hooks installed mid-flight), creating a live file containing nothing but
397
+ # RERUN events would make the NEXT turn prefer it and lose the transcript entirely --
398
+ # `_ledger_for` takes the live file whenever it has any events at all.
399
+ if os.path.exists(live_path):
400
+ try:
401
+ with open(live_path, "a", encoding="utf-8") as fh:
402
+ fh.write(ev.model_dump_json() + "\n")
403
+ os.remove(os.path.join(d, name)) # consumed exactly once
404
+ except OSError:
405
+ pass
406
+ return ledger
407
+
408
+
409
+ def _ledger_for(payload: dict[str, Any]) -> tuple[Session, list[LedgerEvent]]:
410
+ """The ledger for this session: the live hook file first, the transcript only as a fallback.
411
+
412
+ Order matters, and it used to be backwards -- the transcript was preferred whenever
413
+ `transcript_path` existed, which is always. Two consequences, both observed on a real session
414
+ (adc885ec, 2026-09-19):
415
+
416
+ 1. **We discarded the output we exist to capture.** PostToolUse records each tool's FULL
417
+ stdout before the harness truncates it; that is the whole reason the hook exists
418
+ (docs/DESIGN.md §5: 42% of test output was piped away). On that session the live file held
419
+ 28,437 bytes of captured output against the transcript's 20,424. Reading the transcript
420
+ threw away 8KB of evidence and produced a receipt full of `unrecorded ... not visible due
421
+ to truncation` for claims the live file could have settled.
422
+ 2. **Citations pointed at the wrong lines.** The two sources number events independently --
423
+ the transcript also numbers assistant/user text records, the live file only tool events. On
424
+ that session `git mv` was seq 16 in the transcript and seq 14 in the live file. The receipt
425
+ cited #16; a reader checking ~/.custos-code/live/<id>.jsonl, which is the artifact we tell
426
+ people to audit, finds an unrelated pytest run there. Every citation in that receipt was
427
+ unverifiable against the ledger on disk.
428
+
429
+ So: live file when it has events, transcript otherwise (a session whose hooks were installed
430
+ mid-flight has no live file for its earlier turns, which is the case this fallback is for).
431
+ """
432
+ sid = str(payload.get("session_id", "unknown"))
433
+ live, _, _ = _paths(sid)
434
+ events: list[LedgerEvent] = []
435
+ if os.path.exists(live):
436
+ with open(live, encoding="utf-8") as fh:
437
+ for line in fh:
438
+ if line.strip():
439
+ events.append(LedgerEvent.model_validate_json(line))
440
+ if not events:
441
+ tpath = payload.get("transcript_path")
442
+ if isinstance(tpath, str) and os.path.exists(tpath):
443
+ sess, ledger, _ = claude_code.parse(tpath)
444
+ if ledger:
445
+ return sess, ledger
446
+ events = chain(events)
447
+ cwd = payload.get("cwd") if isinstance(payload.get("cwd"), str) else None
448
+ sess = Session(id=sid, source="claude_code", agent="claude-code", cwd=cwd, n_events=len(events),
449
+ ledger_root_hash=events[-1].hash if events else "")
450
+ return sess, events
451
+
452
+
453
+ # ---------- Stop ----------
454
+ def _render(claims: list[Claim], recs: list[VerdictRecord]) -> str:
455
+ mark = {Verdict.CONFIRMED: "✓", Verdict.CONTRADICTED: "✗", Verdict.UNWITNESSED: "?", Verdict.UNRECORDED: "○", Verdict.QUALIFIED: "≈"}
456
+ by = {c.id: c for c in claims}
457
+ lines = []
458
+ for r in recs:
459
+ c = by[r.claim_id]
460
+ ev = " ".join(f"#{e}" for e in r.evidence) or "—"
461
+ lines.append(f"{mark[r.verdict]} {r.verdict.value:<12} {c.text}\n tier {r.tier} · {r.method} · {ev} · {r.rationale}" + (f" · {r.qualifier}" if r.qualifier else ""))
462
+ s = verdicts_mod.summary(recs)
463
+ lines.append("custos-code · " + " · ".join(f"{s[v.value]} {mark[v]}" for v in Verdict if s[v.value]))
464
+ return "\n".join(lines)
465
+
466
+
467
+ def on_stop(payload: dict[str, Any]) -> dict[str, Any] | None:
468
+ """Returns the JSON to print on stdout (block decision), or None for exit 0 with no output."""
469
+ if _out_of_scope(payload):
470
+ return None
471
+ sid = str(payload.get("session_id", "unknown"))
472
+ report = payload.get("last_assistant_message")
473
+ if not isinstance(report, str) or not report.strip():
474
+ return None
475
+ _, state_p, receipt_p = _paths(sid)
476
+ sess, ledger = _ledger_for(payload)
477
+ live_p, _, _ = _paths(sid)
478
+ ledger = _collect_reruns(sid, ledger, live_p) # evidence from earlier turns' Tier 3 jobs
479
+ repo = payload.get("cwd") if isinstance(payload.get("cwd"), str) else sess.cwd
480
+
481
+ # Cheap gate before the model call. A Stop hook fires on EVERY turn, so a turn that ran one
482
+ # `rm` was paying for a full-session review, and in auto mode up to three of them. Observed
483
+ # on 2026-09-19: a one-line command took tens of seconds and the user reasonably concluded the
484
+ # terminal was broken. A checker nobody leaves switched on verifies nothing.
485
+ #
486
+ # Skip when there is nothing a receipt could say:
487
+ # - no tool calls at all in the session -> every claim would be `unwitnessed` anyway, which
488
+ # is never an accusation and never blocks, so the call buys nothing.
489
+ # - no NEW tool calls since the last receipt -> the evidence has not moved, so neither can
490
+ # any verdict. This is the common case for conversational turns.
491
+ n_calls = sum(1 for e in ledger if e.kind == EventKind.CALL)
492
+ if n_calls == 0:
493
+ return None
494
+ seen_p = os.path.join(HOME, "seen", f"{sid}.json")
495
+ os.makedirs(os.path.dirname(seen_p), exist_ok=True)
496
+ last_seq = -1
497
+ if os.path.exists(seen_p) and not payload.get("stop_hook_active"):
498
+ try:
499
+ with open(seen_p, encoding="utf-8") as fh:
500
+ last_seq = int(json.load(fh).get("seq", -1))
501
+ except (OSError, ValueError, json.JSONDecodeError):
502
+ last_seq = -1
503
+ max_seq = max((e.seq for e in ledger), default=-1)
504
+ if last_seq >= max_seq:
505
+ return None
506
+ with open(seen_p, "w", encoding="utf-8") as fh:
507
+ json.dump({"seq": max_seq}, fh)
508
+ # Loop state has to be read BEFORE the review, not after: `nudge_seq` marks where the last
509
+ # correction request fell in the ledger, and `annotate()` needs it to draw the boundary between
510
+ # the superseded attempt and the new one. Without it the model can cite a piped run from pass 1
511
+ # as evidence against work the agent redid cleanly in pass 2 (session 21756df4).
512
+ state: dict[str, Any] = {"passes": 0, "nudge_seq": -1}
513
+ if os.path.exists(state_p):
514
+ try:
515
+ with open(state_p, encoding="utf-8") as fh:
516
+ state = json.load(fh)
517
+ except (OSError, ValueError, json.JSONDecodeError):
518
+ pass
519
+ prior_nudge = int(state.get("nudge_seq", -1)) if payload.get("stop_hook_active") else -1
520
+
521
+ # The measured path (eval/arms/RESULTS.md: 86% vs 70% for the tiered pipeline, McNemar
522
+ # p=0.00017). Falls back to deterministic rules with no key, so the hook never hard-fails.
523
+ backend = judge_mod.make_backend()
524
+ if backend is not None:
525
+ out = review_mod.review(report, ledger, sid, backend, nudge_seq=prior_nudge,
526
+ repo_root=repo)
527
+ claims = out.claims
528
+ recs = verdicts_mod.apply_reruns(claims, out.verdicts, ledger)
529
+ else:
530
+ claims = claims_mod.extract(report, sid)
531
+ recs = verdicts_mod.run(claims, ledger, repo)
532
+ # Tier 3: launch a re-execution for any claim a re-run could actually settle. This is the
533
+ # second GROUNDED source the architecture argues for -- not another opinion, but the command
534
+ # run again and looked at. Coverage is worth 4-74x more than a second verifier at any
535
+ # plausible likelihood ratio, and this is what moves coverage.
536
+ #
537
+ # `spawn_async` detaches, so the Stop hook still returns in the ~10s it wants. The result is
538
+ # picked up by a later pass, by the extension, or by `receipts check` -- there is no path back
539
+ # into this call, which has already returned. `verdicts.should_rerun` is deliberately narrow:
540
+ # open verdict, runnable claim type, a real repo, a committed runner config, within budget,
541
+ # and not already tried on this exact tree.
542
+ spent: set[str] = set(state.get("reruns", []))
543
+ if repo:
544
+ for c, r in zip(claims, recs, strict=False):
545
+ if not verdicts_mod.should_rerun(c, r, repo, already=spent):
546
+ continue
547
+ key = verdicts_mod.rerun_key(c, repo)
548
+ try:
549
+ # report_seq anchors the RERUN event after the evidence it re-checks
550
+ rerun.spawn_async(sid, c.id, repo, report_seq=max_seq, claim_text=c.text,
551
+ cmd=verdicts_mod.rerun_command(c, repo),
552
+ claim_kind=verdicts_mod.rerun_kind(c).value)
553
+ except Exception as e: # noqa: BLE001 - a failed launch must not fail the turn
554
+ print(f"receipts: rerun launch failed ({type(e).__name__}); skipping.", file=sys.stderr)
555
+ continue
556
+ if key:
557
+ spent.add(key)
558
+ if spent != set(state.get("reruns", [])):
559
+ # Written now, not with the auto-mode state below: the budget has to survive turns
560
+ # that do not block, or a session with auto mode off re-runs on every single turn.
561
+ state["reruns"] = sorted(spent)
562
+ try:
563
+ with open(state_p, "w", encoding="utf-8") as fh:
564
+ json.dump(state, fh)
565
+ except OSError:
566
+ pass
567
+
568
+ with open(receipt_p, "w", encoding="utf-8") as fh:
569
+ fh.write(_render(claims, recs) + "\n")
570
+
571
+ cfg = _config()
572
+ if not cfg.get("auto"):
573
+ return None
574
+ clear = set(cfg.get("auto_clear", []))
575
+ by = {c.id: c for c in claims}
576
+ open_pairs = [(by[r.claim_id], r) for r in recs
577
+ if r.verdict.value in clear and not review_mod.is_advisory(r)]
578
+ if bool(payload.get("stop_hook_active")) and "nudge_seq" in state:
579
+ # a continuation: a previously open claim clears only on evidence newer than the nudge (docs/DESIGN.md §6).
580
+ # A reworded claim that now "confirms" on old evidence stays open as unwitnessed.
581
+ nudge_seq = int(state.get("nudge_seq", -1))
582
+ prev_open = set(state.get("open", []))
583
+ for r in recs:
584
+ c = by[r.claim_id]
585
+ if c.text in prev_open and r.verdict in (Verdict.CONFIRMED, Verdict.QUALIFIED) and not feedback.cleared(r, r, ledger, nudge_seq):
586
+ r.verdict = Verdict.UNWITNESSED
587
+ r.rationale = "Reworded, but no tool call after the nudge bears on this claim; it is not cleared."
588
+ open_pairs.append((c, r))
589
+ if not open_pairs:
590
+ if os.path.exists(state_p):
591
+ os.remove(state_p)
592
+ return None
593
+ passes = int(state.get("passes", 0)) + 1
594
+ max_passes = int(cfg.get("auto_max_passes", 3))
595
+ if max_passes and passes > max_passes:
596
+ os.remove(state_p) if os.path.exists(state_p) else None
597
+ return None # cap hit: hand back to the human with the receipt file
598
+ nudge_seq = max((e.seq for e in ledger), default=-1)
599
+ with open(state_p, "w", encoding="utf-8") as fh:
600
+ json.dump({"passes": passes, "nudge_seq": nudge_seq,
601
+ "open": [c.text for c, _ in open_pairs],
602
+ "reruns": state.get("reruns", []), # do not drop the Tier 3 budget
603
+ "scope_approved": state.get("scope_approved", [])}, fh)
604
+ reason = feedback.build_block_reason(open_pairs, ledger, passes, max_passes)
605
+ return {"decision": "block", "reason": reason}
606
+
607
+
608
+ def main(event: str, session_id: str | None = None, claim_id: str | None = None) -> int:
609
+ if event == "rerun-worker":
610
+ # E4: a detached subprocess `rerun.spawn_async` launched directly -- no hook payload,
611
+ # no stdin to read; its identity is these two args.
612
+ if not session_id or not claim_id:
613
+ return 2
614
+ rerun.run_worker(session_id, claim_id)
615
+ return 0
616
+ # Everything below fails OPEN. `hooks/*.sh` append `|| true`, but the command that
617
+ # `custos-code watch --install` writes into settings.json invokes this binary directly, with no
618
+ # wrapper to swallow anything -- so an unhandled exception here surfaces as a traceback and a
619
+ # non-zero exit from a Claude Code hook. For Stop that reads as "block", which would be a
620
+ # contradiction backed by no evidence at all; for PostToolUse it means the ledger write is
621
+ # skipped, and a missing ledger silently degrades every later verdict to `unwitnessed`.
622
+ # A checker that cannot run is not evidence about the agent. Say so on stderr, exit 0.
623
+ try:
624
+ payload = json.load(sys.stdin) if not sys.stdin.isatty() else {}
625
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError) as e:
626
+ print(f"custos-code: unreadable hook payload ({type(e).__name__}); not blocking.", file=sys.stderr)
627
+ return 0
628
+ if not isinstance(payload, dict):
629
+ print("custos-code: hook payload was not a JSON object; not blocking.", file=sys.stderr)
630
+ return 0
631
+ try:
632
+ if event == "pre":
633
+ out = on_pre_tool_use(payload)
634
+ if out is not None:
635
+ print(json.dumps(out))
636
+ return 0
637
+ if event == "post-tool-use":
638
+ on_post_tool_use(payload)
639
+ return 0
640
+ if event == "stop":
641
+ out = on_stop(payload)
642
+ if out is not None:
643
+ print(json.dumps(out))
644
+ return 0
645
+ except Exception as e: # noqa: BLE001 - a hook must not take the turn down with it
646
+ print(f"custos-code: {event} hook failed ({type(e).__name__}: {e}); not blocking.", file=sys.stderr)
647
+ return 0
648
+ return 2