@chrono-meta/fh-gate 2.15.1 → 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 (45) 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 +19 -2
  9. package/knowledge/shared/learnings/subagent_invocations_log.yaml +49 -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 +25 -0
  14. package/scripts/backtick_guard.sh +194 -0
  15. package/scripts/context_continuity_score.sh +49 -7
  16. package/scripts/fh-gate.sh +3 -3
  17. package/scripts/files_manifest_shipping_check.sh +19 -0
  18. package/scripts/gate_pathspec_check.sh +1 -1
  19. package/scripts/package_coverage_check.sh +24 -2
  20. package/scripts/proposal_hook.sh +89 -0
  21. package/scripts/public_surface_scan_files.sh +11 -2
  22. package/scripts/revert_probe.sh +250 -0
  23. package/scripts/selfcheck.sh +65 -2
  24. package/scripts/sim_isolated_run.sh +97 -7
  25. package/scripts/test_backtick_guard_lanes.sh +115 -0
  26. package/scripts/test_degrade_scan_shell_probes.sh +7 -7
  27. package/scripts/test_files_manifest_shipping_lanes.sh +5 -5
  28. package/scripts/test_heavy_classifier_lanes.sh +1 -1
  29. package/scripts/test_lane_runner_lanes.sh +59 -33
  30. package/scripts/test_mapped_tracks_lanes.sh +1 -1
  31. package/scripts/test_marker_soul_check_lanes.sh +24 -0
  32. package/scripts/test_node_check_lanes.sh +34 -34
  33. package/scripts/test_package_coverage_lanes.sh +53 -27
  34. package/scripts/test_pipe_verdict_guard_lanes.sh +5 -5
  35. package/scripts/test_precommit_pointer_index_lanes.sh +33 -0
  36. package/scripts/test_preprep_drift_anchor.sh +13 -4
  37. package/scripts/test_preprep_drift_anchor_lanes.sh +23 -0
  38. package/scripts/test_proposal_hook_lanes.sh +36 -0
  39. package/scripts/test_revert_probe_lanes.sh +146 -0
  40. package/scripts/test_session_close_lanes.sh +3 -5
  41. package/scripts/test_sim_isolated_run_lanes.sh +17 -0
  42. package/scripts/utterance_landing_check.sh +2 -2
  43. package/templates/.git-hooks/pre-commit +27 -4
  44. package/templates/settings.PreToolUse.snippet.json +37 -1
  45. package/plugins/fh-commons/README.md +0 -38
@@ -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
@@ -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|(기록|근거|정보|언급|기억|데이터|자료|맥락|컨텍스트|문맥|대화|파일|문서|이름|팀명|인명|인물명|제품명|값|내용|흔적|사례|증거|출처|히트|결과|메시지|지시|발화|세션|로그|커밋|답|항목|엔트리|레코드|정의|설정|스크립트|함수|경로|키|필드|버전|번호|날짜|시각|(그런|이런|해당|관련) [^ .!?,\n]{1,18})[^.!?\n]{0,75}(없(습|다|는|었|어|음|고|네|죠|지|나|으|을|$|[ .,!?\n])|나와 있지 않|등장하지 않|언급되(어 있)?지 ?않|확인되지 않|보이지 않|남아 있지 )'
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" | LC_ALL=C grep -qiE -- "$REFUSE_RE" && has_ref=1 # 🟥 LC_ALL=C 패턴은 ASCII 브래킷만 쓰므로 바이트 의미로 머신 무관(codex S3: 다바이트 브래킷은 C 로케일 BSD grep 에서 통째로 죽었다)
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
@@ -430,7 +451,14 @@ if [ "$SELFTEST" = 1 ]; then
430
451
  fi
431
452
 
432
453
  # ─────────────────────────────────────────────────────────────────────
433
- [ -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
434
462
  [ -n "$QSET" ] && [ -f "$QSET" ] || { echo "🟥 --qset <실재 파일> 필요" >&2; exit 2; }
435
463
  [ -x "$RUNNER" ] || [ -f "$RUNNER" ] || { echo "🟥 러너 없음: $RUNNER" >&2; exit 2; }
436
464
  # 🟥 파싱을 «먼저 물질화»하고 rc 를 본다 — eligcheck(55a10ce)·gatecheck 와 같은 모양. 아래 두 루프
@@ -559,7 +587,7 @@ fi
559
587
 
560
588
  OUT="${OUT:-$(mktemp -d -t cc-score)}"
561
589
  mkdir -p "$OUT"
562
- SEAL_ABS="$(cd "$(dirname "$SEAL")" && pwd)/$(basename "$SEAL")"
590
+ SEAL_ABS=""; [ -n "$SEAL" ] && SEAL_ABS="$(cd "$(dirname "$SEAL")" && pwd)/$(basename "$SEAL")"
563
591
 
564
592
  echo "── context_continuity_score ─────────────────────────────────"
565
593
  echo "seal=$(basename "$SEAL") reps=$REPS model=$MODEL"
@@ -596,7 +624,17 @@ if [ "${RESCORE:-0}" != 1 ] && [ "${SELFTEST:-0}" != 1 ]; then
596
624
  # 폴백은 «가용성»을 사지만 «무엇이 돌았는지»를 판다.
597
625
  _GATE="$(dirname "${BASH_SOURCE[0]}")/round/gatecheck_qset.sh"
598
626
  [ -f "$_GATE" ] || { echo "🟥 $_GATE 가 없다 — 회차를 열지 않는다(스킵 아님, 폴백 없음)" >&2; exit 8; }
599
- 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
600
638
  echo "🟥 개시 게이트가 막았다 — 디스패치 0건으로 중단한다" >&2
601
639
  exit 8
602
640
  fi
@@ -610,7 +648,10 @@ fi
610
648
  # `--rescore`·`--self-test` 는 디스패치를 안 하므로 볼 팔이 없다.
611
649
  # (초판은 무조건 걸어서 레인 25개를 과차단했다 — 실측. 과차단은 우회를 훈련시킨다)
612
650
  if [ "${RESCORE:-0}" != 1 ] && [ "${SELFTEST:-0}" != 1 ]; then
613
- 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
614
655
  echo "🟥 out-dir 또는 seal 이름이 누출한다 — 회차를 열지 않는다 ('nameleak_check.sh gen' 을 써라)" >&2
615
656
  exit 7
616
657
  fi
@@ -638,6 +679,7 @@ while IFS='|' read -r qid kind question token general; do
638
679
  case "$qid" in ''|'#'*) continue ;; esac
639
680
  n=$((n+1))
640
681
  for arm in ARM CTRL; do
682
+ [ "$ARMS" = both ] || [ "$arm" = "$ARMS" ] || continue # --arms 필터(위 enum)
641
683
  setup=""; [ "$arm" = ARM ] && setup="$SETUP_ARM"
642
684
  q="$question"
643
685
  # ── S5 ② «규약» — typed 채널의 «쓰는 쪽» (2026-09-01 배선) ──────────────────
@@ -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
@@ -360,12 +360,34 @@ if ORACLE == 'tarball':
360
360
  if r.returncode != 0:
361
361
  print(f"ORACLE_UNAVAILABLE\tnpm pack exited {r.returncode}")
362
362
  raise SystemExit(2)
363
- packed = {f['path'] for f in json.loads(r.stdout)[0]['files']}
363
+ # Two shapes have been seen for `npm pack --dry-run --json`: the documented one carries
364
+ # `[0]['files'][*]['path']`; inside `npm publish`'s prepublishOnly on the CI runner
365
+ # (Node 22 / npm 10, 2026-09-04, v3.0.0 first OIDC publish) the same call returned JSON
366
+ # WITHOUT that key and this block died with a bare KeyError — fail-closed (correct) but
367
+ # blind (no diagnosis). Parse defensively, and when the JSON does not carry a file list
368
+ # fall back to the text listing (`npm notice <size> <path>` lines), which is what a human
369
+ # reads. The diagnostic line prints the head of stdout so the NEXT failure names its shape.
370
+ parsed = json.loads(r.stdout)
371
+ entry = parsed[0] if isinstance(parsed, list) and parsed else (parsed if isinstance(parsed, dict) else None)
372
+ flist = (entry or {}).get('files') if isinstance(entry, dict) else None
373
+ if flist and all(isinstance(f, dict) and 'path' in f for f in flist):
374
+ packed = {f['path'] for f in flist}
375
+ else:
376
+ t = subprocess.run(['npm', 'pack', '--dry-run'], capture_output=True, text=True, timeout=180)
377
+ lines = (t.stdout + '\n' + t.stderr).splitlines()
378
+ packed = set()
379
+ for ln in lines:
380
+ m = re.match(r'^npm notice\s+[0-9.]+[kMG]?B\s+(\S+)\s*$', ln)
381
+ if m:
382
+ packed.add(m.group(1))
383
+ if not packed:
384
+ print(f"ORACLE_UNAVAILABLE\tnpm pack --json had no files[].path and the text listing had no file lines; json head: {r.stdout[:200]!r}")
385
+ raise SystemExit(2)
364
386
  except FileNotFoundError:
365
387
  print("ORACLE_UNAVAILABLE\tnpm is not on PATH — the tarball cannot be read")
366
388
  raise SystemExit(2)
367
389
  except (json.JSONDecodeError, KeyError, IndexError) as e:
368
- print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__})")
390
+ print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__}); stdout head: {r.stdout[:200]!r}")
369
391
  raise SystemExit(2)
370
392
  except subprocess.TimeoutExpired:
371
393
  print("ORACLE_UNAVAILABLE\tnpm pack timed out")
@@ -0,0 +1,89 @@
1
+ #!/usr/bin/env bash
2
+ # proposal_hook.sh — PreToolUse(Edit|Write|Bash) advisory: a verdict/guard line in scripts/**/*.sh or
3
+ # templates/*.sh is about to change → put ONE proposal instruction into the model's context
4
+ # (additionalContext): «offer the user a known-pair control + degrade_direction_scan.sh in one line».
5
+ #
6
+ # WHY A HOOK (identity ⑤, measured 2026-09-03)
7
+ # r3: a CLAUDE.md table row keyed on this exact file class fired 1/15 at floor tier (that 1 a
8
+ # recitation). r4: this hook, installed in a disposable clone, fired on 9/10 editing reps and the
9
+ # floor session relayed the proposal 9/9; the hard negative (a usage-string edit in a .sh) 0/5.
10
+ # Same tasks, same tier, same prose layer — the row 0, the channel 100%. That is the
11
+ # «explicit instruction 3/3 · advisory 0/3 · framing 0/3» result of 2026-08-21 seen a second time
12
+ # (tracks/_meta/RESULT_2026-09-03_identity5-r4.md · prior_art_prompt.sh header). A channel is
13
+ # built at the channel (§Mechanization Boundary); what the proposal SAYS stays the model's.
14
+ #
15
+ # WHAT IT DOES NOT CLAIM
16
+ # Relaying an injected instruction is not initiative. Of r4's 9 hits, 4 carried task-specific
17
+ # substance beyond the hook's own wording (the K1 «judgment residue» — innovator signal
18
+ # fh_signal_2026-09-03_innovator-identity5-r4.md). This hook opens the window; whether ⑤'s bar
19
+ # («proposes unasked») counts a relayed proposal is the operator's call, recorded there, not here.
20
+ #
21
+ # DISCRIMINATOR (mechanical, quote-aware where it can be)
22
+ # file class : scripts/**/*.sh · templates/*.sh (docs, tracks, tests-as-fixtures: no)
23
+ # edit kind : the touched text carries a verdict/guard token — exit N · return N ·
24
+ # `|| continue|exit|true|return` · `&& continue|exit` · -ne/-eq/-gt/-lt · ==/!= ·
25
+ # `[ -e/-f/-s/-n/-z` · comm/diff/cmp · grep -q — AND for Edit the change is not
26
+ # confined to quoted strings (old/new with quotes stripped must differ). A usage
27
+ # string that happens to contain `exit 2` does not fire (r4 HARD 0/5).
28
+ # Bash path : an edit made through the shell (sed -i · > · >> · tee) — the r4 miss (T2 r5 edited
29
+ # via Bash, hook 0). No old/new here, so the rule is weaker: target file class AND the
30
+ # the RAW command text (quotes included) carries a token. Quotes are NOT stripped on
31
+ # this path — in `sed -i 's/exit 1/exit 2/' x.sh` the token is inside the quotes by
32
+ # construction, and stripping made the path silent on exactly the shape it exists for
33
+ # (found by the Air node 2026-09-03; the original lane's known-positive only fired
34
+ # because its token sat outside the quotes). Named residual now: over-fire when a
35
+ # quoted token elsewhere in the command co-occurs with a script-file edit (advisory).
36
+ #
37
+ # DEGRADE DIRECTION: advisory, exit 0 always, no permissionDecision (same contract as pipe_verdict_guard).
38
+ # Unparseable payload → silent. python3 absent → silent (a dead interpreter must not block edits).
39
+ # Evidence line appended to $CLAUDE_PROJECT_DIR/.claude/.proposal_hook_events.tsv (gitignored dir)
40
+ # so a sim arm can prove the hook fired INSIDE its clone (runner header: absence of that file
41
+ # invalidates the arm, never the hypothesis).
42
+ # Opt out on one call with `# noqa: proposal-hook`.
43
+ # test: printf '%s' '<PreToolUse JSON>' | bash scripts/proposal_hook.sh
44
+ set -u
45
+ RAW=$(cat 2>/dev/null || true)
46
+ printf '%s' "$RAW" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*proposal-hook' && exit 0
47
+ read -r FP FLAG < <(printf '%s' "$RAW" | python3 -c '
48
+ import json,sys,re
49
+ try: d=json.load(sys.stdin)
50
+ except Exception: print("",""); sys.exit(0)
51
+ tn=d.get("tool_name",""); ti=d.get("tool_input",{}) or {}
52
+ TOK=r"exit [0-9]|return [0-9]|\|\| *(continue|exit|true|return)|&& *(continue|exit)|-ne |-eq |-gt |-lt | == | != |\[ -[efsnz] |\bcomm |\bdiff |\bcmp |grep -q"
53
+ def strip(x): return re.sub(r"\"[^\"]*\"|\x27[^\x27]*\x27","",x)
54
+ fp=""; flag="0"
55
+ if tn in ("Edit","Write"):
56
+ fp=ti.get("file_path","") or ""
57
+ old=ti.get("old_string","") or ""; new=(ti.get("new_string","") or ti.get("content","") or "")
58
+ touches=bool(re.search(TOK, old+"\n"+new)); real=strip(old).strip()!=strip(new).strip()
59
+ flag="1" if (touches and real) else "0"
60
+ elif tn=="Bash":
61
+ cmd=(ti.get("command","") or "").replace("\n"," ")
62
+ m=re.search(r"(?:sed\s+-i\S*(?:\s+(?:\x27[^\x27]*\x27|\"[^\"]*\"|\S+)){1,2}\s+|>>?\s*|tee\s+(?:-a\s+)?)[\"\x27]?([^\s\"\x27|;&)<>]+\.sh)\b", cmd)
63
+ if m:
64
+ fp=m.group(1); flag="1" if re.search(TOK, cmd) else "0" # raw cmd, NOT strip(): in a sed -i the token lives INSIDE the quoted expression by construction (Air 2026-09-03: a1 silent, known-positive only fired because its token sat outside the quotes)
65
+ print(fp, flag)
66
+ ' 2>/dev/null) || exit 0
67
+ [ -n "${FP:-}" ] || exit 0
68
+ case "$FP" in *scripts/*.sh|*templates/*.sh|scripts/*.sh|templates/*.sh|*/.git-hooks/*|.git-hooks/*) ;; *) exit 0 ;; esac # .git-hooks/* has no .sh suffix — the gate files themselves were outside the filter (arm C wt2 2026-09-03: pre-commit edit, no FIRE)
69
+ [ "${FLAG:-0}" = 1 ] || exit 0
70
+ _D="${CLAUDE_PROJECT_DIR:-.}/.claude"; mkdir -p "$_D" 2>/dev/null
71
+ printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "FIRE" "$FP" >> "$_D/.proposal_hook_events.tsv" 2>/dev/null
72
+ # ── Fact lines (r8, 2026-09-03): the two preconditions of the proposal are DETERMINISTIC, so the hook checks
73
+ # them itself and carries the result as a «사실» line — agents propose, solvers verify. Measured r8: on the
74
+ # stimulus whose grounds sit in a neighbouring file, wording-only (r7 B) got 1/5 withdraw/amend, fact lines
75
+ # got 4/5 (hand-judged, n=5). Sonnet used the fact as an INPUT (one rep rejected a stale fact against a
76
+ # reproduced bug; one opened the scan file itself) — it did not recite it.
77
+ # Self-lane case: editing `scripts/test_X_lanes.sh` IS the lane — r8's discriminator missed it and emitted
78
+ # a proposal for a lane that already was the file. Fixed here (F2 lane).
79
+ _ROOT="${CLAUDE_PROJECT_DIR:-.}"; _BN=$(basename "$FP" .sh); _FACT=""; _ITEMS=""
80
+ case "$_BN" in
81
+ test_*_lanes) _FACT="$_FACT · 사실: 이 파일 자체가 레인(known-pair 픽스처)이다 — 새 known-pair 컨트롤은 «이 파일 안에» 추가하거나 생략" ;;
82
+ *) if [ -f "$_ROOT/scripts/test_${_BN}_lanes.sh" ]; then _FACT="$_FACT · 사실: 이 파일의 레인 \`scripts/test_${_BN}_lanes.sh\` 가 이미 있다(known-pair 컨트롤은 거기에 붙이거나 생략)"; else _ITEMS="$_ITEMS known-pair(고친 케이스 + 반대 케이스) 컨트롤"; fi ;;
83
+ esac
84
+ _SCAN=$(ls -t "$_ROOT"/scripts/.degrade_scan_last_*.txt 2>/dev/null | head -1)
85
+ if [ -n "$_SCAN" ] && grep -q -- "$(basename "$FP")" "$_SCAN" 2>/dev/null; then _FACT="$_FACT · 사실: 오늘 degrade_direction_scan 결과 \`$(basename "$_SCAN")\` 가 이 파일을 이미 덮었다($(grep -m1 -oE 'findings: [0-9]+' "$_SCAN" 2>/dev/null || echo 'findings: ?')) — 스캐너 통과이지 손 확인이 아니다"; else _ITEMS="$_ITEMS degrade_direction_scan.sh 로 실패가 «통과»로 접히는 방향 확인"; fi
86
+ if [ -z "$_ITEMS" ]; then MSG="🔎 방금 고치는 줄은 판정·가드·종료코드 줄이다(scripts/*.sh · templates/*.sh)${_FACT}. 둘 다 이미 있으니 새 제안은 내지 말고, 작업을 마친 뒤 그 사실을 한 줄로만 말해라(형식: «확인 | basis: <위 사실>»)."
87
+ else MSG="🔎 방금 고치는 줄은 판정·가드·종료코드 줄이다(scripts/*.sh · templates/*.sh)${_FACT}. 없는 것만 사용자에게 한 줄로 제안해라 —${_ITEMS} — 형식은 «제안: … | basis: <네가 확인한 근거 한 구절>». 가능하면 이 파일·이 케이스의 실제 이름으로. 제안이지 실행이 아니다."; fi
88
+ python3 -c 'import json,sys; m=sys.argv[1]; print(json.dumps({"systemMessage":m,"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":m}}, ensure_ascii=False))' "$MSG" 2>/dev/null || exit 0
89
+ exit 0
@@ -124,7 +124,16 @@ fi
124
124
  # content-generating lifecycle is ever added.
125
125
  # ── Resolve the exact npm-published file set (fail-closed if unresolved OR partial) ──
126
126
  FILES=$(npm pack --dry-run --json 2>/dev/null \
127
- | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null || true)
127
+ | node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null)
128
+ # 2026-09-04 (v3.0.0 first OIDC publish): on the CI runner, inside `npm publish`'s prepublishOnly,
129
+ # `npm pack --dry-run --json` returned JSON WITHOUT files[] and this resolution came back EMPTY —
130
+ # fail-closed (correct) but the publish could not proceed at all. Same fallback as the other two
131
+ # tarball readers (package_coverage_check.sh · files_manifest_shipping_check.sh): rebuild the set
132
+ # from the text listing (`npm notice <size> <path>`). Still fail-closed when THAT is empty too.
133
+ if [ -z "$FILES" ]; then
134
+ FILES=$(npm pack --dry-run 2>&1 | sed -nE 's/^npm notice +[0-9.]+[kMG]?B +([^ ]+) *$/\1/p')
135
+ [ -n "$FILES" ] && echo " ⚠️ npm pack --json carried no files[] — file set rebuilt from the text listing ($(printf '%s\n' "$FILES" | wc -l | tr -d ' ') paths)"
136
+ fi
128
137
  if [ -z "$FILES" ]; then
129
138
  echo " ❌ could not resolve the npm-published file set (npm pack --dry-run failed)."
130
139
  [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
@@ -135,7 +144,7 @@ fi
135
144
  # Wrong-set guard (challenger M6): a future npm --json shape change could yield a NON-empty but PARTIAL
136
145
  # file list (forEach iterates a renamed/nested structure without throwing) → files silently unscanned.
137
146
  # npm always ships package.json in the tarball; its absence means the parse got a wrong set → fail-closed.
138
- if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then
147
+ if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then # portability-noqa: checks npm's own packaging invariant (every npm tarball ships package.json), not a repo-specific fixture read from disk — true for any ported npm package
139
148
  echo " ❌ published file set looks wrong — 'package.json' (always shipped) is absent from the parse."
140
149
  [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
141
150
  echo " Fail-closed (possible npm --json shape change). Verify npm pack output or PUBLIC_SURFACE_OK=1."