@rm0nroe/coach-claw 1.0.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.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/coach/README.md +99 -0
  4. package/coach/bin/aggregate_facets.py +274 -0
  5. package/coach/bin/analyze.py +678 -0
  6. package/coach/bin/bank.py +247 -0
  7. package/coach/bin/banner_themes.py +645 -0
  8. package/coach/bin/coach_paths.py +33 -0
  9. package/coach/bin/coexistence_check.py +129 -0
  10. package/coach/bin/configure.py +245 -0
  11. package/coach/bin/cron_check.py +81 -0
  12. package/coach/bin/default_statusline.py +135 -0
  13. package/coach/bin/doctor.py +663 -0
  14. package/coach/bin/insights-llm.sh +264 -0
  15. package/coach/bin/insights.sh +163 -0
  16. package/coach/bin/insights_window.py +111 -0
  17. package/coach/bin/marker_io.py +154 -0
  18. package/coach/bin/merge.py +671 -0
  19. package/coach/bin/redact.py +86 -0
  20. package/coach/bin/render_env.py +148 -0
  21. package/coach/bin/reward_hints.py +87 -0
  22. package/coach/bin/run-insights.sh +20 -0
  23. package/coach/bin/run_with_lock.py +85 -0
  24. package/coach/bin/scoring.py +260 -0
  25. package/coach/bin/skill_inventory.py +215 -0
  26. package/coach/bin/stats.py +459 -0
  27. package/coach/bin/status.py +293 -0
  28. package/coach/bin/statusline_self_patch.py +205 -0
  29. package/coach/bin/statusline_variants.py +146 -0
  30. package/coach/bin/statusline_wrap.py +244 -0
  31. package/coach/bin/statusline_wrap_action.py +460 -0
  32. package/coach/bin/switch_to_plugin.py +256 -0
  33. package/coach/bin/themes.py +256 -0
  34. package/coach/bin/user_config.py +176 -0
  35. package/coach/bin/xp_accounting.py +98 -0
  36. package/coach/changelog.md +4 -0
  37. package/coach/default-statusline-command.sh +19 -0
  38. package/coach/default-statusline-wrap-command.sh +15 -0
  39. package/coach/profile.yaml +37 -0
  40. package/coach/tests/conftest.py +13 -0
  41. package/coach/tests/test_aggregate_facets.py +379 -0
  42. package/coach/tests/test_analyze_aggregate.py +153 -0
  43. package/coach/tests/test_analyze_redaction.py +105 -0
  44. package/coach/tests/test_analyze_strengths.py +165 -0
  45. package/coach/tests/test_bank_atomic_write.py +61 -0
  46. package/coach/tests/test_bank_concurrency.py +126 -0
  47. package/coach/tests/test_banner_themes.py +981 -0
  48. package/coach/tests/test_celebrate_dedup.py +409 -0
  49. package/coach/tests/test_coach_paths.py +50 -0
  50. package/coach/tests/test_coexistence_check.py +128 -0
  51. package/coach/tests/test_configure.py +258 -0
  52. package/coach/tests/test_cron_check.py +118 -0
  53. package/coach/tests/test_cron_nudge_hook.py +134 -0
  54. package/coach/tests/test_detection_parity.py +105 -0
  55. package/coach/tests/test_doctor.py +595 -0
  56. package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
  57. package/coach/tests/test_hook_module_resolution.py +116 -0
  58. package/coach/tests/test_hook_relevance.py +996 -0
  59. package/coach/tests/test_hook_render_env.py +364 -0
  60. package/coach/tests/test_hook_session_id_guard.py +160 -0
  61. package/coach/tests/test_insights_llm.py +759 -0
  62. package/coach/tests/test_insights_llm_venv_path.py +109 -0
  63. package/coach/tests/test_insights_window.py +237 -0
  64. package/coach/tests/test_install.py +1150 -0
  65. package/coach/tests/test_install_pyyaml_fallback.py +142 -0
  66. package/coach/tests/test_marker_consumption.py +167 -0
  67. package/coach/tests/test_marker_writer_locking.py +305 -0
  68. package/coach/tests/test_merge.py +413 -0
  69. package/coach/tests/test_no_broken_mktemp.py +90 -0
  70. package/coach/tests/test_render_env.py +137 -0
  71. package/coach/tests/test_render_env_glyphs.py +119 -0
  72. package/coach/tests/test_reward_hints.py +59 -0
  73. package/coach/tests/test_scoring.py +147 -0
  74. package/coach/tests/test_session_start_weekly_trigger.py +92 -0
  75. package/coach/tests/test_skill_inventory.py +368 -0
  76. package/coach/tests/test_stats_hybrid.py +142 -0
  77. package/coach/tests/test_status_accounting.py +41 -0
  78. package/coach/tests/test_statusline_failsafe.py +70 -0
  79. package/coach/tests/test_statusline_self_patch.py +261 -0
  80. package/coach/tests/test_statusline_variants.py +110 -0
  81. package/coach/tests/test_statusline_wrap.py +196 -0
  82. package/coach/tests/test_statusline_wrap_action.py +408 -0
  83. package/coach/tests/test_switch_to_plugin.py +360 -0
  84. package/coach/tests/test_themes.py +104 -0
  85. package/coach/tests/test_user_config.py +160 -0
  86. package/coach/tests/test_wrap_announce_hook.py +130 -0
  87. package/coach/tests/test_xp_accounting.py +55 -0
  88. package/hooks/coach-session-start.py +536 -0
  89. package/hooks/coach-user-prompt.py +2288 -0
  90. package/install-launchd.sh +102 -0
  91. package/install.sh +597 -0
  92. package/launchd/com.local.claude-coach.plist.template +34 -0
  93. package/launchd/run-insights.sh +20 -0
  94. package/npm/coach-claw.js +259 -0
  95. package/package.json +52 -0
  96. package/requirements.txt +11 -0
  97. package/settings-snippet.json +31 -0
  98. package/skills/coach/SKILL.md +107 -0
  99. package/skills/coach-insights/SKILL.md +78 -0
  100. package/skills/config/SKILL.md +149 -0
@@ -0,0 +1,2288 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Coach Claw — UserPromptSubmit hook.
4
+
5
+ Two responsibilities on every user prompt:
6
+
7
+ 1. Celebration banners (one-shot). Reads three marker files written by
8
+ upstream processes and injects banner-render instructions, then clears
9
+ the markers.
10
+
11
+ ~/.claude/coach/.pending_levelup — XP threshold crossed
12
+ ~/.claude/coach/.pending_graduation — pattern graduated
13
+ ~/.claude/coach/.pending_regression — graduated pattern regressed
14
+
15
+ 2. Scheduled ambient tips (restored 2026-04-19). Rolls a dice per prompt;
16
+ when it lands, picks one tip from profile.yaml entries + skill_hints,
17
+ rotates through an emoji label pool, and injects a REQUIRED render
18
+ instruction. Replaces the archived coach-stop.py prototype — same
19
+ selection logic but delivered through UserPromptSubmit's proven
20
+ additionalContext channel (the Stop hook's systemMessage renders as a
21
+ warning, wrong vibe; /dev/tty rendered below the input prompt, wrong
22
+ slot). State tracked in ~/.claude/coach/.tip_state.json.
23
+
24
+ Design invariants:
25
+ - Always exits 0. A broken coach must never block a prompt.
26
+ - Emits valid JSON or nothing. No stderr leakage into the UI.
27
+ - Reads markers + clears them (celebrations) or reads + writes tip state
28
+ (scheduler). Never mutates profile.yaml — that's /coach-insights' job.
29
+ """
30
+ from __future__ import annotations
31
+
32
+ import fcntl
33
+ import json
34
+ import os
35
+ import random
36
+ import re
37
+ import sys
38
+ import tempfile
39
+ from collections import deque
40
+ from contextlib import contextmanager
41
+ from datetime import datetime, timedelta, timezone
42
+ from pathlib import Path
43
+
44
+ # Resolve the coach state dir BEFORE adding bin/ to sys.path — the helper
45
+ # we'd otherwise import (`coach_paths.resolve_coach_dir`) lives in that
46
+ # very dir, so we have to inline the env-var contract here. Keep this in
47
+ # sync with `coach/bin/coach_paths.py:resolve_coach_dir()`.
48
+ _COACH_BASE = os.environ.get("COACH_CONFIG_DIR")
49
+ COACH_DIR = Path(_COACH_BASE) if _COACH_BASE else Path.home() / ".claude" / "coach"
50
+
51
+ # Shared modules — locate the bin/ that ships with THIS hook copy.
52
+ # - Plugin context: ${CLAUDE_PLUGIN_ROOT}/bin/ (set by Claude Code).
53
+ # - CLI context: ${COACH_DIR}/bin/.
54
+ # Without this branch, the plugin's hook would pull from the CLI install's
55
+ # stale bin/ (which can be missing newer modules like cron_check or
56
+ # statusline_self_patch — observed during e2e validation 2026-05-09).
57
+ _PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT")
58
+ if _PLUGIN_ROOT:
59
+ sys.path.insert(0, str(Path(_PLUGIN_ROOT) / "bin"))
60
+ else:
61
+ sys.path.insert(0, str(COACH_DIR / "bin"))
62
+ try:
63
+ from reward_hints import ( # noqa: E402
64
+ infer_reward_hint as _shared_infer_reward_hint,
65
+ effective_reward_hint as _shared_effective_reward_hint,
66
+ )
67
+ _SHARED_HINT_OK = True
68
+ except Exception:
69
+ # If the shared module is missing, fall back to the local inline heuristic
70
+ # below so the hook never crashes. Never blocks a user prompt.
71
+ _SHARED_HINT_OK = False
72
+ try:
73
+ from scoring import matches_action as _shared_matches_action # noqa: E402
74
+ _SHARED_SCORING_OK = True
75
+ except Exception:
76
+ _SHARED_SCORING_OK = False
77
+ try:
78
+ from render_env import detect_render_env # noqa: E402
79
+ except Exception:
80
+ # Defensive: if the helper is missing, force terminal shape so the
81
+ # current rendering path always works.
82
+ def detect_render_env(env=None): # type: ignore[no-redef]
83
+ return "terminal"
84
+ try:
85
+ from banner_themes import ( # noqa: E402
86
+ render_celebrate_for_theme as _render_celebrate_for_theme,
87
+ BESPOKE_THEMES as _BESPOKE_THEMES,
88
+ )
89
+ _BESPOKE_OK = True
90
+ except Exception:
91
+ _BESPOKE_OK = False
92
+ _BESPOKE_THEMES = frozenset()
93
+ try:
94
+ from user_config import get_theme as _get_theme # noqa: E402
95
+ except Exception:
96
+ def _get_theme(): # type: ignore[no-redef]
97
+ return "craft"
98
+ PROFILE = COACH_DIR / "profile.yaml"
99
+ LEVELUP_MARKER = COACH_DIR / ".pending_levelup"
100
+ GRADUATION_MARKER = COACH_DIR / ".pending_graduation"
101
+ REGRESSION_MARKER = COACH_DIR / ".pending_regression"
102
+ STREAK_REWARD_MARKER = COACH_DIR / ".pending_streak_rewards"
103
+ WRAP_ANNOUNCE_MARKER = COACH_DIR / ".statusline-wrap-announced"
104
+ WRAP_DUPLICATE_MARKER = COACH_DIR / ".statusline-wrap-duplicate-detected"
105
+ DISABLED_FLAG = COACH_DIR / ".disabled"
106
+ TIP_STATE = COACH_DIR / ".tip_state.json"
107
+ LOG_PATH = COACH_DIR / "log.ndjson"
108
+ LOG_MAX_LINES = 500
109
+ # How long to keep one-shot celebration markers around. Each session
110
+ # consumes a marker once (per-session dedup via `consumed_by`); TTL just
111
+ # bounds how long an unconsumed marker lingers if all sessions go idle.
112
+ MARKER_TTL_HOURS = 24
113
+ MARKER_CONSUMED_BY_CAP = 100
114
+
115
+ # --- Tip scheduler tuning (dial here, not via env) ---
116
+ TIP_FIRE_PROBABILITY = 0.35 # per-prompt roll after cooldown passes
117
+ TIP_GLOBAL_COOLDOWN_SEC = 300 # min seconds between any two tips
118
+ TIP_PER_TIP_COOLDOWN_HOURS = 24 # same tip id won't repeat within this window
119
+
120
+ # --- Weighted selection tuning (Fix 4) ---
121
+ # Drives which eligible tip gets picked when multiple are ready. Baseline
122
+ # weight = confidence × priority (mirrors merge.py:422's cap-eviction rule).
123
+ # Then multiplied by tier + streak-urgency factors. Constants are knobs.
124
+ TIER_MULTIPLIER = {
125
+ "probationary": 1.5, # new pattern, user hasn't seen it yet — surface it
126
+ "active": 1.0, # steady state
127
+ "hint": 0.4, # skill hints — nice-to-have, below weaknesses
128
+ }
129
+ STREAK_URGENCY_HIGH = 1.3 # streak 0-1: early, encourage pickup
130
+ STREAK_URGENCY_MID = 1.0 # streak 2-3: midway
131
+ STREAK_URGENCY_LOW = 0.6 # streak 4: near graduation, user's already doing it
132
+ STRENGTH_WEIGHT_MULTIPLIER = 0.75 # reinforcement should appear, not drown out fixes
133
+
134
+ # Skill-share floor. Without this, heavy-weakness profiles can starve skill
135
+ # hints — cumulative weakness weight drowns the hint multiplier (0.4×) so
136
+ # skills practically never surface. If ANY skill hints are eligible, the
137
+ # scheduler scales their weights up so they collectively receive at least
138
+ # this share of total weight.
139
+ MIN_SKILL_SHARE = 0.25
140
+
141
+ # --- Reward attribution (keeps the reward loop visible in every tip) ---
142
+ # Source of truth for action→XP math is ~/.claude/coach/bin/stats.py:240:
143
+ # xp = test_runs * 2 + commits * 1 + len(unique_skills) * 1
144
+ # and lifetime adds +5 per graduated pattern (5-run clean streak).
145
+ GRADUATION_XP = 5
146
+ GRADUATION_STREAK_TARGET = 5
147
+ SKILL_XP_PER_UNIQUE = 1 # +1 once per skill per session
148
+ SESSION_XP_CAP = 15 # hard ceiling, per stats.py SESSION_XP_CAP
149
+
150
+ # Test-runner + commit regexes — MUST stay in lockstep with stats.py copies
151
+ # so completion detection matches what actually earns the XP there.
152
+ # Position-anchored (start-of-line or after ; && || |) with optional env-var
153
+ # or `cd … &&` prefix — prevents false positives on these tokens when they
154
+ # appear inside commit message bodies. Mirror any edit across both files.
155
+ TEST_RE = re.compile(
156
+ r"(?:^|[;&|])\s*"
157
+ r"(?:\w+=\S+\s+)*"
158
+ r"(?:cd\s+\S+\s*&&\s*)?"
159
+ r"(?:pytest|jest|vitest|mocha|rspec|phpunit|"
160
+ r"cargo\s+test|go\s+test|pnpm\s+test|npm\s+test|bun\s+test|"
161
+ r"yarn\s+test|mix\s+test)"
162
+ r"\b"
163
+ )
164
+ COMMIT_RE = re.compile(
165
+ r"(?:^|[;&|])\s*"
166
+ r"(?:\w+=\S+\s+)*"
167
+ r"(?:cd\s+\S+\s*&&\s*)?"
168
+ r"git\s+commit\b"
169
+ )
170
+
171
+ # --- Dynamic reward attribution (profile-driven) ---
172
+ # The reward for following a tip is derived from each profile.yaml entry's
173
+ # `reward_hint` field. Shape:
174
+ #
175
+ # reward_hint:
176
+ # action: test_run | commit | skill_invoke
177
+ # xp: 2
178
+ # description: "test run (pytest / jest / …)"
179
+ #
180
+ # When `reward_hint` is missing on an entry, _infer_reward_hint() falls back
181
+ # to a keyword heuristic over the entry id. This lets new patterns earn XP
182
+ # without a code edit — /coach-insights can populate reward_hint at promotion time,
183
+ # or we infer at read time. Patterns where neither matches are graduation-only
184
+ # (only reward is the +5 lump sum at 5 clean /coach-insights runs).
185
+ #
186
+ # Keep this list aligned with stats.py's scored actions so the TIP's promised
187
+ # XP actually gets awarded:
188
+ # - test_run → +2 via TEST_RE bash-command match (stats.py same regex)
189
+ # - commit → +1 via COMMIT_RE bash-command match (stats.py same regex)
190
+ # - skill_invoke → +1 once per unique /skill per session (skill tips only)
191
+
192
+ _REWARD_HINT_HEURISTIC: list[tuple[str, dict]] = [
193
+ # (id-substring, reward_hint payload). First match wins.
194
+ ("without-test", {"action": "test_run", "xp": 2,
195
+ "description": "test run (pytest / jest / cargo test / …)"}),
196
+ ("untested", {"action": "test_run", "xp": 2,
197
+ "description": "test run"}),
198
+ ("skip-test", {"action": "test_run", "xp": 2,
199
+ "description": "test run"}),
200
+ ("without-commit", {"action": "commit", "xp": 1,
201
+ "description": "git commit"}),
202
+ ]
203
+
204
+
205
+ def _infer_reward_hint(entry_id: str) -> dict | None:
206
+ """Local fallback if shared reward_hints module isn't importable.
207
+ Same logic as reward_hints.infer_reward_hint but id-only, no nudge text."""
208
+ if not entry_id:
209
+ return None
210
+ eid = entry_id.lower()
211
+ for keyword, hint in _REWARD_HINT_HEURISTIC:
212
+ if keyword in eid:
213
+ return dict(hint)
214
+ return None
215
+
216
+
217
+ def _effective_reward_hint(entry: dict) -> dict | None:
218
+ """Pull reward_hint from the profile entry, else infer. Prefers the
219
+ shared reward_hints module (which also inspects nudge text) and falls
220
+ back to a local id-only heuristic if the import failed."""
221
+ if _SHARED_HINT_OK:
222
+ return _shared_effective_reward_hint(entry)
223
+ explicit = entry.get("reward_hint")
224
+ if (
225
+ isinstance(explicit, dict)
226
+ and explicit.get("action")
227
+ and int(explicit.get("xp", 0)) > 0
228
+ ):
229
+ return explicit
230
+ return _infer_reward_hint(entry.get("id") or "")
231
+
232
+ # Label pools (weakness vs skill flavor). ~60% carry a content-matched emoji.
233
+ # Kept in the hook so selection is deterministic — Claude shapes the BODY,
234
+ # the hook picks the LABEL so rotation actually happens.
235
+ WEAKNESS_LABELS = [
236
+ # Coach-themed / professional (primary)
237
+ "*Tip:*",
238
+ "*Pointer:*",
239
+ "*Heads up:*",
240
+ "*Worth noting:*",
241
+ # Subject-matched emoji (coach voice, occasional visual)
242
+ "*🎯 Tip:*", # focus / precision
243
+ "*✏️ Tip:*", # testing / verification
244
+ "*🧭 Heads up:*", # navigation / direction
245
+ "*🪶 Worth noting:*", # simplification
246
+ "*📌 Worth noting:*", # durability / pin for later
247
+ # Occasional quest flavor (rare — sprinkle, not theme)
248
+ "*🎯 Quest:*",
249
+ ]
250
+ SKILL_LABELS = [
251
+ # Coach-themed / professional (primary)
252
+ "*Coach:*",
253
+ "*🦞 From Coach Claw:*",
254
+ "*🦞 Coach:*",
255
+ # Occasional quest flavor
256
+ "*🌟 Power-up:*",
257
+ ]
258
+ STRENGTH_LABELS = [
259
+ "*Strength:*",
260
+ "*Keep:*",
261
+ "*Good pattern:*",
262
+ "*⚔️ Strength:*",
263
+ "*📌 Keep:*",
264
+ ]
265
+
266
+
267
+ def _ide_label(label: str) -> str:
268
+ """Convert a terminal-shape label (e.g. `*Tip:*`, `*🦞 From Coach Claw:*`,
269
+ `*🎯 Tip:*`) to an IDE-shape label (e.g. `🦞 **Tip**`,
270
+ `🦞 **From Coach Claw**`).
271
+
272
+ Always prefixes with 🦞 as the universal coach persona signature. Drops
273
+ content-matched emoji from the label content since IDE banners get their
274
+ visual signature from the HR frame + 🦞 + bold + code-span pills, not
275
+ from per-tip emoji rotation.
276
+ """
277
+ text = label.strip().strip("*").rstrip(":").strip()
278
+ parts = text.split(" ", 1)
279
+ if len(parts) == 2 and not parts[0].isascii():
280
+ text = parts[1]
281
+ return f"🦞 **{text}**"
282
+
283
+
284
+ def _emit(context: str | None) -> None:
285
+ if context:
286
+ payload = {
287
+ "hookSpecificOutput": {
288
+ "hookEventName": "UserPromptSubmit",
289
+ "additionalContext": context,
290
+ }
291
+ }
292
+ sys.stdout.write(json.dumps(payload))
293
+ sys.exit(0)
294
+
295
+
296
+ def _disabled() -> bool:
297
+ if os.environ.get("COACH_DISABLE") == "1":
298
+ return True
299
+ return DISABLED_FLAG.exists()
300
+
301
+
302
+ def _read_and_consume(path: Path, session_key: str | None, now: datetime) -> dict | None:
303
+ """Read a one-shot celebration marker, returning its payload exactly
304
+ once per `session_key`.
305
+
306
+ Multi-session safe (was BACKLOG P2): the marker JSON carries a
307
+ `consumed_by` list of session keys plus a `created_at` ISO timestamp.
308
+ Each concurrent Claude Code session that polls sees the marker once;
309
+ subsequent polls from the same session return None. Markers are
310
+ auto-cleaned after MARKER_TTL_HOURS so abandoned markers don't
311
+ accumulate. The read-modify-write is serialized via a sidecar flock
312
+ so two sessions polling at the same instant can't both append-and-
313
+ overwrite each other's `consumed_by` entry.
314
+
315
+ Backwards-compat: legacy markers without `created_at` / `consumed_by`
316
+ (written by v0.1.x) are stamped on first read and treated as fresh.
317
+ """
318
+ if not path.exists():
319
+ return None
320
+ key = session_key or "unknown"
321
+ lock_path = path.with_suffix(path.suffix + ".lock")
322
+ try:
323
+ with open(lock_path, "w") as lock_fh:
324
+ try:
325
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
326
+ except Exception as exc:
327
+ if os.environ.get("COACH_DEBUG"):
328
+ print(
329
+ f"coach: marker flock failed for {path.name}: {exc}",
330
+ file=sys.stderr,
331
+ )
332
+ # Re-check existence under lock — another session may have
333
+ # TTL-cleaned it between the outer existence check and here.
334
+ if not path.exists():
335
+ return None
336
+ try:
337
+ data = json.loads(path.read_text())
338
+ except Exception:
339
+ # Corrupt marker — clean up so we don't loop on it.
340
+ try:
341
+ path.unlink()
342
+ except Exception:
343
+ pass
344
+ return None
345
+ if not isinstance(data, dict):
346
+ try:
347
+ path.unlink()
348
+ except Exception:
349
+ pass
350
+ return None
351
+
352
+ consumed_by = data.get("consumed_by")
353
+ if not isinstance(consumed_by, list):
354
+ consumed_by = []
355
+
356
+ # TTL cleanup. Aged markers are deleted on the next poll from
357
+ # any session, regardless of whether that session has seen them.
358
+ created_at = data.get("created_at")
359
+ if isinstance(created_at, str):
360
+ try:
361
+ created_dt = datetime.fromisoformat(created_at)
362
+ if created_dt.tzinfo is None:
363
+ created_dt = created_dt.replace(tzinfo=timezone.utc)
364
+ if (now - created_dt) > timedelta(hours=MARKER_TTL_HOURS):
365
+ try:
366
+ path.unlink()
367
+ except Exception:
368
+ pass
369
+ return None
370
+ except Exception:
371
+ pass
372
+
373
+ # Already consumed by this session — stay silent so the same
374
+ # banner doesn't render on every prompt for the rest of the day.
375
+ if key in consumed_by:
376
+ return None
377
+
378
+ # First-time consumption: append, cap at MARKER_CONSUMED_BY_CAP
379
+ # (drop oldest), atomic-rewrite the marker.
380
+ consumed_by.append(key)
381
+ if len(consumed_by) > MARKER_CONSUMED_BY_CAP:
382
+ consumed_by = consumed_by[-MARKER_CONSUMED_BY_CAP:]
383
+ data["consumed_by"] = consumed_by
384
+ if not isinstance(data.get("created_at"), str):
385
+ data["created_at"] = now.isoformat()
386
+ try:
387
+ _atomic_write_text(path, json.dumps(data, sort_keys=True))
388
+ except Exception:
389
+ # Best-effort: if rewrite fails, still surface the banner
390
+ # this once. Worst case is the banner repeats next prompt.
391
+ pass
392
+ return data
393
+ except Exception:
394
+ return None
395
+
396
+
397
+ def _cron_nudge_block(env: str = "terminal") -> str:
398
+ """Pre-rendered one-time banner pointing plugin-only users at the
399
+ npm CLI for OS-level cron registration. The plugin model can't
400
+ register launchd/cron itself; without the cron, profile.yaml never
401
+ gets the daily deterministic refresh."""
402
+ title = "Daily insights need OS scheduling"
403
+ body_lines = [
404
+ "Coach's daily deterministic insights pass runs from launchd "
405
+ "(macOS) or cron (Linux), which the plugin model can't "
406
+ "register on its own.",
407
+ "Run `npx @rm0nroe/coach-claw launchd` (macOS) or add a "
408
+ "crontab line for `~/.claude/coach/bin/insights.sh 1d` "
409
+ "(Linux) to keep `profile.yaml` fresh.",
410
+ "This nudge fires once per install.",
411
+ ]
412
+ if env == "ide":
413
+ body = f"📅 **{title}**\n\n" + "\n\n".join(body_lines)
414
+ return _hr_frame_stack([body])
415
+ return (
416
+ f"> 📅 **{title}**\n>\n"
417
+ + "\n>\n".join(f"> {line}" for line in body_lines)
418
+ )
419
+
420
+
421
+ def _maybe_cron_nudge_block(env: str = "terminal") -> str | None:
422
+ """Emit the cron nudge once, if running under the plugin AND no
423
+ cron is registered AND we haven't nudged before. Otherwise None.
424
+
425
+ Always failsafe — any exception returns None so the hook keeps
426
+ rendering normally.
427
+ """
428
+ if not os.environ.get("CLAUDE_PLUGIN_ROOT"):
429
+ return None
430
+ try:
431
+ marker = COACH_DIR / ".cron-nudged"
432
+ if marker.exists():
433
+ return None
434
+ from cron_check import is_cron_registered
435
+ if is_cron_registered():
436
+ return None
437
+ block = _cron_nudge_block(env)
438
+ # Persist marker so the nudge fires exactly once. Ignore write
439
+ # failures — re-nudging on the next prompt is a minor regression,
440
+ # not a correctness bug.
441
+ try:
442
+ marker.parent.mkdir(parents=True, exist_ok=True)
443
+ marker.write_text(json.dumps({
444
+ "nudged_at": datetime.now(timezone.utc).isoformat(),
445
+ }))
446
+ except Exception:
447
+ pass
448
+ return block
449
+ except Exception:
450
+ return None
451
+
452
+
453
+ def _wrap_announce_block(env: str = "terminal") -> str:
454
+ """One-time banner shown after auto-wrap installs the wrap shape.
455
+
456
+ Tells the user what just happened to their statusLine and how to
457
+ revert. Pre-rendered (no model interpolation) so the message is
458
+ exact across surfaces."""
459
+ title = "Coach wrapped your existing statusline"
460
+ body_lines = [
461
+ "Coach replaced `statusLine.command` with a wrapper that runs "
462
+ "your original command first, then appends the Coach segment.",
463
+ "Original is preserved in `~/.claude/coach/.statusline-wrap.json`.",
464
+ "Run `/coach-claw:doctor --unwrap-statusline` to revert.",
465
+ ]
466
+ if env == "ide":
467
+ body = f"🦞 **{title}**\n\n" + "\n\n".join(body_lines)
468
+ return _hr_frame_stack([body])
469
+ return (
470
+ f"> 🦞 **{title}**\n>\n"
471
+ + "\n>\n".join(f"> {line}" for line in body_lines)
472
+ )
473
+
474
+
475
+ def _wrap_duplicate_block(env: str = "terminal") -> str:
476
+ """One-time banner shown when the runtime composer detected a Coach
477
+ segment already inside the original output (e.g. user's script
478
+ happens to render Coach internally). Suggests unwrapping to avoid
479
+ a double segment."""
480
+ title = "Coach detected duplicate segments in your statusline"
481
+ body_lines = [
482
+ "The wrapper saw what looks like a Coach segment in your "
483
+ "original statusline output and is suppressing the appended one.",
484
+ "If your custom statusline already integrates Coach, run "
485
+ "`/coach-claw:doctor --unwrap-statusline` to stop wrapping.",
486
+ ]
487
+ if env == "ide":
488
+ body = f"🦞 **{title}**\n\n" + "\n\n".join(body_lines)
489
+ return _hr_frame_stack([body])
490
+ return (
491
+ f"> 🦞 **{title}**\n>\n"
492
+ + "\n>\n".join(f"> {line}" for line in body_lines)
493
+ )
494
+
495
+
496
+ def _maybe_wrap_announce_block(
497
+ session_key: str | None, now: datetime, env: str = "terminal",
498
+ ) -> str | None:
499
+ """Emit the wrap-announce banner once per session, until the
500
+ `.statusline-wrap-announced` marker hits its 24h TTL.
501
+
502
+ Per-session-consumed via `_read_and_consume` (mirrors
503
+ LEVELUP_MARKER etc.) so concurrent Claude Code sessions each see it
504
+ once. Failsafe — any exception returns None."""
505
+ try:
506
+ if _read_and_consume(WRAP_ANNOUNCE_MARKER, session_key, now) is None:
507
+ return None
508
+ return _wrap_announce_block(env)
509
+ except Exception:
510
+ return None
511
+
512
+
513
+ def _maybe_wrap_duplicate_block(
514
+ session_key: str | None, now: datetime, env: str = "terminal",
515
+ ) -> str | None:
516
+ """Emit the duplicate-detected banner once per session."""
517
+ try:
518
+ if _read_and_consume(WRAP_DUPLICATE_MARKER, session_key, now) is None:
519
+ return None
520
+ return _wrap_duplicate_block(env)
521
+ except Exception:
522
+ return None
523
+
524
+
525
+ def _hr_frame_stack(bodies: list[str]) -> str:
526
+ """Wrap N body strings with N+1 shared `---` rules, each body
527
+ followed by a blank line (Setext-H2 guard — without the blank line
528
+ CommonMark fuses the body into an H2 heading and drops the rule)."""
529
+ if not bodies:
530
+ return ""
531
+ out = ["---"]
532
+ for body in bodies:
533
+ out.append(body)
534
+ out.append("")
535
+ out.append("---")
536
+ return "\n".join(out)
537
+
538
+
539
+ def _levelup_block(data: dict, env: str = "terminal") -> str:
540
+ """Pre-rendered level-up banner. Body sentence is templated, not
541
+ model-filled — keeps the banner correct under verbatim-render."""
542
+ to = data.get("to", "?")
543
+ to_idx = int(data.get("to_idx", 0))
544
+ xp = int(data.get("xp_at_levelup", 0))
545
+ title = f"L{to_idx + 1} {to}"
546
+ if env == "ide":
547
+ body = (
548
+ f"🎉 **LEVEL UP** — `{title}` · `{xp} XP total`\n"
549
+ f"A new craft tier unlocks."
550
+ )
551
+ return _hr_frame_stack([body])
552
+ return (
553
+ f"> 🎉 **Level up!** You're now **{title}**.\n"
554
+ f"> A new craft tier unlocks at {xp} XP."
555
+ )
556
+
557
+
558
+ def _regression_block(regs: list, env: str = "terminal") -> str:
559
+ """Pre-rendered regression banners. One per dict, stacked.
560
+ Body is templated — name and originally_graduated_at are
561
+ substituted, no model-filled sentence."""
562
+ bodies_terminal: list[str] = []
563
+ bodies_ide: list[str] = []
564
+ for r in regs:
565
+ if not isinstance(r, dict):
566
+ continue
567
+ rid = r.get("id", "?")
568
+ rname = r.get("name") or rid
569
+ originally_at = r.get("originally_graduated_at", "?")
570
+ sentence = (
571
+ f"Re-detected this run, so it's off the mastered list "
572
+ f"(was graduated {originally_at}). Re-earn mastery by staying "
573
+ f"clean for 5 Coach insights runs."
574
+ )
575
+ bodies_terminal.append(f"> ⚠️ **Regressed: {rname}** — {sentence}")
576
+ bodies_ide.append(f"⚠️ **Regressed** — `{rname}`\n{sentence}")
577
+ if not bodies_terminal:
578
+ return ""
579
+ if env == "ide":
580
+ return _hr_frame_stack(bodies_ide)
581
+ # Terminal: regressions are big news — blank line between adjacent banners.
582
+ return "\n\n".join(bodies_terminal)
583
+
584
+
585
+ def _streak_reward_block(rewards: list, env: str = "terminal") -> str:
586
+ """Pre-rendered mid-streak reward banners. Small wins — tighter than
587
+ graduations so they feel like dopamine pulses, not ceremonies.
588
+ Direction-aware glyph: positive→↑, negative→↓."""
589
+ bodies_terminal: list[str] = []
590
+ bodies_ide: list[str] = []
591
+ for r in rewards:
592
+ if not isinstance(r, dict):
593
+ continue
594
+ rid = r.get("id", "?")
595
+ rname = r.get("name") or rid
596
+ streak = int(r.get("streak", 0))
597
+ target = int(r.get("target", 5))
598
+ xp = int(r.get("xp_awarded", 1))
599
+ direction = r.get("direction", "negative")
600
+ filled = _streak_bar(streak, target)
601
+ arrow = "↑" if direction == "positive" else "↓"
602
+ signed_xp = f"+{xp}" if direction == "positive" else f"-{xp}"
603
+ bodies_terminal.append(
604
+ f"> {arrow} `{rname}` `{filled}` {streak}/{target} · `{signed_xp}`"
605
+ )
606
+ bodies_ide.append(
607
+ f"{arrow} `{rname}` · `{filled} {streak}/{target}` · `{signed_xp}`"
608
+ )
609
+ if not bodies_terminal:
610
+ return ""
611
+ if env == "ide":
612
+ return _hr_frame_stack(bodies_ide)
613
+ # Terminal: stacked with NO blank lines between (small wins, kept tight).
614
+ return "\n".join(bodies_terminal)
615
+
616
+
617
+ def _graduation_block(grads: list, env: str = "terminal") -> str:
618
+ """Pre-rendered graduation banners. Direction-picked in Python:
619
+ positive→MASTERED 🌟, negative→GRADUATED ⚡️. Body sentence is
620
+ templated, not model-filled."""
621
+ positive_sentence = (
622
+ "5 consecutive Coach insights runs detected this habit — "
623
+ "it's now a core strength."
624
+ )
625
+ negative_sentence = (
626
+ "5 clean Coach insights runs in a row — weakness retired."
627
+ )
628
+ full_bar = _streak_bar(GRADUATION_STREAK_TARGET, GRADUATION_STREAK_TARGET)
629
+ bodies_terminal: list[str] = []
630
+ bodies_ide: list[str] = []
631
+ for g in grads:
632
+ if not isinstance(g, dict):
633
+ continue
634
+ gid = g.get("id", "?")
635
+ gname = g.get("name") or gid
636
+ direction = g.get("direction", "negative")
637
+ if direction == "positive":
638
+ sentence = positive_sentence
639
+ term_head = f"> 🎓🌟 **MASTERED: {gname}** `+5 XP`"
640
+ ide_head = f"🎓 **MASTERED** 🌟 — `{gname}` · `+5 XP`"
641
+ else:
642
+ sentence = negative_sentence
643
+ term_head = f"> 🎓⚡️ **GRADUATED: {gname}** `+5 XP`"
644
+ ide_head = f"🎓 **GRADUATED** ⚡ — `{gname}` · `+5 XP`"
645
+ bodies_terminal.append(f"{term_head}\n> `{full_bar}` — {sentence}")
646
+ bodies_ide.append(f"{ide_head}\n`{full_bar}` {sentence}")
647
+ if not bodies_terminal:
648
+ return ""
649
+ if env == "ide":
650
+ return _hr_frame_stack(bodies_ide)
651
+ # Terminal: graduations are big — blank line between adjacent banners.
652
+ return "\n\n".join(bodies_terminal)
653
+
654
+
655
+ def _marker_predates_today(payload: dict | None, now: datetime) -> bool:
656
+ """True if a consumed-marker payload's oldest unconsumed entry was
657
+ written on a calendar date earlier than `now`. Drives the catch-up
658
+ framing line.
659
+
660
+ Reads `oldest_entry_at` (preserved across appends in marker_io as of
661
+ v0.4.2) so a marker that today's `/coach-insights` extended with new
662
+ items still surfaces catch-up framing for the carried-over entries.
663
+ Falls back to `created_at` for legacy markers written before v0.4.2;
664
+ those will undercount catch-up by at most one append cycle until
665
+ they're re-written or expire."""
666
+ if not isinstance(payload, dict):
667
+ return False
668
+ timestamp_str = payload.get("oldest_entry_at") or payload.get("created_at")
669
+ created_dt = _parse_iso(timestamp_str)
670
+ if not created_dt:
671
+ return False
672
+ try:
673
+ return created_dt.date() < now.date()
674
+ except Exception:
675
+ return False
676
+
677
+
678
+ def _assemble_celebrate_block(
679
+ *,
680
+ grads: list,
681
+ regs: list,
682
+ streak_rewards: list,
683
+ levelup: dict | None,
684
+ caught_up: bool,
685
+ env: str = "terminal",
686
+ theme: str = "craft",
687
+ now: datetime | None = None,
688
+ streak_oldest: datetime | None = None,
689
+ ) -> str | None:
690
+ """Return the full <coach-celebrate>...</coach-celebrate> block, or
691
+ None if no events. Applies per-pattern dedup (highest streak wins)
692
+ and graduation-suppresses-tick filtering before rendering.
693
+
694
+ Bespoke themes (forge / ocean / skyrim / military / hacker) replace
695
+ the streak + level-up sections with theme-specific shapes when env
696
+ is terminal. The seven default themes always render the historical
697
+ shape — they're guaranteed byte-identical to the pre-feature output."""
698
+ # Pass A: collapse same-pattern duplicates in streak_rewards. Two
699
+ # /coach-insights runs that ticked the same pattern can both leave
700
+ # markers in .pending_streak_rewards — show only the most
701
+ # informative tick (highest streak).
702
+ by_id: dict[str, dict] = {}
703
+ for s in streak_rewards or []:
704
+ if not isinstance(s, dict):
705
+ continue
706
+ sid = s.get("id")
707
+ if not sid:
708
+ continue
709
+ prev = by_id.get(sid)
710
+ if prev is None or int(s.get("streak", 0)) > int(prev.get("streak", 0)):
711
+ by_id[sid] = s
712
+ streak_rewards = list(by_id.values())
713
+
714
+ # Pass B: graduations subsume same-batch ticks for the same pattern.
715
+ graduated_ids = {g.get("id") for g in (grads or []) if isinstance(g, dict) and g.get("id")}
716
+ streak_rewards = [s for s in streak_rewards if s.get("id") not in graduated_ids]
717
+
718
+ has_any = bool(levelup) or bool(grads) or bool(regs) or bool(streak_rewards)
719
+ if not has_any:
720
+ return None
721
+
722
+ # Bespoke-theme dispatch. Terminal-only — IDE rendering stays on the
723
+ # default HR-framed shape because bespoke ASCII frames clash with
724
+ # WebView typography. Failure path: any exception falls through to
725
+ # default rendering, preserving the "hook crash never breaks a session"
726
+ # invariant.
727
+ if (
728
+ _BESPOKE_OK
729
+ and theme in _BESPOKE_THEMES
730
+ and env == "terminal"
731
+ ):
732
+ try:
733
+ grads_block = _graduation_block(grads, env=env) if grads else ""
734
+ regs_block = _regression_block(regs, env=env) if regs else ""
735
+ bespoke = _render_celebrate_for_theme(
736
+ theme,
737
+ streak_rewards=streak_rewards,
738
+ levelup=levelup,
739
+ grads_block=grads_block,
740
+ regs_block=regs_block,
741
+ now=now or datetime.now(timezone.utc),
742
+ streak_oldest=streak_oldest,
743
+ caught_up=caught_up,
744
+ )
745
+ if bespoke is not None:
746
+ return bespoke
747
+ except Exception:
748
+ pass # fall through to default rendering
749
+
750
+ out: list[str] = ["<coach-celebrate>"]
751
+ out.append(
752
+ "The block below is a pre-rendered set of milestone banners. "
753
+ "Render this block VERBATIM at the very top of your next response, "
754
+ "BEFORE any other content, then continue with the user's request. "
755
+ "Do NOT re-interpret labels, swap directions, change emoji, or "
756
+ "substitute slugs for names — every character is intentional and "
757
+ "pinned by tests."
758
+ )
759
+ if caught_up:
760
+ out.append("")
761
+ out.append(
762
+ "Milestones earned across earlier sessions — not from the "
763
+ "command you just typed."
764
+ )
765
+ out.append("")
766
+
767
+ if regs:
768
+ out.append(_regression_block(regs, env=env))
769
+ if streak_rewards:
770
+ if regs:
771
+ out.append("")
772
+ out.append(_streak_reward_block(streak_rewards, env=env))
773
+ if grads:
774
+ if regs or streak_rewards:
775
+ out.append("")
776
+ out.append(_graduation_block(grads, env=env))
777
+ if levelup:
778
+ if regs or streak_rewards or grads:
779
+ out.append("")
780
+ out.append(_levelup_block(levelup, env=env))
781
+
782
+ out.append("</coach-celebrate>")
783
+ return "\n".join(out)
784
+
785
+
786
+ # -----------------------------------------------------------------------------
787
+ # Ambient tip scheduler
788
+ # -----------------------------------------------------------------------------
789
+
790
+ def _parse_iso(s: str | None) -> datetime | None:
791
+ if not s:
792
+ return None
793
+ try:
794
+ return datetime.fromisoformat(s.replace("Z", "+00:00"))
795
+ except Exception:
796
+ return None
797
+
798
+
799
+ def _tip_state_lock_path() -> Path:
800
+ return TIP_STATE.with_suffix(TIP_STATE.suffix + ".lock")
801
+
802
+
803
+ @contextmanager
804
+ def _locked_tip_state():
805
+ TIP_STATE.parent.mkdir(parents=True, exist_ok=True)
806
+ with open(_tip_state_lock_path(), "w") as lock_fh:
807
+ try:
808
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
809
+ except Exception as exc:
810
+ if os.environ.get("COACH_DEBUG"):
811
+ print(f"coach: tip-state flock failed: {exc}", file=sys.stderr)
812
+ yield
813
+
814
+
815
+ def _load_tip_state_unlocked() -> dict:
816
+ if not TIP_STATE.exists():
817
+ return {}
818
+ try:
819
+ data = json.loads(TIP_STATE.read_text())
820
+ return data if isinstance(data, dict) else {}
821
+ except Exception:
822
+ return {}
823
+
824
+
825
+ def _load_tip_state() -> dict:
826
+ try:
827
+ with _locked_tip_state():
828
+ return _load_tip_state_unlocked()
829
+ except Exception:
830
+ return {}
831
+
832
+
833
+ def _atomic_write_text(path: Path, content: str) -> None:
834
+ """Write `content` to `path` atomically (tempfile + os.replace).
835
+
836
+ A crash mid-write leaves either the old file or no file — never a
837
+ truncated one. Crashed-to-empty would lose cooldowns / pending ACKs
838
+ on the next read; tempfile+replace prevents that.
839
+ """
840
+ path.parent.mkdir(parents=True, exist_ok=True)
841
+ fd, tmp_name = tempfile.mkstemp(prefix="." + path.name + ".", suffix=".tmp", dir=str(path.parent))
842
+ try:
843
+ with os.fdopen(fd, "w") as fh:
844
+ fh.write(content)
845
+ fh.flush()
846
+ os.fsync(fh.fileno())
847
+ os.replace(tmp_name, path)
848
+ except Exception:
849
+ try:
850
+ os.unlink(tmp_name)
851
+ except Exception:
852
+ pass
853
+ raise
854
+
855
+
856
+ def _save_tip_state_unlocked(state: dict) -> None:
857
+ try:
858
+ _atomic_write_text(TIP_STATE, json.dumps(state))
859
+ except Exception:
860
+ pass
861
+
862
+
863
+ def _save_tip_state(state: dict) -> None:
864
+ try:
865
+ with _locked_tip_state():
866
+ _save_tip_state_unlocked(state)
867
+ except Exception:
868
+ pass
869
+
870
+
871
+ def _write_log_record(record: dict) -> None:
872
+ """Append one redacted operational event to log.ndjson.
873
+
874
+ The log is intentionally allowlisted metadata only: no transcript text,
875
+ no tool command contents, no examples, and no generated tip prose. The
876
+ file is trimmed on each write so it stays bounded even in long-running
877
+ installs.
878
+
879
+ Concurrent hook invocations (two sessions firing at once) used to drop
880
+ records here because the read-modify-write was not serialized. We now
881
+ hold an exclusive flock on a sibling lockfile around the whole r-m-w
882
+ and use an atomic tempfile+replace for the write itself.
883
+ """
884
+ try:
885
+ safe: dict[str, object] = {}
886
+ for key, value in record.items():
887
+ if isinstance(value, (str, int, float, bool)) or value is None:
888
+ safe[key] = value
889
+ line = json.dumps(safe, sort_keys=True)
890
+ LOG_PATH.parent.mkdir(parents=True, exist_ok=True)
891
+ lock_path = LOG_PATH.with_suffix(LOG_PATH.suffix + ".lock")
892
+ with open(lock_path, "w") as lock_fh:
893
+ try:
894
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
895
+ except Exception as exc:
896
+ # flock failure (e.g. unsupported fs) is rare but real; surface
897
+ # it under COACH_DEBUG so the silent unserialized-write doesn't
898
+ # disappear during diagnostics.
899
+ if os.environ.get("COACH_DEBUG"):
900
+ print(
901
+ f"coach: log flock failed: {exc}",
902
+ file=sys.stderr,
903
+ )
904
+ try:
905
+ existing = LOG_PATH.read_text().splitlines()
906
+ except Exception:
907
+ existing = []
908
+ kept = existing[-max(LOG_MAX_LINES - 1, 0):]
909
+ _atomic_write_text(LOG_PATH, "\n".join(kept + [line]) + "\n")
910
+ except Exception:
911
+ pass
912
+
913
+
914
+ def _log_tip_fired(tip: dict, spec: dict | None, now: datetime) -> None:
915
+ record = {
916
+ "ts": now.isoformat(),
917
+ "event": "tip_fired",
918
+ "tip_id": tip.get("id"),
919
+ "entry_id": tip.get("entry_id"),
920
+ "kind": tip.get("kind"),
921
+ "tier": tip.get("tier"),
922
+ }
923
+ if isinstance(spec, dict):
924
+ record["action"] = spec.get("action")
925
+ record["xp"] = int(spec.get("xp", 0) or 0)
926
+ if spec.get("skill_id"):
927
+ record["skill_id"] = str(spec.get("skill_id")).lstrip("/")
928
+ _write_log_record(record)
929
+
930
+
931
+ def _log_tip_completed(tip_id: str, entry: dict, now: datetime) -> None:
932
+ spec = entry.get("spec") or {}
933
+ record = {
934
+ "ts": now.isoformat(),
935
+ "event": "tip_completed",
936
+ "tip_id": tip_id,
937
+ "entry_id": entry.get("entry_id"),
938
+ "kind": entry.get("kind"),
939
+ }
940
+ if isinstance(spec, dict):
941
+ record["action"] = spec.get("action")
942
+ record["xp"] = int(spec.get("xp", 0) or 0)
943
+ if spec.get("skill_id"):
944
+ record["skill_id"] = str(spec.get("skill_id")).lstrip("/")
945
+ _write_log_record(record)
946
+
947
+
948
+ def _load_profile() -> dict:
949
+ if not PROFILE.exists():
950
+ return {}
951
+ try:
952
+ import yaml
953
+ except Exception:
954
+ return {}
955
+ try:
956
+ data = yaml.safe_load(PROFILE.read_text()) or {}
957
+ return data if isinstance(data, dict) else {}
958
+ except Exception:
959
+ return {}
960
+
961
+
962
+ # Generic noise words stripped during tokenization — too common to signal
963
+ # anything. Kept small so we don't over-filter skill descriptions.
964
+ _NOISE_WORDS = frozenset({
965
+ "the", "and", "for", "with", "use", "uses", "user", "you", "your",
966
+ "that", "this", "when", "from", "into", "code", "using", "about",
967
+ "file", "tool", "tools", "like", "other", "help", "some", "any", "all",
968
+ "are", "not", "one", "two", "only", "run", "runs", "call", "calls",
969
+ "task", "task.",
970
+ })
971
+
972
+ # Tokens that pass tokenization but are too common across dev work to count
973
+ # as "distinctive" relevance overlap. Seeing "test" or "file" shared between
974
+ # a skill's description and the session signal doesn't tell you the skill
975
+ # is actually relevant — almost every session has those tokens. Used by
976
+ # _skill_fits_session's strict overlap check.
977
+ _COMMON_DEV_VOCAB = frozenset({
978
+ # File/path basics
979
+ "src", "lib", "bin", "dir", "path", "main", "app", "pkg", "module",
980
+ "modules", "package", "packages", "root", "repo", "home",
981
+ # Generic dev actions
982
+ "test", "tests", "build", "check", "load", "save", "read", "write",
983
+ "add", "set", "get", "new", "old", "fix", "run", "init", "update",
984
+ "create", "delete", "remove",
985
+ # Generic objects
986
+ "data", "func", "function", "method", "class", "var", "const",
987
+ "name", "value", "key", "item", "list", "map", "dict", "obj",
988
+ "object", "args", "arg", "opts", "cfg", "config",
989
+ # Generic flow
990
+ "log", "logs", "out", "output", "input", "error", "fail", "pass",
991
+ "success", "failed", "passed", "done", "start", "end", "stop",
992
+ # Generic adjectives
993
+ "main", "tmp", "temp", "backup", "old", "new", "local", "remote",
994
+ "public", "private", "internal", "shared",
995
+ # Common extensions (low signal individually — need another token too)
996
+ "py", "ts", "js", "md", "sh", "go", "rs", "json", "yaml", "yml",
997
+ "txt", "html", "css", "xml", "env",
998
+ # Skill-catalog meta-words. These appear in nearly every skill
999
+ # description ("Official X skill for Y", "the Y API", etc.) so they
1000
+ # don't distinguish one skill from another, AND they commonly appear
1001
+ # in any meta-discussion about Claude Code / skills / the coach
1002
+ # itself — which would otherwise false-positive every skill at once.
1003
+ "skill", "skills", "official", "api", "framework", "library",
1004
+ "plugin", "plugins",
1005
+ # Cross-project plumbing — hardware, hosts, protocols, and generic
1006
+ # workflow verbs. These legitimately span unrelated projects: a
1007
+ # Blender-avatar skill and an AI-agents skill can both reference
1008
+ # "jetson" or "deploy" without the projects being related. Treat
1009
+ # them like `test`/`file`: real words, but not proof of relevance.
1010
+ "jetson", "gpu", "cpu", "arm", "x86", "pi", "raspberrypi",
1011
+ "aws", "gcp", "azure", "vercel", "heroku", "cloudflare",
1012
+ "ssh", "scp", "http", "https", "grpc", "rest", "tcp", "udp",
1013
+ "iterate", "iterates", "iteration", "deploy", "deploys", "deployed",
1014
+ "deployment", "export", "exports", "exported", "compare", "compares",
1015
+ "screenshot", "screenshots", "reference", "references",
1016
+ })
1017
+
1018
+ _TOKEN_RE = re.compile(r"[a-z0-9]{3,}") # runs of ≥3 alphanumerics — file.ts → ['file', 'ts']
1019
+
1020
+
1021
+ def _tokenize(s: str) -> set[str]:
1022
+ """Lowercase → set of alphanumeric tokens ≥3 chars, minus noise words.
1023
+ Splits on every non-alphanumeric (including `.`, `-`, `/`) so paths and
1024
+ hyphenated names contribute all their sub-tokens, not just one blob.
1025
+ Also yields 2-char file suffixes (`ts`, `js`, `py`, `md`) which the
1026
+ regex would drop — those are high-signal so worth keeping explicitly."""
1027
+ if not s:
1028
+ return set()
1029
+ low = s.lower()
1030
+ toks = set(_TOKEN_RE.findall(low))
1031
+ # Preserve a few informative 2-char extension/code tokens that slip past
1032
+ # the ≥3-char floor (file extensions + common acronyms).
1033
+ for short in ("ts", "js", "py", "md", "sh", "rs", "go", "ui", "ai", "ml", "db"):
1034
+ if re.search(rf"\b{short}\b", low):
1035
+ toks.add(short)
1036
+ return {t for t in toks if t not in _NOISE_WORDS}
1037
+
1038
+
1039
+ def _iter_user_texts(lines: list[str], max_msgs: int):
1040
+ """Yield text content from the most recent `max_msgs` user messages in
1041
+ a transcript. Handles both string content and list content (where text
1042
+ blocks live alongside tool_result / image blocks). Skips messages whose
1043
+ content blocks are tool_result-only (no `text` items) so tool output
1044
+ isn't conflated with what the user actually typed."""
1045
+ count = 0
1046
+ for line in reversed(lines):
1047
+ if count >= max_msgs:
1048
+ break
1049
+ try:
1050
+ obj = json.loads(line)
1051
+ except Exception:
1052
+ continue
1053
+ if obj.get("type") != "user":
1054
+ continue
1055
+ msg = obj.get("message") or {}
1056
+ if msg.get("role") != "user":
1057
+ continue
1058
+ content = msg.get("content")
1059
+ if isinstance(content, str):
1060
+ count += 1
1061
+ yield content
1062
+ continue
1063
+ if not isinstance(content, list):
1064
+ continue
1065
+ texts = []
1066
+ saw_nontool = False
1067
+ for block in content:
1068
+ if not isinstance(block, dict):
1069
+ continue
1070
+ btype = block.get("type")
1071
+ if btype == "text":
1072
+ saw_nontool = True
1073
+ t = block.get("text")
1074
+ if isinstance(t, str):
1075
+ texts.append(t)
1076
+ if saw_nontool:
1077
+ count += 1
1078
+ if texts:
1079
+ yield "\n".join(texts)
1080
+
1081
+
1082
+ def _find_git_root_name(cwd: str | None) -> str | None:
1083
+ """Walk upward from cwd until a ``.git`` entry is found. Return the
1084
+ containing dir's name, or None if no git root exists in the
1085
+ ancestor chain. Handles subdirectory cwds where the last path
1086
+ component doesn't name the project (e.g. monorepo packages).
1087
+
1088
+ A ``.git`` found exactly at ``$HOME`` is intentionally ignored —
1089
+ many users keep dotfiles in a home-rooted repo, which would
1090
+ otherwise anchor every non-nested-repo cwd to the username (e.g.
1091
+ ``alice``) and let any skill description containing that token
1092
+ false-positive past the project filter. The walk continues past
1093
+ ``$HOME`` toward root in that case and returns None unless a
1094
+ DIFFERENT ancestor repo is found.
1095
+
1096
+ Silent on any I/O or permission error — the hook path must never
1097
+ raise. Cost: O(depth) stat calls, typically ≤10."""
1098
+ if not cwd:
1099
+ return None
1100
+ try:
1101
+ p = Path(cwd).resolve()
1102
+ home = Path.home().resolve()
1103
+ except Exception:
1104
+ return None
1105
+ try:
1106
+ while True:
1107
+ if (p / ".git").exists() and p != home:
1108
+ return p.name
1109
+ if p == p.parent:
1110
+ return None
1111
+ p = p.parent
1112
+ except Exception:
1113
+ return None
1114
+
1115
+
1116
+ def _session_signal(
1117
+ transcript: Path | None,
1118
+ cwd: str | None,
1119
+ max_events: int = 100,
1120
+ max_user_msgs: int = 10,
1121
+ ) -> tuple[set[str], set[str]]:
1122
+ """Build a lowercase-keyword signal of what the current session is about.
1123
+
1124
+ Returns ``(signal, project_anchors)``:
1125
+ - ``signal``: flat bag of tokens drawn from cwd path, recent user
1126
+ messages, and recent tool_use events.
1127
+ - ``project_anchors``: a small high-signal set identifying the
1128
+ current project. Populated from two sources, unioned:
1129
+ 1. The tokens inside the cwd's last path component
1130
+ (``~/Desktop/dev/widget`` → ``{widget}``).
1131
+ 2. The tokens inside the nearest git-repo root's dir name,
1132
+ via ``_find_git_root_name``. This handles the
1133
+ subdirectory-cwd case: from
1134
+ ``~/Desktop/dev/widget/packages/core`` the last-component
1135
+ is ``{core, packages}``, but walking up to the ``.git``
1136
+ root still yields ``{widget}`` so project-scoped skills
1137
+ keep working.
1138
+ A skill description that literally names the project dir, or
1139
+ declares the project via frontmatter, is almost certainly on-
1140
+ topic for work inside that project; the anchor set is what
1141
+ ``_skill_fits_session`` uses for both the short-circuit pass
1142
+ and the project-scoped gate.
1143
+
1144
+ Pulls ``signal`` tokens from, in order of decreasing strength:
1145
+ - Recent user messages (last `max_user_msgs`) — strongest domain signal
1146
+ - Recent tool_use events (last `max_events`) — what the session was doing
1147
+ - cwd path components — baseline context even on a fresh session
1148
+ """
1149
+ signal: set[str] = set()
1150
+ anchors: set[str] = set()
1151
+ if cwd:
1152
+ parts = [p for p in Path(cwd).parts if p and p not in ("/", ".")]
1153
+ if parts:
1154
+ anchors |= _tokenize(parts[-1].replace("-", " ").replace("_", " "))
1155
+ git_root = _find_git_root_name(cwd)
1156
+ if git_root:
1157
+ anchors |= _tokenize(git_root.replace("-", " ").replace("_", " "))
1158
+ for p in parts[-3:]:
1159
+ signal |= _tokenize(p.replace("-", " ").replace("_", " "))
1160
+ if transcript is None:
1161
+ return signal, anchors
1162
+ try:
1163
+ with transcript.open(errors="replace") as fh:
1164
+ # Stream-read only the last ~4 lines per tool_use we care about,
1165
+ # so a multi-MB transcript doesn't allocate the full file every
1166
+ # turn. CLAUDE.md's transcript-handling rule explicitly forbids
1167
+ # readlines() here. _session_behavior_evidence below uses the
1168
+ # same deque pattern for the same reason.
1169
+ tail = list(deque(fh, maxlen=max_events * 4))
1170
+ except Exception:
1171
+ return signal, anchors
1172
+
1173
+ # Highest-signal source: what the user actually typed.
1174
+ for text in _iter_user_texts(tail, max_user_msgs):
1175
+ signal |= _tokenize(text)
1176
+
1177
+ event_count = 0
1178
+ for line in reversed(tail):
1179
+ if event_count >= max_events:
1180
+ break
1181
+ try:
1182
+ obj = json.loads(line)
1183
+ except Exception:
1184
+ continue
1185
+ msg = obj.get("message") or {}
1186
+ content = msg.get("content")
1187
+ if not isinstance(content, list):
1188
+ continue
1189
+ for item in content:
1190
+ if not isinstance(item, dict) or item.get("type") != "tool_use":
1191
+ continue
1192
+ event_count += 1
1193
+ name = item.get("name", "")
1194
+ inp = item.get("input") or {}
1195
+ if name == "Bash":
1196
+ cmd = str(inp.get("command") or "")
1197
+ words = cmd.split()[:3]
1198
+ signal |= _tokenize(" ".join(words))
1199
+ elif name in ("Edit", "Write", "MultiEdit"):
1200
+ fp = str(inp.get("file_path") or "")
1201
+ if fp:
1202
+ parts = Path(fp).parts
1203
+ suffix = Path(fp).suffix.lstrip(".")
1204
+ if suffix:
1205
+ signal.add(suffix.lower())
1206
+ if parts:
1207
+ signal |= _tokenize(parts[-1])
1208
+ if len(parts) >= 2:
1209
+ signal |= _tokenize(parts[-2])
1210
+ elif name in ("SlashCommand", "Skill"):
1211
+ sid = str(inp.get("command") or inp.get("skill") or "").lstrip("/")
1212
+ if sid:
1213
+ signal |= _tokenize(sid.replace("-", " ").replace("/", " "))
1214
+ return signal, anchors
1215
+
1216
+
1217
+ def _session_behavior_evidence(transcript: Path | None, max_events: int = 120) -> dict:
1218
+ """Summarize recent tool-use behavior for session-gated profile tips."""
1219
+ ev = {
1220
+ "edit_count": 0,
1221
+ "write_count": 0,
1222
+ "read_count": 0,
1223
+ "search_count": 0,
1224
+ "agent_count": 0,
1225
+ "skill_count": 0,
1226
+ "test_count": 0,
1227
+ "commit_count": 0,
1228
+ "rm_rf_count": 0,
1229
+ "first_edit_idx": None,
1230
+ "first_plan_idx": None,
1231
+ "first_read_idx": None,
1232
+ "first_search_idx": None,
1233
+ "last_edit_idx": None,
1234
+ "last_test_idx": None,
1235
+ "last_commit_idx": None,
1236
+ }
1237
+ if transcript is None:
1238
+ return ev
1239
+ tool_uses: list[dict] = []
1240
+ try:
1241
+ with transcript.open(errors="replace") as fh:
1242
+ lines = deque(fh, maxlen=max_events * 4)
1243
+ except Exception:
1244
+ return ev
1245
+ for line in lines:
1246
+ try:
1247
+ obj = json.loads(line)
1248
+ except Exception:
1249
+ continue
1250
+ msg = obj.get("message") or {}
1251
+ content = msg.get("content")
1252
+ if not isinstance(content, list):
1253
+ continue
1254
+ for item in content:
1255
+ if isinstance(item, dict) and item.get("type") == "tool_use":
1256
+ tool_uses.append(item)
1257
+ if len(tool_uses) > max_events:
1258
+ tool_uses = tool_uses[-max_events:]
1259
+
1260
+ for idx, item in enumerate(tool_uses):
1261
+ name = item.get("name", "")
1262
+ inp = item.get("input") or {}
1263
+ if name in ("Edit", "MultiEdit", "Write"):
1264
+ if name == "Write":
1265
+ ev["write_count"] += 1
1266
+ else:
1267
+ ev["edit_count"] += 1
1268
+ if ev["first_edit_idx"] is None:
1269
+ ev["first_edit_idx"] = idx
1270
+ ev["last_edit_idx"] = idx
1271
+ elif name in ("Plan", "TaskCreate", "TodoWrite", "ExitPlanMode"):
1272
+ if ev["first_plan_idx"] is None:
1273
+ ev["first_plan_idx"] = idx
1274
+ elif name == "Read":
1275
+ ev["read_count"] += 1
1276
+ if ev["first_read_idx"] is None:
1277
+ ev["first_read_idx"] = idx
1278
+ elif name in ("Grep", "Glob"):
1279
+ ev["search_count"] += 1
1280
+ if ev["first_search_idx"] is None:
1281
+ ev["first_search_idx"] = idx
1282
+ elif name == "Agent":
1283
+ ev["agent_count"] += 1
1284
+ elif name in ("SlashCommand", "Skill"):
1285
+ ev["skill_count"] += 1
1286
+ if _tool_use_matches_action(item, "test_run"):
1287
+ ev["test_count"] += 1
1288
+ ev["last_test_idx"] = idx
1289
+ if _tool_use_matches_action(item, "commit"):
1290
+ ev["commit_count"] += 1
1291
+ ev["last_commit_idx"] = idx
1292
+ if name == "Bash" and re.search(r"\brm\s+-rf?\b", str(inp.get("command") or "")):
1293
+ ev["rm_rf_count"] += 1
1294
+ return ev
1295
+
1296
+
1297
+ def _idx_before(a: int | None, b: int | None) -> bool:
1298
+ return a is not None and b is not None and a <= b
1299
+
1300
+
1301
+ def _idx_after(a: int | None, b: int | None) -> bool:
1302
+ return a is not None and b is not None and a > b
1303
+
1304
+
1305
+ def _behavior_tip_is_session_eligible(entry: dict, evidence: dict | None) -> bool:
1306
+ """Conservative current-session gates for profile-derived behavior tips."""
1307
+ if evidence is None:
1308
+ return True
1309
+ eid = str(entry.get("id") or entry.get("name") or "").lower()
1310
+ direction = entry.get("direction", "negative")
1311
+ edits = int(evidence.get("edit_count", 0) or 0) + int(evidence.get("write_count", 0) or 0)
1312
+ reads = int(evidence.get("read_count", 0) or 0)
1313
+ searches = int(evidence.get("search_count", 0) or 0)
1314
+ tests = int(evidence.get("test_count", 0) or 0)
1315
+ commits = int(evidence.get("commit_count", 0) or 0)
1316
+ agents = int(evidence.get("agent_count", 0) or 0)
1317
+
1318
+ first_edit = evidence.get("first_edit_idx")
1319
+ first_plan = evidence.get("first_plan_idx")
1320
+ first_read = evidence.get("first_read_idx")
1321
+ first_search = evidence.get("first_search_idx")
1322
+ last_edit = evidence.get("last_edit_idx")
1323
+ last_test = evidence.get("last_test_idx")
1324
+ last_commit = evidence.get("last_commit_idx")
1325
+
1326
+ if "commit-without-testing" in eid:
1327
+ return commits >= 1 and edits >= 1 and (last_test is None or _idx_after(last_commit, last_test))
1328
+ if "edits-without-testing" in eid or "without-test" in eid or "untested" in eid:
1329
+ return edits >= 1 and (last_test is None or _idx_after(last_edit, last_test))
1330
+ if "skipped-search-tools" in eid or "skipped-search" in eid:
1331
+ return reads >= 8 and searches <= 1
1332
+ if "under-planning" in eid or "under-planning" in str(entry.get("name") or "").lower():
1333
+ return edits >= 2 and (first_plan is None or _idx_after(first_plan, first_edit))
1334
+ if "exploration-without-landing" in eid:
1335
+ return reads >= 8 and edits == 0
1336
+ if "heavy-agent-delegation" in eid:
1337
+ return agents >= 4
1338
+
1339
+ if direction == "positive":
1340
+ if "tests-after-edits" in eid or "small-batch-verify" in eid:
1341
+ return edits >= 1 and tests >= 1 and _idx_after(last_test, last_edit)
1342
+ if "plans-before-edits" in eid:
1343
+ return edits >= 1 and _idx_before(first_plan, first_edit)
1344
+ if "commits-gated-by-tests" in eid:
1345
+ return commits >= 1 and tests >= 1 and _idx_before(last_test, last_commit)
1346
+ if "search-before-reading" in eid:
1347
+ return reads >= 1 and searches >= 1 and _idx_before(first_search, first_read)
1348
+ if "safe-git-hygiene" in eid:
1349
+ return commits >= 1 and int(evidence.get("rm_rf_count", 0) or 0) == 0
1350
+ if "effective-skill-use" in eid:
1351
+ return int(evidence.get("skill_count", 0) or 0) >= 1
1352
+
1353
+ return True
1354
+
1355
+
1356
+ def _skill_fits_session(
1357
+ skill_hint: dict,
1358
+ session_signal: set[str],
1359
+ project_anchors: set[str] | frozenset[str] = frozenset(),
1360
+ ) -> bool:
1361
+ """Strict relevance check — skill hints only pass if there's genuine
1362
+ domain overlap with the session.
1363
+
1364
+ Design: the cost of firing an off-topic skill tip is high (visibly
1365
+ incoherent reward line like a frontend-animation skill during a backend
1366
+ debugging session), while the cost of dropping a skill tip is low (another turn
1367
+ always comes along). Default to SKIP when uncertain.
1368
+
1369
+ Two gates run in order:
1370
+
1371
+ 1. **Project-scoped gate** (if the skill declares ``projects: [...]``
1372
+ in SKILL.md frontmatter). The skill is bound to a project set;
1373
+ it fires only when one of those projects matches the current
1374
+ cwd's anchor tokens. Out-of-project → skip, regardless of any
1375
+ token overlap. Missing anchors (unknown cwd) → skip, to stay
1376
+ conservative. In-project → still require some topic overlap so
1377
+ a project-scoped skill doesn't fire on every in-project turn.
1378
+
1379
+ 2. **Overlap gate** (for untagged skills). Passes iff:
1380
+ a. Session signal has ≥3 tokens (we know what the session is
1381
+ about), AND
1382
+ b. Skill has extractable keywords, AND
1383
+ c. Overlap either (i) touches a ``project_anchors`` token —
1384
+ the skill's description literally names the project dir —
1385
+ or (ii) contains ≥2 "distinctive" tokens (not in
1386
+ _COMMON_DEV_VOCAB).
1387
+
1388
+ A single distinctive token is intentionally NOT enough — words like
1389
+ `jetson` or `ssh` are distinctive in the vocabulary sense but
1390
+ span unrelated projects in the real world.
1391
+ """
1392
+ desc = (skill_hint.get("short_tip") or skill_hint.get("description") or "")
1393
+ sid = str(skill_hint.get("id") or "").replace("-", " ").replace("/", " ")
1394
+ skill_kw = _tokenize(desc) | _tokenize(sid)
1395
+ if not skill_kw:
1396
+ return False # nothing to match against → skip
1397
+
1398
+ # Gate 1: project-scoped skills must belong to the current project.
1399
+ skill_projects = skill_hint.get("projects") or []
1400
+ if skill_projects:
1401
+ skill_project_tokens: set[str] = set()
1402
+ for p in skill_projects:
1403
+ skill_project_tokens |= _tokenize(
1404
+ str(p).replace("-", " ").replace("_", " "))
1405
+ if not project_anchors:
1406
+ return False # skill scoped, we don't know where we are
1407
+ if not (skill_project_tokens & project_anchors):
1408
+ return False # scoped to a different project
1409
+ # In-project: require some topic overlap so a widget-scoped
1410
+ # skill doesn't fire on every widget turn regardless of what
1411
+ # the user is actually doing. Crucially, strip the project-
1412
+ # name tokens from the overlap before counting — otherwise
1413
+ # the skill name literally containing `widget` would satisfy
1414
+ # the topic check for free (circular: project-matched token
1415
+ # re-used as topic evidence).
1416
+ topical_overlap = (skill_kw & session_signal) - skill_project_tokens - set(project_anchors)
1417
+ return bool(topical_overlap)
1418
+
1419
+ # Gate 2: untagged skills — prior logic, unchanged.
1420
+ if len(session_signal) < 3:
1421
+ return False # uncertain → skip skills; weaknesses/strengths still fire
1422
+ # Project-anchor shortcut: skill names the current project dir.
1423
+ if project_anchors and (skill_kw & project_anchors):
1424
+ return True
1425
+ overlap = skill_kw & session_signal
1426
+ if not overlap:
1427
+ return False
1428
+ distinctive = overlap - _COMMON_DEV_VOCAB
1429
+ return len(distinctive) >= 2
1430
+
1431
+
1432
+ def _build_tip_pool(
1433
+ profile: dict,
1434
+ session_signal: set[str] | None = None,
1435
+ project_anchors: set[str] | frozenset[str] | None = None,
1436
+ behavior_evidence: dict | None = None,
1437
+ ) -> list[dict]:
1438
+ """Build pool of candidate tips: behavioral entries + skill hints.
1439
+
1440
+ When `session_signal` is provided, skill hints are filtered by token
1441
+ overlap with the current session so e.g. an off-topic skill doesn't
1442
+ fire during unrelated work. `project_anchors` (cwd-derived) is passed
1443
+ through to `_skill_fits_session` for the project-name shortcut.
1444
+
1445
+ Escape hatch: ``COACH_ALL_SKILLS=1`` in the environment disables
1446
+ skill-relevance filtering entirely — all hints become eligible.
1447
+ Intended for debugging or for users who want to see suggestions
1448
+ they're actively avoiding via project scoping."""
1449
+ pool: list[dict] = []
1450
+ bypass_filter = os.environ.get("COACH_ALL_SKILLS") == "1"
1451
+
1452
+ for e in profile.get("entries", []) or []:
1453
+ if not isinstance(e, dict):
1454
+ continue
1455
+ if e.get("tier") == "candidate":
1456
+ continue
1457
+ if float(e.get("confidence", 0)) < 0.30:
1458
+ continue
1459
+ nudge = (e.get("tip") or e.get("nudge") or "").strip()
1460
+ if not nudge:
1461
+ continue
1462
+ if not _behavior_tip_is_session_eligible(e, behavior_evidence):
1463
+ continue
1464
+ direction = e.get("direction", "negative")
1465
+ examples = e.get("examples") or []
1466
+ example = str(examples[0]).strip() if isinstance(examples, list) and examples else ""
1467
+ pool.append({
1468
+ "id": f"entry:{e.get('id') or e.get('name')}",
1469
+ "entry_id": e.get("id") or "",
1470
+ "kind": "strength" if direction == "positive" else "weakness",
1471
+ "name": e.get("name") or e.get("id", "pattern"),
1472
+ "nudge": nudge,
1473
+ "example": example[:160],
1474
+ "tier": e.get("tier", "active"),
1475
+ "clean_streak": int(e.get("clean_streak_runs", 0) or 0),
1476
+ "positive_streak": int(e.get("positive_run_streak", 0) or 0),
1477
+ "reward_hint": _effective_reward_hint(e),
1478
+ "confidence": float(e.get("confidence", 0.5) or 0.5),
1479
+ "priority": int(e.get("priority", 1) or 1),
1480
+ })
1481
+
1482
+ for h in profile.get("skill_hints", []) or []:
1483
+ if not isinstance(h, dict) or not h.get("id"):
1484
+ continue
1485
+ desc = (h.get("short_tip") or h.get("description") or "").strip()
1486
+ if not desc:
1487
+ continue
1488
+ if (
1489
+ not bypass_filter
1490
+ and session_signal is not None
1491
+ and not _skill_fits_session(
1492
+ h, session_signal, project_anchors or frozenset()
1493
+ )
1494
+ ):
1495
+ continue
1496
+ pool.append({
1497
+ "id": f"skill:{h['id']}",
1498
+ "entry_id": h["id"],
1499
+ "kind": "skill",
1500
+ "name": f"/{h['id']}",
1501
+ "nudge": desc[:260],
1502
+ "example": "",
1503
+ "tier": "hint",
1504
+ "clean_streak": 0,
1505
+ "confidence": 1.0, # skill hints are always fully confident
1506
+ "priority": 1, # baseline; below probationary weaknesses
1507
+ })
1508
+
1509
+ return pool
1510
+
1511
+
1512
+ def _completion_spec(tip: dict) -> dict | None:
1513
+ """What user-visible action would 'complete' this tip? None = no direct
1514
+ completion path (e.g., graduation-only patterns)."""
1515
+ kind = tip.get("kind")
1516
+ entry_id = tip.get("entry_id") or ""
1517
+ if kind == "skill":
1518
+ return {"action": "skill_invoke", "skill_id": entry_id}
1519
+ if kind in ("weakness", "strength"):
1520
+ hint = tip.get("reward_hint")
1521
+ if isinstance(hint, dict):
1522
+ action = hint.get("action")
1523
+ xp = int(hint.get("xp", 0))
1524
+ if action and xp > 0:
1525
+ return {
1526
+ "action": action,
1527
+ "xp": xp,
1528
+ "description": hint.get("description") or action,
1529
+ }
1530
+ return None
1531
+
1532
+
1533
+ def _tool_use_matches_action(item: dict, action: str, skill_id: str | None = None) -> bool:
1534
+ if _SHARED_SCORING_OK:
1535
+ try:
1536
+ return bool(_shared_matches_action(item, action, skill_id=skill_id))
1537
+ except Exception:
1538
+ return False
1539
+ name = item.get("name", "")
1540
+ inp = item.get("input") or {}
1541
+ if action == "skill_invoke" and name in ("SlashCommand", "Skill"):
1542
+ sid = (inp.get("command") or inp.get("skill") or "").lstrip("/")
1543
+ return bool(sid) and (not skill_id or sid == skill_id.lstrip("/"))
1544
+ if action == "test_run" and name == "Bash":
1545
+ cmd = inp.get("command", "") or ""
1546
+ if re.search(r"pytest\s+.*--co(llect)?-only", cmd):
1547
+ return False
1548
+ return bool(TEST_RE.search(cmd))
1549
+ if action == "commit" and name == "Bash":
1550
+ cmd = inp.get("command", "") or ""
1551
+ return bool(COMMIT_RE.search(cmd))
1552
+ if action == "doc_write" and name in ("Write", "Edit", "MultiEdit"):
1553
+ fp = inp.get("file_path") or ""
1554
+ return isinstance(fp, str) and fp.endswith(".md")
1555
+ return False
1556
+
1557
+
1558
+ def _find_transcript(payload: dict) -> Path | None:
1559
+ """Resolve and confine the transcript_path supplied by the hook payload.
1560
+
1561
+ Defense in depth: Claude Code is the only normal source of this field, but
1562
+ a malicious settings.json or fork could supply an arbitrary path. We
1563
+ require the resolved path to live under ~/.claude/projects/ so a hook
1564
+ payload can't drive reads of unrelated files. Python 3.8 compatible —
1565
+ Path.is_relative_to() is 3.9+, so we use try/except around relative_to().
1566
+ """
1567
+ tp = payload.get("transcript_path") or payload.get("transcriptPath")
1568
+ if not tp:
1569
+ return None
1570
+ try:
1571
+ p = Path(str(tp)).expanduser().resolve()
1572
+ except Exception:
1573
+ return None
1574
+ if not p.exists():
1575
+ return None
1576
+ projects_root = (Path.home() / ".claude" / "projects").resolve()
1577
+ try:
1578
+ p.relative_to(projects_root)
1579
+ except ValueError:
1580
+ return None
1581
+ return p
1582
+
1583
+
1584
+ def _transcript_matches(path: Path, fired_at: datetime, spec: dict) -> bool:
1585
+ """Scan transcript JSONL for a tool_use matching spec after fired_at."""
1586
+ if not path.exists():
1587
+ return False
1588
+ action = spec.get("action")
1589
+ skill_id = (spec.get("skill_id") or "").lstrip("/")
1590
+ try:
1591
+ with path.open() as fh:
1592
+ for line in fh:
1593
+ try:
1594
+ obj = json.loads(line)
1595
+ except Exception:
1596
+ continue
1597
+ ts = _parse_iso(obj.get("timestamp"))
1598
+ if not ts or ts < fired_at:
1599
+ continue
1600
+ msg = obj.get("message") or {}
1601
+ content = msg.get("content")
1602
+ if not isinstance(content, list):
1603
+ continue
1604
+ for item in content:
1605
+ if not isinstance(item, dict):
1606
+ continue
1607
+ if item.get("type") != "tool_use":
1608
+ continue
1609
+ if _tool_use_matches_action(item, action, skill_id=skill_id):
1610
+ return True
1611
+ except Exception:
1612
+ return False
1613
+ return False
1614
+
1615
+
1616
+ def _detect_completions(
1617
+ state: dict, transcript: Path | None
1618
+ ) -> list[tuple[str, dict]]:
1619
+ """Return [(tip_id, entry)] for pending completions satisfied in transcript."""
1620
+ pending = state.get("pending_completions") or {}
1621
+ if not pending or transcript is None:
1622
+ return []
1623
+ completed: list[tuple[str, dict]] = []
1624
+ for tip_id, entry in list(pending.items()):
1625
+ if not isinstance(entry, dict) or entry.get("acknowledged"):
1626
+ continue
1627
+ fired_at = _parse_iso(entry.get("fired_at"))
1628
+ if not fired_at:
1629
+ continue
1630
+ spec = entry.get("spec") or {}
1631
+ if _transcript_matches(transcript, fired_at, spec):
1632
+ completed.append((tip_id, entry))
1633
+ return completed
1634
+
1635
+
1636
+ def _streak_bar(streak: int, target: int = GRADUATION_STREAK_TARGET) -> str:
1637
+ """Streak bar: 🔴🔴🔴⚪⚪ for 3/5. Emoji glyphs carry color
1638
+ intrinsically — red fill for earned positions, hollow white for
1639
+ remaining — so the bar reads identically in Markdown chat and in
1640
+ /coach status without ANSI escapes."""
1641
+ streak = max(0, min(streak, target))
1642
+ return "🔴" * streak + "⚪" * (target - streak)
1643
+
1644
+
1645
+ def _completion_banner(
1646
+ entries: list[tuple[str, dict]], env: str = "terminal"
1647
+ ) -> str:
1648
+ """Render instructions for tip-complete ack banners."""
1649
+ lines: list[str] = []
1650
+ lines.append("<coach-tip-complete>")
1651
+ lines.append(
1652
+ "One or more tips you fired earlier have been COMPLETED since last "
1653
+ "turn (detected from tool_use events in the transcript). Render ONE "
1654
+ "small ack banner per completion at the TOP of your response, above "
1655
+ "any other content. Keep them tight — one or two lines each, not a paragraph. "
1656
+ "The banners are the dopamine; don't add commentary."
1657
+ )
1658
+ lines.append("")
1659
+ lines.append("Banner shape (pre-computed per entry — render verbatim):")
1660
+ lines.append("")
1661
+ action_labels = {
1662
+ "test_run": ("test runner detected", 2),
1663
+ "commit": ("git commit detected", 1),
1664
+ "skill_invoke": ("skill invoked", SKILL_XP_PER_UNIQUE),
1665
+ }
1666
+ for tip_id, entry in entries:
1667
+ spec = entry.get("spec") or {}
1668
+ kind = entry.get("kind", "weakness")
1669
+ entry_id = entry.get("entry_id") or ""
1670
+ streak = int(
1671
+ entry.get("positive_streak" if kind == "strength" else "clean_streak", 0)
1672
+ )
1673
+ action = spec.get("action")
1674
+ if env == "ide":
1675
+ # Blank line before bottom `---` is load-bearing: without it,
1676
+ # CommonMark renderers fuse the body line into a setext H2
1677
+ # heading, swallowing the closing rule.
1678
+ if action == "skill_invoke" and kind == "skill":
1679
+ banner = (
1680
+ f" ---\n"
1681
+ f" ✅ **Tip cleared** — `/{entry_id}` invoked · "
1682
+ f"`+{SKILL_XP_PER_UNIQUE} XP banked this session`\n"
1683
+ f"\n"
1684
+ f" ---"
1685
+ )
1686
+ elif action in action_labels:
1687
+ label, xp = action_labels[action]
1688
+ bar = _streak_bar(streak)
1689
+ if kind == "strength":
1690
+ banner = (
1691
+ f" ---\n"
1692
+ f" 💪 **Strength reinforced** — `{entry_id}` · "
1693
+ f"`{label}` · `+{xp} XP` · `strength streak {bar}`\n"
1694
+ f"\n"
1695
+ f" ---"
1696
+ )
1697
+ else:
1698
+ banner = (
1699
+ f" ---\n"
1700
+ f" ✅ **Tip cleared** — `{entry_id}` · `{label}` · "
1701
+ f"`+{xp} XP banked` · `streak {bar} advances next /coach-insights`\n"
1702
+ f"\n"
1703
+ f" ---"
1704
+ )
1705
+ else:
1706
+ xp = int(spec.get("xp", 0) or 0)
1707
+ desc = spec.get("description") or action or "action detected"
1708
+ xp_pill = f" · `+{xp} XP`" if xp > 0 else ""
1709
+ prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1710
+ emoji = "💪" if kind == "strength" else "✅"
1711
+ banner = (
1712
+ f" ---\n"
1713
+ f" {emoji} **{prefix}** — `{entry_id}` · `{desc}`{xp_pill}\n"
1714
+ f"\n"
1715
+ f" ---"
1716
+ )
1717
+ lines.append(banner)
1718
+ continue
1719
+ # Terminal path (unchanged)
1720
+ if action == "skill_invoke" and kind == "skill":
1721
+ banner = (
1722
+ f" > ✅ **Tip cleared** — `/{entry_id}` invoked · "
1723
+ f"`+{SKILL_XP_PER_UNIQUE} XP` banked this session"
1724
+ )
1725
+ elif action in action_labels:
1726
+ label, xp = action_labels[action]
1727
+ bar = _streak_bar(streak)
1728
+ if kind == "strength":
1729
+ lines.append(f" > 💪 Strength reinforced — {label}")
1730
+ lines.append(f" > +{xp} XP · {entry_id} strength streak {bar}")
1731
+ continue
1732
+ prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1733
+ streak_label = "strength streak" if kind == "strength" else "streak"
1734
+ banner = (
1735
+ f" > ✅ **{prefix}** — {label} · `+{xp} XP` · "
1736
+ f"`{entry_id}` {streak_label} {bar} (advances on next /coach-insights run)"
1737
+ )
1738
+ else:
1739
+ xp = int(spec.get("xp", 0) or 0)
1740
+ desc = spec.get("description") or action or "action detected"
1741
+ xp_text = f" · `+{xp} XP`" if xp > 0 else ""
1742
+ prefix = "Strength reinforced" if kind == "strength" else "Tip cleared"
1743
+ banner = f" > ✅ **{prefix}** — {desc}{xp_text} · `{entry_id}`"
1744
+ lines.append(banner)
1745
+ lines.append("")
1746
+ lines.append("Rules:")
1747
+ if env == "ide":
1748
+ lines.append(" • Render banners VERBATIM including the `---` horizontal")
1749
+ lines.append(" rules that frame each banner — they're the visual signature")
1750
+ lines.append(" that distinguishes coach output from regular chat content")
1751
+ lines.append(" in the IDE chat panel.")
1752
+ lines.append(" • Stack adjacent banners by collapsing the bottom rule of")
1753
+ lines.append(" one into the top rule of the next (so two banners share")
1754
+ lines.append(" one `---` between them).")
1755
+ else:
1756
+ lines.append(" • Render banners VERBATIM including backticks, emojis, and the")
1757
+ lines.append(" leading `> ` blockquote marker so they render dim/gray and")
1758
+ lines.append(" don't visually compete with your main response body.")
1759
+ lines.append(" • Stack them consecutively if there are multiple, no blank")
1760
+ lines.append(" lines between. One blank line between the last banner and")
1761
+ lines.append(" the rest of your response.")
1762
+ lines.append(" • These announce once — the context won't repeat next turn.")
1763
+ lines.append("</coach-tip-complete>")
1764
+ return "\n".join(lines)
1765
+
1766
+
1767
+ def _streak_stage_label(kind: str, streak: int, target: int) -> str:
1768
+ """User-facing progress stage for ambient tip attribution. Same
1769
+ 🌡️/🌶️/🔥/🏆 ladder for both weakness and strength — the kind
1770
+ distinction lives in the tail wording (`+5 bonus` vs `+5 mastery
1771
+ bonus`), set by `_xp_attribution()`. `kind` stays in the signature
1772
+ so callers don't need to change."""
1773
+ del kind # unified ladder; both kinds render the same stage labels
1774
+ if streak >= target:
1775
+ return "🏆 Mastered"
1776
+ if streak >= 4:
1777
+ return "🔥 Streak"
1778
+ if streak >= 3:
1779
+ return "🌶️ Heating up"
1780
+ if streak >= 2:
1781
+ return "🌡️ Warming up"
1782
+ return "🧊 Ice cold"
1783
+
1784
+
1785
+ def _xp_attribution(tip: dict, env: str = "terminal") -> list[str]:
1786
+ """Build the attribution lines that show WHY this tip is worth following.
1787
+ Returns one or two lines: the per-action reward line (when a
1788
+ reward_hint is present) and the streak/graduation line. Keeping the
1789
+ streak portion on its own line prevents the long single-line wrap that
1790
+ made the bar hard to read.
1791
+
1792
+ Terminal shape uses italics (`_text_`) so theme-driven dim styling
1793
+ applies. IDE shape uses inline-code spans (`` `text` ``) which render
1794
+ as pill-styled badges in IDE chat panels.
1795
+ """
1796
+ streak = int(tip.get("clean_streak", 0))
1797
+ target = GRADUATION_STREAK_TARGET
1798
+ bar = _streak_bar(streak, target)
1799
+
1800
+ kind = tip.get("kind", "weakness")
1801
+ entry_id = tip.get("entry_id") or ""
1802
+
1803
+ # Per-env wrappers: italics for terminal (theme-dimmed), code-span pills
1804
+ # for IDE (renders as badge backgrounds in chat-panel WebViews).
1805
+ if env == "ide":
1806
+ wrap = lambda s: f"`{s}`" # noqa: E731
1807
+ else:
1808
+ wrap = lambda s: f"_{s}._" # noqa: E731
1809
+
1810
+ if kind == "skill":
1811
+ return [wrap(f"↑ +{SKILL_XP_PER_UNIQUE} for trying /{entry_id}")]
1812
+
1813
+ if kind == "strength":
1814
+ streak = int(tip.get("positive_streak", 0) or 0)
1815
+ bar = _streak_bar(streak, target)
1816
+ ready = streak >= target
1817
+ grad_tail = (
1818
+ f"→ +{GRADUATION_XP} mastery bonus ready"
1819
+ if ready
1820
+ else f"→ +{GRADUATION_XP} mastery bonus at {target}/{target}"
1821
+ )
1822
+ stage = _streak_stage_label(kind, streak, target)
1823
+ streak_line = wrap(f"{stage} {bar} {streak}/{target} {grad_tail}")
1824
+ hint = tip.get("reward_hint")
1825
+ if isinstance(hint, dict):
1826
+ xp = int(hint.get("xp", 0))
1827
+ desc = hint.get("description") or hint.get("action") or ""
1828
+ if xp > 0 and desc:
1829
+ return [wrap(f"↑ +{xp} per {desc}"), streak_line]
1830
+ return [streak_line]
1831
+
1832
+ # weakness
1833
+ ready = streak >= target
1834
+ grad_tail = (
1835
+ f"→ +{GRADUATION_XP} bonus ready"
1836
+ if ready
1837
+ else f"→ +{GRADUATION_XP} bonus at {target}/{target}"
1838
+ )
1839
+ stage = _streak_stage_label(kind, streak, target)
1840
+ streak_line = wrap(f"{stage} {bar} {streak}/{target} {grad_tail}")
1841
+ hint = tip.get("reward_hint")
1842
+ if isinstance(hint, dict):
1843
+ xp = int(hint.get("xp", 0))
1844
+ desc = hint.get("description") or hint.get("action") or ""
1845
+ if xp > 0 and desc:
1846
+ return [wrap(f"↑ +{xp} per {desc}"), streak_line]
1847
+ return [streak_line]
1848
+
1849
+
1850
+ def _weight_for_tip(tip: dict) -> float:
1851
+ """Weighted selection input. Baseline = confidence × priority (same
1852
+ formula merge.py uses for cap eviction). Tier and streak-urgency
1853
+ multipliers bias toward newer patterns and under-progressed weaknesses.
1854
+ Floor at 0.01 so no tip is permanently starved."""
1855
+ confidence = float(tip.get("confidence", 0.5) or 0.5)
1856
+ # profile entries expose priority; skill hints don't, so default to 1.
1857
+ priority = int(tip.get("priority", 1) or 1)
1858
+ tier = tip.get("tier", "active")
1859
+ kind = tip.get("kind", "weakness")
1860
+ streak = int(tip.get("clean_streak", 0) or 0)
1861
+
1862
+ tier_mult = TIER_MULTIPLIER.get(tier, 1.0)
1863
+
1864
+ if kind == "weakness":
1865
+ if streak <= 1:
1866
+ streak_mult = STREAK_URGENCY_HIGH
1867
+ elif streak <= 3:
1868
+ streak_mult = STREAK_URGENCY_MID
1869
+ else:
1870
+ streak_mult = STREAK_URGENCY_LOW
1871
+ elif kind == "strength":
1872
+ positive_streak = int(tip.get("positive_streak", 0) or 0)
1873
+ if positive_streak <= 1:
1874
+ streak_mult = 1.1
1875
+ elif positive_streak <= 3:
1876
+ streak_mult = 0.9
1877
+ else:
1878
+ streak_mult = 0.6
1879
+ streak_mult *= STRENGTH_WEIGHT_MULTIPLIER
1880
+ else:
1881
+ # Skills don't have a graduation streak pressure.
1882
+ streak_mult = 1.0
1883
+
1884
+ w = confidence * priority * tier_mult * streak_mult
1885
+ return max(w, 0.01)
1886
+
1887
+
1888
+ def _pick_tip(pool: list[dict], state: dict, now: datetime) -> dict | None:
1889
+ if not pool:
1890
+ return None
1891
+ recent = state.get("last_fired", {}) or {}
1892
+ cutoff = now - timedelta(hours=TIP_PER_TIP_COOLDOWN_HOURS)
1893
+ eligible: list[dict] = []
1894
+ for tip in pool:
1895
+ last = _parse_iso(recent.get(tip["id"]))
1896
+ if last and last > cutoff:
1897
+ continue
1898
+ eligible.append(tip)
1899
+ if not eligible:
1900
+ return None
1901
+ weights = [_weight_for_tip(t) for t in eligible]
1902
+ weights = _apply_skill_share_floor(eligible, weights)
1903
+ # random.choices respects relative weights + picks with replacement — we
1904
+ # only need k=1 so "replacement" is moot. Falls back to uniform if all
1905
+ # weights are equal.
1906
+ return random.choices(eligible, weights=weights, k=1)[0]
1907
+
1908
+
1909
+ def _session_strength_already_fired(state: dict, session_key: str | None) -> bool:
1910
+ if not session_key:
1911
+ return False
1912
+ fired = state.get("strength_fired_sessions") or {}
1913
+ return isinstance(fired, dict) and session_key in fired
1914
+
1915
+
1916
+ def _mark_strength_fired(state: dict, session_key: str | None, now: datetime) -> None:
1917
+ if not session_key:
1918
+ return
1919
+ fired = state.setdefault("strength_fired_sessions", {})
1920
+ if not isinstance(fired, dict):
1921
+ fired = {}
1922
+ state["strength_fired_sessions"] = fired
1923
+ fired[session_key] = now.isoformat()
1924
+ if len(fired) > 20:
1925
+ oldest = sorted(
1926
+ fired.items(),
1927
+ key=lambda item: _parse_iso(item[1]) or datetime.min.replace(tzinfo=timezone.utc),
1928
+ )
1929
+ for key, _ in oldest[:-20]:
1930
+ fired.pop(key, None)
1931
+
1932
+
1933
+ def _apply_skill_share_floor(eligible: list[dict], weights: list[float]) -> list[float]:
1934
+ """Scale skill-hint weights up so they collectively reach MIN_SKILL_SHARE
1935
+ of total weight. Prevents skill hints from being starved on heavy-
1936
+ weakness profiles. No-op if there are no skills, no non-skills, or
1937
+ skills are already at/above the floor."""
1938
+ skill_idx = [i for i, t in enumerate(eligible) if t.get("kind") == "skill"]
1939
+ if not skill_idx:
1940
+ return weights
1941
+ total = sum(weights)
1942
+ if total <= 0:
1943
+ return weights
1944
+ skill_total = sum(weights[i] for i in skill_idx)
1945
+ non_skill_total = total - skill_total
1946
+ if non_skill_total <= 0:
1947
+ return weights # skills already 100% share
1948
+ current_share = skill_total / total
1949
+ if current_share >= MIN_SKILL_SHARE:
1950
+ return weights
1951
+ # Target: skill_total' / (skill_total' + non_skill_total) == MIN_SKILL_SHARE
1952
+ # → skill_total' = non_skill_total × MIN_SKILL_SHARE / (1 − MIN_SKILL_SHARE)
1953
+ target_skill_total = non_skill_total * MIN_SKILL_SHARE / (1.0 - MIN_SKILL_SHARE)
1954
+ if skill_total <= 0:
1955
+ # All skill weights were floored to 0.01 and that rounded down;
1956
+ # distribute the target equally across skill slots.
1957
+ per_skill = target_skill_total / len(skill_idx)
1958
+ scaled = list(weights)
1959
+ for i in skill_idx:
1960
+ scaled[i] = per_skill
1961
+ return scaled
1962
+ scale = target_skill_total / skill_total
1963
+ scaled = list(weights)
1964
+ for i in skill_idx:
1965
+ scaled[i] = weights[i] * scale
1966
+ return scaled
1967
+
1968
+
1969
+ def _maybe_schedule_tip(
1970
+ now: datetime,
1971
+ session_signal: set[str] | None = None,
1972
+ project_anchors: set[str] | frozenset[str] | None = None,
1973
+ behavior_evidence: dict | None = None,
1974
+ session_key: str | None = None,
1975
+ env: str = "terminal",
1976
+ ) -> str | None:
1977
+ """Return a tip-render instruction block, or None if no tip should fire."""
1978
+ try:
1979
+ with _locked_tip_state():
1980
+ state = _load_tip_state_unlocked()
1981
+
1982
+ # Global cooldown
1983
+ last_global = _parse_iso(state.get("last_global_fire"))
1984
+ if last_global and (now - last_global).total_seconds() < TIP_GLOBAL_COOLDOWN_SEC:
1985
+ return None
1986
+
1987
+ # Probability roll
1988
+ if random.random() >= TIP_FIRE_PROBABILITY:
1989
+ return None
1990
+
1991
+ profile = _load_profile()
1992
+ pool = _build_tip_pool(
1993
+ profile,
1994
+ session_signal=session_signal,
1995
+ project_anchors=project_anchors,
1996
+ behavior_evidence=behavior_evidence,
1997
+ )
1998
+ if _session_strength_already_fired(state, session_key):
1999
+ pool = [tip for tip in pool if tip.get("kind") != "strength"]
2000
+ tip = _pick_tip(pool, state, now)
2001
+ if not tip:
2002
+ return None
2003
+
2004
+ if tip["kind"] == "skill":
2005
+ label = random.choice(SKILL_LABELS)
2006
+ elif tip["kind"] == "strength":
2007
+ label = random.choice(STRENGTH_LABELS)
2008
+ else:
2009
+ label = random.choice(WEAKNESS_LABELS)
2010
+
2011
+ xp_lines = _xp_attribution(tip, env=env)
2012
+
2013
+ # Persist cooldowns + pending-completion spec (so the next prompt can ack
2014
+ # when the user actually does the thing).
2015
+ state.setdefault("last_fired", {})[tip["id"]] = now.isoformat()
2016
+ state["last_global_fire"] = now.isoformat()
2017
+ spec = _completion_spec(tip)
2018
+ if spec:
2019
+ state.setdefault("pending_completions", {})[tip["id"]] = {
2020
+ "fired_at": now.isoformat(),
2021
+ "spec": spec,
2022
+ "kind": tip["kind"],
2023
+ "entry_id": tip.get("entry_id"),
2024
+ "clean_streak": int(tip.get("clean_streak", 0)),
2025
+ "positive_streak": int(tip.get("positive_streak", 0)),
2026
+ "acknowledged": False,
2027
+ }
2028
+ if tip.get("kind") == "strength":
2029
+ _mark_strength_fired(state, session_key, now)
2030
+ _save_tip_state_unlocked(state)
2031
+ except Exception:
2032
+ return None
2033
+ _log_tip_fired(tip, spec, now)
2034
+
2035
+ lines: list[str] = []
2036
+ lines.append("<coach-tip>")
2037
+ lines.append(
2038
+ "The Coach scheduler fired an ambient tip for this turn. You MUST "
2039
+ "render it at the very END of your response using the multi-line shape "
2040
+ "below, preceded by a blank line. This is not the 'silent by default' "
2041
+ "path — when this block is present, the tip is REQUIRED."
2042
+ )
2043
+ lines.append("")
2044
+ if env == "ide":
2045
+ ide_label = _ide_label(label)
2046
+ lines.append("RENDER SHAPE (HR-framed coach banner — render the lines below")
2047
+ lines.append("verbatim including the `---` horizontal rules AND the blank")
2048
+ lines.append("line above the bottom `---`. The blank line is load-bearing:")
2049
+ lines.append("without it the renderer fuses the last line into a setext H2")
2050
+ lines.append("heading and drops the closing rule. The HR frame + 🦞 + bold +")
2051
+ lines.append("code-span pills are the coach's visual signature in the IDE")
2052
+ lines.append("chat panel; without them the tip blends into regular prose.):")
2053
+ lines.append(" ---")
2054
+ lines.append(f" {ide_label} — <one sentence, grounded in this session's actual work>")
2055
+ for _xp_line in xp_lines:
2056
+ lines.append(f" {_xp_line}")
2057
+ lines.append("")
2058
+ lines.append(" ---")
2059
+ lines.append("")
2060
+ lines.append("RULES:")
2061
+ lines.append(" • Render the `---` rules and the `🦞 **{label}**` header EXACTLY")
2062
+ lines.append(" as shown — they are the load-bearing visual signature.")
2063
+ lines.append(" • Body is one sentence, addressed to the user (second person).")
2064
+ lines.append(" • Ground it in something concrete from THIS session's work, not")
2065
+ lines.append(" the raw pattern text below. Generic advice = failed tip.")
2066
+ lines.append(" • No 'should'. Observe, suggest, offer.")
2067
+ lines.append(" • Don't narrate what you just did — forward-looking only.")
2068
+ if tip["kind"] == "strength":
2069
+ lines.append(" • This is reinforcement, not correction: name the useful habit")
2070
+ lines.append(" and invite the user to repeat it at the next natural checkpoint.")
2071
+ else:
2072
+ lines.append(" • Phrase the body as a SPECIFIC ACTION the user could take in")
2073
+ lines.append(" the next few minutes, not abstract advice. E.g. 'run the test")
2074
+ lines.append(" for the module you just edited before your next commit' beats")
2075
+ lines.append(" 'remember to test'. Pro-dev tone — no cheerleading, no")
2076
+ lines.append(" exclamation points. Dopamine comes from specificity + the")
2077
+ lines.append(" reward line, not hype.")
2078
+ lines.append(" • The reward attribution lines are PRE-COMPUTED inline-code spans.")
2079
+ lines.append(" Render each one VERBATIM (including the surrounding backticks)")
2080
+ lines.append(" on its own line directly below the header. The backticks become")
2081
+ lines.append(" pill backgrounds in the IDE chat panel — that's the badge look.")
2082
+ else:
2083
+ lines.append("RENDER SHAPE (one tip line + the pre-computed reward attribution lines,")
2084
+ lines.append("in this exact order — each PREFIXED with a markdown blockquote `> ` so")
2085
+ lines.append("the coach text renders in the dim/gray color and visually steps back")
2086
+ lines.append("from your main response body):")
2087
+ lines.append(f" > {label} <one sentence, grounded in this session's actual work>")
2088
+ for _xp_line in xp_lines:
2089
+ lines.append(f" > {_xp_line}")
2090
+ lines.append("")
2091
+ lines.append("RULES:")
2092
+ lines.append(" • Use the label EXACTLY as given above. Don't swap the emoji or")
2093
+ lines.append(" wording — the scheduler picked it to rotate across the session.")
2094
+ lines.append(" • Body is one sentence, addressed to the user (second person).")
2095
+ lines.append(" • Ground it in something concrete from THIS session's work, not")
2096
+ lines.append(" the raw pattern text below. Generic advice = failed tip.")
2097
+ lines.append(" • No 'should'. Observe, suggest, offer.")
2098
+ lines.append(" • Don't narrate what you just did — forward-looking only.")
2099
+ if tip["kind"] == "strength":
2100
+ lines.append(" • This is reinforcement, not correction: name the useful habit")
2101
+ lines.append(" and invite the user to repeat it at the next natural checkpoint.")
2102
+ else:
2103
+ lines.append(" • Phrase the body as a SPECIFIC ACTION the user could take in")
2104
+ lines.append(" the next few minutes, not abstract advice. E.g. 'run the test")
2105
+ lines.append(" for the module you just edited before your next commit' beats")
2106
+ lines.append(" 'remember to test'. Pro-dev tone — no cheerleading, no")
2107
+ lines.append(" exclamation points. Dopamine comes from specificity + the")
2108
+ lines.append(" reward line, not hype.")
2109
+ lines.append(" • The reward attribution lines are PRE-COMPUTED. Render each one")
2110
+ lines.append(" VERBATIM on its own line directly below the tip sentence, in the")
2111
+ lines.append(" order shown. Do NOT edit the numbers, streaks, or labels inside")
2112
+ lines.append(" them, and do NOT collapse them onto the same line — the streak")
2113
+ lines.append(" bar is intentionally on its own row so it doesn't wrap.")
2114
+ lines.append(" • EVERY line (tip sentence + each attribution line) goes inside a")
2115
+ lines.append(" markdown blockquote (`> ` prefix) so they render in the dim/gray")
2116
+ lines.append(" color and don't compete visually with the white chat-completion")
2117
+ lines.append(" prose above them.")
2118
+ lines.append("")
2119
+ lines.append(f"TIP KIND: {tip['kind']}")
2120
+ lines.append(f"PATTERN / SKILL: {tip['name']} (tier: {tip['tier']})")
2121
+ lines.append(f"UNDERLYING NUDGE (diagnostic — do NOT quote verbatim):")
2122
+ lines.append(f" {tip['nudge']}")
2123
+ if tip["example"]:
2124
+ lines.append(f"PRIOR EVIDENCE: {tip['example']}")
2125
+ lines.append("</coach-tip>")
2126
+ return "\n".join(lines)
2127
+
2128
+
2129
+ def main() -> None:
2130
+ try:
2131
+ payload: dict = {}
2132
+ try:
2133
+ raw = sys.stdin.read()
2134
+ if raw:
2135
+ parsed = json.loads(raw)
2136
+ if isinstance(parsed, dict):
2137
+ payload = parsed
2138
+ except Exception:
2139
+ payload = {}
2140
+
2141
+ # Real Claude Code UserPromptSubmit events always carry session_id
2142
+ # and/or transcript_path. Without one (ad-hoc smoke test or
2143
+ # malformed input), reading and consuming pending markers would
2144
+ # add a sentinel session_key to consumed_by — real sessions then
2145
+ # see the marker as already-consumed and skip rendering. Exit
2146
+ # silently to keep marker state clean.
2147
+ if not (
2148
+ payload.get("session_id")
2149
+ or payload.get("sessionId")
2150
+ or payload.get("transcript_path")
2151
+ or payload.get("transcriptPath")
2152
+ ):
2153
+ _emit(None)
2154
+ return
2155
+
2156
+ if _disabled():
2157
+ _emit(None)
2158
+ return
2159
+
2160
+ now = datetime.now(timezone.utc)
2161
+ # Detect render env once per invocation; thread it to every renderer
2162
+ # so the whole emitted block uses one consistent shape (terminal
2163
+ # blockquote OR IDE HR-frame, never a mix).
2164
+ env = detect_render_env()
2165
+ transcript = _find_transcript(payload)
2166
+ # Stable per-session identifier. Used by _read_and_consume() so each
2167
+ # concurrent Claude Code session sees a one-shot celebration marker
2168
+ # exactly once. transcript_path is unique per session and per machine
2169
+ # restart; session_id / sessionId are the documented fallbacks.
2170
+ session_key = (
2171
+ str(transcript)
2172
+ if transcript is not None
2173
+ else str(payload.get("session_id") or payload.get("sessionId") or "")
2174
+ or None
2175
+ )
2176
+
2177
+ # Check pending completions BEFORE firing a new tip, so the ack banner
2178
+ # for the last tip lands on the same response as the next tip.
2179
+ with _locked_tip_state():
2180
+ tip_state = _load_tip_state_unlocked()
2181
+ completions = _detect_completions(tip_state, transcript)
2182
+ if completions:
2183
+ pending = tip_state.setdefault("pending_completions", {})
2184
+ for tip_id, _ in completions:
2185
+ if tip_id in pending and isinstance(pending[tip_id], dict):
2186
+ pending[tip_id]["acknowledged"] = True
2187
+ pending[tip_id]["acknowledged_at"] = now.isoformat()
2188
+ _log_tip_completed(tip_id, pending[tip_id], now)
2189
+ # Prune acknowledged entries older than 24h to keep state tidy.
2190
+ cutoff = now - timedelta(hours=24)
2191
+ for tip_id in list(pending.keys()):
2192
+ entry = pending.get(tip_id)
2193
+ if not isinstance(entry, dict):
2194
+ continue
2195
+ if entry.get("acknowledged"):
2196
+ ack_at = _parse_iso(entry.get("acknowledged_at"))
2197
+ if ack_at and ack_at < cutoff:
2198
+ pending.pop(tip_id, None)
2199
+ _save_tip_state_unlocked(tip_state)
2200
+ completion_block: str | None = None
2201
+ if completions:
2202
+ completion_block = _completion_banner(completions, env=env)
2203
+
2204
+ levelup = _read_and_consume(LEVELUP_MARKER, session_key, now)
2205
+ grad_data = _read_and_consume(GRADUATION_MARKER, session_key, now)
2206
+ reg_data = _read_and_consume(REGRESSION_MARKER, session_key, now)
2207
+ streak_data = _read_and_consume(STREAK_REWARD_MARKER, session_key, now)
2208
+
2209
+ def _items(payload: dict | None, key: str) -> list[dict]:
2210
+ if not isinstance(payload, dict):
2211
+ return []
2212
+ raw = payload.get(key)
2213
+ if not isinstance(raw, list):
2214
+ return []
2215
+ return [x for x in raw if isinstance(x, dict)]
2216
+
2217
+ grads = _items(grad_data, "graduations")
2218
+ regs = _items(reg_data, "regressions")
2219
+ streak_rewards = _items(streak_data, "rewards")
2220
+
2221
+ caught_up = any(
2222
+ _marker_predates_today(p, now)
2223
+ for p in (levelup, grad_data, reg_data, streak_data)
2224
+ )
2225
+
2226
+ # Defensive read of theme + streak window. Both are optional from
2227
+ # _assemble_celebrate_block's POV — only consumed by the bespoke
2228
+ # dispatch — so failures here fall through to default rendering.
2229
+ try:
2230
+ theme = _get_theme()
2231
+ except Exception:
2232
+ theme = "craft"
2233
+ streak_oldest = None
2234
+ if isinstance(streak_data, dict):
2235
+ streak_oldest = _parse_iso(streak_data.get("oldest_entry_at"))
2236
+
2237
+ celebrate_block = _assemble_celebrate_block(
2238
+ grads=grads,
2239
+ regs=regs,
2240
+ streak_rewards=streak_rewards,
2241
+ levelup=levelup,
2242
+ caught_up=caught_up,
2243
+ env=env,
2244
+ theme=theme,
2245
+ now=now,
2246
+ streak_oldest=streak_oldest,
2247
+ )
2248
+ celebrate_blocks: list[str] = [celebrate_block] if celebrate_block else []
2249
+
2250
+ session_signal, project_anchors = _session_signal(transcript, payload.get("cwd"))
2251
+ behavior_evidence = _session_behavior_evidence(transcript)
2252
+ tip_block = _maybe_schedule_tip(
2253
+ now,
2254
+ session_signal=session_signal,
2255
+ project_anchors=project_anchors,
2256
+ behavior_evidence=behavior_evidence,
2257
+ session_key=session_key,
2258
+ env=env,
2259
+ )
2260
+
2261
+ parts: list[str] = []
2262
+ if completion_block:
2263
+ parts.append(completion_block)
2264
+ if celebrate_blocks:
2265
+ parts.append("\n".join(celebrate_blocks))
2266
+ if tip_block:
2267
+ parts.append(tip_block)
2268
+ cron_block = _maybe_cron_nudge_block(env)
2269
+ if cron_block:
2270
+ parts.append(cron_block)
2271
+ wrap_announce = _maybe_wrap_announce_block(session_key, now, env)
2272
+ if wrap_announce:
2273
+ parts.append(wrap_announce)
2274
+ wrap_duplicate = _maybe_wrap_duplicate_block(session_key, now, env)
2275
+ if wrap_duplicate:
2276
+ parts.append(wrap_duplicate)
2277
+
2278
+ if not parts:
2279
+ _emit(None)
2280
+ return
2281
+
2282
+ _emit("\n\n".join(parts))
2283
+ except Exception:
2284
+ _emit(None)
2285
+
2286
+
2287
+ if __name__ == "__main__":
2288
+ main()