oh-my-customcode 1.1.61 → 1.1.62

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.61",
246
+ version: "1.1.62",
247
247
  description: "Batteries-included agent harness for Claude Code",
248
248
  type: "module",
249
249
  bin: {
package/dist/index.js CHANGED
@@ -2539,7 +2539,7 @@ var package_default = {
2539
2539
  workspaces: [
2540
2540
  "packages/*"
2541
2541
  ],
2542
- version: "1.1.61",
2542
+ version: "1.1.62",
2543
2543
  description: "Batteries-included agent harness for Claude Code",
2544
2544
  type: "module",
2545
2545
  bin: {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.1.61",
6
+ "version": "1.1.62",
7
7
  "description": "Batteries-included agent harness for Claude Code",
8
8
  "type": "module",
9
9
  "bin": {
@@ -10,6 +10,10 @@ command -v jq >/dev/null 2>&1 || { cat; exit 0; }
10
10
 
11
11
  input=$(cat)
12
12
 
13
+ # Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0.
14
+ # Hooks must never crash (R021); jq parse errors would otherwise abort under `set -e`. (#1650)
15
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
16
+
13
17
  agent_type=$(printf '%s\n' "$input" | jq -r '.agent_type // "unknown"')
14
18
  model=$(printf '%s\n' "$input" | jq -r '.model // "inherit"')
15
19
  description=$(printf '%s\n' "$input" | jq -r '.description // ""' | head -c 80)
@@ -11,6 +11,10 @@ command -v jq >/dev/null 2>&1 || exit 0
11
11
 
12
12
  input=$(cat)
13
13
 
14
+ # Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0.
15
+ # Hooks must never crash (R021); jq parse errors would otherwise abort under `set -e`. (#1650)
16
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
17
+
14
18
  # Skip unless Agent Teams is CONFIRMED active.
15
19
  # session-env-check.sh writes one of: disabled | env-set | enabled (#1588).
16
20
  # `env-set` = the env var is set but TeamCreate presence is UNVERIFIED — R018 is dormant,
@@ -43,8 +47,8 @@ if [ -f "$RELEASE_PLAN" ]; then
43
47
  fi
44
48
 
45
49
  # Extract task info from input
46
- agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type // "unknown"')
47
- prompt_preview=$(printf '%s\n' "$input" | jq -r '.tool_input.description // ""' | head -c 60)
50
+ agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type? // "unknown"')
51
+ prompt_preview=$(printf '%s\n' "$input" | jq -r '.tool_input.description? // ""' | head -c 60)
48
52
 
49
53
  # Session-scoped counter using parent PID as session identifier
50
54
  COUNTER_FILE="/tmp/.claude-task-count-${PPID}"
@@ -13,12 +13,29 @@ command -v jq >/dev/null 2>&1 || exit 0
13
13
 
14
14
  input=$(cat)
15
15
 
16
+ # Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0.
17
+ # Hooks must never crash (R021); jq parse errors would otherwise abort under `set -e`. (#1650)
18
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
19
+
16
20
  # Extract fields from hook input
17
21
  tool_name=$(printf '%s\n' "$input" | jq -r '.tool_name // "unknown"')
18
- file_path=$(printf '%s\n' "$input" | jq -r '.tool_input.file_path // .tool_input.command // ""' | head -c 200)
22
+ file_path=$(printf '%s\n' "$input" | jq -r '.tool_input.file_path? // .tool_input.command? // ""' | head -c 200)
19
23
  agent_type=$(printf '%s\n' "$input" | jq -r '.agent_type // "unknown"')
20
24
  model=$(printf '%s\n' "$input" | jq -r '.model // "unknown"')
21
- is_error=$(printf '%s\n' "$input" | jq -r '.tool_output.is_error // false')
25
+ # Outcome signal. PostToolUse carries the result under `tool_response`, not
26
+ # `tool_output` (measured 2026-09-03: 1764/1764 PostToolUse payloads had
27
+ # `tool_response`, 0/1764 had `tool_output`), so the old `.tool_output.is_error`
28
+ # read was always absent and every entry was logged as outcome=success.
29
+ # Measured failure signals: `.tool_response.is_error` (generic) and
30
+ # `.tool_response.interrupted` (present on every Bash response).
31
+ # jq's `//` treats both null and false as absent, so this chain is an OR:
32
+ # the result is true iff at least one signal is true.
33
+ is_error=$(printf '%s\n' "$input" | jq -r '
34
+ (.tool_response?.is_error?
35
+ // .tool_response?.interrupted?
36
+ // .tool_output?.is_error?
37
+ // false) | tostring
38
+ ' 2>/dev/null) || is_error="false"
22
39
  timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
23
40
 
24
41
  # Determine outcome
@@ -12,6 +12,10 @@ command -v jq >/dev/null 2>&1 || exit 0
12
12
 
13
13
  input=$(cat)
14
14
 
15
+ # Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0.
16
+ # Hooks must never crash (R021); jq parse errors would otherwise abort under `set -e`. (#1650)
17
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
18
+
15
19
  # Hash store (PPID-scoped, session-only)
16
20
  HASH_STORE="/tmp/.claude-content-hashes-${PPID}"
17
21
 
@@ -20,7 +24,7 @@ tool_name=$(printf '%s\n' "$input" | jq -r '.tool_name // "unknown"')
20
24
  case "$tool_name" in
21
25
  "Read")
22
26
  # Store content hash for the file that was just read
23
- file_path=$(printf '%s\n' "$input" | jq -r '.tool_input.file_path // ""')
27
+ file_path=$(printf '%s\n' "$input" | jq -r '.tool_input.file_path? // ""')
24
28
 
25
29
  if [ -n "$file_path" ] && [ -f "$file_path" ]; then
26
30
  content_hash=$(md5 -q "$file_path" 2>/dev/null || md5sum "$file_path" 2>/dev/null | cut -d' ' -f1 || echo "unknown")
@@ -43,7 +47,7 @@ case "$tool_name" in
43
47
 
44
48
  "Edit")
45
49
  # Validate that file hasn't changed since last Read
46
- file_path=$(printf '%s\n' "$input" | jq -r '.tool_input.file_path // ""')
50
+ file_path=$(printf '%s\n' "$input" | jq -r '.tool_input.file_path? // ""')
47
51
 
48
52
  if [ -n "$file_path" ] && [ -f "$HASH_STORE" ] && [ -f "$file_path" ]; then
49
53
  stored_hash=$(grep "\"path\":\"${file_path}\"" "$HASH_STORE" 2>/dev/null | tail -1 | jq -r '.hash // ""' 2>/dev/null || echo "")
@@ -11,9 +11,13 @@ command -v jq >/dev/null 2>&1 || exit 0
11
11
 
12
12
  input=$(cat)
13
13
 
14
+ # Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0.
15
+ # Hooks must never crash (R021); jq parse errors would otherwise abort under `set -e`. (#1650)
16
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
17
+
14
18
  # Extract current task info
15
- agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type // "unknown"')
16
- current_model=$(printf '%s\n' "$input" | jq -r '.tool_input.model // "inherit"')
19
+ agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type? // "unknown"')
20
+ current_model=$(printf '%s\n' "$input" | jq -r '.tool_input.model? // "inherit"')
17
21
 
18
22
  # Session-scoped outcome log
19
23
  OUTCOME_FILE="/tmp/.claude-task-outcomes-${PPID}"
@@ -7,7 +7,30 @@
7
7
  set -euo pipefail
8
8
 
9
9
  input=$(cat)
10
- tool_output=$(printf '%s\n' "$input" | jq -r '.tool_output // ""')
10
+
11
+ # Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0.
12
+ # Hooks must never crash (R021); jq parse errors would otherwise abort under `set -e`. (#1650)
13
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
14
+ # PostToolUse carries the tool result under `tool_response`, not `tool_output`
15
+ # (measured 2026-09-03: 1764/1764 PostToolUse payloads had `tool_response`,
16
+ # 0/1764 had `tool_output`), so the previous `.tool_output` read always yielded
17
+ # "" and this hook never compressed anything. MCP responses arrive either as a
18
+ # bare string or as `{content: [{type:"text", text:...}]}`; `.tool_output` is
19
+ # kept as a fallback for events still using the older shape.
20
+ tool_output=$(printf '%s\n' "$input" | jq -r '
21
+ [
22
+ (.tool_response? | if type == "string" then . else empty end),
23
+ (.tool_response?.content? | strings),
24
+ (.tool_response?.content? | if type == "array"
25
+ then map(.text? | strings) | join("\n")
26
+ else empty end),
27
+ (.tool_response?.stdout? | strings),
28
+ (.tool_response?.file?.content? | strings),
29
+ (.tool_output? | if type == "string" then . else empty end),
30
+ (.tool_output?.output? | strings)
31
+ ]
32
+ | map(select(. != "")) | join("\n")
33
+ ' 2>/dev/null) || tool_output=""
11
34
 
12
35
  # Skip if output is small (< 3000 chars)
13
36
  output_len=${#tool_output}
@@ -47,4 +70,4 @@ fi
47
70
  compressed_len=${#summary}
48
71
  savings=$(( (output_len - compressed_len) * 100 / output_len ))
49
72
  printf '%s\n' "$input" | jq --arg summary "$summary" --arg savings "${savings}% reduced (${output_len}→${compressed_len} chars)" \
50
- '.tool_output = $summary | .["updatedMCPToolOutput"] = $summary'
73
+ '.tool_response = $summary | .tool_output = $summary | .["updatedMCPToolOutput"] = $summary'
@@ -22,6 +22,11 @@
22
22
  #
23
23
  # Advisory-only: ALWAYS exits 0, NEVER blocks.
24
24
  #
25
+ # Scope gate (#1650 D): the advisor judges the ORCHESTRATOR's turns only. When the hook fires
26
+ # from INSIDE a subagent session (the payload carries `agent_id`) it exits immediately — see
27
+ # the measured rationale at the gate itself. SubagentStart/SubagentStop are exempt because
28
+ # `agent_id` is a required TARGET identifier there, not an in-subagent marker.
29
+ #
25
30
  # ── Transcript schema (MEASURED 2026-08-05, #1553) ────────────────────────────────────
26
31
  # Claude Code JSONL lines do NOT carry a TOP-LEVEL `role`/`content`. The measured top-level
27
32
  # key set is:
@@ -134,15 +139,58 @@ if ! command -v jq >/dev/null 2>&1; then
134
139
  fi
135
140
 
136
141
  # ── 입력 필드 추출 (jq 1회 fork) ──
137
- meta=$(printf '%s' "$input" | jq -r '[(.session_id // ""), (.hook_event_name // ""), (.transcript_path // "")] | @tsv' 2>/dev/null) || exit 0
142
+ # stdin 가드: 비-JSON이면 jq가 실패해 `|| exit 0`으로 무음 종료하고, 유효 JSON이지만 객체가
143
+ # 아니면(배열/문자열/숫자) `select(type == "object")`가 걸러 빈 출력 → session_id 공백 → exit 0.
144
+ # 두 경로 모두 추가 fork 없이 기존 1회 fork 안에서 처리한다(PostToolUse는 매 도구 호출마다 발화).
145
+ meta=$(printf '%s' "$input" | jq -r 'select(type == "object") | [(.session_id // ""), (.hook_event_name // ""), (.transcript_path // ""), (.agent_id // "")] | @tsv' 2>/dev/null) || exit 0
138
146
  session_id=$(printf '%s' "$meta" | cut -f1)
139
147
  hook_event_name=$(printf '%s' "$meta" | cut -f2)
140
148
  transcript_in=$(printf '%s' "$meta" | cut -f3)
149
+ agent_id=$(printf '%s' "$meta" | cut -f4)
141
150
 
142
151
  if [ -z "$session_id" ]; then
143
152
  exit 0
144
153
  fi
145
154
 
155
+ # ── 서브에이전트 세션이면 즉시 종료 (#1650 D) ────────────────────────────────────────────
156
+ # settings 훅은 서브에이전트 세션에서도 발화한다(서브에이전트의 매 도구 호출마다 PostToolUse).
157
+ # 그 발화가 남기는 advisory 피드백이 서브에이전트의 마지막 턴들을 잠식해 완료 보고를 대체하는
158
+ # 현상이 관측됐다(R020 8항 — 훅 피드백 잠식).
159
+ #
160
+ # 판별 신호 실측 (CC 2.1.259 바이너리의 훅 입력 zod 스키마 원문):
161
+ # agent_id: optional — "Subagent identifier. Present only when the hook fires from within a
162
+ # subagent (e.g., a tool called by an AgentTool worker). Absent for the main thread, even in
163
+ # --agent sessions. Use this field (not agent_type) to distinguish subagent calls from
164
+ # main-thread calls."
165
+ # 그리고 공통 페이로드 빌더가 모든 훅 이벤트에 `agent_id: o?.agentId`를 실어 보낸다.
166
+ # 즉 플랫폼이 명시적으로 지정한 판별 필드다. `agent_type`은 쓰지 않는다 — 같은 스키마가
167
+ # "--agent로 시작한 세션의 메인 스레드에도(agent_id 없이) 존재한다"고 못박고 있다.
168
+ #
169
+ # 다른 두 후보가 왜 작동하지 않는지(실측):
170
+ # * 트랜스크립트의 isSidechain / agentId 레코드 필드 — 서브에이전트 전용 파일
171
+ # agent-<hex>.jsonl의 전 레코드에 존재하지만(실측 65/65, 3/3), 공통 빌더는
172
+ # `transcript_path: Lp(e.id)`로 **세션 id**에서 경로를 만들고 서브에이전트는 부모의
173
+ # session id를 공유한다(실측: agent-*.jsonl의 sessionId == 부모 UUID). 따라서 서브에이전트
174
+ # 안에서 발화한 훅이 읽는 파일은 **부모의 메인 트랜스크립트**이고, 그 파일에는 isSidechain
175
+ # true도 agentId도 없다. 이것이 바로 잠식 경로다.
176
+ # * transcript_path의 `agent-*.jsonl` 경로 패턴 — 같은 이유로 그 경로가 오지 않는다
177
+ # (`agent_transcript_path`는 SubagentStop 페이로드에만 추가되는 별도 필드다).
178
+ #
179
+ # SubagentStart/SubagentStop은 예외다: 두 이벤트의 스키마는 agent_id를 **필수**로 두고 그 값은
180
+ # "대상 서브에이전트"의 식별자다(`Ae().and({hook_event_name:"SubagentStop", agent_id, ...})`).
181
+ # 즉 이 두 이벤트에서 agent_id는 "서브에이전트 안에서 발화했다"는 표지가 아니므로, 여기서
182
+ # 게이트를 걸면 #1545 자율 루프 커버리지가 통째로 사라진다.
183
+ case "$hook_event_name" in
184
+ SubagentStart|SubagentStop)
185
+ : # agent_id는 대상 식별자 — 판별 신호로 쓰지 않는다
186
+ ;;
187
+ *)
188
+ if [ -n "$agent_id" ]; then
189
+ exit 0
190
+ fi
191
+ ;;
192
+ esac
193
+
146
194
  # hook_event_name이 없으면 hookSpecificOutput.hookEventName을 정확히 채울 수 없다.
147
195
  # 잘못된 기본값은 출력을 무효화하므로 추측하지 않고 즉시 종료한다 (fallback 제거).
148
196
  if [ -z "$hook_event_name" ]; then
@@ -221,9 +269,31 @@ split("\n")
221
269
  else 1 end) as $r007
222
270
  | ([ $blocks[] | select(.type? == "text") | (.text? // "") ] | join("\n") | split("\n")) as $lines
223
271
  | ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Tool:")) ] | length) as $an_tool
272
+ # Core Rule 접두사 중 Agent 스폰을 가리키는 것만 따로 센다 — 아래 스폰 표기 중복 차감용.
273
+ | ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Tool: ?Agent")) ] | length) as $an_tool_agent
224
274
  | ([ $lines[] | select(test("^[[:space:]]*\\[[0-9]+\\][[:space:]].*(→|->|—>)")) ] | length) as $an_spawn_item
275
+ # 단일 스폰 표기 (#1650 E): R008 §"Agent Tool Format"이 규정하는 `subagent_type:model → desc`
276
+ # 라인. 같은 절의 Parallel Spawn Prefix Rule은 **단일 스폰을 `[N]` 접두사에서 명시적으로
277
+ # 면제**하므로, 번호 없는 이 형태가 단일 스폰의 정규 표기다. $an_spawn_item은 번호 형태만
278
+ # 세므로 이 라인은 announce에 0을 기여했고, Agent tool_use가 그대로 위반으로 계상됐다.
279
+ # 줄 시작 앵커(선행 공백만 허용)는 필수다 — `- mgr-x:sonnet → ...` 같은 리스트 산문이나
280
+ # 문장 중간 인용이 announce로 계상되면 forward 판정(max(0, ntools - announce))에서 실제
281
+ # 누락을 가려버린다.
282
+ | ([ $lines[] | select(test("^[[:space:]]*[a-z][a-z0-9-]*:(haiku|sonnet|opus|fable|inherit)[[:space:]]*(→|->|—>)")) ] | length) as $an_spawn_single
225
283
  | ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Spawning:")) ] | length) as $an_spawn_hdr
226
- | ($an_tool + (if $an_spawn_item > 0 then $an_spawn_item else $an_spawn_hdr end)) as $announce
284
+ # 스폰 표기의 단위는 세분화된 것 우선: 번호 라인 > 단일 스폰 라인 > Spawning 헤더.
285
+ | (if $an_spawn_item > 0 then $an_spawn_item
286
+ elif $an_spawn_single > 0 then $an_spawn_single
287
+ else $an_spawn_hdr end) as $an_spawn
288
+ # 중복 계수 차감 (#1650 E): 같은 턴에 Core Rule `→ Tool: Agent` 접두사가 함께 있으면 스폰
289
+ # 표기 라인은 **같은 호출의 동반 라인**이다 — `→ Target:`이 `→ Tool:`의 동반 라인이라 세지
290
+ # 않는 것과 동일한 논리다. 더해버리면 한 호출이 2로 계상돼, 같은 턴의 다른 진짜 누락을
291
+ # 가린다(forward는 max(0, ntools - announce)라 과대 announce는 조용한 마스킹이 된다).
292
+ # 그래서 스폰 기여분에서 `→ Tool: Agent` 라인 수를 빼되 0에서 바닥을 친다 — 빼기만 하므로
293
+ # announce가 늘지 않아 신규 오탐은 생기지 않는다. 대신 구식의 과대 announce(접두사 +
294
+ # 항목 이중 계수)가 가리던 진짜 누락 — 예: 접두사 1 + [1] 항목 1 + tools=(Agent, Read)에서
295
+ # 미announce Read — 은 새로 발화한다(의도된 동작, v1.1.62 적대적 리뷰 재현 C1/C2).
296
+ | ($an_tool + (if $an_spawn > $an_tool_agent then $an_spawn - $an_tool_agent else 0 end)) as $announce
227
297
  | ([ $blocks[] | select((.type? == "tool_use") and ((.name? // "") != "Skill")) ] | length) as $ntools
228
298
  # 전체 tool_use(Skill 포함)가 0건인 턴은 forward 판정을 무조건 0으로 고정한다(#1625
229
299
  # 찐빠 #3, 이슈 제안 그대로). $ntools(Skill 제외) 기반 산식은 정상 입력에서는 이미
@@ -12,9 +12,14 @@ command -v jq >/dev/null 2>&1 || exit 0
12
12
 
13
13
  input=$(cat)
14
14
 
15
- # Extract tool info
16
- tool_name=$(printf '%s\n' "$input" | jq -r '.tool_name // "unknown"')
17
- tool_input=$(printf '%s\n' "$input" | jq -r '.tool_input // {}')
15
+ # Non-object stdin guard (#1650 B): a hook must never crash on garbage stdin.
16
+ # jq's parse error propagates through `set -euo pipefail` as rc=5 without this.
17
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
18
+
19
+ # Extract tool info — `?` suppresses errors when tool_input arrives as a scalar
20
+ tool_name=$(printf '%s\n' "$input" | jq -r '.tool_name? // "unknown"')
21
+ tool_input=$(printf '%s\n' "$input" | jq -c 'if (.tool_input? | type) == "object" then .tool_input else {} end' 2>/dev/null) || tool_input='{}'
22
+ [ -n "$tool_input" ] || tool_input='{}'
18
23
 
19
24
  SCHEMA_FILE=".claude/schemas/tool-inputs.json"
20
25
 
@@ -28,8 +33,8 @@ warnings=()
28
33
 
29
34
  case "$tool_name" in
30
35
  "Write")
31
- file_path=$(printf '%s\n' "$tool_input" | jq -r '.file_path // ""')
32
- content=$(printf '%s\n' "$tool_input" | jq -r '.content // ""')
36
+ file_path=$(printf '%s\n' "$tool_input" | jq -r '.file_path? // ""')
37
+ content=$(printf '%s\n' "$tool_input" | jq -r '.content? // ""')
33
38
 
34
39
  if [ -z "$file_path" ]; then
35
40
  warnings+=("[Schema] Write: file_path is empty or missing")
@@ -40,9 +45,9 @@ case "$tool_name" in
40
45
  ;;
41
46
 
42
47
  "Edit")
43
- file_path=$(printf '%s\n' "$tool_input" | jq -r '.file_path // ""')
44
- old_string=$(printf '%s\n' "$tool_input" | jq -r '.old_string // ""')
45
- new_string=$(printf '%s\n' "$tool_input" | jq -r '.new_string // ""')
48
+ file_path=$(printf '%s\n' "$tool_input" | jq -r '.file_path? // ""')
49
+ old_string=$(printf '%s\n' "$tool_input" | jq -r '.old_string? // ""')
50
+ new_string=$(printf '%s\n' "$tool_input" | jq -r '.new_string? // ""')
46
51
 
47
52
  if [ -z "$file_path" ]; then
48
53
  warnings+=("[Schema] Edit: file_path is empty or missing")
@@ -56,7 +61,7 @@ case "$tool_name" in
56
61
  ;;
57
62
 
58
63
  "Bash")
59
- command=$(printf '%s\n' "$tool_input" | jq -r '.command // ""')
64
+ command=$(printf '%s\n' "$tool_input" | jq -r '.command? // ""')
60
65
 
61
66
  if [ -z "$command" ]; then
62
67
  warnings+=("[Schema] Bash: command is empty")
@@ -12,9 +12,51 @@ command -v jq >/dev/null 2>&1 || exit 0
12
12
 
13
13
  input=$(cat)
14
14
 
15
- # Extract output to scan
16
- tool_name=$(printf '%s\n' "$input" | jq -r '.tool_name // "unknown"')
17
- output=$(printf '%s\n' "$input" | jq -r '.tool_output.output // ""')
15
+ # Non-object stdin guard (#1650 B) — rejects ONLY non-object stdin, which carries
16
+ # no scannable payload anyway; a well-formed object always reaches the scan below.
17
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
18
+
19
+ tool_name=$(printf '%s\n' "$input" | jq -r '.tool_name? // "unknown"')
20
+
21
+ # Collect every text-bearing field of the payload into one scan buffer.
22
+ #
23
+ # PostToolUse carries the result under `tool_response`, NOT `tool_output`
24
+ # (measured 2026-09-03: 1764 PostToolUse payloads echoed into this project's
25
+ # session transcripts had `tool_response` 1764/1764 and `tool_output` 0/1764;
26
+ # the CC 2.1.259 embedded hook reference documents `"tool_response": {...}
27
+ # // PostToolUse only`). Reading `.tool_output.output` therefore always yielded
28
+ # "" and the scan below never ran — every AWS key, private key and PAT passed
29
+ # through unflagged.
30
+ #
31
+ # Measured text-bearing fields, by tool:
32
+ # Bash .tool_response.stdout / .stderr
33
+ # Read .tool_response.file.content
34
+ # Write .tool_response.content
35
+ # Agent .tool_response.prompt
36
+ # MCP .tool_response (string) or .tool_response.content[].text
37
+ # `.tool_output.output` and a string-shaped `.tool_output` are kept as
38
+ # fallbacks for events that still use the older shape (e.g. SubagentStop).
39
+ #
40
+ # `?` suppresses jq path errors when a field is a scalar rather than an object;
41
+ # every branch is optional, so an unexpected shape degrades to "" instead of
42
+ # aborting the hook under `set -euo pipefail`.
43
+ output=$(printf '%s\n' "$input" | jq -r '
44
+ [
45
+ (.tool_response? | if type == "string" then . else empty end),
46
+ (.tool_response?.stdout? | strings),
47
+ (.tool_response?.stderr? | strings),
48
+ (.tool_response?.content? | strings),
49
+ (.tool_response?.content? | if type == "array"
50
+ then map(.text? | strings) | join("\n")
51
+ else empty end),
52
+ (.tool_response?.file?.content? | strings),
53
+ (.tool_response?.prompt? | strings),
54
+ (.tool_response?.output? | strings),
55
+ (.tool_output? | if type == "string" then . else empty end),
56
+ (.tool_output?.output? | strings)
57
+ ]
58
+ | map(select(. != "")) | join("\n")
59
+ ' 2>/dev/null) || output=""
18
60
 
19
61
  # Skip if no output
20
62
  if [ -z "$output" ] || [ "$output" = "null" ]; then
@@ -44,7 +86,7 @@ if echo "$output" | grep -qE 'ghp_[a-zA-Z0-9]{36}'; then
44
86
  fi
45
87
 
46
88
  # Private Key
47
- if echo "$output" | grep -qE '-----BEGIN.*PRIVATE KEY-----'; then
89
+ if echo "$output" | grep -qE -- '-----BEGIN.*PRIVATE KEY-----'; then
48
90
  echo "[Security] Potential private key detected in ${tool_name} output" >&2
49
91
  detected=true
50
92
  fi
@@ -13,6 +13,10 @@ set -euo pipefail
13
13
  # ── Stop hook 프로토콜: stdin을 먼저 읽음 ──
14
14
  input=$(cat)
15
15
 
16
+ # ── 비객체 stdin 가드 (#1650 B) — 없으면 jq 파싱 오류가 pipefail로 rc=5 전파.
17
+ # worker의 `.message.role` 트랜스크립트 파서 경로는 그대로 유지된다.
18
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
19
+
16
20
  # ── Opt-out 체크 ──
17
21
  if [ "${OMCUSTOM_SESSION_REFLECTION:-}" = "off" ]; then
18
22
  printf '%s\n' "$input"
@@ -40,7 +44,7 @@ bg_tasks_count=$(printf '%s\n' "$bg_tasks_json" | jq 'length' 2>/dev/null || ech
40
44
  session_crons_count=$(printf '%s\n' "$session_crons_json" | jq 'length' 2>/dev/null || echo 0)
41
45
 
42
46
  # 미완료 백그라운드 태스크: running/pending/in_progress 상태 카운트
43
- bg_dangling_count=$(printf '%s\n' "$bg_tasks_json" | jq '[.[] | select(.status == "running" or .status == "pending" or .status == "in_progress")] | length' 2>/dev/null || echo 0)
47
+ bg_dangling_count=$(printf '%s\n' "$bg_tasks_json" | jq '[.[] | select(.status? == "running" or .status? == "pending" or .status? == "in_progress")] | length' 2>/dev/null || echo 0)
44
48
 
45
49
  # ── 경로 결정 (환경변수 override 지원) ──
46
50
  SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -204,17 +204,34 @@ _strip_heredoc_bodies() {
204
204
  local text="$1"
205
205
  local out="" line tok delim="" in_body=0
206
206
  local before_op dq sq
207
+ # A literal single quote cannot be written inside the bracket expression of
208
+ # the quote-counting expansion below, so hold one in a variable.
209
+ local sq_char="'"
207
210
  while IFS= read -r line; do
208
211
  if [ "$in_body" -eq 1 ]; then
209
212
  # "<<-" allows leading tabs before the terminator; accept leading
210
- # whitespace for either form.
211
- tok="$(printf '%s' "$line" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
213
+ # whitespace for either form. The trim is pure parameter expansion: as
214
+ # "printf | sed" it cost TWO forks on every BODY line, which is the
215
+ # dominant cost of a long heredoc (measured: a 1000-line body took
216
+ # 1.8 s of hook latency, #1650 A).
217
+ tok="${line#"${line%%[![:space:]]*}"}"
218
+ tok="${tok%"${tok##*[![:space:]]}"}"
212
219
  if [ "$tok" = "$delim" ]; then
213
220
  in_body=0
214
221
  fi
215
222
  continue
216
223
  fi
217
224
  out="${out}${line}"$'\n'
225
+ # A line with no "<<" can open no heredoc, so neither the forgery guard nor
226
+ # the delimiter scan below has anything to find on it. Skipping them keeps
227
+ # the extraction "sed" off every ordinary line, which used to run
228
+ # unconditionally — two more forks per line (#1650 A). "delim" must still be
229
+ # cleared here, or a delimiter captured on an EARLIER line would re-open a
230
+ # body on this one.
231
+ case "$line" in
232
+ *'<<'*) ;;
233
+ *) delim=""; continue ;;
234
+ esac
218
235
  # A DELIMITER FORGERY guard: the extraction below has no idea whether the
219
236
  # "<<" it finds is shell syntax or literal text inside a quoted string, so
220
237
  # `echo "a << EOF"` followed by `rm -rf x` and a line reading `EOF` used to
@@ -226,14 +243,15 @@ _strip_heredoc_bodies() {
226
243
  # as a write. "${line%<<*}" strips the shortest matching suffix, i.e. it
227
244
  # yields the text before the LAST "<<" — the same occurrence the greedy
228
245
  # extraction regex below settles on.
229
- if [ "$line" != "${line%<<*}" ]; then
230
- before_op="${line%<<*}"
231
- dq="$(printf '%s' "$before_op" | tr -cd '"' | wc -c | tr -d '[:space:]')"
232
- sq="$(printf '%s' "$before_op" | tr -cd "'" | wc -c | tr -d '[:space:]')"
233
- if [ $((dq % 2)) -eq 1 ] || [ $((sq % 2)) -eq 1 ]; then
234
- printf '%s' "${out}__QUOTED_HEREDOC_OPENER__"$'\n'
235
- return 0
236
- fi
246
+ # The quote counts are parameter expansions rather than
247
+ # "printf | tr | wc | tr" (four more forks per opener line): "${v//[^X]/}"
248
+ # deletes everything that is not X, so the remaining LENGTH is the count.
249
+ before_op="${line%<<*}"
250
+ dq="${before_op//[^\"]/}"
251
+ sq="${before_op//[^$sq_char]/}"
252
+ if [ $(( ${#dq} % 2 )) -eq 1 ] || [ $(( ${#sq} % 2 )) -eq 1 ]; then
253
+ printf '%s' "${out}__QUOTED_HEREDOC_OPENER__"$'\n'
254
+ return 0
237
255
  fi
238
256
  delim="$(printf '%s' "$line" \
239
257
  | sed -n -E "s/^.*[^<]<<-?[[:space:]]*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?.*\$/\\1/p")"
@@ -453,7 +471,12 @@ is_readonly_bash_command() {
453
471
  normalized="${normalized//|/$'\n'}"
454
472
  local seg
455
473
  while IFS= read -r seg; do
456
- seg="$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
474
+ # Pure parameter expansion, not "printf | sed": each segment is a single
475
+ # line, so the two forms trim identically, but the sed form spent two
476
+ # forks on EVERY segment of a multi-line command (measured: 300
477
+ # newline-separated segments took 0.8 s of hook latency, #1650 A).
478
+ seg="${seg#"${seg%%[![:space:]]*}"}"
479
+ seg="${seg%"${seg##*[![:space:]]}"}"
457
480
  if [ -z "$seg" ]; then
458
481
  echo "false"
459
482
  return 0
@@ -472,6 +495,15 @@ is_readonly_bash_command() {
472
495
 
473
496
  input=$(cat)
474
497
 
498
+ # A hook must never crash the tool call it observes. stdin that is not a JSON
499
+ # OBJECT — empty, a bare scalar, a JSON array, truncated, or not JSON at all —
500
+ # carries no tool record, and the very first "jq -r" below died on it; with
501
+ # "set -euo pipefail" that took the WHOLE hook down with rc=5 (#1650 B).
502
+ # Swallow it instead: exit 0 with no stdout, exactly as if the hook had not
503
+ # run. "jq -e" exits non-zero both for a parse error and for a false/null
504
+ # result, so one guard covers every non-object shape.
505
+ printf '%s' "$input" | jq -e 'type == "object"' >/dev/null 2>&1 || exit 0
506
+
475
507
  # Extract tool info
476
508
  tool_name=$(printf '%s' "$input" | jq -r '.tool_name // "unknown"')
477
509
  # Bash commands are NOT files. Feeding tool_input.command into file_path made
@@ -11,28 +11,35 @@ command -v jq >/dev/null 2>&1 || exit 0
11
11
 
12
12
  input=$(cat)
13
13
 
14
- # Extract task info — support both PostToolUse (tool_input.*) and SubagentStop (top-level) shapes
15
- agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type // .agent_type // "unknown"')
16
- model=$(printf '%s\n' "$input" | jq -r '.tool_input.model // .model // "inherit"')
17
- description=$(printf '%s\n' "$input" | jq -r '.tool_input.description // .description // ""' | head -c 80)
14
+ # Non-object stdin guard (#1650 B): a hook must never crash on garbage stdin.
15
+ # jq's parse error propagates through `set -euo pipefail` as rc=5 without this.
16
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
17
+
18
+ # Extract task info — support both PostToolUse (tool_input.*) and SubagentStop (top-level) shapes.
19
+ # `?` suppresses "Cannot index string with ..." when tool_input arrives as a scalar.
20
+ agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type? // .agent_type? // "unknown"')
21
+ model=$(printf '%s\n' "$input" | jq -r '.tool_input.model? // .model? // "inherit"')
22
+ description=$(printf '%s\n' "$input" | jq -r '.tool_input.description? // .description? // ""' | head -c 80)
18
23
 
19
24
  # Extract skill name from description or prompt
20
25
  skill_name=""
26
+ # `|| true`: a no-match grep exits 1 and `set -euo pipefail` would abort the hook
27
+ # (measured rc=1 on empty stdin AND on a well-formed object with no skill name).
21
28
  if echo "$description" | grep -qiE '(skill:|routing|→.*skill)'; then
22
- skill_name=$(echo "$description" | grep -oiE '[a-z]+-[a-z]+(-[a-z]+)*-?(routing|skill|practices|detection|decomposition|orchestration|pipeline|guards|cycle|plan|review|refactor|publish|version|audit|exec|analyze|bundle|report|setup|watch|lists|status|help|save|recall)' | head -1)
29
+ skill_name=$(echo "$description" | grep -oiE '[a-z]+-[a-z]+(-[a-z]+)*-?(routing|skill|practices|detection|decomposition|orchestration|pipeline|guards|cycle|plan|review|refactor|publish|version|audit|exec|analyze|bundle|report|setup|watch|lists|status|help|save|recall)' | head -1 || true)
23
30
  fi
24
31
  # Fallback: check prompt field for "Skill: {name}" pattern
25
32
  if [ -z "$skill_name" ]; then
26
- prompt=$(printf '%s\n' "$input" | jq -r '.tool_input.prompt // ""' | head -c 500)
27
- skill_name=$(echo "$prompt" | grep -oiE 'Skill:\s*[a-z]+-[a-z]+(-[a-z]+)*' | sed 's/[Ss]kill:\s*//' | head -1)
33
+ prompt=$(printf '%s\n' "$input" | jq -r '.tool_input.prompt? // ""' | head -c 500)
34
+ skill_name=$(echo "$prompt" | grep -oiE 'Skill:\s*[a-z]+-[a-z]+(-[a-z]+)*' | sed 's/[Ss]kill:\s*//' | head -1 || true)
28
35
  fi
29
36
 
30
37
  # Determine outcome
31
- is_error=$(printf '%s\n' "$input" | jq -r '.tool_output.is_error // false')
38
+ is_error=$(printf '%s\n' "$input" | jq -r '.tool_output.is_error? // false')
32
39
 
33
40
  if [ "$is_error" = "true" ]; then
34
41
  outcome="failure"
35
- error_summary=$(printf '%s\n' "$input" | jq -r '.tool_output.output // ""' | head -c 200)
42
+ error_summary=$(printf '%s\n' "$input" | jq -r '.tool_output.output? // ""' | head -c 200)
36
43
  else
37
44
  outcome="success"
38
45
  error_summary=""
@@ -16,7 +16,7 @@ oh-my-customcode uses an **advisory-first enforcement model**. Most rules are en
16
16
  | Soft Block | Stop hook prompt | R011 session-end saves | Auto-performs then approves |
17
17
  | Conversation Block | PostToolUse hook + `continueOnBlock` (CC v2.1.139+), exit 2 | stuck-detector, context-budget-advisor, cost-cap-advisor | Feeds rejection reason into conversation; Claude continues with awareness |
18
18
  | Advisory | PostToolUse hooks | R007, R008, R009, R010, R018 | Warns via stderr, never blocks |
19
- | Advisory (proactive) | UserPromptSubmit + SubagentStop + PostToolUse hooks | R007, R008 (`r007-r008-drift-advisor.sh` — #1229 UserPromptSubmit, #1545 SubagentStop, #1553 PostToolUse) | Reads last assistant turn; emits advisory if header/prefix absent. SubagentStop wiring (#1545) closes the no-user-input autonomous-loop gap (`/fsd`); PostToolUse (#1553) covers the orchestrator-only stretch before the first subagent spawn. Complements retroactive Stop-hook (`session-reflection.sh`, #1190). **v1.1.43부터 실제 발화 — 아래 각주 참조.** v1.1.49부터 역방향(announce > tool_use) 신호 포함, 기본 off 옵트인 (#1595 #6). |
19
+ | Advisory (proactive) | UserPromptSubmit + SubagentStop + PostToolUse hooks | R007, R008 (`r007-r008-drift-advisor.sh` — #1229 UserPromptSubmit, #1545 SubagentStop, #1553 PostToolUse) | Reads last assistant turn; emits advisory if header/prefix absent. SubagentStop wiring (#1545) closes the no-user-input autonomous-loop gap (`/fsd`); PostToolUse (#1553) covers the orchestrator-only stretch before the first subagent spawn. Complements retroactive Stop-hook (`session-reflection.sh`, #1190). **v1.1.43부터 실제 발화 — 아래 각주 참조.** v1.1.49부터 역방향(announce > tool_use) 신호 포함, 기본 off 옵트인 (#1595 #6). **v1.1.62(#1650 D)부터 서브에이전트 세션에서는 침묵**: hook stdin의 `agent_id`(CC 스키마상 서브에이전트 내부 발화에만 존재)가 있으면 exit 0 — 단 `SubagentStart`/`SubagentStop`은 `agent_id`가 대상 식별자라 예외(판정 수행). 중첩 서브에이전트 안의 SubagentStop은 억제 못 함(R010 정책상 도달 불가 경로). 완료 보고가 advisory 응답으로 대체되던 훅 피드백 잠식(R020 8항, #1652 #3)의 advisory 계열 차단. |
20
20
  | Advisory (telemetry) | PostToolUseFailure hook | — (계측 전용, 규칙 강제 없음) | `failure-ledger.sh` (#1561, v1.1.44) — 도구 실패를 JSONL 원장에 append. stdout/stderr 무출력이라 모델에 도달하지 않으며 절대 차단하지 않음 |
21
21
  | Advisory (proactive) | UserPromptSubmit hook | R020 (원인 진단) | `fail-axis-cause-advisor.sh` (#1561, v1.1.44) — 원장에 실패 기록이 있는데 원인 진술 없는 재촉 프롬프트가 오면 `hookSpecificOutput.additionalContext`로 "원인 가설 되묻기" advisory 전달. 원장 부재 시 조용히 통과 |
22
22
  | Prompt-based | CLAUDE.md + rules/ + `SessionStart` 재주입(matcher `*`; PostCompact 이벤트 배선은 유지되나 효과 미보장[^postcompact]) | All MUST rules | Behavioral guidance in context |
@@ -78,7 +78,7 @@ Agent(description: "[2] Python code review", subagent_type: "lang-python-expert"
78
78
 
79
79
  Single agent spawns do NOT use the `[N]` prefix.
80
80
 
81
- **단일 스폰도 Core Rule 접두사 필수 (Origin: #1652 #3-3)**: 단일 Agent 스폰은 `[N]` 항목 형식 대신 Core Rule 형식의 접두사 라인(에이전트·모델 대괄호 + 화살표 + Tool 표기 + `Agent`)을 호출 직전에 출력한다. advisor(`.claude/hooks/scripts/r007-r008-drift-advisor.sh`)가 announce로 계수하는 것은 3종 — Tool 표기 라인, Spawning 헤더 라인(대괄호 번호 항목이 0개일 때 폴백으로 계수), 대괄호 번호가 붙은 spawn 항목 — 이며, **대괄호 번호 없이 에이전트타입:모델 다음에 화살표와 설명만 이어붙인 단독 라인은 계수하지 않는다** — Tool 표기 라인도 Spawning 헤더도 없이 그 단독 라인만 쓰면 규칙을 지킨 응답이 R008 누락 1건으로 계상된다(v1.1.60 세션 실측 — advisory가 반복돼 세션 중 접두사 형식을 바꿔야 했다; v1.1.61 적대적 리뷰가 합성 트랜스크립트 양성/음성 짝으로 재현). 정합 방향은 **규칙 문구 쪽**으로 확정한다. advisor 정규식 쪽 대안(번호 없는 단독 라인을 spawn 항목으로 계수)은 `.claude/hooks/**` 승인 사안이라 **#1650 E 항목**으로 이월했으며, 적용 시 이 조항의 advisor 동작 서술을 같은 커밋에서 갱신한다(R016 Rule Wiring Check).
81
+ **단일 스폰도 Core Rule 접두사 필수 (Origin: #1652 #3-3, advisor 정합 #1650 E)**: 단일 Agent 스폰은 `[N]` 항목 형식 대신 Core Rule 형식의 접두사 라인(에이전트·모델 대괄호 + 화살표 + Tool 표기 + `Agent`)을 호출 직전에 출력한다. advisor(`.claude/hooks/scripts/r007-r008-drift-advisor.sh`)가 announce로 계수하는 것은 4종 — Tool 표기 라인, 대괄호 번호가 붙은 spawn 항목, **대괄호 번호 없이 에이전트타입:모델 다음에 화살표와 설명이 이어지는 단독 라인**(#1650 E에서 추가), Spawning 헤더 라인 — 이며 스폰 표기 3종은 **번호 항목 > 단독 라인 > Spawning 헤더** 순으로 하나만 채택된다. 단독 라인만 쓴 응답이 R008 누락으로 오계상되던 결함은 v1.1.62에서 해소됐다(v1.1.60 세션 실측 → v1.1.61 적대적 리뷰 재현 → #1650 E 적용). **다만 규칙이 요구하는 형식은 여전히 Core Rule 접두사 라인이다** — 단독 라인 계수는 오탐 제거이지 표기 승격이 아니다. 접두사 라인과 단독 라인이 함께 있으면 advisor는 단독 라인을 **동반 라인으로 보아 차감**하므로(Target 라인과 동일 취급) 병기해도 이중 계수되지 않는다. **단독 라인은 반드시 줄 시작(선행 공백만 허용)이어야 한다** — 리스트 마커가 앞에 붙으면 계수되지 않는다. advisor 판정식을 바꿀 때 이 문단을 같은 커밋에서 갱신한다(R016 Rule Wiring Check).
82
82
 
83
83
  ```
84
84
  [claude][opus] → Tool: Agent
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.61",
2
+ "version": "1.1.62",
3
3
  "lastUpdated": "2026-09-03",
4
4
  "omcustomMinClaudeCode": "2.1.121",
5
5
  "omcustomMinClaudeCodeReason": "Sensitive-path direct Write/Edit on .claude/** under bypassPermissions (R010 deprecation, #1101)",