oh-my-customcode 1.1.42 → 1.1.44

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/hooks.json +33 -0
  5. package/templates/.claude/hooks/scripts/fail-axis-cause-advisor.sh +102 -0
  6. package/templates/.claude/hooks/scripts/failure-ledger.sh +75 -0
  7. package/templates/.claude/hooks/scripts/r007-r008-drift-advisor.sh +177 -93
  8. package/templates/.claude/hooks/scripts/session-reflection.sh +87 -70
  9. package/templates/.claude/rules/MAY-optimization.md +4 -2
  10. package/templates/.claude/rules/MUST-agent-design.md +15 -3
  11. package/templates/.claude/rules/MUST-agent-identification.md +13 -0
  12. package/templates/.claude/rules/MUST-agent-teams.md +8 -0
  13. package/templates/.claude/rules/MUST-completion-verification.md +13 -0
  14. package/templates/.claude/rules/MUST-continuous-improvement.md +12 -1
  15. package/templates/.claude/rules/MUST-enforcement-policy.md +9 -3
  16. package/templates/.claude/rules/MUST-orchestrator-coordination.md +35 -0
  17. package/templates/.claude/rules/MUST-parallel-execution.md +2 -0
  18. package/templates/.claude/rules/MUST-permissions.md +8 -1
  19. package/templates/.claude/rules/MUST-safety.md +5 -1
  20. package/templates/.claude/rules/SHOULD-ecomode.md +2 -0
  21. package/templates/.claude/rules/SHOULD-hud-statusline.md +1 -1
  22. package/templates/.claude/rules/SHOULD-memory-integration.md +10 -0
  23. package/templates/.claude/rules/SHOULD-verification-ladder.md +12 -0
  24. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +56 -0
  25. package/templates/manifest.json +1 -1
  26. package/templates/workflows/auto-dev.yaml +56 -0
package/dist/cli/index.js CHANGED
@@ -241,7 +241,7 @@ var init_package = __esm(() => {
241
241
  workspaces: [
242
242
  "packages/*"
243
243
  ],
244
- version: "1.1.42",
244
+ version: "1.1.44",
245
245
  description: "Batteries-included agent harness for Claude Code",
246
246
  type: "module",
247
247
  bin: {
package/dist/index.js CHANGED
@@ -2031,7 +2031,7 @@ var package_default = {
2031
2031
  workspaces: [
2032
2032
  "packages/*"
2033
2033
  ],
2034
- version: "1.1.42",
2034
+ version: "1.1.44",
2035
2035
  description: "Batteries-included agent harness for Claude Code",
2036
2036
  type: "module",
2037
2037
  bin: {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.1.42",
6
+ "version": "1.1.44",
7
7
  "description": "Batteries-included agent harness for Claude Code",
8
8
  "type": "module",
9
9
  "bin": {
@@ -227,6 +227,16 @@
227
227
  }
228
228
  ],
229
229
  "description": "Inject session auto-fix findings into first user prompt (#838)"
230
+ },
231
+ {
232
+ "matcher": "*",
233
+ "hooks": [
234
+ {
235
+ "type": "command",
236
+ "command": "bash .claude/hooks/scripts/fail-axis-cause-advisor.sh"
237
+ }
238
+ ],
239
+ "description": "Advisory on cause-free progress nudges when this session has logged tool failures \u2014 delivers via hookSpecificOutput.additionalContext (#1561)"
230
240
  }
231
241
  ],
232
242
  "SubagentStart": [
@@ -455,6 +465,17 @@
455
465
  ],
456
466
  "description": "Context budget advisor \u2014 track tool usage patterns and advise ecomode activation"
457
467
  },
468
+ {
469
+ "matcher": "tool == \"Edit\" || tool == \"Write\" || tool == \"Bash\" || tool == \"Task\" || tool == \"Agent\" || tool == \"Read\" || tool == \"Glob\" || tool == \"Grep\"",
470
+ "hooks": [
471
+ {
472
+ "type": "command",
473
+ "command": "bash .claude/hooks/scripts/r007-r008-drift-advisor.sh",
474
+ "continueOnBlock": true
475
+ }
476
+ ],
477
+ "description": "Proactive R007/R008 drift advisory on tool use \u2014 covers the orchestrator-only stretch before the first subagent spawn, where neither UserPromptSubmit nor SubagentStop fires (#1553). Turn-deduplicated; exit 0 advisory only."
478
+ },
458
479
  {
459
480
  "matcher": "tool == \"Edit\" || tool == \"Write\" || tool == \"Bash\" || tool == \"Task\" || tool == \"Agent\"",
460
481
  "hooks": [
@@ -610,6 +631,18 @@
610
631
  ],
611
632
  "description": "Print auto-dev token spend summary on session end (Issue #1057, advisory)"
612
633
  }
634
+ ],
635
+ "PostToolUseFailure": [
636
+ {
637
+ "matcher": "*",
638
+ "hooks": [
639
+ {
640
+ "type": "command",
641
+ "command": "bash .claude/hooks/scripts/failure-ledger.sh"
642
+ }
643
+ ],
644
+ "description": "FAIL-axis instrumentation \u2014 append tool failures to the error ledger (JSONL). Never blocks; feeds fail-axis-cause-advisor.sh (#1561)"
645
+ }
613
646
  ]
614
647
  }
615
648
  }
@@ -0,0 +1,102 @@
1
+ #!/usr/bin/env bash
2
+ # fail-axis-cause-advisor.sh — UserPromptSubmit: 원인 없는 재촉 발화 감지 (FAIL 축)
3
+ #
4
+ # 배경:
5
+ # 8주 세션 실측에서 사용자 발화 216턴 중 원인 분석 표현이 0건(error_cause_ratio = 0.000)이었다.
6
+ # 반면 "계속해"(14) / "ㄱㄱ"(5) / "계속 진행해"(5) 등 원인 진술 없는 진행 지시가 24건,
7
+ # 전체 발화의 11%를 차지했다. 도구 실패가 세션당 1.72건 발생하는데도 원인을 묻지 않고
8
+ # 재촉으로 통과시키는 패턴이다.
9
+ #
10
+ # 역할:
11
+ # (1) 짧은 진행 지시이고 (2) 원인 언급이 없으며 (3) 이 세션에 기록된 도구 실패가 있을 때,
12
+ # Claude에게 "진행 전에 사용자에게 원인 가설 한 줄을 되물어라"는 advisory를 전달한다.
13
+ #
14
+ # 왜 사용자가 아니라 Claude에게 전달하는가:
15
+ # hookSpecificOutput.additionalContext는 모델 컨텍스트로만 들어간다(사용자에게 표시되지 않음).
16
+ # 따라서 Claude가 사용자에게 되묻게 만들고, 사용자의 답변이 대화 로그에 사용자 발화로
17
+ # 남게 하는 우회 경로를 택한다. 이렇게 해야 실제 진단 행동과 계측 지표가 함께 개선된다.
18
+ #
19
+ # 왜 차단하지 않는가:
20
+ # decision:"block"을 쓰면 프롬프트 자체가 거부되어 자율 루프(/fsd)가 멈춘다.
21
+ # R021 advisory-first 원칙에 따라 절대 차단하지 않고 exit 0을 유지한다.
22
+ #
23
+ # 의존:
24
+ # failure-ledger.sh(PostToolUseFailure)가 기록한 원장을 발동 조건으로 읽는다.
25
+ # 원장이 없으면 조용히 통과한다 — 훅 단독으로도 안전하게 동작한다.
26
+ #
27
+ # 환경변수 override:
28
+ # OMCUSTOM_FAIL_ADVISOR=off — advisory 완전 비활성화
29
+ # OMCUSTOM_ERROR_LEDGER=<path> — 원장 경로 override
30
+
31
+ set -euo pipefail
32
+
33
+ input=$(cat)
34
+
35
+ # ── Opt-out 체크 ──
36
+ if [ "${OMCUSTOM_FAIL_ADVISOR:-}" = "off" ]; then
37
+ exit 0
38
+ fi
39
+
40
+ if ! command -v jq >/dev/null 2>&1; then
41
+ exit 0
42
+ fi
43
+
44
+ prompt=$(printf '%s' "$input" | jq -r '.prompt // empty' 2>/dev/null) || exit 0
45
+ session=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null) || exit 0
46
+
47
+ if [ -z "$prompt" ] || [ -z "$session" ]; then
48
+ exit 0
49
+ fi
50
+
51
+ # ── 조건 1: 짧은 발화만 대상 (긴 발화는 이미 맥락을 담고 있다고 본다) ──
52
+ # 실측 p75가 27자이므로 40자를 상한으로 둔다.
53
+ if [ "${#prompt}" -gt 40 ]; then
54
+ exit 0
55
+ fi
56
+
57
+ # ── 조건 2: 진행/재촉 패턴인가 ──
58
+ if ! printf '%s' "$prompt" \
59
+ | grep -qiE '(계속|이어서|진행해|재개|다음|ㄱㄱ|고고|가자|continue|keep going|go on|resume|proceed|next)'; then
60
+ exit 0
61
+ fi
62
+
63
+ # ── 조건 3: 이미 원인/이유를 언급했다면 개입하지 않는다 (오탐 방지) ──
64
+ if printf '%s' "$prompt" \
65
+ | grep -qiE '(원인|이유|왜|때문|에러|오류|error|fail|because|cause)'; then
66
+ exit 0
67
+ fi
68
+
69
+ # ── 조건 4: 이 세션에 기록된 도구 실패가 있는가 ──
70
+ LEDGER="${OMCUSTOM_ERROR_LEDGER:-${HOME}/.claude/error-ledger.jsonl}"
71
+ if [ ! -f "$LEDGER" ]; then
72
+ exit 0
73
+ fi
74
+
75
+ # 원장 꼬리만 스캔한다 (전체 파일 스캔 회피).
76
+ # interrupt == true 는 사용자가 직접 중단시킨 것이므로 진단 대상 실패가 아니다 — 제외한다.
77
+ # (제외하지 않으면 사용자가 스스로 끊은 도구까지 "원인을 대라"고 되묻는 오탐이 된다.)
78
+ fail_count=$(tail -n 300 "$LEDGER" 2>/dev/null \
79
+ | jq -r --arg s "$session" 'select(.session == $s and .interrupt != true) | .tool' 2>/dev/null \
80
+ | wc -l | tr -d ' ') || fail_count=0
81
+
82
+ if [ -z "$fail_count" ] || [ "$fail_count" -eq 0 ] 2>/dev/null; then
83
+ exit 0
84
+ fi
85
+
86
+ # 최근 실패 도구 요약 (최대 3종)
87
+ fail_tools=$(tail -n 300 "$LEDGER" 2>/dev/null \
88
+ | jq -r --arg s "$session" 'select(.session == $s and .interrupt != true) | .tool' 2>/dev/null \
89
+ | sort | uniq -c | sort -rn | head -3 \
90
+ | awk '{printf "%s(%s) ", $2, $1}') || fail_tools=""
91
+
92
+ advisory_text=$(printf '[FAIL Advisory] 이 세션에 도구 실패 %s건이 기록되어 있습니다 (%s). 방금 입력은 원인 언급이 없는 진행 지시입니다. 곧바로 재시도하지 말고, 먼저 직전 실패의 원인 가설을 한 줄로 제시한 뒤 사용자에게 "이 진단이 맞는지 / 다른 원인이 짚이는지" 짧게 한 번만 확인하십시오. 사용자가 답하면 그대로 진행합니다. 이 확인은 한 턴을 넘기지 마십시오.' \
93
+ "$fail_count" "${fail_tools:-unknown}")
94
+
95
+ # 사람이 보는 감사 추적용 (exit 0에서 stderr는 모델에 전달되지 않음)
96
+ printf '%s\n' "$advisory_text" >&2
97
+
98
+ # 실제 전달 경로: additionalContext. decision 필드는 절대 포함하지 않는다.
99
+ jq -cn --arg ctx "$advisory_text" \
100
+ '{hookSpecificOutput: {hookEventName: "UserPromptSubmit", additionalContext: $ctx}}'
101
+
102
+ exit 0
@@ -0,0 +1,75 @@
1
+ #!/usr/bin/env bash
2
+ # failure-ledger.sh — PostToolUseFailure 에러 원장 기록 (FAIL 축 계측)
3
+ #
4
+ # 배경:
5
+ # hooks.json은 성공 경로(PreToolUse 12 / PostToolUse 16 / Stop 7)에는 촘촘히 배선되어
6
+ # 있으나 실패 경로에는 훅이 하나도 없었다. 그 결과 도구 실패가 세션당 평균 1.72건
7
+ # 발생함에도 아무 데이터도 남지 않아, R023 검증 래더의 상위 tier(에러 패턴 인식,
8
+ # post-mortem)를 측정할 근거 자체가 없는 상태였다.
9
+ #
10
+ # 역할:
11
+ # 도구 호출 실패 시 한 줄 JSONL을 원장에 append 한다. 이 원장은
12
+ # (1) recovery 성공률 산출, (2) 반복 실패 도구/명령 식별,
13
+ # (3) fail-axis-cause-advisor.sh(UserPromptSubmit)의 발동 조건으로 쓰인다.
14
+ #
15
+ # 설계 원칙:
16
+ # - 순수 append-only. 기존 파일을 읽거나 수정하지 않는다.
17
+ # - 네트워크 호출 없음. 외부 명령은 jq/date만 사용.
18
+ # - 어떤 실패에도 exit 0 — 원장 기록 실패가 본 작업을 막아서는 안 된다 (R021 advisory-first).
19
+ # - 명령/에러 문자열은 절단하여 기록한다 (원장 비대화 방지).
20
+ #
21
+ # 환경변수 override:
22
+ # OMCUSTOM_FAILURE_LEDGER=off — 기록 완전 비활성화
23
+ # OMCUSTOM_ERROR_LEDGER=<path> — 원장 경로 override (기본: ~/.claude/error-ledger.jsonl)
24
+
25
+ set -euo pipefail
26
+
27
+ input=$(cat)
28
+
29
+ # ── Opt-out 체크 ──
30
+ if [ "${OMCUSTOM_FAILURE_LEDGER:-}" = "off" ]; then
31
+ exit 0
32
+ fi
33
+
34
+ # ── jq 의존성 체크 (없으면 조용히 통과) ──
35
+ if ! command -v jq >/dev/null 2>&1; then
36
+ exit 0
37
+ fi
38
+
39
+ LEDGER="${OMCUSTOM_ERROR_LEDGER:-${HOME}/.claude/error-ledger.jsonl}"
40
+
41
+ if ! mkdir -p "$(dirname "$LEDGER")" 2>/dev/null; then
42
+ exit 0
43
+ fi
44
+
45
+ ts=$(date -u +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")
46
+
47
+ # ── 한 줄 JSONL append ──
48
+ # 에러 필드 위치 (공식 문서 실측, code.claude.com/docs/en/hooks "PostToolUseFailure input"):
49
+ # PostToolUseFailure는 PostToolUse와 달리 tool_response를 보내지 않는다. 에러는
50
+ # **최상위 `error`** 문자열로 오고, 부수적으로 `is_interrupt` / `duration_ms`가 따라온다.
51
+ # 초판이 `.tool_response.error`를 읽어 err가 항상 빈 문자열이 되던 결함을 교정한 것이다
52
+ # (r007-r008-drift-advisor.sh의 `.role` vs `.message.role`과 동일 계열).
53
+ # .tool_error / .tool_response.* fallback은 스키마 변화에 대한 방어로만 남긴다 —
54
+ # tool_response가 문자열인 경우 인덱싱 에러로 레코드가 통째로 유실되므로 type 검사로 감싼다.
55
+ #
56
+ # 단일 라인(<1KB) append 이므로 O_APPEND 원자성에 기대어 병렬 에이전트 환경에서도 안전.
57
+ printf '%s' "$input" \
58
+ | jq -c --arg ts "$ts" --arg cwd "$PWD" '
59
+ {
60
+ ts: $ts,
61
+ session: (.session_id // ""),
62
+ cwd: $cwd,
63
+ tool: (.tool_name // "unknown"),
64
+ target: ((.tool_input.command // .tool_input.file_path // "") | tostring | .[0:160]),
65
+ interrupt: (.is_interrupt == true),
66
+ err: ((.error
67
+ // .tool_error
68
+ // (if (.tool_response | type) == "object"
69
+ then (.tool_response.error // .tool_response.stderr)
70
+ else .tool_response end)
71
+ // "")
72
+ | tostring | gsub("\\s+"; " ") | .[0:320])
73
+ }' >> "$LEDGER" 2>/dev/null || true
74
+
75
+ exit 0
@@ -1,47 +1,111 @@
1
1
  #!/usr/bin/env bash
2
- # r007-r008-drift-advisor.sh — PROACTIVE R007/R008 drift advisory (#1229, #1545, #1547)
2
+ # r007-r008-drift-advisor.sh — PROACTIVE R007/R008 drift advisory (#1229, #1545, #1547, #1553)
3
3
  #
4
- # Wired to TWO trigger points:
4
+ # Wired to THREE trigger points:
5
5
  # 1. UserPromptSubmit — fires before Claude responds to a user-typed prompt (#1229).
6
6
  # 2. SubagentStop — fires when a background subagent (Agent tool) completes, covering
7
7
  # autonomous-loop re-entry (e.g. /fsd) where the orchestrator resumes WITHOUT a
8
8
  # UserPromptSubmit event. Prior to #1545, autonomous-loop re-entry had zero R007/R008
9
9
  # advisory coverage since UserPromptSubmit never fires in that path.
10
+ # 3. PostToolUse — fires after every tool call, covering the remaining structural gap
11
+ # (#1553): an orchestrator-only stretch BEFORE the first subagent spawn satisfies
12
+ # neither UserPromptSubmit (no user input) nor SubagentStop (no subagent yet).
13
+ # Measured 2026-08-05: 7/8 responses in such a stretch had missing R007 headers with
14
+ # zero advisory fires.
10
15
  #
11
- # Inspects the LAST completed assistant turn in the session transcript for R007/R008
12
- # compliance BEFORE Claude responds. If the previous turn drifted (missing identification
13
- # header / tool prefix), delivers an advisory so the upcoming response self-corrects.
16
+ # Inspects the LAST completed assistant TURN in the session transcript for R007/R008
17
+ # compliance. If the turn drifted (missing identification header / tool prefix), delivers
18
+ # an advisory so the next response self-corrects.
14
19
  #
15
20
  # This is the PROACTIVE complement to the retroactive session-reflection.sh (Stop hook, #1190).
16
- # Detection patterns are reused from session-reflection.sh.
21
+ # Detection patterns are shared with session-reflection.sh.
17
22
  #
18
23
  # Advisory-only: ALWAYS exits 0, NEVER blocks.
19
- # Performance: parses ONLY the last assistant turn (not the whole transcript).
20
- # Input-schema note: session_id/transcript_path are COMMON fields present on both
21
- # UserPromptSubmit and SubagentStop hook payloads, so the detection logic below is
22
- # event-agnostic and required no functional changes for the SubagentStop wiring.
23
24
  #
24
- # Delivery mechanism (#1547 fix):
25
- # Prior to this fix, the advisory was written to stderr with exit 0. Per the official
26
- # Claude Code hook spec, stderr on exit 0 is NEVER fed into the model's context for ANY
27
- # hook event (it is only visible in transcript debug mode, i.e. to a human, not Claude) —
28
- # so #1545's SubagentStop wiring never actually reached the model despite firing correctly.
29
- # The confirmed non-blocking delivery path for BOTH UserPromptSubmit and SubagentStop is
30
- # `hookSpecificOutput.additionalContext` in JSON stdout with exit 0:
25
+ # ── Transcript schema (MEASURED 2026-08-05, #1553) ────────────────────────────────────
26
+ # Claude Code JSONL lines do NOT carry a TOP-LEVEL `role`/`content`. The measured top-level
27
+ # key set is:
28
+ # attributionSkill, cwd, effort, entrypoint, gitBranch, isSidechain, message, parentUuid,
29
+ # requestId, sessionId, session_id, timestamp, type, userType, uuid, version
30
+ # The role lives at `.message.role` and the content blocks at `.message.content`.
31
+ # The previous implementation selected `.role` / `.content`, which ALWAYS evaluated to empty
32
+ # — so this advisor exited before ever reaching its detection logic and had NEVER fired
33
+ # (verified: 0 occurrences of `"additionalContext":` across 771 transcripts; a live probe
34
+ # produced 0 bytes on both stdout and stderr).
35
+ #
36
+ # Additional measured facts that shape this implementation:
37
+ # * ONE content block per JSONL line (867 assistant lines across 3 sessions, 0 multi-block).
38
+ # A single assistant TURN therefore spans MULTIPLE consecutive lines. Treating "the last
39
+ # assistant line" as "the last turn" makes the R008 adjacency test (`i > 0`) permanently
40
+ # false — every tool call would be reported as a violation. Turn reconstruction is a
41
+ # PRECONDITION for the PostToolUse wiring, not an optimization.
42
+ # * `isSidechain: true` marks subagent turns. They MUST be excluded or a subagent's turn
43
+ # is misattributed to the orchestrator. (Note: jq's `//` treats `false` as empty, so the
44
+ # filter is written as `(.isSidechain // false) != true`.)
45
+ # * User lines are NOT all turn boundaries — tool results arrive as `.message.role == "user"`
46
+ # with `tool_result` content blocks. Only a genuine prompt (string content, or an array
47
+ # with no tool_result block) ends a turn.
48
+ # * `thinking` blocks are interleaved with text/tool_use and never carry an R008 prefix;
49
+ # they are filtered out before analysis.
50
+ #
51
+ # ── R008 verdict: TURN-LEVEL COUNTING, not block adjacency (#1563 찐빠 #1) ─────────────
52
+ # R008 (`.claude/rules/MUST-tool-identification.md`) says, verbatim:
53
+ # "For parallel calls: list ALL identifications BEFORE the tool calls."
54
+ # The rule therefore requires the announce lines of a parallel BATCH to be grouped ahead of
55
+ # the batch — it does NOT require a text block wedged immediately before every single
56
+ # tool_use. The previous implementation tested block ADJACENCY (`$blocks[i-1]` is a text
57
+ # block matching the prefix), so in a compliant parallel batch `[text, tool_use, tool_use]`
58
+ # every tool_use after the first had a `tool_use` predecessor and was counted as a violation
59
+ # — R009 MANDATES those batches, so the advisor fired against rule-compliant behavior
60
+ # (measured: 212 bytes on a compliant live turn, 348 on a synthetic fixture; both must be 0).
61
+ #
62
+ # The verdict is now a per-turn count comparison:
63
+ # violations = max(0, tool_use_blocks − announce_lines)
64
+ #
65
+ # Announce lines counted (over ALL text blocks of the turn, split into lines):
66
+ # * `[agent][model] → Tool: X` — the Core Rule form; ONE per tool call.
67
+ # * `→ Target:` is NOT counted. It is the COMPANION line of `→ Tool:` (Core Rule prints the
68
+ # pair), so counting it would score 2 per tool and silently mask real omissions.
69
+ # * Spawn notation from R008 §"Parallel Spawn Prefix Rule", which documents parallel Agent
70
+ # calls as a `[agent][model] → Spawning:` header followed by one indented
71
+ # `[N] subagent_type:model → description` line per agent — with NO `→ Tool: Agent` line.
72
+ # The per-agent unit is the numbered line, so those are counted when present; the bare
73
+ # `→ Spawning:` header counts only when no numbered line exists (single-agent spawn, which
74
+ # R008 explicitly exempts from the `[N]` prefix). Excluding this notation would recreate
75
+ # exactly the false positive this fix removes (N Agent tool_use blocks, 0 `Tool:` lines).
76
+ #
77
+ # R007 detection (first line of the turn's first text block) is unchanged.
78
+ #
79
+ # ── Performance ───────────────────────────────────────────────────────────────────────
80
+ # The previous implementation forked jq once PER LINE inside a `while read` loop (measured
81
+ # 2.80s and 8.64s on real transcripts). PostToolUse fires on EVERY tool call, so that cost
82
+ # would stall the session. This version reads a bounded `tail -n 200` window and forks jq
83
+ # exactly ONCE for the whole analysis.
84
+ #
85
+ # ── Dedup ─────────────────────────────────────────────────────────────────────────────
86
+ # PostToolUse fires repeatedly within a single turn. The turn's first assistant line `uuid`
87
+ # is recorded in a per-session marker file; the same turn never produces a second advisory.
88
+ # The marker is derived purely from transcript content (no `date`, no randomness) so the
89
+ # behavior is idempotent and testable.
90
+ #
91
+ # ── Delivery mechanism (#1547 fix) ────────────────────────────────────────────────────
92
+ # Per the official Claude Code hook spec, stderr on exit 0 is NEVER fed into the model's
93
+ # context for ANY hook event (it is only visible in transcript debug mode, i.e. to a human).
94
+ # The confirmed non-blocking delivery path is `hookSpecificOutput.additionalContext` in
95
+ # JSON stdout with exit 0:
31
96
  # {"hookSpecificOutput": {"hookEventName": "<event>", "additionalContext": "<text>"}}
97
+ # `hookEventName` MUST echo the ACTUAL firing event — a wrong value invalidates the output,
98
+ # so a missing `hook_event_name` field is a hard `exit 0` (no default is guessed).
32
99
  # This is NOT the same as `"decision": "block"` — that would force Stop/SubagentStop to
33
- # block (refuse to stop), which is exactly the blocking behavior R021 (advisory-first
34
- # enforcement) forbids for this hook. additionalContext alone (no `decision` field) is
35
- # non-blocking: Claude is allowed to stop/continue normally and simply sees the extra
36
- # context on its next turn. Exit code MUST stay 0 — exit 2 causes Claude Code to discard
37
- # any JSON output and treat stderr as a blocking error instead (see Common JSON Fields /
38
- # Exit Code Behavior in the official hook reference).
39
- # The stderr line is kept for human-visible audit trail (harmless on exit 0) but is no
40
- # longer the delivery mechanism.
100
+ # block, exactly the behavior R021 (advisory-first enforcement) forbids here. Exit code MUST
101
+ # stay 0 — exit 2 causes Claude Code to discard JSON output and treat stderr as a blocking
102
+ # error instead.
103
+ # The stderr line is kept purely as a human-visible audit trail.
41
104
  #
42
105
  # 환경변수 override (테스트/디버깅용):
43
106
  # OMCUSTOM_R007_ADVISOR=off — advisory 완전 비활성화 (pass-through)
44
- # OMCUSTOM_TRANSCRIPT_BASE — transcript 디렉토리 경로 override
107
+ # OMCUSTOM_TRANSCRIPT_BASE — transcript 디렉토리 경로 override (설정 시 최우선)
108
+ # OMCUSTOM_R007_MARKER_DIR — dedup 마커 디렉토리 override (기본 ${TMPDIR:-/tmp})
45
109
 
46
110
  set -euo pipefail
47
111
 
@@ -58,98 +122,118 @@ if ! command -v jq >/dev/null 2>&1; then
58
122
  exit 0
59
123
  fi
60
124
 
61
- # ── session_id 추출 ──
62
- session_id=$(echo "$input" | jq -r '.session_id // empty' 2>/dev/null)
125
+ # ── 입력 필드 추출 (jq 1회 fork) ──
126
+ meta=$(printf '%s' "$input" | jq -r '[(.session_id // ""), (.hook_event_name // ""), (.transcript_path // "")] | @tsv' 2>/dev/null) || exit 0
127
+ session_id=$(printf '%s' "$meta" | cut -f1)
128
+ hook_event_name=$(printf '%s' "$meta" | cut -f2)
129
+ transcript_in=$(printf '%s' "$meta" | cut -f3)
130
+
63
131
  if [ -z "$session_id" ]; then
64
132
  exit 0
65
133
  fi
66
134
 
67
- # ── hook_event_name 추출 (hookSpecificOutput.hookEventName에 되돌려줄 값) ──
68
- hook_event_name=$(echo "$input" | jq -r '.hook_event_name // empty' 2>/dev/null)
135
+ # hook_event_name이 없으면 hookSpecificOutput.hookEventName을 정확히 채울 수 없다.
136
+ # 잘못된 기본값은 출력을 무효화하므로 추측하지 않고 즉시 종료한다 (fallback 제거).
69
137
  if [ -z "$hook_event_name" ]; then
70
- hook_event_name="UserPromptSubmit"
138
+ exit 0
71
139
  fi
72
140
 
73
- # ── 경로 결정 (환경변수 override 지원) ──
74
- TRANSCRIPT_BASE="${OMCUSTOM_TRANSCRIPT_BASE:-${HOME}/.claude/projects/-Users-sangyi-workspace-projects-oh-my-customcode}"
75
- TRANSCRIPT_PATH="${TRANSCRIPT_BASE}/${session_id}.jsonl"
141
+ # ── transcript 경로 결정 ──
142
+ # 우선순위: 테스트 override > 훅 페이로드의 transcript_path > 기본 경로
143
+ if [ -n "${OMCUSTOM_TRANSCRIPT_BASE:-}" ]; then
144
+ TRANSCRIPT_PATH="${OMCUSTOM_TRANSCRIPT_BASE}/${session_id}.jsonl"
145
+ elif [ -n "$transcript_in" ]; then
146
+ TRANSCRIPT_PATH="$transcript_in"
147
+ else
148
+ TRANSCRIPT_PATH="${HOME}/.claude/projects/-Users-sangyi-workspace-projects-oh-my-customcode/${session_id}.jsonl"
149
+ fi
76
150
 
77
151
  if [ ! -f "$TRANSCRIPT_PATH" ]; then
78
152
  exit 0
79
153
  fi
80
154
 
81
- # ── 마지막 assistant 메시지 추출 (성능: 전체 transcript 스캔 회피) ──
82
- # 파일을 역순으로 읽으며 첫 번째 role=="assistant" 라인을 찾는다.
83
- last_assistant=""
84
- while IFS= read -r line; do
85
- role=$(echo "$line" | jq -r '.role // empty' 2>/dev/null) || continue
86
- if [ "$role" = "assistant" ]; then
87
- last_assistant="$line"
88
- break
89
- fi
90
- done < <(tail -r "$TRANSCRIPT_PATH" 2>/dev/null || tac "$TRANSCRIPT_PATH" 2>/dev/null)
91
-
92
- if [ -z "$last_assistant" ]; then
155
+ # ── 마지막 assistant 턴 재구성 + R007/R008 판정 (jq 1회 fork) ──
156
+ # 출력: "<turn-uuid>\t<r007-count>\t<r008-count>" (턴이 없으면 무출력)
157
+ JQ_LAST_TURN='
158
+ split("\n")
159
+ | map(select(length > 0) | (fromjson? // empty))
160
+ | map(select((.isSidechain // false) != true))
161
+ | . as $L
162
+ | ($L | length) as $n
163
+ | [ range(0; $n) | select($L[.].message.role? == "assistant") ] as $ai
164
+ | if ($ai | length) == 0 then empty
165
+ else
166
+ ($ai[-1]) as $last
167
+ | [ range(0; $last + 1)
168
+ | select( ($L[.].message.role? == "user")
169
+ and ( (($L[.].message.content | type) == "string")
170
+ or (([ $L[.].message.content[]? | select(.type? == "tool_result") ] | length) == 0) ) ) ] as $bi
171
+ | (if ($bi | length) > 0 then $bi[-1] else -1 end) as $b
172
+ | [ range($b + 1; $last + 1) | $L[.] | select(.message.role? == "assistant") ] as $turn
173
+ | [ $turn[] | .message.content[]? | select(.type? != "thinking") ] as $blocks
174
+ | ($turn[0].uuid? // "") as $tuuid
175
+ | ([ $blocks[] | select(.type? == "text") ][0].text? // "") as $ftext
176
+ | (($ftext | split("\n") | .[0]) // "") as $fline
177
+ | (if ($ftext | length) == 0 then 0
178
+ elif ($fline | test("^┌─ Agent:")) or ($fline | test("^\\[.+\\]")) then 0
179
+ else 1 end) as $r007
180
+ | ([ $blocks[] | select(.type? == "text") | (.text? // "") ] | join("\n") | split("\n")) as $lines
181
+ | ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Tool:")) ] | length) as $an_tool
182
+ | ([ $lines[] | select(test("^[[:space:]]*\\[[0-9]+\\][[:space:]].*(→|->|—>)")) ] | length) as $an_spawn_item
183
+ | ([ $lines[] | select(test("\\[.+\\]\\[.+\\] ?(→|->|—>) ?Spawning:")) ] | length) as $an_spawn_hdr
184
+ | ($an_tool + (if $an_spawn_item > 0 then $an_spawn_item else $an_spawn_hdr end)) as $announce
185
+ | ([ $blocks[] | select(.type? == "tool_use") ] | length) as $ntools
186
+ | (if $ntools > $announce then $ntools - $announce else 0 end) as $r008
187
+ | [$tuuid, ($r007 | tostring), ($r008 | tostring)] | @tsv
188
+ end
189
+ '
190
+
191
+ result=$(tail -n 200 "$TRANSCRIPT_PATH" 2>/dev/null | jq -Rsr "$JQ_LAST_TURN" 2>/dev/null) || result=""
192
+
193
+ if [ -z "$result" ]; then
93
194
  exit 0
94
195
  fi
95
196
 
96
- # ── content 배열 파싱 ──
97
- content_raw=$(echo "$last_assistant" | jq -c '.content // []' 2>/dev/null) || content_raw="[]"
197
+ turn_uuid=$(printf '%s' "$result" | cut -f1)
198
+ r007_violations=$(printf '%s' "$result" | cut -f2)
199
+ r008_violations=$(printf '%s' "$result" | cut -f3)
98
200
 
99
- r007_violations=0
100
- r008_violations=0
201
+ : "${r007_violations:=0}"
202
+ : "${r008_violations:=0}"
101
203
 
102
- # ── R007: 첫 번째 text 블록의 첫 줄 체크 ──
103
- first_text=$(echo "$content_raw" | jq -r '[.[] | select(.type == "text")][0].text // empty' 2>/dev/null) || first_text=""
104
- if [ -n "$first_text" ]; then
105
- first_line=$(printf '%s' "$first_text" | head -1)
106
- # R007 패턴: '┌─ Agent:' 또는 '[anything]' 단축 형태
107
- if ! printf '%s' "$first_line" | grep -qE '(^┌─ Agent:|^\[.+\])'; then
108
- r007_violations=$((r007_violations + 1))
109
- fi
204
+ # ── 위반이 없으면 아무것도 출력하지 않는다 (오탐 방지) ──
205
+ if [ "$r007_violations" -eq 0 ] && [ "$r008_violations" -eq 0 ]; then
206
+ exit 0
110
207
  fi
111
208
 
112
- # ── R008: tool_use 블록 직전 text에 prefix 체크 ──
113
- content_length=$(echo "$content_raw" | jq 'length' 2>/dev/null) || content_length=0
114
- i=0
115
- while [ "$i" -lt "$content_length" ]; do
116
- block_type=$(echo "$content_raw" | jq -r ".[$i].type // empty" 2>/dev/null) || { i=$((i+1)); continue; }
117
-
118
- if [ "$block_type" = "tool_use" ]; then
119
- has_prefix=false
120
- if [ "$i" -gt 0 ]; then
121
- prev_type=$(echo "$content_raw" | jq -r ".[$(( i - 1 ))].type // empty" 2>/dev/null) || true
122
- if [ "$prev_type" = "text" ]; then
123
- prev_text=$(echo "$content_raw" | jq -r ".[$(( i - 1 ))].text // empty" 2>/dev/null) || true
124
- # R008 패턴: '[agent-name][model] → Tool:' 또는 '→ Target:'
125
- if printf '%s' "$prev_text" | grep -qE '\[.+\]\[.+\] ?(→|->|—>) ?(Tool|Target):'; then
126
- has_prefix=true
127
- fi
128
- fi
129
- fi
130
- if [ "$has_prefix" = "false" ]; then
131
- r008_violations=$((r008_violations + 1))
132
- fi
209
+ # ── dedup: 같은 턴에 대해 두 번 발화하지 않는다 ──
210
+ # 마커 키는 transcript 내용(턴의 첫 assistant uuid)에서만 파생 — date/랜덤 사용 금지.
211
+ MARKER_DIR="${OMCUSTOM_R007_MARKER_DIR:-${TMPDIR:-/tmp}}"
212
+ marker_key=$(printf '%s' "$session_id" | tr -c 'A-Za-z0-9._-' '_')
213
+ MARKER_FILE="${MARKER_DIR}/.omcustom-r007-advisor-${marker_key}"
214
+
215
+ if [ -n "$turn_uuid" ] && [ -f "$MARKER_FILE" ]; then
216
+ prev_uuid=$(cat "$MARKER_FILE" 2>/dev/null || printf '')
217
+ if [ "$prev_uuid" = "$turn_uuid" ]; then
218
+ exit 0
133
219
  fi
220
+ fi
134
221
 
135
- i=$((i+1))
136
- done
222
+ advisory_text=$(printf '[R007/R008 Advisory] 직전 응답에서 식별 누락 감지 (R007 헤더=%s, R008 접두사=%s). 이번 응답은 ┌─ Agent: 헤더로 시작하고, 모든 도구 호출에 [agent][model] → Tool: 접두사를 포함하십시오.' \
223
+ "$r007_violations" "$r008_violations")
137
224
 
138
- # ── advisory 전달 (위반 시에만) ──
139
- if [ "$r007_violations" -gt 0 ] || [ "$r008_violations" -gt 0 ]; then
140
- advisory_text=$(printf '[R007/R008 Advisory] 직전 응답에서 식별 누락 감지 (R007 헤더=%d, R008 접두사=%d). 이번 응답은 ┌─ Agent: 헤더로 시작하고, 모든 도구 호출에 [agent][model] → Tool: 접두사를 포함하십시오.' \
141
- "$r007_violations" "$r008_violations")
225
+ # 사람이 보는 감사 추적용 (exit 0에서는 모델에 전달되지 않음 — #1547 참고)
226
+ printf '%s\n' "$advisory_text" >&2
142
227
 
143
- # 사람이 보는 감사 추적용 (exit 0에서는 모델에 전달되지 않음 — #1547 참고)
144
- printf '%s\n' "$advisory_text" >&2
228
+ # #1547 fix: hookSpecificOutput.additionalContext로 모델 컨텍스트에 실제 전달.
229
+ # decision 필드는 절대 포함하지 않는다 — "block"을 쓰면 Stop/SubagentStop 정지를
230
+ # 강제로 막아버려 advisory-only 원칙(R021)을 위반하게 된다. exit code는 반드시 0.
231
+ jq -cn --arg event "$hook_event_name" --arg ctx "$advisory_text" \
232
+ '{hookSpecificOutput: {hookEventName: $event, additionalContext: $ctx}}'
145
233
 
146
- # #1547 fix: hookSpecificOutput.additionalContext로 모델 컨텍스트에 실제 전달.
147
- # decision 필드는 절대 포함하지 않는다 — "block"을 쓰면 Stop/SubagentStop 정지를
148
- # 강제로 막아버려 advisory-only 원칙(R021)을 위반하게 된다. exit code는 반드시 0.
149
- jq -cn --arg event "$hook_event_name" --arg ctx "$advisory_text" \
150
- '{hookSpecificOutput: {hookEventName: $event, additionalContext: $ctx}}'
234
+ if [ -n "$turn_uuid" ]; then
235
+ printf '%s' "$turn_uuid" > "$MARKER_FILE" 2>/dev/null || true
151
236
  fi
152
237
 
153
- # ── 위반이 없으면 아무것도 출력하지 않는다 (오탐 방지) ──
154
238
  # ── 항상 exit 0 (advisory는 절대 차단 금지) ──
155
239
  exit 0