@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,848 @@
1
+ #!/usr/bin/env bash
2
+ # FH 4-Axis Gate Pre-Commit Hook
3
+ #
4
+ # Blocks git commit when FH assets are staged unless all required axes have passed.
5
+ # Full gate (all 4 axes): SKILL.md · .claude/rules/ · knowledge/shared/rules/ · templates/ · CLAUDE.md
6
+ # + substantive knowledge/ · docs/*.md · AGENTS.md
7
+ # Lightweight gate (Axis 1+4 only): CATALOG.md · tracks/ · prose-only carve-out docs
8
+ #
9
+ # Carve-out (knowledge/ · docs/*.md · AGENTS.md): these are FULL gate only when the
10
+ # staged diff is *substantive* — adds a fenced code block (```) or a factual claim
11
+ # token (arXiv: / DOI / http / a versioned dependency like x.y.z). Prose-only edits
12
+ # (typos, rewording, link text) stay lightweight. Mirrors CLAUDE.md §Substantive carve-out.
13
+ #
14
+ # Install (one-time, from repo root):
15
+ # git config core.hooksPath templates/.git-hooks
16
+ # chmod +x templates/.git-hooks/pre-commit
17
+
18
+ set -uo pipefail
19
+
20
+ REPO_ROOT=$(git rev-parse --show-toplevel)
21
+ BRANCH=$(git rev-parse --abbrev-ref HEAD)
22
+ TODAY=$(date +%Y-%m-%d)
23
+ BRANCH_SLUG="${BRANCH//\//_}"
24
+ FAILED=0
25
+
26
+ # ── Is a path inside the gated (carve-out) namespace? ─────────────────────────
27
+ # Mirror of the CARVEOUT classifier below. Used to tell a rename WITHIN the gated
28
+ # namespace (already-reviewed content relocating) from a rename INTO it from outside
29
+ # (content gated for the first time → must be scanned in full).
30
+ is_carveout_path() {
31
+ echo "$1" | grep -qE '(^knowledge/.*\.md$|^docs/.*\.md$|(^|/)AGENTS\.md$)'
32
+ }
33
+
34
+ # ── Helper: is a carve-out file's staged change substantive? ──────────────────
35
+ # Substantive ⇔ the content this commit puts into the gated file introduces a code
36
+ # fence (``` / ~~~, possibly indented) or a citation/version token. Mechanical.
37
+ #
38
+ # "What content is new to the gate" differs by change shape:
39
+ # - plain edit/add (no rename): the staged DIFF additions — spec "diff adds …",
40
+ # so a prose/typo fix to a doc that already contains a fence stays light.
41
+ # - rename WITHIN the gated namespace (docs/→docs/, knowledge/→…): the rename-paired
42
+ # diff — a pure move adds nothing (light); a move+edit shows only the real delta.
43
+ # - rename INTO the gated namespace from outside (root→docs/, scratch→docs/): the
44
+ # file is gated for the FIRST time, so its ENTIRE staged content is new to the gate
45
+ # and is scanned whole. Otherwise content authored in an ungated path could be
46
+ # `git mv`'d into docs/ to evade review (fh_signal_2026-06-08_hook-rename-false-positive,
47
+ # challenger S1). Filtering by the new path alone also breaks rename pairing, which
48
+ # was the original false positive on pure within-scope moves.
49
+ # Fails CLOSED (returns substantive) on any git error — a gate must not fail open.
50
+ diff_is_substantive() {
51
+ local file="$1" body rc oldpath mode
52
+ oldpath=$(git diff --cached -M --name-status 2>/dev/null \
53
+ | awk -F'\t' -v f="$file" '$1 ~ /^R/ && $3 == f { print $2; exit }')
54
+ if [ -n "$oldpath" ] && ! is_carveout_path "$oldpath"; then
55
+ body=$(git show ":$file" 2>/dev/null); rc=$?; mode=whole # into-scope: whole file
56
+ elif [ -n "$oldpath" ]; then
57
+ body=$(git diff --cached -M -- "$oldpath" "$file" 2>/dev/null); rc=$?; mode=diff
58
+ else
59
+ body=$(git diff --cached -- "$file" 2>/dev/null); rc=$?; mode=diff
60
+ fi
61
+ [ "$rc" -ne 0 ] && return 0 # git error → fail closed (demand review)
62
+
63
+ # In diff mode only ADDED lines count (removed/context lines near a fence must not
64
+ # false-match); in whole mode the raw file content is the body. Strip exactly ONE
65
+ # leading '+' rather than excluding '^+++' — an added content line beginning with
66
+ # '++' renders as '+++…' and a pattern-exclude would silently drop it (challenger
67
+ # A-grade). The diff file-header '+++ b/path' degrades to a harmless '++ b/path'.
68
+ if [ "$mode" = diff ]; then
69
+ body=$(printf '%s\n' "$body" | grep -E '^\+' | sed 's/^\+//' || true)
70
+ fi
71
+ # Fenced code block (leading indentation allowed), OR a citation/version token.
72
+ if printf '%s\n' "$body" | grep -qE '^[[:space:]]*(```|~~~)'; then
73
+ return 0
74
+ fi
75
+ if printf '%s\n' "$body" | grep -qE 'arXiv:|DOI|https?://|[0-9]+\.[0-9]+\.[0-9]+'; then
76
+ return 0
77
+ fi
78
+ return 1
79
+ }
80
+
81
+ # ── Classify staged changes ──────────────────────────────────────────────────
82
+ # `-c core.quotePath=false` — git QUOTES non-ASCII paths by default ("\354\234\240...md"), and a
83
+ # quoted name matches no real file downstream, so a staged Korean-named file scanned CLEAN
84
+ # (cross-family audit R2, 2026-07-26). `--no-renames` — with rename detection ON, `git mv`-ing this
85
+ # hook out of templates/ reported ONLY the destination, so HEAVY and UGUARD_IMPL came up empty and a
86
+ # commit could DELETE the gate while the gate said PASS. Disabling rename detection lists both the
87
+ # old path (deletion) and the new one, so moving a protected file out is still a protected-path edit.
88
+ # NUL-delimited here too (R4, 2026-07-26): quotePath=false stops NON-ASCII quoting, but git still
89
+ # C-quotes a path containing a backslash, and the quoted spelling matches none of the classifier's
90
+ # path terms — so `scripts/back\slash.sh` (a governance script by every rule FH has) dropped clean
91
+ # through to "no FH assets staged". Fixing only the confidentiality loop left this half open: same
92
+ # defect class, second location, which is the propagation-boundary failure this repo keeps hitting.
93
+ STAGED=$(git -c core.quotePath=false diff --cached --name-only --no-renames -z 2>/dev/null | tr '\0' '\n' || true)
94
+ # A literal NEWLINE inside a tracked filename cannot be represented in a line-oriented classifier at
95
+ # all, so it is not silently mis-classified — it fails closed.
96
+ # The first version of this check compared a NUL count to a line count. That was wrong in exactly one
97
+ # direction: `$(...)` strips TRAILING newlines, so a path ending in a newline lost its extra line and
98
+ # the two counts agreed — a middle newline blocked, a trailing one sailed through (R5 audit,
99
+ # 2026-07-26). Counting is the wrong instrument here; inspect each record instead, which cannot be
100
+ # fooled by where in the name the newline sits.
101
+ # NL must come from $'\n', NOT from $(printf '\n'): command substitution strips trailing newlines,
102
+ # so $(printf '\n') is the EMPTY string and `case $p in *""*` matches EVERY path — the detector for
103
+ # trailing-newline paths, defeated by trailing-newline stripping, firing on all 10 staged files of an
104
+ # ordinary commit. Caught by running it on this repo (self-dogfood), not by reading it. Known-pair
105
+ # calibrated after the fix: an ordinary path passes, a path containing a newline blocks.
106
+ _NL=$'\n'
107
+ _badpath=0
108
+ while IFS= read -r -d '' _p; do
109
+ case "$_p" in *"$_NL"*) _badpath=1 ;; esac
110
+ done < <(git -c core.quotePath=false diff --cached --name-only --no-renames -z 2>/dev/null || true)
111
+ if [ "$_badpath" -eq 1 ]; then
112
+ echo "❌ BLOCKED — a staged path contains a newline; this gate's path classifier cannot represent it."
113
+ echo " Rename the file. (Fail-closed: an unclassifiable path must not be read as 'not an FH asset'.)"
114
+ exit 1
115
+ fi
116
+
117
+ # Always-heavy by path (any change is a full gate).
118
+ # scripts/*.sh included (2026-06-26, fh_signal fh-gate-structured-verdict): FH's scripts/
119
+ # dir holds the governance/enforcement engines (fh-gate.sh, below_floor_scan.sh,
120
+ # dlp-filter.sh, …) — the gate's OWN tooling. Leaving it ungated was a gate-locality
121
+ # seam: the engine that enforces the 4-axis could itself ship unverified. Gated broadly,
122
+ # not by an allowlist, so a NEW gate script cannot silently slip in ungated (the drift
123
+ # that would re-open the seam). Scope is `scripts/*.sh` BY CONVENTION (all current gate
124
+ # engines are .sh under scripts/): a governance script with another extension (.py) or
125
+ # placed elsewhere (bin/) is NOT auto-caught and must be hand-gated — broaden this term
126
+ # if that changes. Axis 1 (regression_guard) skips non-markdown, so the live effect on a
127
+ # script is Axes 2-3 (adversarial design + phantom), NOT behavioral regression — that is
128
+ # covered downstream (selfcheck.sh / count_check.sh at publish), not by this commit gate.
129
+ # seam #3 (2026-06-27): agent definitions (plugins/*/agents/*.md, .claude/agents/*.md) are
130
+ # salience-dependent FH assets — a sub-agent follows its prose prompt, the exact class the gate +
131
+ # target-tier-sim discipline targets — yet matched NONE of the HEAVY terms above, so a behavioral
132
+ # change to an agent could ship with only Axis 1+4. Gated broadly (whole file, not substantive-only):
133
+ # an agent .md IS a behavioral spec, so even "prose" edits change behavior (unlike knowledge/ docs,
134
+ # which carve out pure-prose edits). N=3 of the gate-locality class (scripts/ 06-26, AGENTS.md
135
+ # inheritance #111/#117). If pure-doc agent .md edits over-friction in practice, add a substantive
136
+ # carve-out like CARVEOUT below.
137
+ # seam #4 (2026-07-26): SKILL_detail.md. The term above was `SKILL\.md`, and the string
138
+ # "SKILL_detail.md" does NOT contain "SKILL.md" — the underscore breaks it — so every detail file
139
+ # matched NOTHING here and nothing in regression_guard's pathspec either. Measured: 17 files /
140
+ # 208,710 B = 27.7% of the skill-spec surface, 16 of 17 carrying fenced code blocks. It LEAKED
141
+ # twice for real (371c04f, e661931 — both single-file edits to phantom-quench/SKILL_detail.md,
142
+ # i.e. a gate skill's own behavioral spec changed with zero 4-axis coverage). N=4 of the
143
+ # gate-locality class. Worst property: salience-splitter WIDENS this hole every time it moves
144
+ # content out of SKILL.md to lean the resident layer — coverage shrank as the diet progressed.
145
+ # Over-firing measured before shipping: of the last 60 commits, 28 touched a detail file and 26
146
+ # already tripped HEAVY via a companion file, so this adds a heavy path to ~7% of them.
147
+ HEAVY=$(echo "$STAGED" \
148
+ | grep -E "(SKILL(_detail)?\.md|^plugins/[^/]+/skills/[^/]+/.*\.md$|\.claude/rules/|^knowledge/shared/rules/|templates/|CLAUDE\.md|^scripts/.*\.sh$|^plugins/[^/]+/agents/.*\.md$|^\.claude/agents/.*\.md$)" || true)
149
+
150
+ # Carve-out candidates: knowledge/ docs, docs/*.md, AGENTS.md. Heavy only if substantive.
151
+ CARVEOUT=$(echo "$STAGED" \
152
+ | grep -E "(^knowledge/.*\.md$|^docs/.*\.md$|(^|/)AGENTS\.md$)" | grep -v "^knowledge/shared/rules/" || true)
153
+
154
+ # Always-light by path.
155
+ LIGHT=$(echo "$STAGED" \
156
+ | grep -E "(CATALOG\.md|^tracks/)" || true)
157
+
158
+ # Evaluate carve-out files: substantive ones promote to HEAVY, the rest are LIGHT.
159
+ if [ -n "$CARVEOUT" ]; then
160
+ while IFS= read -r f; do
161
+ [ -z "$f" ] && continue
162
+ if diff_is_substantive "$f"; then
163
+ HEAVY="$HEAVY"$'\n'"$f (substantive: code/claim added)"
164
+ else
165
+ LIGHT="$LIGHT"$'\n'"$f (prose-only)"
166
+ fi
167
+ done <<< "$CARVEOUT"
168
+ fi
169
+
170
+ # Trim leading blank lines that the appends may introduce.
171
+ HEAVY=$(echo "$HEAVY" | grep -vE '^\s*$' || true)
172
+ LIGHT=$(echo "$LIGHT" | grep -vE '^\s*$' || true)
173
+
174
+ # ── Universal guards (surface-scoped, NOT 4-axis-scoped) ─────────────────────
175
+ # WHY A FUNCTION, AND WHY IT RUNS BEFORE THE "no FH assets" EXIT (2026-07-26, N=5 of the
176
+ # gate-locality class): these two guards protect the CONFIDENTIALITY BOUNDARY of a public repo,
177
+ # not the STRUCTURAL integrity of FH assets. They were authored inline BELOW the 4-axis
178
+ # classifier, so their scope silently inherited the classifier's asset pathspec — a commit
179
+ # staging only NON-asset paths hit `exit 0 # No FH assets staged` and skipped the
180
+ # confidentiality scan entirely. Measured 2026-07-26: 46/241 tracked files (19.1%) were
181
+ # unscannable that way; 32 of those are also outside npm files[], so they had no publish-time
182
+ # backstop either — including the .gitignore whose leaked comment is the very incident cited in
183
+ # the Privacy guard below, and .github/scripts/*.py. Known-pair verified: the same leak line
184
+ # BLOCKS when staged in CATALOG.md and passes UNSEEN when staged in README.md alone.
185
+ # CI had also delegated this role here (.github/workflows/validate.yml §internal-vocab) without
186
+ # anyone measuring the receiving layer's coverage — half-fix propagation, not a new defect.
187
+ # The 4 axes are a reversible surface (a commit is re-committable); THESE guard the publish
188
+ # boundary, so they run on EVERY commit regardless of what is staged.
189
+ # Body is intentionally NOT indented: it contains heredocs whose terminators must sit at column 0.
190
+ run_universal_guards() {
191
+ # ── Privacy guard — tracks/ staged-path allowlist (structural, name-free) ─────
192
+ # tracks/** is local-by-default (frozen-seed policy); the only public lanes are
193
+ # tracks/_contrib/** (consent lane) and ALREADY-TRACKED .gitkeep skeleton files.
194
+ # A newly staged path outside those lanes is treated as a potential private-name
195
+ # leak and blocks fail-closed. No deny-list of names exists here on purpose: a
196
+ # tracked deny-list would itself publish the names it protects (measured origin:
197
+ # tracked .gitignore carried a private track name + company domain in its comment,
198
+ # fixed 2026-06-11). Intentional public mapping (a new track's .gitkeep) passes
199
+ # with explicit per-commit consent: TRACKS_PUBLIC_OK=1 git commit ...
200
+ echo "[Privacy] tracks/ staged-path allowlist..."
201
+ TRACKS_LEAK=0
202
+ while IFS= read -r p; do
203
+ [ -z "$p" ] && continue
204
+ case "$p" in
205
+ tracks/_contrib/*) ;;
206
+ tracks/*)
207
+ if [ "$(basename "$p")" = ".gitkeep" ]; then
208
+ if git ls-files --error-unmatch "$p" >/dev/null 2>&1; then
209
+ continue # already-tracked skeleton
210
+ elif [ "${TRACKS_PUBLIC_OK:-0}" = "1" ]; then
211
+ echo " ⚠️ new public track skeleton allowed by TRACKS_PUBLIC_OK=1: $p"
212
+ continue
213
+ fi
214
+ fi
215
+ echo " ❌ FAIL — staged tracks/ path outside public lanes: $p"
216
+ TRACKS_LEAK=1; FAILED=1 ;;
217
+ esac
218
+ done <<TRACKS_EOF
219
+ $(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=ACR)
220
+ TRACKS_EOF
221
+ if [ "$TRACKS_LEAK" -eq 1 ]; then
222
+ echo " tracks/** is local-only. Public lanes: tracks/_contrib/** · tracked .gitkeep."
223
+ echo " Consent-lane content → move under tracks/_contrib/. New public track skeleton"
224
+ echo " → re-run with TRACKS_PUBLIC_OK=1. Private content → unstage (git restore --staged)."
225
+ else
226
+ echo " ✅ PASS"
227
+ fi
228
+
229
+ # ── Confidentiality guard — public-surface scan on staged tracked content ─────
230
+ # A commit to a PUBLIC repo is an effective PUBLISH of its content, so the confidentiality boundary is
231
+ # checked here as well as at push/publish. Pattern loading and matching come from
232
+ # scripts/psa_scan_lib.sh — the single implementation shared with pre-push and the publish scanner.
233
+ # (Three near-duplicate copies used to exist; every confidentiality defect found in the 2026-07-26
234
+ # cross-family audit was a divergence between them, so the duplication was removed rather than
235
+ # repaired a fourth time.)
236
+ #
237
+ # DEGRADE DIRECTION HERE IS DELIBERATELY GENTLER THAN AT PUSH — do not "unify" it:
238
+ # • no usable patterns at all, or unusable rows → FAIL (an instrument that cannot run cannot certify)
239
+ # • operator override merely ABSENT → WARN only. It is gitignored, so it is absent on
240
+ # every fresh clone and CI runner by construction; blocking each of their first commits would
241
+ # train PUBLIC_SURFACE_OK into a reflex and disarm the same channel the publish gate depends on.
242
+ # The push-time gate blocks that state instead, which is the boundary that actually publishes.
243
+ echo "[Confidentiality] public-surface scan (staged tracked content)..."
244
+ PSA_LIB="$REPO_ROOT/scripts/psa_scan_lib.sh"
245
+ if [ ! -r "$PSA_LIB" ]; then
246
+ echo " ❌ FAIL — scripts/psa_scan_lib.sh missing; the confidentiality scanner cannot run."
247
+ FAILED=1
248
+ else
249
+ . "$PSA_LIB"
250
+ psa_load "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" \
251
+ "${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"
252
+ if [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
253
+ echo " ⚠️ operator pattern override absent — only committed defaults active (home paths)."
254
+ echo " Populate .claude/rules/.public-surface-patterns (gitignored) for company-specific tokens."
255
+ fi
256
+ if [ "$PSA_DEFAULTS_OK" -eq 0 ] || [ "$PSA_BAD_ROWS" -gt 0 ] \
257
+ || [ -z "$(printf '%s' "$PSA_STREAM" | grep -vE '^[[:space:]]*(#|$)' || true)" ]; then
258
+ echo " ❌ confidentiality gate INACTIVE/INCOMPLETE — cannot certify a clean surface."
259
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
260
+ echo " ⚠️ proceeding with an incomplete gate by PUBLIC_SURFACE_OK=1 (conscious)"
261
+ echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override — branch $BRANCH — gate inactive/incomplete" \
262
+ >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
263
+ else
264
+ FAILED=1
265
+ fi
266
+ else
267
+ PSA_LEAK=0
268
+ # NUL-delimited (regression caught by the refactor's own cross-family review): the pre-refactor
269
+ # loop read this list with `read -r -d ''`, and the rewrite dropped back to a line-oriented read.
270
+ # `core.quotePath=false` stops git quoting NON-ASCII names, but a path holding a backslash or a
271
+ # newline is still C-quoted or unsplittable, and the quoted spelling matches no real file — so the
272
+ # file scanned clean. Exactly the hole R5 closed, reopened by a refactor that claimed to change no
273
+ # behavior. This is why the refactor got its own adversarial pass instead of riding the earlier one.
274
+ while IFS= read -r -d '' f; do
275
+ [ -z "$f" ] && continue
276
+ added=$(git diff --cached -- "$f" 2>/dev/null | grep -E '^\+' | sed 's/^\+//' || true)
277
+ [ -z "$added" ] && continue
278
+ # Per-line pass, path-tagged so the LOW file allowlist applies.
279
+ if ! printf '%s\n' "$added" | sed "s|^|$f |" | psa_scan_tagged; then PSA_LEAK=1; fi
280
+ # Split-token backstop (pre-commit ONLY — this surface is a DIFF, so a literal can be wrapped
281
+ # mid-word across two added lines and neither line matches). TIGHT-join, no separator, so
282
+ # "fh-\nbe" becomes "fh-be". Accepted residual, verified not a missed fix: two whitespace-
283
+ # separated words can RARELY fuse into a spurious match — safe direction (over-block) with the
284
+ # PUBLIC_SURFACE_OK escape. A sentinel separator would close the fusion but RE-OPEN the split
285
+ # catch, which is the more important case since real literals have no internal whitespace.
286
+ joined=$(printf '%s' "$added" | tr -d '\n\r')
287
+ if ! printf '%s\t%s\n' "$f" "$joined" | psa_scan_tagged >/dev/null 2>&1; then
288
+ # Only report if the per-line pass did not already flag this file, to avoid double-reporting.
289
+ if ! printf '%s\n' "$added" | sed "s|^|$f |" | psa_scan_tagged >/dev/null 2>&1; then :; else
290
+ printf '%s\t%s\n' "$f" "$joined" | psa_scan_tagged | sed 's/leak —/leak (line-split) —/'
291
+ PSA_LEAK=1
292
+ fi
293
+ fi
294
+ done < <(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=ACMR -z 2>/dev/null || true)
295
+ if [ "$PSA_LEAK" -eq 1 ]; then
296
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
297
+ echo " ⚠️ public-surface hit(s) allowed by PUBLIC_SURFACE_OK=1 (conscious, reviewed intent)"
298
+ echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override — branch $BRANCH — review suppressed hit(s) above" \
299
+ >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
300
+ else
301
+ echo " Operator-private token reached the public surface. Generalize it (companion-store name"
302
+ echo " → 'a private companion store'; corp-context → 'restricted/corp env'; absolute home path"
303
+ echo " → '~' or '{project}'), or PUBLIC_SURFACE_OK=1 git commit … for a reviewed mention."
304
+ FAILED=1
305
+ fi
306
+ else
307
+ echo " ✅ PASS"
308
+ fi
309
+ fi
310
+ fi
311
+ }
312
+
313
+ if [ -z "$HEAVY" ] && [ -z "$LIGHT" ]; then
314
+ # No FH asset staged → the 4-AXIS gate does not apply. The universal guards still do:
315
+ # their trigger is "content is being committed to a public repo", not "an FH asset changed".
316
+ run_universal_guards
317
+ if [ "$FAILED" -eq 1 ]; then
318
+ echo
319
+ echo "══════════════════════════════════════════════"
320
+ echo " 🚫 BLOCKED — universal guard failed (no FH asset staged; 4-axis gate not applicable)"
321
+ echo "══════════════════════════════════════════════"
322
+ exit 1
323
+ fi
324
+ exit 0
325
+ fi
326
+
327
+ if [ -n "$HEAVY" ]; then
328
+ GATE_MODE="full"
329
+ else
330
+ GATE_MODE="lightweight"
331
+ fi
332
+
333
+ echo ""
334
+ echo "══════════════════════════════════════════════"
335
+ echo " FH 4-Axis Gate — ${GATE_MODE} mode"
336
+ echo "══════════════════════════════════════════════"
337
+ if [ -n "$HEAVY" ]; then
338
+ echo " Heavy assets staged:"
339
+ echo "$HEAVY" | sed 's/^/ /'
340
+ fi
341
+ if [ -n "$LIGHT" ]; then
342
+ echo " Light assets staged:"
343
+ echo "$LIGHT" | sed 's/^/ /'
344
+ fi
345
+ echo ""
346
+
347
+ # ── Doc-code coupling check (measured class — WARN, never blocks) ─────────────
348
+ # Stale docs poison AI accuracy: when executable code changes but the docs that
349
+ # describe it do not, the manuals drift (import: Anthropic 4-layer L4 practice —
350
+ # "code change forces skill-doc update"). FH applies it as a WARN because many
351
+ # script changes are doc-neutral; the warning makes the *decision* conscious.
352
+ EXEC_STAGED=$(echo "$STAGED" | grep -E '^(bin/|scripts/).*' || true)
353
+ DOC_STAGED=$(echo "$STAGED" \
354
+ | grep -E '(README\.md|CHEATSHEET\.md|CLAUDE\.md|AGENTS\.md|^docs/|^knowledge/|SKILL(_detail)?\.md|^\.claude/regression/)' || true)
355
+ if [ -n "$EXEC_STAGED" ] && [ -z "$DOC_STAGED" ]; then
356
+ echo ""
357
+ echo "⚠️ DOC-CODE COUPLING (measured — commit allowed)"
358
+ echo " Executable changes staged with no doc asset staged:"
359
+ echo "$EXEC_STAGED" | sed 's/^/ /'
360
+ echo " If behavior/usage changed: update README/CHEATSHEET/SKILL.md or probes"
361
+ echo " (.claude/regression/probes.md) in this commit. If doc-neutral, proceed."
362
+ echo ""
363
+ fi
364
+
365
+ # ── Axis 1 — Regression Guard (always required) ──────────────────────────────
366
+ echo "[Axis 1] Regression Guard..."
367
+ GUARD="$REPO_ROOT/templates/regression_guard.sh"
368
+ if [ ! -f "$GUARD" ]; then
369
+ echo " ⚠️ SKIP — templates/regression_guard.sh not found"
370
+ else
371
+ GUARD_EXIT=0
372
+ # Pre-commit must evaluate the STAGED index, not --pr merge-base: on a direct-to-main
373
+ # workflow merge-base(main,main)=HEAD makes staged changes invisible (fh_signal 2026-06-04).
374
+ # typed 파일 채널로 verdict 수신 — stdout grep(prose-grep) 대체 (2026-07-23, #165 잔여 폐쇄).
375
+ GUARD_RESULT_FILE=$(mktemp "${TMPDIR:-/tmp}/fh_guard_result.XXXXXX")
376
+ GUARD_OUT="$(REGRESSION_GUARD_RESULT_FILE="$GUARD_RESULT_FILE" bash "$GUARD" --staged 2>&1)" || GUARD_EXIT=$?
377
+ printf '%s\n' "$GUARD_OUT"
378
+ GUARD_VERDICT=$(sed -n 's/^result=//p' "$GUARD_RESULT_FILE" 2>/dev/null | head -1)
379
+ rm -f "$GUARD_RESULT_FILE"
380
+ # 일관성 검사 (challenger C-1): exit 코드와 typed verdict 가 어긋나면 게이트 계기 오류다 —
381
+ # 특히 중도 crash(exit 1, 파일 미작성)가 "S-tier 경고, 커밋 허용"으로 렌더되던 구멍.
382
+ # fh-gate.sh 의 exit-10 harness-error 선례: 계기 오류는 통과도 경고도 아닌 fail-closed.
383
+ if { [ "$GUARD_EXIT" -eq 1 ] && [ "$GUARD_VERDICT" != "review" ]; } \
384
+ || { [ "$GUARD_EXIT" -eq 0 ] && [ "$GUARD_VERDICT" != "pass" ] && [ "$GUARD_VERDICT" != "skip" ]; }; then
385
+ echo " ❌ HARNESS-ERROR — guard exit=$GUARD_EXIT but typed verdict='$GUARD_VERDICT' (crash or instrument fault; not a pass, not a warning)"
386
+ FAILED=1
387
+ elif [ "$GUARD_EXIT" -eq 0 ] && [ "$GUARD_VERDICT" = "skip" ]; then
388
+ # ★ 미검사를 통과로 렌더하지 않는다. 가역 표면이라 커밋은 막지 않지만,
389
+ # "PASS" 라고 쓰면 게이트가 자산을 봤다는 잘못된 신호가 된다(2026-07-22 수리).
390
+ echo " ⏭️ SKIP — 게이트 pathspec 에 걸린 파일이 없다 (검사 안 함 ≠ 통과)"
391
+ elif [ "$GUARD_EXIT" -eq 0 ]; then
392
+ echo " ✅ PASS"
393
+ elif [ "$GUARD_EXIT" -eq 1 ]; then
394
+ echo " ⚠️ S-tier warnings present — review before merge (commit allowed)"
395
+ # Exit 1 = S-tier: guard itself says "merge allowed but verify intent" — not a commit blocker
396
+ else
397
+ echo " ❌ FAIL — M-tier blockers or usage error (exit $GUARD_EXIT)"
398
+ FAILED=1
399
+ fi
400
+ fi
401
+
402
+ # ── Marker floor validation (mechanical below-floor detector) ─────────────────
403
+ # Adversarial (Axis 2) floor compliance was advisory-only — it depended on the
404
+ # orchestrator self-flagging in prose, which fails on rule salience, not tier
405
+ # (fh_signal_2026-06-10_adversarial-floor-enforcement: blind A/B showed both Opus
406
+ # and Sonnet self-flag when the rule is in active context; the live slip happened
407
+ # because it wasn't). So the marker now REQUIRES machine-greppable floor fields,
408
+ # and the hook validates them — compliance no longer depends on recall.
409
+ #
410
+ # Required marker fields (free prose may follow them):
411
+ # axis2-engine: quench-challenger | inline | <external cli>
412
+ # axis2-model: <tier that produced the adversarial pass, e.g. opus>
413
+ # axis2-evidence: <what the pass actually found — finding count + verdict, or
414
+ # "clean — 0 findings"; e.g. "PASS no-S, 4B applied" / "1S/4A fixed">
415
+ # ← Honest scope (judge-robustness swarm, 2026-06-13): the hook enforces
416
+ # this field's PRESENCE + NON-VACUITY (a real result was recorded), not
417
+ # PROVENANCE. The marker is a trusted-runner attestation — a session that
418
+ # fabricates a pass it never ran is the residual the WEEKLY AUDIT + OPERATOR
419
+ # cover, not mechanism (cryptographic provenance is unachievable when the
420
+ # runner controls everything the hook can see). This field makes the marker
421
+ # auditable (the audit can check the recorded verdict against reality) and
422
+ # catches the realistic failure: a vacuous "trust me, it ran" marker.
423
+ # floor-status: at-floor | above-floor | sonnet-floor | below-floor
424
+ # (judged-depth floor = opus, PR #86; BASE floor = sonnet —
425
+ # Sonnet-Floor Doctrine 2026-07-10: a Sonnet inline pass is
426
+ # first-class for base commits when it carries a mechanical
427
+ # anchor line (axis2-anchor:) and it auto-enters the weekly
428
+ # re-validation queue; sub-Sonnet stays below-floor + ack)
429
+ # axis2-anchor: <mechanical evidence grounding the Sonnet judged verdict — a
430
+ # regression test, lint/scan output, probe count; required iff
431
+ # floor-status: sonnet-floor (judged depth at Sonnet is weaker,
432
+ # so the anchor leg is the compensating requirement)>
433
+ # below-floor-ack: "<verbatim operator approval utterance>" — <reason>
434
+ # ← required iff floor-status: below-floor; the quoted span is
435
+ # mandatory (rubber-stamp hardening, 2026-06-13). This narrows
436
+ # rubber-stamping to two residual classes — quote fabrication
437
+ # (active-fabrication, honesty-layer-blocked) and OUT-OF-CONTEXT
438
+ # quoting (a real but non-approval utterance) — both of which
439
+ # are weekly-audit targets: the audit re-queues below-floor
440
+ # markers and checks the quote against the session record.
441
+ # The hook enforces the form, not genuineness.
442
+ #
443
+ # Opus-inline is at-floor (capability met even though dispatch was skipped) — the
444
+ # gate must not block legitimate at/above-floor inline passes by the orchestrator.
445
+ # A below-floor judged verdict is PROVISIONAL (not gate-PASS evidence) per
446
+ # §Floor governance, so it blocks unless the operator explicitly accepts it; the
447
+ # weekly audit remains the standing consumer that re-runs or writes off acks.
448
+ # Single-line recreate command — indentation-immune on paste (a displayed heredoc
449
+ # pastes with leading indent: the anchored grep misses and an indented EOF never
450
+ # terminates <<'EOF' — non-converging fix loop, challenger Axis-5 A-finding).
451
+ marker_recreate_hint() {
452
+ echo " printf 'axis2-engine: quench-challenger\\naxis2-model: opus\\nfloor-status: at-floor\\naxis2-evidence: PASS no-S\\n' > \"$1\""
453
+ }
454
+
455
+ validate_marker_floor() {
456
+ local m="$1" fs model
457
+ # Leading whitespace tolerated on field lines (defense-in-depth vs indented writes).
458
+ fs=$(grep -m1 -E '^[[:space:]]*floor-status:' "$m" 2>/dev/null \
459
+ | sed -E 's/^[[:space:]]*floor-status:[[:space:]]*//; s/[[:space:]].*$//')
460
+ if [ -z "$fs" ]; then
461
+ echo " ❌ MARKER FORMAT — missing floor-status: line (legacy/empty marker)"
462
+ echo " The marker must carry machine-readable floor fields. Recreate it:"
463
+ marker_recreate_hint "$m"
464
+ return 1
465
+ fi
466
+ case "$fs" in
467
+ at-floor|above-floor)
468
+ : ;;
469
+ sonnet-floor)
470
+ # Sonnet-Floor Doctrine (2026-07-10): Sonnet inline is first-class for BASE
471
+ # commits — no operator ack — but the judged verdict is weaker at Sonnet, so
472
+ # a mechanical anchor line is the compensating requirement, and the marker
473
+ # auto-enters the weekly re-validation queue (below_floor_scan.sh, R-tier).
474
+ if ! grep -qE '^[[:space:]]*axis2-anchor:[[:space:]]*[^[:space:]]' "$m"; then
475
+ echo " ❌ SONNET-FLOOR pass without mechanical anchor"
476
+ echo " floor-status: sonnet-floor requires an axis2-anchor: line naming the"
477
+ echo " mechanical evidence that grounds the judged verdict (regression test,"
478
+ echo " scan output, probe count). Judged-only at Sonnet is not gate-PASS."
479
+ echo " Alternatives: dispatch the audit (sidecar / opus agent → at-floor),"
480
+ echo " or record the anchor you ran."
481
+ return 1
482
+ fi
483
+ echo " ⚠️ sonnet-floor pass accepted (base floor met; anchored — provisional"
484
+ echo " for judged depth: weekly audit re-queues sonnet-floor markers, R-tier)"
485
+ ;;
486
+ below-floor)
487
+ if ! grep -qE '^[[:space:]]*below-floor-ack:[[:space:]]*[^[:space:]]' "$m"; then
488
+ echo " ❌ BELOW-FLOOR adversarial pass without operator ack"
489
+ echo " A judged verdict produced below the opus floor is provisional —"
490
+ echo " it is not gate-PASS evidence (§Floor governance). Either:"
491
+ echo " (1) re-run Axis 2 at ≥ floor: dispatch quench-challenger at opus,"
492
+ echo " then set floor-status: at-floor; or"
493
+ echo " (2) record explicit operator acceptance in the marker:"
494
+ echo " below-floor-ack: \"<verbatim operator utterance>\" — <reason>"
495
+ return 1
496
+ fi
497
+ # Rubber-stamp guard: the ack must contain a QUOTED operator utterance
498
+ # ("..." or “…”, ≥2 chars). A bare reason is agent-self-writable; a quote
499
+ # ties the ack to a conversational event auditable in the session record.
500
+ if ! grep -m1 -E '^[[:space:]]*below-floor-ack:' "$m" \
501
+ | grep -qE '"[^"]{2,}"|“[^”]{2,}”'; then
502
+ echo " ❌ BELOW-FLOOR ack without quoted operator utterance (rubber-stamp guard)"
503
+ echo " The ack line must quote the operator's approval verbatim:"
504
+ echo " below-floor-ack: \"<what the operator actually said>\" — <reason>"
505
+ return 1
506
+ fi
507
+ echo " ⚠️ below-floor pass accepted via operator ack (provisional —"
508
+ echo " weekly audit re-queues below-floor markers for floor-tier re-run)"
509
+ ;;
510
+ *)
511
+ echo " ❌ MARKER FORMAT — invalid floor-status: '$fs'"
512
+ echo " Allowed: at-floor | above-floor | sonnet-floor | below-floor"
513
+ return 1
514
+ ;;
515
+ esac
516
+ if ! grep -qE '^[[:space:]]*axis2-engine:[[:space:]]*[^[:space:]]' "$m"; then
517
+ echo " ❌ MARKER FORMAT — missing axis2-engine: line"
518
+ echo " Record what ran the adversarial pass (quench-challenger | inline | <cli>)."
519
+ return 1
520
+ fi
521
+ model=$(grep -m1 -E '^[[:space:]]*axis2-model:' "$m" 2>/dev/null \
522
+ | sed -E 's/^[[:space:]]*axis2-model:[[:space:]]*//; s/[[:space:]].*$//')
523
+ if [ -z "$model" ]; then
524
+ echo " ❌ MARKER FORMAT — missing axis2-model: line"
525
+ echo " Record the tier that actually produced the adversarial pass"
526
+ echo " (e.g. axis2-model: opus). This is what makes the floor auditable."
527
+ return 1
528
+ fi
529
+ # axis2-evidence — presence + non-vacuity (NOT provenance; see header note + the
530
+ # 2026-06-13 judge-robustness swarm). Catches the realistic failure: a marker that
531
+ # asserts a pass with no recorded result. The hook cannot verify the pass ran — that
532
+ # residual is the weekly audit's + operator's, by design (documented, not silent).
533
+ ev=$(grep -m1 -E '^[[:space:]]*axis2-evidence:' "$m" 2>/dev/null \
534
+ | sed -E 's/^[[:space:]]*axis2-evidence:[[:space:]]*//')
535
+ if [ -z "$ev" ]; then
536
+ echo " ❌ MARKER FORMAT — missing axis2-evidence: line"
537
+ echo " Record what the adversarial pass actually found, so the marker is auditable:"
538
+ echo " axis2-evidence: PASS no-S | 1S/4A fixed | clean — 0 findings"
539
+ echo " (The hook enforces form + non-vacuity, not provenance — a fabricated pass is"
540
+ echo " the weekly audit's + operator's residual, not the hook's. See header note.)"
541
+ return 1
542
+ fi
543
+ # Non-vacuity: must carry a finding count/severity OR an explicit verdict token —
544
+ # blocks an empty-substance "it ran, trust me" line.
545
+ if ! echo "$ev" | grep -qiE '[0-9]|clean|pass|fail|no-s|none|finding|converg'; then
546
+ echo " ❌ MARKER — axis2-evidence too vacuous (no count or verdict token)"
547
+ echo " Cite the actual result: 'PASS no-S' / '1S/4A fixed' / 'clean — 0 findings'."
548
+ return 1
549
+ fi
550
+ # Model/floor cross-check — a known below-floor tier cannot claim at/above-floor.
551
+ # This catches the literal 2026-06-10 incident pattern (Sonnet pass labeled
552
+ # "Opus floor") mechanically. Floor = opus; tiers known to sit below it are
553
+ # enumerated here — above-floor models are NOT enumerated (no name baked in).
554
+ case "$fs" in
555
+ at-floor|above-floor)
556
+ if echo "$model" | grep -qiE 'sonnet|haiku'; then
557
+ echo " ❌ MODEL/FLOOR MISMATCH — axis2-model: $model cannot be $fs (judged-depth floor=opus)"
558
+ echo " Sonnet inline → floor-status: sonnet-floor (+ axis2-anchor:); sub-Sonnet →"
559
+ echo " below-floor (+ below-floor-ack:); or dispatch Axis 2 at ≥ floor."
560
+ return 1
561
+ fi
562
+ ;;
563
+ sonnet-floor)
564
+ # sonnet-floor is Sonnet's own lane — a sub-Sonnet tier claiming it is the
565
+ # same mislabel class the at-floor check catches (haiku cannot ride the lane).
566
+ if ! echo "$model" | grep -qiE 'sonnet'; then
567
+ echo " ❌ MODEL/FLOOR MISMATCH — axis2-model: $model cannot be sonnet-floor"
568
+ echo " sonnet-floor is for Sonnet-tier passes only; sub-Sonnet → below-floor + ack."
569
+ return 1
570
+ fi
571
+ ;;
572
+ esac
573
+ return 0
574
+ }
575
+
576
+ # ── Axes 2+3 — steel-quench + phantom-quench (full gate only) ─────────
577
+ if [ "$GATE_MODE" = "full" ]; then
578
+ echo "[Axis 2+3] Adversarial + Source-Grounding..."
579
+ MARKER_DIR="$REPO_ROOT/tracks/_meta"
580
+ MARKER="$MARKER_DIR/.axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker"
581
+
582
+ if [ -f "$MARKER" ]; then
583
+ if validate_marker_floor "$MARKER"; then
584
+ echo " ✅ PASS (marker confirmed + floor fields valid:"
585
+ echo " .axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker)"
586
+ else
587
+ FAILED=1
588
+ fi
589
+ else
590
+ echo " ❌ NOT CONFIRMED"
591
+ echo ""
592
+ echo " Run /steel-quench and /phantom-quench in your Claude session."
593
+ echo " After both PASS, Claude creates the marker automatically. Or manually:"
594
+ echo ""
595
+ echo " mkdir -p \"$MARKER_DIR\""
596
+ marker_recreate_hint "$MARKER"
597
+ echo ""
598
+ FAILED=1
599
+ fi
600
+ else
601
+ echo "[Axis 2+3] SKIP (lightweight mode — CATALOG.md / tracks/ only)"
602
+ fi
603
+
604
+ # ── Axis 4 — Edit Manifest entry (always required) ────────────────────────────
605
+ echo "[Axis 4] Edit Manifest..."
606
+ MANIFEST="$REPO_ROOT/tracks/_meta/edit_manifest.yaml"
607
+ if [ ! -f "$MANIFEST" ]; then
608
+ echo " ❌ FAIL — tracks/_meta/edit_manifest.yaml not found"
609
+ echo " Run /edit-manifest RECORD or create the file manually."
610
+ FAILED=1
611
+ elif grep -q "date: $TODAY" "$MANIFEST" 2>/dev/null; then
612
+ echo " ✅ PASS (entry for $TODAY found in edit_manifest.yaml)"
613
+ else
614
+ echo " ❌ FAIL — no entry dated $TODAY in edit_manifest.yaml"
615
+ echo " Run /edit-manifest RECORD to log predicted impact for today's changes."
616
+ FAILED=1
617
+ fi
618
+
619
+ # Universal guards (defined above) — confidentiality/privacy boundary, every commit.
620
+ run_universal_guards
621
+
622
+ # ── Count-consistency shift-left (gated on a skills-dir add/remove) ───────────
623
+ # The skill/agent count-consistency check historically lived ONLY at the publish
624
+ # boundary (scripts/selfcheck.sh via prepublishOnly). But the actor that BREAKS it —
625
+ # a commit that adds/removes a skill dir — acts here, at commit time. So a skill-adding
626
+ # PR could merge with stale counts undetected until the next publish (fh_signal_2026-06-21:
627
+ # the gate-locality gap that PR #111 itself tripped — it added 2 skills without updating
628
+ # the 4 count declarations and merged green). Shift the check left: run the
629
+ # count-consistency slice WHEN a SKILL.md is added/removed/renamed under plugins/*/skills/.
630
+ # Gated so ordinary commits stay cheap; reuses scripts/count_check.sh (same logic selfcheck
631
+ # uses — single source, no reinvention). NOT the whole of selfcheck (that is a broad
632
+ # publish-readiness check; keep the hook light).
633
+ # ── Gate path-coverage anchor (mandatory-pass — blocks) ───────────────────────
634
+ # Runs when a gate implementation or its canonical rule is staged. The gate-locality class has
635
+ # recurred 4x; each time a path term silently stopped covering a declared asset class, and the miss
636
+ # rendered as PASS. This anchor is calibrated on known pairs (reopening the 07-26 hole makes it FAIL,
637
+ # verified) so the fix cannot be un-done unnoticed. Blocks, because a gate that no longer gates is
638
+ # not a reversible-surface concern — it disables the commit gate itself.
639
+ GATE_IMPL=$(echo "$STAGED" \
640
+ | grep -E '(templates/\.git-hooks/pre-commit|templates/regression_guard\.sh|\.claude/rules/fh_4axis_gate\.md|scripts/gate_pathspec_check\.sh)' || true)
641
+ if [ -n "$GATE_IMPL" ]; then
642
+ echo "[Gate] gate implementation staged — path-coverage known-pair anchor..."
643
+ PSCHECK="$REPO_ROOT/scripts/gate_pathspec_check.sh"
644
+ if [ ! -f "$PSCHECK" ]; then
645
+ echo " ❌ FAIL — scripts/gate_pathspec_check.sh missing while a gate file is being changed."
646
+ echo " The anchor is the only mechanical guard on gate path coverage; its absence during a"
647
+ echo " gate edit is fail-closed, not a skip."
648
+ FAILED=1
649
+ elif bash "$PSCHECK" >/dev/null 2>&1; then
650
+ echo " ✅ PASS (all known pairs hold)"
651
+ else
652
+ echo " ❌ FAIL — a gate path term stopped covering a declared asset class:"
653
+ bash "$PSCHECK" 2>&1 | grep -E '^\s+❌' | sed 's/^/ /'
654
+ echo " (run: bash scripts/gate_pathspec_check.sh)"
655
+ FAILED=1
656
+ fi
657
+ fi
658
+
659
+ # ── Universal-guard scope anchor (mandatory-pass — blocks) ────────────────────
660
+ # Sibling of the path-coverage anchor above, guarding the OTHER half of the gate-locality class:
661
+ # not "does the pathspec still cover the declared assets?" but "do the surface-scoped guards still
662
+ # run on surfaces the 4-axis classifier does not claim?" (the 2026-07-26 hole: the confidentiality
663
+ # scan sat below `exit 0 # No FH assets staged`, so a commit staging only non-asset paths skipped
664
+ # it). Also pins the credential-shape patterns and their one measured false positive, so a pattern
665
+ # edit cannot silently over- or under-block. Triggers on the hook itself or the pattern source.
666
+ # Blocks: a confidentiality gate that stops covering the public surface is not a reversible-surface
667
+ # concern — it is the publish boundary.
668
+ UGUARD_IMPL=$(echo "$STAGED" \
669
+ | grep -E '(templates/\.git-hooks/pre-commit|\.claude/rules/\.public-surface-patterns\.defaults|scripts/universal_guard_check\.sh|scripts/public_surface_scan_files\.sh)' || true)
670
+ # The PUSH-side publish guards get the same treatment (2026-07-26). Their anchor is a separate script
671
+ # because it drives a different hook, but the wiring rule is identical: a check nobody calls is prose.
672
+ PPGUARD_IMPL=$(echo "$STAGED" \
673
+ | grep -E '(templates/\.git-hooks/pre-push|\.claude/rules/\.public-surface-patterns\.defaults|scripts/prepush_guard_check\.sh)' || true)
674
+ if [ -n "$PPGUARD_IMPL" ]; then
675
+ echo "[Gate] pre-push publish surface staged — known-pair anchor..."
676
+ PPCHECK="$REPO_ROOT/scripts/prepush_guard_check.sh"
677
+ if [ ! -f "$PPCHECK" ]; then
678
+ echo " ❌ FAIL — scripts/prepush_guard_check.sh missing while the publish guard is being changed."
679
+ echo " Fail-closed: the anchor is the only mechanical guard on that guard's behavior."
680
+ FAILED=1
681
+ elif bash "$PPCHECK" >/dev/null 2>&1; then
682
+ echo " ✅ PASS (all known pairs hold)"
683
+ else
684
+ echo " ❌ FAIL — a pre-push known pair broke:"
685
+ bash "$PPCHECK" 2>&1 | grep -E '^\s+❌' | sed 's/^/ /'
686
+ echo " (run: bash scripts/prepush_guard_check.sh)"
687
+ FAILED=1
688
+ fi
689
+ fi
690
+ if [ -n "$UGUARD_IMPL" ]; then
691
+ echo "[Gate] universal-guard surface staged — scope known-pair anchor..."
692
+ UGCHECK="$REPO_ROOT/scripts/universal_guard_check.sh"
693
+ if [ ! -f "$UGCHECK" ]; then
694
+ echo " ❌ FAIL — scripts/universal_guard_check.sh missing while the confidentiality guard is"
695
+ echo " being changed. The anchor is the only mechanical guard on that guard's SCOPE; its"
696
+ echo " absence during such an edit is fail-closed, not a skip."
697
+ FAILED=1
698
+ elif bash "$UGCHECK" >/dev/null 2>&1; then
699
+ echo " ✅ PASS (all known pairs hold)"
700
+ else
701
+ echo " ❌ FAIL — a universal-guard known pair broke:"
702
+ bash "$UGCHECK" 2>&1 | grep -E '^\s+❌' | sed 's/^/ /'
703
+ echo " (run: bash scripts/universal_guard_check.sh)"
704
+ FAILED=1
705
+ fi
706
+ fi
707
+
708
+ # ── Load-bearing change gate — mechanical anchor for a rule that was PROSE ONLY ───────────────
709
+ # CLAUDE.md §Field-Harness Load-Bearing Change Gate specifies degrade-lint → cross-family adversarial
710
+ # review → converge, for changes to verdict/gate/irreversible-surface code. Measured 2026-07-26:
711
+ # `auto-decorrelation` had ZERO callers anywhere in scripts/ or the hooks (its only appearance outside
712
+ # prose was a string inside degrade_direction_scan.sh's own closing echo), and degrade_direction_scan.sh
713
+ # was likewise called by nothing. The gate fired that day only because a session chose to read CLAUDE.md
714
+ # and run it — exactly the salience-only floor the gate exists to replace elsewhere.
715
+ #
716
+ # WHAT THIS DOES AND DOES NOT DO — the distinction matters:
717
+ # It does NOT require that a cross-family review happened. A commit is a REVERSIBLE surface, and per
718
+ # the Surface-Class Degrade Invariant the fail-closed leg of this gate belongs at the MERGE boundary,
719
+ # not here; forcing a sidecar dispatch per commit would over-block and train the escape into reflex.
720
+ # It DOES forbid being SILENT about it. The marker must carry a `crossfamily:` line — either naming
721
+ # the engine and its verdict, or explicitly recording `none` with a reason. Same shape as the existing
722
+ # below-floor-ack: the gate blocks on an unstated answer, never on the answer itself. Non-vacuity is
723
+ # the marker's job; provenance is not (a fabricated line is the weekly-audit residual, as elsewhere).
724
+ # The degrade lint runs as an ADVISORY pre-screen, per its own doctrine (FP-tolerant, never a solo block).
725
+ LOADBEARING=$(printf '%s\n%s\n' "$GATE_IMPL" "$UGUARD_IMPL" | grep -v '^[[:space:]]*$' | sort -u || true)
726
+ if [ -n "$LOADBEARING" ]; then
727
+ echo "[Gate] load-bearing change — degrade lint (advisory) + cross-family acknowledgment..."
728
+ DDSCAN="$REPO_ROOT/scripts/degrade_direction_scan.sh"
729
+ if [ -f "$DDSCAN" ]; then
730
+ _dd=$(bash "$DDSCAN" 2>/dev/null \
731
+ | grep -Ff <(printf '%s\n' "$LOADBEARING") 2>/dev/null | head -5 || true)
732
+ if [ -n "$_dd" ]; then
733
+ echo " ⚠️ degrade-direction smell(s) in the staged load-bearing files (advisory):"
734
+ printf '%s\n' "$_dd" | sed 's/^/ /'
735
+ echo " Each = 'prove this is not default-toward-PASS'. Advisory: does not block."
736
+ else
737
+ echo " ✅ degrade lint: no smell in the staged load-bearing files"
738
+ fi
739
+ else
740
+ echo " ⚠️ degrade lint unavailable (scripts/degrade_direction_scan.sh missing) — advisory leg skipped"
741
+ fi
742
+ MARKER_LB="$REPO_ROOT/tracks/_meta/.axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker"
743
+ if [ -f "$MARKER_LB" ] && grep -qE '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$MARKER_LB"; then
744
+ echo " ✅ cross-family leg recorded: $(grep -m1 -E '^[[:space:]]*crossfamily:' "$MARKER_LB" | cut -c1-90)"
745
+ else
746
+ echo " ❌ FAIL — load-bearing file staged with no 'crossfamily:' line in the Axes 2-3 marker."
747
+ echo " Verdict/gate/irreversible-surface code shares the author's blind spot with a same-family"
748
+ echo " reviewer. State the answer, whatever it is — the gate blocks silence, not a 'none':"
749
+ echo " crossfamily: codex/gpt-5.5 — R1..R4, 16 findings, 15 fixed 1 refuted, CONVERGED"
750
+ echo " crossfamily: none — <why no different-family auditor was reachable/needed>"
751
+ echo " Append one line to: $MARKER_LB"
752
+ FAILED=1
753
+ fi
754
+ fi
755
+
756
+ # SKILL.md ONLY here, deliberately (2026-07-26): the 07-26 sweep widened `SKILL\.md` →
757
+ # `SKILL(_detail)?\.md` in the HEAVY and doc-coupling terms above, but NOT here. This slice asks
758
+ # "did the number of SKILLS change?" — a detail file is a referenced companion, not a skill, so
759
+ # adding one must not trip a skill-count check. Do not "fix" this for consistency with the others.
760
+ SKILL_CHANGE=$(git diff --cached --name-status --diff-filter=ADR 2>/dev/null \
761
+ | grep -E 'plugins/[^/]+/skills/[^/]+/SKILL\.md' || true)
762
+ if [ -n "$SKILL_CHANGE" ]; then
763
+ echo "[Count] skills-dir add/remove staged — count-consistency check..."
764
+ CCHECK="$REPO_ROOT/scripts/count_check.sh"
765
+ CCOUT="$(mktemp 2>/dev/null || echo /tmp/fh_count_check.$$)"
766
+ if [ ! -f "$CCHECK" ]; then
767
+ echo " ⚠️ SKIP — scripts/count_check.sh not found"
768
+ elif bash "$CCHECK" --staged >"$CCOUT" 2>&1; then
769
+ echo " ✅ PASS (declared counts match the staged index)"
770
+ else
771
+ echo " ❌ FAIL — declared skill/agent counts drift from the filesystem:"
772
+ grep -E '^(FAIL|COUNT-CHECK)' "$CCOUT" | sed 's/^/ /'
773
+ echo " A skill was added/removed without updating every count declaration."
774
+ echo " Update: plugin.json · marketplace.json · README header · local_fh_context.md"
775
+ echo " (run: bash scripts/count_check.sh — same check selfcheck/publish runs)"
776
+ FAILED=1
777
+ fi
778
+ rm -f "$CCOUT"
779
+ fi
780
+
781
+ # ── Detail-pointer resolution (staged markdown) ──────────────────────────────
782
+ # skill-splitter governance-semantic split relocates execution-detail to on-demand files,
783
+ # linked by imperative `**Detail**: See `<path> §Section`` pointers. phantom-quench (Axis 3)
784
+ # verifies these only WHEN RUN — the marker merely ATTESTS it ran, so a §header rename or file
785
+ # move silently breaks a pointer between manual runs (B#3, fh_signal 2026-06-23 CLAUDE.md split).
786
+ # Make the pointer-resolution slice MECHANICAL at commit: every Detail pointer in a staged .md
787
+ # must resolve to a `## §Section` header in a referenced *.md (sibling- or repo-relative). Bound
788
+ # to `**Detail**: See` blocks → no false-positive on general §cross-refs.
789
+ MD_STAGED=$(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=ACMR 2>/dev/null | grep -E '\.md$' || true)
790
+ if [ -n "$MD_STAGED" ]; then
791
+ echo "[Pointers] Detail-pointer resolution (staged markdown)..."
792
+ PTR_FAIL=0; PTR_CHECKED=0
793
+ for f in $MD_STAGED; do
794
+ [ -f "$REPO_ROOT/$f" ] || continue
795
+ fdir="$(cd "$REPO_ROOT/$(dirname "$f")" && pwd)"
796
+ blocks="$(awk '
797
+ inblk && /^>/ { blk=blk" "$0; next }
798
+ inblk { print blk; inblk=0; blk="" }
799
+ /\*\*Detail\*\*: See/ { blk=$0; inblk=1; next }
800
+ END { if(inblk) print blk }
801
+ ' "$REPO_ROOT/$f")"
802
+ [ -z "$blocks" ] && continue
803
+ while IFS= read -r block; do
804
+ [ -z "$block" ] && continue
805
+ bpaths="$(printf '%s\n' "$block" | grep -oE '[A-Za-z0-9_./-]+\.md' | sort -u)"
806
+ bsecs="$(printf '%s\n' "$block" | grep -oE '§[A-Za-z0-9_-]+' | sed 's/§//' | sort -u)"
807
+ [ -z "$bsecs" ] && continue
808
+ for s in $bsecs; do
809
+ PTR_CHECKED=$((PTR_CHECKED+1)); hit=0
810
+ for p in $bpaths; do
811
+ for cand in "$fdir/$p" "$REPO_ROOT/$p" "$p"; do
812
+ if [ -f "$cand" ] && grep -qE "^## §${s}([[:space:]]|\$)" "$cand"; then hit=1; break; fi
813
+ done
814
+ [ "$hit" = 1 ] && break
815
+ done
816
+ if [ "$hit" = 0 ]; then
817
+ echo " ❌ $f: Detail pointer §$s has no '## §$s' header in [$(echo $bpaths)]"
818
+ PTR_FAIL=1
819
+ fi
820
+ done
821
+ done <<PTR_EOF
822
+ $blocks
823
+ PTR_EOF
824
+ done
825
+ if [ "$PTR_FAIL" -eq 1 ]; then
826
+ echo " A relocated detail section was renamed/moved without updating its pointer."
827
+ echo " Fix the §header or the pointer so every '**Detail**: See ... §X' resolves."
828
+ FAILED=1
829
+ else
830
+ echo " ✅ PASS ($PTR_CHECKED pointer(s) resolve)"
831
+ fi
832
+ fi
833
+
834
+ # ── Verdict ───────────────────────────────────────────────────────────────────
835
+ echo ""
836
+ echo "══════════════════════════════════════════════"
837
+ if [ "$FAILED" -eq 1 ]; then
838
+ echo " 🚫 BLOCKED — resolve failing axes, then retry"
839
+ echo " See CLAUDE.md §FH Improvement 4-Axis Auto-Gate"
840
+ echo "══════════════════════════════════════════════"
841
+ echo ""
842
+ exit 1
843
+ fi
844
+
845
+ echo " ✅ ALL AXES PASSED — commit allowed"
846
+ echo "══════════════════════════════════════════════"
847
+ echo ""
848
+ exit 0