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