@kaydash9999/banker-plugins 0.19.0 → 0.19.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 +63 -0
- package/bin/lib/telemetry-config.mjs +25 -0
- package/hooks/obsidize-hook.mjs +23 -4
- package/hooks/progress.mjs +24 -12
- package/hooks/register.mjs +1 -0
- package/hooks/telemetry-count-skill.mjs +4 -6
- package/hooks/telemetry-count.mjs +4 -6
- package/hooks/update-checkin.mjs +18 -17
- package/hooks/update-fetch.mjs +8 -7
- package/package.json +1 -1
- package/skills/anti-drm/SKILL.md +14 -7
- package/skills/anti-drm/scripts/anti-drm.mjs +92 -31
- package/skills/anti-drm/scripts/instruction-toggle.mjs +45 -14
- package/skills/lineage/SKILL.md +4 -1
- package/skills/lineage/lineage.py +230 -82
- package/skills/omc-patch/SKILL.md +30 -16
- package/skills/omc-patch/scripts/omc-patch.mjs +291 -72
- package/skills/tone-compact/SKILL.md +7 -2
- package/skills/tone-compact/scripts/instruction-toggle.mjs +45 -14
- package/skills/tone-compact/scripts/tone-compact.mjs +2 -1
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
Deterministic core. One-line summary per assistant turn (cached, redacted).
|
|
5
5
|
Renders assistant markdown, filters harness noise, folds long messages, and
|
|
6
6
|
self-verifies the output markup. See SKILL.md for the full design and the
|
|
7
|
-
`
|
|
7
|
+
`docs/lineage-change-request.html` spec for the classification rules.
|
|
8
8
|
|
|
9
9
|
Runtime-agnostic: works from any transcript via --session / --from-transcript.
|
|
10
10
|
Auto-discovery assumes Claude Code's ~/.claude/projects/<encoded-cwd>/ layout.
|
|
@@ -18,10 +18,12 @@ import math
|
|
|
18
18
|
import os
|
|
19
19
|
import pathlib
|
|
20
20
|
import re
|
|
21
|
+
import stat
|
|
21
22
|
import sys
|
|
22
23
|
import tempfile
|
|
23
24
|
import unicodedata
|
|
24
25
|
from html.parser import HTMLParser
|
|
26
|
+
from typing import Any
|
|
25
27
|
|
|
26
28
|
if sys.version_info < (3, 7):
|
|
27
29
|
sys.stderr.write("lineage requires Python 3.7+ (found %s)\n"
|
|
@@ -423,6 +425,146 @@ def session_start(path):
|
|
|
423
425
|
KNOWN_TYPES = {"user", "assistant", "agent-setting", "permission-mode", "summary"}
|
|
424
426
|
KNOWN_CONTENT_ITEMS = {"text", "tool_use", "tool_result", "thinking"}
|
|
425
427
|
_CTRL_RE = re.compile(r"[\x00-\x08\x0b\x0c\x0e-\x1f]") # strip control chars (keeps \t\n)
|
|
428
|
+
# What a JSON escape for a lone surrogate (U+D800 to U+DFFF with no pair) leaves in a str. UTF-8 cannot hold one,
|
|
429
|
+
# so a page, a review pack or a cache key that has it stops with UnicodeEncodeError.
|
|
430
|
+
_LONE_SURROGATE_RE = re.compile("[" + chr(0xD800) + "-" + chr(0xDFFF) + "]")
|
|
431
|
+
|
|
432
|
+
|
|
433
|
+
def _without_lone_surrogates(value: Any) -> Any:
|
|
434
|
+
"""value with each lone surrogate in its strings as U+FFFD, through lists and dicts (keys too). Any, as the
|
|
435
|
+
JSON it takes is: the callers check the shape after."""
|
|
436
|
+
if isinstance(value, str):
|
|
437
|
+
return _LONE_SURROGATE_RE.sub(chr(0xFFFD), value)
|
|
438
|
+
if isinstance(value, list):
|
|
439
|
+
return [_without_lone_surrogates(v) for v in value]
|
|
440
|
+
if isinstance(value, dict):
|
|
441
|
+
return {_without_lone_surrogates(k): _without_lone_surrogates(v) for k, v in value.items()}
|
|
442
|
+
return value
|
|
443
|
+
|
|
444
|
+
|
|
445
|
+
def _lone_to_fffd(value):
|
|
446
|
+
"""A str with each lone surrogate as U+FFFD; anything else as it is (no walk into it)."""
|
|
447
|
+
return _LONE_SURROGATE_RE.sub(chr(0xFFFD), value) if isinstance(value, str) else value
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def _shown_id(value):
|
|
451
|
+
"""An id from a transcript or a reviewer as a message shows it: as it is when it prints as one plain line, else its
|
|
452
|
+
repr, so a line break or a control character in it cannot forge a line of lineage's own."""
|
|
453
|
+
return value if isinstance(value, str) and value.isprintable() else repr(value)
|
|
454
|
+
|
|
455
|
+
|
|
456
|
+
def _clean_strings(line_no, role, text, tools, ts, uuid_):
|
|
457
|
+
"""A record's role, text, tool counts, timestamp and uuid with each lone surrogate as U+FFFD, after one WARN for
|
|
458
|
+
the line. Only text is touched: a uuid of another shape is replaced later, whole."""
|
|
459
|
+
if not any(isinstance(s, str) and _LONE_SURROGATE_RE.search(s) for s in (role, text, ts, uuid_, *tools)):
|
|
460
|
+
return role, text, tools, ts, uuid_
|
|
461
|
+
print(f"[WARN] line {line_no}: lone surrogate replaced with U+FFFD", file=sys.stderr)
|
|
462
|
+
counts = {}
|
|
463
|
+
for name, n in tools.items():
|
|
464
|
+
name = _lone_to_fffd(name)
|
|
465
|
+
counts[name] = counts.get(name, 0) + n
|
|
466
|
+
clean = _lone_to_fffd
|
|
467
|
+
return clean(role), clean(text), counts, clean(ts), clean(uuid_)
|
|
468
|
+
|
|
469
|
+
|
|
470
|
+
def _turn_id(uuid_, line_no, ts):
|
|
471
|
+
"""The record's uuid when it is text, else an id from the line and the timestamp: a missing or empty one as
|
|
472
|
+
before, another shape (a list, an object, a number) after a WARN, since ids are dict keys and joined text."""
|
|
473
|
+
if isinstance(uuid_, str) and uuid_:
|
|
474
|
+
return uuid_
|
|
475
|
+
if uuid_ is not None and uuid_ != "":
|
|
476
|
+
print(f"[WARN] line {line_no}: uuid that is not text replaced by an id from the line", file=sys.stderr)
|
|
477
|
+
return hashlib.sha256(f"{line_no}:{ts}".encode()).hexdigest()[:16]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _json_object(raw, line_no):
|
|
481
|
+
"""The JSON object a transcript line holds, else None after a WARN: a line that is not JSON, is nested
|
|
482
|
+
deeper than the parser reads, holds a number longer than it reads (ValueError since 3.11), or holds
|
|
483
|
+
something other than an object is skipped, not fatal."""
|
|
484
|
+
try:
|
|
485
|
+
obj = json.loads(raw)
|
|
486
|
+
except (ValueError, RecursionError): # JSONDecodeError is a ValueError
|
|
487
|
+
print(f"[WARN] line {line_no}: invalid JSON skipped", file=sys.stderr)
|
|
488
|
+
return None
|
|
489
|
+
if not isinstance(obj, dict):
|
|
490
|
+
print(f"[WARN] line {line_no}: not a JSON object skipped", file=sys.stderr)
|
|
491
|
+
return None
|
|
492
|
+
return obj
|
|
493
|
+
|
|
494
|
+
|
|
495
|
+
def _is_turn_record(obj, seen_unknown_type, unsafe_schema, line_no):
|
|
496
|
+
"""Whether a record is a user or assistant turn. A side chain or a team record is not, nor is another type:
|
|
497
|
+
one nobody knows is named once, and a schema marker (v...) stops the run unless --unsafe-schema. A record
|
|
498
|
+
whose type, or a turn whose message, has a shape a transcript does not give is named and skipped."""
|
|
499
|
+
if obj.get("isSidechain") or obj.get("teamName") or obj.get("agentName"):
|
|
500
|
+
return False
|
|
501
|
+
t = obj.get("type")
|
|
502
|
+
if (t and not isinstance(t, str)) or (t in ("user", "assistant") and _odd_message(obj.get("message"))):
|
|
503
|
+
print(f"[WARN] line {line_no}: record of an unknown shape skipped", file=sys.stderr)
|
|
504
|
+
return False
|
|
505
|
+
if t in ("user", "assistant"):
|
|
506
|
+
return True
|
|
507
|
+
if t and t not in KNOWN_TYPES and t not in seen_unknown_type:
|
|
508
|
+
seen_unknown_type.add(t)
|
|
509
|
+
print(f"[WARN] unknown record type {t!r} skipped", file=sys.stderr) # repr: no raw control character
|
|
510
|
+
if not unsafe_schema and t.startswith("v"):
|
|
511
|
+
print(f"[ERROR] schema marker {t!r} unrecognized — "
|
|
512
|
+
"rerun with --unsafe-schema to proceed",
|
|
513
|
+
file=sys.stderr)
|
|
514
|
+
raise SystemExit(2)
|
|
515
|
+
return False
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _stamp(ts):
|
|
519
|
+
"""A record's timestamp when it is text, else None: a number or another shape reads as a missing one."""
|
|
520
|
+
return ts if isinstance(ts, str) else None
|
|
521
|
+
|
|
522
|
+
|
|
523
|
+
def _odd_message(msg):
|
|
524
|
+
"""Whether a turn's message has a shape a transcript does not give (a missing or empty one reads as empty)."""
|
|
525
|
+
return bool(msg) and not isinstance(msg, dict)
|
|
526
|
+
|
|
527
|
+
|
|
528
|
+
def _odd_item(it, text, name):
|
|
529
|
+
"""Whether a content item's type, text or tool name has a shape a transcript does not give. A missing or empty
|
|
530
|
+
text reads as empty and a missing or null tool name as '?'."""
|
|
531
|
+
return ((it is not None and not isinstance(it, str)) or (it == "text" and not isinstance(text, str))
|
|
532
|
+
or (it == "tool_use" and not isinstance(name, str)))
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
def _take_item(item, line_no, text_parts, tools):
|
|
536
|
+
"""Add one item of a message's content list to the text parts or the tool counts. An item whose type, text or
|
|
537
|
+
tool name has a shape a transcript does not give is named and skipped."""
|
|
538
|
+
if not isinstance(item, dict):
|
|
539
|
+
return
|
|
540
|
+
it, text, name = item.get("type"), item.get("text") or "", item.get("name")
|
|
541
|
+
name = "?" if name is None else name
|
|
542
|
+
if _odd_item(it, text, name):
|
|
543
|
+
print(f"[WARN] line {line_no}: content item of an unknown shape skipped", file=sys.stderr)
|
|
544
|
+
elif it == "text":
|
|
545
|
+
text_parts.append(text)
|
|
546
|
+
elif it == "tool_use":
|
|
547
|
+
tools[name] = tools.get(name, 0) + 1
|
|
548
|
+
elif it not in KNOWN_CONTENT_ITEMS:
|
|
549
|
+
print(f"[WARN] line {line_no}: unknown content type "
|
|
550
|
+
f"{it!r} skipped", file=sys.stderr)
|
|
551
|
+
|
|
552
|
+
|
|
553
|
+
def _content_parts(content, line_no):
|
|
554
|
+
"""(text parts, tool counts) of a message's content, or None for a shape nobody knows (named in a WARN
|
|
555
|
+
unless the content is missing)."""
|
|
556
|
+
text_parts, tools = [], {}
|
|
557
|
+
if isinstance(content, str):
|
|
558
|
+
text_parts.append(content)
|
|
559
|
+
elif isinstance(content, list):
|
|
560
|
+
for item in content:
|
|
561
|
+
_take_item(item, line_no, text_parts, tools)
|
|
562
|
+
else:
|
|
563
|
+
if content is not None:
|
|
564
|
+
print(f"[WARN] line {line_no}: unknown content shape "
|
|
565
|
+
f"({type(content).__name__})", file=sys.stderr)
|
|
566
|
+
return None
|
|
567
|
+
return text_parts, tools
|
|
426
568
|
|
|
427
569
|
|
|
428
570
|
def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
|
|
@@ -439,62 +581,22 @@ def parse_turns(stream, unsafe_schema: bool = False, session_id=None,
|
|
|
439
581
|
raw = raw.strip()
|
|
440
582
|
if not raw:
|
|
441
583
|
continue
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
except json.JSONDecodeError:
|
|
445
|
-
print(f"[WARN] line {line_no}: invalid JSON skipped", file=sys.stderr)
|
|
446
|
-
continue
|
|
447
|
-
if obj.get("isSidechain"):
|
|
448
|
-
continue
|
|
449
|
-
if obj.get("teamName") or obj.get("agentName"):
|
|
584
|
+
obj = _json_object(raw, line_no)
|
|
585
|
+
if obj is None or not _is_turn_record(obj, seen_unknown_type, unsafe_schema, line_no):
|
|
450
586
|
continue
|
|
451
|
-
t = obj.get("type")
|
|
452
|
-
if t not in ("user", "assistant"):
|
|
453
|
-
if t and t not in KNOWN_TYPES and t not in seen_unknown_type:
|
|
454
|
-
seen_unknown_type.add(t)
|
|
455
|
-
print(f"[WARN] unknown record type '{t}' skipped", file=sys.stderr)
|
|
456
|
-
if not unsafe_schema and t and t.startswith("v"):
|
|
457
|
-
print(f"[ERROR] schema marker '{t}' unrecognized — "
|
|
458
|
-
"rerun with --unsafe-schema to proceed",
|
|
459
|
-
file=sys.stderr)
|
|
460
|
-
raise SystemExit(2)
|
|
461
|
-
continue
|
|
462
|
-
|
|
463
587
|
msg = obj.get("message") or {}
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
ts = obj.get("timestamp")
|
|
467
|
-
uuid_ = obj.get("uuid") or hashlib.sha256(
|
|
468
|
-
f"{line_no}:{ts}".encode()).hexdigest()[:16]
|
|
469
|
-
|
|
470
|
-
text_parts = []
|
|
471
|
-
tools = {}
|
|
472
|
-
if isinstance(content, str):
|
|
473
|
-
text_parts.append(content)
|
|
474
|
-
elif isinstance(content, list):
|
|
475
|
-
for item in content:
|
|
476
|
-
if not isinstance(item, dict):
|
|
477
|
-
continue
|
|
478
|
-
it = item.get("type")
|
|
479
|
-
if it == "text":
|
|
480
|
-
text_parts.append(item.get("text") or "")
|
|
481
|
-
elif it == "tool_use":
|
|
482
|
-
tools[item.get("name", "?")] = tools.get(
|
|
483
|
-
item.get("name", "?"), 0) + 1
|
|
484
|
-
elif it in ("tool_result", "thinking"):
|
|
485
|
-
continue
|
|
486
|
-
elif it not in KNOWN_CONTENT_ITEMS:
|
|
487
|
-
print(f"[WARN] line {line_no}: unknown content type "
|
|
488
|
-
f"'{it}' skipped", file=sys.stderr)
|
|
489
|
-
else:
|
|
490
|
-
if content is not None:
|
|
491
|
-
print(f"[WARN] line {line_no}: unknown content shape "
|
|
492
|
-
f"({type(content).__name__})", file=sys.stderr)
|
|
588
|
+
got = _content_parts(msg.get("content"), line_no)
|
|
589
|
+
if got is None:
|
|
493
590
|
continue
|
|
494
|
-
|
|
591
|
+
text_parts, tools = got
|
|
495
592
|
text = _CTRL_RE.sub("", "\n".join(p for p in text_parts if p).strip())
|
|
496
593
|
if not text and not tools:
|
|
497
594
|
continue
|
|
595
|
+
role = msg.get("role")
|
|
596
|
+
role = role if role in ("user", "assistant") else obj.get("type") # the type _is_turn_record let through
|
|
597
|
+
role, text, tools, ts, uuid_ = _clean_strings(
|
|
598
|
+
line_no, role, text, tools, _stamp(obj.get("timestamp")), obj.get("uuid"))
|
|
599
|
+
uuid_ = _turn_id(uuid_, line_no, ts)
|
|
498
600
|
|
|
499
601
|
yield {"role": role, "text": text, "ts": ts, "uuid": uuid_,
|
|
500
602
|
"tools": tools, "line_no": line_no,
|
|
@@ -693,18 +795,16 @@ def _stdin_id_key():
|
|
|
693
795
|
to a model that never sees the key, so an id gives no way to test a guessed password. With no
|
|
694
796
|
cache folder to keep it in, the key lasts one run."""
|
|
695
797
|
path = CACHE_BASE / "stdin-id.key"
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
return key
|
|
700
|
-
except OSError:
|
|
701
|
-
pass
|
|
798
|
+
key = _read_small(path, 64)
|
|
799
|
+
if key is not None and len(key) == 32:
|
|
800
|
+
return key
|
|
702
801
|
key = os.urandom(32)
|
|
703
802
|
tmp = path.with_name("%s.%d" % (path.name, os.getpid()))
|
|
704
803
|
try:
|
|
705
804
|
CACHE_BASE.mkdir(parents=True, exist_ok=True)
|
|
706
805
|
os.chmod(str(CACHE_BASE), 0o700)
|
|
707
|
-
|
|
806
|
+
# O_EXCL: a name that is already there (a link planted at this guessable name among them) is refused
|
|
807
|
+
fd = os.open(str(tmp), os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0), 0o600)
|
|
708
808
|
with os.fdopen(fd, "wb") as f:
|
|
709
809
|
f.write(key)
|
|
710
810
|
os.replace(str(tmp), str(path)) # whole or not at all
|
|
@@ -741,11 +841,14 @@ def _page_clean_turn(t, extra, mode):
|
|
|
741
841
|
# read (3.0.5).
|
|
742
842
|
_BLANK_LETTERS = frozenset(map(chr, (0x115F, 0x1160, 0x3164, 0xFFA0, 0x2800,
|
|
743
843
|
0x034F, 0x17B4, 0x17B5, 0x180B, 0x180C, 0x180D, 0x180F)))
|
|
844
|
+
# Cn follows the Unicode table of the Python that runs: a character newer than that table reads as unassigned, so
|
|
845
|
+
# a cache made only of such characters is summarized again every run (the page is the same).
|
|
744
846
|
_BLANK_CATEGORIES = frozenset(("Cc", "Cf", "Co", "Cn", "Zs", "Zl", "Zp"))
|
|
745
847
|
|
|
746
848
|
|
|
747
849
|
def _blank(ch):
|
|
748
|
-
"""Whether ch prints as nothing: whitespace, a blank-looking letter, or a variation selector.
|
|
850
|
+
"""Whether ch prints as nothing: whitespace, a blank-looking letter, or a variation selector.
|
|
851
|
+
White space is in the blank categories too (Zs, Zl, Zp, Cc); str.isspace() only answers it sooner."""
|
|
749
852
|
if ch.isspace() or ch in _BLANK_LETTERS or unicodedata.category(ch) in _BLANK_CATEGORIES:
|
|
750
853
|
return True
|
|
751
854
|
o = ord(ch)
|
|
@@ -757,16 +860,45 @@ def _has_visible(text):
|
|
|
757
860
|
return not all(_blank(ch) for ch in text)
|
|
758
861
|
|
|
759
862
|
|
|
863
|
+
CACHE_READ_MAX = 64 * 1024 # bytes: a cached summary or decision is some hundred (120 characters), so this is generous
|
|
864
|
+
|
|
865
|
+
|
|
866
|
+
def _read_small(path, limit=CACHE_READ_MAX):
|
|
867
|
+
"""The bytes of the regular file at path when it holds at most `limit`, else None (missing, a link, a pipe, a
|
|
868
|
+
folder, a larger file). Opened without blocking and without following a link, so a pipe or a link left at a
|
|
869
|
+
cache place neither stops a run nor sends it to another file. Windows has neither flag: there the lstat
|
|
870
|
+
turns a link away, but a link swapped in between the lstat and the open is followed."""
|
|
871
|
+
flags = (os.O_RDONLY | getattr(os, "O_NONBLOCK", 0) | getattr(os, "O_NOFOLLOW", 0)
|
|
872
|
+
| getattr(os, "O_BINARY", 0))
|
|
873
|
+
try:
|
|
874
|
+
if not stat.S_ISREG(os.lstat(str(path)).st_mode): # a link (Windows has no O_NOFOLLOW), pipe, folder
|
|
875
|
+
return None
|
|
876
|
+
fd = os.open(str(path), flags)
|
|
877
|
+
with os.fdopen(fd, "rb") as f:
|
|
878
|
+
if not stat.S_ISREG(os.fstat(f.fileno()).st_mode):
|
|
879
|
+
return None
|
|
880
|
+
data = f.read(limit + 1)
|
|
881
|
+
except OSError:
|
|
882
|
+
return None
|
|
883
|
+
return data if len(data) <= limit else None
|
|
884
|
+
|
|
885
|
+
|
|
886
|
+
def _read_text(path):
|
|
887
|
+
"""The UTF-8 text _read_small gives, else None (bytes that are not UTF-8: a DRM encrypted them)."""
|
|
888
|
+
data = _read_small(path)
|
|
889
|
+
try:
|
|
890
|
+
return None if data is None else data.decode("utf-8")
|
|
891
|
+
except UnicodeDecodeError:
|
|
892
|
+
return None
|
|
893
|
+
|
|
894
|
+
|
|
760
895
|
def _read_cached(p):
|
|
761
896
|
"""The summary cached at p, else at the same name with .txt, which 3.0.2 and earlier wrote.
|
|
762
897
|
None when neither holds one: missing, bytes that are not UTF-8 (a DRM encrypted them), or
|
|
763
898
|
nothing a person can see (a write that stopped early, or only blank-looking letters); the
|
|
764
899
|
turn is then summarized again (3.0.4, 3.0.5)."""
|
|
765
900
|
for cached in (p, p.with_name(p.stem + ".txt")):
|
|
766
|
-
|
|
767
|
-
text = cached.read_text(encoding="utf-8").strip()
|
|
768
|
-
except (OSError, UnicodeDecodeError):
|
|
769
|
-
continue
|
|
901
|
+
text = (_read_text(cached) or "").strip()
|
|
770
902
|
if _has_visible(text):
|
|
771
903
|
return text
|
|
772
904
|
return None
|
|
@@ -793,9 +925,7 @@ def read_or_summarize(turn: dict, session_id: str, rebuild: bool = False,
|
|
|
793
925
|
redacted = summarize_turn(_page_clean_turn(turn, redact_extra, redact_mode))
|
|
794
926
|
if p is not None:
|
|
795
927
|
try:
|
|
796
|
-
|
|
797
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
798
|
-
f.write(redacted)
|
|
928
|
+
_write_private(p, redacted)
|
|
799
929
|
except OSError:
|
|
800
930
|
pass
|
|
801
931
|
return redacted, False
|
|
@@ -1398,8 +1528,8 @@ def _write_private(path, text):
|
|
|
1398
1528
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
1399
1529
|
fd, tmp = tempfile.mkstemp(dir=str(path.parent), prefix=f".{path.name}.", suffix=".tmp")
|
|
1400
1530
|
try:
|
|
1401
|
-
with os.fdopen(fd, "w", encoding="utf-8") as f:
|
|
1402
|
-
f.write(text)
|
|
1531
|
+
with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as f:
|
|
1532
|
+
f.write(_lone_to_fffd(text)) # a title or a file name may still bring one: UTF-8 cannot hold it
|
|
1403
1533
|
os.replace(tmp, str(path))
|
|
1404
1534
|
except BaseException:
|
|
1405
1535
|
try:
|
|
@@ -1409,6 +1539,13 @@ def _write_private(path, text):
|
|
|
1409
1539
|
raise
|
|
1410
1540
|
|
|
1411
1541
|
|
|
1542
|
+
def _write_text_lf(path, text):
|
|
1543
|
+
"""Write `text` to `path` as UTF-8 with LF line ends on every OS. Text mode on Windows
|
|
1544
|
+
would write CRLF, so the same transcript would give a different page there."""
|
|
1545
|
+
with open(str(path), "w", encoding="utf-8", newline="\n") as f:
|
|
1546
|
+
f.write(_lone_to_fffd(text)) # a title or a file name may still bring one: UTF-8 cannot hold it
|
|
1547
|
+
|
|
1548
|
+
|
|
1412
1549
|
def _safe_name(s):
|
|
1413
1550
|
"""A file-name-safe form of an id read from a transcript or a pack."""
|
|
1414
1551
|
s = re.sub(r"[^A-Za-z0-9_.-]", "_", str(s or "default"))[:80]
|
|
@@ -1444,15 +1581,15 @@ def read_llm_cache(turn_id, session, digest):
|
|
|
1444
1581
|
"every turn is reviewed")
|
|
1445
1582
|
return None
|
|
1446
1583
|
try:
|
|
1447
|
-
got = json.loads(path
|
|
1448
|
-
except (
|
|
1584
|
+
got = json.loads(_read_text(path) or "")
|
|
1585
|
+
except (ValueError, RecursionError):
|
|
1449
1586
|
return None
|
|
1450
1587
|
if not isinstance(got, dict):
|
|
1451
1588
|
return None
|
|
1452
1589
|
keep, summary = got.get("keep"), got.get("summary")
|
|
1453
1590
|
if not (_is_keep(keep) and _is_summary(summary)):
|
|
1454
1591
|
return None
|
|
1455
|
-
return {"keep": keep, "summary": summary}
|
|
1592
|
+
return {"keep": keep, "summary": _lone_to_fffd(summary)}
|
|
1456
1593
|
|
|
1457
1594
|
|
|
1458
1595
|
def write_llm_cache(turn_id, session, digest, keep, summary):
|
|
@@ -1726,8 +1863,8 @@ def _is_pack_turn(x):
|
|
|
1726
1863
|
|
|
1727
1864
|
def _load_pack(path):
|
|
1728
1865
|
try:
|
|
1729
|
-
pack = json.loads(path.read_text(encoding="utf-8"))
|
|
1730
|
-
except (OSError, ValueError, UnicodeDecodeError) as e:
|
|
1866
|
+
pack = _without_lone_surrogates(json.loads(path.read_text(encoding="utf-8")))
|
|
1867
|
+
except (OSError, ValueError, UnicodeDecodeError, RecursionError) as e:
|
|
1731
1868
|
return _bad(f"cannot read review pack {path}: {e}")
|
|
1732
1869
|
if not isinstance(pack, dict) or pack.get("schema") != REVIEW_SCHEMA:
|
|
1733
1870
|
return _bad(f"{path} is not a {REVIEW_SCHEMA} pack (made by another lineage version? "
|
|
@@ -1829,10 +1966,10 @@ def _load_decisions(files):
|
|
|
1829
1966
|
why = _decision_problem(d)
|
|
1830
1967
|
if why:
|
|
1831
1968
|
return _bad(f"{f}[{k}]: {why}")
|
|
1832
|
-
cur = merged.setdefault(d["id"], {})
|
|
1969
|
+
cur = merged.setdefault(_lone_to_fffd(d["id"]), {}) # a model's JSON may hold a lone surrogate
|
|
1833
1970
|
for key in ("keep", "summary"):
|
|
1834
1971
|
if d.get(key) is not None:
|
|
1835
|
-
cur[key] = d[key]
|
|
1972
|
+
cur[key] = _lone_to_fffd(d[key])
|
|
1836
1973
|
if items and all(d.get("keep") is None and d.get("summary") is None for d in items):
|
|
1837
1974
|
print(f"[lineage] WARN: {f}: all {len(items)} decisions are null; the rules "
|
|
1838
1975
|
"decide those turns", file=sys.stderr)
|
|
@@ -1846,7 +1983,8 @@ def _clean_summary(s, extra, mode):
|
|
|
1846
1983
|
next reviewer and the gate's critic, so no part of a key may stay in it."""
|
|
1847
1984
|
pre, n = review_redact(" ".join(s.split()), extra)
|
|
1848
1985
|
red, found = redact(pre, extra=extra, mode=mode)
|
|
1849
|
-
|
|
1986
|
+
cut = _cut(red, SUMMARY_MAX)
|
|
1987
|
+
return (cut if _has_visible(cut) else None), n + sum(found.values())
|
|
1850
1988
|
|
|
1851
1989
|
|
|
1852
1990
|
def _warn_coverage(pack, decided):
|
|
@@ -1958,7 +2096,7 @@ def _check_pack(pack, merged, args):
|
|
|
1958
2096
|
unknown = [i for i in merged if i not in ids]
|
|
1959
2097
|
if unknown:
|
|
1960
2098
|
print(f"[lineage] WARN: decisions name {len(unknown)} turn(s) not in the pack, "
|
|
1961
|
-
f"ignored: {', '.join(unknown[:5])}", file=sys.stderr)
|
|
2099
|
+
f"ignored: {', '.join(_shown_id(i) for i in unknown[:5])}", file=sys.stderr)
|
|
1962
2100
|
_warn_coverage(pack, set(merged) | {x["id"] for x in pack["turns"]
|
|
1963
2101
|
if x["llm"].get("cached")})
|
|
1964
2102
|
if pack.get("redact_mode") in ("full", "mask"):
|
|
@@ -2023,7 +2161,7 @@ def _report_review(stats, turns, pack, typed):
|
|
|
2023
2161
|
print(f"[lineage] note: {stats['flag-kept']} turn(s) a reviewer marked keep: false "
|
|
2024
2162
|
"stay, as --keep-trivia or --keep-tool-only asked", file=sys.stderr)
|
|
2025
2163
|
if typed:
|
|
2026
|
-
print(f"[lineage] WARN: reviewers dropped {len(typed)} typed user turn(s): {', '.join(typed[:10])}"
|
|
2164
|
+
print(f"[lineage] WARN: reviewers dropped {len(typed)} typed user turn(s): {', '.join(_shown_id(i) for i in typed[:10])}"
|
|
2027
2165
|
f"{' ...' if len(typed) > 10 else ''}; tell the user", file=sys.stderr)
|
|
2028
2166
|
|
|
2029
2167
|
|
|
@@ -2331,6 +2469,16 @@ def default_output(args, jsonl_path, session_id):
|
|
|
2331
2469
|
return f"work/lineage-{slug}.html"
|
|
2332
2470
|
|
|
2333
2471
|
|
|
2472
|
+
# The controls that embed, override or isolate text direction (U+202A..U+202E, U+2066..U+2069). In a page they
|
|
2473
|
+
# reorder what a reader sees and can reach past the element that holds them, so the page shows each as a visible
|
|
2474
|
+
# [U+XXXX] instead. Plain letters of right-to-left scripts, and the direction marks, stay.
|
|
2475
|
+
_BIDI_CONTROLS = re.compile("[" + "".join(map(chr, (*range(0x202A, 0x202F), *range(0x2066, 0x206A)))) + "]")
|
|
2476
|
+
|
|
2477
|
+
|
|
2478
|
+
def _show_bidi_controls(text):
|
|
2479
|
+
return _BIDI_CONTROLS.sub(lambda m: "[U+%04X]" % ord(m.group()), text)
|
|
2480
|
+
|
|
2481
|
+
|
|
2334
2482
|
def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
|
|
2335
2483
|
open_details=False, title="Session Lineage", summaries=None,
|
|
2336
2484
|
prior_redactions=None):
|
|
@@ -2360,8 +2508,8 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
|
|
|
2360
2508
|
html_doc = HTML_TEMPLATE
|
|
2361
2509
|
html_doc = html_doc.replace("{{TITLE}}", html.escape(title), 1)
|
|
2362
2510
|
html_doc = html_doc.replace("{{HEADER_TITLE}}", html.escape(title), 1)
|
|
2363
|
-
html_doc = html_doc.replace("{{DATE_RANGE}}", html.escape(date_range), 1)
|
|
2364
|
-
html_doc = html_doc.replace("{{TURNS}}", "\n".join(rows), 1)
|
|
2511
|
+
html_doc = html_doc.replace("{{DATE_RANGE}}", html.escape(_show_bidi_controls(date_range)), 1)
|
|
2512
|
+
html_doc = html_doc.replace("{{TURNS}}", _show_bidi_controls("\n".join(rows)), 1)
|
|
2365
2513
|
|
|
2366
2514
|
errs = self_verify(html_doc)
|
|
2367
2515
|
if errs:
|
|
@@ -2369,7 +2517,7 @@ def render_and_write(turns, session_id, args, all_sessions=False, markdown=True,
|
|
|
2369
2517
|
|
|
2370
2518
|
out = _with_timestamp_suffix(args.output)
|
|
2371
2519
|
out.parent.mkdir(parents=True, exist_ok=True)
|
|
2372
|
-
out
|
|
2520
|
+
_write_text_lf(out, html_doc)
|
|
2373
2521
|
|
|
2374
2522
|
_run_reviewer_gate(turns, out, session_id, args, summaries=summaries)
|
|
2375
2523
|
|
|
@@ -2613,7 +2761,7 @@ def _check_coverage(verdict, samples):
|
|
|
2613
2761
|
"""Exit 2 unless the verdict answers each of the samples in file `samples`."""
|
|
2614
2762
|
try:
|
|
2615
2763
|
gaps = _verdict_gaps(verdict, json.loads(samples.read_text(encoding="utf-8")))
|
|
2616
|
-
except (OSError, ValueError, UnicodeDecodeError, AttributeError) as e:
|
|
2764
|
+
except (OSError, ValueError, UnicodeDecodeError, AttributeError, RecursionError) as e:
|
|
2617
2765
|
gaps = f"cannot read the samples: {e}"
|
|
2618
2766
|
if gaps:
|
|
2619
2767
|
print(f"[lineage] ERROR: the verdict does not answer {samples} ({gaps}); have the "
|
|
@@ -41,11 +41,17 @@ OMC(oh-my-claudecode)가 새 버전으로 자동 업데이트되면 로컬 훅
|
|
|
41
41
|
- `--root` 는 폴더의 이름표가 OMC 가 아니면 `OMC 플러그인 폴더가 아님` 으로 멈춘다. `.claude-plugin/plugin.json` 의 name 이 있으면 그것으로, 없으면 `package.json` 의 name 으로 정한다. 다른 폴더를 잘못 준 실수를 막는 확인이고, 이름표가 없는 폴더는 통과한다.
|
|
42
42
|
- 쓸 파일이 링크로 플러그인 폴더 밖을 가리키거나 백업 자리(`.omcbak`)가 링크면 도구는 쓰지 않고 실패로 센다. 문구는 적용 때 `쓰지 않음`, 되돌리기 때 `되돌리지 않음`, hooks.json 은 `hooks.json 을 건드리지 않음` 이다.
|
|
43
43
|
- 패치 규칙은 OMC 훅 코드의 모양에 맞춰져 있다. 처음 보는 OMC 버전이면 반드시 `check` 를 먼저 실행한다.
|
|
44
|
-
- `실패 N건` 이 1건 이상이면 멈추고 출력을 그대로 보고한다. 문법 오류가 난 파일은 도구가 원본으로 자동 롤백한다. 고친 hooks.json 이 다시 읽어 JSON 이 아니어도 원본으로 되돌리고(`hooks.json 손상으로 롤백`) 실패로 센다.
|
|
44
|
+
- `실패 N건` 이 1건 이상이면 멈추고 출력을 그대로 보고한다. 예외는 2단계에서 남기기로 한 `PINNED` 줄 하나뿐이다(3단계 2). 문법 오류가 난 파일은 도구가 원본으로 자동 롤백한다. 고친 hooks.json 이 다시 읽어 JSON 이 아니어도 원본으로 되돌리고(`hooks.json 손상으로 롤백`) 실패로 센다.
|
|
45
45
|
- 판정은 출력 문구로 한다. 종료 코드 0 만으로 완료라고 보고하지 않는다.
|
|
46
46
|
- 캐시에는 정션이나 심볼릭 링크로 이어진 버전 폴더가 있을 수 있다(예: 옛 버전 폴더가 새 버전을 가리킴). 이때 한 번의 변경이 두 버전 폴더에 함께 보인다. 정상이다.
|
|
47
47
|
- 도구는 `os.homedir()/.claude` 를 쓴다. `CLAUDE_CONFIG_DIR` 로 설정 폴더를 옮겼다면 이 도구는 그 폴더를 보지 않는다.
|
|
48
|
-
- 출력의 origin 에 `https://사용자:토큰@...` 처럼 자격증명이 들어 있으면, 보고할 때 `//***@` 로 가린다. 출력을 그대로 보고하라는 단계에서도 이 가림은 지킨다. 옛 판의 도구가 만든 `PINNED` 에는 그 URL 이 남아 있을 수 있다. `--check` 는 그 칸을 `[!] 고정 기록(PINNED)에 옛 URL 칸이 남아 있음 (적용하면 지움)` 으로 알리고 모두 적용된 상태로 보지 않는다. 적용(`--check` 가 아닌 실행)은 그 칸을 지우고 권한을 0600 으로
|
|
48
|
+
- 출력의 origin 에 `https://사용자:토큰@...` 처럼 자격증명이 들어 있으면, 보고할 때 `//***@` 로 가린다. 출력을 그대로 보고하라는 단계에서도 이 가림은 지킨다. 옛 판의 도구가 만든 `PINNED` 에는 그 URL 이 남아 있을 수 있다. `--check` 는 그 칸을 `[!] 고정 기록(PINNED)에 옛 URL 칸이 남아 있음 (적용하면 지움)` 으로 알리고 모두 적용된 상태로 보지 않는다. 적용(`--check` 가 아닌 실행)은 그 칸을 지우고 권한을 0600 으로 맞춘다(POSIX. Windows 는 chmod 가 쓰기 비트만 다뤄 폴더의 상속 권한을 따른다). 이 도구가 다 지울 수 없으면(칸이 든 기록이 링크, JSON 이 아님, 폴더에 쓰지 못함, 읽지 못함, 같은 파일의 다른 이름이 있음) `[!] 고정 기록(PINNED)의 옛 URL 칸을 ...` 줄로 알리고 적용은 실패로 센다(종료 1). 다른 이름이 있으면 적용은 이 이름에서만 지우고 다른 이름에는 남긴다. 링크는 따라가 쓰지 않으며, 링크 너머에 그 칸이 없으면 알리지 않는다. 되돌리기는 `PINNED` 를 지운다. 링크 너머에 그 칸이 있거나 링크 너머를 읽지 못하거나, 지우지 못하거나, 같은 파일의 다른 이름이 있을 때만 실패로 세고, 링크가 아닌 기록은 JSON 이 아니거나 읽지 못해도 그냥 지운다. `PINNED` 는 기록일 뿐이라 지워도 고정과 되돌리기에 영향이 없다. 알린 줄은 아래 `PINNED` 줄 다루기대로 묻는다. 기록 파일의 내용은 읽어 보이지 않는다(인증 정보가 들 수 있음).
|
|
49
|
+
- `PINNED` 줄 다루기. `[!] 고정 기록(PINNED)의 옛 URL 칸을 ...` 줄이 나오면 그 줄의 경로를 사용자에게 보이고 그 기록을 지울지 남길지 묻는다. `--check` 에서 나온 줄은 2단계 승인 질문에서 함께 묻는다. 적용이나 되돌리기에서 처음 나온 줄은 멈추고 보고한 뒤 묻는다. 이때도 기록의 내용은 읽지 않고 경로만 보인다.
|
|
50
|
+
- 폴더 권한이 원인(`쓰지 못함`)이면 권한을 고칠지도 묻는다.
|
|
51
|
+
- 링크면 URL 칸은 링크가 가리키는 파일에 있다. 그 파일의 경로도 보인다. 경로는 `node -e "console.log(require('fs').realpathSync(process.argv[1]))" "<링크 경로>"` 로 구한다(bash, PowerShell 같음). 그 파일이 `PINNED` 기록이 맞는지는 사용자가 직접 열어 확인한 뒤 답하게 한다(링크는 글자만 보고 막으므로 무관한 파일일 수 있다).
|
|
52
|
+
- 같은 파일의 다른 이름이 있다는 줄이면 그 아래 `다른 이름은 ... 로 찾습니다.` 줄의 번호로 다른 이름을 찾아 경로를 보인다. 찾은 이름 중 `PINNED` 가 아닌 것이 다른 이름이다. POSIX 는 `find "<맨 위 폴더>" -xdev -inum <번호> 2>/dev/null` 이고, 맨 위 폴더는 `df -P "<PINNED 가 든 폴더>"` 둘째 줄의 마지막 칸이다. Windows 는 `fsutil file queryFileNameById <드라이브>\ <번호>` 이다(관리자 셸에서만 확인함). 찾지 못하면(권한으로 못 본 폴더 등) 번호를 그대로 사용자에게 알린다.
|
|
53
|
+
- 사용자가 지우기를 고르면 그것이 승인이다. 보인 경로만 지운다. 링크면 링크와 그것이 가리키는 파일을, 다른 이름이 있으면 `PINNED` 와 찾은 다른 이름을 모두 지운다. 하나라도 남기면 그 안에 URL 이 남는다. 링크가 가리키는 파일이 `PINNED` 기록이 아니라고 사용자가 답하면 그 파일은 지우지 않고 링크만 지울지 묻는다.
|
|
54
|
+
- 남기기로 하면 그 줄은 예상된 실패다. 적용에서는 3단계 2 의 예외, 검증에서는 4단계 1 의 예외를 따른다. 다른 이름이 있는 기록을 남기고 적용하면 적용은 `PINNED` 의 칸만 지운다. 그 뒤 `--check` 는 다른 이름을 보지 못하니 5단계 보고에 그 경로를 적는다.
|
|
49
55
|
|
|
50
56
|
## 절차
|
|
51
57
|
|
|
@@ -70,7 +76,7 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no
|
|
|
70
76
|
|
|
71
77
|
- `활성 버전: X (installed_plugins.json)` 줄로 실제 활성 버전을 확인한다.
|
|
72
78
|
- 그 줄이 `(추정: ...)` 로 끝나면 도구가 `installed_plugins.json` 에서 활성 버전을 찾지 못한 것이다. 적용하지 않고 출력을 그대로 보고하고 끝낸다. 추정한 폴더를 패치하면 실제 활성 버전은 그대로 남는데 `check` 는 적용된 것으로 보일 수 있다.
|
|
73
|
-
- `패치 필요 N건` 은 설치된 모든 버전 폴더를 합친 수다. 판정은
|
|
79
|
+
- `패치 필요 N건` 은 설치된 모든 버전 폴더를 합친 수다. 판정은 `결과:` 줄로 한다. 그 아래 `참고:` 줄은 아래 항목대로 다룬다.
|
|
74
80
|
|
|
75
81
|
| `결과:` 줄 | 다음 |
|
|
76
82
|
|---|---|
|
|
@@ -78,17 +84,22 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no
|
|
|
78
84
|
| `헬퍼 자리 결함 N건이 있습니다.` | 2단계 확인 뒤 3단계에서 활성 폴더부터 되돌림 |
|
|
79
85
|
| `적용되지 않은 항목이 있습니다.` | 2단계 확인 뒤 3단계 적용 |
|
|
80
86
|
| `hooks.json 을 확인하지 못했습니다.` | 적용하지 않고 위의 `[!]` 줄을 그대로 보고 |
|
|
81
|
-
|
|
|
87
|
+
| `마켓플레이스 origin 을 확인하지 못했습니다.` | 적용하지 않고 위의 `[!]` 줄을 그대로 보고(git 이 없거나 마켓플레이스 저장소를 읽지 못함) |
|
|
88
|
+
| `고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다.` | 적용할 항목은 없다. 2단계에서 그 기록을 지울지 남길지만 묻는다(`규칙` 절의 `PINNED` 줄 다루기). 어느 쪽이든 그 뒤 4단계 검증으로 건너뜀 |
|
|
82
89
|
|
|
83
90
|
- `참고: 비활성 버전 ...` 은 지금 쓰지 않는 버전이라 판정에 들어가지 않는다.
|
|
91
|
+
- `결과:` 줄 아래의 `참고: 고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다.` 는 적용할 항목도 있다는 뜻이다. 적용해도 그곳의 칸은 남고 적용은 `실패 1건` 으로 끝난다. 2단계에서 함께 묻는다. 위 표의 같은 문구 결과 줄은 적용할 항목 없이 이것만 남았을 때 나온다.
|
|
84
92
|
- 인자가 `check` 이면 여기서 결과만 보고하고 끝낸다.
|
|
85
93
|
|
|
86
94
|
### 2. 확인 (적용 전 필수)
|
|
87
95
|
|
|
88
|
-
`--check` 결과를 보여 주고 사용자에게 적용해도 되는지 묻는다. 묻기 전에 아래를 알린다.
|
|
96
|
+
`--check` 결과를 보여 주고 사용자에게 적용해도 되는지 묻는다. 묻기 전에 아래를 알린다. 1단계 결과가 위 표의 `PINNED` 줄이면 적용할 항목이 없다. 적용은 묻지 않고 아래 `PINNED` 항목만 한다.
|
|
89
97
|
- 활성 OMC 버전의 `scripts/*.mjs` 와 `hooks/hooks.json` 을 고친다. 원본은 `.omcbak` 로 남는다.
|
|
90
98
|
- 헬퍼 자리 결함이 있으면 활성 폴더를 `.omcbak` 원본으로 먼저 되돌린 뒤 다시 패치한다.
|
|
91
|
-
- OMC 마켓플레이스 `origin` 을 `omc-pinned://` 로
|
|
99
|
+
- OMC 마켓플레이스 `origin` 을 `omc-pinned://` 로 바꾼다(`이미 고정됨` 이면 그대로 둔다). OMC 가 더 이상 자동 업데이트되지 않는다. 되돌리기는 `revert`.
|
|
100
|
+
- 1단계에 `[!] 고정 기록(PINNED)의 옛 URL 칸을 ...` 줄이 있었으면 그 기록을 어떻게 할지 함께 묻는다(`규칙` 절의 `PINNED` 줄 다루기).
|
|
101
|
+
- 적용할 항목도 있으면 답은 셋이다. 지우고 적용(승인한 파일을 지운 뒤 3단계로, 적용은 실패 0건), 남기고 적용(3단계로, 그 줄 하나가 실패로 남아 `실패 1건`), 적용하지 않음(여기서 끝냄).
|
|
102
|
+
- 1단계 결과가 위 표의 `PINNED` 줄이면 적용할 항목이 없다. 적용은 묻지 않고 지울지 남길지만 묻는다. 어느 쪽이든 그 뒤 4단계로 건너뛴다.
|
|
92
103
|
- 승인이 없으면 적용하지 않는다.
|
|
93
104
|
|
|
94
105
|
### 3. 적용
|
|
@@ -117,12 +128,13 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no
|
|
|
117
128
|
```
|
|
118
129
|
|
|
119
130
|
- `패치 N건 / 실패 0건`, `[3/4]` 의 PostToolUse 제거, `[4/4]` 의 고정 결과를 확인한다.
|
|
120
|
-
-
|
|
131
|
+
- 끝 줄 `완료. 실패 N건.` 의 N 이 1 이상이면 멈추고 출력 그대로 보고한다. 예외는 하나다. `완료. 실패 1건.` 이고 그 1건이 2단계에서 남기기로 한 `PINNED` 의 `[!]` 줄이면 멈추지 않고 4단계로 간다. 끝의 `[!] PostToolUse 훅 제거는 ...` 줄은 재시작 안내라 실패가 아니다.
|
|
132
|
+
- `완료. 실패 1건.` 이고 그 1건이 1단계에 없던 `[!] 고정 기록(PINNED)` 줄이면 멈추고 보고한 뒤 `PINNED` 줄 다루기대로 묻는다. 훅 패치는 이미 적용돼 있다. 지우기로 하면 지운 뒤, 남기기로 하면 그대로 4단계로 간다.
|
|
121
133
|
- `[!] 헬퍼 자리 결함 N건은 고치지 않았습니다.` 가 나오면 1 을 건너뛰었거나 1 이 그 파일을 되돌리지 못한 것이다. 1 과 2 를 한 번만 다시 한다. 다시 해도 같으면 멈추고 보고한다.
|
|
122
134
|
|
|
123
135
|
### 4. 검증 (다섯 가지 모두)
|
|
124
136
|
|
|
125
|
-
1. 다시 진단한다. `결과: 모두 적용된 상태입니다.` 가 나와야 하고, 활성 버전에 `헬퍼 자리 결함` 줄이 없어야 한다.
|
|
137
|
+
1. 다시 진단한다. `결과: 모두 적용된 상태입니다.` 가 나와야 하고, 활성 버전에 `헬퍼 자리 결함` 줄이 없어야 한다. 사용자가 `PINNED` 를 남기기로 했으면 `결과: 고정 기록(PINNED)의 옛 URL 칸이 이 도구로 지울 수 없는 곳에 있습니다.` 와 그 `[!]` 줄이 나오는 것은 정상이다. 나머지 검증을 계속한다.
|
|
126
138
|
|
|
127
139
|
```bash
|
|
128
140
|
node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no-update
|
|
@@ -186,21 +198,22 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --check --no
|
|
|
186
198
|
| 그 밖의 `probe-pm:` 줄(종료 코드 2) | 명령 오류다. 경로를 다시 구해 한 번만 다시 실행한다. 같으면 출력을 보고하고 멈춘다 |
|
|
187
199
|
| 판정 줄 뒤의 `probe-pm: 임시 폴더를 지우지 못함` | 판정은 그대로 쓴다. 남은 임시 폴더 경로를 보고에 적어 사용자가 지우게 한다 |
|
|
188
200
|
|
|
189
|
-
5. 마켓플레이스 고정을 확인한다. `omc-pinned://` 로
|
|
201
|
+
5. 마켓플레이스 고정을 확인한다. 명령은 origin 이 `omc-pinned://` 로 시작하는지만 보고 `pinned` 나 `not-pinned` 한 단어를 낸다. origin 의 자격 증명과 쿼리 값은 출력에 나오지 않는다. `pinned` 면 고정된 것이다.
|
|
190
202
|
|
|
191
203
|
```bash
|
|
192
|
-
git -C "$HOME/.claude/plugins/marketplaces/omc" config --get remote.origin.url
|
|
204
|
+
case "$(git -C "$HOME/.claude/plugins/marketplaces/omc" config --get remote.origin.url)" in omc-pinned://*) echo pinned;; *) echo not-pinned;; esac
|
|
193
205
|
```
|
|
194
206
|
|
|
195
207
|
PowerShell:
|
|
196
208
|
|
|
197
209
|
```powershell
|
|
198
|
-
(git -C "$env:USERPROFILE/.claude/plugins/marketplaces/omc" config --get remote.origin.url) -
|
|
210
|
+
if ((git -C "$env:USERPROFILE/.claude/plugins/marketplaces/omc" config --get remote.origin.url) -like 'omc-pinned://*') { 'pinned' } else { 'not-pinned' }
|
|
199
211
|
```
|
|
200
212
|
|
|
201
213
|
### 5. 보고
|
|
202
214
|
|
|
203
215
|
- 활성 버전, 패치 건수, 헬퍼 자리 결함 처리 여부, PostToolUse 제거 목록, 고정 상태, project-memory 확인 결과
|
|
216
|
+
- `PINNED` 줄이 있었으면 어떻게 했는지(지운 경로, 또는 남긴 기록과 그 경로. 다른 이름이 있던 기록은 다른 이름의 경로)
|
|
204
217
|
- 훅 스크립트 패치는 바로 적용된다. 훅이 호출될 때마다 파일을 새로 읽기 때문이다.
|
|
205
218
|
- PostToolUse 제거는 `hooks.json` 변경이라 각 세션을 다시 시작해야 적용된다.
|
|
206
219
|
- Windows 에서 이미 쌓인 커널 풀은 재부팅으로만 회수된다. 재부팅은 사용자가 직접 한다.
|
|
@@ -215,7 +228,7 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --revert
|
|
|
215
228
|
- `.omcbak` 백업으로 설치된 모든 버전의 훅 스크립트와 `hooks.json` 을 되돌리고 마켓플레이스 `origin` 을 원래 URL 로 복구한다.
|
|
216
229
|
- 되돌린 뒤 Claude Code 를 다시 시작해야 반영된다.
|
|
217
230
|
- `되돌리기를 끝내지 못했습니다.` 가 나오면 위의 `[!]` 줄을 그대로 보고한다. 종료 코드는 1 이다. 백업이 없어 남은 파일과 hooks.json 도 이 끝 줄로 끝난다.
|
|
218
|
-
- 고정 기록(`PINNED`)을 지우지 못하면(읽기 전용 폴더, 폴더인 `PINNED`) `[!] 고정 기록(PINNED)을 지우지 못함` 으로 알리고 실패로 센다. 링크인 `PINNED` 너머에 옛 URL 칸이 있으면 링크를 두고 `[!]` 로 알린다.
|
|
231
|
+
- 고정 기록(`PINNED`)을 지우지 못하면(읽기 전용 폴더, 폴더인 `PINNED`) `[!] 고정 기록(PINNED)을 지우지 못함` 으로 알리고 실패로 센다. 링크인 `PINNED` 너머에 옛 URL 칸이 있으면 링크를 두고 `[!]` 로 알린다. 옛 URL 칸이 든 기록에 같은 파일의 다른 이름이 있으면 이 이름만 지우고 `[!]` 로 알린다. 셋 다 origin 은 그대로 되돌린다. 위 `규칙` 절의 `PINNED` 줄 다루기대로 사용자에게 묻는다.
|
|
219
232
|
- `이 도구가 만들지 않은 수정이 남아 있습니다` 가 나오면 백업이 없는 손 수정이다. 출력에 적힌 복구 명령을 그대로 보고하고, 사용자 승인 없이 실행하지 않는다.
|
|
220
233
|
|
|
221
234
|
## 함정
|
|
@@ -225,14 +238,15 @@ node "<이 스킬 디렉터리 절대경로>/scripts/omc-patch.mjs" --revert
|
|
|
225
238
|
- 헬퍼 자리 결함이 남은 채 `revert` 없이 적용을 다시 돌려도 바뀌는 것이 없다. 이미 감싼 파일은 건너뛰기 때문이다.
|
|
226
239
|
- `--root` 와 `--revert --root` 는 그 폴더만 다룬다. 고정 해제까지 하려면 인자 없는 `--revert` 를 쓴다.
|
|
227
240
|
- 고정을 풀지 않고 OMC 를 올리려면 먼저 `revert` 한다. 그렇지 않으면 마켓플레이스 fetch 가 계속 실패한다.
|
|
228
|
-
- 고정 기록(`PINNED`)은 `~/.claude/omc-local-patches/PINNED` 에 생긴다. 고정한 시각, 판, 기기 이름만 담고 URL 은 담지 않는다. 옛 판의 기록에 URL 칸이 있으면 `--check` 가 알리고 다음 적용 때 지운다. 지우지 못하면 `[!]` 로 알리고 실패로 센다(링크는 따라가 쓰지 않음). 이 계정만 읽을 수 있다(0600). 기록일 뿐이라 지워도 고정은 풀리지 않고, 이미 고정된 상태에서는 다시 만들어지지 않는다. `revert` 는 origin 값에서만 원래 URL 을 되찾는다. 다른 기기의 기록이면 알리기만 한다.
|
|
241
|
+
- 고정 기록(`PINNED`)은 `~/.claude/omc-local-patches/PINNED` 에 생긴다. 고정한 시각, 판, 기기 이름만 담고 URL 은 담지 않는다. 옛 판의 기록에 URL 칸이 있으면 `--check` 가 알리고 다음 적용 때 지운다. 지우지 못하면 `[!]` 로 알리고 실패로 센다(링크는 따라가 쓰지 않음). 하드 링크로 이어진 다른 이름에는 그 칸이 남는다. 도구는 그 이름을 찾을 번호를 알린다. POSIX 에서는 이 계정만 읽을 수 있다(0600). Windows 에서는 폴더의 상속 권한을 따른다. 기록일 뿐이라 지워도 고정은 풀리지 않고, 이미 고정된 상태에서는 다시 만들어지지 않는다. `revert` 는 origin 값에서만 원래 URL 을 되찾는다. 다른 기기의 기록이면 알리기만 한다.
|
|
229
242
|
- `--check` 와 `--revert` 를 함께 주면 도구는 아무것도 하지 않고 종료 코드 1 로 멈춘다. 진단만 하려면 `--check` 만 준다.
|
|
230
243
|
- 도구는 git 을 부를 때 저장소를 고르는 변수(`GIT_DIR`, `GIT_WORK_TREE` 등 `git rev-parse --local-env-vars` 목록)를 지운다. git 훅 안에서 불러도 마켓플레이스 저장소만 다룬다.
|
|
231
244
|
- 마켓플레이스의 `.git` 이 깨졌으면(중단된 clone 등) git 이 위쪽 저장소로 올라가지 못하게 막는다. 그때 `[4/4]` 는 `origin remote 없음` 으로 끝나고 고정하지 않는다.
|
|
232
245
|
- `--scan` 은 읽기만 한다. 플러그인 캐시도 `installed_plugins.json` 도 읽지 않고 준 파일만 본다.
|
|
233
246
|
- 도구는 모르는 인자를 받지 않는다. `--check`, `--revert`, `--no-update`, `--root <폴더>`, `--scan <파일>...`, `--help` 밖의 인자나 `--root=<폴더>` 꼴을 주면 아무것도 하지 않고 사용법과 함께 종료 1 로 멈춘다.
|
|
234
247
|
- 출력의 origin URL 에 사용자 이름과 비밀번호가 있으면 `//***@` 로 가린다. 도구는 origin 을 설정 값 그대로 읽어 고정하고 되돌린다(전역 `insteadOf` 를 펼치지 않음).
|
|
235
|
-
- origin 을 바꾸지 못하면(`.git` 쓰기 권한 등) `[!] 고정 실패` 나 `[!] 고정 해제 실패` 와 종료 1 로 끝난다. 그 줄을 그대로 보고한다. 고정 해제에
|
|
236
|
-
- `--check` 는 쓸 수 없는 파일을 `패치 필요, 쓸 수 없음` 으로 미리 알린다. 폴더 밖 링크 때문에 되돌리지 못한 파일은 손 복구 방법을 함께 낸다. 사용자 승인 없이 그 방법을 실행하지 않는다.
|
|
237
|
-
- 파일은 새 임시
|
|
248
|
+
- origin 을 바꾸지 못하면(`.git` 쓰기 권한 등) `[!] 고정 실패` 나 `[!] 고정 해제 실패` 와 종료 1 로 끝난다. 그 줄을 그대로 보고한다. 고정 해제에 실패하면 도구가 원래 URL 을 호스트까지만 보인다. 원문은 origin 의 `omc-pinned://` 뒤에 그대로 남는다.
|
|
249
|
+
- `--check` 는 쓸 수 없는 파일을 `패치 필요, 쓸 수 없음` 으로 미리 알린다. 폴더 밖 링크 때문에 되돌리지 못한 파일은 손 복구 방법을 함께 낸다. 링크 대상 파일에 이 도구 계열의 패치가 보이지 않으면 덮어쓰라고 하지 않고, 링크가 가리키는 곳이 맞는지 먼저 확인하라고 한다. 사용자 승인 없이 그 방법을 실행하지 않는다.
|
|
250
|
+
- 파일은 새 임시 파일을 디스크까지 내린 뒤 rename 으로 바꾼다. 하드 링크로 이어진 폴더 밖 파일은 바뀌지 않는다. 쓰지 못한 파일은 `쓰지 못함` 으로 이름을 대고 실패로 센다.
|
|
251
|
+
- 읽을 수 없는 훅(권한, 폴더, FIFO)은 `읽지 못함: <이름> (<코드>)`, 쓸 권한이 없거나 일반 파일이 아닌 훅은 `쓰지 않음: <이름> (쓸 권한이 없음 또는 일반 파일이 아님)` 으로 알리고 실패로 센다. `--check` 는 읽지 못한 훅이 있으면 모두 적용된 상태로 보지 않는다. 설정 파일도 일반 파일만 읽는다. `installed_plugins.json` 이 FIFO 처럼 일반 파일이 아니면 활성 버전이 `(추정: ...)` 으로 나오고, `settings.json` 이면 `DISABLE_AUTOUPDATER` 줄을 생략한다. 도구가 처리하지 못한 오류는 경로를 정화한 한 줄과 종료 1 로 끝난다.
|
|
238
252
|
- 도구의 주석과 출력 문구는 한글이다.
|