@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.
- package/.claude/registry/agent_cards.json +1 -1
- package/.claude/rules/fh_4axis_gate.md +25 -0
- package/.claude-plugin/marketplace.json +3 -3
- package/AGENTS.md +2 -2
- package/CATALOG.md +4 -4
- package/CHEATSHEET.md +1 -1
- package/CLAUDE.md +4 -4
- package/docs/OUTPUT_EVIDENCE.md +1 -1
- package/docs/STANDARDS_ALIGNMENT.md +1 -1
- package/docs/codex-compat.md +1 -1
- package/knowledge/shared/harness-core/agents_md_runtime_details.md +2 -2
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +30 -3
- package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +1 -1
- package/knowledge/shared/harness-core/skill_quality_rubric.md +1 -1
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +31 -0
- package/knowledge/shared/rules/modes_and_value.md +2 -2
- package/package.json +2 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-commons/README.md +38 -0
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +95 -0
- package/plugins/fh-meta/skills/agent-composer/SKILL.md +2 -2
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +40 -0
- package/plugins/fh-meta/skills/frontier-digest/SKILL.md +1 -1
- package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +43 -5
- package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL.md +75 -3
- package/plugins/fh-meta/skills/{hub-cc-pr-reviewer → harness-pr-reviewer}/SKILL_detail.md +2 -2
- package/plugins/fh-meta/skills/harvest-loop/SKILL_detail.md +2 -2
- package/plugins/fh-meta/skills/install-doctor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/install-wizard/SKILL.md +1 -1
- package/plugins/fh-meta/skills/meta-prompt-builder/SKILL.md +1 -1
- package/plugins/fh-meta/skills/pipeline-conductor/SKILL.md +1 -1
- package/plugins/fh-meta/skills/plugin-recommender/SKILL.md +1 -1
- package/plugins/fh-meta/skills/sim-conductor/SKILL.md +1 -1
- package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
- package/scripts/finding_fleet.sh +391 -13
- package/scripts/finding_pipeline.sh +370 -11
- package/scripts/finding_verifier.sh +31 -2
- package/scripts/finding_verify.py +198 -13
- package/scripts/frontier_digest_autopilot.sh +3 -3
- package/scripts/gate_shape_scan.sh +16 -2
- package/scripts/test_finding_pipeline_lanes.sh +1556 -4
- package/scripts/test_gate_shape_scan_lanes.sh +11 -0
- package/scripts/test_marker_crossfamily_lanes.sh +75 -3
- package/scripts/test_marker_standpoint_lanes.sh +31 -6
- package/templates/.git-hooks/pre-commit +152 -6
- package/templates/local_fh_context.md +1 -1
- package/templates/regression_guard.sh +1 -1
package/scripts/finding_fleet.sh
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
166
|
-
|
|
167
|
-
[ -
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
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
|
-
|
|
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"
|