@chrono-meta/fh-gate 3.0.0 → 3.1.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 (111) hide show
  1. package/.claude/regression/probes_live.yaml +137 -0
  2. package/.claude/rules/.residency-patterns.defaults +7 -0
  3. package/.claude/rules/fh_4axis_gate.md +50 -1
  4. package/.claude-plugin/marketplace.json +8 -2
  5. package/AGENTS.md +27 -0
  6. package/CATALOG.md +17 -0
  7. package/CLAUDE.md +12 -2
  8. package/README.ja.md +51 -7
  9. package/README.ko.md +48 -7
  10. package/README.md +37 -5
  11. package/README.zh.md +45 -8
  12. package/docs/STANDARDS_ALIGNMENT.md +61 -0
  13. package/docs/USER_GUIDE.md +3 -0
  14. package/docs/USE_CASES.md +50 -0
  15. package/docs/model_tier_expectations.md +60 -0
  16. package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
  17. package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
  18. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
  19. package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
  20. package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
  21. package/knowledge/shared/learnings/subagent_invocations_log.yaml +441 -4
  22. package/package.json +40 -2
  23. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  24. package/plugins/fh-commons/skills/preprep/README.md +4 -1
  25. package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
  26. package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
  27. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
  28. package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
  29. package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
  30. package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
  31. package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
  32. package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
  33. package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
  34. package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
  35. package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
  36. package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
  37. package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
  38. package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
  39. package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
  40. package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
  41. package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
  42. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  43. package/plugins/fh-meta/CHANGELOG.md +43 -1
  44. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
  45. package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
  46. package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
  47. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
  48. package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
  49. package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
  50. package/plugins/fh-qp/README.md +71 -0
  51. package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
  52. package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
  53. package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
  54. package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
  55. package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
  56. package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
  57. package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
  58. package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
  59. package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
  60. package/plugins/fh-qp/qp_profile.example.yaml +29 -0
  61. package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
  62. package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
  63. package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
  64. package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
  65. package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
  66. package/scripts/chamber_run.sh +14 -5
  67. package/scripts/com.forge-harness.live-eval.plist +84 -0
  68. package/scripts/compaction_probe.sh +9 -35
  69. package/scripts/directional_diff_gate.sh +14 -2
  70. package/scripts/frontier_digest_autopilot.sh +4 -1
  71. package/scripts/map_postprocess.py +90 -0
  72. package/scripts/outbound_query_guard.sh +131 -0
  73. package/scripts/outbound_query_hook.sh +373 -0
  74. package/scripts/package_coverage_check.sh +55 -16
  75. package/scripts/pipe_verdict_guard.sh +41 -1
  76. package/scripts/probe_live_eval.sh +240 -0
  77. package/scripts/probe_live_eval_lib.py +579 -0
  78. package/scripts/proposal_hook.sh +120 -17
  79. package/scripts/push_zone_check.sh +78 -0
  80. package/scripts/residency_closure_scan.py +252 -0
  81. package/scripts/selfcheck.sh +41 -1
  82. package/scripts/session_close_check.sh +100 -0
  83. package/scripts/sim_isolated_run.sh +98 -2
  84. package/scripts/test_action_yml_lanes.sh +97 -0
  85. package/scripts/test_fh_qp_lanes.sh +105 -0
  86. package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
  87. package/scripts/test_map_postprocess_lanes.sh +143 -0
  88. package/scripts/test_marker_affected_lanes.sh +93 -0
  89. package/scripts/test_marker_crossfamily_lanes.sh +90 -6
  90. package/scripts/test_marker_oracle_lanes.sh +136 -0
  91. package/scripts/test_outbound_query_hook_lanes.sh +433 -0
  92. package/scripts/test_outbound_query_lanes.sh +87 -0
  93. package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
  94. package/scripts/test_preprep_diagram_lanes.sh +87 -0
  95. package/scripts/test_preprep_drift_anchor.sh +3 -3
  96. package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
  97. package/scripts/test_probe_live_eval_lanes.sh +437 -0
  98. package/scripts/test_proposal_hook_lanes.sh +22 -1
  99. package/scripts/test_push_zone_lanes.sh +304 -0
  100. package/scripts/test_residency_closure_lanes.sh +70 -0
  101. package/scripts/test_sim_isolated_run_lanes.sh +119 -0
  102. package/scripts/test_utterance_intake_lanes.sh +414 -0
  103. package/scripts/test_worktree_reclaim_lanes.sh +70 -0
  104. package/scripts/transcript_utterances.py +222 -0
  105. package/scripts/utterance_intake.sh +424 -0
  106. package/scripts/validate_yaml.sh +27 -0
  107. package/scripts/worktree_reclaim.sh +95 -0
  108. package/templates/.git-hooks/pre-commit +353 -1
  109. package/templates/.git-hooks/pre-push +91 -0
  110. package/templates/RED_TEAM_REPORT.md +49 -0
  111. package/templates/settings.PreToolUse.snippet.json +65 -1
@@ -0,0 +1,373 @@
1
+ #!/usr/bin/env bash
2
+ # outbound_query_hook.sh — PreToolUse(WebSearch|WebFetch): the WIRING for the outbound-query
3
+ # hygiene lint. 🟥 This is the FIRST hook in this repo that can emit a `deny`, so its degrade
4
+ # direction is the design, not a footnote.
5
+ #
6
+ # ── WHY A HOOK, AND WHY NOW ────────────────────────────────────────────────────────────────────
7
+ # `scripts/outbound_query_guard.sh` has owned this lint since 2026-08-21 and its own header says
8
+ # it "is wired to no outbound executor at all". A lint with zero callers is prose in a .sh file
9
+ # ([[feedback_built_but_not_wired]]); the question «did it work» was answered by EXISTENCE, which
10
+ # is the half-externalization shape ([[feedback_half_externalization_slot_without_consumer]]).
11
+ # The session's own WebSearch/WebFetch call IS the outbound executor, and PreToolUse is the only
12
+ # place that sits ON that path — a repo scanner never reads an interactively-composed query
13
+ # ([[feedback_instrument_not_on_the_path]], same reasoning as pipe_verdict_guard's).
14
+ #
15
+ # ── WHY IT IS NOT «just run the guard» ─────────────────────────────────────────────────────────
16
+ # The guard fail-closes (exit 3) when the operator's gitignored override layer is absent. That is
17
+ # RIGHT for a CLI the operator types, and WRONG for a hook: in a consumer install that layer is
18
+ # absent BY CONSTRUCTION, so importing the guard's contract would deny every consumer's WebSearch.
19
+ # CLAUDE.md: «a gate that blocks every new install is not a strict gate, it is a bypass trainer»,
20
+ # and the bypass it trains is disabling hooks — the same channel the destructive-op and
21
+ # confidentiality gates ride on. So the verdict is split BY LAYER:
22
+ #
23
+ # override layer hit → deny the operator's own internal literals. This is the leak class
24
+ # the guard exists for, and it is irreversible once sent.
25
+ # defaults layer hit → advisory universal shapes (emails, home paths, key formats). FP-prone,
26
+ # and it is ALL a consumer has — blocking on it would be the
27
+ # over-block above.
28
+ # no override present → advisory only, plus a once-per-session UNCALIBRATED notice: a
29
+ # defaults-only scan CANNOT say «no internal tokens», it can
30
+ # only say «no universal shapes» ([[feedback_not_found_is_not_zero_family]]).
31
+ # instrument incomplete → deny (lib absent · defaults broken · dropped pattern rows · dead
32
+ # canary · scanner returned a non-verdict). NOT SCANNED is not
33
+ # clean, and this surface is irreversible
34
+ # (CLAUDE.md §Irreversibility Gates: fail CLOSED).
35
+ # not an FH checkout → silent applicability is MECHANICAL (is the tracked defaults file
36
+ # here?), never self-judged — and in that tree we create nothing.
37
+ #
38
+ # 🟥 THE LAYER IS DECIDED BY COUNTS, NOT BY A BOOLEAN. Scan twice — full set, then defaults-only —
39
+ # and compare hit COUNTS. With booleans, «both layers hit» is indistinguishable from «defaults
40
+ # hit», which silently downgrades a real deny to an advisory. `n_full < n_def` is impossible
41
+ # (the full stream is a superset) and is therefore treated as instrument anomaly → deny.
42
+ #
43
+ # 🟥 THE OUTPUT MUST NEVER CARRY THE TOKEN VALUE. psa_scan_tagged prints `SEV leak — path: 'tok'`.
44
+ # Echoing that into a deny reason, a systemMessage, or the event log would make the leak guard
45
+ # the leak channel — and CLAUDE.md's residency rule names logs and comments explicitly. Only
46
+ # the COUNT and the SEVERITY LABEL leave this script, and a label that is not plain ASCII
47
+ # letters is rendered `?`. Lane H10 asserts the fixture token appears on no output surface.
48
+ #
49
+ # ── STDIN SHAPE: WHY KEY NAMES ARE NOT HARDCODED ───────────────────────────────────────────────
50
+ # The official hooks reference (code.claude.com/docs/en/hooks-guide.md, read 2026-09-05) documents
51
+ # `tool_name` and `tool_input` but does NOT document WebSearch's / WebFetch's `tool_input` keys.
52
+ # Hardcoding `query` / `url` / `prompt` on an unverified schema fails SILENTLY OPEN the day the
53
+ # schema changes. So every STRING VALUE in `tool_input` is collected recursively and scanned as
54
+ # one record. The key NAMES (schema, never user content) are recorded in the event log so the
55
+ # real shape becomes measured rather than assumed.
56
+ # MEASURED 2026-09-05, disposable clone, sonnet, real WebSearch call: the logged key column read
57
+ # `query` — so WebSearch's key is `query` today. Recorded as an OBSERVATION, not as a contract to
58
+ # depend on: the scan still walks every string value, so a renamed or added key stays covered.
59
+ #
60
+ # ── WHAT A CLEAN-CLONE RUN FOUND THAT THE LANES COULD NOT ──────────────────────────────────────
61
+ # First run: both evidence files landed as UNTRACKED paths and the runner flagged tree
62
+ # contamination — .gitignore had explicit entries for the other hooks' event files and none for
63
+ # these two. Every lane passed throughout; nothing in a fixture tree can see a repo's .gitignore.
64
+ # That is the «adversarial review is not a substitute for first use» shape
65
+ # ([[feedback_adversarial_review_not_substitute_for_first_use]]). Fixed in .gitignore, and the
66
+ # consumer-facing twin of it is called out in templates/settings.PreToolUse.snippet.json.
67
+ #
68
+ # ── DEGRADE CONTRACT ───────────────────────────────────────────────────────────────────────────
69
+ # unparseable payload / python3 absent → SILENT exit 0. A dead interpreter must not block tool
70
+ # calls (same contract as pipe_verdict_guard / proposal_hook). This is the one place where
71
+ # «cannot measure» does not deny, and it is deliberate: the failure is in OUR harness, it is
72
+ # loud in no direction, and denying on it would take the session's web access away whenever
73
+ # python3 hiccups. Named as a residual, not defended as complete.
74
+ # Every other «cannot measure» state DENIES, because there the scanner ran and did not certify.
75
+ # exit code is ALWAYS 0 — the JSON carries the verdict (docs: "Exit 0 with JSON output: parsed
76
+ # JSON controls behavior"). exit 2 would work too but discards the structured reason.
77
+ #
78
+ # Opt out on one call by putting `# noqa: outbound` in the query. 🟥 That channel is gameable —
79
+ # which is exactly why it writes a log row. An invisible bypass is worse than a recorded one.
80
+ #
81
+ # Wiring: templates/settings.PreToolUse.snippet.json (SIXTH GUARD). Lanes:
82
+ # bash scripts/test_outbound_query_hook_lanes.sh
83
+ # Manual smoke:
84
+ # printf '%s' '{"tool_name":"WebSearch","tool_input":{"query":"hello"}}' | bash scripts/outbound_query_hook.sh
85
+ set -uo pipefail
86
+
87
+ # 🟥 TWO ROOTS, AND THEY ARE NOT THE SAME ROOT. The library ships NEXT TO this script, so it is
88
+ # resolved from $0 — a hook is invoked by absolute path and must find its own scanner regardless
89
+ # of what CLAUDE_PROJECT_DIR points at. The pattern layers and the event log belong to the
90
+ # PROJECT being guarded, so they are resolved from CLAUDE_PROJECT_DIR. In a normal install the
91
+ # two coincide; conflating them made every lane fixture (project root ≠ repo root) deny on
92
+ # "library absent" — caught by the lanes on the first run, which is what they are for.
93
+ SELF_DIR="$(cd "$(dirname "$0")" && pwd)"
94
+ FH="${CLAUDE_PROJECT_DIR:-}"
95
+ [ -n "$FH" ] || FH="$(cd "$SELF_DIR/.." && pwd)"
96
+ # Injection points — the lanes must not move the real library or the real pattern files
97
+ # (test_outbound_query_lanes.sh L7 learned that the hard way).
98
+ LIB="${PSA_LIB_FILE:-$SELF_DIR/psa_scan_lib.sh}"
99
+ DEFAULTS="${PSA_DEFAULTS_FILE:-$FH/.claude/rules/.public-surface-patterns.defaults}"
100
+ OVERRIDE="${PSA_OVERRIDE_FILE:-$FH/.claude/rules/.public-surface-patterns}"
101
+ EVDIR="$FH/.claude"
102
+ EVFILE="$EVDIR/.outbound_hook_events.tsv"
103
+ SENTINEL="$EVDIR/.outbound_hook_uncalibrated_notice"
104
+ # A path that can never exist: /dev/null is not a directory, so any child of it is ENOTDIR.
105
+ NO_OVERRIDE="/dev/null/__fh_no_override__"
106
+
107
+ TOOL=""; SESS=""; KEYS=""; FLAGS=""; QTEXT=""
108
+
109
+ _py_extract() { cat <<'PY'
110
+ import json,sys,re
111
+ def out(a,b,c,f,d):
112
+ # ONE line, exactly four TABs. `text` is flattened below, so no field can contain a TAB and
113
+ # the split in bash is exact. A temp file used to carry this; mktemp's failure branch was a
114
+ # silent `exit 0` on an outbound call (degrade_direction_scan S1, 2026-09-05) — removing the
115
+ # file removed the branch rather than arguing about it.
116
+ sys.stdout.write("OQH2\t"+a+"\t"+b+"\t"+c+"\t"+f+"\t"+d+"\n"); sys.exit(0)
117
+ try:
118
+ d=json.load(sys.stdin)
119
+ except Exception:
120
+ out("","","","-","")
121
+ if not isinstance(d,dict): out("","","","-","")
122
+ tn=re.sub(r"[^A-Za-z0-9_-]","",str(d.get("tool_name","") or ""))[:32]
123
+ sid=re.sub(r"[^A-Za-z0-9_-]","",str(d.get("session_id","") or ""))[:64]
124
+ ti=d.get("tool_input",{})
125
+ # A non-object tool_input is NOT replaced by {} — a bare string is still an outbound value and is
126
+ # scanned like any other (codex finding 1, 2026-09-05). Key NAMES: the log carries only the KNOWN
127
+ # schema names; anything else renders as `other`, so a token used as a key never lands in the
128
+ # event file (finding 4) — and keys are scanned too (walk below), because they leave as well.
129
+ keys=""
130
+ if isinstance(ti,dict):
131
+ keys=",".join(sorted(set(k if k in ("query","url","prompt") else "other" for k in map(str,ti.keys()))))
132
+ vals=[]; trunc=[False]
133
+ def walk(x,depth):
134
+ # Past the limits the extractor STOPS and SAYS SO — a token below the cut would otherwise leave
135
+ # as "clean" (findings 2·3). bash turns the flag into a deny (unmeasured), never a partial scan.
136
+ if depth>8 or len(vals)>4096:
137
+ trunc[0]=True; return
138
+ if isinstance(x,str): vals.append(x)
139
+ elif isinstance(x,dict):
140
+ for k,v in x.items():
141
+ if isinstance(k,str): vals.append(k)
142
+ walk(v,depth+1)
143
+ elif isinstance(x,list):
144
+ for v in x: walk(v,depth+1)
145
+ walk(ti,0)
146
+ text=re.sub(r"[\r\n\t]"," "," ".join(vals))
147
+ out(tn,sid,keys,("T" if trunc[0] else "-"),text)
148
+ PY
149
+ }
150
+
151
+ _py_json() { cat <<'PY'
152
+ import json,sys
153
+ mode=sys.argv[1]; msg=sys.argv[2]
154
+ h={"hookEventName":"PreToolUse"}
155
+ if mode=="deny":
156
+ h["permissionDecision"]="deny"; h["permissionDecisionReason"]=msg
157
+ else:
158
+ h["additionalContext"]=msg
159
+ print(json.dumps({"hookSpecificOutput":h,"systemMessage":msg},ensure_ascii=False))
160
+ PY
161
+ }
162
+
163
+ _log() { # $1=verdict $2=layer
164
+ mkdir -p "$EVDIR" 2>/dev/null || return 1
165
+ # Returns 1 when the row could not be written. Only the noqa path acts on that (see below):
166
+ # for every other verdict a lost log line is a lost record, not a lost block.
167
+ if printf '%s\t%s\t%s\t%s\t%s\n' \
168
+ "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "${TOOL:-?}" "$1" "$2" "${KEYS:-}" >> "$EVFILE" 2>/dev/null
169
+ then return 0; else return 1; fi
170
+ }
171
+
172
+ # 🟥 A DENY THAT CANNOT BE RENDERED MUST NOT BECOME A SILENT ALLOW.
173
+ # The first build ended this function with `|| true`, so a failed JSON build emitted nothing —
174
+ # and "no output" is how this hook says ALLOW. degrade_direction_scan S1 flagged the line and
175
+ # it was a real default-toward-PASS on an irreversible surface, not a false positive.
176
+ # Fallback is the documented second channel: "Exit 2: blocks action, uses stderr as reason".
177
+ # An ADVISORY that cannot be rendered is a lost warning, not a lost block — it stays exit 0.
178
+ # OQH_PY exists so that branch is ANCHORED rather than asserted: the lane points it at a
179
+ # non-interpreter while the parse above still runs on the real python3. Same justification as
180
+ # PSA_LIB_FILE in outbound_query_guard.sh — a branch no lane can reach is decorative.
181
+ _emit() { # $1=deny|advisory $2=message
182
+ local _j
183
+ _j="$("${OQH_PY:-python3}" -c "$(_py_json)" "$1" "$2" 2>/dev/null)" || _j=""
184
+ if [ -n "$_j" ]; then printf '%s\n' "$_j"; return 0; fi
185
+ if [ "$1" = deny ]; then
186
+ printf '%s\n' "$2" >&2
187
+ exit 2
188
+ fi
189
+ return 0
190
+ }
191
+
192
+ # Severity histogram with NO token values. The label is the pattern file's first column, so it is
193
+ # operator-authored text: anything that is not 1-8 plain ASCII letters is rendered `?`.
194
+ _sev_summary() { # $1 = raw scan output
195
+ printf '%s\n' "$1" \
196
+ | grep -F ' leak — ' 2>/dev/null \
197
+ | awk '{ s=$2; if (s=="HIGH" || s=="MED" || s=="LOW") print s; else print "?" }' \
198
+ | sort | uniq -c \
199
+ | awk '{ printf "%s x%s ", $2, $1 }'
200
+ return 0
201
+ }
202
+ _hits() { # $1 = raw scan output → count of reportable hit lines
203
+ # noqa: degrade — `grep -c` exits 1 when the count is ZERO, which is the normal clean case, and
204
+ # it still PRINTS "0". The `|| true` protects the function's rc, not its value; a genuine grep
205
+ # error prints NOTHING, and the empty value fails closed at the `case` guard below. Verified by
206
+ # reading both paths, 2026-09-05; the guard it lands on is labelled unanchored there.
207
+ printf '%s\n' "$1" | grep -cF ' leak — ' 2>/dev/null || true # noqa: degrade (see above)
208
+ }
209
+
210
+ _deny_unmeasured() { # $1 = what is incomplete (no token values, ever)
211
+ _log deny -
212
+ _emit deny "🚫 outbound 차단 — 미측정(계기 불완전: $1). 나가는 질의는 되돌릴 수 없으므로 «못 쟀다»를 «깨끗하다»로 접지 않는다(CLAUDE.md §Irreversibility Gates). 고쳐야 할 것: 위 계기. 확인 후 강행하려면 질의에 '# noqa: outbound' 를 넣어라 — 통과하지만 로그에 남는다."
213
+ exit 0
214
+ }
215
+
216
+ # ── 1. parse ───────────────────────────────────────────────────────────────────────────────────
217
+ # python3 absent / crashed / payload unparseable → empty → silent exit 0. That is the ONE named
218
+ # fail-open in this script (see DEGRADE CONTRACT above): a dead interpreter is our defect, not the
219
+ # session's, and taking web access away on it would train the hook off.
220
+ _META="$(python3 -c "$(_py_extract)" 2>/dev/null)"
221
+ [ -n "$_META" ] || exit 0
222
+ # 🟥 Do NOT use `IFS=$'\t' read` here: TAB is an IFS whitespace character, so consecutive
223
+ # separators MERGE and an empty field SHIFTS every later field left — a session with no
224
+ # session_id would silently read its KEYS as SESS ([[feedback_ifs_read_collapses_empty_fields]]).
225
+ # Positional splits cannot collapse. (An earlier draft said "exact-count validation, then
226
+ # positional splits"; the count is gone — see the next paragraph — and leaving that sentence
227
+ # would be a rule describing a machine it no longer has.)
228
+ # 🟥 VALIDATE WITH A PREFIX, NOT BY COUNTING — the count was a DoS that degraded into a BYPASS.
229
+ # The first build validated the field count with `_TABS="${_META//[!$'\t']/}"`. Measured on
230
+ # bash 3.2 (macOS /bin/bash, the shell this hook actually runs under): that ONE substitution
231
+ # takes **47.7s on a 10,000-char line**, and the whole hook went 0.23s @0.5KB → 6.6s @5KB →
232
+ # 49.7s @10KB. With `"timeout": 5` in settings the hook is KILLED, and a killed PreToolUse hook
233
+ # is a non-blocking error — **the tool proceeds**. So a long enough query walked straight past
234
+ # the guard: a bypass by PADDING, needing no override and leaving no row. The library was never
235
+ # the cost (psa_scan_tagged does the same 10,000 chars in 0.15s); the guard was.
236
+ # The extractor already builds the line and controls every field, so the invariant belongs
237
+ # there: it emits a magic first field, and bash checks a PREFIX (O(1)) instead of scanning the
238
+ # whole string. Positional splits are 0.02s at the same size. Anchored by lane H23.
239
+ case "$_META" in "OQH2"$'\t'*) ;; *) exit 0 ;; esac
240
+ _R0="${_META#*$'\t'}"
241
+ TOOL="${_R0%%$'\t'*}"; _R1="${_R0#*$'\t'}"
242
+ SESS="${_R1%%$'\t'*}"; _R2="${_R1#*$'\t'}"
243
+ KEYS="${_R2%%$'\t'*}"; _R3="${_R2#*$'\t'}"
244
+ FLAGS="${_R3%%$'\t'*}"; QTEXT="${_R3#*$'\t'}"
245
+
246
+ # ── 2. scope: only the two outbound tools, and nothing else is logged ──────────────────────────
247
+ case "$TOOL" in WebSearch|WebFetch) ;; *) exit 0 ;; esac
248
+
249
+ # ── 3. applicability — MECHANICAL, never self-judged (CLAUDE.md §Surface-Class Degrade) ────────
250
+ # The tracked defaults layer ships with this repo. Absent ⇒ this is not an FH-derived checkout,
251
+ # so the guard has no target here. `applicable-but-tooling-down` is handled below, not here.
252
+ # 🟥 THIS RUNS BEFORE THE OPT-OUT, and the order is load-bearing. With the opt-out first, a
253
+ # `# noqa: outbound` query in a NON-FH tree would try to write a log row, and the fail-closed
254
+ # rule below would then DENY a call this hook has no business judging at all — over-blocking a
255
+ # stranger's repo. Applicability first means the bypass rule only binds where the guard is live.
256
+ # N/A is silent AND unrecorded — an earlier build appended an `na` row whenever `.claude/` already
257
+ # existed, which is every Claude Code project, so a user-level hook wrote into stranger repos
258
+ # (codex finding 7, 2026-09-05). Lane H4b now asserts the file is NOT created.
259
+ [ -r "$DEFAULTS" ] || exit 0
260
+
261
+ # ── 4. opt-out, recorded ───────────────────────────────────────────────────────────────────────
262
+ if printf '%s' "$QTEXT" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*outbound' 2>/dev/null; then
263
+ # 🟥 THE RECORD IS THE PRICE OF THE BYPASS. This channel is gameable by design; what keeps it
264
+ # honest is that it leaves a row. If the row cannot be written, the bypass becomes INVISIBLE,
265
+ # and an unrecorded bypass on an irreversible surface is worse than no bypass at all — so the
266
+ # opt-out itself fails closed here. Every other verdict tolerates a lost log line.
267
+ _log noqa - && exit 0
268
+ _deny_unmeasured "opt-out(# noqa: outbound)을 기록할 수 없다 — 기록 못 하는 우회는 보이지 않는 우회다"
269
+ fi
270
+
271
+ # ── 5. nothing to scan ─────────────────────────────────────────────────────────────────────────
272
+ # 🟥 The extractor reports TRUNCATION (nesting deeper than 8, or more than 4096 string values) as a
273
+ # flag instead of scanning what it could reach — a token below the cut would leave as "clean"
274
+ # (codex findings 2·3). Unmeasured ⇒ deny, like every other incomplete-instrument branch. H26/H27.
275
+ case "$FLAGS" in *T*) _deny_unmeasured "입력 구조가 추출 한계(깊이 8 · 값 4096)를 넘어 일부를 못 봤다" ;; esac
276
+ # Emptiness is a bash pattern, not an external `tr`: on a PATH without `tr` the substitution came
277
+ # back empty and that read as "nothing to scan" = clean allow (codex finding 6). Lane H30.
278
+ case "$QTEXT" in *[![:space:]]*) ;; *) _log clean -; exit 0 ;; esac
279
+
280
+ # ── 6. instrument ──────────────────────────────────────────────────────────────────────────────
281
+ [ -r "$LIB" ] || _deny_unmeasured "스캐너 라이브러리 부재"
282
+ # shellcheck source=/dev/null
283
+ . "$LIB" 2>/dev/null || _deny_unmeasured "스캐너 라이브러리 로드 실패"
284
+ # Pin the allowlist root: without it psa_pair_allowlisted falls back to `git rev-parse` or `.`,
285
+ # which in a hook can resolve to a DIFFERENT repo than the one being scanned.
286
+ PSA_REPO_ROOT="$FH"; export PSA_REPO_ROOT
287
+
288
+ # 🟥 FLATTENING LIVES IN THE EXTRACTOR, NOT HERE — measured, and the belt was dead.
289
+ # psa_scan_tagged reads `path<TAB>body` per LINE, so a newline+tab in the query would push the
290
+ # token into the PATH field and out of the scan (the bypass test_outbound_query_lanes.sh L9
291
+ # pins for the CLI). A `tr` belt stood here; a revert probe removed it and lane H8 stayed
292
+ # GREEN (mutant M7) — the extractor's `re.sub` had already done it. Removing THAT (M8) turned
293
+ # H8 into `none`, i.e. SILENT pass-through, because the 4-line read protocol below also
294
+ # depends on the text being one line. One owner, anchored by H8. The duplicate is deleted
295
+ # rather than kept as reassurance ([[feedback_anchor_can_be_decorative]]).
296
+ QFLAT="$QTEXT"
297
+
298
+ _load_and_check() { # $1=defaults $2=override ; state via case, never [ -ne ] (non-numeric → fail-open)
299
+ psa_load "$1" "$2" >/dev/null 2>&1 || true
300
+ case "${PSA_DEFAULTS_OK:-}" in 1) ;; *) return 1 ;; esac
301
+ case "${PSA_BAD_ROWS:-}" in 0) ;; *) return 2 ;; esac
302
+ return 0
303
+ }
304
+
305
+ _load_and_check "$DEFAULTS" "$OVERRIDE"; _lc=$?
306
+ case "$_lc" in
307
+ 0) ;;
308
+ 1) _deny_unmeasured "공용 패턴층(defaults)이 비었거나 못 읽는다" ;;
309
+ *) _deny_unmeasured "패턴 파일에 형식오류 행이 있어 검출기 집합이 부분적이다" ;;
310
+ esac
311
+ OVR="${PSA_OVERRIDE_PRESENT:-0}"
312
+ psa_require_live >/dev/null 2>&1 || _deny_unmeasured "psa_require_live 실패 — 스캐너가 알려진 토큰도 못 잡는다"
313
+
314
+ if FULL="$(printf 'outbound-query\t%s' "$QFLAT" | psa_scan_tagged 2>&1)"; then FRC=0; else FRC=$?; fi
315
+ case "$FRC" in 0|1) ;; *) _deny_unmeasured "스캐너가 측정값을 안 냈다 (rc=$FRC)" ;; esac
316
+ printf '%s' "$FULL" | grep -qF 'INSTRUMENT DEAD' 2>/dev/null && _deny_unmeasured "스캐너가 INSTRUMENT DEAD 를 보고했다"
317
+ N_FULL="$(_hits "$FULL")"
318
+
319
+ # ── 7. defaults-only re-scan → which LAYER produced the hits ───────────────────────────────────
320
+ if [ "$OVR" = 1 ]; then
321
+ _load_and_check "$DEFAULTS" "$NO_OVERRIDE"; _lc=$?
322
+ [ "$_lc" -eq 0 ] || _deny_unmeasured "defaults 전용 재로드 실패 (층 판별 불가)"
323
+ if DEFONLY="$(printf 'outbound-query\t%s' "$QFLAT" | psa_scan_tagged 2>&1)"; then DRC=0; else DRC=$?; fi
324
+ case "$DRC" in 0|1) ;; *) _deny_unmeasured "defaults 전용 스캔이 측정값을 안 냈다 (rc=$DRC)" ;; esac
325
+ N_DEF="$(_hits "$DEFONLY")"
326
+ else
327
+ DEFONLY="$FULL"; N_DEF="$N_FULL"
328
+ fi
329
+ # ⚠️ UNANCHORED, DELIBERATELY KEPT (measured 2026-09-05, mutant M11): removing both lines leaves
330
+ # all 64 lanes green. The only realistic way `_hits` returns a non-number is a broken grep, and
331
+ # `psa_require_live` above dies on that first — so this branch is unreachable today. Kept as the
332
+ # guard for a refactor that removes the canary; labelled, because an unlabelled unreachable
333
+ # guard reads as a control it is not ([[feedback_anchor_can_be_decorative]]).
334
+ case "$N_FULL" in ''|*[!0-9]*) _deny_unmeasured "히트 계수 실패(full)" ;; esac
335
+ case "$N_DEF" in ''|*[!0-9]*) _deny_unmeasured "히트 계수 실패(defaults)" ;; esac
336
+ # A superset cannot produce fewer hits than its subset. If it did, the two scans did not see the
337
+ # same instrument — that is an anomaly, and an anomaly on an irreversible surface is a deny.
338
+ [ "$N_FULL" -ge "$N_DEF" ] || _deny_unmeasured "층 비교 이상 (full=$N_FULL < defaults=$N_DEF)"
339
+
340
+ # ── 8. once-per-session UNCALIBRATED notice (consumer installs) ────────────────────────────────
341
+ _uncal_due() {
342
+ local key cur
343
+ key="${SESS:-}"; [ -n "$key" ] || key="$(date -u +%Y-%m-%d)"
344
+ cur=""; [ -r "$SENTINEL" ] && cur="$(cat "$SENTINEL" 2>/dev/null || true)"
345
+ [ "${cur%% *}" = "$key" ] && return 1
346
+ mkdir -p "$EVDIR" 2>/dev/null || true
347
+ # noqa: degrade — a failed sentinel write makes the UNCALIBRATED notice REPEAT on every call.
348
+ # That degrades toward more noise, never toward silence, so the permissive short-circuit is in
349
+ # the safe direction here. (Contrast `_log` on the noqa path, where the same shape was NOT safe
350
+ # and was changed to fail closed.)
351
+ printf '%s %s\n' "$key" "$(date -u +%Y-%m-%d)" > "$SENTINEL" 2>/dev/null || true # noqa: degrade (see above)
352
+ return 0
353
+ }
354
+ UNCAL=""
355
+ if [ "$OVR" != 1 ]; then
356
+ if _uncal_due; then
357
+ UNCAL=" 🟥 UNCALIBRATED — 운영자 내부 패턴층(.claude/rules/.public-surface-patterns)이 이 설치에 없다. 공용 층만 본 결과라 «내부 토큰 없음»은 말할 수 없다(미측정). 이 고지는 세션당 1회."
358
+ fi
359
+ fi
360
+
361
+ # ── 9. verdict ─────────────────────────────────────────────────────────────────────────────────
362
+ if [ "$N_FULL" -eq 0 ]; then
363
+ _log clean -
364
+ exit 0
365
+ elif [ "$N_FULL" -gt "$N_DEF" ]; then
366
+ _log deny override
367
+ _emit deny "🚫 outbound 차단 — 나가는 질의에 «운영자 내부 패턴층» 히트 $N_FULL 건 (심각도: $(_sev_summary "$FULL")). 🟥 토큰 값은 여기 적지 않는다 — 유출 가드가 유출 채널이 되면 안 되니까. 나간 질의는 되돌릴 수 없다. 고쳐라: 내부 이름 대신 그 이름이 가리키는 «형태»를 일반 어휘로 써라 (예: «우리 X 게이트가…» → «커밋 전에 근거 필드를 강제하는 훅 패턴»). 확인 후 강행: 질의에 '# noqa: outbound' 를 넣어라 — 통과하지만 로그에 남는다."
368
+ exit 0
369
+ else
370
+ _log advisory defaults
371
+ _emit advisory "⚠️ outbound 위생 — 나가는 질의가 «공용(defaults)» 패턴에 $N_DEF 건 걸린다 (심각도: $(_sev_summary "$DEFONLY")). 차단은 안 한다: 공용 층은 보편 형태라 오탐이 잦고, 여기서 막으면 훅을 끄게 만든다. 토큰 값은 적지 않는다 — 질의를 사람 눈으로 한 번 보고, 내부 이름이면 일반 어휘로 바꿔 다시 불러라.$UNCAL"
372
+ exit 0
373
+ fi
@@ -39,18 +39,33 @@ cd "$REPO_ROOT" || exit 1
39
39
  # scripts/sync_to_be_lanes.sh — forward-path lane suite for sync-to-be.sh, itself
40
40
  # ACCEPTED_ABSENT above; added 2026-08-14, pmh-dev#69.
41
41
  ACCEPTED_ABSENT=(
42
- # 🟥 나가는 질의 위생 린트와레인 **일부러 출하하지 않는다.**
43
- # 가드는 운영자 내부 패턴층(`.claude/rules/.public-surface-patterns`, gitignored)이 없으면
44
- # fail-closed 막는다. 층은 소비자에게 나가므로, 출하하면 **신선한 설치는 100% 차단**된다
45
- # CLAUDE.md «모든 install 막는 게이트는 엄격한 게이트가 아니라 우회 훈련기» 라 못박은
46
- # 형태다. 소비자용으로 열려면 override 부재 시의 degrade 를 따로 설계해야 하고, 안 했다.
47
- # `selfcheck.sh` 대상 부재 `_absent_subject_verdict` 빠지므로 패키지 모드에서 옳게 degrade 한다.
42
+ # 🟥 2026-09-05 outbound 가드와레인은 **이 목록에서 나갔다(= 이제 출하한다).**
43
+ # 종전 사유는 «override 층이 없으면 fail-closed 신선 설치를 100% 차단한다» 였는데,
44
+ # 문장은 **두 가지를 뭉쳤다**: 가드가 fail-closed 것은 맞지만 **신선 설치에서 그 가드를
45
+ # 부르는 것이 아무것도 없었다**(호출부 0개 그게 파일의 존재 이유였다). selfcheck
46
+ # 도는 것은 가드가 아니라 **레인**이고, 레인은 픽스처 주입점으로만 돈다.
47
+ # 실제로 막고 있던 것은 다른 것이었고, 실측으로 잡았다: 레인 L4 로그 경로를 박아 둬서
48
+ # `tracks/` 가 없는 트리(= npm 설치본)에서 «+1» 단언이 0 이 되어 **빨개졌다.** 그 한 줄을
49
+ # 주입점으로 바꾸자(OUTBOUND_QUERY_LOG) 소비자 형태 트리에서 11/11 초록이다.
50
+ # 이제 출하하는 이유: `scripts/outbound_query_hook.sh`(PreToolUse WebSearch|WebFetch)가
51
+ # 소비자에게 나가는데, 그 훅은 **WebSearch/WebFetch 만** 덮는다. Bash 로 나가는 질의
52
+ # (curl·외부 CLI)는 사람이 손으로 이 CLI 를 부르는 것 말고 커버가 없다 — 훅만 내보내고
53
+ # 가드를 빼면 소비자에게 «반쪽만» 준다.
54
+ # ⚠️ 정직한 성질 하나: override 층이 없는 소비자가 이 CLI 를 직접 부르면 exit 3(미측정)이다.
55
+ # 쓰려면 `.claude/rules/.public-surface-patterns` 를 만들어야 한다. 훅 쪽은 그 상태에서
56
+ # advisory + UNCALIBRATED 로 degrade 하므로 **소비자 세션이 막히지는 않는다.**
48
57
  # 🟥 2026-09-01 — 아래 여섯은 «출하 문서가 이름을 대지만 selfcheck 가 실행하지 않는» 것들이다.
49
58
  # 판별은 실측이다: `bash <경로>` 형태의 호출을 selfcheck 에서 센 결과 watermark 만 3회이고
50
59
  # 나머지는 0회다 — 그래서 watermark 는 files[] 로 갔고 이 여섯은 여기 남는다.
51
60
  # 🟥 소비자가 «필요로 하지 않는다»가 아니라 «이 레포의 연구·감사 계기다»가 이유다.
52
61
  # 출하하면 소비자 트리에 안 도는 스크립트가 늘고, 그게 orphan 스캔의 소음이 된다.
53
62
  "scripts/stray_path_scan.sh" # 이 레포 경로 위생 스캐너 — 소비자 트리 구조가 다르다
63
+ # 🟥 2026-09-05 — docs/map 은 레포·Pages 표면이다(렌더 산출물 ~4 MB + «노드 = 실재 경로» 계약이
64
+ # .github/·tests/ 등 출하하지 않는 경로를 가리킨다). npm 에는 README 의 포인터만 나간다 —
65
+ # 지도를 출하하면 그 레인이 소비자 트리에서 «부재」로 빨개지고, 그건 결함이 아니라 표면 차이다.
66
+ "docs/map/FH_MAP.md" # 리더용 지도 산문 — 레포/Pages 에서 읽는다
67
+ "docs/map/fh_assets.architecture.json" # 지도 소스(노드 = 레포 경로 계약) — 이 레포 계기의 입력
68
+ "scripts/test_fh_map_paths_lanes.sh" # 그 계약의 레인 — 이 레포에서만 돈다
54
69
  "scripts/test_stray_path_lanes.sh" # 위 레인
55
70
  "scripts/test_fixture_guard_lanes.sh" # 픽스처 가드 레인 — 가드 자체(fixture_guard_lib)는 출하한다
56
71
  "scripts/test_marker_first_use_lanes.sh" # 4축 마커 first-use 레인 — 이 레포 마커 규약 전용
@@ -66,9 +81,9 @@ ACCEPTED_ABSENT=(
66
81
  "scripts/round/gatecheck_qset.sh" # 같은 이유 — 회차 개시 게이트, 소비자 표면 아님 (2026-09-02 짝표 등재로 참조가 생겼다)
67
82
  "scripts/test_round_instruments_lanes.sh"
68
83
  "scripts/fixtures/isolation_assembly_BROKEN_2026-08-30_ccrun7.json" # 역사 산출물(등급표가 증거로 인용)
69
- "scripts/outbound_query_guard.sh"
70
- "scripts/test_outbound_query_lanes.sh"
71
- # 🟥 영혼(judgment-circuit) 3종 **일부러 출하하지 않는다.** 위 outbound 가드와 같은 이유다.
84
+ # 🟥 영혼(judgment-circuit) 3종 — **일부러 출하하지 않는다.**
85
+ # (종전 «위 outbound 가드와 같은 이유»라는 포인터는 2026-09-05 에 끊겼다 — outbound 는 출하로
86
+ # 갔다. 여기 사유는 그것과 무관하게 아래 자기 근거로 선다.)
72
87
  # 이 셋은 `.claude/soul_tenets.txt`(=**이 레포의** 심지 원칙 등록부)를 전제로 하는데, 그 파일은
73
88
  # 소비자에게 안 나간다 — 소비자의 tenet 은 소비자가 쓰는 것이지 우리가 주는 것이 아니다
74
89
  # (`.claude/regression/probes.md` 가 이미 같은 논리로 미출하다).
@@ -85,7 +100,10 @@ ACCEPTED_ABSENT=(
85
100
  "scripts/soul_trace.sh"
86
101
  # 🟥 sim 경로격리 레인 — 출하 안 한다. `sim_isolated_run.sh` 는 이 레포의 측정 도구이고
87
102
  # 이 레인은 «그 러너가 클론에 무엇을 써넣는가»를 본다. 소비자에게 그 러너가 없으면
88
- # 레인이 HARNESS-ERROR 로 죽고, 그건 신선 설치를 막는 형태가 된다(위 outbound 와 동일 논리).
103
+ # 레인이 HARNESS-ERROR 로 죽고, 그건 신선 설치를 막는 형태가 된다.
104
+ # ⚠️ 종전에 여기 «위 outbound 와 동일 논리» 라는 포인터가 있었는데 2026-09-05 에 끊었다 —
105
+ # outbound 는 출하로 갔고, 그 블록의 종전 사유는 실측으로 물러졌다. 이 블록의 사유는
106
+ # 그것과 무관하게 **자기 자신으로 선다**: 여기서는 대상(러너)이 소비자 트리에 정말 없다.
89
107
  "scripts/test_sim_path_isolation_lanes.sh"
90
108
  ".claude/registry/LOCAL_SKILL_REGISTRY.md"
91
109
  ".claude/regression/probes.md"
@@ -193,18 +211,39 @@ ACCEPTED_ABSENT=(
193
211
  # 경로로 적는데 **이 파일 자신이 shipped 문서**라, 「안 싣는 이유」를 설명한 행위가
194
212
  # 곧 「shipped 문서가 미출하 경로를 가리킴」이 됐다. 계기가 자기 수리를 잡은 것이고,
195
213
  # 위 블록이 예고한 형태(*전체 경로를 shipped 파일에 쓰면 팬텀이 된다*)의 **3차 재현**이다.
196
- # 주체 자신도 같은 사유로 등재한다 신규 게이트 둘은 **이 레포 전용**이다(추가 파일을
197
- # 레포 레인 코퍼스에 대고 판정한다). 소비자 트리엔 판정 대상이 없다.
198
- "scripts/residency_closure_scan.py"
199
- "scripts/test_residency_closure_lanes.sh"
214
+ # (이 문단이 이어서 「주체 자신도 같은 사유로 등재한다」고 residency_closure_scan.py
215
+ # 여기 넣었었다 등재는 2026-09-05 **철회했다**. 아래 참조.)
216
+ # 🟥 **철회 (2026-09-05) — `residency_closure_scan.py` / `test_residency_closure_lanes.sh` 가
217
+ # ACCEPTED_ABSENT 였던 이유(「이 레포 전용 감사 도구, 소비자 트리엔 판정 대상 없음」)는
218
+ # 운영자 승인 하에 뒤집혔다: `plugins/fh-meta/skills/auto-decorrelation/SKILL.md` §Step 4.5
219
+ # 가 이제 **소비자의 세션**더러 cross-family dispatch 직전마다 이 스캐너를 돌리라고 지시하고,
220
+ # `templates/.git-hooks/pre-commit` 의 `validate_crossfamily_leg` 가 그 결과(`residency=
221
+ # CLEAN|TAINTED|NOT_SCANNED(...)`)를 `crossfamily:` 근거 안 타입 토큰으로 검사한다
222
+ # (`RESIDENCY_TOKEN_GRACE_DATE`, 소급 없음). 즉 「이 레포 자신의 커밋 이력을 감사하는 도구」
223
+ # 에서 「모든 소비자가 실제로 실행하는 배선의 일부」로 성격이 바뀌었다 — 안 실으면 shipped
224
+ # SKILL.md 가 소비자에게 없는 스크립트를 돌리라고 지시하는 꼴이 되고, 그건 이 검사기 자신이
225
+ # 막으려는 바로 그 결함(«출하 문서가 미출하 경로를 가리킴»)이다.
226
+ # ⚠️ **패턴층도 같이 실었다** — `.claude/rules/.residency-patterns.defaults`
227
+ # (일반형 패턴만, 회사 리터럴 없음; gitignored 운영자 override `.residency-patterns` 는
228
+ # 여전히 미출하). 이게 없으면 스캐너는 "defaults 패턴 파일이 없다" 로 exit 10 — 스캐너
229
+ # 자신의 **의도된** fail-closed(운영자 override 부재)보다 더 나쁜, 설치 결함성 원인으로
230
+ # 신선 설치 100% 를 막는 형태였다. `.public-surface-patterns.defaults`(바로 위 줄)와 같은
231
+ # 2층 패턴 관례를 그대로 따른다.
232
+ # 남은 사실: 운영자 override 없는 신선 설치는 여전히 exit 10 이 **기본**이다 — 이건 결함이
233
+ # 아니라 스캐너 자신의 문서화된 설계이고(§Step 4.5 가 명시), auto-decorrelation 은 그 결과를
234
+ # `residency=NOT_SCANNED(...)` 로 정직하게 적고 `DEGRADED_*` 로 내려간다 — panel(...) 을
235
+ # 조용히 못 쓰게 될 뿐 커밋을 막지는 않는다.
200
236
  # ── caller-zero-ratchet, 2026-08-22. 셋 다 **싣는 것이 틀렸다** — 「아직 안 실었다」가 아니다 ──
201
237
  # 이 게이트는 「production 스크립트에 디스패처가 있나」를 **이 레포의 러너 표면**에 대고 판정한다.
202
238
  # 그 표면의 하나가 `.github/workflows/**` 이고, **워크플로 디렉터리는 files[] 에 없다.** 그러므로
203
239
  # 소비자 트리에서 이 검사기를 돌리면 워크플로에서만 디스패치되는 스크립트들이 전부 caller 0 으로
204
240
  # 떨어지고, 그것들은 소비자의 `caller_zero_baseline.txt` 에 선언돼 있지 않으므로 **UNDECLARED →
205
241
  # exit 1**. 즉 출하하면 **모든 신선한 설치에서 100% 적색**이다 — CLAUDE.md 가 「모든 새 install 을
206
- # 막는 게이트는 엄격한 게이트가 아니라 우회 훈련기」라고 못박은 그 형태이고, 바로 위
207
- # outbound_query_guard 블록과 **같은 사유**다.
242
+ # 막는 게이트는 엄격한 게이트가 아니라 우회 훈련기」라고 못박은 그 형태다.
243
+ # ⚠️ 종전에 여기 «바로 위 outbound_query_guard 블록과 같은 사유» 라고 적혀 있었다. 2026-09-05
244
+ # 에 끊는다 — outbound 는 출하로 갔고, 그쪽 사유는 «가드를 부르는 것이 없었다»는 사실 확인으로
245
+ # 물러졌다. **이 블록의 사유는 그대로 유효하다**: 여기는 검사기가 실제로 소비자 트리에서
246
+ # 돌면서 100% 적색을 낸다(위 outbound 는 애초에 아무도 안 불렀다). 같은 결론, 다른 기전.
208
247
  # 앵커도 같이 안 나간다. 주체 부재라는 짝 규칙 말고 **자기 자신의 사유가 하나 더 있다**: 마지막
209
248
  # 레인(L27)이 `.github/workflows/caller-zero-ratchet.yml` 을 읽어 워크플로가 리졸버를 인라인으로
210
249
  # 되돌리지 않았는지 본다. 그 파일이 없는 트리에서 그 레인은 **부재를 결함으로 읽어 적색**이 된다.
@@ -138,14 +138,54 @@ if ! printf '%s' "$NORM" | grep -qE 'set -o pipefail|set -[a-zA-Z]*o[a-zA-Z]* pi
138
138
  # Branch (b)'s optional `([^|&]*;)?` prefix keeps `| (sort; tail -5); echo $?` caught — the
139
139
  # filter need not be the group's FIRST command, only its LAST — while the `;` requirement keeps
140
140
  # the filter at a statement start, so `| (echo cat); rc=$?` does not match through a bare word.
141
+ # 🟥 «바로 다음 문장» 으로 좁힌다 (2026-09-06, 운영자 실사용 신고 + known-pair 재현).
142
+ # 종전 꼬리는 `[;&].*\$\?` 라 파이프 뒤 **어디든** `$?` 가 있으면 잡았다 — 그게 **다른
143
+ # 명령의** `$?` 여도. 한 호출에 문장이 여럿인 실사용 커맨드는 거의 항상 걸린다.
144
+ # 실측 4팔: ①`out=$(c); rc=$?; echo "$out"|tail` 안 터짐(정상) ②`c|tail; echo $?` 터짐
145
+ # (known-positive) ③표시전용 안 터짐 ④`out=$(a); rc=$?; echo "$out"|tail; b; echo $?`
146
+ # **터짐 = 오탐**. ④의 `$?` 는 `b` 의 것이라 안 잡는 것이 옳다.
147
+ # ⇒ 꼬리를 `[;&]&?[^;&|]*\$\?` 로 — 파이프라인 **직후 한 문장** 안에서만 읽는다.
148
+ # `&?` 는 `cmd | tail && echo $?`(진짜 결함)를 살리기 위한 것이다.
149
+ # 이 좁힘의 재현율 손실은 사실상 0 이다: 사이에 명령이 끼면 `$?` 는 그 명령의 것이므로
150
+ # 원래 **잡으면 안 되는** 자리다. 훅 자기 주석이 «100% FP 는 정작 중요한 한 건을
151
+ # 무시하도록 훈련시킨다» 고 적었고, 운영자 신고가 정확히 그 신호였다.
141
152
  _F='(tail|head|cat|less|more)'
142
153
  if printf '%s' "$NORM" \
143
- | grep -qE "\|&?[[:space:]]*(${_F}([[:space:]][^|;&]*)?|[({]([^|&]*;)?[[:space:]]*${_F}([[:space:]][^|;&()}]*)?[[:space:]]*;?[[:space:]]*[)}])[[:space:]]*[;&].*\\\$\?"; then
154
+ | grep -qE "\|&?[[:space:]]*(${_F}([[:space:]][^|;&]*)?|[({]([^|&]*;)?[[:space:]]*${_F}([[:space:]][^|;&()}]*)?[[:space:]]*;?[[:space:]]*[)}])[[:space:]]*[;&]&?[^;&|]*\\\$\?"; then
144
155
  add "R2 \$? after a display filter" \
145
156
  "\$? holds the filter's status (tail/head/cat almost always succeed), not the command's — a FAILED check reads as 0. Capture first: \`out=\$(cmd 2>&1); rc=\$?\` then print \"\$out\" | tail."
146
157
  fi
147
158
  fi
148
159
 
160
+ # ── R3 — a line that is ONLY redirections (`2>&1` alone on the line after a heredoc). ─────────
161
+ # zsh runs a redirection-only line as `$NULLCMD` (= `cat` by default) — measured 2026-09-04 with
162
+ # `zsh -x`: `+zsh:1> cat`. That `cat` reads STDIN until EOF. In this Bash tool, stdin is `/dev/null`
163
+ # ONLY when the harness appends `< /dev/null`, and it does NOT append it when the top-level command
164
+ # already carries a stdin redirect (`<` or a `<<` heredoc) — then stdin is a pipe held open for the
165
+ # life of the command, and `cat` never returns. Measured 2026-09-04: 9 tasks in two sessions, all of
166
+ # the shape `out=$(git commit -q -F - <<'CEOF' … CEOF ⏎ 2>&1)`: every commit landed within 4 s, no
167
+ # push ever started, the shell sat in `$( )` with no git child, killed later (exit 144). The
168
+ # pre-push hook never ran — it was blamed for a hang that happened before it.
169
+ # Heredoc BODIES are stripped first: a one-word markdown blockquote line (`> 정본`) inside a commit
170
+ # message is data, not a redirection. Deterministic on the remaining lines; zero-FP by construction
171
+ # (a redirection-only line is never what the author meant outside a heredoc body).
172
+ _R3_HIT=$(printf '%s\n' "$CMD" | awk '
173
+ BEGIN { inhd = 0; d = "" }
174
+ inhd { if ($0 == d || $0 ~ ("^[[:space:]]*" d "$")) { inhd = 0 }; next }
175
+ {
176
+ if (match($0, /<<-?[[:space:]]*["'"'"']?[A-Za-z_][A-Za-z_0-9]*["'"'"']?/)) {
177
+ d = substr($0, RSTART, RLENGTH); sub(/^<<-?[[:space:]]*/, "", d); gsub(/["'"'"']/, "", d); inhd = 1
178
+ }
179
+ # a STATEMENT at line start made only of redirections, ended by `)`, `;`, `}`, `&&`, `||` or EOL —
180
+ # the measured shape is `2>&1); rc=$?`, where `)` closes the `$( )` and the last statement inside
181
+ # it is the bare `2>&1`.
182
+ if ($0 ~ /^[[:space:]]*[0-9]*(>>?|<)(&[0-9]+|[[:space:]]*[^[:space:];|&<>()]+)?([[:space:]]+[0-9]*(>>?|<)(&[0-9]+|[[:space:]]*[^[:space:];|&<>()]+)?)*[[:space:]]*([;)}]|&&|\|\||$)/) { print NR ": " $0; exit }
183
+ }')
184
+ if [ -n "$_R3_HIT" ]; then
185
+ add "R3 redirection-only line runs \`cat\` on stdin (line ${_R3_HIT})" \
186
+ "zsh executes a line that is only redirections as \$NULLCMD=cat, which reads stdin to EOF. This command carries a top-level \`<\`/\`<<\`, so the harness leaves stdin as an OPEN PIPE — that cat never returns and everything after it (the push) never starts. Put the redirection on the command line: \`out=\$(git commit -q -F - 2>&1 <<'"'"'CEOF'"'"'\`."
187
+ fi
188
+
149
189
  [ -n "$hits" ] || exit 0
150
190
 
151
191
  if [ "${FH_PIPE_VERDICT_BLOCK:-0}" = "1" ]; then