@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
@@ -36,7 +36,7 @@ AGY="${FH_AGY_BIN:-$(command -v agy 2>/dev/null || echo "$HOME/.local/bin/agy")}
36
36
  default_fleet() {
37
37
  cat <<'EOF'
38
38
  codex|logic|CODEX_BIN exec --sandbox read-only --skip-git-repo-check -m gpt-6-astra -c model_reasoning_effort="high"
39
- gemini|security|AGY_BIN --model gemini-3.8-flash-high --output-format text --print-timeout 5m -p "$(cat PROMPT_FILE)"
39
+ gemini|security|AGY_BIN --mode plan --sandbox --model gemini-3.8-flash-high --output-format text --print-timeout 20m -p "$(cat PROMPT_FILE)"
40
40
  EOF
41
41
  }
42
42
 
@@ -53,6 +53,226 @@ Report only defects you can point to a specific line for. If you find none, outp
53
53
 
54
54
  Your assigned area: '
55
55
 
56
+ PROMPT_HEAD_R2='You reviewed this file already. Another reviewer, from a DIFFERENT model family,
57
+ reviewed the same file independently. Both lists are below.
58
+
59
+ 🟥 THIS IS NOT AN ACCEPT/REJECT PASS. You are not being asked to approve or delete the other reviewer findings.
60
+ You are being asked to produce YOUR OWN final list, having now seen theirs.
61
+
62
+ Do all of these that apply:
63
+ - KEEP each of your findings that still holds. Do not drop one merely because the other reviewer
64
+ did not report it — they had a different assigned area.
65
+ - CORRECT any of yours that their list shows to be wrong, imprecise, or on the wrong line.
66
+ - SHARPEN a finding whose `defeater` you can now state more concretely.
67
+ - ADD findings you did not make the first time, including ones their list made you look again at.
68
+ A finding of theirs that you can now independently confirm against the source IS yours to state —
69
+ state it in your own words with your own defeater.
70
+ - If you now believe one of YOUR earlier findings was wrong, drop it AND say so in a final line
71
+ beginning `DROPPED:` naming the title and why. That line is prose and is expected.
72
+
73
+ Output ONLY JSON Lines in the same schema as before, one object per finding, then optionally the
74
+ DROPPED: lines. Your assigned area is unchanged: '
75
+ # F_r2ctrl(컨트롤) 머리 — 위와 같은 구조(KEEP/CORRECT/SHARPEN/ADD/DROPPED)에서 «다른 검토자» 언급만 뺐다.
76
+ PROMPT_HEAD_R2_BLIND='You reviewed this file already. Your own round-1 list is below.
77
+
78
+ 🟥 THIS IS NOT AN ACCEPT/REJECT PASS. You are being asked to produce YOUR OWN final list on a second,
79
+ independent look at the same file.
80
+
81
+ Do all of these that apply:
82
+ - KEEP each of your findings that still holds.
83
+ - CORRECT any of yours that a second reading shows to be wrong, imprecise, or on the wrong line.
84
+ - SHARPEN a finding whose `defeater` you can now state more concretely.
85
+ - ADD findings you did not make the first time.
86
+ - If you now believe one of YOUR earlier findings was wrong, drop it AND say so in a final line
87
+ beginning `DROPPED:` naming the title and why. That line is prose and is expected.
88
+
89
+ Output ONLY JSON Lines in the same schema as before, one object per finding, then optionally the
90
+ DROPPED: lines. Your assigned area is unchanged: '
91
+
92
+ run_member_r2() { # $1=family $2=role $3=command $4=target $5=outdir
93
+ local fam="$1" role="$2" cmd="$3" tgt="$4" out="$5"
94
+ local q_codex q_agy q_pf
95
+ q_codex="$(printf '%q' "$CODEX")"; q_agy="$(printf '%q' "$AGY")"
96
+ cmd="${cmd//CODEX_BIN/$q_codex}"; cmd="${cmd//AGY_BIN/$q_agy}"
97
+ local raw="$out/raw2_${fam}_${role}.txt" pf="$out/prompt2_${fam}_${role}.txt"
98
+ for _p in "$raw" "$pf" "$out/err2_${fam}_${role}.txt" "$out/part2_${fam}_${role}.jsonl" "$out/peer_${fam}_${role}.txt"; do
99
+ [ -L "$_p" ] && { echo "finding_fleet: refusing to write through a symlink: $_p" >&2; return 1; }
100
+ done
101
+ # 자기 것과 «남의 것» 을 갈라서 보여준다 — 어느 쪽이 자기 것인지 모르면 수정할 수가 없다
102
+ # 🟥 R4 #8: «mine» is the MEMBER (family+role), not the family — two roles of one family were each
103
+ # told the other's findings were their own. R4 #5: own rows carry `id` so a kept/revised finding
104
+ # keeps its identity through round 2 (a declared seed otherwise turns ABSENT while surviving).
105
+ MINE_FAM="$fam" MINE_ROLE="$role" R2_BLIND="${ROUND2_BLIND:-0}" /usr/bin/python3 -c '
106
+ import sys, json, os
107
+ mine, kin, theirs = [], [], []
108
+ fam, role = os.environ["MINE_FAM"], os.environ["MINE_ROLE"]
109
+ blind = os.environ.get("R2_BLIND") == "1"
110
+ for l in open(sys.argv[1], encoding="utf-8"):
111
+ l = l.strip()
112
+ if not l: continue
113
+ try: d = json.loads(l)
114
+ except Exception: continue
115
+ row = {k: d.get(k) for k in ("title","file","line","severity","category","detail","defeater")}
116
+ if d.get("producer_family") == fam and d.get("producer_role") == role:
117
+ mine.append(dict(id=d.get("id"), **row))
118
+ elif d.get("producer_family") == fam:
119
+ kin.append(row)
120
+ else:
121
+ theirs.append(row)
122
+ with open(sys.argv[2], "w", encoding="utf-8") as w:
123
+ w.write("\n===== YOUR OWN ROUND-1 FINDINGS (keep the id field verbatim on any you KEEP or CORRECT) =====\n")
124
+ for r in mine: w.write(json.dumps(r, ensure_ascii=False) + "\n")
125
+ if not mine: w.write("(you reported none)\n")
126
+ if kin:
127
+ w.write("\n===== OTHER MEMBERS OF YOUR OWN FAMILY (not yours — do not keep/drop these) =====\n")
128
+ for r in kin: w.write(json.dumps(r, ensure_ascii=False) + "\n")
129
+ if blind:
130
+ # F_r2ctrl 컨트롤: 상대 목록을 «감춘다». 절 자체를 안 쓴다 — «(withheld)» 라고 쓰면 모델이 그 부재를 추론 재료로 쓴다
131
+ pass
132
+ else:
133
+ w.write("\n===== THE OTHER FAMILY ROUND-1 FINDINGS =====\n")
134
+ for r in theirs: w.write(json.dumps(r, ensure_ascii=False) + "\n")
135
+ if not theirs: w.write("(they reported none)\n")
136
+ ' "$out/findings_r1.jsonl" "$out/peer_${fam}_${role}.txt" || return 1 # R3 #9: per-member — two roles of one family raced on a shared truncate/write
137
+ local head_r2="$PROMPT_HEAD_R2"; [ "${ROUND2_BLIND:-0}" = 1 ] && head_r2="$PROMPT_HEAD_R2_BLIND"
138
+ { printf '%s%s\n\n===== FILE: %s =====\n' "$head_r2" "$role" "$(basename "$tgt")"
139
+ cat "$tgt"; cat "$out/peer_${fam}_${role}.txt"; } > "$pf"
140
+ q_pf="$(printf '%q' "$pf")"
141
+ cmd="${cmd//PROMPT_FILE/$q_pf}"
142
+ eval "$cmd" < "$pf" > "$raw" 2>"$out/err2_${fam}_${role}.txt"
143
+ local rc=$?
144
+ FAM="$fam" ROLE="$role" RC="$rc" /usr/bin/python3 - "$raw" "$out/part2_${fam}_${role}.jsonl" "$out/findings_r1.jsonl" <<'PYR2'
145
+ import json, os, sys
146
+ own_r1 = {} # own round-1 id -> its trusted member_id alias (or None)
147
+ try:
148
+ for _l in open(sys.argv[3], encoding="utf-8"):
149
+ try: _d = json.loads(_l)
150
+ except Exception: continue
151
+ if _d.get("producer_family") == os.environ["FAM"] and _d.get("producer_role") == os.environ["ROLE"] and _d.get("id") is not None:
152
+ own_r1[str(_d["id"])] = _d.get("member_id")
153
+ except OSError:
154
+ pass
155
+ own_r1_ids = set(own_r1)
156
+ own_r1_titles = {} # R8 #1: id -> title, so a DROPPED line citing the title (not the id) still accounts for it
157
+ try:
158
+ for _l in open(sys.argv[3], encoding="utf-8"):
159
+ try: _d = json.loads(_l)
160
+ except Exception: continue
161
+ if _d.get("producer_family") == os.environ["FAM"] and _d.get("producer_role") == os.environ["ROLE"] and _d.get("id") is not None:
162
+ own_r1_titles[str(_d["id"])] = str(_d.get("title") or "")
163
+ except OSError:
164
+ pass
165
+ withdrawn_ids = set()
166
+ all_ids = set() # R6 #2: every round-1 id of every member — the namespace new r2 ids must avoid
167
+ try:
168
+ for _l in open(sys.argv[3], encoding="utf-8"):
169
+ try: _d = json.loads(_l)
170
+ except Exception: continue
171
+ if isinstance(_d, dict) and _d.get("id") is not None:
172
+ all_ids.add(str(_d["id"]))
173
+ except OSError:
174
+ pass
175
+ minted = set()
176
+ kept_ids = set()
177
+ resp_titles = []
178
+ fam, role, rc = os.environ["FAM"], os.environ["ROLE"], os.environ["RC"]
179
+ src, dst = sys.argv[1], sys.argv[2]
180
+ n = 0
181
+ saw_bytes = False
182
+ bad_json = False
183
+ dropped = []
184
+ with open(dst, "w", encoding="utf-8") as w:
185
+ for line in open(src, encoding="utf-8", errors="replace"):
186
+ line = line.strip().lstrip("\ufeff")
187
+ if line.startswith("DROPPED:"):
188
+ _body = line[len("DROPPED:"):]
189
+ for _id, _t in own_r1_titles.items():
190
+ if _id in _body or (_t and _t in _body):
191
+ withdrawn_ids.add(_id)
192
+ # 🟥 saw_bytes 를 여기서 «올리지 않는다». DROPPED 는 2차 계약이 요구한 출력이므로
193
+ # 「계약 밖 응답」의 증거가 될 수 없다 (cross-family #5, codex 2026-09-10).
194
+ # 🟥 "DROPPED: none" 은 삭제가 아니다. 세면 «숨은 수확 손실» 계측이 그 자리에서 거짓이 된다.
195
+ body = line[len("DROPPED:"):].strip().strip("().").lower()
196
+ if body not in ("", "none", "nothing", "n/a", "na", "없음"):
197
+ dropped.append(line)
198
+ continue
199
+ if line:
200
+ saw_bytes = True
201
+ if not line.startswith("{"):
202
+ # R7 #1: a line that PARSES as JSON but is not an object (`[…]`, `null`, `true`, `42`) is a malformed
203
+ # survivor — skipping it as chatter let an array of findings vanish and a `null` certify «all withdrawn».
204
+ if line[:1] in "[ntf0123456789-\"": # R8 #2: a double-encoded finding (JSON string) is a malformed survivor too
205
+ try:
206
+ _v = json.loads(line)
207
+ if not isinstance(_v, dict):
208
+ bad_json = True
209
+ except json.JSONDecodeError:
210
+ pass
211
+ continue
212
+ try:
213
+ d = json.loads(line)
214
+ except json.JSONDecodeError:
215
+ bad_json = True # R5 #2: a survivor that failed to parse is evidence, not silence
216
+ continue
217
+ if not isinstance(d, dict) or not d.get("title"):
218
+ bad_json = True # R6 #1: a schema-invalid finding object is a malformed survivor, not silence
219
+ continue
220
+ n += 1
221
+ resp_titles.append(str(d.get("title") or ""))
222
+ # R4 #5: a KEPT/CORRECTED finding keeps its round-1 id (identity survives the rewrite);
223
+ # anything else gets a fresh routing id. Only THIS member's own r1 ids are honoured.
224
+ # R5 #1: the alias (member_id) comes from the TRUSTED round-1 row, never from the response —
225
+ # a rejected foreign routing id must not survive as a seed-matchable alias.
226
+ d.pop("member_id", None)
227
+ if str(d.get("id")) in own_r1_ids:
228
+ d["id"] = str(d["id"])
229
+ if d["id"] in kept_ids:
230
+ bad_json = True # R7 #2: the same round-1 id KEPT twice — two rows, one identity → invalid response
231
+ continue
232
+ kept_ids.add(d["id"])
233
+ if own_r1[d["id"]] is not None:
234
+ d["member_id"] = own_r1[d["id"]]
235
+ else:
236
+ # R6 #2: allocate against the COMPLETE existing id set — role «logic-r2» owns r1 id
237
+ # `codex-logic-r2-1`, which is exactly what role «logic» would mint here.
238
+ k = n
239
+ cand = f"{fam}-{role}-r2-{k}"
240
+ while cand in all_ids or cand in minted:
241
+ k += 1
242
+ cand = f"{fam}-{role}-r2-{k}"
243
+ d["id"] = cand
244
+ minted.add(cand)
245
+ d["producer_family"] = fam
246
+ d["producer_role"] = role
247
+ d["round"] = 2
248
+ w.write(json.dumps(d, ensure_ascii=False) + "\n")
249
+ # 🟥 R8 #1: 응답이 «성공» 이려면 자기 1차 발견 전부가 설명돼야 한다 — KEEP(id) 되거나 DROPPED 가 id/제목을 부르거나.
250
+ # «A 만 KEEP» 이 B 를 조용히 지웠고, «DROPPED: B» 한 줄이 A 없이 «전원 철회» 를 냈다. 설명 안 된 1차가 남으면
251
+ # 불완전 응답 → 폴백(ZERO_NONJSON 과 같은 처분). 새 발견(ADD)은 설명 대상이 아니다.
252
+ def _accounted(i):
253
+ if i in kept_ids or i in withdrawn_ids:
254
+ return True
255
+ t = own_r1_titles.get(i, "")
256
+ # 느슨한 설명: 응답 발견의 제목이 1차 제목을 잇거나(«G1» → «G1 revised») 그 반대 — id 를 안 실은 CORRECT 형태
257
+ return bool(t) and any((rt.startswith(t) or t.startswith(rt)) and len(rt) >= 2 for rt in resp_titles)
258
+ unaccounted = sorted(i for i in own_r1_ids if not _accounted(i))
259
+ if rc == "0" and not bad_json and unaccounted and (n > 0 or dropped):
260
+ bad_json = True
261
+ print(f"FLEET round2 note member={fam}/{role} unaccounted_r1={','.join(unaccounted)} — incomplete response, falling back")
262
+ # 🟥 «전부 스스로 내렸다» 는 정당한 2차 결과이고 «차단» 이 아니다 — 별 값을 준다.
263
+ # R6 #1: a response with ANY malformed survivor is not a trustworthy replacement of the member's round-1 list —
264
+ # `n > 0` used to win over bad_json, so «valid A + broken B» silently deleted B. bad_json outranks OK.
265
+ status = ("FAILED" if rc != "0"
266
+ else "ZERO_NONJSON" if bad_json # R5 #2 / R6 #1: malformed survivor → fallback, never OK nor «all withdrawn»
267
+ else "OK" if n > 0
268
+ else "ZERO_SELFDROPPED" if dropped
269
+ else "ZERO_NONJSON" if saw_bytes else "ZERO_EMPTY")
270
+ print(f"MEMBER2 family={fam} role={role} rc={rc} findings={n} self_dropped={len(dropped)} status={status}")
271
+ for l in dropped:
272
+ print(" " + l[:200])
273
+ PYR2
274
+ }
275
+
56
276
  run_member() { # $1=family $2=role $3=command $4=target $5=outdir
57
277
  local fam="$1" role="$2" cmd="$3" tgt="$4" out="$5"
58
278
  # 🟥 치환값은 eval 되는 문자열 «안»으로 들어간다. 결박하지 않으면 «출력 디렉터리 이름»만으로도
@@ -77,9 +297,12 @@ import json, os, sys
77
297
  fam, role, rc = os.environ["FAM"], os.environ["ROLE"], os.environ["RC"]
78
298
  src, dst = sys.argv[1], sys.argv[2]
79
299
  n = 0
300
+ saw_bytes = False
80
301
  with open(dst, "w", encoding="utf-8") as w:
81
302
  for line in open(src, encoding="utf-8", errors="replace"):
82
303
  line = line.strip().lstrip("")
304
+ if line:
305
+ saw_bytes = True # 계열이 «무언가» 를 말했다는 사실은 findings 와 별개다
83
306
  if not line.startswith("{"):
84
307
  continue # tolerate banners and fences around the JSONL
85
308
  try:
@@ -89,11 +312,35 @@ with open(dst, "w", encoding="utf-8") as w:
89
312
  if not d.get("title"):
90
313
  continue
91
314
  n += 1
315
+ # 🟥 The member's own id is PRESERVED before renumbering. The fleet assigns a routing id
316
+ # (`family-role-n`) because member ids collide across members — but overwriting the original
317
+ # made the seeded control unusable end to end: a caller cannot declare `--seeded <id>` for an
318
+ # id that does not exist until after the run. Keeping the member id lets the control be
319
+ # declared in the caller's own vocabulary. (Found while writing the end-to-end lane; no
320
+ # review round named it — the lane did.)
321
+ # R6 #4: an incoming `member_id` is never trusted — the alias is derived ONLY from the response's own
322
+ # `id`. Without this, a response carrying member_id="seed-never-emitted" (and no id) kept that
323
+ # alias, and R5's «trusted round-1 alias» then carried it into round 2.
324
+ d.pop("member_id", None)
325
+ if d.get("id") is not None:
326
+ d["member_id"] = str(d["id"])
92
327
  d["id"] = f"{fam}-{role}-{n}"
93
328
  d["producer_family"] = fam
94
329
  d["producer_role"] = role
95
330
  w.write(json.dumps(d, ensure_ascii=False) + "\n")
96
- print(f"MEMBER family={fam} role={role} rc={rc} findings={n}")
331
+ # 🟥 «n==0» «발견 0» 이 아니다. 이유를 단정하지 않고 «채널이 보여주는 것» 만 타입으로 적는다.
332
+ # 실측 2026-09-10 (F_typed case_g02.py r1~r3): gemini 가 안전필터 «차단» 문구를 냈는데 CLI 는
333
+ # rc=0 이었고, 파서는 JSON 이 없어 findings=0 으로 기록했다. 그 계열이 producer 목록에서 사라져
334
+ # 드라이버가 «다른 계열 없음» 으로 읽고 codex 발견 2건을 통째로 미검증 처리했다(coverage 0/2).
335
+ if rc != "0":
336
+ status = "FAILED" # CLI 자체가 실패 (한도 소진 등)
337
+ elif n > 0:
338
+ status = "OK"
339
+ elif saw_bytes:
340
+ status = "ZERO_NONJSON" # 응답은 «있는데» 계약(JSONL) 이 하나도 없다 — 검증 없는 0
341
+ else:
342
+ status = "ZERO_EMPTY" # 출력이 통째로 비었다
343
+ print(f"MEMBER family={fam} role={role} rc={rc} findings={n} status={status}")
97
344
  PY
98
345
  }
99
346
 
@@ -140,14 +387,25 @@ EOS
140
387
  [ "$fails" -eq 0 ] && { echo "SELFTEST: PASS"; return 0; } || { echo "SELFTEST: FAIL"; return 1; }
141
388
  }
142
389
 
143
- [ $# -ge 1 ] || { echo "usage: $0 <target-file> --out <dir> [--fleet <table>] | --selftest" >&2; exit 2; }
390
+ [ $# -ge 1 ] || { echo "usage: $0 <target-file> --out <dir> [--fleet <table>] [--round2|--round2-blind] [--r1-only] [--reuse-r1 <dir>] | --selftest" >&2; exit 2; }
144
391
  [ "$1" = "--selftest" ] && { selftest; exit $?; }
145
392
  TARGET="$1"; shift
146
- OUT=""; FLEET=""
393
+ OUT=""; FLEET=""; ROUND2=0; ROUND2_BLIND=0; R1_ONLY=0; REUSE_R1=""
147
394
  while [ $# -gt 0 ]; do
148
395
  case "$1" in
149
396
  --out) OUT="${2:-}"; shift 2 ;;
150
397
  --fleet) FLEET="${2:-}"; shift 2 ;;
398
+ --round2) ROUND2=1; shift ;;
399
+ --round2-blind) ROUND2=1; ROUND2_BLIND=1; shift ;; # F_r2ctrl: 2차 패스는 돌리되 상대 계열 목록을 «감춘다» — «탈상관 효과» 와 «두 번 본 효과» 를 가른다
400
+ # ── 짝지음(paired) 설계 (F_pair, 2026-09-12) ────────────────────────────────
401
+ # 🟥 왜 있나: F_gen 과 F_r2ctrl 을 «독립 두 팔» 로 돌렸더니 round-1 기저가 146 vs 136 (7.4 %)
402
+ # 으로 이미 달랐다. 블라인드는 round-2 에만 걸렸는데 round-1 부터 다른 실행이라,
403
+ # r1→r2 증가율 비교가 «한 변수 비교» 가 아니었다 (RESULT §15 최대 잔여). 그래서:
404
+ # round-1 을 **한 번** 돌려 두고(--r1-only), 그 **같은** r1 에서 round-2 를 두 번 가른다
405
+ # (--reuse-r1 <r1dir> --round2 / --reuse-r1 <r1dir> --round2-blind).
406
+ # 부수 효과로 더 싸다 — r1 을 두 번 안 돌린다.
407
+ --r1-only) R1_ONLY=1; shift ;;
408
+ --reuse-r1) REUSE_R1="${2:-}"; shift 2 ;;
151
409
  *) echo "unknown flag: $1" >&2; exit 2 ;;
152
410
  esac
153
411
  done
@@ -159,18 +417,138 @@ done
159
417
  # 프롬프트 파일은 소스 전문을 담는다 — umask 022 면 0644 로 남아 다른 계정이 읽는다.
160
418
  umask 077
161
419
  [ -n "$OUT" ] || { echo "--out is required" >&2; exit 2; }
420
+
421
+ # ── 짝지음 플래그 상호 배타 검사 (fail-closed: 모순 조합은 조용히 한쪽을 고르지 않고 거부) ──
422
+ [ "$R1_ONLY" -eq 1 ] && [ -n "$REUSE_R1" ] && { echo "finding_fleet: --r1-only and --reuse-r1 are mutually exclusive" >&2; exit 2; }
423
+ [ "$R1_ONLY" -eq 1 ] && [ "$ROUND2" -eq 1 ] && { echo "finding_fleet: --r1-only cannot be combined with --round2/--round2-blind" >&2; exit 2; }
424
+ [ -n "$REUSE_R1" ] && [ "$ROUND2" -eq 0 ] && { echo "finding_fleet: --reuse-r1 requires --round2 or --round2-blind (otherwise it would do nothing)" >&2; exit 2; }
425
+
162
426
  mkdir -p "$OUT"
163
- if [ -n "$FLEET" ]; then cp "$FLEET" "$OUT/fleet.txt"; else default_fleet > "$OUT/fleet.txt"; fi
164
427
 
165
- : > "$OUT/members.txt"
166
- while IFS='|' read -r fam role cmd; do
167
- [ -n "${fam:-}" ] || continue
168
- case "$fam" in \#*) continue ;; esac
169
- run_member "$fam" "$role" "$cmd" "$TARGET" "$OUT" >> "$OUT/members.txt" 2>&1 &
170
- done < "$OUT/fleet.txt"
171
- wait
428
+ if [ -n "$REUSE_R1" ]; then
429
+ # 공유 round-1 을 이 OUT 으로 씨앗 놓는다. round-1 멤버는 **돌리지 않는다**.
430
+ [ -d "$REUSE_R1" ] || { echo "finding_fleet: --reuse-r1 dir not found: $REUSE_R1" >&2; exit 2; }
431
+ _src="$(cd "$REUSE_R1" && pwd -P)"; _dst="$(cd "$OUT" && pwd -P)"
432
+ [ "$_src" = "$_dst" ] && { echo "finding_fleet: --reuse-r1 dir is the same as --out — refusing (would overwrite its own source)" >&2; exit 2; }
433
+ [ -s "$REUSE_R1/findings.jsonl" ] || { echo "finding_fleet: --reuse-r1 source has no non-empty findings.jsonl — an empty round-1 must not be reused as if measured" >&2; exit 2; }
434
+ [ -f "$REUSE_R1/fleet.txt" ] || { echo "finding_fleet: --reuse-r1 source has no fleet.txt — the roster must be identical across paired branches" >&2; exit 2; }
435
+ # 로스터가 갈리면 두 분기가 «같은 r1» 을 쓰는 의미가 사라진다 — 명시된 --fleet 와 불일치면 거부
436
+ if [ -n "$FLEET" ] && ! /usr/bin/cmp -s "$FLEET" "$REUSE_R1/fleet.txt"; then
437
+ echo "finding_fleet: --fleet differs from the reused r1 roster ($REUSE_R1/fleet.txt) — refusing (paired branches must share one roster)" >&2; exit 2
438
+ fi
439
+ set -- "$REUSE_R1"/part_*.jsonl
440
+ [ -e "$1" ] || { echo "finding_fleet: --reuse-r1 source has no part_*.jsonl (per-member round-1 output) — the per-member fallback would silently see nothing" >&2; exit 2; }
441
+ cp "$REUSE_R1/fleet.txt" "$OUT/fleet.txt"
442
+ cp "$REUSE_R1"/part_*.jsonl "$OUT/" 2>/dev/null || :
443
+ cp "$REUSE_R1/findings.jsonl" "$OUT/findings.jsonl"
444
+ # 🟥 이 OUT 에 남아 있을 수 있는 **옛 round-2 산출**은 지운다 — 재사용한 r1 위에 남의 2차가
445
+ # 섞이면 그 자리는 측정이 아니라 잔재다 (stale-artifact 부류).
446
+ /bin/rm -f "$OUT"/part2_*.jsonl "$OUT"/raw2_*.txt "$OUT"/err2_*.txt "$OUT"/peer_*.txt "$OUT/findings_r2merged.jsonl"
447
+ : > "$OUT/members.txt"
448
+ /bin/rm -f "$OUT/INTENTIONAL_EMPTY"
449
+ echo "FLEET r1 reused from=$REUSE_R1 (round-1 members NOT run here)" >> "$OUT/members.txt"
450
+ else
451
+ if [ -n "$FLEET" ]; then cp "$FLEET" "$OUT/fleet.txt"; else default_fleet > "$OUT/fleet.txt"; fi
452
+
453
+ : > "$OUT/members.txt"
454
+ /bin/rm -f "$OUT/INTENTIONAL_EMPTY" # R3 #8: a stale marker from an earlier run must not certify this one
455
+ while IFS='|' read -r fam role cmd; do
456
+ [ -n "${fam:-}" ] || continue
457
+ case "$fam" in \#*) continue ;; esac
458
+ run_member "$fam" "$role" "$cmd" "$TARGET" "$OUT" >> "$OUT/members.txt" 2>&1 &
459
+ done < "$OUT/fleet.txt"
460
+ wait
461
+
462
+ cat "$OUT"/part_*.jsonl > "$OUT/findings.jsonl" 2>/dev/null || : > "$OUT/findings.jsonl"
463
+ fi
464
+ R1_TOTAL=$(/usr/bin/wc -l < "$OUT/findings.jsonl" | /usr/bin/tr -d ' ')
465
+
466
+ # --r1-only: 공유 round-1 을 남기고 여기서 끝낸다. round-2 는 --reuse-r1 로 두 번 갈라 돈다.
467
+ if [ "$R1_ONLY" -eq 1 ]; then
468
+ echo "FLEET r1-only r1=$R1_TOTAL out=$OUT — reuse with: --reuse-r1 $OUT --round2 | --round2-blind"
469
+ exit 0
470
+ fi
172
471
 
173
- cat "$OUT"/part_*.jsonl > "$OUT/findings.jsonl" 2>/dev/null || : > "$OUT/findings.jsonl"
472
+ # ── 생성시점 탈상관 (2 패스) ─────────────────────────────────────────────────
473
+ # 🟥 여기가 이 플래그의 논지다. 선별 시점에 계열을 교차시키면(«이 발견이 맞나» 를 남에게 묻는 것)
474
+ # 오류율은 내려가지만 **수확을 지불한다** — 실측: 8건 → 6건. 옥토는 같은 오류율 수준을
475
+ # 수확을 지불하지 않고 낸다. 차이는 계열을 «언제» 만나게 하느냐다: 걸러낼 때가 아니라 «쓸 때».
476
+ # 그래서 2차 패스는 수락/거부를 묻지 않는다 — 상대의 목록을 보고 **자기 목록을 다시 쓰게** 한다.
477
+ # 자기 발견을 스스로 내리면 `DROPPED:` 로 사유를 적게 하고, 그 수를 센다(숨은 수확 손실 계측).
478
+ if [ "$ROUND2" -eq 1 ]; then
479
+ if [ "$R1_TOTAL" -eq 0 ]; then
480
+ echo "FLEET round2 skipped — round 1 produced no findings (nothing to decorrelate against)"
481
+ else
482
+ for _p in "$OUT/findings_r1.jsonl" "$OUT/findings_r2merged.jsonl" "$OUT/INTENTIONAL_EMPTY"; do
483
+ [ -L "$_p" ] && { echo "finding_fleet: refusing to write through a symlink: $_p" >&2; exit 2; }
484
+ done
485
+ cp "$OUT/findings.jsonl" "$OUT/findings_r1.jsonl"
486
+ while IFS='|' read -r fam role cmd; do
487
+ [ -n "${fam:-}" ] || continue
488
+ case "$fam" in \#*) continue ;; esac
489
+ run_member_r2 "$fam" "$role" "$cmd" "$TARGET" "$OUT" >> "$OUT/members.txt" 2>&1 &
490
+ done < "$OUT/fleet.txt"
491
+ wait
492
+ # 🟥 교체는 **멤버별** 이다. 초판은 all-or-nothing 이었고 결함 둘을 동시에 만들었다
493
+ # (cross-family, codex 2026-09-10):
494
+ # ① `[ -s "$(ls part2_*.jsonl | head -1)" ]` 은 **glob 첫 파일만** 봤다. codex 것이 비고
495
+ # gemini 것이 차 있으면 «2차가 아무것도 못 냈다» 고 «거짓 보고» 하며 1차를 유지했다.
496
+ # ② 반대로 첫 파일이 차 있으면 **실패한 멤버의 1차 발견까지 통째로 사라졌다** — 그 멤버가
497
+ # 2차에서 일부만 내고 죽어도 교체가 일어났고, `failed_members` 는 `^MEMBER ` 만 세어
498
+ # MEMBER2 실패를 못 봐서 `failed_members=0 rc=0` 으로 나갔다. 조용한 수확 삭제다.
499
+ # ⇒ 2차가 «성공한» 멤버만 2차 목록으로 갈아끼우고, 실패한 멤버는 자기 1차를 그대로 쓴다.
500
+ R2_FALLBACK=0; R2_OK=0
501
+ : > "$OUT/findings_r2merged.jsonl"
502
+ while IFS='|' read -r _fam _role _cmd; do
503
+ [ -n "${_fam:-}" ] || continue
504
+ case "$_fam" in \#*) continue ;; esac
505
+ # 🟥 R2 #3: family/role 을 정규식에 보간하면 `logic.review` 가 `logicXreview` 의 OK 를 집는다.
506
+ # awk 로 «필드 리터럴» 비교 — 값이 데이터로만 다뤄진다.
507
+ # 🟥 R3 #4: awk had already split `role=logic review` into fields, so $3=="role="r never matched a
508
+ # role with a space → «ABSENT» → its valid round-2 discarded. Compare the fixed PREFIX
509
+ # `MEMBER2 family=F role=R rc=` as one literal span (index()==1); still no regex interpolation.
510
+ # 🟥 R4 #4: a PREFIX still aliased `role=logic` onto `role=logic rc=99 …` (executed). The fixed tail
511
+ # ` rc=N findings=N self_dropped=N status=S$` is stripped first and the WHOLE head is compared;
512
+ # ENVIRON instead of -v so a backslash in a role is not escape-interpreted.
513
+ _st=$(_FAM="$_fam" _ROLE="$_role" /usr/bin/awk 'BEGIN{p="MEMBER2 family=" ENVIRON["_FAM"] " role=" ENVIRON["_ROLE"]}
514
+ match($0, / rc=[^ ]+ findings=[^ ]+ self_dropped=[^ ]+ status=[^ ]+$/) && substr($0,1,RSTART-1)==p {v=$NF; sub(/^status=/,"",v)} END{print v}' "$OUT/members.txt")
515
+ _p2="$OUT/part2_${_fam}_${_role}.jsonl"; _p1="$OUT/part_${_fam}_${_role}.jsonl"
516
+ # 🟥 R2 #4: 차단(ZERO_NONJSON)·빈 응답(ZERO_EMPTY)·미실행(부재) 은 «2차가 없다» 이지 «2차가 비었다» 가 아니다
517
+ # → 자기 1차로 폴백. ZERO_SELFDROPPED 만이 «의도된 빈 2차» 다(#5).
518
+ case "${_st:-FAILED}" in
519
+ FAILED|ZERO_NONJSON|ZERO_EMPTY) _fallback=1 ;;
520
+ *) _fallback=0 ;;
521
+ esac
522
+ if [ "$_fallback" -eq 1 ]; then
523
+ [ -f "$_p1" ] && cat "$_p1" >> "$OUT/findings_r2merged.jsonl"
524
+ R2_FALLBACK=$((R2_FALLBACK + 1))
525
+ echo "FLEET round2 member=$_fam/$_role ${_st:-ABSENT} — keeping ITS round-1 findings (per-member fallback)"
526
+ else
527
+ [ -f "$_p2" ] && cat "$_p2" >> "$OUT/findings_r2merged.jsonl"
528
+ R2_OK=$((R2_OK + 1))
529
+ fi
530
+ done < "$OUT/fleet.txt"
531
+ if [ -s "$OUT/findings_r2merged.jsonl" ]; then
532
+ /bin/mv "$OUT/findings_r2merged.jsonl" "$OUT/findings.jsonl"
533
+ elif [ "$R2_OK" -gt 0 ]; then
534
+ # 🟥 R2 #5: 모든 멤버가 «성공적으로» 빈 2차(전량 자기 철회)를 냈다 — 그것은 결과이지 실패가 아니다.
535
+ # 1차로 되돌리면 정당한 철회가 무효화된다. 빈 결과를 그대로 낸다.
536
+ # 🟥 R3 #5: `&& R2_FALLBACK==0` 은 틀렸다 — 폴백 멤버는 이미 «자기 1차» 를 merged 에 얹었으므로
537
+ # merged 가 비었다는 것은 그 1차도 비었다는 뜻이다(codex 철회 + gemini 1차 0·2차 ZERO_EMPTY).
538
+ # 그 경우 1차 전체로 되돌리면 codex 의 정당한 철회가 되살아난다. 멤버별 병합이 «구성됐으면»
539
+ # 빈 것도 권위다. 1차로 돌아가는 것은 R2_OK==0 (아무 멤버도 2차를 못 냈을 때) 뿐이다.
540
+ : > "$OUT/findings.jsonl"
541
+ : > "$OUT/INTENTIONAL_EMPTY" # R3 #8: typed marker the driver reads — «reviewed, withdrawn» ≠ «unreviewed»
542
+ echo "FLEET round2 all members withdrew (ZERO_SELFDROPPED) — result is intentionally empty"
543
+ else
544
+ # 🟥 합쳐서도 비면 1차를 «대체» 하지 않는다 — 그러면 탈상관이 아니라 삭제다.
545
+ echo "FLEET round2 produced nothing — keeping round-1 findings (a failed 2nd pass must not delete the 1st)"
546
+ fi
547
+ echo "FLEET round2 members_ok=$R2_OK fallback=$R2_FALLBACK blind=$ROUND2_BLIND"
548
+ R2_TOTAL=$(/usr/bin/wc -l < "$OUT/findings.jsonl" | /usr/bin/tr -d ' ')
549
+ echo "FLEET round2 r1=$R1_TOTAL r2=$R2_TOTAL delta=$((R2_TOTAL - R1_TOTAL))"
550
+ fi
551
+ fi
174
552
  TOTAL=$(/usr/bin/wc -l < "$OUT/findings.jsonl" | /usr/bin/tr -d ' ')
175
553
  MEMBERS=$(/usr/bin/wc -l < "$OUT/fleet.txt" | /usr/bin/tr -d ' ')
176
554
  OK=$(/usr/bin/grep -c 'rc=0' "$OUT/members.txt" 2>/dev/null); OK=${OK:-0} # 🟥 never `|| echo 0` here: grep prints 0 AND the fallback echoes 0, giving "0\n0"