@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,274 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Aggregator: /insights facets/*.json sidecars → Coach detections JSON.
|
|
3
|
+
|
|
4
|
+
Pure Python, no LLM. Mirrors analyze.py's threshold-based emit pattern but
|
|
5
|
+
sources from Anthropic's structured `/insights` output rather than redacted
|
|
6
|
+
transcripts. The LLM has already done the per-session classification —
|
|
7
|
+
this script just counts stable enum keys across the window and applies
|
|
8
|
+
threshold rules.
|
|
9
|
+
|
|
10
|
+
Inputs:
|
|
11
|
+
--facets-dir DIR default: ~/.claude/usage-data/facets/
|
|
12
|
+
--window-days N default: 7
|
|
13
|
+
--cap N max detections to emit (default: 8)
|
|
14
|
+
|
|
15
|
+
Output:
|
|
16
|
+
stdout: JSON array of detections (matches merge.py --detections schema)
|
|
17
|
+
stderr: one-line summary "n_sessions=N detections=M"
|
|
18
|
+
|
|
19
|
+
Thresholds:
|
|
20
|
+
- friction_counts.* keys appearing in ≥25% of sessions → negative detection
|
|
21
|
+
- primary_success == key appearing in ≥60% of sessions → positive detection
|
|
22
|
+
|
|
23
|
+
Detection id derivation: enum key with `_` → `-` (e.g.
|
|
24
|
+
`misunderstood_request` → `misunderstood-request`). Stable across runs by
|
|
25
|
+
Anthropic's data contract — see plan §Key findings (Agent 1).
|
|
26
|
+
"""
|
|
27
|
+
from __future__ import annotations
|
|
28
|
+
|
|
29
|
+
import argparse
|
|
30
|
+
import json
|
|
31
|
+
import os
|
|
32
|
+
import re
|
|
33
|
+
import sys
|
|
34
|
+
import time
|
|
35
|
+
from pathlib import Path
|
|
36
|
+
|
|
37
|
+
NEGATIVE_THRESHOLD = 0.25
|
|
38
|
+
POSITIVE_THRESHOLD = 0.60
|
|
39
|
+
DEFAULT_CAP = 8
|
|
40
|
+
EXAMPLE_CAP = 3
|
|
41
|
+
EXAMPLE_MAXLEN = 120
|
|
42
|
+
DEFAULT_WINDOW_DAYS = 7
|
|
43
|
+
|
|
44
|
+
# Distinct CLI exit code for "no current-window evidence" — n_sessions == 0
|
|
45
|
+
# in the requested window. The wrapper (insights-llm.sh) translates this to
|
|
46
|
+
# its own exit 7. An empty detections list with zero sessions is "no
|
|
47
|
+
# evidence" and must NOT advance absence-based streaks at merge time;
|
|
48
|
+
# empty detections WITH n_sessions > 0 IS valid (a clean week) and merges
|
|
49
|
+
# normally. See plan-the-fixes-and-partitioned-squirrel.md §Commit 2.
|
|
50
|
+
EXIT_NO_EVIDENCE = 3
|
|
51
|
+
|
|
52
|
+
# Conservative example redaction — these sidecars are Anthropic-side
|
|
53
|
+
# summaries, but the prose may still echo back filenames the user typed.
|
|
54
|
+
# Strip path-like and file-extension-like tokens before storing in
|
|
55
|
+
# profile.yaml.
|
|
56
|
+
_PATH_RE = re.compile(r"(?:/|\\)[\w./\\-]+")
|
|
57
|
+
_FILE_EXT_RE = re.compile(
|
|
58
|
+
r"\b[\w-]+\.(?:py|js|ts|tsx|jsx|mjs|cjs|md|markdown|sh|bash|zsh|"
|
|
59
|
+
r"yaml|yml|toml|json|html|css|scss|sass|rs|go|java|cpp|hpp|h|c|"
|
|
60
|
+
r"rb|sql|env|ini|conf|cfg|log|txt)\b",
|
|
61
|
+
re.IGNORECASE,
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _redact_example(s: str) -> str:
|
|
66
|
+
s = _PATH_RE.sub("[path]", s)
|
|
67
|
+
s = _FILE_EXT_RE.sub("[file]", s)
|
|
68
|
+
return s
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def _kebab(key: str) -> str:
|
|
72
|
+
return str(key).replace("_", "-").lower()
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _trim(s: str, maxlen: int = EXAMPLE_MAXLEN) -> str:
|
|
76
|
+
s = (s or "").strip()
|
|
77
|
+
if len(s) <= maxlen:
|
|
78
|
+
return s
|
|
79
|
+
# Cut on a word boundary if possible.
|
|
80
|
+
cut = s[:maxlen].rsplit(" ", 1)[0]
|
|
81
|
+
return cut if len(cut) >= maxlen // 2 else s[:maxlen]
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _iter_facets(facets_dir: Path, cutoff_ts: float):
|
|
85
|
+
"""Yield (path, parsed_json) for facets newer than cutoff_ts.
|
|
86
|
+
|
|
87
|
+
Skips bad JSON silently. Mirrors analyze.py's tolerant read pattern.
|
|
88
|
+
"""
|
|
89
|
+
if not facets_dir.exists() or not facets_dir.is_dir():
|
|
90
|
+
return
|
|
91
|
+
for p in facets_dir.iterdir():
|
|
92
|
+
if not p.name.endswith(".json"):
|
|
93
|
+
continue
|
|
94
|
+
try:
|
|
95
|
+
if p.stat().st_mtime < cutoff_ts:
|
|
96
|
+
continue
|
|
97
|
+
except OSError:
|
|
98
|
+
continue
|
|
99
|
+
try:
|
|
100
|
+
data = json.loads(p.read_text(errors="replace"))
|
|
101
|
+
except (OSError, ValueError):
|
|
102
|
+
continue
|
|
103
|
+
if not isinstance(data, dict):
|
|
104
|
+
continue
|
|
105
|
+
yield p, data
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def aggregate(facets_dir: Path, window_days: int, cap: int) -> list[dict]:
|
|
109
|
+
cutoff_ts = time.time() - window_days * 86400
|
|
110
|
+
|
|
111
|
+
sessions: list[dict] = []
|
|
112
|
+
for _, data in _iter_facets(facets_dir, cutoff_ts):
|
|
113
|
+
sessions.append(data)
|
|
114
|
+
|
|
115
|
+
n_sessions = len(sessions)
|
|
116
|
+
if n_sessions == 0:
|
|
117
|
+
return []
|
|
118
|
+
|
|
119
|
+
# --- Negative side: friction_counts.* enum keys ---------------------
|
|
120
|
+
# Count distinct sessions where each friction key has count ≥ 1.
|
|
121
|
+
friction_session_counts: dict[str, int] = {}
|
|
122
|
+
friction_examples: dict[str, list[str]] = {}
|
|
123
|
+
for s in sessions:
|
|
124
|
+
fc = s.get("friction_counts") or {}
|
|
125
|
+
if not isinstance(fc, dict):
|
|
126
|
+
continue
|
|
127
|
+
seen_in_session: set[str] = set()
|
|
128
|
+
for key, count in fc.items():
|
|
129
|
+
if not isinstance(count, (int, float)) or count < 1:
|
|
130
|
+
continue
|
|
131
|
+
seen_in_session.add(str(key))
|
|
132
|
+
if not seen_in_session:
|
|
133
|
+
continue
|
|
134
|
+
detail = s.get("friction_detail")
|
|
135
|
+
for key in seen_in_session:
|
|
136
|
+
friction_session_counts[key] = friction_session_counts.get(key, 0) + 1
|
|
137
|
+
if isinstance(detail, str) and detail.strip():
|
|
138
|
+
friction_examples.setdefault(key, []).append(detail)
|
|
139
|
+
|
|
140
|
+
# --- Positive side: primary_success enum value ----------------------
|
|
141
|
+
success_session_counts: dict[str, int] = {}
|
|
142
|
+
success_examples: dict[str, list[str]] = {}
|
|
143
|
+
for s in sessions:
|
|
144
|
+
ps = s.get("primary_success")
|
|
145
|
+
if not isinstance(ps, str) or not ps:
|
|
146
|
+
continue
|
|
147
|
+
success_session_counts[ps] = success_session_counts.get(ps, 0) + 1
|
|
148
|
+
summary = s.get("brief_summary")
|
|
149
|
+
if isinstance(summary, str) and summary.strip():
|
|
150
|
+
success_examples.setdefault(ps, []).append(summary)
|
|
151
|
+
|
|
152
|
+
detections: list[dict] = []
|
|
153
|
+
|
|
154
|
+
for key, hits in friction_session_counts.items():
|
|
155
|
+
ratio = hits / n_sessions
|
|
156
|
+
if ratio < NEGATIVE_THRESHOLD:
|
|
157
|
+
continue
|
|
158
|
+
det_id = _kebab(key)
|
|
159
|
+
examples = []
|
|
160
|
+
seen_ex: set[str] = set()
|
|
161
|
+
for raw in friction_examples.get(key, []):
|
|
162
|
+
ex = _trim(_redact_example(raw))
|
|
163
|
+
if not ex or ex in seen_ex:
|
|
164
|
+
continue
|
|
165
|
+
seen_ex.add(ex)
|
|
166
|
+
examples.append(ex)
|
|
167
|
+
if len(examples) >= EXAMPLE_CAP:
|
|
168
|
+
break
|
|
169
|
+
detections.append({
|
|
170
|
+
"id": det_id,
|
|
171
|
+
"name": det_id.replace("-", " "),
|
|
172
|
+
"direction": "negative",
|
|
173
|
+
"nudge": (
|
|
174
|
+
f"In {hits} of {n_sessions} sessions in the last "
|
|
175
|
+
f"{window_days} days, /insights flagged "
|
|
176
|
+
f"{det_id.replace('-', ' ')}."
|
|
177
|
+
),
|
|
178
|
+
"examples": examples,
|
|
179
|
+
"priority": 2,
|
|
180
|
+
"source": "insights-weekly",
|
|
181
|
+
"ratio": round(ratio, 3),
|
|
182
|
+
"n_sessions": n_sessions,
|
|
183
|
+
})
|
|
184
|
+
|
|
185
|
+
for key, hits in success_session_counts.items():
|
|
186
|
+
ratio = hits / n_sessions
|
|
187
|
+
if ratio < POSITIVE_THRESHOLD:
|
|
188
|
+
continue
|
|
189
|
+
det_id = _kebab(key)
|
|
190
|
+
examples = []
|
|
191
|
+
seen_ex: set[str] = set()
|
|
192
|
+
for raw in success_examples.get(key, []):
|
|
193
|
+
ex = _trim(_redact_example(raw))
|
|
194
|
+
if not ex or ex in seen_ex:
|
|
195
|
+
continue
|
|
196
|
+
seen_ex.add(ex)
|
|
197
|
+
examples.append(ex)
|
|
198
|
+
if len(examples) >= EXAMPLE_CAP:
|
|
199
|
+
break
|
|
200
|
+
detections.append({
|
|
201
|
+
"id": det_id,
|
|
202
|
+
"name": det_id.replace("-", " "),
|
|
203
|
+
"direction": "positive",
|
|
204
|
+
"nudge": (
|
|
205
|
+
f"In {hits} of {n_sessions} sessions in the last "
|
|
206
|
+
f"{window_days} days, /insights tagged "
|
|
207
|
+
f"{det_id.replace('-', ' ')} as the primary success."
|
|
208
|
+
),
|
|
209
|
+
"examples": examples,
|
|
210
|
+
"priority": 2,
|
|
211
|
+
"source": "insights-weekly",
|
|
212
|
+
"ratio": round(ratio, 3),
|
|
213
|
+
"n_sessions": n_sessions,
|
|
214
|
+
})
|
|
215
|
+
|
|
216
|
+
# Schema-validate: id present + direction in {positive, negative}.
|
|
217
|
+
valid = [
|
|
218
|
+
d for d in detections
|
|
219
|
+
if d.get("id") and d.get("direction") in ("positive", "negative")
|
|
220
|
+
]
|
|
221
|
+
|
|
222
|
+
# Cap. Sort by ratio descending so the strongest signals win when
|
|
223
|
+
# the cap bites.
|
|
224
|
+
valid.sort(key=lambda d: d.get("ratio", 0), reverse=True)
|
|
225
|
+
return valid[:cap]
|
|
226
|
+
|
|
227
|
+
|
|
228
|
+
def main() -> int:
|
|
229
|
+
ap = argparse.ArgumentParser(description=__doc__)
|
|
230
|
+
ap.add_argument(
|
|
231
|
+
"--facets-dir",
|
|
232
|
+
type=Path,
|
|
233
|
+
default=None,
|
|
234
|
+
help="Directory of facets/*.json sidecars (default: $HOME/.claude/usage-data/facets/)",
|
|
235
|
+
)
|
|
236
|
+
ap.add_argument("--window-days", type=int, default=DEFAULT_WINDOW_DAYS)
|
|
237
|
+
ap.add_argument("--cap", type=int, default=DEFAULT_CAP)
|
|
238
|
+
args = ap.parse_args()
|
|
239
|
+
|
|
240
|
+
facets_dir = args.facets_dir
|
|
241
|
+
if facets_dir is None:
|
|
242
|
+
env_dir = os.environ.get("COACH_FACETS_DIR")
|
|
243
|
+
if env_dir:
|
|
244
|
+
facets_dir = Path(env_dir)
|
|
245
|
+
else:
|
|
246
|
+
facets_dir = Path.home() / ".claude" / "usage-data" / "facets"
|
|
247
|
+
|
|
248
|
+
detections = aggregate(facets_dir, args.window_days, args.cap)
|
|
249
|
+
n_sessions = sum(
|
|
250
|
+
1 for _ in _iter_facets(facets_dir, time.time() - args.window_days * 86400)
|
|
251
|
+
)
|
|
252
|
+
sys.stderr.write(
|
|
253
|
+
f"facets_dir={facets_dir} n_sessions={n_sessions} "
|
|
254
|
+
f"detections={len(detections)}\n"
|
|
255
|
+
)
|
|
256
|
+
if n_sessions == 0:
|
|
257
|
+
# No evidence in the window. Refuse to emit detections so the
|
|
258
|
+
# wrapper bails before merge — an empty list merged here would
|
|
259
|
+
# be treated as a clean evidence pass and advance absence-based
|
|
260
|
+
# streaks on no data. NOTE: stdout is intentionally empty so a
|
|
261
|
+
# caller that ignores the exit code and pipes stdout into merge
|
|
262
|
+
# gets a parse error rather than a silent `[]` merge.
|
|
263
|
+
sys.stderr.write(
|
|
264
|
+
f"no sessions in last {args.window_days} days — refusing to "
|
|
265
|
+
f"emit detections; weekly path will retry next session\n"
|
|
266
|
+
)
|
|
267
|
+
return EXIT_NO_EVIDENCE
|
|
268
|
+
sys.stdout.write(json.dumps(detections, indent=2))
|
|
269
|
+
sys.stdout.write("\n")
|
|
270
|
+
return 0
|
|
271
|
+
|
|
272
|
+
|
|
273
|
+
if __name__ == "__main__":
|
|
274
|
+
sys.exit(main())
|