oh-my-customcode 1.1.94 → 1.1.95

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli/index.js CHANGED
@@ -81,6 +81,9 @@ function registryDir() {
81
81
  function registryPath() {
82
82
  return join(registryDir(), "projects.json");
83
83
  }
84
+ function emptyRegistry() {
85
+ return { projects: {} };
86
+ }
84
87
  async function readRegistryRaw() {
85
88
  try {
86
89
  const content = await readFile(registryPath(), "utf-8");
@@ -88,9 +91,9 @@ async function readRegistryRaw() {
88
91
  if (typeof parsed === "object" && parsed !== null && "projects" in parsed && typeof parsed.projects === "object" && parsed.projects !== null) {
89
92
  return parsed;
90
93
  }
91
- return { ...EMPTY_REGISTRY };
94
+ return emptyRegistry();
92
95
  } catch {
93
- return { ...EMPTY_REGISTRY };
96
+ return emptyRegistry();
94
97
  }
95
98
  }
96
99
  async function writeRegistry(registry) {
@@ -203,9 +206,8 @@ async function migrateFromLockfiles(searchDirs) {
203
206
  }
204
207
  return imported;
205
208
  }
206
- var _registryDirOverride, EMPTY_REGISTRY, SCAN_SKIP_DIRS;
209
+ var _registryDirOverride, SCAN_SKIP_DIRS;
207
210
  var init_registry = __esm(() => {
208
- EMPTY_REGISTRY = { projects: {} };
209
211
  SCAN_SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git"]);
210
212
  });
211
213
 
@@ -217,7 +219,7 @@ var init_package = __esm(() => {
217
219
  workspaces: [
218
220
  "packages/*"
219
221
  ],
220
- version: "1.1.94",
222
+ version: "1.1.95",
221
223
  description: "Batteries-included agent harness for Claude Code",
222
224
  type: "module",
223
225
  bin: {
@@ -18040,7 +18042,7 @@ var CLASSIFICATION_TABLE = [
18040
18042
  event: "PreToolUse",
18041
18043
  description: "Block rule file deletion — requires individual user confirmation per rule (R001 safety)",
18042
18044
  decision: "drop-condition",
18043
- reason: 'rule-deletion-guard.sh explicitly re-checks `tool != "Bash"` (passthrough) and re-greps tool_input.command for rm/git rm/mv/unlink AND a .claude/rules path before blocking — matcher condition is redundant'
18045
+ reason: 'rule-deletion-guard.sh explicitly re-checks `tool != "Bash"` (passthrough) and itself decides whether to block: it blocks destructive operations (rm/mv/unlink/find -delete/xargs rm/truncate/git rm|mv|clean/`>` redirect) whose resolved target is inside or an ancestor of the project .claude/rules or templates/.claude/rules, falling back to pattern matching for opaque commands (use the Write/Edit tools to modify rule files) — matcher condition is redundant'
18044
18046
  },
18045
18047
  {
18046
18048
  event: "PostToolUse",
package/dist/index.js CHANGED
@@ -239,6 +239,9 @@ function registryDir() {
239
239
  function registryPath() {
240
240
  return join6(registryDir(), "projects.json");
241
241
  }
242
+ function emptyRegistry() {
243
+ return { projects: {} };
244
+ }
242
245
  async function readRegistryRaw() {
243
246
  try {
244
247
  const content = await readFile(registryPath(), "utf-8");
@@ -246,9 +249,9 @@ async function readRegistryRaw() {
246
249
  if (typeof parsed === "object" && parsed !== null && "projects" in parsed && typeof parsed.projects === "object" && parsed.projects !== null) {
247
250
  return parsed;
248
251
  }
249
- return { ...EMPTY_REGISTRY };
252
+ return emptyRegistry();
250
253
  } catch {
251
- return { ...EMPTY_REGISTRY };
254
+ return emptyRegistry();
252
255
  }
253
256
  }
254
257
  async function writeRegistry(registry) {
@@ -272,9 +275,8 @@ async function registerProject(projectPath, version) {
272
275
  };
273
276
  await writeRegistry(registry);
274
277
  }
275
- var _registryDirOverride, EMPTY_REGISTRY, SCAN_SKIP_DIRS;
278
+ var _registryDirOverride, SCAN_SKIP_DIRS;
276
279
  var init_registry = __esm(() => {
277
- EMPTY_REGISTRY = { projects: {} };
278
280
  SCAN_SKIP_DIRS = new Set(["node_modules", "dist", "build", ".git"]);
279
281
  });
280
282
 
@@ -1784,7 +1786,7 @@ var CLASSIFICATION_TABLE = [
1784
1786
  event: "PreToolUse",
1785
1787
  description: "Block rule file deletion — requires individual user confirmation per rule (R001 safety)",
1786
1788
  decision: "drop-condition",
1787
- reason: 'rule-deletion-guard.sh explicitly re-checks `tool != "Bash"` (passthrough) and re-greps tool_input.command for rm/git rm/mv/unlink AND a .claude/rules path before blocking — matcher condition is redundant'
1789
+ reason: 'rule-deletion-guard.sh explicitly re-checks `tool != "Bash"` (passthrough) and itself decides whether to block: it blocks destructive operations (rm/mv/unlink/find -delete/xargs rm/truncate/git rm|mv|clean/`>` redirect) whose resolved target is inside or an ancestor of the project .claude/rules or templates/.claude/rules, falling back to pattern matching for opaque commands (use the Write/Edit tools to modify rule files) — matcher condition is redundant'
1788
1790
  },
1789
1791
  {
1790
1792
  event: "PostToolUse",
@@ -2764,7 +2766,7 @@ var package_default = {
2764
2766
  workspaces: [
2765
2767
  "packages/*"
2766
2768
  ],
2767
- version: "1.1.94",
2769
+ version: "1.1.95",
2768
2770
  description: "Batteries-included agent harness for Claude Code",
2769
2771
  type: "module",
2770
2772
  bin: {
package/package.json CHANGED
@@ -3,7 +3,7 @@
3
3
  "workspaces": [
4
4
  "packages/*"
5
5
  ],
6
- "version": "1.1.94",
6
+ "version": "1.1.95",
7
7
  "description": "Batteries-included agent harness for Claude Code",
8
8
  "type": "module",
9
9
  "bin": {
@@ -33,9 +33,16 @@ You are a Git operations specialist following GitHub flow best practices.
33
33
  ```
34
34
  <type>(<scope>): <subject>
35
35
 
36
- Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
36
+ <trailer string from delegation>
37
37
  ```
38
38
 
39
+ Example trailer line: `Co-Authored-By: <model from delegation> <noreply@anthropic.com>`
40
+
41
+ Trailer rules:
42
+ - The trailer comes from the delegation prompt (which takes it from the session's attribution guidance). This agent definition and its examples never contain a model-name literal; the commit itself carries whatever model name the delegation's trailer string contains.
43
+ - When the delegation gives a trailer string, use it verbatim, even if your own environment suggests a different attribution.
44
+ - When the delegation gives no trailer string, do not commit; stop and report that the delegation did not provide a trailer string. Never invent one.
45
+
39
46
  Types: feat, fix, docs, style, refactor, test, chore
40
47
 
41
48
  ## Safety Rules
@@ -1,60 +1,905 @@
1
1
  #!/bin/bash
2
- # rule-deletion-guard.sh — Block rule file deletion without individual user confirmation
3
- # Requires: jq
2
+ # rule-deletion-guard.sh — Block rule file deletion without individual user confirmation (#1782)
3
+ #
4
+ # Path-aware (python3): a quote-aware shell lexer resolves rm/mv/unlink/rmdir/shred/truncate,
5
+ # find -delete/-exec, xargs <destructive>, git rm|mv|clean and writing redirects (> >| >> &> &>> >&)
6
+ # against the PROJECT rule dirs (<root>/.claude/rules and <root>/templates/.claude/rules).
7
+ # Inside a rule dir -> block. Ancestor of a rule dir -> block only for recursive delete/move.
8
+ # Structural fail-closed: anything the analyzer does not positively model (unknown wrapper before a
9
+ # destructive verb, destructive verb in a non-head position, unresolvable expansion in a target,
10
+ # opaque constructs such as eval / sh -c / $(...) / backticks / source, parse errors, unknown cwd,
11
+ # analyzer exceptions) -> strict legacy regex check (blocks if the pre-#1782 logic would block).
12
+ # No python3: legacy regex check (needs jq). Neither python3 nor jq: fail-open (exit 0).
4
13
 
5
14
  set -uo pipefail
6
15
 
7
16
  input=$(cat)
8
17
 
9
- # Dependency check — allow if jq missing
10
- if ! command -v jq &>/dev/null; then
11
- printf '%s\n' "$input"
12
- exit 0
13
- fi
18
+ # Legacy regex logic (verbatim semantics of the pre-#1782 guard). Arg: command string. Exits 2 on block.
19
+ # Arg 2 = "strict": used after python fallbacks; the verb may also follow a quote/paren/slash
20
+ # (e.g. bash -c 'rm ...', /bin/rm) so the fallback is never weaker than the analyzer's intent.
21
+ legacy_check() {
22
+ local cmd="$1" targets target_count filename
23
+ local pre='(^|\s)'
24
+ [ "${2:-}" = "strict" ] && pre='(^|[^[:alnum:]_-])'
14
25
 
15
- # Parse tool input
16
- tool=$(printf '%s\n' "$input" | jq -r '.tool_name // .tool // ""' 2>/dev/null) || { printf '%s\n' "$input"; exit 0; }
17
- cmd=$(printf '%s\n' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null) || { printf '%s\n' "$input"; exit 0; }
26
+ # Check if command would delete parent directories containing rules
27
+ if echo "$cmd" | grep -qE "${pre}(rm|git\s+rm|mv|unlink)\s" && echo "$cmd" | grep -qE '\.claude/?(\s|$)'; then
28
+ echo "[Hook] ⛔ RULE DELETION BLOCKED — Parent directory deletion detected" >&2
29
+ echo "[Hook] This command combines a delete/move verb with a .claude/ directory reference (pattern check)." >&2
30
+ echo "[Hook] Delete rules individually with user confirmation." >&2
31
+ echo "[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지)." >&2
32
+ exit 2
33
+ fi
18
34
 
19
- # Only check Bash tool
20
- if [ "$tool" != "Bash" ]; then
21
- printf '%s\n' "$input"
22
- exit 0
23
- fi
35
+ # Check if command targets .claude/rules/ for deletion (including mv, unlink)
36
+ if echo "$cmd" | grep -qE "${pre}(rm|git\s+rm|mv|unlink)\s" && echo "$cmd" | grep -qE '\.claude/rules(/|\s|$)'; then
37
+ targets=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | tr '\n' ', ' | sed 's/,$//')
38
+ target_count=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | wc -l | tr -d ' ')
24
39
 
25
- # Check if command would delete parent directories containing rules
26
- if echo "$cmd" | grep -qE '(^|\s)(rm|git\s+rm|mv|unlink)\s' && echo "$cmd" | grep -qE '\.claude/?(\s|$)'; then
27
- echo "[Hook] ⛔ RULE DELETION BLOCKED — Parent directory deletion detected" >&2
28
- echo "[Hook] This command would delete the entire .claude/ directory including all rules." >&2
29
- echo "[Hook] Delete rules individually with user confirmation." >&2
30
- exit 2
31
- fi
40
+ if echo "$cmd" | grep -qE '\.claude/rules/\*|\.claude/rules/[^ ]*\*' || [ "$target_count" -gt 1 ]; then
41
+ echo "[Hook] ⛔ RULE DELETION BLOCKED — Multiple rules detected" >&2
42
+ echo "[Hook] Targets: $targets" >&2
43
+ echo "[Hook] Rule files must be deleted ONE AT A TIME with user confirmation." >&2
44
+ echo "[Hook] Delete each rule individually after asking: \"정말 {파일명}을(를) 삭제하시겠습니까?\"" >&2
45
+ echo "[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지)." >&2
46
+ exit 2
47
+ fi
32
48
 
33
- # Check if command targets .claude/rules/ for deletion (including mv, unlink)
34
- if echo "$cmd" | grep -qE '(^|\s)(rm|git\s+rm|mv|unlink)\s' && echo "$cmd" | grep -qE '\.claude/rules(/|\s|$)'; then
35
- # Extract target files
36
- targets=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | tr '\n' ', ' | sed 's/,$//')
37
- target_count=$(echo "$cmd" | grep -oE '\.claude/rules/[^ ]+' | wc -l | tr -d ' ')
38
-
39
- # Check for glob/wildcard patterns or multiple targets
40
- if echo "$cmd" | grep -qE '\.claude/rules/\*|\.claude/rules/[^ ]*\*' || [ "$target_count" -gt 1 ]; then
41
- echo "[Hook] ⛔ RULE DELETION BLOCKED — Multiple rules detected" >&2
42
- echo "[Hook] Targets: $targets" >&2
43
- echo "[Hook] Rule files must be deleted ONE AT A TIME with user confirmation." >&2
44
- echo "[Hook] Delete each rule individually after asking: \"정말 {파일명}을(를) 삭제하시겠습니까?\"" >&2
49
+ filename=$(basename "$targets" 2>/dev/null || echo "$targets")
50
+ echo "[Hook] ⛔ RULE DELETION BLOCKED" >&2
51
+ echo "[Hook] Target: $filename" >&2
52
+ echo "[Hook] Rule files require individual user confirmation before deletion." >&2
53
+ echo "[Hook] Ask the user: \"정말 ${filename}을(를) 삭제하시겠습니까?\"" >&2
54
+ echo "[Hook] Only proceed after explicit user approval." >&2
55
+ echo "[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지)." >&2
45
56
  exit 2
46
57
  fi
58
+ }
59
+
60
+ pass() { printf '%s\n' "$input"; exit 0; }
61
+
62
+ # Analyzer (script on fd 3; stdin carries the hook JSON, so the payload size is not bound by ARG_MAX).
63
+ run_analyzer() {
64
+ python3 /dev/fd/3 3<<'PY'
65
+ import fnmatch
66
+ import glob as globmod
67
+ import json
68
+ import os
69
+ import re
70
+ import sys
71
+
72
+ # Exit codes: 0 allow, 2 block (message on stderr), 3 fall back to legacy (stdout = command)
73
+
74
+
75
+ class Fallback(Exception):
76
+ pass
77
+
78
+
79
+ class Word(object):
80
+ __slots__ = ('v', 'exp', 'tilde', 'brace', 'raw')
81
+
82
+ def __init__(self, v, exp, tilde, brace, raw):
83
+ self.v, self.exp, self.tilde, self.brace, self.raw = v, exp, tilde, brace, raw
84
+
85
+
86
+ OPAQUE = re.compile(
87
+ r'(?<![\w-])eval(?![\w-])|\$\(|`'
88
+ r'|(?<![\w-])(?:ba|z|da|k)?sh\b[^\n;|&]*?\s-\w*c\b'
89
+ r'|(?<![\w-])(?:ba|z|da|k)?sh\b[^\n;|&]*<<'
90
+ r'|(?<![\w-])source(?![\w-])'
91
+ )
92
+ VAR = re.compile(r'^[A-Za-z_]\w*=')
93
+ GLOBCH = re.compile(r'[*?\[]')
94
+ OPS = ['<<<', '<<-', '&>>', ';;&', '&&', '||', '|&', ';;', ';&', '>>', '>|', '>&', '<&', '<>', '<<', '&>',
95
+ ';', '&', '|', '(', ')', '<', '>']
96
+ SEP = {';', '&', '&&', '||', '|', '|&', ';;', ';&', ';;&', '(', ')'}
97
+ WREDIR = {'>', '>|', '>>', '&>', '&>>', '<>'}
98
+ OREDIR = {'<', '<<<', '<&', '<<', '<<-'}
99
+ DESTR = {'rm', 'unlink', 'rmdir', 'shred', 'truncate'}
100
+ DWORDS = DESTR | {'mv', 'xargs'}
101
+ FINDACT = {'-delete', '-exec', '-execdir', '-ok', '-okdir'}
102
+ FINDFILT = {'-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename', '-regex', '-iregex'}
103
+ RESERVED = {'if', 'then', 'do', 'else', 'elif', 'while', 'until', '!', '{', '}'}
104
+ # wrapper -> (options taking a separate value, leading positionals before the command)
105
+ WRAP = {
106
+ 'sudo': ({'-u', '-g', '-h', '-p', '-C', '-D', '-R', '-T', '-U', '-r', '-t'}, 0),
107
+ 'doas': ({'-u', '-C'}, 0),
108
+ 'command': (set(), 0), 'builtin': (set(), 0), 'exec': ({'-a'}, 0),
109
+ 'env': ({'-u', '-C', '-S', '-P', '--unset', '--chdir', '--split-string'}, 0),
110
+ 'nice': ({'-n', '--adjustment'}, 0), 'nohup': (set(), 0), 'time': ({'-f', '-o'}, 0),
111
+ 'timeout': ({'-s', '-k', '--signal', '--kill-after'}, 1),
112
+ 'gtimeout': ({'-s', '-k', '--signal', '--kill-after'}, 1),
113
+ 'stdbuf': ({'-i', '-o', '-e', '--input', '--output', '--error'}, 0),
114
+ 'caffeinate': ({'-t', '-w'}, 0), 'busybox': (set(), 0), 'ionice': ({'-c', '-n', '-p', '-t'}, 0),
115
+ 'chronic': (set(), 0), 'nocorrect': (set(), 0), 'noglob': (set(), 0),
116
+ }
117
+ GIT_VAL = {'-C', '-c', '--git-dir', '--work-tree', '--namespace', '--super-prefix', '--config-env'}
118
+
119
+
120
+ def lex(s):
121
+ """Quote-aware shell tokenizer: [('op', str) | ('w', Word)]. Raises Fallback when unsure."""
122
+ toks, pend = [], []
123
+ n, i = len(s), 0
124
+ st = {'w': None, 'delim': False}
125
+
126
+ def start():
127
+ if st['w'] is None:
128
+ st['w'] = {'v': [], 'exp': False, 'tilde': False, 'ub': False, 'i': i}
129
+ return st['w']
130
+
131
+ def end(j):
132
+ w = st['w']
133
+ if w is None:
134
+ return
135
+ v = ''.join(w['v'])
136
+ brace = w['ub'] and v not in ('{', '}', '{}') and '}' in v and (',' in v or '..' in v)
137
+ st['w'] = None
138
+ if st['delim']:
139
+ pend.append(v)
140
+ st['delim'] = False
141
+ return
142
+ toks.append(('w', Word(v, w['exp'], w['tilde'], brace, s[w['i']:j])))
143
+
144
+ while i < n:
145
+ c = s[i]
146
+ if c == '#' and st['w'] is None:
147
+ j = s.find('\n', i)
148
+ i = n if j < 0 else j
149
+ continue
150
+ if c in ' \t':
151
+ end(i)
152
+ i += 1
153
+ continue
154
+ if c == '\n':
155
+ end(i)
156
+ if st['delim']:
157
+ raise Fallback()
158
+ toks.append(('op', ';'))
159
+ i += 1
160
+ for d in pend:
161
+ while True:
162
+ if i >= n:
163
+ raise Fallback() # unterminated heredoc
164
+ j = s.find('\n', i)
165
+ line = s[i:] if j < 0 else s[i:j]
166
+ i = n if j < 0 else j + 1
167
+ if line.strip() == d:
168
+ break
169
+ del pend[:]
170
+ continue
171
+ if c in ';&|<>()':
172
+ w = st['w']
173
+ if c in '<>' and w is not None and w['v'] and all(ch.isdigit() for ch in w['v']) and s[w['i']:i].isdigit():
174
+ st['w'] = None # fd number before a redirect
175
+ else:
176
+ end(i)
177
+ if c in '<>' and i + 1 < n and s[i + 1] == '(':
178
+ raise Fallback() # process substitution
179
+ op = next(o for o in OPS if s.startswith(o, i))
180
+ if st['delim']:
181
+ raise Fallback()
182
+ toks.append(('op', op))
183
+ if op in ('<<', '<<-'):
184
+ st['delim'] = True
185
+ i += len(op)
186
+ continue
187
+ if c == '\\':
188
+ w = start()
189
+ if i + 1 < n:
190
+ w['v'].append(s[i + 1])
191
+ i += 2
192
+ continue
193
+ if c == "'":
194
+ w = start()
195
+ j = s.find("'", i + 1)
196
+ if j < 0:
197
+ raise Fallback()
198
+ w['v'].append(s[i + 1:j])
199
+ i = j + 1
200
+ continue
201
+ if c == '"' or (c == '$' and s.startswith('$"', i)):
202
+ w = start()
203
+ i += 2 if c == '$' else 1
204
+ while True:
205
+ if i >= n:
206
+ raise Fallback()
207
+ ch = s[i]
208
+ if ch == '"':
209
+ i += 1
210
+ break
211
+ if ch == '\\' and i + 1 < n and s[i + 1] in '$`"\\\n':
212
+ w['v'].append(s[i + 1])
213
+ i += 2
214
+ continue
215
+ if ch in '$`':
216
+ if ch == '`' or s.startswith('$(', i):
217
+ raise Fallback()
218
+ w['exp'] = True
219
+ w['v'].append(ch)
220
+ i += 1
221
+ continue
222
+ if c == '$' and s.startswith("$'", i):
223
+ w = start()
224
+ j, buf = i + 2, []
225
+ while True:
226
+ if j >= n:
227
+ raise Fallback()
228
+ if s[j] == '\\' and j + 1 < n:
229
+ buf.append(s[j:j + 2])
230
+ j += 2
231
+ continue
232
+ if s[j] == "'":
233
+ break
234
+ buf.append(s[j])
235
+ j += 1
236
+ try:
237
+ w['v'].append(''.join(buf).encode('latin-1', 'backslashreplace').decode('unicode_escape'))
238
+ except Exception:
239
+ w['exp'] = True
240
+ i = j + 1
241
+ continue
242
+ if c == '`' or (c == '$' and s.startswith('$(', i)):
243
+ raise Fallback()
244
+ if c == '$':
245
+ start()['exp'] = True
246
+ if c == '~' and st['w'] is None:
247
+ start()['tilde'] = True
248
+ if c == '{':
249
+ start()['ub'] = True
250
+ start()['v'].append(c)
251
+ i += 1
252
+ end(n)
253
+ if st['delim'] or pend:
254
+ raise Fallback()
255
+ return toks
256
+
257
+
258
+ def low(s):
259
+ return s.lower()
260
+
261
+
262
+ def main(d, cmd):
263
+ start = d.get('cwd')
264
+ if isinstance(start, str) and os.path.isdir(start):
265
+ start = os.path.realpath(start)
266
+ else:
267
+ start = os.getcwd()
268
+ root = os.environ.get('CLAUDE_PROJECT_DIR')
269
+ if not root:
270
+ try:
271
+ import subprocess
272
+ r = subprocess.run(['git', '-C', start, 'rev-parse', '--show-toplevel'],
273
+ capture_output=True, text=True, timeout=5)
274
+ root = r.stdout.strip() if r.returncode == 0 else ''
275
+ except Exception:
276
+ root = ''
277
+ root = root or start
278
+ root = os.path.realpath(root)
279
+ prot = [low(os.path.join(root, '.claude', 'rules')),
280
+ low(os.path.join(root, 'templates', '.claude', 'rules'))]
281
+
282
+ cmd = cmd.replace('\r', '').replace('\\\n', '')
283
+ if OPAQUE.search(cmd):
284
+ return 3
285
+ try:
286
+ toks = lex(cmd)
287
+ except Fallback:
288
+ return 3
289
+
290
+ hits = []
291
+ fb = [False]
292
+ shvars = {}
293
+
294
+ def rp(p):
295
+ b = os.path.basename(p)
296
+ if p.endswith('/') or b in ('', '.', '..'):
297
+ return os.path.realpath(p)
298
+ return os.path.join(os.path.realpath(os.path.dirname(p)), b)
299
+
300
+ def classify(r):
301
+ rl = low(r).rstrip('/') or '/'
302
+ for p in prot:
303
+ if rl == p or rl.startswith(p + '/'):
304
+ return 'inside'
305
+ for p in prot:
306
+ if rl == '/' or p.startswith(rl + '/'):
307
+ return 'anc'
308
+ return None
309
+
310
+ def value(w):
311
+ if w.brace:
312
+ raise Fallback()
313
+ v = w.v
314
+ if w.exp:
315
+ raw = w.raw
316
+ names = re.findall(r'\$\{(\w+)\}|\$(\w+)', raw)
317
+ if raw.count('$') != len(names) or "'" in raw or '\\' in raw:
318
+ raise Fallback()
319
+ for a, b in names:
320
+ if shvars.get(a or b) is None:
321
+ raise Fallback()
322
+ v = re.sub(r'\$\{(\w+)\}|\$(\w+)', lambda m: shvars[m.group(1) or m.group(2)], raw).replace('"', '')
323
+ if w.tilde:
324
+ if v == '~' or v.startswith('~/'):
325
+ v = os.path.expanduser(v)
326
+ else:
327
+ raise Fallback()
328
+ return v
329
+
330
+ def check(w, cwd, anc):
331
+ """anc: what an ANCESTOR of a rule dir means here: 'block' | 'fallback' | 'ignore'."""
332
+ try:
333
+ v = value(w)
334
+ if not os.path.isabs(v):
335
+ if cwd is None:
336
+ raise Fallback()
337
+ v = os.path.join(cwd, v)
338
+ except Fallback:
339
+ fb[0] = True
340
+ return
341
+ isglob = bool(GLOBCH.search(v))
342
+ if isglob:
343
+ static = []
344
+ for p in v.split('/'):
345
+ if GLOBCH.search(p):
346
+ break
347
+ static.append(p)
348
+ pre = os.path.realpath('/'.join(static) or '/')
349
+ if classify(pre) == 'inside':
350
+ hits.append((pre, True, 'inside'))
351
+ paths = [rp(m) for m in globmod.glob(v)]
352
+ else:
353
+ paths = [rp(v)]
354
+ for r in paths:
355
+ c = classify(r)
356
+ if c == 'inside':
357
+ hits.append((r, isglob, 'inside'))
358
+ elif c == 'anc':
359
+ if anc == 'block':
360
+ hits.append((r, isglob, 'anc'))
361
+ elif anc == 'fallback':
362
+ fb[0] = True
363
+
364
+ def names(words):
365
+ out = set()
366
+ for w in words:
367
+ if not w.exp and w.v and not any(ch.isspace() for ch in w.v):
368
+ out.add(os.path.basename(w.v) or w.v)
369
+ return out
370
+
371
+ def risky(words):
372
+ """True when an unmodelled position holds a destructive verb word."""
373
+ nm = names(words)
374
+ return bool(nm & DWORDS) or ('find' in nm and bool(nm & FINDACT)) or ('git' in nm and 'clean' in nm)
47
375
 
48
- # Single rule file
49
- filename=$(basename "$targets" 2>/dev/null || echo "$targets")
50
- echo "[Hook] ⛔ RULE DELETION BLOCKED" >&2
51
- echo "[Hook] Target: $filename" >&2
52
- echo "[Hook] Rule files require individual user confirmation before deletion." >&2
53
- echo "[Hook] Ask the user: \"정말 ${filename}을(를) 삭제하시겠습니까?\"" >&2
54
- echo "[Hook] Only proceed after explicit user approval." >&2
55
- exit 2
376
+ def unwrap(argv, cwd):
377
+ argv = list(argv)
378
+ while argv:
379
+ while argv and not argv[0].exp and VAR.match(argv[0].v):
380
+ argv.pop(0)
381
+ if not argv or argv[0].exp:
382
+ break
383
+ h = argv[0].v
384
+ if h in RESERVED:
385
+ argv.pop(0)
386
+ continue
387
+ b = os.path.basename(h)
388
+ if b not in WRAP:
389
+ break
390
+ vals, npos = WRAP[b]
391
+ argv.pop(0)
392
+ if b == 'busybox':
393
+ continue
394
+ while argv and argv[0].v.startswith('-') and not argv[0].exp:
395
+ o = argv.pop(0).v
396
+ if o == '--':
397
+ break
398
+ key, _, joined = o.partition('=')
399
+ if b == 'env' and key in ('-S', '--split-string'):
400
+ raise Fallback()
401
+ if b == 'env' and key in ('-C', '--chdir'):
402
+ if not joined:
403
+ if not argv:
404
+ break
405
+ dw = argv.pop(0)
406
+ else:
407
+ dw = Word(joined, '$' in joined, False, False, joined)
408
+ try:
409
+ dv = value(dw)
410
+ cwd = None if cwd is None and not os.path.isabs(dv) else \
411
+ os.path.realpath(dv if os.path.isabs(dv) else os.path.join(cwd, dv))
412
+ except Fallback:
413
+ cwd = None
414
+ continue
415
+ if o in vals and argv:
416
+ argv.pop(0)
417
+ for _ in range(npos):
418
+ if argv:
419
+ argv.pop(0)
420
+ return argv, cwd
421
+
422
+ def git_split(args, cwd):
423
+ i = 0
424
+ while i < len(args) and args[i].v.startswith('-'):
425
+ o = args[i].v
426
+ if o in GIT_VAL and i + 1 < len(args):
427
+ if o == '-C':
428
+ try:
429
+ p = value(args[i + 1])
430
+ cwd = None if (cwd is None and not os.path.isabs(p)) else \
431
+ os.path.realpath(p if os.path.isabs(p) else os.path.join(cwd, p))
432
+ except Fallback:
433
+ cwd = None
434
+ i += 2
435
+ else:
436
+ i += 1
437
+ if i >= len(args) or args[i].exp:
438
+ return None, [], cwd
439
+ return args[i].v, args[i + 1:], cwd
440
+
441
+ def kind(argv, cwd):
442
+ """'destr' | 'risky' | None for a (possibly wrapped) command."""
443
+ try:
444
+ argv, _ = unwrap(argv, cwd)
445
+ except Fallback:
446
+ return 'risky'
447
+ if not argv:
448
+ return None
449
+ if argv[0].exp:
450
+ return 'risky'
451
+ v = os.path.basename(argv[0].v)
452
+ if v in DESTR or v == 'mv':
453
+ return 'destr'
454
+ if v == 'git':
455
+ sub, _, _ = git_split(argv[1:], cwd)
456
+ if sub in ('rm', 'mv', 'clean'):
457
+ return 'destr'
458
+ return 'risky' if risky(argv[1:]) else None
459
+
460
+ def opts_pos(args, valopts=()):
461
+ opts, posl, i, rest = [], [], 0, False
462
+ while i < len(args):
463
+ a = args[i]
464
+ if rest or a.exp or not a.v.startswith('-') or a.v == '-':
465
+ posl.append(a)
466
+ elif a.v == '--':
467
+ rest = True
468
+ else:
469
+ opts.append(a.v)
470
+ if a.v in valopts and i + 1 < len(args):
471
+ i += 1
472
+ i += 1
473
+ return opts, posl
474
+
475
+ def short_has(opts, ch):
476
+ return any(o.startswith('-') and not o.startswith('--') and ch in o[1:] for o in opts)
477
+
478
+ def mv_targets(args, cwd):
479
+ dest, plain, i = None, [], 0
480
+ while i < len(args):
481
+ a = args[i].v
482
+ if a in ('-t', '--target-directory') and i + 1 < len(args):
483
+ dest = args[i + 1]
484
+ i += 2
485
+ continue
486
+ if a.startswith('--target-directory='):
487
+ j = a.split('=', 1)[1]
488
+ dest = Word(j, '$' in j, False, False, j)
489
+ elif a in ('-S', '--suffix') and i + 1 < len(args):
490
+ i += 2
491
+ continue
492
+ else:
493
+ plain.append(args[i])
494
+ i += 1
495
+ _, plain = opts_pos(plain)
496
+ if dest is None and plain:
497
+ dest = plain[-1]
498
+ plain = plain[:-1]
499
+ for s in plain:
500
+ check(s, cwd, 'block')
501
+ if dest is not None:
502
+ check(dest, cwd, 'ignore')
503
+
504
+ class Unsup(Exception):
505
+ pass
506
+
507
+ def find_compile(expr):
508
+ """Compile a find expression into a predicate over (disp, name, isdir, depth)."""
509
+ toks = [w.v for w in expr]
510
+ depth = {'max': None, 'min': 0}
511
+ pos = [0]
512
+ TRUEOPS = {'-delete', '-print', '-print0', '-ls', '-depth', '-d', '-xdev', '-mount', '-follow',
513
+ '-noleaf', '-ignore_readdir_race', '-true'}
514
+
515
+ def peek():
516
+ return toks[pos[0]] if pos[0] < len(toks) else None
517
+
518
+ def take():
519
+ t = peek()
520
+ pos[0] += 1
521
+ return t
522
+
523
+ def arg():
524
+ if pos[0] >= len(toks):
525
+ raise Unsup()
526
+ return take()
527
+
528
+ def primary():
529
+ t = take()
530
+ if t == '(':
531
+ e = p_or()
532
+ if take() != ')':
533
+ raise Unsup()
534
+ return e
535
+ if t in ('-name', '-iname', '-path', '-ipath', '-wholename', '-iwholename'):
536
+ pat, ci, onpath = arg(), t.startswith('-i'), t not in ('-name', '-iname')
537
+ if ci:
538
+ pat = pat.lower()
539
+ return lambda c, pat=pat, ci=ci, onpath=onpath: fnmatch.fnmatchcase(
540
+ (c[0] if onpath else c[1]).lower() if ci else (c[0] if onpath else c[1]), pat)
541
+ if t == '-type':
542
+ ty = arg()
543
+ if not set(ty.split(',')) <= {'f', 'd'}:
544
+ raise Unsup()
545
+ tys = set(ty.split(','))
546
+ return lambda c, tys=tys: ('d' if c[2] else 'f') in tys
547
+ if t in ('-maxdepth', '-mindepth'):
548
+ n = arg()
549
+ if not n.isdigit():
550
+ raise Unsup()
551
+ depth['max' if t == '-maxdepth' else 'min'] = int(n)
552
+ return lambda c: True
553
+ if t in TRUEOPS:
554
+ return lambda c: True
555
+ if t in ('-exec', '-execdir', '-ok', '-okdir'):
556
+ while peek() is not None and peek() not in (';', '+'):
557
+ take()
558
+ if take() is None:
559
+ raise Unsup()
560
+ return lambda c: True
561
+ raise Unsup()
562
+
563
+ def p_not():
564
+ if peek() in ('!', '-not'):
565
+ take()
566
+ e = p_not()
567
+ return lambda c, e=e: not e(c)
568
+ return primary()
569
+
570
+ def p_and():
571
+ e = p_not()
572
+ while peek() is not None and peek() not in (')', '-o', '-or'):
573
+ if peek() in ('-a', '-and'):
574
+ take()
575
+ r = p_not()
576
+ e = (lambda l, r: lambda c: l(c) and r(c))(e, r)
577
+ return e
578
+
579
+ def p_or():
580
+ e = p_and()
581
+ while peek() in ('-o', '-or'):
582
+ take()
583
+ r = p_and()
584
+ e = (lambda l, r: lambda c: l(c) or r(c))(e, r)
585
+ return e
586
+
587
+ e = p_or() if toks else (lambda c: True)
588
+ if pos[0] != len(toks):
589
+ raise Unsup()
590
+ return e, depth
591
+
592
+ def find_start(a, cwd, expr):
593
+ try:
594
+ sv = value(a)
595
+ sabs = sv if os.path.isabs(sv) else (None if cwd is None else os.path.join(cwd, sv))
596
+ if sabs is None or GLOBCH.search(sv):
597
+ raise Fallback()
598
+ except Fallback:
599
+ fb[0] = True
600
+ return
601
+ sreal = os.path.realpath(sabs)
602
+ c = classify(sreal)
603
+ if c is None:
604
+ return
605
+ try:
606
+ pred, depth = find_compile(expr)
607
+ except Unsup:
608
+ rl = low(sreal).rstrip('/') or '/'
609
+ rootl = low(root)
610
+ if rl == rootl or rootl.startswith(rl.rstrip('/') + '/') or rl == '/':
611
+ fb[0] = True # project root or above: legacy decides
612
+ else:
613
+ hits.append((sreal, False, c))
614
+ return
615
+ sl = low(sreal).rstrip('/')
616
+ for p in prot:
617
+ if p == sl or p.startswith(sl + '/'):
618
+ walk_root = sreal + p[len(sl):]
619
+ elif sl.startswith(p + '/'):
620
+ walk_root = sreal
621
+ else:
622
+ continue
623
+ if not os.path.exists(walk_root):
624
+ continue
625
+ entries = [(walk_root, os.path.isdir(walk_root))]
626
+ if os.path.isdir(walk_root) and not os.path.islink(walk_root):
627
+ for dp, dns, fns in os.walk(walk_root):
628
+ entries += [(os.path.join(dp, x), True) for x in dns]
629
+ entries += [(os.path.join(dp, x), False) for x in fns]
630
+ for path, isdir in entries:
631
+ rel = os.path.relpath(path, sreal)
632
+ d = 0 if rel == '.' else rel.count(os.sep) + 1
633
+ if d < depth['min'] or (depth['max'] is not None and d > depth['max']):
634
+ continue
635
+ disp = sv if rel == '.' else (sv.rstrip('/') + '/' + rel)
636
+ name = os.path.basename(disp.rstrip('/')) or disp
637
+ if pred((disp, name, isdir, d)):
638
+ hits.append((path, False, 'inside'))
639
+ return
640
+
641
+ xargs_pipes = []
642
+ segs = []
643
+
644
+ def handle(argv, cwd, pipe_id):
645
+ try:
646
+ argv, cwd2 = unwrap(argv, cwd)
647
+ except Fallback:
648
+ fb[0] = True
649
+ return cwd
650
+ if not argv:
651
+ return cwd
652
+ if argv[0].exp:
653
+ fb[0] = True
654
+ return cwd
655
+ v = os.path.basename(argv[0].v)
656
+ args = argv[1:]
657
+ if v in ('cd', 'pushd', 'popd'):
658
+ if cwd2 is not cwd:
659
+ return None
660
+ if v != 'cd':
661
+ return None
662
+ if not args:
663
+ return os.path.expanduser('~')
664
+ tgt = [a for a in args if not (a.v.startswith('-') and a.v != '-')]
665
+ if len(tgt) != 1 or tgt[0].v == '-':
666
+ return None
667
+ try:
668
+ t = value(tgt[0])
669
+ except Fallback:
670
+ return None
671
+ if not os.path.isabs(t):
672
+ if cwd is None:
673
+ return None
674
+ t = os.path.join(cwd, t)
675
+ return os.path.realpath(t)
676
+ shell_cwd, cwd = cwd, cwd2
677
+ if v in DESTR:
678
+ opts, posl = opts_pos(args, {'-s', '--size', '-r', '--reference', '-n', '--iterations'}
679
+ if v in ('truncate', 'shred') else ())
680
+ rec = v == 'rm' and (short_has(opts, 'r') or short_has(opts, 'R') or '--recursive' in opts)
681
+ for a in posl:
682
+ check(a, cwd, 'block' if rec else 'fallback')
683
+ elif v == 'mv':
684
+ mv_targets(args, cwd)
685
+ elif v == 'git':
686
+ sub, rest, gcwd = git_split(args, cwd)
687
+ if sub is None:
688
+ if risky(args):
689
+ fb[0] = True
690
+ elif sub == 'rm':
691
+ opts, posl = opts_pos(rest, {'--pathspec-from-file'})
692
+ if not ('-n' in opts or '--dry-run' in opts or short_has(opts, 'n')):
693
+ rec = short_has(opts, 'r')
694
+ for a in posl:
695
+ check(a, gcwd, 'block' if rec else 'fallback')
696
+ elif sub == 'mv':
697
+ mv_targets(rest, gcwd)
698
+ elif sub == 'clean':
699
+ opts, posl = opts_pos(rest, {'-e', '--exclude'})
700
+ if not (short_has(opts, 'n') or '--dry-run' in opts):
701
+ rec = short_has(opts, 'd')
702
+ if posl:
703
+ for a in posl:
704
+ check(a, gcwd, 'block' if rec else 'ignore')
705
+ elif gcwd is None:
706
+ fb[0] = True
707
+ else:
708
+ check(Word(gcwd, False, False, False, gcwd), gcwd, 'block' if rec else 'ignore')
709
+ elif risky(rest):
710
+ fb[0] = True
711
+ elif v == 'find':
712
+ j, destructive = 0, '-delete' in [a.v for a in args]
713
+ while j < len(args):
714
+ if args[j].v in ('-exec', '-execdir', '-ok', '-okdir'):
715
+ k = j + 1
716
+ while k < len(args) and args[k].v not in (';', '+'):
717
+ k += 1
718
+ body = args[j + 1:k]
719
+ kd = kind(body, cwd) if body else None
720
+ if kd == 'destr':
721
+ destructive = True
722
+ elif kd == 'risky':
723
+ fb[0] = True
724
+ if body:
725
+ handle(body, cwd, pipe_id)
726
+ j = k
727
+ j += 1
728
+ if destructive:
729
+ k = 0
730
+ while k < len(args) and args[k].v in ('-H', '-L', '-P', '-D', '-E', '-X', '-d', '-s', '-x') \
731
+ or (k < len(args) and args[k].v.startswith('-O')):
732
+ k += 2 if args[k].v == '-D' else 1
733
+ starts = []
734
+ while k < len(args) and not (args[k].v.startswith('-') or args[k].v in ('(', '!', ')')):
735
+ starts.append(args[k])
736
+ k += 1
737
+ expr = args[k:]
738
+ for a in (starts or [Word('.', False, False, False, '.')]):
739
+ find_start(a, cwd, expr)
740
+ elif v == 'xargs':
741
+ i = 0
742
+ valopts = {'-I', '-J', '-L', '-n', '-P', '-R', '-S', '-s', '-E', '-a', '-d',
743
+ '--max-args', '--max-procs', '--delimiter', '--arg-file', '--max-chars',
744
+ '--process-slot-var'}
745
+ while i < len(args) and args[i].v.startswith('-') and not args[i].exp:
746
+ if args[i].v == '--':
747
+ i += 1
748
+ break
749
+ i += 2 if args[i].v in valopts else 1
750
+ inner = args[i:]
751
+ kd = kind(inner, cwd) if inner else None
752
+ if kd == 'destr':
753
+ xargs_pipes.append(pipe_id)
754
+ elif kd == 'risky':
755
+ fb[0] = True
756
+ if inner:
757
+ handle(inner, cwd, pipe_id)
758
+ elif risky(args):
759
+ fb[0] = True
760
+ return shell_cwd
761
+
762
+ cur, pipe_id, stack, cwd = [], 0, [], start
763
+ pending_redir = [None]
764
+
765
+ def flush():
766
+ nonlocal cwd
767
+ if pending_redir[0] is not None:
768
+ raise Fallback()
769
+ if not cur:
770
+ return
771
+ argv = [x for x in cur if not isinstance(x, tuple)]
772
+ for op, w in [x for x in cur if isinstance(x, tuple)]:
773
+ if op in WREDIR or (op == '>&' and not re.match(r'^(\d+|-)$', w.v)):
774
+ check(w, cwd, 'ignore')
775
+ if argv and all(not w.exp and VAR.match(w.v) for w in argv):
776
+ for w in argv:
777
+ k, _, val = w.v.partition('=')
778
+ shvars[k] = None if (w.brace or w.tilde or GLOBCH.search(val)) else val
779
+ segs.append((pipe_id, argv, cwd))
780
+ cwd = handle(argv, cwd, pipe_id)
781
+ del cur[:]
782
+
783
+ try:
784
+ for kindt, t in toks:
785
+ if kindt == 'w':
786
+ if pending_redir[0] is not None:
787
+ cur.append((pending_redir[0], t))
788
+ pending_redir[0] = None
789
+ else:
790
+ cur.append(t)
791
+ continue
792
+ if t in SEP:
793
+ flush()
794
+ if t == '(':
795
+ stack.append(cwd)
796
+ elif t == ')' and stack:
797
+ cwd = stack.pop()
798
+ if t not in ('|', '|&'):
799
+ pipe_id += 1
800
+ elif t in ('<<', '<<-'):
801
+ continue # delimiter and body already consumed by the lexer
802
+ elif t in WREDIR or t in OREDIR or t == '>&':
803
+ if pending_redir[0] is not None:
804
+ raise Fallback()
805
+ pending_redir[0] = t
806
+ else:
807
+ raise Fallback()
808
+ flush()
809
+ except Fallback:
810
+ return 3
811
+
812
+ for pid in set(xargs_pipes):
813
+ for sp, sargv, scwd in segs:
814
+ if sp != pid or not sargv or os.path.basename(sargv[0].v) == 'xargs':
815
+ continue
816
+ for a in sargv[1:]:
817
+ if a.v.startswith('-') or a.exp:
818
+ continue
819
+ check(a, scwd, 'fallback')
820
+
821
+ if not hits:
822
+ return 3 if fb[0] else 0
823
+
824
+ anc = [h for h in hits if h[2] == 'anc']
825
+ hnames = sorted({h[0] for h in hits})
826
+ if anc:
827
+ print("[Hook] ⛔ RULE DELETION BLOCKED — Parent directory deletion detected", file=sys.stderr)
828
+ print("[Hook] Target: %s (an ancestor of the project rule directories)" % anc[0][0], file=sys.stderr)
829
+ print("[Hook] This recursive delete/move would also remove .claude/rules (or templates/.claude/rules).",
830
+ file=sys.stderr)
831
+ print("[Hook] Delete rules individually with user confirmation.", file=sys.stderr)
832
+ print("[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지).", file=sys.stderr)
833
+ elif len(hnames) > 1 or any(h[1] for h in hits):
834
+ print("[Hook] ⛔ RULE DELETION BLOCKED — Multiple rules detected", file=sys.stderr)
835
+ print("[Hook] Targets: %s" % ", ".join(hnames), file=sys.stderr)
836
+ print("[Hook] Rule files must be deleted ONE AT A TIME with user confirmation.", file=sys.stderr)
837
+ print("[Hook] Delete each rule individually after asking: \"정말 {파일명}을(를) 삭제하시겠습니까?\"", file=sys.stderr)
838
+ print("[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지).", file=sys.stderr)
839
+ else:
840
+ fn = os.path.basename(hnames[0]) or hnames[0]
841
+ print("[Hook] ⛔ RULE DELETION BLOCKED", file=sys.stderr)
842
+ print("[Hook] Target: %s" % hnames[0], file=sys.stderr)
843
+ print("[Hook] Rule files require individual user confirmation before deletion.", file=sys.stderr)
844
+ print("[Hook] Ask the user: \"정말 %s을(를) 삭제하시겠습니까?\"" % fn, file=sys.stderr)
845
+ print("[Hook] Only proceed after explicit user approval.", file=sys.stderr)
846
+ print("[Hook] 룰 파일 수정은 Write/Edit 도구를 사용하십시오 (셸 리다이렉트/삭제 금지).", file=sys.stderr)
847
+ return 2
848
+
849
+
850
+ try:
851
+ data = json.loads(sys.stdin.buffer.read().decode('utf-8', 'replace'))
852
+ except Exception:
853
+ sys.exit(0)
854
+ if not isinstance(data, dict):
855
+ sys.exit(0)
856
+ tool = data.get('tool_name') or data.get('tool') or ''
857
+ ti = data.get('tool_input')
858
+ command = ti.get('command') if isinstance(ti, dict) else ''
859
+ if tool != 'Bash' or not isinstance(command, str) or not command:
860
+ sys.exit(0)
861
+ try:
862
+ rc = main(data, command)
863
+ except Exception:
864
+ rc = 3
865
+ if rc == 3:
866
+ sys.stdout.write(command)
867
+ sys.exit(rc)
868
+ PY
869
+ }
870
+
871
+ if command -v python3 &>/dev/null; then
872
+ # Cheap pre-filter: skip the interpreter unless a destructive verb word or '>' appears in the payload.
873
+ if ! printf '%s' "$input" | grep -qE '(^|[^[:alnum:]_]|\\[nrt]|\\u00)(rm|mv|unlink|rmdir|shred|truncate|find|xargs|clean)([^[:alnum:]_]|$)|>'; then
874
+ pass
875
+ fi
876
+
877
+ out=$(printf '%s' "$input" | run_analyzer)
878
+ rc=$?
879
+ case "$rc" in
880
+ 0) pass ;;
881
+ 2) exit 2 ;;
882
+ 3) legacy_check "$out" strict; pass ;;
883
+ *)
884
+ # Unexpected interpreter failure: fall back to legacy via jq if possible, else fail-open
885
+ if command -v jq &>/dev/null; then
886
+ cmd=$(printf '%s\n' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null) || pass
887
+ tool=$(printf '%s\n' "$input" | jq -r '.tool_name // .tool // ""' 2>/dev/null) || pass
888
+ [ "$tool" = "Bash" ] && legacy_check "$cmd" strict
889
+ fi
890
+ pass
891
+ ;;
892
+ esac
56
893
  fi
57
894
 
58
- # Not a rule deletion — pass through
59
- printf '%s\n' "$input"
60
- exit 0
895
+ # python3 absent: legacy regex logic (jq required)
896
+ if ! command -v jq &>/dev/null; then
897
+ pass
898
+ fi
899
+ tool=$(printf '%s\n' "$input" | jq -r '.tool_name // .tool // ""' 2>/dev/null) || pass
900
+ cmd=$(printf '%s\n' "$input" | jq -r '.tool_input.command // ""' 2>/dev/null) || pass
901
+ if [ "$tool" != "Bash" ]; then
902
+ pass
903
+ fi
904
+ legacy_check "$cmd"
905
+ pass
@@ -182,13 +182,13 @@ Semver comparison: major → minor → patch (all numeric). Pre-release suffixes
182
182
 
183
183
  ### Automatic (SessionStart Hook)
184
184
 
185
- Can be integrated into the SessionStart hook to check for new releases at session start:
185
+ Can be integrated into the SessionStart hook to check for new releases at session start (illustrative example — `claude-native-check.sh` is a hypothetical script you would write yourself; it is not shipped or registered in `hooks.json`):
186
186
 
187
187
  ```json
188
188
  {
189
189
  "SessionStart": [
190
190
  {
191
- "command": "bash .claude/hooks/scripts/claude-native-check.sh"
191
+ "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/claude-native-check.sh\""
192
192
  }
193
193
  ]
194
194
  }
@@ -145,6 +145,8 @@ Assemble a feedback issue in Korean using the #1266 format:
145
145
 
146
146
  If `--severity` filter is active, include only findings at or above the threshold. Note the filter in the issue body.
147
147
 
148
+ When a 하네스 제안 item contains a runnable command, run it once at draft time (positive and negative fixture where applicable) and record the observed exit code/output next to that proposal; a command that only reports failure must be caught here, not in review (e.g. `bunx biome check` exits 0 inside biome's `files.includes` scope but 1 outside it, so a norm requiring it for all TS/JS reports failure for out-of-scope files regardless of formatting). (#1781 찐빠 #1)
149
+
148
150
  If no findings are discovered, output:
149
151
  ```
150
152
  [homework] 이번 세션에서 찐빠를 발견하지 못했습니다. (세션 정상 종료)
@@ -413,7 +413,7 @@ steps:
413
413
 
414
414
  - name: deep-plan
415
415
  skill: deep-plan
416
- description: "Research-validated implementation plan (research → plan → verify) — skipped if docs-only, integrated-analysis allowed if lite; independent of tier, MAY run in lightweight mode when compression-mode-eval's 'Cross-tier — Lightweight Skill-Mode Substitution' conditions are met. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). 리서치·계측 위임의 완료 조건에는 결론 수치를 산출물 표에서 jq/awk로 재계산해 병기하도록 명시한다(#1707 #4). 테스트 격리·모듈 모킹 수정을 다루는 리서치·계획 위임에도 대상 src 파일과 All files의 수정 전후 커버리지를 동일 하네스로 비교하도록 완료 조건에 명시합니다(#1778 찐빠 #5, implement 스텝 Rules와 동일 요구)."
416
+ description: "Research-validated implementation plan (research → plan → verify) — skipped if docs-only, integrated-analysis allowed if lite; independent of tier, MAY run in lightweight mode when compression-mode-eval's 'Cross-tier — Lightweight Skill-Mode Substitution' conditions are met. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). 리서치·계측 위임의 완료 조건에는 결론 수치를 산출물 표에서 jq/awk로 재계산해 병기하도록 명시한다(#1707 #4). 테스트 격리·모듈 모킹 수정을 다루는 리서치·계획 위임에도 대상 src 파일과 All files의 수정 전후 커버리지를 동일 하네스로 비교하도록 완료 조건에 명시합니다(#1778 찐빠 #5, implement 스텝 Rules와 동일 요구). 적용 경계 표에는 각 행마다 이 위치를 읽는 위임 유형/단계 열을 두고 결함이 발생한 단계와 일치하는지 표에서 대조합니다(#1781 찐빠 #2). 기본값·출력 리터럴을 바꾸는 계획은 그 리터럴을 단언하는 기존 테스트를 git grep으로 전수 열거해 웨이브 표의 파일 소유권에 포함합니다(git grep은 untracked 파일을 보지 못하므로 git status --porcelain도 확인합니다, #1786 찐빠 #5)."
417
417
  depends_on: plan
418
418
 
419
419
  - name: implement
@@ -486,6 +486,9 @@ steps:
486
486
  its lightweight tier substitute, applying to lite and standard tiers, and
487
487
  implement-stage delegation prompts MUST carry the relevant boundary row for each edit
488
488
  (회고 제안 묶음 릴리즈의 통합 분석에 적용 경계 표 필수, #1735 찐빠 #4).
489
+ The table MUST include a column naming which delegation type/step reads each target
490
+ location and be checked against the step where the defect occurred; a mismatch means
491
+ the clause must also be placed at the location that step reads (#1781 찐빠 #2).
489
492
  - Before dispatching any delegation prompt in an iteration that created or reinforced a rule
490
493
  clause, the orchestrator MUST compare that clause's Anti-pattern table rows 1:1 against the
491
494
  delegation prompt's sentences and rewrite any match (cause: the R016 「신설 조항의 동일
@@ -502,7 +505,7 @@ steps:
502
505
  block: (a) Korean 합쇼체 for new sentences, do not imitate adjacent 반말 — completion
503
506
  criteria MUST include THREE deterministic checks, scoped to edited text files (md/yaml)
504
507
  and restricted to added lines only (`git diff -U0 -- <edited md/yaml files> | grep
505
- '^+'`): family check `grep -cE '(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 "]'`
508
+ '^+'`): family check `grep -cE '(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 "(]'`
506
509
  = 0, line-final auxiliary check `grep -E '다[.。"]?$' | grep -vcE '니다[.。"]?$'` = 0
507
510
  (the family list alone misses endings such as 따른다/했다/있다 and unpunctuated
508
511
  line-final endings; the line-final check excludes 합쇼체 `-니다` endings so it does not
@@ -521,7 +524,7 @@ steps:
521
524
  python3 `re` equivalent of all THREE checks above, reading from the SAME piped stdin as
522
525
  (a) (`git diff -U0 -- <edited md/yaml files> | grep '^+' | python3 -c "import re,sys;
523
526
  text=sys.stdin.read();
524
- fam=len(re.findall(r'(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 \"]', text));
527
+ fam=len(re.findall(r'(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 \"(]', text));
525
528
  lf=len(re.findall(r'다[.。\"]?$', text, re.M)) - len(re.findall(r'니다[.。\"]?$', text, re.M));
526
529
  noun=len(re.findall(r'(함|됨|필요)[.。\"]?$', text, re.M));
527
530
  print(fam, lf, noun)"`, NOT a file-argument read — the input is the piped diff, never a
@@ -550,7 +553,11 @@ steps:
550
553
  delegation creates MUST live under a per-agent-unique path (`$TMPDIR` or the session
551
554
  scratchpad), never a fixed shared path (#1722 찐빠 #7). This governs the agent's own
552
555
  ad-hoc temp files; temp paths created at runtime by test code are governed by the
553
- separate test-code temp-path bullet below (#1778 찐빠 #4).
556
+ separate test-code temp-path bullet below (#1778 찐빠 #4); (i) when the edit writes a runnable
557
+ command or code snippet into rule/skill/guide/workflow text, the agent MUST copy that command OUT of the
558
+ edited file and run it on a positive and a negative fixture, putting both raw outputs in the
559
+ completion report (the #1752 fixture bullet below, carried here so it reaches the prompt;
560
+ #1781 찐빠 #1).
554
561
  - Any implement-stage delegation that writes an executable command or code snippet into
555
562
  rule/skill/guide/workflow TEXT (not a prose-only edit) MUST require, as a completion-report
556
563
  item, that the agent copy the command OUT of the edited file and run it once on a positive
@@ -706,6 +713,13 @@ steps:
706
713
  measurement; (c) no working around a discovered product or measurement defect to force
707
714
  a pass — halt and report instead (R023 「Conditional-Output Verification」 양성/음성 짝
708
715
  원칙 확장, #1721 찐빠 #1, #1727 찐빠 #2, #1728 찐빠 #2).
716
+ - A delegation that adds an item to an existing list or loop (e.g. a sync-file list) MUST
717
+ require, as a completion item, citing with file:line how that list handles preserveFiles,
718
+ protection and backup contracts, and judging whether the new item is consistent with the
719
+ other components in the same run (#1786 찐빠 #1).
720
+ - A delegation that writes migration/upgrade tests MUST require, as a completion item, that
721
+ every "old state" fixture be derived from that version's generator output (or real tagged
722
+ artifacts; cite the call used), never hand-written from source expressions (#1786 찐빠 #2).
709
723
  - Delegations (research or implementation) that design or fix test isolation or module
710
724
  mocking MUST include in their completion criteria a before/after coverage comparison
711
725
  of the target src file(s) AND the All-files total, measured with the SAME harness
@@ -50,14 +50,14 @@ MCP tool response (37K+ chars)
50
50
 
51
51
  ## Hook Configuration
52
52
 
53
- Configured in `.claude/hooks/hooks.json` PostToolUse section:
53
+ Configured in `.claude/hooks/hooks.json` PostToolUse section (the entry whose command runs `playwright-compress.sh`):
54
54
 
55
55
  ```json
56
56
  {
57
57
  "matcher": "mcp_tool_name matches \"mcp__playwright__.*\" || mcp_tool_name matches \"mcp__claude-in-chrome__.*\"",
58
58
  "hooks": [{
59
59
  "type": "command",
60
- "command": "bash .claude/hooks/scripts/playwright-compress.sh"
60
+ "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/playwright-compress.sh\""
61
61
  }],
62
62
  "description": "Layer 4: Compress Playwright/Chrome MCP output via Haiku summarization"
63
63
  }
@@ -348,13 +348,13 @@ fi
348
348
  exit 0
349
349
  ```
350
350
 
351
- hooks.json 등록:
351
+ hooks.json 등록 (illustrative — `your-middleware.sh` is a placeholder, not a shipped script):
352
352
  ```json
353
353
  {
354
354
  "PreToolUse": [
355
355
  {
356
356
  "matcher": "Bash",
357
- "command": ".claude/hooks/your-middleware.sh",
357
+ "command": "bash \"${CLAUDE_PROJECT_DIR:-.}/.claude/hooks/scripts/your-middleware.sh\"",
358
358
  "description": "lifecycle: wrap_tool_call — 위험 패턴 감지"
359
359
  }
360
360
  ]
@@ -394,7 +394,7 @@ limitations:
394
394
  훅 스크립트의 단위 테스트:
395
395
  ```bash
396
396
  # PreToolUse hook 직접 테스트
397
- CLAUDE_TOOL_NAME="Bash" CLAUDE_TOOL_INPUT="rm -rf /" .claude/hooks/your-middleware.sh
397
+ CLAUDE_TOOL_NAME="Bash" CLAUDE_TOOL_INPUT="rm -rf /" .claude/hooks/scripts/your-middleware.sh
398
398
  echo "Exit: $?" # 0 또는 2 확인
399
399
  ```
400
400
 
@@ -1,5 +1,5 @@
1
1
  {
2
- "version": "1.1.94",
2
+ "version": "1.1.95",
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)",
@@ -413,7 +413,7 @@ steps:
413
413
 
414
414
  - name: deep-plan
415
415
  skill: deep-plan
416
- description: "Research-validated implementation plan (research → plan → verify) — skipped if docs-only, integrated-analysis allowed if lite; independent of tier, MAY run in lightweight mode when compression-mode-eval's 'Cross-tier — Lightweight Skill-Mode Substitution' conditions are met. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). 리서치·계측 위임의 완료 조건에는 결론 수치를 산출물 표에서 jq/awk로 재계산해 병기하도록 명시한다(#1707 #4). 테스트 격리·모듈 모킹 수정을 다루는 리서치·계획 위임에도 대상 src 파일과 All files의 수정 전후 커버리지를 동일 하네스로 비교하도록 완료 조건에 명시합니다(#1778 찐빠 #5, implement 스텝 Rules와 동일 요구)."
416
+ description: "Research-validated implementation plan (research → plan → verify) — skipped if docs-only, integrated-analysis allowed if lite; independent of tier, MAY run in lightweight mode when compression-mode-eval's 'Cross-tier — Lightweight Skill-Mode Substitution' conditions are met. MULTI-PHASE: 스킬을 그대로 spawn하지 말고 R020 「위임 경계를 Phase 개수로 설계」에 따라 단일 목표 위임으로 분할해 순차 발주한다 (#1595 #4). 리서치·계측 위임의 완료 조건에는 결론 수치를 산출물 표에서 jq/awk로 재계산해 병기하도록 명시한다(#1707 #4). 테스트 격리·모듈 모킹 수정을 다루는 리서치·계획 위임에도 대상 src 파일과 All files의 수정 전후 커버리지를 동일 하네스로 비교하도록 완료 조건에 명시합니다(#1778 찐빠 #5, implement 스텝 Rules와 동일 요구). 적용 경계 표에는 각 행마다 이 위치를 읽는 위임 유형/단계 열을 두고 결함이 발생한 단계와 일치하는지 표에서 대조합니다(#1781 찐빠 #2). 기본값·출력 리터럴을 바꾸는 계획은 그 리터럴을 단언하는 기존 테스트를 git grep으로 전수 열거해 웨이브 표의 파일 소유권에 포함합니다(git grep은 untracked 파일을 보지 못하므로 git status --porcelain도 확인합니다, #1786 찐빠 #5)."
417
417
  depends_on: plan
418
418
 
419
419
  - name: implement
@@ -486,6 +486,9 @@ steps:
486
486
  its lightweight tier substitute, applying to lite and standard tiers, and
487
487
  implement-stage delegation prompts MUST carry the relevant boundary row for each edit
488
488
  (회고 제안 묶음 릴리즈의 통합 분석에 적용 경계 표 필수, #1735 찐빠 #4).
489
+ The table MUST include a column naming which delegation type/step reads each target
490
+ location and be checked against the step where the defect occurred; a mismatch means
491
+ the clause must also be placed at the location that step reads (#1781 찐빠 #2).
489
492
  - Before dispatching any delegation prompt in an iteration that created or reinforced a rule
490
493
  clause, the orchestrator MUST compare that clause's Anti-pattern table rows 1:1 against the
491
494
  delegation prompt's sentences and rewrite any match (cause: the R016 「신설 조항의 동일
@@ -502,7 +505,7 @@ steps:
502
505
  block: (a) Korean 합쇼체 for new sentences, do not imitate adjacent 반말 — completion
503
506
  criteria MUST include THREE deterministic checks, scoped to edited text files (md/yaml)
504
507
  and restricted to added lines only (`git diff -U0 -- <edited md/yaml files> | grep
505
- '^+'`): family check `grep -cE '(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 "]'`
508
+ '^+'`): family check `grep -cE '(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 "(]'`
506
509
  = 0, line-final auxiliary check `grep -E '다[.。"]?$' | grep -vcE '니다[.。"]?$'` = 0
507
510
  (the family list alone misses endings such as 따른다/했다/있다 and unpunctuated
508
511
  line-final endings; the line-final check excludes 합쇼체 `-니다` endings so it does not
@@ -521,7 +524,7 @@ steps:
521
524
  python3 `re` equivalent of all THREE checks above, reading from the SAME piped stdin as
522
525
  (a) (`git diff -U0 -- <edited md/yaml files> | grep '^+' | python3 -c "import re,sys;
523
526
  text=sys.stdin.read();
524
- fam=len(re.findall(r'(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 \"]', text));
527
+ fam=len(re.findall(r'(한다|된다|않는다|따른다|했다|있다|없다|넣는다|이다)[.。 \"(]', text));
525
528
  lf=len(re.findall(r'다[.。\"]?$', text, re.M)) - len(re.findall(r'니다[.。\"]?$', text, re.M));
526
529
  noun=len(re.findall(r'(함|됨|필요)[.。\"]?$', text, re.M));
527
530
  print(fam, lf, noun)"`, NOT a file-argument read — the input is the piped diff, never a
@@ -550,7 +553,11 @@ steps:
550
553
  delegation creates MUST live under a per-agent-unique path (`$TMPDIR` or the session
551
554
  scratchpad), never a fixed shared path (#1722 찐빠 #7). This governs the agent's own
552
555
  ad-hoc temp files; temp paths created at runtime by test code are governed by the
553
- separate test-code temp-path bullet below (#1778 찐빠 #4).
556
+ separate test-code temp-path bullet below (#1778 찐빠 #4); (i) when the edit writes a runnable
557
+ command or code snippet into rule/skill/guide/workflow text, the agent MUST copy that command OUT of the
558
+ edited file and run it on a positive and a negative fixture, putting both raw outputs in the
559
+ completion report (the #1752 fixture bullet below, carried here so it reaches the prompt;
560
+ #1781 찐빠 #1).
554
561
  - Any implement-stage delegation that writes an executable command or code snippet into
555
562
  rule/skill/guide/workflow TEXT (not a prose-only edit) MUST require, as a completion-report
556
563
  item, that the agent copy the command OUT of the edited file and run it once on a positive
@@ -706,6 +713,13 @@ steps:
706
713
  measurement; (c) no working around a discovered product or measurement defect to force
707
714
  a pass — halt and report instead (R023 「Conditional-Output Verification」 양성/음성 짝
708
715
  원칙 확장, #1721 찐빠 #1, #1727 찐빠 #2, #1728 찐빠 #2).
716
+ - A delegation that adds an item to an existing list or loop (e.g. a sync-file list) MUST
717
+ require, as a completion item, citing with file:line how that list handles preserveFiles,
718
+ protection and backup contracts, and judging whether the new item is consistent with the
719
+ other components in the same run (#1786 찐빠 #1).
720
+ - A delegation that writes migration/upgrade tests MUST require, as a completion item, that
721
+ every "old state" fixture be derived from that version's generator output (or real tagged
722
+ artifacts; cite the call used), never hand-written from source expressions (#1786 찐빠 #2).
709
723
  - Delegations (research or implementation) that design or fix test isolation or module
710
724
  mocking MUST include in their completion criteria a before/after coverage comparison
711
725
  of the target src file(s) AND the All-files total, measured with the SAME harness