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.
- package/bin/install-rtk.cjs +282 -0
- package/bin/install.cjs +26 -4
- package/dist/server/api/client.d.ts.map +1 -1
- package/dist/server/api/client.js +35 -6
- package/dist/server/api/client.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +146 -42
- package/dist/server/server.js.map +1 -1
- package/dist/server/session-coach.d.ts.map +1 -1
- package/dist/server/session-coach.js +12 -0
- package/dist/server/session-coach.js.map +1 -1
- package/dist/server/session-guardian.d.ts +8 -1
- package/dist/server/session-guardian.d.ts.map +1 -1
- package/dist/server/session-guardian.js +26 -14
- package/dist/server/session-guardian.js.map +1 -1
- package/dist/server/tools/challenge.d.ts.map +1 -1
- package/dist/server/tools/challenge.js +7 -1
- package/dist/server/tools/challenge.js.map +1 -1
- package/dist/server/tools/computer-use.d.ts.map +1 -1
- package/dist/server/tools/computer-use.js +13 -6
- package/dist/server/tools/computer-use.js.map +1 -1
- package/dist/server/tools/index.d.ts +0 -1
- package/dist/server/tools/index.d.ts.map +1 -1
- package/dist/server/tools/index.js +0 -1
- package/dist/server/tools/index.js.map +1 -1
- package/dist/server/tools/project.d.ts.map +1 -1
- package/dist/server/tools/project.js +14 -12
- package/dist/server/tools/project.js.map +1 -1
- package/dist/server/tools/verification-runner.d.ts +45 -0
- package/dist/server/tools/verification-runner.d.ts.map +1 -0
- package/dist/server/tools/verification-runner.js +264 -0
- package/dist/server/tools/verification-runner.js.map +1 -0
- package/dist/server/tools/verification.d.ts +3 -0
- package/dist/server/tools/verification.d.ts.map +1 -1
- package/dist/server/tools/verification.js +8 -265
- package/dist/server/tools/verification.js.map +1 -1
- package/files/CLAUDE.md +1 -0
- package/files/commands/merlin/check-size.md +152 -0
- package/files/hooks/check-file-size.sh +166 -58
- package/files/hooks/pre-edit-sights-check.sh +19 -3
- package/files/hooks/security-scanner.sh +3 -4
- package/files/hooks/session-end.sh +45 -32
- package/files/hooks/smart-approve.sh +11 -3
- package/files/hooks/user-prompt-router.sh +24 -3
- package/files/merlin/VERSION +1 -1
- package/files/merlin-system-prompt.txt +3 -1
- package/package.json +2 -2
- package/dist/server/tools/context.d.ts +0 -7
- package/dist/server/tools/context.d.ts.map +0 -1
- package/dist/server/tools/context.js +0 -614
- package/dist/server/tools/context.js.map +0 -1
- package/dist/server/tools/hud.d.ts +0 -13
- package/dist/server/tools/hud.d.ts.map +0 -1
- package/dist/server/tools/hud.js +0 -295
- package/dist/server/tools/hud.js.map +0 -1
- package/dist/server/tools/provider-ask.d.ts +0 -10
- package/dist/server/tools/provider-ask.d.ts.map +0 -1
- package/dist/server/tools/provider-ask.js +0 -234
- package/dist/server/tools/provider-ask.js.map +0 -1
- package/dist/server/tools/rate-limit.d.ts +0 -8
- package/dist/server/tools/rate-limit.d.ts.map +0 -1
- package/dist/server/tools/rate-limit.js +0 -184
- package/dist/server/tools/rate-limit.js.map +0 -1
- package/dist/server/tools/team-workers.d.ts +0 -7
- package/dist/server/tools/team-workers.d.ts.map +0 -1
- package/dist/server/tools/team-workers.js +0 -271
- 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
|
-
#
|
|
4
|
-
#
|
|
5
|
-
#
|
|
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
|
-
#
|
|
8
|
-
#
|
|
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
|
-
|
|
11
|
-
|
|
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
|
-
|
|
14
|
-
|
|
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
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
36
|
-
|
|
39
|
+
# Read stdin (defensive: never block on TTY)
|
|
40
|
+
INPUT=""
|
|
37
41
|
if [ ! -t 0 ]; then
|
|
38
|
-
|
|
42
|
+
INPUT=$(cat 2>/dev/null || true)
|
|
39
43
|
fi
|
|
44
|
+
[ -z "$INPUT" ] && allow
|
|
40
45
|
|
|
41
|
-
#
|
|
42
|
-
|
|
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
|
-
|
|
48
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
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 —
|
|
109
|
-
#
|
|
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
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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
|
-
|
|
143
|
+
# Pass COST_FILE as process.argv[1] — never interpolate paths inside the script body.
|
|
144
|
+
COST_SUMMARY="$(node -e '
|
|
144
145
|
try {
|
|
145
|
-
|
|
146
|
+
var f = process.argv[1];
|
|
147
|
+
var d = JSON.parse(require("fs").readFileSync(f, "utf8"));
|
|
146
148
|
if (d.totalCalls > 0) {
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
process.stdout.write(
|
|
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
|
-
|
|
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(
|
|
163
|
+
with open(sys.argv[1]) as f:
|
|
161
164
|
d = json.load(f)
|
|
162
|
-
if d.get(
|
|
163
|
-
tot = d.get(
|
|
164
|
-
calls = d.get(
|
|
165
|
-
tc = d.get(
|
|
166
|
-
tokens = tc.get(
|
|
167
|
-
tok_str = (
|
|
168
|
-
plural =
|
|
169
|
-
sys.stdout.write(
|
|
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
|
-
|
|
255
|
-
if [ "${
|
|
256
|
-
tmp_out="${OUTCOME_FILE}.tmp"
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
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
|
|
248
|
-
#
|
|
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" |
|
|
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
|
-
|
|
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 —
|
|
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
|
|
package/files/merlin/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
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.
|