@kaydash9999/banker-plugins 0.14.0 → 0.15.0

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.
Files changed (80) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +47 -0
  4. package/README.md +23 -11
  5. package/codex/manifest.json +5 -5
  6. package/codex/transform-matrix.md +5 -5
  7. package/commands/front-qa.md +2 -2
  8. package/commands/setup.md +13 -4
  9. package/hooks/graceful-pause.mjs +222 -0
  10. package/hooks/hooks.json +2 -1
  11. package/hooks/register.mjs +12 -0
  12. package/package.json +4 -1
  13. package/skills/3d-intro-build/SKILL.md +1 -1
  14. package/skills/3d-intro-setup/SKILL.md +1 -1
  15. package/skills/all-in-one/SKILL.md +1 -1
  16. package/skills/append_wiki/SKILL.md +1 -1
  17. package/skills/arch-diagram/SKILL.md +1 -1
  18. package/skills/audit-mock/SKILL.md +1 -1
  19. package/skills/audit-security/SKILL.md +1 -1
  20. package/skills/audit-web-page/SKILL.md +1 -1
  21. package/skills/cleansing-memory/SKILL.md +1 -1
  22. package/skills/compact-copy/SKILL.md +1 -1
  23. package/skills/compact-wiki/SKILL.md +1 -1
  24. package/skills/curation/SKILL.md +1 -1
  25. package/skills/deep-init/SKILL.md +1 -1
  26. package/skills/deep-research/SKILL.md +1 -1
  27. package/skills/docs-setup/SKILL.md +1 -1
  28. package/skills/humanizer/SKILL.md +1 -1
  29. package/skills/interval-report/SKILL.md +1 -1
  30. package/skills/lineage/SKILL.md +146 -16
  31. package/skills/lineage/lineage.py +1091 -107
  32. package/skills/make-notion-guide/SKILL.md +1 -1
  33. package/skills/motion-graphic-make/SKILL.md +1 -1
  34. package/skills/motion-graphic-setup/SKILL.md +1 -1
  35. package/skills/nothing-design/SKILL.md +1 -1
  36. package/skills/obsidizer/SKILL.md +1 -1
  37. package/skills/omc-reference/SKILL.md +1 -1
  38. package/skills/payload-mon/SKILL.md +1 -1
  39. package/skills/pdf-vision-extract/SKILL.md +1 -1
  40. package/skills/play-qa/SKILL.md +1 -1
  41. package/skills/ralph-qa/SKILL.md +263 -112
  42. package/skills/ralph-qa/references/gemini-read-only.toml +9 -0
  43. package/skills/ralph-qa/references/gemini-seat.mjs +175 -0
  44. package/skills/ralph-qa/references/payload-scan.mjs +130 -0
  45. package/skills/ralph-qa/references/verifier-probe.mjs +872 -780
  46. package/skills/ready-compact/SKILL.md +1 -1
  47. package/skills/refresh-git-ignore/SKILL.md +1 -1
  48. package/skills/refresh-readme/SKILL.md +1 -1
  49. package/skills/rfp-author/SKILL.md +1 -1
  50. package/skills/setup-bypass-permissions/SKILL.md +123 -0
  51. package/skills/setup-bypass-permissions/agents/openai.yaml +4 -0
  52. package/skills/setup-bypass-permissions/scripts/bypass-permissions.mjs +381 -0
  53. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.ps1 +247 -0
  54. package/skills/setup-bypass-permissions/scripts/fallback/bypass-permissions.py +241 -0
  55. package/skills/{harness-factory → setup-harness-factory}/SKILL.md +3 -3
  56. package/skills/setup-insane-search/SKILL.md +1 -1
  57. package/skills/setup-java/SKILL.md +1 -1
  58. package/skills/setup-lsp/SKILL.md +1 -1
  59. package/skills/setup-mcp/SKILL.md +1 -1
  60. package/skills/setup-node/SKILL.md +1 -1
  61. package/skills/setup-omc/SKILL.md +1 -1
  62. package/skills/setup-omc-hud/SKILL.md +15 -1
  63. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +247 -0
  64. package/skills/setup-playwright/SKILL.md +1 -1
  65. package/skills/setup-pwsh/SKILL.md +1 -1
  66. package/skills/setup-python/SKILL.md +1 -1
  67. package/skills/setup-sandbox/SKILL.md +1 -1
  68. package/skills/setup-stitch/SKILL.md +1 -1
  69. package/skills/setup-tmux/SKILL.md +1 -1
  70. package/skills/smart-compact/SKILL.md +1 -1
  71. package/skills/summary-wiki/SKILL.md +1 -1
  72. package/skills/tone-compact/SKILL.md +1 -1
  73. package/skills/ultra-init/SKILL.md +1 -1
  74. package/skills/ultra-interview/SKILL.md +1 -1
  75. package/skills/ultra-ui-qa/SKILL.md +1 -1
  76. package/skills/update-banker/SKILL.md +1 -1
  77. package/skills/vertical-pptx/SKILL.md +1 -1
  78. package/skills/vertical-pptx-setup/SKILL.md +1 -1
  79. package/skills/visual-ralph/SKILL.md +1 -1
  80. package/skills/graceful_pause/SKILL.md +0 -95
@@ -18,6 +18,7 @@ import os
18
18
  import pathlib
19
19
  import re
20
20
  import sys
21
+ import tempfile
21
22
  from html.parser import HTMLParser
22
23
 
23
24
  if sys.version_info < (3, 7):
@@ -32,6 +33,14 @@ ECHO_ASK = 40 # echo-exchange: user question length ceiling (A-4)
32
33
  ECHO_REPLY = 5 # echo-exchange: assistant reply length ceiling (A-4)
33
34
  _BLOCKQUOTE_MAX_DEPTH = 32 # blockquote recursion cap (C-2)
34
35
  CACHE_BASE = pathlib.Path.home() / ".cache" / "lineage"
36
+ REVIEW_SCHEMA = "lineage-review/1" # --emit-review pack, read back by --apply-review
37
+ REVIEW_PART_SCHEMA = "lineage-review-part/1"
38
+ REVIEW_PART = 40 # turns per part file: one reviewer's share
39
+ PREVIEW_HEAD = 1500 # a long turn's preview: head + tail, where conclusions sit
40
+ PREVIEW_TAIL = 700
41
+ SUMMARY_MAX = 120 # a reviewer's summary is cut to this, after redaction
42
+ LLM_CACHE_VERSION = 1 # bump to drop every cached reviewer decision
43
+ REVIEWER_TIMEOUT = 60 # --reviewer-timeout default, seconds
35
44
 
36
45
  # ---------------------------------------------------------------- Noise / classify
37
46
  # NOTE: JSONL records carry RAW `<...>` tags. Every regex below uses raw `<`/`>`
@@ -155,7 +164,7 @@ def _mask(s: str) -> str:
155
164
  return (s[:4] + "****" + s[-4:]) if len(s) > 8 else "[REDACTED]"
156
165
 
157
166
 
158
- def redact(text: str, extra: str = None, mode: str = "full"):
167
+ def redact(text: str, extra: "str | None" = None, mode: str = "full"):
159
168
  """Multi-layer redaction. Returns (redacted_text, count_by_kind)."""
160
169
  counts = {}
161
170
  out = text
@@ -218,6 +227,39 @@ def redact(text: str, extra: str = None, mode: str = "full"):
218
227
  return out, counts
219
228
 
220
229
 
230
+ # Extra patterns for text a reviewer model reads (part files) and nothing else: the page
231
+ # and --rulebase keep the patterns above, so their output stays as it was.
232
+ REVIEW_SECRET_PATTERNS = [
233
+ ("AnthropicKey", re.compile(r"(?<![A-Za-z0-9])sk-ant-[A-Za-z0-9_-]{20,}")),
234
+ ("OpenAIKey", re.compile(r"(?<![A-Za-z0-9-])sk-(?:proj-|svcacct-|admin-)?[A-Za-z0-9_-]{20,}")),
235
+ ("GoogleKey", re.compile(r"(?<![A-Za-z0-9])AIza[0-9A-Za-z_-]{35}")),
236
+ # bounded repeats: a long run of `key` or `a.` would otherwise take quadratic time
237
+ ("HexKey", re.compile(r"(?i)(?:key|token|secret)[\w-]{0,100}\s*[:=]\s*['\"]?[0-9a-f]{32,}")),
238
+ ("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,}@")),
242
+ ("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,}")),
248
+ ]
249
+
250
+
251
+ def review_redact(text, extra=None):
252
+ """Text as a reviewer model reads it: the reviewer-only patterns first, while the keys
253
+ are whole (the entropy rule would otherwise cut a key and leave the rest), then fully
254
+ 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())
261
+
262
+
221
263
  # ---------------------------------------------------------------- Discovery
222
264
  _TITLE_RE = re.compile(r'"customTitle"\s*:\s*"([^"]+)"')
223
265
 
@@ -372,7 +414,8 @@ def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
372
414
  yield {"role": role, "text": text, "ts": ts, "uuid": uuid_,
373
415
  "tools": tools, "line_no": line_no,
374
416
  "parts": [text] if text else [], # R1: always seed parts
375
- "session": session_id, "session_name": session_name}
417
+ "session": session_id, "session_name": session_name,
418
+ "meta": bool(obj.get("isMeta"))} # a harness-injected body
376
419
 
377
420
 
378
421
  # ---------------------------------------------------------------- Classify (A-1..A-4)
@@ -443,10 +486,10 @@ def merge_assistant_runs(turns):
443
486
  return merged
444
487
 
445
488
 
446
- def drop_echo_exchanges(turns):
447
- """Drop no-op exchanges: a short user question + a tool-less token reply.
448
- Structural, not keyword-based (A-4). Same-session only. Dropping a reply
449
- also drops its question (else the question is orphaned).
489
+ def echo_indices(turns):
490
+ """Indices of no-op exchanges: a short user question + a tool-less token reply.
491
+ Structural, not keyword-based (A-4). Same-session only. A reply's question is
492
+ included with it (else the question is orphaned).
450
493
  """
451
494
  drop = set()
452
495
  for i, t in enumerate(turns):
@@ -457,6 +500,12 @@ def drop_echo_exchanges(turns):
457
500
  and len(n["text"].strip()) <= ECHO_REPLY
458
501
  and n.get("session") == t.get("session")):
459
502
  drop |= {i, i + 1}
503
+ return drop
504
+
505
+
506
+ def drop_echo_exchanges(turns):
507
+ """Drop the no-op exchanges echo_indices finds."""
508
+ drop = echo_indices(turns)
460
509
  return [t for i, t in enumerate(turns) if i not in drop]
461
510
 
462
511
 
@@ -567,23 +616,35 @@ def read_or_summarize(turn: dict, session_id: str, rebuild: bool = False,
567
616
  digest = hashlib.sha256(
568
617
  (turn["text"] + "\x00s" + str(SUMMARIZER_VERSION)).encode()
569
618
  ).hexdigest()[:8]
570
- p = cache_dir(turn.get("session") or session_id) / f"{turn['uuid']}-{digest}.txt"
571
- if p.exists() and not rebuild:
619
+ try: # ids come from the transcript: keep them in the cache
620
+ p = (cache_dir(_safe_name(turn.get("session") or session_id))
621
+ / f"{_safe_name(turn['uuid'])}-{digest}.txt")
622
+ except OSError as e: # no writable cache: summarize anyway
623
+ _warn_once("_no_cache", f"cache unavailable ({e}); summaries are not cached")
624
+ p = None
625
+ if p is not None and p.exists() and not rebuild:
572
626
  try:
573
627
  return p.read_text(encoding="utf-8").strip(), True
574
628
  except (OSError, UnicodeDecodeError):
575
629
  pass
576
630
  raw_summary = summarize_turn(turn)
577
631
  redacted, _ = redact(raw_summary, extra=redact_extra, mode=redact_mode)
578
- try:
579
- fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
580
- with os.fdopen(fd, "w", encoding="utf-8") as f:
581
- f.write(redacted)
582
- except OSError:
583
- pass
632
+ if p is not None:
633
+ try:
634
+ fd = os.open(str(p), os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600)
635
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
636
+ f.write(redacted)
637
+ except OSError:
638
+ pass
584
639
  return redacted, False
585
640
 
586
641
 
642
+ def _warn_once(key, message):
643
+ if not getattr(_warn_once, key, False):
644
+ print(f"[lineage] WARN: {message}", file=sys.stderr)
645
+ setattr(_warn_once, key, True)
646
+
647
+
587
648
  # ---------------------------------------------------------------- Markdown (C-1..C-3)
588
649
  # Carve code spans and https-only links to opaque sentinels BEFORE emphasis, so
589
650
  # no emphasis rule ever runs over emitted markup (C-2). Sentinel chars exclude
@@ -920,12 +981,32 @@ HTML_TEMPLATE = r"""<!DOCTYPE html>
920
981
 
921
982
 
922
983
  # ---------------------------------------------------------------- Render rows
984
+ def user_fold_summary(redacted_text):
985
+ """The summary line of a user turn long enough to fold (USER_FOLD)."""
986
+ return _cut(redacted_text.replace("\n", " "), 90)
987
+
988
+
989
+ def _page_summary(t, summaries, session_id, rebuild, redact_extra, redact_mode):
990
+ """(summary, cache_hit) of a bot turn as the page shows it: the reviewer's when
991
+ `summaries` has one (cache_hit None: no rule summary was looked up), else the
992
+ rules' (cached)."""
993
+ if summaries is not None and t.get("uuid") in summaries:
994
+ return summaries[t["uuid"]], None
995
+ return read_or_summarize(t, session_id, rebuild=rebuild,
996
+ redact_extra=redact_extra, redact_mode=redact_mode)
997
+
998
+
999
+ def _reviewed_fold(t, summaries):
1000
+ """A reviewer's summary for a folded user turn, or None."""
1001
+ return summaries.get(t.get("uuid")) if summaries is not None else None
1002
+
1003
+
923
1004
  def _fmt_time(ts):
924
1005
  return ts[11:16] if ts and len(ts) >= 16 else ""
925
1006
 
926
1007
 
927
1008
  def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
928
- open_details=False, markdown=True, all_sessions=False):
1009
+ open_details=False, markdown=True, all_sessions=False, summaries=None):
929
1010
  rows = []
930
1011
  last_date = None
931
1012
  last_session = None
@@ -968,7 +1049,8 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
968
1049
  if role == "user":
969
1050
  red = _red(t["text"])
970
1051
  if len(red) > USER_FOLD:
971
- summary = html.escape(_cut(red.replace("\n", " "), 90))
1052
+ reviewed = _reviewed_fold(t, summaries)
1053
+ summary = html.escape(_red(reviewed) if reviewed else user_fold_summary(red))
972
1054
  body = render_body(red, markdown=markdown)
973
1055
  rows.append(
974
1056
  '<div class="row me"><span class="time">%s</span>'
@@ -984,12 +1066,11 @@ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
984
1066
  continue
985
1067
 
986
1068
  # assistant or agent bubble
987
- cache_total += 1
988
- summary, hit = read_or_summarize(
989
- t, session_id, rebuild=rebuild,
990
- redact_extra=redact_extra, redact_mode=redact_mode)
991
- if hit:
992
- cache_hits += 1
1069
+ summary, hit = _page_summary(t, summaries, session_id, rebuild,
1070
+ redact_extra, redact_mode)
1071
+ if hit is not None:
1072
+ cache_total += 1
1073
+ cache_hits += bool(hit)
993
1074
  esc_sum = html.escape(_red(summary))
994
1075
  detail_body = render_body(_red(t["text"]), markdown=markdown)
995
1076
 
@@ -1105,6 +1186,649 @@ def self_verify(html_text: str):
1105
1186
 
1106
1187
 
1107
1188
  # ---------------------------------------------------------------- CLI
1189
+ # ---------------------------------------------------------------- Review pack
1190
+ # The default /lineage flow: --emit-review packs the turns for the session's model,
1191
+ # its reviewers write keep/summary decisions, --apply-review renders from them. The
1192
+ # pack holds redacted text only. Noise the rules are certain of (wrapper blocks, hook
1193
+ # feedback, injected bodies) is gone before the pack; the rules' judgement calls (echo
1194
+ # exchanges, tool-only turns) stay in it, marked, for the reviewers to confirm or undo.
1195
+ def _is_keep(v):
1196
+ return v is None or isinstance(v, bool)
1197
+
1198
+
1199
+ def _is_summary(v):
1200
+ return v is None or isinstance(v, str)
1201
+
1202
+
1203
+ def _first(*values):
1204
+ for v in values:
1205
+ if v is not None:
1206
+ return v
1207
+ return None
1208
+
1209
+
1210
+ def _bad(message):
1211
+ print(f"[lineage] ERROR: {message}", file=sys.stderr)
1212
+ return None
1213
+
1214
+
1215
+ def _write_private(path, text):
1216
+ """Write `text` to `path` as a fresh 0600 file, replaced whole. The temporary file
1217
+ comes from mkstemp (created exclusively, 0600), so a link planted at a guessable name
1218
+ is never followed, and it is removed when the write fails."""
1219
+ path = pathlib.Path(path)
1220
+ path.parent.mkdir(parents=True, exist_ok=True)
1221
+ fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
1222
+ try:
1223
+ with os.fdopen(fd, "w", encoding="utf-8") as f:
1224
+ f.write(text)
1225
+ os.replace(tmp, str(path))
1226
+ except BaseException:
1227
+ try:
1228
+ os.unlink(tmp)
1229
+ except OSError:
1230
+ pass
1231
+ raise
1232
+
1233
+
1234
+ def _safe_name(s):
1235
+ """A file-name-safe form of an id read from a transcript or a pack."""
1236
+ s = re.sub(r"[^A-Za-z0-9_.-]", "_", str(s or "default"))[:80]
1237
+ return s if s.strip(".") else "_"
1238
+
1239
+
1240
+ def _decision_context(why, keeps):
1241
+ """What a cached decision rests on beside the text: the rules' call on the turn (a
1242
+ `keep: null` defers to it) and the keep flags the reviewer was told to honour."""
1243
+ return "|".join([why or "", str(keeps.get("keep_trivia") is True),
1244
+ str(keeps.get("keep_tool_only") is True)])
1245
+
1246
+
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"
1251
+
1252
+
1253
+ def read_llm_cache(turn_id, session, redacted_text, context):
1254
+ """A reviewer's earlier {keep, summary} for this exact (redacted) text under the same
1255
+ rule call and keep flags (`context`), or None when no reviewer saw it so. Both values
1256
+ null: the reviewer left the turn to the rules."""
1257
+ try:
1258
+ path = _llm_cache_path(turn_id, session, redacted_text, context)
1259
+ except OSError as e: # no cache folder (a read-only HOME, say)
1260
+ _warn_once("_no_llm_cache", f"reviewer decision cache unavailable ({e}); "
1261
+ "every turn is reviewed")
1262
+ return None
1263
+ try:
1264
+ got = json.loads(path.read_text(encoding="utf-8"))
1265
+ except (OSError, ValueError, UnicodeDecodeError):
1266
+ return None
1267
+ if not isinstance(got, dict):
1268
+ return None
1269
+ keep, summary = got.get("keep"), got.get("summary")
1270
+ if not (_is_keep(keep) and _is_summary(summary)):
1271
+ return None
1272
+ return {"keep": keep, "summary": summary}
1273
+
1274
+
1275
+ def write_llm_cache(turn_id, session, redacted_text, context, keep, summary):
1276
+ try:
1277
+ _write_private(_llm_cache_path(turn_id, session, redacted_text, context),
1278
+ json.dumps({"keep": keep, "summary": summary}, ensure_ascii=False))
1279
+ except OSError as e:
1280
+ _warn_once("_no_llm_cache_write", f"reviewer decisions not cached ({e}); the next "
1281
+ "run reviews this run's turns again (decisions cached before still apply)")
1282
+
1283
+
1284
+ def heuristic_drops(turns, args):
1285
+ """{index: why} for the rules' judgement calls: echo exchanges, tool-only turns."""
1286
+ drops = {}
1287
+ if args.drop_trivia:
1288
+ drops.update((i, "echo") for i in echo_indices(turns))
1289
+ if args.hide_tool_only:
1290
+ for i, t in enumerate(turns):
1291
+ if i not in drops and t["role"] == "assistant" and not t["text"].strip():
1292
+ drops[i] = "tool-only"
1293
+ return drops
1294
+
1295
+
1296
+ def review_window(turns, drops, args):
1297
+ """Indices for the pack: the turns the range flags pick among those the rules keep
1298
+ (what a --rulebase run shows), the rule-dropped turns between them, and those past
1299
+ either end the pick reaches. None on an invalid --turns."""
1300
+ cand = [i for i, t in enumerate(turns) if _in_time(t, args)]
1301
+ kept = [i for i in cand if i not in drops]
1302
+ pool = kept or cand
1303
+ sl = _count_slice(len(pool), args)
1304
+ if sl is None:
1305
+ return None
1306
+ picked = pool[sl]
1307
+ if not kept or not picked:
1308
+ return picked
1309
+ lo = cand[0] if picked[0] == kept[0] else picked[0]
1310
+ hi = cand[-1] if picked[-1] == kept[-1] else picked[-1]
1311
+ chosen = set(picked)
1312
+ return [i for i in cand if lo <= i <= hi and (i in chosen or i in drops)]
1313
+
1314
+
1315
+ def _preview(text):
1316
+ """The reviewer's view of a turn: whole when short, else head + tail."""
1317
+ if len(text) <= PREVIEW_HEAD + PREVIEW_TAIL + 200:
1318
+ return text, False
1319
+ cut = len(text) - PREVIEW_HEAD - PREVIEW_TAIL
1320
+ return (text[:PREVIEW_HEAD].rstrip() + f"\n\n[중간 {cut}자 생략]\n\n"
1321
+ + text[-PREVIEW_TAIL:].lstrip()), True
1322
+
1323
+
1324
+ def _public_counts(counts):
1325
+ """Redaction counts by kind with the --redact-extra keywords folded into `custom`:
1326
+ the keywords themselves stay out of the files lineage writes."""
1327
+ out = {}
1328
+ for k, v in counts.items():
1329
+ key = "custom" if k.startswith("custom:") else k
1330
+ out[key] = out.get(key, 0) + v
1331
+ return out
1332
+
1333
+
1334
+ def _rule_summary(t, red, args):
1335
+ """The rules' summary for the page, made afresh and redacted with THIS run's settings.
1336
+ The summary cache is keyed on the text alone, so a cached one may predate a
1337
+ --redact-extra keyword or carry another --redact-mode."""
1338
+ if t["role"] in ("assistant", "agent"):
1339
+ return redact(summarize_turn(t), extra=args.redact_extra, mode=args.redact_mode)[0]
1340
+ if t["role"] == "user" and len(red) > USER_FOLD:
1341
+ return user_fold_summary(red)
1342
+ return None
1343
+
1344
+
1345
+ def _redacted_turn(t, extra):
1346
+ """`t` with its text and parts fully redacted, so a summary cut from it cannot cut a
1347
+ secret in two and keep the half no pattern recognises."""
1348
+ def clean(s):
1349
+ return review_redact(s, extra)[0]
1350
+ return dict(t, text=clean(t["text"]), parts=[clean(p) for p in t.get("parts") or []])
1351
+
1352
+
1353
+ def _review_view(t, red, args):
1354
+ """What a reviewer reads beside the preview: the rules' summary and the agent's
1355
+ name, fully redacted (the page's copies may be masked)."""
1356
+ if t["role"] in ("assistant", "agent"):
1357
+ summary = review_redact(summarize_turn(_redacted_turn(t, args.redact_extra)),
1358
+ args.redact_extra)[0]
1359
+ elif t["role"] == "user" and len(red) > USER_FOLD:
1360
+ summary = user_fold_summary(review_redact(t["text"], args.redact_extra)[0])
1361
+ else:
1362
+ summary = None
1363
+ who = review_redact(t["agent_from"], args.redact_extra)[0] if t.get("agent_from") else None
1364
+ return {"summary": summary, "agent_from": who}
1365
+
1366
+
1367
+ 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)
1371
+ for k, v in found.items():
1372
+ counts[k] = counts.get(k, 0) + v
1373
+ preview, clipped = _preview(review_redact(t["text"], args.redact_extra)[0])
1374
+ session = t.get("session") or session_id
1375
+ context = _decision_context(why, _keeps(args))
1376
+ cached = None if args.rebuild_summaries else read_llm_cache(tid, session, red, context)
1377
+ view = _review_view(t, red, args)
1378
+ turn = {"id": tid, "n": n, "role": t["role"], "ts": t.get("ts") or "",
1379
+ "tools": dict(t.get("tools") or {}), "text": red,
1380
+ "redactions": _public_counts(found), "preview": preview, "clipped": clipped,
1381
+ "rule": {"keep": why is None, "why": why,
1382
+ "summary": _rule_summary(t, red, args),
1383
+ "review_summary": view["summary"]},
1384
+ "llm": dict(cached, cached=True) if cached else {"keep": None, "summary": None}}
1385
+ if t.get("meta"):
1386
+ turn["meta"] = True
1387
+ for k in ("session", "session_name", "agent_from"):
1388
+ if t.get(k):
1389
+ turn[k] = t[k]
1390
+ return turn, view
1391
+
1392
+
1393
+ def _final_ids(turns, window):
1394
+ """Pack ids, fixed before anything reads the decision cache: the turn uuid, with #N
1395
+ on a repeat (stdin uuids are content hashes, so equal texts share one). Counted over
1396
+ every turn, not the window, so a turn keeps its id whichever range is picked."""
1397
+ seen, ids = {}, {}
1398
+ for i, t in enumerate(turns):
1399
+ u = t["uuid"]
1400
+ seen[u] = seen.get(u, 0) + 1
1401
+ ids[i] = u if seen[u] == 1 else f"{u}#{seen[u]}"
1402
+ return [ids[i] for i in window]
1403
+
1404
+
1405
+ def _split_even(items, size):
1406
+ """Parts of at most `size` items, as even as possible (41: 21 + 20, not 40 + 1)."""
1407
+ if not items:
1408
+ return []
1409
+ step = -(-len(items) // -(-len(items) // size))
1410
+ return [items[k:k + step] for k in range(0, len(items), step)]
1411
+
1412
+
1413
+ def _decisions_name(part_path):
1414
+ return part_path.with_name(part_path.stem + ".decisions.json").name
1415
+
1416
+
1417
+ def _clear_parts(pack_path):
1418
+ """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."""
1421
+ prefix = pack_path.stem + ".part-"
1422
+ try:
1423
+ olds = [p for p in pack_path.parent.iterdir()
1424
+ if p.name.startswith(prefix) and p.name.endswith(".json")]
1425
+ except OSError:
1426
+ return []
1427
+ failed = []
1428
+ for p in olds:
1429
+ try:
1430
+ p.unlink()
1431
+ except OSError:
1432
+ failed.append(p)
1433
+ return failed
1434
+
1435
+
1436
+ def _hide_keywords(s, extra):
1437
+ """`s` with each --redact-extra keyword replaced (for short names such as tool names)."""
1438
+ for kw in [k.strip() for k in (extra or "").split(",") if k.strip()]:
1439
+ s = re.sub(re.escape(kw), "[REDACTED]", s, flags=re.IGNORECASE)
1440
+ return s
1441
+
1442
+
1443
+ def _keeps(args):
1444
+ """The keep flags the reviewers must honour: with them, the rules keep those turns."""
1445
+ return {"keep_trivia": not args.drop_trivia, "keep_tool_only": not args.hide_tool_only}
1446
+
1447
+
1448
+ def _sheet(k, of, part_path, group, args):
1449
+ """Part file `k`: the turns one reviewer reads, without the page-only fields."""
1450
+ turns = []
1451
+ for x, view in group:
1452
+ y = {key: v for key, v in x.items()
1453
+ if key not in ("text", "session", "session_name", "agent_from", "redactions")}
1454
+ y["rule"] = {k: v for k, v in x["rule"].items() if k != "review_summary"}
1455
+ y["rule"]["summary"] = view["summary"]
1456
+ y["tools"] = {_hide_keywords(name, args.redact_extra): n for name, n in x["tools"].items()}
1457
+ if x["llm"].get("summary"): # cached: redacted for the page, maybe masked
1458
+ s = _MASKED.sub("[REDACTED]", x["llm"]["summary"])
1459
+ y["llm"] = dict(x["llm"], summary=review_redact(s, args.redact_extra)[0])
1460
+ if view["agent_from"]:
1461
+ y["agent_from"] = view["agent_from"]
1462
+ turns.append(y)
1463
+ return {"schema": REVIEW_PART_SCHEMA, "part": k, "of": of, **_keeps(args),
1464
+ "decisions": _decisions_name(part_path), "turns": turns}
1465
+
1466
+
1467
+ def _pack_header(session_id, output, args, counts):
1468
+ """The pack's settings: what --apply-review renders with and the quality gate it runs."""
1469
+ return {"schema": REVIEW_SCHEMA, "source": session_id, "output": str(output),
1470
+ "title": args.title, "markdown": args.markdown, "open": args.open_details,
1471
+ "redact_mode": args.redact_mode, "redact_extra": bool(args.redact_extra),
1472
+ "all_sessions": bool(args.all_sessions), "redactions": _public_counts(counts),
1473
+ **_keeps(args),
1474
+ "gate": {"skip_reviewer": bool(args.skip_reviewer),
1475
+ "reviewer_output": args.reviewer_output,
1476
+ "reviewer_timeout": args.reviewer_timeout}}
1477
+
1478
+
1479
+ def _report_emit(pack_path, items, paths, groups):
1480
+ dropped = sum(1 for x in items if not x["rule"]["keep"])
1481
+ cached = sum(1 for x in items if x["llm"].get("cached"))
1482
+ print(f"[lineage] review pack: {pack_path} (turns={len(items)} "
1483
+ f"rule-dropped={dropped} cached={cached})", file=sys.stderr)
1484
+ for k, (p, g) in enumerate(zip(paths, groups), 1):
1485
+ todo = sum(1 for x, _ in g if not x["llm"].get("cached"))
1486
+ print(f"[lineage] part {k}/{len(groups)}: {p} (to review: {todo}) "
1487
+ f"-> {p.with_name(_decisions_name(p))}", file=sys.stderr)
1488
+ print(f"[lineage] next: write each part's decisions, then "
1489
+ f"--apply-review {pack_path}", file=sys.stderr)
1490
+
1491
+
1492
+ 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."""
1494
+ pack_path = pathlib.Path(args.emit_review)
1495
+ counts, pairs = {}, []
1496
+ for n, (i, tid) in enumerate(zip(window, _final_ids(turns, window)), 1):
1497
+ pairs.append(_pack_turn(n, turns[i], tid, drops.get(i), session_id, args, counts))
1498
+ groups = _split_even(pairs, REVIEW_PART)
1499
+ paths = [pack_path.with_name(f"{pack_path.stem}.part-{k}.json")
1500
+ for k in range(1, len(groups) + 1)]
1501
+ pack = _pack_header(session_id, _with_timestamp_suffix(output), args, counts)
1502
+ pack["parts"] = [{"part": k, "file": p.name, "decisions": _decisions_name(p),
1503
+ "ids": [x["id"] for x, _ in g]}
1504
+ for k, (p, g) in enumerate(zip(paths, groups), 1)]
1505
+ pack["turns"] = [x for x, _ in pairs]
1506
+ stuck = _clear_parts(pack_path)
1507
+ if stuck:
1508
+ _bad(f"cannot remove an earlier run's files: {', '.join(map(str, stuck))}")
1509
+ return 2
1510
+ try:
1511
+ _write_private(pack_path, json.dumps(pack, ensure_ascii=False, indent=1))
1512
+ for k, (p, g) in enumerate(zip(paths, groups), 1):
1513
+ _write_private(p, json.dumps(_sheet(k, len(groups), p, g, args),
1514
+ ensure_ascii=False, indent=1))
1515
+ except OSError as e:
1516
+ _bad(f"cannot write the review pack: {e}")
1517
+ return 2
1518
+ _report_emit(pack_path, pack["turns"], paths, groups)
1519
+ return 0
1520
+
1521
+
1522
+ def _is_pack_turn(x):
1523
+ return (isinstance(x, dict) and isinstance(x.get("id"), str)
1524
+ and x.get("role") in ("user", "assistant", "agent", "mark")
1525
+ and isinstance(x.get("text"), str)
1526
+ and isinstance(x.get("rule"), dict) and isinstance(x["rule"].get("keep"), bool)
1527
+ and _is_summary(x["rule"].get("summary"))
1528
+ and isinstance(x.get("llm"), dict) and _is_keep(x["llm"].get("keep"))
1529
+ and _is_summary(x["llm"].get("summary")))
1530
+
1531
+
1532
+ def _load_pack(path):
1533
+ try:
1534
+ pack = json.loads(path.read_text(encoding="utf-8"))
1535
+ except (OSError, ValueError, UnicodeDecodeError) as e:
1536
+ return _bad(f"cannot read review pack {path}: {e}")
1537
+ if not isinstance(pack, dict) or pack.get("schema") != REVIEW_SCHEMA:
1538
+ return _bad(f"{path} is not a {REVIEW_SCHEMA} pack")
1539
+ if not isinstance(pack.get("turns"), list) or not all(map(_is_pack_turn, pack["turns"])):
1540
+ return _bad(f"{path}: malformed turns")
1541
+ return pack
1542
+
1543
+
1544
+ def _decision_files(pack_path, pack, explicit):
1545
+ """The parts' decisions files that exist beside the pack, then each --decisions.
1546
+ Those beside the pack are made 0600: the session wrote them with its own umask."""
1547
+ beside = [pack_path.with_name(p["decisions"]) for p in pack.get("parts") or []
1548
+ if isinstance(p, dict) and isinstance(p.get("decisions"), str)]
1549
+ files, seen = [], set()
1550
+ for f in [f for f in beside if f.exists()] + [pathlib.Path(f) for f in explicit]:
1551
+ if os.path.realpath(str(f)) not in seen:
1552
+ seen.add(os.path.realpath(str(f)))
1553
+ files.append(f)
1554
+ for f in beside:
1555
+ try:
1556
+ os.chmod(str(f), 0o600)
1557
+ except OSError:
1558
+ pass
1559
+ return files
1560
+
1561
+
1562
+ # Keys of a part file's turns: a list of objects carrying one is the reviewer quoting its
1563
+ # input, not its answer.
1564
+ _PART_TURN_KEYS = frozenset({"n", "role", "ts", "tools", "text", "preview", "clipped",
1565
+ "rule", "llm", "meta", "agent_from"})
1566
+
1567
+
1568
+ def _is_answer(d):
1569
+ """A decision, extra keys and all; a part turn quoted back (its keep and summary sit
1570
+ under rule and llm, not on top) is not one."""
1571
+ return (isinstance(d, dict) and "id" in d
1572
+ and ("keep" in d or "summary" in d or not _PART_TURN_KEYS & d.keys()))
1573
+
1574
+
1575
+ def _decisions_text(text):
1576
+ """The JSON list in a reviewer's answer: the whole text, or, when the answer came
1577
+ wrapped (a code fence, a sentence with brackets of its own, a list of files or a quote
1578
+ of the part's turns around it), the longest list of {id, ...} objects in it that is
1579
+ not such a quote (the last of equal ones: an example quoted before the answer loses),
1580
+ else an empty list it holds."""
1581
+ try:
1582
+ return json.loads(text)
1583
+ except (ValueError, RecursionError):
1584
+ pass
1585
+ lists, empty = [], False
1586
+ 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):
1589
+ try:
1590
+ got, _ = decoder.raw_decode(text, m.start())
1591
+ except (ValueError, RecursionError):
1592
+ continue
1593
+ if got == []:
1594
+ empty = True
1595
+ elif isinstance(got, list) and all(_is_answer(d) for d in got):
1596
+ lists.append(got)
1597
+ if lists:
1598
+ return max(reversed(lists), key=len)
1599
+ if empty:
1600
+ return []
1601
+ raise ValueError("no JSON list of {id, keep, summary} objects in it")
1602
+
1603
+
1604
+ def _decision_problem(d):
1605
+ """Why item `d` of a decisions file is not a decision, or ""."""
1606
+ if not (isinstance(d, dict) and isinstance(d.get("id"), str)
1607
+ and _is_keep(d.get("keep")) and _is_summary(d.get("summary"))):
1608
+ return 'want {"id": str, "keep": true|false|null, "summary": str|null}'
1609
+ if not _is_answer(d):
1610
+ return "a turn of the part file, not a decision"
1611
+ return ""
1612
+
1613
+
1614
+ def _load_decisions(files):
1615
+ """{id: {keep, summary}} from the files in order, a later value winning; None when a
1616
+ file is not a JSON list of {id, keep, summary}. A file whose every decision is null
1617
+ is read, with a WARN: the rules then decide all its turns."""
1618
+ merged = {}
1619
+ for f in files:
1620
+ try:
1621
+ items = _decisions_text(f.read_text(encoding="utf-8"))
1622
+ except (OSError, ValueError, UnicodeDecodeError, RecursionError) as e:
1623
+ return _bad(f"cannot read decisions {f}: {e}")
1624
+ if not isinstance(items, list):
1625
+ return _bad(f"{f}: decisions must be a JSON list of {{id, keep, summary}}")
1626
+ for k, d in enumerate(items):
1627
+ why = _decision_problem(d)
1628
+ if why:
1629
+ return _bad(f"{f}[{k}]: {why}")
1630
+ cur = merged.setdefault(d["id"], {})
1631
+ for key in ("keep", "summary"):
1632
+ if d.get(key) is not None:
1633
+ cur[key] = d[key]
1634
+ if items and all(d.get("keep") is None and d.get("summary") is None for d in items):
1635
+ print(f"[lineage] WARN: {f}: all {len(items)} decisions are null; the rules "
1636
+ "decide those turns", file=sys.stderr)
1637
+ return merged
1638
+
1639
+
1640
+ def _clean_summary(s, extra, mode):
1641
+ """A reviewer's summary as the page shows it (one line, redacted, then cut), and how
1642
+ many secret-like strings that redaction hid. The reviewer patterns go first and hide
1643
+ a key whole, whatever --redact-mode says: the summary is cached and goes back to the
1644
+ next reviewer and the gate's critic, so no part of a key may stay in it."""
1645
+ pre, n = review_redact(" ".join(s.split()), extra)
1646
+ red, found = redact(pre, extra=extra, mode=mode)
1647
+ return (_cut(red, SUMMARY_MAX) or None), n + sum(found.values())
1648
+
1649
+
1650
+ def _warn_coverage(pack, decided):
1651
+ """Name the parts that left turns undecided: the rules decide those, and since only
1652
+ decided turns are cached, they are reviewed again next time."""
1653
+ for p in pack.get("parts") or []:
1654
+ ids = p.get("ids") if isinstance(p, dict) else None
1655
+ if not isinstance(ids, list):
1656
+ continue
1657
+ todo = [i for i in ids if i not in decided]
1658
+ if todo:
1659
+ more = ", ..." if len(todo) > 3 else ""
1660
+ print(f"[lineage] WARN: part {p.get('part')}: {len(todo)}/{len(ids)} turns "
1661
+ f"undecided ({', '.join(map(str, todo[:3]))}{more}); the rules decide them",
1662
+ file=sys.stderr)
1663
+
1664
+
1665
+ def _reviewer_summary(x, d, args, stats):
1666
+ """The reviewer's summary for pack turn `x` (this run's decision `d`, else the cached
1667
+ one), cleaned; secret-like strings in it are counted and the turn is named."""
1668
+ raw = _first(d.get("summary"), x["llm"].get("summary"))
1669
+ if not raw:
1670
+ return None
1671
+ s, n = _clean_summary(raw, args.redact_extra, args.redact_mode)
1672
+ if n:
1673
+ stats["hidden"] += n
1674
+ print(f"[lineage] WARN: the reviewer summary for {x['id']} held {n} "
1675
+ "secret-like string(s); redacted", file=sys.stderr)
1676
+ return s
1677
+
1678
+
1679
+ def _show(x, llm_summary, turns, summaries, stats, prior):
1680
+ """Put kept pack turn `x` on the page with the summary it shows; `prior` adds up what
1681
+ --emit-review redacted."""
1682
+ shows = x["role"] in ("assistant", "agent") or (
1683
+ x["role"] == "user" and len(x["text"]) > USER_FOLD)
1684
+ if llm_summary and not shows:
1685
+ stats["ignored"] += 1
1686
+ summary = (llm_summary if shows else None) or x["rule"].get("summary")
1687
+ if summary:
1688
+ summaries[x["id"]] = summary
1689
+ stats["reviewer" if llm_summary and shows else "rule"] += bool(summary)
1690
+ for k, v in (x.get("redactions") or {}).items():
1691
+ if isinstance(v, int):
1692
+ prior[k] = prior.get(k, 0) + v
1693
+ # what the gate's critic reads: the reviewer's summary, else the rules' cut from the
1694
+ # fully redacted text (the page's is cut first and may keep half a secret)
1695
+ sample = (llm_summary if shows else None) or x["rule"].get("review_summary")
1696
+ turns.append({"uuid": x["id"], "role": x["role"], "text": x["text"],
1697
+ "ts": x.get("ts") or None, "tools": dict(x.get("tools") or {}),
1698
+ "parts": [], "session": x.get("session"),
1699
+ "session_name": x.get("session_name"),
1700
+ "agent_from": x.get("agent_from"), "preview": x.get("preview"),
1701
+ "sample_summary": sample})
1702
+
1703
+
1704
+ def _kept_by_flag(pack, x):
1705
+ """True when a keep flag given at --emit-review keeps pack turn `x` whatever the
1706
+ reviewer says: --keep-trivia keeps every turn the rules keep, --keep-tool-only the
1707
+ tool-only ones (the rules keep those only with that flag)."""
1708
+ if not x["rule"]["keep"]:
1709
+ return False
1710
+ if pack.get("keep_trivia") is True:
1711
+ return True
1712
+ tool_only = x["role"] == "assistant" and not x["text"].strip()
1713
+ return pack.get("keep_tool_only") is True and tool_only
1714
+
1715
+
1716
+ 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 = [], {}, [], {}
1721
+ stats = {"restored": 0, "dropped": 0, "reviewer": 0, "rule": 0, "ignored": 0,
1722
+ "hidden": 0, "flag-kept": 0}
1723
+ for x in pack["turns"]:
1724
+ d = merged.get(x["id"], {})
1725
+ llm_keep = _first(d.get("keep"), x["llm"].get("keep"))
1726
+ held = llm_keep is False and _kept_by_flag(pack, x)
1727
+ stats["flag-kept"] += held
1728
+ keep = bool(_first(None if held else llm_keep, x["rule"]["keep"]))
1729
+ llm_summary = _reviewer_summary(x, d, args, stats)
1730
+ 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))
1733
+ stats["restored"] += keep and not x["rule"]["keep"]
1734
+ stats["dropped"] += x["rule"]["keep"] and not keep
1735
+ if keep:
1736
+ _show(x, llm_summary, turns, summaries, stats, prior)
1737
+ return turns, summaries, writes, stats, prior
1738
+
1739
+
1740
+ def _gate_from_pack(args, pack):
1741
+ """The quality-gate flags given at --emit-review, unless this run gives its own."""
1742
+ gate = pack.get("gate") if isinstance(pack.get("gate"), dict) else {}
1743
+ args.skip_reviewer = bool(args.skip_reviewer or gate.get("skip_reviewer") is True)
1744
+ if args.reviewer_output is None and isinstance(gate.get("reviewer_output"), str):
1745
+ args.reviewer_output = gate["reviewer_output"]
1746
+ if args.reviewer_timeout is None and isinstance(gate.get("reviewer_timeout"), int):
1747
+ args.reviewer_timeout = gate["reviewer_timeout"]
1748
+
1749
+
1750
+ def _check_pack(pack, merged, args):
1751
+ """Warnings before the render, and the settings the pack carries from --emit-review."""
1752
+ ids = {x["id"] for x in pack["turns"]}
1753
+ unknown = [i for i in merged if i not in ids]
1754
+ if unknown:
1755
+ print(f"[lineage] WARN: decisions name {len(unknown)} turn(s) not in the pack, "
1756
+ f"ignored: {', '.join(unknown[:5])}", file=sys.stderr)
1757
+ _warn_coverage(pack, set(merged) | {x["id"] for x in pack["turns"]
1758
+ if x["llm"].get("cached")})
1759
+ if pack.get("redact_mode") in ("full", "mask"):
1760
+ args.redact_mode = pack["redact_mode"]
1761
+ if pack.get("redact_extra") is True and not args.redact_extra:
1762
+ print("[lineage] WARN: --emit-review had --redact-extra; give the same keywords "
1763
+ "here too, or reviewer summaries miss that redaction", file=sys.stderr)
1764
+ _gate_from_pack(args, pack)
1765
+
1766
+
1767
+ def _remove_review_files(pack_path, pack):
1768
+ """After a good render the pack, its parts and the decisions beside it go: they hold
1769
+ the redacted session."""
1770
+ samples = f"{pack_path.stem}.reviewer-input.json" # a failed gate's, beside the pack
1771
+ names = [pack_path.name, samples]
1772
+ for p in pack.get("parts") or []:
1773
+ if isinstance(p, dict):
1774
+ names += [p.get(k) for k in ("file", "decisions") if isinstance(p.get(k), str)]
1775
+ prefix = pack_path.stem + ".part-"
1776
+ left = []
1777
+ for name in names:
1778
+ if name not in (pack_path.name, samples) and not name.startswith(prefix):
1779
+ continue # only what --emit-review named
1780
+ try:
1781
+ pack_path.with_name(name).unlink()
1782
+ except FileNotFoundError:
1783
+ pass
1784
+ except (OSError, ValueError):
1785
+ left.append(name)
1786
+ if left:
1787
+ print(f"[lineage] WARN: could not remove review files: {', '.join(left)}",
1788
+ file=sys.stderr)
1789
+
1790
+
1791
+ def apply_review(args):
1792
+ """Render from a review pack and its decisions; cache the reviewers' values and, after
1793
+ a good render, remove the review files."""
1794
+ pack_path = pathlib.Path(args.apply_review)
1795
+ pack = _load_pack(pack_path)
1796
+ if pack is None:
1797
+ return 2
1798
+ merged = _load_decisions(_decision_files(pack_path, pack, args.decisions))
1799
+ if merged is None:
1800
+ return 2
1801
+ _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"
1805
+ if stats["hidden"]:
1806
+ prior["reviewer-summary"] = stats["hidden"]
1807
+ try:
1808
+ rc = render_and_write(turns, pack.get("source") or "session", args,
1809
+ all_sessions=bool(pack.get("all_sessions")),
1810
+ markdown=pack.get("markdown") is not False,
1811
+ open_details=bool(pack.get("open")),
1812
+ title=str(pack.get("title") or "Session Lineage"),
1813
+ summaries=summaries, prior_redactions=prior)
1814
+ except SystemExit as e: # the quality gate failed
1815
+ rc = int(e.code) if isinstance(e.code, int) else 2
1816
+ if rc == 0:
1817
+ for w in writes:
1818
+ write_llm_cache(*w)
1819
+ _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)
1829
+ return rc
1830
+
1831
+
1108
1832
  def build_arg_parser() -> argparse.ArgumentParser:
1109
1833
  ap = argparse.ArgumentParser(
1110
1834
  prog="lineage",
@@ -1133,9 +1857,10 @@ def build_arg_parser() -> argparse.ArgumentParser:
1133
1857
  help="skip reviewer quality gate (warn-only)")
1134
1858
  ap.add_argument("--reviewer-output",
1135
1859
  help="path to Critic response JSON; enforces the quality gate")
1136
- ap.add_argument("--reviewer-timeout", type=int, default=60,
1860
+ ap.add_argument("--reviewer-timeout", type=int, default=None,
1137
1861
  help="seconds to wait for reviewer-output (default 60)")
1138
1862
  ap.add_argument("--title", default="Session Lineage", help="HTML title")
1863
+ _add_review_args(ap)
1139
1864
  # Readability defaults are ON. Opt-out flags restore raw/older behavior.
1140
1865
  ap.add_argument("--hide-tool-only", dest="hide_tool_only", action="store_true",
1141
1866
  help="(DEFAULT) drop assistant turns that are only tool calls")
@@ -1155,6 +1880,19 @@ def build_arg_parser() -> argparse.ArgumentParser:
1155
1880
  return ap
1156
1881
 
1157
1882
 
1883
+ def _add_review_args(ap):
1884
+ """The flags of the reviewed flow (3.0.0): --emit-review, the session's reviewers,
1885
+ then --apply-review."""
1886
+ ap.add_argument("--rulebase", action="store_true",
1887
+ help="one pass, rules only (the default when no review flag is given)")
1888
+ ap.add_argument("--emit-review", metavar="PACK",
1889
+ help="write the turns for the session model to review (JSON) and stop")
1890
+ ap.add_argument("--apply-review", metavar="PACK",
1891
+ help="render from a review pack and the reviewer's decisions")
1892
+ ap.add_argument("--decisions", action="append", default=[], metavar="FILE",
1893
+ help="reviewer decisions [{id, keep, summary}] for --apply-review (repeatable)")
1894
+
1895
+
1158
1896
  def _open_stream(path):
1159
1897
  return open(path, encoding="utf-8", errors="strict")
1160
1898
 
@@ -1172,6 +1910,21 @@ def _load_turns_from(path, unsafe_schema, session_id, session_name):
1172
1910
  return []
1173
1911
 
1174
1912
 
1913
+ def _flag_clash(args):
1914
+ """The review flags that cannot go together, as one message; None when they can."""
1915
+ if args.emit_review == "" or args.apply_review == "":
1916
+ return "--emit-review and --apply-review need a file path"
1917
+ if args.purge_cache and (args.emit_review or args.apply_review):
1918
+ return "--purge-cache is its own run: it takes no review pack"
1919
+ if args.emit_review and args.apply_review:
1920
+ return "--emit-review and --apply-review are two separate runs"
1921
+ if args.rulebase and (args.emit_review or args.apply_review):
1922
+ return "--rulebase is the one-pass run: it takes no review pack"
1923
+ if args.decisions and not args.apply_review:
1924
+ return "--decisions goes with --apply-review"
1925
+ return None
1926
+
1927
+
1175
1928
  def main(argv=None) -> int:
1176
1929
  args = build_arg_parser().parse_args(argv)
1177
1930
 
@@ -1180,6 +1933,11 @@ def main(argv=None) -> int:
1180
1933
  args.redact_extra = ",".join(
1181
1934
  p for p in (args.redact_extra, env_extra) if p)
1182
1935
 
1936
+ clash = _flag_clash(args)
1937
+ if clash:
1938
+ print(f"[lineage] ERROR: {clash}", file=sys.stderr)
1939
+ return 2
1940
+
1183
1941
  if args.purge_cache:
1184
1942
  import shutil
1185
1943
  if CACHE_BASE.exists():
@@ -1189,6 +1947,13 @@ def main(argv=None) -> int:
1189
1947
  print("[lineage] no cache to purge", file=sys.stderr)
1190
1948
  return 0
1191
1949
 
1950
+ if args.apply_review:
1951
+ if any((args.last, args.from_, args.to, args.turns, args.session,
1952
+ args.all_sessions, args.from_transcript)):
1953
+ print("[lineage] note: --apply-review renders the pack; source and range "
1954
+ "flags were applied at --emit-review", file=sys.stderr)
1955
+ return apply_review(args)
1956
+
1192
1957
  # ---- Resolve input → raw turns (parse + parts + session) ----
1193
1958
  turns = []
1194
1959
  session_id = "session"
@@ -1249,51 +2014,95 @@ def main(argv=None) -> int:
1249
2014
  # stdin → derive per-turn uuid from content hash so cache works
1250
2015
  if args.from_transcript == "-":
1251
2016
  for t in turns:
1252
- t["uuid"] = hashlib.sha256(t["text"].encode()).hexdigest()[:16]
2017
+ t["uuid"] = hashlib.sha256(str(t["text"]).encode()).hexdigest()[:16]
1253
2018
 
1254
2019
  # ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
1255
2020
  turns = classify_turns(turns, drop_trivia=args.drop_trivia)
1256
2021
  turns = merge_assistant_runs(turns)
2022
+ if args.emit_review:
2023
+ drops = heuristic_drops(turns, args)
2024
+ window = review_window(turns, drops, args)
2025
+ if window is None:
2026
+ return 2
2027
+ return emit_review(turns, drops, window, session_id,
2028
+ default_output(args, jsonl_path, session_id), args)
1257
2029
  if args.drop_trivia:
1258
2030
  turns = drop_echo_exchanges(turns)
1259
2031
  if args.hide_tool_only:
1260
2032
  turns = [t for t in turns
1261
2033
  if not (t["role"] == "assistant" and not t["text"].strip())]
1262
- if args.from_:
1263
- turns = [t for t in turns if (t["ts"] or "") >= args.from_]
1264
- if args.to:
1265
- # A bare date bound is inclusive of the WHOLE end day: a full ISO
1266
- # timestamp sorts AFTER the date string ("...T10:..." <= "2026-08-09"
1267
- # is False), so append a high sentinel for date-only input.
1268
- to_bound = args.to if "T" in args.to else args.to + "T99"
1269
- turns = [t for t in turns if (t["ts"] or "") <= to_bound]
2034
+ turns = select_range(turns, args)
2035
+ if turns is None:
2036
+ return 2
2037
+ if args.output is None:
2038
+ args.output = default_output(args, jsonl_path, session_id)
2039
+ return render_and_write(turns, session_id, args, all_sessions=args.all_sessions,
2040
+ markdown=args.markdown, open_details=args.open_details,
2041
+ title=args.title)
2042
+
2043
+
2044
+ def _in_time(t, args):
2045
+ """--from / --to on one turn. A bare date bound is inclusive of the WHOLE end
2046
+ day: a full ISO timestamp sorts AFTER the date string ("...T10:..." <=
2047
+ "2026-08-09" is False), so a high sentinel is appended for date-only input."""
2048
+ ts = t["ts"] or ""
2049
+ if args.from_ and ts < args.from_:
2050
+ return False
2051
+ if args.to and ts > (args.to if "T" in args.to else args.to + "T99"):
2052
+ return False
2053
+ return True
2054
+
2055
+
2056
+ def _count_slice(n, args):
2057
+ """--turns then --last over n items, as a slice; None on an invalid --turns."""
2058
+ lo, hi = 0, n
1270
2059
  if args.turns:
1271
2060
  m = re.match(r"^(\d+)-(\d+)$", args.turns)
1272
2061
  if not m:
1273
2062
  print(f"[lineage] invalid --turns '{args.turns}'", file=sys.stderr)
1274
- return 2
1275
- lo, hi = max(1, int(m.group(1))), int(m.group(2))
1276
- turns = turns[lo - 1:hi]
2063
+ return None
2064
+ lo, hi = max(1, int(m.group(1))) - 1, min(int(m.group(2)), n)
1277
2065
  if args.last and args.last > 0:
1278
- turns = turns[-args.last:]
2066
+ lo = max(lo, hi - args.last)
2067
+ return slice(lo, hi)
1279
2068
 
2069
+
2070
+ def select_range(turns, args):
2071
+ """--from / --to / --turns / --last, in that order; None on an invalid --turns."""
2072
+ turns = [t for t in turns if _in_time(t, args)]
2073
+ sl = _count_slice(len(turns), args)
2074
+ if sl is None:
2075
+ return None
2076
+ turns = turns[sl]
1280
2077
  if len(turns) > 100:
1281
2078
  print(f"[lineage] WARN: {len(turns)} turns is large — render may be slow",
1282
2079
  file=sys.stderr)
2080
+ return turns
2081
+
2082
+
2083
+ def default_output(args, jsonl_path, session_id):
2084
+ """work/lineage-<session name or id>.html, or args.output when given."""
2085
+ if args.output is not None:
2086
+ return args.output
2087
+ session_name = discover_session_name(jsonl_path) if jsonl_path else None
2088
+ slug = ("all-sessions" if args.all_sessions
2089
+ else (session_name or session_id[:8]))
2090
+ if session_name:
2091
+ print(f"[lineage] session name: {session_name}", file=sys.stderr)
2092
+ return f"work/lineage-{slug}.html"
1283
2093
 
1284
- # ---- Resolve default output path ----
1285
- if args.output is None:
1286
- session_name = discover_session_name(jsonl_path) if jsonl_path else None
1287
- slug = ("all-sessions" if args.all_sessions
1288
- else (session_name or session_id[:8]))
1289
- args.output = f"work/lineage-{slug}.html"
1290
- if session_name:
1291
- print(f"[lineage] session name: {session_name}", file=sys.stderr)
1292
2094
 
2095
+ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
2096
+ open_details=False, title="Session Lineage", summaries=None,
2097
+ prior_redactions=None):
2098
+ """Render the rows, fill the template, self-verify, write, run the reviewer gate.
2099
+ `prior_redactions`: what --emit-review redacted before the text reached the pack."""
1293
2100
  rows, redact_counts, hits, total = render_rows(
1294
2101
  turns, session_id, args.redact_extra, args.redact_mode,
1295
- args.rebuild_summaries, open_details=args.open_details,
1296
- markdown=args.markdown, all_sessions=args.all_sessions)
2102
+ args.rebuild_summaries, open_details=open_details,
2103
+ markdown=markdown, all_sessions=all_sessions, summaries=summaries)
2104
+ for k, v in (prior_redactions or {}).items():
2105
+ redact_counts[k] = redact_counts.get(k, 0) + v
1297
2106
 
1298
2107
  date_range = ""
1299
2108
  dated = [t for t in turns if t.get("ts")]
@@ -1303,8 +2112,8 @@ def main(argv=None) -> int:
1303
2112
  date_range = first if first == last else f"{first} ~ {last}"
1304
2113
 
1305
2114
  html_doc = HTML_TEMPLATE
1306
- html_doc = html_doc.replace("{{TITLE}}", html.escape(args.title), 1)
1307
- html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(args.title), 1)
2115
+ html_doc = html_doc.replace("{{TITLE}}", html.escape(title), 1)
2116
+ html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(title), 1)
1308
2117
  html_doc = html_doc.replace("{{DATE_RANGE}}", html.escape(date_range), 1)
1309
2118
  html_doc = html_doc.replace("{{TURNS}}", "\n".join(rows), 1)
1310
2119
 
@@ -1316,7 +2125,7 @@ def main(argv=None) -> int:
1316
2125
  out.parent.mkdir(parents=True, exist_ok=True)
1317
2126
  out.write_text(html_doc, encoding="utf-8")
1318
2127
 
1319
- _run_reviewer_gate(turns, out, session_id, args)
2128
+ _run_reviewer_gate(turns, out, session_id, args, summaries=summaries)
1320
2129
 
1321
2130
  tool_total = sum(sum(t.get("tools", {}).values()) for t in turns)
1322
2131
  print(f"[lineage] turns={len(turns)} redacted={sum(redact_counts.values())} "
@@ -1338,70 +2147,245 @@ def _with_timestamp_suffix(path_str):
1338
2147
  return p.with_name(f"{p.stem}{stamp}{p.suffix}")
1339
2148
 
1340
2149
 
1341
- def _run_reviewer_gate(turns, out, session_id, args):
1342
- """Write reviewer samples and (if --reviewer-output) enforce the gate.
1343
- Uses the new summarizer; the input/output contract is unchanged (N3)."""
1344
- bots = [t for t in turns if t["role"] in ("assistant", "agent")]
1345
- if not args.skip_reviewer and bots:
1346
- import random
1347
- sample = random.sample(bots, min(5, len(bots)))
1348
- review_samples = []
1349
- for i, t in enumerate(sample):
1350
- s, _ = read_or_summarize(t, session_id, rebuild=False,
1351
- redact_extra=args.redact_extra,
1352
- redact_mode=args.redact_mode)
2150
+ def _gate_samples(bots, summaries, session_id, args):
2151
+ """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
2153
+ seeded by the turns, so a rerun on the same turns samples the same ones, and a sample
2154
+ names its turn and shows the head and tail the reviewer saw, fully redacted."""
2155
+ import random
2156
+ if summaries is None:
2157
+ out = []
2158
+ for i, t in enumerate(random.sample(bots, min(5, len(bots)))):
2159
+ s, _ = _page_summary(t, summaries, session_id, False,
2160
+ args.redact_extra, args.redact_mode)
1353
2161
  red_s, _ = redact(s, extra=args.redact_extra, mode=args.redact_mode)
1354
- red_d, _ = redact(t["text"][:500], extra=args.redact_extra,
1355
- mode=args.redact_mode)
1356
- review_samples.append({"idx": i, "original_detail": red_d,
1357
- "generated_summary": red_s})
1358
- review_path = out.parent / f".{out.stem}.reviewer-input.json"
2162
+ red_d, _ = redact(t["text"][:500], extra=args.redact_extra, mode=args.redact_mode)
2163
+ out.append({"idx": i, "original_detail": red_d, "generated_summary": red_s})
2164
+ return out
2165
+ seed = hashlib.sha256("\x00".join(t["uuid"] for t in bots).encode()).hexdigest()
2166
+ out = []
2167
+ for i, t in enumerate(random.Random(seed).sample(bots, min(5, len(bots)))):
2168
+ s = t.get("sample_summary") or _page_summary(t, summaries, session_id, False,
2169
+ args.redact_extra, args.redact_mode)[0]
2170
+ detail = t.get("preview") or _preview(review_redact(t["text"], args.redact_extra)[0])[0]
2171
+ gen = review_redact(_MASKED.sub("[REDACTED]", s), args.redact_extra)[0]
2172
+ key = hashlib.sha256("\x00".join([t["uuid"], detail, gen]).encode()).hexdigest()[:12]
2173
+ out.append({"idx": i, "id": t["uuid"], "key": key, "original_detail": detail,
2174
+ "generated_summary": gen})
2175
+ return out
2176
+
2177
+
2178
+ # What --redact-mode mask leaves of a secret (4 characters, ****, 4 characters): the page
2179
+ # keeps it, a model reading the gate samples gets none of it.
2180
+ _MASKED = re.compile(r"[^\s*]{4}\*{4}[^\s*]{4}")
2181
+
2182
+
2183
+ def _renew_samples(path, text, rop):
2184
+ """Write the reviewed flow's samples unless they are there as they are. New samples set
2185
+ aside a verdict list already at `rop` (_refuse_non_verdict has stopped on anything
2186
+ else): no critic judged them in it (a --rulebase gate or an earlier sample set left
2187
+ it). The same samples keep a verdict: a critic answered an earlier run, and this run
2188
+ reads that answer."""
2189
+ try:
2190
+ if path.read_text(encoding="utf-8") == text:
2191
+ return
2192
+ except (OSError, UnicodeDecodeError):
2193
+ pass
2194
+ _write_private(path, text)
2195
+ old = pathlib.Path(rop) if rop else None
2196
+ if old is not None and _is_verdict_file(old):
2197
+ print(f"[lineage] WARN: {rop} predates these samples; it is not read",
2198
+ file=sys.stderr)
2199
+ _set_aside(old)
2200
+
2201
+
2202
+ def _refuse_non_verdict(rop):
2203
+ """Stop (exit 2) before any sample or instruction when the reviewed flow's verdict path
2204
+ holds something other than a verdict list: whoever put it there, nothing printed here
2205
+ may lead the session to write over it."""
2206
+ p = pathlib.Path(rop) if rop else None
2207
+ if p is None or not p.exists() or _is_verdict_file(p):
2208
+ 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)
2212
+ raise SystemExit(2)
2213
+
2214
+
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."""
2218
+ if not p.is_file():
2219
+ return False
2220
+ try:
2221
+ got = json.loads(p.read_text(encoding="utf-8"))
2222
+ except (OSError, ValueError, UnicodeDecodeError, RecursionError):
2223
+ return False
2224
+ return isinstance(got, list) and bool(got) and all(isinstance(v, dict) and "idx" in v for v in got)
2225
+
2226
+
2227
+ def _write_gate_samples(bots, out, summaries, session_id, args):
2228
+ """Write the samples for the critic and say what to do with them; their path, or None.
2229
+ The reviewed flow writes them beside the pack, under one name across its reruns, and
2230
+ stops (exit 2) when it cannot: no critic could judge them."""
2231
+ if summaries is not None:
2232
+ _refuse_non_verdict(args.reviewer_output)
2233
+ path = out.parent / f".{out.stem}.reviewer-input.json"
2234
+ if summaries is not None and getattr(args, "apply_review", None):
2235
+ pack = pathlib.Path(args.apply_review)
2236
+ path = pack.with_name(f"{pack.stem}.reviewer-input.json")
2237
+ text = json.dumps(_gate_samples(bots, summaries, session_id, args),
2238
+ ensure_ascii=False, indent=2)
2239
+ try:
2240
+ if summaries is None:
2241
+ _write_private(path, text)
2242
+ else:
2243
+ _renew_samples(path, text, args.reviewer_output)
2244
+ except OSError as e:
2245
+ if summaries is not None:
2246
+ print(f"[lineage] ERROR: cannot write the gate samples {path}: {e}", file=sys.stderr)
2247
+ raise SystemExit(2)
2248
+ return None
2249
+ print(f"[lineage] reviewer samples: {path}", file=sys.stderr, flush=True)
2250
+ if summaries is None:
2251
+ print("[lineage] next: invoke Skill('oh-my-claudecode:critic') with "
2252
+ "the JSON above; expected [{idx, recoverable, reason}, ...] "
2253
+ "(PASS = 5/5 recoverable)", file=sys.stderr, flush=True)
2254
+ else:
2255
+ print(f"[lineage] next: have a critic agent judge the JSON above and write its "
2256
+ f"[{{idx, id, key, recoverable, reason}}, ...] to {args.reviewer_output} "
2257
+ "(one per sample; PASS = all recoverable)", file=sys.stderr, flush=True)
2258
+ return path
2259
+
2260
+
2261
+ 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."""
2266
+ import time as _time
2267
+ deadline = _time.time() + max(1, timeout)
2268
+ while not rop.exists() and _time.time() < deadline:
2269
+ _time.sleep(1)
2270
+ if not rop.exists():
2271
+ print(f"[lineage] ERROR: reviewer-output not found within "
2272
+ f"{timeout}s: {rop}", file=sys.stderr)
2273
+ raise SystemExit(2)
2274
+ try:
2275
+ verdict = json.loads(rop.read_text(encoding="utf-8"))
2276
+ except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
2277
+ print(f"[lineage] ERROR: reviewer-output parse failed: {e}",
2278
+ file=sys.stderr)
2279
+ raise SystemExit(2)
2280
+ if not isinstance(verdict, list) or not verdict:
2281
+ print("[lineage] ERROR: reviewer-output is not a non-empty array",
2282
+ file=sys.stderr)
2283
+ raise SystemExit(2)
2284
+ if set_aside:
2285
+ _set_aside(rop)
2286
+ return verdict
2287
+
2288
+
2289
+ def _set_aside(rop):
2290
+ """Rename the verdict file to `<name>.used` and say so; a folder or other non-file at
2291
+ that path is left alone. Something other than an earlier verdict at `<name>.used` is
2292
+ kept too: the verdict then goes to the first `<name>.used.N` that is free or holds an
2293
+ earlier verdict."""
2294
+ if not rop.is_file():
2295
+ print(f"[lineage] WARN: {rop} is not a file; left in place", file=sys.stderr)
2296
+ return
2297
+ dest = rop.with_name(rop.name + ".used")
2298
+ k = 0
2299
+ while dest.exists() and not _is_verdict_file(dest):
2300
+ k += 1
2301
+ dest = rop.with_name(f"{rop.name}.used.{k}")
2302
+ try:
2303
+ os.replace(str(rop), str(dest))
2304
+ except OSError as e:
2305
+ print(f"[lineage] WARN: could not set the verdict aside ({e}); remove {rop} "
2306
+ "before the next run", file=sys.stderr)
2307
+ return
2308
+ print(f"[lineage] note: verdict moved to {dest}", file=sys.stderr)
2309
+
2310
+
2311
+ def _verdict_gaps(verdict, samples):
2312
+ """Why a verdict does not answer these samples, or "": it needs one entry per sample
2313
+ idx and no other, each carrying the `id` and `key` of that sample (its turn and its
2314
+ content: an answer to the samples from before a fix carries an old key)."""
2315
+ want = {s["idx"]: s for s in samples if isinstance(s, dict) and type(s.get("idx")) is int}
2316
+ seen, stray = set(), []
2317
+ for v in verdict:
2318
+ idx = v.get("idx") if isinstance(v, dict) else None
2319
+ if (type(idx) is not int or idx not in want or idx in seen
2320
+ or any(v.get(k) != want[idx][k] for k in ("id", "key") if k in want[idx])):
2321
+ stray.append(idx)
2322
+ else:
2323
+ seen.add(idx)
2324
+ missing = sorted(set(want) - seen)
2325
+ if not (missing or stray):
2326
+ return ""
2327
+ return f"missing idx {missing}, entries that match no sample {stray}"
2328
+
2329
+
2330
+ def _check_coverage(verdict, samples):
2331
+ """Exit 2 unless the verdict answers each of the samples in file `samples`."""
2332
+ try:
2333
+ gaps = _verdict_gaps(verdict, json.loads(samples.read_text(encoding="utf-8")))
2334
+ except (OSError, ValueError, UnicodeDecodeError, AttributeError) as e:
2335
+ gaps = f"cannot read the samples: {e}"
2336
+ if gaps:
2337
+ print(f"[lineage] ERROR: the verdict does not answer {samples} ({gaps}); have the "
2338
+ "critic judge every sample again", file=sys.stderr)
2339
+ for v in verdict:
2340
+ if isinstance(v, dict) and v.get("recoverable") is not True:
2341
+ print(f" - not recoverable in it: idx={v.get('idx', '?')}: "
2342
+ f"{v.get('reason', '(no reason)')}", file=sys.stderr)
2343
+ raise SystemExit(2)
2344
+
2345
+
2346
+ def _enforce_gate(rop, timeout, samples=None, reviewed=False):
2347
+ """PASS when every verdict entry is recoverable, else exit 2 with the reasons. In the
2348
+ reviewed flow the verdict is set aside once read, it must answer each sample, and the
2349
+ samples go after a PASS."""
2350
+ verdict = _read_verdict(rop, timeout, set_aside=reviewed)
2351
+ if reviewed and samples is not None:
2352
+ _check_coverage(verdict, samples)
2353
+ fails = [v for v in verdict
2354
+ if not (isinstance(v, dict) and v.get("recoverable") is True)]
2355
+ if fails:
2356
+ print(f"[lineage] FAIL: quality gate {len(verdict) - len(fails)}/"
2357
+ f"{len(verdict)} recoverable. Reasons:", file=sys.stderr)
2358
+ for f in fails:
2359
+ if isinstance(f, dict):
2360
+ print(f" - idx={f.get('idx', '?')}: "
2361
+ f"{f.get('reason', '(no reason)')}", file=sys.stderr)
2362
+ raise SystemExit(2)
2363
+ print(f"[lineage] PASS: quality gate {len(verdict)}/{len(verdict)} "
2364
+ "recoverable", file=sys.stderr)
2365
+ if reviewed and samples is not None:
1359
2366
  try:
1360
- review_path.write_text(
1361
- json.dumps(review_samples, ensure_ascii=False, indent=2),
1362
- encoding="utf-8")
1363
- print(f"[lineage] reviewer samples: {review_path}", file=sys.stderr)
1364
- print("[lineage] next: invoke Skill('oh-my-claudecode:critic') with "
1365
- "the JSON above; expected [{idx, recoverable, reason}, ...] "
1366
- "(PASS = 5/5 recoverable)", file=sys.stderr)
2367
+ samples.unlink()
1367
2368
  except OSError:
1368
2369
  pass
1369
- elif args.skip_reviewer:
2370
+
2371
+
2372
+ def _run_reviewer_gate(turns, out, session_id, args, summaries=None):
2373
+ """Write reviewer samples and (if --reviewer-output) enforce the gate. Samples the
2374
+ summaries the page shows (`summaries` overrides, as in render_rows); the input/output
2375
+ contract is unchanged (N3). The reviewed flow (`summaries` given) already had a model
2376
+ review every turn, so it samples only for a gate it enforces."""
2377
+ bots = [t for t in turns if t["role"] in ("assistant", "agent")]
2378
+ reviewed = summaries is not None
2379
+ if args.skip_reviewer:
1370
2380
  print("[lineage] WARN: --skip-reviewer — quality gate not enforced",
1371
2381
  file=sys.stderr)
1372
-
1373
- if (not args.skip_reviewer) and bots and args.reviewer_output:
1374
- import time as _time
1375
- rop = pathlib.Path(args.reviewer_output)
1376
- deadline = _time.time() + max(1, args.reviewer_timeout)
1377
- while not rop.exists() and _time.time() < deadline:
1378
- _time.sleep(1)
1379
- if not rop.exists():
1380
- print(f"[lineage] ERROR: reviewer-output not found within "
1381
- f"{args.reviewer_timeout}s: {rop}", file=sys.stderr)
1382
- raise SystemExit(2)
1383
- try:
1384
- verdict = json.loads(rop.read_text(encoding="utf-8"))
1385
- except (json.JSONDecodeError, OSError, UnicodeDecodeError) as e:
1386
- print(f"[lineage] ERROR: reviewer-output parse failed: {e}",
1387
- file=sys.stderr)
1388
- raise SystemExit(2)
1389
- if not isinstance(verdict, list) or not verdict:
1390
- print("[lineage] ERROR: reviewer-output is not a non-empty array",
1391
- file=sys.stderr)
1392
- raise SystemExit(2)
1393
- fails = [v for v in verdict
1394
- if not (isinstance(v, dict) and v.get("recoverable") is True)]
1395
- if fails:
1396
- print(f"[lineage] FAIL: quality gate {len(verdict) - len(fails)}/"
1397
- f"{len(verdict)} recoverable. Reasons:", file=sys.stderr)
1398
- for f in fails:
1399
- if isinstance(f, dict):
1400
- print(f" - idx={f.get('idx', '?')}: "
1401
- f"{f.get('reason', '(no reason)')}", file=sys.stderr)
1402
- raise SystemExit(2)
1403
- print(f"[lineage] PASS: quality gate {len(verdict)}/{len(verdict)} "
1404
- "recoverable", file=sys.stderr)
2382
+ return
2383
+ if not bots or (reviewed and not args.reviewer_output):
2384
+ return
2385
+ samples = _write_gate_samples(bots, out, summaries, session_id, args)
2386
+ if args.reviewer_output:
2387
+ timeout = REVIEWER_TIMEOUT if args.reviewer_timeout is None else args.reviewer_timeout
2388
+ _enforce_gate(pathlib.Path(args.reviewer_output), timeout, samples, reviewed)
1405
2389
 
1406
2390
 
1407
2391
  if __name__ == "__main__":