@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,256 @@
1
+ """Switch Coach control from the npm CLI to the Claude Code plugin.
2
+
3
+ Removes CLI-installed hook entries from ~/.claude/settings.json so the
4
+ plugin's hooks take over. Also clears a CLI-installed statusLine entry
5
+ if present (the plugin will reinstall its own on the next session via
6
+ statusline_self_patch).
7
+
8
+ Use case: a user has both distributions installed and the plugin's
9
+ coexistence_check is currently deferring to the CLI (.plugin-deferred
10
+ marker present). They want to flip control. Running
11
+ /coach-claw:switch is the supported way to do that without manually
12
+ editing settings.json.
13
+
14
+ The npm CLI's installed Python files (~/.claude/hooks/coach-*.py) are
15
+ NOT touched by this script — they remain on disk but are no longer
16
+ referenced from settings.json. Run `npx @rm0nroe/coach-claw uninstall`
17
+ separately for full CLI cleanup.
18
+
19
+ Atomic write under flock so concurrent /plugin install or other
20
+ settings mutations don't race.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import argparse
25
+ import fcntl
26
+ import json
27
+ import os
28
+ import shlex
29
+ import sys
30
+ import tempfile
31
+ from datetime import datetime, timezone
32
+ from pathlib import Path
33
+
34
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
35
+ from coach_paths import resolve_coach_dir # noqa: E402
36
+
37
+ SETTINGS_PATH = Path.home() / ".claude" / "settings.json"
38
+
39
+ HOOK_SCRIPT_NAMES = ("coach-session-start.py", "coach-user-prompt.py")
40
+ COACH_STATUSLINE_MARKERS = (
41
+ "default-statusline-command.sh",
42
+ "default_statusline.py",
43
+ )
44
+ # Substring fragments identifying the wrap-shape statusLine entries.
45
+ CLI_WRAP_TRAMPOLINE = "default-statusline-wrap-command.sh"
46
+ PLUGIN_WRAP_SCRIPT = "statusline_wrap.py"
47
+
48
+
49
+ def _is_cli_hook(cmd: str, plugin_root: str) -> bool:
50
+ if not any(name in cmd for name in HOOK_SCRIPT_NAMES):
51
+ return False
52
+ if plugin_root and plugin_root in cmd:
53
+ return False
54
+ return True
55
+
56
+
57
+ def _is_cli_statusline(cmd: str, plugin_root: str) -> bool:
58
+ """CLI's statusLine uses default-statusline-command.sh; plugin's
59
+ uses default_statusline.py via bootstrap.sh. Removing the CLI's
60
+ leaves the plugin's self-patch to reinstall on next SessionStart."""
61
+ if "default-statusline-command.sh" not in cmd:
62
+ return False
63
+ if plugin_root and plugin_root in cmd:
64
+ return False
65
+ return True
66
+
67
+
68
+ def _strip_cli_hooks(data: dict, plugin_root: str) -> int:
69
+ """Remove CLI-pattern hook entries in-place. Returns count removed."""
70
+ removed = 0
71
+ hooks = data.get("hooks")
72
+ if not isinstance(hooks, dict):
73
+ return 0
74
+ for event in ("SessionStart", "UserPromptSubmit"):
75
+ groups = hooks.get(event)
76
+ if not isinstance(groups, list):
77
+ continue
78
+ new_groups = []
79
+ for grp in groups:
80
+ if not isinstance(grp, dict):
81
+ new_groups.append(grp)
82
+ continue
83
+ inner = grp.get("hooks")
84
+ if not isinstance(inner, list):
85
+ new_groups.append(grp)
86
+ continue
87
+ new_inner = []
88
+ for h in inner:
89
+ if isinstance(h, dict) and _is_cli_hook(str(h.get("command", "")), plugin_root):
90
+ removed += 1
91
+ continue
92
+ new_inner.append(h)
93
+ if new_inner:
94
+ new_groups.append({**grp, "hooks": new_inner})
95
+ elif grp.get("hooks"):
96
+ # Group emptied by removals — drop it entirely.
97
+ pass
98
+ else:
99
+ new_groups.append(grp)
100
+ if new_groups:
101
+ hooks[event] = new_groups
102
+ else:
103
+ del hooks[event]
104
+ return removed
105
+
106
+
107
+ def _strip_cli_statusline(data: dict, plugin_root: str) -> bool:
108
+ """Remove the CLI's default statusLine entry if present. Returns True
109
+ if removed, False if nothing to do or it was someone else's."""
110
+ sl = data.get("statusLine")
111
+ if not isinstance(sl, dict):
112
+ return False
113
+ if _is_cli_statusline(str(sl.get("command", "")), plugin_root):
114
+ del data["statusLine"]
115
+ return True
116
+ return False
117
+
118
+
119
+ def _rewrite_cli_wrap_to_plugin(data: dict, plugin_root: str) -> bool:
120
+ """If the statusLine is the CLI wrap-shape trampoline AND we have a
121
+ plugin_root to rewrite to, replace it with the plugin wrap shape.
122
+ Preserves `.statusline-wrap.json` (operate on settings.json only).
123
+
124
+ Returns True when rewritten, False when nothing to do (no statusLine,
125
+ not a wrap shape, or already plugin shape).
126
+ """
127
+ if not plugin_root:
128
+ return False
129
+ sl = data.get("statusLine")
130
+ if not isinstance(sl, dict):
131
+ return False
132
+ cmd = str(sl.get("command", ""))
133
+ # Skip if not the CLI wrap trampoline.
134
+ if CLI_WRAP_TRAMPOLINE not in cmd:
135
+ return False
136
+ # Already pointing at the plugin's bin/ — leave alone.
137
+ if PLUGIN_WRAP_SCRIPT in cmd and plugin_root in cmd:
138
+ return False
139
+ pr = Path(plugin_root)
140
+ bootstrap = shlex.quote(str(pr / "bin" / "bootstrap.sh"))
141
+ wrap_py = shlex.quote(str(pr / "bin" / "statusline_wrap.py"))
142
+ new_cmd = f"{bootstrap} {wrap_py}"
143
+ data["statusLine"] = {"type": "command", "command": new_cmd}
144
+ return True
145
+
146
+
147
+ def _atomic_write(path: Path, payload: dict) -> None:
148
+ path.parent.mkdir(parents=True, exist_ok=True)
149
+ lock_path = path.with_suffix(path.suffix + ".lock")
150
+ with open(lock_path, "w") as lock_fh:
151
+ try:
152
+ fcntl.flock(lock_fh.fileno(), fcntl.LOCK_EX)
153
+ except Exception:
154
+ pass
155
+ fd, tmp = tempfile.mkstemp(
156
+ prefix="." + path.name + ".",
157
+ suffix=".tmp",
158
+ dir=str(path.parent),
159
+ )
160
+ try:
161
+ with os.fdopen(fd, "w") as fh:
162
+ json.dump(payload, fh, indent=2)
163
+ fh.flush()
164
+ os.fsync(fh.fileno())
165
+ os.replace(tmp, path)
166
+ except Exception:
167
+ try:
168
+ os.unlink(tmp)
169
+ except Exception:
170
+ pass
171
+ raise
172
+
173
+
174
+ def _write_marker(coach_dir: Path) -> None:
175
+ """Persist a marker so `/coach-claw:doctor` and the deferred-state
176
+ UI know the user explicitly switched."""
177
+ try:
178
+ coach_dir.mkdir(parents=True, exist_ok=True)
179
+ marker = coach_dir / ".cli-uninstalled-by-plugin"
180
+ marker.write_text(json.dumps({
181
+ "switched_at": datetime.now(timezone.utc).isoformat(),
182
+ }))
183
+ # Clear any stale defer marker since CLI is gone now.
184
+ defer = coach_dir / ".plugin-deferred"
185
+ if defer.exists():
186
+ defer.unlink()
187
+ except Exception:
188
+ pass
189
+
190
+
191
+ def main(argv: list[str] | None = None) -> int:
192
+ parser = argparse.ArgumentParser(description="Switch Coach control to the plugin.")
193
+ parser.add_argument(
194
+ "--settings",
195
+ default=str(SETTINGS_PATH),
196
+ help="Path to settings.json (default: ~/.claude/settings.json)",
197
+ )
198
+ parser.add_argument(
199
+ "--dry-run",
200
+ action="store_true",
201
+ help="Report what would change without writing.",
202
+ )
203
+ args = parser.parse_args(argv)
204
+
205
+ settings_path = Path(args.settings)
206
+ if not settings_path.exists():
207
+ print(f"settings.json not found at {settings_path}", file=sys.stderr)
208
+ return 1
209
+ try:
210
+ data = json.loads(settings_path.read_text())
211
+ except Exception as exc:
212
+ print(f"settings.json is not valid JSON: {exc}", file=sys.stderr)
213
+ return 2
214
+ if not isinstance(data, dict):
215
+ print("settings.json is not a JSON object", file=sys.stderr)
216
+ return 2
217
+
218
+ plugin_root = os.environ.get("CLAUDE_PLUGIN_ROOT", "")
219
+ removed_hooks = _strip_cli_hooks(data, plugin_root)
220
+ rewritten_wrap = _rewrite_cli_wrap_to_plugin(data, plugin_root)
221
+ # Wrap rewrite already mutated statusLine — don't also strip it.
222
+ removed_statusline = (
223
+ False if rewritten_wrap else _strip_cli_statusline(data, plugin_root)
224
+ )
225
+
226
+ if removed_hooks == 0 and not removed_statusline and not rewritten_wrap:
227
+ print("No CLI hooks or statusLine found in settings.json — nothing to do.")
228
+ return 0
229
+
230
+ if args.dry_run:
231
+ print(f"Would remove {removed_hooks} CLI hook entries.")
232
+ if removed_statusline:
233
+ print("Would remove CLI statusLine entry.")
234
+ if rewritten_wrap:
235
+ print("Would rewrite CLI wrap-statusLine to plugin wrap shape.")
236
+ return 0
237
+
238
+ _atomic_write(settings_path, data)
239
+ _write_marker(resolve_coach_dir())
240
+
241
+ print(f"Removed {removed_hooks} CLI hook entries.")
242
+ if removed_statusline:
243
+ print("Removed CLI statusLine entry. Plugin will reinstall its own next session.")
244
+ if rewritten_wrap:
245
+ print("Rewrote CLI wrap-statusLine to plugin wrap shape (saved original preserved).")
246
+ print()
247
+ print("Plugin is now in charge. The CLI's installed files in")
248
+ print(" ~/.claude/hooks/coach-*.py and ~/.claude/coach/")
249
+ print("are untouched. Run `npx @rm0nroe/coach-claw uninstall` for")
250
+ print("full CLI removal (optional — coach state in ~/.claude/coach/")
251
+ print("is shared between distributions and stays put either way).")
252
+ return 0
253
+
254
+
255
+ if __name__ == "__main__":
256
+ sys.exit(main())
@@ -0,0 +1,256 @@
1
+ """Level-name ladders for the rank/level display.
2
+
3
+ Each theme is a 50-element list — one name per level, ordered L1 → L50.
4
+ The progression arc is the theme's identity: craft (technical mastery),
5
+ forge (blacksmithing), cosmic (stellar/spacetime), ocean (lobster-mascot
6
+ marine depth), plus eight pop-culture-inspired ladders.
7
+
8
+ Adding a theme: append a new key to THEMES with exactly 50 unique
9
+ single-word entries. The test suite (test_themes.py) enforces both.
10
+
11
+ The selected theme is read from `~/.claude/coach/.user_config.json`
12
+ via `user_config.get_theme()`. Default = "craft" so existing installs
13
+ without a config file are unchanged.
14
+
15
+ # Brand safety — pop-culture themes
16
+
17
+ The eight pop-culture-inspired themes (skyrim, marvel, dc, finalfantasy,
18
+ military, lotr, starwars, hacker) are FAN-INSPIRED and use only:
19
+
20
+ - Public-domain mythology (Norse, Greek, Mesopotamian, Hindu, Biblical, etc.)
21
+ - Real-world historical figures and titles (Caesar, Khan, Shogun, Centurion)
22
+ - Genre-generic terminology that pre-dates or transcends any single
23
+ franchise (Knight, Wizard, Mage, Apprentice, Master, Sentinel, etc.)
24
+ - Common English compound words (Greybeard, Highking, Worldwarden)
25
+
26
+ Specifically EXCLUDED from every ladder:
27
+ - Names invented by a franchise (Dovahkiin, Padawan, Vibranium, Maiar)
28
+ - Named characters from any franchise
29
+ - Trademarked group / organization names (The Avengers, Justice League,
30
+ Jedi Order, Lantern Corps as such)
31
+
32
+ Coach Claw is not affiliated with or endorsed by any franchise owner.
33
+ """
34
+ from __future__ import annotations
35
+
36
+ # L1-L8 preserved from the original ladder for backwards-compat with
37
+ # existing XP totals. L9-L50 follow the technical-mastery arc:
38
+ # recognized excellence → mastery → cosmic → transcendent.
39
+ THEME_CRAFT = [
40
+ "Drafter", "Iterator", "Builder", "Shipper", "Craftsman",
41
+ "Architect", "Virtuoso", "Sensei", "Luminary", "Legend",
42
+ "Mythic", "Ascendant", "Pioneer", "Vanguard", "Savant",
43
+ "Prodigy", "Visionary", "Oracle", "Sage", "Paragon",
44
+ "Archmage", "Grandmaster", "Elder", "Progenitor", "Sovereign",
45
+ "Titan", "Colossus", "Zenith", "Overmind", "Transcendent",
46
+ "Celestial", "Cosmic", "Stellar", "Nebula", "Supernova",
47
+ "Singularity", "Primordial", "Aether", "Eldritch", "Eternal",
48
+ "Immortal", "Divine", "Demiurge", "Alpha", "Omega",
49
+ "Apex", "Ultima", "Infinite", "Genesis", "Origin",
50
+ ]
51
+
52
+ # Blacksmithing arc — apprentice → master smith → primal forge.
53
+ THEME_FORGE = [
54
+ "Apprentice", "Striker", "Smith", "Forger", "Hammerhand",
55
+ "Bladesmith", "Toolsmith", "Mastersmith", "Forgemaster", "Anvilkeeper",
56
+ "Furnacekeeper", "Quenchmaster", "Edgewright", "Steelwright", "Ironwright",
57
+ "Bellowsmith", "Crucible", "Tempersworn", "Hardener", "Foundrylord",
58
+ "Smelter", "Blastsmith", "Damascus", "Foldweaver", "Patternsmith",
59
+ "Runesmith", "Soulforger", "Starsmith", "Voidwright", "Worldforger",
60
+ "Spiritsmith", "Lightsmith", "Flamewarden", "Inferno", "Cinder",
61
+ "Ember", "Glow", "Furnacecore", "Magmaheart", "Sunsmith",
62
+ "Forgekin", "Coresoul", "Eternalflame", "Worldhammer", "Anvilheart",
63
+ "Pillar", "Bedrock", "Forgefather", "Primalforge", "Genesis",
64
+ ]
65
+
66
+ # Cosmological arc — spark of matter → galaxy → cosmic inflation.
67
+ THEME_COSMIC = [
68
+ "Spark", "Mote", "Particle", "Atom", "Element",
69
+ "Crystal", "Mineral", "Aerolith", "Asteroid", "Meteor",
70
+ "Comet", "Moon", "Planet", "Star", "Pulsar",
71
+ "Quasar", "Magnetar", "Nebula", "Nova", "Supernova",
72
+ "Hypernova", "Galaxy", "Constellation", "Cluster", "Spiral",
73
+ "Filament", "Wormhole", "Horizon", "Singularity", "Universe",
74
+ "Multiverse", "Cosmosphere", "Plenum", "Continuum", "Aether",
75
+ "Ether", "Void", "Abyssum", "Eonkeeper", "Timeweaver",
76
+ "Spaceforger", "Lightspeed", "Causal", "Quantum", "Primordial",
77
+ "Genesis", "Inflation", "Eternity", "Infinity", "Origin",
78
+ ]
79
+
80
+ # Marine-depth arc — lobster mascot tribute. Hatchling → leviathan → origin.
81
+ THEME_OCEAN = [
82
+ "Hatchling", "Larva", "Wriggler", "Postlarva", "Juvenile",
83
+ "Forager", "Burrower", "Reefer", "Hunter", "Patroller",
84
+ "Stalker", "Predator", "Tidewalker", "Currentrider", "Wavecrest",
85
+ "Reefking", "Tidemaster", "Deepswimmer", "Sandstrider", "Coralweaver",
86
+ "Stormbringer", "Squallchaser", "Tidelord", "Reeflord", "Abyssal",
87
+ "Deepkeeper", "Trenchwalker", "Voidswimmer", "Leviathan", "Kraken",
88
+ "Reefshaper", "Oceanic", "Saltkin", "Pearlborn", "Deepforger",
89
+ "Mariner", "Tideborn", "Foamprince", "Wavekeeper", "Tidesage",
90
+ "Cosmic", "Primordial", "Oceanmind", "Worldsea", "Eternalshore",
91
+ "Tideborne", "Currentlord", "Genesis", "Source", "Origin",
92
+ ]
93
+
94
+ # === POP-CULTURE THEMES ===================================================
95
+ # All entries below are public-domain mythology, real historical titles,
96
+ # or genre-generic terminology. No franchise-coined neologisms or named
97
+ # characters. See module docstring "Brand safety" for the policy.
98
+
99
+ # Skyrim-inspired — Norse / Anglo-Saxon hierarchy + generic fantasy.
100
+ # Bandit → Whelp/Initiate paths → Companion / Listener / Greybeard tier
101
+ # → Wyrmkin → Norse pantheon. No Bethesda-coined words (no Dovahkiin,
102
+ # Daedra, Aedra, Talos, Alduin, etc.).
103
+ THEME_SKYRIM = [
104
+ "Pauper", "Citizen", "Footpad", "Bandit", "Adventurer",
105
+ "Whelp", "Initiate", "Pupil", "Apprentice", "Sellsword",
106
+ "Adept", "Skald", "Drengr", "Berserker", "Conjurer",
107
+ "Burglar", "Mercenary", "Slayer", "Wizard", "Centurion",
108
+ "Praefect", "Warlock", "Guildmaster", "Master", "Legate",
109
+ "Archmage", "Companion", "Speaker", "General", "Blade",
110
+ "Harbinger", "Listener", "Greybeard", "Tongue", "Thane",
111
+ "Jarl", "Stormcaller", "Konung", "Champion", "Voidwalker",
112
+ "Wyrmkin", "Wyrmblood", "Dragonkin", "Highking", "Highlord",
113
+ "Worldwarden", "Vanir", "Aesir", "Asgardian", "Anu",
114
+ ]
115
+
116
+ # Marvel-inspired — comics power-tier classification, no character names
117
+ # or trademarked group names. Civilian → Hero → cosmic → abstract.
118
+ THEME_MARVEL = [
119
+ "Civilian", "Witness", "Bystander", "Cadet", "Operative",
120
+ "Agent", "Vigilante", "Sidekick", "Hero", "Defender",
121
+ "Mutant", "Mystic", "Sorcerer", "Specialist", "Captain",
122
+ "Knight", "Crusader", "Sentinel", "Watchman", "Champion",
123
+ "Speedster", "Telepath", "Telekinetic", "Pyromancer", "Cryomancer",
124
+ "Channeler", "Berserker", "Behemoth", "Phoenix", "Avatar",
125
+ "Cosmic", "Celestial", "Eternal", "Galactic", "Stellar",
126
+ "Universal", "Multiversal", "Dimensional", "Astral", "Beyond",
127
+ "Eternity", "Infinity", "Tribunal", "Allfather", "Existence",
128
+ "Sovereign", "Above", "Origin", "Apex", "Source",
129
+ ]
130
+
131
+ # DC-inspired — vigilante / detective angle, Justice / Trinity / New Gods
132
+ # cosmology. Differentiated from Marvel by Detective+Justicer flavor and
133
+ # Highfather/Spectre/Presence top tiers.
134
+ THEME_DC = [
135
+ "Civilian", "Bystander", "Witness", "Patrolman", "Detective",
136
+ "Operative", "Agent", "Vigilante", "Sidekick", "Hero",
137
+ "Crusader", "Justicer", "Defender", "Watchman", "Sentinel",
138
+ "Captain", "Knight", "Centurion", "Champion", "Specialist",
139
+ "Speedster", "Telepath", "Sorcerer", "Mystic", "Lantern",
140
+ "Wonder", "Founder", "Council", "Trinity", "Avatar",
141
+ "Demigod", "Eternal", "Stellar", "Cosmic", "Galactic",
142
+ "Universal", "Multiversal", "Beyond", "Aspect", "Spirit",
143
+ "Spectre", "Phantom", "Highfather", "Endless", "Highest",
144
+ "Sovereign", "Crisis", "Presence", "Origin", "Source",
145
+ ]
146
+
147
+ # Final Fantasy-inspired — generic JRPG job classes + summon-type terms.
148
+ # Specific FF characters / Onion Knight / Cetra / Esper-as-named-summon
149
+ # excluded; mythological summon types (Aeon, Eidolon, Primal) are public
150
+ # domain (Greek "eidolon", broader use), so retained.
151
+ THEME_FINALFANTASY = [
152
+ "Squire", "Page", "Cadet", "Apprentice", "Initiate",
153
+ "Adept", "Knight", "Warrior", "Crusader", "Paladin",
154
+ "Berserker", "Monk", "Lancer", "Dragoon", "Samurai",
155
+ "Ninja", "Bard", "Dancer", "Geomancer", "Cleric",
156
+ "Warlock", "Spellsword", "Mystic", "Scholar", "Astrologer",
157
+ "Necromancer", "Alchemist", "Engineer", "Reaper", "Viper",
158
+ "Mediator", "Channeler", "Summoner", "Hero", "Champion",
159
+ "Lightbringer", "Crystal", "Aeon", "Eidolon", "Esper",
160
+ "Familiar", "Primal", "Avatar", "Sovereign", "Worldwarden",
161
+ "Skywarden", "Source", "Origin", "Singularity", "Genesis",
162
+ ]
163
+
164
+ # Military hierarchy — US enlisted/officer ranks + Roman + Asian + mythic
165
+ # war archetypes. All real historical titles (Caesar, Khan, Shogun) or
166
+ # generic English military terms.
167
+ THEME_MILITARY = [
168
+ "Recruit", "Private", "Specialist", "Corporal", "Sergeant",
169
+ "Staffsergeant", "Mastersergeant", "Sergeantmajor", "Ensign", "Cadet",
170
+ "Lieutenant", "Captain", "Major", "Colonel", "Brigadier",
171
+ "General", "Fieldmarshal", "Commander", "Commodore", "Admiral",
172
+ "Viceadmiral", "Fleetadmiral", "Marshal", "Airmarshal", "Highmarshal",
173
+ "Ranger", "Beret", "Seal", "Marine", "Paratrooper",
174
+ "Sniper", "Operative", "Aviator", "Pilot", "Centurion",
175
+ "Optio", "Tribune", "Legate", "Praetor", "Consul",
176
+ "Imperator", "Caesar", "Augustus", "Samurai", "Daimyo",
177
+ "Khan", "Shogun", "Conqueror", "Generalissimo", "Polemarch",
178
+ ]
179
+
180
+ # LOTR-inspired — Anglo-Saxon / generic medieval high-fantasy. None of:
181
+ # Hobbit, Maiar, Valar, Eru, Numenor, Mordor, Shire, Gondor, Rohan, or
182
+ # named characters. "Halfling" is pre-Tolkien (in 1860s English use).
183
+ THEME_LOTR = [
184
+ "Pauper", "Cottar", "Wanderer", "Squire", "Apprentice",
185
+ "Halfling", "Footsoldier", "Pikeman", "Outrider", "Greenrider",
186
+ "Knight", "Esquire", "Ranger", "Scout", "Hunter",
187
+ "Marshal", "Captain", "Bannerman", "Reeve", "Thegn",
188
+ "Champion", "Crusader", "Sentinel", "Warden", "Guardian",
189
+ "Watchman", "Liegelord", "Lord", "Highlord", "Steward",
190
+ "Regent", "King", "Highking", "Sage", "Loremaster",
191
+ "Wizard", "Conjurer", "Mage", "Archmage", "Highmage",
192
+ "Mystic", "Oracle", "Visionary", "Wraith", "Spectre",
193
+ "Shade", "Avatar", "Forefather", "Source", "Origin",
194
+ ]
195
+
196
+ # Star Wars-inspired — generic monk-knight + Light/Dark hierarchy. None
197
+ # of: Jedi, Sith, Padawan, Lightsaber, Force, Mandalorian, or named
198
+ # characters. Initiate → Apprentice → Knight → Master → Lord progression
199
+ # is broadly monastic, predates SW.
200
+ THEME_STARWARS = [
201
+ "Civilian", "Cadet", "Recruit", "Initiate", "Acolyte",
202
+ "Apprentice", "Disciple", "Adept", "Pupil", "Trainee",
203
+ "Squire", "Knight", "Crusader", "Champion", "Hunter",
204
+ "Tracker", "Warrior", "Sentinel", "Guardian", "Defender",
205
+ "Captain", "Master", "Council", "Sovereign", "Lord",
206
+ "Highlord", "Warlord", "Darklord", "Commander", "Chancellor",
207
+ "Emperor", "Mystic", "Sage", "Seer", "Oracle",
208
+ "Shadowmaster", "Lightbearer", "Voidwalker", "Starwarden", "Skywarden",
209
+ "Voidlord", "Galactic", "Stellar", "Cosmic", "Eternal",
210
+ "Astral", "Celestial", "Origin", "Source", "Above",
211
+ ]
212
+
213
+ # Hacker / dev-culture — mainstream dev hierarchy + system-internals
214
+ # folklore. No company names, no trademarked product names. "Linux",
215
+ # "Unix", "Bell Labs", "Knuth" intentionally absent — using them as
216
+ # rank names is gimmicky and risks association.
217
+ THEME_HACKER = [
218
+ "Lurker", "Reader", "Newbie", "Scriptkiddie", "Tinkerer",
219
+ "Coder", "Programmer", "Hacker", "Junior", "Engineer",
220
+ "Developer", "Senior", "Lead", "Architect", "Principal",
221
+ "Distinguished", "Fellow", "Maintainer", "Reviewer", "Mentor",
222
+ "Wizard", "Sorcerer", "Guru", "Sensei", "Sage",
223
+ "Oracle", "Veteran", "Greybeard", "Founder", "Pioneer",
224
+ "Daemon", "Compiler", "Linker", "Kernel", "Hypervisor",
225
+ "Bootloader", "Firmware", "Microservice", "Distributed", "Quantum",
226
+ "Specter", "Phantom", "Ghost", "Demon", "Druid",
227
+ "Phreaker", "Legend", "Source", "Genesis", "Singularity",
228
+ ]
229
+
230
+ THEMES: dict[str, list[str]] = {
231
+ "craft": THEME_CRAFT,
232
+ "forge": THEME_FORGE,
233
+ "cosmic": THEME_COSMIC,
234
+ "ocean": THEME_OCEAN,
235
+ "skyrim": THEME_SKYRIM,
236
+ "marvel": THEME_MARVEL,
237
+ "dc": THEME_DC,
238
+ "finalfantasy": THEME_FINALFANTASY,
239
+ "military": THEME_MILITARY,
240
+ "lotr": THEME_LOTR,
241
+ "starwars": THEME_STARWARS,
242
+ "hacker": THEME_HACKER,
243
+ }
244
+
245
+ DEFAULT_THEME = "craft"
246
+
247
+
248
+ def get_ladder(theme: str) -> list[str]:
249
+ """Return the 50-element name list for `theme`. Falls back to default
250
+ on unknown themes — the caller never sees a KeyError."""
251
+ return THEMES.get(theme, THEMES[DEFAULT_THEME])
252
+
253
+
254
+ def list_themes() -> list[str]:
255
+ """Stable list of available theme keys, default first for `/config`."""
256
+ return [DEFAULT_THEME] + sorted(k for k in THEMES if k != DEFAULT_THEME)