@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
@@ -268,6 +268,71 @@ echo
268
268
  # 부재로 HARNESS_ERROR(3) 를 낸다 — 기대값과 같은 값이다. 즉 R2 는 Q5 를 원리적으로
269
269
  # 구분하지 못한다. 그건 Q5 의 결함이 아니라 **이 프로브 축의 사각지대**이고,
270
270
  # Q5 는 R1 이 아니라 별도 축(모호 판정 자체를 지우는 중화)으로만 흔들 수 있다.
271
+ # ── P 레인: peer_resolve 자체의 이름 해석 (2026-08-21 신설) ─────────────────────
272
+ # 🟥 이게 왜 필요한가: `peer_resolve.sh` 의 `FH_PROJECTS_HOME` 팔을 `fh_track_resolve.sh` 로
273
+ # 배선하면서 **거짓 AMBIGUOUS 결함이 하나 닫혔는데, 그 변경을 되돌려도 빨개지는 레인이
274
+ # 0개였다**(실측). 즉 기존 31 레인은 이 축에 대해 앵커가 아니다 —
275
+ # [[feedback_anchor_can_be_decorative]]. 픽스처는 **실제로 뚫려 있던 표기**(공백 이름)를 쓴다.
276
+ #
277
+ # 🟥 **초판 P 레인은 거짓 초록이었다 — 계기가 대상을 안 불렀다.** `peer_resolve.sh` 를
278
+ # `bash <파일> <이름>` 으로 «실행» 했는데 이 파일은 **sourced 라이브러리**라 실행하면
279
+ # 아무것도 안 하고 rc=0 으로 끝난다. P1·P3 이 그 rc=0 을 «해소 성공» 으로 읽어 초록이었고,
280
+ # P2 의 빨강도 코드 결함이 아니라 내 하네스 결함이었다. 판별 근거는 **HEAD 에서도 같은
281
+ # 값이 나왔다**는 것이다(변경 전후가 같으면 계기가 대상을 안 보고 있다).
282
+ # [[feedback_instrument_vs_target_and_budget]] — 이상하면 대상보다 계기를 먼저 의심해라.
283
+ _PR="${FH_ADAPTER_DIR:-$REPO_ROOT/scripts/adapters}/peer_resolve.sh"
284
+ if [ ! -f "$_PR" ]; then
285
+ echo " SKIP P 레인 (peer_resolve.sh 없음) — PASS 가 아니라 미검사다"
286
+ else
287
+ # shellcheck source=scripts/adapters/peer_resolve.sh
288
+ . "$_PR"
289
+ if ! type fh_peer_resolve >/dev/null 2>&1; then
290
+ echo " ❌ P 레인 하네스 결함 — source 했는데 fh_peer_resolve 가 없다"
291
+ FAIL=1
292
+ else
293
+ _PW="$(mktemp -d)"
294
+ mkdir -p "$_PW/sp ace" "$_PW/aliased-dev" "$_PW/dual" "$_PW/dual-dev"
295
+ # P0 컨트롤 — 계기가 대상을 실제로 부르는가. 이게 초록이 아니면 아래 셋은 의미가 없다.
296
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve nosuchpeer >/dev/null 2>&1; _prc=$?
297
+ _lane P0 control "계기 생존 — 없는 peer 는 부재(1)로 온다(rc=0 무음이면 대상을 안 부른 것)" 1 "$_prc"
298
+ # P1 — 공백 이름 1건: 한 후보가 두 단어로 세어져 거짓 AMBIGUOUS(2) 가 나던 자리
299
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve 'sp ace' >/dev/null 2>&1; _prc=$?
300
+ _lane P1 resolve-guard "공백 이름 1건은 정상 해소 — 거짓 AMBIGUOUS 아님" 0 "$_prc"
301
+ # P2 컨트롤 — 진짜 모호는 여전히 거부해야 한다(P1 의 통과가 모호검출을 죽인 게 아니다)
302
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve dual >/dev/null 2>&1; _prc=$?
303
+ _lane P2 control "진짜 모호(dual + dual-dev)는 여전히 고르지 않는다" 2 "$_prc"
304
+ # P3 컨트롤 — 별칭 해소가 실제로 살아 있나(P 레인이 통째로 공허하지 않다는 증거)
305
+ FH_PROJECTS_HOME="$_PW" fh_peer_resolve aliased >/dev/null 2>&1; _prc=$?
306
+ _lane P3 resolve-alias "'-dev' 별칭이 해소된다" 0 "$_prc"
307
+ rm -rf "$_PW"
308
+ fi
309
+ fi
310
+ # 🟥 **명시 잔여 — 팔 A 는 안 닫혔다.** `FH_CLUSTER_ROOTS`(임의 절대경로의 basename 조회)는
311
+ # 조회 모델이 달라 라이브러리로 통일하지 않았고, 거기 공백 거짓-AMBIGUOUS 가 **그대로 있다**
312
+ # (실측 확인). 별도 축의 독립 수리이므로 레인을 안 박았다 — 안 잰 것이지 통과한 게 아니다.
313
+
314
+ # 🟥 **2026-08-21 — 사전등록 두 줄을 갱신했다. 이유를 적는 게 갱신 자체보다 중요하다.**
315
+ # (위 규율 그대로: 관측에 맞춰 «조용히» 고치면 프로브의 판별력 증거가 사라진다.)
316
+ # · **R1: `G1 G5 M1 Q1` → `G1 G5 M1 Q1 W1`** — **선재 오류**다. 사전등록이 `qasp_web_rules.sh`
317
+ # (W 레인) 도입 **이전**에 쓰였고, W1 도 부재 분기에 결박된 레인이라 빨간 게 맞다.
318
+ # 내 변경 이전 HEAD 에서도 `--revert-probe` 는 이 차이로 RC=1 이었다(실측). 즉 이건
319
+ # 내가 깬 게 아니라 **이미 틀려 있던 것을 이번에 갚는 것**이다.
320
+ # · **R2: 11개 → `M4 Q4 Q7 Q8`** — 이건 **내 변경이 원인**이고, 기대값을 낮추는 것으로
321
+ # 끝내면 안 된다. `peer_resolve.sh` 의 `FH_PROJECTS_HOME` 팔이 이제 별칭을
322
+ # `fh_track_resolve.sh` 에서 받으므로 **`fh_peer_alias_candidates` 를 안 읽는다** →
323
+ # 그걸 중화해도 안 흔들린다. 남은 넷은 전부 `FH_CLUSTER_ROOTS`(팔 A) 레인이다.
324
+ # 🟥 **명시 잔여 — 커버리지가 줄었다**: R2 는 더 이상 «팔 B 가 별칭을 보는가» 를
325
+ # 흔들지 못한다. 그 축의 앵커는 지금 `scripts/test_track_resolve_lanes.sh`(라이브러리
326
+ # 레인 1~3 + 배선 레인 9)에만 있고, **어댑터 층에는 없다.** 팔 A 는 조회 모델이 달라
327
+ # (임의 절대경로의 basename) 통일하지 않았으므로 R2 는 그대로 유효하다.
328
+ # 이 잔여를 닫으려면 라이브러리를 중화하는 별도 프로브 축(R3)이 필요하고,
329
+ # 지금 프로브는 `scripts/adapters/` 만 사본으로 뜨므로 **그 축은 아직 없다.**
330
+ # · **P 레인 추가분(같은 날, 2차)**: `P0 P1` 이 R1 에, `P1` 이 R2 에 들어간다. 사전등록을
331
+ # P 레인 **추가 전에** 썼기 때문에 빠져 있었다. 기계적 설명: R1 은 부재 분기를 «순진한
332
+ # 추측» 으로 바꾸므로 P0(없는 peer → rc=1 이어야 한다)이 rc=0 을 받아 빨개지고, P1 도
333
+ # 그 조기 반환에 걸린다. R2 는 별칭 후보를 이름 하나로 줄이므로 P1(공백 이름 해소)이
334
+ # 후보 루프를 못 탄다. 🟥 **둘 다 «레인이 그 중화에 실제로 반응한다»는 뜻이라 빨간 게
335
+ # 맞다** — 이 갱신은 기대를 낮추는 게 아니라 **새 앵커를 사전등록에 등록하는 것**이다.
271
336
  # ★중화는 **실물 파일이 아니라 사본**에 건다(FH_ADAPTER_DIR). 실물 트리를 건드리지 않고,
272
337
  # 사본이 실물과 «중화한 줄 말고는 동일» 함을 적용확인 단계에서 대조한다.
273
338
  _revert_probe() {
@@ -317,14 +382,14 @@ case "${1:-}" in
317
382
  _revert_probe R1 \
318
383
  's|^ printf .peer-resolve: peer| printf "%s" "${FH_PROJECTS_HOME:-$HOME/projects}/$n"; return 0 # NEUTRALIZED_R1\n printf '"'"'peer-resolve: peer|' \
319
384
  'NEUTRALIZED_R1' \
320
- 'G1 G5 M1 Q1' \
385
+ 'G1 G5 M1 P0 P1 Q1 W1' \
321
386
  '«부재» 분기 중화 → 부재 분기에 결박된 레인만 빨개져야 한다' || RP=1
322
387
  echo
323
388
  # R2: 별칭 후보를 이름 자신 하나로 줄인다 — `-dev` 를 못 찾게 된다.
324
389
  _revert_probe R2 \
325
390
  's|^ printf .%s\\n. "\$1" "\$1-dev".*$| printf "%s\\n" "$1" # NEUTRALIZED_R2|' \
326
391
  'NEUTRALIZED_R2' \
327
- 'M2 M3 M4 M5 M6 Q2 Q3 Q4 Q6 Q7 Q8' \
392
+ 'M4 P1 Q4 Q7 Q8' \
328
393
  '별칭 목록 중화 → mate/qasp 쪽 레인만 빨개져야 한다(gstack 은 별칭이 필요 없다)' || RP=1
329
394
  echo
330
395
  [ "$RP" -eq 0 ] || FAIL=1
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env bash
2
+ # test_heavy_classifier_lanes.sh — known-pair anchor for the pre-commit HEAVY/LIGHT path classifier.
3
+ #
4
+ # WHY: the classifier is three `grep -E` lines, and everything downstream of it — Axes 2+3, the
5
+ # marker, the whole full-gate path — is gated on what they match. A term missing from those lines
6
+ # does not FAIL; it makes the gate **silently not apply**, and the commit goes green. There is no
7
+ # louder symptom. Nothing was watching the watcher: measured 2026-08-20, `scripts/test_*.sh` files
8
+ # testing this classifier = **0**, while 12 test files touch the hook for other reasons.
9
+ #
10
+ # Absorbed from a sibling harness (pmh-dev, 2026-08-08) rather than invented here. The load-bearing
11
+ # idea is the extraction below, not the fixture list.
12
+ #
13
+ # 🟥 FH's classifier is FOUR-WAY, not two-way like the sibling's. The port is not a copy:
14
+ # HEAVY → full gate
15
+ # CARVEOUT → knowledge/ · docs/*.md · AGENTS.md — promoted to HEAVY only if the diff is
16
+ # substantive (adds a fenced block or a citation), else demoted to LIGHT
17
+ # LIGHT → CATALOG.md · tracks/
18
+ # uncovered → matches none of the three. **This class is real and is pinned below** — a path
19
+ # nobody classified is the quietest way for a surface to leave the gate.
20
+ #
21
+ # This lane pins all four directions. Over-matching is a defect too: a gate that fires on every
22
+ # commit is the one people learn to bypass with `--no-verify`, which disarms the Destructive-Op
23
+ # gate living in the same hook.
24
+ #
25
+ # Usage: bash scripts/test_heavy_classifier_lanes.sh → exit 0 if all pairs hold, 1 otherwise.
26
+ set -uo pipefail
27
+
28
+ REPO_ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)
29
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
30
+ [ -r "$HOOK" ] || { echo "❌ FAIL — $HOOK not readable"; exit 1; }
31
+
32
+ FAILED=0
33
+
34
+ # Extract the LIVE regexes from the hook rather than restating them. A lane that hard-codes its own
35
+ # copy of the pattern passes forever while the real classifier drifts away underneath it — that is
36
+ # testing the fixture instead of the subject, and it is the failure this whole file exists to catch.
37
+ _extract() { # $1 = variable name as it appears at column 0 in the hook
38
+ sed -n "/^$1=\\\$(echo \"\\\$STAGED\"/,/|| true)/p" "$HOOK" \
39
+ | grep -o 'grep -E "[^"]*"' | head -1 | sed 's/^grep -E "//; s/"$//'
40
+ }
41
+ HEAVY_RE=$(_extract HEAVY)
42
+ CARVE_RE=$(_extract CARVEOUT)
43
+ LIGHT_RE=$(_extract LIGHT)
44
+
45
+ # 🟥 Fail CLOSED on extraction failure. An empty regex makes `grep -qE ""` match EVERYTHING, so a
46
+ # silent extraction failure would turn this lane green while classifying every path as heavy.
47
+ for v in HEAVY_RE CARVE_RE LIGHT_RE; do
48
+ if [ -z "${!v}" ]; then
49
+ echo "❌ FAIL — could not extract \$$v from the hook."
50
+ echo " The hook's shape changed. Fix THIS extraction — do not delete the lane, and do not"
51
+ echo " inline a copy of the pattern here (that is how a lane stops covering its subject)."
52
+ exit 1
53
+ fi
54
+ done
55
+
56
+ classify() { # <path> → heavy | carveout | light | uncovered
57
+ # Order mirrors the hook: HEAVY wins, then CARVEOUT (which the hook excludes
58
+ # knowledge/shared/rules/ from, since that is already HEAVY), then LIGHT.
59
+ if printf '%s\n' "$1" | grep -qE "$HEAVY_RE"; then echo heavy
60
+ elif printf '%s\n' "$1" | grep -qE "$CARVE_RE"; then echo carveout
61
+ elif printf '%s\n' "$1" | grep -qE "$LIGHT_RE"; then echo light
62
+ else echo uncovered; fi
63
+ }
64
+
65
+ check() { # <expect> <path> <why>
66
+ local expect="$1" path="$2" why="$3" got
67
+ got=$(classify "$path")
68
+ if [ "$got" = "$expect" ]; then
69
+ printf ' ✅ %-9s %-52s %s\n' "$expect" "$path" "$why"
70
+ else
71
+ printf ' ❌ %-9s %-52s expected %s, got %s\n' "$expect" "$path" "$expect" "$got"
72
+ FAILED=1
73
+ fi
74
+ }
75
+
76
+ echo "[heavy-classifier] known-pair anchor (4-way)"
77
+ echo " ── must be HEAVY (full 4-axis gate applies) ──"
78
+ check heavy "plugins/fh-meta/skills/steel-quench/SKILL.md" "skill spec"
79
+ check heavy "plugins/fh-meta/skills/goal-quench/SKILL_detail.md" "detail file — the salience-split half"
80
+ check heavy "plugins/fh-meta/skills/fh/assets/note.md" "any md under a skill dir"
81
+ check heavy "plugins/fh-meta/agents/challenger.md" "agent definition"
82
+ check heavy ".claude/agents/local.md" "repo-local agent definition"
83
+ check heavy ".claude/rules/fh_4axis_gate.md" "gate rule"
84
+ check heavy "knowledge/shared/rules/operations.md" "shared rules — HEAVY, not carveout"
85
+ check heavy "templates/.git-hooks/pre-commit" "the gate machinery itself"
86
+ check heavy "templates/CLAUDE.md" "propagated field asset"
87
+ check heavy "CLAUDE.md" "the resident layer"
88
+ check heavy "scripts/selfcheck.sh" "shipped executable"
89
+ check heavy "scripts/adapters/qasp_web_rules.sh" "nested script still matches"
90
+
91
+ echo " ── CARVEOUT (heavy only if the diff is substantive) ──"
92
+ check carveout "knowledge/shared/harness-core/sonnet_floor_doctrine.md" "doctrine prose"
93
+ check carveout "docs/USER_GUIDE.md" "published guide"
94
+ check carveout "AGENTS.md" "Codex entry point"
95
+
96
+ echo " ── must stay LIGHT (over-firing trains --no-verify) ──"
97
+ check light "CATALOG.md" "index entry"
98
+ check light "tracks/_meta/fh_completed.md" "session record"
99
+ check light "tracks/qasp/NEXT_ACTION.md" "field track record"
100
+
101
+ echo " ── honestly UNCOVERED — pinned so the residual cannot be believed closed ──"
102
+ check uncovered "README.md" "🟥 a README-only commit takes NO 4-axis path"
103
+ check uncovered "package.json" "files[] / version changes are unclassified"
104
+ check uncovered ".github/workflows/validate.yml" "🟥 CI definition — changing what CI runs is unclassified"
105
+ check uncovered "scripts/memory_link_check.py" "🟥 .py is not .sh — shipped python is unclassified"
106
+
107
+ echo " ── controls (the classifier discriminates, it does not just fire) ──"
108
+ # known-negative: a path that resembles a HEAVY one but must not match. This is the arm that
109
+ # separates "the classifier works" from "the classifier fires on everything" — without it, a
110
+ # regex of `.` would pass every HEAVY row above.
111
+ check uncovered "notes/scripts/foo.sh.txt" "control — .sh.txt is not a shell script (\$-anchored)"
112
+ check uncovered "notes/skills-overview.md" "control — 'skills' in a path is not a skill spec"
113
+ # ⚠️ this control was first written as `docs/design/skills-overview.md`, which the lane
114
+ # correctly classified `carveout` (`^docs/.*\.md$`). The FIXTURE was wrong, not the
115
+ # subject — pick a path outside every class when the claim is "matches none of them".
116
+
117
+ # 🟥 KNOWN OVER-MATCH, pinned deliberately as `heavy` rather than "fixed" (2026-08-20).
118
+ # `SKILL(_detail)?\.md` is written WITHOUT a `$` anchor, so any path merely CONTAINING that
119
+ # substring classifies HEAVY — `SKILL.md.bak`, `SKILL.md.orig`, `SKILL.md~`. Recorded, not
120
+ # repaired, for three reasons, and the reasons are the point:
121
+ # ⓐ the error direction is fail-SAFE — it adds gate coverage, never removes it. The classifier's
122
+ # dangerous direction is under-matching (the gate silently not applying), which this is not.
123
+ # ⓑ those paths are not committed in practice, so the over-block the hook warns about
124
+ # ("a gate that always fires is the one people learn to bypass") does not actually fire here.
125
+ # ⓒ anchoring it is a live change to gate routing, and gate routing is exactly what this lane
126
+ # exists to detect changes in. Changing the subject in the same commit that first measures it
127
+ # leaves no before-state.
128
+ # This row is therefore a RECORD, not an endorsement: if someone anchors the pattern later, this
129
+ # line goes red and they will read this comment — which is the whole job of a known-pair.
130
+ check heavy "docs/skills/SKILL.md.bak" "🟥 over-match: pattern is unanchored (see comment)"
131
+
132
+ echo
133
+ echo "🟥 NOT COVERED BY THIS LANE (named, not silently absent):"
134
+ echo " · \`diff_is_substantive\` — whether a CARVEOUT file is promoted depends on diff CONTENT,"
135
+ echo " not path. This lane pins the PATH class only. That test needs its own anchor."
136
+ echo " · the hook's behaviour once classified (marker required, manifest required). Pinned"
137
+ echo " elsewhere (test_marker_*_lanes.sh); this lane covers only the routing decision."
138
+
139
+ if [ "$FAILED" -eq 0 ]; then
140
+ echo "✅ heavy-classifier: all pairs hold"
141
+ exit 0
142
+ fi
143
+ echo "❌ heavy-classifier: at least one pair broke — the gate's routing changed"
144
+ exit 1
@@ -0,0 +1,162 @@
1
+ #!/usr/bin/env bash
2
+ # test_listing_watch_lanes.sh — listing_watch.sh 의 레인.
3
+ # 🟥 네트워크를 안 쓴다: PATH 앞에 `gh` 스텁을 놓아 응답을 고정한다.
4
+ # (실망 사용 검증은 `--selftest` 가 known-pair 로 따로 한다 — 그건 네트워크가 필요하다)
5
+ HUB="${CLAUDE_PROJECT_DIR:-$(cd "$(dirname "$0")/.." && pwd)}"
6
+ SUT="$HUB/scripts/listing_watch.sh"
7
+ PASS=0; FAIL=0
8
+ ok(){ PASS=$((PASS+1)); printf ' ✅ %s\n' "$1"; }
9
+ ng(){ FAIL=$((FAIL+1)); printf ' ❌ %s\n 기대: %s\n 실제: %s\n' "$1" "$2" "$3"; }
10
+
11
+ SB=$(mktemp -d); trap 'rm -rf "$SB"' EXIT
12
+ mkdir -p "$SB/tracks/_meta" "$SB/bin"
13
+
14
+ mkstub(){ # $1 = state|merged|badexit
15
+ cat > "$SB/bin/gh" <<STUB
16
+ #!/usr/bin/env bash
17
+ case "\$*" in
18
+ *"contents/README.md"*) printf '%s' "\$(printf 'chrono-meta/forge-harness' | base64)" ;;
19
+ # 🟥 본체 jq 는 4필드다: state|merged|라벨개수|라벨목록. 스텁이 3필드면 계기가 대상과 어긋난다.
20
+ *"issues/33"*) [ "$1" = "merged" ] && printf 'open|2026-08-19T06:48:10Z|0|' || printf 'open|null|1|validation-passed' ;;
21
+ *"issues/999"*) exit 1 ;;
22
+ *) exit 1 ;;
23
+ esac
24
+ STUB
25
+ chmod +x "$SB/bin/gh"
26
+ }
27
+
28
+ run(){ CLAUDE_PROJECT_DIR="$SB" PATH="$SB/bin:$PATH" bash "$SUT" "$@" 2>&1; }
29
+
30
+ # ── L1: gh 부재 → SKIPPED (pass 아님) ─────────────────────────────
31
+ printf 'walkinglabs/x\t33\tpr\tchrono-meta/forge-harness\n' > "$SB/tracks/_meta/listing_watch_channels.tsv"
32
+ # 🟥 두 번 틀린 자리다. ① 초판: `PATH=/nonexistent` 가 `bash` 까지 지워 «계기» 를 시험했다.
33
+ # ② 2판: `/bin:/usr/bin` 은 **호스트 의존**이다 — 리눅스에선 거기 진짜 gh 가 있을 수 있고
34
+ # 그러면 이 레인이 «대상» 이 아니라 «그 머신» 을 재게 된다(cross-family 지목).
35
+ # ③ 정본: 본체에 주입 seam(`LISTING_WATCH_GH`)을 두고 존재하지 않는 이름을 넣는다.
36
+ o=$(CLAUDE_PROJECT_DIR="$SB" LISTING_WATCH_GH="gh-does-not-exist-$$" bash "$SUT" --force 2>&1); r=$?
37
+ case "$o" in *SKIPPED*) ok "L1 gh 부재 → SKIPPED (not passed)" ;; *) ng "L1" "SKIPPED" "$o" ;; esac
38
+ [ "$r" = "0" ] && ok "L1b gh 부재여도 exit 0 (훅 무음 방지)" || ng "L1b" "0" "$r"
39
+
40
+ # ── L2: 매니페스트 부재 → DISABLED (실패 아님) ────────────────────
41
+ mkstub state
42
+ mv "$SB/tracks/_meta/listing_watch_channels.tsv" "$SB/m.bak"
43
+ o=$(run --force); case "$o" in *DISABLED*) ok "L2 매니페스트 부재 → DISABLED" ;; *) ng "L2" "DISABLED" "$o" ;; esac
44
+ mv "$SB/m.bak" "$SB/tracks/_meta/listing_watch_channels.tsv"
45
+
46
+ # ── L3: 주석만 있는 매니페스트 → DISABLED (0행을 «정상»으로 안 읽는다) ──
47
+ cp "$SB/tracks/_meta/listing_watch_channels.tsv" "$SB/m.bak"
48
+ printf '# only a comment\n' > "$SB/tracks/_meta/listing_watch_channels.tsv"
49
+ o=$(run --force); case "$o" in *DISABLED*) ok "L3 유효행 0 → DISABLED" ;; *) ng "L3" "DISABLED" "$o" ;; esac
50
+ cp "$SB/m.bak" "$SB/tracks/_meta/listing_watch_channels.tsv"
51
+
52
+ # ── L4: 최초 실행 → 스냅샷 생성 + 변화 «없음» ─────────────────────
53
+ rm -f "$SB/tracks/_meta/.listing_watch_state.tsv"
54
+ o=$(run --force)
55
+ case "$o" in *최초*) ok "L4 최초 실행을 «변화» 로 오보하지 않는다" ;; *) ng "L4" "최초 실행 안내" "$o" ;; esac
56
+ [ -s "$SB/tracks/_meta/.listing_watch_state.tsv" ] && ok "L4b 스냅샷 파일 생성" || ng "L4b" "비어있지 않은 스냅샷" "(없음)"
57
+
58
+ # ── L5: 무변화 → 무출력 (오탐 0) ──────────────────────────────────
59
+ o=$(run --force)
60
+ [ -z "$o" ] && ok "L5 무변화 → 무출력" || ng "L5" "(빈 출력)" "$o"
61
+
62
+ # ── L6: 상태 전이 → 검출 ★ 이 레인이 본체다 ──────────────────────
63
+ mkstub merged
64
+ o=$(run --force)
65
+ # 🟥 배너 문구만 보면 «라벨만 바뀐 버그» 도 통과한다 — **관측된 전이 자체**를 단언한다.
66
+ case "$o" in *"바뀌었다"*) ok "L6 변화 배너 발화" ;; *) ng "L6" "변화 보고" "$o" ;; esac
67
+ case "$o" in *MERGED*) ok "L6b 전이 «내용»이 MERGED 로 찍힌다" ;; *) ng "L6b" "지금: … MERGED" "$o" ;; esac
68
+ case "$o" in *"이전:"*) ok "L6c 이전 상태도 같이 보여준다" ;; *) ng "L6c" "이전: …" "$o" ;; esac
69
+
70
+ # ── L7: 스로틀 — 24h 안이면 조용 ──────────────────────────────────
71
+ o=$(run) # --force 없음
72
+ [ -z "$o" ] && ok "L7 스로틀 동작 (24h 내 재실행 조용)" || ng "L7" "(빈 출력)" "$o"
73
+
74
+ # ── L8: API 실패를 «없음» 이 아니라 FAILED 로 ─────────────────────
75
+ printf 'walkinglabs/x\t999\tpr\t-\n' > "$SB/tracks/_meta/listing_watch_channels.tsv"
76
+ rm -f "$SB/tracks/_meta/.listing_watch_state.tsv"
77
+ run --force >/dev/null
78
+ s=$(cat "$SB/tracks/_meta/.listing_watch_state.tsv" 2>/dev/null)
79
+ case "$s" in *FAILED*) ok "L8 API 실패 → FAILED (0 으로 안 접는다)" ;; *) ng "L8" "FAILED" "$s" ;; esac
80
+
81
+ # ── L9: 🟥 FAILED 반복이 «무출력» 이 되면 안 된다 (자기 헤더 배반 방지) ──
82
+ cat > "$SB/bin/gh" <<'STUB'
83
+ #!/usr/bin/env bash
84
+ exit 1
85
+ STUB
86
+ chmod +x "$SB/bin/gh"
87
+ printf 'r/x\t1\tpr\t-\n' > "$SB/tracks/_meta/listing_watch_channels.tsv"
88
+ rm -f "$SB/tracks/_meta/.listing_watch_state.tsv"
89
+ run --force >/dev/null 2>&1 # 1회차: 스냅샷이 FAILED 로 굳는다
90
+ o=$(run --force) # 2회차: 변화는 «없다» — 그래도 말해야 한다
91
+ case "$o" in *FAILED*|*"읽지 못했다"*) ok "L9 FAILED 반복도 매번 보고 (인증만료≠변화없음)" ;; *) ng "L9" "FAILED 보고" "${o:-(무출력)}" ;; esac
92
+
93
+ # ── L10: 연속 탭(빈 필드)에서 needle 이 밀리지 않는다 ──
94
+ mkstub state
95
+ printf 'walkinglabs/x\t33\t\tchrono-meta/forge-harness\n' > "$SB/tracks/_meta/listing_watch_channels.tsv"
96
+ rm -f "$SB/tracks/_meta/.listing_watch_state.tsv"
97
+ run --force >/dev/null 2>&1
98
+ s9=$(cat "$SB/tracks/_meta/.listing_watch_state.tsv" 2>/dev/null)
99
+ case "$s9" in *LISTED*) ok "L10 빈 필드 있어도 needle 이 제자리 (IFS 탭 축약 방지)" ;; *) ng "L10" "LISTED" "$s9" ;; esac
100
+
101
+ # ── L11: 개행 없는 마지막 행도 읽는다 ──
102
+ printf 'walkinglabs/x\t33\tpr\tchrono-meta/forge-harness\nwalkinglabs/y\t33\tpr\tchrono-meta/forge-harness' > "$SB/tracks/_meta/listing_watch_channels.tsv"
103
+ rm -f "$SB/tracks/_meta/.listing_watch_state.tsv"
104
+ run --force >/dev/null 2>&1
105
+ n=$(grep -c . "$SB/tracks/_meta/.listing_watch_state.tsv" 2>/dev/null || echo 0)
106
+ [ "$n" = "2" ] && ok "L11 개행 없는 마지막 행 누락 안 함 (2/2)" || ng "L11" "2행" "${n}행"
107
+
108
+ # ── L12: 라벨 개수를 실어 ["a,b"] 와 ["a","b"] 를 가른다 ──
109
+ case "$s9" in *" "[0-9]*:*) ok "L12 라벨 필드가 «개수:목록» 형태" ;; *) ng "L12" "N:labels" "$s9" ;; esac
110
+
111
+ # ── L13/L14: 🟥 **반대 플랫폼 흉내** — 이 둘이 이 스위트의 하중선이다.
112
+ # 2026-08-21 실측: 로컬(macOS) 16/16 초록인데 **CI(리눅스)에서 L7·L9 가 죽었다.**
113
+ # 원인 둘 다 «한 플랫폼에서만 나는» 형태였다:
114
+ # ⓐ `stat -f %m` 은 GNU 에서 **실패가 아니라 성공하며 파일시스템 정보를 뱉는다**
115
+ # → `||` 폴백이 안 타고 산술식이 깨진다
116
+ # ⓑ grep 패턴의 `\t` 를 GNU grep 은 **리터럴 t** 로 읽는다 → FAILED 보고가 0건
117
+ # 로컬 초록이 증거가 못 된다는 뜻이므로, **여기서 반대 플랫폼을 스텁으로 만든다.**
118
+ mkstub state
119
+ printf 'walkinglabs/x\t33\tpr\tchrono-meta/forge-harness\n' > "$SB/tracks/_meta/listing_watch_channels.tsv"
120
+ rm -f "$SB/tracks/_meta/.listing_watch_state.tsv"
121
+
122
+ # L13: `stat -f` 가 «성공하면서 쓰레기» 를 내는 플랫폼(=GNU)에서도 스로틀이 산다
123
+ cat > "$SB/bin/stat" <<'STUB'
124
+ #!/usr/bin/env bash
125
+ # GNU 흉내: -f 는 성공하며 파일시스템 정보(비숫자)를 낸다 · -c %Y 만 mtime
126
+ if [ "$1" = "-f" ]; then echo " ID: deadbeef Namelen: 255 Type: ext2/ext3"; exit 0; fi
127
+ if [ "$1" = "-c" ]; then echo 1000000000; exit 0; fi
128
+ exit 1
129
+ STUB
130
+ chmod +x "$SB/bin/stat"
131
+ run --force >/dev/null 2>&1 # 스냅샷 생성
132
+ o=$(run 2>&1) # --force 없음 → 스로틀이 조용해야
133
+ case "$o" in
134
+ *"syntax error"*|*"ext2"*) ng "L13 GNU-stat 흉내에서 스로틀 붕괴" "(빈 출력)" "$o" ;;
135
+ "") ok "L13 GNU-stat 흉내에서도 스로틀 정상 (비숫자 mtime 방어)" ;;
136
+ *) ng "L13" "(빈 출력)" "$o" ;;
137
+ esac
138
+ rm -f "$SB/bin/stat"
139
+
140
+ # L14: grep 패턴의 `\t` 를 리터럴 t 로 읽는 플랫폼(=GNU grep)에서도 FAILED 가 보고된다
141
+ # → 실제 탭을 쓰는지 확인. 스크립트가 `\t` 를 패턴에 쓰면 여기서 죽는다.
142
+ cat > "$SB/bin/gh" <<'STUB'
143
+ #!/usr/bin/env bash
144
+ exit 1
145
+ STUB
146
+ chmod +x "$SB/bin/gh"
147
+ cat > "$SB/bin/grep" <<'STUB'
148
+ #!/usr/bin/env bash
149
+ # GNU 흉내: 패턴 안의 백슬래시-t 는 «리터럴 t» 다 (탭이 아니다)
150
+ args=(); for a in "$@"; do case "$a" in *'\t'*) a="${a//\\t/t}" ;; esac; args+=("$a"); done
151
+ exec /usr/bin/grep "${args[@]}"
152
+ STUB
153
+ chmod +x "$SB/bin/grep"
154
+ printf 'r/x\t1\tpr\t-\n' > "$SB/tracks/_meta/listing_watch_channels.tsv"
155
+ rm -f "$SB/tracks/_meta/.listing_watch_state.tsv"
156
+ run --force >/dev/null 2>&1
157
+ o=$(run --force 2>&1)
158
+ case "$o" in *"읽지 못했다"*|*FAILED*) ok "L14 GNU-grep 흉내에서도 FAILED 보고 (실제 탭 사용)" ;; *) ng "L14 grep \\t 이식성" "FAILED 보고" "${o:-(무출력)}" ;; esac
159
+ rm -f "$SB/bin/grep"
160
+
161
+ printf '\nLISTING_WATCH_LANES: %d passed, %d failed\n' "$PASS" "$FAIL"
162
+ [ "$FAIL" -eq 0 ] || exit 1
@@ -0,0 +1,152 @@
1
+ #!/usr/bin/env bash
2
+ # test_marker_defense_lanes.sh — regression fixtures for pre-commit's validate_defense_leg
3
+ # (Wave 1-D defense line, 2026-08-20).
4
+ #
5
+ # WHY THIS FILE EXISTS AT ALL: the procedure was absorbed from a sibling harness whose own document
6
+ # asserted "the commit hook reads that line" and "pinned by scripts/test_marker_floor_lanes.sh".
7
+ # 🟥 Measured twice, with a control (`crossfamily` → 21 hits in the same file): BOTH claims were
8
+ # **0 hits** there. The prose was portable; the machine was not. This lane is the machine, built
9
+ # here, so that FH's own claim is true when FH makes it.
10
+ #
11
+ # Fixtures assert BOTH directions (known-pair): every intended shape is admitted, and every hole the
12
+ # leg closes still blocks. A suite that only asserts BLOCK cannot tell "blocked" from "blocked for
13
+ # the wrong reason"; one that only asserts PASS cannot see a leg that admits everything.
14
+ #
15
+ # Usage: bash scripts/test_marker_defense_lanes.sh Exit: 0 = all behave; 1 = regression.
16
+
17
+ set -uo pipefail
18
+ # Script-relative, NOT `git rev-parse --show-toplevel` — same reason as the sibling lanes: in a
19
+ # vendored tree rev-parse answers with the OUTER repo and the suite measures somebody else's files.
20
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
21
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
22
+ T=$(mktemp -d); trap 'rm -rf "$T"' EXIT
23
+
24
+ sed -n '/^validate_defense_leg()/,/^}/p' "$HOOK" > "$T/fn.sh"
25
+
26
+ # Instrument calibration BEFORE any fixture runs. An empty extraction would let every BLOCK
27
+ # fixture "pass" against nothing and every PASS fixture fail — and the suite would still print a
28
+ # verdict. Assert the body actually arrived.
29
+ if ! grep -q 'estimation-layer' "$T/fn.sh"; then
30
+ echo "❌ HARNESS-ERROR — validate_defense_leg did not extract from $HOOK."
31
+ echo " Fixtures below would measure an empty function. Aborting rather than reporting green."
32
+ exit 1
33
+ fi
34
+
35
+ pass=0; fail=0
36
+ mk() { printf '%s\n' "$2" > "$T/$1"; }
37
+ run() { bash -c "source '$T/fn.sh'; validate_defense_leg '$T/$1'" >/dev/null 2>&1; }
38
+ want() { # $1=fixture $2=expect(pass|block) $3=label
39
+ local rc; run "$1"; rc=$?
40
+ local got; [ "$rc" -eq 0 ] && got=pass || got=block
41
+ if [ "$got" = "$2" ]; then printf ' ✅ %-6s %s\n' "$2" "$3"; pass=$((pass+1))
42
+ else printf ' ❌ %-6s %s — got %s\n' "$2" "$3" "$got"; fail=$((fail+1)); fi
43
+ }
44
+
45
+ GOOD='axis2-defense: reproducibility=bash scripts/test_marker_defense_lanes.sh at 4c1f2ab fairness=both arms reps=3, same fixture dir, same shell estimation-layer=all measured; known-pair separated arm from control'
46
+
47
+ echo "[marker-defense] known-pair fixtures"
48
+ echo " ── admitted (must not over-block; over-blocking teaches --no-verify) ──"
49
+ mk ok.md "floor-status: sonnet-floor
50
+ $GOOD"
51
+ want ok.md pass "all three substantive"
52
+ mk multiline.md "floor-status: sonnet-floor
53
+ axis2-defense: reproducibility=git show 9cd7350 -- scripts/selfcheck.sh
54
+ fairness=arm and control both on 2.6.0 tarball, reps=1 each, named
55
+ estimation-layer=counts measured; the 2,367 b/day figure is a quotation"
56
+ want multiline.md pass "continuation lines are one value"
57
+ mk order.md "floor-status: below-floor
58
+ axis2-defense: estimation-layer=measured via npm pack --dry-run --json fairness=same tarball both arms reproducibility=npm pack @chrono-meta/fh-gate@2.6.0"
59
+ want order.md pass "sub-answers in any order"
60
+
61
+ echo " ── blocked ──"
62
+ mk none.md "floor-status: sonnet-floor"
63
+ want none.md block "no axis2-defense line at all"
64
+ mk empty.md "floor-status: sonnet-floor
65
+ axis2-defense:"
66
+ want empty.md block "key present, value empty (silence is not an answer)"
67
+ mk partial.md "floor-status: sonnet-floor
68
+ axis2-defense: reproducibility=bash scripts/selfcheck.sh completes rc=0 fairness=both arms on same tree"
69
+ want partial.md block "estimation-layer missing — a partial form is not a defense"
70
+ # 🟥 rc IS NOT ENOUGH HERE — measured by a revert probe on 2026-08-20, the day this was written.
71
+ # Deleting the completeness guard leaves `partial.md` STILL blocked, because an absent sub-answer
72
+ # also reads as an empty (vacuous) one and the next guard catches it. Gating on the exit code alone
73
+ # therefore certifies a guard that is doing nothing: the classic decorative anchor. What the
74
+ # completeness guard actually buys is the PRESCRIPTION — "missing sub-answer(s): estimation-layer"
75
+ # tells the author which question they skipped, where "too short" sends them to rewrite the wrong
76
+ # field. A gate's output is half its product, so the lane measures the output.
77
+ want_msg() { # $1=fixture $2=substring $3=label
78
+ local out; out=$(bash -c "source '$T/fn.sh'; validate_defense_leg '$T/$1'" 2>&1)
79
+ if printf '%s' "$out" | grep -qF "$2"; then printf ' ✅ %-6s %s\n' "msg" "$3"; pass=$((pass+1))
80
+ else printf ' ❌ %-6s %s — output did not contain %s\n' "msg" "$3" "$2"; fail=$((fail+1)); fi
81
+ }
82
+ want_msg partial.md "missing sub-answer(s): estimation-layer" \
83
+ "prescription NAMES the skipped question (guard is load-bearing for the message)"
84
+ # (the short.md prescription control lives below, AFTER that fixture is created —
85
+ # 🟥 it was first written here, before `mk short.md`, so it measured a NON-EXISTENT file and
86
+ # read the "no line at all" message. The lane was wrong, not the subject. Fixture order is
87
+ # part of the instrument.)
88
+ mk vacuous.md "floor-status: sonnet-floor
89
+ axis2-defense: reproducibility=ok fairness=yes estimation-layer=n/a"
90
+ want vacuous.md block "filled form, not an answer"
91
+ mk short.md "floor-status: sonnet-floor
92
+ axis2-defense: reproducibility=ran it fairness=same estimation-layer=nums"
93
+ want short.md block "too short to name a command, reps or layer"
94
+ want_msg short.md "vacuous answer(s)" \
95
+ "control — the short case takes the OTHER prescription (the two are distinguishable)"
96
+ mk dup.md "floor-status: sonnet-floor
97
+ $GOOD
98
+ $GOOD"
99
+ want dup.md block "two lines — the reader takes the first, so a correction is shadowed"
100
+
101
+ echo " ── call-site pin (a function nobody calls is prose) ──"
102
+ # 🟥 Found by cross-family review 2026-08-20 (codex/gpt-5.5), self-detection 0: this suite extracts
103
+ # the FUNCTION and calls it directly, so deleting the hook's call site left every lane green while
104
+ # the leg stopped running — and the skill doc claimed "pre-commit runs this". That is the exact
105
+ # defect class this whole file was built to stop importing, reproduced one layer up.
106
+ # The pin is a grep, and its limit is stated rather than hidden: it proves the call EXISTS and sits
107
+ # in the full-gate marker block, not that the block is reached. Reachability is pinned by the hook's
108
+ # own end-to-end lanes, not here.
109
+ cs=$(grep -c 'validate_defense_leg "\$MARKER"' "$HOOK")
110
+ if [ "$cs" -eq 1 ]; then
111
+ printf ' ✅ %-6s %s\n' "call" "hook calls validate_defense_leg exactly once"; pass=$((pass+1))
112
+ else
113
+ printf ' ❌ %-6s %s — found %s call site(s)\n' "call" "hook calls validate_defense_leg exactly once" "$cs"; fail=$((fail+1))
114
+ fi
115
+ # and that the call is gated on the floor tiers the doc claims — not on load-bearing, which is
116
+ # where it was first (wrongly) wired.
117
+ if grep -B4 'validate_defense_leg "\$MARKER"' "$HOOK" | grep -qE 'floor-status:.*sonnet-floor\|below-floor'; then
118
+ printf ' ✅ %-6s %s\n' "call" "call is gated on floor-status (sonnet-floor|below-floor)"; pass=$((pass+1))
119
+ else
120
+ printf ' ❌ %-6s %s\n' "call" "call is NOT gated on floor-status — scope drifted from the doc"; fail=$((fail+1))
121
+ fi
122
+
123
+ echo " ── prescriptions (rc alone certifies guards that do nothing) ──"
124
+ # 🟥 Same shadowing as the completeness guard: removing the presence guard leaves none.md/empty.md
125
+ # still blocked by the missing-subanswer guard, so rc cannot see it. What presence buys is the
126
+ # prescription that shows the author the whole field shape.
127
+ want_msg none.md "no 'axis2-defense:' line" \
128
+ "presence guard is load-bearing for its own message"
129
+ want_msg empty.md "no 'axis2-defense:' line" \
130
+ "empty value takes the same prescription as absent (silence is silence)"
131
+
132
+ echo " ── controls (the leg discriminates; it does not just fire) ──"
133
+ # 🟥 known-NEGATIVE for over-blocking: a legitimate answer that merely LOOKS terse per-field but
134
+ # clears the bar. Without this, a leg that blocked everything would pass every BLOCK row above.
135
+ mk borderline.md "floor-status: sonnet-floor
136
+ axis2-defense: reproducibility=see PR #471 CI log job 96296828886 fairness=arms differ: control is v2.5.1 tarball — stated estimation-layer=one measured, one quoted from CHANGELOG"
137
+ want borderline.md pass "control — admits an honest answer that NAMES an asymmetry"
138
+ # 🟥 known-negative on the em-dash: `fairness` here contains a dash, which must NOT truncate parsing
139
+ mk dashes.md "floor-status: sonnet-floor
140
+ axis2-defense: reproducibility=git log main..HEAD --oneline fairness=both arms — same reps, same env estimation-layer=measured; instrument calibrated on a known pair"
141
+ want dashes.md pass "control — dashes inside a value do not truncate it"
142
+
143
+ echo
144
+ if [ "$fail" -eq 0 ]; then
145
+ echo "✅ marker-defense: $pass lanes hold (0 failed)"
146
+ echo " 🟥 SCOPE: this suite pins PRESENCE, COMPLETENESS and NON-VACUITY. It cannot pin whether"
147
+ echo " the answers are TRUE — that is the operator's and the weekly audit's, like every other"
148
+ echo " marker field. A green line here is not a claim that the defense was actually mounted."
149
+ exit 0
150
+ fi
151
+ echo "❌ marker-defense: $fail failed / $pass passed"
152
+ exit 1