@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,215 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Skill inventory builder.
4
+
5
+ Scans ~/.claude/skills/*/SKILL.md (user-level only, not plugin-bundled —
6
+ those are already too noisy and mostly auto-triggered), reads the frontmatter
7
+ description, then returns a JSON list of skills the user has NOT invoked in
8
+ this window. The result is written to profile.yaml as a reference snapshot
9
+ for the SessionStart hook to surface as present-tense suggestions.
10
+
11
+ Usage:
12
+ skill_inventory.py --used-json <path> # JSON dict {skill_id: count}
13
+ Prints JSON array of {id, description, last_invoked, projects} to stdout.
14
+
15
+ `projects` is read from the optional SKILL.md frontmatter field of the
16
+ same name. When set, the hook treats the skill as project-scoped — it
17
+ only fires when the current cwd's project matches one of the declared
18
+ entries. Untagged skills (empty list) behave globally as before.
19
+ """
20
+ from __future__ import annotations
21
+
22
+ import argparse
23
+ import json
24
+ import re
25
+ import sys
26
+ from pathlib import Path
27
+
28
+ SKILLS_DIR = Path.home() / ".claude" / "skills"
29
+
30
+ # Skip self-referential and trivial-meta skills
31
+ EXCLUDE_IDS = {
32
+ "coach", "insights", # self (don't recommend coach to itself)
33
+ "checkpoint", "load", "sessions", # session lifecycle — auto-used
34
+ }
35
+
36
+ MAX_HINTS = 20
37
+
38
+ # Inference threshold: number of invocations in a project before that
39
+ # project counts as an "observed" home for a skill. ≥2 protects against
40
+ # one-off experimental invocations from contaminating the inferred
41
+ # scope. Tunable here if real-world data argues for a stricter or
42
+ # looser bar; everywhere else just consumes _infer_projects().
43
+ INFER_THRESHOLD = 2
44
+
45
+
46
+ def _parse_frontmatter_text(text: str) -> dict:
47
+ """Extract YAML frontmatter from a SKILL.md body. Returns an empty
48
+ dict on any failure — frontmatter is best-effort and the /coach-insights
49
+ cron path must never crash on a malformed SKILL.md file."""
50
+ m = re.match(r"^---\s*\n(.*?)\n---\s*\n", text, re.DOTALL)
51
+ if not m:
52
+ return {}
53
+ try:
54
+ import yaml
55
+ except Exception:
56
+ return {}
57
+ try:
58
+ data = yaml.safe_load(m.group(1))
59
+ except Exception:
60
+ return {}
61
+ return data if isinstance(data, dict) else {}
62
+
63
+
64
+ def parse_frontmatter(md_path: Path) -> dict:
65
+ try:
66
+ text = md_path.read_text()
67
+ except Exception:
68
+ return {}
69
+ return _parse_frontmatter_text(text)
70
+
71
+
72
+ def _infer_projects(
73
+ skill_id: str,
74
+ by_project: dict,
75
+ threshold: int = INFER_THRESHOLD,
76
+ ) -> list[str]:
77
+ """Infer a skill's project scope from rolling invocation history.
78
+
79
+ ``by_project`` is the effective accumulator
80
+ ``{project: {skill_id: count}}`` (existing profile state + this
81
+ run's delta).
82
+
83
+ Rule:
84
+ - "Observed" projects = where this skill has been invoked
85
+ ≥``threshold`` times.
86
+ - If observed in ≥2 projects → return ``[]`` (cross-cutting;
87
+ graduates to global behavior identical to today's untagged
88
+ skills). Auto-handles tools like /design or /capability-loop
89
+ that legitimately span projects.
90
+ - If observed in exactly 1 project → return ``[that_project]``.
91
+ - Otherwise → ``[]`` (cold-start: not enough signal to claim
92
+ scope yet; the hook's untagged-skill rules still apply).
93
+
94
+ Frontmatter ``projects:`` always supersedes inference — see
95
+ main(). This function is purely advisory.
96
+ """
97
+ if not isinstance(by_project, dict):
98
+ return []
99
+ observed: list[str] = []
100
+ for proj, skills in by_project.items():
101
+ if not isinstance(skills, dict):
102
+ continue
103
+ try:
104
+ count = int(skills.get(skill_id, 0))
105
+ except (TypeError, ValueError):
106
+ continue
107
+ if count >= threshold:
108
+ observed.append(str(proj))
109
+ if len(observed) == 1:
110
+ return observed
111
+ return [] # 0 → cold-start, ≥2 → graduated to global
112
+
113
+
114
+ def _coerce_projects(raw) -> list[str]:
115
+ """Normalize the frontmatter `projects:` field into a list of
116
+ non-empty strings. Accepts list-form (normal), scalar-string
117
+ (single-project shorthand), anything else → empty list."""
118
+ if raw is None:
119
+ return []
120
+ if isinstance(raw, str):
121
+ s = raw.strip()
122
+ return [s] if s else []
123
+ if isinstance(raw, (list, tuple)):
124
+ out: list[str] = []
125
+ for p in raw:
126
+ if isinstance(p, str) and p.strip():
127
+ out.append(p.strip())
128
+ return out
129
+ return []
130
+
131
+
132
+ def main():
133
+ ap = argparse.ArgumentParser()
134
+ ap.add_argument("--used-json", type=Path, default=None)
135
+ ap.add_argument(
136
+ "--skills-by-project", type=Path, default=None,
137
+ help="Optional JSON {project: {skill_id: count}} of effective "
138
+ "rolling invocation history (profile state + this run's "
139
+ "delta). When provided, skills without an explicit "
140
+ "frontmatter `projects:` field get scope inferred from "
141
+ "history (≥2 invocations in 1 project → tagged; ≥2 "
142
+ "projects observed → graduated to global; otherwise "
143
+ "untagged).")
144
+ args = ap.parse_args()
145
+
146
+ used = {}
147
+ if args.used_json and args.used_json.exists():
148
+ try:
149
+ used = json.loads(args.used_json.read_text()) or {}
150
+ except Exception:
151
+ used = {}
152
+
153
+ by_project: dict = {}
154
+ if args.skills_by_project and args.skills_by_project.exists():
155
+ try:
156
+ raw = json.loads(args.skills_by_project.read_text()) or {}
157
+ if isinstance(raw, dict):
158
+ by_project = raw
159
+ except Exception:
160
+ by_project = {}
161
+
162
+ hints: list[dict] = []
163
+ if not SKILLS_DIR.exists():
164
+ print("[]")
165
+ return
166
+
167
+ for skill_dir in sorted(SKILLS_DIR.iterdir()):
168
+ if not skill_dir.is_dir():
169
+ continue
170
+ skill_md = skill_dir / "SKILL.md"
171
+ if not skill_md.is_file():
172
+ continue
173
+ sid = skill_dir.name
174
+ if sid in EXCLUDE_IDS:
175
+ continue
176
+ # Skip if user invoked it in this window
177
+ if int(used.get(sid, 0)) > 0:
178
+ continue
179
+ fm = parse_frontmatter(skill_md)
180
+ desc_raw = fm.get("description")
181
+ desc = (desc_raw.strip() if isinstance(desc_raw, str) else "")
182
+ if not desc:
183
+ continue
184
+ # Truncate long descriptions to keep injection context small
185
+ desc_short = desc[:220].rstrip()
186
+ if len(desc) > 220:
187
+ desc_short += "..."
188
+ # Frontmatter `projects:` is authoritative when present at all;
189
+ # otherwise we infer from rolling invocation history. The key
190
+ # distinction: `projects: []` (explicitly empty) is a USER
191
+ # DECLARATION of "this skill is cross-project / global" and must
192
+ # NOT silently fall through to inference, which could re-tag
193
+ # the skill from history and reverse the user's intent.
194
+ # `fm.get("projects")` returns None only when the key is
195
+ # genuinely absent (PyYAML's safe_load yields None for
196
+ # `projects:` with no value too — treated as absent here, since
197
+ # the key with no value carries no semantic content).
198
+ raw_projects = fm.get("projects")
199
+ if raw_projects is None:
200
+ projects = _infer_projects(sid, by_project)
201
+ else:
202
+ projects = _coerce_projects(raw_projects)
203
+ hints.append({
204
+ "id": sid,
205
+ "description": desc_short,
206
+ "last_invoked": None,
207
+ "projects": projects,
208
+ })
209
+
210
+ hints = hints[:MAX_HINTS]
211
+ print(json.dumps(hints, indent=2))
212
+
213
+
214
+ if __name__ == "__main__":
215
+ main()
@@ -0,0 +1,459 @@
1
+ #!/usr/bin/env python3
2
+ """
3
+ Emit a statusline segment: sigil + Roman numeral + ELO + level name.
4
+
5
+ XP comes from two places:
6
+
7
+ LIFETIME (from ~/.claude/coach/profile.yaml):
8
+ graduation_xp — current graduated patterns × 5
9
+ max_active_streak — current longest clean streak, in runs
10
+ session_banked_xp — past sessions banked at 10:1
11
+ milestone_xp — mid-streak rewards from /coach-insights
12
+ manual_adjustments — explicit operator edits
13
+
14
+ SESSION (from the active transcript, if passed in via statusline stdin):
15
+ +2 per test-runner Bash invocation (pytest/jest/cargo/go test/...)
16
+ +1 per `git commit`
17
+ +1 per unique skill invoked (SlashCommand or Skill tool)
18
+
19
+ Session XP is capped at 15/session so it never dominates graduations.
20
+
21
+ Display vs bank ratio (whole-number + live)
22
+ -------------------------------------------
23
+ `bank.py` converts a session to lifetime XP at 10:1 (`session // 10`), so
24
+ a raw session ranges 0-15 but only 0-1 ever lands in lifetime. The `↑N`
25
+ arrow renders the **raw session count** (`↑5`, `↑15`) — always a whole
26
+ integer, always reflects live activity. The 10:1 bank ratio stays intact
27
+ internally; it's just not what the arrow displays.
28
+
29
+ Level vs rating are decoupled so the statusline is stable AND feels live:
30
+ • Level index + level-up detection use `lifetime + session // 10`
31
+ (integer — no phantom level-ups that get clawed back at session end).
32
+ • Sigil color + ELO within-level slide use `lifetime + session / 10`
33
+ (float — rating nudges on every test/commit, sigil glides through
34
+ shades within a session even before a bank tick lands).
35
+
36
+ Level ladder: 50 unique levels (see LEVEL_NAMES below), starting at
37
+ Drafter (L1 / 0 xp) and ending at Origin (L50 / 5865 xp). L1-L8 thresholds
38
+ preserved from the original 8-level ladder so existing XP totals don't
39
+ trigger retroactive level-ups. After L8 each delta grows +5 per level.
40
+
41
+ Output (ANSI-colored):
42
+ ◆ Ⅱ 1044 Iterator ↑5
43
+
44
+ • ◆ sigil — bronze → silver → gold → platinum → diamond by within-level %
45
+ • Roman numeral — bold, identifies the level (Ⅰ-Ⅼ, concatenated past Ⅹ)
46
+ • ELO — linear 1000 (L1) → 2800 (L50), 4-digit feel
47
+ • Level name — muted
48
+ • ↑N — raw session XP in emerald (whole integer), only shown when session > 0
49
+
50
+ Silent (no output) when profile.yaml is missing AND session XP is 0.
51
+ """
52
+ from __future__ import annotations
53
+
54
+ import fcntl
55
+ import json
56
+ import os
57
+ import sys
58
+ import tempfile
59
+ from datetime import datetime, timezone
60
+ from pathlib import Path
61
+
62
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
63
+ from marker_io import atomic_marker_replace # noqa: E402
64
+ from statusline_variants import Glyphs, render as render_variant # noqa: E402
65
+ from themes import THEME_CRAFT, get_ladder # noqa: E402
66
+ from user_config import load as load_user_config # noqa: E402
67
+ from xp_accounting import normalize_profile_xp # noqa: E402
68
+
69
+ PROFILE = Path.home() / ".claude" / "coach" / "profile.yaml"
70
+ LEVEL_STATE = Path.home() / ".claude" / "coach" / ".level_state.json"
71
+ LEVELUP_MARKER = Path.home() / ".claude" / "coach" / ".pending_levelup"
72
+
73
+ # Level-name ladder is theme-driven now (themes.py). The default theme
74
+ # ("craft") preserves the original 50 names so existing installs without
75
+ # a .user_config.json see no change. status.py imports LEVEL_NAMES via
76
+ # the same indirection so /coach status and the statusline can never
77
+ # disagree on what level you're at.
78
+ LEVEL_NAMES = THEME_CRAFT
79
+
80
+
81
+ def _build_level_ladder(names: list[str] | None = None) -> list[tuple[int, str]]:
82
+ """50-level ladder with unique names per level. Preserves the original
83
+ L1-L8 thresholds (0,3,8,15,25,40,60,90) exactly so existing XP totals
84
+ don't trigger retroactive level-ups. After L8 the delta grows +5 per
85
+ level, landing L50 at 5865 XP. The threshold curve is theme-independent."""
86
+ names = names or LEVEL_NAMES
87
+ fixed_deltas = [3, 5, 7, 10, 15, 20, 30] # L1→L2 through L7→L8
88
+ thresholds: list[int] = [0]
89
+ for d in fixed_deltas:
90
+ thresholds.append(thresholds[-1] + d)
91
+ delta = 30
92
+ while len(thresholds) < len(names):
93
+ delta += 5
94
+ thresholds.append(thresholds[-1] + delta)
95
+ return list(zip(thresholds, names))
96
+
97
+
98
+ LEVELS = _build_level_ladder()
99
+
100
+ # ELO defaults: linear interpolation from 1000 at L1 → 2800 at L50, with
101
+ # a within-level slide so XP gains between level-ups also nudge the rating.
102
+ # Stays 4-digit across the entire ladder — real chess range. The user can
103
+ # override these via /config elo <min> <max> (~/.claude/coach/.user_config.json).
104
+ ELO_MIN = 1000
105
+ ELO_MAX = 2800
106
+
107
+ # Per-process snapshot of the user's selected theme + variant + ELO range.
108
+ # Read once at module import — slash-command edits to .user_config.json
109
+ # take effect on the next process invocation (every statusline render is
110
+ # a fresh process, so this is immediate from the user's perspective).
111
+ def _load_runtime_config() -> tuple[list[tuple[int, str]], int, int, str]:
112
+ """Returns (LEVELS, ELO_MIN, ELO_MAX, statusline_variant) keyed off
113
+ ~/.claude/coach/.user_config.json. Falls back to compiled defaults on
114
+ any error so the statusline always renders."""
115
+ try:
116
+ cfg = load_user_config()
117
+ ladder = _build_level_ladder(get_ladder(cfg["theme"]))
118
+ return ladder, cfg["elo_min"], cfg["elo_max"], cfg["statusline_variant"]
119
+ except Exception:
120
+ return _build_level_ladder(THEME_CRAFT), ELO_MIN, ELO_MAX, "crystal"
121
+
122
+
123
+ LEVELS, ELO_MIN, ELO_MAX, STATUSLINE_VARIANT = _load_runtime_config()
124
+
125
+ BAR_SEGMENTS = 10
126
+ SESSION_XP_CAP = 15
127
+
128
+ # Palette from ~/.claude/statusline-command.sh, plus a coach-specific
129
+ # empty-segment color. The statusline's DIM_STEEL (#3A3A4A) is almost
130
+ # invisible against a dark terminal background when nothing's adjacent
131
+ # to provide contrast — a fully-empty bar looks blank. MUTED_STEEL is
132
+ # bright enough for the ▱ glyphs to always read, dim enough that it
133
+ # still visually subordinates to filled segments.
134
+ ICE_SILVER = "\x1b[38;2;200;214;229m"
135
+ DIM_STEEL = "\x1b[38;2;58;58;74m"
136
+ MUTED_STEEL = "\x1b[38;2;110;122;140m"
137
+ RESET = "\x1b[0m"
138
+
139
+ # Sub-rank sigil color — the ◆ prefix cycles through bronze → silver → gold
140
+ # → platinum → diamond as the user progresses within their current level.
141
+ # Resets to bronze on level-up. The Roman numeral + ELO number stay neutral
142
+ # so the sigil color carries all the within-level progress signal.
143
+ SIGIL_BRONZE = "\x1b[38;2;205;127;50m" # 0–20% through level
144
+ SIGIL_SILVER = "\x1b[38;2;200;205;215m" # 20–40%
145
+ SIGIL_GOLD = "\x1b[38;2;245;197;66m" # 40–60%
146
+ SIGIL_PLATINUM = "\x1b[38;2;180;220;235m" # 60–80%
147
+ SIGIL_DIAMOND = "\x1b[38;2;150;240;255m" # 80–100% / max level
148
+
149
+ GAIN_EMERALD = "\x1b[38;2;90;218;170m" # ↑N session-gain arrow
150
+
151
+ # Unicode Roman numeral converter for 1..50.
152
+ # Uses concatenation (Ⅹ + Ⅲ → ⅩⅢ) rather than single-char forms (Ⅺ, Ⅻ)
153
+ # so the aesthetic is consistent across the full ladder.
154
+ _ROMAN_TENS = ["", "Ⅹ", "ⅩⅩ", "ⅩⅩⅩ", "ⅩⅬ", "Ⅼ"]
155
+ _ROMAN_UNITS = ["", "Ⅰ", "Ⅱ", "Ⅲ", "Ⅳ", "Ⅴ", "Ⅵ", "Ⅶ", "Ⅷ", "Ⅸ"]
156
+
157
+ def _to_roman(n: int) -> str:
158
+ n = max(1, min(n, 50))
159
+ return _ROMAN_TENS[n // 10] + _ROMAN_UNITS[n % 10]
160
+
161
+
162
+ def _level_for(xp: float) -> tuple[int, str, int, int | None]:
163
+ """Return (level_index, name, current_threshold, next_threshold_or_None)."""
164
+ idx = 0
165
+ for i, (thr, _) in enumerate(LEVELS):
166
+ if xp >= thr:
167
+ idx = i
168
+ name = LEVELS[idx][1]
169
+ cur = LEVELS[idx][0]
170
+ nxt = LEVELS[idx + 1][0] if idx + 1 < len(LEVELS) else None
171
+ return idx, name, cur, nxt
172
+
173
+
174
+ def _compute_hybrid(lifetime: int, session: int) -> dict:
175
+ """Core hybrid math — exposed for testing. See module docstring.
176
+
177
+ Returns a dict with the keys downstream rendering needs:
178
+ level_xp, progress_xp, idx, name, cur, nxt, elo, sigil_pct
179
+ """
180
+ bank_gain = session // 10
181
+ level_xp = lifetime + bank_gain
182
+ progress_xp = lifetime + session / 10
183
+ idx, name, cur, nxt = _level_for(level_xp)
184
+ max_idx = len(LEVELS) - 1
185
+ base = ELO_MIN + (idx / max_idx) * (ELO_MAX - ELO_MIN) if max_idx > 0 else ELO_MIN
186
+ if nxt is None:
187
+ elo = ELO_MAX
188
+ sigil_pct = 1.0
189
+ else:
190
+ span = max(1, nxt - cur)
191
+ within_pct = max(0.0, min(1.0, (progress_xp - cur) / span))
192
+ nxt_base = ELO_MIN + ((idx + 1) / max_idx) * (ELO_MAX - ELO_MIN)
193
+ elo = int(round(base + within_pct * (nxt_base - base)))
194
+ sigil_pct = within_pct
195
+ return {
196
+ "level_xp": level_xp, "progress_xp": progress_xp,
197
+ "idx": idx, "name": name, "cur": cur, "nxt": nxt,
198
+ "elo": elo, "sigil_pct": sigil_pct,
199
+ }
200
+
201
+
202
+ def compute_for_render(lifetime: int, session: int) -> dict:
203
+ """Public render-side helper for banner shapes that need rank metadata.
204
+
205
+ Returns a flat dict with what a renderer typically asks for:
206
+ idx — 0-based level index
207
+ name — theme-aware level name (from active LEVELS ladder)
208
+ elo — interpolated ELO within the current level
209
+ roman — Unicode Roman numeral for level (1..50 → Ⅰ..Ⅼ)
210
+ medal_count — 1 + idx // 4, capped at 5 (rank-ribbon scaling)
211
+ next_xp — XP threshold for the next level, or None at L50
212
+ """
213
+ h = _compute_hybrid(lifetime, session)
214
+ idx = h["idx"]
215
+ return {
216
+ "idx": idx,
217
+ "name": h["name"],
218
+ "elo": h["elo"],
219
+ "roman": _to_roman(idx + 1),
220
+ "medal_count": min(5, idx // 4 + 1),
221
+ "next_xp": h["nxt"],
222
+ }
223
+
224
+
225
+ def _atomic_write_json_under_lock(path: Path, payload: dict) -> None:
226
+ path.parent.mkdir(parents=True, exist_ok=True)
227
+ fd, tmp_name = tempfile.mkstemp(
228
+ prefix="." + path.name + ".",
229
+ suffix=".tmp",
230
+ dir=str(path.parent),
231
+ )
232
+ try:
233
+ with os.fdopen(fd, "w") as fh:
234
+ fh.write(json.dumps(payload))
235
+ fh.flush()
236
+ os.fsync(fh.fileno())
237
+ os.replace(tmp_name, path)
238
+ except Exception:
239
+ try:
240
+ os.unlink(tmp_name)
241
+ except Exception:
242
+ pass
243
+ raise
244
+
245
+
246
+ def _check_levelup(current_idx: int, current_name: str, xp: int) -> None:
247
+ """Detect level-up vs last-seen state. Write a marker file on transition
248
+ for the UserPromptSubmit hook to surface as a celebration. First-time
249
+ initialization: if the user is already above Drafter, write the marker
250
+ so they get a retroactive celebration (then future ups are detected
251
+ normally)."""
252
+ try:
253
+ LEVEL_STATE.parent.mkdir(parents=True, exist_ok=True)
254
+ lock_path = LEVEL_STATE.with_suffix(LEVEL_STATE.suffix + ".lock")
255
+ with open(lock_path, "w") as lock_fh:
256
+ try:
257
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
258
+ except Exception:
259
+ pass
260
+
261
+ last_state = None
262
+ if LEVEL_STATE.exists():
263
+ try:
264
+ last_state = json.loads(LEVEL_STATE.read_text())
265
+ except Exception:
266
+ last_state = None
267
+
268
+ # `created_at` + `consumed_by` are read by the UserPromptSubmit
269
+ # hook's `_read_and_consume()` so concurrent Claude Code sessions
270
+ # each see the level-up celebration once. atomic_marker_replace
271
+ # serializes the write under the same sidecar flock the reader
272
+ # uses, so a stale-snapshot reader can't clobber a fresh level-up.
273
+ now_iso = datetime.now(timezone.utc).isoformat()
274
+
275
+ if last_state is None:
276
+ # First run ever. If they're already above Drafter (L1), treat as
277
+ # pending celebration so they don't miss it retroactively.
278
+ if current_idx > 0:
279
+ from_name = LEVELS[0][1]
280
+ atomic_marker_replace(LEVELUP_MARKER, {
281
+ "from": from_name,
282
+ "from_idx": 0,
283
+ "to": current_name,
284
+ "to_idx": current_idx,
285
+ "xp_at_levelup": xp,
286
+ "created_at": now_iso,
287
+ "consumed_by": [],
288
+ })
289
+ _atomic_write_json_under_lock(LEVEL_STATE, {"level_idx": current_idx})
290
+ return
291
+
292
+ last_idx = int(last_state.get("level_idx", 0))
293
+ if current_idx > last_idx:
294
+ from_name = LEVELS[last_idx][1] if 0 <= last_idx < len(LEVELS) else "?"
295
+ atomic_marker_replace(LEVELUP_MARKER, {
296
+ "from": from_name,
297
+ "from_idx": last_idx,
298
+ "to": current_name,
299
+ "to_idx": current_idx,
300
+ "xp_at_levelup": xp,
301
+ "created_at": now_iso,
302
+ "consumed_by": [],
303
+ })
304
+ _atomic_write_json_under_lock(LEVEL_STATE, {"level_idx": current_idx})
305
+ # NOTE: we never write state downward. Transient low-XP reads
306
+ # (statusline renders before the current transcript has flushed
307
+ # recent tool uses) used to regress the state, which then made
308
+ # the *next* read look like a fresh level-up and re-trigger the
309
+ # celebration. State is a high-water mark; only raise it.
310
+ except Exception:
311
+ # Never let level-up tracking break statusline rendering.
312
+ pass
313
+
314
+
315
+ def _read_stdin_json() -> dict:
316
+ """Parse the Claude Code statusline JSON from stdin, if any."""
317
+ try:
318
+ if sys.stdin.isatty():
319
+ return {}
320
+ raw = sys.stdin.read()
321
+ if not raw:
322
+ return {}
323
+ data = json.loads(raw)
324
+ return data if isinstance(data, dict) else {}
325
+ except Exception:
326
+ return {}
327
+
328
+
329
+ def _find_transcript_path(payload: dict) -> Path | None:
330
+ # Claude Code passes varying shapes; try common ones.
331
+ for key in ("transcript_path", "transcript", "session_transcript"):
332
+ v = payload.get(key)
333
+ if isinstance(v, str) and v:
334
+ p = Path(v).expanduser()
335
+ if p.exists():
336
+ return p
337
+ # Nested shapes
338
+ session = payload.get("session") or {}
339
+ if isinstance(session, dict):
340
+ v = session.get("transcript_path") or session.get("transcript")
341
+ if isinstance(v, str) and v:
342
+ p = Path(v).expanduser()
343
+ if p.exists():
344
+ return p
345
+ return None
346
+
347
+
348
+ def _session_xp_from_transcript(path: Path, profile: dict | None = None) -> int:
349
+ """Scan the transcript JSONL for reward-eligible tool uses.
350
+
351
+ Baseline XP (test_run/commit/skill_invoke) plus any dynamic actions
352
+ declared via reward_hint on profile entries. See scoring.py for the
353
+ single source of truth."""
354
+ # scoring.py lives next to us; import lazily so stats.py startup stays fast.
355
+ from scoring import score_transcript
356
+ return score_transcript(path, profile)
357
+
358
+
359
+ def _lifetime_xp() -> int:
360
+ if not PROFILE.exists():
361
+ return 0
362
+ try:
363
+ import yaml
364
+ data = yaml.safe_load(PROFILE.read_text()) or {}
365
+ except Exception:
366
+ return 0
367
+ if not isinstance(data, dict):
368
+ return 0
369
+ return int(normalize_profile_xp(data)["lifetime_xp"])
370
+
371
+
372
+ def render_segment(payload: dict | None = None) -> str:
373
+ """Compose the coach statusline segment from a parsed payload.
374
+
375
+ Returns the rendered ANSI string, or an empty string when the coach
376
+ has nothing to display (no profile file AND no transcript-derived
377
+ session signal). Side effect: detects level transitions and writes
378
+ the levelup marker for the UserPromptSubmit hook.
379
+
380
+ Public callable consumed by `default_statusline.py` (which composes
381
+ model + context-bar + this segment in-process). The CLI entrypoint
382
+ `main()` is now a thin wrapper around this function.
383
+ """
384
+ if payload is None:
385
+ payload = {}
386
+
387
+ lifetime = _lifetime_xp()
388
+ session = 0
389
+ tpath = _find_transcript_path(payload)
390
+ if tpath is not None:
391
+ # Load the profile (if readable) so dynamic reward_hint actions score too.
392
+ _profile_for_scoring: dict | None = None
393
+ try:
394
+ if PROFILE.exists():
395
+ import yaml
396
+ _profile_for_scoring = yaml.safe_load(PROFILE.read_text()) or {}
397
+ except Exception:
398
+ _profile_for_scoring = None
399
+ session = _session_xp_from_transcript(tpath, _profile_for_scoring)
400
+
401
+ # Stay silent if the coach isn't set up at all (no profile file) AND
402
+ # we got no transcript-derived signal either.
403
+ if not PROFILE.exists() and session == 0:
404
+ return ""
405
+
406
+ h = _compute_hybrid(lifetime, session)
407
+ idx, name, cur, nxt = h["idx"], h["name"], h["cur"], h["nxt"]
408
+ elo = h["elo"]
409
+
410
+ # Side effect: detect level transitions and write a marker for the
411
+ # UserPromptSubmit hook to surface as a visible celebration. Keyed off
412
+ # level_xp so celebrations fire only on actual (banked) level changes.
413
+ _check_levelup(idx, name, h["level_xp"])
414
+
415
+ # Sub-rank sigil tier: bronze → silver → gold → platinum → diamond.
416
+ # Uses the float within-level pct (progress_xp) so the sigil glides
417
+ # through shades within a session even when no bank tick has landed.
418
+ pct = h["sigil_pct"]
419
+ if nxt is None or pct >= 0.8:
420
+ sigil_tier = "diamond"
421
+ elif pct >= 0.6:
422
+ sigil_tier = "platinum"
423
+ elif pct >= 0.4:
424
+ sigil_tier = "gold"
425
+ elif pct >= 0.2:
426
+ sigil_tier = "silver"
427
+ else:
428
+ sigil_tier = "bronze"
429
+
430
+ # Display the raw session count as a whole integer (0-15). The 10:1
431
+ # bank ratio still applies internally (level_xp uses session // 10,
432
+ # ELO slide uses session / 10), but the arrow shows session points so
433
+ # every test/commit produces an immediate, readable bump.
434
+ glyphs = Glyphs(
435
+ level=idx + 1,
436
+ name=name,
437
+ elo=elo,
438
+ session_xp=session,
439
+ sigil_tier=sigil_tier,
440
+ bar_pct=pct,
441
+ )
442
+ return render_variant(STATUSLINE_VARIANT, glyphs)
443
+
444
+
445
+ def main() -> int:
446
+ try:
447
+ out = render_segment(_read_stdin_json())
448
+ if out:
449
+ sys.stdout.write(out)
450
+ except Exception:
451
+ # Failsafe: same contract as default_statusline.main —
452
+ # a render-path crash must not break Claude Code's statusline
453
+ # or noise up the terminal. Emit nothing; exit 0.
454
+ pass
455
+ return 0
456
+
457
+
458
+ if __name__ == "__main__":
459
+ sys.exit(main())