@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,585 @@
1
+ #!/usr/bin/env bash
2
+ # FH Destructive-Op Gate — Pre-Push Hook
3
+ #
4
+ # Mechanically enforces the Destructive-Op Gate (CLAUDE.md) for the *git-side*
5
+ # irreversible surfaces the **pre-commit** hook cannot see, because they happen at
6
+ # push time, not commit time:
7
+ # • remote branch deletion (git push origin --delete X / git push origin :X)
8
+ # • tag / notes ref deletion (git push origin :refs/tags/vX)
9
+ # • FORCE / non-fast-forward push (history rewrite — git push -f)
10
+ # • implicit deletes from git push --mirror / --prune
11
+ #
12
+ # What it is and is NOT (honest scope):
13
+ # • It closes the **honest-weak-model** gap: an agent that simply *forgot* the prose
14
+ # gate is now stopped by a mechanical block. That is the real, common win.
15
+ # • It does NOT close the **injected/adversarial** gap: an agent under instruction can
16
+ # set DESTRUCTIVE_OP_OK=1 or --no-verify (any client-side hook is bypassable, and
17
+ # this hook is readable). The actual mechanical floor for the adversarial case is
18
+ # **server-side branch protection** (GitHub "Restrict deletions" / "Restrict force
19
+ # pushes"). This hook is the honest-model floor; branch protection is the hard floor.
20
+ # • It covers only git pushes FROM a hook-installed repo. Non-git irreversible ops
21
+ # (separate-repo `gh repo create --public`, visibility flip, `npm publish`) are
22
+ # genuinely un-hookable → prose + templates/PRE-PUBLISH-CHECKLIST.md.
23
+ #
24
+ # FH-internal infra: activated only via `core.hooksPath=templates/.git-hooks`, not
25
+ # installed into field projects.
26
+ #
27
+ # Degrade direction: irreversible surface → fail-CLOSED. Missing tooling / unresolvable
28
+ # base / unfetched remote tip → BLOCK (never silently allow). Verified: no fail-open path.
29
+ #
30
+ # Override (explicit, logged — mirrors the pre-commit PUBLIC_SURFACE_OK channel):
31
+ # DESTRUCTIVE_OP_OK=1 git push … ← use AFTER enumerate + recover are done.
32
+ #
33
+ # Install (one-time, from repo root):
34
+ # git config core.hooksPath templates/.git-hooks
35
+ # chmod +x templates/.git-hooks/pre-push
36
+ #
37
+ # git passes on stdin, one line per ref: <local_ref> <local_sha> <remote_ref> <remote_sha>
38
+
39
+ set -uo pipefail
40
+
41
+ REPO_ROOT=$(git rev-parse --show-toplevel 2>/dev/null || true)
42
+ [ -z "$REPO_ROOT" ] && { echo " ❌ pre-push: not in a git repo — fail-closed (block)"; exit 1; }
43
+ ZERO="0000000000000000000000000000000000000000"
44
+ PUSH_REMOTE="${1:-origin}" # git passes the remote NAME as $1; ranges are scoped to it (see below)
45
+ BASE="${FH_DESTRUCTIVE_BASE:-origin/main}"
46
+ # All ancestry/reachability checks below must ignore local `git replace`/graft objects — a graft can
47
+ # falsify merge-base/rev-list/ls-tree to make a divergent force look fast-forward or an unmerged branch
48
+ # look SAFE (cross-family audit 2026-06-27, reproduced). Export here so every git call in the hook honors it.
49
+ export GIT_NO_REPLACE_OBJECTS=1
50
+
51
+ DEL_BRANCHES="" # space-separated "<tip-sha>|refs/heads/X" pairs — SHA first so a '|' that is LEGAL in a
52
+ # ref name (git check-ref-format allows it) cannot corrupt the split (bash-3.2 safe).
53
+ DEL_OTHER="" # refs/tags/* refs/notes/* etc. being deleted
54
+ FORCED_REFS=""
55
+ UNCLASSIFIED="" # force-check impossible (remote tip not fetched)
56
+ DIRECT_MAIN="" # non-delete update pushed straight at the integration branch (PR-only policy)
57
+ SEP=$'\n' # ranges are newline-separated and evaluated ONE REF AT A TIME: concatenating
58
+ # them into one arg string let `--not` from ref A flip polarity for ref B, so a
59
+ # multi-ref push could return an empty or wrong commit set (R6 audit 2026-07-26).
60
+ PUSH_RANGES="" # rev-list args for exactly the commits this push would publish (R5 audit 2026-07-26)
61
+
62
+ # --- stacked-branch advisory (ADVISORY — never changes the verdict) --------------------------
63
+ # Warns when the branch being pushed carries commits that already live on ANOTHER unmerged remote
64
+ # branch. That is the signature of a branch cut while standing on a feature branch instead of on
65
+ # the integration branch.
66
+ #
67
+ # Measured origin (2026-07-27, a field harness, PRs #38/#39): a branch was cut off a feature branch
68
+ # by accident, so the child PR carried the parent's three commits (parent PR = 3 commits, measured).
69
+ # From there BOTH available routes cost something, and both bills arrive at parent-merge time:
70
+ # (a) leave the child based on the integration branch → its diff shows the parent's changes too,
71
+ # and once the parent is squash-merged the SHAs no longer match, so the child goes CONFLICTING;
72
+ # (b) retarget the child onto the parent branch to clean the diff → merging the parent with
73
+ # `--delete-branch` deletes that base and GitHub **CLOSES** the child rather than retargeting it.
74
+ # The observed run took (b) and ended CLOSED + CONFLICTING (`state=CLOSED`, `mergeable=CONFLICTING`).
75
+ # In both routes the tempting recovery is `git push -f`, which walks straight into the irreversible
76
+ # surface THIS HOOK exists to guard. (Clean recovery: re-cut from the integration branch and
77
+ # cherry-pick — no history rewrite.)
78
+ #
79
+ # Why it is worth a line in a destructive-op hook: the mistake happens at branch-cut time but only
80
+ # bites at parent-merge time, so the two are hard to connect — and its natural "fix" is a history
81
+ # rewrite. Surfacing it at push time removes the pressure before it reaches the force-push path.
82
+ #
83
+ # ADVISORY on purpose (Surface-Class Degrade Invariant, reversible half): intentional stacked PRs
84
+ # are a legitimate workflow, so blocking would be pure over-blocking — and over-blocking trains
85
+ # `--no-verify`, which disarms the IRREVERSIBLE guards in this same hook. What this closes is
86
+ # silence, not permission.
87
+ fh_stacked_branch_advisory() {
88
+ _sb_ref="$1"; _sb_sha="$2"
89
+ case "$_sb_ref" in refs/heads/*) ;; *) return 0 ;; esac
90
+ _sb_self="${_sb_ref#refs/heads/}"; _sb_hit=""
91
+
92
+ # Single baseline — this hook already resolved one at the top (BASE). Wave-1 caught the first
93
+ # draft inventing a 4-candidate fallback chain while its own comment claimed it reused BASE:
94
+ # comment and code disagreed, and the extra candidates existed nowhere else in the file.
95
+ if ! git rev-parse --verify --quiet "${BASE}^{commit}" >/dev/null 2>&1; then
96
+ # 부재는 통과가 아니다 — a silent `return` here is indistinguishable from "scanned, nothing
97
+ # found". Say that the scan did not happen. (Advisory, so it still does not block.)
98
+ printf ' ℹ️ [fh-advisory:stacked-branch] SKIPPED (all refs) — base %s does not resolve (FH_DESTRUCTIVE_BASE or default origin/main; shallow clone / unfetched remote). Not scanned. Fix: git fetch origin, or set FH_DESTRUCTIVE_BASE.\n' "$BASE" >&2
99
+ return 0
100
+ fi
101
+ # Orphan / unrelated history has no merge-base, so `BASE..HEAD` would enumerate the branch's
102
+ # ENTIRE history — none of which is "cut off the wrong base". Different situation, not this one.
103
+ if ! git merge-base "$BASE" "$_sb_sha" >/dev/null 2>&1; then
104
+ return 0
105
+ fi
106
+
107
+ # Ref enumeration uses for-each-ref with an explicit TAB-delimited format — never the human
108
+ # `git branch` output. Cross-family review (codex, 2026-07-27) named three defects that all
109
+ # traced to that one choice: `origin/HEAD -> origin/main` parses as ref="HEAD", branch names
110
+ # containing spaces get truncated (so self-exclusion can fail), and `* `/`+ ` prefixes leak in.
111
+ # for-each-ref emits refs, not a display listing, so the input grammar is actually what the
112
+ # parser assumes.
113
+ #
114
+ # Self-exclusion is by EXACT name comparison, never by interpolating the branch name into a
115
+ # regex. Wave-1 reproduced that bug: a branch named `feat/a.b` built the pattern `/feat/a.b$`,
116
+ # whose `.` also matched a genuinely different branch `origin/feat/aXb` — grep -v dropped the
117
+ # REAL hit and the advisory silently no-opped on a true positive.
118
+ #
119
+ # The integration branch is excluded by its RESOLVED name (from BASE), not by hard-coded
120
+ # `main`/`master` (cross-family MED-2): hard-coding hides a genuine stack built on a local
121
+ # branch that merely happens to be called `main` in a repo whose base is something else.
122
+ _sb_base_name="${BASE##*/}"
123
+ #
124
+ # ⚠️ Each commit in the range is checked, NOT just the tip. Wave-2 briefly rewrote this to test
125
+ # `$_sb_sha` alone and the anchors caught it immediately: a child branch that adds its own commit
126
+ # has a tip nobody else carries — the parent's commits are the evidence, and they sit BELOW the
127
+ # tip. Bound: newest 50, stated in the message (a bounded best-effort advisory, not a proof).
128
+ _sb_others=""
129
+ for _sb_c in $(git rev-list "${BASE}..${_sb_sha}" 2>/dev/null | head -50); do
130
+ _sb_others=$(
131
+ { git for-each-ref --contains "$_sb_c" --format='remote %(refname:short)' refs/remotes 2>/dev/null
132
+ # Local refs too: the mistake is made BEFORE the parent is ever pushed, which is the most
133
+ # likely moment for it. A remote-only check structurally misses that case (Wave-1 A).
134
+ git for-each-ref --contains "$_sb_c" --format='local %(refname:short)' refs/heads 2>/dev/null
135
+ } | awk -F'\t' -v self="$_sb_self" -v basename="$_sb_base_name" -v base="$BASE" '
136
+ { kind=$1; ref=$2
137
+ name=ref
138
+ if (kind == "remote") sub(/^[^\/]*\//, "", name) # drop the remote segment only
139
+ if (name == self || name == "HEAD" || name == basename) next
140
+ if (ref == base) next
141
+ print kind " " ref }' | head -3
142
+ )
143
+ [ -n "$_sb_others" ] && { _sb_hit="$_sb_c"; break; }
144
+ done
145
+ [ -n "$_sb_others" ] || return 0
146
+
147
+ printf '\n ⚠️ [fh-advisory:stacked-branch] %s carries commit(s) that already live on another branch.\n' "$_sb_self" >&2
148
+ printf ' shared commit example: %s\n' "$(git log -1 --format='%h %s' "$_sb_hit" 2>/dev/null | cut -c1-72)" >&2
149
+ printf ' also on:\n' >&2
150
+ printf '%s\n' "$_sb_others" | sed 's/^/ - /' >&2
151
+ printf ' Intentional stack? Ignore this. Otherwise the branch was cut off a feature branch\n' >&2
152
+ printf ' instead of %s — re-cut from %s and cherry-pick BEFORE opening the PR.\n' "$BASE" "$BASE" >&2
153
+ printf ' Merging the parent with --squash --delete-branch CLOSES the child PR rather than\n' >&2
154
+ printf ' retargeting it, and `git push -f` to recover is the surface this hook guards.\n' >&2
155
+ printf ' (Advisory only — does not block. Scanned the NEWEST 50 commits of %s..%s.)\n\n' "$BASE" "$_sb_self" >&2
156
+ return 0
157
+ }
158
+
159
+ while read -r local_ref local_sha remote_ref remote_sha; do
160
+ [ -z "${remote_ref:-}" ] && continue
161
+ # Advisory first — it can never alter the verdict, and it must be visible even when a guard
162
+ # below blocks (the two findings are independent and the operator wants both at once).
163
+ [ "$local_sha" = "$ZERO" ] || fh_stacked_branch_advisory "$local_ref" "$local_sha"
164
+ if [ "$local_sha" = "$ZERO" ]; then
165
+ # local side zero → this refspec DELETES remote_ref
166
+ case "$remote_ref" in
167
+ refs/heads/*) DEL_BRANCHES="$DEL_BRANCHES ${remote_sha}|${remote_ref}" ;;
168
+ *) DEL_OTHER="$DEL_OTHER $remote_ref" ;;
169
+ esac
170
+ elif [ "${remote_sha:-$ZERO}" != "$ZERO" ]; then
171
+ # updating an existing remote ref.
172
+ case "$remote_ref" in
173
+ refs/tags/*)
174
+ # Any update to an EXISTING tag is a forced move of a published/external anchor — destructive
175
+ # regardless of ancestry (a descendant retag passes the merge-base FF test but is still a force).
176
+ FORCED_REFS="$FORCED_REFS $remote_ref" ;;
177
+ *)
178
+ # Branch update: fast-forward iff remote_sha is an ancestor of local_sha. Confirm remote_sha is
179
+ # present locally first — if not, cannot classify (fail-closed, say so rather than mislabel FORCE).
180
+ if ! git cat-file -e "${remote_sha}^{commit}" 2>/dev/null; then
181
+ UNCLASSIFIED="$UNCLASSIFIED $remote_ref"
182
+ elif ! git merge-base --is-ancestor "$remote_sha" "$local_sha" 2>/dev/null; then
183
+ FORCED_REFS="$FORCED_REFS $remote_ref"
184
+ fi ;;
185
+ esac
186
+ fi
187
+ # PR-only policy: a non-delete update aimed straight at the integration branch.
188
+ # Detected INSIDE this loop because this is the only place git's ref list is readable.
189
+ if [ "$local_sha" != "$ZERO" ]; then
190
+ case "$remote_ref" in
191
+ refs/heads/main|refs/heads/master) DIRECT_MAIN="$DIRECT_MAIN $remote_ref" ;;
192
+ esac
193
+ # The EXACT set of commits this push publishes. Previously the load-bearing check guessed with
194
+ # HEAD~1..HEAD, which missed a multi-commit first push whose gate edit was not the tip (R5 audit).
195
+ # New branch (remote_sha ZERO) → everything not already on some remote, not the whole history.
196
+ if [ "${remote_sha:-$ZERO}" = "$ZERO" ]; then
197
+ # Scope the exclusion to the remote being pushed TO. `--not --remotes` excludes commits
198
+ # reachable from ANY remote, so a new branch whose commits already sit on a DIFFERENT remote
199
+ # (a fork, a mirror, a company GHE alongside the public origin) produced an EMPTY range —
200
+ # nothing scanned, clean line printed, and the push published them here for the first time
201
+ # (R7 audit, 2026-07-26). $1 is the remote name git passes to this hook.
202
+ PUSH_RANGES="${PUSH_RANGES}${PUSH_RANGES:+$SEP}$local_sha --not --remotes=${PUSH_REMOTE}"
203
+ else
204
+ PUSH_RANGES="${PUSH_RANGES}${PUSH_RANGES:+$SEP}${remote_sha}..${local_sha}"
205
+ fi
206
+ fi
207
+ # remote_sha == ZERO → creating a new branch (not destructive) → ignore
208
+ done
209
+
210
+ # ── Confidentiality at the PUBLISH boundary — instrument completeness + content ───
211
+ # Pattern loading and matching come from scripts/psa_scan_lib.sh, the single implementation shared
212
+ # with pre-commit and the publish scanner. Every confidentiality defect found in the 2026-07-26
213
+ # cross-family audit was a divergence between three near-duplicate copies of that logic, so the
214
+ # copies were removed. What stays HERE is the part that genuinely differs by surface: the degrade
215
+ # direction. A push is the act that makes content public and is not undoable, so an incomplete
216
+ # instrument BLOCKS here, whereas the same state only warns at commit time (a commit is local and
217
+ # re-committable, and the operator override is gitignored — absent on every fresh clone by
218
+ # construction, so blocking there would train the override into a reflex).
219
+ # APPLICABILITY FIRST, before anything is required to exist. The mechanical test for "is this an FH
220
+ # checkout at all" is the COMMITTED pattern source; a bare repo with this hook copied into it (this
221
+ # repo's own selfcheck fixture is exactly that) has neither the patterns nor the library, and demanding
222
+ # them there is over-blocking, not fail-closed. Deleting the defaults to reach this state is not a free
223
+ # bypass — it is a commit against a .claude/rules/ path, which the commit gate treats as HEAVY and
224
+ # universal_guard_check pins in both directions.
225
+ PSA_LIB="$REPO_ROOT/scripts/psa_scan_lib.sh"
226
+ _PP_APPLICABLE=1
227
+ if [ ! -e "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" ]; then
228
+ _PP_APPLICABLE=0
229
+ fi
230
+ if [ "$_PP_APPLICABLE" -eq 0 ]; then
231
+ # An inapplicable check must SAY it is inapplicable. A silent skip is indistinguishable from a
232
+ # check that aborted, and "skipped" reading as "passed" is the defect class this whole gate exists
233
+ # to remove — including when the skip is correct.
234
+ echo " ⏭️ FH Pre-Publish: N/A — no committed pattern source in this repo (not an FH checkout)"
235
+ elif [ ! -r "$PSA_LIB" ]; then
236
+ # The pattern source IS here, so this IS an FH checkout — a missing library is then a broken
237
+ # instrument on a publish surface, not an inapplicable one.
238
+ echo " ❌ scripts/psa_scan_lib.sh missing — the confidentiality scanner cannot run."
239
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || { echo " Fail-closed on the publish boundary."; exit 1; }
240
+ else
241
+ . "$PSA_LIB"
242
+ psa_load "$REPO_ROOT/.claude/rules/.public-surface-patterns.defaults" \
243
+ "${PSA_PATTERNS:-$REPO_ROOT/.claude/rules/.public-surface-patterns}"
244
+
245
+ # Instrument completeness — and a CORRECTION to how this was first written (2026-07-26).
246
+ #
247
+ # The first version blocked on an absent operator override. Two things then showed that was wrong:
248
+ # this repo's own selfcheck flagged it as over-blocking (T7: a feature-branch push blocked → "guard
249
+ # over-fires; that trains the override"), and the reasoning did not survive re-examination. The
250
+ # override holds THIS operator's literals. Another environment lacking it is not thereby unprotected
251
+ # against ITS OWN leaks — those literals were never in the file. The earlier argument (npm publish is
252
+ # not a backstop for a public git push) was right about the gap and wrong about the fix: the thing
253
+ # that actually protects a fresh clone is GENERIC credential shapes in the COMMITTED layer, which is
254
+ # exactly what was added to the defaults in the same session. So:
255
+ # override absent → WARN (a per-operator configuration a fresh clone legitimately lacks)
256
+ # defaults broken → BLOCK (the shipped universal patterns are gone; that affects everyone)
257
+ #
258
+ # Applicability is mechanical, not self-judged (CLAUDE.md §Surface-Class Degrade Invariant): if the
259
+ # COMMITTED defaults file does not exist at all, this is not an FH repo and the confidentiality legs
260
+ # do not apply — a bare repo with this hook copied in is not a publish surface this gate knows how to
261
+ # reason about. Deleting the file to reach that state is not a free bypass: it is a commit against a
262
+ # .claude/rules/ path, which the commit gate treats as HEAVY and universal_guard_check pins.
263
+ _pp_why=""
264
+ [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_DEFAULTS_OK" -eq 0 ] && _pp_why="committed pattern defaults unreadable/empty"
265
+ [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_BAD_ROWS" -gt 0 ] && _pp_why="${_pp_why:+$_pp_why; }$PSA_BAD_ROWS unusable pattern row(s)"
266
+ if [ "$_PP_APPLICABLE" -eq 1 ] && [ "$PSA_OVERRIDE_PRESENT" -eq 0 ]; then
267
+ echo " ⚠️ operator-literal override absent — only the committed defaults (home paths + credential"
268
+ echo " shapes) are active. Populate .claude/rules/.public-surface-patterns for company literals."
269
+ fi
270
+ if [ -n "$_pp_why" ]; then
271
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
272
+ echo " ⚠️ FH Pre-Publish: INCOMPLETE confidentiality instrument allowed by PUBLIC_SURFACE_OK=1"
273
+ echo " ($_pp_why)"
274
+ printf '%s PUBLIC_SURFACE_OK override (git push, incomplete instrument: %s)\n' \
275
+ "$(date +%Y-%m-%dT%H:%M:%S)" "$_pp_why" \
276
+ >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
277
+ else
278
+ echo ""
279
+ echo "══════════════════════════════════════════════"
280
+ echo " ⛔ FH Pre-Publish Gate (pre-push) — incomplete confidentiality instrument"
281
+ echo "══════════════════════════════════════════════"
282
+ echo " $_pp_why"
283
+ echo ""
284
+ echo " A push makes content public. The per-commit scan only WARNS about the missing override,"
285
+ echo " and the npm publish scan never sees a git push — so this is the boundary that must hold."
286
+ echo " Populate .claude/rules/.public-surface-patterns (gitignored), or push consciously:"
287
+ echo " PUBLIC_SURFACE_OK=1 git push …"
288
+ echo "══════════════════════════════════════════════"
289
+ exit 1
290
+ fi
291
+ fi
292
+
293
+ # Content. Scans the ADDED lines of every commit this push publishes — per commit, not the net
294
+ # diff: a token added and later removed still ships inside the pushed history. Lines are tagged
295
+ # with their path so the LOW file allowlist can apply (flattening them is what made this leg block
296
+ # its own first real push on a companion-store name inside the sync script itself).
297
+ if [ -n "$PUSH_RANGES" ] && [ "$_PP_APPLICABLE" -eq 1 ]; then
298
+ _pp_count=0; _pp_added=""; _pp_err=0
299
+ while IFS= read -r _rg; do
300
+ [ -z "$_rg" ] && continue
301
+ _c=$(git rev-list --count $_rg 2>/dev/null) || { _pp_err=1; continue; }
302
+ _pp_count=$(( _pp_count + ${_c:-0} ))
303
+ _d=$(git -c core.quotePath=false log --format= --unified=0 $_rg 2>/dev/null) || { _pp_err=1; continue; }
304
+ _pp_added="$_pp_added$SEP$_d"
305
+ done <<PPRANGES
306
+ $PUSH_RANGES
307
+ PPRANGES
308
+ if [ "$_pp_err" -eq 1 ]; then
309
+ echo " ❌ could not read part of the push range — an unread history is not a clean one."
310
+ [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || exit 1
311
+ fi
312
+ if [ "${_pp_count:-0}" -gt 0 ]; then
313
+ if printf '%s\n' "$_pp_added" | awk '
314
+ /^\+\+\+ b\// { f = substr($0, 7); next }
315
+ /^\+/ { print f "\t" substr($0, 2) }
316
+ ' | psa_scan_tagged; then
317
+ echo " ✅ FH Pre-Publish: no operator-private token in the TEXT added by ${_pp_count} commit(s)"
318
+ echo " (not covered: annotated-tag messages · binary blobs — named residuals)"
319
+ else
320
+ if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
321
+ echo " ⚠️ pushing a flagged token by PUBLIC_SURFACE_OK=1 (conscious, reviewed)"
322
+ printf '%s PUBLIC_SURFACE_OK override (git push, content hit in pushed history)\n' \
323
+ "$(date +%Y-%m-%dT%H:%M:%S)" >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
324
+ else
325
+ echo ""
326
+ echo "══════════════════════════════════════════════"
327
+ echo " ⛔ FH Pre-Publish Gate (pre-push) — token in the history being pushed"
328
+ echo "══════════════════════════════════════════════"
329
+ echo " A push publishes every commit in the range, not just the tip. Rewrite the offending"
330
+ echo " commit(s) (git rebase -i / filter-repo) — removing the line in a NEW commit does not"
331
+ echo " un-publish it once pushed."
332
+ echo " Deliberate mention: PUBLIC_SURFACE_OK=1 git push … (logged)"
333
+ echo "══════════════════════════════════════════════"
334
+ exit 1
335
+ fi
336
+ fi
337
+ fi
338
+ fi
339
+ fi
340
+
341
+
342
+ # ── Load-bearing change: the cross-family leg must have RUN, not just been stated ─────────────
343
+ # The commit hook requires a `crossfamily:` line to EXIST (it blocks silence, not a 'none'), because
344
+ # a commit is local and reversible. A push is where the change leaves this machine and becomes
345
+ # something another party will review — and the review that currently catches this defect class is
346
+ # not a human reading a diff, it is someone RUNNING A HARNESS over the PR. That catch is real and it
347
+ # is what this leg pulls forward: if a different-family auditor is going to find it anyway, it should
348
+ # find it before the PR exists, not after, so the fix and the skill-strengthening land in the same
349
+ # breath instead of as a follow-up round.
350
+ # Same escalation shape as the confidentiality instrument above: WARN at commit, REQUIRE at the
351
+ # boundary that publishes. `none` is still an acceptable answer here — but it must be a REASON, not
352
+ # an empty field, and the push is where that answer stops being free.
353
+ _LB_RAW=""
354
+ while IFS= read -r _r; do
355
+ [ -z "$_r" ] && continue
356
+ _LB_RAW="$_LB_RAW$SEP$(git -c core.quotePath=false log --name-only --format= $_r 2>/dev/null || true)"
357
+ done <<LBR
358
+ $PUSH_RANGES
359
+ LBR
360
+ _LB_FILES=$(printf '%s\n' "$_LB_RAW" \
361
+ | grep -E '(templates/\.git-hooks/|scripts/(fh-gate|degrade_direction_scan|universal_guard_check|public_surface_scan_files|gate_pathspec_check|predelete_check)\.sh|\.claude/rules/\.public-surface-patterns)' \
362
+ | sort -u || true)
363
+ # Also FH-dev-scoped: the marker this requires lives in tracks/_meta, which only an FH checkout has.
364
+ # An outside contributor pushing a gate file cannot produce an FH marker, and blocking them would make
365
+ # the repo hostile to contribution while protecting nothing they could act on.
366
+ if [ -n "$PUSH_RANGES" ] && [ -n "$_LB_FILES" ] && [ -d "$REPO_ROOT/tracks/_meta" ]; then
367
+ _cf_marker=$(ls -t "$REPO_ROOT"/tracks/_meta/.axes_23_passed_*.marker 2>/dev/null | head -1)
368
+ _cf_line=$(grep -m1 -E '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$_cf_marker" 2>/dev/null || true)
369
+ if [ -z "$_cf_line" ]; then
370
+ echo ""
371
+ echo "══════════════════════════════════════════════"
372
+ echo " ⛔ FH Load-Bearing Change Gate (pre-push) — no cross-family leg recorded"
373
+ echo "══════════════════════════════════════════════"
374
+ echo " This push carries gate / verdict / irreversible-surface code, and no marker records"
375
+ echo " whether a DIFFERENT-family auditor saw it."
376
+ echo ""
377
+ echo " A same-family reviewer shares the author's optimistic reading; that is the whole reason"
378
+ echo " this gate exists. The catch usually happens anyway — later, when someone runs a harness"
379
+ echo " over the PR. Running it now is the same work, one round earlier."
380
+ echo ""
381
+ echo " Record the answer in the Axes 2-3 marker, then push:"
382
+ echo " crossfamily: <engine/model> — <rounds, findings, verdict>"
383
+ echo " crossfamily: none — <why none was reachable or needed>"
384
+ echo " Conscious exception: PUBLIC_SURFACE_OK=1 git push … (logged)"
385
+ echo "══════════════════════════════════════════════"
386
+ if [ "${PUBLIC_SURFACE_OK:-0}" != "1" ]; then exit 1; fi
387
+ echo " ⚠️ proceeding without a recorded cross-family leg by PUBLIC_SURFACE_OK=1"
388
+ printf '%s PUBLIC_SURFACE_OK override (git push, no cross-family leg recorded)\n' \
389
+ "$(date +%Y-%m-%dT%H:%M:%S)" >> "$REPO_ROOT/tracks/_meta/.psa_override_log" 2>/dev/null || true
390
+ else
391
+ echo " ✅ FH Load-Bearing Change Gate: $(printf '%s' "$_cf_line" | cut -c1-88)"
392
+ fi
393
+ fi
394
+
395
+ # ── PR-only policy for the integration branch (operator decision 2026-07-20) ──────
396
+ # WHY THIS EXISTS LOCALLY: it is the SHIFT-LEFT half of a two-layer floor.
397
+ # History: on 2026-07-20 the server required a PR but had `enforce_admins: false`, so an admin
398
+ # push satisfied the API and only printed "Bypassed rule violations" — a NOTICE, not a block.
399
+ # A rule that announces its own bypass is not a floor ([[feedback_non_defeasible_floor]]).
400
+ # The operator then flipped `enforce_admins: true` (+ `required_approving_review_count: 0` so a
401
+ # solo operator can still merge their own PR), so the server IS now the hard floor.
402
+ # This hook still earns its place: it fails at push time with the actual remedy in the message,
403
+ # instead of a bare server rejection, and it keeps working if the server setting is ever relaxed.
404
+ # It is deliberately NOT the hard floor — a client-side hook is bypassable by `--no-verify`.
405
+ # Scope: blocks the PUSH, not the merge — `gh pr merge` operates server-side and is unaffected.
406
+ if [ -n "$DIRECT_MAIN" ] && [ "${MAIN_PUSH_OK:-0}" != "1" ]; then
407
+ echo ""
408
+ echo "══════════════════════════════════════════════"
409
+ echo " ⛔ FH PR-Only Policy (pre-push)"
410
+ echo "══════════════════════════════════════════════"
411
+ echo " Direct push to the integration branch:$DIRECT_MAIN"
412
+ echo ""
413
+ echo " main is PR-only (operator decision 2026-07-20). The server enforces this too"
414
+ echo " (enforce_admins: true, review_count: 0) — this hook just fails earlier, with the fix."
415
+ echo ""
416
+ echo " Normal path:"
417
+ echo " git switch -c <branch> && git push -u origin <branch>"
418
+ echo " gh pr create --fill"
419
+ echo " # after review: gh pr merge --squash --delete-branch --admin"
420
+ echo ""
421
+ echo " Deliberate exception (explicit, logged — mirrors DESTRUCTIVE_OP_OK / PUBLIC_SURFACE_OK):"
422
+ echo " MAIN_PUSH_OK=1 git push …"
423
+ exit 1
424
+ fi
425
+ if [ -n "$DIRECT_MAIN" ]; then
426
+ echo " ⚠️ FH PR-Only Policy: direct push to$DIRECT_MAIN allowed by MAIN_PUSH_OK=1 (conscious, gated intent)"
427
+ fi
428
+
429
+ # ── Session-close check (CLAUDE.md §Session Wrap-up ①–⑥) ─────────────────────────
430
+ # WIRED 2026-07-20. Before this, scripts/session_close_check.sh was referenced ONLY by prose
431
+ # (CLAUDE.md + 2 knowledge docs + itself — grep-verified 0 hook references), while CLAUDE.md
432
+ # advertised it as a "mechanical floor" that "blocks the push step". Nothing ran it. A floor that
433
+ # only runs when the session remembers to run it is not a floor ([[feedback_non_defeasible_floor]],
434
+ # §gate-locality). Found by a Fable judgment pass on CLAUDE.md residency, source-closed by grep.
435
+ #
436
+ # WHY ADVISORY BY DEFAULT (and not a hard block on every push): the script's blocking invariants are
437
+ # CLOSE-TIME invariants. ⑤ card-last requires the session card to be the NEWEST close artifact — but
438
+ # CLAUDE.md separately mandates appending to fh_completed_<date>.md **immediately** during the session.
439
+ # So mid-session, an obedient runner necessarily makes the card momentarily stale. Blocking every push
440
+ # on ⑤ would put two documented rules in direct conflict and train the operator to --no-verify, which
441
+ # would disarm the Destructive-Op gate below it. Degrade direction is defensible here because an
442
+ # ordinary branch push is REVERSIBLE (§Irreversibility Surface-Class Degrade Invariant: reversible
443
+ # surface → advisory; only publish/delete/rewrite fail closed).
444
+ #
445
+ # ENFORCING FORM (use at actual session close, step ⑥): FH_SESSION_CLOSE=1 git push …
446
+ if [ -x "$REPO_ROOT/scripts/session_close_check.sh" ] || [ -f "$REPO_ROOT/scripts/session_close_check.sh" ]; then
447
+ # ⚠️ `< /dev/null` is DEFENSE IN DEPTH — keep it, but it is not what makes this safe.
448
+ # git feeds the push ref list on the hook's STDIN, and a subprocess started here INHERITS stdin.
449
+ # If such a helper reads stdin it drains the ref list; a classification loop running AFTERWARDS
450
+ # would then see ZERO refs, leave every DEL_/FORCED_ var empty, and fall into the
451
+ # "nothing destructive → ordinary ff push → exit 0" path — silently DISARMING the Destructive-Op
452
+ # gate on exactly the push it exists to stop. Empirically reproduced 2026-07-20.
453
+ # THE STRUCTURAL FIX IS THE POSITION, NOT THIS REDIRECT: the classification loop above has already
454
+ # consumed stdin into variables before we get here, so draining fd 0 now costs nothing.
455
+ # If anyone ever moves this block back above that loop, the redirect alone is a thin guard —
456
+ # scripts/test_prepush_stdin_integrity.sh asserts the ordering for exactly that reason.
457
+ _SC_OUT=$(bash "$REPO_ROOT/scripts/session_close_check.sh" "$REPO_ROOT" 2>&1 < /dev/null); _SC_RC=$?
458
+ if [ "${FH_SESSION_CLOSE:-0}" = "1" ]; then
459
+ printf '%s\n' "$_SC_OUT"
460
+ if [ "$_SC_RC" -ne 0 ]; then
461
+ echo ""
462
+ echo " ⛔ FH Session-Close Check: close invariant violated (FH_SESSION_CLOSE=1 → enforcing)."
463
+ echo " Fix the ❌ line(s) above — card-last means ⑤ runs AFTER ①–④-c, never before."
464
+ exit 1
465
+ fi
466
+ echo " ✅ FH Session-Close Check: close state consistent."
467
+ elif [ "$_SC_RC" -ne 0 ]; then
468
+ # Not a close push: surface the violations, never block.
469
+ printf '%s\n' "$_SC_OUT" | grep '❌' || true
470
+ echo " ⚠️ FH Session-Close Check: close invariant(s) violated (advisory — this is not a close push)."
471
+ echo " At session close, enforce with: FH_SESSION_CLOSE=1 git push …"
472
+ fi
473
+ fi
474
+
475
+ # NOTE ON POSITION (moved here 2026-07-20 after a cross-family audit returned NOT-CONVERGED):
476
+ # this block ORIGINALLY sat above the ref-reading loop. That was a priority inversion — an
477
+ # ADVISORY check placed above a BLOCKING safety gate — and it opened an fd-0 hazard: the helper
478
+ # subprocess inherits stdin, git delivers the push ref list on stdin, so a helper that read stdin
479
+ # would drain the ref list and the loop below would classify ZERO destructive refs and allow the
480
+ # push. Running AFTER classification removes the hazard structurally (the refs are already read
481
+ # into variables); the `< /dev/null` guard is kept as defense in depth, not as the fix.
482
+ # Nothing destructive and nothing unclassifiable → ordinary ff push / new branch → allow.
483
+ if [ -z "$DEL_BRANCHES$DEL_OTHER$FORCED_REFS$UNCLASSIFIED" ]; then
484
+ exit 0
485
+ fi
486
+
487
+ # ── Explicit, logged operator acknowledgment (enumerate+recover done out-of-band) ──
488
+ if [ "${DESTRUCTIVE_OP_OK:-0}" = "1" ]; then
489
+ echo " ⚠️ FH Destructive-Op Gate: allowed by DESTRUCTIVE_OP_OK=1 (conscious, gated intent)"
490
+ LOG="$REPO_ROOT/tracks/_meta/.destructive_op_override_log"
491
+ if mkdir -p "$REPO_ROOT/tracks/_meta" 2>/dev/null && \
492
+ printf '%s DESTRUCTIVE_OP_OK override — del:%s other:%s forced:%s unclassified:%s\n' \
493
+ "$(date +%Y-%m-%dT%H:%M:%S)" "${DEL_BRANCHES:-none}" "${DEL_OTHER:-none}" \
494
+ "${FORCED_REFS:-none}" "${UNCLASSIFIED:-none}" >> "$LOG" 2>/dev/null; then
495
+ :
496
+ else
497
+ echo " ⚠️ (override could not be logged to $LOG — proceeding, but this override is UNRECORDED)"
498
+ fi
499
+ exit 0
500
+ fi
501
+
502
+ echo "══════════════════════════════════════════════"
503
+ echo " ⛔ FH Destructive-Op Gate (pre-push)"
504
+ echo "══════════════════════════════════════════════"
505
+
506
+ BLOCK=0
507
+
508
+ # ── Force / non-ff pushes: always block (a rewrite always loses the old commits) ──
509
+ for r in $FORCED_REFS; do
510
+ echo " FORCE / non-fast-forward (history rewrite): $r"
511
+ echo " → take a bundle backup first: git bundle create backup.bundle --all"
512
+ BLOCK=1
513
+ done
514
+
515
+ # ── Unclassifiable (remote tip not fetched): fail-closed, accurate message ──
516
+ for r in $UNCLASSIFIED; do
517
+ echo " CANNOT CLASSIFY (remote tip not fetched): $r"
518
+ echo " → run 'git fetch' so a force-push can be distinguished from a fast-forward, then re-push."
519
+ BLOCK=1
520
+ done
521
+
522
+ # ── Tag / notes deletes: block + ref-specific note (predelete_check walks branches only) ──
523
+ for r in $DEL_OTHER; do
524
+ echo " DELETE (non-branch ref): $r"
525
+ echo " → tags/notes carry no unique paths but may be external anchors (a release tag, a"
526
+ echo " published note). Confirm nothing references it before deleting."
527
+ BLOCK=1
528
+ done
529
+
530
+ # ── Branch deletes: per-ref verdict (SAFE auto-allows; CHECK/REVIEW block) ──
531
+ # Makes the enumerate load-bearing instead of decorative: a fully-merged branch (nothing
532
+ # unique lost) passes; a branch with unique paths or commits off base is held.
533
+ if [ -n "$DEL_BRANCHES" ]; then
534
+ if ! git rev-parse --verify --quiet "$BASE" >/dev/null 2>&1; then
535
+ echo " ⚠️ base '$BASE' unresolvable — cannot verify SAFE → all branch deletes BLOCKED (fail-closed)."
536
+ echo " (set FH_DESTRUCTIVE_BASE=<ref> or fetch the base, then re-push.)"
537
+ for r in $DEL_BRANCHES; do echo " DELETE (branch, unverified): $r"; done
538
+ BLOCK=1
539
+ else
540
+ BASE_BRANCH="${BASE##*/}" # last path component: origin/main / refs/remotes/origin/main / refs/heads/main → main
541
+ for pair in $DEL_BRANCHES; do
542
+ tip="${pair%%|*}"; r="${pair#*|}" # SHA is before the first '|' (hex, no '|'); ref is everything after
543
+ # Deleting the integration branch itself is never "SAFE" — it is trivially "merged into
544
+ # itself" (n=0, uniq=0) and would auto-pass. Guard it explicitly.
545
+ if [ "$r" = "refs/heads/${BASE_BRANCH}" ] || [ "$r" = "refs/heads/master" ] || [ "$r" = "refs/heads/main" ]; then
546
+ echo " DELETE (branch): $r — PROTECTED: this is the integration branch → BLOCKED (never auto-SAFE)"
547
+ BLOCK=1; continue
548
+ fi
549
+ if [ "$tip" = "$ZERO" ] || ! git cat-file -e "${tip}^{commit}" 2>/dev/null; then
550
+ echo " DELETE (branch): $r — tip not local, cannot verify → BLOCKED (fail-closed)"
551
+ BLOCK=1; continue
552
+ fi
553
+ n=$(git rev-list --count "$BASE..$tip" 2>/dev/null || echo "?")
554
+ uniq=$(comm -23 \
555
+ <(git ls-tree -r --name-only "$tip" 2>/dev/null | sort) \
556
+ <(git ls-tree -r --name-only "$BASE" 2>/dev/null | sort) | grep -c . || true)
557
+ if [ "${uniq:-0}" -gt 0 ] 2>/dev/null; then
558
+ echo " DELETE (branch): $r — REVIEW: $uniq unique path(s), $n commit(s) off $BASE → recover BEFORE deleting"
559
+ BLOCK=1
560
+ elif [ "${n:-0}" != "0" ]; then
561
+ echo " DELETE (branch): $r — CHECK: $n commit(s) off $BASE, 0 unique paths → judged content look first"
562
+ echo " (a shared file may hold NEWER content, e.g. an unmerged session card — the silent-loss class)"
563
+ BLOCK=1
564
+ else
565
+ echo " DELETE (branch): $r — SAFE: fully merged into $BASE, nothing unique lost → allowed"
566
+ fi
567
+ done
568
+ fi
569
+ fi
570
+
571
+ if [ "$BLOCK" -eq 0 ]; then
572
+ echo ""
573
+ echo " ✅ all destructive refs verified SAFE (fully merged) — allowing push."
574
+ exit 0
575
+ fi
576
+
577
+ echo ""
578
+ echo " Gate order: enumerate → recover (integrate live un-merged state to the base) → destroy."
579
+ echo " This hook is the honest-model floor (it stops a forgotten gate). The hard floor for an"
580
+ echo " adversarial/injected agent is SERVER-SIDE branch protection (GitHub: Restrict deletions /"
581
+ echo " Restrict force pushes) — a client-side hook is bypassable by design."
582
+ echo ""
583
+ echo " When enumerate + recover are done, the OPERATOR re-pushes with the explicit acknowledgment:"
584
+ echo " DESTRUCTIVE_OP_OK=1 git push …"
585
+ exit 1