@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,118 @@
1
+ # Multi-Session Close Protocol — 같은 하네스에서 둘 이상이 돌 때의 마감
2
+
3
+ > **왜 여기 있나.** 이 프로토콜은 2026-08-09 까지 **세션 카드에만** 있었다. 카드는 gitignored 이고
4
+ > **세대마다 통째로 덮인다** — 그날 실제로 이월 8건이 그렇게 사라졌고, 하필 그중 하나가
5
+ > *"⑤-c 발화 착지 검증"*(카드가 내용 착지를 안 본다는 걸 고치려던 항목)이었다. 규율을 담은 곳이
6
+ > 규율이 막으려던 방식으로 유실됐다. **그래서 tracked 로 옮겼다.** 이건 승격이 아니라 이사다.
7
+ >
8
+ > **범위**: FH 자신의 다축 마감. `templates/` 로 전파하지 않는다 — 필드 하네스는 대개 단일
9
+ > 세션이라 아직 필요가 없고, 전파는 그쪽에서 병렬이 실제로 돌기 시작할 때다.
10
+
11
+ ## 0. 전제 — 두 가지 형상을 구분한다
12
+
13
+ ```
14
+ 거버너 + 위임 한 세션이 카드를 쥐고 나머지는 그 세션이 띄운 에이전트
15
+ → 카드 소유가 명확하고 last-writer 경쟁이 없다. **기본형**
16
+ 대등 peer 독립 세션 둘 이상이 각자 마감한다
17
+ → 아래 전부가 필요해진다. **다른 레포일 때만 권장**
18
+ ```
19
+ 2026-08-09 의 2축이 안전했던 이유는 모델이 좋아서가 아니라 **두 축이 다른 레포**였기 때문이다
20
+ (`forge-harness` / `qasp-dev`). 같은 워킹트리를 공유하는 대등 peer 는
21
+ `[[feedback_shared_checkout_ops_touch_others_work]]` 를 정면으로 부른다.
22
+
23
+ ## 1. 순서 — 질문이 재대조보다 **앞이고 더 넓다**
24
+
25
+ ```
26
+ ① peer 가 자기 델타를 append (자기 소유 파일에만. 아래 §4)
27
+ ② 핑
28
+ ③ ★ 접기 전에 「지금부터 마감이다, 더 있나」를 **묻는다**
29
+ ④ 접는다 (⑤ 원자 실행: 로그 → 카드)
30
+ ⑤ `gh pr list --merged` 재대조
31
+ ```
32
+
33
+ **③이 ⑤로 대체되지 않는다.** 재대조는 *내가 아는 축*만 훑고, 질문은 *상대가 아는 축*을 가져온다.
34
+ 2026-08-09 실측: 그 질문이 델타 8건을 꺼냈고 **PR 이 없어 `gh pr list` 로 구조적으로 안 잡히는
35
+ 것들**이 다수였다(스펙/설계만 바뀐 것 · PR 이전 브랜치 커밋 · 외부 승인 대기 · 이월 누락).
36
+
37
+ ## 2. 세 가지를 각각 못 믿는다
38
+
39
+ ### 2-a. 보냈다 ≠ 닿았다
40
+ 크로스세션 메시지는 **수신자 사용자 승인 대기로 보류**될 수 있고, 그래도 발신 호출은 성공을
41
+ 반환한다. 같은 날 카드는 이미 「5곳 전부 통지」라고 적고 있었다.
42
+
43
+ ⚠️ **만료 통지는 계기끼리 어긋났다 — 한쪽으로 단정하지 않는다.** 보류 2건 중 하나에 대해
44
+ 「승인 없이 만료 — 전달되지 않음」 통지가 왔는데, **운영자 확인으로는 도달해 있었다.**
45
+ 어느 쪽이 맞는지 이 세션은 못 가른다. 그러니 **«만료 = 영구 미도달» 로 적지 마라**(초안이 그렇게
46
+ 적었다가 반증됐다). 확실한 것은 하나뿐이다 — **발신 성공은 도달의 증거가 아니고, 통지도 아니다.**
47
+ 도달을 확인하는 유일한 방법은 **답이 오는 것**이다.
48
+ → **보낸 수가 아니라 답 온 수를 적고, 미회신 수를 카드에 명시한다.** 기계화 불가(스크립트는 회신을
49
+ 못 본다) — `session_close_check.sh` ①-c 가 이 문장을 살리언스로 띄운다.
50
+
51
+ ### 2-a-2. 탐지는 넓게, **통지는 좁게** — 둘을 같은 목록으로 쓰지 마라
52
+ 2026-08-09 의 오발신은 탐지가 넓어서가 아니라 **탐지 목록을 그대로 수신자 목록으로 썼기** 때문이다
53
+ (원래 알려야 할 상대는 1곳이었다). 두 목록은 목적이 다르다:
54
+ ```
55
+ 탐지 넓게 — 살아있는 것 전부. 여분이 «마감 미완» 이라는 신호다 (§2-b)
56
+ 통지 좁게 — **지금 돌아가는(working/busy) 세션에만.** 끝난 세션에 보내는 건 소음이고,
57
+ 수신자 승인을 태우며, 상대 기록에 남지도 않는다
58
+ ```
59
+ **운영자 지시(2026-08-09)**: 「지금 돌아가는 동일 하네스 세션에만 보내라」.
60
+
61
+ ### 2-b. 살아있다 ≠ 일하는 중이다 · 그리고 그 여분이 신호다
62
+ 소켓이 살아있으면 **프로세스**가 살아있는 것이지 세션이 활성인 게 아니다. **판별 기준을 정확히
63
+ 적는다**: 소켓 파일명의 PID 에 프로세스가 살아있고 그 cwd 가 이 하네스면 잡힌다 — 그게 전부다.
64
+
65
+ 그래서 **세 가지가 섞여 들어오고, 통지가 필요한 건 첫째뿐이다**(2026-08-09 실측):
66
+ ```
67
+ working 세션 → 접기 전에 물어라
68
+ 놀고 있는 터미널 → 진짜 세션이지만 프롬프트에서 대기 중. 보통 건너뛴다
69
+ 남은 프로세스 → 마감이 안 끝난 것 (1회 관측, 손으로 죽여야 했다)
70
+ 예열 워커(bg-spare) → **세션이 아예 아니다.** args 로 기계 제외 — 안 걸러내면 순수 오탐
71
+ ```
72
+ ⚠️ **초판은 여분 전체를 «마감 미완» 이라고 적었다. 한 사례에서 과일반화한 것이고 틀렸다** —
73
+ 실제 다수는 예열 워커와 놀고 있는 터미널이었다. **셋을 가르는 건 fleet 뷰뿐이고, 이 스캔은 못 한다.**
74
+ ⚠️ **job 상태파일로 좁히지 마라 — 반대로 틀린다.** 같은 날 실측: 상태파일이 **돌고 있는 세션을
75
+ `done` + `firstTerminalAt` 로 표시**했다. 소켓이 넓은 만큼 상태파일은 좁다. **교차 판독.**
76
+
77
+ ### 2-c. 닫혔다 ≠ 안 열렸다
78
+ 「한 번도 안 닫힘」과 「닫고 또 일함」은 **밖에서 동일하게 Completed** 로 보이는데 처방이 반대다
79
+ (전자는 마저 닫기, 후자는 다시 접기). `session_close_check.sh` ①-d 가 마감 게이트 통과를 세서
80
+ 2회 이상이면 알린다. **자기신고다** — 패턴을 감지하지 증명하지 않는다.
81
+
82
+ ## 3. 세대 간 승계 — 동료-질문으로는 구조적으로 안 잡힌다
83
+
84
+ §1의 질문은 **동료 세션**을 향한다. **직전 세대 카드 자신의 이월분**은 그 질문의 사정거리 밖이다.
85
+ 2026-08-09 실측: 그렇게 사라진 이월이 8건이었고 파일은 전부 살아 있었다 — 소실이 아니라
86
+ **카드에서만 안 잡히던 상태**.
87
+
88
+ → **카드를 재작성할 때 이전 카드의 이월 블록을 grep 대조한다.** 새로 쓰는 게 아니라 **덜어낸 것을
89
+ 확인하는** 절차다.
90
+
91
+ ## 4. 쓰기 규율 — 공유 자원은 append 도 충돌한다
92
+
93
+ ```
94
+ ✅ 자기 소유 파일 · 자기 섹션에만 append → **즉시 커밋**
95
+ ❌ `git add -A` (남의 미커밋 append 를 삼킨다)
96
+ ❌ 남의 카드 직접 편집 — 내용을 넘기고 소유자가 싣는다
97
+ ❌ `reset --hard` — Destructive-Op 게이트 대상
98
+ ```
99
+ **메모리 인덱스도 같은 자원이다.** 2026-08-09: 한쪽이 항목을 archive 로 내리는 동안 다른 쪽이
100
+ hot 에 재색인해 **양 tier 동시 등재**가 됐다. 적용 직후 검사는 0이었고 **나중에 겹쳤다** — 즉
101
+ 한 번의 검사로는 못 잡는다.
102
+
103
+ ## 5. 마감 전 마지막 — 내용 착지를 따로 검증한다
104
+
105
+ close check 는 **형식**만 본다(카드가 로그보다 새로운가, 필수 아티팩트가 있는가). **내용이
106
+ 착지했는지는 안 본다.** 2026-08-09 에 `CONSISTENT` 를 열세 번 내고도 발화 2건이 미기록이었고,
107
+ `scripts/utterance_landing_check.sh`(컨트롤 동반 grep)가 처음 잡았다.
108
+
109
+ **가장 잘 빠지는 것은 잊은 발화가 아니라 «행동으로 대응한 발화»다** — 답장에서 조사·판단까지
110
+ 하고 나면 처리된 느낌이 남고, 그 판단이 어디에도 안 적힌 채 세션이 끝난다.
111
+
112
+ ## 명명된 잔여
113
+
114
+ - ①-c 의 peer 판별은 **cwd 매칭**이라 **다른 worktree 의 peer 를 못 잡는다**(Orca 류 격리 사용 시).
115
+ - ①-d 스탬프와 §5 검사 결과는 **자기신고**다. 형식은 잡고 위조는 못 잡는다.
116
+ - §1 의 질문은 **사람/세션이 해야 한다** — 기계 트리거가 없다. ①-c 는 «물어라» 를 띄울 뿐이다.
117
+ - 이 문서 자체가 n=1(2026-08-09 2축 마감 1회) 위에 서 있다. **두 번째 사례가 나오면 다시 봐라** —
118
+ 특히 §0 의 「거버너+위임이 기본형」은 그 한 번에서 나온 판단이다.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@chrono-meta/fh-gate",
3
- "version": "1.4.89",
3
+ "version": "1.4.91",
4
4
  "description": "FH runtime adapters — run FH governance, skills, and agents via Claude or Codex with machine-parseable gates.",
5
5
  "license": "MIT",
6
6
  "keywords": [
@@ -90,10 +90,22 @@
90
90
  "scripts/substrate_jump_detector.sh",
91
91
  "scripts/tier_census_grep.sh",
92
92
  "scripts/test_marker_floor_lanes.sh",
93
+ "scripts/test_marker_crossfamily_lanes.sh",
94
+ "scripts/directional_diff_gate.sh",
95
+ "scripts/reviewer_capability_corpus.tsv",
96
+ "scripts/test_reviewer_capability_conformance.sh",
93
97
  "plugins/fh-meta/CHANGELOG.md",
94
98
  "knowledge/shared/learnings/subagent_invocations_log.yaml",
95
99
  "scripts/chamber_candidate_collect.sh",
100
+ "scripts/chamber_witness.sh",
101
+ "scripts/digest_landing_check.sh",
102
+ "scripts/relay_channel.sh",
103
+ "scripts/test_relay_channel_lanes.sh",
96
104
  "scripts/fh_session_load.sh",
105
+ "scripts/branch_claim.sh",
106
+ "scripts/test_branch_claim_lanes.sh",
107
+ "scripts/hook_source_lib.sh",
108
+ "scripts/test_hook_source_gate_lanes.sh",
97
109
  "templates/.claude/rules/mcp_tool_gating.md",
98
110
  ".claude/rules/fh_4axis_gate.md",
99
111
  "templates/degrade_direction_scan.sh",
@@ -107,6 +119,7 @@
107
119
  "scripts/prepush_guard_check.sh",
108
120
  "scripts/psa_scan_lib.sh",
109
121
  "scripts/session_close_check.sh",
122
+ "scripts/utterance_landing_check.sh",
110
123
  "scripts/test_dispatch_log_lanes.sh",
111
124
  "scripts/memory_link_check.py",
112
125
  "scripts/test_memory_link_check.sh",
@@ -154,6 +167,14 @@
154
167
  "templates/subagent-tally-hook.json",
155
168
  "scripts/test_session_close_chain_lanes.sh",
156
169
  "scripts/test_node_infra_delta_lanes.sh",
157
- "scripts/test_wizard_snippet_merge_lanes.sh"
170
+ "scripts/test_wizard_snippet_merge_lanes.sh",
171
+ "scripts/compaction_probe.sh",
172
+ "scripts/judgment_circuit_lint.sh",
173
+ "scripts/novelty_claim_check.sh",
174
+ "templates/settings.Compaction.snippet.json",
175
+ "scripts/field_canon_preload.sh",
176
+ "scripts/test_field_canon_lanes.sh",
177
+ "templates/settings.FieldCanon.snippet.json",
178
+ ".claude/judgment_circuits.txt"
158
179
  ]
159
180
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-commons",
3
- "version": "1.4.89",
3
+ "version": "1.4.91",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "fh-meta",
3
- "version": "1.4.89",
3
+ "version": "1.4.91",
4
4
  "engines": {
5
5
  "claudeCode": ">=1.0.0"
6
6
  },
@@ -197,13 +197,54 @@ mode**, with an honest *same-family* note but **no "reduced value / degraded" fr
197
197
  this floor. `unset` → ask-once at first load-bearing need (accept → proceed; decline → record + this
198
198
  branch). Only proceed to the discovery ladder below when consent is `accepted`.
199
199
 
200
- 1. frontier cross-family CLI present recruit it (decorrelated, at-floor)best.
201
- 2. only local 4090 present canary pre-screen + in-session opus governor (canary, not full decorrelation).
202
- 3. nothing present → in-session same-family + **honest below-floor/same-family note** (residual named).
203
-
204
- Env non-determinism (CLI presence varies) → **silent degrade, never hard-fail**. Distinguish this
205
- **unavailable-but-wanted** case (consent given, panel down degrade-with-note; for a *load-bearing corp*
206
- surface, fail-closed per `local_pmh_context.md`) from the **declined** case above (chosen floor, first-class).
200
+ Every rung **emits a typed `crossfamily:` value into the Axes 2–3 marker** the rung is not
201
+ done until its verdict is recorded. The value is a closed enum, validated by `pre-commit`
202
+ (`validate_marker_floor`, fixtures in `scripts/test_marker_crossfamily_lanes.sh`); free prose is
203
+ rejected at commit time.
204
+
205
+ | # | Panel state | Emit | Ack |
206
+ |---|---|---|---|
207
+ | 1 | frontier cross-family CLI present → recruit it (decorrelated, at-floor) — best | `panel(<families>)` | — |
208
+ | 2 | only local 4090 → canary pre-screen + in-session opus governor (canary, not full decorrelation) | `panel(<families>)` **only if** a capable non-Claude model actually reviewed; otherwise rung 3 | — |
209
+ | 3 | **probed and nothing capable reachable** → in-session same-family | `DEGRADED_SINGLE_FAMILY` | **required** |
210
+ | — | **capable panel reachable, not recruited** (a choice, not a constraint) | `DEGRADED_PANEL_UNUSED` | **required** |
211
+ | — | consent `declined` (branch above — chosen floor, not a degrade) | `declined` | — |
212
+ | — | change is not load-bearing — decorrelation not required | `single-family` | — |
213
+ | — | **panel never probed** | `UNKNOWN` | **required** |
214
+
215
+ **The three degrade values are the load-bearing split**: *could not* (`DEGRADED_SINGLE_FAMILY`) ·
216
+ *did not* (`DEGRADED_PANEL_UNUSED`) · *did not look* (`UNKNOWN`). Free prose merges all three, and
217
+ each merge hides a different thing — an unrun probe renders as a zero finding
218
+ (`[[feedback_not_found_is_not_zero_family]]`), and an unused panel renders as an unavailable one.
219
+ The sibling-harness signal that motivated this field said it in its own words about itself:
220
+ *"못 한 것이 아니라 안 한 것이다."* All three require an ack naming *what was probed and what it
221
+ returned*. **`DEGRADED_PANEL_UNUSED` was added by dogfooding**: the first real marker written under
222
+ this lane found the panel reachable (codex · agy · gemini) and no cross-family review run — a state
223
+ the original five-value enum could not express without lying
224
+ (`[[feedback_adversarial_review_not_substitute_for_first_use]]`).
225
+
226
+ **Panel membership is capability-gated, and ineligibility is tested FIRST.** A runtime `/models`
227
+ list is a **serving** list, not a generative one. Measured in a sibling harness (2026-08-08): 6 of
228
+ 14 served entries could not review anything (embedding · reranker · OCR · safeguard), and because
229
+ the embeddings' ids matched no family prefix **each counted as its own family — satisfying the
230
+ family-diversity check while the panel was incapable**. Order is the invariant, not the list:
231
+ `GLM-OCR` matches both `ocr` and `glm`, `Qwen3-Embedding` matches both `embed` and `qwen`, so an
232
+ eligibility-first test lets them through silently. If nothing capable survives the filter, that is
233
+ rung 3 — **not** a smaller panel.
234
+
235
+ **Attribution: `panel(...)` records the families *requested*, not confirmed.** At least one
236
+ procurement path (an internal corp-gateway CLI) returns **no model attribution in its response**, so "3/3 responded"
237
+ does not establish which model contributed. Record request-fact and response-fact separately; do not
238
+ launder the former into the latter (`[[feedback_instrument_cannot_discriminate_hypotheses]]`).
239
+
240
+ Env non-determinism (CLI presence varies) → **never hard-fail the run** — but never *silent* either.
241
+ The two are different properties, and the prose this replaces conflated them: the degrade proceeds
242
+ (a commit is a reversible surface) **and** lands in the verdict with an ack. That is how this rung
243
+ and `CLAUDE.md` §Field-Harness gate ("never a silent same-family pass") are simultaneously true.
244
+ Distinguish the **unavailable-but-wanted** case (consent given, panel down → `DEGRADED_SINGLE_FAMILY`
245
+ + ack; for a *load-bearing corp* surface, fail-closed per `local_pmh_context.md` — note that file is
246
+ **pmh-repo-local and unreadable from an FH session**, so on this side the typed value + ack is the
247
+ enforceable layer) from the **declined** case above (chosen floor, first-class, no degrade framing).
207
248
 
208
249
  ## Step 7 — Output
209
250
 
@@ -221,7 +262,14 @@ at N per run (no loop).
221
262
 
222
263
  - Mechanical sidecar discovery returns the available panel. *[mandatory-pass]*
223
264
  - Family-diversity recruitment picks ≥1 cross-family verifier when present (family by model probe). *[mandatory-pass]*
224
- - Degrade ladder applied: local-only canary+note · none same-family note. *[mandatory-pass]*
265
+ - Degrade ladder applied AND its verdict emitted as a typed `crossfamily:` value in the Axes 2–3
266
+ marker (`panel(<families>)` | `declined` | `DEGRADED_SINGLE_FAMILY` | `DEGRADED_PANEL_UNUSED` |
267
+ `UNKNOWN`); the three degrade values carry substantive grounds on the same line. `single-family`
268
+ is NOT accepted on a load-bearing change — that block only runs there, so "decorrelation not
269
+ required" is a contradiction, and as a no-ack pass it was a free bypass of the lane.
270
+ *[mandatory-pass — enforced by pre-commit, fixtures `scripts/test_marker_crossfamily_lanes.sh`]*
271
+ - Panel members are review-capable: embedding/reranker/OCR/safeguard classes are excluded **before**
272
+ family-diversity is counted, never after. *[mandatory-pass — same fixtures, ordering anchor c4/c5]*
225
273
  - Paid recruit was per-run spend-gated (or `paid_auto` set); free local tier may auto-fire. *[mandatory-pass]*
226
274
  - Governor (CC) retains terminal verdict; every accepted sidecar finding is source-grounded. *[judged, pair: judge-robustness / phantom-quench back-trace]*
227
275
  - Marker records panel + families only with a captured sidecar transcript. *[mandatory-pass]*
@@ -170,6 +170,39 @@ Execution priority (behavioral):
170
170
 
171
171
  > **Detail**: See `SKILL_detail.md §Step3-Execution` — action preview example, FH plugin auto-install bash + error handling table + success format, agent-composer mapping update format — read when executing Step 3.
172
172
 
173
+ ### Step 3-D. Dispatch Standing Request — the one moment this is cheap to settle
174
+
175
+ FH/PMH treat isolated delegation as part of what the harness is, so **parallel dispatch is the
176
+ intended default for anyone running them**. It is not default *by assertion*: some runtimes ship a
177
+ line making unprompted dispatch conditional on a user request, and `CLAUDE.md §Agent Dispatch
178
+ Operation` will not read that condition as met unless a standing request is on disk. **A session with
179
+ no such record asks before every dispatch** — which is the friction this step exists to remove, once,
180
+ here, instead of in every future session.
181
+
182
+ So ask, and write the answer down. Three parts, all required — a record missing one is not a record,
183
+ and a session may never supply the missing part itself:
184
+
185
+ ```
186
+ ① the user's own words, quoted — ask them to say it, then quote what they said
187
+ ② a lease — THE USER picks the length. Do not suggest a default and
188
+ do not fill it in. Renewal needs a fresh utterance later
189
+ ③ a scope — which prohibition line this covers. Granting one never
190
+ grants another (agents ≠ workflows ≠ deep-research):
191
+ name each one the user actually intends
192
+ ```
193
+
194
+ Write it into `CLAUDE.local.md` (gitignored). **Declined is a valid, recorded answer** — write that
195
+ down too, so later sessions stop re-asking rather than reading silence as an unanswered question.
196
+
197
+ **Any mechanical settings change belongs here as well**, under the same consent: if enabling the
198
+ intended default needs a `settings.json` edit, propose it in this step with the diff shown, and apply
199
+ it only on approval. Do not change settings silently on the strength of the doctrine alone — the
200
+ doctrine says dispatch is the default posture, not that the wizard may edit a user's config unasked.
201
+
202
+ ⚠️ **The lease is honoured by reading, not by machinery.** `scripts/consent_registry_check.sh`
203
+ enforces leases for *registered consent classes*, and this grant is not one of those — say so when
204
+ recording it rather than implying an expiry alarm exists.
205
+
173
206
  ### Step 4. Acceleration Baseline Setup
174
207
 
175
208
  After executing approved items, install the automatic maintenance structure: zshrc hook (idempotent, preview-then-confirm) + sentinel initialization (per-project) + weekly-audit cadence (no cron — zshrc hook + session-start detection are the durable mechanisms).
@@ -526,31 +526,120 @@ fi
526
526
  # scripts/package_coverage_check.sh caught the omission — without it an npm-installed wizard hit
527
527
  # FileNotFoundError here and registered nothing while reporting success upstream).
528
528
  python3 - "$FH_DIR" <<'PY'
529
- import json, os, sys, collections
529
+ import json, os, sys, glob, collections, re, shutil
530
530
  hub = sys.argv[1]
531
- snippet = os.path.join(hub, "templates", "settings.SessionStart.snippet.json")
532
531
  target = os.path.join(hub, ".claude", "settings.json")
533
- entry = json.load(open(snippet))["project_settings_json"]["hooks"]["SessionStart"]
532
+
533
+ # DISCOVER every shipped snippet, do not name one. Hardcoding a single snippet path is why the
534
+ # compaction hooks shipped unwired while their own README recited the shipping-is-not-wiring lesson
535
+ # (high review, 2026-08-08 — and PreToolUse was already sitting in the same hole, so N=2).
536
+ # A new templates/settings.*.snippet.json is picked up with zero edits here: generation, not detection.
537
+ snippets = sorted(glob.glob(os.path.join(hub, "templates", "settings.*.snippet.json")))
538
+ if not snippets:
539
+ # exit 1, NOT 0. The pre-rewrite code raised FileNotFoundError here, and that loud failure is what
540
+ # made a broken npm `files[]` detectable — the comment above records it happening for real once.
541
+ # The first rewrite replaced it with a quiet exit 0, i.e. it deleted the detector while keeping the
542
+ # bug (high re-review 2026-08-08 #1). Zero snippets means zero hooks registered; that is never a
543
+ # success, and every other zero-registration path below already exits 1.
544
+ print("NO SNIPPETS under templates/settings.*.snippet.json — zero hooks registered.")
545
+ print(" → package `files[]` may have dropped templates/, or this is not an FH hub. NOT a success.")
546
+ raise SystemExit(1)
547
+
534
548
  d = collections.OrderedDict()
535
549
  if os.path.exists(target):
536
550
  d = json.load(open(target), object_pairs_hook=collections.OrderedDict)
537
- import shutil; shutil.copy2(target, target + ".prewizard") # back up before rewriting
551
+ shutil.copy2(target, target + ".prewizard") # back up before rewriting
538
552
  hooks = d.setdefault("hooks", collections.OrderedDict())
539
- # Merge at HOOK level, not group level. A group-level filter drops the whole group when a user's own
540
- # hook shares a group with the FH one — the common shape when someone hand-edits or appends to an
541
- # older wizard's output. (Cross-family review 2026-07-30 reproduced the loss: a group holding
542
- # [my_telemetry.sh, fh_session_load.sh] lost my_telemetry.sh entirely.)
543
- kept = []
544
- for g in hooks.get("SessionStart", []):
545
- survivors = [h for h in g.get("hooks", []) if "fh_node_check" not in h.get("command", "")]
546
- if survivors:
547
- g = dict(g); g["hooks"] = survivors; kept.append(g)
548
- hooks["SessionStart"] = kept + entry
553
+
554
+ def fh_keys(entry_groups):
555
+ """FH script basenames this snippet owns the replace key is DERIVED from the snippet's own
556
+ commands, never hardcoded, so it cannot drift from what is actually being installed."""
557
+ # Key on the snippet's own PATH FORM (scripts/<name>.sh), not a bare basename. A bare basename
558
+ # made the survivor filter delete a user's own hook that merely shared a filename — e.g. their
559
+ # ~/tools/compaction_probe.sh which is the exact loss the hook-level merge exists to prevent,
560
+ # reintroduced through key derivation (high re-review 2026-08-08 #2).
561
+ keys = set()
562
+ for g in entry_groups:
563
+ for h in g.get("hooks", []):
564
+ for m in re.findall(r'((?:[A-Za-z0-9_\-]+/)*scripts/[A-Za-z0-9_\-]+\.sh)', h.get("command", "")):
565
+ keys.add("scripts/" + m.rsplit("scripts/", 1)[1])
566
+ return keys
567
+
568
+ registered, bad_schema, intended = [], [], {}
569
+ for snip in snippets:
570
+ try:
571
+ blob = json.load(open(snip))
572
+ except Exception as e:
573
+ print("SKIP (unparsable):", os.path.basename(snip), e); bad_schema.append(snip); continue
574
+ proj = (blob.get("project_settings_json") or {}).get("hooks") or {}
575
+ if not proj:
576
+ # settings_local_json_MODE_D_ONLY entries carry private paths — never auto-written here.
577
+ print("SKIP (no project_settings_json):", os.path.basename(snip)); bad_schema.append(snip); continue
578
+ for event, entry in proj.items():
579
+ # Shape-validate before touching the user's file. A valid-JSON / invalid-SCHEMA snippet used
580
+ # to be written anyway and reported as success, then detonate on the NEXT run inside the
581
+ # survivor filter — far from the snippet that caused it (lanes BS-*, LT-*).
582
+ if not isinstance(entry, list) or not all(
583
+ isinstance(g, dict) and isinstance(g.get("hooks"), list) for g in entry):
584
+ print("SKIP (bad schema):", os.path.basename(snip), event); bad_schema.append(snip); continue
585
+ keys = fh_keys(entry)
586
+ if not keys:
587
+ print("SKIP (no FH script in commands):", os.path.basename(snip), event)
588
+ bad_schema.append(snip); continue
589
+ intended[event] = intended.get(event, set()) | keys
590
+ # Merge at HOOK level, not group level. A group-level filter drops the whole group when a
591
+ # user's own hook shares a group with the FH one (cross-family review 2026-07-30 reproduced
592
+ # the loss: [my_telemetry.sh, fh_session_load.sh] lost my_telemetry.sh entirely).
593
+ kept = []
594
+ for g in hooks.get(event, []):
595
+ survivors = [h for h in g.get("hooks", [])
596
+ if not any(k in h.get("command", "") for k in keys)]
597
+ if survivors:
598
+ g = dict(g); g["hooks"] = survivors; kept.append(g)
599
+ hooks[event] = kept + entry
600
+ registered.append(f"{event}({','.join(sorted(keys)) or '?'})")
601
+
549
602
  os.makedirs(os.path.dirname(target), exist_ok=True)
550
603
  with open(target, "w") as fh:
551
604
  json.dump(d, fh, indent=2, ensure_ascii=False); fh.write("\n")
552
- print("node-check SessionStart hook registered ->", target)
605
+ # RE-READ WHAT WE JUST WROTE. The success message used to be unconditional — it never checked the
606
+ # serialized result, so a snippet that failed to register still printed "registered ->". Verify, then
607
+ # claim (`[[feedback_gate_verification_must_execute]]`).
608
+ written = json.load(open(target))
609
+ missing = []
610
+ for event, keys in intended.items():
611
+ blob = json.dumps(written.get("hooks", {}).get(event, []))
612
+ for k in sorted(keys):
613
+ if k not in blob:
614
+ missing.append(f"{event}:{k}")
615
+ for r in registered: print(" hook registered ->", r)
616
+ print("snippets scanned:", len(snippets), "· hook entries registered:", len(registered),
617
+ "· skipped:", len(bad_schema), "->", target)
618
+ if missing:
619
+ print("FAILED to register:", ", ".join(missing)); raise SystemExit(1)
620
+ # FAIL CLOSED on any skipped snippet. The good ones are already written (a malformed sibling must not
621
+ # cost you the working hooks), but the RUN exits non-zero — an installer that skips a snippet and
622
+ # reports success leaves hooks unwired, which is the exact defect this rewrite exists to close.
623
+ # Lanes MG-N1..N4 caught this: the first draft skipped-and-exited-0 = fail-open.
624
+ if bad_schema:
625
+ print("SKIPPED snippet(s):", ", ".join(sorted({os.path.basename(b) for b in bad_schema})))
626
+ print(" → non-zero exit on purpose: a skipped snippet is unregistered hooks, not a warning.")
627
+ raise SystemExit(1)
628
+ if not registered:
629
+ print("no hook entries registered — nothing to claim"); raise SystemExit(1)
553
630
  PY
631
+ # CONSUME the merge block's exit status. It raises SystemExit(1) on a skipped/failed registration,
632
+ # and the surrounding bash has no `set -e` — so without this check the wizard sailed past a failed
633
+ # registration and §Step5 still printed success, which is the exact "reports success while unwired"
634
+ # shape the fail-closed direction was added to prevent (high re-review 2026-08-08 #6).
635
+ # The failure is SURFACED, not swallowed: the operator sees which snippet did not register.
636
+ _MERGE_RC=$?
637
+ if [ "$_MERGE_RC" -ne 0 ]; then
638
+ echo "🟥 hook registration FAILED (exit $_MERGE_RC) — one or more snippets are UNREGISTERED."
639
+ echo " Do not read the completion report below as 'hooks are wired'. Fix the snippet and re-run"
640
+ echo " this step; the rest of the wizard continues so the remaining setup is not lost."
641
+ WIZARD_HOOK_REG_FAILED=1
642
+ fi
554
643
  chmod +x "$FH_DIR/scripts/fh_node_check.sh" 2>/dev/null
555
644
  # VERIFY against a THROWAWAY state file (FH_NODE_STATE). Verifying against the real state would
556
645
  # consume the user's one-shot event report, so their actual first session goes quiet and the notice