create-merlin-brain 3.10.0 → 3.12.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.cjs +146 -22
- package/bin/runtime-adapters.cjs +396 -0
- package/dist/server/cost/tracker.d.ts +38 -2
- package/dist/server/cost/tracker.d.ts.map +1 -1
- package/dist/server/cost/tracker.js +87 -15
- package/dist/server/cost/tracker.js.map +1 -1
- package/dist/server/server.d.ts.map +1 -1
- package/dist/server/server.js +74 -30
- package/dist/server/server.js.map +1 -1
- package/dist/server/tools/adaptive.js +1 -1
- package/dist/server/tools/adaptive.js.map +1 -1
- package/dist/server/tools/agents-index.js +3 -3
- package/dist/server/tools/agents-index.js.map +1 -1
- package/dist/server/tools/agents.js +5 -5
- package/dist/server/tools/agents.js.map +1 -1
- package/dist/server/tools/behaviors.js +4 -4
- package/dist/server/tools/behaviors.js.map +1 -1
- package/dist/server/tools/context.js +7 -7
- package/dist/server/tools/context.js.map +1 -1
- package/dist/server/tools/cost.d.ts +3 -1
- package/dist/server/tools/cost.d.ts.map +1 -1
- package/dist/server/tools/cost.js +66 -13
- package/dist/server/tools/cost.js.map +1 -1
- package/dist/server/tools/discoveries.js +6 -6
- package/dist/server/tools/discoveries.js.map +1 -1
- package/dist/server/tools/index.d.ts +4 -0
- package/dist/server/tools/index.d.ts.map +1 -1
- package/dist/server/tools/index.js +4 -0
- package/dist/server/tools/index.js.map +1 -1
- package/dist/server/tools/learning.d.ts +12 -0
- package/dist/server/tools/learning.d.ts.map +1 -0
- package/dist/server/tools/learning.js +269 -0
- package/dist/server/tools/learning.js.map +1 -0
- package/dist/server/tools/project.js +7 -7
- package/dist/server/tools/project.js.map +1 -1
- package/dist/server/tools/promote.d.ts +11 -0
- package/dist/server/tools/promote.d.ts.map +1 -0
- package/dist/server/tools/promote.js +315 -0
- package/dist/server/tools/promote.js.map +1 -0
- package/dist/server/tools/route.d.ts.map +1 -1
- package/dist/server/tools/route.js +65 -24
- package/dist/server/tools/route.js.map +1 -1
- package/dist/server/tools/session-restore.d.ts +18 -0
- package/dist/server/tools/session-restore.d.ts.map +1 -0
- package/dist/server/tools/session-restore.js +154 -0
- package/dist/server/tools/session-restore.js.map +1 -0
- package/dist/server/tools/session-search.d.ts +16 -0
- package/dist/server/tools/session-search.d.ts.map +1 -0
- package/dist/server/tools/session-search.js +240 -0
- package/dist/server/tools/session-search.js.map +1 -0
- package/dist/server/tools/sights-index.js +2 -2
- package/dist/server/tools/sights-index.js.map +1 -1
- package/dist/server/tools/smart-route.d.ts.map +1 -1
- package/dist/server/tools/smart-route.js +4 -5
- package/dist/server/tools/smart-route.js.map +1 -1
- package/dist/server/tools/verification.js +1 -1
- package/dist/server/tools/verification.js.map +1 -1
- package/files/agents/code-organization-supervisor.md +9 -0
- package/files/agents/context-guardian.md +9 -0
- package/files/agents/docs-keeper.md +11 -1
- package/files/agents/dry-refactor.md +12 -1
- package/files/agents/elite-code-refactorer.md +10 -0
- package/files/agents/hardening-guard.md +13 -1
- package/files/agents/implementation-dev.md +12 -1
- package/files/agents/merlin-access-control-reviewer.md +248 -0
- package/files/agents/merlin-api-designer.md +9 -0
- package/files/agents/merlin-codebase-mapper.md +9 -1
- package/files/agents/merlin-debugger.md +10 -0
- package/files/agents/merlin-dependency-auditor.md +216 -0
- package/files/agents/merlin-executor.md +12 -1
- package/files/agents/merlin-frontend.md +9 -0
- package/files/agents/merlin-input-validator.md +247 -0
- package/files/agents/merlin-integration-checker.md +9 -1
- package/files/agents/merlin-migrator.md +9 -0
- package/files/agents/merlin-milestone-auditor.md +8 -0
- package/files/agents/merlin-performance.md +8 -0
- package/files/agents/merlin-planner.md +10 -0
- package/files/agents/merlin-researcher.md +10 -0
- package/files/agents/merlin-reviewer.md +42 -7
- package/files/agents/merlin-sast-reviewer.md +182 -0
- package/files/agents/merlin-secret-scanner.md +203 -0
- package/files/agents/merlin-security.md +9 -0
- package/files/agents/merlin-verifier.md +9 -0
- package/files/agents/merlin-work-verifier.md +9 -0
- package/files/agents/merlin.md +10 -0
- package/files/agents/ops-railway.md +11 -1
- package/files/agents/orchestrator-retrofit.md +9 -1
- package/files/agents/product-spec.md +11 -1
- package/files/agents/remotion.md +8 -0
- package/files/agents/system-architect.md +11 -1
- package/files/agents/tests-qa.md +12 -1
- package/files/commands/merlin/course-correct.md +219 -0
- package/files/commands/merlin/debug.md +2 -2
- package/files/commands/merlin/execute-phase.md +96 -199
- package/files/commands/merlin/execute-plan.md +118 -182
- package/files/commands/merlin/health.md +385 -0
- package/files/commands/merlin/loop-recipes.md +93 -36
- package/files/commands/merlin/map-codebase.md +4 -4
- package/files/commands/merlin/next.md +240 -0
- package/files/commands/merlin/optimize-prompts.md +158 -0
- package/files/commands/merlin/plan-phase.md +1 -1
- package/files/commands/merlin/profiles.md +215 -0
- package/files/commands/merlin/promote.md +176 -0
- package/files/commands/merlin/quick.md +229 -0
- package/files/commands/merlin/readiness-gate.md +208 -0
- package/files/commands/merlin/research-phase.md +2 -2
- package/files/commands/merlin/research-project.md +4 -4
- package/files/commands/merlin/resume-work.md +27 -1
- package/files/commands/merlin/route.md +43 -1
- package/files/commands/merlin/sandbox.md +359 -0
- package/files/commands/merlin/usage.md +55 -0
- package/files/commands/merlin/verify-work.md +1 -1
- package/files/docker/Dockerfile.merlin +20 -0
- package/files/docker/docker-compose.merlin.yml +23 -0
- package/files/hook-templates/auto-commit.sh +64 -0
- package/files/hook-templates/auto-format.sh +95 -0
- package/files/hook-templates/auto-test.sh +117 -0
- package/files/hook-templates/branch-protection.sh +72 -0
- package/files/hook-templates/changelog-reminder.sh +76 -0
- package/files/hook-templates/complexity-check.sh +112 -0
- package/files/hook-templates/import-audit.sh +83 -0
- package/files/hook-templates/license-header.sh +84 -0
- package/files/hook-templates/pr-description.sh +100 -0
- package/files/hook-templates/todo-tracker.sh +80 -0
- package/files/hooks/check-file-size.sh +17 -4
- package/files/hooks/config-change.sh +44 -16
- package/files/hooks/instructions-loaded.sh +22 -5
- package/files/hooks/notify-desktop.sh +157 -0
- package/files/hooks/notify-webhook.sh +141 -0
- package/files/hooks/pre-edit-sights-check.sh +76 -9
- package/files/hooks/security-scanner.sh +153 -0
- package/files/hooks/session-end-memory-sync.sh +97 -0
- package/files/hooks/session-end.sh +274 -1
- package/files/hooks/session-start.sh +19 -6
- package/files/hooks/smart-approve.sh +270 -0
- package/files/hooks/teammate-idle-verify.sh +87 -12
- package/files/hooks/worktree-create.sh +20 -3
- package/files/hooks/worktree-remove.sh +21 -3
- package/files/merlin/references/plan-format.md +37 -9
- package/files/merlin/sandbox.json +9 -0
- package/files/merlin/security.json +11 -0
- package/files/merlin/templates/ci/docs-update.yml +81 -0
- package/files/merlin/templates/ci/pr-review.yml +50 -0
- package/files/merlin/templates/ci/security-audit.yml +74 -0
- package/files/merlin/templates/config.json +9 -1
- package/files/rules/api-rules.md +30 -0
- package/files/rules/frontend-rules.md +25 -0
- package/files/rules/hooks-rules.md +36 -0
- package/files/rules/mcp-rules.md +30 -0
- package/files/rules/worker-rules.md +29 -0
- package/package.json +1 -1
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
3
|
# Merlin Hook: PreToolUse (Edit/Write)
|
|
4
|
-
#
|
|
5
|
-
#
|
|
4
|
+
# Two responsibilities:
|
|
5
|
+
# 1. Secret detection — blocks writes that contain leaked credentials.
|
|
6
|
+
# Exit code 2 with a clear message. This is the only hard block.
|
|
7
|
+
# 2. Sights consultation check — advisory warning if Sights was not
|
|
8
|
+
# consulted recently. Always exits 0 (never blocks on this).
|
|
6
9
|
#
|
|
7
10
|
set -euo pipefail
|
|
8
11
|
trap 'echo "{}"; exit 0' ERR
|
|
@@ -11,9 +14,9 @@ HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
|
11
14
|
|
|
12
15
|
# Source shared libraries
|
|
13
16
|
# shellcheck source=lib/analytics.sh
|
|
14
|
-
. "${HOOKS_DIR}/lib/analytics.sh"
|
|
17
|
+
[ -f "${HOOKS_DIR}/lib/analytics.sh" ] && . "${HOOKS_DIR}/lib/analytics.sh"
|
|
15
18
|
# shellcheck source=lib/sights-check.sh
|
|
16
|
-
. "${HOOKS_DIR}/lib/sights-check.sh"
|
|
19
|
+
[ -f "${HOOKS_DIR}/lib/sights-check.sh" ] && . "${HOOKS_DIR}/lib/sights-check.sh"
|
|
17
20
|
|
|
18
21
|
# Read tool input from stdin (Claude Code pipes JSON)
|
|
19
22
|
input=""
|
|
@@ -21,19 +24,83 @@ if [ ! -t 0 ]; then
|
|
|
21
24
|
input=$(cat 2>/dev/null || true)
|
|
22
25
|
fi
|
|
23
26
|
|
|
24
|
-
# Extract file path from tool input
|
|
27
|
+
# Extract file path and content from tool input
|
|
25
28
|
file_path=""
|
|
29
|
+
content_to_write=""
|
|
26
30
|
if [ -n "$input" ] && command -v jq >/dev/null 2>&1; then
|
|
27
31
|
file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || true)
|
|
32
|
+
# Write tool has 'content', Edit tool has 'new_string'
|
|
33
|
+
content_to_write=$(echo "$input" | jq -r '.tool_input.content // .tool_input.new_string // empty' 2>/dev/null || true)
|
|
28
34
|
fi
|
|
29
35
|
|
|
30
|
-
#
|
|
31
|
-
|
|
32
|
-
|
|
36
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
# SECRET DETECTION
|
|
38
|
+
# Scans the content being written for common credential patterns.
|
|
39
|
+
# Uses grep with POSIX ERE for speed (<5ms on typical file sizes).
|
|
40
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
41
|
+
if [ -n "$content_to_write" ]; then
|
|
42
|
+
SECRET_FOUND=""
|
|
43
|
+
SECRET_TYPE=""
|
|
44
|
+
|
|
45
|
+
# AWS access key: AKIA followed by 16 uppercase alphanumeric chars
|
|
46
|
+
if echo "$content_to_write" | grep -qE 'AKIA[0-9A-Z]{16}' 2>/dev/null; then
|
|
47
|
+
SECRET_FOUND=1
|
|
48
|
+
SECRET_TYPE="AWS access key (AKIA...)"
|
|
49
|
+
fi
|
|
50
|
+
|
|
51
|
+
# OpenAI / Anthropic style API key: sk- followed by 48+ alphanumeric chars
|
|
52
|
+
if [ -z "$SECRET_FOUND" ] && echo "$content_to_write" | grep -qE 'sk-[a-zA-Z0-9]{48,}' 2>/dev/null; then
|
|
53
|
+
SECRET_FOUND=1
|
|
54
|
+
SECRET_TYPE="API secret key (sk-...)"
|
|
55
|
+
fi
|
|
56
|
+
|
|
57
|
+
# PEM private key header
|
|
58
|
+
if [ -z "$SECRET_FOUND" ] && echo "$content_to_write" | \
|
|
59
|
+
grep -qE '-----BEGIN (RSA|EC|DSA|OPENSSH|PRIVATE) KEY-----' 2>/dev/null; then
|
|
60
|
+
SECRET_FOUND=1
|
|
61
|
+
SECRET_TYPE="PEM private key"
|
|
62
|
+
fi
|
|
63
|
+
|
|
64
|
+
# Generic password assignment in config-like contexts
|
|
65
|
+
# Matches: password=, PASSWORD=, "password": "...", passwd=
|
|
66
|
+
# Requires the value to be non-empty and at least 8 chars to avoid false positives
|
|
67
|
+
if [ -z "$SECRET_FOUND" ] && echo "$content_to_write" | \
|
|
68
|
+
grep -qiE '(password|passwd|secret|api_key|apikey)\s*[=:]\s*["\x27]?[A-Za-z0-9@#$%^&*!_\-]{8,}' 2>/dev/null; then
|
|
69
|
+
# Exclude known placeholder patterns: <password>, ${PASSWORD}, %(password)s, REPLACE_ME, etc.
|
|
70
|
+
if ! echo "$content_to_write" | \
|
|
71
|
+
grep -qiE '(password|passwd|secret|api_key|apikey)\s*[=:]\s*["\x27]?(<[^>]+>|\$\{[^}]+\}|%\([^)]+\)s|REPLACE_ME|YOUR_|TODO|CHANGEME|example|placeholder|xxx+|test)' 2>/dev/null; then
|
|
72
|
+
SECRET_FOUND=1
|
|
73
|
+
SECRET_TYPE="credential assignment (password/secret/api_key)"
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
if [ -n "$SECRET_FOUND" ]; then
|
|
78
|
+
if declare -f log_event >/dev/null 2>&1; then
|
|
79
|
+
log_event "secret_detected" "$(printf '{"file":"%s","type":"%s"}' \
|
|
80
|
+
"${file_path:-unknown}" "$SECRET_TYPE")"
|
|
81
|
+
fi
|
|
82
|
+
echo "SECRET DETECTED: ${SECRET_TYPE} found in write to '${file_path:-unknown}'. Remove the secret before committing." >&2
|
|
83
|
+
# Output block decision — Claude Code reads this JSON when exit code is 2
|
|
84
|
+
echo '{"decision":"block","reason":"Secret detected in file write. Remove the secret before committing."}'
|
|
85
|
+
exit 2
|
|
86
|
+
fi
|
|
87
|
+
fi
|
|
88
|
+
|
|
89
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
# SIGHTS CONSULTATION CHECK (advisory only)
|
|
91
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
92
|
+
if declare -f sights_was_checked_recently >/dev/null 2>&1; then
|
|
93
|
+
if ! sights_was_checked_recently 120; then
|
|
94
|
+
if declare -f log_event >/dev/null 2>&1; then
|
|
95
|
+
log_event "sights_skip_warning" "$(printf '{"file":"%s"}' "${file_path:-unknown}")"
|
|
96
|
+
fi
|
|
97
|
+
fi
|
|
33
98
|
fi
|
|
34
99
|
|
|
35
100
|
# Log the pre-edit event
|
|
36
|
-
log_event
|
|
101
|
+
if declare -f log_event >/dev/null 2>&1; then
|
|
102
|
+
log_event "pre_edit" "$(printf '{"file":"%s"}' "${file_path:-unknown}")"
|
|
103
|
+
fi
|
|
37
104
|
|
|
38
105
|
# Claude Code command hooks must output valid JSON to stdout
|
|
39
106
|
echo '{}'
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Merlin Hook: security-scanner.sh
|
|
4
|
+
# Event: PreToolUse (Write, Edit, Bash, NotebookEdit)
|
|
5
|
+
#
|
|
6
|
+
# Scans content being written/executed for:
|
|
7
|
+
# A) Prompt injection patterns
|
|
8
|
+
# B) Secret/credential leaks
|
|
9
|
+
# C) Data exfiltration patterns
|
|
10
|
+
#
|
|
11
|
+
# Exit 2 + JSON {"decision":"block","reason":"..."} to block.
|
|
12
|
+
# Exit 0 to allow.
|
|
13
|
+
#
|
|
14
|
+
set -euo pipefail
|
|
15
|
+
trap 'echo "{}"; exit 0' ERR
|
|
16
|
+
|
|
17
|
+
HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
18
|
+
LOG_DIR="${HOME}/.claude/merlin"
|
|
19
|
+
LOG_FILE="${LOG_DIR}/security.log"
|
|
20
|
+
|
|
21
|
+
# Source shared analytics if available
|
|
22
|
+
[ -f "${HOOKS_DIR}/lib/analytics.sh" ] && . "${HOOKS_DIR}/lib/analytics.sh"
|
|
23
|
+
|
|
24
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
25
|
+
# Read input from stdin
|
|
26
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
27
|
+
input=""
|
|
28
|
+
if [ ! -t 0 ]; then
|
|
29
|
+
input=$(cat 2>/dev/null || true)
|
|
30
|
+
fi
|
|
31
|
+
|
|
32
|
+
[ -z "$input" ] && { echo '{}'; exit 0; }
|
|
33
|
+
|
|
34
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
35
|
+
# Extract content to scan
|
|
36
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
37
|
+
tool_name=""
|
|
38
|
+
content=""
|
|
39
|
+
file_path=""
|
|
40
|
+
|
|
41
|
+
if command -v jq >/dev/null 2>&1; then
|
|
42
|
+
tool_name=$(echo "$input" | jq -r '.tool_name // empty' 2>/dev/null || true)
|
|
43
|
+
file_path=$(echo "$input" | jq -r '.tool_input.file_path // .tool_input.path // empty' 2>/dev/null || true)
|
|
44
|
+
# Write/NotebookEdit → content; Edit → new_string; Bash → command
|
|
45
|
+
content=$(echo "$input" | jq -r '
|
|
46
|
+
.tool_input.content //
|
|
47
|
+
.tool_input.new_string //
|
|
48
|
+
.tool_input.command //
|
|
49
|
+
empty' 2>/dev/null || true)
|
|
50
|
+
else
|
|
51
|
+
# Fallback: crude extraction without jq
|
|
52
|
+
tool_name=$(echo "$input" | grep -o '"tool_name":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
53
|
+
content=$(echo "$input" | grep -o '"command":"[^"]*"' | head -1 | cut -d'"' -f4 || true)
|
|
54
|
+
fi
|
|
55
|
+
|
|
56
|
+
[ -z "$content" ] && { echo '{}'; exit 0; }
|
|
57
|
+
|
|
58
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
59
|
+
# Helpers
|
|
60
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
_log_detection() {
|
|
62
|
+
local threat_type="$1"
|
|
63
|
+
local detail="$2"
|
|
64
|
+
mkdir -p "$LOG_DIR"
|
|
65
|
+
printf '[%s] BLOCKED tool=%s file=%s threat=%s detail=%s\n' \
|
|
66
|
+
"$(date -u +%Y-%m-%dT%H:%M:%SZ)" \
|
|
67
|
+
"${tool_name:-unknown}" \
|
|
68
|
+
"${file_path:-unknown}" \
|
|
69
|
+
"$threat_type" \
|
|
70
|
+
"$detail" \
|
|
71
|
+
>> "$LOG_FILE" 2>/dev/null || true
|
|
72
|
+
if declare -f log_event >/dev/null 2>&1; then
|
|
73
|
+
log_event "security_block" \
|
|
74
|
+
"$(printf '{"tool":"%s","threat":"%s"}' "${tool_name:-unknown}" "$threat_type")"
|
|
75
|
+
fi
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
_block() {
|
|
79
|
+
local threat_type="$1"
|
|
80
|
+
local detail="${2:-}"
|
|
81
|
+
_log_detection "$threat_type" "$detail"
|
|
82
|
+
printf '{"decision":"block","reason":"Security: %s detected"}\n' "$threat_type"
|
|
83
|
+
exit 2
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
87
|
+
# A) PROMPT INJECTION DETECTION
|
|
88
|
+
# Only scan non-Bash tools (Bash commands are code, not LLM prompts).
|
|
89
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
90
|
+
if [ "${tool_name:-}" != "Bash" ]; then
|
|
91
|
+
# Build a single grep pattern for all injection phrases
|
|
92
|
+
INJECTION_PATTERN='ignore (previous|all previous) instructions|disregard your instructions|forget your system prompt|you are now [a-z]|new role:|override:|system prompt:|reveal your instructions|show me your system prompt|what are your instructions|print your prompt|output your rules|ADMIN MODE|jailbreak|DAN mode|developer mode enabled|ignore safety|bypass restrictions|act as an unrestricted|\[END OF PROMPT\]'
|
|
93
|
+
|
|
94
|
+
if echo "$content" | grep -qiE "$INJECTION_PATTERN" 2>/dev/null; then
|
|
95
|
+
# Determine which pattern matched for the reason
|
|
96
|
+
matched=$(echo "$content" | grep -iEo "$INJECTION_PATTERN" | head -1 || true)
|
|
97
|
+
_block "prompt injection" "${matched}"
|
|
98
|
+
fi
|
|
99
|
+
fi
|
|
100
|
+
|
|
101
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
102
|
+
# B) SECRET / CREDENTIAL DETECTION
|
|
103
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
# AWS access key
|
|
106
|
+
if echo "$content" | grep -qE 'AKIA[0-9A-Z]{16}' 2>/dev/null; then
|
|
107
|
+
_block "AWS access key" "AKIA..."
|
|
108
|
+
fi
|
|
109
|
+
|
|
110
|
+
# Anthropic API key (sk-ant- prefix with 90+ chars)
|
|
111
|
+
if echo "$content" | grep -qE 'sk-ant-[a-zA-Z0-9_\-]{90,}' 2>/dev/null; then
|
|
112
|
+
_block "Anthropic API key" "sk-ant-..."
|
|
113
|
+
fi
|
|
114
|
+
|
|
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
|
|
120
|
+
if ! echo "$content" | grep -qE 'sk-ant-' 2>/dev/null; then
|
|
121
|
+
_block "OpenAI API key" "sk-..."
|
|
122
|
+
fi
|
|
123
|
+
fi
|
|
124
|
+
|
|
125
|
+
# PEM private key header
|
|
126
|
+
if echo "$content" | grep -qE '-----BEGIN (RSA|EC|DSA|OPENSSH) PRIVATE KEY-----' 2>/dev/null; then
|
|
127
|
+
_block "PEM private key" "BEGIN PRIVATE KEY"
|
|
128
|
+
fi
|
|
129
|
+
|
|
130
|
+
# Generic credential assignment — exclude common placeholders
|
|
131
|
+
if echo "$content" | grep -qiE '(password|secret|api_key|apikey|token|auth)\s*[:=]\s*["\x27][^"'\'']{8,}["\x27]' 2>/dev/null; then
|
|
132
|
+
# Allow placeholder values
|
|
133
|
+
if ! echo "$content" | grep -qiE '(password|secret|api_key|apikey|token|auth)\s*[:=]\s*["\x27][^"'\'']*?(YOUR_|REPLACE_ME|TODO|CHANGEME|example|placeholder|xxx+|<[^>]+>|\$\{[^}]+\}|%\([^)]+\))' 2>/dev/null; then
|
|
134
|
+
_block "credential assignment" "password/secret/api_key/token"
|
|
135
|
+
fi
|
|
136
|
+
fi
|
|
137
|
+
|
|
138
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
139
|
+
# C) DATA EXFILTRATION PATTERNS
|
|
140
|
+
# Targets Bash commands specifically; also catches these in scripts being written.
|
|
141
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
142
|
+
EXFIL_PATTERN='curl[^|]*POST[^|]*\$\(cat|wget[^|]*-O-[^|]*\|[^|]*curl|\$\(cat [^)]*\)\s*\|\s*(curl|wget|nc |netcat)|env\s*\|\s*(curl|wget)|printenv[^|]*\|\s*(curl|wget)'
|
|
143
|
+
|
|
144
|
+
if echo "$content" | grep -qE "$EXFIL_PATTERN" 2>/dev/null; then
|
|
145
|
+
matched=$(echo "$content" | grep -Eo "$EXFIL_PATTERN" | head -1 || true)
|
|
146
|
+
_block "data exfiltration" "${matched}"
|
|
147
|
+
fi
|
|
148
|
+
|
|
149
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
150
|
+
# All checks passed — allow
|
|
151
|
+
# ─────────────────────────────────────────────────────────────────────────────
|
|
152
|
+
echo '{}'
|
|
153
|
+
exit 0
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
#!/usr/bin/env bash
|
|
2
|
+
#
|
|
3
|
+
# Merlin Hook: Stop — Auto-Memory Sync
|
|
4
|
+
# Extracts session decisions (recent commits, CLAUDE.md changes) and
|
|
5
|
+
# persists them to Sights cloud via merlin_write_state.
|
|
6
|
+
# Always exits 0 — never blocks Claude Code shutdown.
|
|
7
|
+
# Runs asynchronously so session close is instant.
|
|
8
|
+
#
|
|
9
|
+
set -euo pipefail
|
|
10
|
+
trap 'echo "{}"; exit 0' ERR
|
|
11
|
+
|
|
12
|
+
HOOKS_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
13
|
+
|
|
14
|
+
# Source shared libraries defensively
|
|
15
|
+
[ -f "${HOOKS_DIR}/lib/analytics.sh" ] && . "${HOOKS_DIR}/lib/analytics.sh"
|
|
16
|
+
|
|
17
|
+
# ── Main sync logic (runs in background) ────────────────────────
|
|
18
|
+
_merlin_memory_sync() {
|
|
19
|
+
local merlin_dir="${HOME}/.claude/merlin"
|
|
20
|
+
local api_url="${MERLIN_API_URL:-https://api.merlin.build}"
|
|
21
|
+
local api_key="${MERLIN_API_KEY:-}"
|
|
22
|
+
local state_file="${merlin_dir}/session-memory.json"
|
|
23
|
+
local cwd="${PWD:-$(pwd)}"
|
|
24
|
+
|
|
25
|
+
# Need API key to write to cloud
|
|
26
|
+
[ -z "${api_key}" ] && return 0
|
|
27
|
+
|
|
28
|
+
# Must be in a git repo to extract decisions
|
|
29
|
+
git -C "${cwd}" rev-parse --git-dir >/dev/null 2>&1 || return 0
|
|
30
|
+
|
|
31
|
+
# ── Collect recent commits (last 10 from this session, up to 2h old) ──
|
|
32
|
+
local commit_log=""
|
|
33
|
+
commit_log=$(git -C "${cwd}" log \
|
|
34
|
+
--oneline \
|
|
35
|
+
--since="2 hours ago" \
|
|
36
|
+
--max-count=10 \
|
|
37
|
+
--no-merges \
|
|
38
|
+
2>/dev/null) || true
|
|
39
|
+
|
|
40
|
+
# ── Detect CLAUDE.md changes this session ──────────────────────
|
|
41
|
+
local claude_md_changed="false"
|
|
42
|
+
local claude_md_diff=""
|
|
43
|
+
if git -C "${cwd}" diff --name-only HEAD~1 HEAD 2>/dev/null | grep -q "CLAUDE.md"; then
|
|
44
|
+
claude_md_changed="true"
|
|
45
|
+
claude_md_diff=$(git -C "${cwd}" diff HEAD~1 HEAD -- CLAUDE.md 2>/dev/null | head -40) || true
|
|
46
|
+
fi
|
|
47
|
+
|
|
48
|
+
# Nothing interesting to sync — skip API call
|
|
49
|
+
if [ -z "${commit_log}" ] && [ "${claude_md_changed}" = "false" ]; then
|
|
50
|
+
return 0
|
|
51
|
+
fi
|
|
52
|
+
|
|
53
|
+
# ── Build state payload ─────────────────────────────────────────
|
|
54
|
+
local repo_name
|
|
55
|
+
repo_name=$(basename "${cwd}")
|
|
56
|
+
local timestamp
|
|
57
|
+
timestamp=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
|
|
58
|
+
|
|
59
|
+
# Escape values for JSON (basic — avoids jq dependency)
|
|
60
|
+
local safe_commits
|
|
61
|
+
safe_commits=$(echo "${commit_log}" | tr '"' "'" | tr '\n' '|')
|
|
62
|
+
local safe_diff
|
|
63
|
+
safe_diff=$(echo "${claude_md_diff}" | tr '"' "'" | tr '\n' '|' | head -c 500)
|
|
64
|
+
|
|
65
|
+
local payload
|
|
66
|
+
payload=$(printf '{"repo":"%s","timestamp":"%s","commits":"%s","claude_md_changed":%s,"claude_md_diff":"%s"}' \
|
|
67
|
+
"${repo_name}" "${timestamp}" "${safe_commits}" "${claude_md_changed}" "${safe_diff}")
|
|
68
|
+
|
|
69
|
+
# Write to local state file as fallback
|
|
70
|
+
mkdir -p "${merlin_dir}" 2>/dev/null || true
|
|
71
|
+
echo "${payload}" > "${state_file}" 2>/dev/null || true
|
|
72
|
+
|
|
73
|
+
# ── Push to Sights API if available ────────────────────────────
|
|
74
|
+
curl -s \
|
|
75
|
+
--max-time 8 \
|
|
76
|
+
--request POST \
|
|
77
|
+
--header "Content-Type: application/json" \
|
|
78
|
+
--header "x-api-key: ${api_key}" \
|
|
79
|
+
--data "${payload}" \
|
|
80
|
+
"${api_url}/api/memory/session-end" \
|
|
81
|
+
>/dev/null 2>&1 || true
|
|
82
|
+
|
|
83
|
+
# Log the sync event
|
|
84
|
+
if type log_event >/dev/null 2>&1; then
|
|
85
|
+
log_event "memory_sync" "$(printf '{"repo":"%s","commits_synced":%d,"claude_md_changed":%s}' \
|
|
86
|
+
"${repo_name}" \
|
|
87
|
+
"$(echo "${commit_log}" | grep -c . || echo 0)" \
|
|
88
|
+
"${claude_md_changed}")"
|
|
89
|
+
fi
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
# Fire and forget — never delay session shutdown
|
|
93
|
+
_merlin_memory_sync &
|
|
94
|
+
|
|
95
|
+
# Claude Code command hooks must output valid JSON to stdout
|
|
96
|
+
echo '{}'
|
|
97
|
+
exit 0
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
2
|
#
|
|
3
3
|
# Merlin Hook: SessionEnd / Stop
|
|
4
|
-
# Saves checkpoint
|
|
4
|
+
# Saves checkpoint, finalizes session analytics, and writes a structured
|
|
5
|
+
# session summary to ~/.claude/merlin/sessions/ for future restore/search.
|
|
5
6
|
# Always exits 0 — never blocks Claude Code shutdown.
|
|
6
7
|
#
|
|
7
8
|
set -euo pipefail
|
|
@@ -19,11 +20,283 @@ log_event "session_end" '{}'
|
|
|
19
20
|
# Finalize session analytics (adds endTime, duration, summary)
|
|
20
21
|
finalize_session
|
|
21
22
|
|
|
23
|
+
# ── Session Summary ──────────────────────────────────────────────────────────
|
|
24
|
+
# Write a structured session summary to ~/.claude/merlin/sessions/ so that
|
|
25
|
+
# merlin_session_restore and merlin_session_search can provide continuity.
|
|
26
|
+
|
|
27
|
+
SESSIONS_DIR="${HOME}/.claude/merlin/sessions"
|
|
28
|
+
mkdir -p "${SESSIONS_DIR}" 2>/dev/null || true
|
|
29
|
+
|
|
30
|
+
# Only generate summary when jq is available — keeps the fallback path clean
|
|
31
|
+
if command -v jq >/dev/null 2>&1; then
|
|
32
|
+
|
|
33
|
+
TIMESTAMP="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
34
|
+
EPOCH_NOW="$(date +%s)"
|
|
35
|
+
|
|
36
|
+
# ── Duration ────────────────────────────────────────────────────────────────
|
|
37
|
+
SESSION_DURATION=0
|
|
38
|
+
if [ -f "${MERLIN_SESSION_FILE}" ]; then
|
|
39
|
+
dur="$(jq -r '.durationSeconds // 0' "${MERLIN_SESSION_FILE}" 2>/dev/null || echo 0)"
|
|
40
|
+
SESSION_DURATION="${dur}"
|
|
41
|
+
fi
|
|
42
|
+
|
|
43
|
+
# ── Files modified via git diff --stat ──────────────────────────────────────
|
|
44
|
+
FILES_MODIFIED_JSON="[]"
|
|
45
|
+
if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
|
|
46
|
+
# Uncommitted changes (staged + unstaged) compared to HEAD
|
|
47
|
+
raw_files="$(git diff --stat HEAD 2>/dev/null | awk 'NR>1 || /\|/' | grep '|' | awk '{print $1}' | head -20 || true)"
|
|
48
|
+
# Also catch files changed in the last session via log since session start
|
|
49
|
+
sid="$(jq -r '.id // ""' "${MERLIN_SESSION_FILE}" 2>/dev/null || echo "")"
|
|
50
|
+
start_epoch="$(echo "${sid}" | cut -d'-' -f1 2>/dev/null || echo "${EPOCH_NOW}")"
|
|
51
|
+
if [ -n "${start_epoch}" ] && [ "${start_epoch}" -gt 0 ] 2>/dev/null; then
|
|
52
|
+
recent_files="$(git log --since="@${start_epoch}" --name-only --pretty=format: 2>/dev/null | sort -u | head -20 || true)"
|
|
53
|
+
all_files="$(printf '%s\n%s' "${raw_files}" "${recent_files}" | sort -u | grep -v '^$' || true)"
|
|
54
|
+
else
|
|
55
|
+
all_files="${raw_files}"
|
|
56
|
+
fi
|
|
57
|
+
if [ -n "${all_files}" ]; then
|
|
58
|
+
FILES_MODIFIED_JSON="$(echo "${all_files}" | jq -R . | jq -sc . 2>/dev/null || echo "[]")"
|
|
59
|
+
fi
|
|
60
|
+
fi
|
|
61
|
+
|
|
62
|
+
# ── Recent commits ──────────────────────────────────────────────────────────
|
|
63
|
+
COMMITS_JSON="[]"
|
|
64
|
+
if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
|
|
65
|
+
sid="$(jq -r '.id // ""' "${MERLIN_SESSION_FILE}" 2>/dev/null || echo "")"
|
|
66
|
+
start_epoch="$(echo "${sid}" | cut -d'-' -f1 2>/dev/null || echo "")"
|
|
67
|
+
if [ -n "${start_epoch}" ] && [ "${start_epoch}" -gt 0 ] 2>/dev/null; then
|
|
68
|
+
commits="$(git log --since="@${start_epoch}" --pretty=format:"%s" 2>/dev/null | head -10 || true)"
|
|
69
|
+
else
|
|
70
|
+
commits="$(git log -5 --pretty=format:"%s" 2>/dev/null || true)"
|
|
71
|
+
fi
|
|
72
|
+
if [ -n "${commits}" ]; then
|
|
73
|
+
COMMITS_JSON="$(echo "${commits}" | jq -R . | jq -sc . 2>/dev/null || echo "[]")"
|
|
74
|
+
fi
|
|
75
|
+
fi
|
|
76
|
+
|
|
77
|
+
# ── Agents used (from analytics events) ─────────────────────────────────────
|
|
78
|
+
AGENTS_JSON="[]"
|
|
79
|
+
if [ -f "${MERLIN_SESSION_FILE}" ]; then
|
|
80
|
+
AGENTS_JSON="$(jq '[.events[] | select(.type == "agent_route") | .data.agent] | unique | map(select(. != null and . != ""))' \
|
|
81
|
+
"${MERLIN_SESSION_FILE}" 2>/dev/null || echo "[]")"
|
|
82
|
+
fi
|
|
83
|
+
|
|
84
|
+
# ── Text summary (built from commit messages + modified file count) ──────────
|
|
85
|
+
commit_count="$(echo "${COMMITS_JSON}" | jq 'length' 2>/dev/null || echo 0)"
|
|
86
|
+
file_count="$(echo "${FILES_MODIFIED_JSON}" | jq 'length' 2>/dev/null || echo 0)"
|
|
87
|
+
|
|
88
|
+
if [ "${commit_count}" -gt 0 ] 2>/dev/null; then
|
|
89
|
+
first_commit="$(echo "${COMMITS_JSON}" | jq -r '.[0] // ""' 2>/dev/null || echo "")"
|
|
90
|
+
SUMMARY_TEXT="Session with ${commit_count} commit(s) and ${file_count} file(s) modified. Latest: ${first_commit}"
|
|
91
|
+
elif [ "${file_count}" -gt 0 ] 2>/dev/null; then
|
|
92
|
+
SUMMARY_TEXT="Session with ${file_count} file(s) modified, no commits recorded."
|
|
93
|
+
else
|
|
94
|
+
SUMMARY_TEXT="Session completed. No file changes detected."
|
|
95
|
+
fi
|
|
96
|
+
|
|
97
|
+
# Truncate to keep summaries small
|
|
98
|
+
SUMMARY_TEXT="$(echo "${SUMMARY_TEXT}" | cut -c1-200)"
|
|
99
|
+
|
|
100
|
+
# ── Working directory ────────────────────────────────────────────────────────
|
|
101
|
+
WORK_DIR="${CLAUDE_WORKTREE_PATH:-$(pwd 2>/dev/null || echo "")}"
|
|
102
|
+
|
|
103
|
+
# ── Write session summary ────────────────────────────────────────────────────
|
|
104
|
+
SUMMARY_FILE="${SESSIONS_DIR}/session-${EPOCH_NOW}.json"
|
|
105
|
+
jq -n \
|
|
106
|
+
--arg ts "${TIMESTAMP}" \
|
|
107
|
+
--argjson dur "${SESSION_DURATION}" \
|
|
108
|
+
--argjson files "${FILES_MODIFIED_JSON}" \
|
|
109
|
+
--argjson commits "${COMMITS_JSON}" \
|
|
110
|
+
--argjson agents "${AGENTS_JSON}" \
|
|
111
|
+
--arg summary "${SUMMARY_TEXT}" \
|
|
112
|
+
--arg workdir "${WORK_DIR}" \
|
|
113
|
+
'{
|
|
114
|
+
timestamp: $ts,
|
|
115
|
+
duration_seconds: $dur,
|
|
116
|
+
files_modified: $files,
|
|
117
|
+
commits: $commits,
|
|
118
|
+
agents_used: $agents,
|
|
119
|
+
summary: $summary,
|
|
120
|
+
working_directory: $workdir
|
|
121
|
+
}' > "${SUMMARY_FILE}" 2>/dev/null || true
|
|
122
|
+
|
|
123
|
+
# Keep only the last 100 session summaries to cap disk usage
|
|
124
|
+
summary_count="$(ls -1 "${SESSIONS_DIR}"/session-*.json 2>/dev/null | wc -l | tr -d ' ' || echo 0)"
|
|
125
|
+
if [ "${summary_count}" -gt 100 ] 2>/dev/null; then
|
|
126
|
+
ls -1t "${SESSIONS_DIR}"/session-*.json 2>/dev/null | tail -n +"$((100 + 1))" | xargs rm -f 2>/dev/null || true
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
fi
|
|
130
|
+
|
|
22
131
|
# If merlin CLI is available, save checkpoint in background
|
|
23
132
|
if command -v merlin >/dev/null 2>&1; then
|
|
24
133
|
merlin save-checkpoint "Session ended" >/dev/null 2>&1 &
|
|
25
134
|
fi
|
|
26
135
|
|
|
136
|
+
# ── Cost summary ──────────────────────────────────────────────────────────────
|
|
137
|
+
# Read the session cost file written by the MCP server (if present).
|
|
138
|
+
# Only prints when >0 routing calls were made.
|
|
139
|
+
COST_FILE="${HOME}/.claude/merlin/session-cost.json"
|
|
140
|
+
if [ -f "${COST_FILE}" ]; then
|
|
141
|
+
COST_SUMMARY=""
|
|
142
|
+
if command -v node >/dev/null 2>&1; then
|
|
143
|
+
COST_SUMMARY="$(node -e "
|
|
144
|
+
try {
|
|
145
|
+
const d = JSON.parse(require('fs').readFileSync('${COST_FILE}', 'utf8'));
|
|
146
|
+
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 + ')');
|
|
153
|
+
}
|
|
154
|
+
} catch(e) {}
|
|
155
|
+
" 2>/dev/null || true)"
|
|
156
|
+
elif command -v python3 >/dev/null 2>&1; then
|
|
157
|
+
COST_SUMMARY="$(python3 -c "
|
|
158
|
+
import json, sys
|
|
159
|
+
try:
|
|
160
|
+
with open('${COST_FILE}') as f:
|
|
161
|
+
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})')
|
|
170
|
+
except Exception:
|
|
171
|
+
pass
|
|
172
|
+
" 2>/dev/null || true)"
|
|
173
|
+
fi
|
|
174
|
+
if [ -n "${COST_SUMMARY}" ]; then
|
|
175
|
+
echo "[merlin] ${COST_SUMMARY}" >&2
|
|
176
|
+
fi
|
|
177
|
+
fi
|
|
178
|
+
|
|
179
|
+
# ── Outcome Tracking (RL-inspired prompt learning) ───────────────────────────
|
|
180
|
+
# Classify this session's outcome and append to the learning log.
|
|
181
|
+
# Classification is intentional kept simple — git diff + agents used only.
|
|
182
|
+
# Never blocks shutdown; always best-effort.
|
|
183
|
+
if command -v jq >/dev/null 2>&1 && command -v git >/dev/null 2>&1; then
|
|
184
|
+
LEARNING_DIR="${HOME}/.claude/merlin/learning"
|
|
185
|
+
mkdir -p "${LEARNING_DIR}" 2>/dev/null || true
|
|
186
|
+
|
|
187
|
+
OUTCOME_FILE="${LEARNING_DIR}/outcomes.jsonl"
|
|
188
|
+
OUTCOME_TS="$(date -u +"%Y-%m-%dT%H:%M:%SZ")"
|
|
189
|
+
|
|
190
|
+
# Count files changed vs HEAD (uncommitted + committed this session)
|
|
191
|
+
OUTCOME_FILES_CHANGED=0
|
|
192
|
+
if git rev-parse --git-dir >/dev/null 2>&1; then
|
|
193
|
+
# Uncommitted
|
|
194
|
+
uncommitted="$(git diff --stat HEAD 2>/dev/null | tail -1 | grep -oE '[0-9]+ file' | grep -oE '[0-9]+' || echo 0)"
|
|
195
|
+
# Committed this session
|
|
196
|
+
sid_o="$(jq -r '.id // ""' "${MERLIN_SESSION_FILE}" 2>/dev/null || echo "")"
|
|
197
|
+
start_epoch_o="$(echo "${sid_o}" | cut -d'-' -f1 2>/dev/null || echo "")"
|
|
198
|
+
committed=0
|
|
199
|
+
if [ -n "${start_epoch_o}" ] && [ "${start_epoch_o}" -gt 0 ] 2>/dev/null; then
|
|
200
|
+
committed="$(git log --since="@${start_epoch_o}" --name-only --pretty=format: 2>/dev/null | grep -vc '^$' || echo 0)"
|
|
201
|
+
fi
|
|
202
|
+
OUTCOME_FILES_CHANGED=$(( ${uncommitted:-0} + ${committed:-0} ))
|
|
203
|
+
fi
|
|
204
|
+
|
|
205
|
+
# Determine agents used in this session
|
|
206
|
+
OUTCOME_AGENT="unknown"
|
|
207
|
+
if [ -f "${MERLIN_SESSION_FILE}" ]; then
|
|
208
|
+
first_agent="$(jq -r '[.events[] | select(.type == "agent_route") | .data.agent] | first // "unknown"' \
|
|
209
|
+
"${MERLIN_SESSION_FILE}" 2>/dev/null || echo "unknown")"
|
|
210
|
+
OUTCOME_AGENT="${first_agent:-unknown}"
|
|
211
|
+
fi
|
|
212
|
+
|
|
213
|
+
# Classify outcome:
|
|
214
|
+
# SUCCESS — files changed AND session file shows no error flag
|
|
215
|
+
# PARTIAL — files changed but session had errors or warnings
|
|
216
|
+
# FAILURE — no files changed
|
|
217
|
+
OUTCOME_CLASS="failure"
|
|
218
|
+
SESSION_HAD_ERRORS=0
|
|
219
|
+
if [ -f "${MERLIN_SESSION_FILE}" ]; then
|
|
220
|
+
err_count="$(jq '[.events[] | select(.type == "error")] | length' "${MERLIN_SESSION_FILE}" 2>/dev/null || echo 0)"
|
|
221
|
+
SESSION_HAD_ERRORS="${err_count:-0}"
|
|
222
|
+
fi
|
|
223
|
+
|
|
224
|
+
if [ "${OUTCOME_FILES_CHANGED}" -gt 0 ] 2>/dev/null; then
|
|
225
|
+
if [ "${SESSION_HAD_ERRORS:-0}" -gt 0 ] 2>/dev/null; then
|
|
226
|
+
OUTCOME_CLASS="partial"
|
|
227
|
+
else
|
|
228
|
+
OUTCOME_CLASS="success"
|
|
229
|
+
fi
|
|
230
|
+
fi
|
|
231
|
+
|
|
232
|
+
# Duration
|
|
233
|
+
OUTCOME_DURATION=0
|
|
234
|
+
if [ -f "${MERLIN_SESSION_FILE}" ]; then
|
|
235
|
+
OUTCOME_DURATION="$(jq -r '.durationSeconds // 0' "${MERLIN_SESSION_FILE}" 2>/dev/null || echo 0)"
|
|
236
|
+
fi
|
|
237
|
+
|
|
238
|
+
# Append outcome line (append-only)
|
|
239
|
+
outcome_line="$(jq -cn \
|
|
240
|
+
--arg ts "${OUTCOME_TS}" \
|
|
241
|
+
--arg agent "${OUTCOME_AGENT}" \
|
|
242
|
+
--arg outcome "${OUTCOME_CLASS}" \
|
|
243
|
+
--argjson files "${OUTCOME_FILES_CHANGED}" \
|
|
244
|
+
--argjson duration "${OUTCOME_DURATION}" \
|
|
245
|
+
'{"timestamp":$ts,"agent":$agent,"outcome":$outcome,"files_changed":$files,"duration":$duration}' \
|
|
246
|
+
2>/dev/null || true)"
|
|
247
|
+
|
|
248
|
+
if [ -n "${outcome_line}" ]; then
|
|
249
|
+
echo "${outcome_line}" >> "${OUTCOME_FILE}" 2>/dev/null || true
|
|
250
|
+
fi
|
|
251
|
+
|
|
252
|
+
# Keep outcomes.jsonl bounded — drop lines older than 90 days
|
|
253
|
+
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
|
|
265
|
+
fi
|
|
266
|
+
fi
|
|
267
|
+
fi
|
|
268
|
+
|
|
269
|
+
# ── Behavior promotion check ─────────────────────────────────────────────────
|
|
270
|
+
# The MCP server writes promotion-ready behaviors to a temp cache file during
|
|
271
|
+
# the session (via merlin_apply_behavior / merlin_get_behaviors).
|
|
272
|
+
# File: ~/.claude/merlin/promotion-candidates.json
|
|
273
|
+
# Format: { "candidates": [{ "id": "...", "pattern": "...", "confidence": 0.9 }] }
|
|
274
|
+
#
|
|
275
|
+
# We never auto-promote — just surface a tip so the user can decide.
|
|
276
|
+
PROMOTION_CACHE="${HOME}/.claude/merlin/promotion-candidates.json"
|
|
277
|
+
|
|
278
|
+
if [ -f "${PROMOTION_CACHE}" ] && command -v jq >/dev/null 2>&1; then
|
|
279
|
+
CANDIDATE_COUNT=$(jq '.candidates | length' "${PROMOTION_CACHE}" 2>/dev/null || echo 0)
|
|
280
|
+
|
|
281
|
+
if [ "${CANDIDATE_COUNT:-0}" -gt 0 ] 2>/dev/null; then
|
|
282
|
+
TOP_PATTERN=$(jq -r '
|
|
283
|
+
.candidates
|
|
284
|
+
| sort_by(-.confidence)
|
|
285
|
+
| .[0].pattern
|
|
286
|
+
| if length > 60 then .[:60] + "..." else . end
|
|
287
|
+
' "${PROMOTION_CACHE}" 2>/dev/null || echo "a learned behavior")
|
|
288
|
+
|
|
289
|
+
OTHERS=$(( CANDIDATE_COUNT - 1 ))
|
|
290
|
+
if [ "${OTHERS}" -gt 0 ]; then
|
|
291
|
+
echo "[merlin] Behavior '${TOP_PATTERN}' (and ${OTHERS} more) is ready for promotion. Run /merlin:promote to review." >&2
|
|
292
|
+
else
|
|
293
|
+
echo "[merlin] Behavior '${TOP_PATTERN}' is ready for promotion. Run /merlin:promote to review." >&2
|
|
294
|
+
fi
|
|
295
|
+
|
|
296
|
+
log_event "promotion_candidates_found" "$(printf '{"count":%d}' "${CANDIDATE_COUNT}")"
|
|
297
|
+
fi
|
|
298
|
+
fi
|
|
299
|
+
|
|
27
300
|
# Claude Code command hooks must output valid JSON to stdout
|
|
28
301
|
echo '{}'
|
|
29
302
|
exit 0
|