master-skill 0.10.0 → 0.11.0

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 (102) hide show
  1. package/.claude-plugin/marketplace.json +1 -1
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.cursor-plugin/plugin.json +1 -1
  4. package/ETHICS.md +23 -17
  5. package/GEMINI.md +1 -1
  6. package/README.md +57 -295
  7. package/README_EN.md +59 -276
  8. package/SKILL.md +5 -5
  9. package/bin/cli.mjs +545 -78
  10. package/gemini-extension.json +1 -1
  11. package/hooks/run-hook.cmd +18 -5
  12. package/hooks/session-start +4 -1
  13. package/hooks/tests/test_run_hook.sh +114 -0
  14. package/hooks/tests/test_run_hook_cmd.sh +94 -0
  15. package/masters/.gitkeep +0 -0
  16. package/package.json +10 -3
  17. package/prebuilt/{compare → compare-masters}/SKILL.md +5 -5
  18. package/prebuilt/master-ajahn-chah/SKILL.md +13 -11
  19. package/prebuilt/master-ajahn-chah/meta.json +8 -0
  20. package/prebuilt/master-ajahn-chah/references/voice.md +1 -1
  21. package/prebuilt/master-atisha/SKILL.md +13 -11
  22. package/prebuilt/master-atisha/meta.json +8 -0
  23. package/prebuilt/master-atisha/references/voice.md +1 -1
  24. package/prebuilt/master-buddhaghosa/SKILL.md +13 -11
  25. package/prebuilt/master-buddhaghosa/meta.json +8 -0
  26. package/prebuilt/master-buddhaghosa/references/voice.md +1 -1
  27. package/prebuilt/master-curriculum/SKILL.md +1 -1
  28. package/prebuilt/master-debate/SKILL.md +1 -1
  29. package/prebuilt/master-fazang/SKILL.md +3 -3
  30. package/prebuilt/master-fazang/meta.json +8 -0
  31. package/prebuilt/master-help/SKILL.md +86 -0
  32. package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
  33. package/prebuilt/master-huineng/SKILL.md +3 -3
  34. package/prebuilt/master-huineng/meta.json +8 -0
  35. package/prebuilt/master-kumarajiva/SKILL.md +3 -3
  36. package/prebuilt/master-kumarajiva/meta.json +20 -1
  37. package/prebuilt/master-mahasi-sayadaw/SKILL.md +13 -11
  38. package/prebuilt/master-mahasi-sayadaw/meta.json +8 -0
  39. package/prebuilt/master-mahasi-sayadaw/references/voice.md +2 -2
  40. package/prebuilt/master-milarepa/SKILL.md +13 -11
  41. package/prebuilt/master-milarepa/meta.json +8 -0
  42. package/prebuilt/master-milarepa/references/voice.md +1 -1
  43. package/prebuilt/master-nagarjuna/SKILL.md +3 -3
  44. package/prebuilt/master-nagarjuna/meta.json +25 -2
  45. package/prebuilt/master-ouyi/SKILL.md +3 -3
  46. package/prebuilt/master-ouyi/meta.json +8 -0
  47. package/prebuilt/master-tsongkhapa/SKILL.md +13 -11
  48. package/prebuilt/master-tsongkhapa/meta.json +32 -3
  49. package/prebuilt/master-tsongkhapa/references/voice.md +1 -1
  50. package/prebuilt/master-xuanzang/SKILL.md +3 -3
  51. package/prebuilt/master-xuanzang/meta.json +8 -0
  52. package/prebuilt/master-xuyun/SKILL.md +3 -3
  53. package/prebuilt/master-xuyun/meta.json +8 -0
  54. package/prebuilt/master-yinguang/SKILL.md +3 -3
  55. package/prebuilt/master-yinguang/meta.json +8 -0
  56. package/prebuilt/master-zhiyi/SKILL.md +3 -3
  57. package/prebuilt/master-zhiyi/meta.json +8 -0
  58. package/prompts/correction_handler.md +104 -0
  59. package/prompts/doctrine_reviewer.md +61 -0
  60. package/prompts/intake.md +62 -0
  61. package/prompts/merger.md +62 -0
  62. package/prompts/rag_instructions.md +54 -0
  63. package/prompts/sutra_analyzer.md +83 -0
  64. package/prompts/teaching_builder.md +41 -0
  65. package/prompts/voice_analyzer.md +92 -0
  66. package/prompts/voice_builder.md +48 -0
  67. package/prompts/voice_reviewer.md +66 -0
  68. package/references/README.md +12 -0
  69. package/references/ethics-runtime.md +112 -0
  70. package/references/fojin-api.md +223 -0
  71. package/references/source-conventions.md +129 -0
  72. package/references/teaching-modes.md +91 -0
  73. package/references/traditions.md +72 -0
  74. package/references/workflow-details.md +361 -0
  75. package/requirements.txt +6 -0
  76. package/routing.json +209 -0
  77. package/scripts/check-gate-liveness.py +222 -0
  78. package/scripts/select-fidelity-smoke.py +78 -0
  79. package/scripts/test-fidelity.py +339 -51
  80. package/scripts/tests/test_check_gate_liveness.py +232 -0
  81. package/scripts/tests/test_check_response.py +190 -0
  82. package/scripts/tests/test_fidelity_providers.py +202 -0
  83. package/scripts/tests/test_select_fidelity_smoke.py +142 -0
  84. package/scripts/tests/test_validate.py +145 -0
  85. package/scripts/tests/test_validate_citation_contract.py +408 -0
  86. package/scripts/tests/test_validate_fidelity.py +2 -2
  87. package/scripts/tests/test_validate_workflow.py +284 -0
  88. package/scripts/validate-citation-contract.py +193 -0
  89. package/scripts/validate-fidelity.py +6 -1
  90. package/scripts/validate-routing.py +254 -0
  91. package/scripts/validate.py +63 -36
  92. package/scripts/verify_citations.py +8 -1
  93. package/skill-catalog.json +210 -0
  94. package/tools/cross_reference.py +365 -0
  95. package/tools/fojin_bridge.py +146 -0
  96. package/tools/master_builder.py +341 -0
  97. package/tools/rag_query.py +336 -0
  98. package/tools/skill_writer.py +230 -0
  99. package/tools/sutra_collector.py +237 -0
  100. package/tools/verify_sources.py +512 -0
  101. package/tools/version_manager.py +88 -0
  102. /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
package/bin/cli.mjs CHANGED
@@ -8,8 +8,12 @@ import { fileURLToPath } from "url";
8
8
  // fileURLToPath (not new URL().pathname) — on Windows the URL pathname is
9
9
  // "/C:/…", which fs cannot resolve, so every command saw an empty prebuilt/.
10
10
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
11
- const PREBUILT = path.join(__dirname, "..", "prebuilt");
11
+ const PACKAGE_ROOT = path.join(__dirname, "..");
12
+ const PREBUILT = path.join(PACKAGE_ROOT, "prebuilt");
13
+ const CATALOG_PATH = path.join(PACKAGE_ROOT, "skill-catalog.json");
14
+ const ROUTING_PATH = path.join(PACKAGE_ROOT, "routing.json");
12
15
  const SKILLS_DIR = path.join(os.homedir(), ".claude", "skills");
16
+ const SKILL_KINDS = new Set(["persona", "teaching-mode", "generator"]);
13
17
 
14
18
  // --- helpers ---
15
19
 
@@ -27,7 +31,119 @@ function pkgVersion() {
27
31
  // Names feed into path.join + rmSync; reject separators / ".." so a typo
28
32
  // like "../foo" can never escape PREBUILT or SKILLS_DIR.
29
33
  function isSafeName(name) {
30
- return /^[A-Za-z0-9_-]+$/.test(name);
34
+ return typeof name === "string" && /^[A-Za-z0-9_-]+$/.test(name);
35
+ }
36
+
37
+ function isSafeRelativePath(value, { allowDot = false } = {}) {
38
+ if (typeof value !== "string" || value.length === 0) return false;
39
+ if (allowDot && value === ".") return true;
40
+ if (value === "." || value.includes("\\") || path.posix.isAbsolute(value)) return false;
41
+ if (/^[A-Za-z]:/.test(value)) return false;
42
+ const normalized = path.posix.normalize(value);
43
+ return normalized === value && normalized !== ".." && !normalized.startsWith("../");
44
+ }
45
+
46
+ function invalidCatalog(message) {
47
+ throw new Error(`Invalid skill catalog: ${message}`);
48
+ }
49
+
50
+ function loadCatalog() {
51
+ let catalog;
52
+ try {
53
+ catalog = JSON.parse(fs.readFileSync(CATALOG_PATH, "utf8"));
54
+ } catch (err) {
55
+ invalidCatalog(`cannot read ${path.basename(CATALOG_PATH)} (${err.message})`);
56
+ }
57
+
58
+ if (catalog?.version !== 1) invalidCatalog("version must be 1");
59
+ if (!Array.isArray(catalog.skills) || catalog.skills.length === 0) {
60
+ invalidCatalog("skills must be a non-empty array");
61
+ }
62
+
63
+ const uniqueFields = {
64
+ name: new Set(),
65
+ source: new Set(),
66
+ install_dir: new Set(),
67
+ };
68
+
69
+ for (const [index, skill] of catalog.skills.entries()) {
70
+ if (!skill || typeof skill !== "object" || Array.isArray(skill)) {
71
+ invalidCatalog(`skills[${index}] must be an object`);
72
+ }
73
+ if (!isSafeName(skill.name)) {
74
+ invalidCatalog(`skills[${index}].name must be a safe non-empty string`);
75
+ }
76
+ if (!SKILL_KINDS.has(skill.kind)) {
77
+ invalidCatalog(`skills[${index}].kind must be persona, teaching-mode, or generator`);
78
+ }
79
+ if (!isSafeRelativePath(skill.source, { allowDot: true })) {
80
+ invalidCatalog(`skills[${index}].source must be a safe relative path`);
81
+ }
82
+ if (!isSafeName(skill.install_dir)) {
83
+ invalidCatalog(`skills[${index}].install_dir must be a safe non-empty string`);
84
+ }
85
+ if (!Array.isArray(skill.aliases)) {
86
+ invalidCatalog(`skills[${index}].aliases must be an array`);
87
+ }
88
+
89
+ for (const field of Object.keys(uniqueFields)) {
90
+ if (uniqueFields[field].has(skill[field])) {
91
+ invalidCatalog(`duplicate ${field} "${skill[field]}"`);
92
+ }
93
+ uniqueFields[field].add(skill[field]);
94
+ }
95
+
96
+ const sourcePath = path.resolve(PACKAGE_ROOT, skill.source);
97
+ if (!fs.existsSync(sourcePath)) invalidCatalog(`source does not exist: ${skill.source}`);
98
+ if (!fs.statSync(sourcePath).isDirectory()) {
99
+ invalidCatalog(`source is not a directory: ${skill.source}`);
100
+ }
101
+
102
+ if (skill.kind === "generator") {
103
+ if (!Array.isArray(skill.bundle_paths) || skill.bundle_paths.length === 0) {
104
+ invalidCatalog(`generator "${skill.name}" must declare bundle_paths`);
105
+ }
106
+ const bundlePaths = new Set();
107
+ for (const bundlePath of skill.bundle_paths) {
108
+ if (!isSafeRelativePath(bundlePath)) {
109
+ invalidCatalog(`generator "${skill.name}" has an unsafe bundle path`);
110
+ }
111
+ if (bundlePaths.has(bundlePath)) {
112
+ invalidCatalog(`generator "${skill.name}" has duplicate bundle path "${bundlePath}"`);
113
+ }
114
+ bundlePaths.add(bundlePath);
115
+ if (!fs.existsSync(path.resolve(sourcePath, bundlePath))) {
116
+ invalidCatalog(`bundle path does not exist: ${bundlePath}`);
117
+ }
118
+ }
119
+ } else if (skill.bundle_paths !== undefined) {
120
+ invalidCatalog(`only generator skills may declare bundle_paths`);
121
+ }
122
+ }
123
+
124
+ // Register every canonical name before considering aliases so token
125
+ // ownership is independent of catalog record order. An alias may repeat its
126
+ // own canonical name, but it may never shadow another skill's canonical name.
127
+ const aliases = new Set();
128
+ const tokenOwners = new Map(
129
+ catalog.skills.map((skill) => [skill.name, skill.name])
130
+ );
131
+ for (const [index, skill] of catalog.skills.entries()) {
132
+ for (const alias of skill.aliases) {
133
+ if (!isSafeName(alias)) {
134
+ invalidCatalog(`skills[${index}].aliases contains an unsafe value`);
135
+ }
136
+ if (aliases.has(alias)) invalidCatalog(`duplicate alias "${alias}"`);
137
+ aliases.add(alias);
138
+ const existingOwner = tokenOwners.get(alias);
139
+ if (existingOwner && existingOwner !== skill.name) {
140
+ invalidCatalog(`alias "${alias}" conflicts with skill "${existingOwner}"`);
141
+ }
142
+ tokenOwners.set(alias, skill.name);
143
+ }
144
+ }
145
+
146
+ return catalog;
31
147
  }
32
148
 
33
149
  function cpR(src, dest) {
@@ -40,6 +156,65 @@ function cpR(src, dest) {
40
156
  }
41
157
  }
42
158
 
159
+ function copyGeneratorBundle(skill, src, dest) {
160
+ fs.mkdirSync(dest, { recursive: true });
161
+ for (const bundlePath of skill.bundle_paths) {
162
+ const bundleSource = path.join(src, bundlePath);
163
+ const bundleDest = path.join(dest, bundlePath);
164
+ if (fs.statSync(bundleSource).isDirectory()) {
165
+ cpR(bundleSource, bundleDest);
166
+ } else {
167
+ fs.mkdirSync(path.dirname(bundleDest), { recursive: true });
168
+ fs.copyFileSync(bundleSource, bundleDest);
169
+ }
170
+ }
171
+ }
172
+
173
+ function replaceGeneratorInstall(skill, src, dest) {
174
+ const staging = fs.mkdtempSync(
175
+ path.join(SKILLS_DIR, `.${skill.install_dir}-staging-`)
176
+ );
177
+ let backup = null;
178
+
179
+ try {
180
+ copyGeneratorBundle(skill, src, staging);
181
+
182
+ // Generated personas are user data, not package runtime. Merge them into
183
+ // the staged bundle before replacing the old install so reinstall/update
184
+ // can still remove stale runtime files without erasing masters/*.
185
+ const existingMasters = path.join(dest, "masters");
186
+ if (fs.existsSync(existingMasters)) {
187
+ cpR(existingMasters, path.join(staging, "masters"));
188
+ }
189
+
190
+ if (fs.existsSync(dest)) {
191
+ backup = fs.mkdtempSync(
192
+ path.join(SKILLS_DIR, `.${skill.install_dir}-backup-`)
193
+ );
194
+ fs.rmSync(backup, { recursive: true, force: true });
195
+ fs.renameSync(dest, backup);
196
+ }
197
+
198
+ try {
199
+ fs.renameSync(staging, dest);
200
+ } catch (err) {
201
+ if (backup && fs.existsSync(backup) && !fs.existsSync(dest)) {
202
+ fs.renameSync(backup, dest);
203
+ backup = null;
204
+ }
205
+ throw err;
206
+ }
207
+
208
+ if (backup) {
209
+ fs.rmSync(backup, { recursive: true, force: true });
210
+ backup = null;
211
+ }
212
+ } finally {
213
+ fs.rmSync(staging, { recursive: true, force: true });
214
+ if (backup) fs.rmSync(backup, { recursive: true, force: true });
215
+ }
216
+ }
217
+
43
218
  function parseFrontmatter(filepath) {
44
219
  // \r?\n: a CRLF checkout (git autocrlf on Windows) must not blank out
45
220
  // every description.
@@ -60,7 +235,7 @@ function availableMasters() {
60
235
  if (!fs.existsSync(PREBUILT)) return [];
61
236
  return fs
62
237
  .readdirSync(PREBUILT, { withFileTypes: true })
63
- .filter((d) => d.isDirectory() && d.name !== "compare")
238
+ .filter((d) => d.isDirectory() && d.name !== "compare-masters")
64
239
  .map((d) => {
65
240
  const skillMd = path.join(PREBUILT, d.name, "SKILL.md");
66
241
  const fm = fs.existsSync(skillMd) ? parseFrontmatter(skillMd) : {};
@@ -97,12 +272,47 @@ function printJson(payload) {
97
272
  console.log(JSON.stringify(payload, null, 2));
98
273
  }
99
274
 
275
+ let CATALOG;
276
+ try {
277
+ CATALOG = loadCatalog();
278
+ } catch (err) {
279
+ console.error(err.message);
280
+ process.exitCode = 1;
281
+ }
282
+
283
+ function catalogSkills() {
284
+ return CATALOG.skills.map((skill) => {
285
+ const skillMd = path.join(PACKAGE_ROOT, skill.source, "SKILL.md");
286
+ const fm = fs.existsSync(skillMd) ? parseFrontmatter(skillMd) : {};
287
+ return { ...skill, description: fm.description || "" };
288
+ });
289
+ }
290
+
291
+ function resolveSkill(input) {
292
+ return catalogSkills().find(
293
+ (skill) => skill.name === input || skill.aliases.includes(input)
294
+ ) || null;
295
+ }
296
+
100
297
  // --- commands ---
101
298
 
102
299
  function listData() {
103
300
  const masters = availableMasters();
301
+ const skills = catalogSkills();
302
+ const categoryCounts = skills.reduce((counts, skill) => {
303
+ counts[skill.kind] += 1;
304
+ return counts;
305
+ }, { persona: 0, "teaching-mode": 0, generator: 0 });
104
306
  return {
105
307
  count: masters.length,
308
+ skillCount: skills.length,
309
+ categoryCounts,
310
+ skills: skills.map(({ name, kind, install_dir, description }) => ({
311
+ name,
312
+ kind,
313
+ installDir: install_dir,
314
+ description,
315
+ })),
106
316
  masters: masters.map((m) => ({
107
317
  name: m.name,
108
318
  slug: m.name.replace(/^master-/, ""),
@@ -118,16 +328,27 @@ function cmdList({ json = false } = {}) {
118
328
  return;
119
329
  }
120
330
 
121
- const masters = data.masters;
122
- if (!masters.length) {
123
- console.log("No prebuilt masters found.");
331
+ const skills = data.skills;
332
+ if (!skills.length) {
333
+ console.log("No installable skills found.");
124
334
  return;
125
335
  }
126
- console.log(`\nAvailable masters (${data.count}):\n`);
127
- const nameW = Math.max(...masters.map((m) => m.name.length), 4);
128
- for (const m of masters) {
129
- const desc = m.description.length > 80 ? m.description.slice(0, 77) + "..." : m.description;
130
- console.log(` ${m.name.padEnd(nameW)} ${desc}`);
336
+ console.log(`\nAvailable masters (${data.count}); installable skills (${data.skillCount}):`);
337
+ const groups = [
338
+ ["persona", "Personas"],
339
+ ["teaching-mode", "Teaching modes"],
340
+ ["generator", "Generator"],
341
+ ];
342
+ const nameW = Math.max(...skills.map((skill) => skill.name.length), 4);
343
+ for (const [kind, label] of groups) {
344
+ const group = skills.filter((skill) => skill.kind === kind);
345
+ console.log(`\n${label} (${group.length}):`);
346
+ for (const skill of group) {
347
+ const desc = skill.description.length > 80
348
+ ? skill.description.slice(0, 77) + "..."
349
+ : skill.description;
350
+ console.log(` ${skill.name.padEnd(nameW)} ${desc}`);
351
+ }
131
352
  }
132
353
  console.log();
133
354
  }
@@ -153,30 +374,34 @@ function cmdInstall(names) {
153
374
  failed++;
154
375
  continue;
155
376
  }
156
- const src = resolveMasterDir(name);
157
- if (!src) {
158
- console.log(` ✗ ${name} — not found in prebuilt/ (tried "${name}" and "master-${name}")`);
377
+ const skill = resolveSkill(name);
378
+ if (!skill) {
379
+ console.log(` ✗ ${name} — not found in skill catalog`);
159
380
  failed++;
160
381
  continue;
161
382
  }
162
- const dirName = path.basename(src); // master-zhiyi
163
- const dest = path.join(SKILLS_DIR, dirName);
164
- // Clear any previous install first: files renamed or removed upstream
165
- // must not linger as stale skill content under ~/.claude/skills/.
166
- fs.rmSync(dest, { recursive: true, force: true });
167
- cpR(src, dest);
383
+ const src = path.join(PACKAGE_ROOT, skill.source);
384
+ const dest = path.join(SKILLS_DIR, skill.install_dir);
385
+ if (skill.kind === "generator") {
386
+ replaceGeneratorInstall(skill, src, dest);
387
+ } else {
388
+ // Clear any previous install first: files renamed or removed upstream
389
+ // must not linger as stale skill content under ~/.claude/skills/.
390
+ fs.rmSync(dest, { recursive: true, force: true });
391
+ cpR(src, dest);
392
+ }
168
393
  console.log(` ✓ ${name} → ${dest}`);
169
394
  }
170
395
  return failed;
171
396
  }
172
397
 
173
398
  function cmdInstallAll(label = "Installing") {
174
- const all = availableMasters().map((m) => m.name);
399
+ const all = catalogSkills().map((skill) => skill.name);
175
400
  if (!all.length) {
176
- console.log("No masters available.");
401
+ console.log("No skills available.");
177
402
  return 1;
178
403
  }
179
- console.log(`${label} all ${all.length} masters...\n`);
404
+ console.log(`${label} all ${all.length} skills...\n`);
180
405
  return cmdInstall(all);
181
406
  }
182
407
 
@@ -188,12 +413,16 @@ function cmdUninstall(names) {
188
413
  failed++;
189
414
  continue;
190
415
  }
191
- // Try both prefixed and bare directory names for backward compatibility
192
- // with any pre-v0.6 installs that may still sit at ~/.claude/skills/<slug>/.
193
- const candidates = [
194
- path.join(SKILLS_DIR, name), // exact: master-zhiyi
195
- path.join(SKILLS_DIR, `master-${name}`), // short: zhiyi → master-zhiyi
196
- ];
416
+ const skill = resolveSkill(name);
417
+ if (!skill) {
418
+ console.log(` ✗ ${name} not found in skill catalog`);
419
+ failed++;
420
+ continue;
421
+ }
422
+ // Keep removing legacy bare persona directories when they exist, while
423
+ // resolving every public name through the catalog's canonical target.
424
+ const candidates = [path.join(SKILLS_DIR, skill.install_dir)];
425
+ if (name !== skill.install_dir) candidates.push(path.join(SKILLS_DIR, name));
197
426
  const dest = candidates.find((p) => fs.existsSync(p));
198
427
  if (!dest) {
199
428
  console.log(` ✗ ${name} — not installed`);
@@ -336,34 +565,266 @@ function cmdInspect(name, { json = false } = {}) {
336
565
  return 0;
337
566
  }
338
567
 
568
+ // --- recommend ---
569
+ //
570
+ // Routing used to exist only as prose (a weighted-match paragraph and a
571
+ // pairing table in prebuilt/compare-masters/SKILL.md, a decision tree in
572
+ // references/teaching-modes.md), so nothing could execute or test it. This
573
+ // reads routing.json for the parts that had no machine-readable home and
574
+ // scores personas straight off each meta.json search_scope.keywords, which
575
+ // stays the single source of truth for keywords.
576
+ //
577
+ // Only exact keyword containment scores. The prose also described "related
578
+ // match = 2" and "weak match = 1" tiers, but those need a synonym/domain
579
+ // map that does not exist — implementing them would dress a guess up as an
580
+ // algorithm. Ties break on tradition diversity, then slug order, so the
581
+ // same query always yields the same answer.
582
+
583
+ function loadRouting() {
584
+ const routing = JSON.parse(fs.readFileSync(ROUTING_PATH, "utf8"));
585
+ if (routing?.version !== 1) {
586
+ throw new Error("Invalid routing table: version must be 1");
587
+ }
588
+ return routing;
589
+ }
590
+
591
+ function personaCandidates() {
592
+ return CATALOG.skills
593
+ .filter((skill) => skill.kind === "persona")
594
+ .map((skill) => {
595
+ const metaPath = path.join(PACKAGE_ROOT, skill.source, "meta.json");
596
+ const meta = fs.existsSync(metaPath) ? readJson(metaPath) : {};
597
+ return {
598
+ name: skill.name,
599
+ tradition: meta.tradition || "(unspecified)",
600
+ keywords: meta.search_scope?.keywords || [],
601
+ };
602
+ });
603
+ }
604
+
605
+ // Greedy pick: highest score first, then prefer a tradition not yet chosen
606
+ // so the result shows plural perspectives rather than three Chan masters.
607
+ function pickDiverse(scored, limit) {
608
+ const pool = [...scored];
609
+ const chosen = [];
610
+ const seenTraditions = new Set();
611
+ while (pool.length && chosen.length < limit) {
612
+ let idx = pool.findIndex((c) => !seenTraditions.has(c.tradition));
613
+ if (idx === -1) idx = 0;
614
+ const [pick] = pool.splice(idx, 1);
615
+ chosen.push(pick);
616
+ seenTraditions.add(pick.tradition);
617
+ }
618
+ return chosen;
619
+ }
620
+
621
+ // Shared by the situations and topic_pairings layers. Keyword sets are
622
+ // pairwise disjoint within each section (enforced by validate-routing.py),
623
+ // but a query can still touch two rows through different keywords — so the
624
+ // tiebreak is explicit and total: most hits, then longest single hit, then
625
+ // row id, which leaves no room for iteration order to decide.
626
+ function pickRow(rows, hitsFor) {
627
+ return (
628
+ rows
629
+ .map((row) => ({ row, matched: hitsFor(row.keywords) }))
630
+ .filter((entry) => entry.matched.length)
631
+ .sort(
632
+ (a, b) =>
633
+ b.matched.length - a.matched.length ||
634
+ Math.max(...b.matched.map((k) => k.length)) -
635
+ Math.max(...a.matched.map((k) => k.length)) ||
636
+ a.row.id.localeCompare(b.row.id)
637
+ )[0] || null
638
+ );
639
+ }
640
+
641
+ function expandSlugs(slugs) {
642
+ const byName = new Map(personaCandidates().map((c) => [c.name, c]));
643
+ return slugs.map((name) => ({
644
+ name,
645
+ command: `/${name}`,
646
+ tradition: byName.get(name)?.tradition || "(unspecified)",
647
+ score: 0,
648
+ matched: [],
649
+ }));
650
+ }
651
+
652
+ function recommendData(query) {
653
+ const routing = loadRouting();
654
+ const q = String(query).toLowerCase();
655
+ const hitsFor = (keywords) =>
656
+ keywords.filter((kw) => q.includes(String(kw).toLowerCase()));
657
+
658
+ // Priority 1 — teaching mode, short-circuited in declared order.
659
+ for (const rule of [...routing.mode_rules].sort((a, b) => a.order - b.order)) {
660
+ const matched = hitsFor(rule.keywords);
661
+ if (matched.length) {
662
+ return {
663
+ query,
664
+ resolvedBy: "mode_rules",
665
+ kind: "teaching-mode",
666
+ mode: rule.mode,
667
+ command: `/${rule.mode}`,
668
+ matched,
669
+ note: rule.note || null,
670
+ masters: [],
671
+ };
672
+ }
673
+ }
674
+
675
+ // Priority 2 — score personas off their own declared keywords. Keywords
676
+ // below min_keyword_length are skipped: see the note in routing.json.
677
+ const weight = routing.weights?.keyword_hit ?? 3;
678
+ const minLen = routing.min_keyword_length ?? 2;
679
+ const scored = personaCandidates()
680
+ .map((c) => {
681
+ const matched = hitsFor(c.keywords.filter((kw) => String(kw).length >= minLen));
682
+ return { ...c, matched, score: matched.length * weight };
683
+ })
684
+ .filter((c) => c.score > 0)
685
+ .sort((a, b) => b.score - a.score || a.name.localeCompare(b.name));
686
+
687
+ if (scored.length) {
688
+ return {
689
+ query,
690
+ resolvedBy: "persona_keywords",
691
+ kind: "persona",
692
+ mode: null,
693
+ command: null,
694
+ matched: [],
695
+ note: null,
696
+ masters: pickDiverse(scored, 3).map((c) => ({
697
+ name: c.name,
698
+ command: `/${c.name}`,
699
+ tradition: c.tradition,
700
+ score: c.score,
701
+ matched: c.matched,
702
+ })),
703
+ };
704
+ }
705
+
706
+ // Priority 3 — vernacular felt-state. A beginner types 坐不住, not 四念处,
707
+ // and search_scope.keywords carry no such wording, so these queries used to
708
+ // land on the default pairing. Placed after keyword scoring (an explicit
709
+ // doctrinal term is a stronger signal) and before topic_pairings (which was
710
+ // authored to pair masters for /compare-masters, not to answer "ask who?").
711
+ const situation = pickRow(routing.situations || [], hitsFor);
712
+ if (situation) {
713
+ return {
714
+ query,
715
+ resolvedBy: "situations",
716
+ kind: "persona",
717
+ mode: null,
718
+ command: null,
719
+ matched: situation.matched,
720
+ note: situation.row.note || null,
721
+ masters: expandSlugs(situation.row.masters),
722
+ };
723
+ }
724
+
725
+ // Priority 4 — topic pairing fallback.
726
+ const pairing = pickRow(routing.topic_pairings, hitsFor);
727
+ if (pairing) {
728
+ return {
729
+ query,
730
+ resolvedBy: "topic_pairings",
731
+ kind: "persona",
732
+ mode: null,
733
+ command: null,
734
+ matched: pairing.matched,
735
+ note: pairing.row.note || null,
736
+ masters: expandSlugs(pairing.row.masters),
737
+ };
738
+ }
739
+
740
+ // Priority 5 — nothing matched at all.
741
+ return {
742
+ query,
743
+ resolvedBy: "default_pairing",
744
+ kind: "persona",
745
+ mode: null,
746
+ command: null,
747
+ matched: [],
748
+ note: "无关键词命中,回退到默认配对",
749
+ masters: expandSlugs(routing.default_pairing),
750
+ };
751
+ }
752
+
753
+ function cmdRecommend(query, { json = false } = {}) {
754
+ if (!query || !String(query).trim()) {
755
+ console.log('Usage: master-skill recommend "<你的问题或状况>"');
756
+ return 1;
757
+ }
758
+
759
+ const data = recommendData(query);
760
+
761
+ if (json) {
762
+ printJson(data);
763
+ return 0;
764
+ }
765
+
766
+ if (data.kind === "teaching-mode") {
767
+ console.log(`\n建议使用教学模式:${data.command}`);
768
+ if (data.note) console.log(` ${data.note}`);
769
+ console.log(` 命中关键词:${data.matched.join("、")}`);
770
+ console.log(
771
+ `\n(若只想听一位祖师,直接用对应的 /master-<name>;` +
772
+ `master-skill list 可列出全部。)\n`
773
+ );
774
+ return 0;
775
+ }
776
+
777
+ console.log(`\n推荐祖师:`);
778
+ for (const m of data.masters) {
779
+ const why = m.matched.length
780
+ ? `命中 ${m.matched.slice(0, 5).join("、")}`
781
+ : data.note || "主题配对";
782
+ console.log(` ${m.command} [${m.tradition}] ${why}`);
783
+ }
784
+ if (data.resolvedBy === "default_pairing") {
785
+ console.log(`\n 没有明确命中,给的是通用入门配对。`);
786
+ }
787
+ console.log(
788
+ `\n(想看多位祖师并列 → /compare-masters;想看对辩 → /master-debate;` +
789
+ `想要学修路径 → /master-curriculum)\n`
790
+ );
791
+ return 0;
792
+ }
793
+
339
794
  function showHelp() {
340
795
  console.log(`
341
796
  master-skill v${pkgVersion()} — Buddhist Master AI Skills installer
342
797
 
343
798
  Usage:
344
- master-skill install <name...> Install masters to ~/.claude/skills/
345
- master-skill install --all Install all available masters
346
- master-skill update --all Reinstall all masters, clearing stale files
347
- master-skill list List available masters
348
- master-skill list --json Print available masters as JSON
799
+ master-skill install <name...> Install skills to ~/.claude/skills/
800
+ master-skill install --all Install all ${CATALOG.skills.length} available skills
801
+ master-skill update --all Reinstall all skills, clearing stale files
802
+ master-skill list List available skills
803
+ master-skill list --json Print available skills as JSON
349
804
  master-skill inspect <name> Show source/runtime metadata for one master
350
805
  master-skill inspect <name> --json
806
+ master-skill recommend "<问题>" Suggest which master or teaching mode to use
807
+ master-skill recommend "<问题>" --json
351
808
  master-skill doctor Check local install and runtime paths
352
809
  master-skill doctor --json Print diagnostics as JSON
353
- master-skill uninstall <name...> Remove installed masters
810
+ master-skill uninstall <name...> Remove installed skills
354
811
  master-skill --version Print version
355
812
  master-skill --help Show this help
356
813
 
357
- Names accept both short (zhiyi) and full (master-zhiyi) forms.
358
- Slash commands are always /master-<slug> (e.g. /master-zhiyi).
814
+ Persona names accept both short (zhiyi) and full (master-zhiyi) forms.
815
+ Teaching modes and create-master use their public catalog names.
359
816
 
360
817
  Examples:
361
818
  npx master-skill install zhiyi fazang
362
819
  npx master-skill install master-milarepa master-tsongkhapa
820
+ npx master-skill install compare-masters
821
+ npx master-skill install create-master
363
822
  npx master-skill install --all
364
823
  npx master-skill update --all
365
824
  npx master-skill list
366
825
  npx master-skill inspect huineng
826
+ npx master-skill recommend "念佛怎么念才算老实"
827
+ npx master-skill recommend "禅宗从哪开始学"
367
828
  npx master-skill doctor
368
829
  npx master-skill uninstall zhiyi
369
830
  `);
@@ -371,48 +832,54 @@ Examples:
371
832
 
372
833
  // --- main ---
373
834
 
374
- const args = process.argv.slice(2);
375
- const json = args.includes("--json");
376
- const positionalArgs = args.filter((arg) => arg !== "--json");
377
- const cmd = positionalArgs[0];
378
-
379
- if (!cmd || cmd === "--help" || cmd === "-h") {
380
- showHelp();
381
- } else if (cmd === "--version" || cmd === "-v") {
382
- console.log(pkgVersion());
383
- } else if (cmd === "list") {
384
- cmdList({ json });
385
- } else if (cmd === "doctor") {
386
- if (cmdDoctor({ json }) > 0) process.exitCode = 1;
387
- } else if (cmd === "inspect") {
388
- if (cmdInspect(positionalArgs[1], { json }) > 0) process.exitCode = 1;
389
- } else if (cmd === "install") {
390
- const rest = positionalArgs.slice(1);
391
- if (rest.includes("--all")) {
392
- if (cmdInstallAll("Installing") > 0) process.exitCode = 1;
393
- } else if (rest.length === 0) {
394
- console.log("Usage: master-skill install <name...> | --all");
395
- process.exitCode = 1;
396
- } else {
397
- if (cmdInstall(rest) > 0) process.exitCode = 1;
398
- }
399
- } else if (cmd === "update") {
400
- const rest = positionalArgs.slice(1);
401
- if (rest.length === 1 && rest[0] === "--all") {
402
- if (cmdInstallAll("Updating") > 0) process.exitCode = 1;
835
+ if (CATALOG) {
836
+ const args = process.argv.slice(2);
837
+ const json = args.includes("--json");
838
+ const positionalArgs = args.filter((arg) => arg !== "--json");
839
+ const cmd = positionalArgs[0];
840
+
841
+ if (!cmd || cmd === "--help" || cmd === "-h") {
842
+ showHelp();
843
+ } else if (cmd === "--version" || cmd === "-v") {
844
+ console.log(pkgVersion());
845
+ } else if (cmd === "list") {
846
+ cmdList({ json });
847
+ } else if (cmd === "doctor") {
848
+ if (cmdDoctor({ json }) > 0) process.exitCode = 1;
849
+ } else if (cmd === "inspect") {
850
+ if (cmdInspect(positionalArgs[1], { json }) > 0) process.exitCode = 1;
851
+ } else if (cmd === "recommend") {
852
+ // Join the rest so an unquoted multi-word query still works.
853
+ const query = positionalArgs.slice(1).join(" ");
854
+ if (cmdRecommend(query, { json }) > 0) process.exitCode = 1;
855
+ } else if (cmd === "install") {
856
+ const rest = positionalArgs.slice(1);
857
+ if (rest.includes("--all")) {
858
+ if (cmdInstallAll("Installing") > 0) process.exitCode = 1;
859
+ } else if (rest.length === 0) {
860
+ console.log("Usage: master-skill install <name...> | --all");
861
+ process.exitCode = 1;
862
+ } else {
863
+ if (cmdInstall(rest) > 0) process.exitCode = 1;
864
+ }
865
+ } else if (cmd === "update") {
866
+ const rest = positionalArgs.slice(1);
867
+ if (rest.length === 1 && rest[0] === "--all") {
868
+ if (cmdInstallAll("Updating") > 0) process.exitCode = 1;
869
+ } else {
870
+ console.log("Usage: master-skill update --all");
871
+ process.exitCode = 1;
872
+ }
873
+ } else if (cmd === "uninstall") {
874
+ const rest = positionalArgs.slice(1);
875
+ if (rest.length === 0) {
876
+ console.log("Usage: master-skill uninstall <name...>");
877
+ process.exitCode = 1;
878
+ } else {
879
+ if (cmdUninstall(rest) > 0) process.exitCode = 1;
880
+ }
403
881
  } else {
404
- console.log("Usage: master-skill update --all");
882
+ console.log(`Unknown command: ${cmd}\nRun master-skill --help for usage.`);
405
883
  process.exitCode = 1;
406
884
  }
407
- } else if (cmd === "uninstall") {
408
- const rest = positionalArgs.slice(1);
409
- if (rest.length === 0) {
410
- console.log("Usage: master-skill uninstall <name...>");
411
- process.exitCode = 1;
412
- } else {
413
- if (cmdUninstall(rest) > 0) process.exitCode = 1;
414
- }
415
- } else {
416
- console.log(`Unknown command: ${cmd}\nRun master-skill --help for usage.`);
417
- process.exitCode = 1;
418
885
  }