@chrono-meta/fh-gate 3.1.4 → 3.2.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 (41) hide show
  1. package/.claude/rules/fh_4axis_gate.md +1 -1
  2. package/.claude-plugin/marketplace.json +3 -3
  3. package/AGENTS.md +6 -0
  4. package/CLAUDE.md +50 -6
  5. package/README.ja.md +3 -3
  6. package/README.ko.md +3 -3
  7. package/README.md +3 -3
  8. package/README.zh.md +3 -3
  9. package/docs/OUTPUT_EVIDENCE.md +1 -1
  10. package/knowledge/shared/harness-core/claude_md_gate_details.md +26 -0
  11. package/knowledge/shared/harness-core/fh_three_layer_canon.md +1 -1
  12. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +46 -0
  13. package/knowledge/shared/harness-core/governance_engineering_definition.md +89 -0
  14. package/knowledge/shared/learnings/subagent_invocations_log.yaml +68 -0
  15. package/knowledge/shared/rules/auto_project_mapping.md +1 -1
  16. package/package.json +11 -1
  17. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  18. package/plugins/fh-commons/skills/preprep/SKILL.md +23 -0
  19. package/plugins/fh-commons/skills/preprep/fixtures/font_revert_probe.py +92 -0
  20. package/plugins/fh-commons/skills/preprep/lane_font.py +462 -0
  21. package/plugins/fh-commons/skills/preprep/preprep.py +16 -1
  22. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +17 -0
  23. package/plugins/fh-commons/skills/preprep/test_lane_font.py +452 -0
  24. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  25. package/plugins/fh-meta/CHANGELOG.md +69 -0
  26. package/plugins/fh-qp/.claude-plugin/plugin.json +1 -1
  27. package/scripts/doc_claim_triad_scan.py +303 -0
  28. package/scripts/finding_fleet.sh +180 -0
  29. package/scripts/finding_pipeline.sh +213 -0
  30. package/scripts/finding_verifier.sh +144 -0
  31. package/scripts/finding_verify.py +283 -0
  32. package/scripts/gate_pathspec_check.sh +1 -0
  33. package/scripts/gate_shape_scan.sh +106 -0
  34. package/scripts/selfcheck.sh +50 -0
  35. package/scripts/test_doc_claim_triad_lanes.sh +124 -0
  36. package/scripts/test_finding_pipeline_lanes.sh +459 -0
  37. package/scripts/test_gate_shape_scan_lanes.sh +36 -0
  38. package/scripts/test_heavy_classifier_lanes.sh +13 -3
  39. package/scripts/test_preprep_font_lanes.sh +87 -0
  40. package/templates/.git-hooks/pre-commit +6 -5
  41. package/templates/PRE-PUBLISH-CHECKLIST.md +29 -0
@@ -0,0 +1,213 @@
1
+ #!/usr/bin/env bash
2
+ # finding_pipeline.sh — the driver that runs fleet → cross-family reject → drop audit end to end.
3
+ # Until this file the two halves existed but nothing called them together outside the lane suite.
4
+ #
5
+ # bash scripts/finding_pipeline.sh <target-file> --out <dir> [--fleet <table>]
6
+ #
7
+ # WHY THE TWO-SPLIT SHAPE (the load-bearing design decision, not an implementation detail):
8
+ # finding_verify.py refuses to let a family judge its own findings — it stamps them `unverified`
9
+ # rather than judging. With a two-family fleet, ONE verify pass therefore leaves half the findings
10
+ # unjudged, and an `unverified` finding still counts as a survivor. Running it that way and reporting
11
+ # the survivor count would silently mean "half of these were never checked". So the run is split by
12
+ # producer:
13
+ # gemini-produced → verifier codex → drop auditor gemini
14
+ # codex-produced → verifier gemini → drop auditor codex
15
+ #
16
+ # 🟥 NAMED RESIDUAL — with only two families the drop auditor is ALWAYS the producer (an appeal),
17
+ # never a disinterested third party. That is weaker than the protocol allows for, and it is a property
18
+ # of the panel size. Do not read `AUDITED` from a two-family run as `independently audited`; the
19
+ # per-finding `audit_role` field says which it was.
20
+ # 🟥 AND A THIRD FAMILY DOES NOT COME FOR FREE. An earlier draft of this comment claimed the driver
21
+ # "picks up a third family automatically". It cannot: finding_verifier.sh only speaks codex|gemini, so
22
+ # an unknown family exits 2 and the drops come back unaudited (rc=4). Routing is therefore restricted
23
+ # to families the wrapper actually implements — SUPPORTED below — and adding one means adding a
24
+ # backend there first. (cross-family review, 2026-09-09; the claim was mine and was wrong.)
25
+ #
26
+ # EXIT 0 every split verified and every drop audited, with survivors · 1 verified but nothing survived
27
+ # 2 usage / schema · 3 UNVERIFIED — some split was not cross-verified (degraded, never a silent
28
+ # pass) · 4 drops happened that were never audited
29
+ set -uo pipefail
30
+
31
+ SUPPORTED_FAMILIES="codex gemini" # must match finding_verifier.sh's own case statement
32
+
33
+ TARGET=""; OUT=""; FLEET=""
34
+ usage() { echo "usage: finding_pipeline.sh <target-file> --out <dir> [--fleet <table>]" >&2; exit 2; }
35
+ need() { [ $# -ge 2 ] || { echo "finding_pipeline: $1 needs a value" >&2; exit 2; }; }
36
+ [ $# -ge 1 ] || usage
37
+ TARGET="$1"; shift
38
+ while [ $# -gt 0 ]; do
39
+ case "$1" in
40
+ --out) need "$@"; OUT="$2"; shift 2 ;; # `shift 2` on a trailing flag consumes nothing and
41
+ --fleet) need "$@"; FLEET="$2"; shift 2 ;; # spins forever; codex reproduced the hang.
42
+ *) usage ;;
43
+ esac
44
+ done
45
+ [ -n "$TARGET" ] && [ -f "$TARGET" ] && [ -r "$TARGET" ] \
46
+ || { echo "finding_pipeline: target must be a readable file" >&2; exit 2; }
47
+ # 🟥 타깃이 심링크면 체크아웃 «밖»을 가리킬 수 있고, 그 내용은 외부 모델로 전송된다.
48
+ # 리뷰하려던 것은 레포 코드인데 나가는 것은 남의 비밀이 된다 — residency 위반이다.
49
+ # 강행이 필요하면 실제 파일 경로를 직접 주면 된다(그 판단은 사람이 한다).
50
+ if [ -L "$TARGET" ]; then
51
+ echo "finding_pipeline: target is a symlink — refusing (it may point outside the checkout, and the content is SENT to an external model)" >&2
52
+ exit 2
53
+ fi
54
+ [ -n "$OUT" ] || usage
55
+ # 🟥 출력물은 프롬프트와 소스를 담는다 — 권한을 좁혀서 만든다(umask 022 면 0644 로 남는다).
56
+ umask 077
57
+ mkdir -p "$OUT" || { echo "finding_pipeline: cannot create --out" >&2; exit 2; }
58
+ # 🟥 미리 깔린 심링크를 따라가면 «출력»이 남의 파일 truncate 가 된다. 리다이렉션도 open() 도
59
+ # 심링크를 따라간다 — 그래서 쓰기 «전»에 거부한다(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
61
+ if [ -L "$_p" ]; then
62
+ echo "finding_pipeline: refusing to write through a symlink: $_p" >&2; exit 2
63
+ fi
64
+ done
65
+
66
+ HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
67
+ FLEET_SH="$HERE/finding_fleet.sh"; VERIFY_PY="$HERE/finding_verify.py"; VERIFIER_SH="$HERE/finding_verifier.sh"
68
+ for f in "$FLEET_SH" "$VERIFY_PY" "$VERIFIER_SH"; do
69
+ [ -f "$f" ] || { echo "finding_pipeline: missing $f — skipped, NOT passed" >&2; exit 3; }
70
+ done
71
+
72
+ # 🟥 NO SHELL IN THIS PATH. An earlier version built a command STRING and quoted the interpolated
73
+ # path with bash's `printf %q`. That was not enough, and the reason matters: finding_verify.py ran
74
+ # the string with `shell=True`, i.e. `/bin/sh`, while `%q` emits **bash-only** `$'...'` quoting for a
75
+ # path containing a newline. On the many Linux systems where `/bin/sh` is dash, that quoting comes
76
+ # apart and a crafted filename executes a second command. 🟥 macOS CANNOT SEE THIS — its /bin/sh is
77
+ # bash-derived, so the local run is green while the shipped package is not (cross-family review
78
+ # 2026-09-09, reproduced on dash). The fix is not better escaping; it is handing argv, never a string.
79
+ argv_json() { # each argument becomes one JSON string — no shell ever parses these
80
+ ARGV_PY="$*" /usr/bin/python3 -c 'import json,os,sys; print(json.dumps(sys.argv[1:]))' "$@"
81
+ }
82
+
83
+ # ── 1. fleet ──────────────────────────────────────────────────────────────────────────────────────
84
+ # A reused --out is not a fresh run: finding_fleet.sh concatenates EVERY part_*.jsonl it finds, so a
85
+ # member that is absent this time still contributes yesterday's findings — which can silently supply
86
+ # the second family and make a single-family run look cross-verified.
87
+ /bin/rm -rf "$OUT/fleet"
88
+ FA=(); [ -n "$FLEET" ] && FA=(--fleet "$FLEET")
89
+ bash "$FLEET_SH" "$TARGET" --out "$OUT/fleet" ${FA[@]+"${FA[@]}"} 2>&1 | tee "$OUT/fleet_run.log"
90
+ FLEET_RC=${PIPESTATUS[0]}
91
+ FINDINGS="$OUT/fleet/findings.jsonl"
92
+ if [ "$FLEET_RC" -ne 0 ] || [ ! -s "$FINDINGS" ]; then
93
+ echo "PIPELINE target=$(basename "$TARGET") fleet_rc=$FLEET_RC findings=0 status=UNREVIEWED"
94
+ echo " 🟥 an empty finding list is UNREVIEWED, not clean (finding_fleet.sh says so and this agrees)" >&2
95
+ exit 3
96
+ fi
97
+ # A fleet is "ok" when ONE member succeeded. A member that crashed after emitting a few findings still
98
+ # leaves its family present, so the split routing below sees two families and the run looks complete.
99
+ FAILED_MEMBERS=$(grep -c '^MEMBER .* rc=[^0]' "$OUT/fleet_run.log" 2>/dev/null); FAILED_MEMBERS=${FAILED_MEMBERS:-0}
100
+
101
+ # ── 2. split by producer, verify each half with the other family ──────────────────────────────────
102
+ # Families are read into a newline-delimited list and validated. An unquoted space-joined expansion
103
+ # let a family literally named "codex gemini" split into two names that match no record, skipping
104
+ # every finding while the run still exited 0.
105
+ /usr/bin/python3 -c '
106
+ import json,re,sys
107
+ seen=[]
108
+ for l in open(sys.argv[1],encoding="utf-8"):
109
+ l=l.strip()
110
+ if not l: continue
111
+ f=json.loads(l).get("producer_family")
112
+ if f is None: continue
113
+ if not re.fullmatch(r"[A-Za-z0-9_.-]+", str(f)):
114
+ sys.stderr.write("finding_pipeline: illegal producer_family %r — refusing to route\n" % (f,))
115
+ sys.exit(2)
116
+ if f not in seen: seen.append(f)
117
+ print("\n".join(seen))' "$FINDINGS" > "$OUT/families.txt" 2>"$OUT/families.err"
118
+ FAMRC=$?
119
+ if [ "$FAMRC" -ne 0 ] || [ ! -s "$OUT/families.txt" ]; then
120
+ cat "$OUT/families.err" >&2
121
+ echo "finding_pipeline: findings carry no usable producer_family — cannot route" >&2; exit 3
122
+ fi
123
+
124
+ supported() { case " $SUPPORTED_FAMILIES " in *" $1 "*) return 0;; *) return 1;; esac; }
125
+ pick_verifier() { # $1=producer — a DIFFERENT family the wrapper can actually run
126
+ local p="$1" f
127
+ while IFS= read -r f; do [ -n "$f" ] && [ "$f" != "$p" ] && supported "$f" && { echo "$f"; return; }; done < "$OUT/families.txt"
128
+ echo ""
129
+ }
130
+ pick_auditor() { # $1=producer $2=verifier — prefer a third party; fall back to producer (appeal)
131
+ local p="$1" v="$2" f
132
+ while IFS= read -r f; do
133
+ [ -n "$f" ] && [ "$f" != "$p" ] && [ "$f" != "$v" ] && supported "$f" && { echo "$f"; return; }
134
+ done < "$OUT/families.txt"
135
+ supported "$p" && { echo "$p"; return; }
136
+ echo ""
137
+ }
138
+
139
+ # Exit codes are TYPES, not severities — `max(0,1)` turned "one split found nothing" into the whole
140
+ # run's verdict while a confirmed survivor sat in the other split. Rank them explicitly instead, and
141
+ # derive 1-vs-0 from the survivor count at the end, never from a split.
142
+ WORST_RANK=0; WORST_CODE=0
143
+ rank_of() { case "$1" in 2) echo 4;; 3) echo 3;; 4) echo 2;; *) echo 0;; esac; }
144
+ note_rc() { local r; r=$(rank_of "$1"); if [ "$r" -gt "$WORST_RANK" ]; then WORST_RANK="$r"; WORST_CODE="$1"; fi; }
145
+
146
+ : > "$OUT/confirmed.jsonl"; : > "$OUT/dropped.jsonl"; : > "$OUT/splits.txt"
147
+
148
+ while IFS= read -r PROD; do
149
+ [ -n "$PROD" ] || continue
150
+ VER="$(pick_verifier "$PROD")"
151
+ if [ -z "$VER" ]; then
152
+ echo " ⚠️ split producer=$PROD — no other SUPPORTED family present; its findings cannot be cross-verified" >&2
153
+ note_rc 3; printf 'split producer=%s verifier=NONE rc=3 (no cross-family)\n' "$PROD" >> "$OUT/splits.txt"; continue
154
+ fi
155
+ AUD="$(pick_auditor "$PROD" "$VER")"
156
+ SD="$OUT/split_$PROD"
157
+ mkdir -p "$SD" || { echo "finding_pipeline: cannot create $SD" >&2; note_rc 2; continue; }
158
+ # An extraction that FAILS and an extraction that finds nothing are not the same event; the first
159
+ # draft's `[ -s ] || continue` read both as "empty partition" and left WORST at 0.
160
+ if ! /usr/bin/python3 -c '
161
+ import json,sys
162
+ prod=sys.argv[2]
163
+ for l in open(sys.argv[1],encoding="utf-8"):
164
+ l=l.strip()
165
+ if l and json.loads(l).get("producer_family")==prod: print(l)' "$FINDINGS" "$PROD" > "$SD/in.jsonl"; then
166
+ echo "finding_pipeline: split extraction failed for producer=$PROD" >&2; note_rc 3; continue
167
+ fi
168
+ if [ ! -s "$SD/in.jsonl" ]; then
169
+ printf 'split producer=%s verifier=%s rc=0 (no findings)\n' "$PROD" "$VER" >> "$OUT/splits.txt"; continue
170
+ fi
171
+
172
+ AUDARGS=()
173
+ if [ -n "$AUD" ]; then
174
+ AUDARGS=(--audit-verifier-argv "$(argv_json bash "$VERIFIER_SH" --family "$AUD" --target "$TARGET" --audit)" --audit-family "$AUD")
175
+ else
176
+ echo " ⚠️ split producer=$PROD — no supported auditor; any drop will come back UNAUDITED" >&2
177
+ fi
178
+ /usr/bin/python3 "$VERIFY_PY" "$SD/in.jsonl" --out "$SD" \
179
+ --verifier-argv "$(argv_json bash "$VERIFIER_SH" --family "$VER" --target "$TARGET")" --family "$VER" \
180
+ ${AUDARGS[@]+"${AUDARGS[@]}"} > "$SD/summary.txt" 2>"$SD/err.txt"
181
+ RC=$?
182
+ note_rc "$RC"
183
+ cat "$SD/summary.txt"
184
+ printf 'split producer=%s verifier=%s auditor=%s rc=%s\n' "$PROD" "$VER" "${AUD:-NONE}" "$RC" >> "$OUT/splits.txt"
185
+ [ -f "$SD/confirmed.jsonl" ] && cat "$SD/confirmed.jsonl" >> "$OUT/confirmed.jsonl"
186
+ [ -f "$SD/dropped.jsonl" ] && cat "$SD/dropped.jsonl" >> "$OUT/dropped.jsonl"
187
+ done < "$OUT/families.txt"
188
+
189
+ # 🟥 NOT `$(grep -c ... || echo 0)`: grep -c PRINTS "0" and ALSO exits 1 on no match, so the fallback
190
+ # appends a second line and the count becomes "0\n0", which truncates the summary printf.
191
+ count_lines() { local n; n=$(grep -c "$1" "$2" 2>/dev/null); printf '%s' "${n:-0}" | tr -d ' \n'; }
192
+ TOTAL_CONF=$(count_lines '^{' "$OUT/confirmed.jsonl")
193
+ TOTAL_DROP=$(count_lines '^{' "$OUT/dropped.jsonl")
194
+ TOTAL_UNVER=$(count_lines '"verdict": *"unverified"' "$OUT/confirmed.jsonl")
195
+ # An `unaudited` drop is NOT an audited one — counting it as such is how "we checked the deletions"
196
+ # becomes true by wording alone.
197
+ TOTAL_AUD=$(count_lines '"drop_verdict": *"\(correct-drop\|wrong-drop\|uncertain\)"' "$OUT/dropped.jsonl")
198
+ TOTAL_WRONG=$(count_lines '"reinstated": *true' "$OUT/confirmed.jsonl")
199
+
200
+ if [ "$FAILED_MEMBERS" -gt 0 ]; then
201
+ echo " ⚠️ $FAILED_MEMBERS fleet member(s) exited non-zero — a member that crashed after emitting some findings leaves its family looking complete" >&2
202
+ note_rc 3
203
+ fi
204
+
205
+ RC_FINAL="$WORST_CODE"
206
+ [ "$WORST_RANK" -eq 0 ] && [ "$TOTAL_CONF" -eq 0 ] && RC_FINAL=1
207
+
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"
211
+ # `unverified` is reported on its own line rather than folded into `confirmed`, because folding it is
212
+ # exactly the "not found rendered as zero" family this repo keeps re-finding.
213
+ exit "$RC_FINAL"
@@ -0,0 +1,144 @@
1
+ #!/usr/bin/env bash
2
+ # finding_verifier.sh — the wrapper finding_verify.py's --verifier contract asks for, and which did
3
+ # not exist. Reads findings JSONL on stdin, writes verdict JSONL on stdout:
4
+ # {"id","verdict":"confirmed|false-positive|needs-debate","why"}
5
+ # With --audit it answers the drop-audit protocol instead:
6
+ # {"id","verdict":"correct-drop|wrong-drop|uncertain","why"}
7
+ #
8
+ # WHY THIS EXISTS: finding_verify.py runs whatever shell command you hand it. Until this file, the
9
+ # only commands that satisfied its protocol were the hardcoded stubs inside the lane suite, so the
10
+ # pipeline could only ever report status=UNVERIFIED (rc=3) against real findings. A pipeline whose
11
+ # only live path is its own fixture is not wired (CLAUDE.md §built-but-not-wired).
12
+ #
13
+ # 🟥 THE VERIFIER MUST SEE THE CODE, NOT ONLY THE CLAIM. A verdict on "is this finding real" that is
14
+ # reached from the claim text alone measures plausibility, not truth — the model agrees with a
15
+ # well-written wrong claim. So --target is REQUIRED and its content is put in front of the model.
16
+ #
17
+ # EXIT 0 answered · 2 usage · 3 the CLI failed or produced no parsable verdict (caller degrades;
18
+ # finding_verify.py turns an empty answer into `unverified`, never into a silent pass)
19
+ set -uo pipefail
20
+
21
+ CODEX="${FH_CODEX_BIN:-$(command -v codex 2>/dev/null || echo "$HOME/.npm-global/bin/codex")}"
22
+ AGY="${FH_AGY_BIN:-$(command -v agy 2>/dev/null || echo "$HOME/.local/bin/agy")}"
23
+
24
+ FAMILY=""; TARGET=""; MODE="verify"; KEEP=""
25
+ usage() { echo "usage: finding_verifier.sh --family codex|gemini --target <file> [--audit] [--keep <dir>]" >&2; exit 2; }
26
+ # `shift 2` with only one word left FAILS and consumes nothing, so the loop spins forever. Under
27
+ # `set -uo pipefail` (no errexit) nothing stops it. codex reproduced a hang on a trailing --family.
28
+ need() { [ $# -ge 2 ] || { echo "finding_verifier: $1 needs a value" >&2; exit 2; }; }
29
+ while [ $# -gt 0 ]; do
30
+ case "$1" in
31
+ --family) need "$@"; FAMILY="$2"; shift 2 ;;
32
+ --target) need "$@"; TARGET="$2"; shift 2 ;;
33
+ --audit) MODE="audit"; shift ;;
34
+ --keep) need "$@"; KEEP="$2"; shift 2 ;;
35
+ *) usage ;;
36
+ esac
37
+ done
38
+ [ -n "$FAMILY" ] || usage
39
+ # `-f` is a TYPE test, not a readability test: an unreadable regular file passes it, the later `cat`
40
+ # fails silently, and the model then judges claims WITHOUT the source — which is the one thing the
41
+ # header above says must never happen. Test readability, and check the read itself below.
42
+ [ -n "$TARGET" ] && [ -f "$TARGET" ] && [ -r "$TARGET" ] \
43
+ || { echo "finding_verifier: --target must name a readable file" >&2; exit 2; }
44
+ # 🟥 심링크 거부 — 이 파일의 내용은 «외부 모델로 전송»된다. 체크아웃 안의 링크가 바깥
45
+ # 자격증명을 가리키면, 리뷰하려던 코드 대신 그 비밀이 프롬프트가 된다(residency 위반).
46
+ # cross-family security review 2026-09-09.
47
+ if [ -L "$TARGET" ]; then
48
+ echo "finding_verifier: --target is a symlink — refusing (content is SENT to an external model)" >&2
49
+ exit 2
50
+ fi
51
+ # 프롬프트 파일은 소스 전문을 담는다 — 0644 로 남기지 않는다.
52
+ umask 077
53
+
54
+ 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"; }
56
+ trap cleanup EXIT
57
+
58
+ cat > "$WORK/findings.jsonl"
59
+ if [ ! -s "$WORK/findings.jsonl" ]; then exit 0; fi # nothing asked, nothing to answer
60
+
61
+ if [ "$MODE" = "verify" ]; then
62
+ HEAD='You are an independent verifier from a different model family than the reviewer who wrote the
63
+ claims below. For EACH claim decide whether it is real, judged against the file itself.
64
+
65
+ Output ONLY JSON Lines, one object per claim id, nothing else — no prose, no code fences:
66
+ {"id":"<the id verbatim>","verdict":"confirmed|false-positive|needs-debate","why":"<one line, cite the line number you checked>"}
67
+
68
+ confirmed = you read the cited location and the described failure can actually occur there.
69
+ false-positive = the location does not say what the claim says, or the failure cannot occur.
70
+ needs-debate = it depends on a caller or config you cannot see from this file alone.
71
+
72
+ Answer for EVERY id. Do not invent ids. Do not add findings of your own.'
73
+ else
74
+ HEAD='You are auditing DELETIONS made by a different reviewer. Each record below is a claim that was
75
+ dropped as a false positive. For EACH one decide whether dropping it was right, judged against the file.
76
+
77
+ Output ONLY JSON Lines, one object per id, nothing else — no prose, no code fences:
78
+ {"id":"<the id verbatim>","verdict":"correct-drop|wrong-drop|uncertain","why":"<one line, cite the line number you checked>"}
79
+
80
+ correct-drop = the claim really was wrong; deleting it was right.
81
+ wrong-drop = the claim was true and was deleted in error (it will be reinstated).
82
+ uncertain = you cannot tell from this file alone.
83
+
84
+ Answer for EVERY id. A drop you cannot justify is not "correct" by default.'
85
+ fi
86
+
87
+ { printf '%s\n\n===== CLAIMS =====\n' "$HEAD"; cat "$WORK/findings.jsonl"
88
+ printf '\n===== FILE: %s =====\n' "$(basename "$TARGET")"; cat "$TARGET"; } > "$WORK/prompt.txt" \
89
+ || { echo "finding_verifier: could not build the prompt (source unreadable?)" >&2; exit 3; }
90
+ # A prompt that does not actually contain the file is a verdict reached from the claim text alone.
91
+ if ! grep -q "===== FILE: $(basename "$TARGET") =====" "$WORK/prompt.txt"; then
92
+ echo "finding_verifier: source did not reach the prompt — refusing to ask" >&2; exit 3
93
+ fi
94
+
95
+ case "$FAMILY" in
96
+ codex) "$CODEX" exec --sandbox read-only --skip-git-repo-check -m gpt-6-astra \
97
+ -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 \
99
+ -p "$(cat "$WORK/prompt.txt")" < /dev/null > "$WORK/raw.txt" 2>"$WORK/err.txt" ;;
100
+ *) echo "finding_verifier: unknown family '$FAMILY' (codex|gemini)" >&2; exit 2 ;;
101
+ esac
102
+ RC=$?
103
+
104
+ # The CLI's own exit code is not the WHOLE verdict — codex has exited non-zero while still printing
105
+ # usable output, and zero while printing none — but the first draft used it for nothing at all, which
106
+ # made the "cli failed" guard decorative (cross-family finding, 2026-09-09). It is now one of two
107
+ # inputs: a non-zero CLI that nevertheless answered EVERY claim asked is reported and allowed; a
108
+ # non-zero CLI that answered only some is a degraded run (exit 3), because the missing answers and the
109
+ # crash have the same cause and "fewer verdicts" would otherwise read as "fewer problems".
110
+ ASKED=$(grep -c '^{' "$WORK/findings.jsonl" 2>/dev/null); ASKED=${ASKED:-0}
111
+ MODE="$MODE" /usr/bin/python3 - "$WORK/raw.txt" <<'PY'
112
+ import json, os, sys
113
+ allowed = (("confirmed", "false-positive", "needs-debate") if os.environ["MODE"] == "verify"
114
+ else ("correct-drop", "wrong-drop", "uncertain"))
115
+ n = 0
116
+ for line in open(sys.argv[1], encoding="utf-8", errors="replace"):
117
+ line = line.strip().lstrip("")
118
+ if not line.startswith("{"):
119
+ continue # tolerate banners and fences, same as finding_fleet.sh
120
+ try:
121
+ d = json.loads(line)
122
+ except json.JSONDecodeError:
123
+ continue
124
+ if not d.get("id") or d.get("verdict") not in allowed:
125
+ continue # an out-of-enum verdict is dropped, never coerced
126
+ n += 1
127
+ print(json.dumps({"id": d["id"], "verdict": d["verdict"], "why": d.get("why", "")},
128
+ ensure_ascii=False))
129
+ open(os.path.join(os.path.dirname(sys.argv[1]), "n_answered"), "w").write(str(n))
130
+ sys.exit(0 if n else 3)
131
+ PY
132
+ PRC=$?
133
+ if [ "$PRC" -ne 0 ]; then
134
+ echo "finding_verifier: family=$FAMILY mode=$MODE cli_rc=$RC — no parsable verdict" >&2
135
+ head -c 400 "$WORK/err.txt" >&2 2>/dev/null
136
+ exit 3
137
+ fi
138
+ ANSWERED=$(cat "$WORK/n_answered" 2>/dev/null); ANSWERED=${ANSWERED:-0}
139
+ if [ "$RC" -ne 0 ] && [ "$ANSWERED" -lt "$ASKED" ]; then
140
+ echo "finding_verifier: family=$FAMILY mode=$MODE cli_rc=$RC answered=$ANSWERED/$ASKED — partial answer from a failed CLI, degrading" >&2
141
+ exit 3
142
+ fi
143
+ [ "$RC" -ne 0 ] && echo "finding_verifier: family=$FAMILY cli_rc=$RC but answered $ANSWERED/$ASKED — allowed, recorded" >&2
144
+ exit 0
@@ -0,0 +1,283 @@
1
+ #!/usr/bin/env python3
2
+ """finding_verify.py — typed review findings in, cross-family verdicts on, false positives dropped BY CODE.
3
+
4
+ WHY THIS EXISTS. FH's review output is prose end to end: a governor reads, judges, and writes. Nothing
5
+ in that path can be counted, filtered, or handed to a second family, because a prose finding has no
6
+ fields. Measured 2026-09-08 on eight GHSA cases x3: FH's review made 52 claims of which 5 were wrong
7
+ about the code (9.6%); a sibling harness (octo) made 73 claims -- 40% MORE -- with 2 wrong (2.7%). Its
8
+ advantage is not better reading. Its pipeline emits findings as typed JSON, has a different-family
9
+ agent stamp each one `confirmed|false-positive|needs-debate`, and then DELETES the false positives with
10
+ a filter. Judgment stays with a model; the drop is mechanical. This script is that stage for FH.
11
+
12
+ WHAT IS MECHANIZED AND WHAT IS NOT (CLAUDE.md 'Mechanization Boundary'). The channel is mechanized:
13
+ every finding carries a verdict, the verdict comes from a command that is not the author, the drop is
14
+ performed by code, and what was dropped is written down. The judgment -- is this claim true of the
15
+ source -- is made by whatever model the verifier command runs, never frozen here. This file contains
16
+ no rule about what makes a finding wrong.
17
+
18
+ DEGRADE DIRECTION. A review surface is reversible, so an unreachable verifier does not block. It must
19
+ not be silent either: with no verifier every finding is stamped `unverified`, NOTHING is dropped, the
20
+ status is UNVERIFIED and the exit code says so. An unverified run must never read as a clean one.
21
+
22
+ THE DROP SIDE IS MEASURED TOO, OR THE RUN SAYS IT WAS NOT. A stage that deletes claims improves any
23
+ precision number for free: delete enough and nothing wrong survives. So the error rate of the SURVIVORS
24
+ is not a result on its own — it is only meaningful beside the error rate of the DELETIONS. Measured on
25
+ this pipeline's first real use, 2026-09-08: the verifier dropped a claim that the project's own earlier
26
+ record grades a real A-tier defect. One drop, one wrong. That is why `--audit-verifier` exists and why
27
+ the summary line carries `drop_audit=UNAUDITED` in bold terms when drops happened and nobody checked
28
+ them. The auditor must not be the family that made the drop; when it is the family that PRODUCED the
29
+ finding, that is an appeal by an interested party and is recorded as `audit_role=appeal`, not hidden.
30
+
31
+ INPUT JSONL, one finding per line:
32
+ {"id","file","line","severity","category","title","detail","confidence","producer_family"}
33
+ `id` and `title` are required; the rest are optional and pass through untouched.
34
+ When `producer_family` is present and equals the verifier's family, that finding is stamped
35
+ `unverified` rather than judged -- see the note above VERDICTS.
36
+ AUDITOR Optional, and required for the drop-side number to exist. Same protocol as the verifier, but
37
+ it receives only the DROPPED findings and answers {"id","verdict":"correct-drop|wrong-drop|
38
+ uncertain","why"}. A `wrong-drop` finding is moved back into confirmed.jsonl with
39
+ `reinstated: true` -- the audit is not advisory, it reverses the deletion.
40
+ VERIFIER A command that reads the findings JSONL on stdin and writes JSONL verdicts on stdout:
41
+ {"id","verdict":"confirmed|false-positive|needs-debate","why"}
42
+ Set it with --verifier or FH_VERIFY_CMD. Run it as a DIFFERENT model family than the author;
43
+ this script cannot check that, and says so rather than pretending to.
44
+ OUTPUT <out>/confirmed.jsonl survivors (confirmed + needs-debate, the latter flagged)
45
+ <out>/dropped.jsonl false positives, with the verifier's reason -- never silent
46
+ stdout one summary line, machine-readable
47
+ EXIT 0 verified and (no drops, or drops audited) with >=1 survivor · 1 same but nothing survives
48
+ 3 UNVERIFIED (degraded) · 4 drops happened and were never audited · 2 usage or schema error
49
+ """
50
+ import argparse, json, os, subprocess, sys
51
+
52
+ REQUIRED = ("id", "title")
53
+ VERDICTS = ("confirmed", "false-positive", "needs-debate")
54
+ AUDIT_VERDICTS = ("correct-drop", "wrong-drop", "uncertain")
55
+
56
+ # A finding is never verified by the family that produced it. That is the one property of the record
57
+ # this file enforces on its own: same-family review shares the author's blind spot, so a verdict from
58
+ # the producer is not a second opinion. It is a channel rule, not a judgment -- the script does not
59
+ # decide whether the claim is true, only that the party answering must not be the party asking.
60
+
61
+
62
+ def read_findings(path):
63
+ out, seen = [], set()
64
+ src = sys.stdin if path == "-" else open(path, encoding="utf-8")
65
+ for n, line in enumerate(src, 1):
66
+ line = line.strip()
67
+ if not line:
68
+ continue
69
+ try:
70
+ d = json.loads(line)
71
+ except json.JSONDecodeError as e:
72
+ raise SystemExit(f"finding_verify: line {n} is not JSON: {e}")
73
+ for k in REQUIRED:
74
+ if not d.get(k):
75
+ raise SystemExit(f"finding_verify: line {n} missing required field '{k}'")
76
+ if d["id"] in seen:
77
+ raise SystemExit(f"finding_verify: duplicate id {d['id']!r} on line {n}")
78
+ seen.add(d["id"])
79
+ out.append(d)
80
+ return out
81
+
82
+
83
+ def run_verifier(cmd, findings, allowed=VERDICTS):
84
+ """Returns (verdicts_by_id, error_or_None). Any failure degrades; it never raises.
85
+
86
+ `allowed` is the verdict vocabulary. The audit pass speaks a different one, and a verdict outside
87
+ the expected set is dropped rather than coerced -- a stage that silently reinterprets an unknown
88
+ label is how an unanswered question becomes an answer."""
89
+ payload = "\n".join(json.dumps(f, ensure_ascii=False) for f in findings) + "\n"
90
+ # 🟥 A LIST MEANS argv; A STRING MEANS A SHELL. The caller decides, and the shipped caller
91
+ # (finding_pipeline.sh) now hands a list, so no shell parses our paths.
92
+ #
93
+ # Why this branch exists (cross-family security review, 2026-09-09, reproduced on dash):
94
+ # the string form is executed with `shell=True`, i.e. by `/bin/sh`. Quoting the interpolated
95
+ # path with bash's `printf %q` is NOT enough, because %q emits bash-only `$'...'` for a path
96
+ # containing a newline, and `/bin/sh` on most Linux distributions is **dash**, which does not
97
+ # understand that syntax -- the quoting comes apart and a crafted filename executes a second
98
+ # command. macOS cannot observe this at all: its /bin/sh is bash-derived, so a local run is
99
+ # green while the shipped npm package is not. The fix is not better escaping; it is not
100
+ # handing a shell the string in the first place.
101
+ shell = isinstance(cmd, str)
102
+ try:
103
+ p = subprocess.run(cmd, shell=shell, input=payload, capture_output=True,
104
+ text=True, timeout=int(os.environ.get("FH_VERIFY_TIMEOUT", "600")))
105
+ except Exception as e: # noqa: BLE001 - degrade on anything
106
+ return {}, f"verifier did not run: {e}"
107
+ if p.returncode != 0:
108
+ return {}, f"verifier exit {p.returncode}: {(p.stderr or '').strip()[:200]}"
109
+ got = {}
110
+ for line in p.stdout.splitlines():
111
+ line = line.strip()
112
+ if not line or not line.startswith("{"):
113
+ continue # tolerate chatter around the JSONL
114
+ try:
115
+ d = json.loads(line)
116
+ except json.JSONDecodeError:
117
+ continue
118
+ if d.get("id") and d.get("verdict") in allowed:
119
+ got[d["id"]] = d
120
+ if not got:
121
+ return {}, "verifier returned no parseable verdict"
122
+ return got, None
123
+
124
+
125
+ def main():
126
+ ap = argparse.ArgumentParser(add_help=True)
127
+ ap.add_argument("findings", help="JSONL file, or - for stdin")
128
+ ap.add_argument("--out", required=True, help="directory for confirmed.jsonl / dropped.jsonl")
129
+ ap.add_argument("--verifier-argv", default=None,
130
+ help="JSON array form of --verifier. Executed as argv (no shell), which is the "
131
+ "only form immune to path-shaped injection. Wins over --verifier.")
132
+ ap.add_argument("--audit-verifier-argv", default=None,
133
+ help="JSON array form of --audit-verifier. Same reason.")
134
+ ap.add_argument("--verifier", default=os.environ.get("FH_VERIFY_CMD", ""),
135
+ help="shell command; findings JSONL on stdin, verdict JSONL on stdout")
136
+ ap.add_argument("--family", default=os.environ.get("FH_VERIFY_FAMILY", "unstated"),
137
+ help="model family of the verifier, recorded verbatim and never checked")
138
+ ap.add_argument("--audit-verifier", default=os.environ.get("FH_AUDIT_CMD", ""),
139
+ help="command that re-checks the DROPPED findings; without it the run is UNAUDITED")
140
+ ap.add_argument("--audit-family", default=os.environ.get("FH_AUDIT_FAMILY", "unstated"),
141
+ help="model family of the auditor; must differ from the verifier's")
142
+ a = ap.parse_args()
143
+
144
+ # argv 형태가 있으면 그것이 실행 형태다 — 문자열은 셸을 타므로 후순위다.
145
+ def _as_argv(raw, label):
146
+ if not raw:
147
+ return None
148
+ try:
149
+ v = json.loads(raw)
150
+ except json.JSONDecodeError as e:
151
+ raise SystemExit("%s must be a JSON array: %s" % (label, e))
152
+ if not (isinstance(v, list) and v and all(isinstance(x, str) for x in v)):
153
+ raise SystemExit("%s must be a non-empty JSON array of strings" % label)
154
+ return v
155
+
156
+ a.verifier = _as_argv(a.verifier_argv, "--verifier-argv") or a.verifier
157
+ a.audit_verifier = _as_argv(a.audit_verifier_argv, "--audit-verifier-argv") or a.audit_verifier
158
+
159
+ # 문자열이든 리스트든 «비어 있나»를 같은 방법으로 묻는다 — 리스트에 .strip() 은 없다.
160
+ def _configured(cmd):
161
+ return bool(cmd) if isinstance(cmd, list) else bool(str(cmd or "").strip())
162
+
163
+ findings = read_findings(a.findings)
164
+ os.makedirs(a.out, exist_ok=True)
165
+
166
+ if not _configured(a.verifier):
167
+ verdicts, err = {}, "no verifier configured (--verifier / FH_VERIFY_CMD)"
168
+ else:
169
+ verdicts, err = run_verifier(a.verifier, findings)
170
+
171
+ confirmed, dropped, debate, unverified = [], [], 0, 0
172
+ for f in findings:
173
+ v = verdicts.get(f["id"])
174
+ if v is None:
175
+ # Degraded, or the verifier skipped this one. Keep it, mark it, never drop it silently.
176
+ f = dict(f, verdict="unverified",
177
+ verify_note=err or "verifier returned no verdict for this finding")
178
+ unverified += 1
179
+ confirmed.append(f)
180
+ continue
181
+ prod = f.get("producer_family")
182
+ # 🟥 ABSENT is not CLEAN. This guard is the one property this file claims to enforce, and
183
+ # until 2026-09-09 it hung on an OPTIONAL field: omit `producer_family` and the check was
184
+ # skipped entirely, so a family verified its own findings and the run reported
185
+ # `status=VERIFIED rc=0`. Reproduced with a known pair — same table, same verifier, the
186
+ # field the only difference: with it `unverified=1 rc=3`, without it `confirmed=1 rc=0`.
187
+ # A missing producer cannot PROVE the verifier is not the author, so the fail-closed
188
+ # answer is the same one an actual self-verification gets: `unverified`, never a silent
189
+ # pass. (The wired path never reached this — `finding_pipeline.sh` refuses to route a
190
+ # table with no usable `producer_family` (exit 3) and `finding_fleet.sh` always stamps it
191
+ # — but this script ships its own CLI, and a hand-built table is a supported entry point.)
192
+ if not prod:
193
+ f = dict(f, verdict="unverified", verify_note="finding declares no producer_family; "
194
+ f"cannot establish that the verifier ({a.family}) is not its author")
195
+ unverified += 1
196
+ confirmed.append(f)
197
+ continue
198
+ if prod == a.family:
199
+ f = dict(f, verdict="unverified", verify_note="verifier is the producing family "
200
+ f"({a.family}); a finding is not verified by its own author")
201
+ unverified += 1
202
+ confirmed.append(f)
203
+ continue
204
+ f = dict(f, verdict=v["verdict"], verify_why=v.get("why", ""), verify_family=a.family)
205
+ if v["verdict"] == "false-positive":
206
+ dropped.append(f)
207
+ else:
208
+ if v["verdict"] == "needs-debate":
209
+ debate += 1
210
+ confirmed.append(f)
211
+
212
+ # ── drop audit ────────────────────────────────────────────────────────────────────────────────
213
+ # Nothing here judges whether a drop was right; it routes the question to a party that did not make
214
+ # the drop, and moves a reversed drop back. The refusal to report a bare precision number when this
215
+ # did not run is the mechanized part.
216
+ audited = wrong_drops = reinstated = 0
217
+ audit_status = "UNAUDITED"
218
+ audit_note = ""
219
+ if dropped and _configured(a.audit_verifier):
220
+ if a.audit_family == a.family:
221
+ audit_note = ("auditor is the family that made the drop (%s) -- refused; a deletion is not "
222
+ "checked by the party that made it" % a.family)
223
+ else:
224
+ av, aerr = run_verifier(a.audit_verifier, dropped, AUDIT_VERDICTS)
225
+ if aerr:
226
+ audit_note = "auditor did not answer: " + aerr
227
+ else:
228
+ kept = []
229
+ for d in dropped:
230
+ r = av.get(d["id"])
231
+ if r is None:
232
+ kept.append(dict(d, drop_verdict="unaudited"))
233
+ continue
234
+ audited += 1
235
+ role = "appeal" if d.get("producer_family") == a.audit_family else "independent"
236
+ d = dict(d, drop_verdict=r["verdict"], drop_why=r.get("why", ""),
237
+ audit_family=a.audit_family, audit_role=role)
238
+ if r["verdict"] == "wrong-drop":
239
+ wrong_drops += 1
240
+ reinstated += 1
241
+ confirmed.append(dict(d, reinstated=True))
242
+ else:
243
+ kept.append(d)
244
+ dropped = kept
245
+ # 🟥 AUDITED must mean EVERY drop was answered. Setting it unconditionally let an
246
+ # auditor that answered one unrelated id produce `audited=0 drop_audit=AUDITED` and
247
+ # exit 0 — the deletions went unchecked while the record said they were checked.
248
+ # (cross-family review 2026-09-09, reproduced; an EMPTY audit already returned 4, so
249
+ # the hole was specifically the PARTIAL answer.) A partial audit is not an audit.
250
+ unanswered = sum(1 for d in dropped if d.get("drop_verdict") == "unaudited")
251
+ if unanswered:
252
+ audit_status = "PARTIAL"
253
+ audit_note = ("auditor answered %d of %d drops; %d unanswered — a partial audit "
254
+ "is not an audit" % (audited, audited + unanswered, unanswered))
255
+ else:
256
+ audit_status = "AUDITED"
257
+ elif not dropped:
258
+ audit_status = "NO-DROPS"
259
+
260
+ for name, rows in (("confirmed.jsonl", confirmed), ("dropped.jsonl", dropped)):
261
+ with open(os.path.join(a.out, name), "w", encoding="utf-8") as fh:
262
+ for r in rows:
263
+ fh.write(json.dumps(r, ensure_ascii=False) + "\n")
264
+
265
+ status = "UNVERIFIED" if unverified else "VERIFIED"
266
+ print("FINDINGS in={} confirmed={} dropped={} debate={} unverified={} family={} status={}{}".format(
267
+ len(findings), len(confirmed) - unverified, len(dropped), debate, unverified,
268
+ a.family, status, "" if not err else " reason=" + err.replace("\n", " ")))
269
+ # 🟥 The drop line is unconditional. A survivor-side number without it is a precision claim made by
270
+ # deleting, and this pipeline does not let a reader compute one without seeing whether the
271
+ # deletions were checked.
272
+ print("DROPS dropped={} audited={} wrong_drops={} reinstated={} auditor={} drop_audit={}{}".format(
273
+ len(dropped), audited, wrong_drops, reinstated, a.audit_family, audit_status,
274
+ "" if not audit_note else " reason=" + audit_note.replace("\n", " ")))
275
+ if unverified:
276
+ return 3
277
+ if audit_status in ("UNAUDITED", "PARTIAL"):
278
+ return 4 # drops happened and nobody checked them: not a completed run
279
+ return 0 if confirmed else 1
280
+
281
+
282
+ if __name__ == "__main__":
283
+ sys.exit(main())
@@ -230,6 +230,7 @@ else
230
230
  'CLAUDE.md|CLAUDE\.md' \
231
231
  'AGENTS.md|AGENTS\.md' \
232
232
  'scripts/**/*.sh|scripts/\*\*/\*\.sh' \
233
+ 'scripts/**/*.py|scripts/\*\*/\*\.py' \
233
234
  'agent definitions (plugins/*/agents)|plugins/\*/agents/' \
234
235
  'agent definitions (.claude/agents)|\.claude/agents/'
235
236
  do