@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,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())