@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
package/install.sh ADDED
@@ -0,0 +1,597 @@
1
+ #!/usr/bin/env bash
2
+ # Coach Claw — installer
3
+ #
4
+ # Copies the coach binaries, hooks, and skills into ~/.claude/ (or CLAUDE_DIR),
5
+ # registers
6
+ # the SessionStart + UserPromptSubmit hooks plus a statusLine when one is not
7
+ # already configured in settings.json, and git-inits the coach data directory
8
+ # for rollback.
9
+ #
10
+ # Idempotent — re-running is safe:
11
+ # • existing coach dir is moved to coach.bak.<ts> before copy
12
+ # • hooks + settings.json are backed up to .bak.<ts> before patch
13
+ # • existing coach state is preserved (only ships template on fresh install)
14
+ # • settings.json hook/statusline entries are added only if not already present
15
+ #
16
+ # Uninstall: run `/coach uninstall` inside Claude Code after install,
17
+ # or see artifacts/infrastructure.md § Uninstall for manual steps.
18
+
19
+ set -uo pipefail
20
+
21
+ BUNDLE_DIR="$(cd "$(dirname "$0")" && pwd)"
22
+ CLAUDE_DIR="${CLAUDE_DIR:-$HOME/.claude}"
23
+ TS="$(date +%Y%m%d-%H%M%S)"
24
+
25
+ bold() { printf "\033[1m%s\033[0m\n" "$*"; }
26
+ note() { printf " %s\n" "$*"; }
27
+ warn() { printf "\033[33m WARN: %s\033[0m\n" "$*"; }
28
+ ok() { printf "\033[32m OK: %s\033[0m\n" "$*"; }
29
+ die() { printf "\033[31m ERROR: %s\033[0m\n" "$*"; exit 1; }
30
+
31
+ # --- Flags -------------------------------------------------------------------
32
+ # --seed / --bootstrap → after install, run insights.sh 7d once so the
33
+ # user doesn't have an empty profile on first Claude Code session.
34
+
35
+ SEED=0
36
+ NO_SEED=0
37
+ PRUNE_BACKUPS=1
38
+ FRESH=0
39
+ for arg in "$@"; do
40
+ case "$arg" in
41
+ --seed|--bootstrap) SEED=1 ;;
42
+ --no-seed) NO_SEED=1 ;;
43
+ --prune-backups) PRUNE_BACKUPS=1 ;;
44
+ --no-prune-backups) PRUNE_BACKUPS=0 ;;
45
+ --fresh) FRESH=1 ;;
46
+ -h|--help)
47
+ cat <<USAGE
48
+ Usage: $(basename "$0") [--seed | --no-seed] [--no-prune-backups] [--fresh]
49
+
50
+ --seed / --bootstrap After install, run insights.sh 7d against your
51
+ existing Claude Code transcripts so the profile
52
+ isn't empty on your first session. Safe to omit
53
+ (you can run ~/.claude/coach/bin/insights.sh 7d
54
+ later, or invoke /coach-insights inside Claude
55
+ Code).
56
+
57
+ --no-seed Explicitly skip seeding. The seed step is already
58
+ opt-in via --seed today, so this is forward-
59
+ compatible: scripted/CI installs use --no-seed
60
+ to make their intent unambiguous and to suppress
61
+ any future auto-seed prompt. Mutually exclusive
62
+ with --seed.
63
+
64
+ --no-prune-backups Keep all coach.bak.*, settings.json.bak.*, and
65
+ hooks/*.bak.* files. Default is to keep only the
66
+ 3 most recent of each kind so ~/.claude/ doesn't
67
+ accumulate hundreds of backups across upgrades.
68
+
69
+ --fresh Skip recovery from a prior /coach uninstall. By
70
+ default, if no live coach/ dir exists but a
71
+ coach.bak.<ts>/ does, the most-recent backup is
72
+ restored before install (preserving profile,
73
+ throttle marker, and git history). --fresh forces
74
+ a true fresh install regardless.
75
+ USAGE
76
+ exit 0 ;;
77
+ *) warn "unknown arg: $arg (ignored)" ;;
78
+ esac
79
+ done
80
+
81
+ # Mutually-exclusive flag check — fail-fast BEFORE preflight so we don't
82
+ # touch anything on disk when the args are nonsense.
83
+ if [[ "$SEED" == "1" && "$NO_SEED" == "1" ]]; then
84
+ printf "\033[31m ERROR: --seed and --no-seed are mutually exclusive.\033[0m\n" >&2
85
+ exit 1
86
+ fi
87
+
88
+ # --- Preflight ---------------------------------------------------------------
89
+
90
+ bold "Preflight"
91
+
92
+ command -v python3 >/dev/null 2>&1 || die "python3 not found in PATH"
93
+ PY="$(command -v python3)"
94
+ note "python3: $PY"
95
+
96
+ # Resolve the Python version — need 3.8+ for f-strings + from __future__ annotations
97
+ PY_OK="$("$PY" - <<'PYEOF'
98
+ import sys
99
+ print("ok" if sys.version_info >= (3, 8) else f"too old ({sys.version_info[:2]})")
100
+ PYEOF
101
+ )"
102
+ [[ "$PY_OK" == "ok" ]] || die "python3 too old: $PY_OK. Coach needs Python 3.8+."
103
+ ok "python3 version adequate"
104
+
105
+ _have_yaml() { "$PY" -c "import yaml" 2>/dev/null; }
106
+
107
+ # Two-strategy fallback. pip --user covers system Python, pyenv, asdf,
108
+ # conda. --break-system-packages bypasses PEP 668 for Homebrew Python
109
+ # 3.12+ where the first attempt is rejected. (PyYAML is not in Homebrew
110
+ # core, so the brew formula is not a recovery path either.) We re-test
111
+ # `import yaml` after each pip call so a pip that "succeeded" but didn't
112
+ # actually land on PYTHONPATH still triggers the next step.
113
+ _install_pyyaml() {
114
+ note "Trying: $PY -m pip install --user pyyaml"
115
+ if "$PY" -m pip install --user pyyaml >/dev/null 2>&1 && _have_yaml; then
116
+ return 0
117
+ fi
118
+
119
+ note "Trying: $PY -m pip install --user --break-system-packages pyyaml"
120
+ note " (bypasses PEP 668 for the per-user install — safe for libraries)"
121
+ if "$PY" -m pip install --user --break-system-packages pyyaml >/dev/null 2>&1 && _have_yaml; then
122
+ return 0
123
+ fi
124
+
125
+ return 1
126
+ }
127
+
128
+ if ! _have_yaml; then
129
+ warn "PyYAML not installed for $PY — attempting auto-install."
130
+ if ! _install_pyyaml; then
131
+ printf "\033[31m ERROR: could not install PyYAML automatically.\033[0m\n\n"
132
+ printf " Pick one of these and re-run ./install.sh:\n\n"
133
+ printf " %s -m pip install --user --break-system-packages pyyaml\n" "$PY"
134
+ printf " Bypasses PEP 668 for a per-user install. Safe.\n\n"
135
+ printf " %s -m venv ~/.coach-venv && ~/.coach-venv/bin/pip install pyyaml\n" "$PY"
136
+ printf " Then re-run install.sh with PATH=~/.coach-venv/bin:\$PATH so\n"
137
+ printf " the preflight uses the venv's python3.\n"
138
+ exit 1
139
+ fi
140
+ fi
141
+ ok "PyYAML available"
142
+
143
+ if [[ ! -d "$CLAUDE_DIR" ]]; then
144
+ warn "$CLAUDE_DIR does not exist — creating it. (Normally Claude Code creates this on first launch.)"
145
+ mkdir -p "$CLAUDE_DIR"
146
+ fi
147
+ ok "Claude config dir: $CLAUDE_DIR"
148
+
149
+ # --- Recover from prior /coach uninstall -----------------------------------
150
+
151
+ # If a previous /coach uninstall moved coach/ to coach.bak.<ts>/ (so coach/
152
+ # itself doesn't exist), revive the most-recent .bak before the existing
153
+ # preserve + .git restore flow runs. Without this, the install treats it
154
+ # as a true fresh install and silently drops the user's profile.yaml,
155
+ # .last_weekly_insights throttle marker (→ unintended paid /insights
156
+ # call on next SessionStart), and per-run git history. --fresh opts out.
157
+ RECOVERED_FROM=""
158
+ if [[ ! -e "$CLAUDE_DIR/coach" && "$FRESH" != "1" ]]; then
159
+ prior_bak="$(ls -dt "$CLAUDE_DIR"/coach.bak.* 2>/dev/null | head -1)"
160
+ if [[ -n "$prior_bak" && -d "$prior_bak" ]]; then
161
+ mv "$prior_bak" "$CLAUDE_DIR/coach"
162
+ RECOVERED_FROM="$(basename "$prior_bak")"
163
+ fi
164
+ fi
165
+
166
+ # Compute install mode for the banner: fresh / upgrade / recovered.
167
+ if [[ -n "$RECOVERED_FROM" ]]; then
168
+ MODE="recovered"
169
+ elif [[ -e "$CLAUDE_DIR/coach" ]]; then
170
+ MODE="upgrade"
171
+ else
172
+ MODE="fresh"
173
+ fi
174
+
175
+ case "$MODE" in
176
+ fresh)
177
+ bold "Install mode: fresh"
178
+ note "no prior coach/ or coach.bak.* detected"
179
+ ;;
180
+ upgrade)
181
+ bold "Install mode: upgrade"
182
+ note "preserving live ~/.claude/coach/ state"
183
+ ;;
184
+ recovered)
185
+ bold "Install mode: recovered"
186
+ note "restored prior uninstall from $RECOVERED_FROM"
187
+ note " (preserving profile, throttle marker, git history; pass --fresh to skip)"
188
+ ;;
189
+ esac
190
+
191
+ # --- Recover launchd plist from prior /coach uninstall ---------------------
192
+
193
+ # Symmetric with the coach/ recovery above. /coach uninstall renames the
194
+ # plist to .uninstalled.<TS> and unloads it; without this block the user
195
+ # has to run install-launchd.sh separately to get the daily cron back —
196
+ # which is a real footgun (silent gap between reinstall and "Coach is
197
+ # autonomous again"). macOS-only; Linux uses cron, no plist.
198
+ #
199
+ # LAUNCHAGENTS_DIR override exists for test_install.py so the test can
200
+ # stage fixtures in a tmp dir without touching the real ~/Library/LaunchAgents/.
201
+ LAUNCHD_RECOVERED_FROM=""
202
+ LA_DIR="${LAUNCHAGENTS_DIR:-$HOME/Library/LaunchAgents}"
203
+ if [[ "$(uname)" == "Darwin" && "$FRESH" != "1" && -d "$LA_DIR" ]]; then
204
+ LIVE_PLIST="$LA_DIR/com.local.claude-coach.plist"
205
+ if [[ ! -e "$LIVE_PLIST" ]]; then
206
+ prior_plist="$(ls -dt "$LIVE_PLIST".uninstalled.* 2>/dev/null | head -1)"
207
+ if [[ -n "$prior_plist" && -f "$prior_plist" ]]; then
208
+ mv "$prior_plist" "$LIVE_PLIST"
209
+ LAUNCHD_RECOVERED_FROM="$(basename "$prior_plist")"
210
+ if command -v launchctl >/dev/null 2>&1; then
211
+ # unload first in case launchd somehow has a stale registration —
212
+ # mirrors the unload-then-load pattern in install-launchd.sh
213
+ launchctl unload "$LIVE_PLIST" 2>/dev/null || true
214
+ if launchctl load "$LIVE_PLIST" 2>/dev/null; then
215
+ note "restored launchd plist from $LAUNCHD_RECOVERED_FROM (job loaded)"
216
+ else
217
+ warn "restored launchd plist but launchctl load failed; run ./install-launchd.sh to reload"
218
+ fi
219
+ else
220
+ note "restored launchd plist from $LAUNCHD_RECOVERED_FROM (launchctl unavailable; load manually)"
221
+ fi
222
+ fi
223
+ fi
224
+ fi
225
+
226
+ # --- Backup existing pieces (if present) ------------------------------------
227
+
228
+ bold "Backups"
229
+
230
+ if [[ -e "$CLAUDE_DIR/coach" ]]; then
231
+ # Preserve user-owned state so reinstall updates code/docs without resetting
232
+ # progress, cooldowns, pending notifications, or the disabled flag.
233
+ TMP_PARENT="${TMPDIR:-/tmp}"
234
+ PRESERVE_DIR="$(mktemp -d "${TMP_PARENT%/}/coach-preserve.XXXXXX")" || \
235
+ die "failed to create temporary preserve directory"
236
+ for state_file in \
237
+ profile.yaml banked_sessions.json changelog.md log.ndjson \
238
+ .disabled .tip_state.json .level_state.json .last_session_start \
239
+ .last_weekly_insights .user_config.json \
240
+ .pending_* \
241
+ .statusline-wrap.json .statusline-wrap-disabled \
242
+ .statusline-wrap-announced .statusline-wrap-duplicate-detected; do
243
+ for src in "$CLAUDE_DIR/coach"/$state_file; do
244
+ [[ -e "$src" ]] || continue
245
+ cp -p "$src" "$PRESERVE_DIR/$(basename "$src")"
246
+ done
247
+ done
248
+ note "preserved existing coach state → $PRESERVE_DIR"
249
+ mv "$CLAUDE_DIR/coach" "$CLAUDE_DIR/coach.bak.$TS"
250
+ ok "moved existing coach dir → coach.bak.$TS"
251
+ fi
252
+
253
+ for hook in coach-session-start.py coach-user-prompt.py; do
254
+ if [[ -e "$CLAUDE_DIR/hooks/$hook" ]]; then
255
+ # Skip backup when bundle and live copy are byte-identical — re-running
256
+ # the installer with no code changes upstream would otherwise pile up
257
+ # an empty .bak.<ts> per run. Real diffs still get backed up.
258
+ if cmp -s "$BUNDLE_DIR/hooks/$hook" "$CLAUDE_DIR/hooks/$hook"; then
259
+ note "$hook unchanged — skipping backup"
260
+ else
261
+ cp "$CLAUDE_DIR/hooks/$hook" "$CLAUDE_DIR/hooks/$hook.bak.$TS"
262
+ ok "backed up existing hook: $hook"
263
+ fi
264
+ fi
265
+ done
266
+
267
+ if [[ -f "$CLAUDE_DIR/settings.json" ]]; then
268
+ # Snapshot first, then let the patch run. The post-patch cleanup block
269
+ # (after the python heredoc) drops this .bak when the patch is a no-op
270
+ # (settings.json byte-identical post-patch), so byte-identical reinstalls
271
+ # don't pile up backups.
272
+ cp "$CLAUDE_DIR/settings.json" "$CLAUDE_DIR/settings.json.bak.$TS"
273
+ ok "backed up settings.json"
274
+ fi
275
+
276
+ # v0.4.0 — Coach's old /insights skill shadowed Claude Code's built-in.
277
+ # Move the legacy skill aside so the built-in becomes reachable again.
278
+ # `mv` (not rm -rf) so any user customizations are recoverable.
279
+ if [[ -d "$CLAUDE_DIR/skills/insights" ]]; then
280
+ mv "$CLAUDE_DIR/skills/insights" "$CLAUDE_DIR/skills/insights.bak.$TS"
281
+ note "moved legacy /insights skill → skills/insights.bak.$TS"
282
+ note " (Coach's old skill no longer shadows Claude Code's built-in /insights)"
283
+ fi
284
+
285
+ # Claude Code's skill loader picks up any directory under `skills/` that
286
+ # contains a `SKILL.md`. A backup dir like `skills/insights.bak.<ts>/`
287
+ # would therefore become a live slash command (`/insights.bak.<ts>`) —
288
+ # polluting the catalog. Rename SKILL.md → SKILL.md.bak inside any
289
+ # `insights.bak.*/` dir so the loader skips it. Idempotent — runs every
290
+ # install, fixes both the freshly-moved bak from above AND any older
291
+ # bak dirs left over from a buggy v0.4.0 first-pass install.
292
+ for bak_skill in "$CLAUDE_DIR"/skills/insights.bak.*/SKILL.md; do
293
+ [[ -f "$bak_skill" ]] || continue
294
+ mv "$bak_skill" "${bak_skill}.bak"
295
+ note "defanged stale legacy SKILL.md → $(basename "$(dirname "$bak_skill")")/SKILL.md.bak"
296
+ done
297
+
298
+ # --- Copy files --------------------------------------------------------------
299
+
300
+ bold "Installing files"
301
+
302
+ mkdir -p "$CLAUDE_DIR/coach/bin" "$CLAUDE_DIR/coach/tests" \
303
+ "$CLAUDE_DIR/hooks" \
304
+ "$CLAUDE_DIR/skills/coach" "$CLAUDE_DIR/skills/coach-insights"
305
+
306
+ # Data files — profile is restored from preservation below if present
307
+ cp "$BUNDLE_DIR/coach/profile.yaml" "$CLAUDE_DIR/coach/profile.yaml"
308
+ cp "$BUNDLE_DIR/coach/changelog.md" "$CLAUDE_DIR/coach/changelog.md"
309
+ cp "$BUNDLE_DIR/coach/README.md" "$CLAUDE_DIR/coach/README.md"
310
+ [[ -f "$BUNDLE_DIR/coach/.gitignore" ]] && cp "$BUNDLE_DIR/coach/.gitignore" "$CLAUDE_DIR/coach/.gitignore"
311
+ touch "$CLAUDE_DIR/coach/log.ndjson"
312
+
313
+ # Binaries — ALL coach/bin/*.py + *.sh go in
314
+ for f in "$BUNDLE_DIR"/coach/bin/*.py "$BUNDLE_DIR"/coach/bin/*.sh; do
315
+ [[ -e "$f" ]] && cp "$f" "$CLAUDE_DIR/coach/bin/$(basename "$f")"
316
+ done
317
+
318
+ # Tests (optional — contributors can run pytest from ~/.claude/coach/)
319
+ for f in "$BUNDLE_DIR"/coach/tests/*.py; do
320
+ [[ -e "$f" ]] && cp "$f" "$CLAUDE_DIR/coach/tests/$(basename "$f")"
321
+ done
322
+
323
+ # Hooks — BOTH SessionStart AND UserPromptSubmit
324
+ cp "$BUNDLE_DIR/hooks/coach-session-start.py" "$CLAUDE_DIR/hooks/coach-session-start.py"
325
+ cp "$BUNDLE_DIR/hooks/coach-user-prompt.py" "$CLAUDE_DIR/hooks/coach-user-prompt.py"
326
+
327
+ # Skills (slash commands: /coach, /coach-insights, /config)
328
+ cp "$BUNDLE_DIR/skills/coach/SKILL.md" "$CLAUDE_DIR/skills/coach/SKILL.md"
329
+ cp "$BUNDLE_DIR/skills/coach-insights/SKILL.md" "$CLAUDE_DIR/skills/coach-insights/SKILL.md"
330
+ mkdir -p "$CLAUDE_DIR/skills/config"
331
+ [[ -f "$BUNDLE_DIR/skills/config/SKILL.md" ]] && \
332
+ cp "$BUNDLE_DIR/skills/config/SKILL.md" "$CLAUDE_DIR/skills/config/SKILL.md"
333
+
334
+ # Default statusline composition: model + context-bar + coach segment.
335
+ # `@PY@` is substituted with the resolved python path so the script
336
+ # doesn't depend on PATH at statusline-render time.
337
+ if [[ -f "$BUNDLE_DIR/coach/default-statusline-command.sh" ]]; then
338
+ sed "s|@PY@|$PY|g" "$BUNDLE_DIR/coach/default-statusline-command.sh" \
339
+ > "$CLAUDE_DIR/coach/default-statusline-command.sh"
340
+ fi
341
+
342
+ # Wrap-mode statusline trampoline (v0.1.4): symmetric installer-time
343
+ # substitution. settings.json:statusLine.command points at this when
344
+ # the user's existing statusLine got auto-wrapped by the wrap helper.
345
+ if [[ -f "$BUNDLE_DIR/coach/default-statusline-wrap-command.sh" ]]; then
346
+ sed "s|@PY@|$PY|g" "$BUNDLE_DIR/coach/default-statusline-wrap-command.sh" \
347
+ > "$CLAUDE_DIR/coach/default-statusline-wrap-command.sh"
348
+ fi
349
+
350
+ # Make the executables executable
351
+ chmod +x "$CLAUDE_DIR/coach/bin/"*.py "$CLAUDE_DIR/coach/bin/"*.sh \
352
+ "$CLAUDE_DIR/coach/default-statusline-command.sh" \
353
+ "$CLAUDE_DIR/coach/default-statusline-wrap-command.sh" \
354
+ "$CLAUDE_DIR/hooks/coach-session-start.py" \
355
+ "$CLAUDE_DIR/hooks/coach-user-prompt.py" 2>/dev/null || true
356
+ ok "files copied"
357
+
358
+ # Restore preserved user data if this was an upgrade (not a fresh install)
359
+ if [[ -n "${PRESERVE_DIR:-}" && -d "$PRESERVE_DIR" ]]; then
360
+ restored=0
361
+ for src in "$PRESERVE_DIR"/* "$PRESERVE_DIR"/.[!.]*; do
362
+ [[ -f "$src" ]] || continue
363
+ cp -p "$src" "$CLAUDE_DIR/coach/$(basename "$src")"
364
+ restored=$((restored + 1))
365
+ done
366
+ rm -rf "$PRESERVE_DIR"
367
+ ok "restored existing coach state ($restored files; progress preserved)"
368
+ fi
369
+
370
+ # Restore the per-run git history if this was an upgrade. The profile-mutation
371
+ # log lives at ~/.claude/coach/.git/ and the documented rollback UX is
372
+ # `git -C ~/.claude/coach checkout HEAD~1 -- profile.yaml`. Without this,
373
+ # every upgrade would reset that history to a single bootstrap commit.
374
+ if [[ -d "$CLAUDE_DIR/coach.bak.$TS/.git" && ! -d "$CLAUDE_DIR/coach/.git" ]]; then
375
+ cp -R "$CLAUDE_DIR/coach.bak.$TS/.git" "$CLAUDE_DIR/coach/.git"
376
+ ok "restored git history from previous install (rollback UX preserved)"
377
+ fi
378
+
379
+ # --- Git-init the coach data dir for rollback -------------------------------
380
+
381
+ bold "Git-init coach data dir (so every profile change is a commit)"
382
+
383
+ if [[ ! -d "$CLAUDE_DIR/coach/.git" ]]; then
384
+ ( cd "$CLAUDE_DIR/coach" && git init -q && git add -A && \
385
+ git commit -q -m "Bootstrap coach directory" --allow-empty )
386
+ ok "git initialized at ~/.claude/coach"
387
+ else
388
+ ok "git already initialized"
389
+ fi
390
+
391
+ # --- Patch settings.json -----------------------------------------------------
392
+
393
+ bold "Patching settings.json (additive, safe)"
394
+
395
+ SETTINGS="$CLAUDE_DIR/settings.json"
396
+ if [[ ! -f "$SETTINGS" ]]; then
397
+ echo '{}' > "$SETTINGS"
398
+ warn "no existing settings.json — created an empty one"
399
+ fi
400
+
401
+ # Patch is wrapped in try/except so a corrupt settings.json doesn't crash the
402
+ # installer — we report + point the user at their .bak file.
403
+ "$PY" - "$SETTINGS" "$PY" "$CLAUDE_DIR" <<'PYEOF'
404
+ import json, shlex, sys, traceback
405
+
406
+ settings_path, py, claude_dir = sys.argv[1], sys.argv[2], sys.argv[3]
407
+
408
+ try:
409
+ with open(settings_path) as f:
410
+ data = json.load(f)
411
+ except Exception as e:
412
+ print(f" ERROR: settings.json is not valid JSON: {e}")
413
+ print(f" Your original was backed up. Fix the JSON and re-run ./install.sh.")
414
+ sys.exit(1)
415
+
416
+ # We resolve `$PY` at install time and hardcode it in the hook command so the
417
+ # hook fires correctly even if Claude Code's runtime shell PATH doesn't
418
+ # include Homebrew/pyenv etc. Users can swap this manually later if they
419
+ # change interpreter.
420
+ hook_specs = [
421
+ ("SessionStart", "coach-session-start.py", 3),
422
+ ("UserPromptSubmit", "coach-user-prompt.py", 2),
423
+ ]
424
+
425
+ py_cmd = shlex.quote(py)
426
+ stats_path = shlex.quote(f"{claude_dir}/coach/bin/stats.py")
427
+ default_statusline_path = shlex.quote(
428
+ f"{claude_dir}/coach/default-statusline-command.sh"
429
+ )
430
+
431
+ hooks = data.setdefault("hooks", {})
432
+ changed = False
433
+ for event, script_name, timeout in hook_specs:
434
+ buckets = hooks.setdefault(event, [])
435
+ already = any(
436
+ script_name in h.get("command", "")
437
+ for group in buckets if isinstance(group, dict)
438
+ for h in (group.get("hooks") or []) if isinstance(h, dict)
439
+ )
440
+ if already:
441
+ print(f" OK: {event} hook already registered (no change)")
442
+ continue
443
+ entry = {
444
+ "type": "command",
445
+ "command": f"{py_cmd} {shlex.quote(f'{claude_dir}/hooks/{script_name}')}",
446
+ "timeout": timeout,
447
+ }
448
+ buckets.append({"hooks": [entry]})
449
+ changed = True
450
+ print(f" OK: {event} hook added ({script_name}, timeout={timeout}s)")
451
+
452
+ status = data.get("statusLine")
453
+ if isinstance(status, dict) and (
454
+ "default-statusline-command.sh" in str(status.get("command", ""))
455
+ or "stats.py" in str(status.get("command", ""))
456
+ ):
457
+ print(" OK: statusLine already registered for Coach (no change)")
458
+ elif status:
459
+ print(" OK: existing statusLine left unchanged (Coach default not installed)")
460
+ else:
461
+ data["statusLine"] = {
462
+ "type": "command",
463
+ "command": f"bash {default_statusline_path}",
464
+ }
465
+ changed = True
466
+ print(" OK: statusLine added (coach/default-statusline-command.sh)")
467
+
468
+ if changed:
469
+ with open(settings_path, "w") as f:
470
+ json.dump(data, f, indent=2)
471
+ PYEOF
472
+
473
+ [[ $? -eq 0 ]] || die "settings.json patch failed — see message above"
474
+
475
+ # Wrap-mode auto-wrap (v0.1.4). When settings.json:statusLine is
476
+ # `claimed` (user's custom shell script), the helper appends Coach's
477
+ # segment by saving the original and replacing the command with our
478
+ # wrap trampoline. Skips if a sticky opt-out marker is present OR the
479
+ # user's script already references Coach internals (manual-Coach
480
+ # pre-flight). Always exits 0 — never breaks an install.
481
+ COACH_CONFIG_DIR="$CLAUDE_DIR/coach" \
482
+ CLAUDE_SETTINGS_PATH="$SETTINGS" \
483
+ "$PY" "$CLAUDE_DIR/coach/bin/statusline_wrap_action.py" wrap-if-claimed || true
484
+
485
+ # If the patch ran cleanly but didn't actually change settings.json (everything
486
+ # already registered, byte-identical output), the .bak.<ts> from the snapshot
487
+ # above is dead weight. Drop it. Real diffs leave the .bak in place for the
488
+ # user to recover from if anything went sideways.
489
+ if [[ -f "$SETTINGS.bak.$TS" ]] && cmp -s "$SETTINGS" "$SETTINGS.bak.$TS"; then
490
+ rm -f "$SETTINGS.bak.$TS"
491
+ note "settings.json unchanged — discarded redundant backup"
492
+ fi
493
+
494
+ # --- Smoke-test the hooks ----------------------------------------------------
495
+
496
+ bold "Smoke-testing hooks"
497
+ for hook in coach-session-start.py coach-user-prompt.py; do
498
+ OUT="$(echo '{}' | COACH_DISABLE=1 "$PY" "$CLAUDE_DIR/hooks/$hook" 2>/dev/null || true)"
499
+ if [[ -z "$OUT" ]]; then
500
+ ok "$hook exits cleanly with COACH_DISABLE=1 (side-effect-free)"
501
+ else
502
+ note "$hook emitted output even with COACH_DISABLE=1"
503
+ fi
504
+ done
505
+
506
+ # --- Prune accumulated backups (default; opt out with --no-prune-backups) ---
507
+
508
+ if [[ "$PRUNE_BACKUPS" == "1" ]]; then
509
+ bold "Pruning old backups (keeping 3 most recent of each kind)"
510
+ pruned=0
511
+ # `while read` instead of `for $(ls ...)` so paths with spaces in
512
+ # $CLAUDE_DIR (e.g. ~/Library/Application Support/...) survive
513
+ # word-splitting. ls -t is mtime-descending, tail -n +4 skips the 3
514
+ # most recent.
515
+
516
+ # coach.bak.<ts>/ directories
517
+ while IFS= read -r old; do
518
+ [[ -z "$old" ]] && continue
519
+ rm -rf -- "$old" && pruned=$((pruned + 1))
520
+ done <<< "$(ls -dt "$CLAUDE_DIR"/coach.bak.* 2>/dev/null | tail -n +4)"
521
+
522
+ # settings.json.bak.<ts> files
523
+ while IFS= read -r old; do
524
+ [[ -z "$old" ]] && continue
525
+ rm -f -- "$old" && pruned=$((pruned + 1))
526
+ done <<< "$(ls -t "$CLAUDE_DIR"/settings.json.bak.* 2>/dev/null | tail -n +4)"
527
+
528
+ # hooks/<hook>.bak.<ts> files (per-hook accounting so each hook keeps 3)
529
+ for hook in coach-session-start.py coach-user-prompt.py; do
530
+ while IFS= read -r old; do
531
+ [[ -z "$old" ]] && continue
532
+ rm -f -- "$old" && pruned=$((pruned + 1))
533
+ done <<< "$(ls -t "$CLAUDE_DIR"/hooks/"$hook".bak.* 2>/dev/null | tail -n +4)"
534
+ done
535
+
536
+ ok "pruned $pruned old backup(s)"
537
+ fi
538
+
539
+ # --- Optional: seed the profile from recent transcripts ---------------------
540
+
541
+ SEEDED=0
542
+ if [[ "$SEED" == "1" ]]; then
543
+ bold "Seeding profile (--seed)"
544
+ if [[ -d "$HOME/.claude/projects" ]]; then
545
+ note "running insights.sh 7d against $HOME/.claude/projects (may take ~30s)…"
546
+ if "$CLAUDE_DIR/coach/bin/insights.sh" 7d 2>&1 | tail -4; then
547
+ ok "profile seeded — run '/coach status' inside Claude Code to see it"
548
+ SEEDED=1
549
+ else
550
+ warn "seed run didn't complete cleanly — non-fatal, you can run ~/.claude/coach/bin/insights.sh 7d manually later"
551
+ fi
552
+ else
553
+ warn "no $HOME/.claude/projects dir yet — skipping seed (no transcripts to analyze)"
554
+ note "open Claude Code at least once, use it a bit, then re-run with --seed"
555
+ fi
556
+ fi
557
+
558
+ # --- Done --------------------------------------------------------------------
559
+
560
+ bold "Installed. Coach Claw is now active."
561
+
562
+ # Seed-step copy depends on the flag the user passed (or didn't).
563
+ if [[ "$SEEDED" == "1" ]]; then
564
+ SEED_LINE="profile seeded from the last 7 days of transcripts."
565
+ elif [[ "$NO_SEED" == "1" ]]; then
566
+ SEED_LINE="--no-seed honored; to seed later: ~/.claude/coach/bin/insights.sh 7d"
567
+ else
568
+ SEED_LINE="empty profile (no --seed). Re-run with --seed to bootstrap, or just use Claude Code — the daily cron will fill it in."
569
+ fi
570
+ cat <<EOF
571
+
572
+ What's next:
573
+ 1. Restart Claude Code (or open a new session) — the hooks need to load.
574
+
575
+ 2. Send any prompt. Watch the bottom-right statusline:
576
+ ◆ Ⅰ 1000 Drafter
577
+ That's your level + ELO + rank name. It updates as you ship code.
578
+
579
+ 3. Customize the look any time (theme also changes rank names + celebration banners):
580
+ Inside Claude Code:
581
+ /config preview (see all 4 variants × 12 themes)
582
+ /config theme ocean (try a different ladder)
583
+ /config statusline pips (try a different statusline shape)
584
+ From the terminal — same backing file:
585
+ npx @rm0nroe/coach-claw@latest config wizard (interactive)
586
+ npx @rm0nroe/coach-claw@latest config set --theme ocean
587
+
588
+ 4. Schedule daily auto-analysis:
589
+ macOS: npx @rm0nroe/coach-claw@latest launchd
590
+ Linux: README.md → Install → step 3
591
+
592
+ 5. Other slash commands: /coach status /coach off | on /coach uninstall
593
+
594
+ Seed: $SEED_LINE
595
+
596
+ See README.md for design rationale, feature docs, and troubleshooting.
597
+ EOF
@@ -0,0 +1,34 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
3
+ <plist version="1.0">
4
+ <dict>
5
+ <key>Label</key>
6
+ <string>com.local.claude-coach</string>
7
+
8
+ <key>ProgramArguments</key>
9
+ <array>
10
+ <string>@HOME@/.claude/coach/bin/run-insights.sh</string>
11
+ </array>
12
+
13
+ <key>StartCalendarInterval</key>
14
+ <dict>
15
+ <key>Hour</key><integer>4</integer>
16
+ <key>Minute</key><integer>0</integer>
17
+ </dict>
18
+
19
+ <key>RunAtLoad</key>
20
+ <false/>
21
+
22
+ <key>StandardOutPath</key>
23
+ <string>/tmp/claude-coach.out</string>
24
+
25
+ <key>StandardErrorPath</key>
26
+ <string>/tmp/claude-coach.err</string>
27
+
28
+ <key>EnvironmentVariables</key>
29
+ <dict>
30
+ <key>PATH</key>
31
+ <string>/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin</string>
32
+ </dict>
33
+ </dict>
34
+ </plist>
@@ -0,0 +1,20 @@
1
+ #!/bin/bash
2
+ # Wrapper invoked by launchd to run the Coach insights pass once. Logs to
3
+ # /tmp. Runs the deterministic insights.sh — does NOT go through the
4
+ # claude CLI, so no cold-start cost and no slash-command routing issues.
5
+ # (The on-demand `/coach-insights` skill is the LLM-driven counterpart
6
+ # that runs from inside Claude Code; this wrapper is launchd-only.)
7
+
8
+ set -u
9
+ export PATH="/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"
10
+ export HOME="${HOME:-$(eval echo ~$(whoami))}"
11
+
12
+ LOG="/tmp/claude-coach.log"
13
+ TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
14
+ echo "[$TS] starting insights.sh 1d" >> "$LOG"
15
+
16
+ "$HOME/.claude/coach/bin/insights.sh" 1d >> "$LOG" 2>&1
17
+ EXIT=$?
18
+
19
+ echo "[$TS] insights.sh exited $EXIT" >> "$LOG"
20
+ exit "$EXIT"