@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.
- package/.claude/rules/.public-surface-patterns.defaults +44 -0
- package/.claude/rules/fh_4axis_gate.md +207 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/AGENTS.md +26 -2
- package/CATALOG.md +59 -0
- package/README.ja.md +1 -1
- package/README.ko.md +1 -1
- package/README.md +1 -1
- package/README.zh.md +1 -1
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
- package/package.json +21 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
- package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
- package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
- package/plugins/fh-meta/skills/salience-splitter/SKILL.md +1 -1
- package/scripts/chamber_candidate_collect.sh +223 -0
- package/scripts/degrade_direction_scan.sh +222 -0
- package/scripts/fh-gate.sh +76 -2
- package/scripts/fh_session_load.sh +202 -0
- package/scripts/gate_pathspec_check.sh +166 -0
- package/scripts/prepush_guard_check.sh +374 -0
- package/scripts/psa_scan_lib.sh +153 -0
- package/scripts/public_surface_scan_files.sh +157 -0
- package/scripts/selfcheck.sh +16 -0
- package/scripts/session_close_check.sh +171 -0
- package/scripts/test_degrade_scan_shell_probes.sh +185 -0
- package/scripts/test_fh_gate_regressions.sh +46 -2
- package/scripts/test_prepush_stdin_integrity.sh +119 -0
- package/scripts/universal_guard_check.sh +280 -0
- package/templates/.claude/rules/mcp_tool_gating.md +157 -0
- package/templates/.git-hooks/pre-commit +848 -0
- package/templates/.git-hooks/pre-push +585 -0
- package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
- package/templates/degrade_direction_scan.sh +222 -0
- package/templates/predelete_check.sh +72 -0
- 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
|