monster-orchestrator 3.3.0 → 3.5.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/dist-obf/.buildinfo +5 -5
- package/dist-obf/governor-tick.js +1 -1
- package/dist-obf/index.js +1 -1
- package/dist-obf/manifest.json +278 -10
- package/dist-obf/package.json +4 -4
- package/dist-obf/supervisor-notify-tick.js +1 -1
- package/manifest.json +278 -10
- package/package.json +1 -1
- package/scripts/monster-deathcheck +17 -150
- package/scripts/monster-stop-hook +15 -2
|
@@ -8,11 +8,6 @@
|
|
|
8
8
|
- 誰かが list_monsters を叩いた時にしか回らない(pull 型)
|
|
9
9
|
ため、「死んだこと」も「いつ死んだか」もレジストリからは分からない。
|
|
10
10
|
|
|
11
|
-
さらに reconcile() は寿命判定を生存判定の内側に置いているため、寿命切れの
|
|
12
|
-
monster が「先に session が消えていた」だけで timeout ではなく
|
|
13
|
-
"session disappeared" とラベルされる。実測では同一事象(寿命ちょうどの死)が
|
|
14
|
-
片方 timeout・片方 session disappeared になっていた(53cb1d2a / e8df2b33)。
|
|
15
|
-
|
|
16
11
|
このスクリプトは既存の記録だけから死を判定する。何も書き換えず、何も kill しない。
|
|
17
12
|
tmux は has-session のみ(attach / send-keys / kill 系は一切使わない)。
|
|
18
13
|
|
|
@@ -22,16 +17,9 @@ tmux は has-session のみ(attach / send-keys / kill 系は一切使わない
|
|
|
22
17
|
= ターン途中で消えた(異常死)。
|
|
23
18
|
逆に awaiting_input を経てから session_end なら、指示通り止まった(正常完了)。
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
その send 自体が reconcile を回して tmuxKillSession() を起こす**。
|
|
29
|
-
⇒ 予告が無いと「反応が無いから声をかける → その行為が殺す」経路に入る。
|
|
30
|
-
寿命切れ相手への送信は、無駄なのではなく有害。
|
|
31
|
-
|
|
32
|
-
ただし実測では、寿命で終了した 28 体のうち 23 体は awaiting_input で駐機中
|
|
33
|
-
(=仕掛かりが無く無害)だった。全件に鳴らすとノイズが本物を埋めるため、
|
|
34
|
-
「寿命が近く、かつまだ働いている」ものだけを警告対象にする。
|
|
20
|
+
席の寿命について(箱 1812・主君裁定 2026-09-19「約束を消す」):
|
|
21
|
+
orchestrator は時計で席を止めない(寿命・失効の欄も持たない)。
|
|
22
|
+
⇒ この道具も「あと N 分で消える」を予告しない。予告は執行されない約束になる。
|
|
35
23
|
|
|
36
24
|
usage:
|
|
37
25
|
monster-deathcheck 人が読む形式で全 monster を判定(レジストリ外の GHOST を含む)
|
|
@@ -47,16 +35,12 @@ exit code:
|
|
|
47
35
|
成果が失われるという一点で死亡と同じ扱いにしている
|
|
48
36
|
2 = 測定不能(state.json や tmux が読めない等)。⚠️ 0 と混同しないこと:
|
|
49
37
|
「異常なし」と「測れなかった」を同じ値にすると、今日の不検出を再現する
|
|
50
|
-
※ 寿命警告(EXPIRING-*)は exit code を変えない。まだ何も失われていないため、
|
|
51
|
-
「1 が返った=人が見るべきものがある」と混ぜない
|
|
52
38
|
|
|
53
39
|
環境変数:
|
|
54
40
|
MONSTER_ROOT monster レジストリの場所(既定 ~/.claude/monsters)
|
|
55
41
|
TMUX_BIN tmux の絶対パス(既定は PATH → /opt/homebrew/bin/tmux の順に探索)
|
|
56
42
|
⚠️ 非対話 SSH では PATH に /opt/homebrew/bin が乗らないため必要
|
|
57
43
|
CLAUDE_PROJECTS_ROOT transcript の場所(既定 ~/.claude/projects)
|
|
58
|
-
MONSTER_WARN_MIN 寿命の予告閾値・分(既定 30。ダッシュボード表示)
|
|
59
|
-
MONSTER_CRIT_MIN 寿命の最終警告閾値・分(既定 5。鳴らす想定・現在は未発火)
|
|
60
44
|
"""
|
|
61
45
|
|
|
62
46
|
import glob
|
|
@@ -66,7 +50,6 @@ import shutil
|
|
|
66
50
|
import subprocess
|
|
67
51
|
import sys
|
|
68
52
|
import time
|
|
69
|
-
from datetime import datetime, timezone
|
|
70
53
|
|
|
71
54
|
# 板 #442 ③: 解決の式を全経路で揃える(server=src/paths.ts は MONSTER_MCP_ROOT を honour)。
|
|
72
55
|
# MONSTER_ROOT は後方互換の別名として残す(従来この道具だけがこの名前を見ていた)。
|
|
@@ -86,31 +69,21 @@ WATCHER_FRESH_SEC = 180
|
|
|
86
69
|
# そして STALLED-*(レジストリ内で生きていない=応答できない)。
|
|
87
70
|
# GHOST の実害は「成果が見えなくなる」、STALLED の実害は「指揮そのものが誤る」
|
|
88
71
|
#(盤面の稼働数が嘘になり、動けない monster を配車先として数えてしまう)。
|
|
89
|
-
# 寿命警告(EXPIRING-*)はここに入れない——まだ何も失われていないため。
|
|
90
|
-
# 死亡と警告を同じ値にすると「1 が返った=人が見るべきものがある」という読み方が壊れる。
|
|
91
72
|
URGENT = {"DIED-UNDETECTED", "DIED-MIDTURN", "GHOST",
|
|
92
73
|
"STALLED-RATE-LIMIT", "STALLED-BACKEND"}
|
|
93
74
|
|
|
94
75
|
# 鳴らす対象(実発火は CTO 裁定後)
|
|
95
76
|
NOTIFY = {"DIED-UNDETECTED", "DIED-MIDTURN", "GHOST",
|
|
96
|
-
"STALLED-RATE-LIMIT", "STALLED-BACKEND"
|
|
77
|
+
"STALLED-RATE-LIMIT", "STALLED-BACKEND"}
|
|
97
78
|
|
|
98
79
|
VERDICT_ORDER = [
|
|
99
80
|
"GHOST", "DIED-UNDETECTED", "DIED-MIDTURN",
|
|
100
81
|
"STALLED-RATE-LIMIT", "STALLED-BACKEND", "STALLED-API-ERROR",
|
|
101
|
-
"
|
|
102
|
-
"TIMEOUT-KILL", "
|
|
82
|
+
"ALIVE-UNVERIFIED",
|
|
83
|
+
"TIMEOUT-KILL", "COMPLETED-MISLABELED",
|
|
103
84
|
"ALIVE", "COMPLETED", "UNKNOWN",
|
|
104
85
|
]
|
|
105
86
|
|
|
106
|
-
# 寿命の閾値(分)
|
|
107
|
-
WARN_MIN = int(os.environ.get("MONSTER_WARN_MIN") or 30)
|
|
108
|
-
CRIT_MIN = int(os.environ.get("MONSTER_CRIT_MIN") or 5)
|
|
109
|
-
|
|
110
|
-
# 駐機中とみなすイベント。これで止まっているなら仕掛かりが無く、寿命切れは無害。
|
|
111
|
-
PARKED_EVENTS = {"awaiting_input"}
|
|
112
|
-
|
|
113
|
-
|
|
114
87
|
class Unmeasurable(Exception):
|
|
115
88
|
"""測定できなかった。異常なしとは違う。"""
|
|
116
89
|
|
|
@@ -177,40 +150,6 @@ def tmux_alive(tmux, session, retries=3, delay=0.25):
|
|
|
177
150
|
return False
|
|
178
151
|
|
|
179
152
|
|
|
180
|
-
def minutes_until(expires_at, now=None):
|
|
181
|
-
"""残り寿命(分)。パースできなければ None。
|
|
182
|
-
|
|
183
|
-
⚠️ None を「余裕あり」に丸めない。読めなかったものを安全側に倒すと、
|
|
184
|
-
測れなかったことが異常なしとして描かれる(本件の失敗そのもの)。
|
|
185
|
-
"""
|
|
186
|
-
if not expires_at:
|
|
187
|
-
return None
|
|
188
|
-
try:
|
|
189
|
-
exp = datetime.fromisoformat(expires_at.replace("Z", "+00:00"))
|
|
190
|
-
except (ValueError, AttributeError):
|
|
191
|
-
return None
|
|
192
|
-
if exp.tzinfo is None:
|
|
193
|
-
exp = exp.replace(tzinfo=timezone.utc)
|
|
194
|
-
now = now or datetime.now(timezone.utc)
|
|
195
|
-
return (exp - now).total_seconds() / 60.0
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
def classify_expiry(remaining_min, last_event):
|
|
199
|
-
"""寿命が近い running monster を分類する純粋関数(--verify で単体検証する)
|
|
200
|
-
|
|
201
|
-
返り値: (verdict, level)。閾値の外なら (None, None)。
|
|
202
|
-
EXPIRING-PARKED … awaiting_input で駐機中。仕掛かりが無いので寿命切れは無害。
|
|
203
|
-
表示はするが鳴らさない(実測 28 件中 23 件がこれ。
|
|
204
|
-
鳴らせばノイズが本物を埋め、今日と同じ失敗になる)
|
|
205
|
-
EXPIRING-WORKING … まだ働いている。寿命が来れば仕掛かりごと消える。鳴らす対象。
|
|
206
|
-
"""
|
|
207
|
-
if remaining_min is None or remaining_min > WARN_MIN:
|
|
208
|
-
return (None, None)
|
|
209
|
-
if last_event in PARKED_EVENTS:
|
|
210
|
-
return ("EXPIRING-PARKED", None)
|
|
211
|
-
return ("EXPIRING-WORKING", "crit" if remaining_min <= CRIT_MIN else "warn")
|
|
212
|
-
|
|
213
|
-
|
|
214
153
|
|
|
215
154
|
# ---------------------------------------------------------------------------
|
|
216
155
|
# ゴースト monster(レジストリ外で生きている monster)
|
|
@@ -224,7 +163,6 @@ def classify_expiry(remaining_min, last_event):
|
|
|
224
163
|
# レジストリ外なので、
|
|
225
164
|
# - list_monsters に出ない
|
|
226
165
|
# - stop_monster で止められない(resolveMonsterId が見つけられない)
|
|
227
|
-
# - reconcile が走査しないので expires_at が効かない
|
|
228
166
|
# ⇒ 止める手段が tmux 直接操作しかない monster になる。
|
|
229
167
|
#
|
|
230
168
|
# 🔑 実害は暴走ではなく「成果が誰からも見えなくなること」。
|
|
@@ -317,8 +255,7 @@ def find_ghosts(monsters, tmux):
|
|
|
317
255
|
"stalled_unverified": not readable,
|
|
318
256
|
"spawned_at": spawned.get(sess, ""),
|
|
319
257
|
"detail": ("tmux にセッションが在るが state.json にエントリが無い。"
|
|
320
|
-
"list_monsters に出ず、stop_monster
|
|
321
|
-
"reconcile が走査しないので寿命も効かない"),
|
|
258
|
+
"list_monsters に出ず、stop_monster で止められない"),
|
|
322
259
|
})
|
|
323
260
|
return out
|
|
324
261
|
|
|
@@ -445,8 +382,7 @@ def classify(mid, rec, events_by_id, tmux):
|
|
|
445
382
|
return ("DIED-UNDETECTED",
|
|
446
383
|
"state.json は running だが tmux セッションが3回とも取れない")
|
|
447
384
|
|
|
448
|
-
# tmux に居ても API
|
|
449
|
-
# 応答できない monster に「あと N 分」と言っても意味がないため。
|
|
385
|
+
# tmux に居ても API が応答しないことがある。
|
|
450
386
|
readable, is_err, status = read_last_api_state(mid)
|
|
451
387
|
if not readable:
|
|
452
388
|
# ⚠️ 読めなかったものを ALIVE に丸めない(正常と未確認は別物)
|
|
@@ -457,26 +393,12 @@ def classify(mid, rec, events_by_id, tmux):
|
|
|
457
393
|
if stalled:
|
|
458
394
|
return (stalled, stalled_detail(stalled, status))
|
|
459
395
|
|
|
460
|
-
|
|
461
|
-
remaining = minutes_until(rec.get("expires_at"))
|
|
462
|
-
verdict, _level = classify_expiry(remaining, events[-1] if events else "")
|
|
463
|
-
if verdict == "EXPIRING-PARKED":
|
|
464
|
-
return (verdict,
|
|
465
|
-
f"残り {remaining:.0f} 分。awaiting_input で駐機中なので"
|
|
466
|
-
"寿命切れは無害(鳴らさない)")
|
|
467
|
-
if verdict == "EXPIRING-WORKING":
|
|
468
|
-
return (verdict,
|
|
469
|
-
f"🚨 残り {remaining:.0f} 分で作業中。寿命が来れば仕掛かりごと消える。"
|
|
470
|
-
"この状態で send_to_monster を撃つと reconcile が走って"
|
|
471
|
-
"その送信自体が kill を起こす")
|
|
472
|
-
if remaining is None:
|
|
473
|
-
return ("ALIVE", "tmux セッション在席を実測(3回確認)"
|
|
474
|
-
"/⚠️ expires_at が読めず残り寿命は不明")
|
|
475
|
-
return ("ALIVE", f"tmux セッション在席を実測(3回確認)/残り {remaining:.0f} 分")
|
|
396
|
+
return ("ALIVE", "tmux セッション在席を実測(3回確認)")
|
|
476
397
|
|
|
477
398
|
if status == "timeout":
|
|
399
|
+
# 箱 1812 より前の記録だけが持つ status(今の orchestrator は時計で止めない)
|
|
478
400
|
return ("TIMEOUT-KILL",
|
|
479
|
-
"orchestrator
|
|
401
|
+
"(過去の記録)orchestrator が時計で止めた席。今の版はこの status を書かない")
|
|
480
402
|
|
|
481
403
|
# 停止理由は stop_reason ではなく last_action の "stopped: " 接頭辞に入る(実測)。
|
|
482
404
|
# stop_reason フィールドは全件 null だった。
|
|
@@ -488,12 +410,6 @@ def classify(mid, rec, events_by_id, tmux):
|
|
|
488
410
|
|
|
489
411
|
if last_action == "session disappeared":
|
|
490
412
|
# ここが本題。session disappeared は死因ではなくラベルにすぎない。
|
|
491
|
-
expires = rec.get("expires_at") or ""
|
|
492
|
-
found_at = rec.get("last_action_at") or ""
|
|
493
|
-
if expires and found_at and found_at > expires:
|
|
494
|
-
return ("TIMEOUT-MISLABELED",
|
|
495
|
-
f"寿命({expires})を過ぎてから発見({found_at})。"
|
|
496
|
-
"本来 timeout だが session が先に消えたため disappeared と誤ラベル")
|
|
497
413
|
if ended and not progressed:
|
|
498
414
|
return ("DIED-MIDTURN",
|
|
499
415
|
"最後の user_prompt 以降 idle/awaiting_input が一度も出ないまま "
|
|
@@ -533,16 +449,12 @@ def collect():
|
|
|
533
449
|
results = []
|
|
534
450
|
for mid, rec in monsters.items():
|
|
535
451
|
verdict, detail = classify(mid, rec, events_by_id, tmux)
|
|
536
|
-
remaining = minutes_until(rec.get("expires_at"))
|
|
537
452
|
results.append({
|
|
538
453
|
"short_id": rec.get("short_id", mid[:8]),
|
|
539
454
|
"verdict": verdict,
|
|
540
455
|
"detail": detail,
|
|
541
456
|
"workdir": rec.get("workdir", ""),
|
|
542
457
|
"spawned_at": rec.get("spawned_at", ""),
|
|
543
|
-
"expires_at": rec.get("expires_at", ""),
|
|
544
|
-
# ダッシュボードが「あと何分か」を出せるように、running には必ず載せる
|
|
545
|
-
"remaining_min": None if remaining is None else round(remaining),
|
|
546
458
|
})
|
|
547
459
|
ghosts = find_ghosts(monsters, tmux)
|
|
548
460
|
live, orphan = count_watchers(monsters)
|
|
@@ -550,12 +462,10 @@ def collect():
|
|
|
550
462
|
"host": os.uname().nodename,
|
|
551
463
|
"user": os.environ.get("USER", ""),
|
|
552
464
|
"monster_root": ROOT,
|
|
553
|
-
"thresholds": {"warn_min": WARN_MIN, "crit_min": CRIT_MIN},
|
|
554
465
|
"monsters": results,
|
|
555
466
|
"watchers": {"live": live, "orphan": orphan},
|
|
556
467
|
"ghosts": ghosts,
|
|
557
468
|
"urgent": sum(1 for r in results if r["verdict"] in URGENT) + len(ghosts),
|
|
558
|
-
"expiring": sum(1 for r in results if r["verdict"] == "EXPIRING-WORKING"),
|
|
559
469
|
}
|
|
560
470
|
|
|
561
471
|
|
|
@@ -577,11 +487,7 @@ def notify_preview(report):
|
|
|
577
487
|
print(" 送る対象なし")
|
|
578
488
|
return
|
|
579
489
|
for r in sorted(targets, key=lambda x: (x["verdict"], x["short_id"])):
|
|
580
|
-
|
|
581
|
-
# 死んだ monster に「残り寿命」を出さない(無意味なうえ、読む人が
|
|
582
|
-
# 「まだ生きている」と誤読する)。残り寿命は寿命警告のときだけ意味を持つ。
|
|
583
|
-
rem = "" if (dead or r["remaining_min"] is None) else f"(残り {r['remaining_min']} 分)"
|
|
584
|
-
print(f" [{'死亡' if dead else '寿命間近'}] {r['short_id']} {rem} {r['workdir']}")
|
|
490
|
+
print(f" [{r['verdict']}] {r['short_id']} {r['workdir']}")
|
|
585
491
|
print(f" {r['detail']}")
|
|
586
492
|
|
|
587
493
|
|
|
@@ -649,35 +555,6 @@ def verify_stalled():
|
|
|
649
555
|
return ok
|
|
650
556
|
|
|
651
557
|
|
|
652
|
-
def verify_expiry():
|
|
653
|
-
"""寿命判定の単体検証(純粋関数なので実データに依存しない=どの機体でも回る)"""
|
|
654
|
-
W, C = WARN_MIN, CRIT_MIN
|
|
655
|
-
cases = [
|
|
656
|
-
# (残り分, 直近イベント, 期待 verdict, 期待 level)
|
|
657
|
-
(W + 1, "idle", None, None), # 閾値の外は何も出さない
|
|
658
|
-
(W, "idle", "EXPIRING-WORKING", "warn"), # 境界は含む
|
|
659
|
-
(C + 1, "user_prompt", "EXPIRING-WORKING", "warn"),
|
|
660
|
-
(C, "user_prompt", "EXPIRING-WORKING", "crit"), # 境界は含む
|
|
661
|
-
(1, "", "EXPIRING-WORKING", "crit"), # イベント無しは働いている扱い(安全側)
|
|
662
|
-
(0, "idle", "EXPIRING-WORKING", "crit"),
|
|
663
|
-
(-5, "idle", "EXPIRING-WORKING", "crit"), # 既に超過していても出す
|
|
664
|
-
# 駐機中は閾値内でも鳴らさない(23/28 がこれ)
|
|
665
|
-
(W, "awaiting_input", "EXPIRING-PARKED", None),
|
|
666
|
-
(0, "awaiting_input", "EXPIRING-PARKED", None),
|
|
667
|
-
# 読めなかったものを「余裕あり」に丸めない
|
|
668
|
-
(None, "idle", None, None),
|
|
669
|
-
]
|
|
670
|
-
ok = True
|
|
671
|
-
for remaining, last_event, want_v, want_l in cases:
|
|
672
|
-
got_v, got_l = classify_expiry(remaining, last_event)
|
|
673
|
-
good = (got_v == want_v and got_l == want_l)
|
|
674
|
-
ok = ok and good
|
|
675
|
-
rem = "None" if remaining is None else f"{remaining:>4}"
|
|
676
|
-
print(f" {'OK ' if good else 'NG '}残り{rem}分 / {last_event or '(無)':<14} "
|
|
677
|
-
f"→ {got_v}/{got_l}")
|
|
678
|
-
return ok
|
|
679
|
-
|
|
680
|
-
|
|
681
558
|
def verify():
|
|
682
559
|
"""対照実験: 測定器が既知ケースを正しく分類できるか。
|
|
683
560
|
|
|
@@ -688,17 +565,12 @@ def verify():
|
|
|
688
565
|
ghost_ok = verify_ghost()
|
|
689
566
|
print("\n--- API 応答不能の判定(純粋関数・実データ非依存)---")
|
|
690
567
|
stalled_ok = verify_stalled()
|
|
691
|
-
print("\n--- 寿命判定(純粋関数・実データ非依存)---")
|
|
692
|
-
expiry_ok = verify_expiry()
|
|
693
568
|
print(f"\n--- 死亡判定(既知ケース・実データ依存)---")
|
|
694
569
|
cases = {
|
|
695
570
|
# short_id: 期待する分類(人手で一次資料まで確認済み・2026-08-04)
|
|
696
571
|
"6e3077c4": "DIED-MIDTURN", # jest 実行中に SIGKILL、transcript も途中で終端
|
|
697
572
|
"799fafd9": "DIED-MIDTURN", # 同上
|
|
698
|
-
"53cb1d2a": "TIMEOUT-KILL", # reconcile の tmuxKillSession(23ms 後に 137)
|
|
699
|
-
"e8df2b33": "TIMEOUT-MISLABELED", # 寿命ちょうど(+15秒で発見)
|
|
700
|
-
"926c8f08": "TIMEOUT-MISLABELED", # 寿命 37 分超過後に発見
|
|
701
|
-
"0893e508": "TIMEOUT-MISLABELED",
|
|
573
|
+
"53cb1d2a": "TIMEOUT-KILL", # 過去の記録: reconcile の tmuxKillSession(23ms 後に 137)
|
|
702
574
|
"7b0bb651": "COMPLETED-MISLABELED", # 正常完了なのに disappeared と記録
|
|
703
575
|
}
|
|
704
576
|
monsters = load_state()
|
|
@@ -720,13 +592,13 @@ def verify():
|
|
|
720
592
|
|
|
721
593
|
if checked == 0:
|
|
722
594
|
print(" (この機体には既知ケースが無い。死亡判定の生存は未確認)")
|
|
723
|
-
if not (
|
|
595
|
+
if not (ghost_ok and stalled_ok):
|
|
724
596
|
return 1
|
|
725
|
-
print("\n⚠️
|
|
597
|
+
print("\n⚠️ 純粋関数の判定は緑だが、死亡判定は検証できていない")
|
|
726
598
|
return 2
|
|
727
|
-
print("\n測定器は" + ("生きている(既知ケースを全て再現)" if ok and
|
|
599
|
+
print("\n測定器は" + ("生きている(既知ケースを全て再現)" if ok and ghost_ok and stalled_ok
|
|
728
600
|
else "既知ケースを外した。信用しないこと"))
|
|
729
|
-
return 0 if (ok and
|
|
601
|
+
return 0 if (ok and ghost_ok and stalled_ok) else 1
|
|
730
602
|
|
|
731
603
|
|
|
732
604
|
def ghost_as_row(g):
|
|
@@ -773,9 +645,6 @@ def render(report):
|
|
|
773
645
|
print(" " + " ".join(w["orphan"]))
|
|
774
646
|
|
|
775
647
|
print(f"\n>>> 今すぐ人が見るべき monster: {report['urgent']} 体")
|
|
776
|
-
if report["expiring"]:
|
|
777
|
-
print(f">>> 🚨 寿命が近く、まだ働いている monster: {report['expiring']} 体"
|
|
778
|
-
"(この相手に送信すると、その送信が kill を起こす)")
|
|
779
648
|
|
|
780
649
|
|
|
781
650
|
def main(argv):
|
|
@@ -796,8 +665,6 @@ def main(argv):
|
|
|
796
665
|
notify_preview(report)
|
|
797
666
|
else:
|
|
798
667
|
render(report)
|
|
799
|
-
# ⚠️ 寿命警告では exit code を変えない。死亡と警告を同じ値にすると
|
|
800
|
-
# 「1 が返った=誰か死んだ」という読み方が壊れる。
|
|
801
668
|
return 1 if report["urgent"] else 0
|
|
802
669
|
|
|
803
670
|
|
|
@@ -142,21 +142,31 @@ fi
|
|
|
142
142
|
# 指示本文を撒くと機密がそこへ流れる。
|
|
143
143
|
# 🔑 指紋の対象は **Claude が受け取った本文そのもの**。送り手側は同じ正規化
|
|
144
144
|
# (tmux.ts の sanitizeSendText)を通した文字列を対象にすること(二重情報源を作らない)。
|
|
145
|
+
# 🔑 受け手は bracketed paste で届いた本文を `<pasted_content id="…">` で**包み**、改行を足して prompt に
|
|
146
|
+
# 載せる(箱 n=1976・2.1.281 で実測)。包みの id は受け手が振るので、送り手は包んだ形の指紋を作れない。
|
|
147
|
+
# ⇒ 包みと空白を落とした形の指紋も `prompt_norm_sha256` に載せる(送り手は同じ正規化で照合する)。
|
|
145
148
|
# ⚠️ python3 が無い / prompt が無い環境では空になり、meta にキーが増えないだけ。
|
|
146
149
|
# 送り手側は従来どおり件数差へ安全に縮退する(fail-safe)。
|
|
147
150
|
PROMPT_SHA=""
|
|
151
|
+
PROMPT_NORM_SHA=""
|
|
148
152
|
if [ "$EVENT" = "user_prompt" ] && [ -s "$STDIN_TMP" ]; then
|
|
149
153
|
PROMPT_SHA=$(python3 - "$STDIN_TMP" <<'SHAPY' 2>/dev/null
|
|
150
|
-
import json, sys, hashlib
|
|
154
|
+
import json, sys, hashlib, re
|
|
151
155
|
try:
|
|
152
156
|
raw = open(sys.argv[1]).read()
|
|
153
157
|
d = json.loads(raw) if raw.strip() else {}
|
|
154
158
|
except Exception:
|
|
155
159
|
d = {}
|
|
156
160
|
p = d.get('prompt')
|
|
157
|
-
|
|
161
|
+
if isinstance(p, str):
|
|
162
|
+
# 2 つ目は正規化後の指紋(箱 n=1976)。正規化は src/transcript.ts の normalizeForMatch と同じ
|
|
163
|
+
n = re.sub(r'\s+', '', re.sub(r'</?pasted_content\b[^>]*>', '', p))
|
|
164
|
+
print(hashlib.sha256(p.encode('utf-8')).hexdigest()[:16],
|
|
165
|
+
hashlib.sha256(n.encode('utf-8')).hexdigest()[:16])
|
|
158
166
|
SHAPY
|
|
159
167
|
)
|
|
168
|
+
PROMPT_NORM_SHA="${PROMPT_SHA#* }"
|
|
169
|
+
PROMPT_SHA="${PROMPT_SHA%% *}"
|
|
160
170
|
fi
|
|
161
171
|
|
|
162
172
|
# NDJSON 行構築して append(O_APPEND で atomic)
|
|
@@ -167,6 +177,9 @@ if [ -n "$REASON" ]; then
|
|
|
167
177
|
elif [ -n "$SUBTYPE" ]; then
|
|
168
178
|
printf '{"ts":"%s","monster_id":"%s","event":"%s","session":"%s","meta":{"tail":"%s","subtype":"%s","message":%s}}\n' \
|
|
169
179
|
"$TS" "$ID" "$EVENT" "$SESSION" "$TAIL" "$SUBTYPE" "$MESSAGE_JSON" >> "$EVENTS" 2>/dev/null
|
|
180
|
+
elif [ -n "$PROMPT_SHA" ] && [ -n "$PROMPT_NORM_SHA" ]; then
|
|
181
|
+
printf '{"ts":"%s","monster_id":"%s","event":"%s","session":"%s","meta":{"tail":"%s","prompt_sha256":"%s","prompt_norm_sha256":"%s"}}\n' \
|
|
182
|
+
"$TS" "$ID" "$EVENT" "$SESSION" "$TAIL" "$PROMPT_SHA" "$PROMPT_NORM_SHA" >> "$EVENTS" 2>/dev/null
|
|
170
183
|
elif [ -n "$PROMPT_SHA" ]; then
|
|
171
184
|
printf '{"ts":"%s","monster_id":"%s","event":"%s","session":"%s","meta":{"tail":"%s","prompt_sha256":"%s"}}\n' \
|
|
172
185
|
"$TS" "$ID" "$EVENT" "$SESSION" "$TAIL" "$PROMPT_SHA" >> "$EVENTS" 2>/dev/null
|