@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,645 @@
1
+ """Per-theme bespoke <coach-celebrate> banner shapes.
2
+
3
+ Five of the twelve themes get bespoke streak-reward + level-up rendering:
4
+ forge, ocean, skyrim, military, hacker. The other seven keep the default
5
+ shape rendered by `hooks/coach-user-prompt.py:_assemble_celebrate_block`.
6
+
7
+ Bespoke shapes are TERMINAL-ONLY. IDE rendering stays on the existing
8
+ HR-framed default for all themes — bespoke ASCII frames clash with the
9
+ WebView's proportional-ish typography.
10
+
11
+ Public API:
12
+ BESPOKE_THEMES — frozenset of theme names with bespoke shapes
13
+ render_celebrate_for_theme(...) — full block renderer, or None if
14
+ the theme is not bespoke
15
+
16
+ Verbatim-render contract: every banner string is fully-resolved Python
17
+ text. No template-fill via the model. Pinned by literal-substring tests
18
+ in coach/tests/test_banner_themes.py.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ from datetime import datetime, timedelta
23
+ from typing import Callable
24
+
25
+ from render_env import supports_dual_blade
26
+
27
+
28
+ BESPOKE_THEMES = frozenset({"forge", "ocean", "skyrim", "military", "hacker"})
29
+
30
+
31
+ # -----------------------------------------------------------------------------
32
+ # Shared helpers — the small kernel that every theme uses.
33
+
34
+ def _meter(streak: int, target: int, filled: str, empty: str) -> str:
35
+ """Streak meter: `filled * streak + empty * (target - streak)`. Both
36
+ glyphs MUST be 1-cell-wide on the rendering terminal — themes that use
37
+ dual-cell-risk glyphs (e.g., skyrim's ⚔) negotiate fallback before
38
+ calling this."""
39
+ streak = max(0, min(streak, target))
40
+ return filled * streak + empty * max(target - streak, 0)
41
+
42
+
43
+ def _arrow_xp(direction: str, xp: int, *, with_unit: bool = False) -> str:
44
+ """`↑N` for positive, `↓N` for negative. Set `with_unit=True` for
45
+ `↑N XP` (military variant)."""
46
+ arrow = "↑" if direction == "positive" else "↓"
47
+ return f"{arrow}{xp} XP" if with_unit else f"{arrow}{xp}"
48
+
49
+
50
+ def _format_window_phrase(now: datetime, oldest: datetime | None) -> dict:
51
+ """Return per-theme date phrasings for the header window.
52
+
53
+ Keys:
54
+ relative — "yesterday" / "2026-05-06" (skyrim/ocean)
55
+ iso_date — "2026-05-06" (forge / skyrim full)
56
+ iso_datetime — "2026-05-06 19:00" (hacker)
57
+ now_iso_date — "2026-05-07" (military)
58
+ now_zulu_time — "0500Z" (military)
59
+ """
60
+ now_date = now.strftime("%Y-%m-%d")
61
+ now_zulu = now.strftime("%H%MZ")
62
+ if oldest is None:
63
+ return {
64
+ "relative": "earlier",
65
+ "iso_date": now_date,
66
+ "iso_datetime": now.strftime("%Y-%m-%d %H:%M"),
67
+ "now_iso_date": now_date,
68
+ "now_zulu_time": now_zulu,
69
+ }
70
+ iso_date = oldest.strftime("%Y-%m-%d")
71
+ delta_days = (now.date() - oldest.date()).days
72
+ if delta_days == 0:
73
+ relative = "earlier today"
74
+ elif delta_days == 1:
75
+ relative = "yesterday"
76
+ else:
77
+ relative = iso_date
78
+ return {
79
+ "relative": relative,
80
+ "iso_date": iso_date,
81
+ "iso_datetime": oldest.strftime("%Y-%m-%d %H:%M"),
82
+ "now_iso_date": now_date,
83
+ "now_zulu_time": now_zulu,
84
+ }
85
+
86
+
87
+ # -----------------------------------------------------------------------------
88
+ # Verb-style themes — forge, ocean, skyrim share the row skeleton:
89
+ # `> {meter} {name:<W} {verb:<V} {arrow}{xp}`
90
+ # Differences live in the spec dict (header glyph, label, meter glyphs,
91
+ # verb words, footer template).
92
+
93
+ # Top-N cap for streak rows. Banners that ticked many patterns at once
94
+ # (e.g., catch-up after a multi-day idle) get noisy fast — show the
95
+ # closest-to-graduation N and a tail line counting the rest.
96
+ TOP_N = 5
97
+
98
+
99
+ def _sort_and_truncate(rewards: list[dict]) -> tuple[list[dict], int]:
100
+ """Group by direction (positive first, then negative), sort each group
101
+ by streak descending (closer-to-graduation first), then truncate to
102
+ TOP_N. Returns (rows_to_render, hidden_count)."""
103
+ def _key(r: dict) -> tuple:
104
+ return (-int(r.get("streak", 0)), r.get("name") or r.get("id", "?"))
105
+
106
+ pos = sorted(
107
+ (r for r in rewards if r.get("direction") == "positive"),
108
+ key=_key,
109
+ )
110
+ neg = sorted(
111
+ (r for r in rewards if r.get("direction") != "positive"),
112
+ key=_key,
113
+ )
114
+ ordered = pos + neg
115
+ return ordered[:TOP_N], max(0, len(ordered) - TOP_N)
116
+
117
+
118
+ def _render_verb_style_rows(
119
+ rewards: list[dict],
120
+ *,
121
+ meter_filled: str,
122
+ meter_empty: str,
123
+ verb_positive: str,
124
+ verb_negative: str,
125
+ ) -> list[str]:
126
+ """Return one rendered row string per reward, in input order.
127
+
128
+ Column rule: name padded to (max_name + 4); verb padded to max_verb
129
+ in the input set; 3 spaces between verb and arrow. Matches the locked
130
+ visual cadence from the user's mockups."""
131
+ if not rewards:
132
+ return []
133
+
134
+ names = [r.get("name", r.get("id", "?")) for r in rewards]
135
+ verbs = [
136
+ verb_positive if r.get("direction") == "positive" else verb_negative
137
+ for r in rewards
138
+ ]
139
+ name_pad = max(len(n) for n in names) + 4
140
+ verb_pad = max(len(v) for v in verbs)
141
+
142
+ out: list[str] = []
143
+ for r, name, verb in zip(rewards, names, verbs):
144
+ meter = _meter(
145
+ int(r.get("streak", 0)),
146
+ int(r.get("target", 5)),
147
+ meter_filled,
148
+ meter_empty,
149
+ )
150
+ xp = _arrow_xp(r.get("direction", "negative"), int(r.get("xp_awarded", 1)))
151
+ out.append(
152
+ f"> {meter} {name:<{name_pad}}{verb:<{verb_pad}} {xp}"
153
+ )
154
+ return out
155
+
156
+
157
+ # Spec dict shape — read by `_render_verb_style`.
158
+ # header_glyph: 1-cell glyph at the front of the header line
159
+ # header_label: themed phrase for the header (e.g., "The Anvil")
160
+ # header_window_template: format string with {relative}/{iso_date} keys
161
+ # meter_filled / meter_empty: 1-cell glyphs for the streak meter
162
+ # verb_positive / verb_negative: words slotted into each row
163
+ # levelup_glyph: 1-cell or emoji glyph at the front of the footer
164
+ # levelup_template: format string with {name}/{level}/{next_xp} keys
165
+ SPECS: dict[str, dict] = {
166
+ "ocean": {
167
+ "header_glyph": "🦞",
168
+ "header_label": "Tide turned",
169
+ "header_window_template": "since {relative}",
170
+ "meter_filled": "≋",
171
+ "meter_empty": "·",
172
+ "verb_positive": "rising tide",
173
+ "verb_negative": "ebbing",
174
+ "levelup_glyph": "⚓",
175
+ # ⚓ is a 2-cell emoji; tighten the gap so it visually matches the
176
+ # 1-cell glyph + 2-space pad used by skyrim's ⚜.
177
+ "levelup_glyph_pad": " ",
178
+ # 🌊Deep Water🌊 — Reefer (L8) · next fathom at 125 XP
179
+ "levelup_template": (
180
+ "🌊Deep Water🌊 — {name} (L{level}) · next fathom at {next_xp} XP"
181
+ ),
182
+ "levelup_max_template": (
183
+ "🌊Deep Water🌊 — {name} (L{level}) · all fathoms reached"
184
+ ),
185
+ },
186
+ "forge": {
187
+ "header_glyph": "⚒",
188
+ "header_label": "The Anvil",
189
+ "header_window_template": "{iso_date} → now",
190
+ "meter_filled": "▰",
191
+ "meter_empty": "▱",
192
+ "verb_positive": "tempering",
193
+ "verb_negative": "quenching",
194
+ "levelup_glyph": "✨",
195
+ # ✨ **Mastersmith** (L8) forged anew · next heat at 125 XP
196
+ "levelup_template": (
197
+ "**{name}** (L{level}) forged anew · next heat at {next_xp} XP"
198
+ ),
199
+ "levelup_max_template": (
200
+ "**{name}** (L{level}) — the forge is mastered"
201
+ ),
202
+ },
203
+ "skyrim": {
204
+ # Header + meter use ⚔ (U+2694 CROSSED SWORDS). When the active
205
+ # terminal can't render that as a single cell (TERM=dumb, non-UTF8
206
+ # locale, kill switch), the dispatcher swaps both to ✕.
207
+ "header_glyph": "⚔",
208
+ "header_glyph_fallback": "✕",
209
+ "header_label": "Saga",
210
+ "header_window_template": "since {iso_date}",
211
+ "meter_filled": "⚔",
212
+ "meter_filled_fallback": "✕",
213
+ "meter_empty": "·",
214
+ "verb_positive": "oath kept",
215
+ "verb_negative": "curse fades",
216
+ "levelup_glyph": "⚜",
217
+ # ⚜ **Pupil** (L8) — next title at 125 XP
218
+ "levelup_template": (
219
+ "**{name}** (L{level}) — next title at {next_xp} XP"
220
+ ),
221
+ "levelup_max_template": (
222
+ "**{name}** (L{level}) — saga complete"
223
+ ),
224
+ },
225
+ }
226
+
227
+
228
+ def _resolve_spec(theme: str, dual_blade_supported: bool) -> dict:
229
+ """Apply glyph fallbacks to the spec for the active theme + terminal.
230
+ Returns a plain dict — callers must not mutate the SPECS source."""
231
+ spec = dict(SPECS[theme])
232
+ if not dual_blade_supported:
233
+ for key in ("header_glyph", "meter_filled"):
234
+ fallback_key = f"{key}_fallback"
235
+ if fallback_key in spec:
236
+ spec[key] = spec[fallback_key]
237
+ return spec
238
+
239
+
240
+ def _render_verb_style(
241
+ spec: dict,
242
+ *,
243
+ streak_rewards: list[dict],
244
+ levelup: dict | None,
245
+ now: datetime,
246
+ streak_oldest: datetime | None,
247
+ ) -> str:
248
+ """Compose the bespoke streak + levelup section for a verb-style theme.
249
+
250
+ Returns a string (no enclosing <coach-celebrate> tags — caller wraps).
251
+ Empty streak_rewards + no levelup yields an empty string."""
252
+ parts: list[str] = []
253
+
254
+ if streak_rewards:
255
+ window = _format_window_phrase(now, streak_oldest)
256
+ window_text = spec["header_window_template"].format(**window)
257
+ parts.append(
258
+ f"> {spec['header_glyph']} {spec['header_label']} · {window_text}"
259
+ )
260
+ parts.append(">")
261
+ rows_to_show, hidden = _sort_and_truncate(streak_rewards)
262
+ parts.extend(_render_verb_style_rows(
263
+ rows_to_show,
264
+ meter_filled=spec["meter_filled"],
265
+ meter_empty=spec["meter_empty"],
266
+ verb_positive=spec["verb_positive"],
267
+ verb_negative=spec["verb_negative"],
268
+ ))
269
+ if hidden > 0:
270
+ parts.append(f"> …{hidden} more")
271
+
272
+ if levelup:
273
+ # Compose the level-up footer. Pull idx + level name from the
274
+ # marker payload directly — this avoids reading bank state on the
275
+ # hot path (compute_for_render is reserved for the military theme,
276
+ # which actually needs ELO + medal_count).
277
+ level = int(levelup.get("to_idx", 0)) + 1
278
+ name = str(levelup.get("to", "?"))
279
+ # next_xp: the threshold for the level AFTER the one we just
280
+ # crossed. At L50 there is no next level — the renderer swaps to
281
+ # `levelup_max_template` which omits the "next at X XP" suffix.
282
+ next_xp = _next_xp_after_levelup(levelup)
283
+ if next_xp == 0:
284
+ template = spec["levelup_max_template"]
285
+ else:
286
+ template = spec["levelup_template"]
287
+ line = template.format(name=name, level=level, next_xp=next_xp)
288
+ if streak_rewards:
289
+ parts.append(">")
290
+ pad = spec.get("levelup_glyph_pad", " ")
291
+ parts.append(f"> {spec['levelup_glyph']}{pad}{line}")
292
+
293
+ return "\n".join(parts)
294
+
295
+
296
+ # -----------------------------------------------------------------------------
297
+ # Military theme — divergent shape (tag-prefixed rows, rank ribbon footer
298
+ # composed from stats.compute_for_render). Doesn't fit the verb-style
299
+ # helper because rows have a prefix tag instead of a verb suffix.
300
+
301
+ def _render_military(
302
+ *,
303
+ streak_rewards: list[dict],
304
+ levelup: dict | None,
305
+ now: datetime,
306
+ streak_oldest: datetime | None,
307
+ ) -> str:
308
+ """Compose the bespoke military streak + levelup section.
309
+
310
+ Header:
311
+ > ◢ SITREP · 2026-05-07 · 0500Z
312
+
313
+ Rows have a [PUSH]/[HOLD] tag prefix and use ▮▯ meter, no verb column,
314
+ `↑N XP` / `↓N XP` (with unit):
315
+ > [PUSH] ▮▮▮▮▯ safe git hygiene ↑2 XP
316
+
317
+ Footer is a rank ribbon — medal count + Roman numeral + ELO + theme-
318
+ aware level name + next-promotion threshold:
319
+ > ◆ 🎖️🎖️ Ⅷ 1263 **Sensei** · promotion at 125 XP
320
+ """
321
+ parts: list[str] = []
322
+
323
+ if streak_rewards:
324
+ window = _format_window_phrase(now, streak_oldest)
325
+ parts.append(
326
+ f"> ◢ SITREP · {window['now_iso_date']} · {window['now_zulu_time']}"
327
+ )
328
+ parts.append(">")
329
+
330
+ rows_to_show, hidden = _sort_and_truncate(streak_rewards)
331
+ # Pad name column based on the longest visible name.
332
+ names = [r.get("name") or r.get("id", "?") for r in rows_to_show]
333
+ name_pad = (max((len(n) for n in names), default=0)) + 4
334
+
335
+ for r, name in zip(rows_to_show, names):
336
+ tag = "[PUSH]" if r.get("direction") == "positive" else "[HOLD]"
337
+ meter = _meter(
338
+ int(r.get("streak", 0)),
339
+ int(r.get("target", 5)),
340
+ "▮",
341
+ "▯",
342
+ )
343
+ xp = _arrow_xp(
344
+ r.get("direction", "negative"),
345
+ int(r.get("xp_awarded", 1)),
346
+ with_unit=True,
347
+ )
348
+ parts.append(f"> {tag} {meter} {name:<{name_pad}}{xp}")
349
+
350
+ if hidden > 0:
351
+ parts.append(f"> …{hidden} more")
352
+
353
+ if levelup:
354
+ # Compose rank ribbon from compute_for_render. The lifetime XP at
355
+ # the moment of levelup is `xp_at_levelup` from the marker — the
356
+ # threshold the user just crossed. Session XP = 0 here because the
357
+ # ribbon represents the levelup state, not in-progress slide.
358
+ import stats # late import — stats reads user_config at module init
359
+ meta = stats.compute_for_render(
360
+ int(levelup.get("xp_at_levelup", 0)),
361
+ 0,
362
+ )
363
+ # Trust the levelup payload's "to" name in case a custom theme
364
+ # ladder differs from the live LEVELS (e.g., user changed theme
365
+ # between marker write and read). Fall back to compute_for_render's
366
+ # lookup if "to" is missing.
367
+ name = str(levelup.get("to") or meta["name"])
368
+ medals = "🎖️" * meta["medal_count"]
369
+ if streak_rewards:
370
+ parts.append(">")
371
+ # At L50, compute_for_render returns next_xp=None — there is no
372
+ # promotion threshold to render. Swap to a max-rank suffix.
373
+ if meta["next_xp"] is None:
374
+ tail = "· highest grade"
375
+ else:
376
+ tail = f"· promotion at {meta['next_xp']} XP"
377
+ parts.append(
378
+ f"> ◆ {medals} {meta['roman']} {meta['elo']} "
379
+ f"**{name}** {tail}"
380
+ )
381
+
382
+ return "\n".join(parts)
383
+
384
+
385
+ # -----------------------------------------------------------------------------
386
+ # Hacker theme — divergent shape (no verb column, snake_case names, log
387
+ # frame). Doesn't fit the verb-style helper, has its own renderer.
388
+
389
+ def _name_to_snake(name: str) -> str:
390
+ """'safe git hygiene' → 'safe_git_hygiene'. Hacker theme convention."""
391
+ return name.lower().replace(" ", "_").replace("-", "_")
392
+
393
+
394
+ def _render_hacker(
395
+ *,
396
+ streak_rewards: list[dict],
397
+ levelup: dict | None,
398
+ now: datetime,
399
+ streak_oldest: datetime | None,
400
+ ) -> str:
401
+ """Compose the bespoke hacker streak + levelup section.
402
+
403
+ Header is a 2-line shell-prompt + dashed-timestamp frame:
404
+ > 👾 [coach@claw ~]$ tail -f session.log
405
+ > ── 2026-05-06 19:00 → now ────────────────
406
+
407
+ Rows use a shell-coded RUN/KILL prefix so direction is preserved
408
+ (both directions earn XP, but they're semantically different events):
409
+ > ▓▓▓▓░ RUN safe_git_hygiene [↑2 xp]
410
+ > ▓▓▓▓░ KILL heavy_subagent_delegation [↑2 xp]
411
+
412
+ The `↑` in `[↑N xp]` denotes direction of XP movement (always up,
413
+ since both row types are gains). RUN/KILL encodes which kind of
414
+ pattern produced the gain — strength reinforced vs weakness retired.
415
+
416
+ Tail (when truncated): ASCII `...` and a help hint:
417
+ > ...4 more (cat /coach/status)
418
+
419
+ Footer: 2-line uplink/breach shape:
420
+ > :: 📡 UPLINK ↑ L8 / Sensei 🥷 ::
421
+ > next breach 🔓 125 xp
422
+ """
423
+ parts: list[str] = []
424
+
425
+ if streak_rewards:
426
+ window = _format_window_phrase(now, streak_oldest)
427
+ parts.append("> 👾 [coach@claw ~]$ tail -f session.log")
428
+ # The trailing dashes pad the timestamp line to a fixed visual
429
+ # width — matches the locked mockup's `── ... ────────────────`
430
+ # shape. Width chosen so that yesterday-19:00 phrase sits in the
431
+ # middle of the line. 16 trailing dashes covers the locked length.
432
+ parts.append(f"> ── {window['iso_datetime']} → now ────────────────")
433
+ parts.append(">")
434
+
435
+ rows_to_show, hidden = _sort_and_truncate(streak_rewards)
436
+ snake_names = [_name_to_snake(r.get("name") or r.get("id", "?"))
437
+ for r in rows_to_show]
438
+ name_pad = (max((len(n) for n in snake_names), default=0)) + 4
439
+
440
+ for r, sname in zip(rows_to_show, snake_names):
441
+ meter = _meter(
442
+ int(r.get("streak", 0)),
443
+ int(r.get("target", 5)),
444
+ "▓",
445
+ "░",
446
+ )
447
+ xp = int(r.get("xp_awarded", 1))
448
+ # RUN / KILL — direction prefix in shell-coded vocabulary.
449
+ # Both 4 chars wide so the snake_case name column stays
450
+ # aligned across mixed-direction banners.
451
+ prefix = "RUN " if r.get("direction") == "positive" else "KILL"
452
+ parts.append(
453
+ f"> {meter} {prefix} {sname:<{name_pad}}[↑{xp} xp]"
454
+ )
455
+
456
+ if hidden > 0:
457
+ parts.append(f"> ...{hidden} more (cat /coach/status)")
458
+
459
+ if levelup:
460
+ level = int(levelup.get("to_idx", 0)) + 1
461
+ name = str(levelup.get("to", "?"))
462
+ next_xp = _next_xp_after_levelup(levelup)
463
+ if streak_rewards:
464
+ parts.append(">")
465
+ parts.append(f"> :: 📡 UPLINK ↑ L{level} / {name} 🥷 ::")
466
+ # At L50, no next breach — swap the threshold line for a max-rank
467
+ # marker that doesn't promise more progression.
468
+ if next_xp == 0:
469
+ parts.append("> root access 🔓 max layer reached")
470
+ else:
471
+ parts.append(f"> next breach 🔓 {next_xp} xp")
472
+
473
+ return "\n".join(parts)
474
+
475
+
476
+ def _next_xp_after_levelup(levelup: dict) -> int:
477
+ """The XP threshold for the level immediately after the one the user
478
+ just crossed. Read from the active LEVELS ladder in stats — themed
479
+ naming + ELO range honor the user's /config selection automatically.
480
+
481
+ Returns 0 when at L50 (no next level)."""
482
+ import stats # late import: stats reads user_config at module init
483
+ to_idx = int(levelup.get("to_idx", 0))
484
+ next_idx = to_idx + 1
485
+ if next_idx >= len(stats.LEVELS):
486
+ return 0
487
+ return int(stats.LEVELS[next_idx][0])
488
+
489
+
490
+ # -----------------------------------------------------------------------------
491
+ # Public dispatch.
492
+
493
+ def render_celebrate_for_theme(
494
+ theme: str,
495
+ *,
496
+ streak_rewards: list[dict],
497
+ levelup: dict | None,
498
+ grads_block: str = "",
499
+ regs_block: str = "",
500
+ now: datetime,
501
+ streak_oldest: datetime | None = None,
502
+ dual_blade_supported: bool | None = None,
503
+ caught_up: bool = False,
504
+ ) -> str | None:
505
+ """Return the full <coach-celebrate>...</coach-celebrate> block for a
506
+ bespoke theme, or None if the theme isn't in BESPOKE_THEMES (caller
507
+ falls back to default rendering).
508
+
509
+ Composition order inside the block:
510
+ 1. Verbatim-render preamble (same as default).
511
+ 2. Pre-rendered regressions block (default-shape, may be empty).
512
+ 3. Bespoke streak section (theme header + rows).
513
+ 4. Pre-rendered graduations block (default-shape, may be empty).
514
+ 5. Bespoke level-up footer.
515
+
516
+ Steps 2 + 4 are passed in as already-rendered strings — the hook calls
517
+ `_regression_block` / `_graduation_block` from coach-user-prompt.py to
518
+ produce them (avoids a circular import).
519
+
520
+ `dual_blade_supported`: explicit override for tests. None means
521
+ "probe live env" — production callers leave this unset.
522
+
523
+ Returns None if the bespoke render produces no body (no streak rewards,
524
+ no levelup, no grads, no regs) — caller should not emit an empty block.
525
+ """
526
+ if theme not in BESPOKE_THEMES:
527
+ return None
528
+
529
+ if dual_blade_supported is None:
530
+ dual_blade_supported = supports_dual_blade()
531
+
532
+ if theme in SPECS:
533
+ spec = _resolve_spec(theme, dual_blade_supported)
534
+ bespoke_section = _render_verb_style(
535
+ spec,
536
+ streak_rewards=streak_rewards,
537
+ levelup=levelup,
538
+ now=now,
539
+ streak_oldest=streak_oldest,
540
+ )
541
+ elif theme == "hacker":
542
+ bespoke_section = _render_hacker(
543
+ streak_rewards=streak_rewards,
544
+ levelup=levelup,
545
+ now=now,
546
+ streak_oldest=streak_oldest,
547
+ )
548
+ elif theme == "military":
549
+ bespoke_section = _render_military(
550
+ streak_rewards=streak_rewards,
551
+ levelup=levelup,
552
+ now=now,
553
+ streak_oldest=streak_oldest,
554
+ )
555
+ else: # pragma: no cover — guard against future theme key drift
556
+ return None
557
+
558
+ # Guard: if every section is empty, return None so the caller doesn't
559
+ # emit a vacuous <coach-celebrate>...</coach-celebrate>.
560
+ if not (bespoke_section or grads_block or regs_block):
561
+ return None
562
+
563
+ out: list[str] = ["<coach-celebrate>"]
564
+ out.append(
565
+ "The block below is a pre-rendered set of milestone banners. "
566
+ "Render this block VERBATIM at the very top of your next response, "
567
+ "BEFORE any other content, then continue with the user's request. "
568
+ "Do NOT re-interpret labels, swap directions, change emoji, or "
569
+ "substitute slugs for names — every character is intentional and "
570
+ "pinned by tests."
571
+ )
572
+ # Catch-up framing: emit ONLY when there's no streak header to carry
573
+ # the date phrasing. The streak header already says "since {date}",
574
+ # making the framing line redundant for streak banners (locked v1
575
+ # decision). Levelup-only / grad-only / reg-only bespoke banners
576
+ # have no theme header, so the framing line earns its place.
577
+ if caught_up and not streak_rewards:
578
+ out.append("")
579
+ out.append(
580
+ "Milestones earned across earlier sessions — not from the "
581
+ "command you just typed."
582
+ )
583
+ out.append("")
584
+
585
+ # Order: regressions first (big news), then bespoke streak section,
586
+ # then graduations (ceremonies between streak ticks and the level-up
587
+ # crown), then bespoke level-up.
588
+ if regs_block:
589
+ out.append(regs_block)
590
+ out.append("")
591
+ if bespoke_section and streak_rewards:
592
+ # Split bespoke_section into streak header+rows vs levelup tail
593
+ # so graduations can land BETWEEN them. The marker for the split
594
+ # is the level-up glyph line — easier to render the parts
595
+ # separately than try to slice a composed string.
596
+ streak_part, _, levelup_part = _split_bespoke_sections(
597
+ bespoke_section
598
+ )
599
+ out.append(streak_part)
600
+ if grads_block:
601
+ out.append("")
602
+ out.append(grads_block)
603
+ if levelup_part:
604
+ out.append("")
605
+ out.append(levelup_part)
606
+ elif bespoke_section:
607
+ # Levelup-only (no streak rewards). Graduations land above.
608
+ if grads_block:
609
+ out.append(grads_block)
610
+ out.append("")
611
+ out.append(bespoke_section)
612
+ elif grads_block:
613
+ out.append(grads_block)
614
+
615
+ out.append("</coach-celebrate>")
616
+ return "\n".join(out)
617
+
618
+
619
+ def _split_bespoke_sections(body: str) -> tuple[str, str, str]:
620
+ """Split a verb-style render into (streak_part, separator, levelup_part).
621
+
622
+ The split is a blank `>` line that precedes the levelup glyph line.
623
+ Returns ("body", "", "") when no levelup is present (single-section)."""
624
+ lines = body.split("\n")
625
+ # Find the last blank-blockquote separator. Levelup is always last.
626
+ # If the streak section's empty divider (between header and rows) is
627
+ # the only `>` line, there's no levelup section.
628
+ # Heuristic: levelup is the FINAL ">" + glyph + body chunk; everything
629
+ # before the immediately-preceding ">" line is the streak section.
630
+ last_blank = -1
631
+ for i, line in enumerate(lines):
632
+ if line == ">":
633
+ last_blank = i
634
+ # If the last blank is followed by a blockquote line that isn't a
635
+ # row indent (rows start with "> ", levelup starts with "> {glyph}"),
636
+ # treat as split point.
637
+ if last_blank == -1 or last_blank == len(lines) - 1:
638
+ return body, "", ""
639
+ tail = lines[last_blank + 1]
640
+ if tail.startswith("> "):
641
+ # Tail is another row — no levelup section.
642
+ return body, "", ""
643
+ streak_part = "\n".join(lines[:last_blank])
644
+ levelup_part = "\n".join(lines[last_blank + 1:])
645
+ return streak_part, "", levelup_part
@@ -0,0 +1,33 @@
1
+ """Resolve the Coach Claw state directory.
2
+
3
+ Single source of truth for `~/.claude/coach/` path resolution. Honors the
4
+ `COACH_CONFIG_DIR` env var so:
5
+
6
+ - tests can monkeypatch via `monkeypatch.setenv("COACH_CONFIG_DIR", ...)`,
7
+ - the npm CLI wrapper can export `COACH_CONFIG_DIR` to match a custom
8
+ `CLAUDE_DIR` install path,
9
+ - the upcoming Claude Code plugin distribution can point its hooks at a
10
+ plugin-managed data dir without forking any logic.
11
+
12
+ Resolution happens per-call, never cached. That's deliberate — the env
13
+ var may be set after import time (test setup, subprocess wrappers) and
14
+ caching would break that contract. Cost is negligible: a single
15
+ `os.environ.get` per call.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ from pathlib import Path
21
+
22
+
23
+ def resolve_coach_dir() -> Path:
24
+ """Return the Coach Claw state directory.
25
+
26
+ Honors `COACH_CONFIG_DIR` (overrides everything); falls back to
27
+ `~/.claude/coach`. Caller is responsible for creating the directory
28
+ if it doesn't exist — this helper only resolves the path.
29
+ """
30
+ base = os.environ.get("COACH_CONFIG_DIR")
31
+ if base:
32
+ return Path(base)
33
+ return Path.home() / ".claude" / "coach"