@chrono-meta/fh-gate 1.4.89 → 1.4.91

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 (47) hide show
  1. package/.claude/judgment_circuits.txt +14 -0
  2. package/.claude/rules/fh_4axis_gate.md +7 -0
  3. package/.claude-plugin/marketplace.json +2 -2
  4. package/AGENTS.md +25 -0
  5. package/CLAUDE.md +202 -12
  6. package/knowledge/shared/harness-core/dispatch_conditional_prohibition.md +105 -0
  7. package/knowledge/shared/harness-core/fh_three_layer_canon.md +307 -0
  8. package/knowledge/shared/harness-core/harness_incubator_doctrine.md +100 -0
  9. package/knowledge/shared/harness-core/onboarding_acceleration_autopilot.md +3 -1
  10. package/knowledge/shared/harness-core/ship_readiness_gate.md +181 -13
  11. package/knowledge/shared/learnings/subagent_invocations_log.yaml +640 -0
  12. package/knowledge/shared/rules/multi_session_close_protocol.md +118 -0
  13. package/package.json +23 -2
  14. package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
  15. package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
  16. package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +56 -8
  17. package/plugins/fh-meta/skills/install-wizard/SKILL.md +33 -0
  18. package/plugins/fh-meta/skills/install-wizard/SKILL_detail.md +104 -15
  19. package/scripts/branch_claim.sh +266 -0
  20. package/scripts/chamber_run.sh +64 -2
  21. package/scripts/chamber_witness.sh +439 -0
  22. package/scripts/compaction_probe.sh +456 -0
  23. package/scripts/digest_landing_check.sh +385 -0
  24. package/scripts/directional_diff_gate.sh +459 -0
  25. package/scripts/fh_env_delta_scan.sh +15 -0
  26. package/scripts/fh_session_load.sh +34 -1
  27. package/scripts/field_canon_preload.sh +129 -0
  28. package/scripts/hook_source_lib.sh +41 -0
  29. package/scripts/judgment_circuit_lint.sh +239 -0
  30. package/scripts/novelty_claim_check.sh +193 -0
  31. package/scripts/relay_channel.sh +645 -0
  32. package/scripts/reviewer_capability_corpus.tsv +124 -0
  33. package/scripts/selfcheck.sh +106 -0
  34. package/scripts/session_close_check.sh +134 -1
  35. package/scripts/test_branch_claim_lanes.sh +231 -0
  36. package/scripts/test_dispatch_log_lanes.sh +35 -1
  37. package/scripts/test_field_canon_lanes.sh +142 -0
  38. package/scripts/test_hook_source_gate_lanes.sh +81 -0
  39. package/scripts/test_marker_crossfamily_lanes.sh +132 -0
  40. package/scripts/test_marker_floor_lanes.sh +9 -8
  41. package/scripts/test_relay_channel_lanes.sh +583 -0
  42. package/scripts/test_reviewer_capability_conformance.sh +173 -0
  43. package/scripts/test_wizard_snippet_merge_lanes.sh +104 -11
  44. package/scripts/utterance_landing_check.sh +209 -0
  45. package/templates/.git-hooks/pre-commit +297 -13
  46. package/templates/settings.Compaction.snippet.json +56 -0
  47. package/templates/settings.FieldCanon.snippet.json +51 -0
@@ -0,0 +1,173 @@
1
+ #!/usr/bin/env bash
2
+ # test_reviewer_capability_conformance.sh — runs the SHARED corpus against THIS repo's
3
+ # reviewer-capability implementation and reports every disagreement.
4
+ #
5
+ # THE CONTRACT, and why it is shaped this way. Three repos judge "can this model be an
6
+ # adversarial reviewer?" in three languages (bash hook · shell probe · python backend).
7
+ #
8
+ # Be precise about WHY they do not share a library, because the first draft of this comment
9
+ # overstated it and a cross-family review caught the inversion: residency blocks the
10
+ # PRIVATE→PUBLIC direction (internal model ids must never reach a public repo). It does NOT
11
+ # stop a private repo from vendoring a sanitized PUBLIC library. The actual reasons are
12
+ # (a) three languages/runtimes, and (b) the classifier is two regexes — a shared library
13
+ # would add more coupling than the thing it shares. Residency governs the CORPUS's contents,
14
+ # not the decision to share data instead of code.
15
+ #
16
+ # What they share is `scripts/reviewer_capability_corpus.tsv`
17
+ # — pure data, residency-safe. Each side keeps its own implementation and vendors this
18
+ # script plus the corpus; drift then surfaces as a red test in whichever repo drifted,
19
+ # instead of as a silent difference nobody measures.
20
+ #
21
+ # Measured before this existed: 19 ids, 10 disagreements between two implementations, 9 of
22
+ # them the private side reading INCAPABLE as CAPABLE. That drift had shipped and was
23
+ # invisible — nothing compared the two.
24
+ #
25
+ # ── PORTING THIS TO ANOTHER REPO (the adapter is the only thing you write) ──────
26
+ # Copy this file + the corpus, then replace classify() with a call into your own code:
27
+ # shell : classify(){ your_probe.sh --check-model "$1" >/dev/null 2>&1 && echo CAPABLE || echo INCAPABLE; }
28
+ # python: classify(){ python3 -c 'import sys;from x import is_reviewer_capable;
29
+ # print("CAPABLE" if is_reviewer_capable(sys.argv[1]) else "INCAPABLE")' "$1"; }
30
+ # Everything else — corpus parsing, verdict comparison, the UNDECIDABLE rule — stays.
31
+ #
32
+ # ── THE UNDECIDABLE ROWS ARE THE POINT ─────────────────────────────────────────
33
+ # An implementation with only a denylist answers CAPABLE for an unrecognised id. That is
34
+ # fail-open: a denylist cannot know `voyage-3` is an embedding model. Such an implementation
35
+ # FAILS these rows, and that failure is correct — it is telling you to add a default-deny
36
+ # leg, not to edit the corpus. A repo that genuinely cannot express UNDECIDABLE may set
37
+ # ALLOW_UNDECIDABLE_AS_INCAPABLE=1, which is strictly-safer and reported as a named
38
+ # deviation rather than a silent pass.
39
+ #
40
+ # Usage: bash scripts/test_reviewer_capability_conformance.sh Exit: 0 = conforms; 1 = drift.
41
+
42
+ set -uo pipefail
43
+ REPO_ROOT="$(git rev-parse --show-toplevel 2>/dev/null || pwd)"
44
+ CORPUS="${CORPUS:-$REPO_ROOT/scripts/reviewer_capability_corpus.tsv}"
45
+ HOOK="$REPO_ROOT/templates/.git-hooks/pre-commit"
46
+ LENIENT="${ALLOW_UNDECIDABLE_AS_INCAPABLE:-0}"
47
+
48
+ [ -f "$CORPUS" ] || { echo "🟥 HARNESS-ERROR: corpus not found at $CORPUS"; exit 10; }
49
+
50
+ # ── Contract pin: am I reading THE corpus, or a drifted local copy? ─────────────
51
+ # The single shared artifact lives in N repos as N copies, so the first failure mode is not
52
+ # a wrong verdict — it is each side quietly grading itself against its own stale copy while
53
+ # every instrument reports healthy. Verdict on mismatch is HARNESS_ERROR (exit 10), which is
54
+ # neither PASS nor FAIL: nothing was measured against the contract.
55
+ PINNED=$(grep -m1 '^#CORPUS-SHA256' "$CORPUS" | awk '{print $2}')
56
+ ACTUAL=$(grep -E '^[^#[:space:]]' "$CORPUS" | shasum -a 256 2>/dev/null | cut -c1-16)
57
+ [ -z "$ACTUAL" ] && ACTUAL=$(grep -E '^[^#[:space:]]' "$CORPUS" | sha256sum 2>/dev/null | cut -c1-16)
58
+ if [ -z "$PINNED" ] || [ -z "$ACTUAL" ]; then
59
+ echo "🟥 HARNESS-ERROR: cannot verify the corpus pin (pinned='$PINNED' actual='$ACTUAL')."
60
+ echo " An unverifiable pin is not a passing pin — refusing to grade against an unknown corpus."
61
+ exit 10
62
+ fi
63
+ if [ "$PINNED" != "$ACTUAL" ]; then
64
+ echo "🟥 HARNESS-ERROR: corpus drift — pinned=$PINNED actual=$ACTUAL"
65
+ echo " This copy's data rows differ from the contract it names. Either re-sync from the"
66
+ echo " canonical corpus, or bump #CORPUS-VERSION + #CORPUS-SHA256 in EVERY repo that"
67
+ echo " vendors it. A local-only edit is how the shared contract silently stops being shared."
68
+ exit 10
69
+ fi
70
+
71
+ # ── Namespace declaration: what THIS consumer owns (OPA-bundle `roots` shape) ───
72
+ # This consumer composes PANELS, so it judges sidecar/CLI names and model ids alike.
73
+ # A consumer that only classifies model ids declares NS="model-id" and its skipped rows are
74
+ # REPORTED below, never silently counted as passing.
75
+ NS="${NS:-cli model-id}"
76
+
77
+ # ── THIS repo's adapter: the two regexes inside validate_crossfamily_leg ────────
78
+ # Sourced FROM the hook rather than restated, so this test cannot drift from the thing it
79
+ # is testing. A restated copy would be a fourth implementation — the exact defect class
80
+ # this file exists to measure.
81
+ DENY=$(grep -m1 -oE "grep -qiE 'embed\|[^']*'" "$HOOK" | sed -E "s/^grep -qiE '//; s/'$//")
82
+ ALLOW=$(grep -m1 -oE "grep -qiE 'codex\|[^']*'" "$HOOK" | sed -E "s/^grep -qiE '//; s/'$//")
83
+ if [ -z "$DENY" ] || [ -z "$ALLOW" ]; then
84
+ echo "🟥 HARNESS-ERROR: could not extract the classifier from $HOOK"
85
+ echo " (deny='$DENY' allow='$ALLOW') — refusing to measure against an empty pattern,"
86
+ echo " which would report every row as agreeing."
87
+ exit 10
88
+ fi
89
+
90
+ classify() { # $1 = model id → CAPABLE | INCAPABLE | UNDECIDABLE
91
+ # Ineligible-first: the overlap rows depend on this order and exist to catch its inversion.
92
+ if printf '%s' "$1" | grep -qiE "$DENY"; then echo INCAPABLE
93
+ elif printf '%s' "$1" | grep -qiE "$ALLOW"; then echo CAPABLE
94
+ else echo UNDECIDABLE; fi
95
+ }
96
+
97
+ # ── Instrument calibration: prove the adapter separates a known pair BEFORE trusting it ──
98
+ # The pair is DERIVED FROM THE CORPUS within this consumer's own namespace, never hardcoded.
99
+ # A hardcoded pair is namespace-blind: the first draft pinned `codex`, and a consumer that
100
+ # classifies model ids (and correctly has no opinion on CLI names) failed calibration for a
101
+ # reason that had nothing to do with its classifier. A calibration that can fail for the
102
+ # wrong reason cannot certify anything.
103
+ kp_pos=$(sed 's/\t/|/g' "$CORPUS" | awk -F'|' -v ns="$NS" \
104
+ '$2=="CAPABLE" && $3 ~ /^ns:/ { split($3,a,"/"); n=substr(a[1],4); if (index(" " ns " ", " " n " ")) { print $1; exit } }')
105
+ kp_neg=$(sed 's/\t/|/g' "$CORPUS" | awk -F'|' -v ns="$NS" \
106
+ '$2=="INCAPABLE" && $3 ~ /^ns:/ { split($3,a,"/"); n=substr(a[1],4); if (index(" " ns " ", " " n " ")) { print $1; exit } }')
107
+ if [ -z "$kp_pos" ] || [ -z "$kp_neg" ]; then
108
+ echo "🟥 HARNESS-ERROR: corpus has no known pair inside namespace [$NS]"
109
+ echo " (pos='$kp_pos' neg='$kp_neg'). Without a pair that this consumer OWNS, nothing"
110
+ echo " below is calibrated — refusing to grade."
111
+ exit 10
112
+ fi
113
+ gp=$(classify "$kp_pos"); gn=$(classify "$kp_neg")
114
+ # Separate "the instrument is broken" from "the instrument works and disagrees". Collapsing
115
+ # them makes a real, gradeable divergence abort as HARNESS-ERROR and go unmeasured — the
116
+ # finding disappears into the error channel. A classifier that says CAPABLE to a known
117
+ # NEGATIVE is not discriminating at all → unmeasurable. One that is merely too strict on the
118
+ # positive still separates, so grade it and report the strictness as drift.
119
+ if [ "$gn" = CAPABLE ]; then
120
+ echo "🟥 HARNESS-ERROR: adapter does not discriminate — known NEGATIVE $kp_neg → CAPABLE."
121
+ echo " It cannot separate a case whose answer is already known. Aborting, not reporting."
122
+ exit 10
123
+ fi
124
+ if [ "$gp" != CAPABLE ]; then
125
+ echo "⚠️ calibration: known POSITIVE $kp_pos → $gp (expected CAPABLE)."
126
+ echo " The classifier discriminates (negative held) but is stricter than the contract."
127
+ echo " Grading continues — this is drift to report, not an unmeasurable instrument."
128
+ else
129
+ echo "calibrated on [$NS]: $kp_pos → $gp · $kp_neg → $gn"
130
+ fi
131
+
132
+ N=0; BAD=0; DEV=0; SKIP=0
133
+ printf "%-32s %-12s %-12s %s\n" "id" "expected" "actual" ""
134
+ # IFS=$'\t' collapses CONSECUTIVE tabs (POSIX: repeated whitespace-class IFS chars are one
135
+ # delimiter), so an aligned row with an empty field shifts every later field left and the
136
+ # verdict column silently reads someone else's value. Split on a sentinel instead.
137
+ while IFS='|' read -r id want cls why; do
138
+ case "$id" in ''|'#'*|' '*|$'\t'*) continue ;; esac
139
+ # Verdict field must be one of the three tokens. A continuation line of a multi-line note
140
+ # otherwise parses as a row and reports a phantom disagreement — measured on this file's
141
+ # own first draft, which is exactly the "instrument, not target" failure it must not have.
142
+ case "${want:-}" in CAPABLE|INCAPABLE|UNDECIDABLE) : ;; *) continue ;; esac
143
+ row_ns=$(printf '%s' "${cls:-}" | sed -E 's|^ns:([^/]+)/.*|\1|')
144
+ case " $NS " in *" $row_ns "*) : ;; *) SKIP=$((SKIP+1)); continue ;; esac
145
+ N=$((N+1)); got=$(classify "$id")
146
+ if [ "$got" = "$want" ]; then
147
+ printf "%-32s %-12s %-12s ✅\n" "$id" "$want" "$got"
148
+ elif { [ "$want" = UNDECIDABLE ] && [ "$got" = INCAPABLE ]; } \
149
+ || { [ "$want" = INCAPABLE ] && [ "$got" = UNDECIDABLE ]; }; then
150
+ # Outcome-safety grading, not label identity. Both verdicts BLOCK under default-deny, so
151
+ # the operational result is identical and this is a reported deviation, never a failure.
152
+ # Failing it would punish a pattern classifier for an opaque id it cannot possibly read
153
+ # (`voyage-3`, `bge-m3`) and make DELETING THE ROW the rational maintenance move — the
154
+ # corpus would then shrink toward whatever the weakest implementation happens to catch.
155
+ printf "%-32s %-12s %-12s ⚠️ deviation — same outcome (both block)\n" "$id" "$want" "$got"
156
+ DEV=$((DEV+1))
157
+ else
158
+ printf "%-32s %-12s %-12s ❌ %s\n" "$id" "$want" "$got" "$cls"
159
+ # Name the direction: a permissive miss is the one that ships broken panels.
160
+ { [ "$want" = INCAPABLE ] || [ "$want" = UNDECIDABLE ]; } && [ "$got" = CAPABLE ] \
161
+ && echo " ↑ PERMISSIVE drift — this id would be counted as a reviewer"
162
+ BAD=$((BAD+1))
163
+ fi
164
+ done < <(sed 's/\t/|/g' "$CORPUS")
165
+
166
+ echo
167
+ echo "corpus pin: $PINNED · namespaces: [$NS]"
168
+ echo "rows graded: $N · disagreements: $BAD · allowed deviations: $DEV · skipped (other namespace): $SKIP"
169
+ if [ "$BAD" -eq 0 ]; then echo "✅ conforms to the shared corpus"; else
170
+ echo "❌ DRIFT — this repo's classifier disagrees with the shared corpus on $BAD row(s)."
171
+ echo " Fix the implementation, or change the corpus in ALL repos that vendor it."
172
+ fi
173
+ exit $([ "$BAD" -eq 0 ] && echo 0 || echo 1)
@@ -129,6 +129,93 @@ _eq "MG-T → the user's file is left byte-identical" "$(cat "$H/.claude/setti
129
129
  || _pass "MG-T → no misleading backup artifact left behind"
130
130
 
131
131
  echo
132
+ # ══ MULTI-SNIPPET DISCOVERY (regression for the 2026-08-08 high review, finding #1) ══
133
+ # The merge block used to hardcode settings.SessionStart.snippet.json, so every OTHER shipped snippet
134
+ # was structurally unregisterable — the compaction hooks shipped with a README reciting the
135
+ # shipping-is-not-wiring lesson while reproducing it, and PreToolUse was already in the same hole.
136
+ # These lanes fail if anyone reintroduces a single-snippet path.
137
+ echo "══ multi-snippet discovery ══"
138
+
139
+ _hub2() { # $1=name → hub with the shipped SessionStart snippet PLUS a second, unrelated snippet
140
+ local h="$TMPROOT/$1"; mkdir -p "$h/templates" "$h/.claude"
141
+ cp "$SNIPPET" "$h/templates/settings.SessionStart.snippet.json"
142
+ cat > "$h/templates/settings.Zzz.snippet.json" <<'EOF'
143
+ {
144
+ "_README": ["fixture — a snippet the merge code has never heard of"],
145
+ "project_settings_json": {
146
+ "hooks": {
147
+ "PreCompact": [
148
+ { "matcher": "", "hooks": [ { "type": "command", "command": "bash \"$HUB/scripts/zzz_probe.sh\" seal" } ] }
149
+ ]
150
+ }
151
+ }
152
+ }
153
+ EOF
154
+ echo "$h"
155
+ }
156
+
157
+ H=$(_hub2 multi); _merge "$H"
158
+ _rc "MS-1 merge with two snippets exits 0" "$RC" 0
159
+ python3 -c "
160
+ import json,sys
161
+ d=json.load(open('$H/.claude/settings.json'))
162
+ h=d.get('hooks',{})
163
+ ss=json.dumps(h.get('SessionStart',[]))
164
+ pc=json.dumps(h.get('PreCompact',[]))
165
+ print('OK' if ('fh_node_check' in ss and 'zzz_probe' in pc) else 'MISS')
166
+ " > "$TMPROOT/ms.out" 2>/dev/null
167
+ _eq "MS-2 BOTH snippets registered (unknown snippet needs no code edit)" "$(cat "$TMPROOT/ms.out")" "OK"
168
+
169
+ # The registered-event set must come from the snippets, not from a hardcoded list.
170
+ _eq "MS-3 a non-SessionStart event is registered" \
171
+ "$(python3 -c "import json;print('YES' if json.load(open('$H/.claude/settings.json')).get('hooks',{}).get('PreCompact') else 'NO')" 2>/dev/null)" "YES"
172
+
173
+ # Idempotence: re-running must not duplicate either snippet's hooks.
174
+ _merge "$H"
175
+ _eq "MS-4 re-run is idempotent (no duplicate zzz_probe entry)" \
176
+ "$(python3 -c "import json;print(json.dumps(json.load(open('$H/.claude/settings.json')).get('hooks',{}).get('PreCompact',[])).count('zzz_probe'))" 2>/dev/null)" "1"
177
+
178
+ # A user's own hook in the same event must survive the merge (hook-level, not group-level).
179
+ python3 - "$H" <<'PY2'
180
+ import json,collections,os,sys
181
+ t=sys.argv[1]+"/.claude/settings.json"
182
+ d=json.load(open(t),object_pairs_hook=collections.OrderedDict)
183
+ d["hooks"].setdefault("PreCompact",[]).append({"matcher":"","hooks":[{"type":"command","command":"bash my_own.sh"}]})
184
+ json.dump(d,open(t,"w"),indent=2,ensure_ascii=False)
185
+ PY2
186
+ _merge "$H"
187
+ _eq "MS-5 user's own hook in the same event survives" \
188
+ "$(python3 -c "import json;print('YES' if 'my_own.sh' in json.dumps(json.load(open('$H/.claude/settings.json')).get('hooks',{}).get('PreCompact',[])) else 'NO')" 2>/dev/null)" "YES"
189
+
190
+ # ══ RE-REVIEW REPAIRS (2026-08-08 round 2) ══
191
+ # 두 건 다 **직전 라운드의 수리가 만든 결함**이다. 레인 없이 고치면 같은 자리로 돌아온다.
192
+ echo "══ re-review repairs ══"
193
+
194
+ # ZS — 스니펫 0개는 조용한 성공이 아니라 시끄러운 실패여야 한다 (#1)
195
+ ZH="$TMPROOT/zerosnip"; mkdir -p "$ZH/templates" "$ZH/.claude" # templates/ 는 있고 스니펫만 없다
196
+ _merge "$ZH"
197
+ _rc "ZS-1 스니펫 0개 → non-zero (조용한 성공 금지)" "$RC" 1
198
+ case "$OUT" in *"NO SNIPPETS"*) _pass "ZS-2 무엇이 없는지 이름을 말한다" ;; *) _fail "ZS-2 실패 사유가 불명" ;; esac
199
+
200
+ # KC — 파생 키가 베이스네임이면 남의 훅을 지운다 (#2)
201
+ KH=$(_hub2 keycollide)
202
+ python3 - "$KH" <<'PY2'
203
+ import json,collections,os,sys
204
+ t=sys.argv[1]+"/.claude/settings.json"
205
+ d=collections.OrderedDict()
206
+ if os.path.exists(t): d=json.load(open(t),object_pairs_hook=collections.OrderedDict)
207
+ h=d.setdefault("hooks",collections.OrderedDict())
208
+ # 사용자 자기 훅 — 파일명은 겹치지만 **경로가 다르다**
209
+ h.setdefault("PreCompact",[]).append({"matcher":"","hooks":[
210
+ {"type":"command","command":"bash ~/tools/zzz_probe.sh --mine"}]})
211
+ json.dump(d,open(t,"w"),indent=2,ensure_ascii=False)
212
+ PY2
213
+ _merge "$KH"
214
+ _eq "KC-1 파일명만 겹치는 사용자 훅은 살아남는다" \
215
+ "$(python3 -c "import json;print('YES' if 'tools/zzz_probe.sh' in json.dumps(json.load(open('$KH/.claude/settings.json')).get('hooks',{}).get('PreCompact',[])) else 'NO')" 2>/dev/null)" "YES"
216
+ _eq "KC-2 FH 자기 훅은 여전히 교체된다 (중복 없음)" \
217
+ "$(python3 -c "import json;print(json.dumps(json.load(open('$KH/.claude/settings.json')).get('hooks',{}).get('PreCompact',[])).count('scripts/zzz_probe.sh'))" 2>/dev/null)" "1"
218
+
132
219
  echo "══ ⓘ GAP lanes ══"
133
220
 
134
221
  # GAP 1 — the ONLY validation is what `kept + entry` incidentally requires: that entry is a list.
@@ -148,23 +235,29 @@ for name in emptylist nocommand wrongscript juststring; do
148
235
  BADSCHEMA_HITS=$((BADSCHEMA_HITS+1)); DETAIL="$DETAIL $name"
149
236
  fi
150
237
  done
151
- _g=0; [ "$BADSCHEMA_HITS" -gt 0 ] && _g=1
152
- _gap "valid-JSON / invalid-SCHEMA snippet written, reported as success, node hook absent" "$_g" \
153
- "$BADSCHEMA_HITS of $BADSCHEMA_TOTAL cases:$DETAIL. exit 0 and 'node-check SessionStart hook \
154
- registered ->' are printed while fh_node_check is NOT in the file that was just written. The success \
155
- message is unconditional it does not re-read what it wrote. One-line fix: after the write, assert \
156
- fh_node_check appears in the serialized result, else exit non-zero."
238
+ # PROMOTED 2026-08-08 (was a GAP): the merge block now shape-validates before writing and re-reads
239
+ # what it wrote, so a bad-schema snippet can no longer be written-and-reported-as-success.
240
+ # NOTE the fixture nuance: the `wrongscript` case is NOT malformed — it is a valid snippet naming a
241
+ # different script, which the discovery-based merge correctly registers. `_registered` only greps for
242
+ # fh_node_check, so it reads as "not registered". Assert the property that actually matters instead:
243
+ # a run that skips a snippet must not exit 0 (fail-closed), which is what makes the old GAP dead.
244
+ _eq "BS-1 malformed snippet never yields a zero exit (fail-closed)" \
245
+ "$(H=$(_hub bs1 '{"project_settings_json":{"hooks":{"SessionStart":["not even an object"]}}}'); _merge "$H"; [ "$RC" != 0 ] && echo CLOSED || echo OPEN)" "CLOSED"
246
+ # (GAP 1 retired — promoted to BS-1 above, 2026-08-08)
157
247
 
158
248
  # GAP 2 — the corruption from GAP 1 is LATENT: the bad value lands in the user's settings.json and
159
249
  # detonates on the NEXT wizard run, in the kept-loop, far from where it was introduced.
160
250
  H=$(_hub latent '{"project_settings_json":{"hooks":{"SessionStart":["not even an object"]}}}')
161
251
  _merge "$H"; FIRST_RC=$RC
162
252
  _merge "$H"; SECOND_RC=$RC
163
- _g=0; { [ "$FIRST_RC" = 0 ] && [ "$SECOND_RC" != 0 ]; } && _g=1
164
- _gap "the bad write detonates on the NEXT run, not the run that made it" "$_g" \
165
- "run1 exit=$FIRST_RC (reported success), run2 exit=$SECOND_RC (AttributeError in the kept-loop: \
166
- g.get on a str). The user's settings.json is now un-mergeable and the traceback points at the \
167
- survivor filter, not at the snippet that caused it."
253
+ # PROMOTED 2026-08-08 (was a GAP): a bad snippet is skipped with a visible SKIP line instead of
254
+ # corrupting settings.json, so run 2 no longer detonates on run 1's write.
255
+ # The old GAP was: run 1 reports success, run 2 detonates on run 1's write. The promoted property is
256
+ # DETERMINISM both runs must reach the same verdict, so a failure is attributable to the snippet
257
+ # that caused it rather than surfacing later inside the survivor filter.
258
+ _eq "LT-1 verdict is deterministic across runs (no latent detonation)" \
259
+ "$([ "$FIRST_RC" = "$SECOND_RC" ] && echo DETERMINISTIC || echo LATENT)" "DETERMINISTIC"
260
+ # (GAP 2 retired — promoted to LT-1 above, 2026-08-08)
168
261
 
169
262
  # GAP 3 — INSTRUMENT COVERAGE, measured not asserted. Build the exact post-failure state (companion
170
263
  # hook registered, node hook absent) and run install-doctor's registration check on it. The
@@ -0,0 +1,209 @@
1
+ #!/usr/bin/env bash
2
+ # utterance_landing_check.sh — 마감 전, 운영자 발화가 기록에 착지했는지 grep 검증.
3
+ #
4
+ # WHY. 마감 체인이 `CONSISTENT` 를 내면서도 운영자 발화가 통째로 빠지는 일이 두 세션 연속
5
+ # 일어났다(2026-08-07 5건 · 2026-08-08 2건). close check 는 **형식**만 본다 — 카드가 로그보다
6
+ # 새로운가, 필수 아티팩트가 있는가. **내용이 착지했는지는 안 본다.**
7
+ #
8
+ # 가장 잘 빠지는 것은 잊은 발화가 아니라 **행동으로 대응한 발화**다. 답장에서 판단까지 내리고
9
+ # 나면 처리된 느낌이 남아서, 그 판단이 어디에도 안 적힌 채 세션이 끝난다. 2026-08-08 실측:
10
+ # "PMH 가 개인위키에 4축을 돌려 늦어졌다" 에 대해 과적용이라는 판정을 답장에 썼고 기록은 0건.
11
+ #
12
+ # ── 이 스크립트가 존재하는 두 번째 이유: 손으로 짜면 계기가 죽는다 ──
13
+ # 같은 검증을 손으로 짤 때마다 계기가 죽었다(N=3). 마지막 사망 원인은 **zsh word-split**:
14
+ #
15
+ # S="a.md b.md"; grep -l "$pat" $S # bash 는 분리, zsh 는 파일명 하나로 전달 → 전건 0
16
+ #
17
+ # 컨트롤이 없으면 그 0 이 "전부 미착지" 로 읽힌다. 그래서 이 스크립트는 (1) 파일을 배열로
18
+ # 다루고 (2) **컨트롤을 먼저 돌려 살아있음을 증명한 뒤에만** 타깃 결과를 출력한다.
19
+ # 컨트롤이 죽으면 타깃 결과를 아예 인쇄하지 않는다 — 죽은 계기의 출력은 데이터가 아니다.
20
+ #
21
+ # ── 사용 ──
22
+ # bash scripts/utterance_landing_check.sh <probes.tsv> <file> [<file>...]
23
+ # bash scripts/utterance_landing_check.sh --self-test
24
+ #
25
+ # probes.tsv 형식 (탭 구분):
26
+ # CONTROL<TAB><정규식><TAB><라벨> 착지가 확실한 것. 하나라도 0이면 HARNESS-ERROR
27
+ # TARGET<TAB><정규식><TAB><라벨> 검증 대상 발화
28
+ #
29
+ # ── ⚠️ 명명된 잔여: 프로브는 문구 변경에 깨진다 (2026-08-08 실측) ──
30
+ # 정규식을 **정확한 문구**에 걸면, 그 문구를 나중에 고치는 순간 프로브가 죽고 **착지한 항목이
31
+ # 미착지로 뜬다**. 실측: 프로브 `2건만|두 개에만` 이 카드 제목을 `2건만` → `2건 병렬` 로
32
+ # 편집하자 오탐을 냈다(내용은 그대로 있었다).
33
+ #
34
+ # 이건 `[[feedback_typed_verdict_channel]]` 이 이름 붙인 Grep-Collision Treadmill 의 반대편이다:
35
+ # 충돌은 없는데 **너무 좁아서** 깨진다. 두 방향 모두 "문자열을 재고 대상을 안 본다" 의 사례다.
36
+ #
37
+ # 완화(닫히지 않음):
38
+ # · 정규식은 **여러 표현을 OR** 로 — 한 문구에 걸지 마라 (`A|B|C`)
39
+ # · **미착지가 뜨면 먼저 손으로 열어 확인**하라. 프로브 노후가 첫 번째 의심 대상이다.
40
+ # rc=1 은 "기록하라"가 아니라 "확인하라"로 읽어야 한다.
41
+ # · 이 실패 방향은 **안전**하다 — 착지한 것을 미착지로 부르지, 미착지를 착지로 부르지 않는다.
42
+ # 과차단이지 fail-open 이 아니다. (반대였다면 이 도구는 못 쓴다.)
43
+ #
44
+ # ── 종료 코드 ──
45
+ # 0 전건 착지
46
+ # 1 미착지 있음 (라벨과 함께 출력)
47
+ # 10 HARNESS-ERROR — 컨트롤 사망 또는 입력 불량. **PASS 도 FAIL 도 아니다**
48
+
49
+ set -uo pipefail
50
+
51
+ self_test() {
52
+ local T f=0 n=0
53
+ # mktemp 실패를 안 보면 T 가 빈 문자열이 되고 이어지는 쓰기가 `/a.md` 로 나간다 — 루트에
54
+ # 쓰려다 실패하고, 그 실패가 '캘리브레이션 실패' 로 렌더된다. 계기 부재를 계기 결함으로
55
+ # 오진하는 경로라 여기서 갈라 놓는다. (cross-family 감사에서 실제 샌드박스가 이걸 밟았다.)
56
+ T=$(mktemp -d 2>/dev/null) || T=""
57
+ if [ -z "$T" ] || [ ! -d "$T" ]; then
58
+ echo "🟥 HARNESS-ERROR: mktemp -d 실패 — 캘리브레이션을 돌릴 수 없다(계기 결함이 아니라 환경 부재)."
59
+ return 10
60
+ fi
61
+ trap 'rm -rf "$T"' RETURN
62
+ printf 'alpha 라는 단어\n' > "$T/a.md"
63
+ printf 'beta 라는 단어\n' > "$T/b.md"
64
+ t() { n=$((n+1)); if [ "$2" = "$3" ]; then echo "✅ $1 → $3"; else echo "❌ $1 → $3 (기대 $2)"; f=1; fi; }
65
+
66
+ printf 'CONTROL\talpha\tctl\nTARGET\tbeta\thit\n' > "$T/p1.tsv"
67
+ bash "$0" "$T/p1.tsv" "$T/a.md" "$T/b.md" >/dev/null 2>&1 && r=PASS || r=FAIL
68
+ t "컨트롤 생존 + 타깃 착지" PASS "$r"
69
+
70
+ printf 'CONTROL\talpha\tctl\nTARGET\tgamma\tmiss\n' > "$T/p2.tsv"
71
+ bash "$0" "$T/p2.tsv" "$T/a.md" "$T/b.md" >/dev/null 2>&1; rc=$?
72
+ t "타깃 미착지 → rc=1" 1 "$rc"
73
+
74
+ # 핵심 레인: 컨트롤이 죽으면 타깃이 '전부 미착지'처럼 보이는데, 그때 1을 내면 안 된다.
75
+ printf 'CONTROL\tzzz없는단어\tctl\nTARGET\tbeta\thit\n' > "$T/p3.tsv"
76
+ bash "$0" "$T/p3.tsv" "$T/a.md" "$T/b.md" >/dev/null 2>&1; rc=$?
77
+ t "컨트롤 사망 → rc=10 (1 아님)" 10 "$rc"
78
+
79
+ # zsh word-split 재현 방어: 파일 인자가 여러 개일 때 실제로 전부 읽히는가.
80
+ # ⚠️ 이 픽스처는 원래 TARGET 없이 컨트롤 2건만 두고 rc=0 을 기대했다. 그 형태가 곧
81
+ # "아무것도 안 쟀는데 합격" 을 **레인이 승인하는** 상태였다(아래 p6 이 그래서 신설됐다).
82
+ # word-split 을 재려면 컨트롤이 두 파일에 흩어져 있으면 충분하고, TARGET 은 있어야 한다.
83
+ printf 'CONTROL\talpha\tctl\nCONTROL\tbeta\tctl2\nTARGET\t라는\thit\n' > "$T/p4.tsv"
84
+ bash "$0" "$T/p4.tsv" "$T/a.md" "$T/b.md" >/dev/null 2>&1; rc=$?
85
+ t "두 파일에 흩어진 컨트롤 2건 모두 생존" 0 "$rc"
86
+
87
+ printf 'TARGET\tbeta\thit\n' > "$T/p5.tsv"
88
+ bash "$0" "$T/p5.tsv" "$T/a.md" >/dev/null 2>&1; rc=$?
89
+ t "컨트롤 0개 → rc=10 (검증 불가)" 10 "$rc"
90
+
91
+ # 잰 발화가 0 건인데 초록을 내면 안 된다. rc=1(미착지)과도 **다른 값**이어야 한다 —
92
+ # 둘이 뭉치면 "기록하라" 와 "프로브를 안 썼다" 가 구분되지 않는다.
93
+ printf 'CONTROL\talpha\tctl\n' > "$T/p6.tsv"
94
+ bash "$0" "$T/p6.tsv" "$T/a.md" "$T/b.md" >/dev/null 2>&1; rc=$?
95
+ t "TARGET 0개 → rc=10 (0 도 1 도 아님)" 10 "$rc"
96
+
97
+ # 깨진 정규식은 무매치가 아니다. grep 은 에러도 비영으로 주므로 순진하게 받으면
98
+ # **고장난 프로브가 '미착지' 로 렌더된다** = 있지도 않은 누락을 기록하게 만든다.
99
+ printf 'CONTROL\talpha\tctl\nTARGET\t[\tbroken\n' > "$T/p7.tsv"
100
+ bash "$0" "$T/p7.tsv" "$T/a.md" "$T/b.md" >/dev/null 2>&1; rc=$?
101
+ t "깨진 정규식 → rc=10 (1 아님)" 10 "$rc"
102
+
103
+ # 대시로 시작하는 패턴이 grep 옵션으로 먹히면 계기가 통째로 오작동한다.
104
+ printf 'CONTROL\talpha\tctl\nTARGET\t-베타\tdashy\n' > "$T/p8.tsv"
105
+ bash "$0" "$T/p8.tsv" "$T/a.md" "$T/b.md" >/dev/null 2>&1; rc=$?
106
+ t "대시 시작 패턴이 옵션으로 안 먹힘 → rc=1(정상 미착지)" 1 "$rc"
107
+
108
+ echo; [ "$f" -eq 0 ] && echo "✅ 캘리브레이션 통과 ($n 쌍)" || echo "❌ 캘리브레이션 실패 ($n 쌍)"
109
+ return "$f"
110
+ }
111
+
112
+ [ "${1:-}" = "--self-test" ] && { self_test; exit $?; }
113
+
114
+ if [ "$#" -lt 2 ]; then
115
+ echo "usage: $0 <probes.tsv> <file> [<file>...]" >&2
116
+ echo " $0 --self-test" >&2
117
+ exit 10
118
+ fi
119
+
120
+ PROBES="$1"; shift
121
+ FILES=("$@") # 배열 — word-split 에 의존하지 않는다
122
+ [ -f "$PROBES" ] || { echo "🟥 HARNESS-ERROR: probes 파일 없음: $PROBES"; exit 10; }
123
+ for f in "${FILES[@]}"; do
124
+ [ -f "$f" ] || { echo "🟥 HARNESS-ERROR: 대상 파일 없음: $f"; exit 10; }
125
+ done
126
+
127
+ # 단일 검색 경로. CONTROL 과 TARGET 이 **같은 함수**를 통과해야 한다 — 두 벌로 나누면 한쪽만
128
+ # 통과하는 입력이 다른 쪽에서 무음 드롭된다([[feedback_divergent_leniency_duplicate_normalizers]]).
129
+ # 반환: 0=매치 · 1=무매치 · 2=grep 에러(잘못된 정규식 등). grep 은 에러도 2 로 주는데, 그걸
130
+ # 무매치와 뭉치면 **깨진 프로브가 '미착지'로 렌더된다** — 계기 고장을 데이터로 파는 것이라
131
+ # 이 스크립트가 막으려는 바로 그 오독이다. `--` 는 `-foo` 같은 패턴이 옵션으로 먹히는 걸 막는다.
132
+ _probe_hits() {
133
+ local pat="$1"; shift
134
+ grep -lE -- "$pat" "$@" 2>/dev/null
135
+ return "${PIPESTATUS[0]:-$?}"
136
+ }
137
+
138
+ # ── 1단계: 컨트롤. 살아있음을 증명하기 전에는 타깃을 인쇄하지 않는다 ──
139
+ CTL_TOTAL=0; CTL_DEAD=0; DEAD_LABELS=""; BAD_PAT=""
140
+ while IFS=$'\t' read -r kind pat label; do
141
+ [ "${kind:-}" = "CONTROL" ] || continue
142
+ CTL_TOTAL=$((CTL_TOTAL+1))
143
+ _probe_hits "$pat" "${FILES[@]}" >/dev/null; _rc=$?
144
+ if [ "$_rc" -ge 2 ]; then
145
+ BAD_PAT="${BAD_PAT:+$BAD_PAT, }CONTROL/${label:-$pat}"
146
+ elif [ "$_rc" -ne 0 ]; then
147
+ CTL_DEAD=$((CTL_DEAD+1)); DEAD_LABELS="${DEAD_LABELS:+$DEAD_LABELS, }${label:-$pat}"
148
+ fi
149
+ done < "$PROBES"
150
+
151
+ if [ "$CTL_TOTAL" -eq 0 ]; then
152
+ echo "🟥 HARNESS-ERROR: probes 에 CONTROL 이 없다."
153
+ echo " 컨트롤 없는 부재 측정은 근거가 아니다 — 0 이 '미착지'인지 '계기 사망'인지 구분 불가."
154
+ exit 10
155
+ fi
156
+ if [ "$CTL_DEAD" -gt 0 ]; then
157
+ echo "🟥 HARNESS-ERROR: 컨트롤 $CTL_DEAD/$CTL_TOTAL 사망 — [$DEAD_LABELS]"
158
+ echo " 착지가 확실한 문자열이 안 잡힌다 = 계기가 대상을 못 읽고 있다."
159
+ echo " 타깃 결과는 **인쇄하지 않는다** — 죽은 계기의 출력은 데이터가 아니라 소음이다."
160
+ echo " 흔한 원인: zsh word-split(\$FILES 인용 누락) · 경로 오류 · 인코딩 불일치."
161
+ exit 10
162
+ fi
163
+
164
+ # TARGET 이 0건이면 잰 것이 없다. 예전 판본은 여기서 `발화 0 건 전부 착지` 를 인쇄하고 **0 을
165
+ # 냈다** — 빈 프로브 세트가 초록 도장으로 환전됐다는 뜻이고, 그건 이 스크립트가 존재하는
166
+ # 이유와 정확히 반대 방향이다(미측정을 0 으로, 0 을 합격으로). cross-family 감사가 지목했고,
167
+ # self-test 의 p4 픽스처가 그 상태를 **기대값 0 으로 인코딩해** 굳히고 있었다 — 레인이 결함을
168
+ # 방어하는 대신 승인하고 있었던 셈이다([[feedback_not_found_is_not_zero_family]]).
169
+ if ! grep -qE '^TARGET'$'\t' "$PROBES"; then
170
+ echo "🟥 HARNESS-ERROR: probes 에 TARGET 이 없다 — 잰 발화가 0 건이다."
171
+ echo " '전부 착지' 가 아니라 '아무것도 안 쟀다' 다. 빈 프로브 세트는 합격이 아니다."
172
+ exit 10
173
+ fi
174
+ echo "계기 생존: 컨트롤 $CTL_TOTAL/$CTL_TOTAL"
175
+ echo
176
+
177
+ # ── 2단계: 타깃 ──
178
+ MISS=0; N=0
179
+ while IFS=$'\t' read -r kind pat label; do
180
+ [ "${kind:-}" = "TARGET" ] || continue
181
+ N=$((N+1))
182
+ hits=$(_probe_hits "$pat" "${FILES[@]}" | wc -l | tr -d ' '); _rc=${PIPESTATUS[0]}
183
+ if [ "$_rc" -ge 2 ]; then
184
+ printf " 🟥 %-46s 프로브 오류\n" "${label:-$pat}"
185
+ BAD_PAT="${BAD_PAT:+$BAD_PAT, }TARGET/${label:-$pat}"
186
+ elif [ "$hits" -gt 0 ]; then
187
+ printf " ✅ %-46s %s파일\n" "${label:-$pat}" "$hits"
188
+ else
189
+ printf " 🟥 %-46s 미착지\n" "${label:-$pat}"
190
+ MISS=$((MISS+1))
191
+ fi
192
+ done < "$PROBES"
193
+
194
+ echo
195
+ # 깨진 프로브는 '미착지' 가 아니다. 둘을 같은 rc 로 내면 "기록하라" 와 "프로브를 고쳐라" 가
196
+ # 구분되지 않고, 후자를 전자로 읽으면 있지도 않은 누락을 기록하게 된다.
197
+ if [ -n "$BAD_PAT" ]; then
198
+ echo "🟥 HARNESS-ERROR: 프로브 정규식 오류 — [$BAD_PAT]"
199
+ echo " grep 이 에러(rc≥2)를 냈다. 무매치가 아니라 계기 고장이다 — 정규식을 고쳐라."
200
+ exit 10
201
+ fi
202
+ if [ "$MISS" -eq 0 ]; then
203
+ echo "✅ 발화 $N 건 전부 착지"
204
+ exit 0
205
+ fi
206
+ echo "🟥 $N 건 중 $MISS 건 미착지 — 마감 전에 기록하라."
207
+ echo " ⚠️ 가장 잘 빠지는 것은 잊은 발화가 아니라 **행동으로 대응한 발화**다."
208
+ echo " 답장에서 판단까지 내리고 나면 처리된 느낌이 남고, 그 판단이 어디에도 안 적힌다."
209
+ exit 1