@chrono-meta/fh-gate 1.4.89 → 1.4.91
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/judgment_circuits.txt +14 -0
- package/.claude/rules/fh_4axis_gate.md +7 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +25 -0
- package/CLAUDE.md +202 -12
- package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +105 -0
- package/knowledge/shared/harness-core/fh_three_layer_canon.md +307 -0
- package/knowledge/shared/harness-core/harness_incubator_doctrine.md +100 -0
- package/knowledge/shared/harness-core/onboarding_acceleration_autopilot.md +3 -1
- package/knowledge/shared/harness-core/ship_readiness_gate.md +181 -13
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +640 -0
- package/knowledge/shared/rules/multi_session_close_protocol.md +118 -0
- package/package.json +23 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +56 -8
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +33 -0
- package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +104 -15
- package/scripts/branch_claim.sh +266 -0
- package/scripts/chamber_run.sh +64 -2
- package/scripts/chamber_witness.sh +439 -0
- package/scripts/compaction_probe.sh +456 -0
- package/scripts/digest_landing_check.sh +385 -0
- package/scripts/directional_diff_gate.sh +459 -0
- package/scripts/fh_env_delta_scan.sh +15 -0
- package/scripts/fh_session_load.sh +34 -1
- package/scripts/field_canon_preload.sh +129 -0
- package/scripts/hook_source_lib.sh +41 -0
- package/scripts/judgment_circuit_lint.sh +239 -0
- package/scripts/novelty_claim_check.sh +193 -0
- package/scripts/relay_channel.sh +645 -0
- package/scripts/reviewer_capability_corpus.tsv +124 -0
- package/scripts/selfcheck.sh +106 -0
- package/scripts/session_close_check.sh +134 -1
- package/scripts/test_branch_claim_lanes.sh +231 -0
- package/scripts/test_dispatch_log_lanes.sh +35 -1
- package/scripts/test_field_canon_lanes.sh +142 -0
- package/scripts/test_hook_source_gate_lanes.sh +81 -0
- package/scripts/test_marker_crossfamily_lanes.sh +132 -0
- package/scripts/test_marker_floor_lanes.sh +9 -8
- package/scripts/test_relay_channel_lanes.sh +583 -0
- package/scripts/test_reviewer_capability_conformance.sh +173 -0
- package/scripts/test_wizard_snippet_merge_lanes.sh +104 -11
- package/scripts/utterance_landing_check.sh +209 -0
- package/templates/.git-hooks/pre-commit +297 -13
- package/templates/settings.Compaction.snippet.json +56 -0
- package/templates/settings.FieldCanon.snippet.json +51 -0
|
@@ -0,0 +1,456 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# compaction_probe.sh — 압축을 이벤트로 잡아 세션이 쥐고 있던 것을 **압축 전에 디스크로 봉인**하고,
|
|
3
|
+
# 압축 후 첫 프롬프트에서 **포인터만** 되주입한다.
|
|
4
|
+
#
|
|
5
|
+
# ── 왜 (진단) ──
|
|
6
|
+
# 압축은 "질의를 알기 전에, 되돌릴 방법 없이 버린다"(rate-distortion 서베이). FH 는 여기서
|
|
7
|
+
# 구조적으로 유리하다 — **원본이 디스크에 있다. 잃은 건 컨텍스트가 아니라 포인터다.**
|
|
8
|
+
# 그래서 이 스크립트가 복원하는 것은 내용이 아니라 **어디를 열면 되는가**다.
|
|
9
|
+
#
|
|
10
|
+
# ── 이 스크립트가 하는 것과 안 하는 것 (경계를 흐리지 마라) ──
|
|
11
|
+
# seal PreCompact — 세션이 쥔 것의 typed 원장을 디스크에 봉인. 압축을 **차단하지 않는다**
|
|
12
|
+
# digest UserPromptSubmit — 봉인분을 1회 되주입하고 소비 표시
|
|
13
|
+
# score advisory — 🟥 **UNCALIBRATED**. 아래 §계기 타당성 참조
|
|
14
|
+
#
|
|
15
|
+
# ── §계기 타당성 — `score` 는 UNCALIBRATED 가 아니라 **반증됐다** (2026-08-08 실측) ──
|
|
16
|
+
# 채점하려면 *압축 후 transcript 파일이 무엇을 담는지* 를 알아야 했고, 두 가능성이 있었다:
|
|
17
|
+
# ⓐ 파일이 히스토리를 그대로 보존하고 모델 컨텍스트만 압축된다
|
|
18
|
+
# ⓑ 파일에 압축 요약 레코드가 남고 그 이후가 실컨텍스트다
|
|
19
|
+
#
|
|
20
|
+
# **첫 실압축 관측(2026-08-08 15:51:28, 이 훅이 스스로 남긴 seal 이 증거)에서 ⓐ로 확정됐다**:
|
|
21
|
+
# 압축 직후 전사본은 user 109 · assistant 238 레코드를 전부 보존하고 있었고 압축 구조 필드는
|
|
22
|
+
# 0건이었다. 즉 **전사본을 grep 하는 채점기는 손실 0 을 영원히 보고한다 — fail-open 계기다.**
|
|
23
|
+
# 설계 정본의 "압축 후 프로브 채점"은 이 경로로는 성립하지 않는다. 캘리브레이션이 덜 된 게
|
|
24
|
+
# 아니라 **가정이 틀렸다.** 채점을 하려면 전사본이 아니라 *모델이 여전히 답할 수 있는가*를
|
|
25
|
+
# 물어야 하고, 그건 훅이 못 한다(격리 채점자 필요 — 미건축).
|
|
26
|
+
# `score` 는 그 사실을 인쇄하는 자리로만 남긴다. 아무 판정도 여기에 의존하지 않는다.
|
|
27
|
+
#
|
|
28
|
+
# 추가로, 설령 ⓑ 라도 grep 이 재는 것은 **축자 생존**이지 의미 보존이 아니다. 요약되어 살아남은
|
|
29
|
+
# 항목은 미생존으로 잡힌다 — **과보고 방향**이라 안전하지만(fail-open 아님), 그 숫자를
|
|
30
|
+
# "압축이 N 건을 잃었다"로 읽으면 그것이 바로 `[[feedback_metric_measures_presence_not_relation]]` 이다.
|
|
31
|
+
#
|
|
32
|
+
# ── 배선 (설계 정본에서 한 군데 정정됨) ──
|
|
33
|
+
# 설계는 *"불합격은 PostCompact 에서 원본 포인터로 재주입"* 이라 적었다. **불가능하다** —
|
|
34
|
+
# `PostCompact` 는 `additionalContext` 를 지원하지 않는다(공식 훅 문서, 2026-08-08 확인).
|
|
35
|
+
# 되주입은 `UserPromptSubmit`(지원함) 으로 넘긴다. 산문대로 짰으면 배선해놓고 안 도는 걸 몰랐다.
|
|
36
|
+
# PreCompact → seal
|
|
37
|
+
# UserPromptSubmit → digest (봉인분이 있으면 1회 주입, 없으면 무출력)
|
|
38
|
+
#
|
|
39
|
+
# ⚠️ 훅은 **항상 exit 0** 이다. 비영 종료는 stdout 을 통째로 폐기한다
|
|
40
|
+
# (`[[feedback_hook_nonzero_exit_is_silent]]`). 압축은 절대 차단하지 않는다 — 가역 표면이고,
|
|
41
|
+
# 과차단은 override 를 습관화시킨다.
|
|
42
|
+
#
|
|
43
|
+
# ── 사용 ──
|
|
44
|
+
# echo "$HOOK_JSON" | bash scripts/compaction_probe.sh seal
|
|
45
|
+
# echo "$HOOK_JSON" | bash scripts/compaction_probe.sh digest
|
|
46
|
+
# bash scripts/compaction_probe.sh seal --transcript <path> --dir <outdir> # 테스트용
|
|
47
|
+
# bash scripts/compaction_probe.sh --self-test
|
|
48
|
+
|
|
49
|
+
set -uo pipefail
|
|
50
|
+
|
|
51
|
+
REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
52
|
+
SEAL_DIR_DEFAULT="$REPO_ROOT/tracks/_meta/compaction"
|
|
53
|
+
|
|
54
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
# seal — 압축 전에 세션이 쥔 것을 디스크로
|
|
56
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
57
|
+
do_seal() {
|
|
58
|
+
local transcript="$1" outdir="$2" session="$3"
|
|
59
|
+
mkdir -p "$outdir" 2>/dev/null || { echo "seal: outdir 생성 실패: $outdir"; return 0; }
|
|
60
|
+
|
|
61
|
+
local stamp; stamp="$(date +%Y%m%d-%H%M%S)"
|
|
62
|
+
local out="$outdir/seal_${session}_${stamp}.md"
|
|
63
|
+
|
|
64
|
+
{
|
|
65
|
+
echo "# 압축 전 봉인 — session=${session} at ${stamp}"
|
|
66
|
+
echo
|
|
67
|
+
echo "> **포인터 원장이다. 내용 사본이 아니다.** 필요하면 아래 경로를 열어라."
|
|
68
|
+
echo "> 압축이 잃는 것은 컨텍스트가 아니라 포인터라는 진단에 대한 직접 대응."
|
|
69
|
+
echo
|
|
70
|
+
|
|
71
|
+
echo "## 운영자 발화 (이 세션)"
|
|
72
|
+
if [ -f "$transcript" ]; then
|
|
73
|
+
python3 - "$transcript" <<'PY' 2>/dev/null || echo "- (전사본 파싱 실패 — 원본: $transcript)"
|
|
74
|
+
import json,sys
|
|
75
|
+
n=0
|
|
76
|
+
skipped_tool=skipped_meta=skipped_other=0
|
|
77
|
+
for line in open(sys.argv[1], errors='replace'):
|
|
78
|
+
try: d=json.loads(line)
|
|
79
|
+
except Exception: continue
|
|
80
|
+
if d.get('type')!='user': continue
|
|
81
|
+
m=d.get('message') or {}
|
|
82
|
+
c=m.get('content')
|
|
83
|
+
# ⚠️ 초판은 "실발화=str · 툴결과=list" 로 갈랐다. **틀렸다** — 이미지/파일을 첨부한 실발화는
|
|
84
|
+
# list 다(high 리뷰 실측: 전사본 25개에서 그런 발화 18건이 구조적으로 안 보였다). 더 나쁜 건
|
|
85
|
+
# self-test 픽스처가 같은 가정을 인코딩해서 **초록이 그 결함을 보증**했다는 것이다.
|
|
86
|
+
# 이제 list 는 text 블록을 꺼내 쓰고, tool_result 만 제외한다.
|
|
87
|
+
if isinstance(c,str):
|
|
88
|
+
t=c
|
|
89
|
+
elif isinstance(c,list):
|
|
90
|
+
if any(isinstance(b,dict) and b.get('type')=='tool_result' for b in c):
|
|
91
|
+
skipped_tool+=1; continue
|
|
92
|
+
parts=[b.get('text','') for b in c if isinstance(b,dict) and b.get('type')=='text']
|
|
93
|
+
if not parts:
|
|
94
|
+
skipped_other+=1; continue # 이미지-only 등 — 셈에서 지우지 않고 센다
|
|
95
|
+
t=' '.join(parts)
|
|
96
|
+
else:
|
|
97
|
+
skipped_other+=1; continue
|
|
98
|
+
t=' '.join(t.split())
|
|
99
|
+
if not t: skipped_other+=1; continue
|
|
100
|
+
if t.startswith('<') or t.startswith('/'): # 슬래시 커맨드·메타 봉투 제외
|
|
101
|
+
skipped_meta+=1; continue
|
|
102
|
+
n+=1
|
|
103
|
+
print(f"{n}. {t[:200]}")
|
|
104
|
+
# **제외분을 반드시 인쇄한다.** 합계만 찍으면 그 원장이 완전한 것처럼 읽힌다 — `not found ≠ 0`.
|
|
105
|
+
print(f"\n합계: {n}건" if n else "- (발화 0건)")
|
|
106
|
+
print(f"제외: tool_result {skipped_tool} · 메타/커맨드 {skipped_meta} · 텍스트없음 {skipped_other}")
|
|
107
|
+
PY
|
|
108
|
+
else
|
|
109
|
+
echo "- 🟥 전사본 경로 없음: $transcript"
|
|
110
|
+
fi
|
|
111
|
+
echo
|
|
112
|
+
|
|
113
|
+
echo "## 이 세션이 건드린 파일"
|
|
114
|
+
if git -C "$REPO_ROOT" rev-parse --git-dir >/dev/null 2>&1; then
|
|
115
|
+
local changed; changed="$(git -C "$REPO_ROOT" status --porcelain 2>/dev/null | sed 's/^/ /')"
|
|
116
|
+
[ -n "$changed" ] && echo "$changed" || echo " (working tree clean)"
|
|
117
|
+
echo
|
|
118
|
+
echo " 브랜치: $(git -C "$REPO_ROOT" rev-parse --abbrev-ref HEAD 2>/dev/null)"
|
|
119
|
+
# upstream 이 없으면 `@{u}` 는 fatal 이고 2>/dev/null 이 그걸 삼켜 **0건**으로 렌더된다.
|
|
120
|
+
# `git switch -c` 후 첫 푸시 전 = 이 레포의 정상 경로다. 미상과 0 을 갈라야 한다.
|
|
121
|
+
if git -C "$REPO_ROOT" rev-parse --abbrev-ref '@{u}' >/dev/null 2>&1; then
|
|
122
|
+
echo " 미푸시: $(git -C "$REPO_ROOT" log --oneline '@{u}..HEAD' 2>/dev/null | wc -l | tr -d ' ')건"
|
|
123
|
+
else
|
|
124
|
+
echo " 미푸시: unknown (upstream 미설정 — 0건이 아니다)"
|
|
125
|
+
fi
|
|
126
|
+
else
|
|
127
|
+
echo " (git 레포 아님)"
|
|
128
|
+
fi
|
|
129
|
+
echo
|
|
130
|
+
|
|
131
|
+
echo "## 열어야 할 정본 (존재하는 것만)"
|
|
132
|
+
local today; today="$(date +%Y-%m-%d)"
|
|
133
|
+
local p
|
|
134
|
+
for p in "tracks/_meta/fh_completed_${today}.md" \
|
|
135
|
+
"tracks/_meta/reference_next_session_starter.md"; do
|
|
136
|
+
[ -f "$REPO_ROOT/$p" ] && echo " - $p"
|
|
137
|
+
done
|
|
138
|
+
echo
|
|
139
|
+
echo "---"
|
|
140
|
+
echo "payload: ${PAYLOAD_STATUS:-unknown} (parsed=훅 JSON · fallback-cwd=cwd로 자력 탐색 · unresolved=전사본 못 찾음)"
|
|
141
|
+
echo "생성: scripts/compaction_probe.sh seal"
|
|
142
|
+
} > "$out" 2>/dev/null
|
|
143
|
+
|
|
144
|
+
# 되주입 대기 표시 — digest 가 소비한다
|
|
145
|
+
# .pending 에 **세션 id 와 시각**을 같이 쓴다. 초판은 경로만 써서, 세션 A 가 봉인하고 그냥 나가면
|
|
146
|
+
# 며칠 뒤 세션 B 의 첫 프롬프트가 A 의 발화·브랜치·더티파일을 **"직전 압축"이라고 주장하며** 주입했다.
|
|
147
|
+
# **세션별 마커.** 하나를 공유하면 다음 프롬프트를 낸 아무 세션이나 그걸 소비하고, 정작 압축당한
|
|
148
|
+
# 세션은 0바이트를 받는다 — 재주입이 존재하는 유일한 대상이 못 받는 것이다(high 재리뷰 #4).
|
|
149
|
+
# 라벨링(#4 1차 수리)은 오배달을 *말해줬을 뿐* 라우팅하지 않았다.
|
|
150
|
+
printf '%s\t%s\t%s\n' "$out" "$session" "$(date +%s)" > "$outdir/.pending_${session}" 2>/dev/null
|
|
151
|
+
echo "sealed: $out"
|
|
152
|
+
return 0
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
156
|
+
# digest — UserPromptSubmit 에서 1회 되주입 (없으면 무출력)
|
|
157
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
158
|
+
do_digest() {
|
|
159
|
+
local outdir="$1" DIGEST_SESSION="${2:-unknown}"
|
|
160
|
+
# 내 세션 마커를 먼저 본다. 없으면 (세션 미상 등) 주인 없는 것만 관용으로 집는다 —
|
|
161
|
+
# 남의 세션 마커를 **소비하지 않는다**. 소비하면 그 세션이 자기 원장을 영영 못 받는다.
|
|
162
|
+
local pending="$outdir/.pending_${DIGEST_SESSION}"
|
|
163
|
+
if [ ! -f "$pending" ]; then
|
|
164
|
+
if [ "$DIGEST_SESSION" = "unknown" ]; then
|
|
165
|
+
pending="$(ls -t "$outdir"/.pending_* 2>/dev/null | head -1)"
|
|
166
|
+
else
|
|
167
|
+
pending="$outdir/.pending" # 구형식(단일 마커) 관용
|
|
168
|
+
fi
|
|
169
|
+
fi
|
|
170
|
+
[ -n "${pending:-}" ] && [ -f "$pending" ] || return 0
|
|
171
|
+
local sealfile sealsess sealts
|
|
172
|
+
IFS=$'\t' read -r sealfile sealsess sealts < "$pending" 2>/dev/null
|
|
173
|
+
[ -z "${sealfile:-}" ] && sealfile="$(head -1 "$pending" 2>/dev/null)" # 구형식 관용
|
|
174
|
+
# ⚠️ 인쇄 **전에** 소비한다. 소비를 뒤에 두면, 소비자가 파이프를 먼저 닫는 순간(SIGPIPE)
|
|
175
|
+
# 마커가 안 지워지고 **매 프롬프트마다 무한 재주입**된다 — 실측 2026-08-08, `| head -12` 로 재현.
|
|
176
|
+
# 트레이드오프는 의도적이다: 최악이 "한 번 못 보여줌"(복구 가능, 파일은 디스크에 남아 있다) 대
|
|
177
|
+
# "영원히 소음"(사용자가 훅을 꺼버린다). 과차단이 override 를 습관화시키는 것과 같은 방향 판단.
|
|
178
|
+
rm -f "$pending" 2>/dev/null
|
|
179
|
+
if [ ! -f "$sealfile" ]; then return 0; fi
|
|
180
|
+
|
|
181
|
+
# 신선도·세션 정합을 **주장에 반영**한다. 안 맞으면 주입은 하되 "직전 압축"이라고 말하지 않는다.
|
|
182
|
+
local _now _age _stale="" _xsess=""
|
|
183
|
+
_now=$(date +%s); _age=$(( _now - ${sealts:-$_now} ))
|
|
184
|
+
[ "$_age" -gt 43200 ] && _stale=" ⚠️ ${_age}초 전 봉인 — 이 세션의 직전 압축이 아닐 수 있다"
|
|
185
|
+
if [ -n "${sealsess:-}" ] && [ "${sealsess}" != "unknown" ] && [ -n "$DIGEST_SESSION" ] \
|
|
186
|
+
&& [ "$DIGEST_SESSION" != "unknown" ] && [ "${sealsess}" != "$DIGEST_SESSION" ]; then
|
|
187
|
+
_xsess=" ⚠️ 다른 세션(${sealsess})의 봉인이다"
|
|
188
|
+
fi
|
|
189
|
+
if [ -n "$_stale$_xsess" ]; then
|
|
190
|
+
echo "🧭 [FH 압축 복구] 봉인된 포인터 원장이 있다 — 내용이 아니라 경로다.$_stale$_xsess"
|
|
191
|
+
else
|
|
192
|
+
echo "🧭 [FH 압축 복구] 직전 압축 전에 봉인된 포인터 원장이 있다 — 내용이 아니라 경로다."
|
|
193
|
+
fi
|
|
194
|
+
echo " 정본: $sealfile"
|
|
195
|
+
echo
|
|
196
|
+
# ⚠️ 초판은 `sed -n '1,80p'` 였다. 봉인 앞부분은 **발화 덤프**라, 긴 세션에서는 80줄이 발화
|
|
197
|
+
# 목록 중간에서 끊기고 **정작 포인터(git 상태·정본 경로·payload 상태)는 한 줄도 안 들어갔다** —
|
|
198
|
+
# 계약("포인터 원장이다")의 정반대. 이제 포인터 절을 먼저 주입하고 발화는 뒤에서 잘라 붙인다.
|
|
199
|
+
# ⚠️ **절마다 개별 상한**을 준다. 범위 하나에 head 를 걸면 앞 절(더티파일 목록)이 길 때 뒤 절
|
|
200
|
+
# (정본 포인터·payload 상태)이 통째로 잘린다 — 1차 수리는 자르는 위치만 옮겼지 포인터가 그 안에
|
|
201
|
+
# 든다는 보장을 안 만들었다(high 재리뷰 #8, 더티파일 35개로 재현).
|
|
202
|
+
# 포인터가 이 원장의 존재 이유이므로 **포인터 절을 먼저, 그리고 절대 안 자른다.**
|
|
203
|
+
echo "── 열어야 할 정본 ──"
|
|
204
|
+
awk '/^## 열어야 할 정본/{f=1;next} /^## /{f=0} f' "$sealfile" 2>/dev/null | grep -v '^$'
|
|
205
|
+
grep -E '^payload: ' "$sealfile" 2>/dev/null
|
|
206
|
+
echo
|
|
207
|
+
echo "── 이 세션이 건드린 파일 (앞 20줄) ──"
|
|
208
|
+
awk '/^## 이 세션이 건드린 파일/{f=1;next} /^## /{f=0} f' "$sealfile" 2>/dev/null | grep -v '^$' | head -20
|
|
209
|
+
echo
|
|
210
|
+
echo "── 운영자 발화 (앞 25건) ──"
|
|
211
|
+
awk '/^## 운영자 발화/{f=1;next} /^## /{f=0} f' "$sealfile" 2>/dev/null | grep -E '^[0-9]+\.|^제외:|^합계:' | head -25
|
|
212
|
+
echo
|
|
213
|
+
echo " ⚠️ 이 원장은 **축자 기록**이다. 여기 있는 발화가 기록에 착지했는지는 별도 검증이다"
|
|
214
|
+
echo " (scripts/utterance_landing_check.sh)."
|
|
215
|
+
|
|
216
|
+
rm -f "$pending" 2>/dev/null # 1회성 — 매 프롬프트 재주입은 소음이다
|
|
217
|
+
return 0
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
221
|
+
# score — 🟥 UNCALIBRATED advisory (§계기 타당성 참조)
|
|
222
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
223
|
+
do_score() {
|
|
224
|
+
local transcript="$1" outdir="$2"
|
|
225
|
+
local latest; latest="$(ls -t "$outdir"/seal_*.md 2>/dev/null | head -1)"
|
|
226
|
+
echo "🟥 REFUTED — 전사본 grep 채점은 성립하지 않는다. 아무 판정도 여기에 의존하지 않는다."
|
|
227
|
+
echo " 실측(2026-08-08 첫 실압축): 압축 후에도 전사본이 전 레코드를 보존한다(ⓐ)."
|
|
228
|
+
echo " → grep 은 손실 0 을 영원히 보고한다 = fail-open 계기. 캘리브레이션 부족이 아니라 가정 오류."
|
|
229
|
+
echo " 채점하려면 '모델이 여전히 답하는가'를 물어야 하고 훅은 못 한다 — 격리 채점자 필요(미건축)."
|
|
230
|
+
[ -n "$latest" ] && echo " 최근 봉인: $latest" || echo " 봉인 없음."
|
|
231
|
+
[ -f "$transcript" ] && echo " 대상 전사본: $transcript ($(wc -c < "$transcript" | tr -d ' ') bytes)"
|
|
232
|
+
return 0
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
236
|
+
self_test() {
|
|
237
|
+
local T f=0 n=0 rc
|
|
238
|
+
T=$(mktemp -d); trap 'rm -rf "$T"' RETURN
|
|
239
|
+
t() { n=$((n+1)); if [ "$2" = "$3" ]; then echo "✅ $1 → $3"; else echo "❌ $1 → $3 (기대 $2)"; f=1; fi; }
|
|
240
|
+
|
|
241
|
+
# 합성 전사본: 실발화 2건(str) + 툴결과 1건(list) + 슬래시커맨드 1건
|
|
242
|
+
{
|
|
243
|
+
printf '%s\n' '{"type":"user","message":{"content":"엔진 넷을 RC 까지 올린다"}}'
|
|
244
|
+
printf '%s\n' '{"type":"user","message":{"content":[{"type":"tool_result","content":"툴 출력 본문"}]}}'
|
|
245
|
+
printf '%s\n' '{"type":"user","message":{"content":"/clear"}}'
|
|
246
|
+
printf '%s\n' '{"type":"assistant","message":{"content":"응답"}}'
|
|
247
|
+
printf '%s\n' '{"type":"user","message":{"content":"곁가지는 병렬 세션에서"}}'
|
|
248
|
+
# ★ 이미지 첨부 실발화 — **list 인데 진짜 발화다.** 초판 픽스처엔 이 모양이 없었고,
|
|
249
|
+
# 그래서 "list=툴결과" 가정이 self-test 를 통과했다. 픽스처가 결함을 보증한 자리.
|
|
250
|
+
printf '%s\n' '{"type":"user","message":{"content":[{"type":"text","text":"일정이 조정됐어 리더리뷰는 다음주"},{"type":"image","source":{"type":"base64"}}]}}'
|
|
251
|
+
# 이미지만 있는 발화 — 텍스트가 없으니 못 싣지만 **세어야** 한다
|
|
252
|
+
printf '%s\n' '{"type":"user","message":{"content":[{"type":"image","source":{"type":"base64"}}]}}'
|
|
253
|
+
} > "$T/tr.jsonl"
|
|
254
|
+
|
|
255
|
+
do_seal "$T/tr.jsonl" "$T/out" "sess1" >/dev/null 2>&1; rc=$?
|
|
256
|
+
t "seal 정상 종료" 0 "$rc"
|
|
257
|
+
|
|
258
|
+
local sf; sf="$(ls "$T/out"/seal_*.md 2>/dev/null | head -1)"
|
|
259
|
+
[ -n "$sf" ] && r=YES || r=NO
|
|
260
|
+
t "봉인 파일 생성" YES "$r"
|
|
261
|
+
|
|
262
|
+
# known-positive: 실발화 2건이 원장에 들어간다
|
|
263
|
+
grep -q "엔진 넷을 RC 까지 올린다" "$sf" 2>/dev/null && r=YES || r=NO
|
|
264
|
+
t "실발화 착지 (known-positive)" YES "$r"
|
|
265
|
+
grep -q "곁가지는 병렬 세션에서" "$sf" 2>/dev/null && r=YES || r=NO
|
|
266
|
+
t "실발화 2건째 착지 (known-positive)" YES "$r"
|
|
267
|
+
|
|
268
|
+
# known-negative: 툴 결과와 슬래시 커맨드는 발화가 아니다 — 들어가면 안 된다
|
|
269
|
+
grep -q "툴 출력 본문" "$sf" 2>/dev/null && r=YES || r=NO
|
|
270
|
+
t "툴 결과 제외 (known-negative)" NO "$r"
|
|
271
|
+
grep -q '^3\. /clear' "$sf" 2>/dev/null && r=YES || r=NO
|
|
272
|
+
t "슬래시 커맨드 제외 (known-negative)" NO "$r"
|
|
273
|
+
|
|
274
|
+
grep -q "합계: 3건" "$sf" 2>/dev/null && r=YES || r=NO
|
|
275
|
+
t "발화 카운트 정확 (3건 — 이미지 첨부 발화 포함)" YES "$r"
|
|
276
|
+
|
|
277
|
+
# digest: 1회만 나오고 두 번째는 무출력 — 매 프롬프트 재주입은 소음이다
|
|
278
|
+
local d1 d2
|
|
279
|
+
d1="$(do_digest "$T/out" 2>/dev/null | wc -c | tr -d ' ')"
|
|
280
|
+
d2="$(do_digest "$T/out" 2>/dev/null | wc -c | tr -d ' ')"
|
|
281
|
+
[ "$d1" -gt 100 ] && r=YES || r=NO
|
|
282
|
+
t "digest 1회차 주입됨" YES "$r"
|
|
283
|
+
t "digest 2회차 무출력 (소비됨)" 0 "$d2"
|
|
284
|
+
|
|
285
|
+
# 봉인 없는 상태에서 digest 는 조용해야 한다 (신규 세션 오염 금지)
|
|
286
|
+
d2="$(do_digest "$T/empty" 2>/dev/null | wc -c | tr -d ' ')"
|
|
287
|
+
t "봉인 없으면 무출력" 0 "$d2"
|
|
288
|
+
|
|
289
|
+
# ── 회귀 레인: SIGPIPE 소비 누락 (2026-08-08 실측 재현) ──
|
|
290
|
+
# 소비자가 파이프를 먼저 닫아도 마커는 소비돼야 한다. 인쇄 뒤에 rm 을 두면 여기서 되돌아온다.
|
|
291
|
+
do_seal "$T/tr.jsonl" "$T/out3" "sess3" >/dev/null 2>&1
|
|
292
|
+
do_digest "$T/out3" 2>/dev/null | head -1 >/dev/null 2>&1
|
|
293
|
+
d2="$(do_digest "$T/out3" 2>/dev/null | wc -c | tr -d ' ')"
|
|
294
|
+
t "파이프 조기 종료 후에도 소비됨 (무한 재주입 방지)" 0 "$d2"
|
|
295
|
+
|
|
296
|
+
# 전사본이 없어도 훅은 절대 죽지 않는다
|
|
297
|
+
do_seal "$T/NOPE.jsonl" "$T/out2" "sess2" >/dev/null 2>&1; rc=$?
|
|
298
|
+
t "전사본 부재에도 exit 0 (훅 안전)" 0 "$rc"
|
|
299
|
+
|
|
300
|
+
# ── 회귀 레인: 빈/불량 페이로드 (2026-08-08 첫 실발화 실측) ──
|
|
301
|
+
# 실발화가 session=unknown · transcript 빈 값으로 돌아 **빈 봉인**을 남겼다.
|
|
302
|
+
# 훅 페이로드 모양은 런타임 버전에 딸린 외부 의존이라, 거기에 기능 전체를 걸면 안 된다.
|
|
303
|
+
local out_empty
|
|
304
|
+
out_empty="$(printf '%s' '{}' | bash "$0" seal --dir "$T/pl" 2>&1)"
|
|
305
|
+
case "$out_empty" in *sealed:*) rc=YES ;; *) rc=NO ;; esac
|
|
306
|
+
t "빈 페이로드에도 봉인은 생성된다" YES "$rc"
|
|
307
|
+
[ -f "$T/pl/.last_payload" ] && rc=YES || rc=NO
|
|
308
|
+
t "원본 페이로드가 기록된다 (원인 추적 가능)" YES "$rc"
|
|
309
|
+
local sf2; sf2="$(ls -t "$T/pl"/seal_*.md 2>/dev/null | head -1)"
|
|
310
|
+
grep -qE 'payload: (fallback-session|fallback-mtime-UNVERIFIED|unresolved)' "$sf2" 2>/dev/null && rc=YES || rc=NO
|
|
311
|
+
t "payload 상태가 typed 로 남는다 (무음 아님)" YES "$rc"
|
|
312
|
+
|
|
313
|
+
# ── 회귀 레인: high 리뷰 CONFIRMED (2026-08-08) ──
|
|
314
|
+
# #2 list-shaped 실발화 — 픽스처에 이 모양이 없어서 초록이 결함을 보증했다
|
|
315
|
+
grep -q "일정이 조정됐어" "$sf" 2>/dev/null && r=YES || r=NO
|
|
316
|
+
t "#2 이미지 첨부 실발화(list)가 원장에 실린다" YES "$r"
|
|
317
|
+
grep -q "툴 출력 본문" "$sf" 2>/dev/null && r=YES || r=NO
|
|
318
|
+
t "#2 tool_result 는 여전히 제외" NO "$r"
|
|
319
|
+
grep -qE "^제외: tool_result [0-9]+ · 메타/커맨드 [0-9]+ · 텍스트없음 [0-9]+" "$sf" 2>/dev/null && r=YES || r=NO
|
|
320
|
+
t "#2 제외분이 명시 카운트된다 (합계만 찍지 않는다)" YES "$r"
|
|
321
|
+
grep -q "텍스트없음 1" "$sf" 2>/dev/null && r=YES || r=NO
|
|
322
|
+
t "#2 이미지-only 발화가 침묵 드롭되지 않고 계수된다" YES "$r"
|
|
323
|
+
|
|
324
|
+
# #3 digest 는 발화 덤프가 아니라 **포인터**를 주입해야 한다
|
|
325
|
+
# (앞 레인들이 .pending 을 소비했으므로 새로 봉인하고 잰다)
|
|
326
|
+
do_seal "$T/tr.jsonl" "$T/out3b" "sess3b" >/dev/null 2>&1
|
|
327
|
+
local dg; dg="$(do_digest "$T/out3b" 2>/dev/null)"
|
|
328
|
+
case "$dg" in *"열어야 할 정본"*) r=YES ;; *) r=NO ;; esac
|
|
329
|
+
t "#3 digest 가 정본 포인터를 주입한다" YES "$r"
|
|
330
|
+
case "$dg" in *"브랜치:"*) r=YES ;; *) r=NO ;; esac
|
|
331
|
+
t "#3 digest 가 git 상태를 주입한다" YES "$r"
|
|
332
|
+
|
|
333
|
+
# #4 세션 교차 주입 — 다른 세션의 봉인을 "직전 압축"이라고 주장하면 안 된다
|
|
334
|
+
# ⚠️ 이 레인은 1차 수리 때 "라벨하면 된다"는 전제로 썼다가 **갱신됐다**. 라벨은 오배달을
|
|
335
|
+
# 말해줄 뿐 라우팅하지 않았고, 그 사이 압축당한 세션이 0바이트를 받았다(high 재리뷰 #4).
|
|
336
|
+
# 지금의 정답은 **다른 세션은 아무것도 안 받고, 주인 마커는 남아 있는 것**이다.
|
|
337
|
+
do_seal "$T/tr.jsonl" "$T/out4" "SESSA" >/dev/null 2>&1
|
|
338
|
+
local d4; d4="$(do_digest "$T/out4" "SESSB" 2>/dev/null | wc -c | tr -d ' ')"
|
|
339
|
+
t "#4 다른 세션은 남의 봉인을 안 받는다" 0 "$d4"
|
|
340
|
+
local d4o; d4o="$(do_digest "$T/out4" "SESSA" 2>/dev/null | wc -c | tr -d ' ')"
|
|
341
|
+
[ "$d4o" -gt 100 ] && rc=YES || rc=NO
|
|
342
|
+
t "#4 ★ 주인 세션은 자기 원장을 받는다 (소비당하지 않았다)" YES "$rc"
|
|
343
|
+
do_seal "$T/tr.jsonl" "$T/out4b" "SESSA" >/dev/null 2>&1
|
|
344
|
+
local d4b; d4b="$(do_digest "$T/out4b" "SESSA" 2>/dev/null)"
|
|
345
|
+
case "$d4b" in *"직전 압축 전에"*) rc=YES ;; *) rc=NO ;; esac
|
|
346
|
+
t "#4 같은 세션이면 직전-압축 주장 유지 (과경고 아님)" YES "$rc"
|
|
347
|
+
case "$d4b" in *"다른 세션"*) rc=YES ;; *) rc=NO ;; esac
|
|
348
|
+
t "#4 같은 세션에 교차 경고 안 뜬다" NO "$rc"
|
|
349
|
+
|
|
350
|
+
# #8 포인터 절은 **절대 안 잘린다** — 더티파일 목록이 길어도
|
|
351
|
+
do_seal "$T/tr.jsonl" "$T/out8" "SESS8" >/dev/null 2>&1
|
|
352
|
+
local d8; d8="$(do_digest "$T/out8" "SESS8" 2>/dev/null)"
|
|
353
|
+
case "$d8" in *"열어야 할 정본"*) rc=YES ;; *) rc=NO ;; esac
|
|
354
|
+
t "#8 정본 포인터 절이 항상 들어간다" YES "$rc"
|
|
355
|
+
case "$d8" in *"payload:"*) rc=YES ;; *) rc=NO ;; esac
|
|
356
|
+
t "#8 payload typed 상태가 항상 들어간다" YES "$rc"
|
|
357
|
+
|
|
358
|
+
# #5 세션 미상 폴백은 **미검증으로 라벨**돼야 한다 (침묵 추정 금지)
|
|
359
|
+
local sf5
|
|
360
|
+
printf '%s' '{}' | bash "$0" seal --dir "$T/out5" >/dev/null 2>&1
|
|
361
|
+
sf5="$(ls -t "$T/out5"/seal_*.md 2>/dev/null | head -1)"
|
|
362
|
+
# ⚠️ 성질은 "세션 미상 폴백이 **타입으로 남는다**" 이지 특정 값 하나가 아니다. 특정 값으로
|
|
363
|
+
# 과대명세했더니 **저자 머신의 전사본이 있어야만 통과**하는 레인이 됐고, selfcheck 에 배선하자
|
|
364
|
+
# CI 가 82db426 부터 빨개졌다(로컬 초록 ≠ CI 초록, 실측). 전사본이 없는 러너에서는 `unresolved`
|
|
365
|
+
# 가 정답이고 그것도 **무음이 아니다** — 둘 다 통과여야 한다.
|
|
366
|
+
grep -qE "payload: (fallback-mtime-UNVERIFIED|unresolved)" "$sf5" 2>/dev/null && rc=YES || rc=NO
|
|
367
|
+
t "#5 세션 미상 폴백이 타입으로 남는다 (환경 무관)" YES "$rc"
|
|
368
|
+
# known-negative: 무음(빈 값)이면 실패해야 한다 — 레인이 공허하지 않다는 증명
|
|
369
|
+
grep -qE "payload: *$" "$sf5" 2>/dev/null && rc=YES || rc=NO
|
|
370
|
+
t "#5 payload 가 빈 값이면 통과 아님" NO "$rc"
|
|
371
|
+
|
|
372
|
+
echo
|
|
373
|
+
[ "$f" -eq 0 ] && echo "✅ 캘리브레이션 통과 ($n 쌍) — seal/digest 레그 한정. score 는 실측으로 **반증**됐다(§계기 타당성)." \
|
|
374
|
+
|| echo "❌ 캘리브레이션 실패 ($n 쌍)"
|
|
375
|
+
return "$f"
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
379
|
+
[ "${1:-}" = "--self-test" ] && { self_test; exit $?; }
|
|
380
|
+
|
|
381
|
+
MODE="${1:-}"; shift || true
|
|
382
|
+
TRANSCRIPT=""; OUTDIR="$SEAL_DIR_DEFAULT"; SESSION="unknown"
|
|
383
|
+
|
|
384
|
+
while [ "$#" -gt 0 ]; do
|
|
385
|
+
case "$1" in
|
|
386
|
+
# 값 없는 마지막 옵션에서 `shift 2` 는 실패하고, -e 가 없어 루프가 안 돌아 **무한루프**가 된다
|
|
387
|
+
# — cross-family 지적 (c-2), 2026-08-08. 인자 수를 세고 shift 한다.
|
|
388
|
+
--transcript) TRANSCRIPT="${2:-}"; [ "$#" -ge 2 ] && shift 2 || shift ;;
|
|
389
|
+
--dir) OUTDIR="${2:-$OUTDIR}"; [ "$#" -ge 2 ] && shift 2 || shift ;;
|
|
390
|
+
--session) SESSION="${2:-$SESSION}"; [ "$#" -ge 2 ] && shift 2 || shift ;;
|
|
391
|
+
*) shift ;;
|
|
392
|
+
esac
|
|
393
|
+
done
|
|
394
|
+
|
|
395
|
+
# 훅 경로: stdin 의 JSON 에서 읽는다. 인자로 준 값이 있으면 그쪽이 이긴다(테스트용).
|
|
396
|
+
PAYLOAD_STATUS="args"
|
|
397
|
+
# ⚠️ 캡처는 **seal 에서만**. 전 모드에서 돌리면 매 `UserPromptSubmit`(digest)가 PreCompact 페이로드를
|
|
398
|
+
# 덮어써서, 빈 봉인을 진단하라고 만든 증거를 **다음 프롬프트가 파괴**한다. 게다가 사용자 프롬프트
|
|
399
|
+
# 원문이 매 턴 디스크에 남는다. (high 리뷰 실측 재현: seal 직후엔 PreCompact 페이로드, digest 한 번에 교체.)
|
|
400
|
+
# 훅 모드에서만 stdin 을 읽는다. 모드 화이트리스트가 없으면, 오타나 디스패처 소실 시
|
|
401
|
+
# 스크립트가 **stdin 을 기다리며 멈춘다** — 훅에선 페이로드가 오니 안 보이지만 CI 에선 정지다(실측).
|
|
402
|
+
case "$MODE" in seal|digest|score) _READS_STDIN=1 ;; *) _READS_STDIN=0 ;; esac
|
|
403
|
+
if [ "$_READS_STDIN" = "1" ] && [ -z "$TRANSCRIPT" ] && [ ! -t 0 ]; then
|
|
404
|
+
HOOK_JSON="$(cat 2>/dev/null)"
|
|
405
|
+
# 원본 페이로드를 항상 남긴다. 2026-08-08 첫 실발화가 session=unknown · transcript 빈 값으로
|
|
406
|
+
# 돌았는데, 원본을 안 남겨서 **왜 그런지 알 방법이 없었다.** 미측정을 빈 값으로 렌더하지 않는다.
|
|
407
|
+
# 쓰기는 seal 에서만 — digest 가 쓰면 진단 증거를 다음 프롬프트가 파괴한다(#10). 파싱은 전 모드.
|
|
408
|
+
[ "$MODE" = "seal" ] && mkdir -p "$OUTDIR" 2>/dev/null && printf '%s' "$HOOK_JSON" > "$OUTDIR/.last_payload" 2>/dev/null
|
|
409
|
+
if [ -n "$HOOK_JSON" ]; then
|
|
410
|
+
eval "$(printf '%s' "$HOOK_JSON" | python3 -c '
|
|
411
|
+
import json,sys,shlex
|
|
412
|
+
try: d=json.load(sys.stdin)
|
|
413
|
+
except Exception: d={}
|
|
414
|
+
tp=d.get("transcript_path") or ""
|
|
415
|
+
sid=(d.get("session_id") or "unknown")[:12]
|
|
416
|
+
print(f"TRANSCRIPT={shlex.quote(tp)}; SESSION={shlex.quote(sid)}")
|
|
417
|
+
' 2>/dev/null)"
|
|
418
|
+
fi
|
|
419
|
+
fi
|
|
420
|
+
|
|
421
|
+
# 페이로드에서 전사본을 못 얻었으면 **cwd 로 스스로 찾는다.** 빈 봉인은 봉인이 아니다 —
|
|
422
|
+
# 훅 페이로드 모양은 런타임 버전에 딸린 외부 의존이고, 거기에 기능 전체를 걸면 안 된다.
|
|
423
|
+
if [ -z "$TRANSCRIPT" ] || [ ! -f "$TRANSCRIPT" ]; then
|
|
424
|
+
_slug="$(printf '%s' "$REPO_ROOT" | sed 's|/|-|g')"
|
|
425
|
+
_dir="$HOME/.claude/projects/$_slug"
|
|
426
|
+
_cand=""
|
|
427
|
+
# 세션 id 를 알면 **그 세션의 전사본**을 고른다. mtime 최신을 고르면 같은 레포에 세션이 둘 열려
|
|
428
|
+
# 있을 때 **남의 발화를 이 세션 것으로 봉인**한다(high 리뷰 #5, 이 디렉토리에 전사본 125개).
|
|
429
|
+
if [ "$SESSION" != "unknown" ] && [ -n "$SESSION" ]; then
|
|
430
|
+
_cand="$(ls "$_dir"/"$SESSION"*.jsonl 2>/dev/null | head -1)"
|
|
431
|
+
[ -n "$_cand" ] && PAYLOAD_STATUS="fallback-session"
|
|
432
|
+
fi
|
|
433
|
+
if [ -z "$_cand" ]; then
|
|
434
|
+
_cand="$(ls -t "$_dir"/*.jsonl 2>/dev/null | head -1)"
|
|
435
|
+
# 세션 미상 → 최신을 쓰되 **검증 불가임을 타입으로 남긴다.** 침묵 추정 금지.
|
|
436
|
+
[ -n "$_cand" ] && PAYLOAD_STATUS="fallback-mtime-UNVERIFIED"
|
|
437
|
+
fi
|
|
438
|
+
if [ -n "$_cand" ] && [ -f "$_cand" ]; then
|
|
439
|
+
TRANSCRIPT="$_cand"
|
|
440
|
+
[ "$SESSION" = "unknown" ] && SESSION="$(basename "$_cand" .jsonl | cut -c1-12)"
|
|
441
|
+
else
|
|
442
|
+
PAYLOAD_STATUS="unresolved"
|
|
443
|
+
fi
|
|
444
|
+
elif [ "$PAYLOAD_STATUS" = "args" ]; then
|
|
445
|
+
PAYLOAD_STATUS="parsed"
|
|
446
|
+
fi
|
|
447
|
+
|
|
448
|
+
case "$MODE" in
|
|
449
|
+
seal) do_seal "$TRANSCRIPT" "$OUTDIR" "$SESSION" ;;
|
|
450
|
+
digest) do_digest "$OUTDIR" "$SESSION" ;;
|
|
451
|
+
score) do_score "$TRANSCRIPT" "$OUTDIR" ;;
|
|
452
|
+
*) echo "usage: $0 {seal|digest|score} [--transcript P] [--dir D] [--session S]"
|
|
453
|
+
echo " $0 --self-test" ;;
|
|
454
|
+
esac
|
|
455
|
+
|
|
456
|
+
exit 0 # 훅은 절대 비영 종료하지 않는다 — stdout 이 통째로 폐기된다
|