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