@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,437 @@
1
+ #!/usr/bin/env bash
2
+ # test_probe_live_eval_lanes.sh — regression lanes for scripts/probe_live_eval.sh's SCORER and
3
+ # SELECTOR (scripts/probe_live_eval_lib.py). Never calls `claude` and spawns no live session —
4
+ # this pins the scoring logic and the probes.md/probes_live.yaml cross-reference against fixture
5
+ # text, exactly the split ablation_calibrate.sh's own header argues for ("the pair gates the
6
+ # runner; the runner never gates itself" — applied here to the scorer instead of a sim runner).
7
+ #
8
+ # LANE CLASSES
9
+ # score-pair known-pair calibration of score_probe(): PASS / FAIL / UNCALIBRATED(present) /
10
+ # UNCALIBRATED(absent) / FAILED-TO-RUN, both empty-string and missing-file shapes
11
+ # select-guard the mechanical selection rule (class filter, utterance-shape, INERT-ANCHOR,
12
+ # CLI-event exclude) reproduces the 12-selected / 21-excluded split against the
13
+ # REAL probes.md + probes_live.yaml shipped in this repo
14
+ # dead-pointer probes_live.yaml naming an id absent from probes.md is caught (nonzero exit),
15
+ # not silently ignored — this IS the "id 가 probes.md 에 실재하는지" guard the
16
+ # dispatching session's task named explicitly
17
+ # dry-run `--dry-run` on the real files exits 0 and touches nothing under run/ (no live
18
+ # network call, no OUTDIR created)
19
+ #
20
+ # exit: 0 = all lanes as expected · 1 = regression · 10 = harness error (setup failed, not a verdict)
21
+
22
+ set -uo pipefail
23
+
24
+ REPO_ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
25
+ LIB="$REPO_ROOT/scripts/probe_live_eval_lib.py"
26
+ RUNNER="$REPO_ROOT/scripts/probe_live_eval.sh"
27
+ PROBES_MD="$REPO_ROOT/.claude/regression/probes.md"
28
+ PROBES_LIVE="$REPO_ROOT/.claude/regression/probes_live.yaml"
29
+
30
+ command -v python3 >/dev/null 2>&1 || { echo "❌ HARNESS-ERROR — python3 missing"; exit 10; }
31
+ [ -f "$LIB" ] || { echo "❌ HARNESS-ERROR — $LIB missing"; exit 10; }
32
+ [ -f "$RUNNER" ] || { echo "❌ HARNESS-ERROR — $RUNNER missing"; exit 10; }
33
+
34
+ T="$(mktemp -d)" || { echo "❌ HARNESS-ERROR — mktemp failed"; exit 10; }
35
+ trap 'rm -rf "$T"' EXIT
36
+
37
+ FAIL=0; N=0
38
+ _lane() { # $1=id $2=class $3=desc $4=expected $5=actual
39
+ N=$((N + 1))
40
+ if [ "$4" = "$5" ]; then
41
+ printf ' ✅ %-8s [%-14s] %s\n' "$1" "$2" "$3"
42
+ else
43
+ printf ' ❌ %-8s [%-14s] %s — expected=%s actual=%s\n' "$1" "$2" "$3" "$4" "$5"
44
+ FAIL=1
45
+ fi
46
+ }
47
+
48
+ # ── score-pair: known-pair calibration of score_probe() ────────────────────────────────────────
49
+ # $1=primary $2=control $3=polarity $4=expect_re -> prints "VERDICT PRIMARY_HIT CONTROL_HIT"
50
+ # `__NONE__` maps to Python None (FAILED-TO-RUN fixture — a file that was never written, distinct
51
+ # from an empty string, which is a file that WAS written with 0 bytes; both must score the same).
52
+ _score() {
53
+ python3 -c "
54
+ import sys
55
+ sys.path.insert(0, '$REPO_ROOT/scripts')
56
+ from probe_live_eval_lib import score_probe
57
+ primary = None if sys.argv[1] == '__NONE__' else sys.argv[1]
58
+ control = None if sys.argv[2] == '__NONE__' else sys.argv[2]
59
+ v, ph, ch = score_probe(primary, control, sys.argv[3], sys.argv[4])
60
+ print('%s %s %s' % (v, ph, ch))
61
+ " "$1" "$2" "$3" "$4"
62
+ }
63
+
64
+ echo "── score-pair ────────────────────────────────────────────────────"
65
+
66
+ # P1: polarity=present, pattern fires on primary, does not fire on control → PASS
67
+ r="$(_score '🐿️ Welcome to FH' 'the weather is nice today' present '🐿️')"
68
+ _lane P1 score-pair "present polarity, control clean -> PASS" "PASS True False" "$r"
69
+
70
+ # P2: polarity=present, pattern absent from primary -> FAIL
71
+ r="$(_score 'sure, here is the weather' 'the weather is nice today' present '🐿️')"
72
+ _lane P2 score-pair "present polarity, primary silent -> FAIL" "FAIL False False" "$r"
73
+
74
+ # P3: polarity=present, pattern ALSO fires on control -> UNCALIBRATED (the instrument cannot
75
+ # discriminate — this is the known-pair's whole point: a probe whose control leaks the pattern
76
+ # must not be scored as if it discriminated)
77
+ r="$(_score '🐿️ Welcome to FH' 'random text with 🐿️ in it too' present '🐿️')"
78
+ _lane P3 score-pair "present polarity, control ALSO hits -> UNCALIBRATED" "UNCALIBRATED True True" "$r"
79
+
80
+ # P4: polarity=absent, pattern absent from primary, present on control -> PASS
81
+ r="$(_score 'here are the dependencies' '🐿️ Welcome to FH' absent '🐿️')"
82
+ _lane P4 score-pair "absent polarity, control fires -> PASS" "PASS False True" "$r"
83
+
84
+ # P5: polarity=absent, pattern LEAKS into primary too -> FAIL (a real regression: onboarding
85
+ # leaking into an explicit task-utterance response)
86
+ r="$(_score '🐿️ Welcome to FH — here are the dependencies' '🐿️ Welcome to FH' absent '🐿️')"
87
+ _lane P5 score-pair "absent polarity, primary leaks -> FAIL" "FAIL True True" "$r"
88
+
89
+ # P6: polarity=absent, control ALSO never fires -> UNCALIBRATED (control failed to prove the
90
+ # pattern can appear at all — primary's silence is not evidence of anything)
91
+ r="$(_score 'here are the dependencies' 'also just dependencies' absent '🐿️')"
92
+ _lane P6 score-pair "absent polarity, control never fires -> UNCALIBRATED" "UNCALIBRATED False False" "$r"
93
+
94
+ # P7/P8: FAILED-TO-RUN — missing file (None) and empty-string both collapse to the same verdict,
95
+ # never silently read as a FAIL (CLAUDE.md not-found-is-not-zero discipline).
96
+ r="$(_score '__NONE__' 'the weather is nice' present '🐿️')"
97
+ _lane P7 score-pair "primary file missing -> FAILED-TO-RUN" "FAILED-TO-RUN False False" "$r"
98
+ r="$(_score '' 'the weather is nice' present '🐿️')"
99
+ _lane P8 score-pair "primary file empty -> FAILED-TO-RUN" "FAILED-TO-RUN False False" "$r"
100
+
101
+ # ── reason-pair: FAILED-TO-RUN rows carry a `reason` explaining WHY (2026-09-05) ────────────────
102
+ # WHY: the 2026-09-05 launchd incident scored 12/12 FAILED-TO-RUN with no clue why in the report
103
+ # itself — a human had to go dig through stderr files by hand. score_run() now attaches a `reason`
104
+ # to any FAILED-TO-RUN row: the failing arm's own stderr first line when there is one, else an
105
+ # rc/bytes fallback parsed from the runner's console log. This never touches score_probe() itself
106
+ # (unchanged, still tested above) — reason is a diagnostic label on top of the same verdict.
107
+ # $1=run_root $2=id -> "VERDICT<TAB>REASON"
108
+ _reason_row() {
109
+ python3 -c "
110
+ import sys
111
+ sys.path.insert(0, '$REPO_ROOT/scripts')
112
+ from probe_live_eval_lib import score_run
113
+ live = [{'id': sys.argv[2], 'polarity': 'present', 'expect_re': '🐿️'}]
114
+ res = score_run(live, sys.argv[1], [sys.argv[2]], 0.8, 'sonnet')
115
+ row = res['rows'][0]
116
+ sys.stdout.write('%s\t%s' % (row['verdict'], row.get('reason', '')))
117
+ " "$1" "$2"
118
+ }
119
+
120
+ echo ""
121
+ echo "── reason-pair ──────────────────────────────────────────────────"
122
+
123
+ # R-F1: primary's own stderr has a real line (the launchd incident's actual error text) -> reason
124
+ # quotes it verbatim, prefixed by which arm it came from.
125
+ RROOT1="$T/reason_f1"; mkdir -p "$RROOT1/X1"
126
+ printf 'scripts/sim_isolated_run.sh: line 517: timeout: command not found\n' > "$RROOT1/X1/primary_r1.stderr.txt"
127
+ printf 'unused control text' > "$RROOT1/X1/control_r1.txt"
128
+ out="$(_reason_row "$RROOT1" X1)"
129
+ _lane RF1 reason-pair "primary stderr line -> reason quotes it, prefixed 'primary:'" \
130
+ "FAILED-TO-RUN primary: scripts/sim_isolated_run.sh: line 517: timeout: command not found" "$out"
131
+
132
+ # R-F2: stderr file absent/empty -> falls back to rc/bytes, rc parsed from the runner's own
133
+ # console log text (the shape sim_isolated_run.sh itself prints: "(rc=<n>, ...)").
134
+ RROOT2="$T/reason_f2"; mkdir -p "$RROOT2/X2"
135
+ printf ' UNMEASURED (rc=127, 0 bytes) - timeout or crash, NOT a negative result\n' > "$RROOT2/X2/_runner_primary.log"
136
+ printf 'unused' > "$RROOT2/X2/control_r1.txt"
137
+ out="$(_reason_row "$RROOT2" X2)"
138
+ _lane RF2 reason-pair "no stderr line -> rc/bytes fallback parsed from the runner log" \
139
+ "FAILED-TO-RUN primary: rc=127 bytes=0" "$out"
140
+
141
+ # R-F3: the CONTROL side is the one missing (primary present) -> reason is prefixed 'control:',
142
+ # not 'primary:' — proves the label is attributed to the arm that actually failed.
143
+ RROOT3="$T/reason_f3"; mkdir -p "$RROOT3/X3"
144
+ printf '🐿️ Welcome to FH' > "$RROOT3/X3/primary_r1.txt"
145
+ printf 'boom: control side stderr text\n' > "$RROOT3/X3/control_r1.stderr.txt"
146
+ out="$(_reason_row "$RROOT3" X3)"
147
+ _lane RF3 reason-pair "control-side failure -> reason prefixed 'control:', not 'primary:'" \
148
+ "FAILED-TO-RUN control: boom: control side stderr text" "$out"
149
+
150
+ # R-F4 known-negative: a normal PASS row must carry an EMPTY reason — otherwise RF1-RF3 could be
151
+ # passing because `reason` is always non-empty garbage, not because it discriminates on verdict
152
+ # ([[feedback_control_presence_is_not_discrimination]]).
153
+ RROOT4="$T/reason_f4"; mkdir -p "$RROOT4/X4"
154
+ printf '🐿️ Welcome to FH' > "$RROOT4/X4/primary_r1.txt"
155
+ printf 'the weather is nice' > "$RROOT4/X4/control_r1.txt"
156
+ out="$(_reason_row "$RROOT4" X4)"
157
+ _lane RF4 reason-pair "control — a PASS row carries no reason (field is FAILED-TO-RUN-only)" \
158
+ "PASS " "$out"
159
+
160
+ # ── select-guard: mechanical rule reproduces the real 12/21 split ──────────────────────────────
161
+ echo ""
162
+ echo "── select-guard ──────────────────────────────────────────────────"
163
+
164
+ SELECT_JSON="$T/select.json"
165
+ SPEC_DIR="$T/spec"
166
+ sel_out="$(python3 "$LIB" select --probes-md "$PROBES_MD" --probes-live "$PROBES_LIVE" \
167
+ --json-out "$SELECT_JSON" --spec-dir "$SPEC_DIR" 2>&1)"
168
+ sel_rc=$?
169
+ _lane S1 select-guard "real probes.md/probes_live.yaml -> selector exits 0 (no dead pointer)" "0" "$sel_rc"
170
+
171
+ if [ -f "$SELECT_JSON" ]; then
172
+ sel_count=$(python3 -c "import json; print(len(json.load(open('$SELECT_JSON'))['selected']))")
173
+ exc_count=$(python3 -c "import json; print(len(json.load(open('$SELECT_JSON'))['excluded']))")
174
+ else
175
+ sel_count="ERR"; exc_count="ERR"
176
+ fi
177
+ # 🟥 12/21 -> 11/22 on 2026-09-06. These numbers are PINNED on purpose — a derived count would pass
178
+ # no matter what the selector did, which is the one thing this lane exists to prevent. So a change to
179
+ # the selection rule is SUPPOSED to turn these red and force an author to say why. It just did:
180
+ # G-TRIG-03 moved into `ARM_CAPABILITY_EXCLUDE` (probe_live_eval_lib.py) because its probes.md
181
+ # rationale points at a CLAUDE.md table row that the 2026-07-17 row diet deleted, and the behavior was
182
+ # delegated to a skill `description` the arm cannot see (it runs with Read,Grep,Glob and no Skill tool
183
+ # — confirmed by a known-pair: "list every Skill available to you" -> NO-SKILL-TOOL while the
184
+ # tool-listing control answered correctly). It failed 0/5 for that reason alone.
185
+ # 🟥 The exclusion does NOT mean the delegation works — that question is now UNMEASURED, not answered.
186
+ _lane S2 select-guard "selected count == 11 (12 minus G-TRIG-03, ARM_CAPABILITY_EXCLUDE)" "11" "$sel_count"
187
+ _lane S3 select-guard "excluded count == 22 (33 probes.md rows - 11 selected)" "22" "$exc_count"
188
+
189
+ # --subset and --ids filters
190
+ sub_out="$(python3 "$LIB" select --probes-md "$PROBES_MD" --probes-live "$PROBES_LIVE" --subset 3 \
191
+ --spec-dir "$T/spec_subset" 2>&1)"
192
+ sub_count=$(wc -l < "$T/spec_subset/selected_ids.txt" 2>/dev/null | tr -d ' ')
193
+ _lane S4 select-guard "--subset 3 yields exactly 3 selected ids" "3" "${sub_count:-ERR}"
194
+
195
+ ids_out="$(python3 "$LIB" select --probes-md "$PROBES_MD" --probes-live "$PROBES_LIVE" \
196
+ --ids "G-GREET-01,G-TRIG-02" --spec-dir "$T/spec_ids" 2>&1)"
197
+ ids_count=$(wc -l < "$T/spec_ids/selected_ids.txt" 2>/dev/null | tr -d ' ')
198
+ _lane S5 select-guard "--ids G-GREET-01,G-TRIG-02 yields exactly 2" "2" "${ids_count:-ERR}"
199
+
200
+ unk_out="$(python3 "$LIB" select --probes-md "$PROBES_MD" --probes-live "$PROBES_LIVE" \
201
+ --ids "G-NOT-A-REAL-ID-99" --spec-dir "$T/spec_unk" 2>&1)"
202
+ case "$unk_out" in
203
+ *"unknown ids requested"*) unk_hit="yes" ;;
204
+ *) unk_hit="no" ;;
205
+ esac
206
+ _lane S6 select-guard "--ids with an unknown id is reported, not silently dropped" "yes" "$unk_hit"
207
+
208
+ # ── dead-pointer: probes_live.yaml naming an id absent from probes.md must fail loudly ─────────
209
+ echo ""
210
+ echo "── dead-pointer ──────────────────────────────────────────────────"
211
+
212
+ BAD_YAML="$T/probes_live_bad.yaml"
213
+ {
214
+ cat "$PROBES_LIVE"
215
+ cat <<'BADEOF'
216
+ - id: G-DOES-NOT-EXIST-99
217
+ polarity: present
218
+ input: "this id has no row in probes.md"
219
+ expect_re: "x"
220
+ control_input: "y"
221
+ BADEOF
222
+ } > "$BAD_YAML"
223
+
224
+ python3 "$LIB" select --probes-md "$PROBES_MD" --probes-live "$BAD_YAML" \
225
+ --json-out "$T/select_bad.json" --spec-dir "$T/spec_bad" >"$T/bad_out.txt" 2>&1
226
+ bad_rc=$?
227
+ _lane D1 dead-pointer "id absent from probes.md -> select exits nonzero" "nonzero" "$([ "$bad_rc" -ne 0 ] && echo nonzero || echo zero)"
228
+ grep -q "DEAD-POINTER: G-DOES-NOT-EXIST-99" "$T/bad_out.txt" && d2=found || d2=missing
229
+ _lane D2 dead-pointer "dead-pointer id named explicitly in the warning" "found" "$d2"
230
+
231
+ # Known-negative for D1/D2: the SAME check must NOT fire on the real, uncorrupted file — otherwise
232
+ # D1/D2 could be passing on a scanner that always says "dead pointer found" regardless of input.
233
+ python3 "$LIB" select --probes-md "$PROBES_MD" --probes-live "$PROBES_LIVE" \
234
+ --json-out "$T/select_clean.json" --spec-dir "$T/spec_clean" >"$T/clean_out.txt" 2>&1
235
+ clean_rc=$?
236
+ grep -q "DEAD-POINTER" "$T/clean_out.txt" && d3=found || d3=missing
237
+ _lane D3 dead-pointer "control: real file has no dead pointer, rc=0" "0 missing" "${clean_rc} ${d3}"
238
+
239
+ # ── dry-run: probe_live_eval.sh --dry-run touches nothing under a fresh OUTDIR and exits 0 ─────
240
+ echo ""
241
+ echo "── dry-run ───────────────────────────────────────────────────────"
242
+
243
+ DRY_OUT="$T/dryrun_out"
244
+ dry_stdout="$(cd "$REPO_ROOT" && bash "$RUNNER" --dry-run --out "$DRY_OUT" 2>&1)"
245
+ dry_rc=$?
246
+ _lane R1 dry-run "probe_live_eval.sh --dry-run exits 0" "0" "$dry_rc"
247
+ _lane R2 dry-run "--dry-run creates no OUTDIR (no live run attempted)" "absent" "$([ -d "$DRY_OUT" ] && echo present || echo absent)"
248
+ case "$dry_stdout" in
249
+ *"SELECTED (11)"*) r3=yes ;;
250
+ *) r3=no ;;
251
+ esac
252
+ _lane R3 dry-run "--dry-run stdout shows the real 11-probe selection" "yes" "$r3"
253
+
254
+ # ── fail-fast: probe_live_eval.sh aborts on the FIRST rc=2 runner call, not after burning the
255
+ # rest of the selected set (2026-09-05, the launchd incident this exists for) ────────────────────
256
+ # WHY: sim_isolated_run.sh's own usage guards exit 2 before ever calling `claude` (bad flags, no
257
+ # `claude` on PATH, or — the actual incident — a launchd PATH with no `timeout(1)` resolvable
258
+ # before that runner grew its own bash-fallback). That condition is identical for every remaining
259
+ # probe in the run, so continuing just burns the rest of the clones on an environment already
260
+ # known broken. These lanes stub OUT sim_isolated_run.sh entirely via FH_SIM_RUNNER_BIN (added to
261
+ # probe_live_eval.sh for exactly this) so the fail-fast branch can be tested without a live
262
+ # `claude` call at all.
263
+ echo ""
264
+ echo "── fail-fast ────────────────────────────────────────────────────"
265
+
266
+ STUBROOT="$T/failfast_stub"; mkdir -p "$STUBROOT"
267
+
268
+ # Broken stand-in: exactly what sim_isolated_run.sh's own preflight guards do — print one line to
269
+ # stderr and exit 2, never touching a network or spawning `claude`. Records its own invocation
270
+ # count so the lane can prove the caller stopped after the FIRST call.
271
+ cat > "$STUBROOT/fake_sim_broken.sh" <<'FAKESIM'
272
+ #!/usr/bin/env bash
273
+ : "${FH_FAKESIM_COUNTER:?FH_FAKESIM_COUNTER must be set by the caller}"
274
+ echo "$$" >> "$FH_FAKESIM_COUNTER"
275
+ echo "FAIL: claude CLI not on PATH" >&2
276
+ exit 2
277
+ FAKESIM
278
+ chmod +x "$STUBROOT/fake_sim_broken.sh"
279
+
280
+ # Healthy stand-in (known-negative control): same argv shape, writes a plausible output file and
281
+ # exits 0 for EVERY call — proves FF1/FF2 discriminate on rc=2 specifically, not on "stopped after
282
+ # one call" regardless of what the runner returns.
283
+ cat > "$STUBROOT/fake_sim_ok.sh" <<'FAKESIMOK'
284
+ #!/usr/bin/env bash
285
+ : "${FH_FAKESIM_COUNTER:?FH_FAKESIM_COUNTER must be set by the caller}"
286
+ echo "$$" >> "$FH_FAKESIM_COUNTER"
287
+ arm=""; out=""
288
+ while [ $# -gt 0 ]; do
289
+ case "$1" in
290
+ --arm) arm="$2"; shift 2 ;;
291
+ --out) out="$2"; shift 2 ;;
292
+ *) shift ;;
293
+ esac
294
+ done
295
+ mkdir -p "$out"
296
+ echo "stub ok output" > "$out/${arm}_r1.txt"
297
+ echo "RESULT: CLEAN"
298
+ exit 0
299
+ FAKESIMOK
300
+ chmod +x "$STUBROOT/fake_sim_ok.sh"
301
+
302
+ # 🟥 FF5 guard — the live nightly record must be untouched by this suite. Measured 2026-09-05 10:18:
303
+ # FF4 completed the REAL script with a stub runner and, with no --report-out, replaced that night's
304
+ # tracks/_meta/live_eval_<date>.md with stub values. A lane that writes into the live artifact path
305
+ # is the fleet class in miniature ([[feedback_sim_with_write_tools_is_a_fleet]]).
306
+ LIVE_REPORT="$REPO_ROOT/tracks/_meta/live_eval_$(date +%Y-%m-%d).md"
307
+ _live_hash() { if [ -f "$LIVE_REPORT" ]; then shasum "$LIVE_REPORT" | cut -c1-40; else echo ABSENT; fi; }
308
+ live_before="$(_live_hash)"
309
+ COUNTER1="$T/failfast_counter_broken.txt"; : > "$COUNTER1"
310
+ ff_out="$(cd "$REPO_ROOT" && FH_SIM_RUNNER_BIN="$STUBROOT/fake_sim_broken.sh" FH_FAKESIM_COUNTER="$COUNTER1" \
311
+ bash "$RUNNER" --subset 2 --model sonnet --out "$T/failfast_run_broken" --report-out "$T/failfast_report_broken.md" 2>&1)"
312
+ ff_rc=$?
313
+ _lane FF1 fail-fast "aborts with rc=2 on the runner's own preflight failure" "2" "$ff_rc"
314
+ ff_calls=$(wc -l < "$COUNTER1" | tr -d ' ')
315
+ _lane FF2 fail-fast "stops after exactly 1 runner call (does not burn the 2nd probe's 3 remaining calls)" "1" "$ff_calls"
316
+ case "$ff_out" in
317
+ *"Aborting the whole run"*) ff_msg=yes ;;
318
+ *) ff_msg=no ;;
319
+ esac
320
+ _lane FF3 fail-fast "abort message names what happened (not a silent stop)" "yes" "$ff_msg"
321
+
322
+ # Known-negative control: the SAME --subset 2 (2 probes x primary+control = 4 calls) against a
323
+ # HEALTHY runner must run to completion, not stop early — otherwise FF1/FF2 could be passing
324
+ # because the loop always stops after one call for any reason at all
325
+ # ([[feedback_control_presence_is_not_discrimination]]).
326
+ COUNTER2="$T/failfast_counter_ok.txt"; : > "$COUNTER2"
327
+ ( cd "$REPO_ROOT" && FH_SIM_RUNNER_BIN="$STUBROOT/fake_sim_ok.sh" FH_FAKESIM_COUNTER="$COUNTER2" \
328
+ bash "$RUNNER" --subset 2 --model sonnet --out "$T/failfast_run_ok" --report-out "$T/failfast_report_ok.md" ) >/dev/null 2>&1
329
+ ff2_calls=$(wc -l < "$COUNTER2" | tr -d ' ')
330
+ _lane FF4 fail-fast "control — a healthy runner (rc=0) is called for all 4 (2 probes x 2 arms), not stopped early" "4" "$ff2_calls"
331
+ live_after="$(_live_hash)"
332
+ _lane FF5 fail-fast "live nightly record untouched by the suite (hash before == after, or both ABSENT)" "$live_before" "$live_after"
333
+ [ -s "$T/failfast_report_ok.md" ] && ff_rep=yes || ff_rep=no
334
+ _lane FF6 fail-fast "--report-out receives the report instead of the live path" "yes" "$ff_rep"
335
+
336
+ # 🟥 FF7/FF7b — the seam must bypass the CLI preflight, and ONLY the seam. Measured 2026-09-05 on
337
+ # CI (ubuntu, no `claude` on PATH): probe_live_eval.sh checked `command -v claude` BEFORE the
338
+ # runner seam, so the stub was never called — FF2/FF3/FF4/FF6 red, FF1 green by coincidence (both
339
+ # paths exit 2). A developer machine with `claude` installed cannot see that, so this lane HIDES
340
+ # `claude` (a shadow PATH of symlinks to every other executable) and re-runs the healthy stub.
341
+ # FF7b is the control: on the REAL runner path with no `claude`, the script must still refuse.
342
+ SHADOW_NOCLAUDE="$T/shadow_noclaude"; mkdir -p "$SHADOW_NOCLAUDE"
343
+ IFS=':' read -r -a _ff_dirs <<< "$PATH"
344
+ for _d in "${_ff_dirs[@]}"; do
345
+ [ -d "$_d" ] || continue
346
+ for _f in "$_d"/*; do
347
+ [ -x "$_f" ] && [ ! -d "$_f" ] || continue
348
+ _b="${_f##*/}"; [ "$_b" = claude ] && continue
349
+ [ -e "$SHADOW_NOCLAUDE/$_b" ] || ln -s "$_f" "$SHADOW_NOCLAUDE/$_b"
350
+ done
351
+ done
352
+ if PATH="$SHADOW_NOCLAUDE" command -v claude >/dev/null 2>&1; then
353
+ _lane FF7-FIXTURE fail-fast "shadow PATH hides claude (fixture potency — cannot run FF7 on this machine)" "hidden" "still-visible"
354
+ else
355
+ COUNTER3="$T/failfast_counter_noclaude.txt"; : > "$COUNTER3"
356
+ ( cd "$REPO_ROOT" && PATH="$SHADOW_NOCLAUDE" FH_SIM_RUNNER_BIN="$STUBROOT/fake_sim_ok.sh" FH_FAKESIM_COUNTER="$COUNTER3" \
357
+ bash "$RUNNER" --subset 2 --model sonnet --out "$T/failfast_run_noclaude" --report-out "$T/failfast_report_noclaude.md" ) >/dev/null 2>&1
358
+ ff7_calls=$(wc -l < "$COUNTER3" | tr -d ' ')
359
+ _lane FF7 fail-fast "claude absent from PATH + stub runner: stub still called for all 4 (the seam bypasses the CLI preflight)" "4" "$ff7_calls"
360
+ ff7b_out="$(cd "$REPO_ROOT" && PATH="$SHADOW_NOCLAUDE" bash "$RUNNER" --subset 2 --model sonnet --out "$T/failfast_run_noclaude_real" --report-out "$T/failfast_report_noclaude_real.md" 2>&1)"
361
+ ff7b_rc=$?
362
+ case "$ff7b_out" in *"claude CLI not on PATH"*) ff7b_msg=yes ;; *) ff7b_msg=no ;; esac
363
+ _lane FF7b fail-fast "control — the REAL runner path with claude absent still exits 2 and names claude" "2/yes" "$ff7b_rc/$ff7b_msg"
364
+ fi
365
+
366
+
367
+ echo "── majority over reps (2026-09-06) ───────────────────────────────"
368
+ # WHY THESE LANES EXIST. Until 2026-09-06 the scorer read only `*_r1.txt`, so a probe's verdict was
369
+ # one sample. Re-scoring three real run artifacts then found 5 of 12 probes FLAKY — two runs 15
370
+ # minutes apart with an identical `corpus_head_date` flipped 4 of them, and observed pass_rate was
371
+ # 0.50 / 0.67 / 0.67. The single-rep noise band is wider than the distance to the 0.80 threshold,
372
+ # so a reps=1 pass_rate cannot support a threshold decision. score_run now takes `reps` and votes.
373
+ # These lanes pin the composition rules — especially MV4, which is the one a majority could quietly
374
+ # launder.
375
+ _score_run() { # $1=reps $2..= per-rep spec, one arg per rep: "P" pass | "F" fail | "U" uncal | "X" not-run
376
+ local reps="$1"; shift
377
+ python3 - "$REPO_ROOT" "$reps" "$@" <<'PY'
378
+ import sys, os, json, tempfile
379
+ sys.path.insert(0, os.path.join(sys.argv[1], 'scripts'))
380
+ from probe_live_eval_lib import score_run
381
+ reps = int(sys.argv[2]); specs = sys.argv[3:]
382
+ root = tempfile.mkdtemp(); base = os.path.join(root, 'G-X'); os.makedirs(base)
383
+ # pattern '🐿️'; present polarity. primary hit = pattern in primary; control hit = pattern in control.
384
+ for i, kind in enumerate(specs, start=1):
385
+ if kind == 'X': # neither file written -> that rep did not run
386
+ continue
387
+ prim = '🐿️ hi' if kind in ('P', 'U') else 'nothing here'
388
+ ctrl = '🐿️ also here' if kind == 'U' else 'weather'
389
+ open(os.path.join(base, 'primary_r%d.txt' % i), 'w').write(prim)
390
+ open(os.path.join(base, 'control_r%d.txt' % i), 'w').write(ctrl)
391
+ rows = score_run([{'id': 'G-X', 'polarity': 'present', 'expect_re': '🐿️'}],
392
+ root, ['G-X'], 0.8, 'sonnet', reps=reps)['rows'][0]
393
+ print('%s %s' % (rows['verdict'], rows.get('reps')))
394
+ PY
395
+ }
396
+
397
+ r="$(_score_run 3 P P P)"
398
+ _lane MV1 majority "3 reps all pass -> PASS, spread kept as 3/3" "PASS 3/3" "$r"
399
+
400
+ r="$(_score_run 3 P P F)"
401
+ _lane MV2 majority "2 of 3 pass -> PASS (strict majority), spread 2/3" "PASS 2/3" "$r"
402
+
403
+ r="$(_score_run 3 P F F)"
404
+ _lane MV3 majority "1 of 3 pass -> FAIL, spread 1/3" "FAIL 1/3" "$r"
405
+
406
+ # 🟥 MV4 is the load-bearing one. UNCALIBRATED is deliberately NOT majority-voted: if the pattern
407
+ # fires on a known-negative even once, discrimination is in doubt, and a 2-of-3 majority would
408
+ # launder that doubt into a PASS. Two of the three reps here pass cleanly — a naive majority says
409
+ # PASS. The scorer must not.
410
+ r="$(_score_run 3 P P U)"
411
+ _lane MV4 majority "one rep UNCALIBRATED outranks a passing majority (no laundering)" "UNCALIBRATED 2/3" "$r"
412
+
413
+ # MV5: a rep that never ran is excluded from the denominator, not counted as a failure — "did not
414
+ # run" and "ran and failed" are different facts (the not-found-is-not-zero rule).
415
+ r="$(_score_run 3 X P P)"
416
+ _lane MV5 majority "a non-running rep leaves the denominator, not the numerator" "PASS 2/2" "$r"
417
+
418
+ r="$(_score_run 3 X X X)"
419
+ _lane MV6 majority "no rep ran -> FAILED-TO-RUN, never FAIL" "FAILED-TO-RUN 0/0" "$r"
420
+
421
+ # MV7: control — the default path (reps=1) must be byte-for-byte the old behavior. A change that
422
+ # only works at reps=3 would silently alter every existing caller.
423
+ r="$(_score_run 1 P)"
424
+ _lane MV7 majority "control: reps=1 unchanged (PASS, 1/1)" "PASS 1/1" "$r"
425
+
426
+ r="$(_score_run 1 F)"
427
+ _lane MV8 majority "control: reps=1 failing case unchanged" "FAIL 0/1" "$r"
428
+
429
+ echo ""
430
+ echo "── summary ──────────────────────────────────────────────────────"
431
+ echo "lanes: $N failed: $([ "$FAIL" -eq 0 ] && echo 0 || echo '>=1')"
432
+ if [ "$FAIL" -ne 0 ]; then
433
+ echo "RESULT: REGRESSION"
434
+ exit 1
435
+ fi
436
+ echo "RESULT: CLEAN"
437
+ exit 0
@@ -15,9 +15,30 @@ exp "Bash sed -i on scripts/*.sh with token" HIT '{"tool_name":"Bash","tool_i
15
15
  exp "Bash sed -i token ONLY inside quotes (a1)" HIT '{"tool_name":"Bash","tool_input":{"command":"sed -i \"\" \"s/exit 1/exit 2/\" scripts/target.sh"}}'
16
16
  exp "Bash sed -i single-quoted token (a1b)" HIT '{"tool_name":"Bash","tool_input":{"command":"sed -i '"'"''"'"' '"'"'s/|| continue/|| { echo x; continue; }/'"'"' scripts/target.sh"}}'
17
17
  exp "Bash sed -i no token anywhere (a3)" CLEAN '{"tool_name":"Bash","tool_input":{"command":"sed -i \"\" s/foo/bar/ scripts/target.sh"}}'
18
- exp "Bash redirect into scripts/*.sh w/ token" HIT '{"tool_name":"Bash","tool_input":{"command":"printf \"%s\\n\" x >> scripts/x.sh; grep -q y scripts/x.sh || exit 3"}}'
18
+ exp "Bash redirect into scripts/*.sh w/ token" HIT '{"tool_name":"Bash","tool_input":{"command":"printf \"[ -f x ] || exit 1\\n\" >> scripts/x.sh; grep -q y scripts/x.sh"}}'
19
19
  exp "Bash redirect into docs (no)" CLEAN '{"tool_name":"Bash","tool_input":{"command":"echo \"exit 1\" >> docs/a.md || exit 1"}}'
20
20
  exp "Bash ls only (no target)" CLEAN '{"tool_name":"Bash","tool_input":{"command":"ls scripts/ && [ -d scripts ] || exit 1"}}'
21
+ # ── 2026-09-04 계기 교체 lanes — fixtures are the SHAPES the first independent grading quoted
22
+ # (tracks/_meta/RESULT_2026-09-04_identity5-armC-live-count.md §계기 결함), not easier spellings.
23
+ # Fail-before: the HEAD~ hook (whole-command regex) is HIT on R2/R3/R4-ctrl/D3-*/D4 and CLEAN on D2-* (recorded in
24
+ # proposal_hook_repair_lanes.txt for the patch); the scanner hook inverts exactly those.
25
+ exp "R2 marker heredoc QUOTES sed -i … scripts/target.sh (row 2)" CLEAN '{"tool_name":"Bash","tool_input":{"command":"cat > tracks/_meta/.axes_23_2026-09-03.marker <<'"'"'MK'"'"'\naxes-run: ⓐ ⓑ\na1 `sed -i '"'"''"'"' '"'"'s/exit 1/exit 2/'"'"' scripts/target.sh` → HIT · lanes 16/16 · [ -s x ] || exit 1\nMK"}}'
26
+ exp "R3 gh pr --body QUOTES sed -i … scripts/target.sh (row 3)" CLEAN '{"tool_name":"Bash","tool_input":{"command":"gh pr create --title \"fix(hook): a1\" --body \"Lanes: a1 `sed -i '"'"''"'"' '"'"'s/exit 1/exit 2/'"'"' scripts/target.sh` now HIT; 16/16 · revert 14/16 || exit 1\""}}'
27
+ exp "R4-ctrl : > \"\$T/scripts/…\" fixture root (var path)" CLEAN '{"tool_name":"Bash","tool_input":{"command":"mkdir -p \"$T/scripts\"; : > \"$T/scripts/test_has_lane_lanes.sh\"; [ -f x ] || exit 1"}}'
28
+ exp "D2-P python heredoc open(p,\"w\") verdict edit (8746)" HIT '{"tool_name":"Bash","tool_input":{"command":"python3 - <<'"'"'PY'"'"'\np=\"scripts/package_coverage_check.sh\"\ns=open(p).read()\nassert s.count(\" exit 2\") == 1\ns=s.replace(\" exit 2\", \" echo \\\"PKG_ORACLE_MISSING: npm pack gave no files[]\\\" >&2; exit 2\")\nopen(p,\"w\").write(s)\nPY"}}'
29
+ exp "D2-P2 python heredoc q=Path(…); q.write_text (verdict)" HIT '{"tool_name":"Bash","tool_input":{"command":"python3 - <<'"'"'PY'"'"'\nfrom pathlib import Path\nq=Path(\"scripts/selfcheck.sh\")\nq.write_text(q.read_text().replace(\"run_lane x\", \"run_lane x || exit 1\"))\nPY"}}'
30
+ exp "D2-ctrl python heredoc comment-word replace (== in code only)" CLEAN '{"tool_name":"Bash","tool_input":{"command":"python3 - <<'"'"'PY'"'"'\np=\"scripts/sim_isolated_run.sh\"\ns=open(p).read()\nassert s.count(\"# 옛말\") == 1\ns=s.replace(\"# 옛말\", \"# 조직\")\nopen(p,\"w\").write(s)\nPY"}}'
31
+ exp "D3 row 6: sed comment word + || exit 1 in another segment" CLEAN '{"tool_name":"Bash","tool_input":{"command":"sed -i '"'"''"'"' '"'"'s/옛말/조직/g'"'"' scripts/sim_isolated_run.sh && bash scripts/test_sim_isolated_run_lanes.sh; rc=$?; [ $rc = 0 ] || exit 1"}}'
32
+ exp "D3 row 1: self-probe sed (no token in expr) + [ -s ] || exit 1" CLEAN '{"tool_name":"Bash","tool_input":{"command":"sed -i '"'"''"'"' '"'"'s/^#NOOP-PROBE-LINE$//'"'"' scripts/proposal_hook.sh && [ -s scripts/proposal_hook.sh ] || exit 1; tail -1 .claude/.proposal_hook_events.tsv"}}'
33
+ exp "D4 old shape: payload w/o token, check in next segment" CLEAN '{"tool_name":"Bash","tool_input":{"command":"printf \"%s\\n\" x >> scripts/x.sh; grep -q y scripts/x.sh || exit 3"}}'
34
+ exp "H1 cat > scripts/new.sh <<EOF body carries token" HIT '{"tool_name":"Bash","tool_input":{"command":"cat > scripts/new_lane.sh <<'"'"'EOF'"'"'\n#!/usr/bin/env bash\n[ -f x ] || exit 1\nEOF"}}'
35
+ exp "H2 cat <<EOF > \"scripts/q.sh\" (quoted target after >)" HIT '{"tool_name":"Bash","tool_input":{"command":"cat <<'"'"'EOF'"'"' > \"scripts/q.sh\"\nexit 1\nEOF"}}'
36
+ exp "H3 printf | tee -a scripts/t.sh" HIT '{"tool_name":"Bash","tool_input":{"command":"printf \"exit 1\\n\" | tee -a scripts/t.sh"}}'
37
+ # R4 proper: the real edit is a python heredoc whose CONTENT quotes `: > "$T/scripts/test_has_lane_lanes.sh"` — the hook
38
+ # must record the file the python writes, not the fixture root inside the string (row 4 recorded `$T/scripts/…`).
39
+ R4='{"tool_name":"Bash","tool_input":{"command":"python3 - <<'"'"'PY'"'"'\np=\"scripts/test_proposal_hook_lanes.sh\"\ns=open(p).read()\ns=s.replace(\"exp \\\"noqa\", \"mkdir -p \\\"$T/scripts\\\"; : > \\\"$T/scripts/test_has_lane_lanes.sh\\\"; [ -f x ] || exit 1; exp \\\"noqa\")\nopen(p,\"w\").write(s)\nPY"}}'
40
+ printf '%s' "$R4" | bash "$HDIR/proposal_hook.sh" >/dev/null 2>&1; r4fp=$(tail -1 "$T/.claude/.proposal_hook_events.tsv" 2>/dev/null | cut -f3)
41
+ if [ "$r4fp" = "scripts/test_proposal_hook_lanes.sh" ]; then printf ' ✅ %-52s fp=%s\n' "R4 python heredoc → recorded fp is the WRITTEN file" "$r4fp"; pass=$((pass+1)); else printf ' ❌ %-52s fp=%s (expected scripts/test_proposal_hook_lanes.sh)\n' "R4 python heredoc → recorded fp is the WRITTEN file" "${r4fp:-<none>}"; fail=$((fail+1)); fi
21
42
  exp "G1 Edit templates/.git-hooks/pre-commit (no .sh)" HIT '{"tool_name":"Edit","tool_input":{"file_path":"/x/templates/.git-hooks/pre-commit","old_string":" [ -f x ] || continue","new_string":" [ -f x ] || { echo missing; PTR_FAIL=1; continue; }"}}'
22
43
  exp "G1-ctrl Edit .git-hooks docs-ish no token" CLEAN '{"tool_name":"Edit","tool_input":{"file_path":"/x/templates/.git-hooks/pre-commit","old_string":"# note a","new_string":"# note b"}}'
23
44
  exp "noqa exempts" CLEAN '{"tool_name":"Edit","tool_input":{"file_path":"/x/scripts/a.sh","old_string":"a","new_string":"exit 1 # noqa: proposal-hook"}}'