master-skill 0.11.0 → 0.12.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 (69) 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/README.md +48 -55
  5. package/README_EN.md +72 -59
  6. package/bin/cli.mjs +12 -7
  7. package/gemini-extension.json +1 -1
  8. package/hooks/session-start +68 -77
  9. package/hooks/session_start.py +152 -0
  10. package/package.json +5 -2
  11. package/prebuilt/compare-masters/SKILL.md +21 -2
  12. package/prebuilt/master-ajahn-chah/meta.json +6 -0
  13. package/prebuilt/master-ajahn-chah/tests/fidelity.jsonl +6 -6
  14. package/prebuilt/master-atisha/tests/fidelity.jsonl +4 -4
  15. package/prebuilt/master-curriculum/references/tiantai.md +1 -1
  16. package/prebuilt/master-debate/SKILL.md +14 -2
  17. package/prebuilt/master-fazang/tests/fidelity.jsonl +2 -2
  18. package/prebuilt/master-help/SKILL.md +9 -1
  19. package/prebuilt/master-huineng/tests/fidelity.jsonl +4 -4
  20. package/prebuilt/master-kumarajiva/tests/fidelity.jsonl +3 -3
  21. package/prebuilt/master-mahasi-sayadaw/tests/fidelity.jsonl +4 -4
  22. package/prebuilt/master-milarepa/tests/fidelity.jsonl +3 -3
  23. package/prebuilt/master-nagarjuna/tests/fidelity.jsonl +6 -6
  24. package/prebuilt/master-ouyi/meta.json +5 -0
  25. package/prebuilt/master-ouyi/references/teaching.md +3 -3
  26. package/prebuilt/master-ouyi/tests/fidelity.jsonl +3 -3
  27. package/prebuilt/master-tsongkhapa/meta.json +6 -0
  28. package/prebuilt/master-tsongkhapa/tests/fidelity.jsonl +2 -2
  29. package/prebuilt/master-xuanzang/tests/fidelity.jsonl +3 -3
  30. package/prebuilt/master-xuyun/tests/fidelity.jsonl +6 -6
  31. package/prebuilt/master-zhiyi/meta.json +2 -2
  32. package/prebuilt/master-zhiyi/tests/fidelity.jsonl +2 -2
  33. package/scripts/check-audit-ignores.py +105 -0
  34. package/scripts/check-eval-sdk-surface.py +142 -0
  35. package/scripts/check-gate-liveness.py +205 -6
  36. package/scripts/reaudit-report.py +163 -0
  37. package/scripts/regrade-report.py +157 -0
  38. package/scripts/smoke-eval-sdk.py +174 -0
  39. package/scripts/test-fidelity.py +684 -52
  40. package/scripts/validate-citation-references.py +150 -0
  41. package/scripts/validate-citation-templates.py +176 -0
  42. package/scripts/validate-fixture-terms.py +127 -0
  43. package/scripts/verify-adjudication.py +316 -0
  44. package/scripts/verify_citations.py +739 -39
  45. package/tools/cross_reference.py +44 -10
  46. package/tools/fojin-known-absent.json +14 -0
  47. package/tools/fojin_bridge.py +138 -8
  48. package/tools/rag_query.py +45 -2
  49. package/tools/skill_writer.py +50 -7
  50. package/tools/verify_sources.py +240 -15
  51. package/hooks/tests/test_run_hook.sh +0 -114
  52. package/hooks/tests/test_run_hook_cmd.sh +0 -94
  53. package/hooks/tests/test_session_start.sh +0 -149
  54. package/scripts/tests/test_check_gate_liveness.py +0 -232
  55. package/scripts/tests/test_check_manifest_versions.py +0 -217
  56. package/scripts/tests/test_check_response.py +0 -190
  57. package/scripts/tests/test_debate_protocol.py +0 -159
  58. package/scripts/tests/test_fidelity_providers.py +0 -202
  59. package/scripts/tests/test_injection_hardening.py +0 -174
  60. package/scripts/tests/test_select_fidelity_smoke.py +0 -142
  61. package/scripts/tests/test_validate.py +0 -145
  62. package/scripts/tests/test_validate_citation_contract.py +0 -408
  63. package/scripts/tests/test_validate_cross_critique.py +0 -149
  64. package/scripts/tests/test_validate_curriculum_sources.py +0 -144
  65. package/scripts/tests/test_validate_fidelity.py +0 -59
  66. package/scripts/tests/test_validate_lore_triggers_content.py +0 -372
  67. package/scripts/tests/test_validate_persona_fidelity.py +0 -317
  68. package/scripts/tests/test_validate_promptfoo_configs.py +0 -386
  69. package/scripts/tests/test_validate_workflow.py +0 -284
@@ -0,0 +1,316 @@
1
+ #!/usr/bin/env python3
2
+ """Gate: an adjudication must prove it read the answers it ruled on.
3
+
4
+ The first full-coverage fidelity run (`eval/reports/0.11.0-06b8142-deepseek.json`)
5
+ failed 62 of 199 graded cases, and 46 of those failed on `must_mention` alone —
6
+ a bare substring match against 2-to-5-character Chinese terms. Reading the
7
+ stored answers showed most of those failures describe spelling, not behaviour:
8
+ `master-fazang` wrote 「五教之判」 for a fixture demanding 判教, `master-nagarjuna`
9
+ wrote 「空非虚无」 for one demanding 不是虚无, `master-ouyi` answered entirely in
10
+ traditional characters against simplified fixtures, and `master-mahasi-sayadaw`
11
+ wrote 「升、降」 where the fixture wanted 升降.
12
+
13
+ Those rulings are judgements, and a judgement file is exactly the artifact this
14
+ repo keeps catching in the act of reporting green without examining anything.
15
+ So every verdict carries a quote from the answer it judges, and this script
16
+ proves each quote is still there, that each term really failed in the run, and
17
+ that the headline numbers follow from the verdicts rather than being typed in.
18
+
19
+ An adjudication that cannot be re-derived from the run it judges is worth
20
+ nothing, and fails here.
21
+ """
22
+ from __future__ import annotations
23
+
24
+ import json
25
+ import os
26
+ import sys
27
+ from pathlib import Path
28
+
29
+ ROOT = Path(__file__).resolve().parent.parent
30
+ REPORTS = ROOT / "eval" / "reports"
31
+
32
+ MENTION_VERDICTS = {"instrument", "fixture", "upheld"}
33
+ FORBIDDEN_VERDICTS = {"false_failure", "upheld"}
34
+ # `open_question` records a finding without deciding it — the pressure fixtures
35
+ # name one text while the citation contract asks only for a declared source, and
36
+ # which of those `pressure` means is a maintainer's call, not the adjudicator's.
37
+ # It never overturns anything.
38
+ CITE_VERDICTS = {"instrument", "upheld", "open_question"}
39
+ REVIEW_VERDICTS = {"cleared", "cleared_manual", "violation"}
40
+ FAIL_KEYS = (
41
+ "missing_cites",
42
+ "missing_mentions",
43
+ "forbidden_found",
44
+ "boundary_violations",
45
+ "fabricated_cites",
46
+ )
47
+ # recount()'s `overturns` set only ever gains "missing_mentions" / "forbidden_found"
48
+ # / "missing_cites" — there is no case-verdict category for `boundary_violations`
49
+ # or `fabricated_cites`, so a case failing solely on one of those can never be
50
+ # overturned by this gate. That is deliberate, not a gap: `fabricated_cites` is
51
+ # resolved through validate-citation-references.py's KNOWN_UNDECLARED ratchet
52
+ # (declare the source in meta.json, then re-grade — see master-tsongkhapa #2/#3/#9,
53
+ # permanently listed in `failures_not_ruled_on` below until that happens) rather
54
+ # than through a per-term verdict here. `boundary_violations` (first-turn honorific
55
+ # checks) has no live case to adjudicate yet; if one ever needs a verdict, it needs
56
+ # its own case-verdict field and evidence rule, the same way mention/forbidden/cite
57
+ # each got one — not a silent addition to this permitting list.
58
+
59
+
60
+ def _derive_case_verdict(verdicts: list[dict], permitting: set[str]) -> str | None:
61
+ """Recompute what a case-level `*_case_verdict` should be from its own
62
+ per-term verdicts — the same rule `build_verdicts.py` used to write it.
63
+ None if there were no verdicts to summarize (the field should be absent).
64
+ """
65
+ if not verdicts:
66
+ return None
67
+ rulings = {v["verdict"] for v in verdicts}
68
+ return "overturned" if rulings and rulings <= permitting else "upheld"
69
+
70
+
71
+ def _index_report(report: dict) -> dict:
72
+ return {
73
+ (suite["master"], result["index"]): result
74
+ for suite in report["suites"]
75
+ for result in suite["results"]
76
+ }
77
+
78
+
79
+ def recount(adjudication: dict, report: dict) -> dict:
80
+ """Recompute the per-test-type tally from the verdicts alone.
81
+
82
+ A case turns from FAIL to PASS only when *every* check it failed was
83
+ overturned; a `violation` review turns a PASS into a FAIL. Adjudication
84
+ that can only move a number upward is advocacy, not judgement.
85
+ """
86
+ overturns: dict[tuple[str, int], set[str]] = {}
87
+ violations: set[tuple[str, int]] = set()
88
+ for case in adjudication["cases"]:
89
+ key = (case["master"], case["index"])
90
+ keys = set()
91
+ if case.get("mention_case_verdict") == "overturned":
92
+ keys.add("missing_mentions")
93
+ if case.get("forbidden_case_verdict") == "overturned":
94
+ keys.add("forbidden_found")
95
+ if case.get("cite_case_verdict") == "overturned":
96
+ keys.add("missing_cites")
97
+ overturns[key] = keys
98
+ if case.get("review_verdict") == "violation":
99
+ violations.add(key)
100
+
101
+ tally: dict[str, dict[str, int]] = {}
102
+ for suite in report["suites"]:
103
+ for result in suite["results"]:
104
+ if result.get("status") == "truncated":
105
+ continue
106
+ key = (suite["master"], result["index"])
107
+ bucket = tally.setdefault(
108
+ result["test_type"], {"graded": 0, "passed": 0, "adjudicated": 0}
109
+ )
110
+ bucket["graded"] += 1
111
+ passed = result["status"] == "PASS"
112
+ if passed:
113
+ bucket["passed"] += 1
114
+ failed = {k for k in FAIL_KEYS if result.get(k)}
115
+ if not passed and failed and failed <= overturns.get(key, set()):
116
+ passed = True
117
+ if passed and key in violations:
118
+ passed = False
119
+ if passed:
120
+ bucket["adjudicated"] += 1
121
+ return tally
122
+
123
+
124
+ def verify(adjudication: dict, report: dict) -> list[str]:
125
+ """Return every reason this adjudication cannot be trusted. Empty is good."""
126
+ problems: list[str] = []
127
+ cases = adjudication.get("cases") or []
128
+ if not cases:
129
+ problems.append("no cases: an adjudication that ruled on nothing is not a result")
130
+ return problems
131
+
132
+ results = _index_report(report)
133
+
134
+ for case in cases:
135
+ key = (case["master"], case["index"])
136
+ where = f"{case['master']} #{case['index']}"
137
+ result = results.get(key)
138
+ if result is None:
139
+ problems.append(f"{where}: no such case in the run being adjudicated")
140
+ continue
141
+ response = result.get("response") or ""
142
+
143
+ # A case-level `*_case_verdict` is a summary of its own per-term
144
+ # verdicts, not an independent claim. recount() trusts it wholesale to
145
+ # decide whether a FAIL becomes a PASS, so it has to be re-derivable
146
+ # from the terms it summarizes — otherwise editing one field, with no
147
+ # evidence and no per-term change, silently overturns a real failure.
148
+ for field, verdicts_key, permitting in (
149
+ ("mention_case_verdict", "mention_verdicts", {"instrument", "fixture"}),
150
+ ("forbidden_case_verdict", "forbidden_verdicts", {"false_failure"}),
151
+ ("cite_case_verdict", "cite_verdicts", {"instrument"}),
152
+ ):
153
+ stored = case.get(field)
154
+ expected = _derive_case_verdict(case.get(verdicts_key) or [], permitting)
155
+ if stored != expected:
156
+ problems.append(
157
+ f"{where}: {field} is {stored!r} but its {verdicts_key} imply "
158
+ f"{expected!r} — a case verdict must follow from its own terms"
159
+ )
160
+
161
+ for verdict in case.get("mention_verdicts", []):
162
+ term, ruling = verdict["term"], verdict["verdict"]
163
+ if ruling not in MENTION_VERDICTS:
164
+ problems.append(f"{where}: unknown mention verdict {ruling!r}")
165
+ if term not in (result.get("missing_mentions") or []):
166
+ problems.append(
167
+ f"{where}: ruled on {term!r}, which is not a missing_mention in the run"
168
+ )
169
+ evidence = verdict.get("evidence") or ""
170
+ if ruling == "instrument" and not evidence:
171
+ problems.append(
172
+ f"{where}: {term!r} ruled an instrument artifact with no evidence quote"
173
+ )
174
+ if evidence and evidence not in response:
175
+ problems.append(
176
+ f"{where}: {term!r} evidence not present in the stored answer: {evidence!r}"
177
+ )
178
+
179
+ for verdict in case.get("cite_verdicts", []):
180
+ citation, ruling = verdict["citation"], verdict["verdict"]
181
+ if ruling not in CITE_VERDICTS:
182
+ problems.append(f"{where}: unknown citation verdict {ruling!r}")
183
+ if citation not in (result.get("missing_cites") or []):
184
+ problems.append(
185
+ f"{where}: ruled on citation {citation!r}, which the run did not miss"
186
+ )
187
+ evidence = verdict.get("evidence") or ""
188
+ if not evidence:
189
+ problems.append(f"{where}: citation {citation!r} ruled with no evidence quote")
190
+ elif evidence not in response:
191
+ problems.append(
192
+ f"{where}: citation {citation!r} evidence not present in the stored answer"
193
+ )
194
+
195
+ for verdict in case.get("forbidden_verdicts", []):
196
+ term, ruling = verdict["term"], verdict["verdict"]
197
+ if ruling not in FORBIDDEN_VERDICTS:
198
+ problems.append(f"{where}: unknown forbidden verdict {ruling!r}")
199
+ if term not in (result.get("forbidden_found") or []):
200
+ problems.append(
201
+ f"{where}: ruled on forbidden {term!r}, which the run did not flag"
202
+ )
203
+ evidence = verdict.get("evidence") or ""
204
+ if not evidence:
205
+ problems.append(f"{where}: forbidden {term!r} ruled with no evidence quote")
206
+ elif evidence not in response:
207
+ problems.append(
208
+ f"{where}: forbidden {term!r} evidence not present in the stored answer"
209
+ )
210
+
211
+ ruling = case.get("review_verdict")
212
+ if ruling is not None:
213
+ if ruling not in REVIEW_VERDICTS:
214
+ problems.append(f"{where}: unknown review verdict {ruling!r}")
215
+ if not result.get("needs_review"):
216
+ problems.append(
217
+ f"{where}: adjudicated a review the run never raised (needs_review is false)"
218
+ )
219
+ evidence = case.get("review_evidence") or ""
220
+ if not evidence:
221
+ problems.append(f"{where}: review ruled with no evidence quote")
222
+ elif evidence not in response:
223
+ problems.append(
224
+ f"{where}: review evidence not present in the stored answer: {evidence!r}"
225
+ )
226
+
227
+ ruled = {(c["master"], c["index"]) for c in adjudication["cases"]}
228
+ unruled = []
229
+ for suite in report["suites"]:
230
+ for result in suite["results"]:
231
+ if result.get("status") == "truncated" or result["status"] == "PASS":
232
+ continue
233
+ if (suite["master"], result["index"]) not in ruled:
234
+ unruled.append(f"{suite['master']} #{result['index']}")
235
+ claimed_unruled = adjudication["summary"].get("failures_not_ruled_on")
236
+ if claimed_unruled is None:
237
+ problems.append(
238
+ "summary omits failures_not_ruled_on: an adjudication has to say how "
239
+ "much of the run it examined"
240
+ )
241
+ elif sorted(claimed_unruled) != sorted(unruled):
242
+ problems.append(
243
+ f"summary misstates its own coverage: {len(unruled)} unruled failures "
244
+ f"in the run, {len(claimed_unruled)} declared"
245
+ )
246
+
247
+ recomputed = recount(adjudication, report)
248
+ claimed = adjudication["summary"].get("by_test_type", {})
249
+ if recomputed != claimed:
250
+ problems.append(
251
+ "summary does not follow from the verdicts: "
252
+ f"recomputed {recomputed} vs recorded {claimed}"
253
+ )
254
+ return problems
255
+
256
+
257
+ def main() -> int:
258
+ found = sorted(REPORTS.glob("adjudication-*.json"))
259
+ if not found:
260
+ # An adjudication that has been deleted verifies perfectly. This gate
261
+ # exists to keep a hand-ruling honest, so "there are none" has to be a
262
+ # deliberate state rather than the quiet result of removing the file
263
+ # that was inconvenient. Set ADJUDICATION_NONE_EXPECTED=1 to declare a
264
+ # tree that genuinely has not adjudicated anything yet.
265
+ if os.environ.get("ADJUDICATION_NONE_EXPECTED") == "1":
266
+ print("No adjudication files under eval/reports/ — declared expected.")
267
+ return 0
268
+ graded = sorted(
269
+ path for path in REPORTS.glob("*.json")
270
+ if not path.name.startswith("adjudication-")
271
+ )
272
+ if graded:
273
+ print(
274
+ "FAIL: eval/reports/ holds graded runs "
275
+ f"({', '.join(p.name for p in graded)}) but no adjudication of any "
276
+ "of them. Verifying nothing is not the same as verifying clean; "
277
+ "set ADJUDICATION_NONE_EXPECTED=1 if that is really the state."
278
+ )
279
+ return 1
280
+ print("No adjudication files under eval/reports/ — nothing to verify.")
281
+ return 0
282
+
283
+ failed = False
284
+ for path in found:
285
+ adjudication = json.loads(path.read_text())
286
+ report = ROOT / adjudication["summary"]["report"]
287
+ if not report.exists():
288
+ print(f"FAIL: {path.name} judges {report}, which is not in the repo")
289
+ failed = True
290
+ continue
291
+ problems = verify(adjudication, json.loads(report.read_text()))
292
+ cases = len(adjudication["cases"])
293
+
294
+ # The summary states how many cases were ruled on; nothing was checking
295
+ # it against how many are actually here. Dropping the awkward ones left
296
+ # the file self-inconsistent and the gate still printing OK — with a
297
+ # smaller number, in the same sentence that says everything is backed.
298
+ claimed = adjudication.get("summary", {}).get("adjudicated_cases")
299
+ if isinstance(claimed, int) and claimed != cases:
300
+ problems.append(
301
+ f"summary.adjudicated_cases says {claimed} but the file carries "
302
+ f"{cases} — cases were added or removed without the summary "
303
+ "following"
304
+ )
305
+ if problems:
306
+ failed = True
307
+ print(f"FAIL: {path.name} ({cases} cases)")
308
+ for problem in problems:
309
+ print(f" {problem}")
310
+ else:
311
+ print(f"OK: {path.name} — {cases} cases, every verdict backed by the answer text")
312
+ return 1 if failed else 0
313
+
314
+
315
+ if __name__ == "__main__":
316
+ sys.exit(main())