@chrono-meta/fh-gate 1.4.72 → 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 (31) 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 +31 -0
  6. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +10 -0
  7. package/knowledge/shared/learnings/subagent_invocations_log.yaml +554 -0
  8. package/package.json +21 -1
  9. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  10. package/plugins/fh-meta/.claude-plugin/plugin.json +2 -2
  11. package/plugins/fh-meta/skills/context-doctor/SKILL.md +42 -4
  12. package/plugins/fh-meta/skills/context-doctor/SKILL_detail.md +38 -0
  13. package/scripts/chamber_candidate_collect.sh +223 -0
  14. package/scripts/degrade_direction_scan.sh +222 -0
  15. package/scripts/fh_session_load.sh +202 -0
  16. package/scripts/gate_pathspec_check.sh +166 -0
  17. package/scripts/prepush_guard_check.sh +374 -0
  18. package/scripts/psa_scan_lib.sh +153 -0
  19. package/scripts/public_surface_scan_files.sh +157 -0
  20. package/scripts/selfcheck.sh +16 -0
  21. package/scripts/session_close_check.sh +171 -0
  22. package/scripts/test_degrade_scan_shell_probes.sh +185 -0
  23. package/scripts/test_prepush_stdin_integrity.sh +119 -0
  24. package/scripts/universal_guard_check.sh +280 -0
  25. package/templates/.claude/rules/mcp_tool_gating.md +157 -0
  26. package/templates/.git-hooks/pre-commit +848 -0
  27. package/templates/.git-hooks/pre-push +585 -0
  28. package/templates/PRE-PUBLISH-CHECKLIST.md +85 -0
  29. package/templates/degrade_direction_scan.sh +222 -0
  30. package/templates/predelete_check.sh +72 -0
  31. package/templates/regression_guard.sh +563 -0
@@ -0,0 +1,202 @@
1
+ #!/usr/bin/env bash
2
+ # fh_session_load.sh — Mode D SessionStart companion-store freshness load (mechanical).
3
+ #
4
+ # WHY: the session-start companion-store load (refresh the private companion store + read its
5
+ # INDEX + card-vs-commit freshness) is documented in CLAUDE.local.md / modes_and_value.md
6
+ # §Session-start freshness as PROSE. Prose is salience-dependent: when the operator opens a
7
+ # session with an immediate task, the load silently does not fire and the agent operates on
8
+ # stale local memory. (Measured miss 2026-07-05: stale sidecar-tool version + a missed standing
9
+ # instruction, both because the companion refresh was skipped on task-first entry.) A
10
+ # SessionStart hook fires BEFORE the first user turn regardless of what the user types — so it
11
+ # closes the salience gap mechanically. This is the deferred hook in operational_adaptation.md
12
+ # §Guards whose measured revisit-trigger has now fired.
13
+ #
14
+ # WHAT: refresh the companion store, then emit a SHORT, IMPERATIVE freshness delta to stdout.
15
+ # A SessionStart hook's stdout is injected into the session context, so this block becomes
16
+ # unavoidable context the agent sees at turn 0.
17
+ #
18
+ # Graceful: if no companion store is configured (non-Mode-D user / ephemeral clone), emit
19
+ # nothing and exit 0 — this hook is a silent no-op outside Mode D. Offline-safe: refresh
20
+ # failure never blocks the session.
21
+ #
22
+ # Config (operator-local, opt-in): register in .claude/settings.local.json SessionStart and pass
23
+ # the companion-store path via the BE_DIR env in that gitignored registration (the public script
24
+ # hard-codes no private path). HUB_DIR overrides the hub path. Never commit the registration to
25
+ # the public settings.json — the hook is Mode-D-only.
26
+
27
+ set -uo pipefail
28
+
29
+ FH="${HUB_DIR:-${CLAUDE_PROJECT_DIR:-$HOME/projects/forge-harness}}"
30
+
31
+ # ── frontier-digest: 부재를 0으로 읽지 않는다 ────────────────────────────────────
32
+ # 왜: digest 는 launchd 로 매일 09:00 에 돌지만 **31회 중 6회(19%) 산출물 없이 끝났다**
33
+ # (2026-07-21 실측: exit 1 / 재시도 후에도 없음 / Attempt 에서 hang — 세 형태).
34
+ # 그런데 세션 시작은 "있으면 읽는다"만 했다 → **실패가 '오늘은 뉴스 없음'으로 읽혔다.**
35
+ # 부재와 실패는 0이 아니다. 로그가 있는데 산출물이 없으면 그건 부재가 아니라 FAILED 다.
36
+ # Portable mtime (epoch). GNU-first: on GNU/coreutils `stat -f %m` exits 0 with filesystem-format
37
+ # output (never reaching a BSD fallback), so probe `stat -c %Y` FIRST — on BSD/macOS it errors and
38
+ # falls through to `-f %m`. Always echoes a numeric value (0 on total failure) so `-gt` never breaks.
39
+ # (codex cross-family review 2026-07-05 [MED]: BSD-first order silently mis-parsed on GNU.)
40
+ _mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null || echo 0; }
41
+
42
+ _FD_LOG="$FH/tracks/_meta/logs/frontier_digest_$(date +%Y_%m_%d).log"
43
+ _FD_LOCK="$FH/tracks/_meta/logs/.frontier_digest.lock"
44
+ # 스케줄 전 부재 ≠ 실패: 잡은 launchd 로 09:00 에 돈다(com.forge-harness.frontier-digest.plist).
45
+ # 2026-07-23 08:07 세션이 "잡이 안 돌았을 수 있다" 경고를 받았고 잡은 09:00 에 정상 완주 —
46
+ # 경고가 나중에 읽히면 오보처럼 보인다. 그래서 ① 스케줄 전엔 경고하지 않고 ② 모든 분기에
47
+ # 발화 시각을 스탬프하고 ③ 러너 생존 신호(로그 갱신 or 락)가 있는 동안은 실패 판정을 보류한다.
48
+ _FD_SCHED=900 # 09:00, HHMM 를 10진 정수로
49
+ _FD_NOW="${FD_NOW_HHMM:-$(date +%H%M)}" # FD_NOW_HHMM = known-pair 캘리브레이션 전용(숫자 4자리만)
50
+ _FD_STAMP="$(date +%H:%M) 기준"
51
+ # 존재 판정은 러너 digest_ready 와 동일 술어(glob + -size +1k). 정확명 [ -f ] 는 러너와 관대함이
52
+ # 갈린다 — partial 파일(>0 <1k)이 성공으로 오독되고, suffix 착지가 영구 오경보가 된다
53
+ # (divergent-leniency: 같은 상태를 두 술어가 다르게 읽으면 한쪽 결과가 무음으로 샌다).
54
+ _fd_ready() { find "$FH/tracks/_meta" -maxdepth 1 -name "frontier_digest_$(date +%Y_%m_%d)*.md" -size +1k 2>/dev/null | grep -q .; }
55
+ if _fd_ready; then
56
+ :
57
+ elif [ "$((10#$_FD_NOW))" -lt "$_FD_SCHED" ]; then
58
+ echo "ℹ️ [frontier-digest] 오늘 digest 는 09:00 예정 — 아직 전이다($_FD_STAMP). 부재는 정상."
59
+ elif [ -f "$_FD_LOG" ]; then
60
+ _FD_LOG_AGE=$(( $(date +%s) - $(_mtime "$_FD_LOG") ))
61
+ # 러너 생존 신호 2종 — 어느 쪽이든 있으면 FAILED 대신 보류:
62
+ # ① 로그 최근 갱신(임계 2100s > 러너 최장 침묵창 = watchdog 1800s)
63
+ # ② 락 존재이면서 비-stale — stale 임계는 러너 자신의 락-브레이크 술어(-mmin +240)와 동일.
64
+ # 락은 슬립-복귀 직후(로그는 오래됐지만 러너가 살아 재개하는 케이스)를 커버한다.
65
+ _FD_ALIVE=""
66
+ if [ -d "$_FD_LOCK" ] && [ -z "$(find "$_FD_LOCK" -maxdepth 0 -mmin +240 2>/dev/null)" ]; then
67
+ _FD_ALIVE="락 존재"
68
+ fi
69
+ [ "$_FD_LOG_AGE" -lt 2100 ] && _FD_ALIVE="${_FD_ALIVE:+$_FD_ALIVE · }로그 ${_FD_LOG_AGE}s 전 갱신"
70
+ if [ -n "$_FD_ALIVE" ]; then
71
+ echo "ℹ️ [frontier-digest] 잡이 아직 돌고 있는 중일 수 있다($_FD_STAMP, $_FD_ALIVE) — 실패 판정 보류, 나중에 재확인."
72
+ else
73
+ echo "⚠️ [frontier-digest] 오늘 잡은 돌았는데 **산출물이 없다**($_FD_STAMP) — 부재가 아니라 실패다."
74
+ echo " 마지막 로그: $(tail -1 "$_FD_LOG" 2>/dev/null | cut -c1-90)"
75
+ echo " → 수동 재실행하거나 실패 원인을 보라. '오늘은 뉴스 없음'으로 읽지 말 것."
76
+ fi
77
+ else
78
+ echo "⚠️ [frontier-digest] 스케줄(09:00) 지났는데 로그도 산출물도 없다($_FD_STAMP) — 잡이 아예 안 돌았을 수 있다(launchd 확인)."
79
+ fi
80
+ BE="${BE_DIR:-}" # companion-store path — supplied by the gitignored hook registration; no public default.
81
+
82
+ # Non-Mode-D / no companion store → silent no-op (this is the majority path for public users).
83
+ [ -d "$BE/.git" ] || exit 0
84
+
85
+ # (_mtime is defined above the frontier-digest block — single definition, both sections use it.)
86
+
87
+ # 1) Refresh the companion store — fail-fast, never block the first turn, never mutate into a
88
+ # merge/conflict. (codex cross-family review 2026-07-05 [HIGH]/[MED].)
89
+ # - fail-fast env: no credential/SSH/host-key prompts can hang SessionStart.
90
+ # - fetch + merge --ff-only: a fast-forward is the only safe hook mutation; a diverged companion
91
+ # simply does not advance (no merge commit, no conflict state left behind) and we say so.
92
+ export GIT_TERMINAL_PROMPT=0
93
+ export GIT_SSH_COMMAND="${GIT_SSH_COMMAND:-ssh -o BatchMode=yes -o ConnectTimeout=5 -o StrictHostKeyChecking=accept-new}"
94
+
95
+ # Hard wall-clock deadline on the ONLY network step. ConnectTimeout bounds the handshake, but a
96
+ # slow/stalled TRANSFER after connect has no bound — measured 2026-07-12: SessionStart worst-case
97
+ # 17.5s with 2 hook-timeout kills, all attributable to the fetch. perl-alarm is the portable
98
+ # watchdog (macOS ships no coreutils `timeout`); on overrun the fetch dies and the offline branch
99
+ # reports honestly. FH_FETCH_DEADLINE overrides (seconds). If perl is absent the wrapper degrades
100
+ # to running the command with NO deadline — a missing watchdog must never become a permanently
101
+ # skipped fetch misreported as "offline" (challenger catch 2026-07-12).
102
+ if command -v perl >/dev/null 2>&1; then
103
+ _deadline() { perl -e 'alarm shift @ARGV; exec @ARGV' "$@"; }
104
+ else
105
+ _deadline() { shift; "$@"; }
106
+ fi
107
+ PULL_NOTE=""
108
+ if _deadline "${FH_FETCH_DEADLINE:-8}" git -C "$BE" fetch --quiet >/dev/null 2>&1; then
109
+ if git -C "$BE" merge --ff-only --quiet >/dev/null 2>&1; then
110
+ PULL_NOTE="fetched + fast-forwarded"
111
+ else
112
+ PULL_NOTE="fetched but NOT fast-forward (companion diverged — read local + newest remote)"
113
+ fi
114
+ else
115
+ PULL_NOTE="fetch skipped (offline or deadline hit — read local state)"
116
+ fi
117
+
118
+ # 2) Session card date (the pointer the operator's close chain writes last).
119
+ CARD="$FH/tracks/_meta/reference_next_session_starter.md"
120
+ CARD_EPOCH=0
121
+ [ -f "$CARD" ] && CARD_EPOCH="$(_mtime "$CARD")"
122
+
123
+ # 3) Companion files NEWER than the card, in the surfaces that carry landed results/handoffs.
124
+ # (paper-signals = completed experiments; handoff = cross-session/cross-machine; tracks-meta
125
+ # = synced session meta.) These are exactly what a stale card fails to point at.
126
+ NEWER=""
127
+ for sub in paper-signals handoff tracks-meta digests; do
128
+ d="$BE/$sub"
129
+ [ -d "$d" ] || continue
130
+ while IFS= read -r f; do
131
+ [ -n "$f" ] || continue
132
+ fe="$(_mtime "$f")"
133
+ if [ "${fe:-0}" -gt "${CARD_EPOCH:-0}" ]; then
134
+ NEWER="${NEWER} - ${f#$BE/}\n"
135
+ fi
136
+ done <<EOF
137
+ $(find "$d" -type f -name '*.md' -maxdepth 2 2>/dev/null)
138
+ EOF
139
+ done
140
+
141
+ # 3b) Handoff/signal STATUS map — mtime-INDEPENDENT (patched 2026-07-10).
142
+ # WHY: the NEWER-than-card list (step 3) has a permanent blind spot — a status stamp
143
+ # (DONE/SUPERSEDED/RESOLVED) can land in the companion store, then the card gets rewritten
144
+ # WITHOUT reconciling that item; from then on the stamped file is "older than the card"
145
+ # forever and step 3 never surfaces it again. (Measured miss 2026-07-10: a Qwen heavy
146
+ # handoff stamped DONE 07-09 stayed listed as "awaiting RUN" in the card through a later
147
+ # card rewrite — company sessions push results to the companion store but never run the
148
+ # local close chain, so the card's ⑤ update is the ONLY reconcile point and it was prose.)
149
+ # FIX: emit ALL frontmatter status lines from handoff/ + paper-signals/ every session,
150
+ # regardless of mtime, so the turn-0 agent can mechanically cross-check card carry items.
151
+ STATUS_MAP=""
152
+ for sub in handoff paper-signals; do
153
+ d="$BE/$sub"
154
+ [ -d "$d" ] || continue
155
+ while IFS= read -r f; do
156
+ [ -n "$f" ] || continue
157
+ s="$(head -15 "$f" 2>/dev/null | grep -iE '^ *status:' | head -1 | sed 's/^ *//')"
158
+ [ -n "$s" ] || continue
159
+ # Match on the status VALUE's leading word only — a substring match anywhere in the line
160
+ # false-positives on prose like "Remaining for DONE:" inside a PARTIAL status.
161
+ v="$(printf '%s' "$s" | sed -E 's/^[Ss][Tt][Aa][Tt][Uu][Ss]: *//')"
162
+ case "$v" in
163
+ DONE*|SUPERSEDED*|RESOLVED*|CLOSED*) STATUS_MAP="${STATUS_MAP} - ${f#$BE/} → ${s}\n" ;;
164
+ esac
165
+ done <<EOF
166
+ $(find "$d" -type f -name '*.md' -maxdepth 2 2>/dev/null)
167
+ EOF
168
+ done
169
+
170
+ # 4) INDEX.md live pointers (the operator's wiki TOC — read-first per CLAUDE.local.md).
171
+ INDEX_HEAD=""
172
+ if [ -f "$BE/INDEX.md" ]; then
173
+ INDEX_HEAD="$(grep -iE 'live pointer|Live pointers' -A 8 "$BE/INDEX.md" 2>/dev/null | head -10)"
174
+ fi
175
+
176
+ # 5) Emit the freshness block (short + imperative). Only speak if there is something to say.
177
+ {
178
+ echo "🔄 [FH SessionStart] companion-store freshness — $PULL_NOTE."
179
+ if [ -n "$NEWER" ]; then
180
+ echo "⚠️ NEWER THAN SESSION CARD — READ THESE BEFORE ACTING (card may be stale):"
181
+ printf "%b" "$NEWER"
182
+ else
183
+ echo " (no companion files newer than the session card)"
184
+ fi
185
+ if [ -n "$STATUS_MAP" ]; then
186
+ echo "── handoff/signal STATUS map (mtime-independent — closed items) ──"
187
+ printf "%b" "$STATUS_MAP"
188
+ echo "→ CROSS-CHECK: any item above that the session card still lists as open/awaiting = stale card line. Fix it in this session's card update (⑤)."
189
+ fi
190
+ if [ -n "$INDEX_HEAD" ]; then
191
+ echo "── INDEX.md live pointers ──"
192
+ echo "$INDEX_HEAD"
193
+ fi
194
+ echo "Reminder: this is the Mode D auto-read (CLAUDE.local.md §Session-start companion load) —"
195
+ echo "it fires even when the first user message is a task. Do not treat 'pulled' as 'read'."
196
+ } 2>/dev/null
197
+
198
+ # 6) Substrate-jump detection (structure-enforcing — version drift lives outside any session's
199
+ # context boundary; silent when nothing changed). Detector, never a gate.
200
+ [ -x "$FH/scripts/substrate_jump_detector.sh" ] && bash "$FH/scripts/substrate_jump_detector.sh" "$FH" 2>/dev/null
201
+
202
+ exit 0
@@ -0,0 +1,166 @@
1
+ #!/usr/bin/env bash
2
+ # gate_pathspec_check.sh — known-pair regression anchor for gate PATH COVERAGE.
3
+ #
4
+ # WHY THIS EXISTS
5
+ # The gate-locality class has now recurred four times: scripts/ (2026-06-26), AGENTS.md
6
+ # inheritance (#111/#117), agent definitions (2026-06-27), and SKILL_detail.md (2026-07-26).
7
+ # Every instance had the same shape: an asset class the canonical rule *declared* covered, which
8
+ # the gate implementation's path term did not actually match — and the miss rendered as PASS,
9
+ # because "no file matched" and "all files passed" are indistinguishable downstream.
10
+ #
11
+ # The 07-26 instance was the sharpest: the term was the literal `SKILL\.md`, and the string
12
+ # `SKILL_detail.md` does not contain `SKILL.md` (the underscore breaks it). 17 files, 208,710 B,
13
+ # 27.7% of the skill-spec surface, 16 of 17 holding fenced code blocks — ungated. It leaked twice
14
+ # for real (371c04f, e661931: single-file edits to a GATE SKILL's own behavioral spec).
15
+ #
16
+ # So this is not a style check. It is the mechanical anchor for the fix, per the FH rule that a
17
+ # harness edit is a draft until a check fails when the mistake recurs.
18
+ #
19
+ # METHOD — known-pair, per CLAUDE.md §Instrument-Calibration. Every case asserts BOTH directions:
20
+ # a known-positive that MUST match and a known-negative that MUST NOT. A checker that only ever
21
+ # confirms positives cannot tell "covers everything" from "matches everything".
22
+ #
23
+ # Usage: bash scripts/gate_pathspec_check.sh # exit 0 = all pairs hold, 1 = a pair broke
24
+ set -uo pipefail
25
+
26
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
27
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
28
+ GUARD="$REPO_ROOT/templates/regression_guard.sh"
29
+
30
+ fail=0
31
+ pass=0
32
+
33
+ # Extract a live regex from the implementation instead of restating it here. A copy would drift
34
+ # from the thing it claims to verify — which is the very defect class this file exists to catch.
35
+ extract_term() { # $1 = file, $2 = variable-assignment marker
36
+ grep -A2 "^${2}=" "$1" 2>/dev/null | grep -oE '\| grep -E "[^"]+"' | head -1 \
37
+ | sed -E 's/^\| grep -E "//; s/"$//'
38
+ }
39
+
40
+ check() { # $1 = label, $2 = regex, $3 = should-match path, $4 = should-NOT-match path
41
+ local label="$1" re="$2" pos="$3" neg="$4" ok=1 rc
42
+ # grep exits 2 on a MALFORMED regex. `if ! grep -q` would negate that 2 into "true" and report
43
+ # it as "known-positive not covered" — fail-closed in direction, but it misnames the cause, and
44
+ # a checker that misreports why it failed sends the next reader to fix the wrong thing.
45
+ echo "$pos" | grep -qE "$re"; rc=$?
46
+ if [ "$rc" -eq 2 ]; then
47
+ echo " ❌ $label — extracted pattern is not a valid regex (instrument error, NOT a coverage result)"
48
+ echo " pattern: $re"
49
+ fail=$((fail + 1)); return
50
+ fi
51
+ [ "$rc" -ne 0 ] && { echo " ❌ $label — known-POSITIVE not covered: $pos"; ok=0; }
52
+ if echo "$neg" | grep -qE "$re"; then
53
+ echo " ❌ $label — known-NEGATIVE wrongly covered: $neg"; ok=0
54
+ fi
55
+ if [ "$ok" -eq 1 ]; then
56
+ echo " ✅ $label"; pass=$((pass + 1))
57
+ else
58
+ fail=$((fail + 1))
59
+ fi
60
+ }
61
+
62
+ echo "gate_pathspec_check — known-pair coverage anchors"
63
+ echo
64
+
65
+ # ── 1. pre-commit HEAVY term ──────────────────────────────────────────────────
66
+ HEAVY_RE="$(extract_term "$HOOK" HEAVY)"
67
+ if [ -z "$HEAVY_RE" ]; then
68
+ echo " ❌ could not extract HEAVY term from $HOOK — instrument error, NOT a pass"
69
+ exit 1
70
+ fi
71
+ # The 07-26 regression: detail files must be HEAVY. Negative: a tracks/ record must not be.
72
+ check "HEAVY covers SKILL_detail.md" "$HEAVY_RE" \
73
+ "plugins/fh-meta/skills/frontier-digest/SKILL_detail.md" \
74
+ "tracks/_meta/fh_signal_2026-07-26_ai.md"
75
+ check "HEAVY still covers SKILL.md" "$HEAVY_RE" \
76
+ "plugins/fh-meta/skills/frontier-digest/SKILL.md" \
77
+ "README.md"
78
+ # Prior gate-locality instances — anchored so a future edit cannot silently drop them.
79
+ check "HEAVY covers agent definitions (seam #3)" "$HEAVY_RE" \
80
+ "plugins/fh-meta/agents/challenger.md" \
81
+ "docs/README.md"
82
+ check "HEAVY covers scripts/*.sh (seam #1)" "$HEAVY_RE" \
83
+ "scripts/gate_pathspec_check.sh" \
84
+ "tracks/_audit/session_2026_07_26_agentsmith-sister.md"
85
+
86
+ # ── 2. regression_guard GUARD_PATHSPEC ────────────────────────────────────────
87
+ # Read the array as the guard itself defines it; match with the same glob semantics git uses.
88
+ # NOTE: no `mapfile` — macOS ships bash 3.2, where it does not exist. This is the documented
89
+ # bash-3.2 portability class; a 4.x-only builtin here would make the anchor itself the thing that
90
+ # breaks on the operator's own machine.
91
+ SPEC=()
92
+ while IFS= read -r line; do
93
+ [ -n "$line" ] && SPEC+=("$line")
94
+ done < <(sed -n '/^GUARD_PATHSPEC=(/,/^)/p' "$GUARD" | grep -oE "'[^']+'" | tr -d "'")
95
+ if [ "${#SPEC[@]:-0}" -eq 0 ]; then
96
+ echo " ❌ could not extract GUARD_PATHSPEC from $GUARD — instrument error, NOT a pass"
97
+ exit 1
98
+ fi
99
+ spec_matches() { # $1 = path
100
+ local p="$1" g
101
+ for g in "${SPEC[@]}"; do
102
+ # shellcheck disable=SC2254
103
+ case "$p" in $g) return 0 ;; esac
104
+ done
105
+ return 1
106
+ }
107
+ for pair in \
108
+ "plugins/fh-meta/skills/frontier-digest/SKILL_detail.md|tracks/_meta/x.md|PATHSPEC covers SKILL_detail.md" \
109
+ "plugins/fh-meta/skills/frontier-digest/SKILL.md|README.md|PATHSPEC still covers SKILL.md" \
110
+ "CLAUDE.md|CLAUDE.local.md|PATHSPEC covers CLAUDE.md but not the local override"
111
+ do
112
+ IFS='|' read -r pos neg label <<< "$pair"
113
+ ok=1
114
+ spec_matches "$pos" || { echo " ❌ $label — known-POSITIVE not covered: $pos"; ok=0; }
115
+ spec_matches "$neg" && { echo " ❌ $label — known-NEGATIVE wrongly covered: $neg"; ok=0; }
116
+ if [ "$ok" -eq 1 ]; then echo " ✅ $label"; pass=$((pass + 1)); else fail=$((fail + 1)); fi
117
+ done
118
+
119
+ # ── 3. Canonical-vs-implementation parity ─────────────────────────────────────
120
+ # regression_guard.sh's own comment: "두 목록이 갈리면 갈린 쪽이 조용히 무검사 구간이 된다."
121
+ # Anchor that warning mechanically for the asset class that just broke.
122
+ CANON="$REPO_ROOT/.claude/rules/fh_4axis_gate.md"
123
+ # Scope the match to the ASSET-LIST sentence, not the whole file. A bare whole-file grep would go
124
+ # green on a line that says "SKILL_detail.md is excluded" — i.e. it would certify parity against
125
+ # documentation that contradicts the code. Match the declaration line itself.
126
+ if grep -q 'Whenever the AI modifies FH assets.*SKILL_detail\.md' "$CANON" 2>/dev/null; then
127
+ echo " ✅ canonical rule declares SKILL_detail.md (in the asset-list line)"; pass=$((pass + 1))
128
+ else
129
+ echo " ❌ canonical rule ($CANON) no longer declares SKILL_detail.md — the two lists diverged,"
130
+ echo " which is exactly the silent no-check condition this anchor exists to prevent."
131
+ fail=$((fail + 1))
132
+ fi
133
+
134
+ # ── 4. Enumeration sweep — the anti-guessing check ────────────────────────────
135
+ # Every fix above answers "is THIS name covered?" — which only ever closes the names someone
136
+ # thought of. This one inverts it: enumerate what actually EXISTS under plugins/*/skills/ and
137
+ # assert the HEAVY term covers all of it. A new companion-file convention (SKILL_summary.md,
138
+ # a nested docs/ page, a deeper skill directory) then fails HERE, at introduction, instead of
139
+ # waiting for someone to notice the naming gap years later. Reality is the input, not a guess.
140
+ # (Adversarial credit: an Axis-2 sidecar pass argued the name-by-name fixes could not, in
141
+ # principle, close the class — correct, and this is the answer to it.)
142
+ uncovered=""
143
+ while IFS= read -r f; do
144
+ [ -z "$f" ] && continue
145
+ echo "$f" | grep -qE "$HEAVY_RE" || uncovered="$uncovered$f
146
+ "
147
+ done < <(cd "$REPO_ROOT" && find plugins -path '*/skills/*' -name '*.md' -type f 2>/dev/null | sort)
148
+ if [ -z "$uncovered" ]; then
149
+ echo " ✅ enumeration: every .md under plugins/*/skills/ is covered by the HEAVY term"
150
+ pass=$((pass + 1))
151
+ else
152
+ echo " ❌ enumeration: files exist under plugins/*/skills/ that NO gate term covers —"
153
+ printf '%s' "$uncovered" | sed 's/^/ /'
154
+ echo " Either widen the gate term, or state in fh_4axis_gate.md why this class is exempt."
155
+ fail=$((fail + 1))
156
+ fi
157
+
158
+ echo
159
+ if [ "$fail" -eq 0 ]; then
160
+ echo "gate_pathspec_check: PASS ($pass pairs)"
161
+ exit 0
162
+ fi
163
+ echo "gate_pathspec_check: FAIL ($fail broken, $pass ok)"
164
+ echo "A gate path term stopped covering an asset class it is declared to cover."
165
+ echo "Do NOT relax the anchor to make it green — fix the term, or retire the pair deliberately."
166
+ exit 1