@chrono-meta/fh-gate 3.0.0 → 3.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude/regression/probes_live.yaml +137 -0
- package/.claude/rules/.residency-patterns.defaults +7 -0
- package/.claude/rules/fh_4axis_gate.md +50 -1
- package/.claude-plugin/marketplace.json +8 -2
- package/AGENTS.md +27 -0
- package/CATALOG.md +17 -0
- package/CLAUDE.md +12 -2
- package/README.ja.md +51 -7
- package/README.ko.md +48 -7
- package/README.md +37 -5
- package/README.zh.md +45 -8
- package/docs/STANDARDS_ALIGNMENT.md +61 -0
- package/docs/USER_GUIDE.md +3 -0
- package/docs/USE_CASES.md +50 -0
- package/docs/model_tier_expectations.md +60 -0
- package/knowledge/shared/harness-core/fh_three_layer_canon.md +30 -10
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +8 -0
- package/knowledge/shared/harness-core/harness_incubator_doctrine.md +9 -0
- package/knowledge/shared/harness-core/iso_ai_standards_crosswalk.md +139 -0
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +20 -0
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +441 -4
- package/package.json +40 -2
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-commons/skills/preprep/README.md +4 -1
- package/plugins/fh-commons/skills/preprep/SKILL.md +99 -3
- package/plugins/fh-commons/skills/preprep/diagram_from_json.py +154 -0
- package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_negative.pptx +0 -0
- package/plugins/fh-commons/skills/preprep/fixtures/fixture_R3_positive.pptx +0 -0
- package/plugins/fh-commons/skills/preprep/fixtures/mk_slide_fixtures.py +179 -0
- package/plugins/fh-commons/skills/preprep/interslide_deps.py +98 -9
- package/plugins/fh-commons/skills/preprep/lane_adjacent_dup.py +4 -1
- package/plugins/fh-commons/skills/preprep/lane_diagram.py +95 -0
- package/plugins/fh-commons/skills/preprep/lane_geometry.py +181 -0
- package/plugins/fh-commons/skills/preprep/lane_promise.py +4 -1
- package/plugins/fh-commons/skills/preprep/lane_slide_refs.py +134 -0
- package/plugins/fh-commons/skills/preprep/lane_slide_relations.py +300 -0
- package/plugins/fh-commons/skills/preprep/preprep.py +129 -8
- package/plugins/fh-commons/skills/preprep/preprep_wire.py +210 -0
- package/plugins/fh-commons/skills/preprep/presentation_checklist.md +4 -0
- package/plugins/fh-commons/skills/preprep/surfaces.example.yaml +18 -0
- package/plugins/fh-commons/skills/preprep/test_preprep_lanes_rp.py +184 -0
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +43 -1
- package/plugins/fh-meta/skills/auto-decorrelation/SKILL.md +87 -2
- package/plugins/fh-meta/skills/frontier-digest/SKILL.md +2 -2
- package/plugins/fh-meta/skills/frontier-digest/SKILL_detail.md +25 -5
- package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL.md +11 -0
- package/plugins/fh-meta/skills/hub-cc-pr-reviewer/SKILL_detail.md +3 -0
- package/plugins/fh-qp/.claude-plugin/plugin.json +22 -0
- package/plugins/fh-qp/README.md +71 -0
- package/plugins/fh-qp/fixtures/evidence_known_clean.txt +3 -0
- package/plugins/fh-qp/fixtures/evidence_known_dirty.txt +6 -0
- package/plugins/fh-qp/fixtures/reach_known_partial.tsv +4 -0
- package/plugins/fh-qp/fixtures/reach_known_reached.tsv +4 -0
- package/plugins/fh-qp/fixtures/reach_known_wall.tsv +3 -0
- package/plugins/fh-qp/fixtures/verdicts_known_bad_branch.tsv +2 -0
- package/plugins/fh-qp/fixtures/verdicts_known_bad_vacuous_machine.tsv +2 -0
- package/plugins/fh-qp/fixtures/verdicts_known_good.tsv +4 -0
- package/plugins/fh-qp/fixtures/verdicts_verify_only.tsv +3 -0
- package/plugins/fh-qp/qp_profile.example.yaml +29 -0
- package/plugins/fh-qp/scripts/qp_tools.sh +216 -0
- package/plugins/fh-qp/skills/qp/SKILL.md +65 -0
- package/plugins/fh-qp/skills/qp-plan/SKILL.md +42 -0
- package/plugins/fh-qp/skills/qp-regress/SKILL.md +45 -0
- package/plugins/fh-qp/skills/qp-run/SKILL.md +49 -0
- package/scripts/chamber_run.sh +14 -5
- package/scripts/com.forge-harness.live-eval.plist +84 -0
- package/scripts/compaction_probe.sh +9 -35
- package/scripts/directional_diff_gate.sh +14 -2
- package/scripts/frontier_digest_autopilot.sh +4 -1
- package/scripts/map_postprocess.py +90 -0
- package/scripts/outbound_query_guard.sh +131 -0
- package/scripts/outbound_query_hook.sh +373 -0
- package/scripts/package_coverage_check.sh +55 -16
- package/scripts/pipe_verdict_guard.sh +41 -1
- package/scripts/probe_live_eval.sh +240 -0
- package/scripts/probe_live_eval_lib.py +579 -0
- package/scripts/proposal_hook.sh +120 -17
- package/scripts/push_zone_check.sh +78 -0
- package/scripts/residency_closure_scan.py +252 -0
- package/scripts/selfcheck.sh +41 -1
- package/scripts/session_close_check.sh +100 -0
- package/scripts/sim_isolated_run.sh +98 -2
- package/scripts/test_action_yml_lanes.sh +97 -0
- package/scripts/test_fh_qp_lanes.sh +105 -0
- package/scripts/test_gate_two_verdicts_lanes.sh +139 -0
- package/scripts/test_map_postprocess_lanes.sh +143 -0
- package/scripts/test_marker_affected_lanes.sh +93 -0
- package/scripts/test_marker_crossfamily_lanes.sh +90 -6
- package/scripts/test_marker_oracle_lanes.sh +136 -0
- package/scripts/test_outbound_query_hook_lanes.sh +433 -0
- package/scripts/test_outbound_query_lanes.sh +87 -0
- package/scripts/test_pipe_verdict_guard_lanes.sh +26 -0
- package/scripts/test_preprep_diagram_lanes.sh +87 -0
- package/scripts/test_preprep_drift_anchor.sh +3 -3
- package/scripts/test_preprep_slide_refs_lanes.sh +169 -0
- package/scripts/test_probe_live_eval_lanes.sh +437 -0
- package/scripts/test_proposal_hook_lanes.sh +22 -1
- package/scripts/test_push_zone_lanes.sh +304 -0
- package/scripts/test_residency_closure_lanes.sh +70 -0
- package/scripts/test_sim_isolated_run_lanes.sh +119 -0
- package/scripts/test_utterance_intake_lanes.sh +414 -0
- package/scripts/test_worktree_reclaim_lanes.sh +70 -0
- package/scripts/transcript_utterances.py +222 -0
- package/scripts/utterance_intake.sh +424 -0
- package/scripts/validate_yaml.sh +27 -0
- package/scripts/worktree_reclaim.sh +95 -0
- package/templates/.git-hooks/pre-commit +353 -1
- package/templates/.git-hooks/pre-push +91 -0
- package/templates/RED_TEAM_REPORT.md +49 -0
- package/templates/settings.PreToolUse.snippet.json +65 -1
package/scripts/proposal_hook.sh
CHANGED
|
@@ -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
|
|
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
|
-
#
|
|
29
|
-
#
|
|
30
|
-
#
|
|
31
|
-
#
|
|
32
|
-
#
|
|
33
|
-
#
|
|
34
|
-
#
|
|
35
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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=
|
|
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
|
-
|
|
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
|
-
|
|
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())
|
package/scripts/selfcheck.sh
CHANGED
|
@@ -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
|