@rm0nroe/coach-claw 1.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (100) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +311 -0
  3. package/coach/README.md +99 -0
  4. package/coach/bin/aggregate_facets.py +274 -0
  5. package/coach/bin/analyze.py +678 -0
  6. package/coach/bin/bank.py +247 -0
  7. package/coach/bin/banner_themes.py +645 -0
  8. package/coach/bin/coach_paths.py +33 -0
  9. package/coach/bin/coexistence_check.py +129 -0
  10. package/coach/bin/configure.py +245 -0
  11. package/coach/bin/cron_check.py +81 -0
  12. package/coach/bin/default_statusline.py +135 -0
  13. package/coach/bin/doctor.py +663 -0
  14. package/coach/bin/insights-llm.sh +264 -0
  15. package/coach/bin/insights.sh +163 -0
  16. package/coach/bin/insights_window.py +111 -0
  17. package/coach/bin/marker_io.py +154 -0
  18. package/coach/bin/merge.py +671 -0
  19. package/coach/bin/redact.py +86 -0
  20. package/coach/bin/render_env.py +148 -0
  21. package/coach/bin/reward_hints.py +87 -0
  22. package/coach/bin/run-insights.sh +20 -0
  23. package/coach/bin/run_with_lock.py +85 -0
  24. package/coach/bin/scoring.py +260 -0
  25. package/coach/bin/skill_inventory.py +215 -0
  26. package/coach/bin/stats.py +459 -0
  27. package/coach/bin/status.py +293 -0
  28. package/coach/bin/statusline_self_patch.py +205 -0
  29. package/coach/bin/statusline_variants.py +146 -0
  30. package/coach/bin/statusline_wrap.py +244 -0
  31. package/coach/bin/statusline_wrap_action.py +460 -0
  32. package/coach/bin/switch_to_plugin.py +256 -0
  33. package/coach/bin/themes.py +256 -0
  34. package/coach/bin/user_config.py +176 -0
  35. package/coach/bin/xp_accounting.py +98 -0
  36. package/coach/changelog.md +4 -0
  37. package/coach/default-statusline-command.sh +19 -0
  38. package/coach/default-statusline-wrap-command.sh +15 -0
  39. package/coach/profile.yaml +37 -0
  40. package/coach/tests/conftest.py +13 -0
  41. package/coach/tests/test_aggregate_facets.py +379 -0
  42. package/coach/tests/test_analyze_aggregate.py +153 -0
  43. package/coach/tests/test_analyze_redaction.py +105 -0
  44. package/coach/tests/test_analyze_strengths.py +165 -0
  45. package/coach/tests/test_bank_atomic_write.py +61 -0
  46. package/coach/tests/test_bank_concurrency.py +126 -0
  47. package/coach/tests/test_banner_themes.py +981 -0
  48. package/coach/tests/test_celebrate_dedup.py +409 -0
  49. package/coach/tests/test_coach_paths.py +50 -0
  50. package/coach/tests/test_coexistence_check.py +128 -0
  51. package/coach/tests/test_configure.py +258 -0
  52. package/coach/tests/test_cron_check.py +118 -0
  53. package/coach/tests/test_cron_nudge_hook.py +134 -0
  54. package/coach/tests/test_detection_parity.py +105 -0
  55. package/coach/tests/test_doctor.py +595 -0
  56. package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
  57. package/coach/tests/test_hook_module_resolution.py +116 -0
  58. package/coach/tests/test_hook_relevance.py +996 -0
  59. package/coach/tests/test_hook_render_env.py +364 -0
  60. package/coach/tests/test_hook_session_id_guard.py +160 -0
  61. package/coach/tests/test_insights_llm.py +759 -0
  62. package/coach/tests/test_insights_llm_venv_path.py +109 -0
  63. package/coach/tests/test_insights_window.py +237 -0
  64. package/coach/tests/test_install.py +1150 -0
  65. package/coach/tests/test_install_pyyaml_fallback.py +142 -0
  66. package/coach/tests/test_marker_consumption.py +167 -0
  67. package/coach/tests/test_marker_writer_locking.py +305 -0
  68. package/coach/tests/test_merge.py +413 -0
  69. package/coach/tests/test_no_broken_mktemp.py +90 -0
  70. package/coach/tests/test_render_env.py +137 -0
  71. package/coach/tests/test_render_env_glyphs.py +119 -0
  72. package/coach/tests/test_reward_hints.py +59 -0
  73. package/coach/tests/test_scoring.py +147 -0
  74. package/coach/tests/test_session_start_weekly_trigger.py +92 -0
  75. package/coach/tests/test_skill_inventory.py +368 -0
  76. package/coach/tests/test_stats_hybrid.py +142 -0
  77. package/coach/tests/test_status_accounting.py +41 -0
  78. package/coach/tests/test_statusline_failsafe.py +70 -0
  79. package/coach/tests/test_statusline_self_patch.py +261 -0
  80. package/coach/tests/test_statusline_variants.py +110 -0
  81. package/coach/tests/test_statusline_wrap.py +196 -0
  82. package/coach/tests/test_statusline_wrap_action.py +408 -0
  83. package/coach/tests/test_switch_to_plugin.py +360 -0
  84. package/coach/tests/test_themes.py +104 -0
  85. package/coach/tests/test_user_config.py +160 -0
  86. package/coach/tests/test_wrap_announce_hook.py +130 -0
  87. package/coach/tests/test_xp_accounting.py +55 -0
  88. package/hooks/coach-session-start.py +536 -0
  89. package/hooks/coach-user-prompt.py +2288 -0
  90. package/install-launchd.sh +102 -0
  91. package/install.sh +597 -0
  92. package/launchd/com.local.claude-coach.plist.template +34 -0
  93. package/launchd/run-insights.sh +20 -0
  94. package/npm/coach-claw.js +259 -0
  95. package/package.json +52 -0
  96. package/requirements.txt +11 -0
  97. package/settings-snippet.json +31 -0
  98. package/skills/coach/SKILL.md +107 -0
  99. package/skills/coach-insights/SKILL.md +78 -0
  100. package/skills/config/SKILL.md +149 -0
@@ -0,0 +1,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)