create-merlin-brain 5.3.7 → 5.4.0

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 (67) hide show
  1. package/bin/install-rtk.cjs +282 -0
  2. package/bin/install.cjs +26 -4
  3. package/dist/server/api/client.d.ts.map +1 -1
  4. package/dist/server/api/client.js +35 -6
  5. package/dist/server/api/client.js.map +1 -1
  6. package/dist/server/server.d.ts.map +1 -1
  7. package/dist/server/server.js +146 -42
  8. package/dist/server/server.js.map +1 -1
  9. package/dist/server/session-coach.d.ts.map +1 -1
  10. package/dist/server/session-coach.js +12 -0
  11. package/dist/server/session-coach.js.map +1 -1
  12. package/dist/server/session-guardian.d.ts +8 -1
  13. package/dist/server/session-guardian.d.ts.map +1 -1
  14. package/dist/server/session-guardian.js +26 -14
  15. package/dist/server/session-guardian.js.map +1 -1
  16. package/dist/server/tools/challenge.d.ts.map +1 -1
  17. package/dist/server/tools/challenge.js +7 -1
  18. package/dist/server/tools/challenge.js.map +1 -1
  19. package/dist/server/tools/computer-use.d.ts.map +1 -1
  20. package/dist/server/tools/computer-use.js +13 -6
  21. package/dist/server/tools/computer-use.js.map +1 -1
  22. package/dist/server/tools/index.d.ts +0 -1
  23. package/dist/server/tools/index.d.ts.map +1 -1
  24. package/dist/server/tools/index.js +0 -1
  25. package/dist/server/tools/index.js.map +1 -1
  26. package/dist/server/tools/project.d.ts.map +1 -1
  27. package/dist/server/tools/project.js +14 -12
  28. package/dist/server/tools/project.js.map +1 -1
  29. package/dist/server/tools/verification-runner.d.ts +45 -0
  30. package/dist/server/tools/verification-runner.d.ts.map +1 -0
  31. package/dist/server/tools/verification-runner.js +264 -0
  32. package/dist/server/tools/verification-runner.js.map +1 -0
  33. package/dist/server/tools/verification.d.ts +3 -0
  34. package/dist/server/tools/verification.d.ts.map +1 -1
  35. package/dist/server/tools/verification.js +8 -265
  36. package/dist/server/tools/verification.js.map +1 -1
  37. package/files/CLAUDE.md +1 -0
  38. package/files/commands/merlin/check-size.md +152 -0
  39. package/files/hooks/check-file-size.sh +166 -58
  40. package/files/hooks/pre-edit-sights-check.sh +19 -3
  41. package/files/hooks/security-scanner.sh +3 -4
  42. package/files/hooks/session-end.sh +45 -32
  43. package/files/hooks/smart-approve.sh +11 -3
  44. package/files/hooks/user-prompt-router.sh +24 -3
  45. package/files/merlin/VERSION +1 -1
  46. package/files/merlin-system-prompt.txt +3 -1
  47. package/package.json +2 -2
  48. package/dist/server/tools/context.d.ts +0 -7
  49. package/dist/server/tools/context.d.ts.map +0 -1
  50. package/dist/server/tools/context.js +0 -614
  51. package/dist/server/tools/context.js.map +0 -1
  52. package/dist/server/tools/hud.d.ts +0 -13
  53. package/dist/server/tools/hud.d.ts.map +0 -1
  54. package/dist/server/tools/hud.js +0 -295
  55. package/dist/server/tools/hud.js.map +0 -1
  56. package/dist/server/tools/provider-ask.d.ts +0 -10
  57. package/dist/server/tools/provider-ask.d.ts.map +0 -1
  58. package/dist/server/tools/provider-ask.js +0 -234
  59. package/dist/server/tools/provider-ask.js.map +0 -1
  60. package/dist/server/tools/rate-limit.d.ts +0 -8
  61. package/dist/server/tools/rate-limit.d.ts.map +0 -1
  62. package/dist/server/tools/rate-limit.js +0 -184
  63. package/dist/server/tools/rate-limit.js.map +0 -1
  64. package/dist/server/tools/team-workers.d.ts +0 -7
  65. package/dist/server/tools/team-workers.d.ts.map +0 -1
  66. package/dist/server/tools/team-workers.js +0 -271
  67. package/dist/server/tools/team-workers.js.map +0 -1
@@ -0,0 +1,152 @@
1
+ ---
2
+ name: merlin:check-size
3
+ description: Scan the repo for files exceeding the 400-LOC convention and surface violations
4
+ argument-hint: "[path]"
5
+ allowed-tools:
6
+ - Read
7
+ - Glob
8
+ - Grep
9
+ - Bash
10
+ - AskUserQuestion
11
+ - mcp__merlin__merlin_route
12
+ ---
13
+
14
+ <objective>
15
+ Scan the current repository for files that exceed the 400-LOC project convention.
16
+ Surface violations grouped by severity, identify opt-out markers, and offer to route to
17
+ `code-organization-supervisor` for refactor proposals on the worst offenders.
18
+ </objective>
19
+
20
+ <context>
21
+ Optional path argument: $ARGUMENTS
22
+ If provided, scan only that directory. Otherwise scan from repo root (cwd).
23
+ </context>
24
+
25
+ <process>
26
+
27
+ ## Step 1: Discover Code Files
28
+
29
+ Run a find command to locate all code files, excluding common generated/vendor directories:
30
+
31
+ ```bash
32
+ find "${1:-.}" -type f \( \
33
+ -name "*.ts" -o -name "*.tsx" -o -name "*.js" -o -name "*.jsx" \
34
+ -o -name "*.py" -o -name "*.rs" -o -name "*.go" -o -name "*.sh" \
35
+ -o -name "*.cjs" -o -name "*.mjs" -o -name "*.swift" -o -name "*.kt" \
36
+ -o -name "*.java" -o -name "*.rb" -o -name "*.php" -o -name "*.c" \
37
+ -o -name "*.cpp" -o -name "*.h" -o -name "*.hpp" \
38
+ \) \
39
+ -not -path "*/node_modules/*" \
40
+ -not -path "*/dist/*" \
41
+ -not -path "*/build/*" \
42
+ -not -path "*/.next/*" \
43
+ -not -path "*/.git/*" \
44
+ -not -path "*/coverage/*" \
45
+ -not -path "*/.vite/*" \
46
+ -not -path "*/vendor/*" \
47
+ -not -path "*/__pycache__/*" \
48
+ 2>/dev/null | xargs wc -l 2>/dev/null | awk '$1 > 400 && !/total$/' | sort -rn
49
+ ```
50
+
51
+ Capture the output as VIOLATIONS.
52
+
53
+ ## Step 2: Group by Severity
54
+
55
+ Parse VIOLATIONS and categorize:
56
+
57
+ | Tier | LOC Range | Label |
58
+ |------|-----------|-------|
59
+ | Critical | 1000+ | files that urgently need splitting |
60
+ | High | 600-999 | should be refactored soon |
61
+ | Warn | 401-599 | approaching limit, watch closely |
62
+
63
+ ## Step 3: Check for Opt-Out Markers
64
+
65
+ For each violation, check if the file contains the opt-out marker:
66
+
67
+ ```bash
68
+ grep -l "merlin:allow-large-file:" <file>
69
+ ```
70
+
71
+ Tag files that have the marker with "(justified)" in the output.
72
+
73
+ ## Step 4: Present Report
74
+
75
+ Output a structured report:
76
+
77
+ ```
78
+ ============================================
79
+ FILE SIZE AUDIT — 400-LOC Convention
80
+ ============================================
81
+
82
+ CRITICAL (1000+ LOC):
83
+ * path/to/file.ts — 1523 lines
84
+ * path/to/other.js — 1201 lines
85
+
86
+ HIGH (600-999 LOC):
87
+ * path/to/module.py — 812 lines (justified: "generated parser tables")
88
+
89
+ WARN (401-599 LOC):
90
+ * path/to/component.tsx — 456 lines
91
+
92
+ --------------------------------------------
93
+ Total violations: X files
94
+ - X critical, X high, X warn
95
+ - X have justification markers
96
+ --------------------------------------------
97
+ ```
98
+
99
+ If no violations found:
100
+ ```
101
+ All files are within the 400-LOC convention.
102
+ ```
103
+
104
+ ## Step 5: Offer Refactor Help
105
+
106
+ If there are critical-tier violations (1000+ LOC) without markers, ask:
107
+
108
+ ```
109
+ The following files are critically oversized and lack justification:
110
+ * path/to/file.ts (1523 lines)
111
+ * path/to/other.js (1201 lines)
112
+
113
+ Would you like me to route these to `code-organization-supervisor` for
114
+ a proposed split? (y/n)
115
+ ```
116
+
117
+ If user says yes, route to the agent:
118
+
119
+ ```
120
+ Call: merlin_route
121
+ Agent: code-organization-supervisor
122
+ Task: "Propose a refactor plan to split these oversized files into smaller, focused modules:
123
+ - <file1>: <LOC> lines
124
+ - <file2>: <LOC> lines
125
+
126
+ Each resulting module should be under 400 lines. Provide:
127
+ 1. Proposed module boundaries
128
+ 2. Which functions/classes go where
129
+ 3. Import graph after the split
130
+ 4. Step-by-step migration plan"
131
+ ```
132
+
133
+ </process>
134
+
135
+ <error_handling>
136
+
137
+ | Condition | Action |
138
+ |-----------|--------|
139
+ | No code files found | Report "No code files found in <path>." |
140
+ | Permission errors | Skip inaccessible files, note count skipped |
141
+ | wc/find unavailable | Fall back to Glob + Read with manual line counting |
142
+ | Path doesn't exist | Report error and suggest checking the path |
143
+
144
+ </error_handling>
145
+
146
+ <tips>
147
+ - This command is advisory — it doesn't block anything
148
+ - The PreToolUse hook (`~/.claude/hooks/check-file-size.sh`) does the actual blocking
149
+ - Add `// merlin:allow-large-file: <reason>` to justify genuinely large files
150
+ - Override the limit per-session with `MERLIN_FILE_SIZE_LIMIT=600`
151
+ - Skip all checks with `MERLIN_SKIP_FILE_SIZE_CHECK=1`
152
+ </tips>
@@ -1,73 +1,181 @@
1
1
  #!/usr/bin/env bash
2
+ # check-file-size.sh — Claude Code PreToolUse hook
2
3
  #
3
- # Merlin Hook: PostToolUse (Write/Edit)
4
- # Checks if the modified file exceeds the 400-line convention.
5
- # Exits with code 2 to inject feedback when file is too large.
4
+ # Enforces the 400-LOC project rule. Blocks Edit/Write/MultiEdit when the
5
+ # resulting file would exceed the limit, unless the file contains an opt-out
6
+ # marker (`merlin:allow-large-file: <reason>`).
6
7
  #
7
- # Agent-type awareness: only enforce for implementation agents.
8
- # Non-code agents (docs, review, verify, etc.) are fully exempt.
8
+ # Contract per hooks-rules.md + Claude Code hooks docs:
9
+ # - stdin: JSON {tool_name, tool_input, ...}
10
+ # - block: exit 0 + stdout JSON {"decision":"block","reason":"..."}
11
+ # - allow: exit 0 + stdout {} (or empty)
12
+ # - any other exit code is treated as pass; never exit 1 to block
9
13
  #
10
- set -euo pipefail
11
- trap 'echo "{}"; exit 0' ERR
14
+ # Env opt-outs (allow legitimate overrides):
15
+ # MERLIN_SKIP_FILE_SIZE_CHECK=1 — skip entirely
16
+ # MERLIN_FILE_SIZE_LIMIT=N — override 400 default
12
17
 
13
- # Read CLAUDE_AGENT_TYPE from env — support both var names
14
- AGENT_TYPE="${CLAUDE_AGENT_TYPE:-${CLAUDE_CODE_AGENT_TYPE:-main}}"
18
+ set -uo pipefail
19
+ # NO `set -e` and NO ERR trap — explicit exit codes only, so a stray non-zero
20
+ # step (e.g. a grep that finds nothing) doesn't accidentally short-circuit the
21
+ # decision.
15
22
 
16
- # Only enforce for implementation agents
17
- # Skip for all doc/review/verification/analysis agents
18
- case "$AGENT_TYPE" in
19
- implementation-dev|merlin-executor)
20
- # Enforcement is active for these agents — continue
21
- ;;
22
- docs-keeper|merlin-reviewer|merlin-verifier|merlin-milestone-auditor|\
23
- merlin-integration-checker|merlin-work-verifier|code-organization-supervisor|\
24
- context-guardian|dry-refactor|tests-qa|merlin-codebase-mapper)
25
- echo '{}'
26
- exit 0
27
- ;;
28
- *)
29
- # For all other agents (including 'main'), skip enforcement
30
- echo '{}'
31
- exit 0
32
- ;;
33
- esac
23
+ allow() { echo '{}'; exit 0; }
24
+ block() {
25
+ local reason="$1"
26
+ # Escape backslashes and double quotes for JSON.
27
+ local escaped="${reason//\\/\\\\}"
28
+ escaped="${escaped//\"/\\\"}"
29
+ escaped="${escaped//$'\n'/\\n}"
30
+ printf '{"decision":"block","reason":"%s"}\n' "$escaped"
31
+ exit 0
32
+ }
33
+
34
+ # Opt-out via env
35
+ [ "${MERLIN_SKIP_FILE_SIZE_CHECK:-0}" = "1" ] && allow
36
+
37
+ LIMIT="${MERLIN_FILE_SIZE_LIMIT:-400}"
34
38
 
35
- # Read tool input from stdin (Claude Code pipes JSON)
36
- input=""
39
+ # Read stdin (defensive: never block on TTY)
40
+ INPUT=""
37
41
  if [ ! -t 0 ]; then
38
- input=$(cat 2>/dev/null || true)
42
+ INPUT=$(cat 2>/dev/null || true)
39
43
  fi
44
+ [ -z "$INPUT" ] && allow
40
45
 
41
- # Extract file path from tool input
42
- file_path=""
43
- if [ -n "$input" ] && command -v jq >/dev/null 2>&1; then
44
- file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || true)
45
- fi
46
+ # Need python3 for reliable JSON parsing + line counting. If missing, allow.
47
+ command -v python3 >/dev/null 2>&1 || allow
46
48
 
47
- # If no file path found, exit cleanly
48
- if [ -z "$file_path" ] || [ ! -f "$file_path" ]; then
49
- echo '{}'
50
- exit 0
51
- fi
49
+ RESULT=$(MERLIN_LIMIT="$LIMIT" python3 - "$INPUT" <<'PYEOF' 2>/dev/null || echo "ERROR"
50
+ import sys, json, os, fnmatch, re
52
51
 
53
- # Skip non-code files (images, binaries, configs, etc.)
54
- case "$file_path" in
55
- *.png|*.jpg|*.jpeg|*.gif|*.svg|*.ico|*.woff|*.woff2|*.ttf|*.eot) echo '{}'; exit 0 ;;
56
- *.lock|*.json|*.yaml|*.yml|*.toml|*.env*) echo '{}'; exit 0 ;;
57
- *node_modules*|*.git/*) echo '{}'; exit 0 ;;
58
- esac
52
+ try:
53
+ payload = json.loads(sys.argv[1])
54
+ except Exception:
55
+ print("ALLOW||json-error"); sys.exit(0)
59
56
 
60
- # Count lines in the file
61
- line_count=$(wc -l < "$file_path" 2>/dev/null || echo "0")
62
- line_count=$(echo "$line_count" | tr -d ' ')
57
+ limit = int(os.environ.get("MERLIN_LIMIT", "400"))
58
+ tool = payload.get("tool_name", "")
59
+ ti = payload.get("tool_input") or {}
60
+ fp = ti.get("file_path") or ""
63
61
 
64
- # If file exceeds 400 lines, send feedback via exit code 2
65
- if [ "$line_count" -gt 400 ]; then
66
- echo "WARNING: ${file_path} is ${line_count} lines (convention is <400). Consider splitting into smaller, focused modules." >&2
67
- echo '{}'
68
- exit 2
69
- fi
62
+ IGNORE_GLOBS = [
63
+ "*.lock", "*-lock.json", "pnpm-lock.yaml", "yarn.lock", "bun.lockb",
64
+ "Cargo.lock", "Pipfile.lock", "composer.lock", "Gemfile.lock",
65
+ "*.min.*", "*.bundle.*",
66
+ "*.svg", "*.png", "*.jpg", "*.jpeg", "*.gif", "*.ico", "*.webp", "*.pdf",
67
+ "*.woff", "*.woff2", "*.ttf", "*.eot",
68
+ "*.snap", "*.csv", "*.tsv",
69
+ "*.md",
70
+ ]
71
+ IGNORE_PATH_SUBSTRINGS = [
72
+ "/node_modules/", "/dist/", "/build/", "/.next/", "/.git/", "/out/",
73
+ "/coverage/", "/.vite/", "/migrations/", "/__fixtures__/", "/fixtures/",
74
+ "/__snapshots__/",
75
+ ]
76
+
77
+ def is_ignored(path):
78
+ if not path: return True
79
+ base = os.path.basename(path).lower()
80
+ for g in IGNORE_GLOBS:
81
+ if fnmatch.fnmatchcase(base, g.lower()): return True
82
+ norm = path.replace("\\", "/")
83
+ for s in IGNORE_PATH_SUBSTRINGS:
84
+ if s in norm: return True
85
+ return False
86
+
87
+ if is_ignored(fp):
88
+ print("ALLOW||ignored"); sys.exit(0)
89
+
90
+ def read_existing():
91
+ try:
92
+ with open(fp, "r", encoding="utf-8") as f:
93
+ return f.read()
94
+ except Exception:
95
+ return ""
70
96
 
71
- # File is within limits
72
- echo '{}'
73
- exit 0
97
+ if tool == "Write":
98
+ new_content = ti.get("content") or ""
99
+ elif tool == "Edit":
100
+ existing = read_existing()
101
+ old = ti.get("old_string", "")
102
+ new = ti.get("new_string", "")
103
+ if not existing:
104
+ new_content = new
105
+ elif ti.get("replace_all"):
106
+ new_content = existing.replace(old, new)
107
+ else:
108
+ new_content = existing.replace(old, new, 1)
109
+ elif tool == "MultiEdit":
110
+ cur = read_existing()
111
+ for e in ti.get("edits") or []:
112
+ old = e.get("old_string", "")
113
+ new = e.get("new_string", "")
114
+ if e.get("replace_all"):
115
+ cur = cur.replace(old, new)
116
+ else:
117
+ cur = cur.replace(old, new, 1)
118
+ new_content = cur
119
+ else:
120
+ print("ALLOW||unknown-tool"); sys.exit(0)
121
+
122
+ # Opt-out marker scan (first 50 + last 50 lines — let big middles still trigger)
123
+ lines = new_content.split("\n")
124
+ loc = len(lines)
125
+ if loc > 100:
126
+ sample = "\n".join(lines[:50] + lines[-50:])
127
+ else:
128
+ sample = new_content
129
+ marker = re.search(r"merlin:allow-large-file:\s*([^\n\r]*)", sample)
130
+ if marker:
131
+ reason = marker.group(1).strip()[:200].replace("|", "/")
132
+ print(f"ALLOW||marker:{reason}"); sys.exit(0)
133
+
134
+ if loc > limit:
135
+ print(f"BLOCK||{loc}||{fp}"); sys.exit(0)
136
+
137
+ print(f"ALLOW||{loc}"); sys.exit(0)
138
+ PYEOF
139
+ )
140
+
141
+ # Defensive: any python error → allow
142
+ [ "$RESULT" = "ERROR" ] && allow
143
+ [ -z "$RESULT" ] && allow
144
+
145
+ ACTION="${RESULT%%||*}"
146
+
147
+ case "$ACTION" in
148
+ BLOCK)
149
+ REST="${RESULT#BLOCK||}"
150
+ LOC_COUNT="${REST%%||*}"
151
+ FILE_PATH="${REST#*||}"
152
+ REASON="File ${FILE_PATH} would be ${LOC_COUNT} LOC, exceeding the ${LIMIT}-LOC project rule.
153
+
154
+ To proceed, either:
155
+
156
+ 1. REFACTOR — split this file into smaller modules by feature.
157
+ • Invoke the code-organization-supervisor agent for proposed splits
158
+ • Run /merlin:check-size to scan the whole repo for offenders
159
+
160
+ 2. JUSTIFY — add this comment in the first 50 lines of the file:
161
+ // merlin:allow-large-file: <one-line reason this file must stay long>
162
+ (Python/shell: # merlin:allow-large-file: <reason>)
163
+ (CSS: /* merlin:allow-large-file: <reason> */)
164
+ (HTML/JSX: <!-- merlin:allow-large-file: <reason> -->)
165
+ The reason is your audit trail — make it specific (e.g. 'generated by codegen', 'single-source-of-truth lookup table', 'vendored library').
166
+
167
+ Override per-session: MERLIN_SKIP_FILE_SIZE_CHECK=1
168
+ Override limit: MERLIN_FILE_SIZE_LIMIT=600"
169
+ block "$REASON"
170
+ ;;
171
+ ALLOW)
172
+ REST="${RESULT#ALLOW||}"
173
+ if [[ "$REST" == marker:* ]]; then
174
+ echo "[merlin] Allowed large file with marker: ${REST#marker:}" >&2
175
+ fi
176
+ allow
177
+ ;;
178
+ *)
179
+ allow
180
+ ;;
181
+ esac
@@ -99,14 +99,29 @@ fi
99
99
 
100
100
  # ─────────────────────────────────────────────────────────────────────────────
101
101
  # 3. FILE-BASED FALLBACK — guardian unreachable, use timestamp file
102
+ #
103
+ # IMPORTANT: Only block when a live Sights/guardian session genuinely existed
104
+ # (i.e. the timestamp file is present and non-empty, meaning a prior check
105
+ # happened this session) but is now stale.
106
+ #
107
+ # When there is NO guardian, NO API key, or the user is offline / running in
108
+ # Lite mode (timestamp file absent), fall through to ALLOW — we must not brick
109
+ # users who have no way to satisfy the "call merlin_get_context" instruction.
102
110
  # ─────────────────────────────────────────────────────────────────────────────
103
111
  if declare -f sights_was_checked_recently >/dev/null 2>&1; then
104
- if ! sights_was_checked_recently 120; then
112
+ # Determine whether a Sights timestamp file exists at all (proxy for "active session")
113
+ SIGHTS_TS_FILE="${HOME}/.claude/merlin/.sights-last-check"
114
+ _sights_session_active=false
115
+ if [ -f "${SIGHTS_TS_FILE}" ] && [ -s "${SIGHTS_TS_FILE}" ]; then
116
+ _sights_session_active=true
117
+ fi
118
+
119
+ if $_sights_session_active && ! sights_was_checked_recently 120; then
105
120
  if declare -f log_event >/dev/null 2>&1; then
106
121
  log_event "sights_skip_warning" "$(printf '{"file":"%s","source":"fallback"}' "${file_path:-unknown}")"
107
122
  fi
108
- # BLOCK the edit — stale context means the agent skipped merlin_get_context
109
- # This is the structural enforcement: you cannot edit without fresh Sights context
123
+ # BLOCK the edit — a prior Sights check existed but context is now stale.
124
+ # The user HAS a working Sights setup and can satisfy the requirement.
110
125
  _BADGE="$("${HOME}/.claude/scripts/duo-badge.sh" 2>/dev/null || echo "⟡🔮 MERLIN ›")"
111
126
  if command -v jq >/dev/null 2>&1; then
112
127
  jq -n --arg badge "$_BADGE" '{
@@ -121,6 +136,7 @@ if declare -f sights_was_checked_recently >/dev/null 2>&1; then
121
136
  fi
122
137
  exit 0
123
138
  fi
139
+ # No timestamp file → offline/Lite/no-API-key user — fall through and allow.
124
140
  fi
125
141
 
126
142
  # Log the pre-edit event
@@ -113,10 +113,9 @@ if echo "$content" | grep -qE 'sk-ant-[a-zA-Z0-9_\-]{90,}' 2>/dev/null; then
113
113
  fi
114
114
 
115
115
  # OpenAI key (sk- with 48+ chars, but not sk-ant-)
116
- if echo "$content" | grep -qE 'sk-(?!ant-)[a-zA-Z0-9]{48,}' 2>/dev/null; then
117
- _block "OpenAI API key" "sk-..."
118
- elif echo "$content" | grep -qE 'sk-[a-zA-Z0-9]{48,}' 2>/dev/null; then
119
- # Fallback for grep without PCRE: exclude sk-ant- manually
116
+ # Uses ERE only (no PCRE lookahead not portable across GNU and BSD grep).
117
+ # Anthropic keys (sk-ant-) are already caught by the rule above; exclude them here.
118
+ if echo "$content" | grep -qE 'sk-[a-zA-Z0-9_-]{48,}' 2>/dev/null; then
120
119
  if ! echo "$content" | grep -qE 'sk-ant-' 2>/dev/null; then
121
120
  _block "OpenAI API key" "sk-..."
122
121
  fi
@@ -140,36 +140,39 @@ COST_FILE="${HOME}/.claude/merlin/session-cost.json"
140
140
  if [ -f "${COST_FILE}" ]; then
141
141
  COST_SUMMARY=""
142
142
  if command -v node >/dev/null 2>&1; then
143
- COST_SUMMARY="$(node -e "
143
+ # Pass COST_FILE as process.argv[1] — never interpolate paths inside the script body.
144
+ COST_SUMMARY="$(node -e '
144
145
  try {
145
- const d = JSON.parse(require('fs').readFileSync('${COST_FILE}', 'utf8'));
146
+ var f = process.argv[1];
147
+ var d = JSON.parse(require("fs").readFileSync(f, "utf8"));
146
148
  if (d.totalCalls > 0) {
147
- const tot = (d.totalEstimatedCost || 0).toFixed(4);
148
- const calls = d.totalCalls || 0;
149
- const tc = d.tokenCounts || {};
150
- const tokens = (tc.totalInput || 0) + (tc.totalOutput || 0);
151
- const tokStr = tokens > 0 ? ', ' + Math.round(tokens/1000) + 'K tokens' : '';
152
- process.stdout.write('Session cost: \$' + tot + ' (' + calls + ' call' + (calls !== 1 ? 's' : '') + tokStr + ')');
149
+ var tot = (d.totalEstimatedCost || 0).toFixed(4);
150
+ var calls = d.totalCalls || 0;
151
+ var tc = d.tokenCounts || {};
152
+ var tokens = (tc.totalInput || 0) + (tc.totalOutput || 0);
153
+ var tokStr = tokens > 0 ? ", " + Math.round(tokens/1000) + "K tokens" : "";
154
+ process.stdout.write("Session cost: $" + tot + " (" + calls + " call" + (calls !== 1 ? "s" : "") + tokStr + ")");
153
155
  }
154
156
  } catch(e) {}
155
- " 2>/dev/null || true)"
157
+ ' "$COST_FILE" 2>/dev/null || true)"
156
158
  elif command -v python3 >/dev/null 2>&1; then
157
- COST_SUMMARY="$(python3 -c "
159
+ # Pass COST_FILE as sys.argv[1] — never interpolate paths inside the script body.
160
+ COST_SUMMARY="$(python3 -c '
158
161
  import json, sys
159
162
  try:
160
- with open('${COST_FILE}') as f:
163
+ with open(sys.argv[1]) as f:
161
164
  d = json.load(f)
162
- if d.get('totalCalls', 0) > 0:
163
- tot = d.get('totalEstimatedCost', 0)
164
- calls = d.get('totalCalls', 0)
165
- tc = d.get('tokenCounts', {})
166
- tokens = tc.get('totalInput', 0) + tc.get('totalOutput', 0)
167
- tok_str = (', ' + str(round(tokens/1000)) + 'K tokens') if tokens > 0 else ''
168
- plural = '' if calls == 1 else 's'
169
- sys.stdout.write(f'Session cost: \${tot:.4f} ({calls} call{plural}{tok_str})')
165
+ if d.get("totalCalls", 0) > 0:
166
+ tot = d.get("totalEstimatedCost", 0)
167
+ calls = d.get("totalCalls", 0)
168
+ tc = d.get("tokenCounts", {})
169
+ tokens = tc.get("totalInput", 0) + tc.get("totalOutput", 0)
170
+ tok_str = (", " + str(round(tokens/1000)) + "K tokens") if tokens > 0 else ""
171
+ plural = "" if calls == 1 else "s"
172
+ sys.stdout.write("Session cost: ${:.4f} ({} call{}{})".format(tot, calls, plural, tok_str))
170
173
  except Exception:
171
174
  pass
172
- " 2>/dev/null || true)"
175
+ ' "$COST_FILE" 2>/dev/null || true)"
173
176
  fi
174
177
  if [ -n "${COST_SUMMARY}" ]; then
175
178
  echo "[merlin] ${COST_SUMMARY}" >&2
@@ -249,19 +252,29 @@ if command -v jq >/dev/null 2>&1 && command -v git >/dev/null 2>&1; then
249
252
  echo "${outcome_line}" >> "${OUTCOME_FILE}" 2>/dev/null || true
250
253
  fi
251
254
 
252
- # Keep outcomes.jsonl bounded — drop lines older than 90 days
255
+ # Keep outcomes.jsonl bounded — drop lines older than 90 days.
256
+ # Single awk pass: no per-line subprocess, ISO-8601 UTC strings sort lexicographically.
257
+ # On any parse uncertainty, the line is KEPT (never silently dropped).
253
258
  if [ -f "${OUTCOME_FILE}" ]; then
254
- cutoff_epoch="$(date -u -v-90d +%s 2>/dev/null || date -u -d '90 days ago' +%s 2>/dev/null || echo 0)"
255
- if [ "${cutoff_epoch}" -gt 0 ] 2>/dev/null; then
256
- tmp_out="${OUTCOME_FILE}.tmp"
257
- while IFS= read -r line; do
258
- line_ts="$(echo "${line}" | jq -r '.timestamp // ""' 2>/dev/null || echo "")"
259
- if [ -z "${line_ts}" ]; then continue; fi
260
- line_epoch="$(date -u -d "${line_ts}" +%s 2>/dev/null || date -u -j -f "%Y-%m-%dT%H:%M:%SZ" "${line_ts}" +%s 2>/dev/null || echo 0)"
261
- if [ "${line_epoch}" -ge "${cutoff_epoch}" ] 2>/dev/null; then
262
- echo "${line}"
263
- fi
264
- done < "${OUTCOME_FILE}" > "${tmp_out}" 2>/dev/null && mv "${tmp_out}" "${OUTCOME_FILE}" 2>/dev/null || true
259
+ cutoff="$(date -u -v-90d +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || date -u -d '90 days ago' +%Y-%m-%dT%H:%M:%SZ 2>/dev/null || echo "")"
260
+ if [ -n "${cutoff}" ]; then
261
+ tmp_out="${OUTCOME_FILE}.tmp.$$"
262
+ # 2-arg match() only portable to BSD awk (macOS default) AND gawk.
263
+ # The gawk-only 3-arg form `match($0, re, arr)` is a syntax error on BSD
264
+ # awk and would make the whole rotation a silent no-op.
265
+ awk -v cutoff="${cutoff}" '
266
+ {
267
+ ts = ""
268
+ # "timestamp":"<value>" → the literal prefix is 13 chars, trailing quote 1.
269
+ if (match($0, /"timestamp":"[^"]+"/)) {
270
+ ts = substr($0, RSTART + 13, RLENGTH - 14)
271
+ }
272
+ # Keep line if timestamp missing/unparseable OR >= cutoff (ISO string compare)
273
+ if (ts == "" || ts >= cutoff) print
274
+ }
275
+ ' "${OUTCOME_FILE}" > "${tmp_out}" 2>/dev/null \
276
+ && mv "${tmp_out}" "${OUTCOME_FILE}" 2>/dev/null \
277
+ || rm -f "${tmp_out}" 2>/dev/null || true
265
278
  fi
266
279
  fi
267
280
  fi
@@ -146,6 +146,8 @@ fi
146
146
  # ─────────────────────────────────────────────────────────────────────────────
147
147
 
148
148
  # Safe base command list (exact match on first word)
149
+ # NOTE: curl, wget, nc, scp, ssh, telnet are intentionally excluded — network egress
150
+ # must never be auto-approved; even a seemingly-safe command could pipe to a network tool.
149
151
  SAFE_CMDS="cat|head|tail|less|more|wc|file|stat|du|df|which|type|whereis|man|echo|printf|ls|find|tree|fd|grep|rg|ag|ack|fzf|jest|vitest|mocha|pytest|cargo|go|npm|yarn|pnpm|make|tsc|prettier|eslint|rustfmt|black|flake8|mypy|rubocop|uname|hostname|whoami|id|env|printenv|date|uptime|ps|top|git|node|python|python3"
150
152
 
151
153
  # Git read-only subcommands
@@ -244,16 +246,22 @@ _is_safe_segment() {
244
246
  return 0
245
247
  }
246
248
 
247
- # Split on pipe, semicolon, &&, || and check each segment
248
- # Replace separators with newlines, then iterate
249
+ # Split on shell operators (&&, ||, ;, |, &) treating two-char tokens first.
250
+ # Strategy: normalize && and || to newlines first (so they are not left as stray &/|),
251
+ # then normalize remaining ; | & to newlines. Each resulting segment is checked
252
+ # independently — the whole command is safe only when ALL segments are safe.
249
253
  all_safe=true
250
254
  while IFS= read -r segment; do
255
+ segment=$(echo "$segment" | xargs 2>/dev/null || echo "$segment") # trim whitespace
251
256
  [ -z "$segment" ] && continue
252
257
  if ! _is_safe_segment "$segment"; then
253
258
  all_safe=false
254
259
  break
255
260
  fi
256
- done < <(echo "$command_str" | tr '|;&' '\n')
261
+ done < <(echo "$command_str" | sed -E 's/\|\|/\
262
+ /g; s/&&/\
263
+ /g; s/[;|&]/\
264
+ /g')
257
265
 
258
266
  if $all_safe; then
259
267
  if declare -f log_event >/dev/null 2>&1; then
@@ -82,15 +82,36 @@ fi
82
82
  [ -z "$suggestion" ] && echo "{}" && exit 0
83
83
 
84
84
  # ── Emit routing hint ─────────────────────────────────────────────────────────
85
- _BADGE="$("${HOME}/.claude/scripts/duo-badge.sh" 2>/dev/null || echo "⟡🔮 MERLIN ›")"
85
+ # Cache the badge for the session to avoid spawning duo-badge.sh on every prompt.
86
+ # Cache key: the Claude Code session_id (stable for the whole session) parsed
87
+ # from the stdin JSON we already captured. $$ is a FRESH pid per hook spawn —
88
+ # it would never hit and would leak a temp file every prompt — so fall back to
89
+ # $PPID (stable across prompts in one session), never $$.
90
+ _SESSION_KEY=""
91
+ if command -v jq >/dev/null 2>&1; then
92
+ _SESSION_KEY=$(printf '%s' "$input" | jq -r '.session_id // empty' 2>/dev/null || true)
93
+ else
94
+ _SESSION_KEY=$(printf '%s' "$input" | grep -o '"session_id"[[:space:]]*:[[:space:]]*"[^"]*"' | head -1 | sed 's/.*"\([^"]*\)"$/\1/' 2>/dev/null || true)
95
+ fi
96
+ [ -z "${_SESSION_KEY}" ] && _SESSION_KEY="ppid-${PPID}"
97
+ # Sanitize the key so it is safe as a filename fragment.
98
+ _SESSION_KEY=$(printf '%s' "${_SESSION_KEY}" | tr -c 'A-Za-z0-9._-' '_')
99
+ _BADGE_CACHE="${TMPDIR:-/tmp}/.merlin-badge-${_SESSION_KEY}"
100
+ if [ -f "${_BADGE_CACHE}" ]; then
101
+ _BADGE="$(cat "${_BADGE_CACHE}" 2>/dev/null || echo "⟡🔮 MERLIN ›")"
102
+ else
103
+ _BADGE="$("${HOME}/.claude/scripts/duo-badge.sh" 2>/dev/null || echo "⟡🔮 MERLIN ›")"
104
+ printf '%s' "${_BADGE}" > "${_BADGE_CACHE}" 2>/dev/null || true
105
+ fi
106
+
86
107
  _ctx="${_BADGE} ROUTING: ${suggestion}. Remember: YOU are the orchestrator. Answer codebase questions via Sights. Route implementation to agents. Badge every action."
87
108
 
88
109
  if command -v jq >/dev/null 2>&1; then
89
110
  jq -n --arg ctx "$_ctx" \
90
111
  '{hookSpecificOutput:{hookEventName:"UserPromptSubmit",additionalContext:$ctx}}'
91
112
  else
92
- # jq not available — emit via printf, escaping the double-quotes in suggestion
93
- _ctx_escaped=$(printf '%s' "$_ctx" | sed 's/"/\\"/g')
113
+ # jq not available — escape backslashes first, then quotes, then strip control chars
114
+ _ctx_escaped=$(printf '%s' "$_ctx" | sed 's/\\/\\\\/g; s/"/\\"/g' | tr -d '\000-\037')
94
115
  printf '{"hookSpecificOutput":{"hookEventName":"UserPromptSubmit","additionalContext":"%s"}}\n' "$_ctx_escaped"
95
116
  fi
96
117
 
@@ -1 +1 @@
1
- 3.22.0
1
+ 5.4.0
@@ -34,4 +34,6 @@ wait
34
34
 
35
35
  TASK OPTIMIZATION: Before EVERY routing decision (workflows, agents, specialists), run ~/.claude/scripts/task-optimize.sh --task "<user text>". It returns {intent, skills[], agent, score, matched_phrases}. If score>=25, ALWAYS announce "⟡🔮 MERLIN › Intent: <X>. Loading <skills> + routing to <agent>." then route with skills prepended to context. Registry: ~/.claude/merlin/skills/TASK-OPTIMIZER.json. Slash commands /merlin:optimize, /merlin:design-audit, /merlin:polish, /merlin:redesign wrap common flows.
36
36
 
37
- DISCOVERY-FIRST: Before routing any non-trivial task to a built-in specialist, call `merlin_smart_route(task="...")` and `merlin_discover_agents(query="...")` to check the community catalog (1000+ indexed agents/skills). If a catalog match scores a higher grade (A+/A++) than the best built-in for the task, surface it: "⟡🔮 MERLIN › I found a community agent that fits better: <name> (Grade: <X>). Install with /merlin:install <slug> or stick with built-in <built-in>?" — then wait for user choice. NEVER install without confirmation. Skip discovery only for: trivial single-file edits, debug runs, doc updates, or when the user explicitly names an agent.
37
+ DISCOVERY-FIRST: Before routing any non-trivial task to a built-in specialist, call `merlin_smart_route(task="...")` and `merlin_discover_agents(query="...")` to check the community catalog (1000+ indexed agents/skills). If a catalog match scores a higher grade (A+/A++) than the best built-in for the task, surface it: "⟡🔮 MERLIN › I found a community agent that fits better: <name> (Grade: <X>). Install with /merlin:install <slug> or stick with built-in <built-in>?" — then wait for user choice. NEVER install without confirmation. Skip discovery only for: trivial single-file edits, debug runs, doc updates, or when the user explicitly names an agent.
38
+
39
+ FILE-SIZE ENFORCEMENT: Every Write/Edit/MultiEdit is blocked by ~/.claude/hooks/check-file-size.sh if the resulting file exceeds 400 LOC (configurable via MERLIN_FILE_SIZE_LIMIT). When blocked, EITHER refactor into smaller modules (preferred) OR add `// merlin:allow-large-file: <one-line reason>` in the first 50 lines if the size is genuinely justified (e.g., generated code, big lookup table, single-file library). Use /merlin:check-size to scan the whole repo at any time.