@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.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- 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()
|