oh-my-customcode 1.1.61 → 1.1.63

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.
Files changed (26) hide show
  1. package/dist/cli/index.js +1 -1
  2. package/dist/index.js +1 -1
  3. package/package.json +1 -1
  4. package/templates/.claude/hooks/scripts/agent-start-recorder.sh +4 -0
  5. package/templates/.claude/hooks/scripts/agent-teams-advisor.sh +6 -2
  6. package/templates/.claude/hooks/scripts/audit-log.sh +41 -4
  7. package/templates/.claude/hooks/scripts/content-hash-validator.sh +6 -2
  8. package/templates/.claude/hooks/scripts/failure-ledger.sh +6 -1
  9. package/templates/.claude/hooks/scripts/git-delegation-guard.sh +12 -2
  10. package/templates/.claude/hooks/scripts/model-escalation-advisor.sh +20 -5
  11. package/templates/.claude/hooks/scripts/playwright-compress.sh +33 -2
  12. package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +75 -2
  13. package/templates/.claude/hooks/scripts/schema-validator.sh +14 -9
  14. package/templates/.claude/hooks/scripts/secret-filter.sh +50 -4
  15. package/templates/.claude/hooks/scripts/session-reflection.sh +5 -1
  16. package/templates/.claude/hooks/scripts/stuck-detector.sh +80 -14
  17. package/templates/.claude/hooks/scripts/task-outcome-recorder.sh +106 -19
  18. package/templates/.claude/rules/MUST-completion-verification.md +6 -0
  19. package/templates/.claude/rules/MUST-enforcement-policy.md +1 -1
  20. package/templates/.claude/rules/MUST-orchestrator-coordination.md +2 -0
  21. package/templates/.claude/rules/MUST-tool-identification.md +12 -1
  22. package/templates/.claude/rules/SHOULD-verification-ladder.md +2 -0
  23. package/templates/.claude/skills/fsd/SKILL.md +1 -0
  24. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +5 -1
  25. package/templates/manifest.json +1 -1
  26. package/templates/workflows/auto-dev.yaml +5 -1
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.63",
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.63",
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.63",
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,49 @@ 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)
19
- agent_type=$(printf '%s\n' "$input" | jq -r '.agent_type // "unknown"')
20
- model=$(printf '%s\n' "$input" | jq -r '.model // "unknown"')
21
- is_error=$(printf '%s\n' "$input" | jq -r '.tool_output.is_error // false')
22
+ file_path=$(printf '%s\n' "$input" | jq -r '.tool_input.file_path? // .tool_input.command? // ""' | head -c 200)
23
+ # Agent identity and model. PostToolUse has NO top-level `model` at all, and its
24
+ # top-level `agent_type` is present only when the hook fires from inside a subagent
25
+ # (CC 2.1.259 schema: "Present when the hook fires from within a subagent") — so on
26
+ # the main thread both reads resolved to "unknown" on every single entry.
27
+ # Measured replacements (#1656 C): the Agent tool's result object carries
28
+ # `resolvedModel` (892 occurrences in this project's transcript corpus, e.g.
29
+ # "claude-sonnet-5" / "claude-opus-5[1m]") and, on the synchronous shape, `agentType`;
30
+ # the spawn arguments carry `subagent_type`.
31
+ # `?` keeps a scalar-shaped tool_input/tool_response from aborting under `set -euo pipefail`.
32
+ agent_type=$(printf '%s\n' "$input" | jq -r '
33
+ (.agent_type?
34
+ // .tool_input?.subagent_type?
35
+ // .tool_response?.agentType?
36
+ // "unknown") | tostring
37
+ ' 2>/dev/null) || agent_type="unknown"
38
+ model=$(printf '%s\n' "$input" | jq -r '
39
+ (.model?
40
+ // .tool_response?.resolvedModel?
41
+ // "unknown") | tostring
42
+ ' 2>/dev/null) || model="unknown"
43
+ # Outcome signal. PostToolUse carries the result under `tool_response`, not
44
+ # `tool_output` (measured 2026-09-03: 1764/1764 PostToolUse payloads had
45
+ # `tool_response`, 0/1764 had `tool_output`), so the old `.tool_output.is_error`
46
+ # read was always absent and every entry was logged as outcome=success.
47
+ # Failure signals read below: `.tool_response.is_error` (generic) and
48
+ # `.tool_response.interrupted`. The latter is present on every Bash response but
49
+ # was never observed true in this corpus (0/1555), so it is defensive coverage —
50
+ # not a signal measured to fire.
51
+ # jq's `//` treats both null and false as absent, so this chain is an OR:
52
+ # the result is true iff at least one signal is true.
53
+ is_error=$(printf '%s\n' "$input" | jq -r '
54
+ (.tool_response?.is_error?
55
+ // .tool_response?.interrupted?
56
+ // .tool_output?.is_error?
57
+ // false) | tostring
58
+ ' 2>/dev/null) || is_error="false"
22
59
  timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
23
60
 
24
61
  # 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 "")
@@ -52,6 +52,9 @@ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
52
52
  # (r007-r008-drift-advisor.sh의 `.role` vs `.message.role`과 동일 계열).
53
53
  # .tool_error / .tool_response.* fallback은 스키마 변화에 대한 방어로만 남긴다 —
54
54
  # tool_response가 문자열인 경우 인덱싱 에러로 레코드가 통째로 유실되므로 type 검사로 감싼다.
55
+ # .tool_input도 같은 이유로 type 검사를 씌운다 (#1656 A 실측): 스칼라/배열 tool_input이
56
+ # 오면 jq가 인덱싱 에러로 죽고 `2>/dev/null || true` 때문에 **조용히 레코드 전체가
57
+ # 유실**됐다 — rc는 0이고 stderr도 비어 있어 크래시가 아니라 데이터 손실로 나타난다.
55
58
  #
56
59
  # 단일 라인(<1KB) append 이므로 O_APPEND 원자성에 기대어 병렬 에이전트 환경에서도 안전.
57
60
  printf '%s' "$input" \
@@ -61,7 +64,9 @@ printf '%s' "$input" \
61
64
  session: (.session_id // ""),
62
65
  cwd: $cwd,
63
66
  tool: (.tool_name // "unknown"),
64
- target: ((.tool_input.command // .tool_input.file_path // "") | tostring | .[0:160]),
67
+ target: ((if (.tool_input | type) == "object"
68
+ then (.tool_input.command // .tool_input.file_path // "")
69
+ else "" end) | tostring | .[0:160]),
65
70
  interrupt: (.is_interrupt == true),
66
71
  err: ((.error
67
72
  // .tool_error
@@ -14,8 +14,18 @@ command -v jq >/dev/null 2>&1 || exit 0
14
14
 
15
15
  input=$(cat)
16
16
 
17
- agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type // ""')
18
- prompt=$(printf '%s\n' "$input" | jq -r '.tool_input.prompt // ""')
17
+ # Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0
18
+ # instead of reflecting garbage back on stdout. The sibling hooks (audit-log.sh,
19
+ # model-escalation-advisor.sh, task-outcome-recorder.sh) all carry this guard;
20
+ # this one did not, so a non-object payload was echoed through verbatim. (#1656 F)
21
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
22
+
23
+ # `?` suppresses jq's "Cannot index <type> with string" when tool_input arrives as a
24
+ # scalar/array instead of an object (measured #1656 A: the bare form leaked a jq error to
25
+ # stderr on every non-object tool_input). 2>/dev/null + `|| echo ""` keep the hook silent
26
+ # and advisory-only even if jq itself fails for an unrelated reason.
27
+ agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type? // ""' 2>/dev/null || echo "")
28
+ prompt=$(printf '%s\n' "$input" | jq -r '.tool_input.prompt? // ""' 2>/dev/null || echo "")
19
29
 
20
30
  # R010 violation tracking file (PPID-scoped for session persistence)
21
31
  VIOLATION_FILE="/tmp/.claude-r010-violations-${PPID}"
@@ -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}"
@@ -30,13 +34,22 @@ CONSECUTIVE_THRESHOLD=3
30
34
  COOLDOWN=5
31
35
 
32
36
  # Count failures for this agent type
37
+ #
38
+ # `grep -c` COUNT HYGIENE (#1656 E): a no-match `grep -c` prints `0` on stdout AND
39
+ # exits 1, so the old `$(grep -c ... || echo "0")` appended a SECOND zero and the
40
+ # variable became the two-line string "0\n0". Every later `[ "$count" -ge N ]` then
41
+ # failed with "integer expression expected" on stderr — noise indistinguishable
42
+ # from a real hook fault, on the exact path (zero failures) that is the common case.
43
+ # `|| true` keeps grep's own `0` and `${x:-0}` covers an unreadable file.
33
44
  agent_failures=0
34
45
  if [ -n "$agent_type" ] && [ "$agent_type" != "unknown" ]; then
35
- agent_failures=$(grep -c "\"agent_type\":\"${agent_type}\".*\"outcome\":\"failure\"" "$OUTCOME_FILE" 2>/dev/null || echo "0")
46
+ agent_failures=$(grep -c "\"agent_type\":\"${agent_type}\".*\"outcome\":\"failure\"" "$OUTCOME_FILE" 2>/dev/null || true)
47
+ agent_failures=${agent_failures:-0}
36
48
  fi
37
49
 
38
50
  # Count consecutive failures (tail entries)
39
- consecutive_failures=$(tail -${CONSECUTIVE_THRESHOLD} "$OUTCOME_FILE" 2>/dev/null | grep -c '"outcome":"failure"' 2>/dev/null || echo "0")
51
+ consecutive_failures=$(tail -${CONSECUTIVE_THRESHOLD} "$OUTCOME_FILE" 2>/dev/null | grep -c '"outcome":"failure"' 2>/dev/null || true)
52
+ consecutive_failures=${consecutive_failures:-0}
40
53
 
41
54
  # Escalation path
42
55
  # NOTE: Agent tool `model` param is an enum of exactly 4 values: sonnet | opus | haiku | fable.
@@ -86,7 +99,9 @@ fi
86
99
 
87
100
  # De-escalation check
88
101
  if [ "$current_model" != "haiku" ] && [ "$current_model" != "inherit" ] && [ "$current_model" != "" ]; then
89
- recent_successes=$(tail -${COOLDOWN} "$OUTCOME_FILE" 2>/dev/null | grep -c '"outcome":"success"' 2>/dev/null || echo "0")
102
+ # Same `grep -c` hygiene as above (#1656 E).
103
+ recent_successes=$(tail -${COOLDOWN} "$OUTCOME_FILE" 2>/dev/null | grep -c '"outcome":"success"' 2>/dev/null || true)
104
+ recent_successes=${recent_successes:-0}
90
105
 
91
106
  if [ "$recent_successes" -ge "$COOLDOWN" ]; then
92
107
  lower_model=""
@@ -7,7 +7,38 @@
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 at v1.1.62: every PostToolUse record in this project's transcripts
16
+ # carried `tool_response` and none carried `tool_output`), so the previous
17
+ # `.tool_output` read always yielded "" and this hook never compressed anything.
18
+ # MCP responses arrive either as a bare string or as
19
+ # `{content: [{type:"text", text:...}]}`; `.tool_output` is kept as a fallback
20
+ # for events still using the older shape.
21
+ #
22
+ # MATCHER-SCOPED: `.stdout` and `.file.content` are read here only because this
23
+ # hook's matcher is `mcp__playwright__.*|mcp__claude-in-chrome__.*`, so those
24
+ # branches can never fire on a real Bash or Read result. This is a lossy hook —
25
+ # it REPLACES `.tool_response` with a Haiku summary — so widening the matcher to
26
+ # cover Bash/Read without first dropping those two branches would silently
27
+ # destroy genuine command output and file contents. (#1656 F)
28
+ tool_output=$(printf '%s\n' "$input" | jq -r '
29
+ [
30
+ (.tool_response? | if type == "string" then . else empty end),
31
+ (.tool_response?.content? | strings),
32
+ (.tool_response?.content? | if type == "array"
33
+ then map(.text? | strings) | join("\n")
34
+ else empty end),
35
+ (.tool_response?.stdout? | strings),
36
+ (.tool_response?.file?.content? | strings),
37
+ (.tool_output? | if type == "string" then . else empty end),
38
+ (.tool_output?.output? | strings)
39
+ ]
40
+ | map(select(. != "")) | join("\n")
41
+ ' 2>/dev/null) || tool_output=""
11
42
 
12
43
  # Skip if output is small (< 3000 chars)
13
44
  output_len=${#tool_output}
@@ -47,4 +78,4 @@ fi
47
78
  compressed_len=${#summary}
48
79
  savings=$(( (output_len - compressed_len) * 100 / output_len ))
49
80
  printf '%s\n' "$input" | jq --arg summary "$summary" --arg savings "${savings}% reduced (${output_len}→${compressed_len} chars)" \
50
- '.tool_output = $summary | .["updatedMCPToolOutput"] = $summary'
81
+ '.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:
@@ -47,6 +52,9 @@
47
52
  # with no tool_result block) ends a turn.
48
53
  # * `thinking` blocks are interleaved with text/tool_use and never carry an R008 prefix;
49
54
  # they are filtered out before analysis.
55
+ # * NOTE (#1654, v1.1.63): narration 블록은 트랜스크립트에 type:"thinking"(signature 라벨
56
+ # narration)으로 직렬화되므로 아래 select(.type? != "thinking")이 함께 배제한다 — 의도된
57
+ # 동작: narration에는 R007/R008 마커가 실리지 않는다(실측 47블록 0건).
50
58
  #
51
59
  # ── R008 verdict: TURN-LEVEL COUNTING, not block adjacency (#1563 찐빠 #1) ─────────────
52
60
  # R008 (`.claude/rules/MUST-tool-identification.md`) says, verbatim:
@@ -134,15 +142,58 @@ if ! command -v jq >/dev/null 2>&1; then
134
142
  fi
135
143
 
136
144
  # ── 입력 필드 추출 (jq 1회 fork) ──
137
- meta=$(printf '%s' "$input" | jq -r '[(.session_id // ""), (.hook_event_name // ""), (.transcript_path // "")] | @tsv' 2>/dev/null) || exit 0
145
+ # stdin 가드: 비-JSON이면 jq가 실패해 `|| exit 0`으로 무음 종료하고, 유효 JSON이지만 객체가
146
+ # 아니면(배열/문자열/숫자) `select(type == "object")`가 걸러 빈 출력 → session_id 공백 → exit 0.
147
+ # 두 경로 모두 추가 fork 없이 기존 1회 fork 안에서 처리한다(PostToolUse는 매 도구 호출마다 발화).
148
+ 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
149
  session_id=$(printf '%s' "$meta" | cut -f1)
139
150
  hook_event_name=$(printf '%s' "$meta" | cut -f2)
140
151
  transcript_in=$(printf '%s' "$meta" | cut -f3)
152
+ agent_id=$(printf '%s' "$meta" | cut -f4)
141
153
 
142
154
  if [ -z "$session_id" ]; then
143
155
  exit 0
144
156
  fi
145
157
 
158
+ # ── 서브에이전트 세션이면 즉시 종료 (#1650 D) ────────────────────────────────────────────
159
+ # settings 훅은 서브에이전트 세션에서도 발화한다(서브에이전트의 매 도구 호출마다 PostToolUse).
160
+ # 그 발화가 남기는 advisory 피드백이 서브에이전트의 마지막 턴들을 잠식해 완료 보고를 대체하는
161
+ # 현상이 관측됐다(R020 8항 — 훅 피드백 잠식).
162
+ #
163
+ # 판별 신호 실측 (CC 2.1.259 바이너리의 훅 입력 zod 스키마 원문):
164
+ # agent_id: optional — "Subagent identifier. Present only when the hook fires from within a
165
+ # subagent (e.g., a tool called by an AgentTool worker). Absent for the main thread, even in
166
+ # --agent sessions. Use this field (not agent_type) to distinguish subagent calls from
167
+ # main-thread calls."
168
+ # 그리고 공통 페이로드 빌더가 모든 훅 이벤트에 `agent_id: o?.agentId`를 실어 보낸다.
169
+ # 즉 플랫폼이 명시적으로 지정한 판별 필드다. `agent_type`은 쓰지 않는다 — 같은 스키마가
170
+ # "--agent로 시작한 세션의 메인 스레드에도(agent_id 없이) 존재한다"고 못박고 있다.
171
+ #
172
+ # 다른 두 후보가 왜 작동하지 않는지(실측):
173
+ # * 트랜스크립트의 isSidechain / agentId 레코드 필드 — 서브에이전트 전용 파일
174
+ # agent-<hex>.jsonl의 전 레코드에 존재하지만(실측 65/65, 3/3), 공통 빌더는
175
+ # `transcript_path: Lp(e.id)`로 **세션 id**에서 경로를 만들고 서브에이전트는 부모의
176
+ # session id를 공유한다(실측: agent-*.jsonl의 sessionId == 부모 UUID). 따라서 서브에이전트
177
+ # 안에서 발화한 훅이 읽는 파일은 **부모의 메인 트랜스크립트**이고, 그 파일에는 isSidechain
178
+ # true도 agentId도 없다. 이것이 바로 잠식 경로다.
179
+ # * transcript_path의 `agent-*.jsonl` 경로 패턴 — 같은 이유로 그 경로가 오지 않는다
180
+ # (`agent_transcript_path`는 SubagentStop 페이로드에만 추가되는 별도 필드다).
181
+ #
182
+ # SubagentStart/SubagentStop은 예외다: 두 이벤트의 스키마는 agent_id를 **필수**로 두고 그 값은
183
+ # "대상 서브에이전트"의 식별자다(`Ae().and({hook_event_name:"SubagentStop", agent_id, ...})`).
184
+ # 즉 이 두 이벤트에서 agent_id는 "서브에이전트 안에서 발화했다"는 표지가 아니므로, 여기서
185
+ # 게이트를 걸면 #1545 자율 루프 커버리지가 통째로 사라진다.
186
+ case "$hook_event_name" in
187
+ SubagentStart|SubagentStop)
188
+ : # agent_id는 대상 식별자 — 판별 신호로 쓰지 않는다
189
+ ;;
190
+ *)
191
+ if [ -n "$agent_id" ]; then
192
+ exit 0
193
+ fi
194
+ ;;
195
+ esac
196
+
146
197
  # hook_event_name이 없으면 hookSpecificOutput.hookEventName을 정확히 채울 수 없다.
147
198
  # 잘못된 기본값은 출력을 무효화하므로 추측하지 않고 즉시 종료한다 (fallback 제거).
148
199
  if [ -z "$hook_event_name" ]; then
@@ -221,9 +272,31 @@ split("\n")
221
272
  else 1 end) as $r007
222
273
  | ([ $blocks[] | select(.type? == "text") | (.text? // "") ] | join("\n") | split("\n")) as $lines
223
274
  | ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Tool:")) ] | length) as $an_tool
275
+ # Core Rule 접두사 중 Agent 스폰을 가리키는 것만 따로 센다 — 아래 스폰 표기 중복 차감용.
276
+ | ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Tool: ?Agent")) ] | length) as $an_tool_agent
224
277
  | ([ $lines[] | select(test("^[[:space:]]*\\[[0-9]+\\][[:space:]].*(→|->|—>)")) ] | length) as $an_spawn_item
278
+ # 단일 스폰 표기 (#1650 E): R008 §"Agent Tool Format"이 규정하는 `subagent_type:model → desc`
279
+ # 라인. 같은 절의 Parallel Spawn Prefix Rule은 **단일 스폰을 `[N]` 접두사에서 명시적으로
280
+ # 면제**하므로, 번호 없는 이 형태가 단일 스폰의 정규 표기다. $an_spawn_item은 번호 형태만
281
+ # 세므로 이 라인은 announce에 0을 기여했고, Agent tool_use가 그대로 위반으로 계상됐다.
282
+ # 줄 시작 앵커(선행 공백만 허용)는 필수다 — `- mgr-x:sonnet → ...` 같은 리스트 산문이나
283
+ # 문장 중간 인용이 announce로 계상되면 forward 판정(max(0, ntools - announce))에서 실제
284
+ # 누락을 가려버린다.
285
+ | ([ $lines[] | select(test("^[[:space:]]*[a-z][a-z0-9-]*:(haiku|sonnet|opus|fable|inherit)[[:space:]]*(→|->|—>)")) ] | length) as $an_spawn_single
225
286
  | ([ $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
287
+ # 스폰 표기의 단위는 세분화된 것 우선: 번호 라인 > 단일 스폰 라인 > Spawning 헤더.
288
+ | (if $an_spawn_item > 0 then $an_spawn_item
289
+ elif $an_spawn_single > 0 then $an_spawn_single
290
+ else $an_spawn_hdr end) as $an_spawn
291
+ # 중복 계수 차감 (#1650 E): 같은 턴에 Core Rule `→ Tool: Agent` 접두사가 함께 있으면 스폰
292
+ # 표기 라인은 **같은 호출의 동반 라인**이다 — `→ Target:`이 `→ Tool:`의 동반 라인이라 세지
293
+ # 않는 것과 동일한 논리다. 더해버리면 한 호출이 2로 계상돼, 같은 턴의 다른 진짜 누락을
294
+ # 가린다(forward는 max(0, ntools - announce)라 과대 announce는 조용한 마스킹이 된다).
295
+ # 그래서 스폰 기여분에서 `→ Tool: Agent` 라인 수를 빼되 0에서 바닥을 친다 — 빼기만 하므로
296
+ # announce가 늘지 않아 신규 오탐은 생기지 않는다. 대신 구식의 과대 announce(접두사 +
297
+ # 항목 이중 계수)가 가리던 진짜 누락 — 예: 접두사 1 + [1] 항목 1 + tools=(Agent, Read)에서
298
+ # 미announce Read — 은 새로 발화한다(의도된 동작, v1.1.62 적대적 리뷰 재현 C1/C2).
299
+ | ($an_tool + (if $an_spawn > $an_tool_agent then $an_spawn - $an_tool_agent else 0 end)) as $announce
227
300
  | ([ $blocks[] | select((.type? == "tool_use") and ((.name? // "") != "Skill")) ] | length) as $ntools
228
301
  # 전체 tool_use(Skill 포함)가 0건인 턴은 forward 판정을 무조건 0으로 고정한다(#1625
229
302
  # 찐빠 #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,55 @@ 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). PostToolUse stdin is always an object; a
16
+ # bare-string stdin is rejected by design, not because it is known to be empty.
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 at v1.1.62 against this project's session transcripts: every
25
+ # PostToolUse record carried `tool_response` and none carried `tool_output`;
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
+ # Text-bearing fields, by tool. This hook's matcher is `Bash|Read|Grep`, so only
32
+ # the first two rows fire in production; the rest are defensive so a matcher
33
+ # widening does not silently reintroduce the blind spot above.
34
+ # Bash .tool_response.stdout / .stderr (measured, in matcher)
35
+ # Read .tool_response.file.content (measured, in matcher)
36
+ # Write .tool_response.content (measured, out of matcher)
37
+ # Agent .tool_response.prompt / .output (measured, out of matcher)
38
+ # MCP .tool_response (string)
39
+ # or .tool_response.content[].text (defensive — unmeasured;
40
+ # MCP corpus 0 records)
41
+ # `.tool_output.output` and a string-shaped `.tool_output` are kept as
42
+ # fallbacks for events that still use the older shape (e.g. SubagentStop).
43
+ #
44
+ # `?` suppresses jq path errors when a field is a scalar rather than an object;
45
+ # every branch is optional, so an unexpected shape degrades to "" instead of
46
+ # aborting the hook under `set -euo pipefail`.
47
+ output=$(printf '%s\n' "$input" | jq -r '
48
+ [
49
+ (.tool_response? | if type == "string" then . else empty end),
50
+ (.tool_response?.stdout? | strings),
51
+ (.tool_response?.stderr? | strings),
52
+ (.tool_response?.content? | strings),
53
+ (.tool_response?.content? | if type == "array"
54
+ then map(.text? | strings) | join("\n")
55
+ else empty end),
56
+ (.tool_response?.file?.content? | strings),
57
+ (.tool_response?.prompt? | strings),
58
+ (.tool_response?.output? | strings),
59
+ (.tool_output? | if type == "string" then . else empty end),
60
+ (.tool_output?.output? | strings)
61
+ ]
62
+ | map(select(. != "")) | join("\n")
63
+ ' 2>/dev/null) || output=""
18
64
 
19
65
  # Skip if no output
20
66
  if [ -z "$output" ] || [ "$output" = "null" ]; then
@@ -44,7 +90,7 @@ if echo "$output" | grep -qE 'ghp_[a-zA-Z0-9]{36}'; then
44
90
  fi
45
91
 
46
92
  # Private Key
47
- if echo "$output" | grep -qE '-----BEGIN.*PRIVATE KEY-----'; then
93
+ if echo "$output" | grep -qE -- '-----BEGIN.*PRIVATE KEY-----'; then
48
94
  echo "[Security] Potential private key detected in ${tool_name} output" >&2
49
95
  detected=true
50
96
  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,37 @@ _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="'"
210
+ # Same expression the extraction below used to hand to "sed -n -E", now fed
211
+ # to bash's own "=~" (see the delimiter scan for why it moved).
212
+ local delim_re="^.*[^<]<<-?[[:space:]]*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?.*\$"
207
213
  while IFS= read -r line; do
208
214
  if [ "$in_body" -eq 1 ]; then
209
215
  # "<<-" 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:]]*$//')"
216
+ # whitespace for either form. The trim is pure parameter expansion: as
217
+ # "printf | sed" it cost TWO forks on every BODY line, which is the
218
+ # dominant cost of a long heredoc (measured: a 1000-line body took
219
+ # 1.8 s of hook latency, #1650 A).
220
+ tok="${line#"${line%%[![:space:]]*}"}"
221
+ tok="${tok%"${tok##*[![:space:]]}"}"
212
222
  if [ "$tok" = "$delim" ]; then
213
223
  in_body=0
214
224
  fi
215
225
  continue
216
226
  fi
217
227
  out="${out}${line}"$'\n'
228
+ # A line with no "<<" can open no heredoc, so neither the forgery guard nor
229
+ # the delimiter scan below has anything to find on it. Skipping them keeps
230
+ # the extraction "sed" off every ordinary line, which used to run
231
+ # unconditionally — two more forks per line (#1650 A). "delim" must still be
232
+ # cleared here, or a delimiter captured on an EARLIER line would re-open a
233
+ # body on this one.
234
+ case "$line" in
235
+ *'<<'*) ;;
236
+ *) delim=""; continue ;;
237
+ esac
218
238
  # A DELIMITER FORGERY guard: the extraction below has no idea whether the
219
239
  # "<<" it finds is shell syntax or literal text inside a quoted string, so
220
240
  # `echo "a << EOF"` followed by `rm -rf x` and a line reading `EOF` used to
@@ -226,17 +246,28 @@ _strip_heredoc_bodies() {
226
246
  # as a write. "${line%<<*}" strips the shortest matching suffix, i.e. it
227
247
  # yields the text before the LAST "<<" — the same occurrence the greedy
228
248
  # 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
249
+ # The quote counts are parameter expansions rather than
250
+ # "printf | tr | wc | tr" (four more forks per opener line): "${v//[^X]/}"
251
+ # deletes everything that is not X, so the remaining LENGTH is the count.
252
+ before_op="${line%<<*}"
253
+ dq="${before_op//[^\"]/}"
254
+ sq="${before_op//[^$sq_char]/}"
255
+ if [ $(( ${#dq} % 2 )) -eq 1 ] || [ $(( ${#sq} % 2 )) -eq 1 ]; then
256
+ printf '%s' "${out}__QUOTED_HEREDOC_OPENER__"$'\n'
257
+ return 0
258
+ fi
259
+ # bash's "=~" runs the SAME POSIX ERE the "sed -n -E" here used to run —
260
+ # both engines are leftmost-longest, so the greedy "^.*" still settles on
261
+ # the LAST "<<" (verified against sed over 251 cases, #1656 E). Two forks
262
+ # per opener line are saved, but the reason it moved is correctness: sed
263
+ # aborts with "RE error: illegal byte sequence" on a line carrying invalid
264
+ # UTF-8, and under "set -o pipefail" that non-zero pipeline killed the
265
+ # whole hook. "=~" simply reports no match there.
266
+ if [[ "$line" =~ $delim_re ]]; then
267
+ delim="${BASH_REMATCH[1]}"
268
+ else
269
+ delim=""
237
270
  fi
238
- delim="$(printf '%s' "$line" \
239
- | sed -n -E "s/^.*[^<]<<-?[[:space:]]*[\"']?([A-Za-z_][A-Za-z0-9_]*)[\"']?.*\$/\\1/p")"
240
271
  if [ -n "$delim" ]; then
241
272
  in_body=1
242
273
  fi
@@ -274,7 +305,28 @@ is_readonly_bash_command() {
274
305
  return 0
275
306
  fi
276
307
 
277
- cmd="$(printf '%s' "$cmd" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
308
+ # sed applies "^" and "$" once PER LINE, so this trims every line of a
309
+ # multi-line command — it is NOT the whole-string parameter-expansion trim
310
+ # used in Step 6 and in _strip_heredoc_bodies, which is why it survived
311
+ # #1650 A. The loop below reproduces the per-line form exactly: each line is
312
+ # trimmed and re-terminated, then the trailing newlines are dropped the same
313
+ # way the "$( )" around the old pipeline dropped them. Two forks are saved,
314
+ # but the reason it changed (#1656 E) is correctness: sed aborts a multi-line
315
+ # command with "RE error: illegal byte sequence" at the first invalid UTF-8
316
+ # byte, and under "set -o pipefail" that non-zero pipeline killed the hook
317
+ # outright — so a heredoc body carrying raw bytes could stop the classifier
318
+ # before it ever reached a trailing "rm -rf". Parameter expansion has no
319
+ # locale dependency.
320
+ local trimmed="" tline
321
+ while IFS= read -r tline; do
322
+ tline="${tline#"${tline%%[![:space:]]*}"}"
323
+ tline="${tline%"${tline##*[![:space:]]}"}"
324
+ trimmed="${trimmed}${tline}"$'\n'
325
+ done <<< "$cmd"
326
+ while [ "${trimmed%$'\n'}" != "$trimmed" ]; do
327
+ trimmed="${trimmed%$'\n'}"
328
+ done
329
+ cmd="$trimmed"
278
330
  if [ -z "$cmd" ]; then
279
331
  echo "false"
280
332
  return 0
@@ -453,7 +505,12 @@ is_readonly_bash_command() {
453
505
  normalized="${normalized//|/$'\n'}"
454
506
  local seg
455
507
  while IFS= read -r seg; do
456
- seg="$(printf '%s' "$seg" | sed -e 's/^[[:space:]]*//' -e 's/[[:space:]]*$//')"
508
+ # Pure parameter expansion, not "printf | sed": each segment is a single
509
+ # line, so the two forms trim identically, but the sed form spent two
510
+ # forks on EVERY segment of a multi-line command (measured: 300
511
+ # newline-separated segments took 0.8 s of hook latency, #1650 A).
512
+ seg="${seg#"${seg%%[![:space:]]*}"}"
513
+ seg="${seg%"${seg##*[![:space:]]}"}"
457
514
  if [ -z "$seg" ]; then
458
515
  echo "false"
459
516
  return 0
@@ -472,6 +529,15 @@ is_readonly_bash_command() {
472
529
 
473
530
  input=$(cat)
474
531
 
532
+ # A hook must never crash the tool call it observes. stdin that is not a JSON
533
+ # OBJECT — empty, a bare scalar, a JSON array, truncated, or not JSON at all —
534
+ # carries no tool record, and the very first "jq -r" below died on it; with
535
+ # "set -euo pipefail" that took the WHOLE hook down with rc=5 (#1650 B).
536
+ # Swallow it instead: exit 0 with no stdout, exactly as if the hook had not
537
+ # run. "jq -e" exits non-zero both for a parse error and for a false/null
538
+ # result, so one guard covers every non-object shape.
539
+ printf '%s' "$input" | jq -e 'type == "object"' >/dev/null 2>&1 || exit 0
540
+
475
541
  # Extract tool info
476
542
  tool_name=$(printf '%s' "$input" | jq -r '.tool_name // "unknown"')
477
543
  # Bash commands are NOT files. Feeding tool_input.command into file_path made
@@ -5,34 +5,95 @@ set -euo pipefail
5
5
  command -v jq >/dev/null 2>&1 || exit 0
6
6
 
7
7
  # Task/Agent Outcome Recorder Hook
8
- # Trigger: PostToolUse (tool == "Task" || "Agent") and SubagentStop
9
- # Purpose: Record task outcomes for model escalation decisions
8
+ # Trigger: SubagentStop (settings.json wires this script to that event and no other)
9
+ # Purpose: Record agent outcomes for model escalation decisions
10
10
  # Protocol: stdin JSON -> process -> stdout pass-through, exit 0 always
11
+ #
12
+ # MEASURED SubagentStop payload (CC 2.1.259 embedded hook schema; #1656 B):
13
+ # session_id, transcript_path, cwd, prompt_id?, permission_mode?, effort?,
14
+ # hook_event_name:"SubagentStop", stop_hook_active, agent_id,
15
+ # agent_transcript_path, agent_type,
16
+ # last_assistant_message?, background_tasks?, session_crons?
17
+ #
18
+ # Note what the event does NOT carry: no `tool_input`, no `tool_output`, no
19
+ # `model`, no `description`, no `prompt`, and no error signal of any kind. The
20
+ # selectors below therefore read the top-level fields first and fall back to
21
+ # `last_assistant_message` — the one text-bearing field SubagentStop provides —
22
+ # for the description and skill-name extraction. The `tool_input`/`tool_output`
23
+ # branches are retained as fallbacks so the script stays correct if it is ever
24
+ # re-wired to PostToolUse.
25
+ #
26
+ # This project's transcript corpus (889 files) holds ZERO SubagentStop hook
27
+ # records — SubagentStop output is not attached to the parent transcript — so
28
+ # the shape above is schema-derived, not corpus-derived.
29
+ #
30
+ # Outcome caveat: SubagentStop exposes no failure signal, so entries recorded
31
+ # from that event are always outcome=success. There is no working hand-off for
32
+ # that gap today: SubagentStop payload carries no failure signal;
33
+ # subagent-failure-advisor.sh currently reads `.tool_output.is_error`, which is
34
+ # absent here, so failure detection on this path is 0 until #1656 G is resolved.
11
35
 
12
36
  input=$(cat)
13
37
 
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)
38
+ # Non-object stdin guard (#1650 B): a hook must never crash on garbage stdin.
39
+ # jq's parse error propagates through `set -euo pipefail` as rc=5 without this.
40
+ printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
41
+
42
+ # Extract agent info. Measured SubagentStop fields come first; the PostToolUse
43
+ # `tool_input.*` shape is kept as a fallback.
44
+ # `?` suppresses "Cannot index string with ..." when tool_input arrives as a scalar;
45
+ # `tostring` normalises a scalar (e.g. a numeric agent_type) instead of aborting.
46
+ agent_type=$(printf '%s\n' "$input" | jq -r \
47
+ '(.agent_type? // .tool_input?.subagent_type? // "unknown") | tostring' 2>/dev/null) \
48
+ || agent_type="unknown"
49
+ # SubagentStop carries no `model`; this resolves to "inherit" there by design.
50
+ model=$(printf '%s\n' "$input" | jq -r \
51
+ '(.model? // .tool_input?.model? // "inherit") | tostring' 2>/dev/null) || model="inherit"
52
+ # `strings` drops non-string shapes so an object-valued field degrades to "" rather
53
+ # than dumping raw JSON into the description.
54
+ description=$(printf '%s\n' "$input" | jq -r '
55
+ [ (.tool_input?.description? | strings),
56
+ (.description? | strings),
57
+ (.last_assistant_message? | strings) ]
58
+ | map(select(. != "")) | first // ""
59
+ ' 2>/dev/null | head -c 200) || description=""
18
60
 
19
61
  # Extract skill name from description or prompt
20
62
  skill_name=""
63
+ # `|| true`: a no-match grep exits 1 and `set -euo pipefail` would abort the hook
64
+ # (measured rc=1 on empty stdin AND on a well-formed object with no skill name).
21
65
  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)
66
+ 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
67
  fi
24
- # Fallback: check prompt field for "Skill: {name}" pattern
68
+ # Fallback: check the prompt / last assistant message for a "Skill: {name}" pattern.
69
+ # SubagentStop has no `prompt`, so `last_assistant_message` is the live source here.
25
70
  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)
71
+ prompt=$(printf '%s\n' "$input" | jq -r '
72
+ [ (.tool_input?.prompt? | strings),
73
+ (.last_assistant_message? | strings) ]
74
+ | map(select(. != "")) | first // ""
75
+ ' 2>/dev/null | head -c 500) || prompt=""
76
+ # POSIX character classes, not `\s`: BSD sed (macOS, this repo's runtime) does not
77
+ # implement the GNU `\s` escape, so `s/[Ss]kill:\s*//` stripped only the colon and
78
+ # left a leading space on every extracted skill name. Same family as the BSD `\?`
79
+ # gap recorded in R005. (#1656 B)
80
+ skill_name=$(echo "$prompt" | grep -oiE 'Skill:[[:space:]]*[a-z]+-[a-z]+(-[a-z]+)*' | sed 's/[Ss]kill:[[:space:]]*//' | head -1 || true)
28
81
  fi
29
82
 
30
- # Determine outcome
31
- is_error=$(printf '%s\n' "$input" | jq -r '.tool_output.is_error // false')
83
+ # Determine outcome. SubagentStop carries no error signal, so this is always false
84
+ # there; `.tool_response.is_error` is the measured PostToolUse field (`.tool_output`
85
+ # is the legacy shape) and both are kept for re-wiring safety.
86
+ is_error=$(printf '%s\n' "$input" | jq -r '
87
+ (.tool_response?.is_error? // .tool_output?.is_error? // false) | tostring
88
+ ' 2>/dev/null) || is_error="false"
32
89
 
33
90
  if [ "$is_error" = "true" ]; then
34
91
  outcome="failure"
35
- error_summary=$(printf '%s\n' "$input" | jq -r '.tool_output.output // ""' | head -c 200)
92
+ error_summary=$(printf '%s\n' "$input" | jq -r '
93
+ [ (.tool_response?.output? | strings),
94
+ (.tool_output?.output? | strings) ]
95
+ | map(select(. != "")) | first // ""
96
+ ' 2>/dev/null | head -c 200) || error_summary=""
36
97
  else
37
98
  outcome="success"
38
99
  error_summary=""
@@ -43,11 +104,29 @@ OUTCOME_FILE="/tmp/.claude-task-outcomes-${PPID}"
43
104
  TASK_COUNT_FILE="/tmp/.claude-task-count-${PPID}"
44
105
 
45
106
  # --- Pattern Detection ---
46
- # Priority: skill-specific patterns > parallel > sequential (default)
47
- pattern="sequential"
107
+ # Priority: skill-specific patterns > parallel > agent-count inference > default.
108
+ #
109
+ # INPUT SCOPE (#1656 D): the cascade below reads ONLY `tool_input.description` —
110
+ # the spawn argument the orchestrator wrote — never `$description`, which now
111
+ # falls back to `last_assistant_message`, i.e. free-form prose the subagent wrote
112
+ # about itself. A closing summary that merely says "ran the parallel review" or
113
+ # "orchestrator finished" would otherwise be recorded as
114
+ # pattern_used=parallel/orchestrator, fabricating a workflow shape out of English.
115
+ # SubagentStop carries no `tool_input`, so on that event this source is empty and
116
+ # the pattern degrades to the session-level agent-count signal (a real, non-prose
117
+ # signal) and then to "unknown" — an honest absence rather than a default
118
+ # "sequential" that reads as a measurement.
119
+ pattern_source=$(printf '%s\n' "$input" | jq -r '
120
+ (.tool_input?.description? | strings) // ""
121
+ ' 2>/dev/null | head -c 200) || pattern_source=""
122
+
123
+ if [ -n "$pattern_source" ]; then
124
+ pattern="sequential"
125
+ else
126
+ pattern="unknown"
127
+ fi
48
128
 
49
- # Check description for skill-specific workflow patterns
50
- desc_lower=$(echo "$description" | tr '[:upper:]' '[:lower:]')
129
+ desc_lower=$(printf '%s' "$pattern_source" | tr '[:upper:]' '[:lower:]')
51
130
 
52
131
  if echo "$desc_lower" | grep -qE '(evaluator.optimizer|evaluator_optimizer)'; then
53
132
  pattern="evaluator-optimizer"
@@ -80,9 +159,17 @@ if [ -f "$AGENT_START_FILE" ]; then
80
159
  fi
81
160
  fi
82
161
 
83
- # Append JSON line entry
162
+ # Append JSON line entry.
163
+ # `-c` (compact) is REQUIRED, not cosmetic: this file is consumed as JSONL by
164
+ # eval-core's outcome-parser (`content.split('\n')` + `JSON.parse(line)`) and by
165
+ # model-escalation-advisor.sh (`grep -c '"agent_type":"X".*"outcome":"failure"'`,
166
+ # `tail -N`), and the ring buffer below trims by `wc -l`. A pretty-printed entry
167
+ # spans 11 lines (measured: 9 fields plus the braces), so it broke every one of
168
+ # those readers and let `tail -50` slice
169
+ # an object in half. Sibling recorders (agent-start-recorder.sh, stuck-detector.sh)
170
+ # already use `jq -cn`. (#1656 B)
84
171
  timestamp=$(date -u +%Y-%m-%dT%H:%M:%SZ)
85
- entry=$(jq -n \
172
+ entry=$(jq -cn \
86
173
  --arg ts "$timestamp" \
87
174
  --arg agent "$agent_type" \
88
175
  --arg model "$model" \
@@ -388,6 +388,12 @@ This applies when a change touches a field that participates in an override/prec
388
388
  |--------------|----------|
389
389
  | Plan a provider/endpoint switch as N commands without reading the config's override chain | Read the full config schema (which field wins, defaults, inheritance) → enumerate EVERY field the switch touches (incl. base_url) → then plan |
390
390
 
391
+ **훅 스크립트 각도 — stdin 필드 형상은 실측 후 편집 (Origin: #1658 #1, v1.1.62)**: 훅 스크립트가 읽는 stdin 필드(`tool_input`/`tool_response`/`agent_id` 등)를 편집·가드·억제하기 전에 **실제 페이로드 형상을 실측**한다 — 트랜스크립트의 `attachment.type=="hook_success"` 레코드에서 `attachment.stdout`이 pass-through 훅이 되돌린 stdin 원문이며, CC 바이너리 내장 훅 문서로 교차검증한다. 처방("가드 추가·`?` 억제")만 위임하면 선택자 결함 위에 가드를 얹어 마지막 실패 신호까지 지운다. 실증: v1.1.62에서 `secret-filter.sh`가 PostToolUse에 존재하지 않는 `tool_output`(0/1764)을 읽어 실제 페이로드를 한 번도 스캔하지 않던 선재 결함 위에 `?` 억제가 추가됐고(rc=5 신호 소멸), 적대적 리뷰가 실측 형상 재현으로 FAIL 판정해 `tool_response`(1764/1764)로 교체했다. 훅 편집 위임서 표준 문안: "스크립트가 읽는 stdin 필드는 `hook_success` 레코드로 형상 실측 후 편집".
392
+
393
+ | Anti-pattern | Required |
394
+ |--------------|----------|
395
+ | 훅 위임서에 "가드 추가·`?` 억제" 처방만 전달 | 읽는 필드의 실제 형상(`hook_success` stdin 원문 + 바이너리 훅 문서) 실측을 위임서 완료 조건에 포함 |
396
+
391
397
  Sibling discipline to Read-Before-Characterize (that rule governs diagnosis — don't label before reading; this one governs edit-planning completeness — enumerate every interdependent field before editing). Cross-ref: R023 (verification ladder — config completeness is a Tier-1 deterministic pre-check).
392
398
 
393
399
  ### Degraded-Output Re-Verification Gate (529 / buffering)
@@ -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 |
@@ -390,6 +390,8 @@ Origin: #1595 #2 (v1.1.48 세션 — `git checkout -b release/v1.1.48 develop`
390
390
  | 파일 소유권만 고지하고 동일 검증 명령을 각 에이전트 완료 조건에 넣어 병렬 발주 | 검증 명령 공유를 고지하거나 검증을 오케스트레이터가 직렬 1회로 회수 |
391
391
  | 공유 `$TMPDIR`에 고정 경로로 임시 파일을 쓰고 그 디렉토리를 전수 계수 | 에이전트별 고유 경로 사용 + 그 경로만 계수 |
392
392
 
393
+ **순차 위임도 고지 대상 — 직전 완료 변경분의 출처 (Origin: #1658 #4)**: 병렬 형제뿐 아니라 **이미 워킹트리에 있는 미커밋 변경분의 출처**(직전에 완료한 에이전트와 그 담당 파일)를 위임서에 한 줄 고지한다. 고지가 없으면 후속 에이전트가 `git diff`에 보이는 타 변경분을 "형제 담당분"으로 오귀속해 서술한다(v1.1.62 세션 3건 — 행동에는 영향 없었으나 보고가 오염). 문안: "워킹트리의 미커밋 변경 중 X·Y는 직전 에이전트 [N]의 완료분이다 — 건드리지 말고 보고에서도 네 변경분과 구분하라."
394
+
393
395
  > Origin: #1518 (찐빠 #3 — 미고지 git 에이전트가 형제를 "외부 프로세스"로 오귀속; 같은 세션에서 고지한 4개 구현 에이전트는 전원 정확히 구분 보고 — 대조 실증). Cross-ref: R009 (병렬 실행 조건).
394
396
 
395
397
  > Origin 보강: #1598 — 파일이 완전 disjoint한 병렬 배치에서 위양성 4종 발생(judge.sh 테스트 7건 ENOENT: 두 테스트가 tracked `verdict-schema.json`을 cp→rm→복구 / reviewers.sh 타임아웃 테스트 간헐 실패: CPU 포화 / "임시 파일 누수 1건" 오측정: 형제 잔여물, 격리 셔임 재측정 시 0). **3종의 원인은 오케스트레이터가 위임서에 넣은 완료 조건 자체였다** — 형제 고지의 결함이 아니라 고지 항목의 누락이다.
@@ -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
@@ -125,6 +125,17 @@ Origin: #1595 #5 (v1.1.48 세션 — R008 위반 3건이 단일 턴에 집중. t
125
125
  > **v2.1.174+**: Fixed the Workflow tool's `agent()` subagents missing per-agent attribution headers. Workflow-spawned subagents now carry attribution consistent with R008 — when authoring Workflow scripts, each `agent()` call is attributed like a direct Agent tool spawn. Align Workflow orchestration with the R008 `[agent][model] → Tool:` identification discipline: a Workflow `agent()` fan-out should still be reasoned about with the same per-agent identification model as parallel Agent tool spawns.
126
126
  -->
127
127
 
128
+ ## announce와 헤더는 narration이 아니라 visible text 블록으로 (Origin: #1654)
129
+
130
+ 모델 출력에는 `text` 블록과 **narration 블록**(트랜스크립트에 `type:"thinking"` + signature 라벨 `narration`으로 직렬화되는 사용자향 짧은 산문)이 있고, 한 API 메시지에는 **둘 중 하나만** 실린다(v1.1.61~62 세션 실측: 115메시지 중 공존 0). 도구 호출 턴을 narration 요약 한 문장("…했습니다. 이제 …하겠습니다")으로 시작하면 R007 헤더와 R008 접두사는 **어디에도 남지 않는다** — 실측: narration 47블록에 R007 헤더 0건, 대괄호 번호 항목 0건, Tool 표기 0건(조사 문장 인용 제외). advisor는 `type != "thinking"` 필터로 narration을 배제하므로 이 턴들은 전부 누락으로 계상되며, 실제로 v1.1.61 세션 advisory 18건은 **전부 진양성**이었다(직렬화 유실 가설은 advisor가 메시지 직후에 판정했다는 사실로 배제됨).
131
+
132
+ | Anti-pattern | Required |
133
+ |--------------|----------|
134
+ | 도구 호출 턴을 짧은 요약 산문만으로 시작(narration 채널로 흐름) | 헤더(`┌─ Agent:` 또는 단축 헤더)와 Core Rule 접두사를 **text 블록**으로 명시 — 산문 요약은 그 뒤에 |
135
+ | "announce를 썼다"는 기억으로 advisory를 오탐으로 가정 | 트랜스크립트의 `text` 블록에서 마커를 실측(R020 Self-Violation Counting) |
136
+
137
+ Iteration 1(Agent 스폰 15메시지 전부 narration)과 Iteration 2(7메시지 text)의 대비는 계수 도구 결함이 아니라 출력 채널 선택의 차이였다. 채널 선택 요인은 미귀속이다.
138
+
128
139
  ## Tier-3 Interaction Tool Prefix (MANDATORY)
129
140
 
130
141
  R008 "every tool call" applies to Tier-3 interaction tools too — NOT only file/exec tools. Applying the Core Rule prefix form (에이전트·모델 대괄호 다음 화살표와 Tool 표기) to Agent/Bash/Read while omitting it on `AskUserQuestion`, `TodoWrite`, `EnterPlanMode`, etc. is a violation.
@@ -113,6 +113,8 @@ Origin: #1455 #1 (Session 127 회고 찐빠 #1) — cc-release-monitor PR #1449
113
113
  |--------------|----------|
114
114
  | "변경분 영향 범위"만 보고 검증 항목을 정해 위임 → CI 전용 잡(lint 등) 누락 | 워크플로 잡 목록을 하한선으로 삼아 로컬 대응 명령을 완료 조건에 열거 |
115
115
 
116
+ **상한선 — 오케스트레이터 사전 실측 항목은 재실행 금지 (Origin: #1655 제안 2)**: 하한선이 CI 잡 목록이라면 상한선은 "오케스트레이터가 이미 실측한 항목"이다. 검증 위임서에는 사전 실측 결과(테스트 pass/fail, lint·typecheck exit, 스크립트 exit, 미러 md5)를 **재실행 금지 목록**으로 열거하고 재실측 대상만 지정한다 — 서브에이전트가 전체 테스트를 재실행하면 턴 예산이 소진돼 판정 없이 절단된다. 실증: v1.1.61 세션 mgr-sauron 1차 위임(금지 목록 없음)은 `bun test` 재실행으로 25턴 절단, 2차 위임(금지 목록 + 15턴 내 판정 명시)은 16 tool_uses로 PASS 완주(R020 maxTurns 절단 누적 7건째). auto-dev.yaml deep-verify 스텝 description에 같은 문안이 배선돼 있다.
117
+
116
118
  Origin: #1574 (v1.1.44 세션 — 병렬 위임 3건 모두 `bun run lint`를 누락해 verify-build halt, 수정 에이전트 1회 추가 발주). 기존 `feedback_delegation_verify_scope_by_impact`("영향 범위 기준")의 하한선을 명문화한 것이다. Cross-reference: R020(완료 검증 — 선언 전 실제 게이트 통과 확인), R017(커밋 전 검증 게이트).
117
119
 
118
120
  ## Conditional-Output Verification — Positive/Negative Pair Mandate (Origin: #1563 #2)
@@ -131,6 +131,7 @@ Each iteration operates under full project rules — no relaxation because FSD i
131
131
  | R015 (intent persistence) | If user explicitly defers a specific PR this session, honor that deferral — do not retry it. |
132
132
  | R017 (sync verification) | mgr-sauron passes required before any commit. |
133
133
  | R020 (completion verification) | Each release verified via `npm view`, `gh release view`, closed issues before `[Done]`. PR merges verified via `gh pr view` ground-truth before declaring iteration complete. |
134
+ | cost-cap advisory (`cost-cap-advisor.sh`, Conversation Block) | FSD는 사용자가 명시 호출한 다중 릴리즈 루프라 세션 비용이 상한(기본 $5)을 필연적으로 넘는다. advisory 발화는 루프를 멈추는 신호가 아니며, 오케스트레이터는 발화 사실과 누적 비용을 반복 경계(homework 게이트)에서 사용자에게 고지한다. 상한을 올리려면 `CLAUDE_COST_CAP`(v1.1.61 세션 실측: 착수 직후 $5.38에서 발화). |
134
135
 
135
136
  `/homework` runs as a **retrospective gate** between iterations — findings go through `omcustom-feedback`'s Phase 4A confirmation gate. The loop does NOT skip homework on the grounds that it is "automated". If homework requires user confirmation (e.g., to file a feedback issue), the loop pauses and waits.
136
137
 
@@ -441,7 +441,7 @@ steps:
441
441
 
442
442
  - name: deep-verify
443
443
  skill: deep-verify
444
- description: "Multi-angle release quality verification — self-review checklist if docs-only; mgr-sauron R017 + core self-check if lite. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). lite 분할 표준(#1652 #3-4): (1) mgr-sauron R017 구조 검증 단일 목표 위임 1건 + (2) 변경 성격별 적대적 리뷰 단일 목표 위임 1건 — 스크립트 변경이면 실행 재현 기반 adversarial-review, 룰/스킬/yaml 텍스트 변경이면 문구 정합·배선 리뷰. 근거: v1.1.59/60 두 반복 연속 적대적 리뷰가 신규 회귀(M-3/M-4, heredoc 위조)를 실행 재현으로 포착."
444
+ description: "Multi-angle release quality verification — self-review checklist if docs-only; mgr-sauron R017 + core self-check if lite. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). lite 분할 표준(#1652 #3-4): (1) mgr-sauron R017 구조 검증 단일 목표 위임 1건 + (2) 변경 성격별 적대적 리뷰 단일 목표 위임 1건 — 스크립트 변경이면 실행 재현 기반 adversarial-review, 룰/스킬/yaml 텍스트 변경이면 문구 정합·배선 리뷰. 근거: v1.1.59/60 두 반복 연속 적대적 리뷰가 신규 회귀(M-3/M-4, heredoc 위조)를 실행 재현으로 포착. 검증 위임 표준 문안: 오케스트레이터가 이미 실측한 항목(bun test·lint·typecheck·template-sync·wiki-sync·validate-docs·미러 md5)은 위임서에 재실행 금지 목록으로 열거하고 재실측 대상만 지정한다 — v1.1.61 세션에서 금지 목록 없는 sauron 위임이 bun test를 재실행하다 25턴 절단됐고, 금지 목록을 명시한 재위임은 16 tool_uses로 완주했다(R020 maxTurns 절단 누적 7건째)."
445
445
  depends_on: verify-build
446
446
 
447
447
  - name: release
@@ -585,6 +585,10 @@ steps:
585
585
  - If failures: diagnose, fix, re-verify
586
586
  3. For npm projects with auto-tag.yml: MANDATORY additional check:
587
587
  gh run list --workflow auto-tag.yml --limit 1 --json conclusion,displayTitle
588
+ ⚠ auto-tag.yml run의 `headSha`는 **release 브랜치 head(PR head SHA)**이지 머지 커밋이 아니다 — 폴링 종료
589
+ 조건을 머지 커밋 SHA로 걸면 영원히 미일치해 타임아웃까지 전량 소진한다(v1.1.61 세션 실측: 40회/10분 전량
590
+ 소진, v1.1.62에서 PR head로 정정 → 22회 정상 종료). release.yml run은 `displayTitle`에 PR 번호/브랜치명이
591
+ 들어가므로 그것으로 식별한다.
588
592
  → conclusion MUST be "success" before declaring release complete.
589
593
  If auto-tag.yml concluded "failure": diagnose root cause. Do NOT retry with manual git tag.
590
594
  Common causes:
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.61",
2
+ "version": "1.1.63",
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)",
@@ -441,7 +441,7 @@ steps:
441
441
 
442
442
  - name: deep-verify
443
443
  skill: deep-verify
444
- description: "Multi-angle release quality verification — self-review checklist if docs-only; mgr-sauron R017 + core self-check if lite. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). lite 분할 표준(#1652 #3-4): (1) mgr-sauron R017 구조 검증 단일 목표 위임 1건 + (2) 변경 성격별 적대적 리뷰 단일 목표 위임 1건 — 스크립트 변경이면 실행 재현 기반 adversarial-review, 룰/스킬/yaml 텍스트 변경이면 문구 정합·배선 리뷰. 근거: v1.1.59/60 두 반복 연속 적대적 리뷰가 신규 회귀(M-3/M-4, heredoc 위조)를 실행 재현으로 포착."
444
+ description: "Multi-angle release quality verification — self-review checklist if docs-only; mgr-sauron R017 + core self-check if lite. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). lite 분할 표준(#1652 #3-4): (1) mgr-sauron R017 구조 검증 단일 목표 위임 1건 + (2) 변경 성격별 적대적 리뷰 단일 목표 위임 1건 — 스크립트 변경이면 실행 재현 기반 adversarial-review, 룰/스킬/yaml 텍스트 변경이면 문구 정합·배선 리뷰. 근거: v1.1.59/60 두 반복 연속 적대적 리뷰가 신규 회귀(M-3/M-4, heredoc 위조)를 실행 재현으로 포착. 검증 위임 표준 문안: 오케스트레이터가 이미 실측한 항목(bun test·lint·typecheck·template-sync·wiki-sync·validate-docs·미러 md5)은 위임서에 재실행 금지 목록으로 열거하고 재실측 대상만 지정한다 — v1.1.61 세션에서 금지 목록 없는 sauron 위임이 bun test를 재실행하다 25턴 절단됐고, 금지 목록을 명시한 재위임은 16 tool_uses로 완주했다(R020 maxTurns 절단 누적 7건째)."
445
445
  depends_on: verify-build
446
446
 
447
447
  - name: release
@@ -585,6 +585,10 @@ steps:
585
585
  - If failures: diagnose, fix, re-verify
586
586
  3. For npm projects with auto-tag.yml: MANDATORY additional check:
587
587
  gh run list --workflow auto-tag.yml --limit 1 --json conclusion,displayTitle
588
+ ⚠ auto-tag.yml run의 `headSha`는 **release 브랜치 head(PR head SHA)**이지 머지 커밋이 아니다 — 폴링 종료
589
+ 조건을 머지 커밋 SHA로 걸면 영원히 미일치해 타임아웃까지 전량 소진한다(v1.1.61 세션 실측: 40회/10분 전량
590
+ 소진, v1.1.62에서 PR head로 정정 → 22회 정상 종료). release.yml run은 `displayTitle`에 PR 번호/브랜치명이
591
+ 들어가므로 그것으로 식별한다.
588
592
  → conclusion MUST be "success" before declaring release complete.
589
593
  If auto-tag.yml concluded "failure": diagnose root cause. Do NOT retry with manual git tag.
590
594
  Common causes: