@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,293 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Comprehensive coach progress breakdown for `/coach status`.
|
|
4
|
+
|
|
5
|
+
Reports:
|
|
6
|
+
- Current level + XP total + distance to next level
|
|
7
|
+
- Lifetime XP breakdown: graduations, streak, session banking, milestones
|
|
8
|
+
- Session XP breakdown: test runs, commits, skills (live from current transcript)
|
|
9
|
+
- How to earn more (cheat sheet)
|
|
10
|
+
- Profile state: probationary / active / graduated / archived patterns
|
|
11
|
+
- Recent /coach-insights activity
|
|
12
|
+
|
|
13
|
+
Invoked by the /coach skill. Output is plain text with light ANSI color.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
import sys
|
|
19
|
+
from datetime import datetime
|
|
20
|
+
from pathlib import Path
|
|
21
|
+
|
|
22
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
23
|
+
from coach_paths import resolve_coach_dir # noqa: E402
|
|
24
|
+
|
|
25
|
+
COACH_DIR = resolve_coach_dir()
|
|
26
|
+
PROFILE = COACH_DIR / "profile.yaml"
|
|
27
|
+
CHANGELOG = COACH_DIR / "changelog.md"
|
|
28
|
+
LEDGER = COACH_DIR / "banked_sessions.json"
|
|
29
|
+
PROJECTS = Path.home() / ".claude" / "projects"
|
|
30
|
+
|
|
31
|
+
# Ladder is imported from stats.py so `/coach status` and the statusline
|
|
32
|
+
# can never disagree on level name, threshold, or max rank. The legacy
|
|
33
|
+
# 8-level table used to be inlined here and capped at L8 Sensei, which
|
|
34
|
+
# reported "max level" for any lifetime XP > 90 even though stats.py
|
|
35
|
+
# ranked all the way to L50 Origin.
|
|
36
|
+
from stats import LEVELS as _STATS_LEVELS # type: ignore # noqa: E402
|
|
37
|
+
from scoring import score_transcript_with_breakdown # type: ignore # noqa: E402
|
|
38
|
+
from xp_accounting import normalize_profile_xp # type: ignore # noqa: E402
|
|
39
|
+
LEVELS = _STATS_LEVELS
|
|
40
|
+
|
|
41
|
+
SESSION_XP_CAP = 15
|
|
42
|
+
BAR_SEGMENTS = 10 # wider bar in /status than statusline
|
|
43
|
+
GRADUATION_STREAK_TARGET = 5 # matches coach-user-prompt.py; mid-streak path
|
|
44
|
+
|
|
45
|
+
BOLD = "\x1b[1m"
|
|
46
|
+
DIM = "\x1b[2m"
|
|
47
|
+
RESET = "\x1b[0m"
|
|
48
|
+
CYAN = "\x1b[38;2;200;214;229m"
|
|
49
|
+
GREY = "\x1b[38;2;110;122;140m"
|
|
50
|
+
GOLD = "\x1b[38;2;212;175;55m"
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _level_for(xp: int):
|
|
54
|
+
idx = 0
|
|
55
|
+
for i, (thr, _) in enumerate(LEVELS):
|
|
56
|
+
if xp >= thr:
|
|
57
|
+
idx = i
|
|
58
|
+
cur = LEVELS[idx][0]
|
|
59
|
+
nxt = LEVELS[idx + 1][0] if idx + 1 < len(LEVELS) else None
|
|
60
|
+
return idx, LEVELS[idx][1], cur, nxt
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _current_transcript() -> Path | None:
|
|
64
|
+
"""Find the most recently modified main transcript (likely current session)."""
|
|
65
|
+
if not PROJECTS.exists():
|
|
66
|
+
return None
|
|
67
|
+
newest: Path | None = None
|
|
68
|
+
newest_mtime = 0.0
|
|
69
|
+
for p in PROJECTS.rglob("*.jsonl"):
|
|
70
|
+
if "/subagents/" in str(p):
|
|
71
|
+
continue
|
|
72
|
+
try:
|
|
73
|
+
m = p.stat().st_mtime
|
|
74
|
+
if m > newest_mtime:
|
|
75
|
+
newest_mtime = m
|
|
76
|
+
newest = p
|
|
77
|
+
except Exception:
|
|
78
|
+
continue
|
|
79
|
+
return newest
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _score(path: Path, profile: dict | None = None) -> dict:
|
|
83
|
+
try:
|
|
84
|
+
return score_transcript_with_breakdown(path, profile)
|
|
85
|
+
except Exception:
|
|
86
|
+
return {
|
|
87
|
+
"tests": 0,
|
|
88
|
+
"commits": 0,
|
|
89
|
+
"skills_n": 0,
|
|
90
|
+
"skills_list": [],
|
|
91
|
+
"dynamic_actions": {},
|
|
92
|
+
"available_dynamic_actions": {},
|
|
93
|
+
"raw_xp": 0,
|
|
94
|
+
"capped_xp": 0,
|
|
95
|
+
"capped": False,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _bar(filled: int, total: int) -> str:
|
|
100
|
+
return CYAN + "[" + ("▰" * filled) + RESET + GREY + ("▱" * (total - filled)) + RESET + CYAN + "]" + RESET
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _streak_bar(streak: int, target: int = GRADUATION_STREAK_TARGET) -> str:
|
|
104
|
+
"""🔴🔴🔴⚪⚪ bar — red fill for earned positions, hollow white for
|
|
105
|
+
remaining. Matches coach-user-prompt.py:_streak_bar so /coach status
|
|
106
|
+
and the in-chat tip share the same glyph + color story without
|
|
107
|
+
needing ANSI in either surface."""
|
|
108
|
+
streak = max(0, min(streak, target))
|
|
109
|
+
return "🔴" * streak + "⚪" * (target - streak)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def main() -> int:
|
|
113
|
+
if not PROFILE.exists():
|
|
114
|
+
print("Coach profile not found. Run /coach-insights to initialize.")
|
|
115
|
+
return 0
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
import yaml
|
|
119
|
+
profile = yaml.safe_load(PROFILE.read_text()) or {}
|
|
120
|
+
except Exception as e:
|
|
121
|
+
print(f"Failed to read profile.yaml: {e}")
|
|
122
|
+
return 0
|
|
123
|
+
|
|
124
|
+
entries = profile.get("entries", []) or []
|
|
125
|
+
graduated = [g for g in (profile.get("graduated") or []) if isinstance(g, dict)]
|
|
126
|
+
archived = [a for a in (profile.get("archived") or []) if isinstance(a, dict)]
|
|
127
|
+
xp = normalize_profile_xp(profile)
|
|
128
|
+
graduation_xp = int(xp["graduation_xp"])
|
|
129
|
+
max_streak = int(xp["max_active_clean_streak"])
|
|
130
|
+
session_banked_xp = int(xp["session_banked_xp"])
|
|
131
|
+
milestone_xp = int(xp["milestone_xp"])
|
|
132
|
+
manual_adjustments = int(xp["manual_adjustments"])
|
|
133
|
+
lifetime_xp = int(xp["lifetime_xp"])
|
|
134
|
+
|
|
135
|
+
tpath = _current_transcript()
|
|
136
|
+
session = _score(tpath, profile) if tpath else {
|
|
137
|
+
"tests": 0,
|
|
138
|
+
"commits": 0,
|
|
139
|
+
"skills_n": 0,
|
|
140
|
+
"skills_list": [],
|
|
141
|
+
"dynamic_actions": {},
|
|
142
|
+
"available_dynamic_actions": {},
|
|
143
|
+
"raw_xp": 0,
|
|
144
|
+
"capped_xp": 0,
|
|
145
|
+
"capped": False,
|
|
146
|
+
}
|
|
147
|
+
session_xp = session["capped_xp"]
|
|
148
|
+
|
|
149
|
+
total = lifetime_xp + session_xp
|
|
150
|
+
idx, name, cur, nxt = _level_for(total)
|
|
151
|
+
if nxt is None:
|
|
152
|
+
progress_label = "max level"
|
|
153
|
+
filled = BAR_SEGMENTS
|
|
154
|
+
to_next = 0
|
|
155
|
+
else:
|
|
156
|
+
span = max(1, nxt - cur)
|
|
157
|
+
prog = max(0.0, min(1.0, (total - cur) / span))
|
|
158
|
+
filled = int(round(prog * BAR_SEGMENTS))
|
|
159
|
+
if filled == BAR_SEGMENTS and total < nxt:
|
|
160
|
+
filled = BAR_SEGMENTS - 1
|
|
161
|
+
# "Just arrived" bump — first segment lit as soon as they've earned
|
|
162
|
+
# their way past Drafter, so a fresh level-up never reads as empty.
|
|
163
|
+
if filled == 0 and idx > 0:
|
|
164
|
+
filled = 1
|
|
165
|
+
to_next = nxt - total
|
|
166
|
+
progress_label = f"{to_next} xp to {LEVELS[idx + 1][1]}"
|
|
167
|
+
|
|
168
|
+
# --- Header ---
|
|
169
|
+
print(f"{BOLD}{GOLD}L{idx + 1} {name}{RESET} {_bar(filled, BAR_SEGMENTS)} {CYAN}{total} xp{RESET} {GREY}· {progress_label}{RESET}")
|
|
170
|
+
print()
|
|
171
|
+
|
|
172
|
+
# --- Lifetime breakdown ---
|
|
173
|
+
print(f"{BOLD}Lifetime ({lifetime_xp} xp){RESET}")
|
|
174
|
+
print(f" {graduation_xp:3d} xp {GREY}·{RESET} graduated patterns ({len(graduated)} × 5)")
|
|
175
|
+
print(f" {max_streak:3d} xp {GREY}·{RESET} longest active clean streak")
|
|
176
|
+
n_banked = len(json.loads(LEDGER.read_text())) if LEDGER.exists() else 0
|
|
177
|
+
print(f" {session_banked_xp:3d} xp {GREY}·{RESET} completed sessions ({n_banked} sessions at 10:1)")
|
|
178
|
+
print(f" {milestone_xp:3d} xp {GREY}·{RESET} mid-streak milestones")
|
|
179
|
+
if manual_adjustments:
|
|
180
|
+
print(f" {manual_adjustments:3d} xp {GREY}·{RESET} manual adjustments")
|
|
181
|
+
print()
|
|
182
|
+
|
|
183
|
+
# --- Session breakdown ---
|
|
184
|
+
label_session = f"Session ({session_xp} xp"
|
|
185
|
+
if session["capped"]:
|
|
186
|
+
label_session += f", capped from {session['raw_xp']}"
|
|
187
|
+
label_session += ", cap 15)"
|
|
188
|
+
print(f"{BOLD}{label_session}{RESET}")
|
|
189
|
+
print(f" {session['tests'] * 2:3d} xp {GREY}·{RESET} test runs ({session['tests']} × 2)")
|
|
190
|
+
print(f" {session['commits']:3d} xp {GREY}·{RESET} git commits ({session['commits']})")
|
|
191
|
+
skills_preview = ", ".join(session["skills_list"][:5])
|
|
192
|
+
if len(session["skills_list"]) > 5:
|
|
193
|
+
skills_preview += f", +{len(session['skills_list']) - 5} more"
|
|
194
|
+
print(f" {session['skills_n']:3d} xp {GREY}·{RESET} unique skills invoked ({skills_preview or 'none'})")
|
|
195
|
+
for action, info in (session.get("dynamic_actions") or {}).items():
|
|
196
|
+
count = int(info.get("count", 0) or 0)
|
|
197
|
+
xp_each = int(info.get("xp_each", 0) or 0)
|
|
198
|
+
xp = int(info.get("xp", 0) or 0)
|
|
199
|
+
print(f" {xp:3d} xp {GREY}·{RESET} {action} ({count} × {xp_each})")
|
|
200
|
+
print()
|
|
201
|
+
|
|
202
|
+
# --- How to earn more ---
|
|
203
|
+
print(f"{BOLD}How to earn more{RESET}")
|
|
204
|
+
print(f" {GOLD}+2{RESET} per test runner (pytest / jest / cargo test / ...)")
|
|
205
|
+
print(f" {GOLD}+1{RESET} per git commit")
|
|
206
|
+
print(f" {GOLD}+1{RESET} per unique skill invoked (this session)")
|
|
207
|
+
available_dynamic_actions = session.get("available_dynamic_actions") or {}
|
|
208
|
+
for action, xp_each in available_dynamic_actions.items():
|
|
209
|
+
xp_each = int(xp_each or 0)
|
|
210
|
+
print(f" {GOLD}+{xp_each}{RESET} per {action} action from active reward hints")
|
|
211
|
+
print(f" {GOLD}+5{RESET} per graduated pattern (requires 5-run clean streak)")
|
|
212
|
+
print(f" {GREY}session xp banks at 10:1 into lifetime between sessions{RESET}")
|
|
213
|
+
print()
|
|
214
|
+
|
|
215
|
+
# --- Profile state ---
|
|
216
|
+
weaknesses = [e for e in entries if isinstance(e, dict) and e.get("direction", "negative") == "negative"]
|
|
217
|
+
strengths = [e for e in entries if isinstance(e, dict) and e.get("direction") == "positive"]
|
|
218
|
+
grad_neg = [g for g in graduated if isinstance(g, dict) and g.get("direction", "negative") == "negative"]
|
|
219
|
+
grad_pos = [g for g in graduated if isinstance(g, dict) and g.get("direction") == "positive"]
|
|
220
|
+
archived_neg = [
|
|
221
|
+
a for a in archived
|
|
222
|
+
if isinstance(a, dict) and a.get("direction", "negative") == "negative"
|
|
223
|
+
]
|
|
224
|
+
|
|
225
|
+
# Sort entries by streak descending within each section so the ones
|
|
226
|
+
# closest to graduation/mastery read first.
|
|
227
|
+
print(f"{BOLD}Weaknesses{RESET}")
|
|
228
|
+
if weaknesses:
|
|
229
|
+
actives_n = sum(1 for e in weaknesses if e.get("tier") == "active")
|
|
230
|
+
probs_n = sum(1 for e in weaknesses if e.get("tier") == "probationary")
|
|
231
|
+
print(
|
|
232
|
+
f" {actives_n} active · {probs_n} probationary · "
|
|
233
|
+
f"{len(grad_neg)} retired · {len(archived_neg)} archived"
|
|
234
|
+
)
|
|
235
|
+
weaknesses_sorted = sorted(
|
|
236
|
+
weaknesses,
|
|
237
|
+
key=lambda e: int(e.get("clean_streak_runs", 0) or 0),
|
|
238
|
+
reverse=True,
|
|
239
|
+
)
|
|
240
|
+
for e in weaknesses_sorted[:5]:
|
|
241
|
+
eid = e.get("id", "?")
|
|
242
|
+
streak = int(e.get("clean_streak_runs", 0) or 0)
|
|
243
|
+
tier = e.get("tier", "?")
|
|
244
|
+
bar = _streak_bar(streak)
|
|
245
|
+
label = "graduates" if streak >= GRADUATION_STREAK_TARGET else "to graduation"
|
|
246
|
+
print(
|
|
247
|
+
f" {GREY}·{RESET} {eid} {bar} {CYAN}{streak}/{GRADUATION_STREAK_TARGET}{RESET} "
|
|
248
|
+
f"{GREY}({tier} · {label}){RESET}"
|
|
249
|
+
)
|
|
250
|
+
if len(weaknesses) > 5:
|
|
251
|
+
print(f" {GREY}… {len(weaknesses) - 5} more (see ~/.claude/coach/profile.yaml){RESET}")
|
|
252
|
+
else:
|
|
253
|
+
print(f" {GREY}none tracked · {len(archived_neg)} archived{RESET}")
|
|
254
|
+
print()
|
|
255
|
+
|
|
256
|
+
print(f"{BOLD}Strengths{RESET}")
|
|
257
|
+
if strengths or grad_pos:
|
|
258
|
+
actives_n = sum(1 for e in strengths if e.get("tier") == "active")
|
|
259
|
+
probs_n = sum(1 for e in strengths if e.get("tier") == "probationary")
|
|
260
|
+
print(f" {actives_n} active · {probs_n} probationary · {len(grad_pos)} mastered")
|
|
261
|
+
strengths_sorted = sorted(
|
|
262
|
+
strengths,
|
|
263
|
+
key=lambda e: int(e.get("positive_run_streak", 0) or 0),
|
|
264
|
+
reverse=True,
|
|
265
|
+
)
|
|
266
|
+
for e in strengths_sorted[:5]:
|
|
267
|
+
eid = e.get("id", "?")
|
|
268
|
+
streak = int(e.get("positive_run_streak", 0) or 0)
|
|
269
|
+
tier = e.get("tier", "?")
|
|
270
|
+
bar = _streak_bar(streak)
|
|
271
|
+
label = "masters" if streak >= GRADUATION_STREAK_TARGET else "to mastery"
|
|
272
|
+
print(
|
|
273
|
+
f" {GREY}·{RESET} {eid} {bar} {CYAN}{streak}/{GRADUATION_STREAK_TARGET}{RESET} "
|
|
274
|
+
f"{GREY}({tier} · {label}){RESET}"
|
|
275
|
+
)
|
|
276
|
+
if len(strengths) > 5:
|
|
277
|
+
print(f" {GREY}… {len(strengths) - 5} more (see ~/.claude/coach/profile.yaml){RESET}")
|
|
278
|
+
else:
|
|
279
|
+
print(f" {GREY}none tracked yet — /coach-insights scans for strengths at ≥60% session frequency{RESET}")
|
|
280
|
+
|
|
281
|
+
# --- Latest /coach-insights ---
|
|
282
|
+
if CHANGELOG.exists():
|
|
283
|
+
try:
|
|
284
|
+
last = [ln for ln in CHANGELOG.read_text().splitlines() if ln.strip()][-1]
|
|
285
|
+
print(f" {GREY}Last /coach-insights: {last[:120]}{RESET}")
|
|
286
|
+
except Exception:
|
|
287
|
+
pass
|
|
288
|
+
|
|
289
|
+
return 0
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
if __name__ == "__main__":
|
|
293
|
+
sys.exit(main())
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
"""Idempotently install Coach's main statusLine into ~/.claude/settings.json.
|
|
2
|
+
|
|
3
|
+
Plugin distribution only — Claude Code's plugin model doesn't expose the
|
|
4
|
+
top-level `statusLine` key in plugin settings.json (only `agent` and
|
|
5
|
+
`subagentStatusLine` are supported per plugins-reference). Workaround:
|
|
6
|
+
the plugin's SessionStart hook calls `ensure_statusline_installed()` on
|
|
7
|
+
every session start; first call writes the entry, subsequent calls are
|
|
8
|
+
O(read).
|
|
9
|
+
|
|
10
|
+
Why a self-patching approach (vs a one-time companion CLI):
|
|
11
|
+
- Self-healing on plugin install / version-bump.
|
|
12
|
+
- No extra setup step — `/plugin install coach-claw` and the
|
|
13
|
+
statusline appears next session.
|
|
14
|
+
- The cost — `/plugin uninstall coach-claw` does NOT auto-clean the
|
|
15
|
+
settings.json key (Anthropic's plugin lifecycle doesn't touch
|
|
16
|
+
settings.json keys plugins added). `/coach-claw:doctor
|
|
17
|
+
--remove-statusline` covers that path.
|
|
18
|
+
|
|
19
|
+
Decisions:
|
|
20
|
+
- `${CLAUDE_PLUGIN_ROOT}` is NOT expanded inside settings.json (only
|
|
21
|
+
in plugin's own files). Resolve absolute path at write time.
|
|
22
|
+
- If a `statusLine` already exists pointing at Coach's known marker
|
|
23
|
+
paths, no-op.
|
|
24
|
+
- If a `statusLine` already exists pointing somewhere else (user's
|
|
25
|
+
custom one, another plugin's), DO NOT overwrite. Log via stderr.
|
|
26
|
+
- Atomic write under flock (mirrors merge.py:atomic_write_yaml).
|
|
27
|
+
- CLI distribution: this module is in coach/bin/ for code-share, but
|
|
28
|
+
the CLI's coach-session-start.py never calls into it (CLI manages
|
|
29
|
+
its own statusLine via install.sh). Gating happens at the call
|
|
30
|
+
site via `os.environ.get("CLAUDE_PLUGIN_ROOT")`.
|
|
31
|
+
"""
|
|
32
|
+
from __future__ import annotations
|
|
33
|
+
|
|
34
|
+
import fcntl
|
|
35
|
+
import json
|
|
36
|
+
import os
|
|
37
|
+
import shlex
|
|
38
|
+
import sys
|
|
39
|
+
import tempfile
|
|
40
|
+
from pathlib import Path
|
|
41
|
+
from typing import Optional
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
|
|
45
|
+
|
|
46
|
+
# Substring markers that identify a statusLine command as Coach's. The
|
|
47
|
+
# default-shape markers (CLI install via default-statusline-command.sh,
|
|
48
|
+
# plugin via default_statusline.py through bootstrap.sh) and the wrap-
|
|
49
|
+
# shape markers (statusline_wrap.py for plugin, default-statusline-wrap-
|
|
50
|
+
# command.sh for CLI) are all recognized, so a user with any combination
|
|
51
|
+
# installed never gets a duplicate or overwrite.
|
|
52
|
+
COACH_STATUSLINE_MARKERS = (
|
|
53
|
+
"default-statusline-command.sh",
|
|
54
|
+
"default_statusline.py",
|
|
55
|
+
"statusline_wrap.py",
|
|
56
|
+
"default-statusline-wrap-command.sh",
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
# Subset of the markers above that identify the WRAP shape specifically.
|
|
60
|
+
# Lets `ensure_statusline_installed` route ours-wrapped through a path-
|
|
61
|
+
# freshness refresh instead of the simple matched no-op.
|
|
62
|
+
_WRAP_STATUSLINE_MARKERS = (
|
|
63
|
+
"statusline_wrap.py",
|
|
64
|
+
"default-statusline-wrap-command.sh",
|
|
65
|
+
)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
def _is_coach_statusline(entry) -> bool:
|
|
69
|
+
if not isinstance(entry, dict):
|
|
70
|
+
return False
|
|
71
|
+
cmd = str(entry.get("command", ""))
|
|
72
|
+
return any(m in cmd for m in COACH_STATUSLINE_MARKERS)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _is_wrap_statusline(entry) -> bool:
|
|
76
|
+
if not isinstance(entry, dict):
|
|
77
|
+
return False
|
|
78
|
+
cmd = str(entry.get("command", ""))
|
|
79
|
+
return any(m in cmd for m in _WRAP_STATUSLINE_MARKERS)
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _desired_entry(plugin_root: Path) -> dict:
|
|
83
|
+
"""Build the statusLine entry for the plugin distribution.
|
|
84
|
+
|
|
85
|
+
Uses bootstrap.sh as the wrapper so PyYAML imports inside
|
|
86
|
+
default_statusline.py succeed (the script imports stats.py which
|
|
87
|
+
imports user_config.py which doesn't need yaml directly, but
|
|
88
|
+
keeping a single entry pattern simplifies the model).
|
|
89
|
+
"""
|
|
90
|
+
bootstrap = shlex.quote(str(plugin_root / "bin" / "bootstrap.sh"))
|
|
91
|
+
statusline_py = shlex.quote(str(plugin_root / "bin" / "default_statusline.py"))
|
|
92
|
+
return {
|
|
93
|
+
"type": "command",
|
|
94
|
+
"command": f"{bootstrap} {statusline_py}",
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def _atomic_write(path: Path, payload: dict) -> None:
|
|
99
|
+
"""tempfile + os.replace under flock — same pattern as merge.py."""
|
|
100
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
101
|
+
lock_path = path.with_suffix(path.suffix + ".lock")
|
|
102
|
+
with open(lock_path, "w") as lock_fh:
|
|
103
|
+
try:
|
|
104
|
+
fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
|
|
105
|
+
except Exception:
|
|
106
|
+
pass # flock unsupported — proceed best-effort
|
|
107
|
+
fd, tmp = tempfile.mkstemp(
|
|
108
|
+
prefix="." + path.name + ".",
|
|
109
|
+
suffix=".tmp",
|
|
110
|
+
dir=str(path.parent),
|
|
111
|
+
)
|
|
112
|
+
try:
|
|
113
|
+
with os.fdopen(fd, "w") as fh:
|
|
114
|
+
json.dump(payload, fh, indent=2)
|
|
115
|
+
fh.flush()
|
|
116
|
+
os.fsync(fh.fileno())
|
|
117
|
+
os.replace(tmp, path)
|
|
118
|
+
except Exception:
|
|
119
|
+
try:
|
|
120
|
+
os.unlink(tmp)
|
|
121
|
+
except Exception:
|
|
122
|
+
pass
|
|
123
|
+
raise
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def ensure_statusline_installed(
|
|
127
|
+
plugin_root: str,
|
|
128
|
+
settings_path: Optional[Path] = None,
|
|
129
|
+
) -> str:
|
|
130
|
+
"""Idempotently set Coach's statusLine in `settings_path`.
|
|
131
|
+
|
|
132
|
+
Returns one of:
|
|
133
|
+
"installed" — wrote a new entry (was absent).
|
|
134
|
+
"matched" — already present and ours; no write.
|
|
135
|
+
"wrap-refreshed" — ours-wrapped with stale plugin path; rewrote
|
|
136
|
+
with current ${CLAUDE_PLUGIN_ROOT}.
|
|
137
|
+
"wrapped" — auto-wrapped a user's claimed statusline so
|
|
138
|
+
the Coach segment now appends to it.
|
|
139
|
+
"claimed" — present pointing at someone else AND auto-
|
|
140
|
+
wrap was suppressed (opt-out marker or manual
|
|
141
|
+
Coach integration detected).
|
|
142
|
+
"skipped" — settings.json doesn't exist (very fresh user;
|
|
143
|
+
Claude Code creates it on first run).
|
|
144
|
+
"error" — exception during read/write (always fail-soft).
|
|
145
|
+
|
|
146
|
+
`settings_path` defaults to ~/.claude/settings.json; tests pass a
|
|
147
|
+
tmpdir path.
|
|
148
|
+
"""
|
|
149
|
+
target = settings_path or SETTINGS_PATH
|
|
150
|
+
try:
|
|
151
|
+
if not target.exists():
|
|
152
|
+
return "skipped"
|
|
153
|
+
|
|
154
|
+
try:
|
|
155
|
+
data = json.loads(target.read_text())
|
|
156
|
+
except json.JSONDecodeError:
|
|
157
|
+
return "error"
|
|
158
|
+
if not isinstance(data, dict):
|
|
159
|
+
return "error"
|
|
160
|
+
|
|
161
|
+
plugin_root_p = Path(plugin_root).resolve()
|
|
162
|
+
existing = data.get("statusLine")
|
|
163
|
+
if existing:
|
|
164
|
+
# Wrap shape — delegate to the action module so path-freshness
|
|
165
|
+
# refresh and idempotency live in one place.
|
|
166
|
+
if _is_wrap_statusline(existing):
|
|
167
|
+
try:
|
|
168
|
+
import statusline_wrap_action as wa # noqa: WPS433
|
|
169
|
+
res = wa.wrap(
|
|
170
|
+
settings_path=target,
|
|
171
|
+
plugin_root=plugin_root_p,
|
|
172
|
+
)
|
|
173
|
+
except Exception:
|
|
174
|
+
return "matched" # fail-soft; no rewrite happened
|
|
175
|
+
if res.get("reason") == "refreshed-path":
|
|
176
|
+
return "wrap-refreshed"
|
|
177
|
+
return "matched"
|
|
178
|
+
if _is_coach_statusline(existing):
|
|
179
|
+
return "matched"
|
|
180
|
+
|
|
181
|
+
# Claimed: auto-wrap on first encounter, unless opted out or
|
|
182
|
+
# manual-Coach integration detected. wrap_action.wrap()
|
|
183
|
+
# handles both gates internally and falls through to a wrap
|
|
184
|
+
# mutation on success.
|
|
185
|
+
try:
|
|
186
|
+
import statusline_wrap_action as wa # noqa: WPS433
|
|
187
|
+
res = wa.wrap(
|
|
188
|
+
settings_path=target,
|
|
189
|
+
plugin_root=plugin_root_p,
|
|
190
|
+
)
|
|
191
|
+
except Exception:
|
|
192
|
+
res = {"result": "error"}
|
|
193
|
+
if res.get("result") == "wrapped":
|
|
194
|
+
return "wrapped"
|
|
195
|
+
sys.stderr.write(
|
|
196
|
+
"coach-claw plugin: settings.json statusLine left "
|
|
197
|
+
"untouched. Run /coach-claw:doctor to inspect.\n"
|
|
198
|
+
)
|
|
199
|
+
return "claimed"
|
|
200
|
+
|
|
201
|
+
data["statusLine"] = _desired_entry(plugin_root_p)
|
|
202
|
+
_atomic_write(target, data)
|
|
203
|
+
return "installed"
|
|
204
|
+
except Exception:
|
|
205
|
+
return "error"
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
"""Statusline render variants — four compact one-liners that occupy the
|
|
2
|
+
trailing slot in Claude Code's status row.
|
|
3
|
+
|
|
4
|
+
Each variant takes a `Glyphs` payload (level index, level name, ELO,
|
|
5
|
+
session XP gain, sigil color tier — "bronze"/"silver"/"gold"/"platinum"/
|
|
6
|
+
"diamond") and returns a single ANSI-colored string ≤ ~30 visible chars.
|
|
7
|
+
|
|
8
|
+
The selected variant is read from `~/.claude/coach/.user_config.json`
|
|
9
|
+
via `user_config.get_variant()`. Default = "crystal" (preserves the
|
|
10
|
+
v0.2.0 look).
|
|
11
|
+
|
|
12
|
+
Adding a variant: append a render function and register it in VARIANTS.
|
|
13
|
+
The `/config` slash command lists keys from VARIANTS so users can see
|
|
14
|
+
them without re-reading source.
|
|
15
|
+
"""
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass
|
|
19
|
+
from typing import Callable
|
|
20
|
+
|
|
21
|
+
# --- ANSI palette (24-bit RGB) -------------------------------------------
|
|
22
|
+
# Shared with the rest of the coach renderer + the user's statusline. Keep
|
|
23
|
+
# in sync with stats.py if changed.
|
|
24
|
+
ICE_SILVER = "\x1b[38;2;200;214;229m"
|
|
25
|
+
DIM_STEEL = "\x1b[38;2;58;58;74m"
|
|
26
|
+
MUTED_STEEL = "\x1b[38;2;110;122;140m"
|
|
27
|
+
RESET = "\x1b[0m"
|
|
28
|
+
GAIN_EMERALD = "\x1b[38;2;90;218;170m"
|
|
29
|
+
BOLD = "\x1b[1m"
|
|
30
|
+
|
|
31
|
+
SIGIL_COLORS = {
|
|
32
|
+
"bronze": "\x1b[38;2;205;127;50m",
|
|
33
|
+
"silver": "\x1b[38;2;200;205;215m",
|
|
34
|
+
"gold": "\x1b[38;2;245;197;66m",
|
|
35
|
+
"platinum": "\x1b[38;2;180;220;235m",
|
|
36
|
+
"diamond": "\x1b[38;2;150;240;255m",
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
# Roman numeral converter (concatenated form for readability past Ⅹ).
|
|
40
|
+
_ROMAN_TENS = ["", "Ⅹ", "ⅩⅩ", "ⅩⅩⅩ", "ⅩⅬ", "Ⅼ"]
|
|
41
|
+
_ROMAN_UNITS = ["", "Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ", "Ⅴ", "Ⅵ", "Ⅶ", "Ⅷ", "Ⅸ"]
|
|
42
|
+
|
|
43
|
+
def to_roman(n: int) -> str:
|
|
44
|
+
n = max(1, min(n, 50))
|
|
45
|
+
return _ROMAN_TENS[n // 10] + _ROMAN_UNITS[n % 10]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@dataclass(frozen=True)
|
|
49
|
+
class Glyphs:
|
|
50
|
+
"""Render-time payload — pre-computed by stats.py and passed verbatim
|
|
51
|
+
to a variant function. No business logic in variants."""
|
|
52
|
+
level: int # 1-indexed (L1..L50)
|
|
53
|
+
name: str # theme-resolved level name
|
|
54
|
+
elo: int # 4-digit rating, e.g. 1232
|
|
55
|
+
session_xp: int # raw session XP, 0..15 (capped)
|
|
56
|
+
sigil_tier: str # "bronze"/"silver"/"gold"/"platinum"/"diamond"
|
|
57
|
+
bar_pct: float # 0..1 within-level progress (used by pip-bar variants)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _gain(g: Glyphs) -> str:
|
|
61
|
+
"""Trailing ↑N session-gain arrow. Empty when session_xp == 0."""
|
|
62
|
+
return f" {GAIN_EMERALD}↑{g.session_xp}{RESET}" if g.session_xp > 0 else ""
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _sigil_color(tier: str) -> str:
|
|
66
|
+
return SIGIL_COLORS.get(tier, SIGIL_COLORS["bronze"])
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
# === VARIANTS ============================================================
|
|
70
|
+
# Each variant returns a ready-to-print ANSI string.
|
|
71
|
+
|
|
72
|
+
def render_crystal(g: Glyphs) -> str:
|
|
73
|
+
"""The v0.2.0 default. Sigil + roman + ELO + name + arrow.
|
|
74
|
+
|
|
75
|
+
◆ Ⅶ 1232 Virtuoso ↑15
|
|
76
|
+
"""
|
|
77
|
+
sigil = f"{_sigil_color(g.sigil_tier)}◆{RESET}"
|
|
78
|
+
roman = f"{BOLD}{ICE_SILVER}{to_roman(g.level)}{RESET}"
|
|
79
|
+
elo = f"{ICE_SILVER}{g.elo:04d}{RESET}"
|
|
80
|
+
name = f"{MUTED_STEEL}{g.name}{RESET}"
|
|
81
|
+
return f"{sigil} {roman} {elo} {name}{_gain(g)}"
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def render_pips(g: Glyphs) -> str:
|
|
85
|
+
"""Lead with within-level progress as a 5-pip bar. Filled glyphs
|
|
86
|
+
transition color with sigil tier (bronze→silver→gold→platinum→
|
|
87
|
+
diamond), so the bar visually levels up as the user does.
|
|
88
|
+
|
|
89
|
+
●●○○○ Virtuoso ↑15
|
|
90
|
+
"""
|
|
91
|
+
filled = max(0, min(5, round(g.bar_pct * 5)))
|
|
92
|
+
pips = (
|
|
93
|
+
f"{_sigil_color(g.sigil_tier)}{'●' * filled}{RESET}"
|
|
94
|
+
f"{DIM_STEEL}{'○' * (5 - filled)}{RESET}"
|
|
95
|
+
)
|
|
96
|
+
name = f"{BOLD}{ICE_SILVER}{g.name}{RESET}"
|
|
97
|
+
return f"{pips} {name}{_gain(g)}"
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def render_slash(g: Glyphs) -> str:
|
|
101
|
+
"""Path-style separators with a crossed-swords sigil, terse and
|
|
102
|
+
dev-shell aesthetic. The ⚔ glyph color tracks sigil tier (same
|
|
103
|
+
bronze→…→diamond progression as `◆` and `⚒`).
|
|
104
|
+
|
|
105
|
+
⚔ L7 / Virtuoso ↑15
|
|
106
|
+
"""
|
|
107
|
+
sigil = f"{_sigil_color(g.sigil_tier)}⚔{RESET}"
|
|
108
|
+
sep = f"{MUTED_STEEL}/{RESET}"
|
|
109
|
+
lvl = f"{BOLD}{ICE_SILVER}L{g.level}{RESET}"
|
|
110
|
+
name = f"{ICE_SILVER}{g.name}{RESET}"
|
|
111
|
+
return f"{sigil} {lvl} {sep} {name}{_gain(g)}"
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def render_forge(g: Glyphs) -> str:
|
|
115
|
+
"""Anvil-themed sigil, name leads, level follows.
|
|
116
|
+
|
|
117
|
+
⚒ Virtuoso · L7 ↑15
|
|
118
|
+
"""
|
|
119
|
+
sigil = f"{_sigil_color(g.sigil_tier)}⚒{RESET}"
|
|
120
|
+
name = f"{BOLD}{ICE_SILVER}{g.name}{RESET}"
|
|
121
|
+
sep = f"{MUTED_STEEL}·{RESET}"
|
|
122
|
+
lvl = f"{ICE_SILVER}L{g.level}{RESET}"
|
|
123
|
+
return f"{sigil} {name} {sep} {lvl}{_gain(g)}"
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# === REGISTRY ============================================================
|
|
127
|
+
VARIANTS: dict[str, Callable[[Glyphs], str]] = {
|
|
128
|
+
"crystal": render_crystal,
|
|
129
|
+
"pips": render_pips,
|
|
130
|
+
"slash": render_slash,
|
|
131
|
+
"forge": render_forge,
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
DEFAULT_VARIANT = "crystal"
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def render(variant: str, glyphs: Glyphs) -> str:
|
|
138
|
+
"""Dispatch to the selected variant. Falls back to default on unknown
|
|
139
|
+
keys — caller never sees a KeyError."""
|
|
140
|
+
fn = VARIANTS.get(variant, VARIANTS[DEFAULT_VARIANT])
|
|
141
|
+
return fn(glyphs)
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def list_variants() -> list[str]:
|
|
145
|
+
"""Stable list of variant keys, default first for `/config`."""
|
|
146
|
+
return [DEFAULT_VARIANT] + sorted(k for k in VARIANTS if k != DEFAULT_VARIANT)
|