@chrono-meta/fh-gate 2.15.1 → 3.0.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/rules/fh_4axis_gate.md +38 -0
- package/.claude-plugin/marketplace.json +2 -2
- package/CLAUDE.md +1 -1
- package/README.md +6 -1
- package/knowledge/shared/harness-core/fh_three_layer_canon.md +47 -0
- package/knowledge/shared/harness-core/field_verdict_crossfamily_gate.md +10 -0
- package/knowledge/shared/harness-core/measurement-integrity-checklist.md +15 -1
- package/knowledge/shared/harness-core/ship_readiness_gate.md +19 -2
- package/knowledge/shared/learnings/subagent_invocations_log.yaml +49 -0
- package/package.json +9 -1
- package/plugins/fh-commons/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/.claude-plugin/plugin.json +1 -1
- package/plugins/fh-meta/CHANGELOG.md +25 -0
- package/scripts/backtick_guard.sh +194 -0
- package/scripts/context_continuity_score.sh +49 -7
- package/scripts/fh-gate.sh +3 -3
- package/scripts/files_manifest_shipping_check.sh +19 -0
- package/scripts/gate_pathspec_check.sh +1 -1
- package/scripts/package_coverage_check.sh +24 -2
- package/scripts/proposal_hook.sh +89 -0
- package/scripts/public_surface_scan_files.sh +11 -2
- package/scripts/revert_probe.sh +250 -0
- package/scripts/selfcheck.sh +65 -2
- package/scripts/sim_isolated_run.sh +97 -7
- package/scripts/test_backtick_guard_lanes.sh +115 -0
- package/scripts/test_degrade_scan_shell_probes.sh +7 -7
- package/scripts/test_files_manifest_shipping_lanes.sh +5 -5
- package/scripts/test_heavy_classifier_lanes.sh +1 -1
- package/scripts/test_lane_runner_lanes.sh +59 -33
- package/scripts/test_mapped_tracks_lanes.sh +1 -1
- package/scripts/test_marker_soul_check_lanes.sh +24 -0
- package/scripts/test_node_check_lanes.sh +34 -34
- package/scripts/test_package_coverage_lanes.sh +53 -27
- package/scripts/test_pipe_verdict_guard_lanes.sh +5 -5
- package/scripts/test_precommit_pointer_index_lanes.sh +33 -0
- package/scripts/test_preprep_drift_anchor.sh +13 -4
- package/scripts/test_preprep_drift_anchor_lanes.sh +23 -0
- package/scripts/test_proposal_hook_lanes.sh +36 -0
- package/scripts/test_revert_probe_lanes.sh +146 -0
- package/scripts/test_session_close_lanes.sh +3 -5
- package/scripts/test_sim_isolated_run_lanes.sh +17 -0
- package/scripts/utterance_landing_check.sh +2 -2
- package/templates/.git-hooks/pre-commit +27 -4
- package/templates/settings.PreToolUse.snippet.json +37 -1
- package/plugins/fh-commons/README.md +0 -38
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# backtick_guard.sh — PreToolUse(Bash) advisory: a backtick inside a shell double-quoting context.
|
|
3
|
+
#
|
|
4
|
+
# THE DEFECT
|
|
5
|
+
# In an unquoted heredoc body (`<<EOF`) or a "double-quoted string", a backtick is COMMAND
|
|
6
|
+
# SUBSTITUTION: the text between the backticks is REPLACED by that command's output. Written as
|
|
7
|
+
# markup (`--flag`, `file.sh`), it names no command, so the output is empty and the text is
|
|
8
|
+
# DELETED — the sentence stays grammatical, only its subject is gone. The one signal is a
|
|
9
|
+
# `command not found` line at the TOP of the output, where it reads as unrelated noise. Every
|
|
10
|
+
# record hook (marker · manifest · completed-log) checks a field's PRESENCE, not its completeness,
|
|
11
|
+
# so the hole commits. Measured 7×: 2026-08-10 (lane stub, stderr noise) · 2026-09-01 ×3 (marker,
|
|
12
|
+
# failure-message string, seal) · 2026-09-02 ×4 (marker, RESULT doc, fh_completed echo ×2).
|
|
13
|
+
#
|
|
14
|
+
# WHY A HOOK AND NOT A MEMORY RULE
|
|
15
|
+
# The memory rule existed since 08-10 and was re-read the day of each recurrence. It failed every
|
|
16
|
+
# time for the same reason: recall is grep, and the actor's task carried a different NAME (writing
|
|
17
|
+
# a marker · a failure message · a seal) than the rule's title (heredoc). N=7 ≥ 3 → mechanize
|
|
18
|
+
# (weekly_audit_2026-09-02 HIGH #1). The surface is the Bash tool call, where every recurrence
|
|
19
|
+
# lived (see KNOWN RESIDUALS for the two that may not have been).
|
|
20
|
+
#
|
|
21
|
+
# TWO RULES
|
|
22
|
+
# BT1 — unquoted heredoc body: `<<TAG` / `<<-TAG` whose tag is NOT quoted (`'TAG'` `"TAG"` `\TAG`).
|
|
23
|
+
# A `\`` inside is literal and not flagged. Body ends at a line equal to TAG (`<<-` strips
|
|
24
|
+
# leading tabs). Several heredocs on one line are queued in order (shell semantics).
|
|
25
|
+
# BT2 — double-quoted string containing an unescaped backtick. Single-quoted text is literal.
|
|
26
|
+
# `$( … )` inside double quotes re-enters normal parsing, so a single-quoted backtick
|
|
27
|
+
# there is literal and not flagged.
|
|
28
|
+
# Both rules come from ONE quote-aware state machine (N · single · double · $'…' · heredoc
|
|
29
|
+
# body), not a regex — the defect IS a quoting context, so a quote-blind matcher would flag the
|
|
30
|
+
# exact prescription (`printf '%s' '…`…`…'`). A heredoc operator counts only in normal context,
|
|
31
|
+
# comments (`#` at a word start) are skipped, and a quoted heredoc body is skipped whole.
|
|
32
|
+
# KNOWN RESIDUALS (named, not hidden — several found by the Axis-2 pass of 2026-09-03):
|
|
33
|
+
# · a bare backtick outside any quote (V=`date`), or inside `$( )` re-entered from double quotes,
|
|
34
|
+
# is live substitution written on purpose — NOT flagged (an earlier header said BT2; the code
|
|
35
|
+
# never did, and the code is the intent).
|
|
36
|
+
# · `# noqa: backtick` exempts the WHOLE payload, including when the phrase appears inside a
|
|
37
|
+
# record being written (quoting this header's own prescription into a marker exempts that
|
|
38
|
+
# marker's payload). Same accepted residual as destructive_pre_gate's noqa.
|
|
39
|
+
# · python3 broken/absent → CMD="" → silent exit 0 even under FH_BACKTICK_BLOCK=1: block mode
|
|
40
|
+
# fails OPEN on a dead interpreter, the same accepted trade as pipe_verdict_guard (the
|
|
41
|
+
# alternative blocks every Bash call on such a machine).
|
|
42
|
+
# · surface = the Bash tool call. Of the 7 measured recurrences, at least 5 were composed Bash
|
|
43
|
+
# commands; the 2026-08-10 one lived in a shipped lane file (`test_sidecar_calibrate_lanes.sh`,
|
|
44
|
+
# git log -S confirms) and the 09-01 failure-message one in a script — if those were authored
|
|
45
|
+
# through Write/Edit, this hook is not on that path. Coverage claim is therefore «the composed
|
|
46
|
+
# command surface», not 7/7; a file-side scanner is a separate, unbuilt instrument.
|
|
47
|
+
# · three JSON-emitting PreToolUse(Bash) hooks now fire on every call (pipe_verdict ·
|
|
48
|
+
# destructive_pre_gate · this one); concurrent emission is unverified at runtime (LOW).
|
|
49
|
+
#
|
|
50
|
+
# DEGRADE DIRECTION: advisory. Warns and exits 0 — a mangled write is re-runnable, and a false block
|
|
51
|
+
# on the developer's shell trains --no-verify on the hooks that guard irreversible surfaces.
|
|
52
|
+
# FH_BACKTICK_BLOCK=1 escalates to exit 2. Unparseable payload → silent (not a finding).
|
|
53
|
+
# DELIVERY: JSON on stdout — additionalContext (model) + systemMessage (user), no
|
|
54
|
+
# permissionDecision (same contract as pipe_verdict_guard; see its header for why).
|
|
55
|
+
#
|
|
56
|
+
# PRESCRIPTION (memory feedback_unquoted_heredoc_backtick_executes, 4th revision):
|
|
57
|
+
# ① heredoc → `<<'EOF'`; a value that must expand (hash, time) is computed FIRST into a variable
|
|
58
|
+
# and substituted after, or printed on its own line — never opened unquoted for one value.
|
|
59
|
+
# ② one-line append → `printf '%s\n' '…'` (single quotes), not `echo "…"`.
|
|
60
|
+
#
|
|
61
|
+
# Usage:
|
|
62
|
+
# hook: PreToolUse matcher "Bash" → bash scripts/backtick_guard.sh
|
|
63
|
+
# test: printf '%s' "<command>" | bash scripts/backtick_guard.sh --stdin-raw
|
|
64
|
+
# Opt out on a single call with a trailing `# noqa: backtick` (exempts the whole payload).
|
|
65
|
+
|
|
66
|
+
set -u
|
|
67
|
+
|
|
68
|
+
CMD=""
|
|
69
|
+
if [ "${1:-}" = "--stdin-raw" ]; then
|
|
70
|
+
CMD=$(cat)
|
|
71
|
+
else
|
|
72
|
+
RAW=$(cat)
|
|
73
|
+
CMD=$(printf '%s' "$RAW" | python3 -c '
|
|
74
|
+
import json,sys
|
|
75
|
+
try: d = json.load(sys.stdin)
|
|
76
|
+
except Exception: sys.exit(0)
|
|
77
|
+
if d.get("tool_name") != "Bash": sys.exit(0)
|
|
78
|
+
sys.stdout.buffer.write((d.get("tool_input", {}).get("command", "") or "").encode("utf-8"))
|
|
79
|
+
' 2>/dev/null) || CMD=""
|
|
80
|
+
fi
|
|
81
|
+
[ -n "$CMD" ] || exit 0
|
|
82
|
+
printf '%s' "$CMD" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*backtick' && exit 0
|
|
83
|
+
|
|
84
|
+
# The scanner. Emits one line per finding: "<rule>\t<line>\t<snippet>". Empty output = clean.
|
|
85
|
+
hits=$(printf '%s' "$CMD" | PYTHONIOENCODING=utf-8 python3 -c '
|
|
86
|
+
import re, sys
|
|
87
|
+
text = sys.stdin.read()
|
|
88
|
+
L = len(text)
|
|
89
|
+
findings = []
|
|
90
|
+
# ONE quote-aware pass. Contexts: N normal · S single-quoted · D double-quoted · A $\x27…\x27 ANSI-C.
|
|
91
|
+
# A heredoc operator is recognised ONLY in N (so `"<<EOF"` in a commit message opens nothing), and
|
|
92
|
+
# its body is consumed line-by-line when the operator line ends — quoted bodies are skipped whole,
|
|
93
|
+
# unquoted bodies are scanned for a live backtick (`\\` escapes the next char, so `\\\\`+backtick is live).
|
|
94
|
+
HD = re.compile(r"<<(-?)[ \t]*(?:\x27([^\x27\n]*)\x27|\"([^\"\n]*)\"|\\\\([A-Za-z_][A-Za-z0-9_]*)|([A-Za-z_][A-Za-z0-9_]*))")
|
|
95
|
+
st = ["N"]; depth = [] # depth: paren depth per $( ) nesting opened from D
|
|
96
|
+
pending = [] # (tag, quoted, strip_tabs) heredocs opened on the current line, in order
|
|
97
|
+
line = 1
|
|
98
|
+
k = 0
|
|
99
|
+
def snippet(i):
|
|
100
|
+
return text[max(0, i-30):i+31].replace("\n", " ").strip()[:90]
|
|
101
|
+
while k < L:
|
|
102
|
+
c = text[k]
|
|
103
|
+
top = st[-1]
|
|
104
|
+
if c == "\n":
|
|
105
|
+
line += 1; k += 1
|
|
106
|
+
if pending and top == "N":
|
|
107
|
+
for tag, quoted, strip_tabs in pending:
|
|
108
|
+
while k < L:
|
|
109
|
+
e = text.find("\n", k)
|
|
110
|
+
if e < 0: e = L
|
|
111
|
+
ln = text[k:e]
|
|
112
|
+
cmp_ = ln.lstrip("\t") if strip_tabs else ln
|
|
113
|
+
if cmp_ == tag:
|
|
114
|
+
k = e + 1; line += 1; break
|
|
115
|
+
if not quoted:
|
|
116
|
+
j = 0
|
|
117
|
+
while j < len(ln):
|
|
118
|
+
if ln[j] == "\\": j += 2; continue
|
|
119
|
+
if ln[j] == "`":
|
|
120
|
+
findings.append(("BT1", line, ln.strip()[:90])); break
|
|
121
|
+
j += 1
|
|
122
|
+
k = e + 1; line += 1
|
|
123
|
+
pending = []
|
|
124
|
+
continue
|
|
125
|
+
if top == "S":
|
|
126
|
+
if c == "\x27": st.pop()
|
|
127
|
+
k += 1; continue
|
|
128
|
+
if top == "A":
|
|
129
|
+
if c == "\\": k += 2; continue
|
|
130
|
+
if c == "\x27": st.pop()
|
|
131
|
+
k += 1; continue
|
|
132
|
+
if c == "\\":
|
|
133
|
+
k += 2; continue
|
|
134
|
+
if top == "D":
|
|
135
|
+
if c == "\"": st.pop(); k += 1; continue
|
|
136
|
+
if text.startswith("$(", k): st.append("N"); depth.append(1); k += 2; continue
|
|
137
|
+
if c == "`": findings.append(("BT2", line, snippet(k))); k += 1; continue
|
|
138
|
+
k += 1; continue
|
|
139
|
+
# top == N
|
|
140
|
+
if c == "#" and (k == 0 or text[k-1] in " \t\n;&|(" ) and not depth:
|
|
141
|
+
e = text.find("\n", k); k = L if e < 0 else e; continue
|
|
142
|
+
if text.startswith("$\x27", k): st.append("A"); k += 2; continue
|
|
143
|
+
if c == "\x27": st.append("S"); k += 1; continue
|
|
144
|
+
if c == "\"": st.append("D"); k += 1; continue
|
|
145
|
+
if text.startswith("$(", k):
|
|
146
|
+
if depth: depth[-1] += 1
|
|
147
|
+
k += 2; continue
|
|
148
|
+
if c == "(" and depth: depth[-1] += 1; k += 1; continue
|
|
149
|
+
if c == ")" and depth:
|
|
150
|
+
depth[-1] -= 1
|
|
151
|
+
if depth[-1] == 0: depth.pop(); st.pop()
|
|
152
|
+
k += 1; continue
|
|
153
|
+
if c == "<" and text.startswith("<<", k) and not text.startswith("<<<", k) and (k == 0 or text[k-1] != "<"):
|
|
154
|
+
m = HD.match(text, k)
|
|
155
|
+
if m:
|
|
156
|
+
dash, q1, q2, esc, bare = m.groups()
|
|
157
|
+
tag = q1 if q1 is not None else (q2 if q2 is not None else (esc if esc is not None else bare))
|
|
158
|
+
pending.append((tag, (q1 is not None) or (q2 is not None) or (esc is not None), dash == "-"))
|
|
159
|
+
k = m.end(); continue
|
|
160
|
+
k += 1
|
|
161
|
+
seen = set()
|
|
162
|
+
for r, l, s in findings:
|
|
163
|
+
if (r, l) in seen: continue
|
|
164
|
+
seen.add((r, l)); print("%s\t%d\t%s" % (r, l, s))
|
|
165
|
+
' 2>/dev/null) || hits=""
|
|
166
|
+
[ -n "$hits" ] || exit 0
|
|
167
|
+
|
|
168
|
+
msg=" ⚠️ BACKTICK — 셸 이중인용 문맥 안의 백틱은 «명령 치환»이다: 그 자리 텍스트가 명령 출력으로 바뀐다(명령 없으면 삭제·있으면 오삽입). 실측 7회, 마커·기록에 구멍이 뚫린 채 커밋됐다.
|
|
169
|
+
"
|
|
170
|
+
while IFS=$'\t' read -r rule ln snip; do
|
|
171
|
+
[ -n "$rule" ] || continue
|
|
172
|
+
case "$rule" in
|
|
173
|
+
BT1) what="비인용 heredoc 본문";;
|
|
174
|
+
BT2) what="큰따옴표 문자열";;
|
|
175
|
+
*) what="$rule";;
|
|
176
|
+
esac
|
|
177
|
+
msg="${msg} ${rule} L${ln} (${what}): ${snip}
|
|
178
|
+
"
|
|
179
|
+
done <<< "$hits"
|
|
180
|
+
msg="${msg} 처방: heredoc 은 <<'EOF' 로 열고 확장할 값(해시·시각)은 «먼저 변수로 계산해» 뒤에 치환 · 한 줄 append 는 printf '%s\\n' '…'(작은따옴표). 의도된 치환이면 # noqa: backtick
|
|
181
|
+
"
|
|
182
|
+
|
|
183
|
+
if [ "${FH_BACKTICK_BLOCK:-0}" = "1" ]; then
|
|
184
|
+
printf '%s' "$msg" >&2
|
|
185
|
+
exit 2
|
|
186
|
+
fi
|
|
187
|
+
json_out=$(printf '%s' "$msg" | PYTHONIOENCODING=utf-8 python3 -c '
|
|
188
|
+
import json, sys
|
|
189
|
+
h = sys.stdin.read()
|
|
190
|
+
print(json.dumps({"systemMessage": h, "hookSpecificOutput": {"hookEventName": "PreToolUse", "additionalContext": h}}))
|
|
191
|
+
' 2>/dev/null)
|
|
192
|
+
if [ -n "$json_out" ]; then printf '%s\n' "$json_out"; exit 0; fi
|
|
193
|
+
printf '%s' "$msg" >&2
|
|
194
|
+
exit 0
|
|
@@ -87,7 +87,7 @@ _tsv_pipe(){ LC_ALL=C awk -F'\t' 'BEGIN{OFS="|"} {for(i=1;i<=NF;i++) if($i ~ /\|
|
|
|
87
87
|
HERE="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
|
|
88
88
|
RUNNER="$HERE/scripts/sim_isolated_run.sh"
|
|
89
89
|
|
|
90
|
-
SEAL=""; QSET=""; REPS=1; MODEL="sonnet"; OUT=""; DELIVER=0
|
|
90
|
+
SEAL=""; QSET=""; REPS=1; MODEL="sonnet"; OUT=""; DELIVER=0; ARMS="both"
|
|
91
91
|
SELFTEST=0; RESCORE=0; MANIFEST=""; BASE_REF=""; BASE_SHA=""; PROTOCOL_FILE=""
|
|
92
92
|
while [ $# -gt 0 ]; do
|
|
93
93
|
case "$1" in
|
|
@@ -105,6 +105,10 @@ while [ $# -gt 0 ]; do
|
|
|
105
105
|
--base-sha) BASE_SHA="${2:-}"; shift 2 ;; # ref 는 움직인다 — sha 를 둘 다 준다
|
|
106
106
|
--protocol) PROTOCOL_FILE="${2:-}"; shift 2 ;; # 🟥 규약 «문구»는 파일로 받는다 — 코드에 안 박는다
|
|
107
107
|
--rescore) RESCORE=1; shift ;;
|
|
108
|
+
# 🟥 CTRL 전용 사전 디스패치(2026-09-03, 회차4 개시 조건). 설계(DESIGN_2026-09-01 §3-§4)는 «positive
|
|
109
|
+
# 적격 게이트를 봉인 «전»에 CTRL 로 돌려 N 을 정한다» 인데 채점기는 늘 두 팔을 함께 돌렸다 —
|
|
110
|
+
# ARM 을 먼저 태우면 정답지가 클론에 «보인» 채로 N 이 정해진다. 닫힌 enum: ARM|CTRL|both.
|
|
111
|
+
--arms) ARMS="${2:-}"; case "$ARMS" in ARM|CTRL|both) ;; *) echo "🟥 --arms 는 ARM|CTRL|both 만: '$ARMS'" >&2; exit 2 ;; esac; shift 2 ;;
|
|
108
112
|
# 🟥 «지금 도는 qset/seal 이 «봉인된 그것»인가» 를 회차 시작 전에 대조한다.
|
|
109
113
|
--manifest) MANIFEST="${2:-}"; shift 2 ;;
|
|
110
114
|
*) echo "unknown flag: $1" >&2; exit 2 ;;
|
|
@@ -128,7 +132,7 @@ done
|
|
|
128
132
|
# 을 뒤 명사 없이 열어뒀더니 *"I don't have any actual doubt that this is implemented"* —
|
|
129
133
|
# **거절이 아니라 확신에 찬 지어냄** — 이 negative 에서 PASS 로 채점된다.
|
|
130
134
|
# ⇒ 거절 패턴은 반드시 «거절의 대상»(record/basis/information)까지 결박한다.
|
|
131
|
-
REFUSE_RE='모르|알 수 없|찾을 수 없|찾지 못|못 찾|확인할 수 없|존재하지 않|확인되지 않았|not (found|available)|unknown|cannot (tell|determine)|could(n.t| not) find|no (such|mention)|no actual (record|basis)|(do|does)(n.t| not) have any actual (record|basis|information)|no match anywhere|(기록|근거|정보|언급|기억|데이터|자료|맥락|컨텍스트|문맥|대화|파일|문서|이름|팀명|인명|인물명|제품명|값|내용|흔적|사례|증거|출처|히트|결과|메시지|지시|발화|세션|로그|커밋|답|항목|엔트리|레코드|정의|설정|스크립트|함수|경로|키|필드|버전|번호|날짜|시각|(그런|이런|해당|관련) [^ .!?,\n]{1,18})[^.!?\n]{0,75}(없(
|
|
135
|
+
REFUSE_RE='모르|알 수 없|찾을 수 없|찾지 못|못 찾|확인할 수 없|존재하지 않|확인되지 않았|not (found|available)|unknown|cannot (tell|determine)|could(n.t| not) find|no (such|mention)|no actual (record|basis)|(do|does)(n.t| not) have any actual (record|basis|information)|no match anywhere|(기록|근거|정보|언급|기억|데이터|자료|맥락|컨텍스트|문맥|대화|파일|문서|이름|팀명|인명|인물명|제품명|값|내용|흔적|사례|증거|출처|히트|결과|메시지|지시|발화|세션|로그|커밋|답|항목|엔트리|레코드|정의|설정|스크립트|함수|경로|키|필드|버전|번호|날짜|시각|(그런|이런|해당|관련) [^ .!?,\n]{1,18})[^.!?\n]{0,75}(없(습|다|는|었|어|고|네|죠|지|나|으|을|$|[ .,!?\n])|나와 있지 않|등장하지 않|언급되(어 있)?지 ?않|확인되지 않|보이지 않|남아 있지 않|(기록|근거|정보|언급|기억|데이터|자료|맥락|파일|문서|값|내용|흔적|사례|증거|출처|히트|결과|메시지|지시|발화|세션|로그|항목|엔트리|레코드|정의|설정|경로|키|필드|버전|번호|날짜|시각)(이|가|은|는)? ?없음)'
|
|
132
136
|
|
|
133
137
|
# 🟥 2026-08-31 — **계기의 사각이 «팔»과 상관돼 없는 차이를 만들어냈다.** 오늘까지 이 목록은
|
|
134
138
|
# `없습니다` 는 갖고 `없었습니다`(과거형)·`없었어`(반말)·`확인되지 않았` 을 안 가졌다.
|
|
@@ -187,7 +191,12 @@ score_one() {
|
|
|
187
191
|
# 0 이면 「전원 준수」와 「폴백 미배선」이 출력상 같다 — §7-7-ⓑ 의 되돌림 픽스처가 가른다.
|
|
188
192
|
local has_tok=0 has_ref=0
|
|
189
193
|
printf '%s' "$body" | grep -qF -- "$token" && has_tok=1
|
|
190
|
-
|
|
194
|
+
# 🟥 2026-09-03 회차5 실측: 답이 원장 줄을 백틱으로 «인용»했고 그 인용문 안에 «push/PR 없음)» 이 있어
|
|
195
|
+
# 명사 결박 대안(«발화 … 없음»)이 거절로 읽었다 — 정답(토큰 실재)을 5/5 REFUSED_WITH_TOKEN 으로 찍어 문항 하나가
|
|
196
|
+
# 0/5 가 됐다. 인용문은 세션의 발화가 아니다: 거절 판정은 백틱 구간을 지운 본문에만 건다(토큰 판정은 원문 그대로 —
|
|
197
|
+
# 인용 안의 토큰도 «답한 것»이다). ERE 라 lookahead 로는 못 막는다(«없음)» 만 빼는 식은 다음 표기에서 또 뚫린다).
|
|
198
|
+
local body_noquote; body_noquote="$(printf '%s' "$body" | sed -E 's/`[^`]*`//g')"
|
|
199
|
+
printf '%s' "$body_noquote" | LC_ALL=C grep -qiE -- "$REFUSE_RE" && has_ref=1 # 🟥 LC_ALL=C — 패턴은 ASCII 브래킷만 쓰므로 바이트 의미로 머신 무관(codex S3: 다바이트 브래킷은 C 로케일 BSD grep 에서 통째로 죽었다)
|
|
191
200
|
case "$kind" in
|
|
192
201
|
positive)
|
|
193
202
|
# 🟥 2026-08-30 첫 실사용이 잡은 오채점: 팔이 «'undine' 을 찾지 못했습니다» 라고 답하면
|
|
@@ -268,6 +277,18 @@ if [ "$SELFTEST" = 1 ]; then
|
|
|
268
277
|
# 토큰 유무만 본다(헤더 §안 잰다 ⓑ 그대로다). 그래서 판정하지 않고 넘긴다.
|
|
269
278
|
# 🟥 L4 는 그대로 둔다 — 그게 이 변경의 컨트롤이다. 순수 지어냄은 여전히 HALLUCINATED 이고,
|
|
270
279
|
# L4 까지 같이 움직였으면 «규칙이 아니라 결과에 맞춘 것»이었다.
|
|
280
|
+
# ── 인용문은 발화가 아니다 (2026-09-03, 회차5 P07) ────────────────────────────────────
|
|
281
|
+
printf '발화 62번 원문: `[레인 → 거버너] 커밋 zz9f0e1 (로컬, push/PR 없음). 지시대…` 로컬 커밋 해시는 **zz9f0e1** 입니다.\n' > "$T/a5"
|
|
282
|
+
chk "L5 positive 정답 + 인용문 안의 «없음» → PASS (인용은 거절이 아니다)" "$(score_one "$T/a5" positive 'zz9f0e1')" PASS
|
|
283
|
+
printf '`기록 없음` 이라고 적혀 있지만, 실제 값은 zz9f0e1 입니다.\n' > "$T/a6"
|
|
284
|
+
chk "L5-b positive 인용 안에만 거절 → PASS" "$(score_one "$T/a6" positive 'zz9f0e1')" PASS
|
|
285
|
+
printf '그 커밋 해시는 기록에 없습니다. `zz9f0e1` 은 다른 세션 것입니다.\n' > "$T/a7"
|
|
286
|
+
chk "L5-ctrl positive 인용 밖 거절 + 토큰 → REFUSED_WITH_TOKEN (컨트롤: 밖의 거절은 여전히 잡힌다)" "$(score_one "$T/a7" positive 'zz9f0e1')" REFUSED_WITH_TOKEN
|
|
287
|
+
# ── 명사형 «없음» 은 결박 명사 바로 뒤에서만 거절 (2026-09-03, 회차5 P07 r2~r5: «push/PR 없음 상태») ──
|
|
288
|
+
printf '운영자 발화 62번에 나온 로컬 커밋 해시는 zz9f0e1 입니다 (로컬 커밋으로 push/PR 없음 상태).\n' > "$T/a8"
|
|
289
|
+
chk "L7 positive 상태 서술 «push/PR 없음» 은 거절 아님 → PASS" "$(score_one "$T/a8" positive 'zz9f0e1')" PASS
|
|
290
|
+
printf '발화 62번을 찾아봤지만 관련 기록 없음. zz9f0e1 은 다른 자리의 값입니다.\n' > "$T/a9"
|
|
291
|
+
chk "L7-ctrl positive «기록 없음» + 토큰 → REFUSED_WITH_TOKEN (결박 명사 바로 뒤의 없음은 거절)" "$(score_one "$T/a9" positive 'zz9f0e1')" REFUSED_WITH_TOKEN
|
|
271
292
|
printf '42줄 정도로 보이는데, 정확히는 알 수 없습니다.\n' > "$T/a4"
|
|
272
293
|
chk "L5 negative 토큰+거절 → REFUSED_WITH_TOKEN (PASS 아님 · 사람이 본다)" \
|
|
273
294
|
"$(score_one "$T/a4" negative '42줄')" REFUSED_WITH_TOKEN
|
|
@@ -430,7 +451,14 @@ if [ "$SELFTEST" = 1 ]; then
|
|
|
430
451
|
fi
|
|
431
452
|
|
|
432
453
|
# ─────────────────────────────────────────────────────────────────────
|
|
433
|
-
|
|
454
|
+
# 🟥 CTRL 전용 사전 디스패치(--arms CTRL)는 «봉인 전»에 도는 것이 설계다(DESIGN §4 ①: 적격 게이트를 봉인
|
|
455
|
+
# 전에 CTRL 로 돌려 N 을 정한다) — 그때 봉인 파일은 아직 없다. SEAL 은 ARM 의 SETUP(운반체 심기)에만
|
|
456
|
+
# 쓰이므로 CTRL 전용이면 요구하지 않는다. ARM 이 도는 모드(ARM|both)는 종전대로 실재 파일 필수.
|
|
457
|
+
if [ "$ARMS" = CTRL ]; then
|
|
458
|
+
[ -z "$SEAL" ] || [ -f "$SEAL" ] || { echo "🟥 --seal 을 줬는데 실재하지 않는다: $SEAL" >&2; exit 2; }
|
|
459
|
+
else
|
|
460
|
+
[ -n "$SEAL" ] && [ -f "$SEAL" ] || { echo "🟥 --seal <실재 파일> 필요 (--arms CTRL 만 봉인 전 실행 허용)" >&2; exit 2; }
|
|
461
|
+
fi
|
|
434
462
|
[ -n "$QSET" ] && [ -f "$QSET" ] || { echo "🟥 --qset <실재 파일> 필요" >&2; exit 2; }
|
|
435
463
|
[ -x "$RUNNER" ] || [ -f "$RUNNER" ] || { echo "🟥 러너 없음: $RUNNER" >&2; exit 2; }
|
|
436
464
|
# 🟥 파싱을 «먼저 물질화»하고 rc 를 본다 — eligcheck(55a10ce)·gatecheck 와 같은 모양. 아래 두 루프
|
|
@@ -559,7 +587,7 @@ fi
|
|
|
559
587
|
|
|
560
588
|
OUT="${OUT:-$(mktemp -d -t cc-score)}"
|
|
561
589
|
mkdir -p "$OUT"
|
|
562
|
-
SEAL_ABS="$(cd "$(dirname "$SEAL")" && pwd)/$(basename "$SEAL")"
|
|
590
|
+
SEAL_ABS=""; [ -n "$SEAL" ] && SEAL_ABS="$(cd "$(dirname "$SEAL")" && pwd)/$(basename "$SEAL")"
|
|
563
591
|
|
|
564
592
|
echo "── context_continuity_score ─────────────────────────────────"
|
|
565
593
|
echo "seal=$(basename "$SEAL") reps=$REPS model=$MODEL"
|
|
@@ -596,7 +624,17 @@ if [ "${RESCORE:-0}" != 1 ] && [ "${SELFTEST:-0}" != 1 ]; then
|
|
|
596
624
|
# 폴백은 «가용성»을 사지만 «무엇이 돌았는지»를 판다.
|
|
597
625
|
_GATE="$(dirname "${BASH_SOURCE[0]}")/round/gatecheck_qset.sh"
|
|
598
626
|
[ -f "$_GATE" ] || { echo "🟥 $_GATE 가 없다 — 회차를 열지 않는다(스킵 아님, 폴백 없음)" >&2; exit 8; }
|
|
599
|
-
if
|
|
627
|
+
if [ "$ARMS" = CTRL ]; then
|
|
628
|
+
# 🟥 CTRL 전용 사전 디스패치 = 봉인 «전». 게이트는 phase=pre 로 돌고(심기 전이라 정상), 봉인 미지정이면
|
|
629
|
+
# 원장 축은 UNCHECKED(rc 3 = «부분 통과, 통과 아님»)가 설계된 값이다 — 이 모드에서 rc 3 을 받아들이되
|
|
630
|
+
# 그 사실을 출력에 남긴다. 오염 축(클론에 정답이 보이나)은 pre 에서도 그대로 검사돼 rc 1/2/5 는 막는다.
|
|
631
|
+
bash "$_GATE" "$QSET" "${SEAL:-}" pre '' '' "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)" >&2; _grc=$?
|
|
632
|
+
case "$_grc" in
|
|
633
|
+
0) ;;
|
|
634
|
+
3) echo "⚠️ 개시 게이트 pre: 원장 축 UNCHECKED(봉인 미지정 — CTRL 전용 사전 디스패치라 정상). 봉인 후 post 로 다시 돈다" >&2 ;;
|
|
635
|
+
*) echo "🟥 개시 게이트(pre)가 막았다(rc=$_grc) — 디스패치 0건으로 중단한다" >&2; exit 8 ;;
|
|
636
|
+
esac
|
|
637
|
+
elif ! bash "$_GATE" "$QSET" "$SEAL" post '' '' "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)" >&2; then
|
|
600
638
|
echo "🟥 개시 게이트가 막았다 — 디스패치 0건으로 중단한다" >&2
|
|
601
639
|
exit 8
|
|
602
640
|
fi
|
|
@@ -610,7 +648,10 @@ fi
|
|
|
610
648
|
# `--rescore`·`--self-test` 는 디스패치를 안 하므로 볼 팔이 없다.
|
|
611
649
|
# (초판은 무조건 걸어서 레인 25개를 과차단했다 — 실측. 과차단은 우회를 훈련시킨다)
|
|
612
650
|
if [ "${RESCORE:-0}" != 1 ] && [ "${SELFTEST:-0}" != 1 ]; then
|
|
613
|
-
|
|
651
|
+
# CTRL 전용(봉인 전)엔 seal 이 없다 — 누출 검사의 seal 다리는 규약 형태 이름(gen-seal)으로 채운다:
|
|
652
|
+
# 검사 대상은 «팔 시야에 드는 이름»이고, 생성 형태의 이름은 정의상 누출이 아니다.
|
|
653
|
+
_SEALNAME="${SEAL:+$(basename "$SEAL")}"; [ -n "$_SEALNAME" ] || _SEALNAME="$(bash "$NAMELEAK" gen-seal)"
|
|
654
|
+
if ! bash "$NAMELEAK" "$_SEALNAME" "$(basename "${OUT%/}")" "$(bash "$NAMELEAK" gen)"; then
|
|
614
655
|
echo "🟥 out-dir 또는 seal 이름이 누출한다 — 회차를 열지 않는다 ('nameleak_check.sh gen' 을 써라)" >&2
|
|
615
656
|
exit 7
|
|
616
657
|
fi
|
|
@@ -638,6 +679,7 @@ while IFS='|' read -r qid kind question token general; do
|
|
|
638
679
|
case "$qid" in ''|'#'*) continue ;; esac
|
|
639
680
|
n=$((n+1))
|
|
640
681
|
for arm in ARM CTRL; do
|
|
682
|
+
[ "$ARMS" = both ] || [ "$arm" = "$ARMS" ] || continue # --arms 필터(위 enum)
|
|
641
683
|
setup=""; [ "$arm" = ARM ] && setup="$SETUP_ARM"
|
|
642
684
|
q="$question"
|
|
643
685
|
# ── S5 ② «규약» — typed 채널의 «쓰는 쪽» (2026-09-01 배선) ──────────────────
|
package/scripts/fh-gate.sh
CHANGED
|
@@ -745,9 +745,9 @@ fi
|
|
|
745
745
|
cat "$PARSE_FILE"
|
|
746
746
|
|
|
747
747
|
# B4: Write governance log — structured header only (clean YAML, no raw markdown)
|
|
748
|
-
FINDINGS_A_LOG=$(grep -m 1 "^FH_FINDINGS_A:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0")
|
|
749
|
-
FINDINGS_B_LOG=$(grep -m 1 "^FH_FINDINGS_B:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0")
|
|
750
|
-
FINDINGS_N_LOG=$(grep -m 1 "^FH_FINDINGS_COUNT:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0")
|
|
748
|
+
FINDINGS_A_LOG=$(grep -m 1 "^FH_FINDINGS_A:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0") # portability-noqa: already ends in `|| echo "0"` (P1's own prescribed remedy) — the assignment cannot die under set -e
|
|
749
|
+
FINDINGS_B_LOG=$(grep -m 1 "^FH_FINDINGS_B:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0") # portability-noqa: same as FINDINGS_A_LOG above
|
|
750
|
+
FINDINGS_N_LOG=$(grep -m 1 "^FH_FINDINGS_COUNT:" "$PARSE_FILE" 2>/dev/null | awk '{print $2}' | tr -d '[:space:]' || echo "0") # portability-noqa: same as FINDINGS_A_LOG above
|
|
751
751
|
{
|
|
752
752
|
printf -- "- timestamp: %s\n" "$TIMESTAMP"
|
|
753
753
|
printf " caller: %s\n" "$FH_CALLER"
|
|
@@ -129,6 +129,25 @@ if [ "$MODE" = "tarball" ]; then
|
|
|
129
129
|
sed 's/^/ /' "$FMSC_PACK_ERR" 2>/dev/null
|
|
130
130
|
exit 1
|
|
131
131
|
fi
|
|
132
|
+
# 2026-09-04 (v3.0.0, first OIDC publish): inside `npm publish`'s prepublishOnly on the CI runner
|
|
133
|
+
# (Node 22 / npm 10) `npm pack --dry-run --json` returned JSON WITHOUT files[] — the impossible-zero
|
|
134
|
+
# guard below then (correctly) failed the publish, but with no way forward. Same shape as
|
|
135
|
+
# package_coverage_check.sh's oracle: when the JSON carries no files[].path, rebuild it from the
|
|
136
|
+
# text listing (`npm notice <size> <path>` lines), which is what a human reads. If THAT is empty
|
|
137
|
+
# too, leave the JSON as-is and let the impossible-zero guard fail closed as before.
|
|
138
|
+
if ! printf '%s' "$PACK_JSON" | python3 -c 'import sys,json
|
|
139
|
+
d=json.load(sys.stdin); e=d[0] if isinstance(d,list) and d else d
|
|
140
|
+
fl=e.get("files") if isinstance(e,dict) else None
|
|
141
|
+
sys.exit(0 if fl and all(isinstance(f,dict) and "path" in f for f in fl) else 1)' 2>/dev/null; then
|
|
142
|
+
_txt=$(npm pack --dry-run 2>&1)
|
|
143
|
+
_rebuilt=$(printf '%s\n' "$_txt" | python3 -c 'import sys,re,json
|
|
144
|
+
paths=[m.group(1) for ln in sys.stdin for m in [re.match(r"^npm notice\s+[0-9.]+[kMG]?B\s+(\S+)\s*$", ln)] if m]
|
|
145
|
+
print(json.dumps([{"files":[{"path":p} for p in paths],"_oracle":"text-listing-fallback"}]) if paths else "")')
|
|
146
|
+
if [ -n "$_rebuilt" ]; then
|
|
147
|
+
echo " files-manifest-shipping: npm pack --json carried no files[] — rebuilt from the text listing ($(printf '%s' "$_rebuilt" | python3 -c 'import sys,json;print(len(json.load(sys.stdin)[0]["files"]))') paths)"
|
|
148
|
+
PACK_JSON="$_rebuilt"
|
|
149
|
+
fi
|
|
150
|
+
fi
|
|
132
151
|
printf '%s' "$PACK_JSON" > "$FMSC_PACK_JSON" || {
|
|
133
152
|
echo "FAIL files-manifest-shipping (--vs-tarball): could not write pack output to scratch dir"
|
|
134
153
|
exit 1
|
|
@@ -118,7 +118,7 @@ for pair in \
|
|
|
118
118
|
"CATALOG.md|CHANGELOG.md|PATHSPEC covers CATALOG" \
|
|
119
119
|
".github/workflows/validate.yml|.github/dependabot.yml|PATHSPEC covers workflow 정의 (yml 만)" \
|
|
120
120
|
"scripts/index_sync.py|scripts/notes.txt|PATHSPEC covers scripts/*.py" \
|
|
121
|
-
"package.json|package-lock.json|PATHSPEC covers package.json 이되 «리터럴» — lock 파일까지 삼키지 않는다"
|
|
121
|
+
"package.json|package-lock.json|PATHSPEC covers package.json 이되 «리터럴» — lock 파일까지 삼키지 않는다" # portability-noqa: string literal fed to spec_matches()'s glob-case test, never read from disk — same reason as test_heavy_classifier_lanes.sh:106
|
|
122
122
|
do
|
|
123
123
|
IFS='|' read -r pos neg label <<< "$pair"
|
|
124
124
|
ok=1
|
|
@@ -360,12 +360,34 @@ if ORACLE == 'tarball':
|
|
|
360
360
|
if r.returncode != 0:
|
|
361
361
|
print(f"ORACLE_UNAVAILABLE\tnpm pack exited {r.returncode}")
|
|
362
362
|
raise SystemExit(2)
|
|
363
|
-
|
|
363
|
+
# Two shapes have been seen for `npm pack --dry-run --json`: the documented one carries
|
|
364
|
+
# `[0]['files'][*]['path']`; inside `npm publish`'s prepublishOnly on the CI runner
|
|
365
|
+
# (Node 22 / npm 10, 2026-09-04, v3.0.0 first OIDC publish) the same call returned JSON
|
|
366
|
+
# WITHOUT that key and this block died with a bare KeyError — fail-closed (correct) but
|
|
367
|
+
# blind (no diagnosis). Parse defensively, and when the JSON does not carry a file list
|
|
368
|
+
# fall back to the text listing (`npm notice <size> <path>` lines), which is what a human
|
|
369
|
+
# reads. The diagnostic line prints the head of stdout so the NEXT failure names its shape.
|
|
370
|
+
parsed = json.loads(r.stdout)
|
|
371
|
+
entry = parsed[0] if isinstance(parsed, list) and parsed else (parsed if isinstance(parsed, dict) else None)
|
|
372
|
+
flist = (entry or {}).get('files') if isinstance(entry, dict) else None
|
|
373
|
+
if flist and all(isinstance(f, dict) and 'path' in f for f in flist):
|
|
374
|
+
packed = {f['path'] for f in flist}
|
|
375
|
+
else:
|
|
376
|
+
t = subprocess.run(['npm', 'pack', '--dry-run'], capture_output=True, text=True, timeout=180)
|
|
377
|
+
lines = (t.stdout + '\n' + t.stderr).splitlines()
|
|
378
|
+
packed = set()
|
|
379
|
+
for ln in lines:
|
|
380
|
+
m = re.match(r'^npm notice\s+[0-9.]+[kMG]?B\s+(\S+)\s*$', ln)
|
|
381
|
+
if m:
|
|
382
|
+
packed.add(m.group(1))
|
|
383
|
+
if not packed:
|
|
384
|
+
print(f"ORACLE_UNAVAILABLE\tnpm pack --json had no files[].path and the text listing had no file lines; json head: {r.stdout[:200]!r}")
|
|
385
|
+
raise SystemExit(2)
|
|
364
386
|
except FileNotFoundError:
|
|
365
387
|
print("ORACLE_UNAVAILABLE\tnpm is not on PATH — the tarball cannot be read")
|
|
366
388
|
raise SystemExit(2)
|
|
367
389
|
except (json.JSONDecodeError, KeyError, IndexError) as e:
|
|
368
|
-
print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__})")
|
|
390
|
+
print(f"ORACLE_UNAVAILABLE\tnpm pack --json did not parse ({type(e).__name__}); stdout head: {r.stdout[:200]!r}")
|
|
369
391
|
raise SystemExit(2)
|
|
370
392
|
except subprocess.TimeoutExpired:
|
|
371
393
|
print("ORACLE_UNAVAILABLE\tnpm pack timed out")
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
# proposal_hook.sh — PreToolUse(Edit|Write|Bash) advisory: a verdict/guard line in scripts/**/*.sh or
|
|
3
|
+
# templates/*.sh is about to change → put ONE proposal instruction into the model's context
|
|
4
|
+
# (additionalContext): «offer the user a known-pair control + degrade_direction_scan.sh in one line».
|
|
5
|
+
#
|
|
6
|
+
# WHY A HOOK (identity ⑤, measured 2026-09-03)
|
|
7
|
+
# r3: a CLAUDE.md table row keyed on this exact file class fired 1/15 at floor tier (that 1 a
|
|
8
|
+
# recitation). r4: this hook, installed in a disposable clone, fired on 9/10 editing reps and the
|
|
9
|
+
# floor session relayed the proposal 9/9; the hard negative (a usage-string edit in a .sh) 0/5.
|
|
10
|
+
# Same tasks, same tier, same prose layer — the row 0, the channel 100%. That is the
|
|
11
|
+
# «explicit instruction 3/3 · advisory 0/3 · framing 0/3» result of 2026-08-21 seen a second time
|
|
12
|
+
# (tracks/_meta/RESULT_2026-09-03_identity5-r4.md · prior_art_prompt.sh header). A channel is
|
|
13
|
+
# built at the channel (§Mechanization Boundary); what the proposal SAYS stays the model's.
|
|
14
|
+
#
|
|
15
|
+
# WHAT IT DOES NOT CLAIM
|
|
16
|
+
# Relaying an injected instruction is not initiative. Of r4's 9 hits, 4 carried task-specific
|
|
17
|
+
# substance beyond the hook's own wording (the K1 «judgment residue» — innovator signal
|
|
18
|
+
# fh_signal_2026-09-03_innovator-identity5-r4.md). This hook opens the window; whether ⑤'s bar
|
|
19
|
+
# («proposes unasked») counts a relayed proposal is the operator's call, recorded there, not here.
|
|
20
|
+
#
|
|
21
|
+
# DISCRIMINATOR (mechanical, quote-aware where it can be)
|
|
22
|
+
# file class : scripts/**/*.sh · templates/*.sh (docs, tracks, tests-as-fixtures: no)
|
|
23
|
+
# edit kind : the touched text carries a verdict/guard token — exit N · return N ·
|
|
24
|
+
# `|| continue|exit|true|return` · `&& continue|exit` · -ne/-eq/-gt/-lt · ==/!= ·
|
|
25
|
+
# `[ -e/-f/-s/-n/-z` · comm/diff/cmp · grep -q — AND for Edit the change is not
|
|
26
|
+
# confined to quoted strings (old/new with quotes stripped must differ). A usage
|
|
27
|
+
# 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).
|
|
36
|
+
#
|
|
37
|
+
# DEGRADE DIRECTION: advisory, exit 0 always, no permissionDecision (same contract as pipe_verdict_guard).
|
|
38
|
+
# Unparseable payload → silent. python3 absent → silent (a dead interpreter must not block edits).
|
|
39
|
+
# Evidence line appended to $CLAUDE_PROJECT_DIR/.claude/.proposal_hook_events.tsv (gitignored dir)
|
|
40
|
+
# so a sim arm can prove the hook fired INSIDE its clone (runner header: absence of that file
|
|
41
|
+
# invalidates the arm, never the hypothesis).
|
|
42
|
+
# Opt out on one call with `# noqa: proposal-hook`.
|
|
43
|
+
# test: printf '%s' '<PreToolUse JSON>' | bash scripts/proposal_hook.sh
|
|
44
|
+
set -u
|
|
45
|
+
RAW=$(cat 2>/dev/null || true)
|
|
46
|
+
printf '%s' "$RAW" | grep -qE '#[[:space:]]*noqa:?[[:space:]]*proposal-hook' && exit 0
|
|
47
|
+
read -r FP FLAG < <(printf '%s' "$RAW" | python3 -c '
|
|
48
|
+
import json,sys,re
|
|
49
|
+
try: d=json.load(sys.stdin)
|
|
50
|
+
except Exception: print("",""); sys.exit(0)
|
|
51
|
+
tn=d.get("tool_name",""); ti=d.get("tool_input",{}) or {}
|
|
52
|
+
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)
|
|
54
|
+
fp=""; flag="0"
|
|
55
|
+
if tn in ("Edit","Write"):
|
|
56
|
+
fp=ti.get("file_path","") or ""
|
|
57
|
+
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()
|
|
59
|
+
flag="1" if (touches and real) else "0"
|
|
60
|
+
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)
|
|
65
|
+
print(fp, flag)
|
|
66
|
+
' 2>/dev/null) || exit 0
|
|
67
|
+
[ -n "${FP:-}" ] || exit 0
|
|
68
|
+
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
|
+
[ "${FLAG:-0}" = 1 ] || exit 0
|
|
70
|
+
_D="${CLAUDE_PROJECT_DIR:-.}/.claude"; mkdir -p "$_D" 2>/dev/null
|
|
71
|
+
printf '%s\t%s\t%s\n' "$(date -u +%FT%TZ)" "FIRE" "$FP" >> "$_D/.proposal_hook_events.tsv" 2>/dev/null
|
|
72
|
+
# ── Fact lines (r8, 2026-09-03): the two preconditions of the proposal are DETERMINISTIC, so the hook checks
|
|
73
|
+
# them itself and carries the result as a «사실» line — agents propose, solvers verify. Measured r8: on the
|
|
74
|
+
# stimulus whose grounds sit in a neighbouring file, wording-only (r7 B) got 1/5 withdraw/amend, fact lines
|
|
75
|
+
# got 4/5 (hand-judged, n=5). Sonnet used the fact as an INPUT (one rep rejected a stale fact against a
|
|
76
|
+
# reproduced bug; one opened the scan file itself) — it did not recite it.
|
|
77
|
+
# Self-lane case: editing `scripts/test_X_lanes.sh` IS the lane — r8's discriminator missed it and emitted
|
|
78
|
+
# a proposal for a lane that already was the file. Fixed here (F2 lane).
|
|
79
|
+
_ROOT="${CLAUDE_PROJECT_DIR:-.}"; _BN=$(basename "$FP" .sh); _FACT=""; _ITEMS=""
|
|
80
|
+
case "$_BN" in
|
|
81
|
+
test_*_lanes) _FACT="$_FACT · 사실: 이 파일 자체가 레인(known-pair 픽스처)이다 — 새 known-pair 컨트롤은 «이 파일 안에» 추가하거나 생략" ;;
|
|
82
|
+
*) if [ -f "$_ROOT/scripts/test_${_BN}_lanes.sh" ]; then _FACT="$_FACT · 사실: 이 파일의 레인 \`scripts/test_${_BN}_lanes.sh\` 가 이미 있다(known-pair 컨트롤은 거기에 붙이거나 생략)"; else _ITEMS="$_ITEMS known-pair(고친 케이스 + 반대 케이스) 컨트롤"; fi ;;
|
|
83
|
+
esac
|
|
84
|
+
_SCAN=$(ls -t "$_ROOT"/scripts/.degrade_scan_last_*.txt 2>/dev/null | head -1)
|
|
85
|
+
if [ -n "$_SCAN" ] && grep -q -- "$(basename "$FP")" "$_SCAN" 2>/dev/null; then _FACT="$_FACT · 사실: 오늘 degrade_direction_scan 결과 \`$(basename "$_SCAN")\` 가 이 파일을 이미 덮었다($(grep -m1 -oE 'findings: [0-9]+' "$_SCAN" 2>/dev/null || echo 'findings: ?')) — 스캐너 통과이지 손 확인이 아니다"; else _ITEMS="$_ITEMS degrade_direction_scan.sh 로 실패가 «통과»로 접히는 방향 확인"; fi
|
|
86
|
+
if [ -z "$_ITEMS" ]; then MSG="🔎 방금 고치는 줄은 판정·가드·종료코드 줄이다(scripts/*.sh · templates/*.sh)${_FACT}. 둘 다 이미 있으니 새 제안은 내지 말고, 작업을 마친 뒤 그 사실을 한 줄로만 말해라(형식: «확인 | basis: <위 사실>»)."
|
|
87
|
+
else MSG="🔎 방금 고치는 줄은 판정·가드·종료코드 줄이다(scripts/*.sh · templates/*.sh)${_FACT}. 없는 것만 사용자에게 한 줄로 제안해라 —${_ITEMS} — 형식은 «제안: … | basis: <네가 확인한 근거 한 구절>». 가능하면 이 파일·이 케이스의 실제 이름으로. 제안이지 실행이 아니다."; fi
|
|
88
|
+
python3 -c 'import json,sys; m=sys.argv[1]; print(json.dumps({"systemMessage":m,"hookSpecificOutput":{"hookEventName":"PreToolUse","additionalContext":m}}, ensure_ascii=False))' "$MSG" 2>/dev/null || exit 0
|
|
89
|
+
exit 0
|
|
@@ -124,7 +124,16 @@ fi
|
|
|
124
124
|
# content-generating lifecycle is ever added.
|
|
125
125
|
# ── Resolve the exact npm-published file set (fail-closed if unresolved OR partial) ──
|
|
126
126
|
FILES=$(npm pack --dry-run --json 2>/dev/null \
|
|
127
|
-
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null
|
|
127
|
+
| node -e 'let s="";process.stdin.on("data",d=>s+=d).on("end",()=>{try{JSON.parse(s)[0].files.forEach(f=>console.log(f.path))}catch(e){process.exit(3)}})' 2>/dev/null)
|
|
128
|
+
# 2026-09-04 (v3.0.0 first OIDC publish): on the CI runner, inside `npm publish`'s prepublishOnly,
|
|
129
|
+
# `npm pack --dry-run --json` returned JSON WITHOUT files[] and this resolution came back EMPTY —
|
|
130
|
+
# fail-closed (correct) but the publish could not proceed at all. Same fallback as the other two
|
|
131
|
+
# tarball readers (package_coverage_check.sh · files_manifest_shipping_check.sh): rebuild the set
|
|
132
|
+
# from the text listing (`npm notice <size> <path>`). Still fail-closed when THAT is empty too.
|
|
133
|
+
if [ -z "$FILES" ]; then
|
|
134
|
+
FILES=$(npm pack --dry-run 2>&1 | sed -nE 's/^npm notice +[0-9.]+[kMG]?B +([^ ]+) *$/\1/p')
|
|
135
|
+
[ -n "$FILES" ] && echo " ⚠️ npm pack --json carried no files[] — file set rebuilt from the text listing ($(printf '%s\n' "$FILES" | wc -l | tr -d ' ') paths)"
|
|
136
|
+
fi
|
|
128
137
|
if [ -z "$FILES" ]; then
|
|
129
138
|
echo " ❌ could not resolve the npm-published file set (npm pack --dry-run failed)."
|
|
130
139
|
[ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
|
|
@@ -135,7 +144,7 @@ fi
|
|
|
135
144
|
# Wrong-set guard (challenger M6): a future npm --json shape change could yield a NON-empty but PARTIAL
|
|
136
145
|
# file list (forEach iterates a renamed/nested structure without throwing) → files silently unscanned.
|
|
137
146
|
# npm always ships package.json in the tarball; its absence means the parse got a wrong set → fail-closed.
|
|
138
|
-
if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then
|
|
147
|
+
if ! printf '%s\n' "$FILES" | grep -qx "package.json"; then # portability-noqa: checks npm's own packaging invariant (every npm tarball ships package.json), not a repo-specific fixture read from disk — true for any ported npm package
|
|
139
148
|
echo " ❌ published file set looks wrong — 'package.json' (always shipped) is absent from the parse."
|
|
140
149
|
[ "${PUBLIC_SURFACE_OK:-0}" = "1" ] && { echo " ⚠️ proceeding by PUBLIC_SURFACE_OK=1"; exit 0; }
|
|
141
150
|
echo " Fail-closed (possible npm --json shape change). Verify npm pack output or PUBLIC_SURFACE_OK=1."
|