@kaydash9999/banker-plugins 0.15.0 → 0.15.2

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.
@@ -11,6 +11,7 @@ Auto-discovery assumes Claude Code's ~/.claude/projects/<encoded-cwd>/ layout.
11
11
  """
12
12
  import argparse
13
13
  import hashlib
14
+ import hmac
14
15
  import html
15
16
  import json
16
17
  import math
@@ -27,13 +28,13 @@ if sys.version_info < (3, 7):
27
28
  sys.exit(2)
28
29
 
29
30
  SCHEMA_VERSION = 2 # cache dir schema (v2: redacted summaries only)
30
- SUMMARIZER_VERSION = 2 # bump when summarize_turn logic changes (B-3 cache key)
31
+ SUMMARIZER_VERSION = 3 # bump when summarize_turn logic changes (B-3 cache key); 3: cut from redacted text
31
32
  USER_FOLD = 400 # collapse user messages longer than this (E-1)
32
33
  ECHO_ASK = 40 # echo-exchange: user question length ceiling (A-4)
33
34
  ECHO_REPLY = 5 # echo-exchange: assistant reply length ceiling (A-4)
34
35
  _BLOCKQUOTE_MAX_DEPTH = 32 # blockquote recursion cap (C-2)
35
36
  CACHE_BASE = pathlib.Path.home() / ".cache" / "lineage"
36
- REVIEW_SCHEMA = "lineage-review/1" # --emit-review pack, read back by --apply-review
37
+ REVIEW_SCHEMA = "lineage-review/2" # --emit-review pack, read back by --apply-review; 2: llm_key, pages redacted (3.0.2)
37
38
  REVIEW_PART_SCHEMA = "lineage-review-part/1"
38
39
  REVIEW_PART = 40 # turns per part file: one reviewer's share
39
40
  PREVIEW_HEAD = 1500 # a long turn's preview: head + tail, where conclusions sit
@@ -41,6 +42,8 @@ PREVIEW_TAIL = 700
41
42
  SUMMARY_MAX = 120 # a reviewer's summary is cut to this, after redaction
42
43
  LLM_CACHE_VERSION = 1 # bump to drop every cached reviewer decision
43
44
  REVIEWER_TIMEOUT = 60 # --reviewer-timeout default, seconds
45
+ DECISIONS_MAX = 1_000_000 # bytes in one decisions file (a 40-turn part answers in some 20 KB)
46
+ DECISIONS_TRIES = 200 # places a wrapped answer's list may start, tried at most
44
47
 
45
48
  # ---------------------------------------------------------------- Noise / classify
46
49
  # NOTE: JSONL records carry RAW `<...>` tags. Every regex below uses raw `<`/`>`
@@ -219,45 +222,126 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
219
222
 
220
223
  if extra:
221
224
  for kw in [k.strip() for k in extra.split(",") if k.strip()]:
222
- pat = re.compile(re.escape(kw), re.IGNORECASE)
223
- n = len(pat.findall(out))
224
- if n:
225
- counts[f"custom:{kw}"] = n
226
- out = pat.sub("[REDACTED]", out)
225
+ # a marker an earlier pass left stays whole: a keyword inside its name is no secret
226
+ pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
227
+ hits = []
228
+ out = pat.sub(lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
229
+ else hits.append(1) or "[REDACTED]", out)
230
+ if hits:
231
+ counts[f"custom:{kw}"] = len(hits)
227
232
  return out, counts
228
233
 
229
234
 
235
+ # A character of curl's user before the colon: no space, : or =, and a quote, backtick or ( only
236
+ # where no flag follows (-u, -Xu or --user, as either branch below starts) and no comma and quote
237
+ # follow (the end of an item in an argument list written without spaces). A match starts only
238
+ # there, so no user runs past the place where the next match may start: a long run stays linear,
239
+ # and a user joined to a variable by a - ("$USER"-bot) is read whole.
240
+ _CU_USER = (r"(?:[^\s:=\"'`(]|[\"'`(](?!-(?:u|[A-Za-z0-9]{1,6}u[\s\"']|-user[\s=\"'])"
241
+ r"|,\\{0,7}[\"']))")
242
+ # The quotes that may open the value, bare or escaped (\" and \\\" in a quoted command, '\'' and
243
+ # '"'"' from bash and shlex.quote, ^" in cmd, `" in PowerShell, $' in bash): up to six in a row,
244
+ # none that closes an item of an argument list.
245
+ _CU_LEAD = r"(?:[\\^`$]{0,7}[\"'](?!,\\{0,7}[\"'])){0,6}"
246
+ # Values read as a whole token, past the quotes that open them, that hold no user and password: a
247
+ # uid:gid (docker), a date format (date -u +%H:%M), two references ($UID:$GID, ${UID}:${GID},
248
+ # %USER%:%PASS%, $(id -u):$(id -g), whose inner `-u)` is a match of its own) and a path default
249
+ # (mktemp -u "${TMPDIR:-/tmp}/x"). A literal password, or a default that is no path or holds a
250
+ # colon, is hidden.
251
+ _CU_REF = r"(?:\$\{?\w+\}?|%\w+%|\$\([^\s()]*(?:\s+[^\s()]+)*\))"
252
+ _CU_SKIP = (r"(?!" + _CU_LEAD + r"(?:\+[\"']?%[-_0^#]?[A-Za-z%]|(?:\d+:\d+|(?:" + _CU_REF
253
+ + r"|[^\s:=\"'`(]*\)):" + _CU_REF + r")(?![^\s\"'`;|&)\\^])"
254
+ r"|\$\{\w+:[-=?+]?[/~.][^\s}\"'`(:]*\}[^\s:]*(?!\S)))")
255
+
230
256
  # Extra patterns for text a reviewer model reads (part files) and nothing else: the page
231
257
  # and --rulebase keep the patterns above, so their output stays as it was.
258
+ # A value a redaction already replaced: a marker ([REDACTED...]) or a value that is only a mask
259
+ # (abcd****wxyz: up to 4 characters, a whole run of *, up to 4 characters, then the value ends; a
260
+ # closing `, |, * or ) and then . , ; : ! ? may stand between, as in **password: ****h12** or
261
+ # `DB_PASSWORD=****`.). The
262
+ # reviewer patterns leave it, so a second pass over redacted text changes nothing. The check reads
263
+ # those few characters, not the rest of the token, so a long run of keywords stays linear.
264
+ _NOT_REDACTED = r"(?!\[REDACTED|[^\s'\"*]{0,4}\*{4,}(?!\*)[^\s'\"*]{0,4}(?=[`|*)]*[.,;:!?]*(?:[\s'\"]|$)))"
265
+ # After a colon: not the colon of a marker an earlier pass left ([REDACTED:GitHubPAT]), which
266
+ # splits no user from a password. A whole marker is one piece of a user: a key an earlier pattern
267
+ # of this pass hid ([REDACTED:OpenAIKey]:pw) still has its password hidden.
268
+ _NOT_MARKED = r"(?<!\[REDACTED:)"
269
+ _MARKER_UNIT = r"\[REDACTED:\w+\]"
270
+
232
271
  REVIEW_SECRET_PATTERNS = [
233
272
  ("AnthropicKey", re.compile(r"(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}")),
234
273
  ("OpenAIKey", re.compile(r"(?<![A-Za-z0-9-])sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}")),
235
274
  ("GoogleKey", re.compile(r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}")),
236
275
  # bounded repeats: a long run of `key` or `a.` would otherwise take quadratic time
237
276
  ("HexKey", re.compile(r"(?i)(?:key|token|secret)[\w-]{0,100}\s*[:=]\s*['\"]?[0-9a-f]{32,}")),
277
+ # a value, not a path, a variable or a redaction ($PWD is a variable, PGPASSWORD a name);
278
+ # markdown, a table cell or a bracket that closes after the value stays outside the match
238
279
  ("PasswordBare", re.compile(
239
- r"(?i)(?:password|passwd|passcode|pwd|암호|비번|비밀\s?번호|패스워드)\s*[:=]\s*[^\s'\"]{6,}")),
240
- # the password runs to the last @ before the host: it may hold an @ of its own
241
- ("UrlCredential", re.compile(r"(?i)[a-z][a-z0-9+.-]{0,30}://[^\s:@/]*:[^\s/]{3,}@")),
280
+ r"(?i)(?<!\$)(?:password|passwd|passcode|pwd|암호|비번|비밀\s?번호|패스워드)\s*[:=]\s*"
281
+ r"(?![/~$])" + _NOT_REDACTED + r"(?=[^\s'\"]{6})[^\s'\"]*[^\s'\"`|*)]")),
282
+ # the password runs to the last @ before the host: it may hold an @ of its own. A marker is
283
+ # no user or password, so a second pass leaves a token-only URL (https://[REDACTED:...]@host).
284
+ ("UrlCredential", re.compile(r"(?i)[a-z][a-z0-9+.-]{0,30}://(?:" + _MARKER_UNIT + r"|[^\s:@/])*:" + _NOT_MARKED
285
+ + r"(?!\[REDACTED)[^\s/]{3,}@")),
242
286
  ("Bearer", re.compile(r"(?i)\bbearer\s+[A-Za-z0-9._~+/=-]{16,}")),
243
- ("BasicAuth", re.compile(r"(?i)\bauthorization[\"']?\s*[:=]\s*[\"']?basic\s+[A-Za-z0-9+/]{8,}={0,2}")),
244
- # curl's -u and --user flags with a user and password pair after a space, an = or nothing;
245
- # the separators share no character with the user name, so a long run of them stays linear.
246
- # A uid:gid (docker) and a host:/path (rsync) are left.
247
- ("CurlUser", re.compile(r"(?<!\S)(?:-u\s*|--user(?:\s+|=))(?!\d+:\d+\b(?![:@]))[^\s:=]+:(?!/)\S{3,}")),
287
+ # Authorization then `:`, `=`, `=>` or headers["Authorization"] =, a call's ("Authorization",
288
+ # "...") and a HAR name/value pair, or a space before a quote (nginx, Apache)
289
+ ("BasicAuth", re.compile(
290
+ r"(?i)\bauthorization"
291
+ r"(?:[\"']?\]?\s*(?:=>|[:=])"
292
+ r"|[\"']\s*,\s*(?:[\"']value[\"']\s*:\s*)?(?=[\"'])"
293
+ r"|\s+(?=[\"']))"
294
+ r"\s*[\"']?basic\s+[A-Za-z0-9+/]{8,}={0,2}")),
295
+ # curl's -u and --user flags, alone or last in a bundle (-su, -4u), with a user and password
296
+ # after a space, an = or nothing, quoted together or apart ("user":"pw"), with quotes bare,
297
+ # escaped or nested (_CU_LEAD) or none, in inline code or parentheses, or as two items of an
298
+ # argument list (quotes bare or escaped, with or without spaces; a host:/path is left). An
299
+ # empty user with a token, and a
300
+ # long key as the user with no password, count too. The separators share no character with
301
+ # the user name, so a long run of them stays linear. The values _CU_SKIP names and a
302
+ # host:/path (rsync) are left, and so is a marker an earlier pass left (-u [REDACTED:JWT]).
303
+ ("CurlUser", re.compile(
304
+ r"(?<![^\s\"'`(])(?:-u\s*|-[A-Za-z0-9]{1,6}u\s+|--user(?:\s+|=))" + _CU_SKIP + _CU_LEAD
305
+ + r"(?:(?:" + _MARKER_UNIT + r"|" + _CU_USER + r")*:" + _NOT_MARKED + r"(?!/|\[REDACTED)\S{3,}|" + _CU_USER
306
+ + r"{15,}:" + _NOT_MARKED
307
+ + r"(?=[\s\"'`).,;\\]|$))"
308
+ r"|(\\{0,7}[\"'])(?:-[A-Za-z0-9]{0,6}u|--user)\1\s*,\s*(\\{0,7}[\"'])" + _CU_SKIP
309
+ + r"(?:(?:" + _MARKER_UNIT + r"|" + _CU_USER + r")*:" + _NOT_MARKED + r"(?!/|\[REDACTED)[^\s\"']{3,}|"
310
+ + _CU_USER + r"{15,}:"
311
+ + _NOT_MARKED + r")\2")),
248
312
  ]
249
313
 
250
314
 
315
+ def _reviewer_patterns(text):
316
+ """`text` with what the reviewer patterns find replaced whole by [REDACTED:<name>], and the
317
+ count by name."""
318
+ counts = {}
319
+ for name, pat in REVIEW_SECRET_PATTERNS:
320
+ text, k = pat.subn(f"[REDACTED:{name}]", text)
321
+ if k:
322
+ counts[name] = k
323
+ return text, counts
324
+
325
+
251
326
  def review_redact(text, extra=None):
252
327
  """Text as a reviewer model reads it: the reviewer-only patterns first, while the keys
253
328
  are whole (the entropy rule would otherwise cut a key and leave the rest), then fully
254
329
  redacted (never masked, whatever --redact-mode says). Returns (text, count)."""
255
- n = 0
256
- for name, pat in REVIEW_SECRET_PATTERNS:
257
- text, k = pat.subn(f"[REDACTED:{name}]", text)
258
- n += k
259
- red, found = redact(text, extra=extra, mode="full")
260
- return red, n + sum(found.values())
330
+ pre, counts = _reviewer_patterns(text)
331
+ red, found = redact(pre, extra=extra, mode="full")
332
+ return red, sum(counts.values()) + sum(found.values())
333
+
334
+
335
+ def page_redact(text, extra=None, mode="full"):
336
+ """Text as every page shows it (3.0.2): the reviewer patterns first, while keys are whole,
337
+ each hit replaced whole whatever --redact-mode says (a mask keeps 4+4 characters, most of
338
+ a short password); then the page's redaction with this run's mode and keywords. Returns
339
+ (text, count_by_kind); a second pass over its output finds nothing."""
340
+ pre, counts = _reviewer_patterns(text)
341
+ red, found = redact(pre, extra=extra, mode=mode)
342
+ for k, v in found.items():
343
+ counts[k] = counts.get(k, 0) + v
344
+ return red, counts
261
345
 
262
346
 
263
347
  # ---------------------------------------------------------------- Discovery
@@ -533,9 +617,15 @@ SHORT_DETAIL = 160
533
617
  SINGLE_SPLIT_MIN = SHORT_DETAIL
534
618
 
535
619
 
620
+ _OPEN_MARKER_RE = re.compile(r"\[REDACTED[^\]]*$")
621
+
622
+
536
623
  def _cut(s, n):
537
624
  s = s.strip()
538
- return s if len(s) <= n else s[:max(1, n - 1)].rstrip() + "…"
625
+ if len(s) <= n:
626
+ return s
627
+ head = _OPEN_MARKER_RE.sub("", s[:max(1, n - 1)]) # a marker cut in two goes whole
628
+ return head.rstrip() + "…"
539
629
 
540
630
 
541
631
  def naive_summary(text: str) -> str:
@@ -596,6 +686,35 @@ def summarize_turn(turn):
596
686
 
597
687
 
598
688
  # ---------------------------------------------------------------- Summary cache
689
+ def _stdin_id_key():
690
+ """The key that turns a pasted turn's text into its id: random, kept 0600 in the cache folder,
691
+ so ids (and the caches keyed on them) stay the same on this machine. Part files carry the id
692
+ to a model that never sees the key, so an id gives no way to test a guessed password. With no
693
+ cache folder to keep it in, the key lasts one run."""
694
+ path = CACHE_BASE / "stdin-id.key"
695
+ try:
696
+ key = path.read_bytes()
697
+ if len(key) == 32:
698
+ return key
699
+ except OSError:
700
+ pass
701
+ key = os.urandom(32)
702
+ tmp = path.with_name("%s.%d" % (path.name, os.getpid()))
703
+ try:
704
+ CACHE_BASE.mkdir(parents=True, exist_ok=True)
705
+ os.chmod(str(CACHE_BASE), 0o700)
706
+ fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_TRUNC | getattr(os, "O_BINARY", 0), 0o600)
707
+ with os.fdopen(fd, "wb") as f:
708
+ f.write(key)
709
+ os.replace(str(tmp), str(path)) # whole or not at all
710
+ except OSError:
711
+ try:
712
+ os.unlink(str(tmp))
713
+ except OSError:
714
+ pass
715
+ return key
716
+
717
+
599
718
  def cache_dir(session_id: str):
600
719
  d = CACHE_BASE / str(SCHEMA_VERSION) / (session_id or "default")
601
720
  d.mkdir(parents=True, exist_ok=True)
@@ -607,11 +726,19 @@ def cache_dir(session_id: str):
607
726
  return d
608
727
 
609
728
 
729
+ def _page_clean_turn(t, extra, mode):
730
+ """`t` with its text and parts as a page shows them, so a summary cut from it cannot cut a
731
+ secret in two and keep the half no pattern recognises."""
732
+ def clean(s):
733
+ return page_redact(s, extra, mode)[0]
734
+ return dict(t, text=clean(t["text"]), parts=[clean(p) for p in t.get("parts") or []])
735
+
736
+
610
737
  def read_or_summarize(turn: dict, session_id: str, rebuild: bool = False,
611
738
  redact_extra=None, redact_mode: str = "full"):
612
739
  """Return (summary, cache_hit). Cache key includes the content hash AND the
613
740
  summarizer version (B-3), so changing the summarizer invalidates old entries.
614
- Cached text is always redacted (secret hygiene).
741
+ Cached text is always redacted (secret hygiene). It is cut from the redacted text (3.0.2).
615
742
  """
616
743
  digest = hashlib.sha256(
617
744
  (turn["text"] + "\x00s" + str(SUMMARIZER_VERSION)).encode()
@@ -627,8 +754,7 @@ def read_or_summarize(turn: dict, session_id: str, rebuild: bool = False,
627
754
  return p.read_text(encoding="utf-8").strip(), True
628
755
  except (OSError, UnicodeDecodeError):
629
756
  pass
630
- raw_summary = summarize_turn(turn)
631
- redacted, _ = redact(raw_summary, extra=redact_extra, mode=redact_mode)
757
+ redacted = summarize_turn(_page_clean_turn(turn, redact_extra, redact_mode))
632
758
  if p is not None:
633
759
  try:
634
760
  fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
@@ -1016,7 +1142,7 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
1016
1142
  open_attr = " open" if open_details else ""
1017
1143
 
1018
1144
  def _red(text):
1019
- r, c = redact(text, extra=redact_extra, mode=redact_mode)
1145
+ r, c = page_redact(text, extra=redact_extra, mode=redact_mode)
1020
1146
  for k, v in c.items():
1021
1147
  redact_counts[k] = redact_counts.get(k, 0) + v
1022
1148
  return r
@@ -1026,7 +1152,7 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
1026
1152
  # session-transition divider (--all-sessions)
1027
1153
  if all_sessions and t.get("session") != last_session:
1028
1154
  last_session = t.get("session")
1029
- name = html.escape(t.get("session_name") or last_session or "session")
1155
+ name = html.escape(_red(t.get("session_name") or last_session or "session"))
1030
1156
  rows.append('<div class="day" data-mark="%s">'
1031
1157
  '<span class="pill pill-session">%s</span></div>'
1032
1158
  % (name, name))
@@ -1076,13 +1202,14 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
1076
1202
 
1077
1203
  tools_html = ""
1078
1204
  if t.get("tools"):
1079
- ts_list = ", ".join(f"{k}×{v}" for k, v in sorted(t["tools"].items()))
1205
+ ts_list = ", ".join(f"{_hide_keywords(k, redact_extra)}×{v}"
1206
+ for k, v in sorted(t["tools"].items()))
1080
1207
  total = sum(t["tools"].values())
1081
1208
  tools_html = ('<div class="tools">🔧 도구 %d건: %s</div>'
1082
1209
  % (total, html.escape(ts_list)))
1083
1210
 
1084
1211
  if role == "agent":
1085
- who = html.escape(t.get("agent_from") or "agent")
1212
+ who = html.escape(_red(t.get("agent_from") or "agent"))
1086
1213
  avatar = "🤝"
1087
1214
  from_line = '<div class="from">%s</div>' % who
1088
1215
  row_cls = "row bot agent"
@@ -1102,6 +1229,21 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
1102
1229
  return rows, redact_counts, cache_hits, cache_total
1103
1230
 
1104
1231
 
1232
+ _TAG_RE = re.compile(r"<[^>]*>")
1233
+
1234
+
1235
+ def _residual_secrets(rows_html):
1236
+ """What the reviewer patterns still find in a page's text (tags dropped, entities decoded),
1237
+ by pattern name: a field the page forgot to redact."""
1238
+ text = html.unescape(_TAG_RE.sub(" ", rows_html))
1239
+ left = {}
1240
+ for name, pat in REVIEW_SECRET_PATTERNS:
1241
+ n = len(pat.findall(text))
1242
+ if n:
1243
+ left[name] = n
1244
+ return left
1245
+
1246
+
1105
1247
  # ---------------------------------------------------------------- Self-verify (F-1)
1106
1248
  _VOID = frozenset(
1107
1249
  "area base br col embed hr img input link meta param source track wbr".split())
@@ -1244,18 +1386,23 @@ def _decision_context(why, keeps):
1244
1386
  str(keeps.get("keep_tool_only") is True)])
1245
1387
 
1246
1388
 
1247
- def _llm_cache_path(turn_id, session, redacted_text, context):
1248
- digest = hashlib.sha256((redacted_text + "\x00llm" + str(LLM_CACHE_VERSION)
1249
- + "\x00" + context).encode()).hexdigest()[:12]
1250
- return cache_dir(_safe_name(session)) / f"{_safe_name(turn_id)}-{digest}-llm.json"
1389
+ def _llm_digest(redacted_text, context):
1390
+ """The decision cache key: the text with the page's patterns only (as 3.0.1 keyed it, so
1391
+ no turn is reviewed again for 3.0.2), the cache version and what the decision rests on."""
1392
+ return hashlib.sha256((redacted_text + "\x00llm" + str(LLM_CACHE_VERSION)
1393
+ + "\x00" + context).encode()).hexdigest()[:12]
1394
+
1395
+
1396
+ def _llm_cache_path(turn_id, session, digest):
1397
+ return cache_dir(_safe_name(session)) / f"{_safe_name(turn_id)}-{_safe_name(digest)}-llm.json"
1251
1398
 
1252
1399
 
1253
- def read_llm_cache(turn_id, session, redacted_text, context):
1400
+ def read_llm_cache(turn_id, session, digest):
1254
1401
  """A reviewer's earlier {keep, summary} for this exact (redacted) text under the same
1255
1402
  rule call and keep flags (`context`), or None when no reviewer saw it so. Both values
1256
1403
  null: the reviewer left the turn to the rules."""
1257
1404
  try:
1258
- path = _llm_cache_path(turn_id, session, redacted_text, context)
1405
+ path = _llm_cache_path(turn_id, session, digest)
1259
1406
  except OSError as e: # no cache folder (a read-only HOME, say)
1260
1407
  _warn_once("_no_llm_cache", f"reviewer decision cache unavailable ({e}); "
1261
1408
  "every turn is reviewed")
@@ -1272,9 +1419,9 @@ def read_llm_cache(turn_id, session, redacted_text, context):
1272
1419
  return {"keep": keep, "summary": summary}
1273
1420
 
1274
1421
 
1275
- def write_llm_cache(turn_id, session, redacted_text, context, keep, summary):
1422
+ def write_llm_cache(turn_id, session, digest, keep, summary):
1276
1423
  try:
1277
- _write_private(_llm_cache_path(turn_id, session, redacted_text, context),
1424
+ _write_private(_llm_cache_path(turn_id, session, digest),
1278
1425
  json.dumps({"keep": keep, "summary": summary}, ensure_ascii=False))
1279
1426
  except OSError as e:
1280
1427
  _warn_once("_no_llm_cache_write", f"reviewer decisions not cached ({e}); the next "
@@ -1336,7 +1483,7 @@ def _rule_summary(t, red, args):
1336
1483
  The summary cache is keyed on the text alone, so a cached one may predate a
1337
1484
  --redact-extra keyword or carry another --redact-mode."""
1338
1485
  if t["role"] in ("assistant", "agent"):
1339
- return redact(summarize_turn(t), extra=args.redact_extra, mode=args.redact_mode)[0]
1486
+ return summarize_turn(_page_clean_turn(t, args.redact_extra, args.redact_mode))
1340
1487
  if t["role"] == "user" and len(red) > USER_FOLD:
1341
1488
  return user_fold_summary(red)
1342
1489
  return None
@@ -1365,18 +1512,22 @@ def _review_view(t, red, args):
1365
1512
 
1366
1513
 
1367
1514
  def _pack_turn(n, t, tid, why, session_id, args, counts):
1368
- """One turn of the pack under its final id `tid` (redacted text, the rules' decision,
1369
- the reviewer's slot), and the reviewer's view of it."""
1370
- red, found = redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)
1515
+ """One turn of the pack under its final id `tid` (the text as the page shows it, the rules'
1516
+ decision, the reviewer's slot), and the reviewer's view of it. The cached decision keys on
1517
+ the text with the page's patterns only (`llm_key`), as 3.0.1 did."""
1518
+ red, found = page_redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)
1371
1519
  for k, v in found.items():
1372
1520
  counts[k] = counts.get(k, 0) + v
1521
+ hit = any(name in found for name, _ in REVIEW_SECRET_PATTERNS)
1522
+ key_text = redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)[0] if hit else red
1373
1523
  preview, clipped = _preview(review_redact(t["text"], args.redact_extra)[0])
1374
1524
  session = t.get("session") or session_id
1375
1525
  context = _decision_context(why, _keeps(args))
1376
- cached = None if args.rebuild_summaries else read_llm_cache(tid, session, red, context)
1526
+ llm_key = _llm_digest(key_text, context)
1527
+ cached = None if args.rebuild_summaries else read_llm_cache(tid, session, llm_key)
1377
1528
  view = _review_view(t, red, args)
1378
1529
  turn = {"id": tid, "n": n, "role": t["role"], "ts": t.get("ts") or "",
1379
- "tools": dict(t.get("tools") or {}), "text": red,
1530
+ "tools": dict(t.get("tools") or {}), "text": red, "llm_key": llm_key,
1380
1531
  "redactions": _public_counts(found), "preview": preview, "clipped": clipped,
1381
1532
  "rule": {"keep": why is None, "why": why,
1382
1533
  "summary": _rule_summary(t, red, args),
@@ -1386,7 +1537,7 @@ def _pack_turn(n, t, tid, why, session_id, args, counts):
1386
1537
  turn["meta"] = True
1387
1538
  for k in ("session", "session_name", "agent_from"):
1388
1539
  if t.get(k):
1389
- turn[k] = t[k]
1540
+ turn[k] = t[k] if k == "session" else page_redact(t[k], args.redact_extra, args.redact_mode)[0]
1390
1541
  return turn, view
1391
1542
 
1392
1543
 
@@ -1416,12 +1567,14 @@ def _decisions_name(part_path):
1416
1567
 
1417
1568
  def _clear_parts(pack_path):
1418
1569
  """Remove the part and decisions files an earlier --emit-review left beside the
1419
- pack: that run's decisions must not be applied to this one. Returns the files it
1420
- could not remove."""
1570
+ pack, and an earlier gate's samples: that run's decisions must not be applied to this
1571
+ one, and its samples would stop this pack's rerun. Returns the files it could not
1572
+ remove."""
1421
1573
  prefix = pack_path.stem + ".part-"
1574
+ samples = f"{pack_path.stem}.reviewer-input.json"
1422
1575
  try:
1423
1576
  olds = [p for p in pack_path.parent.iterdir()
1424
- if p.name.startswith(prefix) and p.name.endswith(".json")]
1577
+ if (p.name.startswith(prefix) and p.name.endswith(".json")) or p.name == samples]
1425
1578
  except OSError:
1426
1579
  return []
1427
1580
  failed = []
@@ -1450,7 +1603,7 @@ def _sheet(k, of, part_path, group, args):
1450
1603
  turns = []
1451
1604
  for x, view in group:
1452
1605
  y = {key: v for key, v in x.items()
1453
- if key not in ("text", "session", "session_name", "agent_from", "redactions")}
1606
+ if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key")}
1454
1607
  y["rule"] = {k: v for k, v in x["rule"].items() if k != "review_summary"}
1455
1608
  y["rule"]["summary"] = view["summary"]
1456
1609
  y["tools"] = {_hide_keywords(name, args.redact_extra): n for name, n in x["tools"].items()}
@@ -1490,7 +1643,13 @@ def _report_emit(pack_path, items, paths, groups):
1490
1643
 
1491
1644
 
1492
1645
  def emit_review(turns, drops, window, session_id, output, args):
1493
- """Write the pack and one part file per reviewer; print them and the next step."""
1646
+ """Write the pack and one part file per reviewer; print them and the next step. A
1647
+ --reviewer-output that holds something other than a verdict list stops it here, before
1648
+ any reviewer runs: the gate would refuse it only after them."""
1649
+ rop = pathlib.Path(args.reviewer_output) if args.reviewer_output and not args.skip_reviewer else None
1650
+ if rop is not None and rop.exists() and not _is_verdict_file(rop):
1651
+ _bad(f"{rop} is there and is not a verdict list; give a --reviewer-output that does not exist yet")
1652
+ return 2
1494
1653
  pack_path = pathlib.Path(args.emit_review)
1495
1654
  counts, pairs = {}, []
1496
1655
  for n, (i, tid) in enumerate(zip(window, _final_ids(turns, window)), 1):
@@ -1522,7 +1681,7 @@ def emit_review(turns, drops, window, session_id, output, args):
1522
1681
  def _is_pack_turn(x):
1523
1682
  return (isinstance(x, dict) and isinstance(x.get("id"), str)
1524
1683
  and x.get("role") in ("user", "assistant", "agent", "mark")
1525
- and isinstance(x.get("text"), str)
1684
+ and isinstance(x.get("text"), str) and isinstance(x.get("llm_key"), str)
1526
1685
  and isinstance(x.get("rule"), dict) and isinstance(x["rule"].get("keep"), bool)
1527
1686
  and _is_summary(x["rule"].get("summary"))
1528
1687
  and isinstance(x.get("llm"), dict) and _is_keep(x["llm"].get("keep"))
@@ -1535,7 +1694,8 @@ def _load_pack(path):
1535
1694
  except (OSError, ValueError, UnicodeDecodeError) as e:
1536
1695
  return _bad(f"cannot read review pack {path}: {e}")
1537
1696
  if not isinstance(pack, dict) or pack.get("schema") != REVIEW_SCHEMA:
1538
- return _bad(f"{path} is not a {REVIEW_SCHEMA} pack")
1697
+ return _bad(f"{path} is not a {REVIEW_SCHEMA} pack (made by another lineage version? "
1698
+ "run --emit-review again)")
1539
1699
  if not isinstance(pack.get("turns"), list) or not all(map(_is_pack_turn, pack["turns"])):
1540
1700
  return _bad(f"{path}: malformed turns")
1541
1701
  return pack
@@ -1584,8 +1744,12 @@ def _decisions_text(text):
1584
1744
  pass
1585
1745
  lists, empty = [], False
1586
1746
  decoder = json.JSONDecoder()
1587
- # only where a list of objects or an empty list can start: a run of `[` is tried once
1588
- for m in re.finditer(r"\[(?=\s*[{\]])", text):
1747
+ # only where a list of objects or an empty list can start: a run of `[` is tried once,
1748
+ # and only so many starts (each failed try parses up to the nesting limit)
1749
+ for k, m in enumerate(re.finditer(r"\[(?=\s*[{\]])", text)):
1750
+ if k == DECISIONS_TRIES:
1751
+ raise ValueError(f"more than {DECISIONS_TRIES} places a list may start; "
1752
+ "write the bare JSON array")
1589
1753
  try:
1590
1754
  got, _ = decoder.raw_decode(text, m.start())
1591
1755
  except (ValueError, RecursionError):
@@ -1618,6 +1782,8 @@ def _load_decisions(files):
1618
1782
  merged = {}
1619
1783
  for f in files:
1620
1784
  try:
1785
+ if f.stat().st_size > DECISIONS_MAX:
1786
+ raise ValueError(f"larger than {DECISIONS_MAX} bytes")
1621
1787
  items = _decisions_text(f.read_text(encoding="utf-8"))
1622
1788
  except (OSError, ValueError, UnicodeDecodeError, RecursionError) as e:
1623
1789
  return _bad(f"cannot read decisions {f}: {e}")
@@ -1714,10 +1880,11 @@ def _kept_by_flag(pack, x):
1714
1880
 
1715
1881
 
1716
1882
  def _review_result(pack, merged, args):
1717
- """(turns to render, their summaries, cache writes, counts, prior redactions) from the
1718
- pack and the decisions: a reviewer's value first, then the pack's cached one, then the
1719
- rules'. A keep flag outranks a reviewer's `false`; the cache still gets what was said."""
1720
- turns, summaries, writes, prior = [], {}, [], {}
1883
+ """(turns to render, their summaries, cache writes, counts, prior redactions, the ids of
1884
+ turns the user typed that the reviewers dropped) from the pack and the decisions: a
1885
+ reviewer's value first, then the pack's cached one, then the rules'. A keep flag outranks
1886
+ a reviewer's `false`; the cache still gets what was said."""
1887
+ turns, summaries, writes, prior, typed = [], {}, [], {}, []
1721
1888
  stats = {"restored": 0, "dropped": 0, "reviewer": 0, "rule": 0, "ignored": 0,
1722
1889
  "hidden": 0, "flag-kept": 0}
1723
1890
  for x in pack["turns"]:
@@ -1726,15 +1893,17 @@ def _review_result(pack, merged, args):
1726
1893
  held = llm_keep is False and _kept_by_flag(pack, x)
1727
1894
  stats["flag-kept"] += held
1728
1895
  keep = bool(_first(None if held else llm_keep, x["rule"]["keep"]))
1896
+ if not keep and x["role"] == "user" and x["rule"]["keep"] and not x.get("meta"):
1897
+ typed.append(x["id"]) # typed by the user: the rules keep these
1729
1898
  llm_summary = _reviewer_summary(x, d, args, stats)
1730
1899
  if x["id"] in merged: # reviewed, even if left to the rules
1731
- writes.append((x["id"], x.get("session") or pack.get("source"), x["text"],
1732
- _decision_context(x["rule"].get("why"), pack), llm_keep, llm_summary))
1900
+ writes.append((x["id"], x.get("session") or pack.get("source"), x["llm_key"],
1901
+ llm_keep, llm_summary))
1733
1902
  stats["restored"] += keep and not x["rule"]["keep"]
1734
1903
  stats["dropped"] += x["rule"]["keep"] and not keep
1735
1904
  if keep:
1736
1905
  _show(x, llm_summary, turns, summaries, stats, prior)
1737
- return turns, summaries, writes, stats, prior
1906
+ return turns, summaries, writes, stats, prior, typed
1738
1907
 
1739
1908
 
1740
1909
  def _gate_from_pack(args, pack):
@@ -1788,6 +1957,40 @@ def _remove_review_files(pack_path, pack):
1788
1957
  file=sys.stderr)
1789
1958
 
1790
1959
 
1960
+ def _samples_left_unread(pack_path, args):
1961
+ """True, with the reason, when a gated run left its samples beside the pack and this run
1962
+ gives neither a verdict path nor --skip-reviewer: it would pass over that run's verdict."""
1963
+ left = pack_path.with_name(f"{pack_path.stem}.reviewer-input.json")
1964
+ if left.exists() and not (args.reviewer_output or args.skip_reviewer):
1965
+ _bad(f"{left} was left by a gated run; give its --reviewer-output again, or --skip-reviewer")
1966
+ return True
1967
+ return False
1968
+
1969
+
1970
+ def _review_output(args, pack):
1971
+ """The page path: the pack's when this run gives none, else the given one with the pack's
1972
+ stamp, so the gate's runs write one page."""
1973
+ if args.output is None:
1974
+ return pack.get("output") or "work/lineage-review.html"
1975
+ return _with_pack_stamp(args.output, pack.get("output"))
1976
+
1977
+
1978
+ def _report_review(stats, turns, pack, typed):
1979
+ """What the render took from the reviewers, and the turns typed by the user they dropped."""
1980
+ print(f"[lineage] review applied: shown={len(turns)}/{len(pack['turns'])} "
1981
+ f"restored={stats['restored']} dropped={stats['dropped']} "
1982
+ f"summaries reviewer={stats['reviewer']} rule={stats['rule']}", file=sys.stderr)
1983
+ if stats["ignored"]:
1984
+ print(f"[lineage] note: {stats['ignored']} summaries for turns the page shows "
1985
+ "in full were ignored", file=sys.stderr)
1986
+ if stats["flag-kept"]:
1987
+ print(f"[lineage] note: {stats['flag-kept']} turn(s) a reviewer marked keep: false "
1988
+ "stay, as --keep-trivia or --keep-tool-only asked", file=sys.stderr)
1989
+ if typed:
1990
+ print(f"[lineage] WARN: reviewers dropped {len(typed)} typed user turn(s): {', '.join(typed[:10])}"
1991
+ f"{' ...' if len(typed) > 10 else ''}; tell the user", file=sys.stderr)
1992
+
1993
+
1791
1994
  def apply_review(args):
1792
1995
  """Render from a review pack and its decisions; cache the reviewers' values and, after
1793
1996
  a good render, remove the review files."""
@@ -1799,9 +2002,10 @@ def apply_review(args):
1799
2002
  if merged is None:
1800
2003
  return 2
1801
2004
  _check_pack(pack, merged, args)
1802
- turns, summaries, writes, stats, prior = _review_result(pack, merged, args)
1803
- if args.output is None:
1804
- args.output = pack.get("output") or "work/lineage-review.html"
2005
+ if _samples_left_unread(pack_path, args):
2006
+ return 2
2007
+ turns, summaries, writes, stats, prior, typed = _review_result(pack, merged, args)
2008
+ args.output = _review_output(args, pack)
1805
2009
  if stats["hidden"]:
1806
2010
  prior["reviewer-summary"] = stats["hidden"]
1807
2011
  try:
@@ -1817,15 +2021,7 @@ def apply_review(args):
1817
2021
  for w in writes:
1818
2022
  write_llm_cache(*w)
1819
2023
  _remove_review_files(pack_path, pack)
1820
- print(f"[lineage] review applied: shown={len(turns)}/{len(pack['turns'])} "
1821
- f"restored={stats['restored']} dropped={stats['dropped']} "
1822
- f"summaries reviewer={stats['reviewer']} rule={stats['rule']}", file=sys.stderr)
1823
- if stats["ignored"]:
1824
- print(f"[lineage] note: {stats['ignored']} summaries for turns the page shows "
1825
- "in full were ignored", file=sys.stderr)
1826
- if stats["flag-kept"]:
1827
- print(f"[lineage] note: {stats['flag-kept']} turn(s) a reviewer marked keep: false "
1828
- "stay, as --keep-trivia or --keep-tool-only asked", file=sys.stderr)
2024
+ _report_review(stats, turns, pack, typed)
1829
2025
  return rc
1830
2026
 
1831
2027
 
@@ -1858,7 +2054,8 @@ def build_arg_parser() -> argparse.ArgumentParser:
1858
2054
  ap.add_argument("--reviewer-output",
1859
2055
  help="path to Critic response JSON; enforces the quality gate")
1860
2056
  ap.add_argument("--reviewer-timeout", type=int, default=None,
1861
- help="seconds to wait for reviewer-output (default 60)")
2057
+ help="seconds to wait for reviewer-output (default 60 with --rulebase; "
2058
+ "the reviewed flow waits only when this is given)")
1862
2059
  ap.add_argument("--title", default="Session Lineage", help="HTML title")
1863
2060
  _add_review_args(ap)
1864
2061
  # Readability defaults are ON. Opt-out flags restore raw/older behavior.
@@ -2011,10 +2208,11 @@ def main(argv=None) -> int:
2011
2208
  except SystemExit as e:
2012
2209
  return int(e.code) if isinstance(e.code, int) else 2
2013
2210
 
2014
- # stdin → derive per-turn uuid from content hash so cache works
2211
+ # stdin → derive per-turn uuid from the content so the caches work (keyed: see _stdin_id_key)
2015
2212
  if args.from_transcript == "-":
2213
+ key = _stdin_id_key()
2016
2214
  for t in turns:
2017
- t["uuid"] = hashlib.sha256(str(t["text"]).encode()).hexdigest()[:16]
2215
+ t["uuid"] = hmac.new(key, str(t["text"]).encode(), hashlib.sha256).hexdigest()[:16]
2018
2216
 
2019
2217
  # ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
2020
2218
  turns = classify_turns(turns, drop_trivia=args.drop_trivia)
@@ -2081,10 +2279,15 @@ def select_range(turns, args):
2081
2279
 
2082
2280
 
2083
2281
  def default_output(args, jsonl_path, session_id):
2084
- """work/lineage-<session name or id>.html, or args.output when given."""
2282
+ """work/lineage-<session name or id>.html, or args.output when given. A name that holds a
2283
+ secret-like string gives way to the session id."""
2085
2284
  if args.output is not None:
2086
2285
  return args.output
2087
2286
  session_name = discover_session_name(jsonl_path) if jsonl_path else None
2287
+ if session_name and page_redact(session_name)[0] != session_name:
2288
+ print(f"[lineage] session name: {page_redact(session_name)[0]} "
2289
+ "(holds a secret-like string; the file is named by the session id)", file=sys.stderr)
2290
+ session_name = None
2088
2291
  slug = ("all-sessions" if args.all_sessions
2089
2292
  else (session_name or session_id[:8]))
2090
2293
  if session_name:
@@ -2103,6 +2306,13 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
2103
2306
  markdown=markdown, all_sessions=all_sessions, summaries=summaries)
2104
2307
  for k, v in (prior_redactions or {}).items():
2105
2308
  redact_counts[k] = redact_counts.get(k, 0) + v
2309
+ left = _residual_secrets("\n".join(rows))
2310
+ if left:
2311
+ print("[lineage] WARN: the page still holds secret-like text ("
2312
+ + ", ".join(f"{k}={v}" for k, v in sorted(left.items()))
2313
+ + "); check the page before sharing it, or run again with the value in LINEAGE_REDACT_EXTRA"
2314
+ " (with --rulebase, add --rebuild-summaries)",
2315
+ file=sys.stderr)
2106
2316
 
2107
2317
  date_range = ""
2108
2318
  dated = [t for t in turns if t.get("ts")]
@@ -2137,6 +2347,16 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
2137
2347
  return 0
2138
2348
 
2139
2349
 
2350
+ def _with_pack_stamp(output, pack_output):
2351
+ """`output` with the `_YYMMDD+HHMM` stamp of the pack's own output, so the gate's runs
2352
+ minutes apart write one page; as given when it has a stamp or the pack's output has none."""
2353
+ stamp = re.search(r"_\d{6}\+\d{4}$", pathlib.Path(str(pack_output or "")).stem)
2354
+ out = pathlib.Path(output)
2355
+ if stamp and not re.search(r"_\d{6}\+\d{4}$", out.stem):
2356
+ return str(out.with_name(f"{out.stem}{stamp.group(0)}{out.suffix}"))
2357
+ return output
2358
+
2359
+
2140
2360
  def _with_timestamp_suffix(path_str):
2141
2361
  """Append `_YYMMDD+HHMM` before the extension (idempotent)."""
2142
2362
  import datetime as _dt
@@ -2149,7 +2369,8 @@ def _with_timestamp_suffix(path_str):
2149
2369
 
2150
2370
  def _gate_samples(bots, summaries, session_id, args):
2151
2371
  """Up to 5 bot turns as the gate's critic reads them. --rulebase keeps the 2.x shape
2152
- (random turns, page redaction). With reviewer summaries (the default flow) the pick is
2372
+ (random turns); a model reads the samples, so every value is hidden whole before the cut,
2373
+ as in part files (3.0.2). With reviewer summaries (the default flow) the pick is
2153
2374
  seeded by the turns, so a rerun on the same turns samples the same ones, and a sample
2154
2375
  names its turn and shows the head and tail the reviewer saw, fully redacted."""
2155
2376
  import random
@@ -2158,8 +2379,8 @@ def _gate_samples(bots, summaries, session_id, args):
2158
2379
  for i, t in enumerate(random.sample(bots, min(5, len(bots)))):
2159
2380
  s, _ = _page_summary(t, summaries, session_id, False,
2160
2381
  args.redact_extra, args.redact_mode)
2161
- red_s, _ = redact(s, extra=args.redact_extra, mode=args.redact_mode)
2162
- red_d, _ = redact(t["text"][:500], extra=args.redact_extra, mode=args.redact_mode)
2382
+ red_s = review_redact(_MASKED.sub("[REDACTED]", s), args.redact_extra)[0]
2383
+ red_d = review_redact(t["text"], args.redact_extra)[0][:500]
2163
2384
  out.append({"idx": i, "original_detail": red_d, "generated_summary": red_s})
2164
2385
  return out
2165
2386
  seed = hashlib.sha256("\x00".join(t["uuid"] for t in bots).encode()).hexdigest()
@@ -2206,22 +2427,41 @@ def _refuse_non_verdict(rop):
2206
2427
  p = pathlib.Path(rop) if rop else None
2207
2428
  if p is None or not p.exists() or _is_verdict_file(p):
2208
2429
  return
2209
- print(f"[lineage] ERROR: {rop} is not a verdict list; if the session wrote it, write it "
2210
- "again as a bare JSON array of {idx, id, key, recoverable, reason}, else give a "
2211
- "--reviewer-output that does not exist yet", file=sys.stderr)
2430
+ if _json_list(p): # step 8: written again, the same answer fails the same way
2431
+ why = ("it is a JSON array where no entry names idx; if the session wrote the critic's "
2432
+ "answer, have the critic judge the samples again (a failed gate run)")
2433
+ else:
2434
+ why = ("if the session wrote it, write it again as a bare JSON array of "
2435
+ "{idx, id, key, recoverable, reason}")
2436
+ print(f"[lineage] ERROR: {rop} is not a verdict list; {why}, else give a --reviewer-output "
2437
+ "that does not exist yet", file=sys.stderr)
2212
2438
  raise SystemExit(2)
2213
2439
 
2214
2440
 
2215
- def _is_verdict_file(p):
2216
- """True when `p` is a file holding a critic's verdict: a non-empty JSON list of objects
2217
- that each name an `idx`. A list of anything else (a user's data file) is not one."""
2441
+ def _json_list(p):
2442
+ """True when `p` is a file holding a JSON array, whatever its entries."""
2443
+ try:
2444
+ return p.is_file() and isinstance(json.loads(p.read_text(encoding="utf-8")), list)
2445
+ except (OSError, ValueError, UnicodeDecodeError, RecursionError):
2446
+ return False
2447
+
2448
+
2449
+ def _is_verdict_file(p, every=False):
2450
+ """True when `p` is a file holding a critic's verdict: a JSON list with at least one
2451
+ object that names an `idx`. A list of anything else (a user's data file) is not one; a
2452
+ verdict with an entry short of its idx is one, and the coverage check says what it
2453
+ misses. With `every`, each entry must be such an object: only that file may be written
2454
+ over, so a user's list holding one such object among other data stays."""
2218
2455
  if not p.is_file():
2219
2456
  return False
2220
2457
  try:
2221
2458
  got = json.loads(p.read_text(encoding="utf-8"))
2222
2459
  except (OSError, ValueError, UnicodeDecodeError, RecursionError):
2223
2460
  return False
2224
- return isinstance(got, list) and bool(got) and all(isinstance(v, dict) and "idx" in v for v in got)
2461
+ if not isinstance(got, list):
2462
+ return False
2463
+ named = [isinstance(v, dict) and "idx" in v for v in got]
2464
+ return bool(named) and all(named) if every else any(named)
2225
2465
 
2226
2466
 
2227
2467
  def _write_gate_samples(bots, out, summaries, session_id, args):
@@ -2251,6 +2491,8 @@ def _write_gate_samples(bots, out, summaries, session_id, args):
2251
2491
  print("[lineage] next: invoke Skill('oh-my-claudecode:critic') with "
2252
2492
  "the JSON above; expected [{idx, recoverable, reason}, ...] "
2253
2493
  "(PASS = 5/5 recoverable)", file=sys.stderr, flush=True)
2494
+ elif pathlib.Path(args.reviewer_output).exists():
2495
+ print(f"[lineage] reading the verdict at {args.reviewer_output}", file=sys.stderr, flush=True)
2254
2496
  else:
2255
2497
  print(f"[lineage] next: have a critic agent judge the JSON above and write its "
2256
2498
  f"[{{idx, id, key, recoverable, reason}}, ...] to {args.reviewer_output} "
@@ -2259,21 +2501,25 @@ def _write_gate_samples(bots, out, summaries, session_id, args):
2259
2501
 
2260
2502
 
2261
2503
  def _read_verdict(rop, timeout, set_aside):
2262
- """The critic's verdict list from `rop`, waited for up to `timeout` seconds. With
2263
- `set_aside` a verdict list is renamed once read, so a rerun waits for a fresh one
2264
- instead of reusing it; anything else stays where it is. Exits 2 when it is missing,
2265
- unreadable or not a non-empty list."""
2504
+ """The critic's verdict list from `rop`, waited for up to `timeout` seconds (None: not
2505
+ waited for, the next run reads it). With `set_aside` a verdict list is renamed once read,
2506
+ so a rerun waits for a fresh one instead of reusing it; anything else stays where it is.
2507
+ Exits 2 when it is missing, unreadable or not a non-empty list."""
2266
2508
  import time as _time
2267
- deadline = _time.time() + max(1, timeout)
2509
+ deadline = _time.time() + (0 if timeout is None else max(1, timeout))
2268
2510
  while not rop.exists() and _time.time() < deadline:
2269
2511
  _time.sleep(1)
2270
2512
  if not rop.exists():
2271
- print(f"[lineage] ERROR: reviewer-output not found within "
2272
- f"{timeout}s: {rop}", file=sys.stderr)
2513
+ if timeout is None:
2514
+ print(f"[lineage] ERROR: no verdict at {rop} yet; have a critic judge the samples, "
2515
+ "write its answer there and run this again", file=sys.stderr)
2516
+ else:
2517
+ print(f"[lineage] ERROR: reviewer-output not found within "
2518
+ f"{timeout}s: {rop}", file=sys.stderr)
2273
2519
  raise SystemExit(2)
2274
2520
  try:
2275
2521
  verdict = json.loads(rop.read_text(encoding="utf-8"))
2276
- except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
2522
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError, RecursionError) as e:
2277
2523
  print(f"[lineage] ERROR: reviewer-output parse failed: {e}",
2278
2524
  file=sys.stderr)
2279
2525
  raise SystemExit(2)
@@ -2296,7 +2542,7 @@ def _set_aside(rop):
2296
2542
  return
2297
2543
  dest = rop.with_name(rop.name + ".used")
2298
2544
  k = 0
2299
- while dest.exists() and not _is_verdict_file(dest):
2545
+ while dest.exists() and not _is_verdict_file(dest, every=True):
2300
2546
  k += 1
2301
2547
  dest = rop.with_name(f"{rop.name}.used.{k}")
2302
2548
  try:
@@ -2384,7 +2630,10 @@ def _run_reviewer_gate(turns, out, session_id, args, summaries=None):
2384
2630
  return
2385
2631
  samples = _write_gate_samples(bots, out, summaries, session_id, args)
2386
2632
  if args.reviewer_output:
2387
- timeout = REVIEWER_TIMEOUT if args.reviewer_timeout is None else args.reviewer_timeout
2633
+ if reviewed and args.reviewer_timeout is None:
2634
+ timeout = None # the run after the critic reads the verdict; this one does not wait
2635
+ else:
2636
+ timeout = REVIEWER_TIMEOUT if args.reviewer_timeout is None else args.reviewer_timeout
2388
2637
  _enforce_gate(pathlib.Path(args.reviewer_output), timeout, samples, reviewed)
2389
2638
 
2390
2639