@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,109 @@
|
|
|
1
|
+
"""coach/bin/insights-llm.sh — plugin-context PATH wedge.
|
|
2
|
+
|
|
3
|
+
When `CLAUDE_PLUGIN_DATA` is set + a `venv/bin/python3` exists under
|
|
4
|
+
it, insights-llm.sh prepends that dir to `PATH` at startup, so all
|
|
5
|
+
subsequent `python3` invocations (including child processes calling
|
|
6
|
+
aggregate_facets.py / merge.py) resolve to the plugin's venv
|
|
7
|
+
interpreter — and therefore find PyYAML on a plugin-only fresh box.
|
|
8
|
+
|
|
9
|
+
CLI users never have `CLAUDE_PLUGIN_DATA` set; for them the wedge is
|
|
10
|
+
a no-op. Same script ships into both distributions via
|
|
11
|
+
`tools/build_plugin.py`.
|
|
12
|
+
|
|
13
|
+
These tests verify the wedge fires (or doesn't) under the right
|
|
14
|
+
env conditions. They don't run the full insights pipeline — that's
|
|
15
|
+
covered by other test modules. Just the PATH semantics.
|
|
16
|
+
"""
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
import os
|
|
20
|
+
import shutil
|
|
21
|
+
import subprocess
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
|
|
24
|
+
import pytest
|
|
25
|
+
|
|
26
|
+
REPO_ROOT = Path(__file__).resolve().parent.parent.parent
|
|
27
|
+
INSIGHTS_LLM = REPO_ROOT / "coach" / "bin" / "insights-llm.sh"
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _run_path_probe(env: dict[str, str]) -> str:
|
|
31
|
+
"""Inject a tiny probe at the top of insights-llm.sh's already-run
|
|
32
|
+
PATH-wedge logic. We can't easily run the full script (it'd start
|
|
33
|
+
a real claude -p), so we extract the wedge block + run it
|
|
34
|
+
standalone and dump the resulting PATH.
|
|
35
|
+
|
|
36
|
+
The wedge is the first ~10 lines after `set -uo pipefail`. We
|
|
37
|
+
simulate it inline so behavior tests don't depend on the rest of
|
|
38
|
+
the script.
|
|
39
|
+
"""
|
|
40
|
+
wedge = r"""
|
|
41
|
+
set -uo pipefail
|
|
42
|
+
if [[ -n "${CLAUDE_PLUGIN_DATA:-}" && -x "$CLAUDE_PLUGIN_DATA/venv/bin/python3" ]]; then
|
|
43
|
+
export PATH="$CLAUDE_PLUGIN_DATA/venv/bin:$PATH"
|
|
44
|
+
fi
|
|
45
|
+
echo "$PATH"
|
|
46
|
+
"""
|
|
47
|
+
result = subprocess.run(
|
|
48
|
+
["bash", "-c", wedge],
|
|
49
|
+
env={**os.environ, **env},
|
|
50
|
+
capture_output=True,
|
|
51
|
+
text=True,
|
|
52
|
+
timeout=5,
|
|
53
|
+
)
|
|
54
|
+
assert result.returncode == 0, result.stderr
|
|
55
|
+
return result.stdout.strip()
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def test_wedge_fires_when_plugin_data_and_venv_present(tmp_path, monkeypatch):
|
|
59
|
+
venv_bin = tmp_path / "venv" / "bin"
|
|
60
|
+
venv_bin.mkdir(parents=True)
|
|
61
|
+
fake_python = venv_bin / "python3"
|
|
62
|
+
fake_python.write_text("#!/bin/sh\nexit 0\n")
|
|
63
|
+
fake_python.chmod(0o755)
|
|
64
|
+
|
|
65
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
|
|
66
|
+
|
|
67
|
+
path = _run_path_probe({})
|
|
68
|
+
assert path.startswith(str(venv_bin) + ":"), (
|
|
69
|
+
f"venv bin should be prepended to PATH; got: {path!r}"
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def test_wedge_skips_when_plugin_data_unset(monkeypatch):
|
|
74
|
+
monkeypatch.delenv("CLAUDE_PLUGIN_DATA", raising=False)
|
|
75
|
+
path = _run_path_probe({})
|
|
76
|
+
# Just assert no /venv/bin marker injected; PATH content depends
|
|
77
|
+
# on the test environment.
|
|
78
|
+
assert "/venv/bin:" not in path or "/.coach-venv/" in path, (
|
|
79
|
+
f"PATH should be unchanged when CLAUDE_PLUGIN_DATA unset; got: {path!r}"
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def test_wedge_skips_when_venv_python_missing(tmp_path, monkeypatch):
|
|
84
|
+
"""CLAUDE_PLUGIN_DATA set but no venv/bin/python3 exists yet (e.g.,
|
|
85
|
+
the very first run before bootstrap.sh has set up the venv).
|
|
86
|
+
Wedge should be a no-op — the test asserts the PATH doesn't pick
|
|
87
|
+
up the (nonexistent) venv path."""
|
|
88
|
+
monkeypatch.setenv("CLAUDE_PLUGIN_DATA", str(tmp_path))
|
|
89
|
+
# No venv directory created.
|
|
90
|
+
path = _run_path_probe({})
|
|
91
|
+
expected_marker = str(tmp_path / "venv" / "bin")
|
|
92
|
+
assert expected_marker not in path, (
|
|
93
|
+
f"PATH should NOT include nonexistent venv bin; got: {path!r}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def test_real_insights_llm_sh_contains_the_wedge():
|
|
98
|
+
"""Sanity: pin that the wedge block is actually present in the
|
|
99
|
+
real script (so a future refactor doesn't accidentally remove
|
|
100
|
+
it)."""
|
|
101
|
+
body = INSIGHTS_LLM.read_text()
|
|
102
|
+
assert "CLAUDE_PLUGIN_DATA" in body, (
|
|
103
|
+
"insights-llm.sh has lost its CLAUDE_PLUGIN_DATA wedge — the "
|
|
104
|
+
"plugin's venv won't be picked up by child python invocations"
|
|
105
|
+
)
|
|
106
|
+
assert "venv/bin/python3" in body, (
|
|
107
|
+
"insights-llm.sh wedge no longer references venv/bin/python3 "
|
|
108
|
+
"as the existence-check probe"
|
|
109
|
+
)
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""coach/bin/insights_window.py: TZ-correct /coach-insights window filter.
|
|
2
|
+
|
|
3
|
+
Pins the bug fix where BSD `find -newermt "$SINCE_TS"` was reading a
|
|
4
|
+
UTC-formatted timestamp in the host's *local* timezone, causing the
|
|
5
|
+
cron's 24h window to silently shrink or grow by tz_offset hours every
|
|
6
|
+
run on any non-UTC host.
|
|
7
|
+
|
|
8
|
+
These tests are deliberately TZ-parametrized: setting `TZ` at the
|
|
9
|
+
process level should NOT change the cutoff math, because we always
|
|
10
|
+
compute from `datetime.now(timezone.utc)` and compare against POSIX
|
|
11
|
+
`st_mtime` (both TZ-independent). If a future refactor reintroduces
|
|
12
|
+
a TZ-naive datetime or local-time formatting, one of these parametrize
|
|
13
|
+
cases will fail.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import importlib.util
|
|
18
|
+
import os
|
|
19
|
+
import subprocess
|
|
20
|
+
import sys
|
|
21
|
+
import time
|
|
22
|
+
from datetime import datetime, timedelta, timezone
|
|
23
|
+
from pathlib import Path
|
|
24
|
+
|
|
25
|
+
import pytest
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
@pytest.fixture(scope="module")
|
|
29
|
+
def iw():
|
|
30
|
+
"""Load coach/bin/insights_window.py as a module."""
|
|
31
|
+
repo_path = Path(__file__).resolve().parents[2] / "coach" / "bin" / "insights_window.py"
|
|
32
|
+
path = repo_path if repo_path.exists() else Path.home() / ".claude" / "coach" / "bin" / "insights_window.py"
|
|
33
|
+
if not path.exists():
|
|
34
|
+
pytest.skip(f"insights_window.py not installed at {path}")
|
|
35
|
+
spec = importlib.util.spec_from_file_location("iw_under_test", str(path))
|
|
36
|
+
mod = importlib.util.module_from_spec(spec)
|
|
37
|
+
spec.loader.exec_module(mod)
|
|
38
|
+
return mod
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# --- parse_window ----------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
@pytest.mark.parametrize("spec,expected", [
|
|
44
|
+
("1d", timedelta(days=1)),
|
|
45
|
+
("7d", timedelta(days=7)),
|
|
46
|
+
("2h", timedelta(hours=2)),
|
|
47
|
+
("30m", timedelta(minutes=30)),
|
|
48
|
+
("60m", timedelta(minutes=60)),
|
|
49
|
+
])
|
|
50
|
+
def test_parse_window_accepts_valid_specs(iw, spec, expected):
|
|
51
|
+
assert iw.parse_window(spec) == expected
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@pytest.mark.parametrize("bad", ["", "1", "d", "1w", "1.5h", "-1d", "abc"])
|
|
55
|
+
def test_parse_window_rejects_bad_specs(iw, bad):
|
|
56
|
+
with pytest.raises(ValueError):
|
|
57
|
+
iw.parse_window(bad)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
# --- cutoff_epoch ----------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
def test_cutoff_epoch_with_frozen_now(iw):
|
|
63
|
+
now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
|
|
64
|
+
cutoff = iw.cutoff_epoch("1d", now=now)
|
|
65
|
+
expected = now - timedelta(days=1)
|
|
66
|
+
assert cutoff == expected.timestamp()
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def test_cutoff_epoch_requires_tz_aware_now(iw):
|
|
70
|
+
naive = datetime(2026, 5, 2, 11, 0, 0) # no tzinfo
|
|
71
|
+
with pytest.raises(ValueError):
|
|
72
|
+
iw.cutoff_epoch("1d", now=naive)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@pytest.mark.parametrize("tz", ["UTC", "America/Los_Angeles", "Asia/Tokyo"])
|
|
76
|
+
def test_cutoff_epoch_is_tz_independent(iw, tz, monkeypatch):
|
|
77
|
+
"""The cutoff should be the same absolute moment regardless of host TZ.
|
|
78
|
+
Setting TZ in the env must not shift the result — that was the whole
|
|
79
|
+
point of moving off `find -newermt`."""
|
|
80
|
+
monkeypatch.setenv("TZ", tz)
|
|
81
|
+
time.tzset()
|
|
82
|
+
try:
|
|
83
|
+
now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
|
|
84
|
+
cutoff = iw.cutoff_epoch("1d", now=now)
|
|
85
|
+
# Same UTC moment → same epoch, regardless of TZ env.
|
|
86
|
+
assert cutoff == (now - timedelta(days=1)).timestamp()
|
|
87
|
+
finally:
|
|
88
|
+
# Restore process TZ so other tests aren't affected.
|
|
89
|
+
monkeypatch.delenv("TZ", raising=False)
|
|
90
|
+
time.tzset()
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
# --- recent_transcripts ----------------------------------------------------
|
|
94
|
+
|
|
95
|
+
def _seed_transcript(path: Path, mtime: float) -> Path:
|
|
96
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
97
|
+
path.write_text("{}\n")
|
|
98
|
+
os.utime(path, (mtime, mtime))
|
|
99
|
+
return path
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def test_recent_transcripts_filters_by_mtime(iw, tmp_path):
|
|
103
|
+
now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
|
|
104
|
+
fresh = _seed_transcript(
|
|
105
|
+
tmp_path / "p1" / "fresh.jsonl",
|
|
106
|
+
(now - timedelta(hours=1)).timestamp(),
|
|
107
|
+
)
|
|
108
|
+
stale = _seed_transcript(
|
|
109
|
+
tmp_path / "p1" / "stale.jsonl",
|
|
110
|
+
(now - timedelta(days=2)).timestamp(),
|
|
111
|
+
)
|
|
112
|
+
out = iw.recent_transcripts(tmp_path, "1d", now=now)
|
|
113
|
+
assert fresh in out
|
|
114
|
+
assert stale not in out
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def test_recent_transcripts_excludes_subagents(iw, tmp_path):
|
|
118
|
+
now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
|
|
119
|
+
main = _seed_transcript(
|
|
120
|
+
tmp_path / "p1" / "main.jsonl",
|
|
121
|
+
(now - timedelta(hours=1)).timestamp(),
|
|
122
|
+
)
|
|
123
|
+
sub = _seed_transcript(
|
|
124
|
+
tmp_path / "p1" / "subagents" / "spawn.jsonl",
|
|
125
|
+
(now - timedelta(hours=1)).timestamp(),
|
|
126
|
+
)
|
|
127
|
+
out = iw.recent_transcripts(tmp_path, "1d", now=now)
|
|
128
|
+
assert main in out
|
|
129
|
+
assert sub not in out
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def test_recent_transcripts_handles_missing_dir(iw, tmp_path):
|
|
133
|
+
missing = tmp_path / "does-not-exist"
|
|
134
|
+
assert iw.recent_transcripts(missing, "1d") == []
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def test_recent_transcripts_validates_window_even_when_dir_missing(iw, tmp_path):
|
|
138
|
+
missing = tmp_path / "does-not-exist"
|
|
139
|
+
with pytest.raises(ValueError):
|
|
140
|
+
iw.recent_transcripts(missing, "1week")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
@pytest.mark.parametrize("tz", ["UTC", "America/Los_Angeles", "Asia/Tokyo"])
|
|
144
|
+
def test_recent_transcripts_window_is_tz_independent(iw, tmp_path, tz, monkeypatch):
|
|
145
|
+
"""End-to-end TZ regression: a transcript whose mtime is 23h before
|
|
146
|
+
`now` must always land inside a 1d window, regardless of host TZ.
|
|
147
|
+
The previous `find -newermt` implementation failed this on
|
|
148
|
+
Asia/Tokyo / America/Los_Angeles / any non-UTC host."""
|
|
149
|
+
monkeypatch.setenv("TZ", tz)
|
|
150
|
+
time.tzset()
|
|
151
|
+
try:
|
|
152
|
+
now = datetime(2026, 5, 2, 11, 0, 0, tzinfo=timezone.utc)
|
|
153
|
+
in_window = _seed_transcript(
|
|
154
|
+
tmp_path / "p1" / "23h.jsonl",
|
|
155
|
+
(now - timedelta(hours=23)).timestamp(),
|
|
156
|
+
)
|
|
157
|
+
out_of_window = _seed_transcript(
|
|
158
|
+
tmp_path / "p1" / "25h.jsonl",
|
|
159
|
+
(now - timedelta(hours=25)).timestamp(),
|
|
160
|
+
)
|
|
161
|
+
out = iw.recent_transcripts(tmp_path, "1d", now=now)
|
|
162
|
+
assert in_window in out, f"23h-old transcript missed in TZ={tz}"
|
|
163
|
+
assert out_of_window not in out, f"25h-old transcript included in TZ={tz}"
|
|
164
|
+
finally:
|
|
165
|
+
monkeypatch.delenv("TZ", raising=False)
|
|
166
|
+
time.tzset()
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# --- subprocess smoke ------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
def test_main_prints_paths_one_per_line(tmp_path):
|
|
172
|
+
"""Smoke-test the CLI entrypoint that insights.sh actually invokes."""
|
|
173
|
+
repo_path = Path(__file__).resolve().parents[2] / "coach" / "bin" / "insights_window.py"
|
|
174
|
+
if not repo_path.exists():
|
|
175
|
+
pytest.skip("insights_window.py not present in repo")
|
|
176
|
+
now = datetime.now(timezone.utc)
|
|
177
|
+
fresh = _seed_transcript(
|
|
178
|
+
tmp_path / "p1" / "fresh.jsonl",
|
|
179
|
+
(now - timedelta(hours=1)).timestamp(),
|
|
180
|
+
)
|
|
181
|
+
stale = _seed_transcript(
|
|
182
|
+
tmp_path / "p1" / "stale.jsonl",
|
|
183
|
+
(now - timedelta(days=2)).timestamp(),
|
|
184
|
+
)
|
|
185
|
+
result = subprocess.run(
|
|
186
|
+
[sys.executable, str(repo_path), str(tmp_path), "1d"],
|
|
187
|
+
capture_output=True,
|
|
188
|
+
text=True,
|
|
189
|
+
check=True,
|
|
190
|
+
)
|
|
191
|
+
lines = [ln for ln in result.stdout.strip().splitlines() if ln]
|
|
192
|
+
assert str(fresh) in lines
|
|
193
|
+
assert str(stale) not in lines
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def test_main_rejects_bad_window():
|
|
197
|
+
repo_path = Path(__file__).resolve().parents[2] / "coach" / "bin" / "insights_window.py"
|
|
198
|
+
if not repo_path.exists():
|
|
199
|
+
pytest.skip("insights_window.py not present in repo")
|
|
200
|
+
result = subprocess.run(
|
|
201
|
+
[sys.executable, str(repo_path), "/tmp", "1week"],
|
|
202
|
+
capture_output=True,
|
|
203
|
+
text=True,
|
|
204
|
+
)
|
|
205
|
+
assert result.returncode == 2
|
|
206
|
+
assert "bad window" in result.stderr
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def test_insights_sh_propagates_window_helper_failure(tmp_path):
|
|
210
|
+
"""insights.sh must fail when insights_window.py rejects the window.
|
|
211
|
+
|
|
212
|
+
Bash process substitution hides producer exit statuses, so this pins
|
|
213
|
+
the shell integration rather than only the Python helper behavior.
|
|
214
|
+
"""
|
|
215
|
+
repo_root = Path(__file__).resolve().parents[2]
|
|
216
|
+
script = repo_root / "coach" / "bin" / "insights.sh"
|
|
217
|
+
helper = repo_root / "coach" / "bin" / "insights_window.py"
|
|
218
|
+
if not script.exists() or not helper.exists():
|
|
219
|
+
pytest.skip("insights runner not present in repo")
|
|
220
|
+
|
|
221
|
+
home = tmp_path / "home"
|
|
222
|
+
bin_dir = home / ".claude" / "coach" / "bin"
|
|
223
|
+
projects = home / ".claude" / "projects"
|
|
224
|
+
bin_dir.mkdir(parents=True)
|
|
225
|
+
projects.mkdir(parents=True)
|
|
226
|
+
(bin_dir / "insights.sh").write_text(script.read_text())
|
|
227
|
+
(bin_dir / "insights_window.py").write_text(helper.read_text())
|
|
228
|
+
|
|
229
|
+
result = subprocess.run(
|
|
230
|
+
["bash", str(bin_dir / "insights.sh"), "1week"],
|
|
231
|
+
env={**os.environ, "HOME": str(home)},
|
|
232
|
+
capture_output=True,
|
|
233
|
+
text=True,
|
|
234
|
+
)
|
|
235
|
+
assert result.returncode == 2
|
|
236
|
+
assert "bad window" in result.stderr
|
|
237
|
+
assert "done" not in result.stdout
|