claudia-mentor 0.5.3 → 0.5.6

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/hooks/hooks.json CHANGED
@@ -78,7 +78,7 @@
78
78
  "hooks": [
79
79
  {
80
80
  "type": "command",
81
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-teach.py",
81
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-milestones.py",
82
82
  "timeout": 10
83
83
  }
84
84
  ]
@@ -105,7 +105,7 @@
105
105
  "hooks": [
106
106
  {
107
107
  "type": "command",
108
- "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-milestones.py",
108
+ "command": "python3 ${CLAUDE_PLUGIN_ROOT}/hooks/scripts/claudia-teach.py",
109
109
  "timeout": 10
110
110
  }
111
111
  ]
@@ -159,7 +159,7 @@ def main():
159
159
  if warnings:
160
160
  save_state(session_id, shown)
161
161
  message = "Claudia noticed some accessibility concerns:\n" + "\n".join(warnings)
162
- output = json.dumps({"systemMessage": message})
162
+ output = json.dumps({"systemMessage": f"\033[38;5;209m{message}\033[0m"})
163
163
  print(output)
164
164
 
165
165
  sys.exit(0)
@@ -109,7 +109,7 @@ def main():
109
109
  if warnings:
110
110
  save_state(session_id, shown)
111
111
  message = "Claudia noticed some dependency concerns:\n" + "\n".join(warnings)
112
- output = json.dumps({"systemMessage": message})
112
+ output = json.dumps({"systemMessage": f"\033[38;5;209m{message}\033[0m"})
113
113
  print(output)
114
114
 
115
115
  sys.exit(0)
@@ -166,7 +166,7 @@ def main():
166
166
  if warnings:
167
167
  save_state(session_id, shown)
168
168
  message = "Claudia noticed some Dockerfile patterns worth reviewing:\n" + "\n".join(warnings)
169
- output = json.dumps({"systemMessage": message})
169
+ output = json.dumps({"systemMessage": f"\033[38;5;209m{message}\033[0m"})
170
170
  print(output)
171
171
 
172
172
  sys.exit(0)
@@ -134,7 +134,7 @@ def main():
134
134
  if warnings:
135
135
  save_state(session_id, shown)
136
136
  message = "Claudia noticed a git hygiene concern:\n" + "\n".join(warnings)
137
- output = json.dumps({"systemMessage": message})
137
+ output = json.dumps({"systemMessage": f"\033[38;5;209m{message}\033[0m"})
138
138
  print(output)
139
139
 
140
140
  sys.exit(0)
@@ -156,7 +156,7 @@ def main():
156
156
  if warnings:
157
157
  save_state(session_id, shown)
158
158
  message = "Claudia noticed some license concerns:\n" + "\n".join(warnings)
159
- output = json.dumps({"systemMessage": message})
159
+ output = json.dumps({"systemMessage": f"\033[38;5;209m{message}\033[0m"})
160
160
  print(output)
161
161
 
162
162
  sys.exit(0)
@@ -159,7 +159,7 @@ def main():
159
159
  save_state(session_id, shown)
160
160
  # Advisory output via JSON on stdout (systemMessage)
161
161
  message = "Claudia noticed some patterns worth reviewing:\n" + "\n".join(warnings)
162
- output = json.dumps({"systemMessage": message})
162
+ output = json.dumps({"systemMessage": f"\033[38;5;209m{message}\033[0m"})
163
163
  print(output)
164
164
 
165
165
  sys.exit(0)
@@ -99,15 +99,17 @@ with open('$STATE_FILE', 'w') as f:
99
99
  echo "[\"${WARNING_KEY}\"]" > "$STATE_FILE"
100
100
  fi
101
101
 
102
- # Block with explanation
103
- echo "Claudia: ${description} in ${FILE_PATH}." >&2
102
+ # Block with explanation (vermillion colored)
103
+ C="\033[38;5;209m"
104
+ R="\033[0m"
105
+ echo -e "${C}Claudia: ${description} in ${FILE_PATH}.${R}" >&2
104
106
  echo "" >&2
105
- echo "Secrets should never be hardcoded in source files. Use:" >&2
107
+ echo -e "${C}Secrets should never be hardcoded in source files. Use:" >&2
106
108
  echo " - Environment variables (.env file, gitignored)" >&2
107
109
  echo " - A secret manager (AWS SSM, Vault, Doppler)" >&2
108
- echo " - CI/CD secrets for pipelines" >&2
110
+ echo -e " - CI/CD secrets for pipelines${R}" >&2
109
111
  echo "" >&2
110
- echo "If this is intentionally a test fixture or example, rename the file to include 'test', 'example', or 'fixture'." >&2
112
+ echo -e "${C}If this is intentionally a test fixture or example, rename the file to include 'test', 'example', or 'fixture'.${R}" >&2
111
113
  exit 2
112
114
  fi
113
115
  done
@@ -101,9 +101,9 @@ def main():
101
101
 
102
102
  if tip:
103
103
  save_state(session_id, state)
104
+ # Only systemMessage here -- additionalContext gets wiped by compaction
104
105
  output = json.dumps({
105
- "additionalContext": tip,
106
- "systemMessage": tip,
106
+ "systemMessage": f"\033[38;5;209m{tip}\033[0m",
107
107
  })
108
108
  print(output)
109
109
 
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import re
12
12
  import sys
13
+ import time
13
14
 
14
15
  # Milestone definitions: key, detection pattern, celebration message
15
16
  MILESTONES = {
@@ -55,6 +56,24 @@ MILESTONES = {
55
56
  STATE_FILE = os.path.expanduser("~/.claude/claudia-milestones.json")
56
57
 
57
58
 
59
+ def stop_lock_acquire(session_id):
60
+ """Try to acquire the per-turn Stop hook lock. Returns True if acquired."""
61
+ lock_file = os.path.expanduser(f"~/.claude/claudia_stop_lock_{session_id}.tmp")
62
+ now = time.time()
63
+ try:
64
+ if os.path.exists(lock_file):
65
+ with open(lock_file) as f:
66
+ ts = float(f.read().strip())
67
+ if now - ts < 2.0:
68
+ return False # Another hook already claimed this turn
69
+ os.makedirs(os.path.dirname(lock_file), exist_ok=True)
70
+ with open(lock_file, "w") as f:
71
+ f.write(str(now))
72
+ return True
73
+ except (IOError, ValueError):
74
+ return True # On error, let it through
75
+
76
+
58
77
  def load_state():
59
78
  if os.path.exists(STATE_FILE):
60
79
  try:
@@ -147,12 +166,14 @@ def main():
147
166
  if celebration:
148
167
  state["achieved"] = list(achieved)
149
168
  save_state(state)
150
- msg = f"Claudia: {celebration}"
151
- output = json.dumps({
152
- "additionalContext": msg,
153
- "systemMessage": msg,
154
- })
155
- print(output)
169
+ session_id = input_data.get("session_id", "default")
170
+ if stop_lock_acquire(session_id):
171
+ msg = f"Claudia: {celebration}"
172
+ output = json.dumps({
173
+ "additionalContext": msg,
174
+ "systemMessage": f"\033[38;5;209m{msg}\033[0m",
175
+ })
176
+ print(output)
156
177
 
157
178
  # Save state even without celebration (for file_count tracking)
158
179
  elif new_files > 0:
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import re
12
12
  import sys
13
+ import time
13
14
 
14
15
  # Completion signals
15
16
  COMPLETION_PATTERNS = [
@@ -74,6 +75,24 @@ NEXT_STEPS = {
74
75
  ],
75
76
  }
76
77
 
78
+ def stop_lock_acquire(session_id):
79
+ """Try to acquire the per-turn Stop hook lock. Returns True if acquired."""
80
+ lock_file = os.path.expanduser(f"~/.claude/claudia_stop_lock_{session_id}.tmp")
81
+ now = time.time()
82
+ try:
83
+ if os.path.exists(lock_file):
84
+ with open(lock_file) as f:
85
+ ts = float(f.read().strip())
86
+ if now - ts < 2.0:
87
+ return False
88
+ os.makedirs(os.path.dirname(lock_file), exist_ok=True)
89
+ with open(lock_file, "w") as f:
90
+ f.write(str(now))
91
+ return True
92
+ except (IOError, ValueError):
93
+ return True
94
+
95
+
77
96
  # Pattern to extract filenames from the message
78
97
  FILENAME_PATTERN = r"[`'\"]?(\S+\.(\w{1,4}))[`'\"]?"
79
98
 
@@ -174,6 +193,9 @@ def main():
174
193
  else:
175
194
  formatted.append(step.replace(" {filename}", "").replace("{filename}", "the file"))
176
195
 
196
+ if not stop_lock_acquire(session_id):
197
+ sys.exit(0)
198
+
177
199
  state["count"] += 1
178
200
  save_state(session_id, state)
179
201
 
@@ -182,7 +204,7 @@ def main():
182
204
  )
183
205
  output = json.dumps({
184
206
  "additionalContext": suggestion_text,
185
- "systemMessage": suggestion_text,
207
+ "systemMessage": f"\033[38;5;209m{suggestion_text}\033[0m",
186
208
  })
187
209
  print(output)
188
210
  sys.exit(0)
@@ -176,7 +176,7 @@ def main():
176
176
  save_state(session_id, state)
177
177
  result = {"additionalContext": coaching_note}
178
178
  if user_msg:
179
- result["systemMessage"] = user_msg
179
+ result["systemMessage"] = f"\033[38;5;209m{user_msg}\033[0m"
180
180
  print(json.dumps(result))
181
181
 
182
182
  sys.exit(0)
@@ -10,6 +10,7 @@ import json
10
10
  import os
11
11
  import re
12
12
  import sys
13
+ import time
13
14
 
14
15
  # File type -> run suggestion
15
16
  RUN_SUGGESTIONS = {
@@ -28,6 +29,24 @@ FILE_PATTERNS = [
28
29
  r"(?:new file|writing to|saved to)\s+[`'\"]?(\S+\.(\w+))[`'\"]?",
29
30
  ]
30
31
 
32
+ def stop_lock_acquire(session_id):
33
+ """Try to acquire the per-turn Stop hook lock. Returns True if acquired."""
34
+ lock_file = os.path.expanduser(f"~/.claude/claudia_stop_lock_{session_id}.tmp")
35
+ now = time.time()
36
+ try:
37
+ if os.path.exists(lock_file):
38
+ with open(lock_file) as f:
39
+ ts = float(f.read().strip())
40
+ if now - ts < 2.0:
41
+ return False
42
+ os.makedirs(os.path.dirname(lock_file), exist_ok=True)
43
+ with open(lock_file, "w") as f:
44
+ f.write(str(now))
45
+ return True
46
+ except (IOError, ValueError):
47
+ return True
48
+
49
+
31
50
  PACKAGE_JSON_PATTERN = r"(?:created|wrote|updated|modified)\s+[`'\"]?package\.json[`'\"]?"
32
51
 
33
52
 
@@ -105,12 +124,14 @@ def main():
105
124
 
106
125
  # Check for package.json mentions
107
126
  if "package.json" not in shown_types and re.search(PACKAGE_JSON_PATTERN, message, re.IGNORECASE):
127
+ if not stop_lock_acquire(session_id):
128
+ sys.exit(0)
108
129
  shown_types.add("package.json")
109
130
  state["shown_types"] = list(shown_types)
110
131
  save_state(session_id, state)
111
132
  suggestion = RUN_SUGGESTIONS["package.json"][1]
112
133
  msg = f"Claudia: {suggestion}"
113
- output = json.dumps({"additionalContext": msg, "systemMessage": msg})
134
+ output = json.dumps({"additionalContext": msg, "systemMessage": f"\033[38;5;209m{msg}\033[0m"})
114
135
  print(output)
115
136
  sys.exit(0)
116
137
 
@@ -121,12 +142,14 @@ def main():
121
142
  filename = match.group(1)
122
143
  ext = match.group(2).lower()
123
144
  if ext in RUN_SUGGESTIONS and ext not in shown_types:
145
+ if not stop_lock_acquire(session_id):
146
+ sys.exit(0)
124
147
  shown_types.add(ext)
125
148
  state["shown_types"] = list(shown_types)
126
149
  save_state(session_id, state)
127
150
  suggestion = RUN_SUGGESTIONS[ext][1].format(filename=filename)
128
151
  msg = f"Claudia: {suggestion}"
129
- output = json.dumps({"additionalContext": msg, "systemMessage": msg})
152
+ output = json.dumps({"additionalContext": msg, "systemMessage": f"\033[38;5;209m{msg}\033[0m"})
130
153
  print(output)
131
154
  sys.exit(0)
132
155
 
@@ -9,6 +9,7 @@ Advisory only (exit 0 with additionalContext), never blocks.
9
9
  import json
10
10
  import os
11
11
  import random
12
+ import subprocess
12
13
  import sys
13
14
 
14
15
  # Pool of startup tips for beginners
@@ -30,6 +31,64 @@ COMPACT_TIP = (
30
31
  "conversation details. If you need to re-explain something, that's normal."
31
32
  )
32
33
 
34
+
35
+ def gather_project_context():
36
+ """Read claudia-context.json, milestones, and git state to rebuild context after compaction."""
37
+ parts = []
38
+
39
+ # Stack and decisions from context file
40
+ context_path = os.path.expanduser("~/.claude/claudia-context.json")
41
+ if os.path.exists(context_path):
42
+ try:
43
+ with open(context_path) as f:
44
+ ctx = json.load(f)
45
+ stack = ctx.get("stack", {})
46
+ decisions = ctx.get("decisions", [])
47
+ experience = ctx.get("experience", "intermediate")
48
+ if stack:
49
+ parts.append(f"Project stack: {json.dumps(stack)}")
50
+ if decisions:
51
+ parts.append("Decisions made this session: " + "; ".join(str(d) for d in decisions[-5:]))
52
+ if experience == "beginner":
53
+ parts.append("User experience level: beginner. Use simple language, explain jargon.")
54
+ except (json.JSONDecodeError, IOError):
55
+ pass
56
+
57
+ # Milestones achieved
58
+ milestones_path = os.path.expanduser("~/.claude/claudia-milestones.json")
59
+ if os.path.exists(milestones_path):
60
+ try:
61
+ with open(milestones_path) as f:
62
+ ms = json.load(f)
63
+ achieved = ms.get("achieved", [])
64
+ if achieved:
65
+ parts.append(f"Milestones achieved: {', '.join(achieved)}")
66
+ except (json.JSONDecodeError, IOError):
67
+ pass
68
+
69
+ # Recent git activity
70
+ try:
71
+ log = subprocess.run(
72
+ ["git", "log", "--oneline", "-5"],
73
+ capture_output=True, text=True, timeout=5
74
+ )
75
+ if log.returncode == 0 and log.stdout.strip():
76
+ parts.append(f"Recent commits:\n{log.stdout.strip()}")
77
+ except (subprocess.TimeoutExpired, FileNotFoundError):
78
+ pass
79
+
80
+ try:
81
+ status = subprocess.run(
82
+ ["git", "status", "--short"],
83
+ capture_output=True, text=True, timeout=5
84
+ )
85
+ if status.returncode == 0 and status.stdout.strip():
86
+ parts.append(f"Uncommitted changes:\n{status.stdout.strip()}")
87
+ except (subprocess.TimeoutExpired, FileNotFoundError):
88
+ pass
89
+
90
+ return "\n".join(parts) if parts else None
91
+
33
92
  # Beginner greeting — no command list, just reassurance
34
93
  BEGINNER_GREETING = (
35
94
  "IMPORTANT — Claudia plugin is loaded. On this very first response, "
@@ -161,15 +220,41 @@ def main():
161
220
  state.setdefault("shown_tips_history", []).append(tip_text)
162
221
  tip = f"\U0001f4a1 Claudia tip: {tip_text}"
163
222
 
164
- elif source == "compact" and is_beginner:
165
- if not state.get("shown_compact_tip"):
223
+ elif source == "compact":
224
+ # Re-inject project context after compaction (this survives because
225
+ # SessionStart fires AFTER compaction completes)
226
+ project_ctx = gather_project_context()
227
+ if project_ctx:
228
+ context_injection = (
229
+ "Claudia context recovery: The conversation was just compacted. "
230
+ "Here is what Claudia knows about the current project and session:\n\n"
231
+ + project_ctx
232
+ + "\n\nUse this context to stay grounded. Don't mention compaction unless the user asks."
233
+ )
234
+ # Prepend to greeting so it goes into additionalContext
235
+ greeting = context_injection
236
+
237
+ if is_beginner and not state.get("shown_compact_tip"):
166
238
  state["shown_compact_tip"] = True
167
239
  tip = f"\U0001f4a1 Claudia: {COMPACT_TIP}"
240
+ elif not is_beginner:
241
+ tip = "\U0001f4a1 Claudia: Context compacted. I've caught Claude up on your project."
168
242
 
169
243
  elif source == "resume":
244
+ # Re-inject context on resume too
245
+ project_ctx = gather_project_context()
246
+ if project_ctx:
247
+ context_injection = (
248
+ "Claudia context recovery: This is a resumed session. "
249
+ "Here is what Claudia knows about the current project:\n\n"
250
+ + project_ctx
251
+ + "\n\nUse this context to stay grounded."
252
+ )
253
+ greeting = context_injection
254
+
170
255
  if not state.get("shown_resume_tip"):
171
256
  state["shown_resume_tip"] = True
172
- tip = "\U0001f4a1 Claudia: Welcome back. I'm still watching."
257
+ tip = "\U0001f4a1 Claudia: Welcome back. I've caught Claude up on your project."
173
258
 
174
259
  # source == "clear": no tip needed
175
260
 
@@ -188,7 +273,8 @@ def main():
188
273
  if tip:
189
274
  visible_parts.append(tip)
190
275
  if visible_parts:
191
- result["systemMessage"] = " | ".join(visible_parts) if greeting and tip else visible_parts[0]
276
+ msg = " | ".join(visible_parts) if greeting and tip else visible_parts[0]
277
+ result["systemMessage"] = f"\033[38;5;209m{msg}\033[0m"
192
278
  print(json.dumps(result))
193
279
 
194
280
  sys.exit(0)
@@ -12,6 +12,7 @@ import json
12
12
  import os
13
13
  import re
14
14
  import sys
15
+ import time
15
16
 
16
17
  # Technology keywords by category
17
18
  KEYWORDS = {
@@ -77,6 +78,24 @@ ERROR_PATTERNS = [
77
78
  (r'\bReferenceError\b', "a reference error — usually a typo or missing variable"),
78
79
  ]
79
80
 
81
+ def stop_lock_acquire(session_id):
82
+ """Try to acquire the per-turn Stop hook lock. Returns True if acquired."""
83
+ lock_file = os.path.expanduser(f"~/.claude/claudia_stop_lock_{session_id}.tmp")
84
+ now = time.time()
85
+ try:
86
+ if os.path.exists(lock_file):
87
+ with open(lock_file) as f:
88
+ ts = float(f.read().strip())
89
+ if now - ts < 2.0:
90
+ return False
91
+ os.makedirs(os.path.dirname(lock_file), exist_ok=True)
92
+ with open(lock_file, "w") as f:
93
+ f.write(str(now))
94
+ return True
95
+ except (IOError, ValueError):
96
+ return True
97
+
98
+
80
99
  # Contextual command reveals for beginners
81
100
  COMMAND_REVEALS = {
82
101
  "file_written": {
@@ -276,12 +295,14 @@ def main():
276
295
  state["shown_keywords"] = list(shown_keywords)
277
296
  state["revealed_commands"] = list(revealed_commands)
278
297
  save_state(session_id, state)
279
- tip_text = "\n".join(f"Claudia: {tip}" for tip in tips)
280
- output = json.dumps({
281
- "additionalContext": tip_text,
282
- "systemMessage": tip_text,
283
- })
284
- print(output)
298
+ if stop_lock_acquire(session_id):
299
+ tip_text = "\n".join(f"Claudia: {tip}" for tip in tips)
300
+ colored = "\n".join(f"\033[38;5;209m{line}\033[0m" for line in tip_text.split("\n"))
301
+ output = json.dumps({
302
+ "additionalContext": tip_text,
303
+ "systemMessage": colored,
304
+ })
305
+ print(output)
285
306
  elif shown_keywords != set(state["shown_keywords"]) or revealed_commands != set(state["revealed_commands"]):
286
307
  state["shown_keywords"] = list(shown_keywords)
287
308
  state["revealed_commands"] = list(revealed_commands)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "claudia-mentor",
3
- "version": "0.5.3",
3
+ "version": "0.5.6",
4
4
  "description": "Proactive technology mentor, security advisor, and prompt coach for Claude Code",
5
5
  "author": "Regan O'Malley <regan@reganomalley.com>",
6
6
  "license": "MIT",