@chrono-meta/fh-gate 3.0.0 → 3.1.1

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 +451 -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 +76 -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 +160 -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
@@ -3,6 +3,13 @@
3
3
  # templates/*.sh is about to change → put ONE proposal instruction into the model's context
4
4
  # (additionalContext): «offer the user a known-pair control + degrade_direction_scan.sh in one line».
5
5
  #
6
+ # 🟥 2026-09-04 계기 교체 — 이후 F 행은 이전 F 행과 같은 계기가 아니다. The Bash-path discriminator below
7
+ # was replaced after the first independent grading of the live FIRE rows
8
+ # (tracks/_meta/RESULT_2026-09-04_identity5-armC-live-count.md §계기 결함 1·2·3): 3 of 7 rows were not edits
9
+ # at all, and 6 of 8 load-bearing verdict edits that day never fired. Rows written by the old rule and rows
10
+ # written by this one must not be pooled into one count. Whether the open falsification window is closed,
11
+ # restarted, or split is the governor's / operator's decision, recorded there — not here.
12
+ #
6
13
  # WHY A HOOK (identity ⑤, measured 2026-09-03)
7
14
  # r3: a CLAUDE.md table row keyed on this exact file class fired 1/15 at floor tier (that 1 a
8
15
  # recitation). r4: this hook, installed in a disposable clone, fired on 9/10 editing reps and the
@@ -19,20 +26,42 @@
19
26
  # («proposes unasked») counts a relayed proposal is the operator's call, recorded there, not here.
20
27
  #
21
28
  # DISCRIMINATOR (mechanical, quote-aware where it can be)
22
- # file class : scripts/**/*.sh · templates/*.sh (docs, tracks, tests-as-fixtures: no)
29
+ # file class : scripts/**/*.sh · templates/*.sh · */.git-hooks/* (docs, tracks, tests-as-fixtures: no)
23
30
  # edit kind : the touched text carries a verdict/guard token — exit N · return N ·
24
31
  # `|| continue|exit|true|return` · `&& continue|exit` · -ne/-eq/-gt/-lt · ==/!= ·
25
32
  # `[ -e/-f/-s/-n/-z` · comm/diff/cmp · grep -q — AND for Edit the change is not
26
33
  # confined to quoted strings (old/new with quotes stripped must differ). A usage
27
34
  # string that happens to contain `exit 2` does not fire (r4 HARD 0/5).
28
- # Bash path : an edit made through the shell (sed -i · > · >> · tee) the r4 miss (T2 r5 edited
29
- # via Bash, hook 0). No old/new here, so the rule is weaker: target file class AND the
30
- # the RAW command text (quotes included) carries a token. Quotes are NOT stripped on
31
- # this path in `sed -i 's/exit 1/exit 2/' x.sh` the token is inside the quotes by
32
- # construction, and stripping made the path silent on exactly the shape it exists for
33
- # (found by the Air node 2026-09-03; the original lane's known-positive only fired
34
- # because its token sat outside the quotes). Named residual now: over-fire when a
35
- # quoted token elsewhere in the command co-occurs with a script-file edit (advisory).
35
+ # Edit/Write : the file is `tool_input.file_path` the tool tells us what changes; nothing is inferred.
36
+ # Bash path : the shell has no file_path, so the question «which file does this command WRITE» is answered
37
+ # by a small scanner instead of a whole-command regex (the 2026-09-03 rule matched the raw
38
+ # text, so a heredoc body, a PR body, or a python string that merely QUOTED `sed -i … x.sh`
39
+ # counted as an edit RESULT 2026-09-04 rows 2·3·4):
40
+ # ① heredoc bodies are lifted out (`<<EOF` `EOF`) and kept aside, keyed to the line that opened them
41
+ # the rest is split into segments on `;` `&&` `||` newline — OUTSIDE quotes; quoted text is masked
42
+ # a target is an ARGUMENT POSITION only: `> path` · `>> path` · `tee [-a] path` (unquoted in the
43
+ # masked text, or a quoted path directly after the operator) · the file argument of `sed -i …`
44
+ # ④ a `python3 … <<HEREDOC` segment is opened: `open(P,"w"|"a")` / `Path(P).write_text` with P a
45
+ # literal or a variable assigned ONCE in the body (`p="scripts/x.sh"`) names the target
46
+ # (RESULT §결함 2 — 16 of 27 script edits that day were this shape and all were silent)
47
+ # ⑤ the token test runs on the segment that OWNS the target (+ its heredoc body), not on the
48
+ # whole command — `|| exit 1` two segments later is a check, not an edit (RESULT §결함 3, row 6).
49
+ # For a python heredoc the token text is the body's STRING LITERALS only, so `if a == b:` in the
50
+ # python code itself is not a verdict edit, and a comment-word replace stays silent
51
+ # ⑥ a path containing `$` is dropped — `: > "$T/scripts/x.sh"` is a fixture root, not this repo
52
+ # Quotes are NOT stripped from the owning segment's token text: in `sed -i 's/exit 1/exit 2/' x.sh`
53
+ # the token sits inside the quotes by construction (Air 2026-09-03).
54
+ # WHAT THE BASH PATH STILL CANNOT SEE (named, not closed)
55
+ # · a script path reached through a variable (`"$REPO_ROOT/scripts/x.sh"`, `$f`) — ⑥ drops it
56
+ # · python targets built at runtime (`os.path.join`, `sys.argv[1]`, `p = base + name`)
57
+ # · edits by other tools: `perl -pi`, `awk … > tmp && mv tmp x.sh`, `patch`, `git apply`, `ed`,
58
+ # `cp`/`mv`/`install` onto a script, `sponge`
59
+ # · an unterminated quote earlier in the command swallows every target after it
60
+ # · a token inside the owning segment that is not the edit payload (`sed -i 's/a/b/' x.sh | grep -q y`
61
+ # is still one segment) — narrower than before, not zero
62
+ # It is PreToolUse, so «what actually changed on disk» is not available here at all; a PostToolUse
63
+ # twin diffing mtimes would close the tool-shape residuals above and was NOT built in this patch
64
+ # (it needs a wiring change in settings.json, outside this file).
36
65
  #
37
66
  # DEGRADE DIRECTION: advisory, exit 0 always, no permissionDecision (same contract as pipe_verdict_guard).
38
67
  # Unparseable payload → silent. python3 absent → silent (a dead interpreter must not block edits).
@@ -44,26 +73,100 @@
44
73
  set -u
45
74
  RAW=$(cat 2>/dev/null || true)
46
75
  printf '%s' "$RAW" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*proposal-hook' && exit 0
47
- read -r FP FLAG < <(printf '%s' "$RAW" | python3 -c '
76
+ # 🟥 bash-version trap (measured 2026-09-04): `PYCODE=$(cat <<'PY' PY)` parses under macOS bash 3.2 but
77
+ # bash 5.3 (CI runner, homebrew) scans the $(...) body for a matching `)` BEFORE honoring the quoted
78
+ # heredoc, so the regex parens below break it — `bash -n` fails, lanes were green locally. The
79
+ # opposite direction of the bash-3.2 heredoc bug frontier_digest_autopilot.sh documents; neither
80
+ # version is safe with a heredoc inside a command substitution. A function body is parsed the same
81
+ # way by both, so the heredoc lives in a function and $(...) only calls it.
82
+ _fh_ph_pycode() { cat <<'PY'
48
83
  import json,sys,re
49
84
  try: d=json.load(sys.stdin)
50
85
  except Exception: print("",""); sys.exit(0)
51
86
  tn=d.get("tool_name",""); ti=d.get("tool_input",{}) or {}
52
87
  TOK=r"exit [0-9]|return [0-9]|\|\| *(continue|exit|true|return)|&& *(continue|exit)|-ne |-eq |-gt |-lt | == | != |\[ -[efsnz] |\bcomm |\bdiff |\bcmp |grep -q"
53
- def strip(x): return re.sub(r"\"[^\"]*\"|\x27[^\x27]*\x27","",x)
88
+ CLASS=re.compile(r"(scripts/[^\s]*\.sh$|templates/[^\s]*\.sh$|(^|/)\.git-hooks/[^/]+$)")
89
+ def strip(x): return re.sub(r"\"[^\"]*\"|'[^']*'","",x)
90
+ def is_target(p): return bool(p) and "$" not in p and bool(CLASS.search(p))
91
+ def tok(x): return bool(re.search(TOK,x))
92
+
93
+ def lift_heredocs(cmd):
94
+ # replace each `<<[-]['"]DELIM['"]` with \x02k\x02 and cut its body out; bodies[k]=text
95
+ HD=re.compile(r"<<-?\s*(['\"]?)([A-Za-z_][A-Za-z0-9_]*)\1")
96
+ lines=cmd.split("\n"); out=[]; bodies=[]; i=0
97
+ while i<len(lines):
98
+ ln=lines[i]; i+=1; ms=list(HD.finditer(ln))
99
+ for m in ms:
100
+ k=len(bodies); body=[]
101
+ while i<len(lines) and lines[i].strip()!=m.group(2): body.append(lines[i]); i+=1
102
+ i+=1
103
+ bodies.append("\n".join(body)); ln=ln.replace(m.group(0),"\x02%d\x02"%k,1)
104
+ out.append(ln)
105
+ return "\n".join(out),bodies
106
+
107
+ def segments(text):
108
+ # split on ; && || newline outside quotes; returns [(raw, masked, quoted_list)]
109
+ segs=[]; raw=[]; masked=[]; quoted=[]; q=None; i=0; n=len(text)
110
+ def flush():
111
+ segs.append(("".join(raw),"".join(masked),list(quoted))); raw[:]=[]; masked[:]=[]; quoted[:]=[]
112
+ while i<n:
113
+ c=text[i]
114
+ if q:
115
+ j=i
116
+ while j<n:
117
+ if text[j]=="\\" and q=='"' and j+1<n: j+=2; continue
118
+ if text[j]==q: break
119
+ j+=1
120
+ body=text[i:j]; raw.append(body); masked.append("\x01%d\x01"%len(quoted)); quoted.append(body)
121
+ if j<n: raw.append(q); masked.append(q)
122
+ q=None; i=j+1; continue
123
+ if c in "\"'": q=c; raw.append(c); masked.append(c); i+=1; continue
124
+ if c=="\\" and i+1<n: raw.append(text[i:i+2]); masked.append(text[i:i+2]); i+=2; continue
125
+ if text.startswith("&&",i) or text.startswith("||",i): flush(); i+=2; continue
126
+ if c==";" or c=="\n": flush(); i+=1; continue
127
+ raw.append(c); masked.append(c); i+=1
128
+ flush(); return segs
129
+
130
+ PATH_RE=r"(?:[\"']\x01(\d+)\x01[\"']|([^\s\"'|;&<>()]+))"
131
+ RED=re.compile(r"(?:(?<![<\w])>>?|\btee\s+(?:-a\s+)?)\s*"+PATH_RE)
132
+ PYW=re.compile(r"open\(\s*(?:['\"]([^'\"]+)['\"]|([A-Za-z_]\w*))\s*,\s*['\"][wa]|Path\(\s*(?:['\"]([^'\"]+)['\"]|([A-Za-z_]\w*))\s*\)\.write_(?:text|bytes)|\b([A-Za-z_]\w*)\.write_(?:text|bytes)\(")
133
+ PYSTR=re.compile(r"\"\"\"(?:.|\n)*?\"\"\"|'''(?:.|\n)*?'''|\"(?:\\.|[^\"\\\n])*\"|'(?:\\.|[^'\\\n])*'")
134
+
135
+ def bash_targets(cmd):
136
+ text,bodies=lift_heredocs(cmd); found=[]
137
+ for raw,masked,quoted in segments(text):
138
+ hb="\n".join(bodies[int(k)] for k in re.findall(r"\x02(\d+)\x02",masked))
139
+ cands=[]
140
+ for m in RED.finditer(masked):
141
+ p=quoted[int(m.group(1))] if m.group(1) else m.group(2)
142
+ if is_target(p): cands.append((p,raw+"\n"+hb))
143
+ if re.search(r"\bsed\s+(?:-\S+\s+)*-i",masked):
144
+ for a in masked.split():
145
+ if is_target(a): cands.append((a,raw+"\n"+hb)); break
146
+ if hb and re.search(r"\bpython[0-9.]*\b",masked):
147
+ assigns=dict(re.findall(r"^\s*([A-Za-z_]\w*)\s*=\s*(?:Path\(\s*)?['\"]([^'\"\n]+)['\"]\s*\)?\s*$",hb,re.M))
148
+ lits=" ".join(PYSTR.findall(hb))
149
+ for m in PYW.finditer(hb):
150
+ p=m.group(1) or m.group(3) or assigns.get(m.group(2) or m.group(4) or m.group(5) or "","")
151
+ if is_target(p): cands.append((p,lits))
152
+ for p,ttext in cands: found.append((p,"1" if tok(ttext) else "0"))
153
+ for p,f in found:
154
+ if f=="1": return p,f
155
+ return (found[0] if found else ("","0"))
156
+
54
157
  fp=""; flag="0"
55
158
  if tn in ("Edit","Write"):
56
159
  fp=ti.get("file_path","") or ""
57
160
  old=ti.get("old_string","") or ""; new=(ti.get("new_string","") or ti.get("content","") or "")
58
- touches=bool(re.search(TOK, old+"\n"+new)); real=strip(old).strip()!=strip(new).strip()
161
+ touches=tok(old+"\n"+new); real=strip(old).strip()!=strip(new).strip()
59
162
  flag="1" if (touches and real) else "0"
60
163
  elif tn=="Bash":
61
- cmd=(ti.get("command","") or "").replace("\n"," ")
62
- m=re.search(r"(?:sed\s+-i\S*(?:\s+(?:\x27[^\x27]*\x27|\"[^\"]*\"|\S+)){1,2}\s+|>>?\s*|tee\s+(?:-a\s+)?)[\"\x27]?([^\s\"\x27|;&)<>]+\.sh)\b", cmd)
63
- if m:
64
- fp=m.group(1); flag="1" if re.search(TOK, cmd) else "0" # raw cmd, NOT strip(): in a sed -i the token lives INSIDE the quoted expression by construction (Air 2026-09-03: a1 silent, known-positive only fired because its token sat outside the quotes)
164
+ fp,flag=bash_targets(ti.get("command","") or "")
65
165
  print(fp, flag)
66
- ' 2>/dev/null) || exit 0
166
+ PY
167
+ }
168
+ PYCODE=$(_fh_ph_pycode)
169
+ read -r FP FLAG < <(printf '%s' "$RAW" | python3 -c "$PYCODE" 2>/dev/null) || exit 0
67
170
  [ -n "${FP:-}" ] || exit 0
68
171
  case "$FP" in *scripts/*.sh|*templates/*.sh|scripts/*.sh|templates/*.sh|*/.git-hooks/*|.git-hooks/*) ;; *) exit 0 ;; esac # .git-hooks/* has no .sh suffix — the gate files themselves were outside the filter (arm C wt2 2026-09-03: pre-commit edit, no FIRE)
69
172
  [ "${FLAG:-0}" = 1 ] || exit 0
@@ -0,0 +1,78 @@
1
+ #!/usr/bin/env bash
2
+ # push_zone_check.sh — enumerate every repo you are about to push to, and say which channel each
3
+ # one requires. Run it BEFORE a multi-repo push round, not per-push.
4
+ #
5
+ # WHY THIS SHAPE
6
+ # The operator's account rule (a push under a non-owner account goes through the REST Contents API,
7
+ # new files only, never overwriting or deleting) is not hard to remember — it was read and correctly
8
+ # applied on 2026-07-26. It was applied to ONE org repo and missed on ANOTHER org repo in the same
9
+ # round. The failure was not ignorance of the rule; it was applying it at the point of discovery
10
+ # instead of across every repo in scope — the half-fix / propagation-boundary class.
11
+ #
12
+ # A per-push guard would not have caught that either: each individual push looks locally fine. What
13
+ # was missing is the ENUMERATION — "here are all the remotes in play tonight, and here is the
14
+ # channel each one needs". So this tool lists, it does not block.
15
+ #
16
+ # Usage:
17
+ # bash scripts/push_zone_check.sh # scan sibling repos under the projects root
18
+ # bash scripts/push_zone_check.sh ~/p/a ~/p/b # scan the given repos
19
+ #
20
+ # Zone rule is intentionally NOT hardcoded here: the owner account and org names are
21
+ # operator-private. It reads them from the same gitignored source the surface scan uses, and says
22
+ # UNCALIBRATED if that source is missing rather than guessing (a guessed zone verdict is worse than
23
+ # no verdict — it would authorise the wrong channel).
24
+ set -uo pipefail
25
+
26
+ FH="$(git -C "$(dirname "$0")/.." rev-parse --show-toplevel 2>/dev/null || pwd)"
27
+ ZONE_SRC="${PUSH_ZONE_OWNERS:-$FH/.claude/rules/.push-zone-owners}"
28
+
29
+ owner_accounts=""
30
+ if [ -f "$ZONE_SRC" ]; then
31
+ owner_accounts="$(grep -vE '^\s*(#|$)' "$ZONE_SRC" | tr '\n' ' ')"
32
+ fi
33
+
34
+ if [ -z "$owner_accounts" ]; then
35
+ echo "push_zone_check: UNCALIBRATED — no owner-account list at $ZONE_SRC"
36
+ echo " Create it (gitignored, one account/org per line: the accounts whose repos you may push to"
37
+ echo " with plain git). Without it this tool cannot tell a personal remote from an org remote,"
38
+ echo " and a guessed verdict would authorise the wrong channel."
39
+ exit 2
40
+ fi
41
+
42
+ active="$(gh auth status 2>/dev/null | sed -n 's/.*Logged in to github.com account \([^ ]*\).*/\1/p' | head -1)"
43
+ [ -n "$active" ] || active="(unknown)"
44
+
45
+ echo "push_zone_check — active gh account: $active"
46
+ echo "owner accounts (plain git allowed): $owner_accounts"
47
+ echo
48
+
49
+ targets=("$@")
50
+ if [ "${#targets[@]}" -eq 0 ]; then
51
+ root="$(dirname "$FH")"
52
+ while IFS= read -r d; do targets+=("$(dirname "$d")"); done < <(find "$root" -maxdepth 2 -name .git -type d 2>/dev/null | sort)
53
+ fi
54
+
55
+ need_rest=0
56
+ for repo in "${targets[@]}"; do
57
+ [ -d "$repo/.git" ] || continue
58
+ url="$(git -C "$repo" remote get-url origin 2>/dev/null)"
59
+ [ -n "$url" ] || continue
60
+ # owner = the path segment before the repo name, for both ssh and https forms
61
+ owner="$(printf '%s' "$url" | sed -E 's#^.*[:/]([^/]+)/[^/]+$#\1#')"
62
+ ahead="$(git -C "$repo" rev-list --count '@{u}..HEAD' 2>/dev/null || echo '?')"
63
+ channel="REST Contents API (new files only, no overwrite/delete)"
64
+ mark="🔶"
65
+ for o in $owner_accounts; do
66
+ if [ "$owner" = "$o" ]; then channel="plain git push"; mark="✅"; break; fi
67
+ done
68
+ [ "$mark" = "🔶" ] && need_rest=$((need_rest + 1))
69
+ printf '%s %-26s owner=%-18s unpushed=%-3s → %s\n' "$mark" "$(basename "$repo")" "$owner" "$ahead" "$channel"
70
+ done
71
+
72
+ echo
73
+ if [ "$need_rest" -gt 0 ]; then
74
+ echo "⚠️ $need_rest repo(s) are outside the owner accounts — those need the REST channel."
75
+ echo " Decide the channel for ALL of them now, in one pass. The 2026-07-26 miss was exactly"
76
+ echo " this: the rule was applied to one such repo and forgotten on another in the same round."
77
+ fi
78
+ echo "This tool lists; it does not block. The blocking floor for THIS repo stays templates/.git-hooks/pre-push."
@@ -0,0 +1,252 @@
1
+ #!/usr/bin/env python3
2
+ """Residency 폐포 스캔 — 외부 계열 dispatch 전에 **무엇이 실제로 나가는지**를 판별한다.
3
+
4
+ 운영자 결정(2026-08-09, 요지 — 원문 인용 아님): 엔진 소스 자체는 보호 대상 IP 가 아니고,
5
+ 외부 계열 송신 가부의 기준은 **폐포 스캔**이다.
6
+
7
+ → residency 대상은 **코드 자체가 아니라 거기 섞여 들어간 조직 식별자**다.
8
+ 그래서 판별자는 「이 레포는 누구 것인가」(사람 판단)가 아니라
9
+ **「이 모듈의 로컬 임포트 폐포에 조직 식별자가 있는가」**(기계 판정)다.
10
+
11
+ ## 왜 폐포인가 — 그리고 폐포의 한계
12
+
13
+ 파일 하나만 보면 그 파일이 import 하는 설정/클라이언트 모듈에 박힌 호스트명을 놓친다.
14
+ 실측(2026-08-09, qasp-dev):
15
+
16
+ <clean module> 폐포 2개 조직 식별자 0건 → 밖으로 나가도 된다
17
+ <config-coupled> 폐포 2개 조직토큰 ×2 · 호스트 ×3 → 안 된다 (설정 모듈 경유)
18
+ <wide closure> 폐포 27개 조직토큰 ×30 · 호스트 ×30 → 안 된다
19
+
20
+ 🟡 **폐포는 스크리닝이지 송신물 그 자체가 아니다.** 폐포가 0이면 확실히 안전하지만,
21
+ 폐포가 0이 아니라고 **반드시 못 보내는 것은 아니다** — 오염된 파일을 payload 에서
22
+ 빼면 된다. 이 스크립트는 «어느 파일이 오염됐는지» 를 찍어서 그 선택을 가능하게 한다.
23
+ (`--files` 로 실제 송신 파일 목록을 주면 폐포 대신 그것만 잰다.)
24
+
25
+ ## 🟥 CLEAN 은 «보내도 안전» 이 아니다 — 스크리닝이다
26
+
27
+ cross-family 적대검증(gpt-5.5, 2026-08-09)이 false-CLEAN 경로 다수를 냈고 **픽스처로 재현**했다.
28
+ 확정 3건은 닫았다(상대 임포트 · 루트 화이트리스트 · `from pkg import sub`; 레인 테스트 참조).
29
+ **닫지 못한 것은 원리적이라 이름을 붙여 남긴다** — 이걸 안 적고 CLEAN 을 floor 로 쓰면 유출한다:
30
+
31
+ · 동적 임포트(`importlib`) · 플러그인 엔트리포인트 · 네임스페이스 패키지 → 간선이 안 보인다
32
+ · **비-파이썬 반출물**(.env · YAML/JSON · 픽스처 · 노트북 · 락파일) → 폐포에 아예 없다
33
+ · 인코딩/조립(문자열 분할 · base64 · 퍼센트 · 동형문자) → 정규식이 못 본다
34
+ · `--files` 는 **큐레이션 우회**가 가능하다 — 목록을 좁게 주면 CLEAN 이 나온다
35
+ · TOCTOU — 스캔 후 송신 전에 파일이 바뀌면 판정이 낡는다
36
+ · 파싱 실패 모듈의 의존은 안 따라간다(그 경우 stderr 로 알린다)
37
+
38
+ ∴ **판정 의미**: CLEAN = «명시된 패턴·명시된 한계 안에서 조직 식별자를 못 찾았다».
39
+ TAINTED 는 강한 신호(찾았다)이고, CLEAN 은 약한 신호(못 찾았다)다 — 비대칭이 본질이다.
40
+ `not found ≠ 0` 을 이 도구 자신에게도 적용한다.
41
+
42
+ ## 종료 코드
43
+
44
+ 0 CLEAN — 조직 식별자 0건. 외부 계열 dispatch 가능
45
+ 1 TAINTED — 오염 파일 있음. 그 파일을 빼거나 경계-내 패널로
46
+ 10 HARNESS — 패턴 파일 없음/스캔 불가. **fail-closed** (0 으로 읽지 마라)
47
+
48
+ ## 패턴 (2층 — 공개 레포에 조직 리터럴을 두지 않는다)
49
+
50
+ .claude/rules/.residency-patterns.defaults 커밋됨. 일반형만(조직 리터럴 없음)
51
+ .claude/rules/.residency-patterns gitignored. 운영자 리터럴
52
+ RESIDENCY_PATTERNS=<path> 환경변수 오버라이드
53
+
54
+ 한 줄 = 정규식 하나. `#` 주석·빈 줄 무시.
55
+ 🟥 **오버라이드 파일이 없으면 exit 10 이다.** 일반형 defaults 만으로 "0건" 을 내면
56
+ 그건 «깨끗하다»가 아니라 «조직 리터럴을 안 봤다» 이고, 그 둘을 접으면
57
+ not-found 를 0 으로 렌더하는 그 결함이다.
58
+ """
59
+ from __future__ import annotations
60
+
61
+ import argparse
62
+ import ast
63
+ import os
64
+ import re
65
+ import sys
66
+ from collections import deque
67
+ from pathlib import Path
68
+
69
+
70
+ EXIT_CLEAN, EXIT_TAINTED, EXIT_HARNESS = 0, 1, 10
71
+
72
+
73
+ def _module_path(repo: Path, mod: str) -> Path | None:
74
+ rel = mod.replace(".", "/")
75
+ for cand in (repo / f"{rel}.py", repo / rel / "__init__.py"):
76
+ if cand.is_file():
77
+ return cand
78
+ return None
79
+
80
+
81
+ def _local_imports(path: Path, mod: str) -> tuple[list[str], bool]:
82
+ """(후보 모듈, 파싱실패여부). **파싱 실패를 조용히 0으로 렌더하지 않는다.**
83
+
84
+ cross-family 적대검증(2026-08-09)이 재현으로 반증한 3가지를 여기서 닫는다:
85
+ · 상대 임포트(`from .x import y`)를 `level == 0` 조건이 통째로 버렸다
86
+ · `("src","scripts",…)` 루트 화이트리스트가 다른 레이아웃을 통째로 못 봤다
87
+ · `from pkg import sub` 가 `pkg/__init__.py` 만 훑고 `pkg/sub.py` 를 놓쳤다
88
+ """
89
+ try:
90
+ tree = ast.parse(path.read_text(encoding="utf-8", errors="ignore"))
91
+ except (SyntaxError, ValueError):
92
+ return [], True
93
+ pkg = mod.rsplit(".", 1)[0] if "." in mod else ""
94
+ out: list[str] = []
95
+ for node in ast.walk(tree):
96
+ if isinstance(node, ast.Import):
97
+ out += [a.name for a in node.names]
98
+ elif isinstance(node, ast.ImportFrom):
99
+ base = node.module or ""
100
+ if node.level: # 상대 임포트 — 현재 패키지 기준으로 푼다
101
+ parts = pkg.split(".") if pkg else []
102
+ up = node.level - 1
103
+ anchor = ".".join(parts[:len(parts) - up]) if up <= len(parts) else ""
104
+ base = f"{anchor}.{base}".strip(".") if base else anchor
105
+ if base:
106
+ out.append(base)
107
+ # `from pkg import sub` — sub 가 모듈일 수 있다
108
+ out += [f"{base}.{a.name}" for a in node.names]
109
+ return out, False
110
+
111
+
112
+ def closure(repo: Path, start: str) -> tuple[list[Path], int]:
113
+ """도달 가능한 **레포 안** 모듈 파일 + 파싱실패 수.
114
+
115
+ 로컬 판정은 화이트리스트가 아니라 **레포 안에 해당 파일이 실재하는가**다.
116
+ 부모 패키지의 `__init__.py` 도 포함한다 — 거기 상수가 박혀 있으면 같이 나간다.
117
+ """
118
+ seen: set[str] = set()
119
+ files: list[Path] = []
120
+ unparsed = 0
121
+ q = deque([start])
122
+ while q:
123
+ mod = q.popleft()
124
+ if mod in seen:
125
+ continue
126
+ seen.add(mod)
127
+ p = _module_path(repo, mod)
128
+ if p is None:
129
+ continue
130
+ files.append(p)
131
+ # 부모 패키지 __init__.py 도 payload 에 딸려간다
132
+ parts = mod.split(".")
133
+ for i in range(1, len(parts)):
134
+ init = _module_path(repo, ".".join(parts[:i]))
135
+ if init is not None and init not in files:
136
+ files.append(init)
137
+ deps, failed = _local_imports(p, mod)
138
+ unparsed += int(failed)
139
+ q.extend(d for d in deps if d not in seen)
140
+ return files, unparsed
141
+
142
+
143
+ def load_patterns(repo: Path) -> tuple[list[tuple[str, re.Pattern]], str | None]:
144
+ """(패턴 목록, 오류). 오버라이드가 없으면 오류를 돌려준다 — fail-closed."""
145
+ # ★패턴은 **이 스크립트가 사는 레포**(FH)에서 찾는다 — 스캔 대상 레포가 아니다.
146
+ # 대상 레포 기준으로 찾으면 남의 레포를 스캔할 때마다 defaults 부재로 fail-closed 가 되어
147
+ # 주 용도(다른 레포의 모듈을 재는 것)를 통째로 막는다. 실측으로 잡았다.
148
+ home = Path(__file__).resolve().parent.parent
149
+ defaults = home / ".claude/rules/.residency-patterns.defaults"
150
+ override = Path(os.environ.get("RESIDENCY_PATTERNS", "")) if os.environ.get(
151
+ "RESIDENCY_PATTERNS") else home / ".claude/rules/.residency-patterns"
152
+ pats: list[tuple[str, re.Pattern]] = []
153
+ for src in (defaults, override):
154
+ if not src.is_file():
155
+ continue
156
+ for line in src.read_text(encoding="utf-8", errors="ignore").splitlines():
157
+ line = line.strip()
158
+ if not line or line.startswith("#"):
159
+ continue
160
+ try:
161
+ pats.append((src.name, re.compile(line, re.I)))
162
+ except re.error:
163
+ return [], f"패턴 컴파일 실패: {src}: {line!r}"
164
+ override_pats = 0
165
+ if override.is_file():
166
+ for line in override.read_text(encoding="utf-8", errors="ignore").splitlines():
167
+ line = line.strip()
168
+ if line and not line.startswith("#"):
169
+ override_pats += 1
170
+ if override.is_file() and override_pats == 0:
171
+ return [], (
172
+ f"운영자 패턴 파일이 비었다(주석뿐이거나 0줄): {override}\n"
173
+ " «비었다» 를 «깨끗하다» 로 읽으면 안 된다 — 없는 것과 같다. fail-closed.")
174
+ if not defaults.is_file():
175
+ return [], f"defaults 패턴 파일이 없다: {defaults} — 패턴 층이 불완전하다. fail-closed."
176
+ if not override.is_file():
177
+ return pats, (
178
+ f"운영자 패턴 파일이 없다: {override}\n"
179
+ " 일반형 defaults 만으로 낸 '0건' 은 «깨끗하다» 가 아니라 «조직 리터럴을 안 봤다» 다.\n"
180
+ " → 파일을 만들거나 RESIDENCY_PATTERNS=<path> 를 줘라. fail-closed 로 멈춘다.")
181
+ if not pats:
182
+ return [], "패턴이 하나도 없다 (defaults·override 둘 다 비었다)"
183
+ return pats, None
184
+
185
+
186
+ def scan(files: list[Path], pats) -> dict[Path, list[str]]:
187
+ hits: dict[Path, list[str]] = {}
188
+ for f in files:
189
+ text = f.read_text(encoding="utf-8", errors="ignore")
190
+ for _src, rx in pats:
191
+ found = rx.findall(text)
192
+ if found:
193
+ hits.setdefault(f, []).append(f"{rx.pattern} ×{len(found)}")
194
+ return hits
195
+
196
+
197
+ def main() -> int:
198
+ ap = argparse.ArgumentParser(description=__doc__.splitlines()[0])
199
+ ap.add_argument("targets", nargs="+",
200
+ help="모듈 경로(src.act2.precondition) 또는 --files 일 때 파일 경로")
201
+ ap.add_argument("--repo", default=".", help="레포 루트 (기본 cwd)")
202
+ ap.add_argument("--files", action="store_true",
203
+ help="폐포를 안 따라가고 **주어진 파일만** 잰다 — 실제 송신 payload 를 잴 때")
204
+ args = ap.parse_args()
205
+
206
+ repo = Path(args.repo).resolve()
207
+ pats, err = load_patterns(repo)
208
+ if err:
209
+ print(f"🟥 HARNESS_ERROR — {err}", file=sys.stderr)
210
+ return EXIT_HARNESS
211
+
212
+ if args.files:
213
+ files = [Path(t) if Path(t).is_absolute() else repo / t for t in args.targets]
214
+ missing = [f for f in files if not f.is_file()]
215
+ if missing:
216
+ print(f"🟥 HARNESS_ERROR — 파일 없음: {missing}", file=sys.stderr)
217
+ return EXIT_HARNESS
218
+ else:
219
+ files = []
220
+ unparsed_total = 0
221
+ for t in args.targets:
222
+ got, unparsed = closure(repo, t)
223
+ unparsed_total += unparsed
224
+ if not got:
225
+ print(f"🟥 HARNESS_ERROR — 모듈을 못 찾았다: {t} (repo={repo})", file=sys.stderr)
226
+ return EXIT_HARNESS
227
+ files += got
228
+ files = sorted(set(files))
229
+ if unparsed_total:
230
+ print(f"🟡 파싱 실패 모듈 {unparsed_total}개 — 그 의존은 **안 따라갔다**. CLEAN 이어도 미탐 잔여다.", file=sys.stderr)
231
+
232
+ hits = scan(files, pats)
233
+ scope = "파일" if args.files else "폐포"
234
+ print(f"{scope} {len(files)}개 · 패턴 {len(pats)}개")
235
+ if not hits:
236
+ print("✅ CLEAN(스크리닝) — 이 패턴·이 한계 안에서 조직 식별자를 못 찾았다")
237
+ print(" 🟡 «보내도 안전» 이 아니다. 비-파이썬 반출물·동적 임포트·인코딩·"
238
+ "TOCTOU 는 이 도구가 못 본다 (헤더 §CLEAN 은 스크리닝이다).")
239
+ return EXIT_CLEAN
240
+
241
+ print(f"🟥 TAINTED — 오염 파일 {len(hits)}개")
242
+ for f, why in sorted(hits.items()):
243
+ print(f" {f.relative_to(repo) if repo in f.parents else f}")
244
+ for w in why:
245
+ print(f" {w}")
246
+ print("\n→ 선택지: ⓐ 이 파일들을 payload 에서 빼고 --files 로 재검 "
247
+ "ⓑ 경계-내 패널로 라우팅")
248
+ return EXIT_TAINTED
249
+
250
+
251
+ if __name__ == "__main__":
252
+ sys.exit(main())
@@ -606,6 +606,9 @@ for _pair in \
606
606
  "scripts/destructive_pre_gate.sh|scripts/test_destructive_pre_gate_lanes.sh" \
607
607
  "templates/.git-hooks/pre-push|scripts/test_prepush_destructive_lanes.sh" \
608
608
  "templates/.git-hooks/pre-push|scripts/test_prepush_destructive_liveness.sh" \
609
+ "templates/.git-hooks/pre-push|scripts/test_push_zone_lanes.sh" \
610
+ "scripts/push_zone_check.sh|scripts/test_push_zone_lanes.sh" \
611
+ "scripts/session_close_check.sh|scripts/test_push_zone_lanes.sh" \
609
612
  "scripts/session_close_check.sh|scripts/test_peer_worktree_detect_lanes.sh" \
610
613
  "scripts/env_purity_scan.sh|scripts/test_env_purity_lanes.sh" \
611
614
  "scripts/frontier_digest_daily.sh|scripts/test_frontier_digest_retry.sh" \
@@ -618,13 +621,28 @@ for _pair in \
618
621
  "templates/.git-hooks/pre-commit|scripts/test_marker_thirdparty_lanes.sh" \
619
622
  "templates/.git-hooks/pre-commit|scripts/test_marker_soul_check_lanes.sh" \
620
623
  "templates/.git-hooks/pre-commit|scripts/test_marker_soul_tenet_lanes.sh" \
624
+ "templates/.git-hooks/pre-commit|scripts/test_marker_affected_lanes.sh" \
625
+ "templates/.git-hooks/pre-commit|scripts/test_marker_oracle_lanes.sh" \
626
+ `# ── 판정을 둘로 (2026-09-06, SWE-Gate 답습): 합성 «PASS» 가 acceptance-evidence 를 가리지 않는가 ──` \
627
+ "templates/.git-hooks/pre-commit|scripts/test_gate_two_verdicts_lanes.sh" \
628
+ `# ── fh-qp (QP) — chamber run #18 EMIT 2026-09-05: qp_tools.sh known-pair + residency lanes ──` \
629
+ "plugins/fh-qp/scripts/qp_tools.sh|scripts/test_fh_qp_lanes.sh" \
630
+ "plugins/fh-commons/skills/preprep/diagram_from_json.py|scripts/test_preprep_diagram_lanes.sh" \
631
+ `# ── action.yml — the GitHub Action wrapper: its exit-code mapping is where a typed verdict could become a boolean ──` \
632
+ "action.yml|scripts/test_action_yml_lanes.sh" \
621
633
  "scripts/sim_isolated_run.sh|scripts/test_sim_path_isolation_lanes.sh" \
634
+ `# ── live-eval — the live twin of the static /prompt-regression probe check (2026-09-04) ──` \
635
+ "scripts/probe_live_eval.sh|scripts/test_probe_live_eval_lanes.sh" \
636
+ "scripts/probe_live_eval_lib.py|scripts/test_probe_live_eval_lanes.sh" \
622
637
  "templates/.git-hooks/pre-commit|scripts/test_marker_first_use_lanes.sh" \
623
638
  "scripts/fixture_guard_lib.sh|scripts/test_fixture_guard_lanes.sh" \
624
639
  "scripts/stray_path_scan.sh|scripts/test_stray_path_lanes.sh" \
625
640
  "scripts/session_close_check.sh|scripts/test_stray_path_lanes.sh" \
626
641
  "templates/.git-hooks/pre-commit|scripts/test_hook_leg_wiring_lanes.sh" \
627
642
  ".claude/soul_tenets.txt|scripts/test_marker_soul_tenet_lanes.sh" \
643
+ "docs/map/fh_assets.architecture.json|scripts/test_fh_map_paths_lanes.sh" \
644
+ `# ── 지도 후처리(2026-09-06): 발행 폭 하한 + SVG 재생성. 리터럴 드리프트를 fail-closed 로 잡는다 ──` \
645
+ "scripts/map_postprocess.py|scripts/test_map_postprocess_lanes.sh" \
628
646
  "templates/.git-hooks/pre-commit|scripts/test_precommit_staged_drift_lanes.sh" \
629
647
  "templates/.git-hooks/pre-commit|scripts/test_marker_address_lanes.sh" \
630
648
  "templates/.git-hooks/pre-commit|scripts/test_precommit_pointer_index_lanes.sh" \
@@ -644,6 +662,7 @@ for _pair in \
644
662
  "plugins/fh-commons/skills/preprep/lane_progression.py|scripts/test_preprep_progression_lanes.sh" \
645
663
  "plugins/fh-commons/skills/preprep/lane_adjacent_dup.py|scripts/test_preprep_adjacent_dup_lanes.sh" \
646
664
  "plugins/fh-commons/skills/preprep/lane_promise.py|scripts/test_preprep_promise_lanes.sh" \
665
+ "plugins/fh-commons/skills/preprep/lane_slide_refs.py|scripts/test_preprep_slide_refs_lanes.sh" \
647
666
  "plugins/fh-commons/skills/preprep/SKILL.md|scripts/test_preprep_drift_anchor.sh" \
648
667
  "scripts/test_preprep_drift_anchor.sh|scripts/test_preprep_drift_anchor_lanes.sh" \
649
668
  "scripts/field_canon_preload.sh|scripts/test_skill_canon_preload_lanes.sh" \
@@ -652,7 +671,14 @@ for _pair in \
652
671
  "scripts/round/target_pin.sh|scripts/test_round_instruments_lanes.sh" \
653
672
  "scripts/round/instrument_manifest.sh|scripts/test_round_instruments_lanes.sh" \
654
673
  "scripts/round/eligcheck_qset.sh|scripts/test_round_instruments_lanes.sh" \
655
- "scripts/round/gatecheck_qset.sh|scripts/test_round_instruments_lanes.sh"
674
+ "scripts/round/gatecheck_qset.sh|scripts/test_round_instruments_lanes.sh" \
675
+ `# ── 발화 착지(2026-09-05). 네 주체가 한 스위트로 묶인다 — 주체별로 행을 둔다 ──` \
676
+ "scripts/utterance_intake.sh|scripts/test_utterance_intake_lanes.sh" \
677
+ "scripts/transcript_utterances.py|scripts/test_utterance_intake_lanes.sh" \
678
+ "scripts/compaction_probe.sh|scripts/test_utterance_intake_lanes.sh" \
679
+ "scripts/session_close_check.sh|scripts/test_utterance_intake_lanes.sh" \
680
+ `# ── 워크트리 회수(2026-09-05, N=2 재발 → 스크립트). 사람이 부르는 도구지만 레인은 자동 ──` \
681
+ "scripts/worktree_reclaim.sh|scripts/test_worktree_reclaim_lanes.sh"
656
682
  do
657
683
  _subj="${_pair%%|*}"; _anc="${_pair##*|}"; _lbl="${_anc##*/}"
658
684
  if [ ! -f "$_subj" ]; then
@@ -1585,6 +1611,20 @@ else
1585
1611
  fail=1
1586
1612
  fi
1587
1613
 
1614
+ # outbound-query PreToolUse 훅 — 위 CLI 가드의 **배선**(WebSearch|WebFetch). 레인이 두 층 판정
1615
+ # (override→deny · defaults→advisory), 미측정 4가지→deny, N/A 무음, 그리고 🟥 «출력에 토큰 값이
1616
+ # 없다»(H10)를 각각 짝으로 잡는다. 마지막 것이 이 훅의 존재 이유를 지키는 레인이다.
1617
+ if [ ! -f scripts/outbound_query_hook.sh ]; then
1618
+ _absent_subject_verdict "test_outbound_query_hook_lanes.sh" "scripts/outbound_query_hook.sh" || fail=1
1619
+ elif [ -f scripts/test_outbound_query_hook_lanes.sh ]; then
1620
+ if ! bash scripts/test_outbound_query_hook_lanes.sh; then
1621
+ fail=1
1622
+ fi
1623
+ else
1624
+ echo "FAIL test_outbound_query_hook_lanes.sh: outbound_query_hook.sh present but its anchor is missing"
1625
+ fail=1
1626
+ fi
1627
+
1588
1628
  if [ ! -f scripts/pipe_verdict_guard.sh ]; then
1589
1629
  _absent_subject_verdict "test_pipe_verdict_guard_lanes.sh" "scripts/pipe_verdict_guard.sh" || fail=1
1590
1630
  elif [ -f scripts/test_pipe_verdict_guard_lanes.sh ]; then