master-skill 0.10.1 → 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 (35) 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/GEMINI.md +1 -1
  5. package/README.md +52 -297
  6. package/README_EN.md +52 -278
  7. package/bin/cli.mjs +237 -2
  8. package/gemini-extension.json +1 -1
  9. package/hooks/session-start +4 -1
  10. package/package.json +3 -2
  11. package/prebuilt/master-curriculum/SKILL.md +1 -1
  12. package/prebuilt/master-debate/SKILL.md +1 -1
  13. package/prebuilt/master-help/SKILL.md +86 -0
  14. package/prebuilt/master-help/tests/fidelity.jsonl +10 -0
  15. package/prebuilt/master-kumarajiva/meta.json +14 -3
  16. package/prebuilt/master-nagarjuna/meta.json +19 -4
  17. package/prebuilt/master-tsongkhapa/meta.json +26 -5
  18. package/references/teaching-modes.md +8 -1
  19. package/routing.json +209 -0
  20. package/scripts/check-gate-liveness.py +222 -0
  21. package/scripts/test-fidelity.py +320 -49
  22. package/scripts/tests/test_check_gate_liveness.py +232 -0
  23. package/scripts/tests/test_check_response.py +190 -0
  24. package/scripts/tests/test_fidelity_providers.py +202 -0
  25. package/scripts/tests/test_select_fidelity_smoke.py +2 -2
  26. package/scripts/tests/test_validate.py +145 -0
  27. package/scripts/tests/test_validate_citation_contract.py +1 -1
  28. package/scripts/tests/test_validate_fidelity.py +2 -2
  29. package/scripts/tests/test_validate_workflow.py +21 -2
  30. package/scripts/validate-fidelity.py +6 -1
  31. package/scripts/validate-routing.py +254 -0
  32. package/scripts/validate.py +63 -36
  33. package/skill-catalog.json +83 -20
  34. /package/prebuilt/{compare → compare-masters}/SKILL.md +0 -0
  35. /package/prebuilt/{compare → compare-masters}/tests/fidelity.jsonl +0 -0
@@ -31,6 +31,161 @@ from pathlib import Path
31
31
  from verify_citations import audit_answer, load_declared_ids
32
32
 
33
33
  PREBUILT_DIR = Path(__file__).resolve().parent.parent / "prebuilt"
34
+ SCHEMA_VERSION = 1
35
+
36
+ # This project ships one prebuilt/ to five hosts (Claude Code, Cursor, Codex
37
+ # CLI, OpenCode, Gemini CLI), but every fidelity number it has produced came
38
+ # from one Anthropic model. A fixture measures whether the prompt induces the
39
+ # right behaviour, and that is a property of the prompt-and-model pair — so
40
+ # provider is an axis of the eval matrix, not a way to spend less.
41
+ #
42
+ # `api` selects the request/response shape. DeepSeek and Gemini both expose
43
+ # OpenAI-compatible endpoints, so one adapter covers them.
44
+ PROVIDERS: dict[str, dict] = {
45
+ "anthropic": {
46
+ "env": "ANTHROPIC_API_KEY",
47
+ "api": "anthropic",
48
+ "base_url": None,
49
+ "default_model": "claude-sonnet-4-6",
50
+ "models_url": "https://docs.claude.com/en/docs/about-claude/models",
51
+ },
52
+ "deepseek": {
53
+ "env": "DEEPSEEK_API_KEY",
54
+ "api": "openai",
55
+ "base_url": "https://api.deepseek.com/v1",
56
+ "default_model": None,
57
+ "models_url": "https://api-docs.deepseek.com/quick_start/pricing",
58
+ },
59
+ "gemini": {
60
+ "env": "GEMINI_API_KEY",
61
+ "api": "openai",
62
+ "base_url": "https://generativelanguage.googleapis.com/v1beta/openai/",
63
+ "default_model": None,
64
+ "models_url": "https://ai.google.dev/gemini-api/docs/models",
65
+ },
66
+ }
67
+
68
+ DEFAULT_PROVIDER = "anthropic"
69
+
70
+
71
+ def resolve_provider(name: str) -> dict:
72
+ """Look up a provider spec, failing with the list of what is available."""
73
+ try:
74
+ return PROVIDERS[name]
75
+ except KeyError:
76
+ known = ", ".join(sorted(PROVIDERS))
77
+ raise ValueError(f"unknown provider {name!r} — known providers: {known}") from None
78
+
79
+
80
+ def resolve_model(provider: str, explicit: str | None) -> str:
81
+ """Pick the model id for a run.
82
+
83
+ Anthropic keeps a default so existing invocations are unchanged. Every other
84
+ provider must be named explicitly: a guessed model id committed to this repo
85
+ would rot silently, and a run that cannot say which model produced it is not
86
+ a reproducible measurement.
87
+ """
88
+ if explicit:
89
+ return explicit
90
+ spec = resolve_provider(provider)
91
+ if spec["default_model"]:
92
+ return spec["default_model"]
93
+ raise ValueError(
94
+ f"provider {provider!r} has no default model — pass --model explicitly. "
95
+ f"Current model ids: {spec['models_url']}"
96
+ )
97
+
98
+
99
+ def build_request(
100
+ provider: str, model: str, system_prompt: str, question: str, max_tokens: int
101
+ ) -> dict:
102
+ """Build the request body for this provider's API shape."""
103
+ spec = resolve_provider(provider)
104
+ if spec["api"] == "anthropic":
105
+ return {
106
+ "model": model,
107
+ "max_tokens": max_tokens,
108
+ "system": system_prompt,
109
+ "messages": [{"role": "user", "content": question}],
110
+ }
111
+ return {
112
+ "model": model,
113
+ "max_tokens": max_tokens,
114
+ "messages": [
115
+ {"role": "system", "content": system_prompt},
116
+ {"role": "user", "content": question},
117
+ ],
118
+ }
119
+
120
+
121
+ def extract_text(provider: str, response: object) -> str:
122
+ """Pull the answer text out of this provider's response object.
123
+
124
+ Raises rather than returning "" on an empty response: a blank answer scored
125
+ as a normal case fails every must_mention and would be recorded as a persona
126
+ defect when it is really a transport problem.
127
+ """
128
+ spec = resolve_provider(provider)
129
+ if spec["api"] == "anthropic":
130
+ blocks = getattr(response, "content", None) or []
131
+ for block in blocks:
132
+ if getattr(block, "type", None) == "text":
133
+ return block.text
134
+ raise ValueError(f"{provider}: response carried no text block")
135
+
136
+ choices = getattr(response, "choices", None) or []
137
+ if not choices:
138
+ raise ValueError(f"{provider}: response carried no choices")
139
+ content = getattr(choices[0].message, "content", None)
140
+ if content is None:
141
+ raise ValueError(f"{provider}: response message had no content")
142
+ return content
143
+
144
+
145
+ def aggregation_conflicts(suites: list[dict]) -> list[str]:
146
+ """Report why a set of suites must not be pooled into one number.
147
+
148
+ Two models are two instruments. Averaging a Sonnet run with a DeepSeek run
149
+ — or a Sonnet run with an Opus run — produces a figure that describes
150
+ neither. Report per model instead.
151
+ """
152
+ seen = {
153
+ (s.get("provider", DEFAULT_PROVIDER), s.get("model"))
154
+ for s in suites
155
+ if s.get("model")
156
+ }
157
+ if len(seen) <= 1:
158
+ return []
159
+ listed = ", ".join(f"{p}/{m}" for p, m in sorted(seen))
160
+ return [
161
+ "refusing to aggregate across instruments — these suites came from "
162
+ f"different models ({listed}). Report a row per model."
163
+ ]
164
+
165
+
166
+ def suite_common(
167
+ master_name: str, dry_run: bool, outcome: str, provider: str = DEFAULT_PROVIDER
168
+ ) -> dict:
169
+ """Return fields shared by every fidelity JSON v1 suite."""
170
+ return {
171
+ "schema_version": SCHEMA_VERSION,
172
+ "master": master_name,
173
+ "provider": provider,
174
+ "mode": "dry_run" if dry_run else "graded",
175
+ "outcome": outcome,
176
+ }
177
+
178
+
179
+ def suite_error(
180
+ master_name: str, dry_run: bool, message: str, provider: str = DEFAULT_PROVIDER
181
+ ) -> dict:
182
+ """Return a fidelity JSON v1 suite for a precondition or execution error."""
183
+ return {
184
+ **suite_common(master_name, dry_run, "error", provider),
185
+ "total": 0,
186
+ "results": [],
187
+ "error": message,
188
+ }
34
189
 
35
190
 
36
191
  def load_skill_context(master_dir: Path) -> str:
@@ -64,12 +219,56 @@ def load_tests(master_dir: Path) -> list[dict]:
64
219
  if not fidelity_path.exists():
65
220
  return []
66
221
  tests = []
67
- for line in fidelity_path.read_text(encoding="utf-8").strip().splitlines():
222
+ for line_number, line in enumerate(
223
+ fidelity_path.read_text(encoding="utf-8").splitlines(),
224
+ start=1,
225
+ ):
68
226
  if line.strip():
69
- tests.append(json.loads(line))
227
+ try:
228
+ test = json.loads(line)
229
+ except json.JSONDecodeError as error:
230
+ raise ValueError(
231
+ f"Invalid fidelity.jsonl line {line_number}: {error.msg}"
232
+ ) from error
233
+ if not isinstance(test, dict):
234
+ raise ValueError(
235
+ f"Invalid fidelity.jsonl line {line_number}: expected a JSON object"
236
+ )
237
+ question = test.get("q")
238
+ if not isinstance(question, str) or not question.strip():
239
+ raise ValueError(
240
+ f"Invalid fidelity.jsonl line {line_number}: "
241
+ "expected a non-empty string q"
242
+ )
243
+ tests.append(test)
70
244
  return tests
71
245
 
72
246
 
247
+ def _split_echoes(
248
+ terms: list[str], response: str, question: str
249
+ ) -> tuple[list[str], list[str]]:
250
+ """Split forbidden-term hits into genuine violations and question echoes.
251
+
252
+ ``must_not_contain`` is a substring match against the response, and the
253
+ boundary fixtures are baited questions that carry the loaded term
254
+ themselves ("华严宗是不是佛教最高的宗派?" forbids 最高). A response that
255
+ quotes the bait in order to refuse it — "你问是不是最高,佛法无高下" —
256
+ trips the match exactly as hard as an actual ranking does.
257
+
258
+ Substring matching cannot tell those apart, so a hit whose term already
259
+ appears in the fixture's own question is undecidable here. It is returned
260
+ separately and does not fail the case; it flags the case for human review
261
+ instead. A hit on a term the question never used is a real violation.
262
+ """
263
+ found: list[str] = []
264
+ echoed: list[str] = []
265
+ for term in terms:
266
+ if term not in response:
267
+ continue
268
+ (echoed if term in question else found).append(term)
269
+ return found, echoed
270
+
271
+
73
272
  def check_response(
74
273
  response: str,
75
274
  test_case: dict,
@@ -79,8 +278,13 @@ def check_response(
79
278
  """Check a response against expected citations, mentions, and boundaries.
80
279
 
81
280
  Returns {passed: bool, missing_cites: [...], missing_mentions: [...],
82
- forbidden_found: [...], boundary_violations: [...],
83
- fabricated_cites: [...]}.
281
+ forbidden_found: [...], forbidden_echoed: [...],
282
+ boundary_violations: [...], boundary_echoed: [...],
283
+ needs_review: bool, fabricated_cites: [...]}.
284
+
285
+ ``*_echoed`` holds forbidden terms that the fixture's own question already
286
+ contains — see ``_split_echoes``. They do not fail the case; they set
287
+ ``needs_review`` so a human can look at the stored response and decide.
84
288
 
85
289
  When the test sets ``must_cite_only_existing_sources`` and ``declared_ids``
86
290
  is supplied, every citation in the response must be either a declared
@@ -97,18 +301,20 @@ def check_response(
97
301
  if mention not in response:
98
302
  missing_mentions.append(mention)
99
303
 
304
+ question = test_case.get("q", "")
305
+
100
306
  # Boundary tests: must_not_contain
101
- forbidden_found = []
102
- for forbidden in test_case.get("must_not_contain", []):
103
- if forbidden in response:
104
- forbidden_found.append(forbidden)
307
+ forbidden_found, forbidden_echoed = _split_echoes(
308
+ test_case.get("must_not_contain", []), response, question
309
+ )
105
310
 
106
311
  # First-turn boundary: must_not_contain_first_turn
107
- boundary_violations = []
312
+ boundary_violations: list[str] = []
313
+ boundary_echoed: list[str] = []
108
314
  if is_first_turn:
109
- for forbidden in test_case.get("must_not_contain_first_turn", []):
110
- if forbidden in response:
111
- boundary_violations.append(forbidden)
315
+ boundary_violations, boundary_echoed = _split_echoes(
316
+ test_case.get("must_not_contain_first_turn", []), response, question
317
+ )
112
318
 
113
319
  # B1: must_cite_only_existing_sources — no hallucinated citations
114
320
  fabricated_cites = []
@@ -128,26 +334,68 @@ def check_response(
128
334
  "missing_cites": missing_cites,
129
335
  "missing_mentions": missing_mentions,
130
336
  "forbidden_found": forbidden_found,
337
+ "forbidden_echoed": forbidden_echoed,
131
338
  "boundary_violations": boundary_violations,
339
+ "boundary_echoed": boundary_echoed,
340
+ "needs_review": bool(forbidden_echoed or boundary_echoed),
132
341
  "fabricated_cites": fabricated_cites,
133
342
  }
134
343
 
135
344
 
345
+ def result_entry(
346
+ index: int, test: dict, check: dict, response_text: str
347
+ ) -> dict:
348
+ """Build one graded result record.
349
+
350
+ Carries the response itself, not just its length. The first baseline
351
+ stored only ``response_length``, which left every failure unadjudicable
352
+ after the fact — there was no way to revisit a case and see what the
353
+ persona actually said.
354
+ """
355
+ return {
356
+ "index": index,
357
+ "question": test["q"],
358
+ "difficulty": test.get("difficulty", "unknown"),
359
+ "test_type": test.get("test_type", "fidelity"),
360
+ "status": "PASS" if check["passed"] else "FAIL",
361
+ "missing_cites": check["missing_cites"],
362
+ "missing_mentions": check["missing_mentions"],
363
+ "forbidden_found": check["forbidden_found"],
364
+ "forbidden_echoed": check["forbidden_echoed"],
365
+ "boundary_violations": check["boundary_violations"],
366
+ "boundary_echoed": check["boundary_echoed"],
367
+ "fabricated_cites": check["fabricated_cites"],
368
+ "needs_review": check["needs_review"],
369
+ "response": response_text,
370
+ "response_length": len(response_text),
371
+ }
372
+
373
+
136
374
  def run_tests(
137
375
  master_name: str,
138
376
  dry_run: bool = False,
139
- model: str = "claude-sonnet-4-6",
377
+ model: str | None = None,
140
378
  max_tests: int | None = None,
141
379
  quiet: bool = False,
380
+ provider: str = DEFAULT_PROVIDER,
142
381
  ) -> dict:
143
382
  """Run fidelity tests for a master. Returns summary."""
144
383
  master_dir = PREBUILT_DIR / master_name
145
384
  if not master_dir.exists():
146
- return {"error": f"Master '{master_name}' not found"}
385
+ return suite_error(
386
+ master_name, dry_run, f"Master '{master_name}' not found", provider
387
+ )
147
388
 
148
- tests = load_tests(master_dir)
389
+ try:
390
+ tests = load_tests(master_dir)
391
+ except (OSError, ValueError) as error:
392
+ return suite_error(
393
+ master_name, dry_run, f"Unable to load fidelity suite: {error}", provider
394
+ )
149
395
  if not tests:
150
- return {"error": f"No fidelity.jsonl found for '{master_name}'"}
396
+ return suite_error(
397
+ master_name, dry_run, f"No fidelity.jsonl found for '{master_name}'", provider
398
+ )
151
399
 
152
400
  if max_tests is not None and max_tests > 0:
153
401
  # Prefer easier/basic tests when capping — smoke suite should hit
@@ -171,22 +419,49 @@ def run_tests(
171
419
  "difficulty": test.get("difficulty", "unknown"),
172
420
  "status": "dry_run",
173
421
  })
174
- return {"master": master_name, "total": len(tests), "results": results}
422
+ return {
423
+ **suite_common(master_name, dry_run, "completed", provider),
424
+ "total": len(tests),
425
+ "results": results,
426
+ }
175
427
 
176
428
  # Load skill context
177
429
  system_prompt = load_skill_context(master_dir)
178
430
 
179
- # Import anthropic
180
431
  try:
181
- import anthropic
182
- except ImportError:
183
- return {"error": "anthropic package not installed. Run: pip install anthropic"}
432
+ spec = resolve_provider(provider)
433
+ model = resolve_model(provider, model)
434
+ except ValueError as error:
435
+ return suite_error(master_name, dry_run, str(error), provider)
184
436
 
185
- api_key = os.environ.get("ANTHROPIC_API_KEY")
437
+ api_key = os.environ.get(spec["env"])
186
438
  if not api_key:
187
- return {"error": "ANTHROPIC_API_KEY environment variable not set"}
439
+ return suite_error(
440
+ master_name, dry_run, f"{spec['env']} environment variable not set", provider
441
+ )
188
442
 
189
- client = anthropic.Anthropic(api_key=api_key)
443
+ if spec["api"] == "anthropic":
444
+ try:
445
+ import anthropic
446
+ except ImportError:
447
+ return suite_error(
448
+ master_name, dry_run,
449
+ "anthropic package not installed. Run: pip install anthropic",
450
+ provider,
451
+ )
452
+ client = anthropic.Anthropic(api_key=api_key)
453
+ send = lambda body: client.messages.create(**body) # noqa: E731
454
+ else:
455
+ try:
456
+ import openai
457
+ except ImportError:
458
+ return suite_error(
459
+ master_name, dry_run,
460
+ "openai package not installed. Run: pip install openai",
461
+ provider,
462
+ )
463
+ client = openai.OpenAI(api_key=api_key, base_url=spec["base_url"])
464
+ send = lambda body: client.chat.completions.create(**body) # noqa: E731
190
465
 
191
466
  # Declared offline sources, for the must_cite_only_existing_sources B1 check.
192
467
  try:
@@ -202,13 +477,10 @@ def run_tests(
202
477
  print(f" [{i+1}/{len(tests)}] {test['q'][:50]}...", end=" ", flush=True)
203
478
 
204
479
  try:
205
- message = client.messages.create(
206
- model=model,
207
- max_tokens=2048,
208
- system=system_prompt,
209
- messages=[{"role": "user", "content": test["q"]}],
480
+ response = send(
481
+ build_request(provider, model, system_prompt, test["q"], 2048)
210
482
  )
211
- response_text = message.content[0].text
483
+ response_text = extract_text(provider, response)
212
484
  except Exception as e:
213
485
  results.append({
214
486
  "index": i,
@@ -224,27 +496,12 @@ def run_tests(
224
496
  check = check_response(
225
497
  response_text, test, is_first_turn=True, declared_ids=declared_ids
226
498
  )
227
- status = "PASS" if check["passed"] else "FAIL"
228
-
229
- result_entry = {
230
- "index": i,
231
- "question": test["q"],
232
- "difficulty": test.get("difficulty", "unknown"),
233
- "test_type": test.get("test_type", "fidelity"),
234
- "status": status,
235
- "missing_cites": check["missing_cites"],
236
- "missing_mentions": check["missing_mentions"],
237
- "forbidden_found": check["forbidden_found"],
238
- "boundary_violations": check["boundary_violations"],
239
- "fabricated_cites": check["fabricated_cites"],
240
- "response_length": len(response_text),
241
- }
242
- results.append(result_entry)
499
+ results.append(result_entry(i, test, check, response_text))
243
500
 
244
501
  if check["passed"]:
245
502
  passed += 1
246
503
  if not quiet:
247
- print("PASS")
504
+ print("PASS (review)" if check["needs_review"] else "PASS")
248
505
  else:
249
506
  failed += 1
250
507
  failures = (check["missing_cites"] + check["missing_mentions"]
@@ -253,7 +510,7 @@ def run_tests(
253
510
  print(f"FAIL ({failures})")
254
511
 
255
512
  return {
256
- "master": master_name,
513
+ **suite_common(master_name, dry_run, "completed", provider),
257
514
  "model": model,
258
515
  "total": len(tests),
259
516
  "passed": passed,
@@ -283,7 +540,16 @@ def main() -> int:
283
540
  parser.add_argument("--master", type=str, help="Test a specific master")
284
541
  parser.add_argument("--all", action="store_true", help="Test all masters with fidelity.jsonl")
285
542
  parser.add_argument("--dry-run", action="store_true", help="Show test cases without calling API")
286
- parser.add_argument("--model", type=str, default="claude-sonnet-4-6", help="Claude model to use")
543
+ parser.add_argument(
544
+ "--provider", type=str, default=DEFAULT_PROVIDER, choices=sorted(PROVIDERS),
545
+ help="Which API to grade against (default: anthropic). Non-anthropic "
546
+ "providers require --model.",
547
+ )
548
+ parser.add_argument(
549
+ "--model", type=str, default=None,
550
+ help="Model id. Defaults to claude-sonnet-4-6 for --provider anthropic; "
551
+ "required for every other provider.",
552
+ )
287
553
  parser.add_argument("--json", action="store_true", help="Output as JSON")
288
554
  parser.add_argument(
289
555
  "--max-tests",
@@ -317,6 +583,7 @@ def main() -> int:
317
583
  master,
318
584
  dry_run=args.dry_run,
319
585
  model=args.model,
586
+ provider=args.provider,
320
587
  max_tests=args.max_tests,
321
588
  quiet=args.json,
322
589
  )
@@ -329,6 +596,10 @@ def main() -> int:
329
596
  if args.json:
330
597
  print(json.dumps(all_results, indent=2, ensure_ascii=False))
331
598
  elif len(masters) > 1:
599
+ conflicts = aggregation_conflicts(all_results)
600
+ for conflict in conflicts:
601
+ print(f"\n::warning:: {conflict}")
602
+
332
603
  print(f"\n{'='*50}")
333
604
  print("Overall Summary:")
334
605
  for r in all_results: