@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,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
|