@chrono-meta/fh-gate 3.2.0 → 3.4.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 (48) hide show
  1. package/.claude/registry/agent_cards.json +1 -1
  2. package/.claude/rules/fh_4axis_gate.md +25 -0
  3. package/.claude-plugin/marketplace.json +3 -3
  4. package/AGENTS.md +2 -2
  5. package/CATALOG.md +4 -4
  6. package/CHEATSHEET.md +1 -1
  7. package/CLAUDE.md +4 -4
  8. package/docs/OUTPUT_EVIDENCE.md +1 -1
  9. package/docs/STANDARDS_ALIGNMENT.md +1 -1
  10. package/docs/codex-compat.md +1 -1
  11. package/knowledge/shared/harness-core/agents_md_runtime_details.md +2 -2
  12. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +30 -3
  13. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +1 -1
  14. package/knowledge/shared/harness-core/skill_quality_rubric.md +1 -1
  15. package/knowledge/shared/learnings/subagent_invocations_log.yaml +31 -0
  16. package/knowledge/shared/rules/modes_and_value.md +2 -2
  17. package/package.json +2 -1
  18. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  19. package/plugins/fh-commons/README.md +38 -0
  20. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  21. package/plugins/fh-meta/CHANGELOG.md +95 -0
  22. package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
  23. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +40 -0
  24. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +1 -1
  25. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +43 -5
  26. package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL.md +75 -3
  27. package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL_detail.md +2 -2
  28. package/plugins/fh-meta/skills/harvest-loop/SKILL_detail.md +2 -2
  29. package/plugins/fh-meta/skills/install-doctor/SKILL.md +1 -1
  30. package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
  31. package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +1 -1
  32. package/plugins/fh-meta/skills/pipeline-conductor/SKILL.md +1 -1
  33. package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +1 -1
  34. package/plugins/fh-meta/skills/sim-conductor/SKILL.md +1 -1
  35. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  36. package/scripts/finding_fleet.sh +391 -13
  37. package/scripts/finding_pipeline.sh +370 -11
  38. package/scripts/finding_verifier.sh +31 -2
  39. package/scripts/finding_verify.py +198 -13
  40. package/scripts/frontier_digest_autopilot.sh +3 -3
  41. package/scripts/gate_shape_scan.sh +16 -2
  42. package/scripts/test_finding_pipeline_lanes.sh +1556 -4
  43. package/scripts/test_gate_shape_scan_lanes.sh +11 -0
  44. package/scripts/test_marker_crossfamily_lanes.sh +75 -3
  45. package/scripts/test_marker_standpoint_lanes.sh +31 -6
  46. package/templates/.git-hooks/pre-commit +152 -6
  47. package/templates/local_fh_context.md +1 -1
  48. package/templates/regression_guard.sh +1 -1
@@ -30,13 +30,20 @@ set -uo pipefail
30
30
 
31
31
  SUPPORTED_FAMILIES="codex gemini" # must match finding_verifier.sh's own case statement
32
32
 
33
- TARGET=""; OUT=""; FLEET=""
34
- usage() { echo "usage: finding_pipeline.sh <target-file> --out <dir> [--fleet <table>]" >&2; exit 2; }
33
+ TARGET=""; OUT=""; FLEET=""; SEEDED_IDS=""; SEEDED_FILE=""; SEEDS_ROUTED=""; ROUND2=0; ROUND2_BLIND=0; R1_ONLY=0; REUSE_R1=""
34
+ usage() { echo "usage: finding_pipeline.sh <target-file> --out <dir> [--fleet <table>] [--round2|--round2-blind] [--r1-only] [--reuse-r1 <dir>] [--seeded <ids>] [--seeded-file <path>]" >&2; exit 2; }
35
35
  need() { [ $# -ge 2 ] || { echo "finding_pipeline: $1 needs a value" >&2; exit 2; }; }
36
36
  [ $# -ge 1 ] || usage
37
37
  TARGET="$1"; shift
38
38
  while [ $# -gt 0 ]; do
39
39
  case "$1" in
40
+ --round2) ROUND2=1; shift ;;
41
+ --round2-blind) ROUND2=1; ROUND2_BLIND=1; shift ;;
42
+ # 짝지음(paired) — 플래그만 fleet 으로 통과시킨다(측정 층은 fleet 이다). RESULT §15 잔여 참조.
43
+ --r1-only) R1_ONLY=1; shift ;;
44
+ --reuse-r1) REUSE_R1="${2:-}"; shift 2 ;;
45
+ --seeded) need "$@"; SEEDED_IDS="$2"; shift 2 ;;
46
+ --seeded-file) need "$@"; SEEDED_FILE="$2"; shift 2 ;;
40
47
  --out) need "$@"; OUT="$2"; shift 2 ;; # `shift 2` on a trailing flag consumes nothing and
41
48
  --fleet) need "$@"; FLEET="$2"; shift 2 ;; # spins forever; codex reproduced the hang.
42
49
  *) usage ;;
@@ -57,13 +64,31 @@ umask 077
57
64
  mkdir -p "$OUT" || { echo "finding_pipeline: cannot create --out" >&2; exit 2; }
58
65
  # 🟥 미리 깔린 심링크를 따라가면 «출력»이 남의 파일 truncate 가 된다. 리다이렉션도 open() 도
59
66
  # 심링크를 따라간다 — 그래서 쓰기 «전»에 거부한다(cross-family review 2026-09-09).
60
- for _p in "$OUT" "$OUT/fleet" "$OUT/confirmed.jsonl" "$OUT/dropped.jsonl" "$OUT/splits.txt" "$OUT/families.txt"; do
67
+ for _p in "$OUT" "$OUT/fleet" "$OUT/confirmed.jsonl" "$OUT/dropped.jsonl" "$OUT/splits.txt" "$OUT/families.txt" "$OUT/roster.txt" "$OUT/roster.err" "$OUT/families.err"; do
61
68
  if [ -L "$_p" ]; then
62
69
  echo "finding_pipeline: refusing to write through a symlink: $_p" >&2; exit 2
63
70
  fi
64
71
  done
65
72
 
66
73
  HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
74
+ # 씨앗 선언을 «작업 시작 전» 에 확정한다 — verify 쪽과 같은 규율(빈 파일·디코드 오류는 설정 오류다)
75
+ ALL_SEEDS="$SEEDED_IDS"
76
+ if [ -n "$SEEDED_FILE" ]; then
77
+ _FROM_FILE=$(/usr/bin/python3 -c '
78
+ import sys
79
+ try:
80
+ with open(sys.argv[1], encoding="utf-8") as fh:
81
+ ids = [l.strip() for l in fh if l.strip() and not l.startswith("#")]
82
+ except (OSError, UnicodeDecodeError) as e:
83
+ print("finding_pipeline: --seeded-file unusable: %s" % e, file=sys.stderr); sys.exit(2)
84
+ if not ids:
85
+ print("finding_pipeline: --seeded-file %r yielded no ids — a control file that declares "
86
+ "nothing is a disabled control, not an absent one" % sys.argv[1], file=sys.stderr)
87
+ sys.exit(2)
88
+ print(",".join(ids))' "$SEEDED_FILE") || exit 2
89
+ ALL_SEEDS="${ALL_SEEDS:+$ALL_SEEDS,}$_FROM_FILE"
90
+ fi
91
+
67
92
  FLEET_SH="$HERE/finding_fleet.sh"; VERIFY_PY="$HERE/finding_verify.py"; VERIFIER_SH="$HERE/finding_verifier.sh"
68
93
  for f in "$FLEET_SH" "$VERIFY_PY" "$VERIFIER_SH"; do
69
94
  [ -f "$f" ] || { echo "finding_pipeline: missing $f — skipped, NOT passed" >&2; exit 3; }
@@ -86,9 +111,68 @@ argv_json() { # each argument becomes one JSON string — no shell ever parses
86
111
  # the second family and make a single-family run look cross-verified.
87
112
  /bin/rm -rf "$OUT/fleet"
88
113
  FA=(); [ -n "$FLEET" ] && FA=(--fleet "$FLEET")
89
- bash "$FLEET_SH" "$TARGET" --out "$OUT/fleet" ${FA[@]+"${FA[@]}"} 2>&1 | tee "$OUT/fleet_run.log"
114
+ # 생성시점 탈상관은 fleet 층의 일이다 드라이버는 플래그만 통과시킨다.
115
+ R2ARGS=(); [ "$ROUND2" -eq 1 ] && R2ARGS=(--round2); [ "$ROUND2_BLIND" -eq 1 ] && R2ARGS=(--round2-blind)
116
+
117
+ # ── 짝지음 인자 해석 ────────────────────────────────────────────────────────────────
118
+ # 🟥 --r1-only 는 검증·드롭감사·씨앗 판정을 «돌리지 않는다». 그러므로 씨앗을 선언한 채로는
119
+ # 거부한다 — 선언된 컨트롤이 돈 적 없이 통과로 읽히는 것이 이 레포가 이름 붙인 결함이다.
120
+ PAIRARGS=()
121
+ if [ "$R1_ONLY" -eq 1 ]; then
122
+ [ "$ROUND2" -eq 1 ] && { echo "finding_pipeline: --r1-only cannot be combined with --round2/--round2-blind" >&2; exit 2; }
123
+ [ -n "$REUSE_R1" ] && { echo "finding_pipeline: --r1-only and --reuse-r1 are mutually exclusive" >&2; exit 2; }
124
+ [ -n "$ALL_SEEDS" ] && { echo "finding_pipeline: --r1-only runs no verification — refusing to accept seeds that would never be judged" >&2; exit 2; }
125
+ PAIRARGS=(--r1-only)
126
+ fi
127
+ if [ -n "$REUSE_R1" ]; then
128
+ [ "$ROUND2" -eq 1 ] || { echo "finding_pipeline: --reuse-r1 requires --round2 or --round2-blind" >&2; exit 2; }
129
+ # 호출자가 파이프라인 out 을 줬으면 그 안의 fleet/ 로 내려간다. 어느 쪽을 골랐는지 «찍는다».
130
+ _R1SRC="$REUSE_R1"
131
+ [ -s "$REUSE_R1/fleet/findings.jsonl" ] && _R1SRC="$REUSE_R1/fleet"
132
+ [ -s "$_R1SRC/findings.jsonl" ] || { echo "finding_pipeline: --reuse-r1 resolved to '$_R1SRC' which has no non-empty findings.jsonl" >&2; exit 2; }
133
+ echo "PIPELINE reuse_r1 resolved=$_R1SRC sha256=$(/usr/bin/shasum -a 256 "$_R1SRC/findings.jsonl" | /usr/bin/cut -c1-16)"
134
+ PAIRARGS=(--reuse-r1 "$_R1SRC")
135
+ fi
136
+
137
+ bash "$FLEET_SH" "$TARGET" --out "$OUT/fleet" ${FA[@]+"${FA[@]}"} ${R2ARGS[@]+"${R2ARGS[@]}"} ${PAIRARGS[@]+"${PAIRARGS[@]}"} 2>&1 | tee "$OUT/fleet_run.log"
90
138
  FLEET_RC=${PIPESTATUS[0]}
139
+
140
+ # --r1-only: 공유 round-1 만 남기고 끝낸다(검증 단계 없음 — 위에서 씨앗을 이미 거부했다)
141
+ if [ "$R1_ONLY" -eq 1 ]; then
142
+ # 🟥 `|| echo 0` 을 쓰면 안 된다 — `grep -c .` 는 빈 파일에서 «0 을 찍고 rc=1» 이라 폴백이
143
+ # 한 줄을 더 붙여 `_n1="0\n0"` 이 되고, `[ "$_n1" -eq 0 ]` 은 **구문오류로 거짓**이 되어
144
+ # 빈 round-1 이 «사용 가능» 으로 통과한다(fail-OPEN). cross-family codex 가 적발, 자력 0.
145
+ # [[feedback_pipefail_fallback_disarms_guard]] — 질문은 «폴백 붙였나» 가 아니라
146
+ # «실패 시 stdout 이 비는가» 다. 여기서는 안 빈다.
147
+ _n1=$(/usr/bin/grep -c . "$OUT/fleet/findings.jsonl" 2>/dev/null)
148
+ case "${_n1:-}" in ''|*[!0-9]*) _n1=0 ;; esac
149
+ if [ "$FLEET_RC" -ne 0 ] || [ "$_n1" -eq 0 ]; then
150
+ echo "PIPELINE target=$(basename "$TARGET") fleet_rc=$FLEET_RC findings=$_n1 status=R1_UNUSABLE rc=3"
151
+ exit 3
152
+ fi
153
+ echo "PIPELINE target=$(basename "$TARGET") fleet_rc=0 findings=$_n1 status=R1_ONLY r1_dir=$OUT/fleet sha256=$(/usr/bin/shasum -a 256 "$OUT/fleet/findings.jsonl" | /usr/bin/cut -c1-16) rc=0"
154
+ exit 0
155
+ fi
91
156
  FINDINGS="$OUT/fleet/findings.jsonl"
157
+ if [ "$FLEET_RC" -eq 0 ] && [ ! -s "$FINDINGS" ] && [ -f "$OUT/fleet/INTENTIONAL_EMPTY" ]; then
158
+ # 🟥 R3 #8: every member completed round 2 and withdrew everything — that is a REVIEWED empty result,
159
+ # not a missing review. The typed marker (not the empty file) is what distinguishes the two.
160
+ # R4 #6: a reused --out kept the previous run's aggregates beside this early return — reset them here.
161
+ for _p in "$OUT/confirmed.jsonl" "$OUT/dropped.jsonl" "$OUT/splits.txt"; do
162
+ [ -L "$_p" ] && { echo "finding_pipeline: refusing to write through a symlink: $_p" >&2; exit 2; }
163
+ : > "$_p"
164
+ done
165
+ # R4 #1: a declared control that never entered the run is ABSENT on this path too — WITHDRAWN must not
166
+ # pass a seed check it never ran. Same exit (5) and same SEEDED line the verifier would print.
167
+ if [ -n "$ALL_SEEDS" ]; then
168
+ _n=$(printf '%s' "$ALL_SEEDS" | /usr/bin/tr ',' '\n' | /usr/bin/grep -c .)
169
+ echo "PIPELINE target=$(basename "$TARGET") fleet_rc=0 findings=0 status=WITHDRAWN confirmed=0 dropped=0 coverage=0/0 rc=5"
170
+ echo "SEEDED declared=$_n present=0 kept=0 dropped=0 abstained=0 status=ABSENT"
171
+ exit 5
172
+ fi
173
+ echo "PIPELINE target=$(basename "$TARGET") fleet_rc=0 findings=0 status=WITHDRAWN confirmed=0 dropped=0 coverage=0/0 rc=0"
174
+ exit 0
175
+ fi
92
176
  if [ "$FLEET_RC" -ne 0 ] || [ ! -s "$FINDINGS" ]; then
93
177
  echo "PIPELINE target=$(basename "$TARGET") fleet_rc=$FLEET_RC findings=0 status=UNREVIEWED"
94
178
  echo " 🟥 an empty finding list is UNREVIEWED, not clean (finding_fleet.sh says so and this agrees)" >&2
@@ -97,6 +181,9 @@ fi
97
181
  # A fleet is "ok" when ONE member succeeded. A member that crashed after emitting a few findings still
98
182
  # leaves its family present, so the split routing below sees two families and the run looks complete.
99
183
  FAILED_MEMBERS=$(grep -c '^MEMBER .* rc=[^0]' "$OUT/fleet_run.log" 2>/dev/null); FAILED_MEMBERS=${FAILED_MEMBERS:-0}
184
+ # 🟥 rc=0 인데 계약 출력이 0 인 멤버 — 안전필터 차단이 이 얼굴이다. failed_members 로는 안 보인다.
185
+ # «0건» 이 아니라 «검증 없는 0» 이므로 별 칸으로 표면화한다(기존 필드 의미는 안 건드린다).
186
+ BLOCKED_MEMBERS=$(grep -c '^MEMBER2\{0,1\} .*status=ZERO_NONJSON$' "$OUT/fleet_run.log" 2>/dev/null); BLOCKED_MEMBERS=${BLOCKED_MEMBERS:-0}
100
187
 
101
188
  # ── 2. split by producer, verify each half with the other family ──────────────────────────────────
102
189
  # Families are read into a newline-delimited list and validated. An unquoted space-joined expansion
@@ -121,17 +208,62 @@ if [ "$FAMRC" -ne 0 ] || [ ! -s "$OUT/families.txt" ]; then
121
208
  echo "finding_pipeline: findings carry no usable producer_family — cannot route" >&2; exit 3
122
209
  fi
123
210
 
211
+ # 🟥 검증자 후보 풀은 «발견을 낸 계열» 이 아니라 «실제로 돌아간 함대 명부» 다.
212
+ # families.txt 는 producer_family 에서 만들어지므로 0건을 낸 계열은 목록에서 사라진다. 그러면
213
+ # pick_verifier 가 «다른 계열이 없다» 로 읽고 그 발견들을 통째로 미검증 처리한다 — 검증자로
214
+ # 쓸 수 있는 계열이 멀쩡히 돌고 있었는데도. 측정이 스스로 찾아낸 결함이다:
215
+ # F_typed 팔 case_g02.py r1~r3 이 정확히 이 형태로 coverage 0/2 (0%) 였다(2026-09-10).
216
+ # 분할 루프는 바뀌지 않는다 — 여전히 발견자별이다. 바뀌는 것은 «누가 검증할 수 있나» 뿐이다.
217
+ # rc!=0 인 멤버는 제외한다: 안 돌아간 CLI 는 검증도 못 한다(한도 소진이 그 형태).
218
+ ROSTER="$OUT/roster.txt"
219
+ /usr/bin/python3 -c '
220
+ import re,sys
221
+ seen=[]
222
+ for l in open(sys.argv[1],encoding="utf-8",errors="replace"):
223
+ # 🟥 role 값에 공백이 들어갈 수 있다(fleet 표가 허용한다). 접두 전체를 한 패턴으로 묶으면
224
+ # 그런 줄이 통째로 무시되고 명부가 «빈 채로 성공» 한다 — 두 필드를 따로 잡는다.
225
+ # 🟥 R2 #6: `\brc=` 는 role 안의 rc= 를 먼저 잡는다(role=rc=1 rc=0 → 오탈락, 실행 확인).
226
+ # 레코드 형식이 고정이므로 «뒤에서» 잡는다 — rc= 다음에 findings= 가 오는 것이 앵커다.
227
+ # 🟥 R3 #6: «findings= 가 뒤에 온다» 는 접미 경계가 아니었다 — role=`rc=0 findings=x` 가 먼저 잡혔다.
228
+ # 레코드 끝(`rc=N findings=N [self_dropped=N] status=S$`)에 앵커한다.
229
+ fm = re.search(r"\bfamily=(\S+)", l); rm = re.search(r" rc=(\S+) findings=\S+(?: self_dropped=\S+)? status=\S+\s*$", l)
230
+ if not l.startswith("MEMBER") or not fm or not rm: continue
231
+ fam, rc = fm.group(1), rm.group(1)
232
+ if rc != "0": continue
233
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+", fam):
234
+ sys.stderr.write("finding_pipeline: illegal family %r in fleet roster — refusing to route\n" % (fam,))
235
+ sys.exit(2)
236
+ if fam not in seen: seen.append(fam)
237
+ if not seen:
238
+ # 🟥 빈 목록을 «성공» 으로 내보내면 안 된다. `print("\n".join([]))` 는 개행 1바이트를 쓰고
239
+ # `[ -s ]` 는 그걸 «내용 있음» 으로 읽는다 — 그러면 DEGRADE 분기가 통째로 건너뛰어지고
240
+ # 아무도 검증자를 못 받는다(cross-family #4, codex 2026-09-10).
241
+ sys.stderr.write("finding_pipeline: fleet roster parsed 0 usable families\n"); sys.exit(4)
242
+ print("\n".join(seen))' "$OUT/fleet/members.txt" > "$ROSTER" 2>"$OUT/roster.err"
243
+ ROSTRC=$?
244
+ _ROSTER_N=$(/usr/bin/grep -c . "$ROSTER" 2>/dev/null); _ROSTER_N=${_ROSTER_N:-0}
245
+ if [ "$ROSTRC" -ne 0 ] || [ "$_ROSTER_N" -eq 0 ]; then
246
+ cat "$OUT/roster.err" >&2
247
+ # 🟥 degrade 는 «조용히 넘어감» 이 아니라 «좁은 풀로 되돌아감 + 그 사실을 말함» 이다.
248
+ # 없는 검증자를 만들어내지 않으므로 방향은 안전하다(과소검증 쪽으로 실패한다).
249
+ echo " ⚠️ fleet roster unusable (rc=$ROSTRC) — verifier pool DEGRADED to producer families; a family that produced 0 findings cannot be picked" >&2
250
+ /bin/cp "$OUT/families.txt" "$ROSTER" 2>/dev/null || : > "$ROSTER"
251
+ ROSTER_SRC="families(DEGRADED)"
252
+ else
253
+ ROSTER_SRC="fleet"
254
+ fi
255
+
124
256
  supported() { case " $SUPPORTED_FAMILIES " in *" $1 "*) return 0;; *) return 1;; esac; }
125
257
  pick_verifier() { # $1=producer — a DIFFERENT family the wrapper can actually run
126
258
  local p="$1" f
127
- while IFS= read -r f; do [ -n "$f" ] && [ "$f" != "$p" ] && supported "$f" && { echo "$f"; return; }; done < "$OUT/families.txt"
259
+ while IFS= read -r f; do [ -n "$f" ] && [ "$f" != "$p" ] && supported "$f" && { echo "$f"; return; }; done < "$ROSTER"
128
260
  echo ""
129
261
  }
130
262
  pick_auditor() { # $1=producer $2=verifier — prefer a third party; fall back to producer (appeal)
131
263
  local p="$1" v="$2" f
132
264
  while IFS= read -r f; do
133
265
  [ -n "$f" ] && [ "$f" != "$p" ] && [ "$f" != "$v" ] && supported "$f" && { echo "$f"; return; }
134
- done < "$OUT/families.txt"
266
+ done < "$ROSTER"
135
267
  supported "$p" && { echo "$p"; return; }
136
268
  echo ""
137
269
  }
@@ -140,7 +272,11 @@ pick_auditor() { # $1=producer $2=verifier — prefer a third party; fall back t
140
272
  # run's verdict while a confirmed survivor sat in the other split. Rank them explicitly instead, and
141
273
  # derive 1-vs-0 from the survivor count at the end, never from a split.
142
274
  WORST_RANK=0; WORST_CODE=0
143
- rank_of() { case "$1" in 2) echo 4;; 3) echo 3;; 4) echo 2;; *) echo 0;; esac; }
275
+ # 🟥 exit 5 (SEEDED DEGRADED/INCONCLUSIVE/ABSENT) had NO CASE here, so it fell to the default 0
276
+ # and the driver exited 0 while a split had just reported that the filter deleted a known-true
277
+ # finding. The entire seeded control was invisible at the level a reader actually reads.
278
+ # (cross-family round 4, gemini family, S severity — three same-family rounds missed it.)
279
+ rank_of() { case "$1" in 2) echo 5;; 5) echo 4;; 3) echo 3;; 4) echo 2;; *) echo 0;; esac; }
144
280
  note_rc() { local r; r=$(rank_of "$1"); if [ "$r" -gt "$WORST_RANK" ]; then WORST_RANK="$r"; WORST_CODE="$1"; fi; }
145
281
 
146
282
  : > "$OUT/confirmed.jsonl"; : > "$OUT/dropped.jsonl"; : > "$OUT/splits.txt"
@@ -169,16 +305,84 @@ for l in open(sys.argv[1],encoding="utf-8"):
169
305
  printf 'split producer=%s verifier=%s rc=0 (no findings)\n' "$PROD" "$VER" >> "$OUT/splits.txt"; continue
170
306
  fi
171
307
 
308
+ # 🟥 검증 패스의 CLI stderr(토큰 회계 «tokens used» 포함)가 mktemp 에 쓰이고 cleanup 이 지웠다.
309
+ # 그래서 «검증 패스 토큰 = 0바이트» 로 보였는데 0 이 아니라 «버려진 것» 이다. --keep 은 이미
310
+ # 있는 채널이고 파이프라인이 안 넘겼을 뿐이다. split 디렉터리에 남긴다(2026-09-10).
311
+ # 🟥 R2 #7: 검증과 감사가 같은 keep 을 쓰면 드롭이 있는 런에서 감사가 검증 err.txt 를 덮는다 —
312
+ # 토큰 회계가 «바로 그 런» 에서 또 버려진다. 갈라 둔다. #1: 잎 경로 심링크도 여기서 거부.
313
+ for _p in "$SD/keep_verify" "$SD/keep_audit"; do
314
+ [ -L "$_p" ] && { echo "finding_pipeline: refusing to write through a symlink: $_p" >&2; note_rc 2; continue 2; }
315
+ mkdir -p "$_p"
316
+ for _leaf in raw.txt err.txt prompt.txt findings.jsonl; do
317
+ [ -L "$_p/$_leaf" ] && { echo "finding_pipeline: refusing to write through a symlink: $_p/$_leaf" >&2; note_rc 2; continue 3; }
318
+ done
319
+ done
320
+ export FH_VERIFIER_KEEP="$SD" # verifier 가 MODE 별로 keep_verify/ keep_audit/ 를 «스스로» 고른다 (R2 #7)
172
321
  AUDARGS=()
173
322
  if [ -n "$AUD" ]; then
174
323
  AUDARGS=(--audit-verifier-argv "$(argv_json bash "$VERIFIER_SH" --family "$AUD" --target "$TARGET" --audit)" --audit-family "$AUD")
175
324
  else
176
325
  echo " ⚠️ split producer=$PROD — no supported auditor; any drop will come back UNAUDITED" >&2
177
326
  fi
327
+ # 🟥 Seeds are forwarded PER SPLIT, filtered to the ids that actually live in that split's input.
328
+ # Forwarding the whole declared set to every split would make each split report ABSENT for the
329
+ # seeds that belong to the other producer — a false alarm manufactured by the routing, not by the
330
+ # filter. The global "did any declared seed enter at all?" question is answered once, below.
331
+ SEEDARGS=()
332
+ if [ -n "$ALL_SEEDS" ]; then
333
+ # 🟥 R6 #3: resolve each declared seed against the WHOLE fleet result BEFORE partitioning — two rows in
334
+ # different splits sharing one member_id each looked unique inside their split (CLEAN twice) while the
335
+ # unsplit verifier calls the same declaration AMBIGUOUS. Global multiplicity is decided once, here.
336
+ _AMBIG=$(ALL_SEEDS="$ALL_SEEDS" /usr/bin/python3 -c '
337
+ import sys, json, os, collections
338
+ want = {x.strip() for x in os.environ.get("ALL_SEEDS", "").split(",") if x.strip()}
339
+ cnt = collections.Counter()
340
+ try:
341
+ for l in open(sys.argv[1], encoding="utf-8"):
342
+ l = l.strip()
343
+ if not l: continue
344
+ try: d = json.loads(l)
345
+ except Exception: continue
346
+ if not isinstance(d, dict): continue
347
+ keys = {str(d[k]) for k in ("id", "member_id") if d.get(k) is not None}
348
+ for k in keys & want: cnt[k] += 1
349
+ except OSError: pass
350
+ print(",".join(sorted(k for k, c in cnt.items() if c > 1)))' "$FINDINGS")
351
+ if [ -n "$_AMBIG" ]; then
352
+ echo "PIPELINE target=$(basename "$TARGET") status=SEED_AMBIGUOUS seeds=$_AMBIG rc=5"
353
+ echo "SEEDED declared=$(printf '%s' "$ALL_SEEDS" | /usr/bin/tr ',' '\n' | /usr/bin/grep -c .) present=0 kept=0 dropped=0 abstained=0 status=AMBIGUOUS"
354
+ echo " 🟥 seed(s) $_AMBIG match more than one fleet row — member ids are only locally unique; declare the routing id instead" >&2
355
+ exit 5
356
+ fi
357
+ _SPLIT_SEEDS=$(ALL_SEEDS="$ALL_SEEDS" /usr/bin/python3 -c '
358
+ import sys, json, os
359
+ want = {x.strip() for x in os.environ.get("ALL_SEEDS", "").split(",") if x.strip()}
360
+ have = set()
361
+ try:
362
+ for l in open(sys.argv[1], encoding="utf-8"):
363
+ l = l.strip()
364
+ if not l: continue
365
+ try: d = json.loads(l)
366
+ except Exception: continue
367
+ if isinstance(d, dict):
368
+ if d.get("id") is not None: have.add(str(d["id"]))
369
+ if d.get("member_id") is not None: have.add(str(d["member_id"]))
370
+ except OSError: pass
371
+ print(",".join(sorted(want & have)))' "$SD/in.jsonl")
372
+ if [ -n "$_SPLIT_SEEDS" ]; then
373
+ SEEDARGS=(--seeded "$_SPLIT_SEEDS")
374
+ fi
375
+ fi
178
376
  /usr/bin/python3 "$VERIFY_PY" "$SD/in.jsonl" --out "$SD" \
179
377
  --verifier-argv "$(argv_json bash "$VERIFIER_SH" --family "$VER" --target "$TARGET")" --family "$VER" \
180
- ${AUDARGS[@]+"${AUDARGS[@]}"} > "$SD/summary.txt" 2>"$SD/err.txt"
378
+ ${SEEDARGS[@]+"${SEEDARGS[@]}"} ${AUDARGS[@]+"${AUDARGS[@]}"} > "$SD/summary.txt" 2>"$SD/err.txt"
181
379
  RC=$?
380
+ # 🟥 «라우팅했다» 를 «검증됐다» 로 세면 안 된다. 초판은 verify 를 부르기 «전» 에 기록해서,
381
+ # verify 가 크래시해도 그 씨앗은 «돌았다» 로 남았다(cross-family round 6). 요약을 실제로
382
+ # 낸 실행에 대해서만 기록한다.
383
+ if [ -n "${_SPLIT_SEEDS:-}" ] && [ -s "$SD/summary.txt" ]; then
384
+ SEEDS_ROUTED="${SEEDS_ROUTED:+$SEEDS_ROUTED,}$_SPLIT_SEEDS"
385
+ fi
182
386
  note_rc "$RC"
183
387
  cat "$SD/summary.txt"
184
388
  printf 'split producer=%s verifier=%s auditor=%s rc=%s\n' "$PROD" "$VER" "${AUD:-NONE}" "$RC" >> "$OUT/splits.txt"
@@ -196,6 +400,154 @@ TOTAL_UNVER=$(count_lines '"verdict": *"unverified"' "$OUT/confirmed.jsonl")
196
400
  # becomes true by wording alone.
197
401
  TOTAL_AUD=$(count_lines '"drop_verdict": *"\(correct-drop\|wrong-drop\|uncertain\)"' "$OUT/dropped.jsonl")
198
402
  TOTAL_WRONG=$(count_lines '"reinstated": *true' "$OUT/confirmed.jsonl")
403
+ # 🟥 COVERAGE ALSO RIDES THE DRIVER LINE, not only the per-split one. `finding_verify.py` records it
404
+ # per split, but the driver's summary is what a reader actually reads — a field recorded in a place
405
+ # nobody reads is the half-externalisation shape this repo keeps re-finding (a slot with zero
406
+ # consumers always reports "done", because presence is doing the judging).
407
+ #
408
+ # 🟥 THREE things this number got wrong on the first attempt, all found by cross-family round 2:
409
+ # ⓐ it subtracted `unverified` but NOT `needs-debate` — an all-debate run printed 100%. The
410
+ # verifier had already been fixed for exactly this and the driver had not: a half-fix that
411
+ # stopped at the propagation boundary, which is the failure mode this repo has a name for.
412
+ # ⓑ the DENOMINATOR was `confirmed + dropped`, i.e. what came OUT. A partition that is skipped
413
+ # (unsupported family) then vanishes from numerator and denominator alike and coverage reads
414
+ # 100% while findings were never judged at all. The denominator must be what came IN.
415
+ # ⓒ the counters were line greps, so passthrough metadata containing `"verdict": "unverified"`
416
+ # anywhere in a row counted as an abstention. Verdicts are read from the TOP LEVEL of each row.
417
+ # 🟥 판정 수는 «뺄셈» 이 아니라 «양의 계수» 로 구한다. 초판은 입력 총수에서 기권을 뺐는데,
418
+ # 그러면 **출력에 아예 안 나타난 것이 판정된 것으로 계수된다** — 건너뛴 파티션도, 읽기 실패도,
419
+ # 깨진 JSON 도 전부 «100%» 가 된다(cross-family R3 가 A급 둘로 지목, 재현됨).
420
+ # 판정 = 「해결된 verdict 를 실제로 들고 있는 행」의 수다. 없으면 0 이고, 그것이 fail-closed 다.
421
+ _count_json() { # $1 = 파일, $2 = 필드, $3.. = 셀 값들. 실패는 «0» 이 아니라 비-영 종료로 낸다.
422
+ /usr/bin/python3 -c '
423
+ import sys, json
424
+ path, field, want = sys.argv[1], sys.argv[2], set(sys.argv[3:])
425
+ n = 0
426
+ try:
427
+ fh = open(path, encoding="utf-8")
428
+ except FileNotFoundError:
429
+ print(0); sys.exit(0) # 파일 자체가 없는 것은 «행이 0» 이다
430
+ except OSError as e:
431
+ print("count: %s" % e, file=sys.stderr); sys.exit(9)
432
+ with fh:
433
+ try:
434
+ for l in fh:
435
+ l = l.strip()
436
+ if not l: continue
437
+ d = json.loads(l) # 깨진 줄은 «건너뛰기» 가 아니라 계측 오류다
438
+ if not isinstance(d, dict): raise ValueError("row is not an object")
439
+ v = d.get(field)
440
+ if v is not None and not isinstance(v, str): raise ValueError("non-string %s" % field)
441
+ if v in want: n += 1
442
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
443
+ print("count: %s" % e, file=sys.stderr); sys.exit(9)
444
+ print(n)' "$@"
445
+ }
446
+ _count_lines_json() { # 행 수 — 같은 실패 규율
447
+ /usr/bin/python3 -c '
448
+ import sys
449
+ n = 0
450
+ try:
451
+ fh = open(sys.argv[1], encoding="utf-8")
452
+ except FileNotFoundError:
453
+ print(0); sys.exit(0)
454
+ except OSError as e:
455
+ print("count: %s" % e, file=sys.stderr); sys.exit(9)
456
+ with fh:
457
+ try:
458
+ for l in fh:
459
+ if l.strip(): n += 1
460
+ except UnicodeDecodeError as e:
461
+ print("count: %s" % e, file=sys.stderr); sys.exit(9)
462
+ print(n)' "$1"
463
+ }
464
+ COUNT_ERR=0
465
+ TOTAL_IN=$(_count_lines_json "$FINDINGS") || COUNT_ERR=1
466
+ TOTAL_UNVER=$(_count_json "$OUT/confirmed.jsonl" verdict unverified) || COUNT_ERR=1
467
+ TOTAL_DEBATE=$(_count_json "$OUT/confirmed.jsonl" verdict needs-debate) || COUNT_ERR=1
468
+ # 🟥 양의 계수: 실제로 «해결된» 판정을 들고 있는 행만 센다.
469
+ # 복권된 행은 이제 verdict=confirmed 를 단다(verify 쪽 근원 수리). 그 전에 만들어진 산출물과의
470
+ # 호환을 위해 «reinstated 이면서 false-positive» 도 판정으로 센다 — 그 행은 감사가 «되돌렸다» 는
471
+ # 결정을 받은 것이지 미판정이 아니다.
472
+ JUDGED_CONF=$(_count_json "$OUT/confirmed.jsonl" verdict confirmed) || COUNT_ERR=1
473
+ JUDGED_REINST=$(/usr/bin/python3 -c '
474
+ import sys, json
475
+ n = 0
476
+ try:
477
+ fh = open(sys.argv[1], encoding="utf-8")
478
+ except FileNotFoundError:
479
+ print(0); sys.exit(0)
480
+ except OSError as e:
481
+ print("count: %s" % e, file=sys.stderr); sys.exit(9)
482
+ with fh:
483
+ try:
484
+ for l in fh:
485
+ l = l.strip()
486
+ if not l: continue
487
+ d = json.loads(l)
488
+ if isinstance(d, dict) and d.get("reinstated") is True and d.get("verdict") != "confirmed":
489
+ n += 1
490
+ except (json.JSONDecodeError, ValueError, UnicodeDecodeError) as e:
491
+ print("count: %s" % e, file=sys.stderr); sys.exit(9)
492
+ print(n)' "$OUT/confirmed.jsonl") || COUNT_ERR=1
493
+ JUDGED_DROP=$(_count_json "$OUT/dropped.jsonl" verdict false-positive) || COUNT_ERR=1
494
+ TOTAL_IN=${TOTAL_IN:-0}; TOTAL_UNVER=${TOTAL_UNVER:-0}; TOTAL_DEBATE=${TOTAL_DEBATE:-0}
495
+ JUDGED_CONF=${JUDGED_CONF:-0}; JUDGED_DROP=${JUDGED_DROP:-0}
496
+ JUDGED_REINST=${JUDGED_REINST:-0}
497
+ TOTAL_JUDGED=$(( JUDGED_CONF + JUDGED_DROP + JUDGED_REINST ))
498
+ # 🟥 Split-level ABSENT cannot fire once seeds are filtered per split, so the "did the control run
499
+ # at all?" question is asked ONCE, globally, against the whole fleet output.
500
+ if [ -n "$ALL_SEEDS" ]; then
501
+ _SEEDS_SEEN=$(ALL_SEEDS="$ALL_SEEDS" /usr/bin/python3 -c '
502
+ import sys, json, os
503
+ want = {x.strip() for x in os.environ.get("ALL_SEEDS", "").split(",") if x.strip()}
504
+ have = set()
505
+ try:
506
+ for l in open(sys.argv[1], encoding="utf-8"):
507
+ l = l.strip()
508
+ if not l: continue
509
+ try: d = json.loads(l)
510
+ except Exception: continue
511
+ if isinstance(d, dict):
512
+ if d.get("id") is not None: have.add(str(d["id"]))
513
+ if d.get("member_id") is not None: have.add(str(d["member_id"]))
514
+ except OSError: pass
515
+ print(len(want & have))' "$FINDINGS")
516
+ if [ "${_SEEDS_SEEN:-0}" -eq 0 ]; then
517
+ echo " ⚠️ declared seeds are not present anywhere in the fleet output — the control never ran" >&2
518
+ note_rc 5
519
+ fi
520
+ # 🟥 «하나라도 들어왔나» 로는 부족하다. 선언한 씨앗 중 **어떤 split 에도 안 실린 것**이 있으면
521
+ # 그 씨앗은 검증기를 통과한 적이 없고, 그런데도 나머지가 CLEAN 이면 드라이버는 0 을 낸다.
522
+ # 두 경로로 그렇게 된다: ⓐ fleet 출력에 애초에 없다 ⓑ 있는데 producer_family 가 없거나
523
+ # 지원되지 않아 라우팅에서 빠진다. 둘 다 «통제가 그 씨앗에 대해 안 돌았다» 는 같은 사실이다.
524
+ # (cross-family round 5, gemini 계열.)
525
+ _SEEDS_MISSED=$(ALL_SEEDS="$ALL_SEEDS" ROUTED="${SEEDS_ROUTED:-}" /usr/bin/python3 -c '
526
+ import os
527
+ want = {x.strip() for x in os.environ.get("ALL_SEEDS", "").split(",") if x.strip()}
528
+ got = {x.strip() for x in os.environ.get("ROUTED", "").split(",") if x.strip()}
529
+ missed = sorted(want - got)
530
+ print(",".join(missed))')
531
+ if [ -n "$_SEEDS_MISSED" ]; then
532
+ echo " ⚠️ declared seeds never reached any verified split: $_SEEDS_MISSED — the control did not run for them" >&2
533
+ note_rc 5
534
+ fi
535
+ fi
536
+ if [ "$COUNT_ERR" -ne 0 ]; then
537
+ echo " ⚠️ coverage 계측이 실패했다 — 이 런의 coverage 는 판정이 아니라 «못 잼» 이다" >&2
538
+ note_rc 3
539
+ fi
540
+ # 🟥 회계 불일치는 clamp 로 덮지 않는다 — 판정이 입력보다 많으면 그건 «0으로 눌러서 정상처럼
541
+ # 보이게 할 것» 이 아니라 계측 결함이다(R3 지적).
542
+ if [ "$TOTAL_JUDGED" -gt "$TOTAL_IN" ]; then
543
+ echo " ⚠️ coverage 회계 불일치: 판정 $TOTAL_JUDGED > 입력 $TOTAL_IN" >&2
544
+ note_rc 3
545
+ fi
546
+ if [ "$TOTAL_IN" -gt 0 ]; then
547
+ COV_PCT=$(( TOTAL_JUDGED * 100 / TOTAL_IN )) # floored, never rounded
548
+ else
549
+ COV_PCT=0
550
+ fi
199
551
 
200
552
  if [ "$FAILED_MEMBERS" -gt 0 ]; then
201
553
  echo " ⚠️ $FAILED_MEMBERS fleet member(s) exited non-zero — a member that crashed after emitting some findings leaves its family looking complete" >&2
@@ -205,9 +557,16 @@ fi
205
557
  RC_FINAL="$WORST_CODE"
206
558
  [ "$WORST_RANK" -eq 0 ] && [ "$TOTAL_CONF" -eq 0 ] && RC_FINAL=1
207
559
 
208
- printf 'PIPELINE target=%s families=%s confirmed=%s dropped=%s unverified=%s audited_drops=%s reinstated=%s failed_members=%s rc=%s\n' \
209
- "$(basename "$TARGET")" "$(tr '\n' ',' < "$OUT/families.txt" | sed 's/,$//')" "$TOTAL_CONF" "$TOTAL_DROP" \
210
- "$TOTAL_UNVER" "$TOTAL_AUD" "$TOTAL_WRONG" "$FAILED_MEMBERS" "$RC_FINAL"
560
+ # 🟥 `confirmed=` prints the RESOLVED confirmations, not the raw line count of confirmed.jsonl.
561
+ # That file also holds debate and unverified rows, so the driver printed `confirmed=2 debate=2
562
+ # coverage=0/2` three numbers in one line contradicting each other. The verifier had already been
563
+ # fixed for exactly this; the driver had not. **Third recurrence of the same half-fix shape in this
564
+ # change** (cross-family round 6). Survivor semantics for rc stay on TOTAL_CONF.
565
+ printf 'PIPELINE target=%s families=%s roster=%s(%s) confirmed=%s dropped=%s unverified=%s debate=%s coverage=%s/%s (%s%%) audited_drops=%s reinstated=%s failed_members=%s blocked_members=%s rc=%s\n' \
566
+ "$(basename "$TARGET")" "$(tr '\n' ',' < "$OUT/families.txt" | sed 's/,$//')" \
567
+ "$(tr '\n' ',' < "$ROSTER" | sed 's/,$//')" "$ROSTER_SRC" "$JUDGED_CONF" "$TOTAL_DROP" \
568
+ "$TOTAL_UNVER" "$TOTAL_DEBATE" "$TOTAL_JUDGED" "$TOTAL_IN" "$COV_PCT" "$TOTAL_AUD" "$TOTAL_WRONG" \
569
+ "$FAILED_MEMBERS" "$BLOCKED_MEMBERS" "$RC_FINAL"
211
570
  # `unverified` is reported on its own line rather than folded into `confirmed`, because folding it is
212
571
  # exactly the "not found rendered as zero" family this repo keeps re-finding.
213
572
  exit "$RC_FINAL"
@@ -22,6 +22,9 @@ CODEX="${FH_CODEX_BIN:-$(command -v codex 2>/dev/null || echo "$HOME/.npm-global
22
22
  AGY="${FH_AGY_BIN:-$(command -v agy 2>/dev/null || echo "$HOME/.local/bin/agy")}"
23
23
 
24
24
  FAMILY=""; TARGET=""; MODE="verify"; KEEP=""
25
+ # 호출자(파이프라인)가 env 로 보존 경로를 줄 수 있다 — 검증 패스의 CLI stderr(토큰 회계)가 여기서 산다.
26
+ # --keep 플래그가 뒤에서 덮으므로 env 는 «폴백» 이지 강제가 아니다. (2026-09-10: 0바이트 ≠ 0 토큰)
27
+ KEEP="${FH_VERIFIER_KEEP:-}"
25
28
  usage() { echo "usage: finding_verifier.sh --family codex|gemini --target <file> [--audit] [--keep <dir>]" >&2; exit 2; }
26
29
  # `shift 2` with only one word left FAILS and consumes nothing, so the loop spins forever. Under
27
30
  # `set -uo pipefail` (no errexit) nothing stops it. codex reproduced a hang on a trailing --family.
@@ -52,7 +55,33 @@ fi
52
55
  umask 077
53
56
 
54
57
  WORK="$(mktemp -d 2>/dev/null)" || { echo "finding_verifier: mktemp failed" >&2; exit 3; }
55
- cleanup() { [ -n "$KEEP" ] && cp "$WORK"/* "$KEEP"/ 2>/dev/null; rm -rf "$WORK"; }
58
+ # 🟥 R2 #7: 검증과 감사가 같은 keep 쓰면 감사가 검증 err.txt(토큰 회계)를 덮는다 — 드롭이 있는 런에서
59
+ # «바로 그 런» 의 회계가 또 버려진다. MODE 별 하위 디렉터리로 가른다. R2 #1: 심링크 잎은 복사 전에 거부.
60
+ # 🟥 R3 #3: the root is checked BEFORE the mode dir is appended — appended first, a symlink root became
61
+ # an ancestor and passed both the dir check and the leaf check (path probe: INPUT_IS_SYMLINK → GUARD_ALLOWS).
62
+ # R4 #2: `link/` makes -L false (the trailing slash resolves through the link) — strip separators first, keep "/" intact.
63
+ # R6 #5: `link/.` and `link//.` also resolve through the link — strip trailing "/." and "/" until stable.
64
+ while [ "${#KEEP}" -gt 1 ]; do
65
+ case "$KEEP" in
66
+ */.) KEEP="${KEEP%/.}" ;;
67
+ */) KEEP="${KEEP%/}" ;;
68
+ *) break ;;
69
+ esac
70
+ done
71
+ if [ -n "$KEEP" ] && [ -L "$KEEP" ]; then echo "finding_verifier: --keep root is a symlink: $KEEP — refusing" >&2; exit 2; fi
72
+ if [ -n "$KEEP" ] && [ -z "${FH_VERIFIER_KEEP_FLAT:-}" ]; then KEEP="$KEEP/keep_$MODE"; fi
73
+ cleanup() {
74
+ if [ -n "$KEEP" ]; then
75
+ [ -L "$KEEP" ] && { echo "finding_verifier: refusing to keep through a symlink: $KEEP" >&2; rm -rf "$WORK"; return; }
76
+ mkdir -p "$KEEP" 2>/dev/null
77
+ for _f in "$WORK"/*; do
78
+ _dst="$KEEP/$(basename "$_f")"
79
+ [ -L "$_dst" ] && { echo "finding_verifier: refusing to keep through a symlink: $_dst" >&2; continue; }
80
+ cp "$_f" "$_dst" 2>/dev/null
81
+ done
82
+ fi
83
+ rm -rf "$WORK"
84
+ }
56
85
  trap cleanup EXIT
57
86
 
58
87
  cat > "$WORK/findings.jsonl"
@@ -95,7 +124,7 @@ fi
95
124
  case "$FAMILY" in
96
125
  codex) "$CODEX" exec --sandbox read-only --skip-git-repo-check -m gpt-6-astra \
97
126
  -c model_reasoning_effort="high" < "$WORK/prompt.txt" > "$WORK/raw.txt" 2>"$WORK/err.txt" ;;
98
- gemini) "$AGY" --model gemini-3.8-flash-high --output-format text --print-timeout 5m \
127
+ gemini) "$AGY" --mode plan --sandbox --model gemini-3.8-flash-high --output-format text --print-timeout 20m \
99
128
  -p "$(cat "$WORK/prompt.txt")" < /dev/null > "$WORK/raw.txt" 2>"$WORK/err.txt" ;;
100
129
  *) echo "finding_verifier: unknown family '$FAMILY' (codex|gemini)" >&2; exit 2 ;;
101
130
  esac