oh-my-customcode 2.0.7 → 2.0.9
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/README.md +3 -2
- package/dist/cli/index.js +28 -7
- package/dist/index.js +28 -7
- package/package.json +1 -1
- package/templates/.claude/hooks/hooks.json +29 -13
- package/templates/.claude/hooks/scripts/agent-teams-advisor.sh +47 -22
- package/templates/.claude/hooks/scripts/auto-continue-guard.sh +5 -5
- package/templates/.claude/hooks/scripts/auto-dev-token-tracker.sh +12 -1
- package/templates/.claude/hooks/scripts/bash-cd-advisor.sh +172 -0
- package/templates/.claude/hooks/scripts/content-hash-validator.sh +65 -48
- package/templates/.claude/hooks/scripts/destructive-git-guard.sh +32 -17
- package/templates/.claude/hooks/scripts/fail-axis-cause-advisor.sh +2 -2
- package/templates/.claude/hooks/scripts/failure-ledger.sh +18 -0
- package/templates/.claude/hooks/scripts/file-change-validator.sh +6 -3
- package/templates/.claude/hooks/scripts/git-delegation-guard.sh +178 -27
- package/templates/.claude/hooks/scripts/model-escalation-advisor.sh +43 -20
- package/templates/.claude/hooks/scripts/omcustom-auto-update.sh +19 -5
- package/templates/.claude/hooks/scripts/rule-deletion-guard.sh +118 -1
- package/templates/.claude/hooks/scripts/schema-validator.sh +8 -11
- package/templates/.claude/hooks/scripts/session-env-check.sh +20 -5
- package/templates/.claude/hooks/scripts/stall-detection-advisor.sh +9 -5
- package/templates/.claude/hooks/scripts/stuck-detector.sh +21 -4
- package/templates/.claude/hooks/scripts/subagent-failure-advisor.sh +48 -29
- package/templates/.claude/hooks/scripts/task-outcome-recorder.sh +15 -8
- package/templates/.claude/ontology/agents.yaml +71 -7
- package/templates/.claude/ontology/graphs/agent-skill.json +64 -5
- package/templates/.claude/ontology/graphs/full-graph.json +236 -3
- package/templates/.claude/ontology/skills.yaml +65 -1
- package/templates/.claude/rules/MUST-agent-design.md +1 -11
- package/templates/.claude/rules/MUST-enforcement-policy.md +7 -7
- package/templates/.claude/rules/MUST-orchestrator-coordination.md +1 -1
- package/templates/.claude/rules/SHOULD-memory-integration.md +1 -1
- package/templates/.claude/settings.json +29 -13
- package/templates/.claude/skills/fsd/SKILL.md +10 -9
- package/templates/.claude/skills/goal/SKILL.md +5 -5
- package/templates/.claude/skills/model-escalation/SKILL.md +16 -12
- package/templates/.claude/skills/omcustom-loop/SKILL.md +4 -4
- package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +13 -0
- package/templates/guides/claude-code/04-agent-skills.md +1 -1
- package/templates/guides/claude-code/15-version-compatibility.md +47 -47
- package/templates/guides/git-safety/README.md +4 -1
- package/templates/guides/middleware-patterns/README.md +4 -4
- package/templates/guides/multi-model-routing/README.md +1 -7
- package/templates/manifest.json +1 -1
- package/templates/workflows/auto-dev.yaml +13 -0
|
@@ -1,7 +1,69 @@
|
|
|
1
1
|
#!/bin/bash
|
|
2
|
-
#
|
|
3
|
-
#
|
|
4
|
-
#
|
|
2
|
+
# git-delegation-guard.sh — PreToolUse(Task|Agent): R010 git 위임 감지 (advisory, #1885)
|
|
3
|
+
#
|
|
4
|
+
# Warns when a delegation prompt to a non-mgr-gitnerd agent mentions git write keywords.
|
|
5
|
+
# Advisory only: always exit 0, never blocks, never modifies tool input.
|
|
6
|
+
#
|
|
7
|
+
# Delivery (#1885): exit 0 + stderr never reaches the model (measured, #1775 comment
|
|
8
|
+
# 6405197553). The warning is therefore emitted as ONE stdout JSON line
|
|
9
|
+
# hookSpecificOutput.additionalContext; nothing is written to stderr and the input is
|
|
10
|
+
# no longer echoed back. No warning -> no output.
|
|
11
|
+
#
|
|
12
|
+
# Precision (#1885): a keyword occurrence does NOT count when (case-insensitive, LC_ALL=C bytes;
|
|
13
|
+
# U+2019 is folded to `'`; `git -C <path>` / `git -c k=v` option groups are stripped first so
|
|
14
|
+
# `git -C /repo commit` is treated like `git commit`):
|
|
15
|
+
# (a) a negation token appears BEFORE it on the same line. English tokens are whole words
|
|
16
|
+
# (`-` and `_` stay inside words, so `--no-rebase`, `no-op`, `not_found` do not count):
|
|
17
|
+
# not, never, no, don't, avoid, without, forbidden, prohibited (so "do not" is covered);
|
|
18
|
+
# Korean substrings 금지, 말 것, 하지 마 (bare "않" was removed: too broad);
|
|
19
|
+
# (b) it sits in a prohibition block. Header words (word-bounded, after list/markdown markers):
|
|
20
|
+
# Forbidden, Prohibited, Do not, Don't, Never, Avoid, 금지, 하지 말 것.
|
|
21
|
+
# - colon header: a line starting with a header word whose `:` or full-width `:` ends the
|
|
22
|
+
# header phrase (at most 4 words before it). The block runs to the next blank line
|
|
23
|
+
# or the next non-prohibition label line (`Allowed: …`, `Steps:`, `**Steps**:`); a
|
|
24
|
+
# prohibition label (`Never: …`) continues it.
|
|
25
|
+
# - markdown heading (`#`..`######` + space; `#1885` is not a heading) starting with a header
|
|
26
|
+
# word (colon optional). The block covers only the FIRST paragraph or list after the
|
|
27
|
+
# heading: blank lines directly after the heading are skipped, then it runs through
|
|
28
|
+
# consecutive non-blank lines and ends at the next blank line, heading, or label line
|
|
29
|
+
# (`**Bold**` or a non-list line ending in a colon);
|
|
30
|
+
# - header colons count only when followed by whitespace/end of line (`https://x`, `10:30`
|
|
31
|
+
# do not); Korean header words need a boundary (whitespace, colon or end: `금지어` is not
|
|
32
|
+
# a header). Lines inside ``` fences never open or close blocks (fence lines themselves
|
|
33
|
+
# count as block content);
|
|
34
|
+
# (c) it is a read-only query:
|
|
35
|
+
# - `git branch` followed by --list, -l, --show-current, --contains, --all, --merged,
|
|
36
|
+
# --no-merged, -a, -r, -v or -vv, unless the same command also has -d/-D/--delete/-m/-M/
|
|
37
|
+
# -c/-C (`git branch -r -d origin/x` is a write and fires);
|
|
38
|
+
# - `git tag` followed by --list, -l, --contains, --points-at or --sort;
|
|
39
|
+
# - bare `git tag` / `git branch` used as a noun: followed by end of line, punctuation, `+`,
|
|
40
|
+
# a backtick, an opening bracket, or whitespace + a common prose word (is/the/and/...).
|
|
41
|
+
# `git branch feature-x` / `git tag v1` are creations and still fire.
|
|
42
|
+
# Keyword boundary: a keyword matches only if the next char is not [A-Za-z0-9_-] and the
|
|
43
|
+
# previous char is not [A-Za-z0-9_] (`git merge-base` does not match `git merge`; "legit
|
|
44
|
+
# commit" does not match `git commit`). Quoted `-C "/my repo"` / `-c 'k=v w'` arguments with
|
|
45
|
+
# spaces are stripped like unquoted ones.
|
|
46
|
+
# Warn only if at least one occurrence survives. The keyword set and the mgr-gitnerd
|
|
47
|
+
# exemption are unchanged. A non-string prompt is treated as empty; a missing subagent_type
|
|
48
|
+
# is shown as (unspecified).
|
|
49
|
+
#
|
|
50
|
+
# Measured precision: replay (8 newest main transcripts, dedup by tool_use id; subagent
|
|
51
|
+
# transcripts carry no Agent calls): old 167 -> 23 of 752 inputs; 0 clear true positives found
|
|
52
|
+
# in this repo's corpus. The hook stays model-visible (additionalContext, worded as a
|
|
53
|
+
# conditional advisory) because the base rate in installed projects differs [가설].
|
|
54
|
+
#
|
|
55
|
+
# Known false negatives (accepted trade-offs):
|
|
56
|
+
# - a real command after a negation word on the same line ("Do not skip this: git commit now"
|
|
57
|
+
# is read as a colon header and skipped with its block);
|
|
58
|
+
# - a real command inside a prohibition block not separated by a blank line (colon header) or
|
|
59
|
+
# placed in the first paragraph/list under a Forbidden heading;
|
|
60
|
+
# - prose "git tag"/"git branch" followed by a stop-listed word is treated as a noun.
|
|
61
|
+
# Known false positive (SOV trade-off): Korean "git commit 금지" (token after the keyword) is
|
|
62
|
+
# NOT excluded by (a) unless it sits in a block (b), so it still warns.
|
|
63
|
+
#
|
|
64
|
+
# State writes (violation file, threshold marker) are best-effort: failures are silent and never
|
|
65
|
+
# abort or leak to stderr. The per-session count line is emitted once, when the count is >= 3
|
|
66
|
+
# and the atomic marker dir /tmp/.claude-r010-threshold-${PPID} is created by this firing.
|
|
5
67
|
#
|
|
6
68
|
# PPID Scoping Convention:
|
|
7
69
|
# All session-scoped temp files MUST use $PPID (Claude Code parent PID),
|
|
@@ -14,22 +76,18 @@ command -v jq >/dev/null 2>&1 || exit 0
|
|
|
14
76
|
|
|
15
77
|
input=$(cat)
|
|
16
78
|
|
|
17
|
-
# Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0
|
|
18
|
-
# instead of reflecting garbage back on stdout. The sibling hooks (audit-log.sh,
|
|
19
|
-
# model-escalation-advisor.sh, task-outcome-recorder.sh) all carry this guard;
|
|
20
|
-
# this one did not, so a non-object payload was echoed through verbatim. (#1656 F)
|
|
79
|
+
# Guard: non-object stdin (non-JSON / JSON array / empty) — swallow and exit 0. (#1656 F)
|
|
21
80
|
printf '%s' "$input" | jq -e 'type=="object"' >/dev/null 2>&1 || exit 0
|
|
22
81
|
|
|
23
|
-
# `?` suppresses jq
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
# and advisory-only even if jq itself fails for an unrelated reason.
|
|
27
|
-
agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type? // ""' 2>/dev/null || echo "")
|
|
28
|
-
prompt=$(printf '%s\n' "$input" | jq -r '.tool_input.prompt? // ""' 2>/dev/null || echo "")
|
|
82
|
+
# `?` suppresses jq errors when tool_input is a scalar/array (#1656 A).
|
|
83
|
+
agent_type=$(printf '%s\n' "$input" | jq -r '(.tool_input.subagent_type? | strings) // ""' 2>/dev/null || echo "")
|
|
84
|
+
prompt=$(printf '%s\n' "$input" | jq -r '(.tool_input.prompt? | strings) // ""' 2>/dev/null || echo "")
|
|
29
85
|
|
|
30
86
|
# R010 violation tracking file (PPID-scoped for session persistence)
|
|
31
87
|
VIOLATION_FILE="/tmp/.claude-r010-violations-${PPID}"
|
|
32
88
|
|
|
89
|
+
ctx=""
|
|
90
|
+
|
|
33
91
|
# Only warn when the delegated agent is NOT mgr-gitnerd
|
|
34
92
|
if [ "$agent_type" != "mgr-gitnerd" ]; then
|
|
35
93
|
git_keywords=(
|
|
@@ -45,23 +103,116 @@ if [ "$agent_type" != "mgr-gitnerd" ]; then
|
|
|
45
103
|
"git cherry-pick"
|
|
46
104
|
"git tag"
|
|
47
105
|
)
|
|
106
|
+
kw_list=$(IFS='|'; printf '%s' "${git_keywords[*]}")
|
|
48
107
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
108
|
+
# First keyword (in list order) with at least one surviving occurrence.
|
|
109
|
+
keyword=$(printf '%s\n' "$prompt" | LC_ALL=C awk -v kws="$kw_list" '
|
|
110
|
+
function hw(x) {
|
|
111
|
+
return (x ~ /^(forbidden|prohibited|do not|don.t|never|avoid)([^a-z0-9_]|$)/ || x ~ /^(금지|하지 말 것)([ \t:]|$|\357\274\232)/)
|
|
112
|
+
}
|
|
113
|
+
# header colon: ":" or full-width ":" followed by whitespace/end of line, at most 4 words before it
|
|
114
|
+
function colon_header(x, i, len, ch, pre, k, tmp) {
|
|
115
|
+
len = length(x)
|
|
116
|
+
for (i = 1; i <= len; i++) {
|
|
117
|
+
ch = substr(x, i, 1)
|
|
118
|
+
if (ch == ":" && (i == len || substr(x, i + 1, 1) ~ /[ \t]/)) break
|
|
119
|
+
if (substr(x, i, 3) == "\357\274\232" && (i + 2 == len || substr(x, i + 3, 1) ~ /[ \t]/)) break
|
|
120
|
+
}
|
|
121
|
+
if (i > len) return 0
|
|
122
|
+
pre = substr(x, 1, i - 1)
|
|
123
|
+
k = split(pre, tmp, /[ \t]+/)
|
|
124
|
+
return (k <= 4)
|
|
125
|
+
}
|
|
126
|
+
BEGIN {
|
|
127
|
+
n = split(kws, K, "|")
|
|
128
|
+
inblock = 0; btype = ""; hstate = ""; infence = 0
|
|
129
|
+
neg = " (not|never|no|don.t|avoid|without|forbidden|prohibited) "
|
|
130
|
+
prose = "(is|was|are|and|or|the|a|an|to|of|in|on|at|for|with|before|after|then|that|which|when|if|as|from|by|name|names|first|output|result|state|you|we|it)"
|
|
131
|
+
}
|
|
132
|
+
{
|
|
133
|
+
l = tolower($0)
|
|
134
|
+
gsub(/\342\200\231/, "\047", l)
|
|
135
|
+
gsub(/git[ \t]+(-[cC][ \t]+([\"][^\"]*[\"]|\047[^\047]*\047|[^ \t]+)[ \t]+)+/, "git ", l)
|
|
136
|
+
isfence = (l ~ /^[ \t]*```/)
|
|
137
|
+
if (isfence) infence = !infence
|
|
138
|
+
if (l ~ /^[ \t\r]*$/) {
|
|
139
|
+
if (inblock && !infence) {
|
|
140
|
+
if (btype == "C") inblock = 0
|
|
141
|
+
else if (hstate == "body") inblock = 0
|
|
142
|
+
}
|
|
143
|
+
next
|
|
144
|
+
}
|
|
145
|
+
s = l
|
|
146
|
+
sub(/^[ \t>*#+_`-]+/, "", s)
|
|
147
|
+
sub(/^[0-9]+[.)][ \t]+/, "", s)
|
|
148
|
+
sub(/^[*_`]+/, "", s)
|
|
149
|
+
if (!isfence && !infence) {
|
|
150
|
+
if (l ~ /^[ \t]*(#|##|###|####|#####|######)[ \t]/) {
|
|
151
|
+
# heading: ends any block; a prohibition heading opens an H block (first paragraph/list only)
|
|
152
|
+
if (hw(s)) { inblock = 1; btype = "H"; hstate = "lead"; next }
|
|
153
|
+
inblock = 0; btype = ""
|
|
154
|
+
} else {
|
|
155
|
+
# a NEW colon-header line that is not itself a prohibition header ends a colon block
|
|
156
|
+
if (inblock && btype == "C" && colon_header(s) && !hw(s)) { inblock = 0; btype = "" }
|
|
157
|
+
# a label line (**Bold** or "text:") ends an H block
|
|
158
|
+
if (inblock && btype == "H" && (l ~ /^[ \t]*\*\*[^*]+\*\*[ \t]*[::]?[ \t]*$/ || (l !~ /^[ \t]*([-*+]|[0-9]+[.)])[ \t]/ && l ~ /(:|\357\274\232)[ \t]*$/))) { inblock = 0; btype = "" }
|
|
159
|
+
if (!inblock && hw(s) && colon_header(s)) { inblock = 1; btype = "C"; next }
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
if (inblock) { if (btype == "H") hstate = "body"; next }
|
|
163
|
+
for (i = 1; i <= n; i++) {
|
|
164
|
+
kw = K[i]
|
|
165
|
+
if (kw in surv) continue
|
|
166
|
+
off = 0
|
|
167
|
+
rest = l
|
|
168
|
+
while ((p = index(rest, kw)) > 0) {
|
|
169
|
+
pos = off + p
|
|
170
|
+
pre = substr(l, 1, pos - 1)
|
|
171
|
+
post = substr(l, pos + length(kw))
|
|
172
|
+
rest = substr(l, pos + 1)
|
|
173
|
+
off = pos
|
|
174
|
+
if (post ~ /^[a-z0-9_-]/) continue
|
|
175
|
+
if (pos > 1 && substr(l, pos - 1, 1) ~ /[a-z0-9_]/) continue
|
|
176
|
+
w = pre
|
|
177
|
+
gsub(/[^a-z0-9\047_-]/, " ", w)
|
|
178
|
+
w = " " w " "
|
|
179
|
+
if (w ~ neg) continue
|
|
180
|
+
if (index(pre, "금지") || index(pre, "말 것") || index(pre, "하지 마")) continue
|
|
181
|
+
if (kw == "git branch" || kw == "git tag") {
|
|
182
|
+
cmd = post
|
|
183
|
+
sub(/[;&|].*$/, "", cmd)
|
|
184
|
+
if (kw == "git branch" && cmd ~ /(^|[ \t])(--delete|-[a-z]*[dmc][a-z]*)([ \t]|$)/) { surv[kw] = 1; break }
|
|
185
|
+
if (kw == "git branch" && post ~ /^[ \t]+(--list|-l|--show-current|--contains|--all|--merged|--no-merged|-a|-r|-v|-vv)([^a-z0-9-]|$)/) continue
|
|
186
|
+
if (kw == "git tag" && post ~ /^[ \t]+(--list|-l|--contains|--points-at|--sort)([^a-z0-9-]|$)/) continue
|
|
187
|
+
if (post ~ /^[ \t]*$/ || post ~ /^[+`.,;:)\]\047"!?|\/]/ || post ~ /^[ \t]+[(\[]/ || post ~ ("^[ \t]+" prose "([^a-z0-9_-]|$)")) continue
|
|
188
|
+
}
|
|
189
|
+
surv[kw] = 1
|
|
190
|
+
break
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
END {
|
|
195
|
+
for (i = 1; i <= n; i++) if (K[i] in surv) { print K[i]; exit }
|
|
196
|
+
}
|
|
197
|
+
')
|
|
53
198
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
echo "[Hook] R010 violations: ${violation_count} this session — R021 promotion threshold reached" >&2
|
|
59
|
-
fi
|
|
199
|
+
if [ -n "$keyword" ]; then
|
|
200
|
+
if [ -n "$agent_type" ]; then shown="'$agent_type'"; else shown="(unspecified)"; fi
|
|
201
|
+
ctx="[Hook] ADVISORY: delegation prompt to $shown mentions '$keyword' — if this delegates a git operation, route it to mgr-gitnerd (R010)."
|
|
202
|
+
ctx="$ctx"$'\n'"[Hook] Per R010, all git operations (commit/push/branch/merge/etc.) MUST be delegated to mgr-gitnerd"
|
|
60
203
|
|
|
61
|
-
|
|
204
|
+
# Record mention for R021 promotion tracking (best-effort, silent on failure)
|
|
205
|
+
{ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $keyword ${agent_type:-(unspecified)}" >> "$VIOLATION_FILE"; } 2>/dev/null || true
|
|
206
|
+
violation_count=$({ wc -l < "$VIOLATION_FILE" | tr -d ' '; } 2>/dev/null || true)
|
|
207
|
+
# Count line once: count >= 3 AND the atomic marker dir is created by this firing.
|
|
208
|
+
if [ "${violation_count:-0}" -ge 3 ] 2>/dev/null && { mkdir "/tmp/.claude-r010-threshold-${PPID}"; } 2>/dev/null; then
|
|
209
|
+
ctx="$ctx"$'\n'"[Hook] R010 git-keyword mentions: ${violation_count} this session (advisory telemetry; R021 promotion needs user approval)"
|
|
62
210
|
fi
|
|
63
|
-
|
|
211
|
+
fi
|
|
212
|
+
fi
|
|
213
|
+
|
|
214
|
+
if [ -n "$ctx" ]; then
|
|
215
|
+
jq -cn --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
|
|
64
216
|
fi
|
|
65
217
|
|
|
66
|
-
|
|
67
|
-
printf '%s\n' "$input"
|
|
218
|
+
exit 0
|
|
@@ -7,7 +7,13 @@ command -v jq >/dev/null 2>&1 || exit 0
|
|
|
7
7
|
# Model Escalation Advisor Hook
|
|
8
8
|
# Trigger: PreToolUse, tool == "Task" || tool == "Agent"
|
|
9
9
|
# Purpose: Advise model escalation when failure patterns detected
|
|
10
|
-
# Protocol: stdin JSON -> process ->
|
|
10
|
+
# Protocol: stdin JSON -> process -> exit 0 always
|
|
11
|
+
# Once per session (#1885): each distinct advisory key is shown at most once, claimed atomically via a
|
|
12
|
+
# per-key marker dir under /tmp/.claude-model-advisory-shown-${PPID}.d/ (parallel-hook safe).
|
|
13
|
+
# De-escalation is opt-in (OMCUSTOM_MODEL_DEESCALATION=on): task-outcome-recorder.sh records every
|
|
14
|
+
# SubagentStop as outcome=success (no error signal), so "N consecutive successes" only means N agents finished (#1885).
|
|
15
|
+
# Channel (#1885): exit-0 stderr never reaches the model; advice is delivered via stdout JSON
|
|
16
|
+
# hookSpecificOutput.additionalContext (measured CC 2.1.310, #1775). No stdin passthrough, no stderr.
|
|
11
17
|
|
|
12
18
|
input=$(cat)
|
|
13
19
|
|
|
@@ -24,10 +30,25 @@ OUTCOME_FILE="/tmp/.claude-task-outcomes-${PPID}"
|
|
|
24
30
|
|
|
25
31
|
# Skip if no history
|
|
26
32
|
if [ ! -f "$OUTCOME_FILE" ]; then
|
|
27
|
-
printf '%s\n' "$input"
|
|
28
33
|
exit 0
|
|
29
34
|
fi
|
|
30
35
|
|
|
36
|
+
ctx=""
|
|
37
|
+
SHOWN_DIR="/tmp/.claude-model-advisory-shown-${PPID}.d"
|
|
38
|
+
# fresh KEY: atomic claim via mkdir of a per-key marker dir (hash of KEY; no shared file, so no
|
|
39
|
+
# check-then-append race and no newline pollution). Success => emit. State failures are silent;
|
|
40
|
+
# if the marker dir itself is unusable we fail open (emit) rather than lose the advisory.
|
|
41
|
+
fresh() {
|
|
42
|
+
{ mkdir -p "$SHOWN_DIR"; } 2>/dev/null || true
|
|
43
|
+
[ -d "$SHOWN_DIR" ] || return 0
|
|
44
|
+
local h
|
|
45
|
+
h=$(printf '%s' "$1" | cksum | cut -d' ' -f1) || h=""
|
|
46
|
+
[ -n "$h" ] || return 0
|
|
47
|
+
if { mkdir "$SHOWN_DIR/$h"; } 2>/dev/null; then return 0; fi
|
|
48
|
+
return 1
|
|
49
|
+
}
|
|
50
|
+
add() { if [ -z "$ctx" ]; then ctx="$1"; else ctx="${ctx}"$'\n'"$1"; fi; }
|
|
51
|
+
|
|
31
52
|
# Thresholds
|
|
32
53
|
FAILURE_THRESHOLD=2
|
|
33
54
|
CONSECUTIVE_THRESHOLD=3
|
|
@@ -85,20 +106,20 @@ if [ -n "$next_model" ]; then
|
|
|
85
106
|
reason="${consecutive_failures} consecutive failures"
|
|
86
107
|
fi
|
|
87
108
|
|
|
88
|
-
if [ "$should_advise" = true ]; then
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
109
|
+
if [ "$should_advise" = true ] && fresh "esc:${agent_type}:${current_model}->${next_model}"; then
|
|
110
|
+
add ""
|
|
111
|
+
add "--- [Model Escalation Advisory] ---"
|
|
112
|
+
add " Agent type: ${agent_type}"
|
|
113
|
+
add " Current model: ${current_model}"
|
|
114
|
+
add " ⚡ Recommended: Escalate to ${next_model}"
|
|
115
|
+
add " Cost impact: ${cost_multiplier} per task"
|
|
116
|
+
add " Reason: ${reason}"
|
|
117
|
+
add "------------------------------------"
|
|
97
118
|
fi
|
|
98
119
|
fi
|
|
99
120
|
|
|
100
121
|
# De-escalation check
|
|
101
|
-
if [ "$current_model" != "haiku" ] && [ "$current_model" != "inherit" ] && [ "$current_model" != "" ]; then
|
|
122
|
+
if [ "${OMCUSTOM_MODEL_DEESCALATION:-}" = "on" ] && [ "$current_model" != "haiku" ] && [ "$current_model" != "inherit" ] && [ "$current_model" != "" ]; then
|
|
102
123
|
# Same `grep -c` hygiene as above (#1656 E).
|
|
103
124
|
recent_successes=$(tail -${COOLDOWN} "$OUTCOME_FILE" 2>/dev/null | grep -c '"outcome":"success"' 2>/dev/null || true)
|
|
104
125
|
recent_successes=${recent_successes:-0}
|
|
@@ -111,16 +132,18 @@ if [ "$current_model" != "haiku" ] && [ "$current_model" != "inherit" ] && [ "$c
|
|
|
111
132
|
sonnet) lower_model="haiku" ;;
|
|
112
133
|
esac
|
|
113
134
|
|
|
114
|
-
if [ -n "$lower_model" ]; then
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
135
|
+
if [ -n "$lower_model" ] && fresh "deesc:${current_model}->${lower_model}"; then
|
|
136
|
+
add ""
|
|
137
|
+
add "--- [Model De-escalation Advisory] ---"
|
|
138
|
+
add " ↓ Consider: ${current_model} → ${lower_model}"
|
|
139
|
+
add " ${recent_successes} consecutive successes"
|
|
140
|
+
add "--------------------------------------"
|
|
120
141
|
fi
|
|
121
142
|
fi
|
|
122
143
|
fi
|
|
123
144
|
|
|
124
|
-
#
|
|
125
|
-
|
|
145
|
+
# Emit advisory to the model (#1885); silent when nothing to advise.
|
|
146
|
+
if [ -n "$ctx" ]; then
|
|
147
|
+
jq -cn --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
|
|
148
|
+
fi
|
|
126
149
|
exit 0
|
|
@@ -30,8 +30,20 @@ if ! [ -c /dev/tty ] 2>/dev/null; then
|
|
|
30
30
|
fi
|
|
31
31
|
|
|
32
32
|
# --- Configuration ---
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
# Cache location. Deliberate difference from the TS resolver (src/utils/home.ts
|
|
34
|
+
# resolveHomeDir, which falls back to the OS/passwd home): when HOME is unset, empty or not
|
|
35
|
+
# an absolute path, this hook SKIPS the cache entirely (no read, no write, no mkdir) rather
|
|
36
|
+
# than resolving to "/.oh-my-customcode" or a passwd home (#1775).
|
|
37
|
+
CACHE_ENABLED=true
|
|
38
|
+
CACHE_DIR=""
|
|
39
|
+
CACHE_FILE=""
|
|
40
|
+
case "${HOME:-}" in
|
|
41
|
+
/*)
|
|
42
|
+
CACHE_DIR="$HOME/.oh-my-customcode"
|
|
43
|
+
CACHE_FILE="${CACHE_DIR}/self-update-cache.json"
|
|
44
|
+
;;
|
|
45
|
+
*) CACHE_ENABLED=false ;;
|
|
46
|
+
esac
|
|
35
47
|
CACHE_MAX_AGE=3600 # 1 hour in seconds
|
|
36
48
|
NPM_TIMEOUT=3 # seconds for npm view
|
|
37
49
|
INPUT_TIMEOUT=10 # seconds for user prompt
|
|
@@ -69,10 +81,12 @@ LATEST_VERSION=""
|
|
|
69
81
|
CACHE_HIT=false
|
|
70
82
|
|
|
71
83
|
# Ensure cache directory exists
|
|
72
|
-
|
|
84
|
+
if [ "$CACHE_ENABLED" = true ]; then
|
|
85
|
+
mkdir -p "$CACHE_DIR" 2>/dev/null
|
|
86
|
+
fi
|
|
73
87
|
|
|
74
88
|
# Check cache freshness
|
|
75
|
-
if [ -f "$CACHE_FILE" ]; then
|
|
89
|
+
if [ "$CACHE_ENABLED" = true ] && [ -f "$CACHE_FILE" ]; then
|
|
76
90
|
CACHE_TIMESTAMP=$(grep -o '"timestamp"[[:space:]]*:[[:space:]]*[0-9]*' "$CACHE_FILE" 2>/dev/null | grep -o '[0-9]*$')
|
|
77
91
|
CACHED_VERSION=$(grep -o '"version"[[:space:]]*:[[:space:]]*"[^"]*"' "$CACHE_FILE" 2>/dev/null | grep -o '"[^"]*"$' | tr -d '"')
|
|
78
92
|
|
|
@@ -93,7 +107,7 @@ if [ "$CACHE_HIT" = false ]; then
|
|
|
93
107
|
LATEST_VERSION=$(timeout "$NPM_TIMEOUT" npm view "$PACKAGE_NAME" version 2>/dev/null || echo "")
|
|
94
108
|
|
|
95
109
|
# Update cache on successful fetch
|
|
96
|
-
if [ -n "$LATEST_VERSION" ]; then
|
|
110
|
+
if [ -n "$LATEST_VERSION" ] && [ "$CACHE_ENABLED" = true ]; then
|
|
97
111
|
NOW=$(date +%s)
|
|
98
112
|
cat > "$CACHE_FILE" << EOF
|
|
99
113
|
{
|
|
@@ -9,7 +9,31 @@
|
|
|
9
9
|
# destructive verb, destructive verb in a non-head position, unresolvable expansion in a target,
|
|
10
10
|
# opaque constructs such as eval / sh -c / $(...) / backticks / source, parse errors, unknown cwd,
|
|
11
11
|
# analyzer exceptions) -> strict legacy regex check (blocks if the pre-#1782 logic would block).
|
|
12
|
+
# Opaque markers are looked for outside quoted text (#1859): markers inside a quoted string whose last
|
|
13
|
+
# path component contains whitespace no longer force the fallback (the shell does not run them as a
|
|
14
|
+
# command); single-quoted `$(`/backticks are likewise ignored. `$(` and backticks inside double quotes,
|
|
15
|
+
# and any other quoted string (it may be the command, e.g. "eval", "/my dir/bash"), stay opaque.
|
|
16
|
+
# The fallback fires only if the raw command AND its masked copy both match.
|
|
12
17
|
# No python3: legacy regex check (needs jq). Neither python3 nor jq: fail-open (exit 0).
|
|
18
|
+
#
|
|
19
|
+
# Intended limitation (#1784): the blocked set is the one modelled above (v1.1.95, #1782: rm/unlink/
|
|
20
|
+
# rmdir/shred/truncate, mv, find, xargs, git rm|mv|clean, writing redirects); the exclusion of the
|
|
21
|
+
# overwrite/interpreter forms is recorded at
|
|
22
|
+
# https://github.com/baekenough/oh-my-customcode/issues/1784#issuecomment-6405528206 . Content-overwrite
|
|
23
|
+
# forms (cp /dev/null, tee, dd of=, sed -i, ln -sf, install, rsync --delete, tar --remove-files,
|
|
24
|
+
# git checkout -- / restore / stash), interpreter one-liners (perl -e, python -c, node -e) and shell
|
|
25
|
+
# aliases and git clean -f without -d run from an ancestor of a rule dir (e.g. the project root, where it can still remove untracked files inside the rule dirs; a target or cwd inside a rule dir is blocked) are NOT blocked: they are edit-class or interpreter forms whose blocking
|
|
26
|
+
# would raise false positives like #1859, and git history is the recovery path.
|
|
27
|
+
#
|
|
28
|
+
# Residual limits accepted with the #1859 change: (a) a quoted payload that carries an opaque marker
|
|
29
|
+
# and is handed to an executor the analyzer does not model (ssh, su -c, script -c, ..., or stdin piped
|
|
30
|
+
# to a shell: `| bash`, `| sh`) is no longer forced to the fallback, so it is not inspected - the same
|
|
31
|
+
# as such payloads without a marker always were; `env -S` / `--split-string` is likewise not parsed:
|
|
32
|
+
# unwrap() sends it to the legacy regex check only; (b) the quoted-option gap predates it: `bash "-c"
|
|
33
|
+
# ...` is not recognised as `-c`, because the marker pattern needs whitespace right before an unquoted
|
|
34
|
+
# `-`; (c) a quoted string is treated as text
|
|
35
|
+
# only when its last path component (after the final `/`) contains whitespace, so a lone quoted word
|
|
36
|
+
# or path to a binary stays visible and still falls back (conservative).
|
|
13
37
|
|
|
14
38
|
set -uo pipefail
|
|
15
39
|
|
|
@@ -259,6 +283,97 @@ def low(s):
|
|
|
259
283
|
return s.lower()
|
|
260
284
|
|
|
261
285
|
|
|
286
|
+
def _blank_quoted(body, double):
|
|
287
|
+
"""Blank the inside of one quoted string for the OPAQUE scan (#1859).
|
|
288
|
+
|
|
289
|
+
A string whose last path component (after the final `/`) contains whitespace cannot name a shell
|
|
290
|
+
binary or `eval`/`source`, so the markers in it are not run as a command by that shell: everything
|
|
291
|
+
is blanked, except `$(` and backticks inside double quotes (the shell expands those). This does
|
|
292
|
+
not make the text safe: a payload handed to another executor (ssh, su -c, ...) is not inspected.
|
|
293
|
+
Any other string may BE the command (`"eval"`, `'sh'`, `"/my dir/bash"`), so it stays visible;
|
|
294
|
+
only the `$(`/backtick of a single-quoted one, which the shell never expands, is neutralised.
|
|
295
|
+
"""
|
|
296
|
+
if not any(ch.isspace() for ch in body.rsplit('/', 1)[-1]):
|
|
297
|
+
return body if double else body.replace('$(', '_(').replace('`', '_')
|
|
298
|
+
if not double:
|
|
299
|
+
return '_' * len(body)
|
|
300
|
+
res, k = [], 0
|
|
301
|
+
while k < len(body):
|
|
302
|
+
if body.startswith('$(', k):
|
|
303
|
+
res.append('$(')
|
|
304
|
+
k += 2
|
|
305
|
+
else:
|
|
306
|
+
res.append(body[k] if body[k] == '`' else '_')
|
|
307
|
+
k += 1
|
|
308
|
+
return ''.join(res)
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def mask_quoted(s):
|
|
312
|
+
"""Copy of `s` for the OPAQUE scan with non-executable quoted text blanked (#1859).
|
|
313
|
+
|
|
314
|
+
Mirrors the quote handling of lex(). When unsure (heredoc operator, unterminated quote) the rest
|
|
315
|
+
of the text is returned unmasked, so the scan is then at least as strict as on the raw command.
|
|
316
|
+
Unquoted text, comments and `$'...'` strings are never touched.
|
|
317
|
+
"""
|
|
318
|
+
out, n, i = [], len(s), 0
|
|
319
|
+
wordstart = True
|
|
320
|
+
while i < n:
|
|
321
|
+
c = s[i]
|
|
322
|
+
if c in ' \t\n':
|
|
323
|
+
out.append(c)
|
|
324
|
+
i += 1
|
|
325
|
+
wordstart = True
|
|
326
|
+
elif s.startswith('<<<', i):
|
|
327
|
+
out.append('<<<')
|
|
328
|
+
i += 3
|
|
329
|
+
wordstart = True
|
|
330
|
+
elif s.startswith('<<', i):
|
|
331
|
+
return ''.join(out) + s[i:] # heredoc body is skipped by lex(): quote state is unknowable
|
|
332
|
+
elif c in ';&|<>()':
|
|
333
|
+
out.append(c)
|
|
334
|
+
i += 1
|
|
335
|
+
wordstart = True
|
|
336
|
+
elif c == '#' and wordstart:
|
|
337
|
+
j = s.find('\n', i)
|
|
338
|
+
j = n if j < 0 else j
|
|
339
|
+
out.append(s[i:j])
|
|
340
|
+
i = j
|
|
341
|
+
elif c == '\\':
|
|
342
|
+
out.append(s[i:i + 2])
|
|
343
|
+
i += 2
|
|
344
|
+
wordstart = False
|
|
345
|
+
elif c == '$' and s.startswith("$'", i):
|
|
346
|
+
j = i + 2
|
|
347
|
+
while j < n and s[j] != "'":
|
|
348
|
+
j += 2 if s[j] == '\\' else 1
|
|
349
|
+
if j >= n:
|
|
350
|
+
return ''.join(out) + s[i:]
|
|
351
|
+
out.append(s[i:j + 1])
|
|
352
|
+
i = j + 1
|
|
353
|
+
wordstart = False
|
|
354
|
+
elif c == "'":
|
|
355
|
+
j = s.find("'", i + 1)
|
|
356
|
+
if j < 0:
|
|
357
|
+
return ''.join(out) + s[i:]
|
|
358
|
+
out.append("'" + _blank_quoted(s[i + 1:j], False) + "'")
|
|
359
|
+
i = j + 1
|
|
360
|
+
wordstart = False
|
|
361
|
+
elif c == '"':
|
|
362
|
+
j = i + 1
|
|
363
|
+
while j < n and s[j] != '"':
|
|
364
|
+
j += 2 if s[j] == '\\' and j + 1 < n and s[j + 1] in '$`"\\\n' else 1
|
|
365
|
+
if j >= n:
|
|
366
|
+
return ''.join(out) + s[i:]
|
|
367
|
+
out.append('"' + _blank_quoted(s[i + 1:j], True) + '"')
|
|
368
|
+
i = j + 1
|
|
369
|
+
wordstart = False
|
|
370
|
+
else:
|
|
371
|
+
out.append(c)
|
|
372
|
+
i += 1
|
|
373
|
+
wordstart = False
|
|
374
|
+
return ''.join(out)
|
|
375
|
+
|
|
376
|
+
|
|
262
377
|
def main(d, cmd):
|
|
263
378
|
start = d.get('cwd')
|
|
264
379
|
if isinstance(start, str) and os.path.isdir(start):
|
|
@@ -280,7 +395,9 @@ def main(d, cmd):
|
|
|
280
395
|
low(os.path.join(root, 'templates', '.claude', 'rules'))]
|
|
281
396
|
|
|
282
397
|
cmd = cmd.replace('\r', '').replace('\\\n', '')
|
|
283
|
-
|
|
398
|
+
# Fall back only when BOTH scans match: masking must never add a fallback the raw scan lacks
|
|
399
|
+
# (blanking `; | & \n` inside quotes can otherwise create an `sh ... -c` match, #1859 review F1).
|
|
400
|
+
if OPAQUE.search(cmd) and OPAQUE.search(mask_quoted(cmd)):
|
|
284
401
|
return 3
|
|
285
402
|
try:
|
|
286
403
|
toks = lex(cmd)
|
|
@@ -2,8 +2,10 @@
|
|
|
2
2
|
# Schema Validator Hook — PreToolUse input validation
|
|
3
3
|
# Trigger: PreToolUse on Write, Edit, Bash
|
|
4
4
|
# Purpose: Validate tool inputs against JSON Schema definitions
|
|
5
|
-
# Phase 1: Advisory only (exit 0
|
|
6
|
-
# Protocol: stdin JSON -> validate ->
|
|
5
|
+
# Phase 1: Advisory only (exit 0; warnings via stdout JSON additionalContext, #1885)
|
|
6
|
+
# Protocol: stdin JSON -> validate -> exit 0
|
|
7
|
+
# Channel (#1885): exit-0 stderr never reaches the model (CC 2.1.310 probe, #1775 comment 6405197553);
|
|
8
|
+
# warnings are delivered as stdout JSON hookSpecificOutput.additionalContext. No stdin passthrough.
|
|
7
9
|
|
|
8
10
|
set -euo pipefail
|
|
9
11
|
|
|
@@ -29,7 +31,6 @@ SCHEMA_FILE="$ROOT/.claude/schemas/tool-inputs.json"
|
|
|
29
31
|
|
|
30
32
|
# Skip if schema file doesn't exist
|
|
31
33
|
if [ ! -f "$SCHEMA_FILE" ]; then
|
|
32
|
-
printf '%s\n' "$input"
|
|
33
34
|
exit 0
|
|
34
35
|
fi
|
|
35
36
|
|
|
@@ -72,7 +73,7 @@ case "$tool_name" in
|
|
|
72
73
|
fi
|
|
73
74
|
|
|
74
75
|
# Check dangerous patterns
|
|
75
|
-
if echo "$command" | grep -qE 'rm\s+-rf\s
|
|
76
|
+
if echo "$command" | grep -qE 'rm\s+-rf\s+["'"'"']?/["'"'"']?(\s|\*|[;&|)]|$)'; then
|
|
76
77
|
warnings+=("[Schema] Bash: DANGER — recursive delete from root detected")
|
|
77
78
|
fi
|
|
78
79
|
if echo "$command" | grep -qE '^\s*sudo\s+'; then
|
|
@@ -102,14 +103,10 @@ case "$tool_name" in
|
|
|
102
103
|
;;
|
|
103
104
|
esac
|
|
104
105
|
|
|
105
|
-
# Output warnings (advisory only)
|
|
106
|
+
# Output warnings (advisory only) via additionalContext
|
|
106
107
|
if [ ${#warnings[@]} -gt 0 ]; then
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
done
|
|
110
|
-
echo "[Schema] Phase 1: advisory only — not blocking" >&2
|
|
108
|
+
ctx=$(printf '%s\n' "${warnings[@]}"; printf '%s' "[Schema] Phase 1: advisory only — not blocking")
|
|
109
|
+
jq -cn --arg ctx "$ctx" '{hookSpecificOutput:{hookEventName:"PreToolUse",additionalContext:$ctx}}'
|
|
111
110
|
fi
|
|
112
111
|
|
|
113
|
-
# Always pass through (Phase 1)
|
|
114
|
-
printf '%s\n' "$input"
|
|
115
112
|
exit 0
|
|
@@ -99,7 +99,14 @@ fi
|
|
|
99
99
|
|
|
100
100
|
# Drift Detection: compare git HEAD between sessions
|
|
101
101
|
DRIFT_STATUS="not-git"
|
|
102
|
-
|
|
102
|
+
# When HOME is unset, empty or not an absolute path, skip the session-state write entirely
|
|
103
|
+
# (no mkdir, no state file; drift stays "not-git") instead of aborting under set -eu or
|
|
104
|
+
# writing to "/.claude/..." (#1775; same rule as the self-update cache below).
|
|
105
|
+
HOME_ABSOLUTE=false
|
|
106
|
+
case "${HOME:-}" in
|
|
107
|
+
/*) HOME_ABSOLUTE=true ;;
|
|
108
|
+
esac
|
|
109
|
+
if [ "$HOME_ABSOLUTE" = true ] && command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
|
|
103
110
|
SESSION_STATE_DIR="$HOME/.claude/session-state"
|
|
104
111
|
mkdir -p "$SESSION_STATE_DIR"
|
|
105
112
|
|
|
@@ -132,8 +139,10 @@ if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/n
|
|
|
132
139
|
fi
|
|
133
140
|
|
|
134
141
|
# --- CI Status Check ---
|
|
135
|
-
# Check last CI run status if gh CLI is available
|
|
136
|
-
|
|
142
|
+
# Check last CI run status if gh CLI is available.
|
|
143
|
+
# Skipped when HOME is not absolute (HOME_ABSOLUTE, set above): external CLIs keep state under
|
|
144
|
+
# $HOME and would write it into the cwd (the project dir) instead (#1775).
|
|
145
|
+
if [ "$HOME_ABSOLUTE" = true ] && command -v gh &>/dev/null; then
|
|
137
146
|
ci_status=$(gh run list --limit 1 --json conclusion -q '.[0].conclusion' 2>/dev/null || echo "unknown")
|
|
138
147
|
ci_name=$(gh run list --limit 1 --json name -q '.[0].name' 2>/dev/null || echo "unknown")
|
|
139
148
|
if [ "$ci_status" = "failure" ]; then
|
|
@@ -179,8 +188,14 @@ fi
|
|
|
179
188
|
# the hard-coded "latestVersion" lookup matched nothing. Read `latestVersion` first, then fall
|
|
180
189
|
# back to `version`. The `"version"` pattern includes the opening quote, so it cannot
|
|
181
190
|
# accidentally match the tail of `"latestVersion"`.
|
|
182
|
-
|
|
183
|
-
|
|
191
|
+
# Deliberate difference from the TS resolver (src/utils/home.ts resolveHomeDir falls back to
|
|
192
|
+
# the OS/passwd home): when HOME is unset, empty or not an absolute path, skip the cache
|
|
193
|
+
# entirely instead of reading "/.oh-my-customcode/..." (#1775).
|
|
194
|
+
CACHE_FILE=""
|
|
195
|
+
case "${HOME:-}" in
|
|
196
|
+
/*) CACHE_FILE="$HOME/.oh-my-customcode/self-update-cache.json" ;;
|
|
197
|
+
esac
|
|
198
|
+
if [ -n "$CACHE_FILE" ] && [ -f "$CACHE_FILE" ]; then
|
|
184
199
|
CACHED_LATEST=$(json_string_field "$CACHE_FILE" "latestVersion")
|
|
185
200
|
if [ -z "$CACHED_LATEST" ]; then
|
|
186
201
|
CACHED_LATEST=$(json_string_field "$CACHE_FILE" "version")
|