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 +8 -6
- package/dist/index.js +8 -6
- package/package.json +1 -1
- package/templates/.claude/agents/mgr-gitnerd.md +8 -1
- package/templates/.claude/hooks/scripts/rule-deletion-guard.sh +890 -45
- package/templates/.claude/skills/claude-native/SKILL.md +2 -2
- package/templates/.claude/skills/homework/SKILL.md +2 -0
- package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +18 -4
- package/templates/.claude/skills/playwright-compress/SKILL.md +2 -2
- package/templates/guides/middleware-patterns/README.md +3 -3
- package/templates/manifest.json +1 -1
- package/templates/workflows/auto-dev.yaml +18 -4
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
|
|
94
|
+
return emptyRegistry();
|
|
92
95
|
} catch {
|
|
93
|
-
return
|
|
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,
|
|
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.
|
|
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
|
|
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
|
|
252
|
+
return emptyRegistry();
|
|
250
253
|
} catch {
|
|
251
|
-
return
|
|
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,
|
|
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
|
|
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.
|
|
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
|
@@ -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
|
-
|
|
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
|
-
#
|
|
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
|
-
#
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
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
|
-
#
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
#
|
|
20
|
-
if
|
|
21
|
-
|
|
22
|
-
|
|
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
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
#
|
|
59
|
-
|
|
60
|
-
|
|
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
|
|
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
|
|
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": "
|
|
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
|
|
package/templates/manifest.json
CHANGED
|
@@ -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
|