@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,124 @@
1
+ # reviewer_capability_corpus.tsv — SHARED known-pair corpus for "can this model be an
2
+
3
+ # adversarial reviewer?". Language-neutral DATA, deliberately not code.
4
+ #
5
+ # WHY DATA AND NOT A SHARED LIBRARY. The same judgment lives in at least three places, in
6
+ # three languages, across a public/private repo boundary: a public gate hook (bash), a
7
+ # field probe (shell), and a review backend (python). They cannot import each other —
8
+ # different repos, and the private side's vocabulary contains identifiers that must not
9
+ # reach a public repo. So the shareable artifact is the CORPUS, not the classifier: each
10
+ # side implements in its own language and proves agreement by running these rows.
11
+ #
12
+ # Measured drift 2026-08-08, before this file existed: 19 probe ids, **10 disagreements**
13
+ # between two of the implementations — and asymmetrically, 9 of them were the private side
14
+ # reading INCAPABLE models as CAPABLE. A permissive drift on the side that faces a real
15
+ # model catalog is the dangerous direction: it inflates panel size and family-diversity
16
+ # counts simultaneously, so the defense reports green while the panel cannot review.
17
+ #
18
+ # RESIDENCY: this file is PUBLIC. Generic capability classes and publicly-documented model
19
+ # families only. A corp/internal model id NEVER goes here — carry those in a local overlay
20
+ # corpus alongside (gitignored), same two-layer shape as the public-surface pattern file.
21
+ #
22
+ # NAMESPACES — declare what you own, skip what you do not (OPA-bundle `roots` shape).
23
+ # Two different things get classified under the same question and they are NOT interchangeable:
24
+ # ns:cli sidecar / CLI names a panel is composed of — `codex`, `gemini`, `agy`
25
+ # ns:model-id concrete served model identifiers — `voyage-3`, `glm-ocr`
26
+ # A consumer that classifies model ids has no opinion on `codex`; a consumer that composes
27
+ # panels has no opinion on `bge-m3`. Mixing them silently is a defect: measured 2026-08-08,
28
+ # the first draft of this corpus put both in one list and a model-id classifier failed the
29
+ # `codex` row — reported as implementation drift when it was corpus scope error. An adapter
30
+ # MUST declare its namespaces and SKIP rows outside them EXPLICITLY (a skipped row is
31
+ # reported, never silently counted as passing — unmeasured is not clean).
32
+ #
33
+ # FORMAT (tab-separated): id <TAB> expected <TAB> ns:<namespace>/<class> <TAB> why
34
+ # expected ∈ CAPABLE | INCAPABLE | UNDECIDABLE
35
+ # CAPABLE a generative model of a family known to return prose findings
36
+ # INCAPABLE emits vectors / scores / labels / transcripts — cannot return a finding
37
+ # UNDECIDABLE unrecognised. NOT "capable by default" — default-deny, because a
38
+ # denylist cannot know that `voyage-3` is an embedding model. An
39
+ # implementation that answers CAPABLE here is failing open.
40
+ #
41
+ # THE CORPUS STATES GROUND TRUTH, NOT WHAT ANY IMPLEMENTATION CAN SEE. `voyage-3` IS an
42
+ # embedding model, so the row says INCAPABLE — even though a pattern classifier cannot
43
+ # tell from the string. Grading is therefore on OUTCOME SAFETY, not label identity:
44
+ #
45
+ # hard FAIL want ∈ {INCAPABLE, UNDECIDABLE} and got = CAPABLE (permissive — ships a
46
+ # broken panel; the only direction that can actually hurt)
47
+ # hard FAIL want = CAPABLE and got ≠ CAPABLE (over-block — an
48
+ # always-red gate teaches --no-verify)
49
+ # DEVIATION INCAPABLE ↔ UNDECIDABLE in either direction (reported, not failed:
50
+ # both BLOCK under default-deny, so the outcome is identical)
51
+ #
52
+ # This rule exists because the first draft graded on label identity and was silently fitted
53
+ # to one implementation's regex — `armorm` was written UNDECIDABLE and `voyage-3` INCAPABLE
54
+ # purely because that regex caught one and not the other. Two ids with the same property,
55
+ # two different standards, and the corpus was grading itself. Worse, it made "delete the
56
+ # row" the rational move for whoever maintained the weaker classifier.
57
+ #
58
+ # ORDERING INVARIANT the rows enforce: ineligibility must be tested BEFORE eligibility.
59
+ # Rows marked `overlap` match a valid family AND an incapable class simultaneously; an
60
+ # eligibility-first implementation admits them silently. They are the anchor for the ORDER,
61
+ # not for the list.
62
+
63
+ # ── CONTRACT PIN ───────────────────────────────────────────────────────────────
64
+ #CORPUS-VERSION 2
65
+ #CORPUS-SHA256 e0d8d83b9ae042e4
66
+ # Hash covers the DATA rows only (comments/format may be reworded without a version bump).
67
+ # Recompute EXACTLY this way — two ways of computing it is itself a drift source, and the
68
+ # first draft of this pin failed on a trailing-newline difference between two implementations:
69
+ # grep -E '^[^#[:space:]]' reviewer_capability_corpus.tsv | shasum -a 256 | cut -c1-16
70
+ # Every consumer recomputes it and compares. A mismatch is HARNESS_ERROR — not PASS, not FAIL:
71
+ # it means the consumer is reading a DIFFERENT corpus than the one this contract names, and a
72
+ # drifted local copy is the failure mode that makes every downstream instrument report healthy
73
+ # while nothing is actually shared. (Independent precedent: OPA bundle `roots`, test262/CNCF
74
+ # conformance-suite-as-contract; failure taxonomy: local-first resolution · silent skipping ·
75
+ # drifted duplicates.)
76
+
77
+ # ── capable: generative families ────────────────────────────────────────────────
78
+ codex CAPABLE ns:cli/generative GPT-family agent CLI
79
+ gpt-5.5 CAPABLE ns:model-id/generative GPT family
80
+ gemini CAPABLE ns:cli/generative Gemini family
81
+ qwen3-32b CAPABLE ns:model-id/generative Qwen family, generative variant
82
+ glm-4.6 CAPABLE ns:model-id/generative GLM family, generative variant
83
+ deepseek-v3 CAPABLE ns:model-id/generative DeepSeek family
84
+ mistral-large CAPABLE ns:model-id/generative Mistral family
85
+ llama-4-70b CAPABLE ns:model-id/generative Llama family
86
+
87
+ # ── incapable: named by class token ─────────────────────────────────────────────
88
+ text-embedding-3-large INCAPABLE ns:model-id/embedding emits vectors
89
+ some-reranker-v1 INCAPABLE ns:model-id/reranker emits scores
90
+ generic-ocr-2b INCAPABLE ns:model-id/ocr emits transcribed text, not judgment
91
+ acme-safeguard-8b INCAPABLE ns:model-id/safeguard classifier — emits labels
92
+ text-moderation-latest INCAPABLE ns:model-id/moderation classifier — emits labels
93
+ whisper-large-v3 INCAPABLE ns:model-id/speech speech-to-text
94
+
95
+ # ── incapable WITHOUT a class token in the name (the denylist's blind spot) ─────
96
+ # These are why a denylist alone is insufficient and default-deny is required: nothing in
97
+ # the string says "embedding". Every one is a real, publicly-documented model.
98
+ voyage-3 INCAPABLE ns:model-id/embedding embedding model; name carries no class token
99
+ bge-m3 INCAPABLE ns:model-id/embedding embedding model; name carries no class token
100
+ gte-large INCAPABLE ns:model-id/embedding embedding model; name carries no class token
101
+ all-minilm-l6-v2 INCAPABLE ns:model-id/embedding embedding model; name carries no class token
102
+ armorm INCAPABLE ns:model-id/reward reward model — emits a scalar; opaque name, no class token
103
+ starling-rm INCAPABLE ns:model-id/reward reward model — emits a scalar
104
+ cohere-rank-v3 INCAPABLE ns:model-id/reranker reranker named `rank`, not `rerank`
105
+
106
+ # ── overlap: matches a VALID family AND an incapable class (ordering anchor) ────
107
+ glm-ocr INCAPABLE ns:model-id/overlap matches `glm` (capable) and `ocr` (incapable) — order decides
108
+ qwen3-embedding-8b INCAPABLE ns:model-id/overlap matches `qwen` (capable) and `embed` (incapable)
109
+ snowflake-arctic-embed-l-v2.0 INCAPABLE ns:model-id/overlap family prefix unrecognised; `embed` decides
110
+
111
+ # ── undecidable: unrecognised → default-deny, never assumed capable ─────────────
112
+ some-new-model-x UNDECIDABLE ns:model-id/unknown no known family; answering CAPABLE here is fail-open
113
+ internal-model-7 UNDECIDABLE ns:model-id/unknown no known family
114
+ codeguardian UNDECIDABLE ns:cli/unknown NOT auto-incapable — a bare `guard` substring rule blocks this legitimately-named review tool; unrecognised, so default-deny applies, but as UNDECIDABLE not INCAPABLE
115
+
116
+ # ── same-family exclusion (decorrelation, not capability) ──────────────────────
117
+ # Capable models that must still be REJECTED as panel members when the governor is of that
118
+ # family. Capability and decorrelation are different axes; an implementation that only
119
+ # checks capability passes a same-family panel. Marked separately so an adapter that does
120
+ # not own the decorrelation axis can skip this block explicitly rather than silently.
121
+ #SAMEFAMILY claude
122
+ #SAMEFAMILY opus
123
+ #SAMEFAMILY sonnet
124
+ #SAMEFAMILY haiku
@@ -211,6 +211,45 @@ else
211
211
  fail=1
212
212
  fi
213
213
 
214
+ # embedded --self-test suites (compaction_probe · judgment_circuit_lint · novelty_claim_check).
215
+ # These three carry their lanes INSIDE the script (`--self-test`) rather than in a sibling
216
+ # test_*_lanes.sh, so the name-list wiring above skipped them silently: 48 lanes existed and ran
217
+ # only when a human typed the command. That is built-but-not-wired applied to the anchors themselves
218
+ # — a later edit that breaks a lane stays green everywhere the project actually checks
219
+ # (high re-review 2026-08-08). Same shape as the block above: subject absent → SKIP, subject present
220
+ # but self-test missing → FAIL, never a silent pass.
221
+ for _subj in compaction_probe judgment_circuit_lint novelty_claim_check; do
222
+ if [ ! -f "scripts/$_subj.sh" ]; then
223
+ echo "SKIP $_subj --self-test (subject scripts/$_subj.sh absent)"
224
+ else
225
+ # ⚠️ **문자열 존재로 판정하지 마라.** 초판은 `grep -q -- '--self-test'` 였는데, 그 문자열은
226
+ # 헤더 주석과 usage echo 에도 있어서 **디스패처 한 줄만 지워도 여전히 매치**한다. 그리고
227
+ # 인식 못 한 모드에서 스크립트가 usage 를 찍고 exit 0 을 내므로, selfcheck 는 rc=0 을 보고
228
+ # 조용히 통과했다 — 25개 레인이 통째로 사라져도 `npm test` 는 PASS (high 3차 리뷰 실측).
229
+ # 존재검사가 진위를 못 본다는 그 클래스의 재발이다. **실행이 일어났다는 증거**를 요구한다.
230
+ # `< /dev/null` 필수: 인식 못 한 모드로 떨어지면 스크립트가 stdin 을 기다려 **무한 대기**한다
231
+ # (실측 — 디스패처 제거 known-negative 가 2분 타임아웃). CI 를 멈추는 건 조용한 통과보다 나쁘다.
232
+ # `timeout` 은 GNU coreutils 이고 **stock macOS 에 없다**. 가용성 확인 없이 부르면 rc=127 +
233
+ # 빈 출력 → 아래 `*)` 가 발동해 "dispatcher missing?" 이라는 **틀린 원인**으로 거짓 FAIL 이
234
+ # 난다(실측: homebrew 없는 PATH 에서 3개 subject 전부). 소비자 머신에서 `npm test` 와
235
+ # `prepublishOnly` 를 깨뜨리는 경로다. 정답 폼은 이미 레포에 있다(sync-from-be.sh:134).
236
+ # 없으면 무한대기 방지를 잃는 대신 도는 쪽을 택한다 — `< /dev/null` 이 그 방어의 본체다.
237
+ local _to=""; command -v timeout >/dev/null 2>&1 && _to="timeout 120"
238
+ _st_out="$($_to bash "scripts/$_subj.sh" --self-test < /dev/null 2>&1)"; _st_rc=$?
239
+ case "$_st_out" in
240
+ *캘리브레이션*) : ;;
241
+ *) echo "FAIL $_subj: --self-test produced no calibration verdict (dispatcher missing?)"
242
+ printf '%s\n' "$_st_out" | head -3 | sed 's/^/ /'
243
+ fail=1; _st_rc=0 ;; # 이미 FAIL 로 셌으니 아래서 중복 계상 안 한다
244
+ esac
245
+ if [ "$_st_rc" -ne 0 ]; then
246
+ echo "FAIL $_subj --self-test (exit $_st_rc)"
247
+ printf '%s\n' "$_st_out" | tail -6 | sed 's/^/ /'
248
+ fail=1
249
+ fi
250
+ fi
251
+ done
252
+
214
253
  # memory-link-check — the memory store is a GRAPH (memory_intent_recall.md: nodes=files,
215
254
  # edges=[[links]], recall walks one hop). Measured 2026-07-28: 50 of 872 edges pointed at a note
216
255
  # that existed under a different separator and 22 at nothing — a dead edge returns nothing and is
@@ -281,6 +320,34 @@ else
281
320
  fail=1
282
321
  fi
283
322
 
323
+ # branch-claim lanes — shipped with 27 hand-run lanes and ZERO callers, so CI never ran one of them.
324
+ # That is the exact defect its own subject guards against (a gate nobody claims against passes
325
+ # forever), reproduced one layer up in its anchor. Same SKIP/FAIL shape: a missing subject is a
326
+ # legitimate skip, a present subject with a missing anchor is a real failure. The lanes are hermetic
327
+ # (per-PID temp dirs under the worktree, no network, no API spend), so running them here costs
328
+ # nothing.
329
+ #
330
+ # ⚠️ Subject-absent is a FAIL here, NOT the SKIP the sibling blocks use. The SKIP arm exists for
331
+ # subjects that may legitimately not ship; branch_claim.sh IS in package.json files[], so it is
332
+ # present in the source checkout AND in the published package — absence can only mean deletion.
333
+ # Nothing else catches that: measured 2026-08-09, package_coverage_check.sh returns PASS on a
334
+ # files[] entry whose file does not exist, so a deleted subject would be green on every surface
335
+ # (SKIP here + PASS there + npm silently omitting it). A cross-family reviewer caught this; the
336
+ # first draft of this block used SKIP and would have silenced exactly that case.
337
+ # Named residual, deliberately not fixed here: the sibling blocks above carry the same hole for
338
+ # their own shipped subjects. Fixing them is a separate change with its own verification.
339
+ if [ ! -f scripts/branch_claim.sh ]; then
340
+ echo "FAIL scripts/branch_claim.sh is in package.json files[] but absent — a deleted subject, not a skip"
341
+ fail=1
342
+ elif [ -f scripts/test_branch_claim_lanes.sh ]; then
343
+ if ! bash scripts/test_branch_claim_lanes.sh; then
344
+ fail=1
345
+ fi
346
+ else
347
+ echo "FAIL test_branch_claim_lanes.sh: branch_claim.sh present but its anchor is missing"
348
+ fail=1
349
+ fi
350
+
284
351
  # And the ablation calibrator, for the third instance of the same reason. Its whole job is telling
285
352
  # apart states that look identical from outside — "the arm could not answer" vs "the runner is dead"
286
353
  # vs "the runner read the answer off disk" — and round 2 of its own adversarial review found that
@@ -439,6 +506,18 @@ else
439
506
  fail=1
440
507
  fi
441
508
 
509
+ # hook_source_gate 는 subject 가 session_close_check.sh 가 아니라 별도로 돌린다 — 위 루프의
510
+ # SKIP 조건(subject 부재)에 얹으면 엉뚱한 이유로 건너뛴다.
511
+ if [ -f scripts/test_hook_source_gate_lanes.sh ]; then
512
+ if bash scripts/test_hook_source_gate_lanes.sh >/dev/null 2>&1; then
513
+ echo "PASS test_hook_source_gate_lanes.sh"
514
+ else
515
+ echo "FAIL test_hook_source_gate_lanes.sh (SessionStart source 게이트 쌍 붕괴)"
516
+ bash scripts/test_hook_source_gate_lanes.sh 2>&1 | grep "❌" | head -5 | sed "s/^/ /"
517
+ fail=1
518
+ fi
519
+ fi
520
+
442
521
  for _anchor in scripts/test_session_close_lanes.sh scripts/test_card_drift_probe.sh scripts/test_session_close_chain_lanes.sh; do
443
522
  if [ ! -f scripts/session_close_check.sh ]; then
444
523
  echo "SKIP ${_anchor##*/} (subject scripts/session_close_check.sh absent)"
@@ -520,6 +599,33 @@ else
520
599
  fail=1
521
600
  fi
522
601
 
602
+ # utterance landing check — the close chain's CONTENT anchor, and it has to be RUN, not parsed.
603
+ # Measured 2026-08-08 on the branch that introduced it: the only thing in this file that touched
604
+ # `scripts/utterance_landing_check.sh` was the `bash -n` syntax sweep at the top. Syntax passing is
605
+ # not the instrument working, so the script shipped via files[] with zero behavioural callers —
606
+ # [[feedback_built_but_not_wired]] in its purest form, where the sole caller is prose in CLAUDE.md.
607
+ #
608
+ # What the self-test defends is specifically the DEGRADE DIRECTION. This checker's whole reason for
609
+ # existing is that a dead grep prints zero hits and zero hits read as "nothing landed" — a fail-open
610
+ # that manufactures a clean verdict out of a broken instrument. Its known-pair set pins that apart:
611
+ # a genuine miss must exit 1 while a dead control must exit 10, and 10 must never collapse into 1.
612
+ # Left unrun, the file rots exactly where it is load-bearing and nothing here would notice.
613
+ # Absence is a FAIL, not a SKIP — and the distinction is mechanical, not stylistic. The SKIP arm
614
+ # above for `probe_scope_check.sh` is correct because that file genuinely never ships; this one is
615
+ # listed in package.json files[], so in BOTH a source tree and an installed package it must be here.
616
+ # A SKIP would convert `rm scripts/utterance_landing_check.sh` into a green run — deletion as a
617
+ # clean bill of health, which is the same fail-open shape the checker itself exists to refuse.
618
+ if [ ! -f scripts/utterance_landing_check.sh ]; then
619
+ echo "FAIL utterance_landing_check.sh: missing — it ships via package.json files[], so absence is deletion, not package mode"
620
+ fail=1
621
+ elif _out=$(bash scripts/utterance_landing_check.sh --self-test 2>&1); then
622
+ echo "PASS utterance_landing_check.sh (known-pair self-test: control-death → 10, target-miss → 1)"
623
+ else
624
+ echo "FAIL utterance_landing_check.sh: self-test failed — the close-chain content anchor cannot be trusted"
625
+ _show_failure "$_out"
626
+ fail=1
627
+ fi
628
+
523
629
  # tag/version consistency guard. Wired with the guard it anchors: the guard exists because a wrong
524
630
  # tag reached the remote and a publish from the wrong tree was stopped only by npm's own collision
525
631
  # check, so an unrun anchor here would be the same luck-as-floor arrangement one layer up.
@@ -94,6 +94,128 @@ if command -v gh >/dev/null 2>&1; then
94
94
  [ "${PRS:-0}" -gt 0 ] && echo "⚠️ ①-b $PRS open PR(s) by you — classify: self-mergeable vs awaiting-external"
95
95
  fi
96
96
 
97
+ # ── ①-d CLOSE RE-OPENED? ──────────────────────────────────────────────────────
98
+ # WHY: a session that closed cleanly and then kept working looks IDENTICAL to one that never
99
+ # closed — both read Completed from outside, and ①-c cannot separate them. Measured 2026-08-09:
100
+ # this session ran step 5 eleven times and a peer independently named the same gap. The two need
101
+ # opposite actions — an unfinished close must be FINISHED, a re-opened one must be RE-FOLDED.
102
+ # HOW: stamp each close-gated pass and count. Self-attested (whoever runs the check writes it):
103
+ # it detects the pattern, it does not prove it.
104
+ CLOSE_STAMP="$FH/tracks/_meta/.close_stamps_$TODAY"
105
+ if [ "${FH_SESSION_CLOSE:-0}" = "1" ]; then
106
+ mkdir -p "$(dirname "$CLOSE_STAMP")" 2>/dev/null
107
+ date -u +%Y-%m-%dT%H:%M:%SZ >> "$CLOSE_STAMP" 2>/dev/null || true
108
+ fi
109
+ if [ -f "$CLOSE_STAMP" ]; then
110
+ CLOSE_N=$(grep -c . "$CLOSE_STAMP" 2>/dev/null | tr -d ' ')
111
+ if [ "${CLOSE_N:-0}" -gt 1 ]; then
112
+ echo "ℹ️ ①-d close ran $CLOSE_N times today — CLOSED-THEN-WORKED, not a stalled close."
113
+ echo " Both look Completed from outside; only this counter separates them."
114
+ echo " Every re-open owes a FULL step 5: append to fh_completed FIRST, then rewrite the card."
115
+ fi
116
+ fi
117
+
118
+ # ── ①-c LIVE PEER SESSIONS in this same harness ─────────────────────────────────
119
+ # WHY: FH's close chain has an agreed two-axis order (peer appends → ping → **ask "더 있나" before
120
+ # folding** → fold → re-check merged PRs), and it lived only in the session card as prose. Measured
121
+ # 2026-08-09, the first two-axis close: the ping surfaced four deltas the closing session did not
122
+ # know about, and **two of them were structurally invisible to `gh pr list`** (an inheritance
123
+ # channel and a landing check — no PR exists for either). So a PR sweep is not a substitute; the
124
+ # question is. This block does the DETECTION half mechanically. It never merges anything and never
125
+ # blocks — the merge is where the loss risk is (two sessions writing one card is the shared-checkout
126
+ # hazard this repo already named), and one occurrence is below this repo's own mechanization bar.
127
+ #
128
+ # HOW: a live session owns /tmp/cc-socks/<pid>.sock. pid → cwd resolves the ONE thing that actually
129
+ # matters, "is this peer in MY harness" — which the agent-facing session list cannot answer, because
130
+ # it reports names, not directories. Stale sockets are detectable (pid gone), so absence here is a
131
+ # measurement rather than silence.
132
+ # SCOPE (operator, 2026-08-09): this is a **claude-agents / Agent-View** feature — the surface where
133
+ # sessions are deliberately run in parallel. A plain solo terminal must not be nagged because some
134
+ # other window happens to be open. Discriminator is env, set by the parent that spawns the session.
135
+ # ⚠️ NEGATIVE ARM UNVERIFIED: a child session cannot observe a top-level session's environment, so
136
+ # "a real top-level session lacks these vars" is asserted, not measured. The lane simulates absence
137
+ # by unsetting them, which tests THIS SCRIPT'S logic — not that claim. Verify from a plain terminal.
138
+ if [ -z "${CLAUDE_CODE_CHILD_SESSION:-}${CLAUDE_CODE_AGENT:-}" ] && [ "${FH_PEER_SCAN_FORCE:-0}" != "1" ]; then
139
+ : # solo/top-level surface — ①-c is out of scope here, and silence is the correct output
140
+ else
141
+ PEER_LIVE=0; PEER_UNKNOWN=0; PEER_STALE=0; PEER_SPARE=0; PEER_IDS=""
142
+ SOCK_DIR="${FH_PEER_SOCK_DIR:-/tmp/cc-socks}"
143
+ # Self-exclusion must walk the ANCESTOR chain, not compare `$$`. This script is a grandchild of the
144
+ # session process that owns the socket, so `$$` never matches it — measured: the first run counted
145
+ # the calling session as its own peer, i.e. it would tell you to go ask yourself.
146
+ SELF_CHAIN=" $$ "
147
+ _p=$$
148
+ while :; do
149
+ _p=$(ps -o ppid= -p "$_p" 2>/dev/null | tr -d ' ')
150
+ case "$_p" in ''|0|1) break ;; esac
151
+ SELF_CHAIN="$SELF_CHAIN$_p "
152
+ done
153
+ _peer_cwd() { # $1=pid → cwd on stdout, empty if unresolvable
154
+ if [ -r "/proc/$1/cwd" ]; then readlink "/proc/$1/cwd" 2>/dev/null; return; fi
155
+ command -v lsof >/dev/null 2>&1 || return 0
156
+ lsof -a -p "$1" -d cwd -Fn 2>/dev/null | sed -n 's/^n//p' | head -1
157
+ }
158
+ if [ -d "$SOCK_DIR" ]; then
159
+ for _s in "$SOCK_DIR"/*.sock; do
160
+ [ -e "$_s" ] || continue
161
+ _pid="${_s##*/}"; _pid="${_pid%.sock}"
162
+ case "$_pid" in ''|*[!0-9]*) continue ;; esac
163
+ case "$SELF_CHAIN" in *" $_pid "*) continue ;; esac # 나 자신(조상 체인)
164
+ if ! ps -p "$_pid" >/dev/null 2>&1; then PEER_STALE=$((PEER_STALE+1)); continue; fi
165
+ # bg-spare = pre-warmed worker, NOT a session. It owns a socket and a cwd like everything else,
166
+ # so cwd matching alone counts it as a peer — measured 2026-08-09, and it is a pure false
167
+ # positive with a clean signature. Exclude mechanically rather than explaining it away.
168
+ case "$(ps -p "$_pid" -o args= 2>/dev/null)" in *--bg-spare*) PEER_SPARE=$((PEER_SPARE+1)); continue ;; esac
169
+ _pcwd="$(_peer_cwd "$_pid")"
170
+ if [ -z "$_pcwd" ]; then
171
+ # Alive but undirected. NOT zero — a peer we cannot place is exactly the case that must not
172
+ # render as "no peers" (not-found != zero).
173
+ PEER_UNKNOWN=$((PEER_UNKNOWN+1)); continue
174
+ fi
175
+ case "$_pcwd" in
176
+ "$FH"|"$FH"/*) PEER_LIVE=$((PEER_LIVE+1)); PEER_IDS="$PEER_IDS $_pid" ;;
177
+ esac
178
+ done
179
+ else
180
+ PEER_UNKNOWN=-1
181
+ fi
182
+ if [ "$PEER_UNKNOWN" = "-1" ]; then
183
+ echo "ℹ️ ①-c peer scan UNMEASURED — no session-socket dir at $SOCK_DIR (not 'no peers')"
184
+ elif [ "$PEER_LIVE" -gt 0 ]; then
185
+ echo "⚠️ ①-c $PEER_LIVE peer CANDIDATE(s) in THIS harness —$PEER_IDS"
186
+ echo " The surplus over the fleet view Working list is MIXED — do not read it as one thing."
187
+ echo " Measured 2026-08-09, three kinds land here and only the first needs a message:"
188
+ echo " working session → ask before folding"
189
+ echo " idle terminal → a real session sitting at a prompt. Not working. Usually skip."
190
+ echo " lingering process → a close that never finished (seen once, had to be killed by hand)"
191
+ echo " Only the fleet view separates them. An earlier version of this text called the whole"
192
+ echo " surplus unfinished closes; that was one case generalised, and it was wrong."
193
+ echo " Do NOT switch to the job state file to narrow it: it marked THIS session done"
194
+ echo " (firstTerminalAt set) while it was still running — it under-reports as hard as"
195
+ echo " sockets over-report. Cross-read with the fleet view; neither source alone is right."
196
+ echo " THIS IS A DETECTION LIST, NOT A RECIPIENT LIST — do not message all of them."
197
+ echo " What survives here is: a live PROCESS whose cwd is this harness, minus pre-warmed"
198
+ echo " spares. That still includes IDLE TERMINALS sitting at a prompt. It is NOT a list of"
199
+ echo " sessions that are working — nothing here can tell you that; the fleet view can."
200
+ echo " Notify only the ones actually WORKING/BUSY right now. Messaging a finished session"
201
+ echo " is noise, burns a recipient approval, and does not land in their record."
202
+ echo " Of those still working, ASK '지금부터 마감이다, 더 있나' — the question is wider than a"
203
+ echo " PR sweep: a peer's inheritance-channel or landing-check work has no PR to find."
204
+ echo " Then fold, then re-check \`gh pr list --merged\`. Merging their delta stays MANUAL."
205
+ echo " SENDING IS NOT DELIVERING — a cross-session message can be held for the recipient"
206
+ echo " user approval and never arrive; the send call still reports success. Measured"
207
+ echo " 2026-08-09: 2 of 5 notices were held while the card already claimed all 5 informed."
208
+ echo " Record ANSWERS, not sends, and put the unanswered count in the card."
209
+ [ "$PEER_UNKNOWN" -gt 0 ] && echo " + $PEER_UNKNOWN live session(s) whose directory is UNKNOWN (counted, not dismissed)"
210
+ elif [ "$PEER_UNKNOWN" -gt 0 ]; then
211
+ echo "⚠️ ①-c 0 peers placed in this harness, but $PEER_UNKNOWN live session(s) are UNPLACEABLE"
212
+ echo " — treat as possible peers and ask; do not read this as 'closing alone'."
213
+ else
214
+ echo "✅ ①-c no live peer in this harness (${PEER_STALE} stale · ${PEER_SPARE} pre-warmed spare(s) ignored)"
215
+ fi
216
+
217
+ fi
218
+
97
219
  # ② FH assets changed today → harvest-loop owed
98
220
  # NOTE: no `grep -q` here — under `set -o pipefail`, -q's early exit SIGPIPEs git log (141),
99
221
  # masking a real match as pipeline failure so the warning NEVER fired on true positives
@@ -219,7 +341,18 @@ DISPATCHED=$(_int "$(grep -c "^$TODAY$" "$TALLY" 2>/dev/null | tr -d ' ' || true
219
341
  # while anything appended via yaml.dump renders `- date: '"'"'2026-08-02'"'"'`. Matching one form counted
220
342
  # half the entries as absent — a divergent-normalizer miss inside the check that exists to catch
221
343
  # missing records. Known-pair calibrated below in test_dispatch_log_lanes.sh.
222
- LOGGED=$(_int "$(grep -cE "^- date: *'?$TODAY'?" "$LOG" 2>/dev/null | tr -d ' ' || true)")
344
+ # ── 전환 설계: 레거시 단일 파일 세션별 디렉터리를 **둘 다** 읽는다 ────────────
345
+ # WHY: 같은 파일 끝에 여러 세션이 append 하면 브랜치 병합이 확정 충돌이고, 그 해소가
346
+ # 조용히 손상된다 — git 이 엔트리 헤더 한 줄을 공통 접두로 밀어내서 «양쪽 다 보존»이라는
347
+ # 정답 해소가 엔트리를 흡수시킨다(2026-08-09 재현: 항목 133/정답 134, 파싱은 통과).
348
+ # 처방은 세션당 한 파일. 다만 **마이그레이션과 배선을 같은 커밋에 묶으면** 아직 머지 안 된
349
+ # 원장 브랜치와 대형 충돌이 나므로, 소비자를 먼저 «둘 다 읽게» 만든다. 그러면 데이터가
350
+ # 언제 옮겨가든 이 검사는 안 깨진다. 두 상태 모두 known-pair 로 고정돼 있다.
351
+ LOGDIR="$FH/knowledge/shared/learnings/subagent_invocations"
352
+ _cat_logs() { # 다중 파일에 grep -c 를 직접 걸면 파일별 `경로:개수` 를 찍어 합산이 깨진다(실측).
353
+ { [ -f "$LOG" ] && cat "$LOG"; [ -d "$LOGDIR" ] && cat "$LOGDIR"/*.yaml; } 2>/dev/null
354
+ }
355
+ LOGGED=$(_int "$(_cat_logs | grep -cE "^- date: *'?$TODAY'?" | tr -d ' ' || true)")
223
356
  if [ "${DISPATCHED:-0}" -gt 0 ] && [ "${LOGGED:-0}" -eq 0 ]; then
224
357
  echo "❌ ④-e $DISPATCHED sub-agent dispatch(es) today and ZERO invocation-log entries — the 60/40"
225
358
  echo " promotion gate and the UAP loop both read that file; an unlogged session is invisible to"
@@ -0,0 +1,231 @@
1
+ #!/usr/bin/env bash
2
+ # test_branch_claim_lanes.sh — branch_claim.sh known-pair 앵커.
3
+ #
4
+ # 왜 실행 테스트인가: `bash -n` 은 계기가 아니다 — 문법만 보고 exit 0 을 내므로
5
+ # «전부 통과»와 구분이 안 된다([[feedback_gate_verification_must_execute]]).
6
+ # «막혔다» ≠ «옳게 막혔다» 이므로 BLOCK 레인은 **완성 문장**으로 매칭한다 —
7
+ # 조각 매칭은 claim/HEAD 역할을 뒤집는 뮤테이션을 통과시켰다(적대검증 A-5 실측).
8
+ # 그 역할 뒤바뀜은 사용자를 반대 브랜치로 보내므로 게이트가 사고를 만드는 쪽이 된다.
9
+ #
10
+ # 격리: 레인마다 임시 저장소를 새로 판다. 실제 레포는 안 건드린다.
11
+ # 세션 시뮬레이션: FH_CLAIM_TEST=1 + FH_CLAIM_SESSION 으로 두 세션을 흉내낸다.
12
+ #
13
+ # usage: bash scripts/test_branch_claim_lanes.sh
14
+
15
+ set -uo pipefail
16
+ SCRIPT="$(cd "$(dirname "$0")" && pwd)/branch_claim.sh"
17
+ PASS=0; FAIL=0; LIVE_PID=""
18
+
19
+ cleanup() { [ -n "$LIVE_PID" ] && kill "$LIVE_PID" 2>/dev/null; return 0; }
20
+ trap cleanup EXIT
21
+
22
+ _mkrepo() {
23
+ local d; d=$(mktemp -d "${TMPDIR:-/tmp}/bclaim.XXXXXX")
24
+ git -C "$d" init -q -b main
25
+ git -C "$d" config user.email t@t; git -C "$d" config user.name t
26
+ echo x > "$d/f"; git -C "$d" add f; git -C "$d" commit -qm init
27
+ echo "$d"
28
+ }
29
+ # 살아있는 peer 세션 하나를 실제 프로세스로 만든다 (kill -0 이 참이 되도록)
30
+ # ⚠️ 반드시 fd 를 닫아서 띄운다: 백그라운드 프로세스가 stdout 을 물고 있으면
31
+ # 이 스크립트를 파이프에 물릴 때 파이프가 안 닫혀 무한 대기한다(실측: 120s 타임아웃).
32
+ _spawn_live() { sleep 900 >/dev/null 2>&1 & LIVE_PID=$!; echo "$LIVE_PID"; }
33
+
34
+ _lane() { # name expected_rc actual_rc [must_contain...]
35
+ local name="$1" exp="$2" got="$3"; shift 3
36
+ local out="$LANE_OUT" ok=1 pat
37
+ [ "$got" = "$exp" ] || { ok=0; echo " ❌ $name — rc=$got (기대 $exp)"; }
38
+ for pat in "$@"; do
39
+ case "$out" in *"$pat"*) ;; *) ok=0; echo " ❌ $name — 출력에 «${pat}» 없음";; esac
40
+ done
41
+ if [ "$ok" = 1 ]; then echo " ✅ $name"; PASS=$((PASS+1)); else FAIL=$((FAIL+1));
42
+ echo " ── 실제 출력 ──"; echo "$out" | sed 's/^/ /'; fi
43
+ }
44
+ _ok() { if [ "$2" = 1 ]; then echo " ✅ $1"; PASS=$((PASS+1)); else echo " ❌ $1"; FAIL=$((FAIL+1)); fi; }
45
+
46
+ # 세션 B(=peer)의 claim 을 살아있는 pid 로 직접 심는다
47
+ _plant_peer() { # repo branch pid
48
+ mkdir -p "$1/.git/fh-claims"
49
+ printf 'branch=%s\nlabel=peer\npid=%s\nat_epoch=%s\n' "$2" "$3" "$(date +%s)" \
50
+ > "$1/.git/fh-claims/session-PEER"
51
+ }
52
+
53
+ # ⚠️ 주변 환경을 흡입하지 않는다 — 이 한 줄이 없어서 이 스위트는 «거짓 초록» 이었다.
54
+ # branch_claim.sh 의 `_session_pid() { echo "${CLAUDE_PID:-${PPID:-$$}}"; }` 때문에, 저자 셸에
55
+ # CLAUDE_PID 가 있으면 claim 이 **살아있는 pid**(그 Claude 프로세스)를 기록한다. 레인 ③ 은
56
+ # A 가 재-claim 해서 peer 기록을 덮는 시나리오라, 그 pid 가 살아있느냐가 판정을 통째로 뒤집는다.
57
+ # 실측 2026-08-09 (known-pair, 같은 트리·같은 커밋):
58
+ # CLAUDE_PID 있음 → 24 PASS / 0 FAIL (저자 로컬 — 초록)
59
+ # CLAUDE_PID 없음 → 23 PASS / 1 FAIL ③ (CI — 적색, rc=0 기대 1)
60
+ # 즉 코드가 아니라 **저자 환경**을 재고 있었고, 그 초록이 S-1 재설계의 유일한 증명이었다.
61
+ # 앵커가 배선되기 전까지(=호출부 0개) 이 사실은 드러날 수 없었다.
62
+ unset CLAUDE_PID
63
+
64
+ echo "[branch-claim] known-pair 앵커"
65
+ PEERPID=$(_spawn_live)
66
+
67
+ # ── ① 기록 없음 → 통과 (부재 ≠ 위반) ───────────────────────────────────────
68
+ R=$(_mkrepo); LANE_OUT=$(cd "$R" && bash "$SCRIPT" check 2>&1); rc=$?
69
+ _lane "① 기록 없음 → 통과" 0 "$rc"; rm -rf "$R"
70
+
71
+ # ── ② 내 기록 == HEAD → 통과, 그리고 at_epoch 이 갱신된다 (A-6) ────────────
72
+ R=$(_mkrepo)
73
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=1000 bash "$SCRIPT" claim a >/dev/null 2>&1)
74
+ before=$(sed -n 's/^at_epoch=//p' "$R/.git/fh-claims/me")
75
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=99000 bash "$SCRIPT" check 2>&1); rc=$?
76
+ after=$(sed -n 's/^at_epoch=//p' "$R/.git/fh-claims/me")
77
+ _lane "② 내 기록==HEAD → 통과" 0 "$rc"
78
+ _ok "②-b at_epoch 갱신됨 (${before} -> ${after}) — 나이가 «유휴»를 재게 된다" "$([ "$after" = 99000 ] && echo 1 || echo 0)"
79
+ rm -rf "$R"
80
+
81
+ # ── ③ ★ S-1 회귀: 양쪽 세션이 다 프로토콜을 지켜도 잡는다 ──────────────────
82
+ # 초판 설계는 여기서 통과했다(= 오늘 사고를 못 막았다). 이 레인이 그 반증이다.
83
+ # 타임라인: B가 main claim → A가 switch+claim(프로토콜 준수) → B가 커밋 시도
84
+ R=$(_mkrepo)
85
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=B bash "$SCRIPT" claim bee >/dev/null 2>&1)
86
+ _plant_peer "$R" main "$PEERPID" # A 를 살아있는 peer 로
87
+ git -C "$R" switch -q -c ifkakao # A 가 트리를 옮기고
88
+ # A 는 «살아있는» 세션이다 — 그 사실을 환경에서 빌리지 말고 레인이 직접 고정한다.
89
+ # CLAUDE_PID 를 안 주면 claim 이 곧 죽을 서브셸의 PPID 를 기록하고, B 의 check 는
90
+ # «살아있는 peer 0» 으로 통과한다 — 시나리오가 성립하지도 않은 채 초록이 된다.
91
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=session-PEER CLAUDE_PID="$PEERPID" bash "$SCRIPT" claim ay >/dev/null 2>&1) # 프로토콜대로 claim
92
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=B bash "$SCRIPT" check 2>&1); rc=$?
93
+ _lane "③ ★S-1 회귀: 양쪽이 프로토콜을 지켜도 B 는 차단된다" 1 "$rc" \
94
+ "이 세션은 'main' 로 기록돼 있는데 HEAD 는 'ifkakao' 다" "git switch -- 'main'"
95
+ rm -rf "$R"
96
+
97
+ # ── ④ 불일치인데 살아있는 peer 없음 → 경고만, 통과 (A-2 과차단 방지) ───────
98
+ R=$(_mkrepo)
99
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" claim s >/dev/null 2>&1)
100
+ git -C "$R" switch -q -c next
101
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" check 2>&1); rc=$?
102
+ _lane "④ 1인 세션(살아있는 peer 0) → 차단 안 함" 0 "$rc" "살아있는 peer 세션이 없어"
103
+ rm -rf "$R"
104
+
105
+ # ── ④-b 컨트롤: 같은 상황에 살아있는 peer 가 있으면 차단 ───────────────────
106
+ R=$(_mkrepo)
107
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" claim s >/dev/null 2>&1)
108
+ _plant_peer "$R" whatever "$PEERPID"
109
+ git -C "$R" switch -q -c next
110
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" check 2>&1); rc=$?
111
+ _lane "④-b 컨트롤: peer 살아있으면 차단 (④가 무조건 통과가 아님)" 1 "$rc"
112
+ rm -rf "$R"
113
+
114
+ # ── ④-c 죽은 peer 는 동시성 증거가 아니다 ──────────────────────────────────
115
+ R=$(_mkrepo)
116
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" claim s >/dev/null 2>&1)
117
+ _plant_peer "$R" whatever 999999 # 존재하지 않는 pid
118
+ git -C "$R" switch -q -c next
119
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=solo bash "$SCRIPT" check 2>&1); rc=$?
120
+ _lane "④-c 죽은 peer → 차단 안 함 (시간 상수 의존을 줄인다)" 0 "$rc"
121
+ rm -rf "$R"
122
+
123
+ # ── ⑤ STALE → 통과 / 임계 미만 → 차단 ──────────────────────────────────────
124
+ R=$(_mkrepo)
125
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=0 bash "$SCRIPT" claim s >/dev/null 2>&1)
126
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other
127
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=$((13*3600)) bash "$SCRIPT" check 2>&1); rc=$?
128
+ _lane "⑤ STALE(13h) → 통과" 0 "$rc" "STALE"
129
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_CLAIM_NOW=$((11*3600)) bash "$SCRIPT" check 2>&1); rc=$?
130
+ _lane "⑤-b 컨트롤: 11h → 여전히 차단" 1 "$rc"
131
+ rm -rf "$R"
132
+
133
+ # ── ⑥ override → 통과 + **파일에 실제로 기록** (A-4 팬텀 주장 수리) ────────
134
+ R=$(_mkrepo)
135
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
136
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other
137
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me FH_BRANCH_CLAIM_OK=1 bash "$SCRIPT" check 2>&1); rc=$?
138
+ _lane "⑥ override → 통과" 0 "$rc" "FH_BRANCH_CLAIM_OK"
139
+ _ok "⑥-b override 로그 파일이 실제로 생성·append 됨" \
140
+ "$([ -s "$R/.git/fh-branch-claim-overrides.log" ] && echo 1 || echo 0)"
141
+ rm -rf "$R"
142
+
143
+ # ── ⑦ detached / rebase 중 → 통과 (A-1 과차단 수리) ────────────────────────
144
+ R=$(_mkrepo)
145
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
146
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" checkout -q --detach
147
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
148
+ _lane "⑦ detached HEAD → 통과 (rebase/bisect 중 커밋을 막지 않는다)" 0 "$rc"
149
+ rm -rf "$R"
150
+ R=$(_mkrepo)
151
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
152
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other; mkdir -p "$R/.git/rebase-merge"
153
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
154
+ _lane "⑦-b rebase 진행 중 → 통과" 0 "$rc"
155
+ rm -rf "$R"
156
+
157
+ # ── ⑧ 테스트 노브는 FH_CLAIM_TEST=1 없이 무시된다 (A-3 무음 우회 차단) ─────
158
+ R=$(_mkrepo)
159
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
160
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other
161
+ # 테스트 모드 없이 노브만 주면 → 세션ID 폴백이 달라 기록을 못 찾아 통과할 수 있으므로,
162
+ # 실제 세션ID를 고정해 두고 STALE/NOW 노브가 먹지 않는지만 본다
163
+ export CLAUDE_CODE_SESSION_ID=me
164
+ LANE_OUT=$(cd "$R" && FH_CLAIM_STALE_HOURS=0 FH_CLAIM_NOW=99999999999 bash "$SCRIPT" check 2>&1); rc=$?
165
+ _lane "⑧ FH_CLAIM_TEST 없이 STALE/NOW 노브 → 무시되고 차단 유지" 1 "$rc"
166
+ unset CLAUDE_CODE_SESSION_ID
167
+ rm -rf "$R"
168
+
169
+ # ── ⑨ 워크트리는 기록이 분리된다 ───────────────────────────────────────────
170
+ R=$(_mkrepo)
171
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
172
+ WT="$R-wt"; git -C "$R" worktree add -q -b wtb "$WT" >/dev/null 2>&1
173
+ if [ -d "$WT" ]; then
174
+ LANE_OUT=$(cd "$WT" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
175
+ _lane "⑨ 워크트리는 기록 분리 → 통과" 0 "$rc"
176
+ git -C "$R" worktree remove --force "$WT" >/dev/null 2>&1
177
+ else echo " ⚠️ ⑨ 워크트리 생성 실패 — SKIPPED(통과로 세지 않음)"; fi
178
+ rm -rf "$R" "$WT"
179
+
180
+ # ── ⑩ 서브디렉터리에서도 같은 기록을 본다 (상대 .git 절대화) ───────────────
181
+ R=$(_mkrepo)
182
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
183
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q -c other; mkdir -p "$R/deep/nested"
184
+ LANE_OUT=$(cd "$R/deep/nested" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
185
+ _lane "⑩ 서브디렉터리에서도 차단" 1 "$rc"
186
+ _ok "⑩-b 기록이 저장소 .git 에만 생성됨" \
187
+ "$([ -d "$R/.git/fh-claims" ] && [ ! -d "$R/deep/nested/.git" ] && echo 1 || echo 0)"
188
+ rm -rf "$R"
189
+
190
+ # ── ⑪ release / reap / show 도 앵커가 있다 (B-3 무앵커 경로 수리) ──────────
191
+ R=$(_mkrepo)
192
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
193
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" release >/dev/null 2>&1)
194
+ _ok "⑪ release 가 실제로 기록을 지운다" "$([ ! -f "$R/.git/fh-claims/me" ] && echo 1 || echo 0)"
195
+ _plant_peer "$R" x 999999
196
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" reap >/dev/null 2>&1)
197
+ _ok "⑪-b reap 이 죽은 세션 기록을 지운다" "$([ ! -f "$R/.git/fh-claims/session-PEER" ] && echo 1 || echo 0)"
198
+ _plant_peer "$R" zzz "$PEERPID"
199
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" show 2>&1); rc=$?
200
+ _lane "⑪-c show 가 HEAD·세션·peer 를 낸다" 0 "$rc" "HEAD" "살아있는 peer claim: 1" "zzz"
201
+ rm -rf "$R"
202
+
203
+ # ── ⑫ 복구 명령의 브랜치명이 shell-quote 된다 (cross-family B-6) ──────────
204
+ # git ref 는 공백은 못 쓰지만 `;` `$` 는 허용한다. 복붙 시 주입형 사고.
205
+ R=$(_mkrepo); B='a;touch$IFS/tmp/BCLAIM_PWNED'
206
+ git -C "$R" branch "$B" >/dev/null 2>&1 && git -C "$R" switch -q "$B"
207
+ (cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim s >/dev/null 2>&1)
208
+ _plant_peer "$R" x "$PEERPID"; git -C "$R" switch -q main
209
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" check 2>&1); rc=$?
210
+ EXPECT="git switch -- '${B}'"
211
+ _lane "⑫ metachar 브랜치명이 인용된 채 출력된다" 1 "$rc" "$EXPECT"
212
+ rm -rf "$R"
213
+
214
+ # ── ⑬ --if-absent: 없으면 만들고, 있으면 안 덮는다 (세션시작 훅이 부르는 형태) ──
215
+ # 훅 안에 같은 로직을 복제하면 앵커가 안 걸리므로 스크립트에 두고 여기서 잡는다.
216
+ R=$(_mkrepo)
217
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim --if-absent s 2>&1); rc=$?
218
+ _ok "⑬ --if-absent: 기록 없으면 생성" \
219
+ "$([ "$rc" = 0 ] && [ -f "$R/.git/fh-claims/me" ] && echo 1 || echo 0)"
220
+ git -C "$R" switch -q -c moved # 브랜치가 옮겨진 뒤 다시 불러도
221
+ LANE_OUT=$(cd "$R" && FH_CLAIM_TEST=1 FH_CLAIM_SESSION=me bash "$SCRIPT" claim --if-absent s 2>&1); rc=$?
222
+ kept=$(sed -n 's/^branch=//p' "$R/.git/fh-claims/me")
223
+ _lane "⑬-b --if-absent: 기록 있으면 덮지 않는다" 0 "$rc" "이미 있다"
224
+ _ok "⑬-c 기존 값 보존 (main 이어야 함 · 지금=$kept)" "$([ "$kept" = main ] && echo 1 || echo 0)"
225
+ # ★ 이게 중요한 이유: 덮으면 「내가 믿는 브랜치」가 매 세션시작마다 HEAD 로 갱신돼
226
+ # 게이트가 영원히 통과한다 — 자동화가 게이트를 무장해제하는 경로다
227
+ rm -rf "$R"
228
+
229
+ echo
230
+ echo "[branch-claim] PASS=$PASS FAIL=$FAIL"
231
+ [ "$FAIL" -eq 0 ] || exit 1