@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,165 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import analyze
6
+
7
+
8
+ def _session(
9
+ *,
10
+ session_hash: str = "abcd1234",
11
+ edits: int = 0,
12
+ writes: int = 0,
13
+ tests: int = 0,
14
+ commits: int = 0,
15
+ reads: int = 0,
16
+ grep: int = 0,
17
+ glob: int = 0,
18
+ skills: int = 0,
19
+ rm_rf: int = 0,
20
+ plan_before_edit: bool = False,
21
+ first_plan_idx=None,
22
+ first_edit_idx=None,
23
+ last_edit_idx=None,
24
+ first_test_idx=None,
25
+ last_test_idx=None,
26
+ first_commit_idx=None,
27
+ last_commit_idx=None,
28
+ first_read_idx=None,
29
+ first_search_idx=None,
30
+ ) -> dict:
31
+ return {
32
+ "project": "-Users-r-Desktop-dev-coach",
33
+ "session_hash": session_hash,
34
+ "tool_counts": {},
35
+ "user_turns": 1,
36
+ "assistant_turns": 5,
37
+ "first_ts": None,
38
+ "last_ts": None,
39
+ "first_user_ts": None,
40
+ "first_edit_ts": None,
41
+ "first_plan_ts": None,
42
+ "task_create_count": 0,
43
+ "exit_plan_count": 0,
44
+ "edit_count": edits,
45
+ "write_count": writes,
46
+ "bash_count": tests + commits,
47
+ "commit_count": commits,
48
+ "test_run_count": tests,
49
+ "has_any_commit": commits > 0,
50
+ "has_any_test_run": tests > 0,
51
+ "bash_rm_rf_count": rm_rf,
52
+ "read_count": reads,
53
+ "grep_count": grep,
54
+ "glob_count": glob,
55
+ "agent_count": 0,
56
+ "skill_count": skills,
57
+ "skills_invoked": {},
58
+ "sec_first_user_to_first_edit": None,
59
+ "plan_before_edit": plan_before_edit,
60
+ "first_plan_idx": first_plan_idx,
61
+ "first_edit_idx": first_edit_idx,
62
+ "last_edit_idx": last_edit_idx,
63
+ "first_test_idx": first_test_idx,
64
+ "last_test_idx": last_test_idx,
65
+ "first_commit_idx": first_commit_idx,
66
+ "last_commit_idx": last_commit_idx,
67
+ "first_read_idx": first_read_idx,
68
+ "first_search_idx": first_search_idx,
69
+ }
70
+
71
+
72
+ def _strong_session(i: int) -> dict:
73
+ return _session(
74
+ session_hash=f"good{i}",
75
+ edits=3,
76
+ tests=1,
77
+ commits=1,
78
+ reads=2,
79
+ grep=1,
80
+ skills=1,
81
+ plan_before_edit=True,
82
+ first_plan_idx=0,
83
+ first_search_idx=1,
84
+ first_read_idx=2,
85
+ first_edit_idx=3,
86
+ last_edit_idx=3,
87
+ first_test_idx=4,
88
+ last_test_idx=4,
89
+ first_commit_idx=5,
90
+ last_commit_idx=5,
91
+ )
92
+
93
+
94
+ def test_aggregate_emits_positive_strength_detections():
95
+ detections, _summary = analyze.aggregate([_strong_session(i) for i in range(3)])
96
+
97
+ by_id = {d["id"]: d for d in detections}
98
+ expected = {
99
+ "tests-after-edits",
100
+ "plans-before-edits",
101
+ "commits-gated-by-tests",
102
+ "search-before-reading",
103
+ "small-batch-verify",
104
+ "safe-git-hygiene",
105
+ "effective-skill-use",
106
+ }
107
+
108
+ assert expected <= set(by_id)
109
+ assert all(by_id[eid]["direction"] == "positive" for eid in expected)
110
+ assert by_id["tests-after-edits"]["reward_hint"]["action"] == "test_run"
111
+ assert by_id["effective-skill-use"]["reward_hint"]["action"] == "skill_invoke"
112
+
113
+
114
+ def test_positive_strengths_require_repeated_majority_evidence():
115
+ sessions = [
116
+ _session(
117
+ session_hash=f"good{i}",
118
+ edits=2,
119
+ tests=1,
120
+ first_edit_idx=1,
121
+ last_edit_idx=1,
122
+ first_test_idx=2,
123
+ last_test_idx=2,
124
+ )
125
+ for i in range(2)
126
+ ]
127
+ sessions += [
128
+ _session(session_hash=f"bad{i}", edits=2, first_edit_idx=1, last_edit_idx=1)
129
+ for i in range(3)
130
+ ]
131
+
132
+ detections, _summary = analyze.aggregate(sessions)
133
+
134
+ assert "tests-after-edits" not in {d["id"] for d in detections}
135
+
136
+
137
+ def test_analyze_session_records_tool_order_for_strength_detectors(tmp_path):
138
+ transcript = tmp_path / "session.jsonl"
139
+ record = {
140
+ "type": "assistant",
141
+ "message": {
142
+ "role": "assistant",
143
+ "content": [
144
+ {"type": "tool_use", "name": "Plan", "input": {}},
145
+ {"type": "tool_use", "name": "Grep", "input": {"pattern": "x"}},
146
+ {"type": "tool_use", "name": "Read", "input": {"file_path": "a.py"}},
147
+ {"type": "tool_use", "name": "Edit", "input": {"file_path": "a.py"}},
148
+ {"type": "tool_use", "name": "Bash", "input": {"command": "pytest"}},
149
+ {"type": "tool_use", "name": "Bash", "input": {"command": "git commit -m ok"}},
150
+ {"type": "tool_use", "name": "Skill", "input": {"skill": "design"}},
151
+ ],
152
+ },
153
+ }
154
+ transcript.write_text(json.dumps(record) + "\n")
155
+
156
+ sig = analyze.analyze_session(transcript)
157
+
158
+ assert sig is not None
159
+ assert sig["first_plan_idx"] == 0
160
+ assert sig["first_search_idx"] == 1
161
+ assert sig["first_read_idx"] == 2
162
+ assert sig["first_edit_idx"] == 3
163
+ assert sig["last_test_idx"] == 4
164
+ assert sig["last_commit_idx"] == 5
165
+ assert sig["plan_before_edit"] is True
@@ -0,0 +1,61 @@
1
+ """bank.py — atomic write unlinks tmp on os.replace failure.
2
+
3
+ Pinned regression for the unlink-on-failure path in
4
+ `bank._atomic_write_yaml`. Before the fix, a rare cross-device or
5
+ filesystem-quirk failure of `os.replace` would leak a stale
6
+ `.profile.<rand>.tmp` into `~/.claude/coach/`, eventually accumulating
7
+ visible junk. This test forces the failure path and asserts the temp
8
+ is cleaned up. Mirrors the cleanup pattern in
9
+ `merge.atomic_write_yaml` and `marker_io._atomic_write_under_lock`.
10
+ """
11
+ from __future__ import annotations
12
+
13
+ import os
14
+ from pathlib import Path
15
+
16
+ import pytest
17
+
18
+ import bank
19
+
20
+
21
+ def test_atomic_write_unlinks_temp_on_replace_failure(tmp_path, monkeypatch):
22
+ """If os.replace raises, _atomic_write_yaml must unlink the .tmp and re-raise."""
23
+ target = tmp_path / "profile.yaml"
24
+ target.write_text("schema_version: 1\nentries: []\n")
25
+
26
+ captured: list[str] = []
27
+
28
+ def failing_replace(src, _dst):
29
+ captured.append(src)
30
+ raise OSError("simulated cross-device rename failure")
31
+
32
+ monkeypatch.setattr(bank.os, "replace", failing_replace)
33
+
34
+ with pytest.raises(OSError, match="simulated cross-device rename failure"):
35
+ bank._atomic_write_yaml(target, {"schema_version": 1, "entries": []})
36
+
37
+ # The exception must propagate (caller decides what to do).
38
+ assert captured, "os.replace was never called — test setup wrong"
39
+ leftover = captured[0]
40
+ assert not Path(leftover).exists(), (
41
+ f"tmp file {leftover} was not unlinked after os.replace failure — "
42
+ "matches the bug class merge.atomic_write_yaml fixed; ensure "
43
+ "bank._atomic_write_yaml mirrors that pattern."
44
+ )
45
+
46
+ # No stray .profile.*.tmp in the directory either.
47
+ stragglers = sorted(p.name for p in tmp_path.glob(".profile.*.tmp"))
48
+ assert stragglers == [], f"leftover tmp files: {stragglers}"
49
+
50
+
51
+ def test_atomic_write_happy_path_still_works(tmp_path):
52
+ """Round-trip on the happy path — confirm the unlink-on-failure
53
+ addition didn't break the normal write."""
54
+ target = tmp_path / "profile.yaml"
55
+ bank._atomic_write_yaml(target, {"schema_version": 1, "entries": []})
56
+ assert target.exists()
57
+ content = target.read_text()
58
+ assert "schema_version: 1" in content
59
+ assert "entries: []" in content
60
+ # No stray tmp files.
61
+ assert sorted(p.name for p in tmp_path.glob(".profile.*.tmp")) == []
@@ -0,0 +1,126 @@
1
+ """bank.py concurrency — bounded-wait lock acquire.
2
+
3
+ Regression guard for the race where /coach-insights holds the profile lock at
4
+ the moment SessionStart spawns bank.py. Before this fix, bank.py would
5
+ non-blocking-fail immediately and silently drop the session's XP banking
6
+ until the NEXT SessionStart. Now bank.py waits up to LOCK_WAIT_SECONDS
7
+ for the lock to free up.
8
+ """
9
+ from __future__ import annotations
10
+
11
+ import fcntl
12
+ import json
13
+ import os
14
+ import threading
15
+ import time
16
+ from pathlib import Path
17
+
18
+ import pytest
19
+ import yaml
20
+
21
+ import bank
22
+
23
+
24
+ def _hold_lock_for(path: Path, seconds: float, released: threading.Event) -> None:
25
+ """Acquire the lock, hold it for `seconds`, release, signal."""
26
+ with path.open("a+") as fh:
27
+ fcntl.flock(fh, fcntl.LOCK_EX)
28
+ time.sleep(seconds)
29
+ fcntl.flock(fh, fcntl.LOCK_UN)
30
+ released.set()
31
+
32
+
33
+ def test_lock_acquired_immediately_when_free(tmp_path, monkeypatch):
34
+ """When nothing holds the lock, bank.py acquires essentially instantly."""
35
+ monkeypatch.setattr(bank, "LOCK_WAIT_SECONDS", 5)
36
+ monkeypatch.setattr(bank, "LOCK_RETRY_INTERVAL", 0.05)
37
+ lockfile_path = tmp_path / ".lock"
38
+
39
+ start = time.monotonic()
40
+ with lockfile_path.open("a+") as fh:
41
+ assert bank._acquire_lock_bounded(fh) is True
42
+ elapsed = time.monotonic() - start
43
+ assert elapsed < 0.2 # essentially immediate
44
+
45
+
46
+ def test_lock_waits_then_succeeds_when_released(tmp_path, monkeypatch):
47
+ """If the lock frees up before the bounded-wait timeout, bank.py
48
+ acquires it and proceeds — the case that fixes the race."""
49
+ monkeypatch.setattr(bank, "LOCK_WAIT_SECONDS", 3)
50
+ monkeypatch.setattr(bank, "LOCK_RETRY_INTERVAL", 0.05)
51
+ lockfile_path = tmp_path / ".lock"
52
+ released = threading.Event()
53
+
54
+ # Hold the lock for 0.4s from a background thread.
55
+ holder = threading.Thread(
56
+ target=_hold_lock_for, args=(lockfile_path, 0.4, released), daemon=True,
57
+ )
58
+ holder.start()
59
+ time.sleep(0.05) # ensure holder grabbed the lock first
60
+
61
+ start = time.monotonic()
62
+ with lockfile_path.open("a+") as fh:
63
+ acquired = bank._acquire_lock_bounded(fh)
64
+ elapsed = time.monotonic() - start
65
+
66
+ holder.join(timeout=2)
67
+ assert acquired is True
68
+ assert elapsed >= 0.3 # did wait for the holder
69
+ assert elapsed < 2.0 # but came back well before the timeout
70
+ assert released.is_set()
71
+
72
+
73
+ def test_lock_gives_up_after_timeout(tmp_path, monkeypatch):
74
+ """If the holder never releases, bank.py returns False after the
75
+ bounded-wait deadline passes — safe fallback so a stuck lock never
76
+ hangs the process indefinitely."""
77
+ monkeypatch.setattr(bank, "LOCK_WAIT_SECONDS", 0.3)
78
+ monkeypatch.setattr(bank, "LOCK_RETRY_INTERVAL", 0.05)
79
+ lockfile_path = tmp_path / ".lock"
80
+ released = threading.Event()
81
+
82
+ # Hold the lock longer than LOCK_WAIT_SECONDS.
83
+ holder = threading.Thread(
84
+ target=_hold_lock_for, args=(lockfile_path, 1.0, released), daemon=True,
85
+ )
86
+ holder.start()
87
+ time.sleep(0.05)
88
+
89
+ start = time.monotonic()
90
+ with lockfile_path.open("a+") as fh:
91
+ acquired = bank._acquire_lock_bounded(fh)
92
+ elapsed = time.monotonic() - start
93
+
94
+ holder.join(timeout=3)
95
+ assert acquired is False
96
+ assert 0.2 <= elapsed < 0.8 # gave up around the timeout, not much later
97
+
98
+
99
+ def test_bank_writes_session_banked_xp_not_milestones(tmp_path, monkeypatch):
100
+ profile_path = tmp_path / "profile.yaml"
101
+ ledger_path = tmp_path / "banked_sessions.json"
102
+ projects = tmp_path / "projects"
103
+ projects.mkdir()
104
+ transcript = projects / "session-1.jsonl"
105
+ transcript.write_text("{}\n")
106
+ old = time.time() - 3600
107
+ os.utime(transcript, (old, old))
108
+ profile_path.write_text(yaml.safe_dump({
109
+ "entries": [],
110
+ "graduated": [],
111
+ "milestone_xp": 3,
112
+ }))
113
+ monkeypatch.setattr(bank, "PROFILE", profile_path)
114
+ monkeypatch.setattr(bank, "LEDGER", ledger_path)
115
+ monkeypatch.setattr(bank, "PROJECTS", projects)
116
+ monkeypatch.setattr(bank, "_score_transcript", lambda _path, _profile: 12)
117
+
118
+ summary = bank._bank()
119
+
120
+ written = yaml.safe_load(profile_path.read_text())
121
+ ledger = json.loads(ledger_path.read_text())
122
+ assert summary["xp_added"] == 1
123
+ assert written["session_banked_xp"] == 1
124
+ assert written["banked_session_xp"] == 1
125
+ assert written["milestone_xp"] == 3
126
+ assert ledger["session-1"]["banked"] == 1