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 CHANGED
@@ -243,7 +243,7 @@ var init_package = __esm(() => {
243
243
  workspaces: [
244
244
  "packages/*"
245
245
  ],
246
- version: "1.1.53",
246
+ version: "1.1.55",
247
247
  description: "Batteries-included agent harness for Claude Code",
248
248
  type: "module",
249
249
  bin: {
package/dist/index.js CHANGED
@@ -2535,7 +2535,7 @@ var package_default = {
2535
2535
  workspaces: [
2536
2536
  "packages/*"
2537
2537
  ],
2538
- version: "1.1.53",
2538
+ version: "1.1.55",
2539
2539
  description: "Batteries-included agent harness for Claude Code",
2540
2540
  type: "module",
2541
2541
  bin: {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.1.53",
6
+ "version": "1.1.55",
7
7
  "description": "Batteries-included agent harness for Claude Code",
8
8
  "type": "module",
9
9
  "bin": {
@@ -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 — advisory 완전 비활성화
29
- # OMCUSTOM_ERROR_LEDGER=<path> — 원장 경로 override
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 '(계속|이어서|진행해|재개|다음|ㄱㄱ|고고|가자|continue|keep going|go on|resume|proceed|next)'; then
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
- | [ range($b + 1; $last + 1) | $L[.] | select(.message.role? == "assistant") ] as $turn
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
- | (if $ntools > $announce then $ntools - $announce else 0 end) as $r008
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
- | ($blocks | map(select(.type? == "tool_use")) | length) as $nall_tools
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
- result=$(tail -n 200 "$TRANSCRIPT_PATH" 2>/dev/null | jq -Rsr "$JQ_LAST_TURN" 2>/dev/null) || result=""
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=$(echo "$input" | jq -r '.tool_name // "unknown"')
22
- file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.command // ""' | head -c 120)
23
- is_error=$(echo "$input" | jq -r '.tool_output.is_error // false')
24
- output_preview=$(echo "$input" | jq -r '.tool_output.output // ""' | head -c 200)
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
- '{timestamp: $ts, tool: $tool, path: $path, is_error: $err, error_hash: $hash, preview: $preview}')
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
- if [ "$stuck_detected" = false ]; then
110
- tool_repeat=$(tail -8 "$HISTORY_FILE" | grep -c "\"tool\":\"${tool_name}\"" 2>/dev/null || echo "0")
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
- if [ -n "$file_path" ]; then
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
- if [ "$hard_block" = false ] && [ -n "$file_path" ]; then
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
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.53",
2
+ "version": "1.1.55",
3
3
  "lastUpdated": "2026-07-14T00:00:00.000Z",
4
4
  "omcustomMinClaudeCode": "2.1.121",
5
5
  "omcustomMinClaudeCodeReason": "Sensitive-path direct Write/Edit on .claude/** under bypassPermissions (R010 deprecation, #1101)",