@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,536 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Coach Claw — SessionStart hook.
|
|
4
|
+
|
|
5
|
+
Reads ~/.claude/coach/profile.yaml and emits a short additionalContext block
|
|
6
|
+
telling Claude which behavior patterns to watch for this session. Claude
|
|
7
|
+
decides whether to surface a footnote in its own voice when one of the
|
|
8
|
+
patterns actually shows up — the hook never writes the nudge itself.
|
|
9
|
+
|
|
10
|
+
Design invariants:
|
|
11
|
+
- Always exits 0. A broken coach must never block a Claude Code session.
|
|
12
|
+
- Emits valid JSON or nothing. No stderr leakage into the UI.
|
|
13
|
+
- Respects COACH_DISABLE=1 env and ~/.claude/coach/.disabled flag file.
|
|
14
|
+
- Reads only. Writes only the "last seen session start" stamp for changelog
|
|
15
|
+
deltas. No profile mutation here — that's /coach-insights' job.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import json
|
|
20
|
+
import os
|
|
21
|
+
import random
|
|
22
|
+
import sys
|
|
23
|
+
from datetime import datetime, timedelta, timezone
|
|
24
|
+
from pathlib import Path
|
|
25
|
+
|
|
26
|
+
# Resolve the coach state dir BEFORE adding bin/ to sys.path — the helper
|
|
27
|
+
# we'd otherwise import (`coach_paths.resolve_coach_dir`) lives in that
|
|
28
|
+
# very dir, so we have to inline the env-var contract here. Keep this in
|
|
29
|
+
# sync with `coach/bin/coach_paths.py:resolve_coach_dir()`.
|
|
30
|
+
_COACH_BASE = os.environ.get("COACH_CONFIG_DIR")
|
|
31
|
+
COACH_DIR = Path(_COACH_BASE) if _COACH_BASE else Path.home() / ".claude" / "coach"
|
|
32
|
+
PROFILE = COACH_DIR / "profile.yaml"
|
|
33
|
+
CHANGELOG = COACH_DIR / "changelog.md"
|
|
34
|
+
LAST_SEEN = COACH_DIR / ".last_session_start"
|
|
35
|
+
DISABLED_FLAG = COACH_DIR / ".disabled"
|
|
36
|
+
|
|
37
|
+
# Shared modules — locate the bin/ that ships with THIS hook copy.
|
|
38
|
+
# - Plugin context: ${CLAUDE_PLUGIN_ROOT}/bin/ (set by Claude Code).
|
|
39
|
+
# - CLI context: ${COACH_DIR}/bin/.
|
|
40
|
+
# Without this branch, the plugin's hook would pull from the CLI install's
|
|
41
|
+
# stale bin/ (which can be missing newer modules like cron_check or
|
|
42
|
+
# statusline_self_patch — observed during e2e validation 2026-05-09).
|
|
43
|
+
_PLUGIN_ROOT = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
44
|
+
if _PLUGIN_ROOT:
|
|
45
|
+
sys.path.insert(0, str(Path(_PLUGIN_ROOT) / "bin"))
|
|
46
|
+
else:
|
|
47
|
+
sys.path.insert(0, str(COACH_DIR / "bin"))
|
|
48
|
+
try:
|
|
49
|
+
from render_env import detect_render_env # noqa: E402
|
|
50
|
+
except Exception:
|
|
51
|
+
# Defensive fallback: missing helper → terminal shape (which renders fine
|
|
52
|
+
# everywhere, just without IDE-specific polish).
|
|
53
|
+
def detect_render_env(env=None): # type: ignore[no-redef]
|
|
54
|
+
return "terminal"
|
|
55
|
+
|
|
56
|
+
MAX_INJECTED = 5 # top N entries shown per session
|
|
57
|
+
PROBATIONARY_DAYS = 7 # length of probation window
|
|
58
|
+
PROBATIONARY_FIRE_RATE = 0.30 # probability a probationary entry is shown
|
|
59
|
+
COOLDOWN_HOURS = 24 # per-entry min hours between firings
|
|
60
|
+
MIN_CONFIDENCE = 0.30 # below this = auto-filtered
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _emit(additional_context: str | None) -> None:
|
|
64
|
+
"""Emit the hook JSON envelope (or nothing) and exit 0."""
|
|
65
|
+
if additional_context:
|
|
66
|
+
payload = {
|
|
67
|
+
"hookSpecificOutput": {
|
|
68
|
+
"hookEventName": "SessionStart",
|
|
69
|
+
"additionalContext": additional_context,
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
sys.stdout.write(json.dumps(payload))
|
|
73
|
+
sys.exit(0)
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def _should_silence() -> bool:
|
|
77
|
+
if os.environ.get("COACH_DISABLE") == "1":
|
|
78
|
+
return True
|
|
79
|
+
if DISABLED_FLAG.exists():
|
|
80
|
+
return True
|
|
81
|
+
return False
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _load_profile() -> dict:
|
|
85
|
+
import yaml
|
|
86
|
+
with PROFILE.open("r") as f:
|
|
87
|
+
data = yaml.safe_load(f) or {}
|
|
88
|
+
if not isinstance(data, dict):
|
|
89
|
+
return {"entries": []}
|
|
90
|
+
data.setdefault("entries", [])
|
|
91
|
+
return data
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def _parse_iso(s: str | None) -> datetime | None:
|
|
95
|
+
if not s:
|
|
96
|
+
return None
|
|
97
|
+
try:
|
|
98
|
+
if len(s) == 10: # date only
|
|
99
|
+
return datetime.fromisoformat(s).replace(tzinfo=timezone.utc)
|
|
100
|
+
return datetime.fromisoformat(s.replace("Z", "+00:00"))
|
|
101
|
+
except Exception:
|
|
102
|
+
return None
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _is_on_cooldown(entry: dict, now: datetime) -> bool:
|
|
106
|
+
last = _parse_iso(entry.get("last_fired"))
|
|
107
|
+
if last is None:
|
|
108
|
+
return False
|
|
109
|
+
return (now - last) < timedelta(hours=COOLDOWN_HOURS)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _probationary_fire(entry: dict, now: datetime) -> bool:
|
|
113
|
+
"""Roll probability for probationary entries; active entries always pass."""
|
|
114
|
+
tier = entry.get("tier", "active")
|
|
115
|
+
if tier != "probationary":
|
|
116
|
+
return True
|
|
117
|
+
promoted_at = _parse_iso(entry.get("promoted_at"))
|
|
118
|
+
first_seen = _parse_iso(entry.get("first_seen"))
|
|
119
|
+
anchor = promoted_at or first_seen or now
|
|
120
|
+
if (now - anchor) > timedelta(days=PROBATIONARY_DAYS):
|
|
121
|
+
return True # past probation window — treat as active
|
|
122
|
+
return random.random() < PROBATIONARY_FIRE_RATE
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def _select_entries(profile: dict, now: datetime) -> list[dict]:
|
|
126
|
+
active_pool = []
|
|
127
|
+
for e in profile.get("entries", []):
|
|
128
|
+
if not isinstance(e, dict):
|
|
129
|
+
continue
|
|
130
|
+
tier = e.get("tier", "active")
|
|
131
|
+
if tier == "candidate":
|
|
132
|
+
continue # not promoted yet
|
|
133
|
+
if float(e.get("confidence", 0)) < MIN_CONFIDENCE:
|
|
134
|
+
continue
|
|
135
|
+
if _is_on_cooldown(e, now):
|
|
136
|
+
continue
|
|
137
|
+
if not _probationary_fire(e, now):
|
|
138
|
+
continue
|
|
139
|
+
active_pool.append(e)
|
|
140
|
+
|
|
141
|
+
active_pool.sort(
|
|
142
|
+
key=lambda e: (
|
|
143
|
+
float(e.get("confidence", 0)) * int(e.get("priority", 1)),
|
|
144
|
+
),
|
|
145
|
+
reverse=True,
|
|
146
|
+
)
|
|
147
|
+
return active_pool[:MAX_INJECTED]
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _changelog_delta() -> str | None:
|
|
151
|
+
"""Lines added to changelog since the last SessionStart."""
|
|
152
|
+
if not CHANGELOG.exists():
|
|
153
|
+
return None
|
|
154
|
+
try:
|
|
155
|
+
last_ts = _parse_iso(LAST_SEEN.read_text().strip()) if LAST_SEEN.exists() else None
|
|
156
|
+
except Exception:
|
|
157
|
+
last_ts = None
|
|
158
|
+
|
|
159
|
+
now = datetime.now(timezone.utc)
|
|
160
|
+
try:
|
|
161
|
+
LAST_SEEN.write_text(now.isoformat())
|
|
162
|
+
except Exception:
|
|
163
|
+
pass
|
|
164
|
+
|
|
165
|
+
if last_ts is None:
|
|
166
|
+
return None # first run — no delta to show
|
|
167
|
+
|
|
168
|
+
mtime = datetime.fromtimestamp(CHANGELOG.stat().st_mtime, tz=timezone.utc)
|
|
169
|
+
if mtime <= last_ts:
|
|
170
|
+
return None
|
|
171
|
+
|
|
172
|
+
text = CHANGELOG.read_text()
|
|
173
|
+
lines = [ln for ln in text.splitlines() if ln.strip().startswith(("20", "- 20"))]
|
|
174
|
+
if not lines:
|
|
175
|
+
return None
|
|
176
|
+
|
|
177
|
+
recent = []
|
|
178
|
+
for ln in lines[-5:]:
|
|
179
|
+
try:
|
|
180
|
+
date_part = ln.lstrip("- ").split(":", 1)[0]
|
|
181
|
+
ts = _parse_iso(date_part)
|
|
182
|
+
if ts and ts > last_ts:
|
|
183
|
+
recent.append(ln)
|
|
184
|
+
except Exception:
|
|
185
|
+
continue
|
|
186
|
+
|
|
187
|
+
if not recent:
|
|
188
|
+
return None
|
|
189
|
+
return "\n".join(recent)
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _format_context(
|
|
193
|
+
entries: list[dict],
|
|
194
|
+
changelog_delta: str | None,
|
|
195
|
+
skill_hints: list[dict],
|
|
196
|
+
env: str = "terminal",
|
|
197
|
+
) -> str:
|
|
198
|
+
if not entries and not changelog_delta and not skill_hints:
|
|
199
|
+
return ""
|
|
200
|
+
|
|
201
|
+
parts: list[str] = []
|
|
202
|
+
parts.append("<coach>")
|
|
203
|
+
parts.append(
|
|
204
|
+
"This block installs an ambient coaching layer on top of your normal "
|
|
205
|
+
"work. The coach exists to teach the USER — not to narrate your own "
|
|
206
|
+
"behavior back at them. Every tip is addressed to the user (you/your) "
|
|
207
|
+
"and teaches them something about working with Claude Code more "
|
|
208
|
+
"effectively. The user sees these as 'Coach tips', not as Claude "
|
|
209
|
+
"reflecting on itself."
|
|
210
|
+
)
|
|
211
|
+
parts.append("")
|
|
212
|
+
parts.append("WHEN TO SURFACE A TIP")
|
|
213
|
+
parts.append(" • Silent by default. Only speak when you have something")
|
|
214
|
+
parts.append(" concretely useful to teach the user about how they collaborate")
|
|
215
|
+
parts.append(" with Claude Code — given what the session is actually doing.")
|
|
216
|
+
parts.append(" • Ambient, not one-shot: tips can appear in multiple responses")
|
|
217
|
+
parts.append(" across the session at natural moments (end of a major step,")
|
|
218
|
+
parts.append(" after a visible pattern, before starting something the user")
|
|
219
|
+
parts.append(" has installed tooling for). Do NOT tip on every response —")
|
|
220
|
+
parts.append(" aim for a light touch. When in doubt, stay quiet.")
|
|
221
|
+
parts.append(" • Never surface the SAME underlying pattern or skill twice in")
|
|
222
|
+
parts.append(" one session. Rotate what you notice.")
|
|
223
|
+
parts.append("")
|
|
224
|
+
parts.append("VOICE — this is the whole point, get it right")
|
|
225
|
+
parts.append(" • Second person or imperative: 'you', 'your', 'try …',")
|
|
226
|
+
parts.append(" 'consider …', 'worth …'. Address the user.")
|
|
227
|
+
parts.append(" • NEVER first person about Claude. NEVER narrate what you")
|
|
228
|
+
parts.append(" (Claude) just did back at the user — they watched it happen.")
|
|
229
|
+
parts.append(" A tip is forward-looking teaching, not a post-mortem.")
|
|
230
|
+
parts.append(" • NEVER use the word 'should'. Observe, suggest, offer.")
|
|
231
|
+
parts.append(" • Translate observed patterns (Watch list below) into advice")
|
|
232
|
+
parts.append(" about the USER's workflow. Example:")
|
|
233
|
+
parts.append(" GOOD: *Tip:* kicking off a quick pytest run after batches")
|
|
234
|
+
parts.append(" of edits like this one catches regressions while the")
|
|
235
|
+
parts.append(" context is still fresh.")
|
|
236
|
+
parts.append(" BAD: *Tip:* I edited 10 files without running tests.")
|
|
237
|
+
parts.append(" BAD: *Tip:* You should run tests after editing files.")
|
|
238
|
+
parts.append(" • Vary phrasing. Different opening, different rhythm each time.")
|
|
239
|
+
parts.append(" The user will see many of these; a templated shape gets dull.")
|
|
240
|
+
parts.append("")
|
|
241
|
+
parts.append("FORMAT")
|
|
242
|
+
parts.append(" • Own line, preceded by a blank line. Place at the end of the")
|
|
243
|
+
parts.append(" response, or at a natural pause between major steps.")
|
|
244
|
+
if env == "ide":
|
|
245
|
+
parts.append(" • Shape — HR-framed coach banner (the IDE chat panel renders")
|
|
246
|
+
parts.append(" `---` as a sharp horizontal rule and inline `code spans` as")
|
|
247
|
+
parts.append(" pill backgrounds; together they form the coach's visual")
|
|
248
|
+
parts.append(" signature):")
|
|
249
|
+
parts.append(" ---")
|
|
250
|
+
parts.append(" 🦞 **<Label>** — <one sentence of advice>")
|
|
251
|
+
parts.append("")
|
|
252
|
+
parts.append(" ---")
|
|
253
|
+
parts.append(" The blank line above the bottom `---` is load-bearing:")
|
|
254
|
+
parts.append(" without it the CommonMark renderer fuses the body into a")
|
|
255
|
+
parts.append(" setext H2 heading and drops the closing rule.")
|
|
256
|
+
parts.append(" The 🦞 + bold label + HR rules are the load-bearing visual")
|
|
257
|
+
parts.append(" cue. Blockquotes (`> `) render with no visible styling in")
|
|
258
|
+
parts.append(" the IDE chat panel — do NOT use them.")
|
|
259
|
+
parts.append(" • Rotate labels — pick ONE per tip, vary across the session.")
|
|
260
|
+
parts.append(" The label pool differs by tip flavor (see TIP FLAVORS below):")
|
|
261
|
+
parts.append(" weakness: **Tip** **Pointer** **Heads up** **Worth noting**")
|
|
262
|
+
parts.append(" strength: **Nice** **Strong move** **Solid** **Locked in**")
|
|
263
|
+
parts.append(" **On track** **Well done**")
|
|
264
|
+
parts.append(" skill: **Coach** **From Coach Claw**")
|
|
265
|
+
parts.append(" Always prefix the label with the 🦞 emoji and wrap in `**bold**`.")
|
|
266
|
+
parts.append(" Drop the trailing colon — the `—` em-dash separator follows.")
|
|
267
|
+
parts.append(" • One sentence. Concrete, specific to the session, never")
|
|
268
|
+
parts.append(" generic advice that could apply to anyone.")
|
|
269
|
+
else:
|
|
270
|
+
parts.append(" • Shape: `> *Label:*` (italic label inside a markdown")
|
|
271
|
+
parts.append(" blockquote) + space + one sentence of plain-text advice.")
|
|
272
|
+
parts.append(" The blockquote prefix is load-bearing — terminal Claude")
|
|
273
|
+
parts.append(" Code themes render `> ` quoted text in dim/gray, which")
|
|
274
|
+
parts.append(" visually steps the coach line back from the main")
|
|
275
|
+
parts.append(" response prose. Only the label is styled inside the")
|
|
276
|
+
parts.append(" blockquote; no bold, no code fences.")
|
|
277
|
+
parts.append(" • Rotate labels — pick ONE per tip, vary across the session.")
|
|
278
|
+
parts.append(" The label pool differs by tip flavor (see TIP FLAVORS below):")
|
|
279
|
+
parts.append(" weakness: *Tip:* *Pointer:* *Heads up:* *Worth noting:*")
|
|
280
|
+
parts.append(" strength: *Nice:* *Strong move:* *Solid:* *Locked in:*")
|
|
281
|
+
parts.append(" *On track:* *Well done:*")
|
|
282
|
+
parts.append(" skill: *Coach:* *🦞 From Coach Claw:*")
|
|
283
|
+
parts.append(" • Emojis are optional and must be content-matched, not")
|
|
284
|
+
parts.append(" decorative. At most ~1 in 4 tips carries an emoji, and the")
|
|
285
|
+
parts.append(" emoji is chosen for what the tip is actually about. Examples")
|
|
286
|
+
parts.append(" of the kind of fit to aim for (not a menu — pick whatever")
|
|
287
|
+
parts.append(" fits the specific tip):")
|
|
288
|
+
parts.append(" ✏️ testing / verification advice")
|
|
289
|
+
parts.append(" ⚡ performance, speed, latency")
|
|
290
|
+
parts.append(" 🔒 security, secrets, auth")
|
|
291
|
+
parts.append(" 🎯 precision, focus, scoping")
|
|
292
|
+
parts.append(" 🪶 simplification, reducing scope")
|
|
293
|
+
parts.append(" 🧭 navigation, planning, direction")
|
|
294
|
+
parts.append(" 📌 durability — worth pinning for later")
|
|
295
|
+
parts.append(" 🧠 strategy, mental model")
|
|
296
|
+
parts.append(" 🌿 git / branches / worktrees")
|
|
297
|
+
parts.append(" 💡 genuine insight / non-obvious idea")
|
|
298
|
+
parts.append(" Place the emoji before the label: `> *✏️ Tip:*`. NEVER default")
|
|
299
|
+
parts.append(" to 💡 out of habit — only use it when the tip genuinely is a")
|
|
300
|
+
parts.append(" fresh insight the user hadn't connected. If no emoji fits")
|
|
301
|
+
parts.append(" naturally, omit it — a plain `> *Tip:*` is always fine.")
|
|
302
|
+
parts.append(" • One sentence. Concrete, specific to the session, never")
|
|
303
|
+
parts.append(" generic advice that could apply to anyone.")
|
|
304
|
+
parts.append("")
|
|
305
|
+
parts.append("THREE FLAVORS OF TIP")
|
|
306
|
+
parts.append(" WEAKNESS — triggered when the session matches a pattern on")
|
|
307
|
+
parts.append(" the Weaknesses list (direction=negative). Teach the user")
|
|
308
|
+
parts.append(" the better habit, forward-looking.")
|
|
309
|
+
parts.append(" Labels: *Tip:*, *Pointer:*, *Heads up:*, *Worth noting:*.")
|
|
310
|
+
parts.append(" STRENGTH — triggered when the session shows a pattern from")
|
|
311
|
+
parts.append(" the Strengths list (direction=positive). Acknowledge the")
|
|
312
|
+
parts.append(" habit warmly — they're doing the thing. Not a correction.")
|
|
313
|
+
parts.append(" Labels: *Nice:*, *Strong move:*, *Solid:*, *Locked in:*,")
|
|
314
|
+
parts.append(" *On track:*, *Well done:*. Rotate across the session.")
|
|
315
|
+
parts.append(" GOOD: *Strong move:* kicking off pytest right after that")
|
|
316
|
+
parts.append(" edit batch is exactly the habit your profile tracks.")
|
|
317
|
+
parts.append(" BAD: *Nice:* good job writing code today. ← generic")
|
|
318
|
+
parts.append(" Fire sparingly — at most one strength tip per session, and")
|
|
319
|
+
parts.append(" only when the strength is genuinely visible in the current")
|
|
320
|
+
parts.append(" work (not just because it's on the list).")
|
|
321
|
+
parts.append(" SKILL — a personalized recommendation from the user's")
|
|
322
|
+
parts.append(" /coach-insights-trained profile. Must read as personalized, not")
|
|
323
|
+
parts.append(" generic. Labels: *Coach:*, *🦞 From Coach Claw:*.")
|
|
324
|
+
parts.append(" GOOD: *Coach:* /<skill-id> handles this exact loop end-to-end —")
|
|
325
|
+
parts.append(" you've been doing the steps by hand for the last few turns.")
|
|
326
|
+
parts.append(" BAD: *Coach:* You could use /<skill-id>.")
|
|
327
|
+
parts.append(" Only suggest a skill the user has NOT already invoked this")
|
|
328
|
+
parts.append(" session.")
|
|
329
|
+
|
|
330
|
+
weaknesses = [e for e in entries if e.get("direction", "negative") == "negative"]
|
|
331
|
+
strengths = [e for e in entries if e.get("direction") == "positive"]
|
|
332
|
+
|
|
333
|
+
def _render_entry(e: dict) -> None:
|
|
334
|
+
name = e.get("name") or e.get("id", "unknown")
|
|
335
|
+
nudge = (e.get("nudge") or "").strip()
|
|
336
|
+
tier = e.get("tier", "active")
|
|
337
|
+
tier_tag = " (probationary)" if tier == "probationary" else ""
|
|
338
|
+
parts.append(f" • {name}{tier_tag}: {nudge}")
|
|
339
|
+
examples = e.get("examples") or []
|
|
340
|
+
if isinstance(examples, list) and examples:
|
|
341
|
+
ex = str(examples[0]).strip()[:120]
|
|
342
|
+
if ex:
|
|
343
|
+
parts.append(f" prior evidence: \"{ex}\"")
|
|
344
|
+
|
|
345
|
+
if weaknesses:
|
|
346
|
+
parts.append("")
|
|
347
|
+
parts.append("Weaknesses to watch (fire WEAKNESS-flavor tips when these show up):")
|
|
348
|
+
for e in weaknesses:
|
|
349
|
+
_render_entry(e)
|
|
350
|
+
|
|
351
|
+
if strengths:
|
|
352
|
+
parts.append("")
|
|
353
|
+
parts.append("Strengths to reinforce (fire STRENGTH-flavor tips when these show up):")
|
|
354
|
+
for e in strengths:
|
|
355
|
+
_render_entry(e)
|
|
356
|
+
|
|
357
|
+
if weaknesses or strengths:
|
|
358
|
+
parts.append("")
|
|
359
|
+
parts.append(" (Watch list entries are diagnostic context for YOU — do not")
|
|
360
|
+
parts.append(" quote them verbatim. Translate into user-facing advice or")
|
|
361
|
+
parts.append(" acknowledgment as appropriate for the flavor.)")
|
|
362
|
+
|
|
363
|
+
if skill_hints:
|
|
364
|
+
parts.append("")
|
|
365
|
+
parts.append("Skill hints (installed + personally relevant — surface as SKILL tips):")
|
|
366
|
+
for h in skill_hints:
|
|
367
|
+
sid = h.get("id", "?")
|
|
368
|
+
desc = (h.get("description") or "").strip()
|
|
369
|
+
parts.append(f" • /{sid}: {desc}")
|
|
370
|
+
|
|
371
|
+
if changelog_delta:
|
|
372
|
+
parts.append("")
|
|
373
|
+
parts.append(
|
|
374
|
+
"(Coach profile updated since last session — silently noted, do not "
|
|
375
|
+
"mention unless asked:)"
|
|
376
|
+
)
|
|
377
|
+
for ln in changelog_delta.splitlines():
|
|
378
|
+
parts.append(f" {ln}")
|
|
379
|
+
|
|
380
|
+
parts.append("</coach>")
|
|
381
|
+
return "\n".join(parts)
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def _maybe_install_plugin_statusline() -> None:
|
|
385
|
+
"""Plugin distribution only: idempotently set Coach's statusLine in
|
|
386
|
+
~/.claude/settings.json. The plugin model can't declare a top-level
|
|
387
|
+
statusLine in plugin settings.json (Claude Code only honors `agent`
|
|
388
|
+
and `subagentStatusLine` there), so we self-patch from inside a hook
|
|
389
|
+
we already run on every session start.
|
|
390
|
+
|
|
391
|
+
Gated on CLAUDE_PLUGIN_ROOT — only set by Claude Code when running
|
|
392
|
+
under a plugin install. CLI distribution never has it set, so this
|
|
393
|
+
is a no-op for CLI users (whose statusLine is managed by
|
|
394
|
+
install.sh)."""
|
|
395
|
+
plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT")
|
|
396
|
+
if not plugin_root:
|
|
397
|
+
return
|
|
398
|
+
try:
|
|
399
|
+
from statusline_self_patch import ensure_statusline_installed
|
|
400
|
+
ensure_statusline_installed(plugin_root)
|
|
401
|
+
except Exception:
|
|
402
|
+
# Hook failsafe is non-negotiable. Silent on any error.
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
|
|
406
|
+
def _bank_completed_sessions() -> None:
|
|
407
|
+
"""Fire the XP banker in the background (fire-and-forget).
|
|
408
|
+
|
|
409
|
+
bank.py scans recently-completed transcripts and converts session XP
|
|
410
|
+
to lifetime XP at 10:1. Fully silent, never blocks session start.
|
|
411
|
+
"""
|
|
412
|
+
try:
|
|
413
|
+
import subprocess
|
|
414
|
+
import sys as _sys
|
|
415
|
+
bank_script = COACH_DIR / "bin" / "bank.py"
|
|
416
|
+
if bank_script.exists():
|
|
417
|
+
# Use sys.executable so Homebrew / pyenv / system installs all
|
|
418
|
+
# work without a hardcoded /usr/bin/python3.
|
|
419
|
+
subprocess.Popen(
|
|
420
|
+
[_sys.executable, str(bank_script)],
|
|
421
|
+
stdin=subprocess.DEVNULL,
|
|
422
|
+
stdout=subprocess.DEVNULL,
|
|
423
|
+
stderr=subprocess.DEVNULL,
|
|
424
|
+
start_new_session=True,
|
|
425
|
+
)
|
|
426
|
+
except Exception:
|
|
427
|
+
pass
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
WEEKLY_INSIGHTS_MARKER = COACH_DIR / ".last_weekly_insights"
|
|
431
|
+
WEEKLY_INSIGHTS_THROTTLE_SECONDS = 7 * 24 * 3600
|
|
432
|
+
|
|
433
|
+
|
|
434
|
+
def _maybe_spawn_weekly_insights(now: datetime) -> None:
|
|
435
|
+
"""Spawn `insights-llm.sh` detached when the weekly throttle is stale.
|
|
436
|
+
|
|
437
|
+
The wrapper itself enforces the 7-day throttle (see insights-llm.sh's
|
|
438
|
+
--force semantics) — this hook just avoids forking when we already
|
|
439
|
+
know the throttle would skip. Mirrors the bank.py spawn pattern.
|
|
440
|
+
|
|
441
|
+
Trigger choice: SessionStart (not launchd) is deliberate. macOS
|
|
442
|
+
laptops sleeping on battery silently skip launchd jobs without
|
|
443
|
+
`WakeFromSleep`; firing on the user's first session of the week is
|
|
444
|
+
more reliable for a coaching tool with no SLA on weekly freshness.
|
|
445
|
+
"""
|
|
446
|
+
try:
|
|
447
|
+
import subprocess
|
|
448
|
+
script = COACH_DIR / "bin" / "insights-llm.sh"
|
|
449
|
+
if not script.exists():
|
|
450
|
+
return
|
|
451
|
+
if WEEKLY_INSIGHTS_MARKER.exists():
|
|
452
|
+
try:
|
|
453
|
+
age = now.timestamp() - WEEKLY_INSIGHTS_MARKER.stat().st_mtime
|
|
454
|
+
except OSError:
|
|
455
|
+
age = WEEKLY_INSIGHTS_THROTTLE_SECONDS + 1
|
|
456
|
+
if age < WEEKLY_INSIGHTS_THROTTLE_SECONDS:
|
|
457
|
+
return
|
|
458
|
+
subprocess.Popen(
|
|
459
|
+
["bash", str(script)],
|
|
460
|
+
stdin=subprocess.DEVNULL,
|
|
461
|
+
stdout=subprocess.DEVNULL,
|
|
462
|
+
stderr=subprocess.DEVNULL,
|
|
463
|
+
start_new_session=True,
|
|
464
|
+
)
|
|
465
|
+
except Exception:
|
|
466
|
+
pass
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def main() -> None:
|
|
470
|
+
try:
|
|
471
|
+
# Read stdin (Claude Code sends session JSON). Closing prematurely
|
|
472
|
+
# can cause SIGPIPE on the sender, so we always read fully even
|
|
473
|
+
# when we don't use the payload.
|
|
474
|
+
payload: dict = {}
|
|
475
|
+
try:
|
|
476
|
+
raw = sys.stdin.read()
|
|
477
|
+
if raw:
|
|
478
|
+
parsed = json.loads(raw)
|
|
479
|
+
if isinstance(parsed, dict):
|
|
480
|
+
payload = parsed
|
|
481
|
+
except Exception:
|
|
482
|
+
payload = {}
|
|
483
|
+
|
|
484
|
+
# Real Claude Code SessionStart events always carry session_id and/or
|
|
485
|
+
# transcript_path. Without one, the caller is an ad-hoc smoke test
|
|
486
|
+
# or malformed input — in which case spawning bank.py (would race
|
|
487
|
+
# against unknown banked_sessions writes) or weekly insights (would
|
|
488
|
+
# fire a paid /insights call on a stale throttle) is wrong. Exit
|
|
489
|
+
# silently with no side effects.
|
|
490
|
+
if not (
|
|
491
|
+
payload.get("session_id")
|
|
492
|
+
or payload.get("sessionId")
|
|
493
|
+
or payload.get("transcript_path")
|
|
494
|
+
or payload.get("transcriptPath")
|
|
495
|
+
):
|
|
496
|
+
_emit(None)
|
|
497
|
+
return
|
|
498
|
+
|
|
499
|
+
if _should_silence():
|
|
500
|
+
_emit(None)
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
# Plugin distribution: idempotently ensure the statusLine entry
|
|
504
|
+
# is in settings.json. No-op for CLI distribution (gated on
|
|
505
|
+
# CLAUDE_PLUGIN_ROOT). Cheap when matched (single read).
|
|
506
|
+
_maybe_install_plugin_statusline()
|
|
507
|
+
|
|
508
|
+
# Bank yesterday's session XP into lifetime XP (async, never blocks).
|
|
509
|
+
_bank_completed_sessions()
|
|
510
|
+
|
|
511
|
+
now = datetime.now(timezone.utc)
|
|
512
|
+
|
|
513
|
+
# Trigger the weekly LLM-driven insights pass if its 7-day throttle
|
|
514
|
+
# is stale. Detached, never blocks. The wrapper itself reapplies
|
|
515
|
+
# the throttle check before it does any real work.
|
|
516
|
+
_maybe_spawn_weekly_insights(now)
|
|
517
|
+
|
|
518
|
+
if not PROFILE.exists():
|
|
519
|
+
_emit(None)
|
|
520
|
+
return
|
|
521
|
+
|
|
522
|
+
profile = _load_profile()
|
|
523
|
+
entries = _select_entries(profile, now)
|
|
524
|
+
delta = _changelog_delta()
|
|
525
|
+
raw_hints = profile.get("skill_hints") or []
|
|
526
|
+
skill_hints = [h for h in raw_hints if isinstance(h, dict) and h.get("id")]
|
|
527
|
+
env = detect_render_env()
|
|
528
|
+
ctx = _format_context(entries, delta, skill_hints, env=env)
|
|
529
|
+
_emit(ctx if ctx else None)
|
|
530
|
+
except Exception:
|
|
531
|
+
# Failsafe: coach must never block a session.
|
|
532
|
+
_emit(None)
|
|
533
|
+
|
|
534
|
+
|
|
535
|
+
if __name__ == "__main__":
|
|
536
|
+
main()
|