@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,261 @@
1
+ """statusline_self_patch.ensure_statusline_installed — plugin self-patch
2
+ of ~/.claude/settings.json:statusLine.
3
+
4
+ Plugin distribution only. Gating happens at the call site
5
+ (coach-session-start.py:_maybe_install_plugin_statusline checks
6
+ CLAUDE_PLUGIN_ROOT). These tests exercise the patcher directly with a
7
+ tmpdir settings.json.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import json
12
+ import shlex
13
+ from pathlib import Path
14
+
15
+ import pytest
16
+
17
+ import statusline_self_patch as sp
18
+
19
+
20
+ @pytest.fixture
21
+ def settings(tmp_path):
22
+ """Return a settings.json path inside tmp_path. Caller writes it."""
23
+ return tmp_path / "settings.json"
24
+
25
+
26
+ @pytest.fixture
27
+ def plugin_root(tmp_path):
28
+ root = tmp_path / "plugin"
29
+ (root / "bin").mkdir(parents=True)
30
+ return root
31
+
32
+
33
+ def test_inserts_statusline_when_absent(settings, plugin_root):
34
+ settings.write_text(json.dumps({}))
35
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
36
+ assert result == "installed"
37
+
38
+ written = json.loads(settings.read_text())
39
+ assert "statusLine" in written
40
+ cmd = written["statusLine"]["command"]
41
+ assert "bootstrap.sh" in cmd
42
+ assert "default_statusline.py" in cmd
43
+ # Absolute path (no ${CLAUDE_PLUGIN_ROOT} placeholder; that wouldn't
44
+ # expand inside settings.json).
45
+ assert "${CLAUDE_PLUGIN_ROOT}" not in cmd
46
+
47
+
48
+ def test_preserves_other_keys(settings, plugin_root):
49
+ """Patching statusLine must not clobber unrelated settings."""
50
+ settings.write_text(json.dumps({
51
+ "permissions": {"allow": ["read"]},
52
+ "hooks": {"SessionStart": [{"hooks": [{"command": "foo"}]}]},
53
+ }))
54
+ sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
55
+ written = json.loads(settings.read_text())
56
+ assert written["permissions"] == {"allow": ["read"]}
57
+ assert "SessionStart" in written["hooks"]
58
+
59
+
60
+ def test_noop_when_coach_statusline_already_present(settings, plugin_root):
61
+ """statusLine pointing at Coach's known marker → no-op (file
62
+ untouched)."""
63
+ settings.write_text(json.dumps({
64
+ "statusLine": {
65
+ "type": "command",
66
+ "command": "bash /some/other/path/default-statusline-command.sh",
67
+ },
68
+ }))
69
+ mtime_before = settings.stat().st_mtime_ns
70
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
71
+ assert result == "matched"
72
+ assert settings.stat().st_mtime_ns == mtime_before, (
73
+ "matched path must not rewrite the file"
74
+ )
75
+
76
+
77
+ def test_auto_wraps_when_other_statusline_present(
78
+ settings, plugin_root, tmp_path, monkeypatch
79
+ ):
80
+ """v0.1.4: claimed statusLine on first encounter → auto-wrap (instead
81
+ of leaving alone). Original is saved to .statusline-wrap.json and the
82
+ wrapper command replaces it in settings.json."""
83
+ coach_dir = tmp_path / "coach"
84
+ coach_dir.mkdir()
85
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
86
+
87
+ settings.write_text(json.dumps({
88
+ "statusLine": {"type": "command", "command": "bash /custom/user-thing.sh"},
89
+ }))
90
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
91
+ assert result == "wrapped"
92
+ new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
93
+ assert "statusline_wrap.py" in new_cmd
94
+ saved = json.loads((coach_dir / ".statusline-wrap.json").read_text())
95
+ assert saved["original_command"] == "bash /custom/user-thing.sh"
96
+
97
+
98
+ def test_claimed_when_optout_marker_present(
99
+ settings, plugin_root, tmp_path, monkeypatch, capfd
100
+ ):
101
+ """User explicitly unwrapped earlier → opt-out marker exists → patcher
102
+ leaves the user's claimed statusLine alone (no auto-wrap, no rewrite)."""
103
+ coach_dir = tmp_path / "coach"
104
+ coach_dir.mkdir()
105
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
106
+ (coach_dir / ".statusline-wrap-disabled").write_text(json.dumps({
107
+ "reason": "user-unwrapped",
108
+ }))
109
+
110
+ settings.write_text(json.dumps({
111
+ "statusLine": {"type": "command", "command": "bash /custom/user-thing.sh"},
112
+ }))
113
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
114
+ assert result == "claimed"
115
+ # User's command preserved
116
+ assert json.loads(settings.read_text())["statusLine"]["command"] == "bash /custom/user-thing.sh"
117
+
118
+
119
+ def test_claimed_when_user_script_integrates_coach(
120
+ settings, plugin_root, tmp_path, monkeypatch
121
+ ):
122
+ """Manual-Coach pre-flight: user's script already calls coach/bin/stats.py
123
+ → patcher detects, writes opt-out marker, leaves statusLine alone."""
124
+ coach_dir = tmp_path / "coach"
125
+ coach_dir.mkdir()
126
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
127
+
128
+ user_script = tmp_path / "statusline-command.sh"
129
+ user_script.write_text(
130
+ "#!/bin/bash\nexec coach/bin/stats.py\n"
131
+ )
132
+ settings.write_text(json.dumps({
133
+ "statusLine": {"type": "command", "command": f"bash {user_script}"},
134
+ }))
135
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
136
+ assert result == "claimed"
137
+ # Opt-out marker auto-written by manual-Coach pre-flight
138
+ disabled = json.loads((coach_dir / ".statusline-wrap-disabled").read_text())
139
+ assert disabled["reason"] == "already-integrated"
140
+
141
+
142
+ def test_recognizes_wrapped_statusline_as_ours(settings, plugin_root):
143
+ """ours-wrapped pointing at the CURRENT plugin_root → matched no-op
144
+ (the wrap shape is recognized as ours, no rewrite)."""
145
+ settings.write_text(json.dumps({
146
+ "statusLine": {
147
+ "type": "command",
148
+ "command": f"{plugin_root}/bin/bootstrap.sh {plugin_root}/bin/statusline_wrap.py",
149
+ },
150
+ }))
151
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
152
+ assert result == "matched"
153
+
154
+
155
+ def test_refreshes_stale_plugin_path_on_wrapped(
156
+ settings, plugin_root, tmp_path, monkeypatch
157
+ ):
158
+ """ours-wrapped pointing at a stale plugin version dir → patcher
159
+ rewrites with the current plugin_root."""
160
+ coach_dir = tmp_path / "coach"
161
+ coach_dir.mkdir()
162
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(coach_dir))
163
+ # Pretend an older plugin dir wrote the entry
164
+ old_root = tmp_path / "plugin-old"
165
+ settings.write_text(json.dumps({
166
+ "statusLine": {
167
+ "type": "command",
168
+ "command": f"{old_root}/bin/bootstrap.sh {old_root}/bin/statusline_wrap.py",
169
+ },
170
+ }))
171
+ # Also need a wrap marker so the action recognizes ours-wrapped
172
+ (coach_dir / ".statusline-wrap.json").write_text(json.dumps({
173
+ "original_command": "bash /opt/x.sh",
174
+ }))
175
+
176
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
177
+ assert result == "wrap-refreshed"
178
+ new_cmd = json.loads(settings.read_text())["statusLine"]["command"]
179
+ assert str(plugin_root) in new_cmd
180
+ assert str(old_root) not in new_cmd
181
+
182
+
183
+ def test_skipped_when_settings_absent(tmp_path, plugin_root):
184
+ """No settings.json → no-op (returns 'skipped'). Don't create it
185
+ from scratch — Claude Code itself will create it on first run."""
186
+ missing = tmp_path / "no-such-settings.json"
187
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=missing)
188
+ assert result == "skipped"
189
+ assert not missing.exists()
190
+
191
+
192
+ def test_error_on_malformed_json(settings, plugin_root):
193
+ """Existing settings.json that's not valid JSON → returns 'error',
194
+ never raises (caller is hook context — mustn't crash)."""
195
+ settings.write_text("{ not valid json")
196
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
197
+ assert result == "error"
198
+
199
+
200
+ def test_atomic_no_partial_write_on_failure(settings, plugin_root, monkeypatch):
201
+ """If json.dump raises mid-write, settings.json must not be
202
+ truncated. Verified by simulating an exception in os.replace."""
203
+ settings.write_text(json.dumps({"statusLine": None}))
204
+ original = json.loads(settings.read_text())
205
+
206
+ real_replace = sp.os.replace
207
+
208
+ def boom(*args, **kwargs):
209
+ raise OSError("simulated disk failure")
210
+
211
+ monkeypatch.setattr(sp.os, "replace", boom)
212
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
213
+ assert result == "error"
214
+ # Original file content preserved (atomic semantics).
215
+ after = json.loads(settings.read_text())
216
+ assert after == original
217
+
218
+
219
+ def test_recognizes_cli_installed_statusline(settings, plugin_root):
220
+ """A CLI-installed statusLine (uses default-statusline-command.sh)
221
+ must be recognized as 'ours' so the plugin doesn't try to
222
+ overwrite when the user has both installs side-by-side."""
223
+ settings.write_text(json.dumps({
224
+ "statusLine": {
225
+ "type": "command",
226
+ "command": "bash /Users/foo/.claude/coach/default-statusline-command.sh",
227
+ },
228
+ }))
229
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
230
+ assert result == "matched"
231
+
232
+
233
+ def test_recognizes_plugin_installed_statusline(settings, plugin_root):
234
+ """Plugin-installed statusLine (uses default_statusline.py via
235
+ bootstrap.sh) must also be recognized as 'ours' on the second
236
+ SessionStart."""
237
+ settings.write_text(json.dumps({
238
+ "statusLine": {
239
+ "type": "command",
240
+ "command": "/path/to/plugin/bin/bootstrap.sh /path/to/plugin/bin/default_statusline.py",
241
+ },
242
+ }))
243
+ result = sp.ensure_statusline_installed(str(plugin_root), settings_path=settings)
244
+ assert result == "matched"
245
+
246
+
247
+ def test_desired_entry_quotes_paths_with_spaces(tmp_path):
248
+ """Symmetric with `_build_wrapper_command` in statusline_wrap_action:
249
+ a plugin_root with spaces must produce a command that bash parses
250
+ as exactly two tokens (bootstrap.sh + default_statusline.py)."""
251
+ plugin_root = tmp_path / "Plugin Dir With Spaces"
252
+ (plugin_root / "bin").mkdir(parents=True)
253
+ entry = sp._desired_entry(plugin_root)
254
+
255
+ tokens = shlex.split(entry["command"])
256
+ assert len(tokens) == 2, (
257
+ f"plugin_root paths split by bash; tokens={tokens!r} "
258
+ f"command={entry['command']!r}"
259
+ )
260
+ assert tokens[0] == str(plugin_root / "bin" / "bootstrap.sh")
261
+ assert tokens[1] == str(plugin_root / "bin" / "default_statusline.py")
@@ -0,0 +1,110 @@
1
+ """statusline_variants.py — every variant renders for sample inputs and
2
+ contains the expected key glyphs (level/name/elo/arrow)."""
3
+ from __future__ import annotations
4
+
5
+ import re
6
+
7
+ import statusline_variants as sv
8
+
9
+
10
+ def _strip_ansi(s: str) -> str:
11
+ return re.sub(r"\x1b\[[0-9;]*m", "", s)
12
+
13
+
14
+ def _sample_glyphs(level: int = 7, session_xp: int = 15) -> sv.Glyphs:
15
+ return sv.Glyphs(
16
+ level=level,
17
+ name="Virtuoso",
18
+ elo=1232,
19
+ session_xp=session_xp,
20
+ sigil_tier="silver",
21
+ bar_pct=0.30,
22
+ )
23
+
24
+
25
+ def test_every_variant_renders_a_non_empty_string():
26
+ g = _sample_glyphs()
27
+ for name in sv.VARIANTS:
28
+ assert sv.render(name, g), f"{name} produced empty output"
29
+
30
+
31
+ def test_unknown_variant_falls_back_to_default():
32
+ g = _sample_glyphs()
33
+ fallback = sv.render("does-not-exist", g)
34
+ assert fallback == sv.render(sv.DEFAULT_VARIANT, g)
35
+
36
+
37
+ def test_default_variant_is_crystal_and_includes_canonical_glyphs():
38
+ """Pin v0.2.0 visual contract — `◆ Ⅶ 1232 Virtuoso ↑15` shape."""
39
+ plain = _strip_ansi(sv.render("crystal", _sample_glyphs()))
40
+ assert plain == "◆ Ⅶ 1232 Virtuoso ↑15"
41
+
42
+
43
+ def test_pips_variant_renders_pip_bar():
44
+ plain = _strip_ansi(sv.render("pips", _sample_glyphs()))
45
+ # bar_pct=0.30 → round(0.30*5) = 2 filled pips, 3 empty
46
+ assert plain.startswith("●●○○○ Virtuoso")
47
+ assert plain.endswith("↑15")
48
+
49
+
50
+ def _glyphs_with_tier(tier: str) -> sv.Glyphs:
51
+ base = _sample_glyphs()
52
+ return sv.Glyphs(
53
+ level=base.level, name=base.name, elo=base.elo,
54
+ session_xp=base.session_xp, sigil_tier=tier, bar_pct=base.bar_pct,
55
+ )
56
+
57
+
58
+ def test_pips_filled_glyph_color_tracks_sigil_tier():
59
+ """Filled `●` glyphs render in the sigil-tier color so the bar
60
+ progresses bronze → silver → gold → platinum → diamond as the user
61
+ levels up. Empty `○` glyphs stay DIM_STEEL."""
62
+ bronze_out = sv.render("pips", _glyphs_with_tier("bronze"))
63
+ diamond_out = sv.render("pips", _glyphs_with_tier("diamond"))
64
+ assert sv.SIGIL_COLORS["bronze"] in bronze_out
65
+ assert sv.SIGIL_COLORS["diamond"] in diamond_out
66
+ assert bronze_out != diamond_out
67
+
68
+
69
+ def test_slash_variant_has_swords_sigil_and_drops_elo():
70
+ plain = _strip_ansi(sv.render("slash", _sample_glyphs()))
71
+ assert plain == "⚔ L7 / Virtuoso ↑15"
72
+
73
+
74
+ def test_slash_sigil_color_tracks_sigil_tier():
75
+ """⚔ joins ◆ and ⚒ in the tier-color family — same mechanism."""
76
+ out = sv.render("slash", _glyphs_with_tier("diamond"))
77
+ assert sv.SIGIL_COLORS["diamond"] in out
78
+
79
+
80
+ def test_forge_variant_uses_anvil_sigil():
81
+ plain = _strip_ansi(sv.render("forge", _sample_glyphs()))
82
+ assert plain == "⚒ Virtuoso · L7 ↑15"
83
+
84
+
85
+ def test_bracket_variant_removed_from_registry():
86
+ """v0.1.4: bracket dropped. `render()` falls back to crystal so
87
+ saved configs with statusline_variant=bracket keep rendering."""
88
+ assert "bracket" not in sv.VARIANTS
89
+ assert len(sv.VARIANTS) == 4
90
+ fallback = sv.render("bracket", _sample_glyphs())
91
+ assert fallback == sv.render("crystal", _sample_glyphs())
92
+
93
+
94
+ def test_zero_session_xp_drops_the_arrow():
95
+ g = _sample_glyphs(session_xp=0)
96
+ for name in sv.VARIANTS:
97
+ plain = _strip_ansi(sv.render(name, g))
98
+ assert "↑" not in plain, f"{name} kept the arrow at session_xp=0"
99
+
100
+
101
+ def test_to_roman_handles_full_50_level_range():
102
+ expectations = {1: "Ⅰ", 4: "Ⅳ", 9: "Ⅸ", 10: "Ⅹ", 13: "ⅩⅢ", 50: "Ⅼ"}
103
+ for n, want in expectations.items():
104
+ assert sv.to_roman(n) == want, f"to_roman({n})"
105
+
106
+
107
+ def test_list_variants_puts_default_first():
108
+ keys = sv.list_variants()
109
+ assert keys[0] == sv.DEFAULT_VARIANT
110
+ assert sorted(keys) == sorted(sv.VARIANTS.keys())
@@ -0,0 +1,196 @@
1
+ """statusline_wrap.py — runtime composer with ANSI-strip + trailing-aware
2
+ separator detection. Failsafe contract: never crash the render."""
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+ import io
8
+ from pathlib import Path
9
+
10
+ import pytest
11
+
12
+ import statusline_wrap as sw
13
+
14
+
15
+ # --- compose() pure function ----------------------------------------------
16
+
17
+
18
+ def test_compose_appends_when_original_ends_with_separator():
19
+ """User's command already trails with `┃` → just append, single space."""
20
+ out = sw.compose("opus·4.7 ┃ ▰▰▱▱ 14% ┃", "◆ Ⅷ 1265 Sensei ↑1")
21
+ assert out == "opus·4.7 ┃ ▰▰▱▱ 14% ┃ ◆ Ⅷ 1265 Sensei ↑1"
22
+
23
+
24
+ def test_compose_strips_ansi_before_trailing_check():
25
+ """Real terminal output has ANSI color codes wrapped around the `┃`.
26
+ Last raw char is `m`; after ANSI-strip, last visible char is `┃`."""
27
+ ansi_pipe = "\x1b[38;2;30;144;255m┃\x1b[0m"
28
+ original = f"opus·4.7 \x1b[38;2;30;144;255m┃\x1b[0m ▰▰▱▱ 14% {ansi_pipe}"
29
+ out = sw.compose(original, "COACH")
30
+ # No double separator inserted — trailing `┃` was detected post-strip.
31
+ assert out.endswith(" COACH")
32
+ assert " ┃ COACH" not in out # would be doubled
33
+
34
+
35
+ def test_compose_uses_inline_separator_when_no_trailing_one():
36
+ """No trailing sep, but inline ` | ` runs around → insert one."""
37
+ out = sw.compose("a | b | c", "COACH")
38
+ assert out == "a | b | c | COACH"
39
+
40
+
41
+ def test_compose_falls_through_to_space_when_no_separator_signal():
42
+ """Nothing recognizable → single-space join."""
43
+ out = sw.compose("plain text", "COACH")
44
+ assert out == "plain text COACH"
45
+
46
+
47
+ def test_compose_empty_original_returns_coach_alone():
48
+ assert sw.compose("", "COACH") == "COACH"
49
+ assert sw.compose(" ", "COACH") == "COACH"
50
+
51
+
52
+ def test_compose_empty_coach_returns_original_alone():
53
+ assert sw.compose("original ┃", "") == "original ┃"
54
+
55
+
56
+ # --- inline-separator detection edge cases ---------------------------------
57
+
58
+
59
+ def test_detect_inline_separator_picks_most_frequent():
60
+ assert sw._detect_inline_separator("a ┃ b ┃ c | d") == "┃"
61
+
62
+
63
+ def test_detect_inline_separator_returns_none_when_squashed():
64
+ """`opus·4.7·(1m)` — `·` between alphanumerics, not space-padded.
65
+ Must not be picked as a separator."""
66
+ assert sw._detect_inline_separator("opus·4.7·(1m)") is None
67
+
68
+
69
+ def test_detect_inline_separator_returns_none_on_no_separators():
70
+ assert sw._detect_inline_separator("plain text only") is None
71
+
72
+
73
+ # --- runtime duplicate-detection signature --------------------------------
74
+
75
+
76
+ def test_looks_like_coach_output_matches_sigil_plus_roman():
77
+ """`◆ Ⅷ` — sigil + roman within last 80 chars → duplicate signature."""
78
+ assert sw._looks_like_coach_output("model bar ┃ ◆ Ⅷ 1265 Sensei ↑1") is True
79
+
80
+
81
+ def test_looks_like_coach_output_matches_sigil_plus_rank_name():
82
+ """`⚒ Virtuoso` — sigil + theme rank name → duplicate signature."""
83
+ assert sw._looks_like_coach_output("custom prefix ⚒ Virtuoso · L7 ↑15") is True
84
+
85
+
86
+ def test_looks_like_coach_output_no_match_on_unrelated():
87
+ """No sigil → no match."""
88
+ assert sw._looks_like_coach_output("just plain text 14%") is False
89
+
90
+
91
+ def test_looks_like_coach_output_no_match_on_sigil_alone():
92
+ """Sigil present but no roman + no rank → not a Coach signature."""
93
+ assert sw._looks_like_coach_output("symbol ◆ alone with no rank") is False
94
+
95
+
96
+ def test_looks_like_coach_output_strips_ansi_first():
97
+ """ANSI colors around the sigil/numeral mustn't break detection."""
98
+ text = "x \x1b[38;2;200;205;215m◆\x1b[0m \x1b[1mⅧ\x1b[0m 1265"
99
+ assert sw._looks_like_coach_output(text) is True
100
+
101
+
102
+ # --- main() integration with subprocess ------------------------------------
103
+
104
+
105
+ @pytest.fixture
106
+ def isolated_coach_dir(tmp_path, monkeypatch):
107
+ """Redirect resolve_coach_dir() to a tmp dir."""
108
+ monkeypatch.setenv("COACH_CONFIG_DIR", str(tmp_path))
109
+ return tmp_path
110
+
111
+
112
+ def _write_marker(coach_dir: Path, command: str) -> None:
113
+ (coach_dir / sw.WRAP_MARKER_NAME).write_text(json.dumps({
114
+ "original_command": command,
115
+ }))
116
+
117
+
118
+ def _run_main(monkeypatch, capsys, payload: str = ""):
119
+ monkeypatch.setattr("sys.stdin", io.StringIO(payload))
120
+ rc = sw.main()
121
+ return rc, capsys.readouterr().out
122
+
123
+
124
+ def test_main_no_marker_emits_coach_alone(isolated_coach_dir, monkeypatch, capsys):
125
+ """Without a saved-original marker, the wrapper has nothing to run.
126
+ It still composes — original is empty, so just the coach segment
127
+ emits (or empty if no profile)."""
128
+ rc, out = _run_main(monkeypatch, capsys, payload="")
129
+ assert rc == 0
130
+ # Either empty (no profile signal) or starts with a sigil; both are
131
+ # valid failsafe outcomes. Key invariant: no traceback, no crash.
132
+ assert isinstance(out, str)
133
+
134
+
135
+ def test_main_runs_saved_command_and_appends_coach(
136
+ isolated_coach_dir, monkeypatch, capsys, tmp_path
137
+ ):
138
+ """Saved original is `echo 'orig ┃'` → wrapper appends Coach with
139
+ trailing-aware logic (already has trailing ┃)."""
140
+ _write_marker(isolated_coach_dir, "printf 'orig ┃'")
141
+ monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
142
+
143
+ rc, out = _run_main(monkeypatch, capsys, payload="")
144
+ assert rc == 0
145
+ assert out == "orig ┃ COACH"
146
+
147
+
148
+ def test_main_timeout_emits_coach_alone(isolated_coach_dir, monkeypatch, capsys):
149
+ """Saved command sleeps past the 2s budget → original captured as ""
150
+ → wrapper falls through to coach-alone."""
151
+ _write_marker(isolated_coach_dir, "sleep 5")
152
+ monkeypatch.setattr(sw, "ORIGINAL_TIMEOUT_SECONDS", 0.1)
153
+ monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
154
+
155
+ rc, out = _run_main(monkeypatch, capsys, payload="")
156
+ assert rc == 0
157
+ assert out == "COACH"
158
+
159
+
160
+ def test_main_invalid_payload_does_not_crash(isolated_coach_dir, monkeypatch, capsys):
161
+ """Garbage stdin → parsed as empty dict; wrapper still emits."""
162
+ _write_marker(isolated_coach_dir, "printf 'orig'")
163
+ monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
164
+
165
+ rc, out = _run_main(monkeypatch, capsys, payload="not json {{{")
166
+ assert rc == 0
167
+ assert "orig" in out
168
+
169
+
170
+ def test_main_duplicate_detection_writes_marker_and_skips_coach(
171
+ isolated_coach_dir, monkeypatch, capsys
172
+ ):
173
+ """Original output already contains a Coach signature → the wrapper
174
+ must NOT append a second segment AND must drop a marker file for the
175
+ hook to surface the suggestion banner."""
176
+ _write_marker(isolated_coach_dir, "printf '◆ Ⅷ 1265 Sensei ↑1'")
177
+ monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
178
+
179
+ rc, out = _run_main(monkeypatch, capsys, payload="")
180
+ assert rc == 0
181
+ assert "COACH" not in out
182
+ assert out == "◆ Ⅷ 1265 Sensei ↑1"
183
+ marker = isolated_coach_dir / sw.DUPLICATE_MARKER_NAME
184
+ assert marker.exists()
185
+
186
+
187
+ def test_main_subprocess_failure_falls_back_to_coach(
188
+ isolated_coach_dir, monkeypatch, capsys
189
+ ):
190
+ """Saved command exits nonzero → original captured as "" → coach alone."""
191
+ _write_marker(isolated_coach_dir, "false")
192
+ monkeypatch.setattr(sw, "_coach_segment", lambda payload: "COACH")
193
+
194
+ rc, out = _run_main(monkeypatch, capsys, payload="")
195
+ assert rc == 0
196
+ assert out == "COACH"