@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,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())