@rm0nroe/coach-claw 1.0.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/coach/README.md +99 -0
- package/coach/bin/aggregate_facets.py +274 -0
- package/coach/bin/analyze.py +678 -0
- package/coach/bin/bank.py +247 -0
- package/coach/bin/banner_themes.py +645 -0
- package/coach/bin/coach_paths.py +33 -0
- package/coach/bin/coexistence_check.py +129 -0
- package/coach/bin/configure.py +245 -0
- package/coach/bin/cron_check.py +81 -0
- package/coach/bin/default_statusline.py +135 -0
- package/coach/bin/doctor.py +663 -0
- package/coach/bin/insights-llm.sh +264 -0
- package/coach/bin/insights.sh +163 -0
- package/coach/bin/insights_window.py +111 -0
- package/coach/bin/marker_io.py +154 -0
- package/coach/bin/merge.py +671 -0
- package/coach/bin/redact.py +86 -0
- package/coach/bin/render_env.py +148 -0
- package/coach/bin/reward_hints.py +87 -0
- package/coach/bin/run-insights.sh +20 -0
- package/coach/bin/run_with_lock.py +85 -0
- package/coach/bin/scoring.py +260 -0
- package/coach/bin/skill_inventory.py +215 -0
- package/coach/bin/stats.py +459 -0
- package/coach/bin/status.py +293 -0
- package/coach/bin/statusline_self_patch.py +205 -0
- package/coach/bin/statusline_variants.py +146 -0
- package/coach/bin/statusline_wrap.py +244 -0
- package/coach/bin/statusline_wrap_action.py +460 -0
- package/coach/bin/switch_to_plugin.py +256 -0
- package/coach/bin/themes.py +256 -0
- package/coach/bin/user_config.py +176 -0
- package/coach/bin/xp_accounting.py +98 -0
- package/coach/changelog.md +4 -0
- package/coach/default-statusline-command.sh +19 -0
- package/coach/default-statusline-wrap-command.sh +15 -0
- package/coach/profile.yaml +37 -0
- package/coach/tests/conftest.py +13 -0
- package/coach/tests/test_aggregate_facets.py +379 -0
- package/coach/tests/test_analyze_aggregate.py +153 -0
- package/coach/tests/test_analyze_redaction.py +105 -0
- package/coach/tests/test_analyze_strengths.py +165 -0
- package/coach/tests/test_bank_atomic_write.py +61 -0
- package/coach/tests/test_bank_concurrency.py +126 -0
- package/coach/tests/test_banner_themes.py +981 -0
- package/coach/tests/test_celebrate_dedup.py +409 -0
- package/coach/tests/test_coach_paths.py +50 -0
- package/coach/tests/test_coexistence_check.py +128 -0
- package/coach/tests/test_configure.py +258 -0
- package/coach/tests/test_cron_check.py +118 -0
- package/coach/tests/test_cron_nudge_hook.py +134 -0
- package/coach/tests/test_detection_parity.py +105 -0
- package/coach/tests/test_doctor.py +595 -0
- package/coach/tests/test_hook_bespoke_dispatch.py +288 -0
- package/coach/tests/test_hook_module_resolution.py +116 -0
- package/coach/tests/test_hook_relevance.py +996 -0
- package/coach/tests/test_hook_render_env.py +364 -0
- package/coach/tests/test_hook_session_id_guard.py +160 -0
- package/coach/tests/test_insights_llm.py +759 -0
- package/coach/tests/test_insights_llm_venv_path.py +109 -0
- package/coach/tests/test_insights_window.py +237 -0
- package/coach/tests/test_install.py +1150 -0
- package/coach/tests/test_install_pyyaml_fallback.py +142 -0
- package/coach/tests/test_marker_consumption.py +167 -0
- package/coach/tests/test_marker_writer_locking.py +305 -0
- package/coach/tests/test_merge.py +413 -0
- package/coach/tests/test_no_broken_mktemp.py +90 -0
- package/coach/tests/test_render_env.py +137 -0
- package/coach/tests/test_render_env_glyphs.py +119 -0
- package/coach/tests/test_reward_hints.py +59 -0
- package/coach/tests/test_scoring.py +147 -0
- package/coach/tests/test_session_start_weekly_trigger.py +92 -0
- package/coach/tests/test_skill_inventory.py +368 -0
- package/coach/tests/test_stats_hybrid.py +142 -0
- package/coach/tests/test_status_accounting.py +41 -0
- package/coach/tests/test_statusline_failsafe.py +70 -0
- package/coach/tests/test_statusline_self_patch.py +261 -0
- package/coach/tests/test_statusline_variants.py +110 -0
- package/coach/tests/test_statusline_wrap.py +196 -0
- package/coach/tests/test_statusline_wrap_action.py +408 -0
- package/coach/tests/test_switch_to_plugin.py +360 -0
- package/coach/tests/test_themes.py +104 -0
- package/coach/tests/test_user_config.py +160 -0
- package/coach/tests/test_wrap_announce_hook.py +130 -0
- package/coach/tests/test_xp_accounting.py +55 -0
- package/hooks/coach-session-start.py +536 -0
- package/hooks/coach-user-prompt.py +2288 -0
- package/install-launchd.sh +102 -0
- package/install.sh +597 -0
- package/launchd/com.local.claude-coach.plist.template +34 -0
- package/launchd/run-insights.sh +20 -0
- package/npm/coach-claw.js +259 -0
- package/package.json +52 -0
- package/requirements.txt +11 -0
- package/settings-snippet.json +31 -0
- package/skills/coach/SKILL.md +107 -0
- package/skills/coach-insights/SKILL.md +78 -0
- package/skills/config/SKILL.md +149 -0
|
@@ -0,0 +1,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())
|