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