@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,247 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Convert completed-session XP into lifetime XP at a 10:1 discount.
|
|
4
|
+
|
|
5
|
+
Invoked from the SessionStart hook (and runnable manually for testing).
|
|
6
|
+
For each main session transcript that:
|
|
7
|
+
- has been inactive for >= COOLDOWN_MINUTES (i.e. the session is over), and
|
|
8
|
+
- has not already been banked (not in banked_sessions.json)
|
|
9
|
+
...compute that session's XP with the same scoring as stats.py, then:
|
|
10
|
+
lifetime_gain = floor(session_xp / 10)
|
|
11
|
+
profile.session_banked_xp += lifetime_gain
|
|
12
|
+
banked_sessions[session_id] = {"xp": session_xp, "banked": lifetime_gain, "at": ISO}
|
|
13
|
+
|
|
14
|
+
Scoring (must match stats.py):
|
|
15
|
+
+2 per test-runner Bash invocation (pytest/jest/cargo/go test/...)
|
|
16
|
+
+1 per `git commit`
|
|
17
|
+
+1 per unique slash-command / skill invoked
|
|
18
|
+
session XP capped at 15
|
|
19
|
+
|
|
20
|
+
Design invariants:
|
|
21
|
+
- Always exits 0. Never blocks a session start.
|
|
22
|
+
- Uses flock to avoid racing with /coach-insights's merge.py on profile.yaml.
|
|
23
|
+
- Writes profile.yaml atomically (tempfile + os.replace).
|
|
24
|
+
"""
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import fcntl
|
|
28
|
+
import json
|
|
29
|
+
import os
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from datetime import datetime, timezone
|
|
33
|
+
from pathlib import Path
|
|
34
|
+
|
|
35
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
36
|
+
from xp_accounting import add_session_banked_xp, normalize_profile_xp # noqa: E402
|
|
37
|
+
from coach_paths import resolve_coach_dir # noqa: E402
|
|
38
|
+
|
|
39
|
+
COACH_DIR = resolve_coach_dir()
|
|
40
|
+
PROFILE = COACH_DIR / "profile.yaml"
|
|
41
|
+
LOCK = COACH_DIR / ".lock"
|
|
42
|
+
LEDGER = COACH_DIR / "banked_sessions.json"
|
|
43
|
+
PROJECTS = Path.home() / ".claude" / "projects"
|
|
44
|
+
|
|
45
|
+
# Session is considered "done" once its transcript has been untouched for
|
|
46
|
+
# this long. Short enough that a normally-ended session banks promptly,
|
|
47
|
+
# long enough that a paused session isn't prematurely banked.
|
|
48
|
+
COOLDOWN_MINUTES = 30
|
|
49
|
+
SCAN_WINDOW_DAYS = 7
|
|
50
|
+
SESSION_XP_CAP = 15
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def _now() -> datetime:
|
|
54
|
+
return datetime.now(timezone.utc)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _iso(dt: datetime) -> str:
|
|
58
|
+
return dt.isoformat()
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _load_ledger() -> dict:
|
|
62
|
+
if not LEDGER.exists():
|
|
63
|
+
return {}
|
|
64
|
+
try:
|
|
65
|
+
data = json.loads(LEDGER.read_text())
|
|
66
|
+
return data if isinstance(data, dict) else {}
|
|
67
|
+
except Exception:
|
|
68
|
+
return {}
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _save_ledger(ledger: dict) -> None:
|
|
72
|
+
"""Atomic write — a crash mid-write must not corrupt banked-session
|
|
73
|
+
state. The main flock is held by the caller, so this only needs to
|
|
74
|
+
guard against partial writes (e.g. process kill, disk-full half-write).
|
|
75
|
+
"""
|
|
76
|
+
try:
|
|
77
|
+
LEDGER.parent.mkdir(parents=True, exist_ok=True)
|
|
78
|
+
fd, tmp_name = tempfile.mkstemp(
|
|
79
|
+
prefix=".banked_sessions.", suffix=".tmp", dir=str(LEDGER.parent)
|
|
80
|
+
)
|
|
81
|
+
try:
|
|
82
|
+
with os.fdopen(fd, "w") as fh:
|
|
83
|
+
json.dump(ledger, fh, indent=2)
|
|
84
|
+
fh.flush()
|
|
85
|
+
os.fsync(fh.fileno())
|
|
86
|
+
os.replace(tmp_name, LEDGER)
|
|
87
|
+
except Exception:
|
|
88
|
+
try:
|
|
89
|
+
os.unlink(tmp_name)
|
|
90
|
+
except Exception:
|
|
91
|
+
pass
|
|
92
|
+
raise
|
|
93
|
+
except Exception:
|
|
94
|
+
pass
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _score_transcript(path: Path, profile: dict | None = None) -> int:
|
|
98
|
+
"""Score one session's transcript. Delegates to scoring.py so stats.py
|
|
99
|
+
and bank.py can never drift — one source of truth for action detection
|
|
100
|
+
and baseline XP. `profile` is the already-loaded yaml; when present,
|
|
101
|
+
dynamic reward_hint actions also score."""
|
|
102
|
+
from scoring import score_transcript
|
|
103
|
+
return score_transcript(path, profile)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _recent_main_transcripts() -> list[Path]:
|
|
107
|
+
if not PROJECTS.exists():
|
|
108
|
+
return []
|
|
109
|
+
cutoff = _now().timestamp() - SCAN_WINDOW_DAYS * 86400
|
|
110
|
+
out: list[Path] = []
|
|
111
|
+
for p in PROJECTS.rglob("*.jsonl"):
|
|
112
|
+
if "/subagents/" in str(p):
|
|
113
|
+
continue
|
|
114
|
+
try:
|
|
115
|
+
if p.stat().st_mtime >= cutoff:
|
|
116
|
+
out.append(p)
|
|
117
|
+
except Exception:
|
|
118
|
+
continue
|
|
119
|
+
return out
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def _atomic_write_yaml(path: Path, data: dict) -> None:
|
|
123
|
+
import yaml
|
|
124
|
+
tmp = tempfile.NamedTemporaryFile(
|
|
125
|
+
"w", delete=False, dir=path.parent, prefix=".profile.", suffix=".tmp"
|
|
126
|
+
)
|
|
127
|
+
try:
|
|
128
|
+
yaml.safe_dump(data, tmp, sort_keys=False, default_flow_style=False, allow_unicode=True)
|
|
129
|
+
tmp.flush()
|
|
130
|
+
os.fsync(tmp.fileno())
|
|
131
|
+
finally:
|
|
132
|
+
tmp.close()
|
|
133
|
+
try:
|
|
134
|
+
os.replace(tmp.name, path)
|
|
135
|
+
except Exception:
|
|
136
|
+
# Mirrors merge.atomic_write_yaml + marker_io._atomic_write_under_lock:
|
|
137
|
+
# don't leak an orphan .profile.*.tmp on a rare cross-device or
|
|
138
|
+
# filesystem-quirk failure of os.replace.
|
|
139
|
+
try:
|
|
140
|
+
os.unlink(tmp.name)
|
|
141
|
+
except Exception:
|
|
142
|
+
pass
|
|
143
|
+
raise
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _bank() -> dict:
|
|
147
|
+
"""Do the actual banking work. Returns a summary dict."""
|
|
148
|
+
summary = {"scanned": 0, "skipped_active": 0, "already_banked": 0, "banked": 0, "xp_added": 0}
|
|
149
|
+
|
|
150
|
+
if not PROFILE.exists():
|
|
151
|
+
return summary
|
|
152
|
+
|
|
153
|
+
try:
|
|
154
|
+
import yaml
|
|
155
|
+
profile = yaml.safe_load(PROFILE.read_text()) or {}
|
|
156
|
+
except Exception:
|
|
157
|
+
return summary
|
|
158
|
+
if not isinstance(profile, dict):
|
|
159
|
+
return summary
|
|
160
|
+
needs_migration_write = "session_banked_xp" not in profile
|
|
161
|
+
normalize_profile_xp(profile)
|
|
162
|
+
|
|
163
|
+
ledger = _load_ledger()
|
|
164
|
+
now = _now()
|
|
165
|
+
cooldown_seconds = COOLDOWN_MINUTES * 60
|
|
166
|
+
|
|
167
|
+
total_gain = 0
|
|
168
|
+
for t in _recent_main_transcripts():
|
|
169
|
+
summary["scanned"] += 1
|
|
170
|
+
session_id = t.stem # uuid
|
|
171
|
+
if session_id in ledger:
|
|
172
|
+
summary["already_banked"] += 1
|
|
173
|
+
continue
|
|
174
|
+
try:
|
|
175
|
+
age = now.timestamp() - t.stat().st_mtime
|
|
176
|
+
except Exception:
|
|
177
|
+
continue
|
|
178
|
+
if age < cooldown_seconds:
|
|
179
|
+
summary["skipped_active"] += 1
|
|
180
|
+
continue
|
|
181
|
+
|
|
182
|
+
session_xp = _score_transcript(t, profile)
|
|
183
|
+
lifetime_gain = session_xp // 10
|
|
184
|
+
ledger[session_id] = {
|
|
185
|
+
"xp": session_xp,
|
|
186
|
+
"banked": lifetime_gain,
|
|
187
|
+
"at": _iso(now),
|
|
188
|
+
"transcript": str(t),
|
|
189
|
+
}
|
|
190
|
+
total_gain += lifetime_gain
|
|
191
|
+
summary["banked"] += 1
|
|
192
|
+
|
|
193
|
+
if total_gain > 0:
|
|
194
|
+
add_session_banked_xp(profile, total_gain)
|
|
195
|
+
summary["xp_added"] = total_gain
|
|
196
|
+
if total_gain > 0 or needs_migration_write:
|
|
197
|
+
_atomic_write_yaml(PROFILE, profile)
|
|
198
|
+
|
|
199
|
+
_save_ledger(ledger)
|
|
200
|
+
return summary
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
# Max seconds to wait for the profile.yaml lock before giving up. /coach-insights
|
|
204
|
+
# typically completes in well under a second; 30s is a comfortable ceiling
|
|
205
|
+
# that lets us absorb an unusually slow run without dropping session XP.
|
|
206
|
+
# Bounded-wait (not infinite) so a stuck lock file can't keep bank.py alive
|
|
207
|
+
# forever. SessionStart spawns bank.py detached, so waiting here does NOT
|
|
208
|
+
# delay the hook — safe to block.
|
|
209
|
+
LOCK_WAIT_SECONDS = 30
|
|
210
|
+
LOCK_RETRY_INTERVAL = 0.5
|
|
211
|
+
|
|
212
|
+
|
|
213
|
+
def _acquire_lock_bounded(lockfile) -> bool:
|
|
214
|
+
"""Try LOCK_EX up to LOCK_WAIT_SECONDS. Returns True on acquire, False
|
|
215
|
+
if we timed out (profile is still being merged by /coach-insights — next
|
|
216
|
+
SessionStart will pick up the missed banking)."""
|
|
217
|
+
import time
|
|
218
|
+
deadline = time.monotonic() + LOCK_WAIT_SECONDS
|
|
219
|
+
while True:
|
|
220
|
+
try:
|
|
221
|
+
fcntl.flock(lockfile, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
222
|
+
return True
|
|
223
|
+
except OSError:
|
|
224
|
+
if time.monotonic() >= deadline:
|
|
225
|
+
return False
|
|
226
|
+
time.sleep(LOCK_RETRY_INTERVAL)
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def main() -> int:
|
|
230
|
+
try:
|
|
231
|
+
LOCK.parent.mkdir(parents=True, exist_ok=True)
|
|
232
|
+
with LOCK.open("a+") as lockfile:
|
|
233
|
+
if not _acquire_lock_bounded(lockfile):
|
|
234
|
+
# /coach-insights has been holding the lock for >30s — exceptional.
|
|
235
|
+
# Bail; the transcript is still on disk and within the 7-day
|
|
236
|
+
# scan window, so the next SessionStart will bank it.
|
|
237
|
+
return 0
|
|
238
|
+
summary = _bank()
|
|
239
|
+
if "--verbose" in sys.argv:
|
|
240
|
+
print(json.dumps(summary, indent=2))
|
|
241
|
+
except Exception:
|
|
242
|
+
pass
|
|
243
|
+
return 0
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
if __name__ == "__main__":
|
|
247
|
+
sys.exit(main())
|