@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,119 @@
|
|
|
1
|
+
"""render_env.supports_dual_blade — glyph-capability probe.
|
|
2
|
+
|
|
3
|
+
Pins each branch of the detection order so a regression in one branch doesn't
|
|
4
|
+
mask a regression in another. Each test passes an explicit `env` mapping to
|
|
5
|
+
bypass the module-level cache (the cache is its own concern, tested separately
|
|
6
|
+
below).
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import pytest
|
|
11
|
+
|
|
12
|
+
import render_env
|
|
13
|
+
from render_env import supports_dual_blade
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@pytest.fixture(autouse=True)
|
|
17
|
+
def _clear_dual_blade_cache():
|
|
18
|
+
"""Module-level cache must not leak between tests."""
|
|
19
|
+
render_env._DUAL_BLADE_CACHE = None
|
|
20
|
+
yield
|
|
21
|
+
render_env._DUAL_BLADE_CACHE = None
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def test_default_modern_terminal_returns_true():
|
|
25
|
+
"""Sensible default: a UTF-8 xterm with no overrides should render ⚔."""
|
|
26
|
+
env = {"LANG": "en_US.UTF-8", "TERM": "xterm-256color"}
|
|
27
|
+
assert supports_dual_blade(env) is True
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_force_ascii_kill_switch_wins():
|
|
31
|
+
"""COACH_FORCE_ASCII_GLYPHS truthy beats every other signal."""
|
|
32
|
+
env = {
|
|
33
|
+
"COACH_FORCE_ASCII_GLYPHS": "1",
|
|
34
|
+
"COACH_SUPPORTS_DUAL_BLADE": "1", # would otherwise be True
|
|
35
|
+
"LANG": "en_US.UTF-8",
|
|
36
|
+
"TERM": "xterm-256color",
|
|
37
|
+
}
|
|
38
|
+
assert supports_dual_blade(env) is False
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def test_explicit_support_override_true():
|
|
42
|
+
"""COACH_SUPPORTS_DUAL_BLADE=1 forces True even if locale would say no."""
|
|
43
|
+
env = {"COACH_SUPPORTS_DUAL_BLADE": "true", "LANG": "C", "TERM": "dumb"}
|
|
44
|
+
assert supports_dual_blade(env) is True
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_explicit_support_override_false():
|
|
48
|
+
"""COACH_SUPPORTS_DUAL_BLADE=0 forces False even on a UTF-8 xterm."""
|
|
49
|
+
env = {"COACH_SUPPORTS_DUAL_BLADE": "0", "LANG": "en_US.UTF-8",
|
|
50
|
+
"TERM": "xterm-256color"}
|
|
51
|
+
assert supports_dual_blade(env) is False
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_non_utf8_locale_returns_false():
|
|
55
|
+
"""LANG=C with no UTF-8 anywhere → ASCII fallback."""
|
|
56
|
+
env = {"LANG": "C", "TERM": "xterm-256color"}
|
|
57
|
+
assert supports_dual_blade(env) is False
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def test_utf8_in_lc_all_alone_is_enough():
|
|
61
|
+
"""Locale check accepts LC_ALL=UTF-8 when LANG is unset."""
|
|
62
|
+
env = {"LC_ALL": "en_US.UTF-8", "TERM": "xterm-256color"}
|
|
63
|
+
assert supports_dual_blade(env) is True
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def test_lc_all_overrides_lang_per_posix():
|
|
67
|
+
"""POSIX precedence: LC_ALL wins over LANG. An explicit LC_ALL=C must
|
|
68
|
+
NOT be overridden by an otherwise-unused LANG=en_US.UTF-8."""
|
|
69
|
+
env = {"LC_ALL": "C", "LANG": "en_US.UTF-8", "TERM": "xterm-256color"}
|
|
70
|
+
assert supports_dual_blade(env) is False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_lc_ctype_overrides_lang_when_lc_all_empty():
|
|
74
|
+
"""When LC_ALL is unset, LC_CTYPE takes precedence over LANG."""
|
|
75
|
+
env = {"LC_CTYPE": "C", "LANG": "en_US.UTF-8", "TERM": "xterm-256color"}
|
|
76
|
+
assert supports_dual_blade(env) is False
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_lang_alone_is_consulted_when_others_empty():
|
|
80
|
+
"""LANG is the lowest-priority fallback — used only when LC_ALL and
|
|
81
|
+
LC_CTYPE are both unset."""
|
|
82
|
+
env = {"LANG": "en_US.UTF-8", "TERM": "xterm-256color"}
|
|
83
|
+
assert supports_dual_blade(env) is True
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
def test_term_dumb_returns_false():
|
|
87
|
+
"""TERM=dumb is the canonical 'no fancy glyphs' signal."""
|
|
88
|
+
env = {"LANG": "en_US.UTF-8", "TERM": "dumb"}
|
|
89
|
+
assert supports_dual_blade(env) is False
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def test_term_linux_returns_false():
|
|
93
|
+
"""Linux console framebuffer can't render U+2694."""
|
|
94
|
+
env = {"LANG": "en_US.UTF-8", "TERM": "linux"}
|
|
95
|
+
assert supports_dual_blade(env) is False
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_empty_env_defaults_true():
|
|
99
|
+
"""No locale info at all → trust the default. Most cron / launchd
|
|
100
|
+
invocations on modern macOS/Linux work fine without LANG set."""
|
|
101
|
+
assert supports_dual_blade({}) is True
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def test_module_cache_returns_first_probe_result():
|
|
105
|
+
"""First call (no env arg) populates the cache; subsequent calls
|
|
106
|
+
return the cached value without re-probing."""
|
|
107
|
+
render_env._DUAL_BLADE_CACHE = False
|
|
108
|
+
assert supports_dual_blade() is False # served from cache
|
|
109
|
+
render_env._DUAL_BLADE_CACHE = True
|
|
110
|
+
assert supports_dual_blade() is True
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def test_explicit_env_does_not_pollute_cache():
|
|
114
|
+
"""Tests pass an explicit env to force a probe; that probe must not
|
|
115
|
+
write back to the module cache (tests would interfere with each other
|
|
116
|
+
and with the normal hot-path read)."""
|
|
117
|
+
assert render_env._DUAL_BLADE_CACHE is None
|
|
118
|
+
supports_dual_blade({"LANG": "C", "TERM": "dumb"})
|
|
119
|
+
assert render_env._DUAL_BLADE_CACHE is None
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
"""reward_hints.py — keyword inference + explicit-vs-inference precedence."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from reward_hints import infer_reward_hint, effective_reward_hint
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def test_infer_test_run_from_id():
|
|
8
|
+
entry = {"id": "edits-without-testing", "nudge": ""}
|
|
9
|
+
hint = infer_reward_hint(entry)
|
|
10
|
+
assert hint is not None
|
|
11
|
+
assert hint["action"] == "test_run"
|
|
12
|
+
assert hint["xp"] == 2
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def test_infer_test_run_from_nudge_only():
|
|
16
|
+
entry = {"id": "some-unrelated-id", "nudge": "User skipped tests after edits."}
|
|
17
|
+
hint = infer_reward_hint(entry)
|
|
18
|
+
assert hint is not None
|
|
19
|
+
assert hint["action"] == "test_run"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def test_infer_commit_from_id():
|
|
23
|
+
entry = {"id": "edits-without-committing", "nudge": ""}
|
|
24
|
+
hint = infer_reward_hint(entry)
|
|
25
|
+
assert hint is not None
|
|
26
|
+
assert hint["action"] == "commit"
|
|
27
|
+
assert hint["xp"] == 1
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def test_infer_no_match_returns_none():
|
|
31
|
+
entry = {"id": "random-pattern", "nudge": "Something with no keyword hit."}
|
|
32
|
+
assert infer_reward_hint(entry) is None
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_effective_explicit_wins_over_inference():
|
|
36
|
+
# id would match test_run via keyword, but explicit hint should dominate
|
|
37
|
+
entry = {
|
|
38
|
+
"id": "edits-without-testing",
|
|
39
|
+
"nudge": "",
|
|
40
|
+
"reward_hint": {"action": "commit", "xp": 1, "description": "explicit override"},
|
|
41
|
+
}
|
|
42
|
+
hint = effective_reward_hint(entry)
|
|
43
|
+
assert hint["action"] == "commit"
|
|
44
|
+
assert hint["description"] == "explicit override"
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def test_effective_falls_through_to_inference_when_invalid():
|
|
48
|
+
# reward_hint present but invalid (missing action) → fall through
|
|
49
|
+
entry = {"id": "edits-without-testing", "nudge": "", "reward_hint": {}}
|
|
50
|
+
hint = effective_reward_hint(entry)
|
|
51
|
+
assert hint["action"] == "test_run"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def test_infer_returns_a_copy_not_default():
|
|
55
|
+
entry = {"id": "edits-without-testing", "nudge": ""}
|
|
56
|
+
h1 = infer_reward_hint(entry)
|
|
57
|
+
h1["xp"] = 99 # mutate caller's copy
|
|
58
|
+
h2 = infer_reward_hint(entry)
|
|
59
|
+
assert h2["xp"] == 2 # defaults unchanged
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
"""scoring.py — baseline actions, SESSION_XP_CAP, dynamic reward_hint actions."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import pytest
|
|
8
|
+
|
|
9
|
+
from scoring import (
|
|
10
|
+
BASELINE_ACTIONS,
|
|
11
|
+
SESSION_XP_CAP,
|
|
12
|
+
matches_action,
|
|
13
|
+
score_transcript,
|
|
14
|
+
score_transcript_with_breakdown,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _write_transcript(path: Path, tool_uses: list[dict]) -> None:
|
|
19
|
+
"""Write a minimal JSONL transcript with the given tool_uses."""
|
|
20
|
+
lines = []
|
|
21
|
+
for tu in tool_uses:
|
|
22
|
+
lines.append(json.dumps({"message": {"content": [dict(tu, type="tool_use")]}}))
|
|
23
|
+
path.write_text("\n".join(lines) + "\n")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def test_baseline_test_run_scoring(tmp_path: Path):
|
|
27
|
+
t = tmp_path / "t.jsonl"
|
|
28
|
+
_write_transcript(t, [
|
|
29
|
+
{"name": "Bash", "input": {"command": "pytest tests/"}},
|
|
30
|
+
{"name": "Bash", "input": {"command": "pytest tests/unit/"}},
|
|
31
|
+
])
|
|
32
|
+
assert score_transcript(t, {}) == 2 * BASELINE_ACTIONS["test_run"]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def test_baseline_commit_scoring(tmp_path: Path):
|
|
36
|
+
t = tmp_path / "t.jsonl"
|
|
37
|
+
_write_transcript(t, [
|
|
38
|
+
{"name": "Bash", "input": {"command": "git commit -m 'x'"}},
|
|
39
|
+
{"name": "Bash", "input": {"command": "git commit -m 'y'"}},
|
|
40
|
+
{"name": "Bash", "input": {"command": "git commit -m 'z'"}},
|
|
41
|
+
])
|
|
42
|
+
assert score_transcript(t, {}) == 3 * BASELINE_ACTIONS["commit"]
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def test_skill_invoke_counted_unique(tmp_path: Path):
|
|
46
|
+
t = tmp_path / "t.jsonl"
|
|
47
|
+
_write_transcript(t, [
|
|
48
|
+
{"name": "Skill", "input": {"skill": "/foo"}},
|
|
49
|
+
{"name": "Skill", "input": {"skill": "/foo"}}, # duplicate → same unique
|
|
50
|
+
{"name": "SlashCommand", "input": {"command": "/bar"}},
|
|
51
|
+
])
|
|
52
|
+
# 2 unique skill ids (foo, bar) × 1 = 2
|
|
53
|
+
assert score_transcript(t, {}) == 2 * BASELINE_ACTIONS["skill_invoke"]
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def test_collect_only_pytest_ignored(tmp_path: Path):
|
|
57
|
+
t = tmp_path / "t.jsonl"
|
|
58
|
+
_write_transcript(t, [
|
|
59
|
+
{"name": "Bash", "input": {"command": "pytest --collect-only tests/"}},
|
|
60
|
+
])
|
|
61
|
+
assert score_transcript(t, {}) == 0
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def test_shared_matcher_ignores_collect_only_pytest():
|
|
65
|
+
tu = {
|
|
66
|
+
"type": "tool_use",
|
|
67
|
+
"name": "Bash",
|
|
68
|
+
"input": {"command": "pytest --collect-only tests/"},
|
|
69
|
+
}
|
|
70
|
+
assert matches_action(tu, "test_run") is False
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
@pytest.mark.parametrize("command", ["mocha", "yarn test", "pnpm test", "vitest"])
|
|
74
|
+
def test_shared_matcher_accepts_supported_test_runners(command: str):
|
|
75
|
+
tu = {"type": "tool_use", "name": "Bash", "input": {"command": command}}
|
|
76
|
+
assert matches_action(tu, "test_run") is True
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def test_commit_message_text_does_not_count_as_action():
|
|
80
|
+
tu = {
|
|
81
|
+
"type": "tool_use",
|
|
82
|
+
"name": "Bash",
|
|
83
|
+
"input": {"command": "printf 'run pytest and git commit after this'"},
|
|
84
|
+
}
|
|
85
|
+
assert matches_action(tu, "test_run") is False
|
|
86
|
+
assert matches_action(tu, "commit") is False
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def test_session_cap_enforced(tmp_path: Path):
|
|
90
|
+
t = tmp_path / "t.jsonl"
|
|
91
|
+
# 20 test runs × 2 XP = 40 raw, capped at 15
|
|
92
|
+
_write_transcript(t, [
|
|
93
|
+
{"name": "Bash", "input": {"command": "pytest"}} for _ in range(20)
|
|
94
|
+
])
|
|
95
|
+
assert score_transcript(t, {}) == SESSION_XP_CAP
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def test_dynamic_action_from_profile(tmp_path: Path):
|
|
99
|
+
t = tmp_path / "t.jsonl"
|
|
100
|
+
_write_transcript(t, [
|
|
101
|
+
{"name": "Edit", "input": {"file_path": "docs/README.md"}},
|
|
102
|
+
{"name": "Edit", "input": {"file_path": "docs/guide.md"}},
|
|
103
|
+
{"name": "Edit", "input": {"file_path": "src/main.py"}}, # not md → ignored
|
|
104
|
+
])
|
|
105
|
+
profile = {
|
|
106
|
+
"entries": [{
|
|
107
|
+
"id": "skipping-docs",
|
|
108
|
+
"nudge": "",
|
|
109
|
+
"reward_hint": {"action": "doc_write", "xp": 1, "description": "doc update"},
|
|
110
|
+
}]
|
|
111
|
+
}
|
|
112
|
+
# 2 .md edits × 1 xp = 2
|
|
113
|
+
assert score_transcript(t, profile) == 2
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def test_breakdown_includes_dynamic_reward_hint_actions(tmp_path: Path):
|
|
117
|
+
t = tmp_path / "t.jsonl"
|
|
118
|
+
_write_transcript(t, [
|
|
119
|
+
{"name": "Bash", "input": {"command": "mocha"}},
|
|
120
|
+
{"name": "Edit", "input": {"file_path": "README.md"}},
|
|
121
|
+
])
|
|
122
|
+
profile = {
|
|
123
|
+
"entries": [{
|
|
124
|
+
"id": "skipping-docs",
|
|
125
|
+
"reward_hint": {"action": "doc_write", "xp": 1, "description": "doc update"},
|
|
126
|
+
}]
|
|
127
|
+
}
|
|
128
|
+
got = score_transcript_with_breakdown(t, profile)
|
|
129
|
+
assert got["tests"] == 1
|
|
130
|
+
assert got["dynamic_actions"]["doc_write"] == {"count": 1, "xp_each": 1, "xp": 1}
|
|
131
|
+
assert got["available_dynamic_actions"] == {"doc_write": 1}
|
|
132
|
+
assert got["capped_xp"] == 3
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def test_baseline_action_not_double_counted_via_reward_hint(tmp_path: Path):
|
|
136
|
+
"""reward_hint.action=test_run is BASELINE — must not double-count."""
|
|
137
|
+
t = tmp_path / "t.jsonl"
|
|
138
|
+
_write_transcript(t, [{"name": "Bash", "input": {"command": "pytest"}}])
|
|
139
|
+
profile = {
|
|
140
|
+
"entries": [{
|
|
141
|
+
"id": "edits-without-testing",
|
|
142
|
+
"nudge": "",
|
|
143
|
+
"reward_hint": {"action": "test_run", "xp": 2, "description": "test run"},
|
|
144
|
+
}]
|
|
145
|
+
}
|
|
146
|
+
# Should still be exactly +2 (baseline), not +4
|
|
147
|
+
assert score_transcript(t, profile) == 2
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
"""Tests for the SessionStart hook's weekly insights trigger.
|
|
2
|
+
|
|
3
|
+
Covers _maybe_spawn_weekly_insights — the 7-day-stale check that decides
|
|
4
|
+
whether to fork insights-llm.sh on session start. The wrapper itself
|
|
5
|
+
enforces the throttle inside its own logic; the hook just avoids the fork
|
|
6
|
+
when we already know it would skip.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import importlib.util
|
|
11
|
+
import os
|
|
12
|
+
import sys
|
|
13
|
+
import time
|
|
14
|
+
from datetime import datetime, timezone
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
import pytest
|
|
18
|
+
|
|
19
|
+
HOOK_PATH = Path(__file__).resolve().parent.parent.parent / "hooks" / "coach-session-start.py"
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@pytest.fixture
|
|
23
|
+
def hook_module(tmp_path, monkeypatch):
|
|
24
|
+
"""Load the hook source against a tmp COACH_DIR.
|
|
25
|
+
|
|
26
|
+
The hook resolves COACH_DIR = Path.home() / ".claude" / "coach" at
|
|
27
|
+
module-load time, so we patch Path.home() to return tmp_path BEFORE
|
|
28
|
+
exec'ing the module, then create the matching dir tree under it.
|
|
29
|
+
"""
|
|
30
|
+
coach_dir = tmp_path / ".claude" / "coach"
|
|
31
|
+
bin_dir = coach_dir / "bin"
|
|
32
|
+
bin_dir.mkdir(parents=True)
|
|
33
|
+
|
|
34
|
+
invocation_log = tmp_path / "invocation.log"
|
|
35
|
+
fake_script = bin_dir / "insights-llm.sh"
|
|
36
|
+
fake_script.write_text(
|
|
37
|
+
f"#!/bin/bash\necho fired > '{invocation_log}'\n"
|
|
38
|
+
)
|
|
39
|
+
fake_script.chmod(0o755)
|
|
40
|
+
|
|
41
|
+
monkeypatch.setattr("pathlib.Path.home", lambda: tmp_path)
|
|
42
|
+
|
|
43
|
+
spec = importlib.util.spec_from_file_location("coach_session_start", HOOK_PATH)
|
|
44
|
+
module = importlib.util.module_from_spec(spec)
|
|
45
|
+
sys.modules["coach_session_start"] = module
|
|
46
|
+
spec.loader.exec_module(module)
|
|
47
|
+
|
|
48
|
+
return module, coach_dir, invocation_log
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def test_spawns_when_marker_missing(hook_module) -> None:
|
|
52
|
+
module, coach_dir, invocation_log = hook_module
|
|
53
|
+
assert not (coach_dir / ".last_weekly_insights").exists()
|
|
54
|
+
module._maybe_spawn_weekly_insights(datetime.now(timezone.utc))
|
|
55
|
+
# Wait briefly for the detached subprocess to finish writing.
|
|
56
|
+
for _ in range(20):
|
|
57
|
+
if invocation_log.exists():
|
|
58
|
+
break
|
|
59
|
+
time.sleep(0.1)
|
|
60
|
+
assert invocation_log.exists(), "expected wrapper to be spawned"
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def test_skips_when_marker_recent(hook_module) -> None:
|
|
64
|
+
module, coach_dir, invocation_log = hook_module
|
|
65
|
+
marker = coach_dir / ".last_weekly_insights"
|
|
66
|
+
marker.touch()
|
|
67
|
+
module._maybe_spawn_weekly_insights(datetime.now(timezone.utc))
|
|
68
|
+
# No fork should happen — wait, then assert nothing fired.
|
|
69
|
+
time.sleep(0.5)
|
|
70
|
+
assert not invocation_log.exists(), "wrapper was spawned despite recent marker"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_spawns_when_marker_stale(hook_module) -> None:
|
|
74
|
+
module, coach_dir, invocation_log = hook_module
|
|
75
|
+
marker = coach_dir / ".last_weekly_insights"
|
|
76
|
+
marker.touch()
|
|
77
|
+
stale_ts = time.time() - 8 * 86400
|
|
78
|
+
os.utime(marker, (stale_ts, stale_ts))
|
|
79
|
+
module._maybe_spawn_weekly_insights(datetime.now(timezone.utc))
|
|
80
|
+
for _ in range(20):
|
|
81
|
+
if invocation_log.exists():
|
|
82
|
+
break
|
|
83
|
+
time.sleep(0.1)
|
|
84
|
+
assert invocation_log.exists(), "expected wrapper to fire on stale marker"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def test_no_crash_when_script_missing(hook_module, tmp_path) -> None:
|
|
88
|
+
module, coach_dir, _ = hook_module
|
|
89
|
+
# Remove the script — the hook must still return cleanly.
|
|
90
|
+
(coach_dir / "bin" / "insights-llm.sh").unlink()
|
|
91
|
+
module._maybe_spawn_weekly_insights(datetime.now(timezone.utc))
|
|
92
|
+
# No exception is the assertion.
|