create-merlin-brain 3.11.0 → 3.13.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 (147) hide show
  1. package/bin/install.cjs +156 -32
  2. package/bin/runtime-adapters.cjs +396 -0
  3. package/dist/server/api/types.d.ts +7 -0
  4. package/dist/server/api/types.d.ts.map +1 -1
  5. package/dist/server/cost/tracker.d.ts +38 -2
  6. package/dist/server/cost/tracker.d.ts.map +1 -1
  7. package/dist/server/cost/tracker.js +87 -15
  8. package/dist/server/cost/tracker.js.map +1 -1
  9. package/dist/server/server.d.ts.map +1 -1
  10. package/dist/server/server.js +74 -30
  11. package/dist/server/server.js.map +1 -1
  12. package/dist/server/tools/__tests__/augmentation.test.d.ts +8 -0
  13. package/dist/server/tools/__tests__/augmentation.test.d.ts.map +1 -0
  14. package/dist/server/tools/__tests__/augmentation.test.js +76 -0
  15. package/dist/server/tools/__tests__/augmentation.test.js.map +1 -0
  16. package/dist/server/tools/__tests__/route-helpers.test.d.ts +5 -0
  17. package/dist/server/tools/__tests__/route-helpers.test.d.ts.map +1 -0
  18. package/dist/server/tools/__tests__/route-helpers.test.js +49 -0
  19. package/dist/server/tools/__tests__/route-helpers.test.js.map +1 -0
  20. package/dist/server/tools/adaptive.js +1 -1
  21. package/dist/server/tools/adaptive.js.map +1 -1
  22. package/dist/server/tools/agent-spawn.d.ts +25 -0
  23. package/dist/server/tools/agent-spawn.d.ts.map +1 -0
  24. package/dist/server/tools/agent-spawn.js +95 -0
  25. package/dist/server/tools/agent-spawn.js.map +1 -0
  26. package/dist/server/tools/agents-index.js +3 -3
  27. package/dist/server/tools/agents-index.js.map +1 -1
  28. package/dist/server/tools/agents.js +5 -5
  29. package/dist/server/tools/agents.js.map +1 -1
  30. package/dist/server/tools/augmentation.d.ts +45 -0
  31. package/dist/server/tools/augmentation.d.ts.map +1 -0
  32. package/dist/server/tools/augmentation.js +167 -0
  33. package/dist/server/tools/augmentation.js.map +1 -0
  34. package/dist/server/tools/behaviors.js +4 -4
  35. package/dist/server/tools/behaviors.js.map +1 -1
  36. package/dist/server/tools/context.js +7 -7
  37. package/dist/server/tools/context.js.map +1 -1
  38. package/dist/server/tools/cost.d.ts +3 -1
  39. package/dist/server/tools/cost.d.ts.map +1 -1
  40. package/dist/server/tools/cost.js +66 -13
  41. package/dist/server/tools/cost.js.map +1 -1
  42. package/dist/server/tools/discoveries.js +6 -6
  43. package/dist/server/tools/discoveries.js.map +1 -1
  44. package/dist/server/tools/index.d.ts +4 -0
  45. package/dist/server/tools/index.d.ts.map +1 -1
  46. package/dist/server/tools/index.js +4 -0
  47. package/dist/server/tools/index.js.map +1 -1
  48. package/dist/server/tools/learning.d.ts +12 -0
  49. package/dist/server/tools/learning.d.ts.map +1 -0
  50. package/dist/server/tools/learning.js +269 -0
  51. package/dist/server/tools/learning.js.map +1 -0
  52. package/dist/server/tools/project.js +7 -7
  53. package/dist/server/tools/project.js.map +1 -1
  54. package/dist/server/tools/promote.d.ts +11 -0
  55. package/dist/server/tools/promote.d.ts.map +1 -0
  56. package/dist/server/tools/promote.js +315 -0
  57. package/dist/server/tools/promote.js.map +1 -0
  58. package/dist/server/tools/route-helpers.d.ts +45 -0
  59. package/dist/server/tools/route-helpers.d.ts.map +1 -0
  60. package/dist/server/tools/route-helpers.js +93 -0
  61. package/dist/server/tools/route-helpers.js.map +1 -0
  62. package/dist/server/tools/route.d.ts +4 -3
  63. package/dist/server/tools/route.d.ts.map +1 -1
  64. package/dist/server/tools/route.js +80 -284
  65. package/dist/server/tools/route.js.map +1 -1
  66. package/dist/server/tools/session-restore.d.ts +18 -0
  67. package/dist/server/tools/session-restore.d.ts.map +1 -0
  68. package/dist/server/tools/session-restore.js +154 -0
  69. package/dist/server/tools/session-restore.js.map +1 -0
  70. package/dist/server/tools/session-search.d.ts +16 -0
  71. package/dist/server/tools/session-search.d.ts.map +1 -0
  72. package/dist/server/tools/session-search.js +240 -0
  73. package/dist/server/tools/session-search.js.map +1 -0
  74. package/dist/server/tools/sights-index.js +2 -2
  75. package/dist/server/tools/sights-index.js.map +1 -1
  76. package/dist/server/tools/smart-route.d.ts.map +1 -1
  77. package/dist/server/tools/smart-route.js +4 -5
  78. package/dist/server/tools/smart-route.js.map +1 -1
  79. package/dist/server/tools/verification.js +1 -1
  80. package/dist/server/tools/verification.js.map +1 -1
  81. package/files/agents/code-organization-supervisor.md +1 -0
  82. package/files/agents/context-guardian.md +1 -0
  83. package/files/agents/docs-keeper.md +1 -0
  84. package/files/agents/dry-refactor.md +1 -0
  85. package/files/agents/elite-code-refactorer.md +1 -0
  86. package/files/agents/hardening-guard.md +1 -0
  87. package/files/agents/implementation-dev.md +1 -0
  88. package/files/agents/merlin-access-control-reviewer.md +248 -0
  89. package/files/agents/merlin-codebase-mapper.md +1 -1
  90. package/files/agents/merlin-dependency-auditor.md +216 -0
  91. package/files/agents/merlin-executor.md +1 -0
  92. package/files/agents/merlin-input-validator.md +247 -0
  93. package/files/agents/merlin-reviewer.md +1 -0
  94. package/files/agents/merlin-sast-reviewer.md +182 -0
  95. package/files/agents/merlin-secret-scanner.md +203 -0
  96. package/files/agents/tests-qa.md +1 -0
  97. package/files/commands/merlin/execute-phase.md +94 -197
  98. package/files/commands/merlin/execute-plan.md +116 -180
  99. package/files/commands/merlin/health.md +385 -0
  100. package/files/commands/merlin/loop-recipes.md +93 -36
  101. package/files/commands/merlin/optimize-prompts.md +158 -0
  102. package/files/commands/merlin/profiles.md +215 -0
  103. package/files/commands/merlin/promote.md +176 -0
  104. package/files/commands/merlin/quick.md +229 -0
  105. package/files/commands/merlin/resume-work.md +27 -1
  106. package/files/commands/merlin/route.md +43 -1
  107. package/files/commands/merlin/sandbox.md +359 -0
  108. package/files/commands/merlin/usage.md +55 -0
  109. package/files/docker/Dockerfile.merlin +20 -0
  110. package/files/docker/docker-compose.merlin.yml +23 -0
  111. package/files/hook-templates/auto-commit.sh +64 -0
  112. package/files/hook-templates/auto-format.sh +95 -0
  113. package/files/hook-templates/auto-test.sh +117 -0
  114. package/files/hook-templates/branch-protection.sh +72 -0
  115. package/files/hook-templates/changelog-reminder.sh +76 -0
  116. package/files/hook-templates/complexity-check.sh +112 -0
  117. package/files/hook-templates/import-audit.sh +83 -0
  118. package/files/hook-templates/license-header.sh +84 -0
  119. package/files/hook-templates/pr-description.sh +100 -0
  120. package/files/hook-templates/todo-tracker.sh +80 -0
  121. package/files/hooks/check-file-size.sh +17 -4
  122. package/files/hooks/config-change.sh +44 -16
  123. package/files/hooks/instructions-loaded.sh +22 -5
  124. package/files/hooks/notify-desktop.sh +157 -0
  125. package/files/hooks/notify-webhook.sh +141 -0
  126. package/files/hooks/pre-edit-sights-check.sh +76 -9
  127. package/files/hooks/security-scanner.sh +153 -0
  128. package/files/hooks/session-end-memory-sync.sh +97 -0
  129. package/files/hooks/session-end.sh +274 -1
  130. package/files/hooks/session-start.sh +19 -6
  131. package/files/hooks/smart-approve.sh +270 -0
  132. package/files/hooks/teammate-idle-verify.sh +87 -12
  133. package/files/hooks/worktree-create.sh +20 -3
  134. package/files/hooks/worktree-remove.sh +21 -3
  135. package/files/merlin/references/plan-format.md +37 -9
  136. package/files/merlin/sandbox.json +9 -0
  137. package/files/merlin/security.json +11 -0
  138. package/files/merlin/templates/ci/docs-update.yml +81 -0
  139. package/files/merlin/templates/ci/pr-review.yml +50 -0
  140. package/files/merlin/templates/ci/security-audit.yml +74 -0
  141. package/files/merlin/templates/config.json +9 -1
  142. package/files/rules/api-rules.md +30 -0
  143. package/files/rules/frontend-rules.md +25 -0
  144. package/files/rules/hooks-rules.md +36 -0
  145. package/files/rules/mcp-rules.md +30 -0
  146. package/files/rules/worker-rules.md +29 -0
  147. package/package.json +5 -2
@@ -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 and finalizes session analytics.
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
@@ -176,16 +176,29 @@ _merlin_auto_update() {
176
176
  }
177
177
  _merlin_auto_update &
178
178
 
179
- # ── 3. Context injection (the only stdout output) ──────────────
179
+ # ── 3. Voice mode check (background) ──────────────────────────
180
+ # If voice_mode_concise is enabled in settings, export env var for the session.
181
+ _merlin_check_voice_mode() {
182
+ local settings_file="${HOME}/.claude/merlin/settings.local.json"
183
+ [ -f "${settings_file}" ] || return 0
184
+ command -v jq >/dev/null 2>&1 || return 0
185
+ local voice
186
+ voice=$(jq -r '.voice_mode_concise // false' "${settings_file}" 2>/dev/null) || return 0
187
+ [ "${voice}" = "true" ] && export MERLIN_VOICE_MODE=1
188
+ }
189
+ _merlin_check_voice_mode
190
+
191
+ # ── 4. Context injection (the only stdout output) ──────────────
180
192
  # Output additionalContext JSON for Claude to see at session start.
181
193
  # Full boot instructions are in CLAUDE.md — this is a lightweight nudge.
182
- cat <<'CONTEXT_JSON'
183
- {
194
+ _voice_note=""
195
+ [ "${MERLIN_VOICE_MODE:-}" = "1" ] && _voice_note=" Voice mode active: keep all responses short and direct."
196
+
197
+ printf '{
184
198
  "hookSpecificOutput": {
185
199
  "hookEventName": "SessionStart",
186
- "additionalContext": "STOP. Your FIRST action must be: call merlin_get_selected_repo, then call merlin_get_project_status, then show the user a status summary. Do not respond to the user until you complete these calls."
200
+ "additionalContext": "STOP. Your FIRST action must be: call merlin_get_selected_repo, then call merlin_get_project_status, then show the user a status summary. Do not respond to the user until you complete these calls.%s"
187
201
  }
188
- }
189
- CONTEXT_JSON
202
+ }\n' "${_voice_note}"
190
203
 
191
204
  exit 0