@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,109 @@
1
+ """coach/bin/insights-llm.sh — plugin-context PATH wedge.
2
+
3
+ When `CLAUDE_PLUGIN_DATA` is set + a `venv/bin/python3` exists under
4
+ it, insights-llm.sh prepends that dir to `PATH` at startup, so all
5
+ subsequent `python3` invocations (including child processes calling
6
+ aggregate_facets.py / merge.py) resolve to the plugin's venv
7
+ interpreter — and therefore find PyYAML on a plugin-only fresh box.
8
+
9
+ CLI users never have `CLAUDE_PLUGIN_DATA` set; for them the wedge is
10
+ a no-op. Same script ships into both distributions via
11
+ `tools/build_plugin.py`.
12
+
13
+ These tests verify the wedge fires (or doesn't) under the right
14
+ env conditions. They don't run the full insights pipeline — that's
15
+ covered by other test modules. Just the PATH semantics.
16
+ """
17
+ from __future__ import annotations
18
+
19
+ import os
20
+ import shutil
21
+ import subprocess
22
+ from pathlib import Path
23
+
24
+ import pytest
25
+
26
+ REPO_ROOT = Path(__file__).resolve().parent.parent.parent
27
+ INSIGHTS_LLM = REPO_ROOT / "coach" / "bin" / "insights-llm.sh"
28
+
29
+
30
+ def _run_path_probe(env: dict[str, str]) -> str:
31
+ """Inject a tiny probe at the top of insights-llm.sh's already-run
32
+ PATH-wedge logic. We can't easily run the full script (it'd start
33
+ a real claude -p), so we extract the wedge block + run it
34
+ standalone and dump the resulting PATH.
35
+
36
+ The wedge is the first ~10 lines after `set -uo pipefail`. We
37
+ simulate it inline so behavior tests don't depend on the rest of
38
+ the script.
39
+ """
40
+ wedge = r"""
41
+ set -uo pipefail
42
+ if [[ -n "${CLAUDE_PLUGIN_DATA:-}" && -x "$CLAUDE_PLUGIN_DATA/venv/bin/python3" ]]; then
43
+ export PATH="$CLAUDE_PLUGIN_DATA/venv/bin:$PATH"
44
+ fi
45
+ echo "$PATH"
46
+ """
47
+ result = subprocess.run(
48
+ ["bash", "-c", wedge],
49
+ env={**os.environ, **env},
50
+ capture_output=True,
51
+ text=True,
52
+ timeout=5,
53
+ )
54
+ assert result.returncode == 0, result.stderr
55
+ return result.stdout.strip()
56
+
57
+
58
+ def test_wedge_fires_when_plugin_data_and_venv_present(tmp_path, monkeypatch):
59
+ venv_bin = tmp_path / "venv" / "bin"
60
+ venv_bin.mkdir(parents=True)
61
+ fake_python = venv_bin / "python3"
62
+ fake_python.write_text("#!/bin/sh\nexit 0\n")
63
+ fake_python.chmod(0o755)
64
+
65
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
66
+
67
+ path = _run_path_probe({})
68
+ assert path.startswith(str(venv_bin) + ":"), (
69
+ f"venv bin should be prepended to PATH; got: {path!r}"
70
+ )
71
+
72
+
73
+ def test_wedge_skips_when_plugin_data_unset(monkeypatch):
74
+ monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False)
75
+ path = _run_path_probe({})
76
+ # Just assert no /venv/bin marker injected; PATH content depends
77
+ # on the test environment.
78
+ assert "/venv/bin:" not in path or "/.coach-venv/" in path, (
79
+ f"PATH should be unchanged when CLAUDE_PLUGIN_DATA unset; got: {path!r}"
80
+ )
81
+
82
+
83
+ def test_wedge_skips_when_venv_python_missing(tmp_path, monkeypatch):
84
+ """CLAUDE_PLUGIN_DATA set but no venv/bin/python3 exists yet (e.g.,
85
+ the very first run before bootstrap.sh has set up the venv).
86
+ Wedge should be a no-op — the test asserts the PATH doesn't pick
87
+ up the (nonexistent) venv path."""
88
+ monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
89
+ # No venv directory created.
90
+ path = _run_path_probe({})
91
+ expected_marker = str(tmp_path / "venv" / "bin")
92
+ assert expected_marker not in path, (
93
+ f"PATH should NOT include nonexistent venv bin; got: {path!r}"
94
+ )
95
+
96
+
97
+ def test_real_insights_llm_sh_contains_the_wedge():
98
+ """Sanity: pin that the wedge block is actually present in the
99
+ real script (so a future refactor doesn't accidentally remove
100
+ it)."""
101
+ body = INSIGHTS_LLM.read_text()
102
+ assert "CLAUDE_PLUGIN_DATA" in body, (
103
+ "insights-llm.sh has lost its CLAUDE_PLUGIN_DATA wedge — the "
104
+ "plugin's venv won't be picked up by child python invocations"
105
+ )
106
+ assert "venv/bin/python3" in body, (
107
+ "insights-llm.sh wedge no longer references venv/bin/python3 "
108
+ "as the existence-check probe"
109
+ )
@@ -0,0 +1,237 @@
1
+ """coach/bin/insights_window.py: TZ-correct /coach-insights window filter.
2
+
3
+ Pins the bug fix where BSD `find -newermt "$SINCE_TS"` was reading a
4
+ UTC-formatted timestamp in the host's *local* timezone, causing the
5
+ cron's 24h window to silently shrink or grow by tz_offset hours every
6
+ run on any non-UTC host.
7
+
8
+ These tests are deliberately TZ-parametrized: setting `TZ` at the
9
+ process level should NOT change the cutoff math, because we always
10
+ compute from `datetime.now(timezone.utc)` and compare against POSIX
11
+ `st_mtime` (both TZ-independent). If a future refactor reintroduces
12
+ a TZ-naive datetime or local-time formatting, one of these parametrize
13
+ cases will fail.
14
+ """
15
+ from __future__ import annotations
16
+
17
+ import importlib.util
18
+ import os
19
+ import subprocess
20
+ import sys
21
+ import time
22
+ from datetime import datetime, timedelta, timezone
23
+ from pathlib import Path
24
+
25
+ import pytest
26
+
27
+
28
+ @pytest.fixture(scope="module")
29
+ def iw():
30
+ """Load coach/bin/insights_window.py as a module."""
31
+ repo_path = Path(__file__).resolve().parents[2] / "coach" / "bin" / "insights_window.py"
32
+ path = repo_path if repo_path.exists() else Path.home() / ".claude" / "coach" / "bin" / "insights_window.py"
33
+ if not path.exists():
34
+ pytest.skip(f"insights_window.py not installed at {path}")
35
+ spec = importlib.util.spec_from_file_location("iw_under_test", str(path))
36
+ mod = importlib.util.module_from_spec(spec)
37
+ spec.loader.exec_module(mod)
38
+ return mod
39
+
40
+
41
+ # --- parse_window ----------------------------------------------------------
42
+
43
+ @pytest.mark.parametrize("spec,expected", [
44
+ ("1d", timedelta(days=1)),
45
+ ("7d", timedelta(days=7)),
46
+ ("2h", timedelta(hours=2)),
47
+ ("30m", timedelta(minutes=30)),
48
+ ("60m", timedelta(minutes=60)),
49
+ ])
50
+ def test_parse_window_accepts_valid_specs(iw, spec, expected):
51
+ assert iw.parse_window(spec) == expected
52
+
53
+
54
+ @pytest.mark.parametrize("bad", ["", "1", "d", "1w", "1.5h", "-1d", "abc"])
55
+ def test_parse_window_rejects_bad_specs(iw, bad):
56
+ with pytest.raises(ValueError):
57
+ iw.parse_window(bad)
58
+
59
+
60
+ # --- cutoff_epoch ----------------------------------------------------------
61
+
62
+ def test_cutoff_epoch_with_frozen_now(iw):
63
+ now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
64
+ cutoff = iw.cutoff_epoch("1d", now=now)
65
+ expected = now - timedelta(days=1)
66
+ assert cutoff == expected.timestamp()
67
+
68
+
69
+ def test_cutoff_epoch_requires_tz_aware_now(iw):
70
+ naive = datetime(2026, 5, 2, 11, 0, 0) # no tzinfo
71
+ with pytest.raises(ValueError):
72
+ iw.cutoff_epoch("1d", now=naive)
73
+
74
+
75
+ @pytest.mark.parametrize("tz", ["UTC", "America/Los_Angeles", "Asia/Tokyo"])
76
+ def test_cutoff_epoch_is_tz_independent(iw, tz, monkeypatch):
77
+ """The cutoff should be the same absolute moment regardless of host TZ.
78
+ Setting TZ in the env must not shift the result — that was the whole
79
+ point of moving off `find -newermt`."""
80
+ monkeypatch.setenv("TZ", tz)
81
+ time.tzset()
82
+ try:
83
+ now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
84
+ cutoff = iw.cutoff_epoch("1d", now=now)
85
+ # Same UTC moment → same epoch, regardless of TZ env.
86
+ assert cutoff == (now - timedelta(days=1)).timestamp()
87
+ finally:
88
+ # Restore process TZ so other tests aren't affected.
89
+ monkeypatch.delenv("TZ", raising=False)
90
+ time.tzset()
91
+
92
+
93
+ # --- recent_transcripts ----------------------------------------------------
94
+
95
+ def _seed_transcript(path: Path, mtime: float) -> Path:
96
+ path.parent.mkdir(parents=True, exist_ok=True)
97
+ path.write_text("{}\n")
98
+ os.utime(path, (mtime, mtime))
99
+ return path
100
+
101
+
102
+ def test_recent_transcripts_filters_by_mtime(iw, tmp_path):
103
+ now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
104
+ fresh = _seed_transcript(
105
+ tmp_path / "p1" / "fresh.jsonl",
106
+ (now - timedelta(hours=1)).timestamp(),
107
+ )
108
+ stale = _seed_transcript(
109
+ tmp_path / "p1" / "stale.jsonl",
110
+ (now - timedelta(days=2)).timestamp(),
111
+ )
112
+ out = iw.recent_transcripts(tmp_path, "1d", now=now)
113
+ assert fresh in out
114
+ assert stale not in out
115
+
116
+
117
+ def test_recent_transcripts_excludes_subagents(iw, tmp_path):
118
+ now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
119
+ main = _seed_transcript(
120
+ tmp_path / "p1" / "main.jsonl",
121
+ (now - timedelta(hours=1)).timestamp(),
122
+ )
123
+ sub = _seed_transcript(
124
+ tmp_path / "p1" / "subagents" / "spawn.jsonl",
125
+ (now - timedelta(hours=1)).timestamp(),
126
+ )
127
+ out = iw.recent_transcripts(tmp_path, "1d", now=now)
128
+ assert main in out
129
+ assert sub not in out
130
+
131
+
132
+ def test_recent_transcripts_handles_missing_dir(iw, tmp_path):
133
+ missing = tmp_path / "does-not-exist"
134
+ assert iw.recent_transcripts(missing, "1d") == []
135
+
136
+
137
+ def test_recent_transcripts_validates_window_even_when_dir_missing(iw, tmp_path):
138
+ missing = tmp_path / "does-not-exist"
139
+ with pytest.raises(ValueError):
140
+ iw.recent_transcripts(missing, "1week")
141
+
142
+
143
+ @pytest.mark.parametrize("tz", ["UTC", "America/Los_Angeles", "Asia/Tokyo"])
144
+ def test_recent_transcripts_window_is_tz_independent(iw, tmp_path, tz, monkeypatch):
145
+ """End-to-end TZ regression: a transcript whose mtime is 23h before
146
+ `now` must always land inside a 1d window, regardless of host TZ.
147
+ The previous `find -newermt` implementation failed this on
148
+ Asia/Tokyo / America/Los_Angeles / any non-UTC host."""
149
+ monkeypatch.setenv("TZ", tz)
150
+ time.tzset()
151
+ try:
152
+ now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
153
+ in_window = _seed_transcript(
154
+ tmp_path / "p1" / "23h.jsonl",
155
+ (now - timedelta(hours=23)).timestamp(),
156
+ )
157
+ out_of_window = _seed_transcript(
158
+ tmp_path / "p1" / "25h.jsonl",
159
+ (now - timedelta(hours=25)).timestamp(),
160
+ )
161
+ out = iw.recent_transcripts(tmp_path, "1d", now=now)
162
+ assert in_window in out, f"23h-old transcript missed in TZ={tz}"
163
+ assert out_of_window not in out, f"25h-old transcript included in TZ={tz}"
164
+ finally:
165
+ monkeypatch.delenv("TZ", raising=False)
166
+ time.tzset()
167
+
168
+
169
+ # --- subprocess smoke ------------------------------------------------------
170
+
171
+ def test_main_prints_paths_one_per_line(tmp_path):
172
+ """Smoke-test the CLI entrypoint that insights.sh actually invokes."""
173
+ repo_path = Path(__file__).resolve().parents[2] / "coach" / "bin" / "insights_window.py"
174
+ if not repo_path.exists():
175
+ pytest.skip("insights_window.py not present in repo")
176
+ now = datetime.now(timezone.utc)
177
+ fresh = _seed_transcript(
178
+ tmp_path / "p1" / "fresh.jsonl",
179
+ (now - timedelta(hours=1)).timestamp(),
180
+ )
181
+ stale = _seed_transcript(
182
+ tmp_path / "p1" / "stale.jsonl",
183
+ (now - timedelta(days=2)).timestamp(),
184
+ )
185
+ result = subprocess.run(
186
+ [sys.executable, str(repo_path), str(tmp_path), "1d"],
187
+ capture_output=True,
188
+ text=True,
189
+ check=True,
190
+ )
191
+ lines = [ln for ln in result.stdout.strip().splitlines() if ln]
192
+ assert str(fresh) in lines
193
+ assert str(stale) not in lines
194
+
195
+
196
+ def test_main_rejects_bad_window():
197
+ repo_path = Path(__file__).resolve().parents[2] / "coach" / "bin" / "insights_window.py"
198
+ if not repo_path.exists():
199
+ pytest.skip("insights_window.py not present in repo")
200
+ result = subprocess.run(
201
+ [sys.executable, str(repo_path), "/tmp", "1week"],
202
+ capture_output=True,
203
+ text=True,
204
+ )
205
+ assert result.returncode == 2
206
+ assert "bad window" in result.stderr
207
+
208
+
209
+ def test_insights_sh_propagates_window_helper_failure(tmp_path):
210
+ """insights.sh must fail when insights_window.py rejects the window.
211
+
212
+ Bash process substitution hides producer exit statuses, so this pins
213
+ the shell integration rather than only the Python helper behavior.
214
+ """
215
+ repo_root = Path(__file__).resolve().parents[2]
216
+ script = repo_root / "coach" / "bin" / "insights.sh"
217
+ helper = repo_root / "coach" / "bin" / "insights_window.py"
218
+ if not script.exists() or not helper.exists():
219
+ pytest.skip("insights runner not present in repo")
220
+
221
+ home = tmp_path / "home"
222
+ bin_dir = home / ".claude" / "coach" / "bin"
223
+ projects = home / ".claude" / "projects"
224
+ bin_dir.mkdir(parents=True)
225
+ projects.mkdir(parents=True)
226
+ (bin_dir / "insights.sh").write_text(script.read_text())
227
+ (bin_dir / "insights_window.py").write_text(helper.read_text())
228
+
229
+ result = subprocess.run(
230
+ ["bash", str(bin_dir / "insights.sh"), "1week"],
231
+ env={**os.environ, "HOME": str(home)},
232
+ capture_output=True,
233
+ text=True,
234
+ )
235
+ assert result.returncode == 2
236
+ assert "bad window" in result.stderr
237
+ assert "done" not in result.stdout