@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,176 @@
1
+ """Read/write `~/.claude/coach/.user_config.json` — the operator-tunable
2
+ settings the `/config` slash command edits.
3
+
4
+ Schema (v1):
5
+ {
6
+ "schema_version": 1,
7
+ "statusline_variant": one of VALID_VARIANTS (see below),
8
+ "theme": one of VALID_THEMES (see below),
9
+ "elo_min": int (default 1000, must be < elo_max),
10
+ "elo_max": int (default 2800, must be > elo_min)
11
+ }
12
+
13
+ The valid sets are defined as constants below — they are the single
14
+ source of truth, consumed by `/config` validation and the statusline
15
+ preview. Don't duplicate the lists into this docstring; they will rot.
16
+
17
+ Missing file → defaults. Unknown keys → ignored. Invalid values →
18
+ fall back to defaults for that field. Reads never raise.
19
+
20
+ Writes are atomic (tempfile + os.replace) but NOT locked — `/config`
21
+ is interactive + slow-path, no concurrency concern with `stats.py`
22
+ which only reads.
23
+
24
+ Path resolution: by default the file lives at
25
+ `~/.claude/coach/.user_config.json`. Set `COACH_CONFIG_DIR=/some/dir` to
26
+ override the directory (the file name `.user_config.json` is fixed).
27
+ This is what lets `coach/bin/configure.py` invoked via `npx coach-claw
28
+ config` honor a custom `CLAUDE_DIR` install — the npm wrapper exports
29
+ `COACH_CONFIG_DIR` to match. Resolution happens at every read/write so
30
+ tests can monkeypatch `COACH_CONFIG_DIR` via `monkeypatch.setenv`.
31
+ """
32
+ from __future__ import annotations
33
+
34
+ import json
35
+ import os
36
+ import tempfile
37
+ from pathlib import Path
38
+
39
+ from coach_paths import resolve_coach_dir
40
+
41
+
42
+ def _resolve_config_path() -> Path:
43
+ """Return the path to .user_config.json. Delegates to
44
+ `coach_paths.resolve_coach_dir()` so the COACH_CONFIG_DIR contract
45
+ is enforced in exactly one place. Resolved per-call so the env var
46
+ can be set at test time or by the npm wrapper before the Python
47
+ entry point reads it."""
48
+ return resolve_coach_dir() / ".user_config.json"
49
+
50
+
51
+ def __getattr__(name):
52
+ """Module-level __getattr__ (PEP 562). Lets external code read
53
+ `user_config.CONFIG_PATH` and get a fresh, env-aware path. Internal
54
+ code should call `_resolve_config_path()` directly so behavior is
55
+ explicit at the call site."""
56
+ if name == "CONFIG_PATH":
57
+ return _resolve_config_path()
58
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
59
+
60
+ DEFAULTS: dict = {
61
+ "schema_version": 1,
62
+ "statusline_variant": "crystal",
63
+ "theme": "craft",
64
+ "elo_min": 1000,
65
+ "elo_max": 2800,
66
+ }
67
+
68
+ VALID_VARIANTS = {"crystal", "pips", "slash", "forge"}
69
+ VALID_THEMES = {
70
+ # abstract themes
71
+ "craft", "forge", "cosmic", "ocean",
72
+ # pop-culture-inspired (fan-themed; see themes.py docstring on brand safety)
73
+ "skyrim", "marvel", "dc", "finalfantasy",
74
+ "military", "lotr", "starwars", "hacker",
75
+ }
76
+
77
+
78
+ def load() -> dict:
79
+ """Return a complete config dict. Always populates every key — caller
80
+ can index without `.get()`."""
81
+ cfg = dict(DEFAULTS)
82
+ config_path = _resolve_config_path()
83
+ try:
84
+ if config_path.exists():
85
+ raw = json.loads(config_path.read_text())
86
+ if isinstance(raw, dict):
87
+ _coerce_into(cfg, raw)
88
+ except Exception:
89
+ pass
90
+ return cfg
91
+
92
+
93
+ def _coerce_into(cfg: dict, raw: dict) -> None:
94
+ """Apply each valid field from `raw` into `cfg`. Invalid values stay
95
+ at their default — never raises."""
96
+ v = raw.get("statusline_variant")
97
+ if isinstance(v, str) and v in VALID_VARIANTS:
98
+ cfg["statusline_variant"] = v
99
+ t = raw.get("theme")
100
+ if isinstance(t, str) and t in VALID_THEMES:
101
+ cfg["theme"] = t
102
+ emin = raw.get("elo_min")
103
+ emax = raw.get("elo_max")
104
+ if isinstance(emin, int) and isinstance(emax, int) and 0 < emin < emax:
105
+ cfg["elo_min"] = emin
106
+ cfg["elo_max"] = emax
107
+
108
+
109
+ def save(cfg: dict) -> None:
110
+ """Atomic write. Validates against schema before persisting; raises
111
+ ValueError on invalid input so `/config` can show a clear error."""
112
+ validated = dict(DEFAULTS)
113
+ _coerce_into(validated, cfg)
114
+ # If the caller passed an unknown variant/theme, _coerce_into silently
115
+ # kept the default. Detect that and complain so /config can surface
116
+ # which key was rejected.
117
+ for key in ("statusline_variant", "theme"):
118
+ if key in cfg and cfg[key] != validated[key]:
119
+ valid = (
120
+ VALID_VARIANTS if key == "statusline_variant" else VALID_THEMES
121
+ )
122
+ raise ValueError(
123
+ f"unknown {key} {cfg[key]!r}; valid: {sorted(valid)}"
124
+ )
125
+ if "elo_min" in cfg or "elo_max" in cfg:
126
+ emin = cfg.get("elo_min", validated["elo_min"])
127
+ emax = cfg.get("elo_max", validated["elo_max"])
128
+ if not (isinstance(emin, int) and isinstance(emax, int) and 0 < emin < emax):
129
+ raise ValueError(
130
+ f"elo_min ({emin}) must be a positive int less than "
131
+ f"elo_max ({emax})"
132
+ )
133
+
134
+ config_path = _resolve_config_path()
135
+ config_path.parent.mkdir(parents=True, exist_ok=True)
136
+ fd, tmp = tempfile.mkstemp(
137
+ prefix="." + config_path.name + ".",
138
+ suffix=".tmp",
139
+ dir=str(config_path.parent),
140
+ )
141
+ try:
142
+ with os.fdopen(fd, "w") as fh:
143
+ fh.write(json.dumps(validated, indent=2, sort_keys=True))
144
+ fh.flush()
145
+ os.fsync(fh.fileno())
146
+ os.replace(tmp, config_path)
147
+ except Exception:
148
+ try:
149
+ os.unlink(tmp)
150
+ except Exception:
151
+ pass
152
+ raise
153
+
154
+
155
+ # --- Convenience accessors used by stats.py / variants render path ------
156
+
157
+ def get_variant() -> str:
158
+ return load()["statusline_variant"]
159
+
160
+
161
+ def get_theme() -> str:
162
+ return load()["theme"]
163
+
164
+
165
+ def get_elo_range() -> tuple[int, int]:
166
+ cfg = load()
167
+ return cfg["elo_min"], cfg["elo_max"]
168
+
169
+
170
+ def update(**kwargs) -> dict:
171
+ """Merge updates into the existing config and persist. Returns the
172
+ new config. Raises ValueError on invalid input."""
173
+ cfg = load()
174
+ cfg.update(kwargs)
175
+ save(cfg)
176
+ return cfg
@@ -0,0 +1,98 @@
1
+ """Shared lifetime XP accounting for Coach profile data.
2
+
3
+ The profile used to store all non-graduation lifetime XP in
4
+ ``banked_session_xp``. Newer code keeps the sources split so status output,
5
+ exports, and future UI can explain where progress came from:
6
+
7
+ - session_banked_xp: completed-session XP converted at 10:1 by bank.py
8
+ - milestone_xp: mid-streak rewards from merge.py
9
+ - graduation_xp: derived from current graduated entries
10
+ - manual_adjustments: explicit operator edits
11
+
12
+ ``banked_session_xp`` remains as a deprecated alias for session banking only.
13
+ """
14
+ from __future__ import annotations
15
+
16
+ GRADUATION_XP = 5
17
+
18
+
19
+ def _as_int(value, default: int = 0) -> int:
20
+ try:
21
+ return int(value or 0)
22
+ except Exception:
23
+ return default
24
+
25
+
26
+ def graduation_xp(profile: dict) -> int:
27
+ graduated = [
28
+ g for g in (profile.get("graduated") or [])
29
+ if isinstance(g, dict)
30
+ ]
31
+ return len(graduated) * GRADUATION_XP
32
+
33
+
34
+ def max_active_clean_streak(profile: dict) -> int:
35
+ max_streak = 0
36
+ for entry in profile.get("entries", []) or []:
37
+ if not isinstance(entry, dict):
38
+ continue
39
+ max_streak = max(max_streak, _as_int(entry.get("clean_streak_runs")))
40
+ return max_streak
41
+
42
+
43
+ def normalize_profile_xp(profile: dict) -> dict:
44
+ """Ensure split XP fields exist and return an explainable breakdown.
45
+
46
+ Mutates ``profile`` in place. For legacy profiles with only
47
+ ``banked_session_xp``, that value is migrated into ``session_banked_xp``.
48
+ Historical milestone/session split cannot be recovered, so preserving the
49
+ total is the safe migration.
50
+ """
51
+ if not isinstance(profile, dict):
52
+ profile = {}
53
+
54
+ has_split = any(
55
+ key in profile
56
+ for key in ("session_banked_xp", "milestone_xp", "manual_adjustments")
57
+ )
58
+ legacy_banked = _as_int(profile.get("banked_session_xp"))
59
+
60
+ if has_split:
61
+ session_banked = _as_int(profile.get("session_banked_xp"))
62
+ else:
63
+ session_banked = legacy_banked
64
+
65
+ milestone = _as_int(profile.get("milestone_xp"))
66
+ manual = _as_int(profile.get("manual_adjustments"))
67
+ graduated = graduation_xp(profile)
68
+ clean_streak = max_active_clean_streak(profile)
69
+
70
+ profile["session_banked_xp"] = session_banked
71
+ profile["milestone_xp"] = milestone
72
+ profile["graduation_xp"] = graduated
73
+ profile["manual_adjustments"] = manual
74
+ # Deprecated compatibility alias. New code should read session_banked_xp.
75
+ profile["banked_session_xp"] = session_banked
76
+
77
+ lifetime = session_banked + milestone + graduated + clean_streak + manual
78
+ return {
79
+ "session_banked_xp": session_banked,
80
+ "milestone_xp": milestone,
81
+ "graduation_xp": graduated,
82
+ "manual_adjustments": manual,
83
+ "max_active_clean_streak": clean_streak,
84
+ "lifetime_xp": lifetime,
85
+ }
86
+
87
+
88
+ def add_session_banked_xp(profile: dict, amount: int) -> dict:
89
+ normalize_profile_xp(profile)
90
+ profile["session_banked_xp"] = _as_int(profile.get("session_banked_xp")) + _as_int(amount)
91
+ profile["banked_session_xp"] = profile["session_banked_xp"]
92
+ return normalize_profile_xp(profile)
93
+
94
+
95
+ def add_milestone_xp(profile: dict, amount: int) -> dict:
96
+ normalize_profile_xp(profile)
97
+ profile["milestone_xp"] = _as_int(profile.get("milestone_xp")) + _as_int(amount)
98
+ return normalize_profile_xp(profile)
@@ -0,0 +1,4 @@
1
+ # Coach changelog
2
+
3
+ One line per `/coach-insights` run. Tailed by the SessionStart hook to surface
4
+ a terse info message when new changes exist since the last session.
@@ -0,0 +1,19 @@
1
+ #!/bin/bash
2
+ # Coach Claw — default statusline composition.
3
+ #
4
+ # Thin trampoline: locates `bin/default_statusline.py` relative to this
5
+ # wrapper's own directory using bash parameter expansion only — no
6
+ # external commands, no PATH lookups. settings.json registers this
7
+ # wrapper by absolute path, so ${BASH_SOURCE%/*} reliably yields the
8
+ # wrapper's parent dir. Resolving relatively (instead of hardcoding
9
+ # $HOME/.claude/coach/...) keeps custom CLAUDE_DIR installs working.
10
+ #
11
+ # `@PY@` is substituted with the absolute python3 path at install time
12
+ # so the script doesn't depend on PATH at statusline-render time.
13
+ #
14
+ # To customize the rich default: edit `bin/default_statusline.py` (pure
15
+ # Python, no external dependencies). Or run `/config` to change the
16
+ # trailing coach segment's variant + theme without touching either
17
+ # file.
18
+
19
+ exec "@PY@" "${BASH_SOURCE%/*}/bin/default_statusline.py"
@@ -0,0 +1,15 @@
1
+ #!/bin/bash
2
+ # Coach Claw — wrap-mode statusline trampoline.
3
+ #
4
+ # Runs the user's saved original statusLine command first (captured in
5
+ # `~/.claude/coach/.statusline-wrap.json` by `statusline_wrap_action.wrap`),
6
+ # then appends the Coach segment with trailing-aware separator handling.
7
+ #
8
+ # Symmetric with `default-statusline-command.sh` — same `${BASH_SOURCE%/*}`
9
+ # parameter expansion to locate the bin/ dir relative to this wrapper, so
10
+ # custom CLAUDE_DIR installs keep working without configuration. `@PY@`
11
+ # is substituted with the absolute python3 path at install time.
12
+ #
13
+ # To opt out of wrap mode after install: `/coach-claw:doctor --unwrap-statusline`.
14
+
15
+ exec "@PY@" "${BASH_SOURCE%/*}/bin/statusline_wrap.py"
@@ -0,0 +1,37 @@
1
+ schema_version: 1
2
+ updated: null
3
+ # Coach profile — autonomously maintained by /coach-insights.
4
+ # Hand-editing is allowed but not required. Git-tracked for rollback.
5
+ #
6
+ # Tiers:
7
+ # candidate — detected in 1 recent /coach-insights run; not yet injected
8
+ # probationary — detected in 2-of-3 runs; injected at 30% probability
9
+ # for first 7 days, then promoted to active
10
+ # active — fully injected (confidence × cooldown gating)
11
+ #
12
+ # Confidence grows on recurrence, decays 5%/day untouched. <0.3 auto-retires.
13
+ # Cap = 10 active entries; lowest-confidence evicted when a new one promotes.
14
+ #
15
+ # Each entry schema:
16
+ # id: stable-slug
17
+ # name: human-readable short name
18
+ # tier: candidate | probationary | active
19
+ # confidence: 0.0-1.0
20
+ # priority: 1-5 (for tie-breaking at the cap)
21
+ # nudge: single observational sentence, never prescriptive
22
+ # examples: short quoted evidence from transcripts (redacted)
23
+ # first_seen: ISO date
24
+ # last_seen_in_run: ISO date
25
+ # last_fired: ISO datetime (24h per-entry cooldown)
26
+ # promoted_at: ISO date | null (probationary→active transition)
27
+ # source_session_ids: [short hashes]
28
+ entries: []
29
+ graduated: []
30
+ archived: []
31
+ session_banked_xp: 0
32
+ milestone_xp: 0
33
+ graduation_xp: 0
34
+ manual_adjustments: 0
35
+ # Deprecated compatibility alias for older installs; new code reads
36
+ # session_banked_xp / milestone_xp / graduation_xp / manual_adjustments.
37
+ banked_session_xp: 0
@@ -0,0 +1,13 @@
1
+ """Shared pytest fixtures + path setup for the coach suite.
2
+
3
+ Adds coach/bin/ to sys.path so `import reward_hints, scoring, merge` works
4
+ whether you run pytest from ~/.claude/coach/ or from the shareable bundle.
5
+ """
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from pathlib import Path
10
+
11
+ _BIN = Path(__file__).resolve().parent.parent / "bin"
12
+ if str(_BIN) not in sys.path:
13
+ sys.path.insert(0, str(_BIN))