@kaydash9999/banker-plugins 0.20.2 → 0.20.5

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 (82) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +179 -1
  4. package/PRIVACY.md +13 -3
  5. package/README.md +25 -2
  6. package/bin/banker.js +84 -40
  7. package/bin/lib/telemetry-config.mjs +31 -16
  8. package/codex/manifest.json +1 -1
  9. package/codex/transform-matrix.md +1 -1
  10. package/commands/setup.md +23 -0
  11. package/hooks/progress.mjs +143 -38
  12. package/hooks/register.mjs +13 -3
  13. package/hooks/update-checkin.mjs +15 -7
  14. package/hooks/update-fetch.mjs +15 -9
  15. package/hooks/update-notify.mjs +2 -9
  16. package/package.json +1 -1
  17. package/skills/3d-intro-build/SKILL.md +11 -5
  18. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  19. package/skills/3d-intro-build/references/curate.mjs +48 -14
  20. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  21. package/skills/3d-intro-build/references/prompts.md +4 -4
  22. package/skills/3d-intro-build/references/serve.mjs +6 -1
  23. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  24. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  25. package/skills/3d-intro-setup/SKILL.md +41 -10
  26. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  28. package/skills/anti-drm/SKILL.md +6 -3
  29. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  30. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  31. package/skills/audit-mock/SKILL.md +1 -1
  32. package/skills/audit-security/SKILL.md +64 -2
  33. package/skills/deep-research/SKILL.md +5 -0
  34. package/skills/docs-setup/SKILL.md +37 -7
  35. package/skills/lineage/SKILL.md +12 -12
  36. package/skills/lineage/lineage.py +399 -300
  37. package/skills/motion-graphic-make/SKILL.md +1 -1
  38. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  39. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  40. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  41. package/skills/motion-graphic-setup/SKILL.md +58 -13
  42. package/skills/obsidizer/SKILL.md +1 -1
  43. package/skills/obsidizer/obsidize.mjs +8 -1
  44. package/skills/omc-patch/SKILL.md +80 -23
  45. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  46. package/skills/payload-mon/SKILL.md +4 -2
  47. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  48. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  49. package/skills/pdf-vision-extract/SKILL.md +11 -6
  50. package/skills/ralph-qa/SKILL.md +125 -151
  51. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  52. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  53. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  54. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  55. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  56. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  57. package/skills/setup-harness-factory/SKILL.md +17 -1
  58. package/skills/setup-insane-search/SKILL.md +16 -0
  59. package/skills/setup-java/SKILL.md +20 -1
  60. package/skills/setup-lsp/SKILL.md +56 -13
  61. package/skills/setup-mcp/SKILL.md +73 -4
  62. package/skills/setup-node/SKILL.md +31 -6
  63. package/skills/setup-omc/SKILL.md +24 -0
  64. package/skills/setup-omc-hud/SKILL.md +9 -6
  65. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  66. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  67. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  68. package/skills/setup-playwright/SKILL.md +45 -2
  69. package/skills/setup-pwsh/SKILL.md +19 -0
  70. package/skills/setup-python/SKILL.md +45 -7
  71. package/skills/setup-sandbox/SKILL.md +30 -5
  72. package/skills/setup-stitch/SKILL.md +22 -0
  73. package/skills/setup-tmux/SKILL.md +24 -4
  74. package/skills/tone-compact/SKILL.md +2 -1
  75. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  76. package/skills/ultra-chain/SKILL.md +4 -3
  77. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  78. package/skills/ultra-chain/references/serve.mjs +6 -1
  79. package/skills/ultra-interview/SKILL.md +1 -0
  80. package/skills/update-banker/SKILL.md +19 -3
  81. package/skills/vertical-pptx/SKILL.md +6 -3
  82. package/skills/vertical-pptx-setup/SKILL.md +76 -20
@@ -34,7 +34,8 @@ if sys.version_info < (3, 7):
34
34
  sys.exit(2)
35
35
 
36
36
  SCHEMA_VERSION = 2 # cache dir schema (v2: redacted summaries only)
37
- SUMMARIZER_VERSION = 3 # bump when summarize_turn logic changes (B-3 cache key); 3: cut from redacted text
37
+ SUMMARIZER_VERSION = 4 # bump when summarize_turn or the page redaction changes (B-3 cache key)
38
+ # 3: cut from redacted text (3.0.2); 4: split secrets joined first (3.0.8)
38
39
  USER_FOLD = 400 # collapse user messages longer than this (E-1)
39
40
  ECHO_ASK = 40 # echo-exchange: user question length ceiling (A-4)
40
41
  ECHO_REPLY = 5 # echo-exchange: assistant reply length ceiling (A-4)
@@ -345,10 +346,9 @@ def _rejoin_split(text, patterns, entropy=False, trail=None):
345
346
  return "".join(out) + text[at:]
346
347
 
347
348
 
348
- def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None):
349
- """Multi-layer redaction; page_redact and review_redact join split secrets first (3.0.8). Returns (text, counts).
350
- With `trail` (a _Trail), where each character of the result came from is followed too."""
351
- counts, out, sub = {}, text, _sub_for(trail)
349
+ def _hide_patterns(out, mode, sub, counts):
350
+ """`out` with each value the page patterns and the entropy rule find replaced by its marker, or by a mask with
351
+ mode mask; `counts` adds them up by name."""
352
352
  for name, pat in SECRET_PATTERNS:
353
353
  def repl(m, n=name):
354
354
  counts[n] = counts.get(n, 0) + 1
@@ -361,32 +361,43 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None
361
361
  counts["entropy"] = counts.get("entropy", 0) + 1
362
362
  return _mask(s) if mode == "mask" else "[REDACTED:entropy]"
363
363
  return s
364
- out = sub(ENTROPY_LONG, ent_repl, out)
365
-
364
+ return sub(ENTROPY_LONG, ent_repl, out)
365
+
366
+
367
+ def _scan_detected(held, sub, counts):
368
+ """held[0] with each value detect-secrets finds in it replaced: raises ImportError when it is not installed.
369
+ held[0] keeps what was replaced before a failure, as one pass over a local did. The scratch file it scans goes
370
+ whatever happens, a write that fails too (a full disk, a character UTF-8 cannot hold): it holds the text before
371
+ detect-secrets hid anything."""
372
+ from detect_secrets import SecretsCollection
373
+ from detect_secrets.settings import default_settings
374
+ tf = tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8")
375
+ tmp = tf.name
366
376
  try:
367
- from detect_secrets import SecretsCollection
368
- from detect_secrets.settings import default_settings
369
- import tempfile
370
- with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False,
371
- encoding="utf-8") as tf:
372
- tf.write(out)
373
- tmp = tf.name
377
+ with tf:
378
+ tf.write(held[0])
379
+ col = SecretsCollection()
380
+ with default_settings():
381
+ col.scan_file(tmp)
382
+ for fname in col.files:
383
+ for s in col[fname]:
384
+ sv = s.secret_value
385
+ if sv and sv in held[0]:
386
+ held[0] = sub(re.compile(re.escape(sv)), lambda m: "[REDACTED:ds]", held[0])
387
+ counts["ds:detect-secrets"] = counts.get(
388
+ "ds:detect-secrets", 0) + 1
389
+ finally:
374
390
  try:
375
- col = SecretsCollection()
376
- with default_settings():
377
- col.scan_file(tmp)
378
- for fname in col.files:
379
- for s in col[fname]:
380
- sv = s.secret_value
381
- if sv and sv in out:
382
- out = sub(re.compile(re.escape(sv)), lambda m: "[REDACTED:ds]", out)
383
- counts["ds:detect-secrets"] = counts.get(
384
- "ds:detect-secrets", 0) + 1
385
- finally:
386
- try:
387
- os.unlink(tmp)
388
- except OSError:
389
- pass
391
+ os.unlink(tmp)
392
+ except OSError:
393
+ pass
394
+
395
+
396
+ def _hide_detected(out, sub, counts):
397
+ """`out` after detect-secrets, when it is installed; a WARN once a run when it is not, or when it fails."""
398
+ held = [out]
399
+ try:
400
+ _scan_detected(held, sub, counts)
390
401
  except ImportError:
391
402
  if not getattr(redact, "_ds_warned", False):
392
403
  print("[lineage] WARN: detect-secrets unavailable, using fallback "
@@ -397,16 +408,30 @@ def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None
397
408
  print(f"[lineage] WARN: detect-secrets error ({e}), continuing "
398
409
  "with fallback", file=sys.stderr)
399
410
  redact._ds_err_warned = True
411
+ return held[0]
412
+
413
+
414
+ def _hide_extra(out, extra, sub, counts):
415
+ """`out` with each --redact-extra keyword replaced; a marker an earlier pass left stays whole: a keyword inside
416
+ its name is no secret."""
417
+ for kw in [k.strip() for k in extra.split(",") if k.strip()]:
418
+ pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
419
+ hits = []
420
+ out = sub(pat, lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
421
+ else hits.append(1) or "[REDACTED]", out)
422
+ if hits:
423
+ counts[f"custom:{kw}"] = len(hits)
424
+ return out
400
425
 
426
+
427
+ def redact(text: str, extra: "str | None" = None, mode: str = "full", trail=None):
428
+ """Multi-layer redaction; page_redact and review_redact join split secrets first (3.0.8). Returns (text, counts).
429
+ With `trail` (a _Trail), where each character of the result came from is followed too."""
430
+ counts, sub = {}, _sub_for(trail)
431
+ out = _hide_patterns(text, mode, sub, counts)
432
+ out = _hide_detected(out, sub, counts)
401
433
  if extra:
402
- for kw in [k.strip() for k in extra.split(",") if k.strip()]:
403
- # a marker an earlier pass left stays whole: a keyword inside its name is no secret
404
- pat = re.compile(r"\[REDACTED(?::[^\]\s]*)?\]|" + re.escape(kw), re.IGNORECASE)
405
- hits = []
406
- out = sub(pat, lambda m: m.group(0) if m.group(0).upper().startswith("[REDACTED")
407
- else hits.append(1) or "[REDACTED]", out)
408
- if hits:
409
- counts[f"custom:{kw}"] = len(hits)
434
+ out = _hide_extra(out, extra, sub, counts)
410
435
  return out, counts
411
436
 
412
437
 
@@ -431,8 +456,8 @@ _CU_SKIP = (r"(?!" + _CU_LEAD + r"(?:\+[\"']?%[-_0^#]?[A-Za-z%]|(?:\d+:\d+|(?:"
431
456
  + r"|[^\s:=\"'`(]*\)):" + _CU_REF + r")(?![^\s\"'`;|&)\\^])"
432
457
  r"|\$\{\w+:[-=?+]?[/~.][^\s}\"'`(:]*\}[^\s:]*(?!\S)))")
433
458
 
434
- # Extra patterns for text a reviewer model reads (part files) and nothing else: the page
435
- # and --rulebase keep the patterns above, so their output stays as it was.
459
+ # Patterns every page (the reviewed flow and --rulebase) and every part file hides with before the
460
+ # page patterns above, while keys are whole (3.0.2).
436
461
  # A value a redaction already replaced: a marker ([REDACTED...]) or a value that is only a mask
437
462
  # (abcd****wxyz: up to 4 characters, a whole run of *, up to 4 characters, then the value ends; a
438
463
  # closing `, |, * or ) and then . , ; : ! ? may stand between, as in **password: ****h12** or
@@ -1210,7 +1235,8 @@ def _read_text(path):
1210
1235
 
1211
1236
 
1212
1237
  def _read_cached(p):
1213
- """The summary cached at p, else at the same name with .txt, which 3.0.2 and earlier wrote.
1238
+ """The summary cached at p, else at the same name with .txt, the name 3.0.2 and earlier wrote (their digests
1239
+ carry summarizer version 2 or 3, so since version 4 no file of theirs is found).
1214
1240
  None when neither holds one: missing, bytes that are not UTF-8 (a DRM encrypted them), or
1215
1241
  nothing a person can see (a write that stopped early, or only blank-looking letters); the
1216
1242
  turn is then summarized again (3.0.4, 3.0.5)."""
@@ -1300,11 +1326,13 @@ def _is_block_start(lines, i):
1300
1326
  if (_FENCE_RE.match(line) or _HEAD_RE.match(line) or _HR_RE.match(line)
1301
1327
  or _QUOTE_RE.match(line) or _LIST_RE.match(line)):
1302
1328
  return True
1303
- # table: this line has a pipe AND the next line is a delimiter row
1304
- if ("|" in line and i + 1 < len(lines)
1305
- and "-" in lines[i + 1] and _TABLE_DELIM_RE.match(lines[i + 1])):
1306
- return True
1307
- return False
1329
+ return _is_table_start(lines, i)
1330
+
1331
+
1332
+ def _is_table_start(lines, i):
1333
+ """Whether lines[i] starts a table: it has a pipe AND the next line is a delimiter row."""
1334
+ return bool("|" in lines[i] and i + 1 < len(lines)
1335
+ and "-" in lines[i + 1] and _TABLE_DELIM_RE.match(lines[i + 1]))
1308
1336
 
1309
1337
 
1310
1338
  def _render_table(lines, i):
@@ -1323,85 +1351,90 @@ def _render_table(lines, i):
1323
1351
  % (thead, body)), i
1324
1352
 
1325
1353
 
1354
+ def _md_fence(lines, i, fence):
1355
+ """A code block from the fence at lines[i] to the first closing fence at least as long (or the end), and the
1356
+ index past it."""
1357
+ i += 1
1358
+ buf = []
1359
+ while i < len(lines):
1360
+ mc = _FENCE_CLOSE_RE.match(lines[i])
1361
+ if mc and len(mc.group(1)) >= len(fence):
1362
+ i += 1
1363
+ break
1364
+ buf.append(lines[i])
1365
+ i += 1
1366
+ return "<pre><code>%s</code></pre>" % html.escape("\n".join(buf)), i
1367
+
1368
+
1369
+ def _md_quote(lines, i, depth):
1370
+ """The blockquote of the run of `>` lines at lines[i], rendered one level deeper, and the index past it."""
1371
+ buf = []
1372
+ while i < len(lines) and _QUOTE_RE.match(lines[i]):
1373
+ buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
1374
+ i += 1
1375
+ return "<blockquote>%s</blockquote>" % render_markdown("\n".join(buf), depth + 1), i
1376
+
1377
+
1378
+ def _md_list(lines, i):
1379
+ """The list of the run of list lines at lines[i] (ordered when the first is numbered; nested ones flatten),
1380
+ and the index past it."""
1381
+ tag = "ol" if re.match(r"^\s*\d+[.)]", lines[i]) else "ul"
1382
+ items = []
1383
+ while i < len(lines):
1384
+ mli = _LIST_RE.match(lines[i])
1385
+ if not mli:
1386
+ break
1387
+ items.append(_md_inline(html.escape(mli.group(3))))
1388
+ i += 1
1389
+ return "<%s>%s</%s>" % (tag, "".join("<li>%s</li>" % it for it in items), tag), i
1390
+
1391
+
1392
+ def _md_paragraph(lines, i):
1393
+ """The paragraph that starts at lines[i] (at least that line: C-2, no endless loop), its lines joined with <br>
1394
+ (N5: pre-wrap keeps leading spaces without a second break), and the index past it."""
1395
+ buf = [lines[i]]
1396
+ i += 1
1397
+ while i < len(lines) and lines[i].strip() and not _is_block_start(lines, i):
1398
+ buf.append(lines[i])
1399
+ i += 1
1400
+ return "<p>%s</p>" % "<br>".join(_md_inline(html.escape(b)) for b in buf), i
1401
+
1402
+
1403
+ def _md_block(lines, i, depth):
1404
+ """The HTML of the block that starts at lines[i] (None for a blank line), and the index past it."""
1405
+ line = lines[i]
1406
+ mf = _FENCE_RE.match(line)
1407
+ if mf:
1408
+ return _md_fence(lines, i, mf.group(1))
1409
+ mh = _HEAD_RE.match(line)
1410
+ if mh:
1411
+ hl = max(3, min(6, len(mh.group(1)))) # h3..h6 (avoid page-header clash)
1412
+ return "<h%d>%s</h%d>" % (hl, _md_inline(html.escape(mh.group(2).strip())), hl), i + 1
1413
+ if _HR_RE.match(line):
1414
+ return "<hr>", i + 1
1415
+ if _QUOTE_RE.match(line):
1416
+ return _md_quote(lines, i, depth)
1417
+ if _is_table_start(lines, i):
1418
+ return _render_table(lines, i)
1419
+ if _LIST_RE.match(line):
1420
+ return _md_list(lines, i)
1421
+ if not line.strip():
1422
+ return None, i + 1
1423
+ return _md_paragraph(lines, i)
1424
+
1425
+
1326
1426
  def render_markdown(text, depth=0):
1327
1427
  """Block-level markdown → HTML. External deps: none. Escapes first; no stage
1328
1428
  re-runs over prior markup. Nested lists flatten; footnotes unsupported.
1329
1429
  """
1330
1430
  if depth > _BLOCKQUOTE_MAX_DEPTH:
1331
1431
  return "<p>%s</p>" % _md_inline(html.escape(text))
1332
- text = _CTRL_RE.sub("", text)
1333
- lines = text.split("\n")
1334
- out = []
1335
- i, n = 0, len(lines)
1336
- while i < n:
1337
- line = lines[i]
1338
- mf = _FENCE_RE.match(line)
1339
- if mf:
1340
- fence = mf.group(1)
1341
- i += 1
1342
- buf = []
1343
- while i < n:
1344
- mc = _FENCE_CLOSE_RE.match(lines[i])
1345
- if mc and len(mc.group(1)) >= len(fence):
1346
- i += 1
1347
- break
1348
- buf.append(lines[i])
1349
- i += 1
1350
- out.append("<pre><code>%s</code></pre>"
1351
- % html.escape("\n".join(buf)))
1352
- continue
1353
- mh = _HEAD_RE.match(line)
1354
- if mh:
1355
- hl = max(3, min(6, len(mh.group(1)))) # h3..h6 (avoid page-header clash)
1356
- out.append("<h%d>%s</h%d>"
1357
- % (hl, _md_inline(html.escape(mh.group(2).strip())), hl))
1358
- i += 1
1359
- continue
1360
- if _HR_RE.match(line):
1361
- out.append("<hr>")
1362
- i += 1
1363
- continue
1364
- if _QUOTE_RE.match(line):
1365
- buf = []
1366
- while i < n and _QUOTE_RE.match(lines[i]):
1367
- buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
1368
- i += 1
1369
- out.append("<blockquote>%s</blockquote>"
1370
- % render_markdown("\n".join(buf), depth + 1))
1371
- continue
1372
- if ("|" in line and i + 1 < n and "-" in lines[i + 1]
1373
- and _TABLE_DELIM_RE.match(lines[i + 1])):
1374
- tbl, i = _render_table(lines, i)
1375
- out.append(tbl)
1376
- continue
1377
- ml = _LIST_RE.match(line)
1378
- if ml:
1379
- ordered = bool(re.match(r"^\s*\d+[.)]", line))
1380
- tag = "ol" if ordered else "ul"
1381
- items = []
1382
- while i < n:
1383
- mli = _LIST_RE.match(lines[i])
1384
- if not mli:
1385
- break
1386
- items.append(_md_inline(html.escape(mli.group(3))))
1387
- i += 1
1388
- out.append("<%s>%s</%s>"
1389
- % (tag, "".join("<li>%s</li>" % it for it in items), tag))
1390
- continue
1391
- if not line.strip():
1392
- i += 1
1393
- continue
1394
- # paragraph: force-consume at least this line (C-2: no infinite loop)
1395
- buf = [line]
1396
- i += 1
1397
- while i < n and lines[i].strip() and not _is_block_start(lines, i):
1398
- buf.append(lines[i])
1399
- i += 1
1400
- # source newlines within a paragraph become <br> (N5). pre-wrap on the
1401
- # container preserves leading/aligned spaces without double-breaking,
1402
- # because we join with <br> and emit no literal newline between lines.
1403
- out.append("<p>%s</p>"
1404
- % "<br>".join(_md_inline(html.escape(b)) for b in buf))
1432
+ lines = _CTRL_RE.sub("", text).split("\n")
1433
+ out, i = [], 0
1434
+ while i < len(lines):
1435
+ block, i = _md_block(lines, i, depth)
1436
+ if block is not None:
1437
+ out.append(block)
1405
1438
  return "\n".join(out)
1406
1439
 
1407
1440
 
@@ -1614,102 +1647,110 @@ def _fmt_time(ts):
1614
1647
  return ts[11:16] if ts and len(ts) >= 16 else ""
1615
1648
 
1616
1649
 
1617
- def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
1618
- open_details=False, markdown=True, all_sessions=False, summaries=None):
1619
- rows = []
1620
- last_date = None
1621
- last_session = None
1622
- cache_hits = 0
1623
- cache_total = 0
1624
- redact_counts = {}
1625
- open_attr = " open" if open_details else ""
1626
-
1627
- def _red(text):
1628
- r, c = page_redact(text, extra=redact_extra, mode=redact_mode)
1629
- for k, v in c.items():
1630
- redact_counts[k] = redact_counts.get(k, 0) + v
1631
- return r
1650
+ class _Rows:
1651
+ """What render_rows carries from turn to turn: the rows so far, the redaction settings and what they hid by kind,
1652
+ the open flag and markdown, the date and session the last pills showed, and the rule summaries' cache hits."""
1632
1653
 
1633
- for t in turns:
1634
- ts = t.get("ts") or ""
1635
- # session-transition divider (--all-sessions)
1636
- if all_sessions and t.get("session") != last_session:
1637
- last_session = t.get("session")
1638
- name = html.escape(_red(t.get("session_name") or last_session or "session"))
1639
- rows.append('<div class="day" data-mark="%s">'
1640
- '<span class="pill pill-session">%s</span></div>'
1641
- % (name, name))
1642
- last_date = None
1643
- if ts:
1644
- d = ts[:10]
1645
- if d != last_date:
1646
- rows.append('<div class="day" data-mark="%s">'
1647
- '<span class="pill pill-date">%s</span></div>'
1648
- % (html.escape(d), html.escape(d)))
1649
- last_date = d
1650
- time_hm = html.escape(_fmt_time(ts))
1651
- role = t["role"]
1652
-
1653
- if role == "mark":
1654
- rows.append('<div class="day"><span class="pill pill-compact">'
1655
- '⋯ 이전 대화 요약(compaction) ⋯</span></div>')
1656
- continue
1654
+ def __init__(self, redact_extra, redact_mode, open_details, markdown):
1655
+ self.extra, self.mode, self.markdown = redact_extra, redact_mode, markdown
1656
+ self.open_attr = " open" if open_details else ""
1657
+ self.rows, self.counts = [], {}
1658
+ self.last_date = self.last_session = None
1659
+ self.hits = self.total = 0
1657
1660
 
1658
- if role == "user":
1659
- red = _red(t["text"])
1660
- if len(red) > USER_FOLD:
1661
- reviewed = _reviewed_fold(t, summaries)
1662
- summary = html.escape(_red(reviewed) if reviewed else user_fold_summary(red))
1663
- body = render_body(red, markdown=markdown)
1664
- rows.append(
1665
- '<div class="row me"><span class="time">%s</span>'
1666
- '<div class="bubble"><details%s>'
1667
- '<summary><span class="sum">%s</span></summary>'
1668
- '<div class="detail">%s</div></details></div></div>'
1669
- % (time_hm, open_attr, summary, body))
1670
- else:
1671
- rows.append(
1672
- '<div class="row me"><span class="time">%s</span>'
1673
- '<div class="bubble"><div class="plain">%s</div></div></div>'
1674
- % (time_hm, render_body(red, markdown=markdown)))
1675
- continue
1661
+ def red(self, text):
1662
+ r, c = page_redact(text, extra=self.extra, mode=self.mode)
1663
+ for k, v in c.items():
1664
+ self.counts[k] = self.counts.get(k, 0) + v
1665
+ return r
1676
1666
 
1677
- # assistant or agent bubble
1678
- summary, hit = _page_summary(t, summaries, session_id, rebuild,
1679
- redact_extra, redact_mode)
1680
- if hit is not None:
1681
- cache_total += 1
1682
- cache_hits += bool(hit)
1683
- esc_sum = html.escape(_red(summary))
1684
- detail_body = render_body(_red(t["text"]), markdown=markdown)
1685
-
1686
- tools_html = ""
1687
- if t.get("tools"):
1688
- ts_list = ", ".join(f"{_hide_keywords(k, redact_extra)}×{v}"
1689
- for k, v in sorted(t["tools"].items()))
1690
- total = sum(t["tools"].values())
1691
- tools_html = ('<div class="tools">🔧 도구 %d건: %s</div>'
1692
- % (total, html.escape(ts_list)))
1693
-
1694
- if role == "agent":
1695
- who = html.escape(_red(t.get("agent_from") or "agent"))
1696
- avatar = "🤝"
1697
- from_line = '<div class="from">%s</div>' % who
1698
- row_cls = "row bot agent"
1699
- else:
1700
- avatar = "C"
1701
- from_line = ""
1702
- row_cls = "row bot"
1703
1667
 
1704
- rows.append(
1705
- '<div class="%s"><div class="avatar%s">%s</div>'
1668
+ def _pills(rs, t, all_sessions):
1669
+ """The session pill (--all-sessions, when the session changes) and the date pill (when the day changes) before
1670
+ turn `t`."""
1671
+ if all_sessions and t.get("session") != rs.last_session:
1672
+ rs.last_session = t.get("session")
1673
+ name = html.escape(rs.red(t.get("session_name") or rs.last_session or "session"))
1674
+ rs.rows.append('<div class="day" data-mark="%s">'
1675
+ '<span class="pill pill-session">%s</span></div>'
1676
+ % (name, name))
1677
+ rs.last_date = None
1678
+ ts = t.get("ts") or ""
1679
+ if ts:
1680
+ d = ts[:10]
1681
+ if d != rs.last_date:
1682
+ rs.rows.append('<div class="day" data-mark="%s">'
1683
+ '<span class="pill pill-date">%s</span></div>'
1684
+ % (html.escape(d), html.escape(d)))
1685
+ rs.last_date = d
1686
+
1687
+
1688
+ def _user_row(rs, t, summaries, time_hm):
1689
+ """A user bubble: plain, or folded under a summary (the reviewer's, else cut from it) past USER_FOLD."""
1690
+ red = rs.red(t["text"])
1691
+ if len(red) > USER_FOLD:
1692
+ reviewed = _reviewed_fold(t, summaries)
1693
+ summary = html.escape(rs.red(reviewed) if reviewed else user_fold_summary(red))
1694
+ body = render_body(red, markdown=rs.markdown)
1695
+ return ('<div class="row me"><span class="time">%s</span>'
1696
+ '<div class="bubble"><details%s>'
1697
+ '<summary><span class="sum">%s</span></summary>'
1698
+ '<div class="detail">%s</div></details></div></div>'
1699
+ % (time_hm, rs.open_attr, summary, body))
1700
+ return ('<div class="row me"><span class="time">%s</span>'
1701
+ '<div class="bubble"><div class="plain">%s</div></div></div>'
1702
+ % (time_hm, render_body(red, markdown=rs.markdown)))
1703
+
1704
+
1705
+ def _tools_line(tools, redact_extra):
1706
+ """The `🔧 도구 N건` line of a bot bubble ("" without tools), the --redact-extra keywords hidden in the names."""
1707
+ if not tools:
1708
+ return ""
1709
+ ts_list = ", ".join(f"{_hide_keywords(k, redact_extra)}×{v}"
1710
+ for k, v in sorted(tools.items()))
1711
+ return ('<div class="tools">🔧 도구 %d건: %s</div>'
1712
+ % (sum(tools.values()), html.escape(ts_list)))
1713
+
1714
+
1715
+ def _bot_row(rs, t, summary, time_hm):
1716
+ """An assistant or agent bubble: the summary, the details (body, tools) and, for an agent, who sent it."""
1717
+ esc_sum = html.escape(rs.red(summary))
1718
+ detail_body = render_body(rs.red(t["text"]), markdown=rs.markdown)
1719
+ tools_html = _tools_line(t.get("tools"), rs.extra)
1720
+ role = t["role"]
1721
+ if role == "agent":
1722
+ who = html.escape(rs.red(t.get("agent_from") or "agent"))
1723
+ avatar, from_line, row_cls = "🤝", '<div class="from">%s</div>' % who, "row bot agent"
1724
+ else:
1725
+ avatar, from_line, row_cls = "C", "", "row bot"
1726
+ return ('<div class="%s"><div class="avatar%s">%s</div>'
1706
1727
  '<div class="bubble"><details%s>'
1707
1728
  '<summary><span class="sum">%s</span></summary>'
1708
1729
  '<div class="detail">%s%s%s</div></details></div>'
1709
1730
  '<span class="time">%s</span></div>'
1710
1731
  % (row_cls, " agent" if role == "agent" else "", avatar,
1711
- open_attr, esc_sum, from_line, detail_body, tools_html, time_hm))
1712
- return rows, redact_counts, cache_hits, cache_total
1732
+ rs.open_attr, esc_sum, from_line, detail_body, tools_html, time_hm))
1733
+
1734
+
1735
+ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
1736
+ open_details=False, markdown=True, all_sessions=False, summaries=None):
1737
+ rs = _Rows(redact_extra, redact_mode, open_details, markdown)
1738
+ for t in turns:
1739
+ _pills(rs, t, all_sessions)
1740
+ time_hm = html.escape(_fmt_time(t.get("ts") or ""))
1741
+ if t["role"] == "mark":
1742
+ rs.rows.append('<div class="day"><span class="pill pill-compact">'
1743
+ '⋯ 이전 대화 요약(compaction) ⋯</span></div>')
1744
+ elif t["role"] == "user":
1745
+ rs.rows.append(_user_row(rs, t, summaries, time_hm))
1746
+ else:
1747
+ summary, hit = _page_summary(t, summaries, session_id, rebuild,
1748
+ redact_extra, redact_mode)
1749
+ if hit is not None:
1750
+ rs.total += 1
1751
+ rs.hits += bool(hit)
1752
+ rs.rows.append(_bot_row(rs, t, summary, time_hm))
1753
+ return rs.rows, rs.counts, rs.hits, rs.total
1713
1754
 
1714
1755
 
1715
1756
  _TAG_RE = re.compile(r"<[^>]*>")
@@ -2004,12 +2045,15 @@ def _review_view(t, red, args):
2004
2045
  def _pack_turn(n, t, tid, why, session_id, args, counts):
2005
2046
  """One turn of the pack under its final id `tid` (the text as the page shows it, the rules'
2006
2047
  decision, the reviewer's slot), and the reviewer's view of it. The cached decision keys on
2007
- the text with the page's patterns only (`llm_key`), as 3.0.1 did."""
2048
+ the text with the page's patterns only (`llm_key`), as 3.0.1 did, its split secrets joined first: a key
2049
+ found only once joined keys anew, so no decision 3.0.8 or earlier cached for such a turn is read (it is
2050
+ reviewed once more)."""
2008
2051
  red, found = page_redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)
2009
2052
  for k, v in found.items():
2010
2053
  counts[k] = counts.get(k, 0) + v
2011
2054
  hit = any(name in found for name, _ in REVIEW_SECRET_PATTERNS)
2012
- key_text = redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)[0] if hit else red
2055
+ key_text = (redact(_rejoin_secrets(t["text"], args.redact_extra), extra=args.redact_extra,
2056
+ mode=args.redact_mode)[0] if hit else red)
2013
2057
  preview, clipped = _preview(review_redact(t["text"], args.redact_extra)[0])
2014
2058
  session = t.get("session") or session_id
2015
2059
  context = _decision_context(why, _keeps(args))
@@ -2025,6 +2069,8 @@ def _pack_turn(n, t, tid, why, session_id, args, counts):
2025
2069
  "llm": dict(cached, cached=True) if cached else {"keep": None, "summary": None}}
2026
2070
  if t.get("meta"):
2027
2071
  turn["meta"] = True
2072
+ if t["role"] == "user" and _bang_output(t["text"]):
2073
+ turn["bang_output"] = True # for the dropped-turn WARN only: no part file carries it
2028
2074
  for k in ("session", "session_name", "agent_from"):
2029
2075
  if t.get(k):
2030
2076
  turn[k] = t[k] if k == "session" else page_redact(t[k], args.redact_extra, args.redact_mode)[0]
@@ -2093,7 +2139,8 @@ def _sheet(k, of, part_path, group, args):
2093
2139
  turns = []
2094
2140
  for x, view in group:
2095
2141
  y = {key: v for key, v in x.items()
2096
- if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key")}
2142
+ if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key",
2143
+ "bang_output")}
2097
2144
  y["rule"] = {k: v for k, v in x["rule"].items() if k != "review_summary"}
2098
2145
  y["rule"]["summary"] = view["summary"]
2099
2146
  y["tools"] = {_hide_keywords(name, args.redact_extra): n for name, n in x["tools"].items()}
@@ -2348,7 +2395,7 @@ def _show(x, llm_summary, turns, summaries, stats, prior):
2348
2395
  if isinstance(v, int):
2349
2396
  prior[k] = prior.get(k, 0) + v
2350
2397
  # what the gate's critic reads: the reviewer's summary, else the rules' cut from the
2351
- # fully redacted text (the page's is cut first and may keep half a secret)
2398
+ # fully redacted text (the page's may hold a --redact-mode mask: 4 + 4 characters of a value)
2352
2399
  sample = (llm_summary if shows else None) or x["rule"].get("review_summary")
2353
2400
  turns.append({"uuid": x["id"], "role": x["role"], "text": x["text"],
2354
2401
  "ts": x.get("ts") or None, "tools": dict(x.get("tools") or {}),
@@ -2370,6 +2417,23 @@ def _kept_by_flag(pack, x):
2370
2417
  return pack.get("keep_tool_only") is True and tool_only
2371
2418
 
2372
2419
 
2420
+ def _bang_output(text):
2421
+ """Whether `text` is the record Claude Code writes after a `!` command and nothing else (2.1.304, no isMeta): a
2422
+ <bash-stdout> block then a <bash-stderr> block, or one <bash-stderr> block when the command failed. String checks
2423
+ only, linear in the text: a pattern repeating the blocks could take exponential time on many closing tags."""
2424
+ if text.startswith("<bash-stdout>"):
2425
+ return "</bash-stdout><bash-stderr>" in text and text.endswith("</bash-stderr>")
2426
+ return text.startswith("<bash-stderr>") and text.endswith("</bash-stderr>")
2427
+
2428
+
2429
+ def _typed_by_user(x):
2430
+ """Whether pack turn `x` holds words the user typed: a user turn the rules keep that is neither a body the
2431
+ harness injected (meta) nor what Claude Code writes after a `!` command (bang_output, judged on the text before
2432
+ redaction: a hidden value at the end of the output takes the closing tags with it)."""
2433
+ return (x["role"] == "user" and x["rule"]["keep"] and not x.get("meta")
2434
+ and x.get("bang_output") is not True)
2435
+
2436
+
2373
2437
  def _review_result(pack, merged, args):
2374
2438
  """(turns to render, their summaries, cache writes, counts, prior redactions, the ids of
2375
2439
  turns the user typed that the reviewers dropped) from the pack and the decisions: a
@@ -2384,7 +2448,7 @@ def _review_result(pack, merged, args):
2384
2448
  held = llm_keep is False and _kept_by_flag(pack, x)
2385
2449
  stats["flag-kept"] += held
2386
2450
  keep = bool(_first(None if held else llm_keep, x["rule"]["keep"]))
2387
- if not keep and x["role"] == "user" and x["rule"]["keep"] and not x.get("meta"):
2451
+ if not keep and _typed_by_user(x):
2388
2452
  typed.append(x["id"]) # typed by the user: the rules keep these
2389
2453
  llm_summary = _reviewer_summary(x, d, args, stats)
2390
2454
  if x["id"] in merged: # reviewed, even if left to the rules
@@ -2613,114 +2677,149 @@ def _flag_clash(args):
2613
2677
  return None
2614
2678
 
2615
2679
 
2616
- def main(argv=None) -> int:
2617
- args = build_arg_parser().parse_args(argv)
2618
-
2680
+ def _merge_env_extra(args):
2681
+ """LINEAGE_REDACT_EXTRA's keywords after --redact-extra's."""
2619
2682
  env_extra = os.environ.get("LINEAGE_REDACT_EXTRA", "").strip()
2620
2683
  if env_extra:
2621
2684
  args.redact_extra = ",".join(
2622
2685
  p for p in (args.redact_extra, env_extra) if p)
2623
2686
 
2624
- clash = _flag_clash(args)
2625
- if clash:
2626
- print(f"[lineage] ERROR: {clash}", file=sys.stderr)
2627
- return 2
2628
2687
 
2629
- if args.purge_cache:
2630
- import shutil
2631
- if CACHE_BASE.exists():
2632
- shutil.rmtree(CACHE_BASE)
2633
- print(f"[lineage] cache purged: {CACHE_BASE}", file=sys.stderr)
2634
- else:
2635
- print("[lineage] no cache to purge", file=sys.stderr)
2636
- return 0
2688
+ def _purge_cache():
2689
+ import shutil
2690
+ if CACHE_BASE.exists():
2691
+ shutil.rmtree(CACHE_BASE)
2692
+ print(f"[lineage] cache purged: {CACHE_BASE}", file=sys.stderr)
2693
+ else:
2694
+ print("[lineage] no cache to purge", file=sys.stderr)
2695
+ return 0
2637
2696
 
2638
- if args.apply_review:
2639
- if any((args.last, args.from_, args.to, args.turns, args.session,
2640
- args.all_sessions, args.from_transcript)):
2641
- print("[lineage] note: --apply-review renders the pack; source and range "
2642
- "flags were applied at --emit-review", file=sys.stderr)
2643
- return apply_review(args)
2644
2697
 
2645
- # ---- Resolve input → raw turns (parse + parts + session) ----
2698
+ def _run_apply(args):
2699
+ if any((args.last, args.from_, args.to, args.turns, args.session,
2700
+ args.all_sessions, args.from_transcript)):
2701
+ print("[lineage] note: --apply-review renders the pack; source and range "
2702
+ "flags were applied at --emit-review", file=sys.stderr)
2703
+ return apply_review(args)
2704
+
2705
+
2706
+ def _read_all_sessions(args):
2707
+ """Every session of the project folder, merged record by record in time order."""
2708
+ files = project_jsonl_files()
2709
+ if not files:
2710
+ print("[lineage] --all-sessions: no jsonl found in project dir",
2711
+ file=sys.stderr)
2712
+ return 2, [], "session", None
2713
+ starts = {p: session_start(p) for p in files}
2646
2714
  turns = []
2647
- session_id = "session"
2648
- jsonl_path = None
2715
+ for p in files:
2716
+ name = discover_session_name(p) or p.stem[:8]
2717
+ part = _load_turns_from(p, args.unsafe_schema, p.stem, name)
2718
+ fill_missing_ts(part)
2719
+ for t in part:
2720
+ t["session_ts"] = starts[p] or "9999"
2721
+ turns.extend(part)
2722
+ # record-level chronological sort (stable → same-ts keeps file order)
2723
+ turns.sort(key=lambda t: t.get("ts") or t.get("session_ts") or "9999")
2724
+ print(f"[lineage] --all-sessions: merged {len(files)} sessions",
2725
+ file=sys.stderr)
2726
+ return None, turns, "all-sessions", None
2727
+
2728
+
2729
+ def _read_transcript(args):
2730
+ """A transcript pasted on stdin ('-') or read from a file."""
2731
+ if args.from_transcript == "-":
2732
+ turns = list(parse_turns(sys.stdin, unsafe_schema=args.unsafe_schema,
2733
+ session_id="stdin"))
2734
+ return None, turns, "stdin", None
2735
+ jsonl_path = pathlib.Path(args.from_transcript)
2736
+ session_id = "file-" + jsonl_path.stem
2737
+ turns = _load_turns_from(jsonl_path, args.unsafe_schema, session_id, None)
2738
+ return None, turns, session_id, jsonl_path
2739
+
2740
+
2741
+ def _read_discovered(args):
2742
+ """The newest session of the project folder."""
2743
+ f = auto_discover_jsonl()
2744
+ if not f:
2745
+ print("[lineage] No jsonl found. Use one of:",
2746
+ "\n --session FILE explicit jsonl path",
2747
+ "\n --all-sessions merge all project sessions",
2748
+ "\n --from-transcript - paste transcript via stdin",
2749
+ "\n --from-transcript FILE read transcript from file",
2750
+ "\n(non-ASCII cwd may not auto-encode — use --session)",
2751
+ file=sys.stderr)
2752
+ return 2, [], "session", None
2753
+ turns = _load_turns_from(f, args.unsafe_schema, f.stem, None)
2754
+ print(f"[lineage] auto-discovered session: {f.name}", file=sys.stderr)
2755
+ return None, turns, f.stem, f
2756
+
2757
+
2758
+ def _read_input(args):
2759
+ """(exit code or None, raw turns, session id, jsonl path) from the source the flags name. The code is 2 when
2760
+ there is no source, or the code a schema marker stopped the read with."""
2649
2761
  try:
2650
2762
  if args.all_sessions:
2651
- files = project_jsonl_files()
2652
- if not files:
2653
- print("[lineage] --all-sessions: no jsonl found in project dir",
2654
- file=sys.stderr)
2655
- return 2
2656
- starts = {p: session_start(p) for p in files}
2657
- for p in files:
2658
- name = discover_session_name(p) or p.stem[:8]
2659
- part = _load_turns_from(p, args.unsafe_schema, p.stem, name)
2660
- fill_missing_ts(part)
2661
- for t in part:
2662
- t["session_ts"] = starts[p] or "9999"
2663
- turns.extend(part)
2664
- # record-level chronological sort (stable → same-ts keeps file order)
2665
- turns.sort(key=lambda t: t.get("ts") or t.get("session_ts") or "9999")
2666
- session_id = "all-sessions"
2667
- print(f"[lineage] --all-sessions: merged {len(files)} sessions",
2668
- file=sys.stderr)
2669
- elif args.from_transcript:
2670
- if args.from_transcript == "-":
2671
- turns = list(parse_turns(sys.stdin, unsafe_schema=args.unsafe_schema,
2672
- session_id="stdin"))
2673
- session_id = "stdin"
2674
- else:
2675
- jsonl_path = pathlib.Path(args.from_transcript)
2676
- session_id = "file-" + jsonl_path.stem
2677
- turns = _load_turns_from(jsonl_path, args.unsafe_schema,
2678
- session_id, None)
2679
- elif args.session:
2763
+ return _read_all_sessions(args)
2764
+ if args.from_transcript:
2765
+ return _read_transcript(args)
2766
+ if args.session:
2680
2767
  jsonl_path = pathlib.Path(args.session)
2681
- session_id = jsonl_path.stem
2682
- turns = _load_turns_from(jsonl_path, args.unsafe_schema,
2683
- session_id, None)
2684
- else:
2685
- f = auto_discover_jsonl()
2686
- if not f:
2687
- print("[lineage] No jsonl found. Use one of:",
2688
- "\n --session FILE explicit jsonl path",
2689
- "\n --all-sessions merge all project sessions",
2690
- "\n --from-transcript - paste transcript via stdin",
2691
- "\n --from-transcript FILE read transcript from file",
2692
- "\n(non-ASCII cwd may not auto-encode — use --session)",
2693
- file=sys.stderr)
2694
- return 2
2695
- jsonl_path = f
2696
- session_id = f.stem
2697
- turns = _load_turns_from(f, args.unsafe_schema, session_id, None)
2698
- print(f"[lineage] auto-discovered session: {f.name}", file=sys.stderr)
2768
+ turns = _load_turns_from(jsonl_path, args.unsafe_schema, jsonl_path.stem, None)
2769
+ return None, turns, jsonl_path.stem, jsonl_path
2770
+ return _read_discovered(args)
2699
2771
  except SystemExit as e:
2700
- return int(e.code) if isinstance(e.code, int) else 2
2772
+ return (int(e.code) if isinstance(e.code, int) else 2), [], "session", None
2701
2773
 
2702
- # stdin → derive per-turn uuid from the content so the caches work (keyed: see _stdin_id_key)
2703
- if args.from_transcript == "-":
2704
- key = _stdin_id_key()
2705
- for t in turns:
2706
- t["uuid"] = hmac.new(key, str(t["text"]).encode(), hashlib.sha256).hexdigest()[:16]
2707
2774
 
2708
- # ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
2709
- turns = classify_turns(turns, drop_trivia=args.drop_trivia)
2710
- turns = merge_assistant_runs(turns)
2711
- if args.emit_review:
2712
- drops = heuristic_drops(turns, args)
2713
- window = review_window(turns, drops, args)
2714
- if window is None:
2715
- return 2
2716
- return emit_review(turns, drops, window, session_id,
2717
- default_output(args, jsonl_path, session_id), args)
2775
+ def _stdin_ids(turns):
2776
+ """stdin → derive per-turn uuid from the content so the caches work (keyed: see _stdin_id_key)."""
2777
+ key = _stdin_id_key()
2778
+ for t in turns:
2779
+ t["uuid"] = hmac.new(key, str(t["text"]).encode(), hashlib.sha256).hexdigest()[:16]
2780
+
2781
+
2782
+ def _run_emit(turns, session_id, jsonl_path, args):
2783
+ drops = heuristic_drops(turns, args)
2784
+ window = review_window(turns, drops, args)
2785
+ if window is None:
2786
+ return 2
2787
+ return emit_review(turns, drops, window, session_id,
2788
+ default_output(args, jsonl_path, session_id), args)
2789
+
2790
+
2791
+ def _rulebase_turns(turns, args):
2792
+ """The turns a --rulebase page shows: echo exchanges and tool-only turns gone (unless kept), then the range.
2793
+ None on an invalid --turns."""
2718
2794
  if args.drop_trivia:
2719
2795
  turns = drop_echo_exchanges(turns)
2720
2796
  if args.hide_tool_only:
2721
2797
  turns = [t for t in turns
2722
2798
  if not (t["role"] == "assistant" and not t["text"].strip())]
2723
- turns = select_range(turns, args)
2799
+ return select_range(turns, args)
2800
+
2801
+
2802
+ def main(argv=None) -> int:
2803
+ args = build_arg_parser().parse_args(argv)
2804
+ _merge_env_extra(args)
2805
+ clash = _flag_clash(args)
2806
+ if clash:
2807
+ print(f"[lineage] ERROR: {clash}", file=sys.stderr)
2808
+ return 2
2809
+ if args.purge_cache:
2810
+ return _purge_cache()
2811
+ if args.apply_review:
2812
+ return _run_apply(args)
2813
+ rc, turns, session_id, jsonl_path = _read_input(args)
2814
+ if rc is not None:
2815
+ return rc
2816
+ if args.from_transcript == "-":
2817
+ _stdin_ids(turns)
2818
+ # ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
2819
+ turns = merge_assistant_runs(classify_turns(turns, drop_trivia=args.drop_trivia))
2820
+ if args.emit_review:
2821
+ return _run_emit(turns, session_id, jsonl_path, args)
2822
+ turns = _rulebase_turns(turns, args)
2724
2823
  if turns is None:
2725
2824
  return 2
2726
2825
  if args.output is None:
@@ -2823,8 +2922,8 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
2823
2922
  date_range = first if first == last else f"{first} ~ {last}"
2824
2923
 
2825
2924
  html_doc = HTML_TEMPLATE
2826
- html_doc = html_doc.replace("{{TITLE}}", html.escape(title), 1)
2827
- html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(title), 1)
2925
+ html_doc = html_doc.replace("{{TITLE}}", html.escape(_show_bidi_controls(title)), 1)
2926
+ html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(_show_bidi_controls(title)), 1)
2828
2927
  html_doc = html_doc.replace("{{DATE_RANGE}}", html.escape(_show_bidi_controls(date_range)), 1)
2829
2928
  html_doc = html_doc.replace("{{TURNS}}", _show_bidi_controls("\n".join(rows)), 1)
2830
2929