@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,130 @@
1
+ """coach-user-prompt.py: wrap-announce + wrap-duplicate banner gating.
2
+
3
+ The plugin auto-wraps a claimed statusLine on first encounter; the
4
+ `.statusline-wrap-announced` marker tells the next user-prompt hook to
5
+ surface a one-time banner explaining what happened. Symmetric:
6
+ `.statusline-wrap-duplicate-detected` is dropped by the runtime
7
+ composer when it sees a Coach signature already in the original
8
+ output, and the hook surfaces a "consider unwrapping" banner.
9
+
10
+ Both markers use the consumed-by pattern (per-session dedup, 24h TTL),
11
+ mirroring LEVELUP_MARKER et al.
12
+ """
13
+ from __future__ import annotations
14
+
15
+ import importlib.util
16
+ import json
17
+ from datetime import datetime, timezone
18
+ from pathlib import Path
19
+
20
+ import pytest
21
+
22
+
23
+ @pytest.fixture(scope="module")
24
+ def cup():
25
+ repo_path = (
26
+ Path(__file__).resolve().parents[2] / "hooks" / "coach-user-prompt.py"
27
+ )
28
+ path = (
29
+ repo_path
30
+ if repo_path.exists()
31
+ else Path.home() / ".claude" / "hooks" / "coach-user-prompt.py"
32
+ )
33
+ if not path.exists():
34
+ pytest.skip(f"hook not installed at {path}")
35
+ spec = importlib.util.spec_from_file_location("cup_wrap_under_test", str(path))
36
+ mod = importlib.util.module_from_spec(spec)
37
+ spec.loader.exec_module(mod)
38
+ return mod
39
+
40
+
41
+ @pytest.fixture
42
+ def isolated(tmp_path, monkeypatch, cup):
43
+ """Redirect marker paths to a tmpdir so tests don't touch real
44
+ `~/.claude/coach/` state."""
45
+ monkeypatch.setattr(cup, "COACH_DIR", tmp_path)
46
+ monkeypatch.setattr(
47
+ cup, "WRAP_ANNOUNCE_MARKER", tmp_path / ".statusline-wrap-announced"
48
+ )
49
+ monkeypatch.setattr(
50
+ cup, "WRAP_DUPLICATE_MARKER", tmp_path / ".statusline-wrap-duplicate-detected"
51
+ )
52
+ return tmp_path
53
+
54
+
55
+ def _now() -> datetime:
56
+ return datetime.now(timezone.utc)
57
+
58
+
59
+ def test_announce_block_silent_when_no_marker(cup, isolated):
60
+ """No marker → no banner."""
61
+ out = cup._maybe_wrap_announce_block("session-A", _now())
62
+ assert out is None
63
+
64
+
65
+ def test_announce_block_emits_once_per_session(cup, isolated):
66
+ """Marker present → first call emits, second call (same session)
67
+ is silent (consumed-by dedup)."""
68
+ isolated.joinpath(".statusline-wrap-announced").write_text(json.dumps({
69
+ "created_at": _now().isoformat(),
70
+ "consumed_by": [],
71
+ }))
72
+ first = cup._maybe_wrap_announce_block("session-A", _now())
73
+ second = cup._maybe_wrap_announce_block("session-A", _now())
74
+ assert first is not None
75
+ assert "wrapped" in first.lower()
76
+ assert "/coach-claw:doctor --unwrap-statusline" in first
77
+ assert second is None
78
+
79
+
80
+ def test_announce_block_separate_sessions_each_see_marker_once(cup, isolated):
81
+ """Two different sessions each consume the marker once."""
82
+ isolated.joinpath(".statusline-wrap-announced").write_text(json.dumps({
83
+ "created_at": _now().isoformat(),
84
+ "consumed_by": [],
85
+ }))
86
+ a = cup._maybe_wrap_announce_block("session-A", _now())
87
+ b = cup._maybe_wrap_announce_block("session-B", _now())
88
+ assert a is not None
89
+ assert b is not None
90
+
91
+
92
+ def test_announce_block_renders_terminal_blockquote(cup, isolated):
93
+ isolated.joinpath(".statusline-wrap-announced").write_text(json.dumps({
94
+ "created_at": _now().isoformat(),
95
+ "consumed_by": [],
96
+ }))
97
+ out = cup._maybe_wrap_announce_block("session-A", _now(), env="terminal")
98
+ assert out.startswith(">")
99
+
100
+
101
+ def test_announce_block_renders_ide_hr_frame(cup, isolated):
102
+ isolated.joinpath(".statusline-wrap-announced").write_text(json.dumps({
103
+ "created_at": _now().isoformat(),
104
+ "consumed_by": [],
105
+ }))
106
+ out = cup._maybe_wrap_announce_block("session-A", _now(), env="ide")
107
+ assert out.startswith("---")
108
+
109
+
110
+ def test_duplicate_block_silent_when_no_marker(cup, isolated):
111
+ out = cup._maybe_wrap_duplicate_block("session-A", _now())
112
+ assert out is None
113
+
114
+
115
+ def test_duplicate_block_emits_when_marker_present(cup, isolated):
116
+ isolated.joinpath(".statusline-wrap-duplicate-detected").write_text(json.dumps({
117
+ "created_at": _now().isoformat(),
118
+ "consumed_by": [],
119
+ }))
120
+ out = cup._maybe_wrap_duplicate_block("session-A", _now())
121
+ assert out is not None
122
+ assert "duplicate" in out.lower()
123
+ assert "/coach-claw:doctor --unwrap-statusline" in out
124
+
125
+
126
+ def test_failsafe_returns_none_on_corrupt_marker(cup, isolated):
127
+ """Corrupt JSON in the marker → never raise; return None."""
128
+ isolated.joinpath(".statusline-wrap-announced").write_text("{not json")
129
+ out = cup._maybe_wrap_announce_block("session-A", _now())
130
+ assert out is None
@@ -0,0 +1,55 @@
1
+ from __future__ import annotations
2
+
3
+ from xp_accounting import (
4
+ add_milestone_xp,
5
+ add_session_banked_xp,
6
+ normalize_profile_xp,
7
+ )
8
+
9
+
10
+ def test_legacy_banked_session_xp_migrates_to_session_bucket():
11
+ profile = {
12
+ "banked_session_xp": 7,
13
+ "graduated": [{"id": "w1"}, {"id": "s1"}],
14
+ "archived": [{"id": "aged-out"}],
15
+ "entries": [{"id": "w2", "clean_streak_runs": 3}],
16
+ }
17
+
18
+ xp = normalize_profile_xp(profile)
19
+
20
+ assert profile["session_banked_xp"] == 7
21
+ assert profile["banked_session_xp"] == 7
22
+ assert profile["milestone_xp"] == 0
23
+ assert profile["graduation_xp"] == 10
24
+ assert xp["lifetime_xp"] == 20
25
+
26
+
27
+ def test_split_fields_are_summed_without_refolding_milestones():
28
+ profile = {
29
+ "session_banked_xp": 4,
30
+ "milestone_xp": 3,
31
+ "manual_adjustments": -1,
32
+ "banked_session_xp": 999,
33
+ "graduated": [{"id": "w1"}],
34
+ "entries": [{"id": "w2", "clean_streak_runs": 2}],
35
+ }
36
+
37
+ xp = normalize_profile_xp(profile)
38
+
39
+ assert profile["session_banked_xp"] == 4
40
+ assert profile["banked_session_xp"] == 4
41
+ assert profile["milestone_xp"] == 3
42
+ assert profile["graduation_xp"] == 5
43
+ assert xp["lifetime_xp"] == 13
44
+
45
+
46
+ def test_add_helpers_write_the_correct_buckets():
47
+ profile = {"graduated": [], "entries": []}
48
+
49
+ add_session_banked_xp(profile, 2)
50
+ add_milestone_xp(profile, 3)
51
+
52
+ assert profile["session_banked_xp"] == 2
53
+ assert profile["banked_session_xp"] == 2
54
+ assert profile["milestone_xp"] == 3
55
+ assert normalize_profile_xp(profile)["lifetime_xp"] == 5