@kaydash9999/banker-plugins 0.20.2 → 0.21.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 (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/CHANGELOG.md +203 -1
  4. package/PRIVACY.md +34 -4
  5. package/README.md +46 -15
  6. package/bin/banker.js +228 -51
  7. package/bin/lib/codex-hooks.mjs +249 -0
  8. package/bin/lib/telemetry-config.mjs +33 -16
  9. package/codex/manifest.json +14 -11
  10. package/codex/skills/graceful-pause/SKILL.md +30 -0
  11. package/codex/skills/progress/SKILL.md +42 -0
  12. package/codex/skills/progress/scripts/progress-plan.mjs +722 -0
  13. package/codex/skills/progress/scripts/progress.mjs +295 -0
  14. package/codex/transform-matrix.md +19 -14
  15. package/commands/setup.md +23 -0
  16. package/hooks/obsidize-hook.mjs +116 -4
  17. package/hooks/progress-plan.mjs +1 -1
  18. package/hooks/progress.mjs +143 -38
  19. package/hooks/register.mjs +13 -3
  20. package/hooks/telemetry-count-codex.mjs +222 -0
  21. package/hooks/update-checkin.mjs +15 -7
  22. package/hooks/update-fetch.mjs +15 -9
  23. package/hooks/update-notify.mjs +30 -17
  24. package/package.json +1 -1
  25. package/skills/3d-intro-build/SKILL.md +11 -5
  26. package/skills/3d-intro-build/references/azure-adapter.mjs +374 -81
  27. package/skills/3d-intro-build/references/curate.mjs +48 -14
  28. package/skills/3d-intro-build/references/preview-lib.mjs +51 -15
  29. package/skills/3d-intro-build/references/prompts.md +4 -4
  30. package/skills/3d-intro-build/references/serve.mjs +6 -1
  31. package/skills/3d-intro-build/references/verify-intro.mjs +16 -9
  32. package/skills/3d-intro-build/references/video-pool.mjs +250 -135
  33. package/skills/3d-intro-setup/SKILL.md +41 -10
  34. package/skills/3d-intro-setup/references/azure-adapter.mjs +374 -81
  35. package/skills/3d-intro-setup/references/video-pool.mjs +250 -135
  36. package/skills/anti-drm/SKILL.md +6 -3
  37. package/skills/anti-drm/scripts/anti-drm.mjs +49 -12
  38. package/skills/anti-drm/scripts/instruction-toggle.mjs +105 -20
  39. package/skills/append_wiki/SKILL.md +34 -8
  40. package/skills/audit-mock/SKILL.md +1 -1
  41. package/skills/audit-security/SKILL.md +64 -2
  42. package/skills/compact-wiki/SKILL.md +37 -6
  43. package/skills/deep-research/SKILL.md +11 -0
  44. package/skills/docs-setup/SKILL.md +37 -7
  45. package/skills/lfm/SKILL.md +148 -0
  46. package/skills/lineage/SKILL.md +25 -15
  47. package/skills/lineage/lineage.py +564 -301
  48. package/skills/motion-graphic-make/SKILL.md +1 -1
  49. package/skills/motion-graphic-make/references/curate.mjs +48 -14
  50. package/skills/motion-graphic-make/references/preview-lib.mjs +51 -15
  51. package/skills/motion-graphic-make/references/serve.mjs +6 -1
  52. package/skills/motion-graphic-setup/SKILL.md +58 -13
  53. package/skills/obsidizer/SKILL.md +30 -11
  54. package/skills/obsidizer/obsidize.mjs +50 -12
  55. package/skills/omc-patch/SKILL.md +86 -23
  56. package/skills/omc-patch/scripts/omc-patch.mjs +460 -116
  57. package/skills/payload-mon/SKILL.md +6 -2
  58. package/skills/payload-mon/scripts/payload-mon.mjs +88 -12
  59. package/skills/payload-mon/scripts/payload-size.mjs +4 -1
  60. package/skills/pdf-vision-extract/SKILL.md +11 -6
  61. package/skills/ralph-qa/SKILL.md +125 -151
  62. package/skills/ralph-qa/references/gemini-seat.mjs +67 -19
  63. package/skills/ralph-qa/references/payload-scan.mjs +22 -7
  64. package/skills/ralph-qa/references/seat-payload.mjs +646 -0
  65. package/skills/ralph-qa/references/seat-run.mjs +956 -0
  66. package/skills/ralph-qa/references/seat-spawn.mjs +205 -0
  67. package/skills/ralph-qa/references/verifier-probe.mjs +57 -16
  68. package/skills/rfp-author/SKILL.md +5 -3
  69. package/skills/rfp-author/references/vendor-rfp-structure.md +1 -1
  70. package/skills/setup-bypass-permissions/SKILL.md +27 -9
  71. package/skills/setup-bypass-permissions/scripts/codex-bypass.mjs +135 -0
  72. package/skills/setup-bypass-permissions/scripts/codex-config.mjs +210 -0
  73. package/skills/setup-harness-factory/SKILL.md +17 -1
  74. package/skills/setup-insane-search/SKILL.md +16 -0
  75. package/skills/setup-java/SKILL.md +20 -1
  76. package/skills/setup-lsp/SKILL.md +56 -13
  77. package/skills/setup-mcp/SKILL.md +73 -4
  78. package/skills/setup-node/SKILL.md +31 -6
  79. package/skills/setup-omc/SKILL.md +24 -0
  80. package/skills/setup-omc-hud/SKILL.md +42 -9
  81. package/skills/setup-omc-hud/assets/apply-hud.mjs +242 -27
  82. package/skills/setup-omc-hud/assets/codex-config.mjs +210 -0
  83. package/skills/setup-omc-hud/assets/codex-status-line.mjs +90 -0
  84. package/skills/setup-omc-hud/assets/omc-hud-custom.mjs +81 -14
  85. package/skills/setup-omc-hud/scripts/claude-update-last.mjs +5 -3
  86. package/skills/setup-playwright/SKILL.md +45 -2
  87. package/skills/setup-pwsh/SKILL.md +19 -0
  88. package/skills/setup-python/SKILL.md +45 -7
  89. package/skills/setup-sandbox/SKILL.md +30 -5
  90. package/skills/setup-stitch/SKILL.md +22 -0
  91. package/skills/setup-tmux/SKILL.md +24 -4
  92. package/skills/smart-compact/SKILL.md +58 -12
  93. package/skills/smart-compact/scripts/codex-gate.mjs +384 -0
  94. package/skills/tone-compact/SKILL.md +2 -1
  95. package/skills/tone-compact/scripts/instruction-toggle.mjs +105 -20
  96. package/skills/ultra-chain/SKILL.md +12 -7
  97. package/skills/ultra-chain/references/preview-lib.mjs +51 -15
  98. package/skills/ultra-chain/references/serve.mjs +6 -1
  99. package/skills/ultra-interview/SKILL.md +14 -2
  100. package/skills/update-banker/SKILL.md +19 -3
  101. package/skills/vertical-pptx/SKILL.md +6 -3
  102. 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]
400
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
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
@@ -698,7 +723,68 @@ def encode_cwd(cwd) -> str:
698
723
  return re.sub(r"[^A-Za-z0-9]", "-", str(cwd))
699
724
 
700
725
 
726
+ def codex_sessions_dir():
727
+ """Where Codex keeps its session records (rollouts): <CODEX_HOME>/sessions/YYYY/MM/DD/rollout-*.jsonl."""
728
+ home = os.environ.get("CODEX_HOME") or str(pathlib.Path.home() / ".codex")
729
+ return pathlib.Path(home) / "sessions"
730
+
731
+
732
+ def _codex_thread():
733
+ """This Codex session's id when lineage runs inside Codex (Codex gives its shell CODEX_THREAD_ID, the id at the
734
+ end of the rollout file name), else None."""
735
+ tid = os.environ.get("CODEX_THREAD_ID", "")
736
+ return tid if re.fullmatch(r"[0-9A-Za-z-]{8,64}", tid) else None
737
+
738
+
739
+ def _mtime(path):
740
+ """A file's change time, or 0 for one that went away between the listing and the look."""
741
+ try:
742
+ return path.stat().st_mtime
743
+ except OSError:
744
+ return 0
745
+
746
+
747
+ def _codex_rollouts(pattern):
748
+ try:
749
+ found = list(codex_sessions_dir().glob(f"*/*/*/{pattern}"))
750
+ except OSError:
751
+ return []
752
+ return sorted(found, key=_mtime)
753
+
754
+
755
+ def _codex_meta(path):
756
+ """The session_meta payload a rollout opens with, else None (not a Codex rollout, or unreadable). Only a regular
757
+ file is opened, so a named pipe is not waited on."""
758
+ try:
759
+ if not stat.S_ISREG(os.stat(str(path)).st_mode):
760
+ return None
761
+ with open(path, encoding="utf-8") as f:
762
+ obj = json.loads(f.readline())
763
+ return obj["payload"] if obj.get("type") == "session_meta" and isinstance(obj.get("payload"), dict) else None
764
+ except (OSError, UnicodeDecodeError, ValueError, RecursionError, KeyError, TypeError, AttributeError):
765
+ return None
766
+
767
+
768
+ def _codex_cwd(path):
769
+ """The cwd a rollout's first record (session_meta) names, else None. A subagent's rollout (its source names
770
+ the parent thread) gives None: what the parent handed it reads as a user turn, so it is no session of the user."""
771
+ try:
772
+ with open(path, encoding="utf-8") as f:
773
+ obj = json.loads(f.readline())
774
+ meta = obj["payload"] if obj.get("type") == "session_meta" else {}
775
+ source = meta.get("source")
776
+ if isinstance(source, dict) and "subagent" in source:
777
+ return None
778
+ return meta.get("cwd")
779
+ except (OSError, UnicodeDecodeError, ValueError, RecursionError, KeyError, TypeError, AttributeError):
780
+ return None
781
+
782
+
701
783
  def auto_discover_jsonl():
784
+ tid = _codex_thread()
785
+ if tid: # inside Codex: this session or nothing, never a Claude Code one
786
+ found = _codex_rollouts(f"rollout-*-{tid}.jsonl")
787
+ return found[-1] if found else None
702
788
  projdir = pathlib.Path.home() / ".claude" / "projects" / encode_cwd(
703
789
  pathlib.Path.cwd())
704
790
  if not projdir.exists():
@@ -712,7 +798,10 @@ def auto_discover_jsonl():
712
798
 
713
799
 
714
800
  def project_jsonl_files():
715
- """All *.jsonl in the encoded project dir, for --all-sessions."""
801
+ """All *.jsonl in the encoded project dir, for --all-sessions. Inside Codex, the Codex sessions of this folder."""
802
+ if _codex_thread():
803
+ here = str(pathlib.Path.cwd())
804
+ return [p for p in _codex_rollouts("rollout-*.jsonl") if _codex_cwd(p) == here]
716
805
  projdir = pathlib.Path.home() / ".claude" / "projects" / encode_cwd(
717
806
  pathlib.Path.cwd())
718
807
  if not projdir.exists():
@@ -884,6 +973,74 @@ def _content_parts(content, line_no):
884
973
  return text_parts, tools
885
974
 
886
975
 
976
+ def _is_codex_meta(obj):
977
+ """Whether a record opens a Codex session record (rollout): a session_meta with a payload."""
978
+ return obj.get("type") == "session_meta" and isinstance(obj.get("payload"), dict)
979
+
980
+
981
+ _CODEX_ITEM_ROLE = {"UserMessage": "user", "AgentMessage": "assistant"}
982
+ _CODEX_INNER_TOOL_RE = re.compile(r"\btools\.([A-Za-z_][A-Za-z0-9_]{0,63})\(")
983
+
984
+
985
+ def _codex_item_text(item):
986
+ """The text parts of a completed item (Codex 0.160: content [{type: text|Text, text}])."""
987
+ content = item.get("content")
988
+ if not isinstance(content, list):
989
+ return ""
990
+ return "\n".join(c["text"] for c in content if isinstance(c, dict) and isinstance(c.get("text"), str))
991
+
992
+
993
+ def _codex_tools(p, pt):
994
+ """Tool counts of one call record. Codex 0.160 runs tools from code (`exec` with `tools.exec_command(...)` in its
995
+ input), so the inner tool names count, not the wrapper."""
996
+ name = p.get("name")
997
+ name = name if isinstance(name, str) and name else pt[:-5]
998
+ inner = _CODEX_INNER_TOOL_RE.findall(p.get("input")) if isinstance(p.get("input"), str) else []
999
+ counts = {}
1000
+ for n in inner or [name]:
1001
+ counts[n] = counts.get(n, 0) + 1
1002
+ return counts
1003
+
1004
+
1005
+ def _codex_record(obj):
1006
+ """(role, text, tools) a Codex rollout record shows as a turn, else None. What the user typed and what the
1007
+ model said come from the event records (0.144: user_message, agent_message; 0.160: item_completed with a
1008
+ UserMessage or AgentMessage item), so what Codex puts into the model input (environment, instructions, an
1009
+ injected skill body) is not a turn; a tool call is an assistant record with no text, merged into the turn around
1010
+ it like a Claude Code tool_use; a compaction is a divider."""
1011
+ p = obj.get("payload")
1012
+ kind = obj.get("type")
1013
+ if kind == "compacted":
1014
+ return "mark", "compaction", {}
1015
+ if not isinstance(p, dict):
1016
+ return None
1017
+ pt = p.get("type")
1018
+ if kind == "event_msg" and pt in ("user_message", "agent_message"):
1019
+ msg = p.get("message")
1020
+ return ("user" if pt == "user_message" else "assistant"), (msg if isinstance(msg, str) else ""), {}
1021
+ item = p.get("item")
1022
+ if kind == "event_msg" and pt == "item_completed" and isinstance(item, dict) and item.get("type") in _CODEX_ITEM_ROLE:
1023
+ return _CODEX_ITEM_ROLE[item["type"]], _codex_item_text(item), {}
1024
+ if kind == "response_item" and isinstance(pt, str) and pt.endswith("_call"):
1025
+ return "assistant", "", _codex_tools(p, pt)
1026
+ return None
1027
+
1028
+
1029
+ def _codex_turn(obj, line_no, session_id, session_name):
1030
+ """The turn dict of one Codex rollout record, else None."""
1031
+ got = _codex_record(obj)
1032
+ if got is None:
1033
+ return None
1034
+ role, text, tools = got
1035
+ text = _CTRL_RE.sub("", text.strip())
1036
+ if not text and not tools:
1037
+ return None
1038
+ role, text, tools, ts, uuid_ = _clean_strings(line_no, role, text, tools, _stamp(obj.get("timestamp")), None)
1039
+ uuid_ = _turn_id(uuid_, line_no, ts)
1040
+ return {"role": role, "text": text, "ts": ts, "uuid": uuid_, "tools": tools, "line_no": line_no,
1041
+ "parts": [text] if text else [], "session": session_id, "session_name": session_name, "meta": False}
1042
+
1043
+
887
1044
  def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
888
1045
  session_name=None):
889
1046
  """Yield dicts: {role, text, ts, uuid, tools, line_no, parts, session,
@@ -894,11 +1051,23 @@ def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
894
1051
  needs it) and `session` (session guards for merge/echo).
895
1052
  """
896
1053
  seen_unknown_type = set()
1054
+ codex = None # set by the first record: a Codex rollout or a Claude transcript
1055
+ last_said = None # Codex: (role, text) of the last message, so one said twice counts once
897
1056
  for line_no, raw in enumerate(stream, 1):
898
1057
  raw = raw.strip()
899
1058
  if not raw:
900
1059
  continue
901
1060
  obj = _json_object(raw, line_no)
1061
+ if obj is not None and codex is None:
1062
+ codex = _is_codex_meta(obj)
1063
+ if obj is not None and codex:
1064
+ turn = _codex_turn(obj, line_no, session_id, session_name)
1065
+ said = (turn["role"], turn["text"]) if turn and turn["text"] else None
1066
+ if turn and not (said and said == last_said):
1067
+ yield turn
1068
+ if said:
1069
+ last_said = said
1070
+ continue
902
1071
  if obj is None or not _is_turn_record(obj, seen_unknown_type, unsafe_schema, line_no):
903
1072
  continue
904
1073
  msg = obj.get("message") or {}
@@ -1210,7 +1379,8 @@ def _read_text(path):
1210
1379
 
1211
1380
 
1212
1381
  def _read_cached(p):
1213
- """The summary cached at p, else at the same name with .txt, which 3.0.2 and earlier wrote.
1382
+ """The summary cached at p, else at the same name with .txt, the name 3.0.2 and earlier wrote (their digests
1383
+ carry summarizer version 2 or 3, so since version 4 no file of theirs is found).
1214
1384
  None when neither holds one: missing, bytes that are not UTF-8 (a DRM encrypted them), or
1215
1385
  nothing a person can see (a write that stopped early, or only blank-looking letters); the
1216
1386
  turn is then summarized again (3.0.4, 3.0.5)."""
@@ -1300,11 +1470,13 @@ def _is_block_start(lines, i):
1300
1470
  if (_FENCE_RE.match(line) or _HEAD_RE.match(line) or _HR_RE.match(line)
1301
1471
  or _QUOTE_RE.match(line) or _LIST_RE.match(line)):
1302
1472
  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
1473
+ return _is_table_start(lines, i)
1474
+
1475
+
1476
+ def _is_table_start(lines, i):
1477
+ """Whether lines[i] starts a table: it has a pipe AND the next line is a delimiter row."""
1478
+ return bool("|" in lines[i] and i + 1 < len(lines)
1479
+ and "-" in lines[i + 1] and _TABLE_DELIM_RE.match(lines[i + 1]))
1308
1480
 
1309
1481
 
1310
1482
  def _render_table(lines, i):
@@ -1323,85 +1495,90 @@ def _render_table(lines, i):
1323
1495
  % (thead, body)), i
1324
1496
 
1325
1497
 
1498
+ def _md_fence(lines, i, fence):
1499
+ """A code block from the fence at lines[i] to the first closing fence at least as long (or the end), and the
1500
+ index past it."""
1501
+ i += 1
1502
+ buf = []
1503
+ while i < len(lines):
1504
+ mc = _FENCE_CLOSE_RE.match(lines[i])
1505
+ if mc and len(mc.group(1)) >= len(fence):
1506
+ i += 1
1507
+ break
1508
+ buf.append(lines[i])
1509
+ i += 1
1510
+ return "<pre><code>%s</code></pre>" % html.escape("\n".join(buf)), i
1511
+
1512
+
1513
+ def _md_quote(lines, i, depth):
1514
+ """The blockquote of the run of `>` lines at lines[i], rendered one level deeper, and the index past it."""
1515
+ buf = []
1516
+ while i < len(lines) and _QUOTE_RE.match(lines[i]):
1517
+ buf.append(re.sub(r"^\s*>\s?", "", lines[i]))
1518
+ i += 1
1519
+ return "<blockquote>%s</blockquote>" % render_markdown("\n".join(buf), depth + 1), i
1520
+
1521
+
1522
+ def _md_list(lines, i):
1523
+ """The list of the run of list lines at lines[i] (ordered when the first is numbered; nested ones flatten),
1524
+ and the index past it."""
1525
+ tag = "ol" if re.match(r"^\s*\d+[.)]", lines[i]) else "ul"
1526
+ items = []
1527
+ while i < len(lines):
1528
+ mli = _LIST_RE.match(lines[i])
1529
+ if not mli:
1530
+ break
1531
+ items.append(_md_inline(html.escape(mli.group(3))))
1532
+ i += 1
1533
+ return "<%s>%s</%s>" % (tag, "".join("<li>%s</li>" % it for it in items), tag), i
1534
+
1535
+
1536
+ def _md_paragraph(lines, i):
1537
+ """The paragraph that starts at lines[i] (at least that line: C-2, no endless loop), its lines joined with <br>
1538
+ (N5: pre-wrap keeps leading spaces without a second break), and the index past it."""
1539
+ buf = [lines[i]]
1540
+ i += 1
1541
+ while i < len(lines) and lines[i].strip() and not _is_block_start(lines, i):
1542
+ buf.append(lines[i])
1543
+ i += 1
1544
+ return "<p>%s</p>" % "<br>".join(_md_inline(html.escape(b)) for b in buf), i
1545
+
1546
+
1547
+ def _md_block(lines, i, depth):
1548
+ """The HTML of the block that starts at lines[i] (None for a blank line), and the index past it."""
1549
+ line = lines[i]
1550
+ mf = _FENCE_RE.match(line)
1551
+ if mf:
1552
+ return _md_fence(lines, i, mf.group(1))
1553
+ mh = _HEAD_RE.match(line)
1554
+ if mh:
1555
+ hl = max(3, min(6, len(mh.group(1)))) # h3..h6 (avoid page-header clash)
1556
+ return "<h%d>%s</h%d>" % (hl, _md_inline(html.escape(mh.group(2).strip())), hl), i + 1
1557
+ if _HR_RE.match(line):
1558
+ return "<hr>", i + 1
1559
+ if _QUOTE_RE.match(line):
1560
+ return _md_quote(lines, i, depth)
1561
+ if _is_table_start(lines, i):
1562
+ return _render_table(lines, i)
1563
+ if _LIST_RE.match(line):
1564
+ return _md_list(lines, i)
1565
+ if not line.strip():
1566
+ return None, i + 1
1567
+ return _md_paragraph(lines, i)
1568
+
1569
+
1326
1570
  def render_markdown(text, depth=0):
1327
1571
  """Block-level markdown → HTML. External deps: none. Escapes first; no stage
1328
1572
  re-runs over prior markup. Nested lists flatten; footnotes unsupported.
1329
1573
  """
1330
1574
  if depth > _BLOCKQUOTE_MAX_DEPTH:
1331
1575
  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))
1576
+ lines = _CTRL_RE.sub("", text).split("\n")
1577
+ out, i = [], 0
1578
+ while i < len(lines):
1579
+ block, i = _md_block(lines, i, depth)
1580
+ if block is not None:
1581
+ out.append(block)
1405
1582
  return "\n".join(out)
1406
1583
 
1407
1584
 
@@ -1614,102 +1791,110 @@ def _fmt_time(ts):
1614
1791
  return ts[11:16] if ts and len(ts) >= 16 else ""
1615
1792
 
1616
1793
 
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
1794
+ class _Rows:
1795
+ """What render_rows carries from turn to turn: the rows so far, the redaction settings and what they hid by kind,
1796
+ the open flag and markdown, the date and session the last pills showed, and the rule summaries' cache hits."""
1632
1797
 
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
1798
+ def __init__(self, redact_extra, redact_mode, open_details, markdown):
1799
+ self.extra, self.mode, self.markdown = redact_extra, redact_mode, markdown
1800
+ self.open_attr = " open" if open_details else ""
1801
+ self.rows, self.counts = [], {}
1802
+ self.last_date = self.last_session = None
1803
+ self.hits = self.total = 0
1657
1804
 
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
1805
+ def red(self, text):
1806
+ r, c = page_redact(text, extra=self.extra, mode=self.mode)
1807
+ for k, v in c.items():
1808
+ self.counts[k] = self.counts.get(k, 0) + v
1809
+ return r
1676
1810
 
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
1811
 
1704
- rows.append(
1705
- '<div class="%s"><div class="avatar%s">%s</div>'
1812
+ def _pills(rs, t, all_sessions):
1813
+ """The session pill (--all-sessions, when the session changes) and the date pill (when the day changes) before
1814
+ turn `t`."""
1815
+ if all_sessions and t.get("session") != rs.last_session:
1816
+ rs.last_session = t.get("session")
1817
+ name = html.escape(rs.red(t.get("session_name") or rs.last_session or "session"))
1818
+ rs.rows.append('<div class="day" data-mark="%s">'
1819
+ '<span class="pill pill-session">%s</span></div>'
1820
+ % (name, name))
1821
+ rs.last_date = None
1822
+ ts = t.get("ts") or ""
1823
+ if ts:
1824
+ d = ts[:10]
1825
+ if d != rs.last_date:
1826
+ rs.rows.append('<div class="day" data-mark="%s">'
1827
+ '<span class="pill pill-date">%s</span></div>'
1828
+ % (html.escape(d), html.escape(d)))
1829
+ rs.last_date = d
1830
+
1831
+
1832
+ def _user_row(rs, t, summaries, time_hm):
1833
+ """A user bubble: plain, or folded under a summary (the reviewer's, else cut from it) past USER_FOLD."""
1834
+ red = rs.red(t["text"])
1835
+ if len(red) > USER_FOLD:
1836
+ reviewed = _reviewed_fold(t, summaries)
1837
+ summary = html.escape(rs.red(reviewed) if reviewed else user_fold_summary(red))
1838
+ body = render_body(red, markdown=rs.markdown)
1839
+ return ('<div class="row me"><span class="time">%s</span>'
1840
+ '<div class="bubble"><details%s>'
1841
+ '<summary><span class="sum">%s</span></summary>'
1842
+ '<div class="detail">%s</div></details></div></div>'
1843
+ % (time_hm, rs.open_attr, summary, body))
1844
+ return ('<div class="row me"><span class="time">%s</span>'
1845
+ '<div class="bubble"><div class="plain">%s</div></div></div>'
1846
+ % (time_hm, render_body(red, markdown=rs.markdown)))
1847
+
1848
+
1849
+ def _tools_line(tools, redact_extra):
1850
+ """The `🔧 도구 N건` line of a bot bubble ("" without tools), the --redact-extra keywords hidden in the names."""
1851
+ if not tools:
1852
+ return ""
1853
+ ts_list = ", ".join(f"{_hide_keywords(k, redact_extra)}×{v}"
1854
+ for k, v in sorted(tools.items()))
1855
+ return ('<div class="tools">🔧 도구 %d건: %s</div>'
1856
+ % (sum(tools.values()), html.escape(ts_list)))
1857
+
1858
+
1859
+ def _bot_row(rs, t, summary, time_hm):
1860
+ """An assistant or agent bubble: the summary, the details (body, tools) and, for an agent, who sent it."""
1861
+ esc_sum = html.escape(rs.red(summary))
1862
+ detail_body = render_body(rs.red(t["text"]), markdown=rs.markdown)
1863
+ tools_html = _tools_line(t.get("tools"), rs.extra)
1864
+ role = t["role"]
1865
+ if role == "agent":
1866
+ who = html.escape(rs.red(t.get("agent_from") or "agent"))
1867
+ avatar, from_line, row_cls = "🤝", '<div class="from">%s</div>' % who, "row bot agent"
1868
+ else:
1869
+ avatar, from_line, row_cls = "C", "", "row bot"
1870
+ return ('<div class="%s"><div class="avatar%s">%s</div>'
1706
1871
  '<div class="bubble"><details%s>'
1707
1872
  '<summary><span class="sum">%s</span></summary>'
1708
1873
  '<div class="detail">%s%s%s</div></details></div>'
1709
1874
  '<span class="time">%s</span></div>'
1710
1875
  % (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
1876
+ rs.open_attr, esc_sum, from_line, detail_body, tools_html, time_hm))
1877
+
1878
+
1879
+ def render_rows(turns, session_id, redact_extra, redact_mode, rebuild,
1880
+ open_details=False, markdown=True, all_sessions=False, summaries=None):
1881
+ rs = _Rows(redact_extra, redact_mode, open_details, markdown)
1882
+ for t in turns:
1883
+ _pills(rs, t, all_sessions)
1884
+ time_hm = html.escape(_fmt_time(t.get("ts") or ""))
1885
+ if t["role"] == "mark":
1886
+ rs.rows.append('<div class="day"><span class="pill pill-compact">'
1887
+ '⋯ 이전 대화 요약(compaction) ⋯</span></div>')
1888
+ elif t["role"] == "user":
1889
+ rs.rows.append(_user_row(rs, t, summaries, time_hm))
1890
+ else:
1891
+ summary, hit = _page_summary(t, summaries, session_id, rebuild,
1892
+ redact_extra, redact_mode)
1893
+ if hit is not None:
1894
+ rs.total += 1
1895
+ rs.hits += bool(hit)
1896
+ rs.rows.append(_bot_row(rs, t, summary, time_hm))
1897
+ return rs.rows, rs.counts, rs.hits, rs.total
1713
1898
 
1714
1899
 
1715
1900
  _TAG_RE = re.compile(r"<[^>]*>")
@@ -2004,12 +2189,15 @@ def _review_view(t, red, args):
2004
2189
  def _pack_turn(n, t, tid, why, session_id, args, counts):
2005
2190
  """One turn of the pack under its final id `tid` (the text as the page shows it, the rules'
2006
2191
  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."""
2192
+ the text with the page's patterns only (`llm_key`), as 3.0.1 did, its split secrets joined first: a key
2193
+ found only once joined keys anew, so no decision 3.0.8 or earlier cached for such a turn is read (it is
2194
+ reviewed once more)."""
2008
2195
  red, found = page_redact(t["text"], extra=args.redact_extra, mode=args.redact_mode)
2009
2196
  for k, v in found.items():
2010
2197
  counts[k] = counts.get(k, 0) + v
2011
2198
  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
2199
+ key_text = (redact(_rejoin_secrets(t["text"], args.redact_extra), extra=args.redact_extra,
2200
+ mode=args.redact_mode)[0] if hit else red)
2013
2201
  preview, clipped = _preview(review_redact(t["text"], args.redact_extra)[0])
2014
2202
  session = t.get("session") or session_id
2015
2203
  context = _decision_context(why, _keeps(args))
@@ -2025,6 +2213,8 @@ def _pack_turn(n, t, tid, why, session_id, args, counts):
2025
2213
  "llm": dict(cached, cached=True) if cached else {"keep": None, "summary": None}}
2026
2214
  if t.get("meta"):
2027
2215
  turn["meta"] = True
2216
+ if t["role"] == "user" and _bang_output(t["text"]):
2217
+ turn["bang_output"] = True # for the dropped-turn WARN only: no part file carries it
2028
2218
  for k in ("session", "session_name", "agent_from"):
2029
2219
  if t.get(k):
2030
2220
  turn[k] = t[k] if k == "session" else page_redact(t[k], args.redact_extra, args.redact_mode)[0]
@@ -2093,7 +2283,8 @@ def _sheet(k, of, part_path, group, args):
2093
2283
  turns = []
2094
2284
  for x, view in group:
2095
2285
  y = {key: v for key, v in x.items()
2096
- if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key")}
2286
+ if key not in ("text", "session", "session_name", "agent_from", "redactions", "llm_key",
2287
+ "bang_output")}
2097
2288
  y["rule"] = {k: v for k, v in x["rule"].items() if k != "review_summary"}
2098
2289
  y["rule"]["summary"] = view["summary"]
2099
2290
  y["tools"] = {_hide_keywords(name, args.redact_extra): n for name, n in x["tools"].items()}
@@ -2348,7 +2539,7 @@ def _show(x, llm_summary, turns, summaries, stats, prior):
2348
2539
  if isinstance(v, int):
2349
2540
  prior[k] = prior.get(k, 0) + v
2350
2541
  # 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)
2542
+ # fully redacted text (the page's may hold a --redact-mode mask: 4 + 4 characters of a value)
2352
2543
  sample = (llm_summary if shows else None) or x["rule"].get("review_summary")
2353
2544
  turns.append({"uuid": x["id"], "role": x["role"], "text": x["text"],
2354
2545
  "ts": x.get("ts") or None, "tools": dict(x.get("tools") or {}),
@@ -2370,6 +2561,23 @@ def _kept_by_flag(pack, x):
2370
2561
  return pack.get("keep_tool_only") is True and tool_only
2371
2562
 
2372
2563
 
2564
+ def _bang_output(text):
2565
+ """Whether `text` is the record Claude Code writes after a `!` command and nothing else (2.1.304, no isMeta): a
2566
+ <bash-stdout> block then a <bash-stderr> block, or one <bash-stderr> block when the command failed. String checks
2567
+ only, linear in the text: a pattern repeating the blocks could take exponential time on many closing tags."""
2568
+ if text.startswith("<bash-stdout>"):
2569
+ return "</bash-stdout><bash-stderr>" in text and text.endswith("</bash-stderr>")
2570
+ return text.startswith("<bash-stderr>") and text.endswith("</bash-stderr>")
2571
+
2572
+
2573
+ def _typed_by_user(x):
2574
+ """Whether pack turn `x` holds words the user typed: a user turn the rules keep that is neither a body the
2575
+ harness injected (meta) nor what Claude Code writes after a `!` command (bang_output, judged on the text before
2576
+ redaction: a hidden value at the end of the output takes the closing tags with it)."""
2577
+ return (x["role"] == "user" and x["rule"]["keep"] and not x.get("meta")
2578
+ and x.get("bang_output") is not True)
2579
+
2580
+
2373
2581
  def _review_result(pack, merged, args):
2374
2582
  """(turns to render, their summaries, cache writes, counts, prior redactions, the ids of
2375
2583
  turns the user typed that the reviewers dropped) from the pack and the decisions: a
@@ -2384,7 +2592,7 @@ def _review_result(pack, merged, args):
2384
2592
  held = llm_keep is False and _kept_by_flag(pack, x)
2385
2593
  stats["flag-kept"] += held
2386
2594
  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"):
2595
+ if not keep and _typed_by_user(x):
2388
2596
  typed.append(x["id"]) # typed by the user: the rules keep these
2389
2597
  llm_summary = _reviewer_summary(x, d, args, stats)
2390
2598
  if x["id"] in merged: # reviewed, even if left to the rules
@@ -2613,114 +2821,169 @@ def _flag_clash(args):
2613
2821
  return None
2614
2822
 
2615
2823
 
2616
- def main(argv=None) -> int:
2617
- args = build_arg_parser().parse_args(argv)
2618
-
2824
+ def _merge_env_extra(args):
2825
+ """LINEAGE_REDACT_EXTRA's keywords after --redact-extra's."""
2619
2826
  env_extra = os.environ.get("LINEAGE_REDACT_EXTRA", "").strip()
2620
2827
  if env_extra:
2621
2828
  args.redact_extra = ",".join(
2622
2829
  p for p in (args.redact_extra, env_extra) if p)
2623
2830
 
2624
- clash = _flag_clash(args)
2625
- if clash:
2626
- print(f"[lineage] ERROR: {clash}", file=sys.stderr)
2627
- return 2
2628
2831
 
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
2832
+ def _purge_cache():
2833
+ import shutil
2834
+ if CACHE_BASE.exists():
2835
+ shutil.rmtree(CACHE_BASE)
2836
+ print(f"[lineage] cache purged: {CACHE_BASE}", file=sys.stderr)
2837
+ else:
2838
+ print("[lineage] no cache to purge", file=sys.stderr)
2839
+ return 0
2637
2840
 
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
2841
 
2645
- # ---- Resolve input → raw turns (parse + parts + session) ----
2842
+ def _run_apply(args):
2843
+ if any((args.last, args.from_, args.to, args.turns, args.session,
2844
+ args.all_sessions, args.from_transcript)):
2845
+ print("[lineage] note: --apply-review renders the pack; source and range "
2846
+ "flags were applied at --emit-review", file=sys.stderr)
2847
+ return apply_review(args)
2848
+
2849
+
2850
+ def _read_all_sessions(args):
2851
+ """Every session of the project folder, merged record by record in time order."""
2852
+ files = project_jsonl_files()
2853
+ if not files:
2854
+ print("[lineage] --all-sessions: no jsonl found in project dir",
2855
+ file=sys.stderr)
2856
+ return 2, [], "session", None
2857
+ starts = {p: session_start(p) for p in files}
2646
2858
  turns = []
2647
- session_id = "session"
2648
- jsonl_path = None
2859
+ for p in files:
2860
+ name = discover_session_name(p) or (p.stem[-36:] if p.stem.startswith("rollout-") else p.stem)[:8]
2861
+ part = _load_turns_from(p, args.unsafe_schema, p.stem, name)
2862
+ meta = _codex_meta(p)
2863
+ if meta is not None and not any(t["role"] == "user" for t in part):
2864
+ print(f"[lineage] WARN: {p.name}: no user turn read from this Codex {meta.get('cli_version', '?')} "
2865
+ "record (a new record shape?); left out", file=sys.stderr)
2866
+ continue
2867
+ fill_missing_ts(part)
2868
+ for t in part:
2869
+ t["session_ts"] = starts[p] or "9999"
2870
+ turns.extend(part)
2871
+ # record-level chronological sort (stable → same-ts keeps file order)
2872
+ turns.sort(key=lambda t: t.get("ts") or t.get("session_ts") or "9999")
2873
+ print(f"[lineage] --all-sessions: merged {len(files)} sessions",
2874
+ file=sys.stderr)
2875
+ return None, turns, "all-sessions", None
2876
+
2877
+
2878
+ def _read_transcript(args):
2879
+ """A transcript pasted on stdin ('-') or read from a file."""
2880
+ if args.from_transcript == "-":
2881
+ turns = list(parse_turns(sys.stdin, unsafe_schema=args.unsafe_schema,
2882
+ session_id="stdin"))
2883
+ return None, turns, "stdin", None
2884
+ jsonl_path = pathlib.Path(args.from_transcript)
2885
+ session_id = "file-" + jsonl_path.stem
2886
+ turns = _load_turns_from(jsonl_path, args.unsafe_schema, session_id, None)
2887
+ return None, turns, session_id, jsonl_path
2888
+
2889
+
2890
+ def _read_discovered(args):
2891
+ """The newest session of the project folder."""
2892
+ f = auto_discover_jsonl()
2893
+ if not f:
2894
+ print("[lineage] No jsonl found. Use one of:",
2895
+ "\n --session FILE explicit jsonl path",
2896
+ "\n --all-sessions merge all project sessions",
2897
+ "\n --from-transcript - paste transcript via stdin",
2898
+ "\n --from-transcript FILE read transcript from file",
2899
+ "\n(non-ASCII cwd may not auto-encode — use --session)",
2900
+ file=sys.stderr)
2901
+ return 2, [], "session", None
2902
+ turns = _load_turns_from(f, args.unsafe_schema, f.stem, None)
2903
+ print(f"[lineage] auto-discovered session: {f.name}", file=sys.stderr)
2904
+ return None, turns, f.stem, f
2905
+
2906
+
2907
+ def _read_input(args):
2908
+ """(exit code or None, raw turns, session id, jsonl path) from the source the flags name: a Claude Code
2909
+ transcript or a Codex session record (rollout), told apart by its first record. The code is 2 when there is no
2910
+ source, or the code a schema marker stopped the read with. A Codex rollout that gives no user turn stops with 2
2911
+ too: every Codex session starts with what the user typed, so none means this Codex version writes its record in a
2912
+ shape lineage does not read, and an empty page would hide that."""
2913
+ rc, turns, session_id, jsonl_path = _read_source(args)
2914
+ meta = _codex_meta(jsonl_path) if rc is None and jsonl_path is not None else None
2915
+ if meta is not None and not any(t["role"] == "user" for t in turns):
2916
+ print(f"[lineage] ERROR: {jsonl_path.name} is a Codex {meta.get('cli_version', '?')} session record, but no "
2917
+ "user turn was read from it; this Codex version may write its record in a new shape "
2918
+ "(nothing written; update banker or report it)", file=sys.stderr)
2919
+ return 2, [], session_id, None
2920
+ return rc, turns, session_id, jsonl_path
2921
+
2922
+
2923
+ def _read_source(args):
2924
+ """(exit code or None, raw turns, session id, jsonl path) as read, before the Codex shape check."""
2649
2925
  try:
2650
2926
  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:
2927
+ return _read_all_sessions(args)
2928
+ if args.from_transcript:
2929
+ return _read_transcript(args)
2930
+ if args.session:
2680
2931
  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)
2932
+ turns = _load_turns_from(jsonl_path, args.unsafe_schema, jsonl_path.stem, None)
2933
+ return None, turns, jsonl_path.stem, jsonl_path
2934
+ return _read_discovered(args)
2699
2935
  except SystemExit as e:
2700
- return int(e.code) if isinstance(e.code, int) else 2
2936
+ return (int(e.code) if isinstance(e.code, int) else 2), [], "session", None
2701
2937
 
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
2938
 
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)
2939
+ def _stdin_ids(turns):
2940
+ """stdin → derive per-turn uuid from the content so the caches work (keyed: see _stdin_id_key)."""
2941
+ key = _stdin_id_key()
2942
+ for t in turns:
2943
+ t["uuid"] = hmac.new(key, str(t["text"]).encode(), hashlib.sha256).hexdigest()[:16]
2944
+
2945
+
2946
+ def _run_emit(turns, session_id, jsonl_path, args):
2947
+ drops = heuristic_drops(turns, args)
2948
+ window = review_window(turns, drops, args)
2949
+ if window is None:
2950
+ return 2
2951
+ return emit_review(turns, drops, window, session_id,
2952
+ default_output(args, jsonl_path, session_id), args)
2953
+
2954
+
2955
+ def _rulebase_turns(turns, args):
2956
+ """The turns a --rulebase page shows: echo exchanges and tool-only turns gone (unless kept), then the range.
2957
+ None on an invalid --turns."""
2718
2958
  if args.drop_trivia:
2719
2959
  turns = drop_echo_exchanges(turns)
2720
2960
  if args.hide_tool_only:
2721
2961
  turns = [t for t in turns
2722
2962
  if not (t["role"] == "assistant" and not t["text"].strip())]
2723
- turns = select_range(turns, args)
2963
+ return select_range(turns, args)
2964
+
2965
+
2966
+ def main(argv=None) -> int:
2967
+ args = build_arg_parser().parse_args(argv)
2968
+ _merge_env_extra(args)
2969
+ clash = _flag_clash(args)
2970
+ if clash:
2971
+ print(f"[lineage] ERROR: {clash}", file=sys.stderr)
2972
+ return 2
2973
+ if args.purge_cache:
2974
+ return _purge_cache()
2975
+ if args.apply_review:
2976
+ return _run_apply(args)
2977
+ rc, turns, session_id, jsonl_path = _read_input(args)
2978
+ if rc is not None:
2979
+ return rc
2980
+ if args.from_transcript == "-":
2981
+ _stdin_ids(turns)
2982
+ # ---- Pipeline order (R6): classify → merge → echo → hide-tool-only → range ----
2983
+ turns = merge_assistant_runs(classify_turns(turns, drop_trivia=args.drop_trivia))
2984
+ if args.emit_review:
2985
+ return _run_emit(turns, session_id, jsonl_path, args)
2986
+ turns = _rulebase_turns(turns, args)
2724
2987
  if turns is None:
2725
2988
  return 2
2726
2989
  if args.output is None:
@@ -2823,8 +3086,8 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
2823
3086
  date_range = first if first == last else f"{first} ~ {last}"
2824
3087
 
2825
3088
  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)
3089
+ html_doc = html_doc.replace("{{TITLE}}", html.escape(_show_bidi_controls(title)), 1)
3090
+ html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(_show_bidi_controls(title)), 1)
2828
3091
  html_doc = html_doc.replace("{{DATE_RANGE}}", html.escape(_show_bidi_controls(date_range)), 1)
2829
3092
  html_doc = html_doc.replace("{{TURNS}}", _show_bidi_controls("\n".join(rows)), 1)
2830
3093