oh-my-customcode 2.0.7 → 2.0.8

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 (34) 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 +28 -12
  6. package/templates/.claude/hooks/scripts/auto-continue-guard.sh +5 -5
  7. package/templates/.claude/hooks/scripts/auto-dev-token-tracker.sh +12 -1
  8. package/templates/.claude/hooks/scripts/bash-cd-advisor.sh +172 -0
  9. package/templates/.claude/hooks/scripts/fail-axis-cause-advisor.sh +2 -2
  10. package/templates/.claude/hooks/scripts/failure-ledger.sh +18 -0
  11. package/templates/.claude/hooks/scripts/file-change-validator.sh +6 -3
  12. package/templates/.claude/hooks/scripts/omcustom-auto-update.sh +19 -5
  13. package/templates/.claude/hooks/scripts/rule-deletion-guard.sh +118 -1
  14. package/templates/.claude/hooks/scripts/session-env-check.sh +20 -5
  15. package/templates/.claude/hooks/scripts/stall-detection-advisor.sh +9 -5
  16. package/templates/.claude/hooks/scripts/stuck-detector.sh +21 -4
  17. package/templates/.claude/hooks/scripts/subagent-failure-advisor.sh +48 -29
  18. package/templates/.claude/hooks/scripts/task-outcome-recorder.sh +15 -8
  19. package/templates/.claude/ontology/agents.yaml +71 -7
  20. package/templates/.claude/ontology/graphs/agent-skill.json +64 -5
  21. package/templates/.claude/ontology/graphs/full-graph.json +236 -3
  22. package/templates/.claude/ontology/skills.yaml +65 -1
  23. package/templates/.claude/rules/MUST-enforcement-policy.md +3 -3
  24. package/templates/.claude/rules/MUST-orchestrator-coordination.md +1 -1
  25. package/templates/.claude/rules/SHOULD-memory-integration.md +1 -1
  26. package/templates/.claude/settings.json +28 -12
  27. package/templates/.claude/skills/fsd/SKILL.md +10 -9
  28. package/templates/.claude/skills/goal/SKILL.md +5 -5
  29. package/templates/.claude/skills/omcustom-loop/SKILL.md +4 -4
  30. package/templates/.claude/skills/pipeline/workflows/auto-dev.yaml +13 -0
  31. package/templates/guides/claude-code/04-agent-skills.md +1 -1
  32. package/templates/guides/claude-code/15-version-compatibility.md +46 -46
  33. package/templates/manifest.json +1 -1
  34. package/templates/workflows/auto-dev.yaml +13 -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)
@@ -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")
@@ -11,9 +11,11 @@ set -euo pipefail
11
11
  # array/scalar payloads gave rc=5 (unguarded `jq` agent_type extraction); a well-formed payload with a
12
12
  # 300KB `.description` gave rc=141 (5/5; 70KB rc=0; boundary near 81,920 bytes here, platform-dependent; `| head -c 80` SIGPIPE under pipefail). CC sends objects and
13
13
  # SubagentStop has no `.description`, so these paths are not reached at runtime.
14
- # Start-record `start_epoch`: "abc" gives rc=1 (`set -u`: "abc: unbound variable"); arithmetic-syntax
15
- # values ("1792939922.5", "09") print an error and give rc=1 (`set -u`: "duration_seconds: unbound
16
- # variable"); "" gives rc=0.
14
+ # Start-record `start_epoch` (also the still-running-agent `running_start`): a value that is not a
15
+ # positive decimal integer without a leading zero ("", "0", "null", "abc", "1792939922.5", "09", "-5")
16
+ # is treated as no start record: rc=0, nothing recorded for the completing agent, and the loop skips
17
+ # that running entry (measured on bash 3.2; before this check "abc"/"1792939922.5"/"09" gave rc=1 and
18
+ # "" recorded now_epoch, about 1.79e9, as the duration).
17
19
 
18
20
  # ORDERING: This hook MUST run AFTER task-outcome-recorder.sh in hooks.json SubagentStop array.
19
21
  # Reason: This script removes consumed entries from AGENT_START_FILE; task-outcome-recorder reads them first.
@@ -36,7 +38,9 @@ description=$(printf '%s\n' "$input" | jq -r '.description // ""' | head -c 80)
36
38
  # --- 2. Calculate duration from start record ---
37
39
  start_epoch=$(grep -F "\"agent_type\":\"${agent_type}\"" "$AGENT_START_FILE" 2>/dev/null | tail -1 | jq -r '.start_epoch // "0"' 2>/dev/null || echo "0")
38
40
 
39
- if [ "$start_epoch" = "0" ] || [ "$start_epoch" = "null" ]; then
41
+ # Only a positive decimal integer without a leading zero is a usable epoch; anything else
42
+ # ("", "0", "null", "abc", "1792939922.5", "09", negatives) is treated as no start record.
43
+ if ! [[ "$start_epoch" =~ ^[1-9][0-9]*$ ]]; then
40
44
  printf '%s\n' "$input"
41
45
  exit 0
42
46
  fi
@@ -70,7 +74,7 @@ if [ "$completed_count" -ge 1 ]; then
70
74
  running_desc=$(printf '%s\n' "$line" | jq -r '.description // ""' 2>/dev/null || true)
71
75
  running_model=$(printf '%s\n' "$line" | jq -r '.model // "inherit"' 2>/dev/null || true)
72
76
 
73
- if [ "$running_start" = "0" ] || [ "$running_start" = "null" ]; then continue; fi
77
+ if ! [[ "$running_start" =~ ^[1-9][0-9]*$ ]]; then continue; fi
74
78
 
75
79
  elapsed=$((now_epoch - running_start))
76
80
 
@@ -545,6 +545,7 @@ extraction_mode=$(printf '%s' "$input" | jq -er '
545
545
  elif ((.tool_name // "unknown" | type) != "string") then "legacy"
546
546
  elif ((.tool_input != null) and ((.tool_input | type) != "object")) then "legacy"
547
547
  elif ((.tool_output != null) and ((.tool_output | type) != "object")) then "legacy"
548
+ elif ((.tool_response != null) and ((.tool_response | type) != "object")) then "legacy"
548
549
  elif ((.tool_input.file_path // "" | type) != "string")
549
550
  or ((.tool_input.command // "" | type) != "string")
550
551
  or ((.tool_input.old_string // .tool_input.content // .tool_input.new_string // "" | type) != "string")
@@ -579,6 +580,22 @@ _read_stuck_fields() {
579
580
 
580
581
  TARGET_KEY_CAP=1000
581
582
 
583
+ # PostToolUse carries "tool_response", never "tool_output" (#1775; probe: CC
584
+ # 2.1.310, "claude -p", one run, model haiku). Measured Bash shape: object
585
+ # {interrupted,isImage,noOutputExpected,stderr,stdout}; no measured PostToolUse
586
+ # shape has is_error. Bash failures arrive via PostToolUseFailure (a Bash exit 3
587
+ # produced PostToolUseFailure with error "Exit code 3"), which this hook is not
588
+ # wired to, so the is_error path stays inert for Bash. Read tool_response first;
589
+ # ".tool_output" is a legacy fallback only. A non-object tool_response routes to
590
+ # the "legacy" extraction path (see extraction_mode) and is used as text only
591
+ # when it is a string.
592
+ JQ_IS_ERROR='((.tool_response | if type == "object" then .is_error else null end) // .tool_output.is_error // false)'
593
+ JQ_OUTPUT_PREVIEW='((.tool_response) as $r
594
+ | if ($r | type) == "object" and (($r.stdout | type) == "string") then $r.stdout
595
+ elif ($r | type) == "string" then $r
596
+ else (.tool_output.output // "") end
597
+ | tostring)[0:200]'
598
+
582
599
  truncate_key() {
583
600
  local v="$1"
584
601
  if [ "${#v}" -gt "$TARGET_KEY_CAP" ]; then
@@ -595,7 +612,7 @@ if [ "$extraction_mode" = "batched" ]; then
595
612
  (if (.tool_name // "unknown") == "Bash"
596
613
  then (.tool_input.command // .tool_input.file_path // "")
597
614
  else (.tool_input.file_path // "") end), "\u0000",
598
- (.tool_output.is_error // false), "\u0000"'
615
+ '"$JQ_IS_ERROR"', "\u0000"'
599
616
  [ "${#STUCK_FIELDS[@]}" -eq 3 ] || exit 1
600
617
  tool_name="${STUCK_FIELDS[0]}"
601
618
  if [ "$tool_name" = "Bash" ]; then
@@ -608,7 +625,7 @@ if [ "$extraction_mode" = "batched" ]; then
608
625
  fi
609
626
  is_error="${STUCK_FIELDS[2]}"
610
627
  output_preview=$(printf '%s' "$input" \
611
- | jq -r '(.tool_output.output // "" | tostring)[0:200]' \
628
+ | jq -r "$JQ_OUTPUT_PREVIEW" \
612
629
  | head -c 200 || true)
613
630
  _read_stuck_fields '(.tool_input.command // ""), "\u0000",
614
631
  (if (.tool_name == "Edit" or .tool_name == "Write")
@@ -653,7 +670,7 @@ else
653
670
  file_path=$(printf '%s' "$file_path_full" | head -c "$TARGET_KEY_CAP" || true)
654
671
  target_key=$(truncate_key "$file_path_full")
655
672
  fi
656
- is_error=$(printf '%s' "$input" | jq -r '.tool_output.is_error // false')
673
+ is_error=$(printf '%s' "$input" | jq -r "$JQ_IS_ERROR")
657
674
  # "head -c 200" closes the pipe after 200 bytes. Once tool_output.output is
658
675
  # larger than the pipe buffer (~64 KB), jq keeps writing, dies of SIGPIPE
659
676
  # (exit 141), and "set -o pipefail" + "set -e" aborted the ENTIRE hook before
@@ -664,7 +681,7 @@ is_error=$(printf '%s' "$input" | jq -r '.tool_output.is_error // false')
664
681
  # enforced by head (jq slices by codepoint, head by byte, and 200 codepoints
665
682
  # are always >= 200 bytes, so the resulting bytes are unchanged).
666
683
  output_preview=$(printf '%s' "$input" \
667
- | jq -r '(.tool_output.output // "" | tostring)[0:200]' \
684
+ | jq -r "$JQ_OUTPUT_PREVIEW" \
668
685
  | head -c 200 || true)
669
686
  raw_command=$(printf '%s' "$input" | jq -r '.tool_input.command // ""')
670
687
 
@@ -5,38 +5,57 @@ set -euo pipefail
5
5
  command -v jq >/dev/null 2>&1 || exit 0
6
6
 
7
7
  # Subagent Failure Advisor Hook
8
- # Trigger: SubagentStop
9
- # Purpose: Advisory-only replacement for the removed `type: "prompt"` SubagentStop hook
10
- # (#1631). The prompt hook asked an LLM judge whether the CURRENT session's own
11
- # background_tasks entry had transitioned to "completed" before allowing Stop —
12
- # but that transition only happens AFTER the SubagentStop hook chain resolves, so the
13
- # judgment was structurally always "not yet completed" for a subagent evaluating its
14
- # OWN stop. That produced a self-referential `Stop hook feedback:` re-injection loop
15
- # with no dedup, observed to repeat 8-10x per session before manual override.
16
- # Fix: replace the blocking LLM judge with a plain stderr-only warning that never
17
- # triggers the Stop-hook-feedback re-injection path. No block, no LLM call, no loop.
18
- # Protocol: stdin JSON -> read tool_output.is_error -> stderr warning if failed -> pass through -> exit 0
19
- # Exit status (MEASURED, bash 3.2, `set -euo pipefail`): rc=0 in every probe (well-formed payload,
20
- # is_error=true payload, malformed JSON, empty stdin, array/scalar payloads) except one synthetic case:
21
- # the jq calls for is_error and agent_type are guarded with `|| echo`, but the error_summary pipeline
22
- # (`jq ... | head -c 200`) is not; with is_error=true and a 300KB `tool_output.output` it gave rc=141
23
- # (SIGPIPE under pipefail, 3/3). SubagentStop payloads have no `tool_output`, so is_error is always
24
- # false there and that path is unreachable at runtime.
8
+ # Trigger: PostToolUseFailure (matcher: Agent / Task tool)
9
+ # Purpose: Advisory-only. When an Agent/Task tool call fails, tell the model not to
10
+ # auto-continue as if the subagent had succeeded. Replaces the removed
11
+ # `type: "prompt"` SubagentStop hook (#1631), whose LLM judge was structurally
12
+ # always "not yet completed" and produced a Stop-hook-feedback re-injection loop.
13
+ # Why not SubagentStop (#1775): the SubagentStop payload has no tool_response /
14
+ # tool_output / is_error (measured keys on CC 2.1.310, `claude -p`, haiku, one run, see
15
+ # https://github.com/baekenough/oh-my-customcode/issues/1775#issuecomment-6404861043: agent_id, agent_transcript_path, agent_type,
16
+ # background_tasks, cwd, effort, hook_event_name, last_assistant_message,
17
+ # permission_mode, prompt_id, session_crons, session_id, stop_hook_active,
18
+ # transcript_path), so a failure check there can never fire.
19
+ # Input (PostToolUseFailure; measured on CC 2.1.310, `claude -p`, haiku, one run, unknown
20
+ # subagent_type; additionalContext delivery evidence is weak because the probe prompt named
21
+ # the marker; measured fields only: tool_name, is_interrupt, error (string), see
22
+ # https://github.com/baekenough/oh-my-customcode/issues/1775#issuecomment-6405197553).
23
+ # Other fields (tool_input sub-fields, duration_ms, tool_use_id, hook_event_name, ...) come from
24
+ # the PostToolUseFailure input table in https://code.claude.com/docs/en/hooks.md, not measured.
25
+ # Output: stdout JSON {"hookSpecificOutput":{"hookEventName":"PostToolUseFailure",
26
+ # "additionalContext": "..."}} -- observed as delivered to the model (weak evidence, see above).
27
+ # Never a `decision` field; the only explicit exit is exit 0. Silent for interrupts, non-Agent/Task tools,
28
+ # missing error, or non-object stdin.
25
29
 
26
30
  input=$(cat)
27
31
 
28
- is_error=$(printf '%s\n' "$input" | jq -r '.tool_output.is_error // false' 2>/dev/null || echo "false")
32
+ # Single jq pass; any parse failure or non-object stdin -> silent exit 0.
33
+ fields=$(printf '%s' "$input" | jq -r '
34
+ if type == "object" then
35
+ [ (.tool_name // ""),
36
+ (if .is_interrupt == true then "1" else "0" end),
37
+ ((.tool_input.subagent_type // "unknown") | tostring | .[0:80]),
38
+ ((.tool_input.description // "") | tostring | gsub("[\n\r\t]"; " ") | .[0:120]),
39
+ ((.error // "") | tostring | gsub("[\n\r\t]"; " ") | .[0:200])
40
+ ] | join("\u001f")
41
+ else empty end' 2>/dev/null || true)
29
42
 
30
- if [ "$is_error" = "true" ]; then
31
- agent_type=$(printf '%s\n' "$input" | jq -r '.tool_input.subagent_type // .agent_type // "unknown"' 2>/dev/null || echo "unknown")
32
- error_summary=$(printf '%s\n' "$input" | jq -r '.tool_output.output // ""' 2>/dev/null | head -c 200)
33
- echo "" >&2
34
- echo "--- [Subagent Failure Advisor] background subagent (${agent_type}) reported an error ---" >&2
35
- echo " ${error_summary}" >&2
36
- echo " If it FAILED, do NOT auto-continue -- report the failure and wait for user input." >&2
37
- echo "-----------------------------------------------------------------------------------" >&2
38
- fi
43
+ [ -n "$fields" ] || exit 0
39
44
 
40
- # Always pass through -- advisory only, never blocks, never triggers Stop hook feedback re-injection
41
- printf '%s\n' "$input"
45
+ IFS=$'\x1f' read -r tool_name is_interrupt subagent_type description error_text <<EOT
46
+ $fields
47
+ EOT
48
+
49
+ case "$tool_name" in
50
+ Agent|Task) ;;
51
+ *) exit 0 ;;
52
+ esac
53
+ [ "$is_interrupt" = "0" ] || exit 0
54
+ [ -n "$error_text" ] || exit 0
55
+
56
+ msg="[Subagent Failure Advisor] ${tool_name} call failed (subagent_type=${subagent_type}"
57
+ [ -z "$description" ] || msg="${msg}, description=${description}"
58
+ msg="${msg}): ${error_text}. Fix the arguments (e.g. the subagent_type) and retry once; if the failure is a permission denial or repeats, report it and do not pretend the subagent ran."
59
+
60
+ jq -n --arg ctx "$msg" '{hookSpecificOutput:{hookEventName:"PostToolUseFailure",additionalContext:$ctx}}' 2>/dev/null || true
42
61
  exit 0
@@ -10,9 +10,10 @@ command -v jq >/dev/null 2>&1 || exit 0
10
10
  # Protocol: stdin JSON -> process -> stdout pass-through
11
11
  # Exit status (MEASURED, bash 3.2, `set -euo pipefail`): the only explicit exits are `exit 0`, but
12
12
  # there is no trap, so it is NOT guaranteed. rc=0 for a well-formed payload, malformed JSON, empty
13
- # stdin, array/scalar payloads and a corrupt AGENT_START_FILE, with start_epoch exceptions: "abc" gives
14
- # rc=1 (`set -u`: "abc: unbound variable"); arithmetic-syntax values ("1792939922.5", "09") print an
15
- # error but give rc=0 (bash 3.2 drops the line; duration stays 0); "" gives rc=0.
13
+ # stdin, array/scalar payloads and a corrupt AGENT_START_FILE. A start_epoch that is not a positive
14
+ # decimal integer without a leading zero ("", "0", "null", "abc", "1792939922.5", "09", "-5") is
15
+ # treated as no start record: rc=0 and duration_seconds 0 (measured on bash 3.2; before this check
16
+ # "abc" gave rc=1 and "" recorded now_epoch, about 1.79e9, as the duration).
16
17
  #
17
18
  # MEASURED SubagentStop payload (CC 2.1.259 embedded hook schema; #1656 B):
18
19
  # session_id, transcript_path, cwd, prompt_id?, permission_mode?, effort?,
@@ -35,8 +36,8 @@ command -v jq >/dev/null 2>&1 || exit 0
35
36
  # Outcome caveat: SubagentStop exposes no failure signal, so entries recorded
36
37
  # from that event are always outcome=success. There is no working hand-off for
37
38
  # that gap today: SubagentStop payload carries no failure signal;
38
- # subagent-failure-advisor.sh currently reads `.tool_output.is_error`, which is
39
- # absent here, so failure detection on this path is 0 until #1656 G is resolved.
39
+ # subagent-failure-advisor.sh reads `.error` from PostToolUseFailure (Agent/Task) payloads,
40
+ # not from SubagentStop, so this recorder still has no failure hand-off (#1656 G, #1775).
40
41
 
41
42
  input=$(cat)
42
43
 
@@ -65,8 +66,11 @@ description=$(printf '%s\n' "$input" | jq -r '
65
66
 
66
67
  # Extract skill name from description or prompt
67
68
  skill_name=""
68
- # `|| true`: a no-match grep exits 1 and `set -euo pipefail` would abort the hook
69
- # (this line's guard: rc=1 when text passes the gate below but not the name pattern; the later guard: rc=1 on {"agent_type":"X"}).
69
+ # `|| true`: a no-match grep exits 1 and `set -euo pipefail` would abort the hook.
70
+ # Two guards follow. The `|| true` at the end of the `grep -oiE '[a-z]+-[a-z]+...'` line inside the
71
+ # `if` below covers the case where the text passes the `grep -qiE` gate but not the name pattern.
72
+ # The `|| true` at the end of the later `grep -oiE 'Skill:...'` line covers a payload with no
73
+ # "Skill:" text, e.g. {"agent_type":"X"} (rc=1 without it).
70
74
  # Empty stdin now exits at the non-object guard above.
71
75
  if echo "$description" | grep -qiE '(skill:|routing|→.*skill)'; then
72
76
  skill_name=$(echo "$description" | grep -oiE '[a-z]+-[a-z]+(-[a-z]+)*-?(routing|skill|practices|detection|decomposition|orchestration|pipeline|guards|cycle|plan|review|refactor|publish|version|audit|exec|analyze|bundle|report|setup|watch|lists|status|help|save|recall)' | head -1 || true)
@@ -159,7 +163,10 @@ AGENT_START_FILE="/tmp/.claude-agent-starts-${PPID}"
159
163
  duration_seconds=0
160
164
  if [ -f "$AGENT_START_FILE" ]; then
161
165
  start_epoch=$(grep -F "\"agent_type\":\"${agent_type}\"" "$AGENT_START_FILE" 2>/dev/null | tail -1 | jq -r '.start_epoch // "0"' 2>/dev/null || echo "0")
162
- if [ "$start_epoch" != "0" ] && [ "$start_epoch" != "null" ]; then
166
+ # Only a positive decimal integer without a leading zero is a usable epoch. Anything else
167
+ # ("", "0", "null", "abc", "1792939922.5", "09", negatives) takes the no-start-record path
168
+ # (duration 0): bash arithmetic would otherwise read "" as 0 and record now_epoch as the duration.
169
+ if [[ "$start_epoch" =~ ^[1-9][0-9]*$ ]]; then
163
170
  now_epoch=$(date +%s)
164
171
  duration_seconds=$((now_epoch - start_epoch))
165
172
  fi
@@ -9,16 +9,16 @@ classes:
9
9
  agents: [lang-golang-expert, lang-python-expert, lang-rust-expert, lang-kotlin-expert, lang-typescript-expert, lang-java-expert]
10
10
  description: "Programming language specialists"
11
11
  BackendExpert:
12
- agents: [be-fastapi-expert, be-springboot-expert, be-go-backend-expert, be-express-expert, be-nestjs-expert]
12
+ agents: [be-fastapi-expert, be-springboot-expert, be-go-backend-expert, be-express-expert, be-nestjs-expert, be-django-expert]
13
13
  description: "Backend framework specialists"
14
14
  FrontendExpert:
15
- agents: [fe-vercel-agent, fe-vuejs-agent, fe-svelte-agent, fe-flutter-agent]
15
+ agents: [fe-vercel-agent, fe-vuejs-agent, fe-svelte-agent, fe-flutter-agent, fe-design-expert]
16
16
  description: "Frontend framework specialists"
17
17
  DataEngineer:
18
18
  agents: [de-airflow-expert, de-dbt-expert, de-spark-expert, de-kafka-expert, de-snowflake-expert, de-pipeline-expert]
19
19
  description: "Data engineering specialists"
20
20
  DatabaseExpert:
21
- agents: [db-postgres-expert, db-redis-expert, db-supabase-expert]
21
+ agents: [db-postgres-expert, db-redis-expert, db-supabase-expert, db-alembic-expert]
22
22
  description: "Database specialists"
23
23
  ToolingExpert:
24
24
  agents: [tool-npm-expert, tool-optimizer, tool-bun-expert]
@@ -103,14 +103,14 @@ agents:
103
103
 
104
104
  lang-java-expert:
105
105
  class: LanguageExpert
106
- description: "Expert Java 21 developer for modern Java with Virtual Threads, Pattern Matching, Record Patterns, and Sequenced Collections. Use for Java files (*.java, pom.xml, build.gradle), Java-related keywords, high-concurrency systems, data-centric APIs with Records, and migrating from older Java versions."
106
+ description: "Expert Java 25 LTS developer for modern Java with Virtual Threads, Pattern Matching, Record Patterns, and Sequenced Collections. Use for Java files (*.java, pom.xml, build.gradle), Java-related keywords, high-concurrency systems, data-centric APIs with Records, and migrating from older Java versions."
107
107
  model: sonnet
108
108
  memory: project
109
109
  effort: high
110
- skills: []
110
+ skills: [java-best-practices]
111
111
  tools: [Read, Write, Edit, Grep, Glob, Bash]
112
- summary: "Java 21 expert for modern features including virtual threads and pattern matching"
113
- keywords: [java, java21, virtual-threads, pattern-matching, records, sequenced-collections]
112
+ summary: "Java 25 LTS expert for modern features including virtual threads and pattern matching"
113
+ keywords: [java, java25, virtual-threads, pattern-matching, records, sequenced-collections]
114
114
  file_patterns: ["*.java", "pom.xml", "build.gradle", "build.gradle.kts"]
115
115
 
116
116
  be-fastapi-expert:
@@ -544,3 +544,67 @@ agents:
544
544
  summary: "Task tracker with proactive reminders for TODO management"
545
545
  keywords: [todo, tasks, reminders, tracking, deadlines, project-momentum]
546
546
  file_patterns: ["TODO.md", "*.todo"]
547
+
548
+ agora-runner:
549
+ description: "Executes exactly one agora session step (start, one round, or finish) via the skill scripts and returns only a verdict summary. Never returns reviewer text, vendor attribution or sealed paths."
550
+ model: claude-sonnet-5-5
551
+ skills: [agora]
552
+ tools: [Bash, Read, Write, Glob]
553
+
554
+ be-django-expert:
555
+ class: BackendExpert
556
+ description: "Expert Django developer for production-ready Python web applications. Use for Django projects, models/views/templates, Django REST Framework, authentication, admin customization, and deployment optimization."
557
+ model: claude-sonnet-5-5
558
+ memory: local
559
+ effort: high
560
+ skills: [django-best-practices]
561
+ tools: [Read, Write, Edit, Grep, Glob, Bash]
562
+
563
+ db-alembic-expert:
564
+ class: DatabaseExpert
565
+ description: "Alembic migration specialist for generating, reviewing, fixing, and advising on SQLAlchemy database migrations"
566
+ model: claude-opus-5-5
567
+ memory: local
568
+ effort: high
569
+ skills: [alembic-best-practices, postgres-best-practices]
570
+ tools: [Read, Write, Edit, Grep, Glob, Bash]
571
+
572
+ fe-design-expert:
573
+ class: FrontendExpert
574
+ description: "Use for design system review, typography audit, color palette evaluation, motion design, and AI-generated design quality assessment"
575
+ model: claude-sonnet-5-5
576
+ memory: local
577
+ effort: medium
578
+ skills: [impeccable-design, web-design-guidelines, diagram-design]
579
+ tools: [Read, Write, Edit, Grep, Glob]
580
+
581
+ sec-codeql-expert:
582
+ description: "Expert security code analyst using CodeQL for vulnerability detection, call graph analysis, and SARIF output. Use for security audits, CVE triage, code pattern analysis, and vulnerability validation."
583
+ model: claude-opus-5-5
584
+ memory: local
585
+ effort: high
586
+ skills: [cve-triage, adversarial-review]
587
+ tools: [Read, Write, Grep, Bash]
588
+
589
+ slack-cli-expert:
590
+ description: "Expert Slack CLI developer for Slack app management, deployment, triggers, and workspace automation"
591
+ model: claude-sonnet-5-5
592
+ effort: medium
593
+ skills: []
594
+ tools: [Read, Write, Edit, Grep, Glob, Bash]
595
+
596
+ tracker-checkpoint:
597
+ description: "Pipeline execution state tracker with checkpoint persistence. Reads/writes /tmp/.claude-pipeline-*-{PPID}.json state files and validates state transitions. Used by dag-orchestration for resume-after-failure and pipeline-guards for quality gate state."
598
+ model: haiku
599
+ memory: local
600
+ effort: medium
601
+ skills: [dag-orchestration, pipeline-guards]
602
+ tools: [Read, Write, Edit, Bash, Glob, Grep]
603
+
604
+ wiki-curator:
605
+ description: "Dedicated wiki page CRUD agent — creates, updates, and maintains wiki/ markdown pages for the codebase knowledge base"
606
+ model: claude-sonnet-5-5
607
+ memory: local
608
+ effort: medium
609
+ skills: []
610
+ tools: [Read, Write, Edit, Glob, Grep, Bash]