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.
Files changed (45) hide show
  1. package/README.md +3 -2
  2. package/dist/cli/index.js +28 -7
  3. package/dist/index.js +28 -7
  4. package/package.json +1 -1
  5. package/templates/.claude/hooks/hooks.json +29 -13
  6. package/templates/.claude/hooks/scripts/agent-teams-advisor.sh +47 -22
  7. package/templates/.claude/hooks/scripts/auto-continue-guard.sh +5 -5
  8. package/templates/.claude/hooks/scripts/auto-dev-token-tracker.sh +12 -1
  9. package/templates/.claude/hooks/scripts/bash-cd-advisor.sh +172 -0
  10. package/templates/.claude/hooks/scripts/content-hash-validator.sh +65 -48
  11. package/templates/.claude/hooks/scripts/destructive-git-guard.sh +32 -17
  12. package/templates/.claude/hooks/scripts/fail-axis-cause-advisor.sh +2 -2
  13. package/templates/.claude/hooks/scripts/failure-ledger.sh +18 -0
  14. package/templates/.claude/hooks/scripts/file-change-validator.sh +6 -3
  15. package/templates/.claude/hooks/scripts/git-delegation-guard.sh +178 -27
  16. package/templates/.claude/hooks/scripts/model-escalation-advisor.sh +43 -20
  17. package/templates/.claude/hooks/scripts/omcustom-auto-update.sh +19 -5
  18. package/templates/.claude/hooks/scripts/rule-deletion-guard.sh +118 -1
  19. package/templates/.claude/hooks/scripts/schema-validator.sh +8 -11
  20. package/templates/.claude/hooks/scripts/session-env-check.sh +20 -5
  21. package/templates/.claude/hooks/scripts/stall-detection-advisor.sh +9 -5
  22. package/templates/.claude/hooks/scripts/stuck-detector.sh +21 -4
  23. package/templates/.claude/hooks/scripts/subagent-failure-advisor.sh +48 -29
  24. package/templates/.claude/hooks/scripts/task-outcome-recorder.sh +15 -8
  25. package/templates/.claude/ontology/agents.yaml +71 -7
  26. package/templates/.claude/ontology/graphs/agent-skill.json +64 -5
  27. package/templates/.claude/ontology/graphs/full-graph.json +236 -3
  28. package/templates/.claude/ontology/skills.yaml +65 -1
  29. package/templates/.claude/rules/MUST-agent-design.md +1 -11
  30. package/templates/.claude/rules/MUST-enforcement-policy.md +7 -7
  31. package/templates/.claude/rules/MUST-orchestrator-coordination.md +1 -1
  32. package/templates/.claude/rules/SHOULD-memory-integration.md +1 -1
  33. package/templates/.claude/settings.json +29 -13
  34. package/templates/.claude/skills/fsd/SKILL.md +10 -9
  35. package/templates/.claude/skills/goal/SKILL.md +5 -5
  36. package/templates/.claude/skills/model-escalation/SKILL.md +16 -12
  37. package/templates/.claude/skills/omcustom-loop/SKILL.md +4 -4
  38. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +13 -0
  39. package/templates/guides/claude-code/04-agent-skills.md +1 -1
  40. package/templates/guides/claude-code/15-version-compatibility.md +47 -47
  41. package/templates/guides/git-safety/README.md +4 -1
  42. package/templates/guides/middleware-patterns/README.md +4 -4
  43. package/templates/guides/multi-model-routing/README.md +1 -7
  44. package/templates/manifest.json +1 -1
  45. package/templates/workflows/auto-dev.yaml +13 -0
@@ -1,7 +1,69 @@
1
1
  #!/bin/bash
2
- # R010 git-delegation-guard hook
3
- # Warns when git operations are delegated to a non-mgr-gitnerd agent via Agent/Task tool.
4
- # WARN only - does NOT block (exit 0, passes input through).
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's "Cannot index <type> with string" when tool_input arrives as a
24
- # scalar/array instead of an object (measured #1656 A: the bare form leaked a jq error to
25
- # stderr on every non-object tool_input). 2>/dev/null + `|| echo ""` keep the hook silent
26
- # and advisory-only even if jq itself fails for an unrelated reason.
27
- agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type? // ""' 2>/dev/null || echo "")
28
- prompt=$(printf '%s\n' "$input" | jq -r '.tool_input.prompt? // ""' 2>/dev/null || echo "")
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
- for keyword in "${git_keywords[@]}"; do
50
- if echo "$prompt" | grep -qi "$keyword"; then
51
- echo "[Hook] WARNING: R010 violation detected - git operation ('$keyword') delegated to '$agent_type' instead of 'mgr-gitnerd'" >&2
52
- echo "[Hook] Per R010, all git operations (commit/push/branch/merge/etc.) MUST be delegated to mgr-gitnerd" >&2
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
- # Record violation for R021 promotion tracking
55
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) $keyword $agent_type" >> "$VIOLATION_FILE"
56
- violation_count=$(wc -l < "$VIOLATION_FILE" 2>/dev/null | tr -d ' ')
57
- if [ "$violation_count" -ge 3 ]; then
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
- break
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
- done
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
- # Always pass through - this hook is advisory only
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 -> stdout pass-through, exit 0 always
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
- echo "" >&2
90
- echo "--- [Model Escalation Advisory] ---" >&2
91
- echo " Agent type: ${agent_type}" >&2
92
- echo " Current model: ${current_model}" >&2
93
- echo " ⚡ Recommended: Escalate to ${next_model}" >&2
94
- echo " Cost impact: ${cost_multiplier} per task" >&2
95
- echo " Reason: ${reason}" >&2
96
- echo "------------------------------------" >&2
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
- echo "" >&2
116
- echo "--- [Model De-escalation Advisory] ---" >&2
117
- echo " ↓ Consider: ${current_model} → ${lower_model}" >&2
118
- echo " ${recent_successes} consecutive successes" >&2
119
- echo "--------------------------------------" >&2
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
- # Pass through
125
- printf '%s\n' "$input"
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
- CACHE_DIR="$HOME/.oh-my-customcode"
34
- CACHE_FILE="${CACHE_DIR}/self-update-cache.json"
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
- mkdir -p "$CACHE_DIR" 2>/dev/null
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
- if OPAQUE.search(cmd):
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 with stderr warning)
6
- # Protocol: stdin JSON -> validate -> stdout pass-through
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+/[^.]'; then
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
- for w in "${warnings[@]}"; do
108
- echo "$w" >&2
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
- if command -v git >/dev/null 2>&1 && git rev-parse --is-inside-work-tree >/dev/null 2>&1; then
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
- if command -v gh &>/dev/null; then
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
- CACHE_FILE="$HOME/.oh-my-customcode/self-update-cache.json"
183
- if [ -f "$CACHE_FILE" ]; then
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")