@chrono-meta/fh-gate 2.6.0 → 2.8.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 (55) hide show
  1. package/.claude/rules/fh_4axis_gate.md +26 -3
  2. package/.claude-plugin/marketplace.json +2 -2
  3. package/AGENTS.md +28 -3
  4. package/CLAUDE.md +146 -168
  5. package/README.ja.md +144 -24
  6. package/README.ko.md +135 -22
  7. package/README.md +204 -27
  8. package/README.zh.md +126 -21
  9. package/docs/ETHOS.md +10 -3
  10. package/knowledge/shared/dialogue/ai_dialogue_playbook.md +131 -0
  11. package/knowledge/shared/harness-core/claude_md_gate_details.md +90 -0
  12. package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +75 -0
  13. package/knowledge/shared/harness-core/fh_three_layer_canon.md +77 -3
  14. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +92 -5
  15. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +12 -2
  16. package/knowledge/shared/harness-core/multi_model_sidecar_strategy.md +32 -0
  17. package/knowledge/shared/harness-core/ship_readiness_gate.md +77 -8
  18. package/knowledge/shared/learnings/subagent_invocations_log.yaml +196 -0
  19. package/knowledge/shared/rules/knowledge_layer_seam.md +1 -1
  20. package/knowledge/shared/rules/multi_session_close_protocol.md +7 -3
  21. package/package.json +15 -2
  22. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  23. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-meta/CHANGELOG.md +157 -0
  25. package/plugins/fh-meta/agents/persona-innovator.md +170 -0
  26. package/plugins/fh-meta/skills/cross-ecosystem-synergy-detection/SKILL.md +37 -0
  27. package/plugins/fh-meta/skills/public-surface-audit/SKILL_detail.md +16 -1
  28. package/plugins/fh-meta/skills/steel-quench/SKILL.md +97 -0
  29. package/scripts/adapters/peer_resolve.sh +58 -6
  30. package/scripts/branch_claim.sh +3 -1
  31. package/scripts/cluster_capability_scan.sh +42 -13
  32. package/scripts/digest_landing_check.sh +142 -2
  33. package/scripts/fh_hub_identity.sh +83 -0
  34. package/scripts/fh_session_load.sh +53 -5
  35. package/scripts/fh_track_resolve.sh +114 -0
  36. package/scripts/field_canon_preload.sh +50 -5
  37. package/scripts/listing_watch.sh +187 -0
  38. package/scripts/package_coverage_check.sh +8 -0
  39. package/scripts/prior_art_prompt.sh +168 -0
  40. package/scripts/psa_scan_lib.sh +201 -15
  41. package/scripts/residency_admission_check.sh +204 -0
  42. package/scripts/selfcheck.sh +103 -0
  43. package/scripts/test_adapter_lanes.sh +67 -2
  44. package/scripts/test_heavy_classifier_lanes.sh +144 -0
  45. package/scripts/test_listing_watch_lanes.sh +162 -0
  46. package/scripts/test_marker_defense_lanes.sh +152 -0
  47. package/scripts/test_marker_soul_check_lanes.sh +211 -0
  48. package/scripts/test_prior_art_prompt_lanes.sh +128 -0
  49. package/scripts/test_psa_singlefile_lanes.sh +351 -1
  50. package/scripts/test_residency_admission_lanes.sh +60 -0
  51. package/scripts/test_track_resolve_lanes.sh +158 -0
  52. package/scripts/test_wizard_snippet_merge_lanes.sh +40 -0
  53. package/templates/.git-hooks/pre-commit +400 -4
  54. package/templates/.git-hooks/pre-push +17 -2
  55. package/templates/settings.PriorArt.snippet.json +44 -0
@@ -282,6 +282,46 @@ SessionStart REGISTRATION of the script that reports on them. So the wizard's un
282
282
  message (GAP 1) is never contradicted by anything, and the check whose job is announcing an unwired \
283
283
  machine can itself be unwired silently."
284
284
 
285
+ # ── SHIP-1/2: 출하되는 모든 스니펫이 머지 코드가 읽는 키를 갖고 있나 (2026-08-22 신설) ─────
286
+ # 🟥 왜 이 레인이 뒤늦게 생겼나 — 이 파일은 «머지 코드가 옳게 동작하나» 만 재고 있었고,
287
+ # «출하물이 그 코드가 읽는 모양인가» 는 아무도 안 봤다. 그 사이
288
+ # templates/settings.PriorArt.snippet.json 이 project_settings_json 래퍼 **없이** 출하됐고,
289
+ # 머지 코드는 그 키가 없으면 `SKIP (no project_settings_json)` 로 **조용히 건너뛴다**.
290
+ # 결과: install-wizard 를 돌려도 그 훅은 **한 번도 등록된 적이 없었다.**
291
+ # 실측 2026-08-22: 스니펫 5개 중 4개 래퍼 O · PriorArt 만 부재(전수, 컨트롤 = 나머지 넷).
292
+ # ⇒ 레인이 «구현» 만 보고 «출하물» 을 안 보면 이 클래스는 영원히 초록이다.
293
+ echo
294
+ echo "── SHIP: shipped-snippet schema conformance ──"
295
+ _ship_total=0; _ship_bad=0
296
+ for _snip in "$ROOT"/templates/settings.*.snippet.json; do
297
+ [ -f "$_snip" ] || continue
298
+ _ship_total=$((_ship_total+1))
299
+ if ! python3 -c "import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d.get('project_settings_json'),dict) else 1)" "$_snip" 2>/dev/null; then
300
+ _ship_bad=$((_ship_bad+1))
301
+ _fail "SHIP-1 $(basename "$_snip") has no dict 'project_settings_json' — install-wizard will SKIP it silently"
302
+ fi
303
+ done
304
+ if [ "$_ship_total" -eq 0 ]; then
305
+ # 부재를 통과로 렌더하지 않는다 — 0 개를 스캔하고 PASS 하면 계기가 죽은 것이다
306
+ _fail "SHIP-1 scanned ZERO snippets — instrument dead, not a pass (glob or ROOT wrong)"
307
+ elif [ "$_ship_bad" -eq 0 ]; then
308
+ _pass "SHIP-1 all $_ship_total shipped snippet(s) carry a dict 'project_settings_json'"
309
+ fi
310
+
311
+ # SHIP-2 컨트롤 — 이 검사가 실제로 판별하나. 래퍼를 뗀 픽스처는 반드시 걸려야 한다.
312
+ _ctl=$(mktemp -t fh_ship_ctl.XXXXXX) || _ctl=""
313
+ if [ -n "$_ctl" ]; then
314
+ printf '%s\n' '{"hooks":{"PreToolUse":[]}}' > "$_ctl"
315
+ if python3 -c "import json,sys; d=json.load(open(sys.argv[1])); sys.exit(0 if isinstance(d.get('project_settings_json'),dict) else 1)" "$_ctl" 2>/dev/null; then
316
+ _fail "SHIP-2 CONTROL — a wrapper-less fixture PASSED the check; the check does not discriminate"
317
+ else
318
+ _pass "SHIP-2 CONTROL — wrapper-less fixture is caught (check discriminates)"
319
+ fi
320
+ rm -f "$_ctl"
321
+ else
322
+ _fail "SHIP-2 CONTROL could not run (mktemp failed) — reported as a gap, not a pass"
323
+ fi
324
+
285
325
  echo
286
326
  echo "──────────────────────────────────────────────"
287
327
  if [ "$FAILED" -ne 0 ]; then
@@ -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,44 @@
1
+ {
2
+ "_README": [
3
+ "prior-art 프롬프트 훅 스니펫 — install-wizard 가 사용자 로컬 .claude/settings.json 에 머지하는",
4
+ "tracked SOURCE 다. 이 레포의 .claude/settings*.json 은 전부 gitignored 라 **훅 등록 자체를",
5
+ "tracked 로 둘 수 없다.** 머지 단계가 없으면 훅은 돌지 않는다 —",
6
+ "**스크립트를 출하하는 것과 배선하는 것은 같은 명제가 아니다.**",
7
+ "",
8
+ "🟥 왜 이 래퍼가 뒤늦게 생겼나 (2026-08-22 실측, 컨트롤 동반):",
9
+ " 이 파일은 형제 스니펫 넷과 달리 `project_settings_json` 래퍼 **없이** 출하되고 있었다.",
10
+ " install-wizard 의 실제 머지 코드(install-wizard/SKILL_detail.md:642-645)는 그 키가 없으면",
11
+ " print(\"SKIP (no project_settings_json)\", ...); continue",
12
+ " 로 **조용히 건너뛴다.** 즉 이 훅은 「아직 안 돌렸다」가 아니라",
13
+ " **「install-wizard 를 돌려도 항상 스킵돼서 한 번도 등록된 적이 없다」** 였다.",
14
+ " 전수 실측: Compaction·FieldCanon·PreToolUse·SessionStart = 래퍼 O · PriorArt = 혼자 부재.",
15
+ " 머지 코드를 그대로 복사해 실행한 수리 전/후 대조로 SKIP→등록 전이를 확인했다.",
16
+ " ⇒ built_but_not_wired 보다 나쁘다. **설치기가 자기 출하물을 조용히 건너뛰는** 형태다.",
17
+ " 회귀 앵커: scripts/test_wizard_snippet_merge_lanes.sh 의 전수 래퍼 검사(이 수리와 같이 들어갔다).",
18
+ "",
19
+ "무엇을 하나: 새 메커니즘을 짓기 직전에 «책장 먼저, 없으면 도서관» 을 띄운다.",
20
+ "정체성 ④(프런티어 답습)의 **빌드 시점 격발 표면**이다.",
21
+ "🟥 명시된 잔여: matcher 가 `Write|Bash` 라도 **auto-mode 세션이 heredoc 으로 파일을 만드는",
22
+ "경로는 Bash 로 잡히지만**, 그 안에서 실제로 새 메커니즘을 짓는지까지는 훅이 모른다.",
23
+ "표면화까지가 훅의 몫이고 판정은 사람/세션이 한다 — 판정하는 훅을 만들면",
24
+ "§Mechanization Boundary 가 금하는 «결론을 단언하는 체크»가 된다.",
25
+ "",
26
+ "PRIVATE PATH 없음 → .claude/settings.json(프로젝트 로컬)에 속한다.",
27
+ "머지하되 덮어쓰지 마라: 기존 PreToolUse 엔트리를 보존하고 스크립트 이름을 키로 FH 것만 교체."
28
+ ],
29
+ "project_settings_json": {
30
+ "hooks": {
31
+ "PreToolUse": [
32
+ {
33
+ "matcher": "Write|Bash",
34
+ "hooks": [
35
+ {
36
+ "type": "command",
37
+ "command": "bash \"$CLAUDE_PROJECT_DIR/scripts/prior_art_prompt.sh\""
38
+ }
39
+ ]
40
+ }
41
+ ]
42
+ }
43
+ }
44
+ }