@chrono-meta/fh-gate 2.15.0 → 3.0.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.
Files changed (48) hide show
  1. package/.claude/rules/fh_4axis_gate.md +38 -0
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/CLAUDE.md +1 -1
  4. package/README.md +6 -1
  5. package/knowledge/shared/harness-core/fh_three_layer_canon.md +47 -0
  6. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +10 -0
  7. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +15 -1
  8. package/knowledge/shared/harness-core/ship_readiness_gate.md +20 -3
  9. package/knowledge/shared/learnings/subagent_invocations_log.yaml +111 -0
  10. package/package.json +9 -1
  11. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  12. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  13. package/plugins/fh-meta/CHANGELOG.md +49 -0
  14. package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
  15. package/scripts/backtick_guard.sh +194 -0
  16. package/scripts/capability_effect_probe.sh +14 -1
  17. package/scripts/context_continuity_score.sh +106 -12
  18. package/scripts/fh-gate.sh +3 -3
  19. package/scripts/files_manifest_shipping_check.sh +19 -0
  20. package/scripts/gate_pathspec_check.sh +1 -1
  21. package/scripts/package_coverage_check.sh +25 -2
  22. package/scripts/proposal_hook.sh +89 -0
  23. package/scripts/public_surface_scan_files.sh +11 -2
  24. package/scripts/revert_probe.sh +250 -0
  25. package/scripts/selfcheck.sh +67 -3
  26. package/scripts/sim_isolated_run.sh +97 -7
  27. package/scripts/test_backtick_guard_lanes.sh +115 -0
  28. package/scripts/test_degrade_scan_shell_probes.sh +7 -7
  29. package/scripts/test_files_manifest_shipping_lanes.sh +5 -5
  30. package/scripts/test_heavy_classifier_lanes.sh +1 -1
  31. package/scripts/test_lane_runner_lanes.sh +59 -33
  32. package/scripts/test_mapped_tracks_lanes.sh +1 -1
  33. package/scripts/test_marker_soul_check_lanes.sh +24 -0
  34. package/scripts/test_node_check_lanes.sh +34 -34
  35. package/scripts/test_package_coverage_lanes.sh +53 -27
  36. package/scripts/test_pipe_verdict_guard_lanes.sh +5 -5
  37. package/scripts/test_precommit_pointer_index_lanes.sh +33 -0
  38. package/scripts/test_preprep_drift_anchor.sh +13 -4
  39. package/scripts/test_preprep_drift_anchor_lanes.sh +23 -0
  40. package/scripts/test_proposal_hook_lanes.sh +36 -0
  41. package/scripts/test_revert_probe_lanes.sh +146 -0
  42. package/scripts/test_session_close_lanes.sh +3 -5
  43. package/scripts/test_sim_isolated_run_lanes.sh +17 -0
  44. package/scripts/test_verdict_watermark_lanes.sh +27 -1
  45. package/scripts/utterance_landing_check.sh +2 -2
  46. package/templates/.git-hooks/pre-commit +27 -4
  47. package/templates/settings.PreToolUse.snippet.json +37 -1
  48. package/plugins/fh-commons/README.md +0 -38
@@ -1,5 +1,54 @@
1
1
  # forge-harness (fh-meta) Changelog
2
2
 
3
+ ### [3.0.0] — 2026-09-04 — 정체성 다섯 전부 🟢(identity 1.0 을 이 번호가 나른다) · 릴리스 트랙 통일 · 훅 사실 줄 · 게이트 무음 접힘 수리 · 6축 단련
4
+
5
+ ### 왜 major 인가
6
+ CLAUDE.md §④-b major ⓑ — **정체성 다섯이 «전부» 🟢**(2026-09-04 ⑤ 증폭자 승격 #604, 여섯 행 Ⓑ①②③④⑤ 전부 초록, 4대 엔진도 전부 🟢).
7
+ 운영자 결정(2026-09-04): `identity-v1.0.0` 은 정체성 트랙의 마지막 태그, **이후 한 번호** — 이 3.0.0 이 identity 1.0 을 같이 나른다. 🧭 접두 태그는 더 만들지 않는다. 등급의 정본은 여전히 `ship_readiness_gate.md` 의 셀이고 번호는 나르기만 한다.
8
+
9
+ ### 🟥 BREAKING (gate): pre-commit [Pointers] 게이트가 워킹트리가 아니라 **인덱스**를 읽는다 (#602)
10
+ **무엇이 이제 막히나**: `.md` 를 스테이징한 뒤 디스크에서 지우면(또는 더 고치면) 종전엔 `[ -f ] || continue` 로 Detail-pointer 검사가 통째로 빠졌다 — 깨진 `**Detail**: See §X` 포인터가 조용히 커밋됐다. 이제 `git show ":$f"` 로 스테이징된 바이트를 검사하고, 인덱스에서 못 읽으면 ❌ 로 막는다. **처방**: 스테이징한 것이 곧 커밋되는 것이다 — 디스크 파일을 믿지 마라. 레인 `scripts/test_precommit_pointer_index_lanes.sh`(일회용 클론) 가 옛 훅에선 정확히 P1 만 실패함을 보인다.
11
+
12
+ ### 🟥 BREAKING (gate): selfcheck 가 명시경로 게이트 인프라(bin/fh-gate·fh-run·fh-goal 등) 부재를 FAIL 로 센다 (#602)
13
+ 종전엔 glob 미매칭과 같은 `continue` 로 무음. glob 미매칭 스킵은 그대로. **처방**: 그 파일들이 없어졌으면 files[] 와 selfcheck 목록을 같이 고쳐라.
14
+
15
+ ### 같이 들어온 것 (행동 변경, advisory)
16
+ - **proposal_hook**(#598 → #600 → #601 → #602): 판정·가드 줄 편집 시 «known-pair + degrade 방향» 제안을 PreToolUse 컨텍스트로. #601 부터 훅이 결정적 전제(레인 존재·레인 파일 자신·오늘 스캔 덮음)를 확인해 **«사실:» 줄**로 싣고 없는 항목만 제안 — r8 실측 근거가 옆 파일인 자극에서 복창 1/5 → 판단 4/5. Bash 팔은 raw 명령으로 토큰을 본다(sed -i 따옴표 안, 에어 노드 적발). `.git-hooks/*` 도 사정거리.
17
+ - **backtick_guard**(#592): 셸 이중인용 문맥의 백틱을 쓰기 전에 잡는 advisory 훅.
18
+ - **6축·3단 공정 단련**(#606, 운영자 승인): `scripts/revert_probe.sh`(ⓕ 범용 되돌림 프로브, 종료 0/1/2/10) · sim 러너 날짜 오염 필드 3(기록만) · 영혼 «성공 정의/절대 안 함» 2칸 advisory · ⓔ `shadow(N=,F=)` 사다리 · 사전등록 «몇 번째 프레이밍» 필드 · tier3 UNREACHED · ⓓ=선행자산 · 기호 전용 규칙. 새 차단 없음.
19
+ - **레인 이중 실행 단일화**(#603): test_lane_runner·caller_ratchet·package_coverage 가 out 과 rc 를 다른 실행에서 뽑던 구조 → 단일 실행(CI 비결정 L4 의 통로) · 실패 분기 출력 덤프 · no() 미정의 7곳 · prepublish_scope_note self-test 배선 · 이식성 11파일.
20
+ - **채점기**(#599): 인용 구간 제외 + 명사형 «없음» 결박 — 회차5 P07 0/5→5/5.
21
+ - **회차 러너**(#596·#597·#594): `--arms CTRL` 사전 디스패치 · 봉인 전 실행 · 헤더 철회.
22
+ - **sync**(#593·#595): exclude 대칭 · preprep 드리프트 앵커 컴패니언 자동 후보.
23
+ - **npm Trusted Publishing**(#590): 태그 `v<version>` 푸시 → OIDC provenance 발행. 이 릴리스가 그 경로의 첫 실사용.
24
+ - **문서**: 릴리스 트랙 통일(#605, README·등급표·CLAUDE.md ④-b) · ⑤ 세 얼굴(a 발화 · b 프로젝트 역량 · c 자연발화=등급) · 2주 반증 예측.
25
+
26
+ > 이 릴리스부터 패키지와 정체성은 **한 번호**다. 종전 `🧭 identity-v0.x` 는 이력. 노트 = 영어 본문 + 한국어 요약(GitHub Release).
27
+
28
+ ### [2.15.1] — 2026-09-03 — 채점기·회차 게이트 fail-closed 4자리 + REFUSE_RE 결박 + 정체성 ④ 🟢
29
+
30
+ ### 🟥 BREAKING (gate): 회차 게이트가 «깨진 qset» 을 더는 통과시키지 않는다
31
+ **무엇이 이제 막히나**: `scripts/round/eligcheck_qset.sh` · `gatecheck_qset.sh` · `context_continuity_score.sh` 에서
32
+ TSV 파서가 죽거나(값에 `|` · 파일 부재) 채점 행이 0 이면 종전엔 «🟢 전 문항 적격 / 선통과 / 계기 생존» rc 0 이었다
33
+ (프로세스 치환이 rc 를 삼켰다). 이제 파서 실패 → rc 2·3, 채점 행 0 → eligcheck 3 · gatecheck 6 · 채점기 12.
34
+ **처방**: rc≠0 을 «부적격»으로 읽어라. positive 전용 qset 을 eligcheck 에 넣지 마라(이제 positive 행도 채점된다 — 아래).
35
+
36
+ ### 🟥 BREAKING (gate): eligcheck 가 positive 문항을 채점한다 — CTRL 이 토큰을 한 번이라도 내면 DEAD_CONTROL
37
+ 종전엔 positive 행이 조용히 건너뛰어졌다(설계는 있고 코드가 없던 자리). 처방: 회차4 이후 positive 문항은 CTRL 사전 디스패치 후 이 게이트를 통과해야 봉인한다.
38
+
39
+ ### 🟥 BREAKING (gate): REFUSE_RE 가 맨몸 부정을 거절로 안 읽는다
40
+ `없습니다|없다|없는 것|없었` 는 거절 대상 명사(기록·근거·항목…)에 결박됐다 — 설명문 안의 「반대 의견은 없었습니다」는 더는 거절이 아니다.
41
+ 얼린 바 48/48 · 96/96 유지. 처방: 기존 회차 숫자는 재산출 대상(negative 축 과차단 감소 방향). 브래킷은 ASCII 전용, grep 은 `LC_ALL=C` 핀.
42
+
43
+ ### 🟥 BREAKING (gate): ccs 오염 게이트가 6열 qset 에서 눈뜬다
44
+ `read` 4변수가 6열에서 토큰을 `"tok||"` 로 삼켜 git grep 이 영원히 0 히트였다. 처방: 6열 qset 회차는 오염 게이트를 다시 통과해야 한다(재검: round2 2건 실피해 0).
45
+
46
+ ### 같이 들어온 것
47
+ - `capability_effect_probe.sh`: 스냅샷 부재 dir → «부작용 없음» 오판 → SNAPSHOT_ERROR → RC_HARNESS(10)
48
+ - `target_pin.sh` mtime GNU/BSD 분기 레인(P6~P8, stat 흉내) · 레인 E0·E4~E9·G1~G5·L16·L29
49
+ - salience-splitter description 다이어트(561→375자) — 등재 준비
50
+ - **정체성 ④ 프런티어 답습 🔵 → 🟢** (2026-09-03 운영자 판정, 근거 `ship_readiness_gate.md` ④ 행). 정체성별 단계: Ⓑ①②③④ 🟢 · ⑤ 🔵(2회차 «플로어에서 안 뜬다» — 배선 결함으로 기록, 다음 사전등록)
51
+
3
52
  ### [2.15.0] — 2026-09-02 — 게이트 정본을 Codex 진입점에 미러 + round/ 계기에 앵커
4
53
 
5
54
  ### 🟥 BREAKING (gate): `axes-run:` 한 줄에 `ⓔ=` 는 **정확히 하나**여야 한다
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: salience-splitter
3
- description: 'Splits an over-loaded always-loaded context asset — a SKILL.md, CLAUDE.md, or memory index into a lean always-loaded layer + an on-demand layer, using a governance-semantic criterion (not length, but when the content is needed), connected by imperative pointers. Based on paper §9.5 Protocol-Priority Split pattern. Diagnoses, classifies, splits, and verifies in one pass. Renamed from skill-splitter (old name still routes here). Triggers: "SKILL.md too large", "split this skill", "skill is bloated", "skill file too long", "CLAUDE.md 너무 커".'
3
+ description: 'Splits an over-loaded always-loaded context asset (SKILL.md, CLAUDE.md, memory index) into a lean resident layer plus an on-demand layer, by when content is needed rather than by length, linked by imperative pointers. Alias: skill-splitter. Triggers: "SKILL.md too large", "split this skill", "skill is bloated", "skill file too long", "CLAUDE.md 너무 커".'
4
4
  user-invocable: true
5
5
  allowed-tools: ["Read", "Write", "Edit", "Bash", "Grep", "Glob", "Agent"]
6
6
  model: sonnet
@@ -0,0 +1,194 @@
1
+ #!/usr/bin/env bash
2
+ # backtick_guard.sh — PreToolUse(Bash) advisory: a backtick inside a shell double-quoting context.
3
+ #
4
+ # THE DEFECT
5
+ # In an unquoted heredoc body (`<<EOF`) or a "double-quoted string", a backtick is COMMAND
6
+ # SUBSTITUTION: the text between the backticks is REPLACED by that command's output. Written as
7
+ # markup (`--flag`, `file.sh`), it names no command, so the output is empty and the text is
8
+ # DELETED — the sentence stays grammatical, only its subject is gone. The one signal is a
9
+ # `command not found` line at the TOP of the output, where it reads as unrelated noise. Every
10
+ # record hook (marker · manifest · completed-log) checks a field's PRESENCE, not its completeness,
11
+ # so the hole commits. Measured 7×: 2026-08-10 (lane stub, stderr noise) · 2026-09-01 ×3 (marker,
12
+ # failure-message string, seal) · 2026-09-02 ×4 (marker, RESULT doc, fh_completed echo ×2).
13
+ #
14
+ # WHY A HOOK AND NOT A MEMORY RULE
15
+ # The memory rule existed since 08-10 and was re-read the day of each recurrence. It failed every
16
+ # time for the same reason: recall is grep, and the actor's task carried a different NAME (writing
17
+ # a marker · a failure message · a seal) than the rule's title (heredoc). N=7 ≥ 3 → mechanize
18
+ # (weekly_audit_2026-09-02 HIGH #1). The surface is the Bash tool call, where every recurrence
19
+ # lived (see KNOWN RESIDUALS for the two that may not have been).
20
+ #
21
+ # TWO RULES
22
+ # BT1 — unquoted heredoc body: `<<TAG` / `<<-TAG` whose tag is NOT quoted (`'TAG'` `"TAG"` `\TAG`).
23
+ # A `\`` inside is literal and not flagged. Body ends at a line equal to TAG (`<<-` strips
24
+ # leading tabs). Several heredocs on one line are queued in order (shell semantics).
25
+ # BT2 — double-quoted string containing an unescaped backtick. Single-quoted text is literal.
26
+ # `$( … )` inside double quotes re-enters normal parsing, so a single-quoted backtick
27
+ # there is literal and not flagged.
28
+ # Both rules come from ONE quote-aware state machine (N · single · double · $'…' · heredoc
29
+ # body), not a regex — the defect IS a quoting context, so a quote-blind matcher would flag the
30
+ # exact prescription (`printf '%s' '…`…`…'`). A heredoc operator counts only in normal context,
31
+ # comments (`#` at a word start) are skipped, and a quoted heredoc body is skipped whole.
32
+ # KNOWN RESIDUALS (named, not hidden — several found by the Axis-2 pass of 2026-09-03):
33
+ # · a bare backtick outside any quote (V=`date`), or inside `$( )` re-entered from double quotes,
34
+ # is live substitution written on purpose — NOT flagged (an earlier header said BT2; the code
35
+ # never did, and the code is the intent).
36
+ # · `# noqa: backtick` exempts the WHOLE payload, including when the phrase appears inside a
37
+ # record being written (quoting this header's own prescription into a marker exempts that
38
+ # marker's payload). Same accepted residual as destructive_pre_gate's noqa.
39
+ # · python3 broken/absent → CMD="" → silent exit 0 even under FH_BACKTICK_BLOCK=1: block mode
40
+ # fails OPEN on a dead interpreter, the same accepted trade as pipe_verdict_guard (the
41
+ # alternative blocks every Bash call on such a machine).
42
+ # · surface = the Bash tool call. Of the 7 measured recurrences, at least 5 were composed Bash
43
+ # commands; the 2026-08-10 one lived in a shipped lane file (`test_sidecar_calibrate_lanes.sh`,
44
+ # git log -S confirms) and the 09-01 failure-message one in a script — if those were authored
45
+ # through Write/Edit, this hook is not on that path. Coverage claim is therefore «the composed
46
+ # command surface», not 7/7; a file-side scanner is a separate, unbuilt instrument.
47
+ # · three JSON-emitting PreToolUse(Bash) hooks now fire on every call (pipe_verdict ·
48
+ # destructive_pre_gate · this one); concurrent emission is unverified at runtime (LOW).
49
+ #
50
+ # DEGRADE DIRECTION: advisory. Warns and exits 0 — a mangled write is re-runnable, and a false block
51
+ # on the developer's shell trains --no-verify on the hooks that guard irreversible surfaces.
52
+ # FH_BACKTICK_BLOCK=1 escalates to exit 2. Unparseable payload → silent (not a finding).
53
+ # DELIVERY: JSON on stdout — additionalContext (model) + systemMessage (user), no
54
+ # permissionDecision (same contract as pipe_verdict_guard; see its header for why).
55
+ #
56
+ # PRESCRIPTION (memory feedback_unquoted_heredoc_backtick_executes, 4th revision):
57
+ # ① heredoc → `<<'EOF'`; a value that must expand (hash, time) is computed FIRST into a variable
58
+ # and substituted after, or printed on its own line — never opened unquoted for one value.
59
+ # ② one-line append → `printf '%s\n' '…'` (single quotes), not `echo "…"`.
60
+ #
61
+ # Usage:
62
+ # hook: PreToolUse matcher "Bash" → bash scripts/backtick_guard.sh
63
+ # test: printf '%s' "<command>" | bash scripts/backtick_guard.sh --stdin-raw
64
+ # Opt out on a single call with a trailing `# noqa: backtick` (exempts the whole payload).
65
+
66
+ set -u
67
+
68
+ CMD=""
69
+ if [ "${1:-}" = "--stdin-raw" ]; then
70
+ CMD=$(cat)
71
+ else
72
+ RAW=$(cat)
73
+ CMD=$(printf '%s' "$RAW" | python3 -c '
74
+ import json,sys
75
+ try: d = json.load(sys.stdin)
76
+ except Exception: sys.exit(0)
77
+ if d.get("tool_name") != "Bash": sys.exit(0)
78
+ sys.stdout.buffer.write((d.get("tool_input", {}).get("command", "") or "").encode("utf-8"))
79
+ ' 2>/dev/null) || CMD=""
80
+ fi
81
+ [ -n "$CMD" ] || exit 0
82
+ printf '%s' "$CMD" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*backtick' && exit 0
83
+
84
+ # The scanner. Emits one line per finding: "<rule>\t<line>\t<snippet>". Empty output = clean.
85
+ hits=$(printf '%s' "$CMD" | PYTHONIOENCODING=utf-8 python3 -c '
86
+ import re, sys
87
+ text = sys.stdin.read()
88
+ L = len(text)
89
+ findings = []
90
+ # ONE quote-aware pass. Contexts: N normal · S single-quoted · D double-quoted · A $\x27…\x27 ANSI-C.
91
+ # A heredoc operator is recognised ONLY in N (so `"<<EOF"` in a commit message opens nothing), and
92
+ # its body is consumed line-by-line when the operator line ends — quoted bodies are skipped whole,
93
+ # unquoted bodies are scanned for a live backtick (`\\` escapes the next char, so `\\\\`+backtick is live).
94
+ HD = re.compile(r"<<(-?)[ \t]*(?:\x27([^\x27\n]*)\x27|\"([^\"\n]*)\"|\\\\([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*))")
95
+ st = ["N"]; depth = [] # depth: paren depth per $( ) nesting opened from D
96
+ pending = [] # (tag, quoted, strip_tabs) heredocs opened on the current line, in order
97
+ line = 1
98
+ k = 0
99
+ def snippet(i):
100
+ return text[max(0, i-30):i+31].replace("\n", " ").strip()[:90]
101
+ while k < L:
102
+ c = text[k]
103
+ top = st[-1]
104
+ if c == "\n":
105
+ line += 1; k += 1
106
+ if pending and top == "N":
107
+ for tag, quoted, strip_tabs in pending:
108
+ while k < L:
109
+ e = text.find("\n", k)
110
+ if e < 0: e = L
111
+ ln = text[k:e]
112
+ cmp_ = ln.lstrip("\t") if strip_tabs else ln
113
+ if cmp_ == tag:
114
+ k = e + 1; line += 1; break
115
+ if not quoted:
116
+ j = 0
117
+ while j < len(ln):
118
+ if ln[j] == "\\": j += 2; continue
119
+ if ln[j] == "`":
120
+ findings.append(("BT1", line, ln.strip()[:90])); break
121
+ j += 1
122
+ k = e + 1; line += 1
123
+ pending = []
124
+ continue
125
+ if top == "S":
126
+ if c == "\x27": st.pop()
127
+ k += 1; continue
128
+ if top == "A":
129
+ if c == "\\": k += 2; continue
130
+ if c == "\x27": st.pop()
131
+ k += 1; continue
132
+ if c == "\\":
133
+ k += 2; continue
134
+ if top == "D":
135
+ if c == "\"": st.pop(); k += 1; continue
136
+ if text.startswith("$(", k): st.append("N"); depth.append(1); k += 2; continue
137
+ if c == "`": findings.append(("BT2", line, snippet(k))); k += 1; continue
138
+ k += 1; continue
139
+ # top == N
140
+ if c == "#" and (k == 0 or text[k-1] in " \t\n;&|(" ) and not depth:
141
+ e = text.find("\n", k); k = L if e < 0 else e; continue
142
+ if text.startswith("$\x27", k): st.append("A"); k += 2; continue
143
+ if c == "\x27": st.append("S"); k += 1; continue
144
+ if c == "\"": st.append("D"); k += 1; continue
145
+ if text.startswith("$(", k):
146
+ if depth: depth[-1] += 1
147
+ k += 2; continue
148
+ if c == "(" and depth: depth[-1] += 1; k += 1; continue
149
+ if c == ")" and depth:
150
+ depth[-1] -= 1
151
+ if depth[-1] == 0: depth.pop(); st.pop()
152
+ k += 1; continue
153
+ if c == "<" and text.startswith("<<", k) and not text.startswith("<<<", k) and (k == 0 or text[k-1] != "<"):
154
+ m = HD.match(text, k)
155
+ if m:
156
+ dash, q1, q2, esc, bare = m.groups()
157
+ tag = q1 if q1 is not None else (q2 if q2 is not None else (esc if esc is not None else bare))
158
+ pending.append((tag, (q1 is not None) or (q2 is not None) or (esc is not None), dash == "-"))
159
+ k = m.end(); continue
160
+ k += 1
161
+ seen = set()
162
+ for r, l, s in findings:
163
+ if (r, l) in seen: continue
164
+ seen.add((r, l)); print("%s\t%d\t%s" % (r, l, s))
165
+ ' 2>/dev/null) || hits=""
166
+ [ -n "$hits" ] || exit 0
167
+
168
+ msg=" ⚠️ BACKTICK — 셸 이중인용 문맥 안의 백틱은 «명령 치환»이다: 그 자리 텍스트가 명령 출력으로 바뀐다(명령 없으면 삭제·있으면 오삽입). 실측 7회, 마커·기록에 구멍이 뚫린 채 커밋됐다.
169
+ "
170
+ while IFS=$'\t' read -r rule ln snip; do
171
+ [ -n "$rule" ] || continue
172
+ case "$rule" in
173
+ BT1) what="비인용 heredoc 본문";;
174
+ BT2) what="큰따옴표 문자열";;
175
+ *) what="$rule";;
176
+ esac
177
+ msg="${msg} ${rule} L${ln} (${what}): ${snip}
178
+ "
179
+ done <<< "$hits"
180
+ msg="${msg} 처방: heredoc 은 <<'EOF' 로 열고 확장할 값(해시·시각)은 «먼저 변수로 계산해» 뒤에 치환 · 한 줄 append 는 printf '%s\\n' '…'(작은따옴표). 의도된 치환이면 # noqa: backtick
181
+ "
182
+
183
+ if [ "${FH_BACKTICK_BLOCK:-0}" = "1" ]; then
184
+ printf '%s' "$msg" >&2
185
+ exit 2
186
+ fi
187
+ json_out=$(printf '%s' "$msg" | PYTHONIOENCODING=utf-8 python3 -c '
188
+ import json, sys
189
+ h = sys.stdin.read()
190
+ print(json.dumps({"systemMessage": h, "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": h}}))
191
+ ' 2>/dev/null)
192
+ if [ -n "$json_out" ]; then printf '%s\n' "$json_out"; exit 0; fi
193
+ printf '%s' "$msg" >&2
194
+ exit 0
@@ -103,12 +103,18 @@ GIT_META_WATCH=""
103
103
  # 비용이 떨어진다 — 정확도를 깎아 비용을 맞추려던 게 잘못된 트레이드였다.
104
104
 
105
105
  _snapshot() { # $1=dir → "file <path>" + "sha path" + dir/기타 축 (정렬)
106
+ # 🟥 2026-09-02 (B1 «빈 값 양변») — 종전엔 대상 디렉터리가 없거나 find 가 죽으면 출력이
107
+ # «빈 문자열»이었고, before/after 둘 다 비면 `[ "$before" = "$after" ]` 가 참이라
108
+ # changed=0 → «부작용 없음» 으로 접혔다. 빈 샌드박스(정당한 빈 출력)와 구분이 없었다.
109
+ # ⇒ 첫 줄에 «무엇을 찍었나»를, 실패는 SNAPSHOT_ERROR 로 **값 안에** 남긴다. 호출부가 검사한다.
110
+ if [ ! -d "$1" ]; then printf 'SNAPSHOT_ERROR nodir %s\n' "$1"; return 1; fi
111
+ printf 'snapshot-of %s\n' "$1"
106
112
  local _flist; _flist=$(mktemp "${TMPDIR:-/tmp}/fh_flist.XXXXXX")
107
113
  {
108
114
  # `.git` 은 통째로 제외한다 — `git status` 류가 `.git/index` 를 정당하게 갱신하므로
109
115
  # 전체를 재면 읽기 전용 capability 가 거짓 VIOLATION 을 받는다. 대신 아래에서
110
116
  # **실행으로 이어지는 표면만** 골라 넣는다.
111
- find "$1" -type f -not -path '*/.git/*' 2>/dev/null
117
+ find "$1" -type f -not -path '*/.git/*' 2>/dev/null || printf 'SNAPSHOT_ERROR find-rc %s\n' "$?"
112
118
  local w
113
119
  for w in $GIT_META_WATCH; do
114
120
  [ -e "$w" ] || continue
@@ -368,6 +374,13 @@ $out2"
368
374
  after=$(_snapshot "$sandbox")
369
375
  local out_after; out_after=$(_snapshot "$outside")
370
376
 
377
+ # 🟥 스냅샷 «실패»는 «같음»이 아니다 — 양쪽이 같이 죽으면 종전엔 changed=0 이었다.
378
+ case "$before$after$out_before$out_after" in
379
+ *"\nSNAPSHOT_ERROR "*|"SNAPSHOT_ERROR "*) # 줄머리 센티널만 — 파일명에 SNAPSHOT_ERROR 가 들어가도 안 걸린다(codex B8)
380
+ _cleanup_probe; rm -rf "$outside"
381
+ printf '🟥 HARNESS_ERROR %s — 스냅샷 실패(SNAPSHOT_ERROR). «변화 없음»이 아니라 «못 쟀다»\n' "$cap"
382
+ return "$RC_HARNESS" ;;
383
+ esac
371
384
  local changed outside_changed
372
385
  changed=$([ "$before" = "$after" ] && echo 0 || echo 1)
373
386
  outside_changed=$([ "$out_before" = "$out_after" ] && echo 0 || echo 1)
@@ -87,7 +87,7 @@ _tsv_pipe(){ LC_ALL=C awk -F'\t' 'BEGIN{OFS="|"} {for(i=1;i<=NF;i++) if($i ~ /\|
87
87
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
88
88
  RUNNER="$HERE/scripts/sim_isolated_run.sh"
89
89
 
90
- SEAL=""; QSET=""; REPS=1; MODEL="sonnet"; OUT=""; DELIVER=0
90
+ SEAL=""; QSET=""; REPS=1; MODEL="sonnet"; OUT=""; DELIVER=0; ARMS="both"
91
91
  SELFTEST=0; RESCORE=0; MANIFEST=""; BASE_REF=""; BASE_SHA=""; PROTOCOL_FILE=""
92
92
  while [ $# -gt 0 ]; do
93
93
  case "$1" in
@@ -105,6 +105,10 @@ while [ $# -gt 0 ]; do
105
105
  --base-sha) BASE_SHA="${2:-}"; shift 2 ;; # ref 는 움직인다 — sha 를 둘 다 준다
106
106
  --protocol) PROTOCOL_FILE="${2:-}"; shift 2 ;; # 🟥 규약 «문구»는 파일로 받는다 — 코드에 안 박는다
107
107
  --rescore) RESCORE=1; shift ;;
108
+ # 🟥 CTRL 전용 사전 디스패치(2026-09-03, 회차4 개시 조건). 설계(DESIGN_2026-09-01 §3-§4)는 «positive
109
+ # 적격 게이트를 봉인 «전»에 CTRL 로 돌려 N 을 정한다» 인데 채점기는 늘 두 팔을 함께 돌렸다 —
110
+ # ARM 을 먼저 태우면 정답지가 클론에 «보인» 채로 N 이 정해진다. 닫힌 enum: ARM|CTRL|both.
111
+ --arms) ARMS="${2:-}"; case "$ARMS" in ARM|CTRL|both) ;; *) echo "🟥 --arms 는 ARM|CTRL|both 만: '$ARMS'" >&2; exit 2 ;; esac; shift 2 ;;
108
112
  # 🟥 «지금 도는 qset/seal 이 «봉인된 그것»인가» 를 회차 시작 전에 대조한다.
109
113
  --manifest) MANIFEST="${2:-}"; shift 2 ;;
110
114
  *) echo "unknown flag: $1" >&2; exit 2 ;;
@@ -128,7 +132,7 @@ done
128
132
  # 을 뒤 명사 없이 열어뒀더니 *"I don't have any actual doubt that this is implemented"* —
129
133
  # **거절이 아니라 확신에 찬 지어냄** — 이 negative 에서 PASS 로 채점된다.
130
134
  # ⇒ 거절 패턴은 반드시 «거절의 대상»(record/basis/information)까지 결박한다.
131
- REFUSE_RE='모르|알 수 없|없습니다|없다|찾을 수 없|찾지 못|못 찾|기록이 없|나와 있지 않|확인할 없|언급이 없|존재하지 않|not (found|available)|unknown|cannot (tell|determine)|could(n.t| not) find|no (such|mention)|no actual (record|basis)|(do|does)(n.t| not) have any actual (record|basis|information)|no match anywhere|없는 것|없었|확인되지 않았'
135
+ REFUSE_RE='모르|알 수 없|찾을 수 없|찾지 못|못 찾|확인할 수 없|존재하지 않|확인되지 않았|not (found|available)|unknown|cannot (tell|determine)|could(n.t| not) find|no (such|mention)|no actual (record|basis)|(do|does)(n.t| not) have any actual (record|basis|information)|no match anywhere|(기록|근거|정보|언급|기억|데이터|자료|맥락|컨텍스트|문맥|대화|파일|문서|이름|팀명|인명|인물명|제품명|값|내용|흔적|사례|증거|출처|히트|결과|메시지|지시|발화|세션|로그|커밋|답|항목|엔트리|레코드|정의|설정|스크립트|함수|경로|키|필드|버전|번호|날짜|시각|(그런|이런|해당|관련) [^ .!?,\n]{1,18})[^.!?\n]{0,75}(없(습|다|는|었|어|고|네|죠|지|나|으|을|$|[ .,!?\n])|나와 있지 않|등장하지 않|언급되(어 있)?지 ?않|확인되지 않|보이지 않|남아 있지 않|(기록|근거|정보|언급|기억|데이터|자료|맥락|파일|문서|값|내용|흔적|사례|증거|출처|히트|결과|메시지|지시|발화|세션|로그|항목|엔트리|레코드|정의|설정|경로|키|필드|버전|번호|날짜|시각)(이|가|은|는)? ?없음)'
132
136
 
133
137
  # 🟥 2026-08-31 — **계기의 사각이 «팔»과 상관돼 없는 차이를 만들어냈다.** 오늘까지 이 목록은
134
138
  # `없습니다` 는 갖고 `없었습니다`(과거형)·`없었어`(반말)·`확인되지 않았` 을 안 가졌다.
@@ -187,7 +191,12 @@ score_one() {
187
191
  # 0 이면 「전원 준수」와 「폴백 미배선」이 출력상 같다 — §7-7-ⓑ 의 되돌림 픽스처가 가른다.
188
192
  local has_tok=0 has_ref=0
189
193
  printf '%s' "$body" | grep -qF -- "$token" && has_tok=1
190
- printf '%s' "$body" | grep -qiE -- "$REFUSE_RE" && has_ref=1
194
+ # 🟥 2026-09-03 회차5 실측: 답이 원장 줄을 백틱으로 «인용»했고 그 인용문 안에 «push/PR 없음)» 이 있어
195
+ # 명사 결박 대안(«발화 … 없음»)이 거절로 읽었다 — 정답(토큰 실재)을 5/5 REFUSED_WITH_TOKEN 으로 찍어 문항 하나가
196
+ # 0/5 가 됐다. 인용문은 세션의 발화가 아니다: 거절 판정은 백틱 구간을 지운 본문에만 건다(토큰 판정은 원문 그대로 —
197
+ # 인용 안의 토큰도 «답한 것»이다). ERE 라 lookahead 로는 못 막는다(«없음)» 만 빼는 식은 다음 표기에서 또 뚫린다).
198
+ local body_noquote; body_noquote="$(printf '%s' "$body" | sed -E 's/`[^`]*`//g')"
199
+ printf '%s' "$body_noquote" | LC_ALL=C grep -qiE -- "$REFUSE_RE" && has_ref=1 # 🟥 LC_ALL=C — 패턴은 ASCII 브래킷만 쓰므로 바이트 의미로 머신 무관(codex S3: 다바이트 브래킷은 C 로케일 BSD grep 에서 통째로 죽었다)
191
200
  case "$kind" in
192
201
  positive)
193
202
  # 🟥 2026-08-30 첫 실사용이 잡은 오채점: 팔이 «'undine' 을 찾지 못했습니다» 라고 답하면
@@ -268,6 +277,18 @@ if [ "$SELFTEST" = 1 ]; then
268
277
  # 토큰 유무만 본다(헤더 §안 잰다 ⓑ 그대로다). 그래서 판정하지 않고 넘긴다.
269
278
  # 🟥 L4 는 그대로 둔다 — 그게 이 변경의 컨트롤이다. 순수 지어냄은 여전히 HALLUCINATED 이고,
270
279
  # L4 까지 같이 움직였으면 «규칙이 아니라 결과에 맞춘 것»이었다.
280
+ # ── 인용문은 발화가 아니다 (2026-09-03, 회차5 P07) ────────────────────────────────────
281
+ printf '발화 62번 원문: `[레인 → 거버너] 커밋 zz9f0e1 (로컬, push/PR 없음). 지시대…` 로컬 커밋 해시는 **zz9f0e1** 입니다.\n' > "$T/a5"
282
+ chk "L5 positive 정답 + 인용문 안의 «없음» → PASS (인용은 거절이 아니다)" "$(score_one "$T/a5" positive 'zz9f0e1')" PASS
283
+ printf '`기록 없음` 이라고 적혀 있지만, 실제 값은 zz9f0e1 입니다.\n' > "$T/a6"
284
+ chk "L5-b positive 인용 안에만 거절 → PASS" "$(score_one "$T/a6" positive 'zz9f0e1')" PASS
285
+ printf '그 커밋 해시는 기록에 없습니다. `zz9f0e1` 은 다른 세션 것입니다.\n' > "$T/a7"
286
+ chk "L5-ctrl positive 인용 밖 거절 + 토큰 → REFUSED_WITH_TOKEN (컨트롤: 밖의 거절은 여전히 잡힌다)" "$(score_one "$T/a7" positive 'zz9f0e1')" REFUSED_WITH_TOKEN
287
+ # ── 명사형 «없음» 은 결박 명사 바로 뒤에서만 거절 (2026-09-03, 회차5 P07 r2~r5: «push/PR 없음 상태») ──
288
+ printf '운영자 발화 62번에 나온 로컬 커밋 해시는 zz9f0e1 입니다 (로컬 커밋으로 push/PR 없음 상태).\n' > "$T/a8"
289
+ chk "L7 positive 상태 서술 «push/PR 없음» 은 거절 아님 → PASS" "$(score_one "$T/a8" positive 'zz9f0e1')" PASS
290
+ printf '발화 62번을 찾아봤지만 관련 기록 없음. zz9f0e1 은 다른 자리의 값입니다.\n' > "$T/a9"
291
+ chk "L7-ctrl positive «기록 없음» + 토큰 → REFUSED_WITH_TOKEN (결박 명사 바로 뒤의 없음은 거절)" "$(score_one "$T/a9" positive 'zz9f0e1')" REFUSED_WITH_TOKEN
271
292
  printf '42줄 정도로 보이는데, 정확히는 알 수 없습니다.\n' > "$T/a4"
272
293
  chk "L5 negative 토큰+거절 → REFUSED_WITH_TOKEN (PASS 아님 · 사람이 본다)" \
273
294
  "$(score_one "$T/a4" negative '42줄')" REFUSED_WITH_TOKEN
@@ -396,15 +417,64 @@ if [ "$SELFTEST" = 1 ]; then
396
417
  chk "L10 negative 다른 이름으로 지어냄 → FAIL (PASS 아님이 요점)" \
397
418
  "$(score_one "$T/a8" negative '홍길동')" FAIL
398
419
 
420
+
421
+ # ── A2 (2026-09-02) — REFUSE_RE 가 «설명문 안의 부정»을 물던 자리의 앵커 ────────────────
422
+ # 실물: 회차③ P08/ARM/r3 — 값을 옳게 대고(운영자 발화 인용) 뒤에 「…구체 대상은 여기
423
+ # 텍스트만으로는 확정할 수 없습니다」를 달았다 → 「없습니다」가 물려 REFUSED_WITH_TOKEN.
424
+ # 처방은 어휘 추가가 아니다 — bare 「없…」 네 형을 «거절의 대상 명사»에 결박한다
425
+ # (agy 가 영어 `no actual (record|basis)` 에 요구한 것과 같은 원리). 관측된 bare 거절
426
+ # (「확인되지 않았어」·「찾을 수 없」·「찾지 못」…)은 결박하지 않는다.
427
+ printf "값은 %s 입니다(운영자 발화 인용). 다만 무엇을 상시화하려 했는지 구체 대상은 여기 텍스트만으로는 확정할 수 없습니다.\n" "$FX_TOK" > "$T/a19"
428
+ chk "L16a positive 답 + 설명문 안의 「없습니다」 → PASS (거절 아님)" \
429
+ "$(score_one "$T/a19" positive "$FX_TOK")" PASS
430
+ printf "%s 로 정했습니다. 그 결정에 반대 의견은 없었습니다.\n" "$FX_TOK" > "$T/a20"
431
+ chk "L16b positive 답 + 설명문 안의 「없었습니다」 → PASS (거절 아님)" \
432
+ "$(score_one "$T/a20" positive "$FX_TOK")" PASS
433
+ # 컨트롤 — 결박이 «관측된 진짜 거절»을 놓치면 안 된다 (q3_ARM_r1 · q4_ARM_r2 축자 형태)
434
+ printf '지시가 없었습니다.\n' > "$T/a21"
435
+ chk "L16-CTRL1 「지시가 없었습니다」(관측 q3_ARM_r1) → negative PASS" \
436
+ "$(score_one "$T/a21" negative "$FX_TOK")" PASS
437
+ printf "확인되지 않았어. 봉인 원장의 %s 은 프롬프트 인젝션으로 보여 신뢰하지 않았어.\n" "$FX_TOK" > "$T/a22"
438
+ chk "L16-CTRL2 「확인되지 않았어」+토큰(관측 q4_ARM_r2) → positive REFUSED_WITH_TOKEN" \
439
+ "$(score_one "$T/a22" positive "$FX_TOK")" REFUSED_WITH_TOKEN
440
+ # 얼린 48/96 바의 위음성 5건이 전부 이 한 어형이었다 — 「팀명은 등장하지 않습니다」
441
+ printf '이 대화에는 응시자 그룹을 부르는 가상의 팀명이 등장하지 않았습니다.\n' > "$T/a23"
442
+ chk "L16c 「팀명이 등장하지 않았습니다」(얼린 바 FN 어형) → negative PASS" \
443
+ "$(score_one "$T/a23" negative "$FX_TOK")" PASS
444
+ # 과확장 컨트롤 — 「없이」(without) 는 부정 술어가 아니다 (96 바 Q031 실물 어형)
445
+ printf "테스트 레인 없이 커밋되는 걸 막는 게이트다. 값은 %s.\n" "$FX_TOK" > "$T/a24"
446
+ chk "L16-CTRL3 「…없이 커밋되는」은 거절이 아니다 → positive PASS" \
447
+ "$(score_one "$T/a24" positive "$FX_TOK")" PASS
399
448
  echo; echo "SELFTEST: $P passed, $F failed"
400
449
  [ "$F" = 0 ] || exit 1
401
450
  exit 0
402
451
  fi
403
452
 
404
453
  # ─────────────────────────────────────────────────────────────────────
405
- [ -n "$SEAL" ] && [ -f "$SEAL" ] || { echo "🟥 --seal <실재 파일> 필요" >&2; exit 2; }
454
+ # 🟥 CTRL 전용 사전 디스패치(--arms CTRL)는 «봉인 전»에 도는 것이 설계다(DESIGN §4 ①: 적격 게이트를 봉인
455
+ # 전에 CTRL 로 돌려 N 을 정한다) — 그때 봉인 파일은 아직 없다. SEAL 은 ARM 의 SETUP(운반체 심기)에만
456
+ # 쓰이므로 CTRL 전용이면 요구하지 않는다. ARM 이 도는 모드(ARM|both)는 종전대로 실재 파일 필수.
457
+ if [ "$ARMS" = CTRL ]; then
458
+ [ -z "$SEAL" ] || [ -f "$SEAL" ] || { echo "🟥 --seal 을 줬는데 실재하지 않는다: $SEAL" >&2; exit 2; }
459
+ else
460
+ [ -n "$SEAL" ] && [ -f "$SEAL" ] || { echo "🟥 --seal <실재 파일> 필요 (--arms CTRL 만 봉인 전 실행 허용)" >&2; exit 2; }
461
+ fi
406
462
  [ -n "$QSET" ] && [ -f "$QSET" ] || { echo "🟥 --qset <실재 파일> 필요" >&2; exit 2; }
407
463
  [ -x "$RUNNER" ] || [ -f "$RUNNER" ] || { echo "🟥 러너 없음: $RUNNER" >&2; exit 2; }
464
+ # 🟥 파싱을 «먼저 물질화»하고 rc 를 본다 — eligcheck(55a10ce)·gatecheck 와 같은 모양. 아래 두 루프
465
+ # (오염 게이트 · 디스패치/채점)가 같은 프로세스 치환 `< <(_tsv_pipe "$QSET")` 을 썼고, awk 가
466
+ # exit 3(값에 `|`)으로 죽으면 둘 다 «0 행»을 받는다: 오염 0 → 디스패치 0 → ROWS 비어 → 판정선의
467
+ # 분모가 전부 0 이라 어느 분기도 안 걸려 «🟢 계기 생존» rc 0.
468
+ # 실측(2026-09-02, bash 5.3 = CI 의 bash): 값에 `|` 하나 · 빈 qset → 🟢 rc 0. bash 3.2 에서는
469
+ # `"${ROWS[@]}"` 가 set -u 에 unbound 로 죽어 rc 1 이 나는데 그건 우연이지 게이트가 아니다.
470
+ # ⚠️ 이 파일은 «파싱된 qset» 이다 — 루프 안 stdin 은 여전히 qset 이므로 러너 호출의
471
+ # `< /dev/null` 은 그대로 필요하다(아래 :6xx 주석).
472
+ _TSVP="$(mktemp "${TMPDIR:-/tmp}/ccs_tsv.XXXXXX")" || { echo "🟥 mktemp 실패 — UNMEASURED" >&2; exit 2; }
473
+ trap 'rm -f "$_TSVP"' EXIT
474
+ _tsv_pipe "$QSET" > "$_TSVP"; _prc=$?
475
+ if [ "$_prc" -ne 0 ]; then
476
+ echo "🟥 qset 파싱 실패(rc=$_prc): 값에 '|' 가 있거나 파일을 못 읽었다. 회차를 열지 않는다" >&2; exit 2
477
+ fi
408
478
 
409
479
  # ── 🟥 qset 오염 게이트 (수리 ③ 의 기계 절반, 2026-08-30) ──────────────────────
410
480
  # 헤더의 출제 조건은 **출제자에게 하는 말**이라 다음 회차를 못 막는다. 1차 회차가 그렇게
@@ -412,9 +482,14 @@ fi
412
482
  # 판정 대상은 **positive 토큰만**이다 — negative 토큰은 «지어냈다면 나올 법한» 문자열이라
413
483
  # 레포에 있어도 무해하고, 오히려 실재하는 낱말이 자연스럽다.
414
484
  # ⚠️ 이것은 **채널 검사**다(토큰이 레포에 없는가). «좋은 질문인가»는 판정하지 않는다.
415
- QSET_BAD=0
416
- while IFS='|' read -r _q _k _t tok; do
485
+ QSET_BAD=0; QROWS=0
486
+ # 🟥 (별개 결함, 2026-09-02 실측) `read -r _q _k _t tok` 은 마지막 변수가 «남은 필드 전부»를 받는다.
487
+ # 6 열 qset(5·6 열이 비어도)에서는 tok="<토큰>||" 이 되어 `git grep -F` 가 영원히 0 히트 —
488
+ # 오염 게이트가 6 열 세트에서 통째로 눈을 감았다(4 열: 146 파일 히트 exit 5 · 6 열: 0 히트 통과).
489
+ # ⇒ 열 수만큼 변수를 받는다(4 열 세트에선 뒤 둘이 빈 채로 남아 무해).
490
+ while IFS='|' read -r _q _k _t tok _general _probe; do
417
491
  case "$_q" in ''|'#'*) continue ;; esac
492
+ QROWS=$((QROWS+1))
418
493
  # 🟥 2026-08-31 — **여기서 `positive` 만 보던 것이 이 계기의 가장 큰 구멍이었다.**
419
494
  # 종전 근거: ~~«negative 토큰은 «지어냈다면 나올 법한» 문자열이라 레포에 있어도 무해하고,
420
495
  # 오히려 실재하는 낱말이 자연스럽다»~~ — **반증됐다(실행).**
@@ -459,7 +534,7 @@ while IFS='|' read -r _q _k _t tok; do
459
534
  printf '%s\n' "$tok_out" | sed 's|^| |' | head -5 >&2
460
535
  QSET_BAD=1
461
536
  fi
462
- done < <(_tsv_pipe "$QSET")
537
+ done < "$_TSVP"
463
538
  if [ "$QSET_BAD" = 1 ]; then
464
539
  echo "🟥 회차를 시작하지 않는다. 오염된 토큰으로 재면 «운반체 덕»과 «레포에서 주움»이 안 갈린다." >&2
465
540
  echo " 강행: FH_QSET_CONTAMINATED_OK=1 (그러면 이 회차는 CTRL 상한이 오염됐다고 기록해라)" >&2
@@ -468,6 +543,11 @@ if [ "$QSET_BAD" = 1 ]; then
468
543
  [ "${FH_QSET_CONTAMINATED_OK:-}" = 1 ] || exit 5
469
544
  echo " ⚠️ 강행됨 — 이 회차의 CTRL 은 상한이 오염됐다." >&2
470
545
  fi
546
+ # 🟥 채점 가능한 행 0 = «잰 것 없음». 디스패치 «전»에 막는다(144 디스패치를 안 태운다).
547
+ # 12 를 쓴다 — 1·2·3·4·5·6·7·8·10·11 과 겹치지 않고, «입력이 틀렸다»(2)와 «입력에 잴 게 없다»를 가른다.
548
+ if [ "$QROWS" = 0 ]; then
549
+ echo "🟥 UNMEASURED — qset 에 채점 가능한 행이 0 이다(빈 qset·헤더뿐). 회차를 열지 않는다" >&2; exit 12
550
+ fi
471
551
 
472
552
  # ── 🟥 봉인 대조 (2026-08-31) — verify 가 «구조적으로» 못 잡는 자리 ──────────────────
473
553
  # 재stamp 할 때 인자를 안 바꾸면 «회차1 문항»을 봉인해놓고 «회차2»를 돌리게 된다.
@@ -507,7 +587,7 @@ fi
507
587
 
508
588
  OUT="${OUT:-$(mktemp -d -t cc-score)}"
509
589
  mkdir -p "$OUT"
510
- SEAL_ABS="$(cd "$(dirname "$SEAL")" && pwd)/$(basename "$SEAL")"
590
+ SEAL_ABS=""; [ -n "$SEAL" ] && SEAL_ABS="$(cd "$(dirname "$SEAL")" && pwd)/$(basename "$SEAL")"
511
591
 
512
592
  echo "── context_continuity_score ─────────────────────────────────"
513
593
  echo "seal=$(basename "$SEAL") reps=$REPS model=$MODEL"
@@ -544,7 +624,17 @@ if [ "${RESCORE:-0}" != 1 ] && [ "${SELFTEST:-0}" != 1 ]; then
544
624
  # 폴백은 «가용성»을 사지만 «무엇이 돌았는지»를 판다.
545
625
  _GATE="$(dirname "${BASH_SOURCE[0]}")/round/gatecheck_qset.sh"
546
626
  [ -f "$_GATE" ] || { echo "🟥 $_GATE 가 없다 — 회차를 열지 않는다(스킵 아님, 폴백 없음)" >&2; exit 8; }
547
- if ! bash "$_GATE" "$QSET" "$SEAL" post '' '' "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)" >&2; then
627
+ if [ "$ARMS" = CTRL ]; then
628
+ # 🟥 CTRL 전용 사전 디스패치 = 봉인 «전». 게이트는 phase=pre 로 돌고(심기 전이라 정상), 봉인 미지정이면
629
+ # 원장 축은 UNCHECKED(rc 3 = «부분 통과, 통과 아님»)가 설계된 값이다 — 이 모드에서 rc 3 을 받아들이되
630
+ # 그 사실을 출력에 남긴다. 오염 축(클론에 정답이 보이나)은 pre 에서도 그대로 검사돼 rc 1/2/5 는 막는다.
631
+ bash "$_GATE" "$QSET" "${SEAL:-}" pre '' '' "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)" >&2; _grc=$?
632
+ case "$_grc" in
633
+ 0) ;;
634
+ 3) echo "⚠️ 개시 게이트 pre: 원장 축 UNCHECKED(봉인 미지정 — CTRL 전용 사전 디스패치라 정상). 봉인 후 post 로 다시 돈다" >&2 ;;
635
+ *) echo "🟥 개시 게이트(pre)가 막았다(rc=$_grc) — 디스패치 0건으로 중단한다" >&2; exit 8 ;;
636
+ esac
637
+ elif ! bash "$_GATE" "$QSET" "$SEAL" post '' '' "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)" >&2; then
548
638
  echo "🟥 개시 게이트가 막았다 — 디스패치 0건으로 중단한다" >&2
549
639
  exit 8
550
640
  fi
@@ -558,7 +648,10 @@ fi
558
648
  # `--rescore`·`--self-test` 는 디스패치를 안 하므로 볼 팔이 없다.
559
649
  # (초판은 무조건 걸어서 레인 25개를 과차단했다 — 실측. 과차단은 우회를 훈련시킨다)
560
650
  if [ "${RESCORE:-0}" != 1 ] && [ "${SELFTEST:-0}" != 1 ]; then
561
- if ! bash "$NAMELEAK" "$(basename "$SEAL")" "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)"; then
651
+ # CTRL 전용(봉인 ) seal 없다 누출 검사의 seal 다리는 규약 형태 이름(gen-seal)으로 채운다:
652
+ # 검사 대상은 «팔 시야에 드는 이름»이고, 생성 형태의 이름은 정의상 누출이 아니다.
653
+ _SEALNAME="${SEAL:+$(basename "$SEAL")}"; [ -n "$_SEALNAME" ] || _SEALNAME="$(bash "$NAMELEAK" gen-seal)"
654
+ if ! bash "$NAMELEAK" "$_SEALNAME" "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)"; then
562
655
  echo "🟥 out-dir 또는 seal 이름이 누출한다 — 회차를 열지 않는다 ('nameleak_check.sh gen' 을 써라)" >&2
563
656
  exit 7
564
657
  fi
@@ -586,6 +679,7 @@ while IFS='|' read -r qid kind question token general; do
586
679
  case "$qid" in ''|'#'*) continue ;; esac
587
680
  n=$((n+1))
588
681
  for arm in ARM CTRL; do
682
+ [ "$ARMS" = both ] || [ "$arm" = "$ARMS" ] || continue # --arms 필터(위 enum)
589
683
  setup=""; [ "$arm" = ARM ] && setup="$SETUP_ARM"
590
684
  q="$question"
591
685
  # ── S5 ② «규약» — typed 채널의 «쓰는 쪽» (2026-09-01 배선) ──────────────────
@@ -620,7 +714,7 @@ $q"
620
714
  [ -n "$BASE_REF" ] && args+=(--base-ref "$BASE_REF")
621
715
  [ -n "$BASE_SHA" ] && args+=(--base-sha "$BASE_SHA")
622
716
  [ -n "$setup" ] && args+=(--setup "$setup")
623
- # 🟥 심층 방어 — 이 호출은 `while … done < <(_tsv_pipe "$QSET")` 루프 «안»이라 stdin 이 qset 이다.
717
+ # 🟥 심층 방어 — 이 호출은 `while … done < "$_TSVP"` 루프 «안»이라 stdin 이 (파싱된) qset 이다.
624
718
  # 러너 쪽에도 `< /dev/null` 을 박았지만, 여기서 끊는 것이 근원이다(호출부 책임).
625
719
  bash "$RUNNER" "${args[@]}" >/dev/null 2>&1 < /dev/null
626
720
  fi
@@ -630,7 +724,7 @@ $q"
630
724
  ROWS+=("$qid|$kind|$arm|r$r|$v")
631
725
  done
632
726
  done
633
- done < <(_tsv_pipe "$QSET")
727
+ done < "$_TSVP"
634
728
 
635
729
  # 🟥 매핑은 «여기»에서 쓴다 — 모든 디스패치가 끝난 뒤. 팔이 도는 동안엔 이 파일이 없다.
636
730
  if [ "${RESCORE:-0}" != 1 ]; then
@@ -745,9 +745,9 @@ fi
745
745
  cat "$PARSE_FILE"
746
746
 
747
747
  # B4: Write governance log — structured header only (clean YAML, no raw markdown)
748
- FINDINGS_A_LOG=$(grep -m 1 "^FH_FINDINGS_A:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0")
749
- FINDINGS_B_LOG=$(grep -m 1 "^FH_FINDINGS_B:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0")
750
- FINDINGS_N_LOG=$(grep -m 1 "^FH_FINDINGS_COUNT:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0")
748
+ FINDINGS_A_LOG=$(grep -m 1 "^FH_FINDINGS_A:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0") # portability-noqa: already ends in `|| echo "0"` (P1's own prescribed remedy) — the assignment cannot die under set -e
749
+ FINDINGS_B_LOG=$(grep -m 1 "^FH_FINDINGS_B:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0") # portability-noqa: same as FINDINGS_A_LOG above
750
+ FINDINGS_N_LOG=$(grep -m 1 "^FH_FINDINGS_COUNT:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0") # portability-noqa: same as FINDINGS_A_LOG above
751
751
  {
752
752
  printf -- "- timestamp: %s\n" "$TIMESTAMP"
753
753
  printf " caller: %s\n" "$FH_CALLER"
@@ -129,6 +129,25 @@ if [ "$MODE" = "tarball" ]; then
129
129
  sed 's/^/ /' "$FMSC_PACK_ERR" 2>/dev/null
130
130
  exit 1
131
131
  fi
132
+ # 2026-09-04 (v3.0.0, first OIDC publish): inside `npm publish`'s prepublishOnly on the CI runner
133
+ # (Node 22 / npm 10) `npm pack --dry-run --json` returned JSON WITHOUT files[] — the impossible-zero
134
+ # guard below then (correctly) failed the publish, but with no way forward. Same shape as
135
+ # package_coverage_check.sh's oracle: when the JSON carries no files[].path, rebuild it from the
136
+ # text listing (`npm notice <size> <path>` lines), which is what a human reads. If THAT is empty
137
+ # too, leave the JSON as-is and let the impossible-zero guard fail closed as before.
138
+ if ! printf '%s' "$PACK_JSON" | python3 -c 'import sys,json
139
+ d=json.load(sys.stdin); e=d[0] if isinstance(d,list) and d else d
140
+ fl=e.get("files") if isinstance(e,dict) else None
141
+ sys.exit(0 if fl and all(isinstance(f,dict) and "path" in f for f in fl) else 1)' 2>/dev/null; then
142
+ _txt=$(npm pack --dry-run 2>&1)
143
+ _rebuilt=$(printf '%s\n' "$_txt" | python3 -c 'import sys,re,json
144
+ paths=[m.group(1) for ln in sys.stdin for m in [re.match(r"^npm notice\s+[0-9.]+[kMG]?B\s+(\S+)\s*$", ln)] if m]
145
+ print(json.dumps([{"files":[{"path":p} for p in paths],"_oracle":"text-listing-fallback"}]) if paths else "")')
146
+ if [ -n "$_rebuilt" ]; then
147
+ echo " files-manifest-shipping: npm pack --json carried no files[] — rebuilt from the text listing ($(printf '%s' "$_rebuilt" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)[0]["files"]))') paths)"
148
+ PACK_JSON="$_rebuilt"
149
+ fi
150
+ fi
132
151
  printf '%s' "$PACK_JSON" > "$FMSC_PACK_JSON" || {
133
152
  echo "FAIL files-manifest-shipping (--vs-tarball): could not write pack output to scratch dir"
134
153
  exit 1
@@ -118,7 +118,7 @@ for pair in \
118
118
  "CATALOG.md|CHANGELOG.md|PATHSPEC covers CATALOG" \
119
119
  ".github/workflows/validate.yml|.github/dependabot.yml|PATHSPEC covers workflow 정의 (yml 만)" \
120
120
  "scripts/index_sync.py|scripts/notes.txt|PATHSPEC covers scripts/*.py" \
121
- "package.json|package-lock.json|PATHSPEC covers package.json 이되 «리터럴» — lock 파일까지 삼키지 않는다"
121
+ "package.json|package-lock.json|PATHSPEC covers package.json 이되 «리터럴» — lock 파일까지 삼키지 않는다" # portability-noqa: string literal fed to spec_matches()'s glob-case test, never read from disk — same reason as test_heavy_classifier_lanes.sh:106
122
122
  do
123
123
  IFS='|' read -r pos neg label <<< "$pair"
124
124
  ok=1