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