oh-my-customcode 1.1.53 → 1.1.55
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/dist/cli/index.js +1 -1
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/templates/.claude/hooks/scripts/fail-axis-cause-advisor.sh +38 -3
- package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +45 -4
- package/templates/.claude/hooks/scripts/stuck-detector.sh +176 -9
- package/templates/.claude/rules/MAY-optimization.md +2 -0
- package/templates/.claude/rules/MUST-completion-verification.md +2 -0
- package/templates/manifest.json +1 -1
package/dist/cli/index.js
CHANGED
package/dist/index.js
CHANGED
package/package.json
CHANGED
|
@@ -24,9 +24,19 @@
|
|
|
24
24
|
# failure-ledger.sh(PostToolUseFailure)가 기록한 원장을 발동 조건으로 읽는다.
|
|
25
25
|
# 원장이 없으면 조용히 통과한다 — 훅 단독으로도 안전하게 동작한다.
|
|
26
26
|
#
|
|
27
|
+
# 해소(resolve) 경로 (#1625 찐빠 #4):
|
|
28
|
+
# failure-ledger.sh는 append-only라 이 advisor에는 원래 해소 경로가 없었다. 그 결과 세션
|
|
29
|
+
# 내에서 이미 원인 확정·해소된 실패가 원장에 그대로 남아, 이후 무관한 짧은 진행 지시마다
|
|
30
|
+
# 반복 발화했다(실측). 완전한 해소 트래킹 대신, "신규 실패가 늘지 않았으면 침묵"하는
|
|
31
|
+
# 실용적 dedup을 둔다 — 세션당 마커 파일에 마지막 발화 시점의 fail_count를 기록하고,
|
|
32
|
+
# 다음 발동 시 fail_count가 그 값보다 늘지 않았으면 조용히 통과한다. 신규 실패가 쌓이면
|
|
33
|
+
# 다시 발화한다. r007-r008-drift-advisor.sh의 turn-uuid 마커 패턴과 동형 — 마커 값이
|
|
34
|
+
# "턴 uuid" 대신 "fail_count"일 뿐이다.
|
|
35
|
+
#
|
|
27
36
|
# 환경변수 override:
|
|
28
|
-
# OMCUSTOM_FAIL_ADVISOR=off
|
|
29
|
-
# OMCUSTOM_ERROR_LEDGER=<path>
|
|
37
|
+
# OMCUSTOM_FAIL_ADVISOR=off — advisory 완전 비활성화
|
|
38
|
+
# OMCUSTOM_ERROR_LEDGER=<path> — 원장 경로 override
|
|
39
|
+
# OMCUSTOM_FAIL_MARKER_DIR=<dir> — dedup 마커 디렉토리 override (기본 ${TMPDIR:-/tmp})
|
|
30
40
|
|
|
31
41
|
set -euo pipefail
|
|
32
42
|
|
|
@@ -55,8 +65,12 @@ if [ "${#prompt}" -gt 40 ]; then
|
|
|
55
65
|
fi
|
|
56
66
|
|
|
57
67
|
# ── 조건 2: 진행/재촉 패턴인가 ──
|
|
68
|
+
# "다음"은 v1.1.53까지 단독 매치였으나 "다음엔"/"다음 세션"/"다음 릴리즈"/"다음번" 같은
|
|
69
|
+
# 스코프 지시(미래 작업 언급)까지 오분류했다 — 예: "다음엔 Pre,PostToolUse 찐빠도 해결한다"는
|
|
70
|
+
# 원인 진술이 필요 없는 계획 서술이지 원인 없는 진행 재촉이 아니다. 즉시-진행 의미가 뚜렷한
|
|
71
|
+
# 구체적 구(다음 단계/다음으로/다음 진행)로 좁힌다 (#1625 찐빠 #4).
|
|
58
72
|
if ! printf '%s' "$prompt" \
|
|
59
|
-
| grep -qiE '(
|
|
73
|
+
| grep -qiE '(계속|이어서|진행해|재개|다음 단계|다음으로|다음 진행|ㄱㄱ|고고|가자|continue|keep going|go on|resume|proceed|next)'; then
|
|
60
74
|
exit 0
|
|
61
75
|
fi
|
|
62
76
|
|
|
@@ -83,6 +97,24 @@ if [ -z "$fail_count" ] || [ "$fail_count" -eq 0 ] 2>/dev/null; then
|
|
|
83
97
|
exit 0
|
|
84
98
|
fi
|
|
85
99
|
|
|
100
|
+
# ── 조건 5: 해소(resolve) dedup — 마지막 발화 이후 신규 실패가 없으면 침묵 ──
|
|
101
|
+
# 마커에는 fail_count(정수)를 저장한다. fail_count가 마커 값보다 늘지 않았다면 이미
|
|
102
|
+
# 발화했던 동일 실패 집합에 대한 재촉이므로 침묵한다. 신규 실패가 append되면 fail_count가
|
|
103
|
+
# 늘어나 다시 발화한다 (append-only 원장을 그대로 두고 발화 측에서만 dedup).
|
|
104
|
+
MARKER_DIR="${OMCUSTOM_FAIL_MARKER_DIR:-${TMPDIR:-/tmp}}"
|
|
105
|
+
marker_key=$(printf '%s' "$session" | tr -c 'A-Za-z0-9._-' '_')
|
|
106
|
+
MARKER_FILE="${MARKER_DIR}/.omcustom-fail-advisor-${marker_key}"
|
|
107
|
+
|
|
108
|
+
if [ -f "$MARKER_FILE" ]; then
|
|
109
|
+
prev_count=$(cat "$MARKER_FILE" 2>/dev/null || printf '0')
|
|
110
|
+
case "$prev_count" in
|
|
111
|
+
''|*[!0-9]*) prev_count=0 ;;
|
|
112
|
+
esac
|
|
113
|
+
if [ "$fail_count" -le "$prev_count" ] 2>/dev/null; then
|
|
114
|
+
exit 0
|
|
115
|
+
fi
|
|
116
|
+
fi
|
|
117
|
+
|
|
86
118
|
# 최근 실패 도구 요약 (최대 3종)
|
|
87
119
|
fail_tools=$(tail -n 300 "$LEDGER" 2>/dev/null \
|
|
88
120
|
| jq -r --arg s "$session" 'select(.session == $s and .interrupt != true) | .tool' 2>/dev/null \
|
|
@@ -99,4 +131,7 @@ printf '%s\n' "$advisory_text" >&2
|
|
|
99
131
|
jq -cn --arg ctx "$advisory_text" \
|
|
100
132
|
'{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $ctx}}'
|
|
101
133
|
|
|
134
|
+
# 발화 후 마커 갱신 — 다음 발동 시 이 fail_count까지는 재발화하지 않는다.
|
|
135
|
+
printf '%s' "$fail_count" > "$MARKER_FILE" 2>/dev/null || true
|
|
136
|
+
|
|
102
137
|
exit 0
|
|
@@ -182,12 +182,36 @@ split("\n")
|
|
|
182
182
|
| if ($ai | length) == 0 then empty
|
|
183
183
|
else
|
|
184
184
|
($ai[-1]) as $last
|
|
185
|
+
# (#1625 찐빠 #3 조사 메모) `<task-notification>` / `Stop hook feedback:`를 role=user
|
|
186
|
+
# string content 경계 판정에서 제외하는 방안을 시도했으나, 실측(200ee7fe /fsd 자율 루프
|
|
187
|
+
# 세션, tail-200 윈도우 시뮬레이션)에서 **역효과가 확정**되어 반려한다. 이 마커들은
|
|
188
|
+
# UserPromptSubmit이 발생하지 않는 자율 루프 재진입에서 사실상 유일한 턴 경계 신호다
|
|
189
|
+
# (파일 상단 header 주석 참조) — 제외하면 $bi가 통째로 비어 $b=-1로 폴백해, 서로
|
|
190
|
+
# 독립적이고 각각 컴플라이언트한 후속 응답들이 하나의 거대 turn으로 계속 병합되고
|
|
191
|
+
# r008 드리프트가 6→10→9로 오히려 커졌다(제외 적용 전에는 각 응답이 새 turn으로
|
|
192
|
+
# 올바르게 분리되어 0/0이었음). 이 조사에서 확정된 실제 원인은 별도 메커니즘이다:
|
|
193
|
+
# tail -n 200 윈도우 안에 경계가 전혀 없을 때 $b=-1로 폴백해 슬라이딩 윈도우 전체를
|
|
194
|
+
# 하나의 turn으로 병합하는 구조 — 본 수정의 승인 범위(0-tool-use 가드 1건) 밖이므로
|
|
195
|
+
# 별도 이슈로 보고한다(오케스트레이터 완료 보고 참조). → #1628로 등록·수정 완료:
|
|
196
|
+
# 아래 $b 바인딩 직후의 "(#1628)" 주석 및 $wlc 포화 가드 참조.
|
|
185
197
|
| [ range(0; $last + 1)
|
|
186
198
|
| select( ($L[.].message.role? == "user")
|
|
187
199
|
and ( (($L[.].message.content | type) == "string")
|
|
188
200
|
or (([ $L[.].message.content[]? | select(.type? == "tool_result") ] | length) == 0) ) ) ] as $bi
|
|
189
201
|
| (if ($bi | length) > 0 then $bi[-1] else -1 end) as $b
|
|
190
|
-
|
|
202
|
+
# (#1628) 경계가 전혀 없는 경우($bi 비어있음)를 두 케이스로 나눈다:
|
|
203
|
+
# (a) 윈도우가 포화($wlc >= 200 — tail -n 200이 실제로 200줄을 반환) — 윈도우 밖에
|
|
204
|
+
# 진짜 경계가 있었을 수 있다. 이 경우 $b=-1로 윈도우 전체를 하나의 turn으로
|
|
205
|
+
# 병합하면, 서로 독립적이고 각각 컴플라이언트한 여러 응답이 합쳐져 announce/
|
|
206
|
+
# tool_use 카운트가 어긋나 R008 위양성이 5~10건대로 요동친다(#1628 실측). 판정
|
|
207
|
+
# 자체를 스킵한다(empty — 아래에서 result가 비어 exit 0으로 이어진다).
|
|
208
|
+
# (b) 윈도우가 포화되지 않음($wlc < 200 — tail이 transcript 전체를 다 읽었다는 뜻) —
|
|
209
|
+
# 세션이 정말로 짧아 첫 턴부터 지금까지 경계가 없는 것이므로, 기존 $b=-1 폴백을
|
|
210
|
+
# 그대로 유지한다(#1625 찐빠 #3이 확정한 자율 루프 경계 대체 신호와 동일 로직).
|
|
211
|
+
| if ($bi | length) == 0 and $wlc >= 200 then empty
|
|
212
|
+
else
|
|
213
|
+
(
|
|
214
|
+
[ range($b + 1; $last + 1) | $L[.] | select(.message.role? == "assistant") ] as $turn
|
|
191
215
|
| [ $turn[] | .message.content[]? | select(.type? != "thinking") ] as $blocks
|
|
192
216
|
| ($turn[0].uuid? // "") as $tuuid
|
|
193
217
|
| ([ $blocks[] | select(.type? == "text") ][0].text? // "") as $ftext
|
|
@@ -201,7 +225,14 @@ split("\n")
|
|
|
201
225
|
| ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Spawning:")) ] | length) as $an_spawn_hdr
|
|
202
226
|
| ($an_tool + (if $an_spawn_item > 0 then $an_spawn_item else $an_spawn_hdr end)) as $announce
|
|
203
227
|
| ([ $blocks[] | select((.type? == "tool_use") and ((.name? // "") != "Skill")) ] | length) as $ntools
|
|
204
|
-
|
|
228
|
+
# 전체 tool_use(Skill 포함)가 0건인 턴은 forward 판정을 무조건 0으로 고정한다(#1625
|
|
229
|
+
# 찐빠 #3, 이슈 제안 그대로). $ntools(Skill 제외) 기반 산식은 정상 입력에서는 이미
|
|
230
|
+
# 0을 내지만, 명시 가드로 불변식을 코드에 못박아 향후 산식 변경이 이 보장을 조용히
|
|
231
|
+
# 깨뜨리지 않게 한다. $nall_tools는 아래 REVERSE 신호와 공유하는 단일 바인딩이다.
|
|
232
|
+
| ($blocks | map(select(.type? == "tool_use")) | length) as $nall_tools
|
|
233
|
+
| (if $nall_tools == 0 then 0
|
|
234
|
+
elif $ntools > $announce then $ntools - $announce
|
|
235
|
+
else 0 end) as $r008
|
|
205
236
|
# REVERSE signal (#1595 제안 #6): announced a tool call, then ended the turn without
|
|
206
237
|
# emitting ANY tool_use block (R009 Self-Check #6). Uses its OWN ANCHORED counter — do NOT
|
|
207
238
|
# reuse $announce. MEASURED over 482 orchestrator turns:
|
|
@@ -216,13 +247,23 @@ split("\n")
|
|
|
216
247
|
# the $ntools denominator above; it is a separate variable, not a weakening of the Skill
|
|
217
248
|
# exemption (#1569), which still governs the forward verdict.
|
|
218
249
|
| ([ $lines[] | select(test("^\\[[^\\]]+\\]\\[[^\\]]+\\] ?(→|->|—>) ?Tool:")) ] | length) as $an_anchored
|
|
219
|
-
|
|
250
|
+
# $nall_tools는 위 forward r008 가드에서 이미 바인딩됨 — 재바인딩하지 않고 재사용한다.
|
|
220
251
|
| (if $nall_tools == 0 and $an_anchored > 0 then $an_anchored else 0 end) as $r008rev
|
|
221
252
|
| [$tuuid, ($r007 | tostring), ($r008 | tostring), ($r008rev | tostring)] | @tsv
|
|
253
|
+
)
|
|
254
|
+
end
|
|
222
255
|
end
|
|
223
256
|
'
|
|
224
257
|
|
|
225
|
-
|
|
258
|
+
# ── 윈도우 포화 여부 판별 (#1628) ──
|
|
259
|
+
# tail -n 200이 실제로 200줄을 반환했는지(포화 — 윈도우 밖에 잘려나간 내용이 있을 수 있음)를
|
|
260
|
+
# jq 호출 전에 bash에서 측정해 $wlc로 전달한다. awk는 트레일링 개행 유무와 무관하게 정확한
|
|
261
|
+
# 레코드 수를 센다(wc -l은 트레일링 개행이 없는 마지막 줄을 놓칠 수 있음).
|
|
262
|
+
window_content=$(tail -n 200 "$TRANSCRIPT_PATH" 2>/dev/null) || window_content=""
|
|
263
|
+
window_line_count=$(printf '%s' "$window_content" | awk 'END{print NR}')
|
|
264
|
+
: "${window_line_count:=0}"
|
|
265
|
+
|
|
266
|
+
result=$(printf '%s' "$window_content" | jq -Rsr --argjson wlc "$window_line_count" "$JQ_LAST_TURN" 2>/dev/null) || result=""
|
|
226
267
|
|
|
227
268
|
if [ -z "$result" ]; then
|
|
228
269
|
exit 0
|
|
@@ -15,13 +15,171 @@ command -v jq >/dev/null 2>&1 || exit 0
|
|
|
15
15
|
# Hard block threshold: consecutive identical operations before blocking
|
|
16
16
|
HARD_BLOCK_THRESHOLD=${CLAUDE_STUCK_THRESHOLD:-3}
|
|
17
17
|
|
|
18
|
+
# Determine if a Bash command is read-only (metadata/query only, no side effects).
|
|
19
|
+
# Conservative: any ambiguity (redirection, substitution, "||", or an
|
|
20
|
+
# unrecognized command/subcommand) is treated as NOT read-only (write).
|
|
21
|
+
# Compound commands chained with "&&", ";", or a single "|" are split into
|
|
22
|
+
# segments (#1629) — the whole command is read-only ONLY when EVERY segment
|
|
23
|
+
# is read-only; one write segment (or one unparseable/empty segment) makes
|
|
24
|
+
# the whole command a write.
|
|
25
|
+
# Used to exclude read-only Bash polling from Hard Block Checks 1 and 3
|
|
26
|
+
# (same-path / same-tool+target consecutive-repeat blocking) AND the Signal 3
|
|
27
|
+
# tool-spam advisory below — Signal 1 (repeated-error advisory) and Hard
|
|
28
|
+
# Block Check 2 (same error hash) are intentionally unaffected: repeated
|
|
29
|
+
# errors are a genuine stuck signal regardless of read/write (issue #1625).
|
|
30
|
+
|
|
31
|
+
# Classify a SINGLE (non-compound) command segment as read-only. Callers
|
|
32
|
+
# (is_readonly_bash_command) are responsible for stripping ambiguous
|
|
33
|
+
# constructs and splitting compound commands before invoking this directly.
|
|
34
|
+
is_readonly_single_command() {
|
|
35
|
+
local cmd="$1"
|
|
36
|
+
if [ -z "$cmd" ]; then
|
|
37
|
+
echo "false"
|
|
38
|
+
return 0
|
|
39
|
+
fi
|
|
40
|
+
|
|
41
|
+
local parts=()
|
|
42
|
+
read -ra parts <<< "$cmd"
|
|
43
|
+
local w1="${parts[0]:-}"
|
|
44
|
+
local w2="${parts[1]:-}"
|
|
45
|
+
local w3="${parts[2]:-}"
|
|
46
|
+
|
|
47
|
+
case "$w1" in
|
|
48
|
+
ls|cat|head|tail|grep|rg|wc|jq|md5|md5sum|type|command|which|echo|printf|pwd|date)
|
|
49
|
+
echo "true"
|
|
50
|
+
return 0
|
|
51
|
+
;;
|
|
52
|
+
find)
|
|
53
|
+
case "$cmd" in
|
|
54
|
+
*-delete*|*-exec*|*-fprintf*)
|
|
55
|
+
echo "false"
|
|
56
|
+
;;
|
|
57
|
+
*)
|
|
58
|
+
echo "true"
|
|
59
|
+
;;
|
|
60
|
+
esac
|
|
61
|
+
return 0
|
|
62
|
+
;;
|
|
63
|
+
git)
|
|
64
|
+
case "$w2" in
|
|
65
|
+
status|log|diff|show|rev-parse|ls-files)
|
|
66
|
+
echo "true"
|
|
67
|
+
;;
|
|
68
|
+
tag|branch)
|
|
69
|
+
# Only a bare query (no positional args, flags only) counts as read-only.
|
|
70
|
+
# e.g. "git branch -a" / "git tag --sort=..." => read; "git branch foo"
|
|
71
|
+
# or "git branch -D foo" => write (has a non-flag token).
|
|
72
|
+
local ok="true"
|
|
73
|
+
local i
|
|
74
|
+
for ((i = 2; i < ${#parts[@]}; i++)); do
|
|
75
|
+
case "${parts[$i]}" in
|
|
76
|
+
-*) ;;
|
|
77
|
+
*) ok="false" ;;
|
|
78
|
+
esac
|
|
79
|
+
done
|
|
80
|
+
echo "$ok"
|
|
81
|
+
;;
|
|
82
|
+
*)
|
|
83
|
+
# fetch and all other subcommands (checkout/merge/rebase/push/...) => write
|
|
84
|
+
echo "false"
|
|
85
|
+
;;
|
|
86
|
+
esac
|
|
87
|
+
return 0
|
|
88
|
+
;;
|
|
89
|
+
gh)
|
|
90
|
+
if [ "$w2" = "api" ]; then
|
|
91
|
+
# GET-style only; any field/method-mutation flag => write
|
|
92
|
+
case "$cmd" in
|
|
93
|
+
*' -f '*|*' -F '*|*'--raw-field'*|*'--input'*|*' -X '*|*'--method'*)
|
|
94
|
+
echo "false"
|
|
95
|
+
;;
|
|
96
|
+
*)
|
|
97
|
+
echo "true"
|
|
98
|
+
;;
|
|
99
|
+
esac
|
|
100
|
+
else
|
|
101
|
+
case "$w3" in
|
|
102
|
+
view|list)
|
|
103
|
+
echo "true"
|
|
104
|
+
;;
|
|
105
|
+
*)
|
|
106
|
+
echo "false"
|
|
107
|
+
;;
|
|
108
|
+
esac
|
|
109
|
+
fi
|
|
110
|
+
return 0
|
|
111
|
+
;;
|
|
112
|
+
*)
|
|
113
|
+
echo "false"
|
|
114
|
+
return 0
|
|
115
|
+
;;
|
|
116
|
+
esac
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
is_readonly_bash_command() {
|
|
120
|
+
local cmd="$1"
|
|
121
|
+
cmd="$(printf '%s' "$cmd" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
122
|
+
if [ -z "$cmd" ]; then
|
|
123
|
+
echo "false"
|
|
124
|
+
return 0
|
|
125
|
+
fi
|
|
126
|
+
|
|
127
|
+
# Truly ambiguous constructs: redirection, command/process substitution, or
|
|
128
|
+
# "||" (conditional-OR — intentionally NOT decomposed into segments, since
|
|
129
|
+
# its branch that actually runs depends on the first segment's exit code)
|
|
130
|
+
# => always treated as write.
|
|
131
|
+
case "$cmd" in
|
|
132
|
+
*'>'*|*'||'*|*'$('*|*'`'*|*'<('*)
|
|
133
|
+
echo "false"
|
|
134
|
+
return 0
|
|
135
|
+
;;
|
|
136
|
+
esac
|
|
137
|
+
|
|
138
|
+
# Compound command: split on "&&", ";", or a single "|" into segments and
|
|
139
|
+
# require EVERY segment to be read-only (#1629). An empty segment (e.g. a
|
|
140
|
+
# trailing separator) is treated as ambiguous => write, same as any
|
|
141
|
+
# segment that fails single-command classification.
|
|
142
|
+
if [[ "$cmd" == *'&&'* || "$cmd" == *';'* || "$cmd" == *'|'* ]]; then
|
|
143
|
+
local normalized="$cmd"
|
|
144
|
+
normalized="${normalized//&&/$'\n'}"
|
|
145
|
+
normalized="${normalized//;/$'\n'}"
|
|
146
|
+
normalized="${normalized//|/$'\n'}"
|
|
147
|
+
local seg
|
|
148
|
+
while IFS= read -r seg; do
|
|
149
|
+
seg="$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
|
|
150
|
+
if [ -z "$seg" ]; then
|
|
151
|
+
echo "false"
|
|
152
|
+
return 0
|
|
153
|
+
fi
|
|
154
|
+
if [ "$(is_readonly_single_command "$seg")" != "true" ]; then
|
|
155
|
+
echo "false"
|
|
156
|
+
return 0
|
|
157
|
+
fi
|
|
158
|
+
done <<< "$normalized"
|
|
159
|
+
echo "true"
|
|
160
|
+
return 0
|
|
161
|
+
fi
|
|
162
|
+
|
|
163
|
+
is_readonly_single_command "$cmd"
|
|
164
|
+
}
|
|
165
|
+
|
|
18
166
|
input=$(cat)
|
|
19
167
|
|
|
20
168
|
# Extract tool info
|
|
21
|
-
tool_name=$(
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
169
|
+
tool_name=$(printf '%s' "$input" | jq -r '.tool_name // "unknown"')
|
|
170
|
+
# 300 (not 120): a 120-char cutoff let two genuinely different long Bash
|
|
171
|
+
# commands collide on their shared 120-char prefix, causing a false-positive
|
|
172
|
+
# same-path/same-tool+target hard-block. 300 chars covers the vast majority
|
|
173
|
+
# of real commands without their differing tail being cut off (#1629).
|
|
174
|
+
file_path=$(printf '%s' "$input" | jq -r '.tool_input.file_path // .tool_input.command // ""' | head -c 300)
|
|
175
|
+
is_error=$(printf '%s' "$input" | jq -r '.tool_output.is_error // false')
|
|
176
|
+
output_preview=$(printf '%s' "$input" | jq -r '.tool_output.output // ""' | head -c 200)
|
|
177
|
+
raw_command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
|
|
178
|
+
|
|
179
|
+
is_readonly="false"
|
|
180
|
+
if [ "$tool_name" = "Bash" ]; then
|
|
181
|
+
is_readonly=$(is_readonly_bash_command "$raw_command")
|
|
182
|
+
fi
|
|
25
183
|
|
|
26
184
|
# Session-scoped history
|
|
27
185
|
HISTORY_FILE="/tmp/.claude-tool-history-${PPID}"
|
|
@@ -42,7 +200,8 @@ entry=$(jq -cn \
|
|
|
42
200
|
--arg err "$is_error" \
|
|
43
201
|
--arg hash "$error_hash" \
|
|
44
202
|
--arg preview "$output_preview" \
|
|
45
|
-
|
|
203
|
+
--arg readonly "$is_readonly" \
|
|
204
|
+
'{timestamp: $ts, tool: $tool, path: $path, is_error: $err, error_hash: $hash, preview: $preview, readonly: $readonly}')
|
|
46
205
|
|
|
47
206
|
echo "$entry" >> "$HISTORY_FILE"
|
|
48
207
|
|
|
@@ -106,8 +265,13 @@ if [ "$stuck_detected" = false ] && { [ "$tool_name" = "Edit" ] || [ "$tool_name
|
|
|
106
265
|
fi
|
|
107
266
|
|
|
108
267
|
# Signal 3: Tool spam (same tool 5+ times in last 8 entries)
|
|
109
|
-
|
|
110
|
-
|
|
268
|
+
# Read-only Bash polling is excluded from this count (issue #1629): skip
|
|
269
|
+
# entirely when the CURRENT call is read-only, and exclude prior read-only
|
|
270
|
+
# entries from the historical count so a mix of harmless read-only Bash
|
|
271
|
+
# calls (git status / gh view / ls / ...) doesn't inflate the "same tool
|
|
272
|
+
# called N times" advisory.
|
|
273
|
+
if [ "$stuck_detected" = false ] && [ "$is_readonly" != "true" ]; then
|
|
274
|
+
tool_repeat=$(tail -8 "$HISTORY_FILE" | grep -v "\"readonly\":\"true\"" | grep -c "\"tool\":\"${tool_name}\"" 2>/dev/null || echo "0")
|
|
111
275
|
if [ "$tool_repeat" -ge 5 ]; then
|
|
112
276
|
stuck_detected=true
|
|
113
277
|
signal_type="Tool loop"
|
|
@@ -142,7 +306,9 @@ if [ -f "$HISTORY_FILE" ]; then
|
|
|
142
306
|
|
|
143
307
|
if [ "$last_n_count" -ge "$HARD_BLOCK_THRESHOLD" ]; then
|
|
144
308
|
# Check 1: Same file edited HARD_BLOCK_THRESHOLD+ times consecutively
|
|
145
|
-
|
|
309
|
+
# (skip when current call is a read-only Bash command — repeated read-only
|
|
310
|
+
# polling of the same target is not a stuck-loop signal; see #1625)
|
|
311
|
+
if [ "$is_readonly" != "true" ] && [ -n "$file_path" ]; then
|
|
146
312
|
escaped_path=$(echo "$file_path" | sed 's/[.[\*^$()+?{|]/\\&/g')
|
|
147
313
|
consecutive_file=$(echo "$last_n" | grep -c "\"path\":\"${escaped_path}\"" 2>/dev/null || echo "0")
|
|
148
314
|
if [ "$consecutive_file" -ge "$HARD_BLOCK_THRESHOLD" ]; then
|
|
@@ -161,7 +327,8 @@ if [ -f "$HISTORY_FILE" ]; then
|
|
|
161
327
|
fi
|
|
162
328
|
|
|
163
329
|
# Check 3: Same tool+target combination HARD_BLOCK_THRESHOLD+ times consecutively
|
|
164
|
-
|
|
330
|
+
# (skip when current call is a read-only Bash command — see Check 1 note)
|
|
331
|
+
if [ "$hard_block" = false ] && [ "$is_readonly" != "true" ] && [ -n "$file_path" ]; then
|
|
165
332
|
escaped_path=$(echo "$file_path" | sed 's/[.[\*^$()+?{|]/\\&/g')
|
|
166
333
|
consecutive_tool_target=$(echo "$last_n" | grep "\"tool\":\"${tool_name}\"" | grep -c "\"path\":\"${escaped_path}\"" 2>/dev/null || echo "0")
|
|
167
334
|
if [ "$consecutive_tool_target" -ge "$HARD_BLOCK_THRESHOLD" ]; then
|
|
@@ -18,6 +18,8 @@
|
|
|
18
18
|
|
|
19
19
|
> **Sandbox/container tool gaps (#1401 찐빠 #4)**: `curl`, `wget`, `nc` 등 공통 CLI 도구는 샌드박스·컨테이너 환경에서 미설치일 수 있다. HTTP 요청에는 `WebFetch` 도구를 우선 사용하고, CLI 도구 사용 전 `command -v <tool>` 으로 가용성을 사전 확인한다.
|
|
20
20
|
|
|
21
|
+
> **zsh 내장 `echo`는 이스케이프를 확장한다 (#1625)**: zsh(이 저장소 Bash 도구 실행 셸)의 내장 `echo`는 `\n` 등 백슬래시 이스케이프를 기본 확장하므로, JSON 문자열을 파이프에 실을 때 `echo "$var"`를 쓰면 valid JSON을 스스로 깨뜨려 하류 파서 오진을 유발한다(v1.1.53 세션 훅 오진의 실제 원인). JSON/구조화 문자열 전달은 `printf '%s' "$var"`를 표준으로 한다.
|
|
22
|
+
|
|
21
23
|
> **로컬 실행 옵션 제시 전 자원 가용성 선확인 (#1455 #2)**: 로컬 실행에 의존하는 검증 옵션(로컬 스모크 테스트, 로컬 스크립트 실행 등)을 사용자에게 제시하기 **전에**, 그 실행에 필요한 로컬 자원(env 키, CLI 도구, 인증 상태)의 가용성을 먼저 확인한다. **저장소 secret 존재 ≠ 로컬 셸 env 존재** — `gh secret list`로 저장소 secret을 확인해도 로컬 셸에 해당 env가 있으리라 단정하지 말 것. 자원 부재 시 옵션에 전제조건을 명시하거나 옵션에서 제외하여, 사용자가 실행 불가한 옵션을 선택했다가 되돌리는 왕복(AskUserQuestion 재질문)을 방지한다. Cross-ref: R020(사전 검증). Origin: #1455 #2 (Session 127 회고 찐빠 #2) — 사용자가 "로컬 스모크 테스트 먼저"를 선택했으나 로컬 셸에 ANTHROPIC_API_KEY 부재로 실행 불가 → "스킵, 바로 커밋" 재선택, AskUserQuestion 왕복 1회 발생.
|
|
22
24
|
|
|
23
25
|
> **Shell output parsing — use Python, not read/grep (#1401 찐빠 #3)**: adb bounds rect, 좌표쌍, JSON 분할 등 구조화된 출력 파싱은 `read`+`grep -o` 파이프라인 대신 Python (`python3 -c "..."`) 을 사용한다. `read`+`grep -o` 조합은 공백 차이에 취약해 헛값을 산출한다. SSH 원격 `bash -c` 인자에 소괄호 포함 금지 — `ssh host "cmd; cmd2"` 형식 사용.
|
|
@@ -137,11 +137,13 @@ Cross-reference: R018 (Member Completion Verification), `feedback_release_delega
|
|
|
137
137
|
5. **완화책**: 미러 동기화처럼 **후행 필수 작업은 마지막에 몰지 말고 파일 단위로 즉시 수행**한다 — 절단은 항상 마지막 작업을 자르므로, 마지막에 몰린 작업은 절단 시 전량 유실된다.
|
|
138
138
|
6. **정량 기준 정밀화 — 산술 단위는 파일이 아니라 편집 항목 (Origin: #1621 #2a, v1.1.51 세션)**: 위 4항의 "파일 4~5개 상한"은 신설 당일 재절단을 막지 못했다 — R017 그룹은 담당 6파일로 상한 인접이었으나, 실제 초과 원인은 파일 수가 아니라 **조항 2개 신설**이었다. 파일당 편집 항목이 1개라는 암묵 가정이 깨지면 파일 수 기준 산술이 무의미해진다. 위임 크기 산술의 단위는 **편집 항목 수**(신설 조항 1개, 버전 노트 1개, 사본 배선 1개 등)로 바꾼다: **항목당 약 2턴(Edit + 위치 탐색) + 파일당 고정비(Read 1 + 미러 Edit 1 + diff 1 = 3턴)**로 계산한다. 같은 세션에서 4파일·항목 소수 위임들은 완주해 대조를 이룬다.
|
|
139
139
|
7. **리서치형 위임의 산출물 우선 기록 (Origin: #1621 #2b, v1.1.51 세션)**: 파일 편집형뿐 아니라 리서치형(수집 중심) 위임도 절단에 취약하다 — v1.1.51 세션에서 훅 이벤트 감사 위임이 22회 도구 호출(WebFetch/Read)을 전부 수집에 쓰고 **아티팩트를 1회도 Write하지 않은 채 절단**되어, 수집한 산출물 전량이 에이전트 컨텍스트에만 존재하고 파일에는 남지 않았다(재개 지시 "산출물 우선 기록"으로 복구). 리서치형 위임의 표준 문안에는 **"첫 2턴 안에 아티팩트 골격을 Write하고 수집 즉시 증분 Edit하라 — 수집 완료 후 일괄 기록 금지"**를 포함한다. 절단은 항상 마지막 작업을 자르므로, 기록을 마지막에 몰면 절단 시 산출물이 전량 유실된다 — 위 5항(파일 편집형의 "미러 즉시 동기화")의 리서치형 대응이다.
|
|
140
|
+
8. **훅 피드백 잠식 (Origin: #1625 #5)**: settings 훅은 서브에이전트 세션에도 발화하므로, 세션 종료성 훅(Stop 계열)의 반복 피드백이 서브에이전트의 마지막 턴들을 소모·오염시켜 최종 보고가 "대기 중" 류로 끝날 수 있다 — mid-step 종료·"실제가 보고보다 앞섬"의 신규 원인 축. v1.1.53 세션에서 커밋 에이전트 2건의 "대기 중" 보고가 실측 결과 모두 완전 완료였다. 절단·대기 보고를 받으면 훅 피드백 잠식 가능성도 원인 후보에 포함하고 ground-truth로 판정한다.
|
|
140
141
|
|
|
141
142
|
| Anti-pattern | Required |
|
|
142
143
|
|--------------|----------|
|
|
143
144
|
| 파일 수만 세어 위임 크기 판정 → 조항 다수 파일에서 절단 | 편집 항목 수 × 2턴 + 파일 고정비(3턴)로 산술 |
|
|
144
145
|
| 리서치 위임이 수집을 끝낸 뒤 일괄 기록 | 첫 2턴 내 골격 Write + 증분 Edit |
|
|
146
|
+
| "대기 중" 보고를 미완료로 단정 | 훅 피드백 잠식 가능성 포함해 ground-truth로 완료 여부 판정 |
|
|
145
147
|
|
|
146
148
|
Cross-reference: R018 (v2.1.246 maxTurns partial-marking 노트), R009 (Member Prompt Size Cap — 프롬프트 토큰 상한과 별개로 턴 수 상한도 위임 크기 설계 변수임을 추가).
|
|
147
149
|
|
package/templates/manifest.json
CHANGED