@chrono-meta/fh-gate 2.6.0 β†’ 2.7.0

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 (44) hide show
  1. package/.claude/rules/fh_4axis_gate.md +26 -3
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/AGENTS.md +24 -3
  4. package/CLAUDE.md +39 -12
  5. package/README.ja.md +32 -8
  6. package/README.ko.md +32 -8
  7. package/README.md +112 -15
  8. package/README.zh.md +28 -8
  9. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +19 -5
  10. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +12 -2
  11. package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +32 -0
  12. package/knowledge/shared/harness-core/ship_readiness_gate.md +77 -8
  13. package/knowledge/shared/learnings/subagent_invocations_log.yaml +90 -0
  14. package/knowledge/shared/rules/knowledge_layer_seam.md +1 -1
  15. package/package.json +13 -2
  16. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  17. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-meta/CHANGELOG.md +111 -0
  19. package/plugins/fh-meta/agents/persona-innovator.md +170 -0
  20. package/plugins/fh-meta/skills/public-surface-audit/SKILL_detail.md +16 -1
  21. package/plugins/fh-meta/skills/steel-quench/SKILL.md +97 -0
  22. package/scripts/adapters/peer_resolve.sh +58 -6
  23. package/scripts/cluster_capability_scan.sh +42 -13
  24. package/scripts/digest_landing_check.sh +142 -2
  25. package/scripts/fh_hub_identity.sh +83 -0
  26. package/scripts/fh_session_load.sh +53 -5
  27. package/scripts/fh_track_resolve.sh +114 -0
  28. package/scripts/field_canon_preload.sh +50 -5
  29. package/scripts/package_coverage_check.sh +8 -0
  30. package/scripts/prior_art_prompt.sh +168 -0
  31. package/scripts/psa_scan_lib.sh +201 -15
  32. package/scripts/residency_admission_check.sh +204 -0
  33. package/scripts/selfcheck.sh +88 -0
  34. package/scripts/test_adapter_lanes.sh +67 -2
  35. package/scripts/test_heavy_classifier_lanes.sh +144 -0
  36. package/scripts/test_marker_defense_lanes.sh +152 -0
  37. package/scripts/test_marker_soul_check_lanes.sh +211 -0
  38. package/scripts/test_prior_art_prompt_lanes.sh +128 -0
  39. package/scripts/test_psa_singlefile_lanes.sh +351 -1
  40. package/scripts/test_residency_admission_lanes.sh +60 -0
  41. package/scripts/test_track_resolve_lanes.sh +158 -0
  42. package/templates/.git-hooks/pre-commit +400 -4
  43. package/templates/.git-hooks/pre-push +17 -2
  44. package/templates/settings.PriorArt.snippet.json +15 -0
@@ -356,6 +356,7 @@ else
356
356
  fi
357
357
  else
358
358
  PSA_LEAK=0
359
+ PSA_DEAD=0
359
360
  # NUL-delimited (regression caught by the refactor's own cross-family review): the pre-refactor
360
361
  # loop read this list with `read -r -d ''`, and the rewrite dropped back to a line-oriented read.
361
362
  # `core.quotePath=false` stops git quoting NON-ASCII names, but a path holding a backslash or a
@@ -367,7 +368,11 @@ else
367
368
  added=$(git diff --cached -- "$f" 2>/dev/null | grep -E '^\+' | sed 's/^\+//' || true)
368
369
  [ -z "$added" ] && continue
369
370
  # Per-line pass, path-tagged so the LOW file allowlist applies.
370
- if ! printf '%s\n' "$added" | sed "s|^|$f |" | psa_scan_tagged; then PSA_LEAK=1; fi
371
+ # πŸŸ₯ rc λ₯Ό Β«λΉ„μ˜Β» 으둜 λ­‰κ°œμ§€ 마라 (cross-family, 2026-08-21): 1=유좜 Β· 3=계기 사망.
372
+ # λ‘˜μ„ ν•©μΉ˜λ©΄ μ•„λž˜ PUBLIC_SURFACE_OK κ°€ **계기 μ‚¬λ§κΉŒμ§€ «승인된 유좜»둜 톡과**μ‹œν‚¨λ‹€.
373
+ _psa_rc=0
374
+ printf '%s\n' "$added" | sed "s|^|$f |" | psa_scan_tagged || _psa_rc=$?
375
+ case "$_psa_rc" in 0) ;; 3) PSA_DEAD=1 ;; *) PSA_LEAK=1 ;; esac
371
376
  # Split-token backstop (pre-commit ONLY β€” this surface is a DIFF, so a literal can be wrapped
372
377
  # mid-word across two added lines and neither line matches). TIGHT-join, no separator, so
373
378
  # "fh-\nbe" becomes "fh-be". Accepted residual, verified not a missed fix: two whitespace-
@@ -375,15 +380,31 @@ else
375
380
  # PUBLIC_SURFACE_OK escape. A sentinel separator would close the fusion but RE-OPEN the split
376
381
  # catch, which is the more important case since real literals have no internal whitespace.
377
382
  joined=$(printf '%s' "$added" | tr -d '\n\r')
378
- if ! printf '%s\t%s\n' "$f" "$joined" | psa_scan_tagged >/dev/null 2>&1; then
383
+ _psa_rc2=0
384
+ printf '%s\t%s\n' "$f" "$joined" | psa_scan_tagged >/dev/null 2>&1 || _psa_rc2=$?
385
+ [ "$_psa_rc2" = "3" ] && PSA_DEAD=1
386
+ if [ "$_psa_rc2" != "0" ] && [ "$_psa_rc2" != "3" ]; then
379
387
  # Only report if the per-line pass did not already flag this file, to avoid double-reporting.
380
388
  if ! printf '%s\n' "$added" | sed "s|^|$f |" | psa_scan_tagged >/dev/null 2>&1; then :; else
389
+ # πŸŸ₯ **ν‘œμ‹œ μ „μš© β€” νŒμ • κ²½λ‘œκ°€ μ•„λ‹ˆλ‹€** (2026-08-21 λ°°μ„  리뷰 R-1). 이 νŒŒμ΄ν”„λŠ” 비말단이라
390
+ # `$?` κ°€ `sed` 것이닀. κ·Έλž˜λ„ λ˜λŠ” μ΄μœ λŠ” 도달 쑰건이 **이미 «유좜 ν™•μ •Β»** 이고 λ°”λ‘œ
391
+ # λ‹€μŒ 쀄이 `PSA_LEAK=1` 을 무쑰건 μ„Έμš°κΈ° λ•Œλ¬Έμ΄λ‹€. νŒμ •μ€ μœ„ `_psa_rc` 3λΆ„κΈ°κ°€ μ₯”λ‹€.
392
+ # ⚠️ 이 주석이 μ—†μœΌλ©΄ λ‹€μŒ μ‚¬λžŒμ΄ νŒμ • 경둜둜 읽고 Β«κ³ μΉœλ‹€Β» β€” 그게 이 쀄을 μœ„ν—˜ν•˜κ²Œ λ§Œλ“ λ‹€.
381
393
  printf '%s\t%s\n' "$f" "$joined" | psa_scan_tagged | sed 's/leak β€”/leak (line-split) β€”/'
382
394
  PSA_LEAK=1
383
395
  fi
384
396
  fi
385
397
  done < <(git -c core.quotePath=false diff --cached --name-only --no-renames --diff-filter=ACMR -z 2>/dev/null || true)
386
- if [ "$PSA_LEAK" -eq 1 ]; then
398
+ # πŸŸ₯ 계기 사망은 override λŒ€μƒμ΄ **μ•„λ‹ˆλ‹€.** override λŠ” Β«μ•Œκ³  μžˆλŠ” 언급»을 μŠΉμΈν•˜λŠ” 것이지
399
+ # Β«μ•ˆ 잰 ν‘œλ©΄Β»μ„ μŠΉμΈν•˜λŠ” 게 μ•„λ‹ˆλ‹€. μ΄ˆνŒμ€ 1 κ³Ό 3 을 ν•©μ³μ„œ, PUBLIC_SURFACE_OK=1 이면
400
+ # μŠ€μΊλ„ˆκ°€ 죽은 μƒνƒœλ„ ν†΅κ³Όν–ˆλ‹€ β€” λΉ„κ°€μ—­ ν‘œλ©΄μ—μ„œ κ°€μž₯ λ‚˜μœ 쑰합이닀.
401
+ if [ "${PSA_DEAD:-0}" -eq 1 ]; then
402
+ echo " β›” INSTRUMENT DEAD β€” the public-surface scan did NOT run (rc=3). NOT SCANNED is not clean."
403
+ echo " PUBLIC_SURFACE_OK does NOT cover this. Fix the scanner, then commit:"
404
+ echo " Β· run under bash (zsh special vars can blank PATH inside the matcher)"
405
+ echo " Β· confirm psa_load ran and PSA_STREAM is non-empty"
406
+ FAILED=1
407
+ elif [ "$PSA_LEAK" -eq 1 ]; then
387
408
  if [ "${PUBLIC_SURFACE_OK:-0}" = "1" ]; then
388
409
  echo " ⚠️ public-surface hit(s) allowed by PUBLIC_SURFACE_OK=1 (conscious, reviewed intent)"
389
410
  echo "$(date +%Y-%m-%dT%H:%M:%S) PUBLIC_SURFACE_OK override β€” branch $BRANCH β€” review suppressed hit(s) above" \
@@ -575,6 +596,86 @@ marker_recreate_hint() {
575
596
  # VALUE is a closed enum; the three degrade values additionally require a non-vacuous reason
576
597
  # on the SAME line (keeping the established one-line marker convention rather than adding a
577
598
  # second field). Returns 0 = accepted, 1 = block.
599
+ validate_defense_leg() { # $1 = marker path β€” fires ONLY at the floor tiers (see call site)
600
+ # Wave 1-D defense line. Absorbed from a sibling harness 2026-08-20, but the machine is OURS:
601
+ # πŸŸ₯ the source document claimed "the commit hook reads that line" and "pinned by
602
+ # test_marker_floor_lanes.sh" β€” measured twice with a control (`crossfamily` 21 hits), BOTH were
603
+ # 0 hits there. Porting the prose without building this function would have imported the phantom.
604
+ #
605
+ # WHY IT EXISTS: measured n=6 in the origin field β€” a floor-tier pass executes the attack angles
606
+ # without defect but does NOT spontaneously ask these three of its own numbers. That is a
607
+ # checklist gap, not a capability gap, and a harness whose behaviour depends on which model drives
608
+ # it is defective by sonnet_floor_doctrine.md rather than merely limited.
609
+ # FH-side corroboration (2026-08-20, this session, n=1): a comparison was published from two
610
+ # DIFFERENT package versions and read as a defect ("15 lanes vs 13 lanes β€” two vanished"). The
611
+ # 비ꡐ곡정성 question β€” *were the two arms measured under the same conditions?* β€” asks exactly
612
+ # that, and the claim was retracted only because a later step happened to surface the version gap.
613
+ #
614
+ # SCOPE, and why it is not wider: `below-floor` occurs 0 times across the existing marker corpus,
615
+ # so gating on it alone would be a decoration that never fires. `sonnet-floor` occurs 11 times.
616
+ # Both are gated here; the wide reading ("any marker carrying numbers") is deliberately NOT taken β€”
617
+ # it would price this axis at a near-universal rate, which is the over-trigger Β§7 rejects.
618
+ #
619
+ # πŸŸ₯ WHAT WAS NOT IMPORTED: the origin also amends its floor rule so that below-floor + this line
620
+ # + a crossfamily record PASSES WITHOUT the operator ack. That is a LOOSENING of an existing FH
621
+ # gate and it is not adopted. Here this leg is purely additive: below-floor still requires
622
+ # below-floor-ack, unchanged.
623
+ local m="$1" line r f e
624
+ if [ ! -f "$m" ] || ! grep -qE '^[[:space:]]*axis2-defense:[[:space:]]*[^[:space:]]' "$m"; then
625
+ echo " ❌ FAIL β€” floor-tier marker with no 'axis2-defense:' line."
626
+ echo " At the floor tier the attack angles run but the three defense questions do not get"
627
+ echo " asked spontaneously. Ask them OF YOUR OWN findings and numbers, then record:"
628
+ echo " axis2-defense: reproducibility=<exact command or file:line another session runs>"
629
+ echo " fairness=<reps/inputs/env named for BOTH arms>"
630
+ echo " estimation-layer=<per number: measured | estimate | quotation;"
631
+ echo " for measured, what showed the instrument works here>"
632
+ echo " Append to: $m"
633
+ return 1
634
+ fi
635
+ if [ "$(grep -cE '^[[:space:]]*axis2-defense:' "$m")" -gt 1 ]; then
636
+ echo " ❌ FAIL β€” marker carries MORE THAN ONE 'axis2-defense:' line."
637
+ grep -nE '^[[:space:]]*axis2-defense:' "$m" | sed 's/^/ /'
638
+ echo " The reader takes the FIRST, so an appended correction is silently shadowed."
639
+ echo " Leave exactly one line."
640
+ return 1
641
+ fi
642
+ # The whole value, including any continuation lines, up to the next top-level field.
643
+ line=$(sed -n '/^[[:space:]]*axis2-defense:/,/^[a-z0-9-]*:[[:space:]]/p' "$m" | sed -E '2,$ { /^[a-z0-9-]+:[[:space:]]/d; }' | tr '\n' ' ')
644
+ r=$(printf '%s' "$line" | sed -nE 's/.*reproducibility=([^ ].*)/\1/p' | sed -E 's/(fairness=|estimation-layer=).*//')
645
+ f=$(printf '%s' "$line" | sed -nE 's/.*fairness=([^ ].*)/\1/p' | sed -E 's/(reproducibility=|estimation-layer=).*//')
646
+ e=$(printf '%s' "$line" | sed -nE 's/.*estimation-layer=([^ ].*)/\1/p' | sed -E 's/(reproducibility=|fairness=).*//')
647
+ local missing=""
648
+ [ -n "$r" ] || missing="$missing reproducibility"
649
+ [ -n "$f" ] || missing="$missing fairness"
650
+ [ -n "$e" ] || missing="$missing estimation-layer"
651
+ if [ -n "$missing" ]; then
652
+ echo " ❌ FAIL β€” axis2-defense: missing sub-answer(s):$missing"
653
+ echo " All three are required. A partially-filled form is not a defense β€” the question you"
654
+ echo " skipped is the one you did not want to answer."
655
+ return 1
656
+ fi
657
+ # Non-vacuity. `ok` / `yes` / `n/a` / `done` are a FILLED FORM, not an answer β€” the same shape the
658
+ # crossfamily leg rejects. Anything under 12 chars cannot name a command, a reps count, or a layer.
659
+ local bad=""
660
+ for pair in "reproducibility:$r" "fairness:$f" "estimation-layer:$e"; do
661
+ local k="${pair%%:*}" v="${pair#*:}"
662
+ v=$(printf '%s' "$v" | sed -E 's/^[[:space:]]+//; s/[[:space:]]+$//')
663
+ case "$(printf '%s' "$v" | tr 'A-Z' 'a-z')" in
664
+ ok|yes|y|n/a|na|none|done|true|-|tbd|todo) bad="$bad $k(vacuous:'$v')" ;;
665
+ *) [ "${#v}" -ge 12 ] || bad="$bad $k(too short:'$v')" ;;
666
+ esac
667
+ done
668
+ if [ -n "$bad" ]; then
669
+ echo " ❌ FAIL β€” axis2-defense: vacuous answer(s):$bad"
670
+ echo " 'ok'/'yes'/'n/a' is a filled form, not an answer. Name the command, the reps, the layer."
671
+ echo " πŸŸ₯ The hook checks PRESENCE and NON-VACUITY only. It cannot check whether the answers"
672
+ echo " are TRUE β€” that stays with the operator and the weekly audit, exactly like every other"
673
+ echo " marker field. Do not read a PASS here as a claim that these answers were verified."
674
+ return 1
675
+ fi
676
+ return 0
677
+ }
678
+
578
679
  validate_crossfamily_leg() { # $1 = marker path
579
680
  local m="$1" line val reason fams
580
681
  if [ ! -f "$m" ] || ! grep -qE '^[[:space:]]*crossfamily:[[:space:]]*[^[:space:]]' "$m"; then
@@ -971,6 +1072,263 @@ validate_thirdparty_leg() { # $1 = marker path
971
1072
  return 0
972
1073
  }
973
1074
 
1075
+ SOUL_PRESENT_GRACE_DATE="2026-08-21"
1076
+ validate_soul_present_leg() { # $1 = marker path
1077
+ # ── β‘ μ˜ν˜Ό 쑴재 검사 (2026-08-21) ──────────────────────────────────────────────
1078
+ # CLAUDE.md §자기 λŒ€μ‘° has mandated an β‘ μ˜ν˜Ό line (성곡 μ •μ˜ / μ ˆλŒ€ μ•ˆ 함) on every marker
1079
+ # since 2026-08-09. MEASURED on the real corpus this day, with the instrument calibrated over
1080
+ # EIGHT spellings and two controls (`axes-run` and `axis2-engine` = 98/98): of 98 post-grace
1081
+ # markers, 61 carry it and **37 (37.8%) carry no form of it at all**.
1082
+ #
1083
+ # πŸŸ₯ The first pass of that same measurement grepped only the exact `β‘ μ˜ν˜Ό` string and returned
1084
+ # "92.9% missing". That would have been a false alarm ([[feedback_measured_scope_vs_claimed_scope]]
1085
+ # β€” hand-enumerating spellings). The union + hand-verification of one negative sample is what
1086
+ # makes the 37 real: `.axes_23_passed_feat_target-freeze-gate_2026-08-18.marker` was opened by
1087
+ # eye β€” a panel-reviewed, 28-lane, controls-alive marker with every OTHER field filled, and no
1088
+ # soul line in any spelling.
1089
+ #
1090
+ # πŸŸ₯ WHY THIS IS NOT THE THING THE β‘ μ˜ν˜Ό COMMENT BELOW DECLINES. That comment refuses to check
1091
+ # Β«was it written BEFORE designingΒ» β€” unverifiable from a file, correctly excluded, and this
1092
+ # lane does not check it. PRESENCE is a different question and is trivially decidable. The
1093
+ # impossible question carried the possible one out on its ticket.
1094
+ #
1095
+ # SCOPE β€” channel, not judgment. Asserts the field EXISTS and is not vacuous. Says nothing about
1096
+ # whether the success definition is good, was honoured, or was written in advance.
1097
+ local m="$1" n body words
1098
+ # Grace: only markers dated on/after the grace date are required to carry it. Retroactive
1099
+ # enforcement would block every in-flight branch, and that over-block trains `--no-verify`,
1100
+ # which would disarm the Destructive-Op gate living in this same hook.
1101
+ local mdate
1102
+ mdate=$(printf '%s' "$m" | sed -nE 's/.*_([0-9]{4}-[0-9]{2}-[0-9]{2})\.marker$/\1/p')
1103
+ # πŸŸ₯ An unparseable date used to `return 0` β€” fail-OPEN. The hook itself builds this filename
1104
+ # as `..._${TODAY}.marker`, so a name without a date is unexpected, not old. Unknown is not
1105
+ # exempt ([[feedback_not_found_is_not_zero_family]]); it falls through to the presence check.
1106
+ [ -z "$mdate" ] || { [ "$mdate" \< "$SOUL_PRESENT_GRACE_DATE" ] && return 0; }
1107
+ # πŸŸ₯ agy (claims axis): the comment above says the MEASUREMENT swept eight spellings; this
1108
+ # regex carries FOUR alternatives (β‘ μ˜ν˜Ό Β· a `soul:` key Β· 성곡[ ]?μ •μ˜ Β· μ ˆλŒ€ μ•ˆ 함). Both are
1109
+ # true and they are different instruments β€” but writing only the first invites reading the
1110
+ # second's coverage off it ([[feedback_rule_misdescribes_its_own_machine]]). The four are the
1111
+ # ones the corpus actually uses as a FIELD; the other four measured spellings appeared only
1112
+ # inside prose, which this leg deliberately excludes.
1113
+ # πŸŸ₯ The soul detector must NOT read the `soul-check:` line itself. codex's alignment fix widened
1114
+ # the pattern and G8 flipped to PASS: `soul-check: reflected(β€¦μ„±κ³΅μ •μ˜β€¦)` matched as its own
1115
+ # evidence β€” self-referential FP ([[feedback_lane_runner_self_referential_fp]]). The claim can
1116
+ # never be its own ground. Same reason comment/prose lines are excluded: a marker that merely
1117
+ # SAYS 성곡 μ •μ˜ in narration has not declared one.
1118
+ # πŸŸ₯ A DECLARATION, not a MENTION. First real use (isolated clone, 2026-08-21) broke this on
1119
+ # attempt #1: this very delta's marker says Β«change: 마컀의 β‘ μ˜ν˜Ό μŠ¬λ‘―μ— μ†ŒλΉ„μ²˜κ°€ 0이던 것을…»
1120
+ # in a prose FIELD VALUE, and the leg reported "β‘ μ˜ν˜Ό present: 10 words" for a marker that
1121
+ # declares nothing. Excluding `#` and `soul-check:` was not enough β€” any field whose value
1122
+ # discusses the axis satisfies a substring test, and markers ABOUT this axis always will.
1123
+ # 39 lanes, codex (diff axis) and agy (claims axis) all missed it; running it once did not
1124
+ # ([[feedback_adversarial_review_not_substitute_for_first_use]]).
1125
+ # So the token must sit at LINE START as the key (a leading markdown `#` is allowed β€” the real
1126
+ # corpus writes Β«## β‘ μ˜ν˜Ό β€” 섀계 전에 μ“΄ …» as a heading, and P7 flipped to BLOCK when the first
1127
+ # version of this regex forbade it. The known-pair caught that in one run).
1128
+ # Prose inside a field VALUE can no longer vote.
1129
+ _soul_src=$(grep -E '^[[:space:]]*#*[[:space:]]*(β‘ μ˜ν˜Ό|soul|μ ˆλŒ€ μ•ˆ 함|성곡 ?μ •μ˜)[[:space:]]*[:β€”-]' "$m" 2>/dev/null \
1130
+ | grep -vE '^[[:space:]]*soul-check[[:space:]]*:')
1131
+ n=$(printf '%s\n' "$_soul_src" | grep -cE 'β‘ μ˜ν˜Ό|^[[:space:]]*soul:|성곡 ?μ •μ˜|μ ˆλŒ€ μ•ˆ 함'); n=${n:-0}
1132
+ if [ "$n" -eq 0 ]; then
1133
+ echo " ❌ FAIL β€” marker carries no β‘ μ˜ν˜Ό line (성곡 μ •μ˜ / μ ˆλŒ€ μ•ˆ 함)."
1134
+ echo " CLAUDE.md §자기 λŒ€μ‘° requires it on every marker. Measured 2026-08-21: 37 of 98"
1135
+ echo " post-grace markers had none β€” including panel-reviewed ones with every other"
1136
+ echo " field filled. Nothing read the field, so nothing noticed."
1137
+ echo " Write, before the design: 성곡 μ •μ˜ = «…». μ ˆλŒ€ μ•ˆ 함 = «…»."
1138
+ echo " πŸŸ₯ This checks PRESENCE, not provenance. Β«Was it written firstΒ» stays unverifiable"
1139
+ echo " and unchecked β€” see the β‘ μ˜ν˜Ό comment below. Presence is not that question."
1140
+ return 1
1141
+ fi
1142
+ # Non-vacuity β€” counted in WORDS, not characters (Korean corpus; ${#} is locale-dependent).
1143
+ body=$(awk '/β‘ μ˜ν˜Ό|^[[:space:]]*soul:|성곡 ?μ •μ˜|μ ˆλŒ€ μ•ˆ 함/{if (!f) f=1}
1144
+ f && /^[[:space:]]*$/{exit}
1145
+ f && seen && /^[[:space:]]*[A-Za-z][A-Za-z0-9_-]*:/{exit}
1146
+ f{print; seen=1}' <<< "$_soul_src")
1147
+ words=$(printf '%s' "$body" | wc -w | tr -d ' '); words=${words:-0}
1148
+ # πŸŸ₯ A DECLARED ABSENCE IS A VALUE, not a gap. CLAUDE.md §자기 λŒ€μ‘° explicitly permits
1149
+ # Β«μ—†μœΌλ©΄ `μ—†μŒ`Β», and the first version of this leg BLOCKED a bare `soul: μ—†μŒ` (2 words) while
1150
+ # PASSING a 33-word paragraph whose content was Β«I did not write oneΒ». That punishes the honest
1151
+ # form and rewards padding β€” [[feedback_unreachable_done_when_trains_evasion]] exactly. Found by
1152
+ # a peer session's first real use of this gate (forge-harness-59, 2026-08-21); self-detection 0,
1153
+ # and 41 lanes + two cross-family panels had all passed.
1154
+ # Same shape as the sibling axes' `UNKNOWN`: recorded, not silent, and never free of a record.
1155
+ # πŸŸ₯ `$body` β€” this leg's own local. The first version read `$soul_body`, which is the OTHER
1156
+ # leg's variable name; unset here, so the case never matched and the fix was inert while the
1157
+ # lane suite stayed green ([[feedback_unedited_parts_of_my_own_file]] Β· half-fix).
1158
+ _sbare=$(printf '%s' "$body" | sed -E 's/^[[:space:]]*#*[[:space:]]*(β‘ μ˜ν˜Ό|soul|μ ˆλŒ€ μ•ˆ 함|성곡 ?μ •μ˜)[[:space:]]*[:β€”-]*//' | tr -d '[:space:].Β·]')
1159
+ case "$_sbare" in
1160
+ μ—†μŒ|μ—†μŒβ€”|none|None|NONE|n/a|N/A|-|ν•΄λ‹Ήμ—†μŒ)
1161
+ echo " ⚠️ β‘ μ˜ν˜Ό: μ—†μŒ β€” declared absent. Recorded, not silent."
1162
+ echo " πŸŸ₯ A marker that declares no success definition cannot carry"
1163
+ echo " soul-check: reflected(...) β€” there is nothing to reflect against."
1164
+ return 0 ;;
1165
+ esac
1166
+ if [ "$words" -lt 6 ]; then
1167
+ echo " ❌ FAIL β€” β‘ μ˜ν˜Ό line is vacuous ($words word(s)). Name the success definition AND"
1168
+ echo " the μ ˆλŒ€ μ•ˆ 함. A one-word placeholder trains the ritual this field exists to avoid."
1169
+ return 1
1170
+ fi
1171
+ echo " βœ… β‘ μ˜ν˜Ό present: $words word(s)"
1172
+ return 0
1173
+ }
1174
+
1175
+ validate_soul_check_leg() { # $1 = marker path
1176
+ # ── β“’ 격리 κ·ΈλΌμš΄λ”© β€” 사전 μ„ μ–ΈμœΌλ‘œ ν™•μž₯ (2026-08-21) ─────────────────────────
1177
+ # πŸŸ₯ NOT a seventh axis. Classified against `fh_three_layer_canon.md Β§1-a-2`, whose
1178
+ # discriminator is explicit: «좕은 Β«μ–Όλ§ˆλ‚˜ μ λŒ€μ μΈκ°€Β»λ‘œ κ°ˆλ¦¬μ§€ μ•ŠλŠ”λ‹€. «무엇을 λ°›μ•˜λŠ”κ°€Β»λ‘œ
1179
+ # κ°ˆλ¦°λ‹€.Β» β“’ 격리 κ·ΈλΌμš΄λ”© receives Β«μ €μžκ°€ μ“΄ λ¬Έμž₯ + μ§€κΈˆμ˜ 트리» β€” which is, character for
1180
+ # character, what this leg receives (β‘ μ˜ν˜Ό is μ €μžκ°€ μ“΄ λ¬Έμž₯; the delta is μ§€κΈˆμ˜ 트리).
1181
+ # The tense differs (사전 μ„ μ–Έ vs 사후 μ£Όμž₯) and tense is a posture, not an axis, exactly as
1182
+ # that section says adversarialness is (Β«μ λŒ€μ„±μ€ μžμ„Έμ§€ 좕이 μ•„λ‹ˆλ‹€Β»).
1183
+ # Precedent, in the canon itself: the author attributed 5 findings to β““ and two independent
1184
+ # cross-family classifiers reclassified 3 of them β€” Β«κ·Έ 셋은 좕이 ν•„μš”ν–ˆλ˜ 게 μ•„λ‹ˆλΌ λ‹€λ₯Έ 좕이
1185
+ # λ†“μΉœ 것이닀». Minting β“– would have been that mistake a second time.
1186
+ # β‡’ β“’'s γ€Œλ°›λŠ” 것」 widens to Β«μ €μžκ°€ μ“΄ λ¬Έμž₯(사후 μ£Όμž₯ 및 **사전 μ„ μ–Έ**)Β», and `soul-check:`
1187
+ # is β“’'s own field in the same shape `standpoint:` is β“‘'s. The six axes stay six.
1188
+ # WHY: CLAUDE.md §자기 λŒ€μ‘° has required an `β‘ μ˜ν˜Ό` line (성곡 μ •μ˜ / μ ˆλŒ€ μ•ˆ 함) on every
1189
+ # marker since 2026-08-09, and it is written 4/4 on the real corpus. NOTHING has ever read it
1190
+ # back. Measured this day with a control: `crossfamily` = 21 hits in this hook, `β‘ μ˜ν˜Ό`/`soul`
1191
+ # = 0 lines of consuming code anywhere in templates/ or scripts/; `fh_4axis_gate.md` does not
1192
+ # even name the field. That is the half-externalization shape β€” a slot with zero consumers,
1193
+ # which always reports "done" because presence is doing the judging.
1194
+ #
1195
+ # πŸŸ₯ The reason this hook gave for NOT checking the field answers a DIFFERENT question. Line
1196
+ # ~1058 declines it because Β«did you write it BEFORE designingΒ» is unverifiable from a file β€”
1197
+ # true, and correctly excluded. But Β«was it then SATISFIEDΒ» is a separate question that was
1198
+ # never asked, and it rode out on the first one's ticket.
1199
+ #
1200
+ # SCOPE β€” channel, not judgment (CLAUDE.md Β§Mechanization Boundary). This lane asserts
1201
+ # properties of the RECORD: single Β· closed enum Β· non-vacuous Β· not wearing another axis's
1202
+ # tokens Β· **and consistent with the β‘ μ˜ν˜Ό line in the same file**. It never asserts the
1203
+ # read-back reached the right conclusion. A fabricated `reflected(...)` with a fluent reason
1204
+ # PASSES β€” that hole is Β§4-b's (cross-family reads the marker), not this lane's, and it is
1205
+ # named here rather than papered over.
1206
+ local m="$1" line val reason n soul_n soul_body
1207
+ n=$(grep -cE '^[[:space:]]*soul-check:' "$m" 2>/dev/null); n=${n:-0}
1208
+ if [ "$n" -eq 0 ]; then
1209
+ # πŸŸ₯ A near-miss key reads as "absent" and takes the grace exit β€” silent, and the author
1210
+ # believes they recorded it. Absent is legal; MISSPELLED is not.
1211
+ if grep -qE '^[[:space:]]*(soul[ _-]?check[[:space:]]*:|soulcheck:|soul-checks:)' "$m" 2>/dev/null \
1212
+ && ! grep -qE '^[[:space:]]*soul-check:' "$m" 2>/dev/null; then
1213
+ echo " ❌ FAIL β€” a line looks like a soul-check key but is not exactly 'soul-check:'."
1214
+ grep -nE '^[[:space:]]*(soul[ _-]?check[[:space:]]*:|soulcheck:|soul-checks:)' "$m" | sed 's/^/ /'
1215
+ return 1
1216
+ fi
1217
+ return 0 # genuinely absent β€” adoption is incremental, same as thirdparty:
1218
+ fi
1219
+ if [ "$n" -gt 1 ]; then
1220
+ echo " ❌ FAIL β€” marker carries MORE THAN ONE 'soul-check:' line."
1221
+ grep -nE '^[[:space:]]*soul-check:' "$m" | sed 's/^/ /'
1222
+ echo " Readers take the first, so an appended correction is silently shadowed."
1223
+ return 1
1224
+ fi
1225
+ line=$(grep -m1 -E '^[[:space:]]*soul-check:' "$m" | sed -E 's/^[[:space:]]*soul-check:[[:space:]]*//')
1226
+ val=$(printf '%s' "$line" | sed -E 's/\(.*$//' | sed -E 's/[[:space:]]*(β€”|--).*$//' | sed -E 's/[[:space:]]+$//')
1227
+
1228
+ # Cross-axis contamination β€” the other three axes ask different questions.
1229
+ case "$val" in
1230
+ tier1|tier1b|tier2|tier2b|tier3|panel|declined|checked|none-found|peer-review|\
1231
+ DEGRADED_SINGLE_FAMILY|DEGRADED_PANEL_UNUSED|DEGRADED_NO_TARGET_ACCESS|DEGRADED_NO_ACCESS)
1232
+ echo " ❌ FAIL β€” '$val' belongs to another axis, not soul-check:."
1233
+ echo " standpoint = whose repo was ground truth Β· crossfamily = which model family reviewed"
1234
+ echo " thirdparty = was this already done outside Β· soul-check = was THIS marker's own"
1235
+ echo " β‘ μ˜ν˜Ό (성곡 μ •μ˜ / μ ˆλŒ€ μ•ˆ 함) read back against the delta."
1236
+ return 1 ;;
1237
+ esac
1238
+
1239
+ # Is there an β‘ μ˜ν˜Ό line in this same marker, and does it carry content?
1240
+ # πŸŸ₯ The soul detector must NOT read the `soul-check:` line itself. codex's alignment fix widened
1241
+ # the pattern and G8 flipped to PASS: `soul-check: reflected(β€¦μ„±κ³΅μ •μ˜β€¦)` matched as its own
1242
+ # evidence β€” self-referential FP ([[feedback_lane_runner_self_referential_fp]]). The claim can
1243
+ # never be its own ground. Same reason comment/prose lines are excluded: a marker that merely
1244
+ # SAYS 성곡 μ •μ˜ in narration has not declared one.
1245
+ # πŸŸ₯ A DECLARATION, not a MENTION. First real use (isolated clone, 2026-08-21) broke this on
1246
+ # attempt #1: this very delta's marker says Β«change: 마컀의 β‘ μ˜ν˜Ό μŠ¬λ‘―μ— μ†ŒλΉ„μ²˜κ°€ 0이던 것을…»
1247
+ # in a prose FIELD VALUE, and the leg reported "β‘ μ˜ν˜Ό present: 10 words" for a marker that
1248
+ # declares nothing. Excluding `#` and `soul-check:` was not enough β€” any field whose value
1249
+ # discusses the axis satisfies a substring test, and markers ABOUT this axis always will.
1250
+ # 39 lanes, codex (diff axis) and agy (claims axis) all missed it; running it once did not
1251
+ # ([[feedback_adversarial_review_not_substitute_for_first_use]]).
1252
+ # So the token must sit at LINE START as the key (a leading markdown `#` is allowed β€” the real
1253
+ # corpus writes Β«## β‘ μ˜ν˜Ό β€” 섀계 전에 μ“΄ …» as a heading, and P7 flipped to BLOCK when the first
1254
+ # version of this regex forbade it. The known-pair caught that in one run).
1255
+ # Prose inside a field VALUE can no longer vote.
1256
+ _soul_src=$(grep -E '^[[:space:]]*#*[[:space:]]*(β‘ μ˜ν˜Ό|soul|μ ˆλŒ€ μ•ˆ 함|성곡 ?μ •μ˜)[[:space:]]*[:β€”-]' "$m" 2>/dev/null \
1257
+ | grep -vE '^[[:space:]]*soul-check[[:space:]]*:')
1258
+ soul_n=$(printf '%s\n' "$_soul_src" | grep -cE 'β‘ μ˜ν˜Ό|^[[:space:]]*soul:|성곡 ?μ •μ˜|μ ˆλŒ€ μ•ˆ 함'); soul_n=${soul_n:-0}
1259
+ # Body = the β‘ μ˜ν˜Ό block only: stop at a blank line OR at the next ASCII field key. An earlier
1260
+ # draft ran the range to EOF, so a marker reading Β«β‘ μ˜ν˜Ό: μ—†μŒΒ» followed by any other field was
1261
+ # scored as having content β€” the fixture G9 caught it.
1262
+ # πŸŸ₯ Counted in WORDS, not characters. `${#var}` on Korean text is bytes-or-chars depending on
1263
+ # locale, and this corpus is Korean β€” a char threshold silently changes meaning per machine
1264
+ # ⚠️ Honest caveat, measured: `wc -w` is NOT a human word count on Korean either (Β«β‘ μ˜ν˜Ό: μ™„μ£ΌΒ»
1265
+ # reports 3, not 2). It is stable per-machine and monotonic, which is all a floor needs β€” but
1266
+ # every threshold here was calibrated by RUNNING it, never by reasoning about word counts.
1267
+ # ([[feedback_instrument_vs_target_and_budget]], λ‹¨μœ„ ν˜Όλ™). Word count is locale-independent.
1268
+ soul_body=$(awk '/β‘ μ˜ν˜Ό|^[[:space:]]*soul:|성곡 ?μ •μ˜|μ ˆλŒ€ μ•ˆ 함/{if (!f) f=1}
1269
+ f && /^[[:space:]]*$/{exit}
1270
+ f && seen && /^[[:space:]]*[A-Za-z][A-Za-z0-9_-]*:/{exit}
1271
+ f{print; seen=1}' "$m" 2>/dev/null \
1272
+ | sed -E '1s/^[[:space:]]*(β‘ μ˜ν˜Ό|soul)[^[:alnum:]Β«]*//')
1273
+ soul_words=$(printf '%s' "$soul_body" | wc -w | tr -d ' ')
1274
+ soul_words=${soul_words:-0}
1275
+
1276
+ case "$val" in
1277
+ UNKNOWN)
1278
+ echo " ⚠️ soul-check leg: UNKNOWN β€” μ•ˆ λ΄€λ‹€. Recorded, not silent." ;;
1279
+ reflected|violated|DEGRADED_NOT_RUN|DEGRADED_NO_SOUL|not-applicable)
1280
+ case "$line" in *\(*) ;; *)
1281
+ echo " ❌ FAIL β€” '$val' without a (<grounds>) parenthetical."
1282
+ echo " A read-back that names neither WHAT read it nor WHAT came back is"
1283
+ echo " indistinguishable from not running it."
1284
+ return 1 ;;
1285
+ esac
1286
+ # πŸŸ₯ Measure INSIDE the parens. The old form took everything after the first `(`, so
1287
+ # `reflected() β€” <long tail>` passed on text that was never grounds (codex, diff axis).
1288
+ reason=$(printf '%s' "$line" | sed -E 's/^[^(]*\(//; s/\)[^)]*$//')
1289
+ # πŸŸ₯ agy (claims axis, 2026-08-21): this leg's own comment above condemns `${#var}` on
1290
+ # Korean text and then used `${#reason}` twelve lines later β€” [[feedback_citing_a_rule_is_not_obeying_it]],
1291
+ # verbatim. Inherited from the sibling legs, which does not excuse it here. Word count.
1292
+ _rwords=$(printf '%s' "$reason" | wc -w | tr -d ' '); _rwords=${_rwords:-0}
1293
+ if [ "$_rwords" -lt 3 ]; then
1294
+ echo " ❌ FAIL β€” '$val(...)' grounds are vacuous. Name what read the β‘ μ˜ν˜Ό line back,"
1295
+ echo " against what, and what came back."
1296
+ return 1
1297
+ fi
1298
+ # ── the consistency check that makes this lane non-decorative ──────────────
1299
+ # Two fields in ONE record, mechanically decidable. Not a conclusion.
1300
+ case "$val" in
1301
+ reflected|violated)
1302
+ if [ "$soul_n" -eq 0 ] || [ "$soul_words" -lt 6 ]; then
1303
+ echo " ❌ FAIL β€” '$val' claims the β‘ μ˜ν˜Ό line was read back, but this marker has"
1304
+ echo " no β‘ μ˜ν˜Ό line with content (found: $soul_n line(s), $soul_words word(s))."
1305
+ echo " πŸŸ₯ Reflecting against an absent success-definition is the exact"
1306
+ echo " half-externalization this lane exists to catch. Write β‘ μ˜ν˜Ό first,"
1307
+ echo " or record DEGRADED_NO_SOUL(<why>)."
1308
+ return 1
1309
+ fi ;;
1310
+ DEGRADED_NO_SOUL)
1311
+ if [ "$soul_n" -gt 0 ] && [ "$soul_words" -ge 6 ]; then
1312
+ echo " ❌ FAIL β€” 'DEGRADED_NO_SOUL' says there is nothing to reflect against, but"
1313
+ echo " this marker DOES carry an β‘ μ˜ν˜Ό line with content ($soul_n line(s))."
1314
+ echo " A recorded absence contradicted by the same record is worse than silence."
1315
+ return 1
1316
+ fi ;;
1317
+ esac
1318
+ echo " βœ… soul-check leg: $val" ;;
1319
+ *)
1320
+ echo " ❌ FAIL β€” '$val' is not a member of the soul-check: enum."
1321
+ echo " Closed enum: reflected(<what came back β€” 어긋남 μ—¬λΆ€. NOT just who reviewed:
1322
+ that is crossfamily's column, and duplicating it double-counts one event>) Β·"
1323
+ echo " violated(<which half broke β€” μ„±κ³΅μ •μ˜ 미달 / μ ˆλŒ€μ•ˆν•¨ μœ„λ°˜ β€” and what was done>) Β·"
1324
+ echo " DEGRADED_NOT_RUN(<why>) Β· DEGRADED_NO_SOUL(<why>) Β· UNKNOWN Β· not-applicable(<why>)"
1325
+ echo " πŸŸ₯ 'violated' is a legal value ON PURPOSE. A recorded violation is worth more"
1326
+ echo " than a hidden one, and a lane that only admits success trains erasure."
1327
+ return 1 ;;
1328
+ esac
1329
+ return 0
1330
+ }
1331
+
974
1332
 
975
1333
 
976
1334
  # ── 4μΆ• 자기 λŒ€μ‘° β€” Β«μ–΄λŠ 좕을 돌렸고 μ–΄λŠ 좕을 μ•ˆ λŒλ Έλ‚˜Β» ν˜•μ‹ 검사 ────────────
@@ -1292,7 +1650,27 @@ if [ "$GATE_MODE" = "full" ]; then
1292
1650
  MARKER="$MARKER_DIR/.axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker"
1293
1651
 
1294
1652
  if [ -f "$MARKER" ]; then
1295
- if validate_marker_floor "$MARKER" && validate_marker_axes_run "$MARKER"; then
1653
+ # Wave 1-D defense leg β€” floor tiers only (sonnet-floor / below-floor), on ANY full-gate marker.
1654
+ # πŸŸ₯ It was first wired inside the LOAD-BEARING block below, which made the real condition
1655
+ # "load-bearing AND floor tier" while the skill documented "floor tiers only" β€” a SKILL.md
1656
+ # change at sonnet-floor would have passed with no defense line. Found by cross-family review
1657
+ # (codex/gpt-5.5, 2026-08-20); self-detection 0. The scope the doctrine states is the scope
1658
+ # the hook now has.
1659
+ _DEFENSE_OK=1
1660
+ if grep -qE '^[[:space:]]*floor-status:[[:space:]]*(sonnet-floor|below-floor)' "$MARKER"; then
1661
+ validate_defense_leg "$MARKER" || _DEFENSE_OK=0
1662
+ fi
1663
+ # β“’ 사전 μ„ μ–Έ (β‘ μ˜ν˜Ό) β€” scope is EVERY marker (CLAUDE.md §자기 λŒ€μ‘° mandates β‘ μ˜ν˜Ό on all of them), so it
1664
+ # belongs HERE and not in the load-bearing block below.
1665
+ # πŸŸ₯ It was first wired there, exactly like `validate_defense_leg` above β€” the real condition
1666
+ # would have been "load-bearing AND has a marker" while the doctrine says every marker. The
1667
+ # note recording that precedent is ~8 lines above this one and was in the file at the time;
1668
+ # the same defect was made one day later anyway ([[feedback_unedited_parts_of_my_own_file]]).
1669
+ # Caught by the operator asking Β«λ°°μ„  μ™„λ£Œλ˜μ—ˆλ‚˜?Β», not by a lane and not by cross-family.
1670
+ _SOUL_OK=1
1671
+ validate_soul_present_leg "$MARKER" || _SOUL_OK=0
1672
+ validate_soul_check_leg "$MARKER" || _SOUL_OK=0
1673
+ if [ "$_DEFENSE_OK" -eq 1 ] && [ "$_SOUL_OK" -eq 1 ] && validate_marker_floor "$MARKER" && validate_marker_axes_run "$MARKER"; then
1296
1674
  echo " βœ… PASS (marker confirmed + floor/axes fields valid:"
1297
1675
  echo " .axes_23_passed_${BRANCH_SLUG}_${TODAY}.marker)"
1298
1676
  else
@@ -1827,6 +2205,24 @@ PTR_EOF
1827
2205
  fi
1828
2206
  fi
1829
2207
 
2208
+ # ── 상주 μœ μž… 게이트 (mandatory-pass β€” blocks) ────────────────────────────────
2209
+ # μ™œ μ—¬κΈ° μžˆλ‚˜ (2026-08-20 μ‹€μΈ‘): κ°λŸ‰μ€ 유좜이 μ•„λ‹ˆλΌ **μœ μž…** λ¬Έμ œλ‹€.
2210
+ # CLAUDE.md 23,839(05-26) β†’ 70,758(07-20 Β«11.9% κ°λŸ‰Β» 직후) β†’ 144,138(08-20)
2211
+ # 07-20 이후 31일 +73,380 = μ•½ 2,367 bytes/일. 같은 κΈ°κ°„ μ–΄λΈ”λ ˆμ΄μ…˜ CUT νŒμ • **0건**.
2212
+ # μ΅œλŒ€ κ°λŸ‰ 후보 절(22k)μ‘°μ°¨ μœ μž… 9일치 β‡’ λ‚˜κ°€λŠ” 문만 λ‹ΉκΈ°λ©΄ ꡬ쑰적으둜 μ§„λ‹€.
2213
+ # κ²€μ‚¬ν•˜λŠ” 것은 **기둝의 속성**이지 결둠이 μ•„λ‹ˆλ‹€(Β§Mechanization Boundary):
2214
+ # γ€Œμ΄ 문단이 상주할 κ°€μΉ˜κ°€ μžˆλ‚˜γ€λ₯Ό νŒμ •ν•˜μ§€ μ•Šκ³  γ€Œμ €μžκ°€ μœ ν˜•μ„ μ„ μ–Έν–ˆλ‚˜γ€λ§Œ λ³Έλ‹€.
2215
+ # Anchor: scripts/test_residency_admission_lanes.sh (레인 10 Β· 컨트둀 2 Β· κ°€λ“œ 7 μ „μˆ˜ 되돌림 격리)
2216
+ RESID="$REPO_ROOT/scripts/residency_admission_check.sh"
2217
+ if [ -f "$RESID" ]; then
2218
+ echo "[Residency] 상주 μœ μž… 게이트..."
2219
+ resid_out=$(bash "$RESID" "$REPO_ROOT" 2>&1); resid_rc=$?
2220
+ printf '%s\n' "$resid_out" | sed 's/^/ /'
2221
+ if [ "$resid_rc" -ne 0 ]; then FAILED=1; fi
2222
+ else
2223
+ echo " ⚠️ 상주 μœ μž… 게이트 λ―Έμ„€μΉ˜ (scripts/residency_admission_check.sh) β€” skipped, not passed"
2224
+ fi
2225
+
1830
2226
  # ── Verdict ───────────────────────────────────────────────────────────────────
1831
2227
  echo ""
1832
2228
  echo "══════════════════════════════════════════════"
@@ -357,10 +357,25 @@ PPRANGES
357
357
  [ "${PUBLIC_SURFACE_OK:-0}" = "1" ] || exit 1
358
358
  fi
359
359
  if [ "${_pp_count:-0}" -gt 0 ]; then
360
- if printf '%s\n' "$_pp_added" | awk '
360
+ # πŸŸ₯ rc λ₯Ό Β«λΉ„μ˜Β» 으둜 λ­‰κ°œμ§€ 마라 (cross-family, 2026-08-21): 1=유좜 Β· 3=계기 사망.
361
+ # ν•©μΉ˜λ©΄ μ•„λž˜ PUBLIC_SURFACE_OK κ°€ **계기 μ‚¬λ§κΉŒμ§€ 승인**ν•œλ‹€ β€” push λŠ” 비가역이닀.
362
+ _pp_rc=0
363
+ printf '%s\n' "$_pp_added" | awk '
361
364
  /^\+\+\+ b\// { f = substr($0, 7); next }
362
365
  /^\+/ { print f "\t" substr($0, 2) }
363
- ' | psa_scan_tagged; then
366
+ ' | psa_scan_tagged || _pp_rc=$?
367
+ if [ "$_pp_rc" = "3" ]; then
368
+ echo ""
369
+ echo "══════════════════════════════════════════════"
370
+ echo " β›” FH Pre-Publish Gate (pre-push) β€” INSTRUMENT DEAD, scan did NOT run"
371
+ echo "══════════════════════════════════════════════"
372
+ echo " rc=3 = NOT SCANNED. That is not Β«no token foundΒ» β€” nothing was measured."
373
+ echo " PUBLIC_SURFACE_OK does NOT cover this: an override approves a KNOWN mention,"
374
+ echo " never an unmeasured surface. Fix the scanner first (run under bash; psa_load)."
375
+ echo "══════════════════════════════════════════════"
376
+ exit 1
377
+ fi
378
+ if [ "$_pp_rc" = "0" ]; then
364
379
  echo " βœ… FH Pre-Publish: no operator-private token in the TEXT added by ${_pp_count} commit(s)"
365
380
  echo " (not covered: annotated-tag messages Β· binary blobs β€” named residuals)"
366
381
  else
@@ -0,0 +1,15 @@
1
+ {
2
+ "hooks": {
3
+ "PreToolUse": [
4
+ {
5
+ "matcher": "Write|Bash",
6
+ "hooks": [
7
+ {
8
+ "type": "command",
9
+ "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/prior_art_prompt.sh\""
10
+ }
11
+ ]
12
+ }
13
+ ]
14
+ }
15
+ }