@chrono-meta/fh-gate 1.4.71 → 1.4.73

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 (38) hide show
  1. package/.claude/rules/.public-surface-patterns.defaults +44 -0
  2. package/.claude/rules/fh_4axis_gate.md +207 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +26 -2
  5. package/CATALOG.md +59 -0
  6. package/README.ja.md +1 -1
  7. package/README.ko.md +1 -1
  8. package/README.md +1 -1
  9. package/README.zh.md +1 -1
  10. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  12. package/package.json +21 -1
  13. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  14. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  15. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  16. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  17. package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
  18. package/scripts/chamber_candidate_collect.sh +223 -0
  19. package/scripts/degrade_direction_scan.sh +222 -0
  20. package/scripts/fh-gate.sh +76 -2
  21. package/scripts/fh_session_load.sh +202 -0
  22. package/scripts/gate_pathspec_check.sh +166 -0
  23. package/scripts/prepush_guard_check.sh +374 -0
  24. package/scripts/psa_scan_lib.sh +153 -0
  25. package/scripts/public_surface_scan_files.sh +157 -0
  26. package/scripts/selfcheck.sh +16 -0
  27. package/scripts/session_close_check.sh +171 -0
  28. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  29. package/scripts/test_fh_gate_regressions.sh +46 -2
  30. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  31. package/scripts/universal_guard_check.sh +280 -0
  32. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  33. package/templates/.git-hooks/pre-commit +848 -0
  34. package/templates/.git-hooks/pre-push +585 -0
  35. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  36. package/templates/degrade_direction_scan.sh +222 -0
  37. package/templates/predelete_check.sh +72 -0
  38. package/templates/regression_guard.sh +563 -0
@@ -0,0 +1,563 @@
1
+ #!/usr/bin/env bash
2
+ # regression_guard.sh — verifies SKILL.md / .claude/rules changes preserve operational content.
3
+ #
4
+ # Usage:
5
+ # bash templates/regression_guard.sh [BASE_REF]
6
+ # bash templates/regression_guard.sh main # compare working tree vs main
7
+ # bash templates/regression_guard.sh origin/main HEAD # compare HEAD vs origin/main
8
+ # bash templates/regression_guard.sh --pr BRANCH # PR mode: auto merge-base (recommended)
9
+ # bash templates/regression_guard.sh --staged # pre-commit: staged index vs HEAD
10
+ # bash templates/regression_guard.sh --verbose --staged # include suppression reasons
11
+ #
12
+ # Exit codes: 0=PASS **또는 SKIP** / 1=S-tier warnings / 2=M-tier block / 3=usage error
13
+ #
14
+ # ⚠️ exit 0 은 두 의미를 갖는다 — 검사해서 통과(PASS)와 **검사 대상이 없었음(SKIP)**.
15
+ # 구분하려면 stdout 의 `REGRESSION_GUARD_RESULT=skip` 을 보라. 종료코드만 보는 호출자는
16
+ # 미검사를 통과로 읽는다(2026-07-22: pre-commit 이 정확히 그랬고, AGENTS.md 변경이
17
+ # 그 경로로 '✅ PASS' 를 받고 지나갔다).
18
+ # 배선 현황: pre-commit ✅ / harness-doctor · harvest-loop · hub-cc-pr-reviewer ·
19
+ # self_evolution_routine = **미배선(종료코드만 판정)** — 알려진 잔여.
20
+ #
21
+ # PR mode rationale: using 'main' as BASE_REF for a PR branch includes changes from OTHER
22
+ # merged PRs as false positives. --pr computes the fork-point (merge-base) automatically,
23
+ # so only THIS branch's own changes are evaluated.
24
+ #
25
+ # Called by:
26
+ # - harness-doctor Step 10 (Regression Guard)
27
+ # - harvest-loop Step 4 (harness-doctor invocation)
28
+ # - CLAUDE.md §3-axis auto-gate (Axis 1) — use --pr mode for PRs
29
+ # - manual pre-merge gate
30
+ #
31
+ # Self-test note: when editing this guard, verify in a disposable git repo that (1) a trigger
32
+ # heading rename, (2) a SKILL.md → SKILL_detail.md section/code/token move, and (3) a short
33
+ # deprecation tombstone do not M-block, while a real Done When deletion still exits 2.
34
+
35
+ set -u
36
+
37
+ STAGED_MODE=0
38
+ VERBOSE=0
39
+
40
+ ARGS=()
41
+ while [ "$#" -gt 0 ]; do
42
+ if [ "$1" = "--verbose" ]; then
43
+ VERBOSE=1
44
+ shift
45
+ continue
46
+ fi
47
+ ARGS[${#ARGS[@]}]="$1"
48
+ shift
49
+ done
50
+ # bash 3.2 (macOS default /bin/bash) treats "${empty_array[@]}" as unbound under `set -u` and
51
+ # aborts — a bare `bash regression_guard.sh` (no flags, ARGS stays empty) crashed here before this
52
+ # guard (cross-family self-test, 2026-07-07). Only reset the positional params when ARGS is non-empty.
53
+ [ "${#ARGS[@]}" -gt 0 ] && set -- "${ARGS[@]}"
54
+
55
+ verbose() {
56
+ [ "$VERBOSE" -eq 1 ] && echo " [verbose] $*"
57
+ }
58
+
59
+ # --pr mode: compute merge-base automatically
60
+ if [ "${1:-}" = "--pr" ]; then
61
+ if [ -z "${2:-}" ]; then
62
+ echo "Usage: regression_guard.sh --pr BRANCH" >&2
63
+ exit 3
64
+ fi
65
+ PR_BRANCH="$2"
66
+ BASE_BRANCH="${3:-main}"
67
+ BASE_REF=$(git merge-base "$BASE_BRANCH" "$PR_BRANCH" 2>/dev/null)
68
+ if [ -z "$BASE_REF" ]; then
69
+ echo "ERROR: cannot compute merge-base for $PR_BRANCH vs $BASE_BRANCH" >&2
70
+ exit 3
71
+ fi
72
+ HEAD_REF="$PR_BRANCH"
73
+ echo "PR MODE: merge-base=$(git rev-parse --short "$BASE_REF") branch=$PR_BRANCH"
74
+ elif [ "${1:-}" = "--staged" ]; then
75
+ # Pre-commit context: evaluate the staged index against HEAD. On a direct-to-main
76
+ # workflow, --pr's merge-base(main,main)=HEAD yields an empty diff, so staged changes
77
+ # — exactly what a pre-commit hook must check — are invisible. --staged compares the
78
+ # index (what is about to be committed) against HEAD instead.
79
+ STAGED_MODE=1
80
+ BASE_REF="HEAD"
81
+ HEAD_REF=""
82
+ echo "STAGED MODE: index vs HEAD"
83
+ else
84
+ BASE_REF="${1:-main}"
85
+ HEAD_REF="${2:-}" # empty = working tree
86
+ fi
87
+
88
+ # 게이트 커버 자산 = 4축 정본(.claude/rules/fh_4axis_gate.md §48)이 선언한 목록과 맞춘다.
89
+ # 2026-07-22 수리: AGENTS.md · knowledge/** · docs/*.md 가 여기 없어서, 정본이 "커버한다"고
90
+ # 선언한 자산을 Axis 1 이 **아예 보지 못했다**. 그리고 그 미검사가 호출부(pre-commit)에서
91
+ # `✅ PASS` 로 렌더됐다 = 검사 안 함이 통과로 보고되는 fail-open.
92
+ # 새 경로를 추가할 때는 반드시 fh_4axis_gate.md §48 과 대조할 것 — 두 목록이 갈리면
93
+ # 갈린 쪽이 조용히 무검사 구간이 된다.
94
+ # 2026-07-26 수리 (2차 — 이름 열거에서 디렉토리 스코프로): 처음엔 `SKILL_detail.md` 를 이름으로
95
+ # 추가했으나, Axis-2 적대 패스가 "이름을 하나씩 막는 방식은 원리적으로 이 클래스를 못 닫는다"고
96
+ # 지적했고 맞다. 그래서 scripts/gate_pathspec_check.sh 에 **열거 스윕**(plugins/*/skills/ 밑 실제
97
+ # .md 를 전부 세어 미커버를 찾는 검사)을 넣었더니 첫 실행에서 실물을 잡았다 —
98
+ # `dialogue-harvest/calibration_pair.md`(known-pair 캘리브레이션 코퍼스, 07-25 출하). 이름 목록엔
99
+ # 영영 안 올랐을 파일이다. 결론: 스킬 디렉토리의 **모든 .md** 를 덮는다. 새 동반파일 관례가
100
+ # 생겨도 자동 커버되고, 열거 스윕이 그걸 도입 시점에 확인한다.
101
+ #
102
+ # (1차 기록) SKILL_detail.md 가 여기 없어서 **양쪽 게이트 모두** 이 파일을 못 봤다.
103
+ # 원인은 리터럴이다 — 게이트는 `SKILL.md` 를 찾는데 `SKILL_detail.md` 라는 문자열엔 `SKILL.md` 가
104
+ # 들어있지 않다(밑줄이 끊는다). 실측: detail 17파일 208,710B = 스킬 명세 표면의 27.7%,
105
+ # 16/17 이 펜스 코드블록 보유. 실제 누출 2건(371c04f · e661931 — 둘 다 단일파일
106
+ # phantom-quench/SKILL_detail.md, 4축 0회). 이 구멍은 salience-splitter 가 상주층을 줄이려
107
+ # SKILL.md → SKILL_detail.md 로 컨텐츠를 옮길 때마다 **넓어졌다** — 다이어트가 진행될수록
108
+ # 커버리지가 줄어드는 구조였다(gate-locality).
109
+ GUARD_PATHSPEC=(
110
+ 'plugins/*/skills/*/*.md'
111
+ '.claude/rules/*.md'
112
+ 'knowledge/shared/rules/*.md'
113
+ 'knowledge/*.md'
114
+ 'knowledge/*/*.md'
115
+ 'knowledge/*/*/*.md'
116
+ 'CLAUDE.md'
117
+ 'AGENTS.md'
118
+ 'docs/*.md'
119
+ 'templates/*.md'
120
+ )
121
+
122
+ # 계기 무결: base ref 가 안 풀리면 diff 실패가 2>/dev/null 로 삼켜져 CHANGED 공백 →
123
+ # `result=skip` 으로 세탁된다(challenger C-2 실측: no-such-ref → 확신형 skip + exit 0).
124
+ # shallow clone / detached CI 에서 실제로 나는 경로 — 계기 에러는 skip 이 아니라 error 다.
125
+ if [ "$STAGED_MODE" -ne 1 ]; then
126
+ if ! git rev-parse --verify --quiet "$BASE_REF" >/dev/null 2>&1; then
127
+ echo "REGRESSION_GUARD: base ref '$BASE_REF' does not resolve — instrument error, NOT a skip" >&2
128
+ if [ -n "${REGRESSION_GUARD_RESULT_FILE:-}" ]; then
129
+ printf 'result=error\nm_tier=0\ns_tier=0\nfiles_checked=0\n' > "$REGRESSION_GUARD_RESULT_FILE"
130
+ fi
131
+ exit 3
132
+ fi
133
+ fi
134
+
135
+ # Discover changed files
136
+ if [ "$STAGED_MODE" -eq 1 ]; then
137
+ CHANGED=$(git diff --cached --name-only -- "${GUARD_PATHSPEC[@]}" 2>/dev/null)
138
+ elif [ -z "$HEAD_REF" ]; then
139
+ CHANGED=$(git diff --name-only "$BASE_REF" -- "${GUARD_PATHSPEC[@]}" 2>/dev/null)
140
+ else
141
+ CHANGED=$(git diff --name-only "$BASE_REF" "$HEAD_REF" -- "${GUARD_PATHSPEC[@]}" 2>/dev/null)
142
+ fi
143
+
144
+ if [ -z "$CHANGED" ]; then
145
+ # ★ SKIP 은 PASS 가 아니다. 이 스크립트는 exit 0 을 유지하지만(가역 표면 · 호출부 호환),
146
+ # **문구로 통과와 구분**한다 — 과거 호출부가 이 줄을 받고 `✅ PASS` 를 찍어
147
+ # "검사 안 함"이 "통과"로 보고됐다(2026-07-22 수리).
148
+ echo "REGRESSION_GUARD: SKIP (not-checked, NOT a pass) — no file matched the gate pathspec"
149
+ echo "REGRESSION_GUARD_RESULT=skip"
150
+ if [ -n "${REGRESSION_GUARD_RESULT_FILE:-}" ]; then
151
+ printf 'result=skip
152
+ m_tier=0
153
+ s_tier=0
154
+ files_checked=0
155
+ ' > "$REGRESSION_GUARD_RESULT_FILE"
156
+ fi
157
+ exit 0
158
+ fi
159
+
160
+ M_TIER=0
161
+ S_TIER=0
162
+ echo "REGRESSION_GUARD vs $BASE_REF${HEAD_REF:+ ($HEAD_REF)}"
163
+ echo "Files changed: $(echo "$CHANGED" | wc -l | tr -d ' ')"
164
+ echo "----"
165
+
166
+ read_before() { git show "$BASE_REF:$1" 2>/dev/null; }
167
+ read_after() {
168
+ if [ "$STAGED_MODE" -eq 1 ]; then git show ":$1" 2>/dev/null # staged blob from the index
169
+ elif [ -z "$HEAD_REF" ]; then cat "$1" 2>/dev/null
170
+ else git show "$HEAD_REF:$1" 2>/dev/null; fi
171
+ }
172
+ # Clean integer count — grep -c outputs "0" + exit 1 when no match, which collides with `|| echo 0`
173
+ count_in() {
174
+ local n
175
+ n=$(echo "$1" | grep -c "$2" 2>/dev/null) || true
176
+ echo "${n:-0}"
177
+ }
178
+ count_regex_in() {
179
+ local n
180
+ n=$(printf '%s\n' "$1" | grep -cE "$2" 2>/dev/null) || true
181
+ echo "${n:-0}"
182
+ }
183
+ count_exact_line_in() {
184
+ printf '%s\n' "$1" | awk -v needle="$2" '$0 == needle { n++ } END { print n + 0 }'
185
+ }
186
+ # Extract a resolvable path token near a tombstone phrase and verify it exists on disk (repo root
187
+ # or the tombstone file's own directory). Closes a demonstrated bypass: a bare phrase like "merged
188
+ # into nothing" with no real target previously exempted F2/F3/F4/F6 on any <=80-line file, including
189
+ # a still-live asset with its Done When section gutted (cross-family audit 2026-07-07: agy static
190
+ # trace + Sonnet-pinned self-test both reproduced it independently). Requires an actual citation —
191
+ # backtick path, markdown-link target, or bare path with a known extension — not just the phrase.
192
+ resolve_tombstone_target() {
193
+ local body="$1" src_file="$2" tok=""
194
+ tok=$(printf '%s\n' "$body" | grep -oE '`[^`]+`' | head -1 | tr -d '`')
195
+ if [ -z "$tok" ]; then
196
+ tok=$(printf '%s\n' "$body" | grep -oE '\]\([^)]+\)' | head -1 | sed -E 's/^\]\(//; s/\)$//')
197
+ fi
198
+ if [ -z "$tok" ]; then
199
+ tok=$(printf '%s\n' "$body" | grep -oE '[A-Za-z0-9_./-]+\.(md|sh|py|ts|js|json)' | head -1)
200
+ fi
201
+ [ -z "$tok" ] && return 1
202
+ if [ -e "$tok" ] || [ -e "$(dirname "$src_file")/$tok" ]; then
203
+ printf '%s\n' "$tok"
204
+ return 0
205
+ fi
206
+ return 1
207
+ }
208
+
209
+ for f in $CHANGED; do
210
+ # Skip if file deleted (deletion is intentional, not regression)
211
+ read_after "$f" > /dev/null 2>&1 || continue
212
+ [ -z "$(read_after "$f")" ] && continue
213
+
214
+ echo
215
+ echo "=== $f ==="
216
+
217
+ # F1. Frontmatter integrity — SKILL.md ONLY, deliberately.
218
+ # `name:`/`description:` are the skill's ROUTING surface; a detail file is referenced, never
219
+ # routed, so the contract does not apply to it. Before 2026-07-26 this exclusion was ACCIDENTAL
220
+ # (detail files simply were not in the pathspec, and this regex never matched them); it is now
221
+ # explicit. Observation, NOT gated: 16 of 17 detail files carry frontmatter anyway as convention —
222
+ # the lone exception is phantom-quench/SKILL_detail.md. Gating that convention would M-TIER a
223
+ # working file for a contract it does not owe, so it stays an observation.
224
+ if echo "$f" | grep -qE "(^|/)SKILL\.md$"; then
225
+ fm_check=$(read_after "$f" | python3 -c "
226
+ import sys
227
+ c = sys.stdin.read()
228
+ if not c.startswith('---'):
229
+ print('FAIL: no frontmatter')
230
+ sys.exit(1)
231
+ parts = c.split('---', 2)
232
+ if len(parts) < 3:
233
+ print('FAIL: unclosed frontmatter')
234
+ sys.exit(1)
235
+ fm = parts[1]
236
+ for req in ('name:', 'description:'):
237
+ if req not in fm:
238
+ print(f'FAIL: missing {req}')
239
+ sys.exit(1)
240
+ print('OK')
241
+ " 2>&1)
242
+ if echo "$fm_check" | grep -q FAIL; then
243
+ echo " ❌ M-TIER frontmatter: $fm_check"
244
+ M_TIER=$((M_TIER + 1))
245
+ else
246
+ echo " ✅ frontmatter intact"
247
+ fi
248
+ fi
249
+
250
+ before_content=$(read_before "$f")
251
+ after_content=$(read_after "$f")
252
+ # Sibling lookup — content that MOVED between the pair is not content LOST.
253
+ # Must be SYMMETRIC (2026-07-26): before this file was gated, only SKILL.md was ever the checked
254
+ # file, so a one-way lookup (SKILL.md → its detail) sufficed. Now that SKILL_detail.md is gated
255
+ # too, the reverse consolidation (detail → SKILL.md, e.g. un-splitting a skill) would otherwise
256
+ # read as content loss in the detail file and fire a false positive on exactly the refactor
257
+ # salience-splitter is designed to reverse.
258
+ detail_content=""
259
+ case "$f" in
260
+ */SKILL.md) detail_content=$(read_after "$(dirname "$f")/SKILL_detail.md") ;;
261
+ */SKILL_detail.md) detail_content=$(read_after "$(dirname "$f")/SKILL.md") ;;
262
+ esac
263
+
264
+ # Deprecation/tombstone exemption (stub-shaped): a file soft-deleted into a small pointer
265
+ # stub. Content loss is the INTENT (mirrors the file-deletion skip above), so content-
266
+ # preservation checks (F2 sections, F3 code blocks, F4 tokens, F6 line reduction) are skipped.
267
+ # Structural-integrity checks a stub must STILL satisfy keep running: F1 frontmatter, F5 ref
268
+ # resolution, F7 bash syntax.
269
+ #
270
+ # Guarded so it cannot be abused to gut a LIVE asset (Axis-2 challenger 2026-06-16):
271
+ # (a) frontmatter must start at line 1 AND be CLOSED — kills the `---` horizontal-rule
272
+ # collision in non-SKILL files (CLAUDE.md / rules have HRs but no real frontmatter, so
273
+ # a body line `deprecated: true` could otherwise self-exempt them);
274
+ # (b) `deprecated: true` in canonical YAML (one+ space) inside that block;
275
+ # (c) a non-empty `successor:` pointer (enforces what this comment promises — no dead-end stub);
276
+ # (d) the result is actually stub-sized (<= 50 lines) — a "deprecated" 200-line file is not a
277
+ # soft-delete and runs the full checks.
278
+ # Tombstone-body mode additionally accepts <= 80-line files whose body says DEPRECATED,
279
+ # renamed to, or merged into — BUT ONLY when the body also cites a target path that actually
280
+ # resolves on disk (resolve_tombstone_target). A bare phrase with no real target does NOT
281
+ # exempt: this was a demonstrated bypass (a still-live asset could drop its Done When section
282
+ # and dodge M-tier detection just by adding "renamed to X" for a nonexistent X) caught by
283
+ # cross-family review before merge (2026-07-07) — fixed by requiring the same auditable
284
+ # short-pointer discipline the frontmatter path already enforces via `successor:`.
285
+ # (We deliberately do NOT also require deprecated-in-BOTH-before+after: that would block the
286
+ # common one-commit "deprecate + stub" flow with no safety gain once (a)-(d) hold — the residual
287
+ # is a loud, reviewable, git-recoverable deprecation declaration, not silent content loss.)
288
+ is_deprecated=0
289
+ after_line_count=$(printf '%s\n' "$after_content" | wc -l | tr -d ' ')
290
+ if [ "$(printf '%s\n' "$after_content" | head -1)" = "---" ]; then
291
+ fm=$(printf '%s\n' "$after_content" | awk 'NR==1{next} /^---$/{exit} {print}')
292
+ has_close=$(printf '%s\n' "$after_content" | awk 'NR==1{next} /^---$/{print "yes"; exit}')
293
+ if [ "$has_close" = "yes" ] \
294
+ && printf '%s\n' "$fm" | grep -qE '^deprecated:[[:space:]]+true[[:space:]]*$' \
295
+ && printf '%s\n' "$fm" | grep -qE '^successor:[[:space:]]+[^[:space:]]' \
296
+ && [ "$after_line_count" -le 50 ]; then
297
+ is_deprecated=1
298
+ echo " ℹ️ deprecated stub — content-loss checks (F2/F3/F4/F6) exempted (F1/F5/F7 still enforced)"
299
+ verbose "suppressed content-loss checks: frontmatter tombstone has deprecated:true, successor, and $after_line_count lines"
300
+ fi
301
+ fi
302
+ if [ "$is_deprecated" -eq 0 ] \
303
+ && [ "$after_line_count" -le 80 ] \
304
+ && printf '%s\n' "$after_content" | grep -qiE 'DEPRECATED|renamed to|merged into'; then
305
+ tombstone_target=$(resolve_tombstone_target "$after_content" "$f") || tombstone_target=""
306
+ if [ -n "$tombstone_target" ]; then
307
+ is_deprecated=1
308
+ echo " ℹ️ deprecation tombstone — content-loss checks (F2/F3/F4/F6) exempted (F1/F5/F7 still enforced); target: $tombstone_target"
309
+ verbose "suppressed content-loss checks: tombstone body contains DEPRECATED/renamed to/merged into, resolvable target '$tombstone_target', and $after_line_count lines"
310
+ else
311
+ echo " ⚠️ tombstone phrase found but no resolvable target path — content-loss checks NOT exempted (fail-closed; cite an existing path, e.g. \`plugins/x/y/SKILL.md\`, so this stub can be trusted)"
312
+ fi
313
+ fi
314
+
315
+ check_section_group() {
316
+ local label="$1"
317
+ local pattern="$2"
318
+ local before after detail combined
319
+ before=$(count_regex_in "$before_content" "$pattern")
320
+ after=$(count_regex_in "$after_content" "$pattern")
321
+ detail=0
322
+ [ -n "$detail_content" ] && detail=$(count_regex_in "$detail_content" "$pattern")
323
+ combined=$((after + detail))
324
+ if [ "$before" -gt 0 ] && [ "$combined" -lt "$before" ]; then
325
+ echo " ❌ M-TIER '$label' section group dropped ($before → $combined)"
326
+ M_TIER=$((M_TIER + 1))
327
+ elif [ "$before" -gt 0 ] && [ "$after" -lt "$before" ]; then
328
+ if [ "$detail" -gt 0 ]; then
329
+ echo " ℹ️ '$label' section moved to SKILL_detail.md ($before → $after + $detail in detail)"
330
+ verbose "suppressed section loss: sibling SKILL_detail.md preserves '$label' section header count"
331
+ else
332
+ echo " ℹ️ '$label' section heading renamed within known synonym group ($before → $after)"
333
+ verbose "suppressed section loss: known synonym heading preserves '$label' section semantics"
334
+ fi
335
+ fi
336
+ }
337
+
338
+ explain_section_rename() {
339
+ local group_label="$1"
340
+ local group_pattern="$2"
341
+ shift 2
342
+ local group_before group_after group_detail group_combined name before after detail exact_pattern
343
+ group_before=$(count_regex_in "$before_content" "$group_pattern")
344
+ group_after=$(count_regex_in "$after_content" "$group_pattern")
345
+ group_detail=0
346
+ [ -n "$detail_content" ] && group_detail=$(count_regex_in "$detail_content" "$group_pattern")
347
+ group_combined=$((group_after + group_detail))
348
+ [ "$group_before" -gt 0 ] && [ "$group_combined" -ge "$group_before" ] || return
349
+ for name in "$@"; do
350
+ exact_pattern="^##[[:space:]]+$name([[:space:]].*)?$"
351
+ before=$(count_regex_in "$before_content" "$exact_pattern")
352
+ after=$(count_regex_in "$after_content" "$exact_pattern")
353
+ detail=0
354
+ [ -n "$detail_content" ] && detail=$(count_regex_in "$detail_content" "$exact_pattern")
355
+ if [ "$before" -gt 0 ] && [ $((after + detail)) -lt "$before" ]; then
356
+ verbose "suppressed dropped heading '$name': '$group_label' known-synonym group is preserved ($group_before → $group_combined)"
357
+ fi
358
+ done
359
+ }
360
+
361
+ # F2. Critical section preservation
362
+ if [ "$is_deprecated" -eq 0 ]; then
363
+ execution_pattern='^##[[:space:]]+(Execution Steps|Steps)([[:space:]].*)?$'
364
+ done_when_pattern='^##[[:space:]]+(Done When|Completion Criteria)([[:space:]].*)?$'
365
+ triggers_pattern='^##[[:space:]]+(Triggers|Trigger Phrases|Activation Triggers|Invocation Triggers|Natural Language Triggers)([[:space:]].*)?$'
366
+
367
+ check_section_group "Execution Steps" "$execution_pattern"
368
+ check_section_group "Done When" "$done_when_pattern"
369
+ check_section_group "Triggers" "$triggers_pattern"
370
+
371
+ explain_section_rename "Execution Steps" "$execution_pattern" "Execution Steps" "Steps"
372
+ explain_section_rename "Done When" "$done_when_pattern" "Done When" "Completion Criteria"
373
+ explain_section_rename "Triggers" "$triggers_pattern" "Triggers" "Trigger Phrases" "Activation Triggers" "Invocation Triggers" "Natural Language Triggers"
374
+
375
+ if [ -n "$detail_content" ]; then
376
+ printf '%s\n' "$before_content" | grep -E '^##[[:space:]]+' | sort -u | while IFS= read -r header; do
377
+ [ -n "$header" ] || continue
378
+ before=$(count_exact_line_in "$before_content" "$header")
379
+ after=$(count_exact_line_in "$after_content" "$header")
380
+ detail=$(count_exact_line_in "$detail_content" "$header")
381
+ if [ "$before" -gt 0 ] && [ "$after" -lt "$before" ] && [ $((after + detail)) -ge "$before" ]; then
382
+ verbose "suppressed section-header reduction: '$header' moved to sibling SKILL_detail.md"
383
+ fi
384
+ done
385
+ fi
386
+ fi
387
+
388
+ # F3. Code block count
389
+ if [ "$is_deprecated" -eq 0 ]; then
390
+ before_code=$(count_in "$before_content" '^```')
391
+ after_code=$(count_in "$after_content" '^```')
392
+ detail_code=0
393
+ [ -n "$detail_content" ] && detail_code=$(count_in "$detail_content" '^```')
394
+ if [ "$before_code" -gt 0 ]; then
395
+ combined_code=$((after_code + detail_code))
396
+ delta=$((before_code - combined_code))
397
+ if [ "$after_code" -lt "$before_code" ] && [ "$combined_code" -ge "$before_code" ]; then
398
+ echo " ℹ️ code blocks moved to SKILL_detail.md ($before_code → $after_code + $detail_code in detail)"
399
+ verbose "suppressed code-block reduction: sibling SKILL_detail.md preserves fenced-block count"
400
+ fi
401
+ if [ "$delta" -gt 4 ]; then
402
+ echo " ⚠️ S-TIER code blocks reduced ($before_code → $combined_code, -$delta)"
403
+ S_TIER=$((S_TIER + 1))
404
+ fi
405
+ fi
406
+ fi
407
+
408
+ # F4. Operational keyword preservation
409
+ # Split-awareness: a skill-splitter split moves content to the sibling
410
+ # SKILL_detail.md — a token still present in SKILL.md + SKILL_detail.md combined
411
+ # is a MOVE, not a loss. Only the combined shortfall is a regression signal.
412
+ # NOTE: combined-count is a PRESENCE heuristic, not semantic equivalence — an
413
+ # unrelated detail-file line can absorb the count. True equivalence is owned by
414
+ # F2 (critical sections) + the Axis 2/3 review, not this counter.
415
+ if [ "$is_deprecated" -eq 0 ]; then
416
+ for token in "M-tier" "S-tier" "R-tier" "PASS" "BLOCK" "Wave 0" "Wave 1" "Wave 4" "Step 0" "Step 1" "Step 2" "Step 3" "Step 4" "fan-in" "Done When"; do
417
+ before=$(count_in "$before_content" "$token")
418
+ after=$(count_in "$after_content" "$token")
419
+ if [ "$before" -gt 0 ] && [ "$after" -lt "$before" ]; then
420
+ if [ -n "$detail_content" ]; then
421
+ in_detail=$(count_in "$detail_content" "$token")
422
+ if [ $((after + in_detail)) -ge "$before" ]; then
423
+ echo " ℹ️ token '$token' moved to SKILL_detail.md ($before → $after + $in_detail in detail)"
424
+ verbose "suppressed token reduction: sibling SKILL_detail.md preserves '$token' count"
425
+ continue
426
+ fi
427
+ after=$((after + in_detail)) # genuine combined shortfall → evaluate on combined count
428
+ fi
429
+ diff=$((before - after))
430
+ ratio=$((diff * 100 / before))
431
+ if [ "$ratio" -ge 50 ]; then
432
+ echo " ❌ M-TIER token '$token' dropped ${ratio}% ($before → $after)"
433
+ M_TIER=$((M_TIER + 1))
434
+ elif [ "$ratio" -ge 20 ]; then
435
+ echo " ⚠️ S-TIER token '$token' dropped ${ratio}% ($before → $after)"
436
+ S_TIER=$((S_TIER + 1))
437
+ fi
438
+ fi
439
+ done
440
+ fi
441
+
442
+ # F5. Cross-reference integrity (broken file paths)
443
+ # Use process substitution to avoid subshell — M_TIER must update in parent shell
444
+ # Skip placeholder patterns ending in `...` or `/...`
445
+ while read -r ref; do
446
+ [ -z "$ref" ] && continue
447
+ echo "$ref" | grep -qE '/\.\.\.|^`\{FH_ROOT\}/\.\.\.' && continue
448
+ path=$(echo "$ref" | sed "s|{FH_ROOT}|.|g" | tr -d '`')
449
+ # Skip paths that still contain {placeholder} tokens after substitution (template files)
450
+ echo "$path" | grep -qE '\{[^}]+\}' && continue
451
+ if [ ! -e "$path" ]; then
452
+ echo " ❌ M-TIER broken ref: $ref"
453
+ M_TIER=$((M_TIER + 1))
454
+ fi
455
+ done < <(echo "$after_content" | grep -oE '`\{FH_ROOT\}/[^`]+`' | sort -u)
456
+
457
+ # F7. Bash block syntax regression — per-block bash -n
458
+ # bash -n stops at first error per file; split each ```bash block into its own file
459
+ # so multiple errors are countable. Catches: new error added to a previously-clean block,
460
+ # or a new bad block introduced.
461
+ count_bad_blocks() {
462
+ local content="$1"
463
+ local tmpdir; tmpdir=$(mktemp -d)
464
+ echo "$content" | awk -v d="$tmpdir" '
465
+ /^```bash$/ { in_b=1; n++; out=d"/blk_"n".sh"; next }
466
+ /^```$/ && in_b { in_b=0; next }
467
+ in_b { print > out }
468
+ '
469
+ local bad=0
470
+ for blk in "$tmpdir"/blk_*.sh; do
471
+ [ -e "$blk" ] && [ -s "$blk" ] || continue
472
+ bash -n "$blk" 2>/dev/null || bad=$((bad + 1))
473
+ done
474
+ rm -rf "$tmpdir"
475
+ echo "$bad"
476
+ }
477
+ before_bash_err=$(count_bad_blocks "$before_content")
478
+ after_bash_err=$(count_bad_blocks "$after_content")
479
+ if [ "$after_bash_err" -gt "$before_bash_err" ]; then
480
+ diff=$((after_bash_err - before_bash_err))
481
+ echo " ❌ M-TIER bash blocks with syntax errors increased ($before_bash_err → $after_bash_err, +$diff)"
482
+ M_TIER=$((M_TIER + 1))
483
+ elif [ "$before_bash_err" -gt 0 ] && [ "$after_bash_err" = "$before_bash_err" ]; then
484
+ echo " ℹ️ pre-existing bash syntax errors: $before_bash_err block(s) (no change — separate fix)"
485
+ fi
486
+
487
+ # F6. Line reduction percentage
488
+ before_lines=$(read_before "$f" | wc -l | tr -d ' ')
489
+ after_lines=$(read_after "$f" | wc -l | tr -d ' ')
490
+ if [ "$before_lines" -gt 0 ]; then
491
+ if [ "$after_lines" -lt "$before_lines" ]; then
492
+ delta=$((before_lines - after_lines))
493
+ pct=$((delta * 100 / before_lines))
494
+ if [ "$pct" -ge 30 ] && [ "$is_deprecated" -eq 0 ]; then
495
+ echo " ⚠️ S-TIER reduced ${pct}% ($before_lines → $after_lines lines, -$delta)"
496
+ S_TIER=$((S_TIER + 1))
497
+ else
498
+ echo " ✅ -${delta} lines (-${pct}%, safe)"
499
+ fi
500
+ else
501
+ echo " ✅ ${after_lines} lines (+$((after_lines - before_lines)))"
502
+ fi
503
+ fi
504
+ done
505
+
506
+ echo
507
+ echo "===================="
508
+ echo "VERDICT"
509
+ echo "===================="
510
+ # ── carve-out 경로 강등 (2026-07-22, challenger HIGH-1) ──────────────────────
511
+ # knowledge/ · docs/ · AGENTS.md 는 **커버되어야 하지만 산문이 본체**다. 이 경로에
512
+ # 내용-손실 검사(토큰 카운트·섹션 그룹)를 그대로 걸면 동의어 교체 한 번에 M-tier 가
513
+ # 뜬다(실측: 산문 한 단어 교체 → 토큰 2→1, 50% 드롭 → 하드 블록).
514
+ # 과차단은 이론 비용이 아니다 — `--no-verify` 를 근육에 새기고, 그러면 **같은 훅의
515
+ # Destructive-Op 게이트까지 함께 무장해제**된다. 그래서 이 경로는 차단이 아니라 경고다.
516
+ # 호출부(pre-commit)의 CARVEOUT 분류기와 **같은 방향**이되, 여기서 substantive 판정을
517
+ # 재구현하지는 않는다 — 판정 로직을 두 벌 두면 관대함이 갈리고, 그게 이번 주에
518
+ # qasp 에서 고친 바로 그 결함 클래스다(divergent-leniency).
519
+ if [ "$M_TIER" -gt 0 ] && [ -n "$CHANGED" ]; then
520
+ NON_CARVEOUT=$(printf '%s\n' "$CHANGED" \
521
+ | grep -vE '(^knowledge/.*\.md$|^docs/.*\.md$|(^|/)AGENTS\.md$)' \
522
+ | grep -vE '^\s*$' || true)
523
+ if [ -z "$NON_CARVEOUT" ]; then
524
+ echo " ⚠️ carve-out 경로만 변경 — M-tier ${M_TIER}건을 S-tier 로 강등한다"
525
+ echo " (산문 자산에 내용-손실 검사를 하드 블록으로 걸면 과차단 → --no-verify 학습)"
526
+ S_TIER=$((S_TIER + M_TIER))
527
+ M_TIER=0
528
+ fi
529
+ fi
530
+
531
+ echo "M-tier blockers: $M_TIER"
532
+ echo "S-tier warnings: $S_TIER"
533
+
534
+ # ── typed verdict 채널 (2026-07-23, #165 잔여 폐쇄) ──────────────────────────
535
+ # 종료코드는 다의적이고(0=pass|skip) stdout grep 은 prose-grep 채널이라 취약하다
536
+ # ([[feedback_typed_verdict_channel]]). REGRESSION_GUARD_RESULT_FILE 이 설정돼 있으면
537
+ # 기계 판독용 typed verdict 를 그 파일에 쓴다 — 소비자는 stdout 을 파싱할 필요가 없다.
538
+ # stdout 의 REGRESSION_GUARD_RESULT= 줄은 파일 채널 없는 소비자용 폴백(전 결과에 방출).
539
+ _emit_result() { # $1=verdict
540
+ echo "REGRESSION_GUARD_RESULT=$1"
541
+ if [ -n "${REGRESSION_GUARD_RESULT_FILE:-}" ]; then
542
+ printf 'result=%s
543
+ m_tier=%s
544
+ s_tier=%s
545
+ files_checked=%s
546
+ ' "$1" "$M_TIER" "$S_TIER" "$(printf '%s
547
+ ' "$CHANGED" | grep -c . || true)" > "$REGRESSION_GUARD_RESULT_FILE"
548
+ fi
549
+ }
550
+
551
+ if [ "$M_TIER" -gt 0 ]; then
552
+ echo "❌ BLOCK — fix M-tier issues before merge"
553
+ _emit_result block
554
+ exit 2
555
+ elif [ "$S_TIER" -gt 0 ]; then
556
+ echo "⚠️ REVIEW — S-tier warnings present (merge allowed but verify intent)"
557
+ _emit_result review
558
+ exit 1
559
+ else
560
+ echo "✅ PASS — safe to merge"
561
+ _emit_result pass
562
+ exit 0
563
+ fi