skill-lens 0.2.0__tar.gz

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 (159) hide show
  1. skill_lens-0.2.0/.claude/skills/writing-skill-evals/SKILL.md +99 -0
  2. skill_lens-0.2.0/.claude/skills/writing-skill-evals/evals/writing-skill-evals.eval.yaml +93 -0
  3. skill_lens-0.2.0/.claude/skills/writing-skill-evals/references/auditing.md +38 -0
  4. skill_lens-0.2.0/.claude/skills/writing-skill-evals/references/case-design.md +51 -0
  5. skill_lens-0.2.0/.claude/skills/writing-skill-evals/references/eval-file-syntax.md +100 -0
  6. skill_lens-0.2.0/.github/copilot-instructions.md +60 -0
  7. skill_lens-0.2.0/.github/instructions/cli-gating.instructions.md +27 -0
  8. skill_lens-0.2.0/.github/instructions/docs.instructions.md +27 -0
  9. skill_lens-0.2.0/.github/instructions/evaluators.instructions.md +34 -0
  10. skill_lens-0.2.0/.github/instructions/models-loaders.instructions.md +25 -0
  11. skill_lens-0.2.0/.github/instructions/runners.instructions.md +46 -0
  12. skill_lens-0.2.0/.github/instructions/tests.instructions.md +23 -0
  13. skill_lens-0.2.0/.github/pull_request_template.md +14 -0
  14. skill_lens-0.2.0/.github/workflows/ci.yml +180 -0
  15. skill_lens-0.2.0/.github/workflows/docs.yml +55 -0
  16. skill_lens-0.2.0/.github/workflows/refresh-cassettes.yml +109 -0
  17. skill_lens-0.2.0/.github/workflows/release.yml +200 -0
  18. skill_lens-0.2.0/.gitignore +19 -0
  19. skill_lens-0.2.0/.pre-commit-config.yaml +8 -0
  20. skill_lens-0.2.0/.python-version +1 -0
  21. skill_lens-0.2.0/ARCHITECTURE.md +441 -0
  22. skill_lens-0.2.0/CHANGELOG.md +16 -0
  23. skill_lens-0.2.0/CLAUDE.md +280 -0
  24. skill_lens-0.2.0/LICENSE +21 -0
  25. skill_lens-0.2.0/PKG-INFO +317 -0
  26. skill_lens-0.2.0/README.md +269 -0
  27. skill_lens-0.2.0/action.yml +219 -0
  28. skill_lens-0.2.0/docs/architecture.md +1 -0
  29. skill_lens-0.2.0/docs/ci.md +145 -0
  30. skill_lens-0.2.0/docs/cli.md +109 -0
  31. skill_lens-0.2.0/docs/comparative-evals.md +267 -0
  32. skill_lens-0.2.0/docs/configuration.md +86 -0
  33. skill_lens-0.2.0/docs/contributing.md +93 -0
  34. skill_lens-0.2.0/docs/eval-files.md +161 -0
  35. skill_lens-0.2.0/docs/gating.md +126 -0
  36. skill_lens-0.2.0/docs/getting-started.md +81 -0
  37. skill_lens-0.2.0/docs/index.md +55 -0
  38. skill_lens-0.2.0/docs/releasing.md +173 -0
  39. skill_lens-0.2.0/docs/roadmap.md +55 -0
  40. skill_lens-0.2.0/docs/runners.md +109 -0
  41. skill_lens-0.2.0/docs/superpowers/plans/2026-07-30-skill-eval-m0-m1.md +2359 -0
  42. skill_lens-0.2.0/docs/superpowers/plans/2026-08-01-skill-eval-m2.md +2668 -0
  43. skill_lens-0.2.0/docs/superpowers/plans/2026-08-02-docs-infrastructure.md +1940 -0
  44. skill_lens-0.2.0/docs/superpowers/plans/2026-08-03-skill-eval-m3.md +2828 -0
  45. skill_lens-0.2.0/docs/superpowers/plans/2026-08-03-skill-eval-m4.md +3213 -0
  46. skill_lens-0.2.0/docs/superpowers/plans/2026-08-03-writing-skill-evals.md +1411 -0
  47. skill_lens-0.2.0/docs/superpowers/plans/2026-08-05-skill-eval-m5.md +2709 -0
  48. skill_lens-0.2.0/docs/superpowers/plans/2026-09-09-skill-eval-m5-part2.md +1086 -0
  49. skill_lens-0.2.0/docs/superpowers/specs/2026-07-30-skill-eval-design.md +203 -0
  50. skill_lens-0.2.0/docs/superpowers/specs/2026-08-01-skill-eval-m2-design.md +250 -0
  51. skill_lens-0.2.0/docs/superpowers/specs/2026-08-02-docs-infrastructure-design.md +210 -0
  52. skill_lens-0.2.0/docs/superpowers/specs/2026-08-03-skill-eval-m3-design.md +345 -0
  53. skill_lens-0.2.0/docs/superpowers/specs/2026-08-03-skill-eval-m4-design.md +402 -0
  54. skill_lens-0.2.0/docs/superpowers/specs/2026-08-03-writing-skill-evals-design.md +181 -0
  55. skill_lens-0.2.0/docs/superpowers/specs/2026-08-05-skill-eval-m5-design.md +479 -0
  56. skill_lens-0.2.0/docs/superpowers/specs/2026-09-09-skill-eval-m5-part2-design.md +293 -0
  57. skill_lens-0.2.0/docs/writing-evals.md +37 -0
  58. skill_lens-0.2.0/examples/ci/skill-lens-cli.yml +44 -0
  59. skill_lens-0.2.0/examples/ci/skill-lens.yml +98 -0
  60. skill_lens-0.2.0/examples/greeting/SKILL.md +7 -0
  61. skill_lens-0.2.0/examples/greeting/greeting.eval.yaml +23 -0
  62. skill_lens-0.2.0/examples/order-support/SKILL.md +15 -0
  63. skill_lens-0.2.0/examples/order-support/order-support.eval.yaml +97 -0
  64. skill_lens-0.2.0/mkdocs.yml +63 -0
  65. skill_lens-0.2.0/pyproject.toml +128 -0
  66. skill_lens-0.2.0/scripts/check_commits.py +96 -0
  67. skill_lens-0.2.0/scripts/check_docs_updated.py +81 -0
  68. skill_lens-0.2.0/scripts/legacy-commits.txt +6 -0
  69. skill_lens-0.2.0/src/skill_lens/__init__.py +10 -0
  70. skill_lens-0.2.0/src/skill_lens/cases/__init__.py +0 -0
  71. skill_lens-0.2.0/src/skill_lens/cases/loader.py +187 -0
  72. skill_lens-0.2.0/src/skill_lens/cli.py +323 -0
  73. skill_lens-0.2.0/src/skill_lens/comparison.py +242 -0
  74. skill_lens-0.2.0/src/skill_lens/config.py +112 -0
  75. skill_lens-0.2.0/src/skill_lens/evaluators/__init__.py +0 -0
  76. skill_lens-0.2.0/src/skill_lens/evaluators/assertion.py +79 -0
  77. skill_lens-0.2.0/src/skill_lens/evaluators/base.py +18 -0
  78. skill_lens-0.2.0/src/skill_lens/evaluators/budget.py +106 -0
  79. skill_lens-0.2.0/src/skill_lens/evaluators/judge.py +112 -0
  80. skill_lens-0.2.0/src/skill_lens/evaluators/trajectory.py +121 -0
  81. skill_lens-0.2.0/src/skill_lens/gating.py +110 -0
  82. skill_lens-0.2.0/src/skill_lens/judges/__init__.py +0 -0
  83. skill_lens-0.2.0/src/skill_lens/judges/base.py +26 -0
  84. skill_lens-0.2.0/src/skill_lens/judges/fake.py +38 -0
  85. skill_lens-0.2.0/src/skill_lens/judges/prompt.py +81 -0
  86. skill_lens-0.2.0/src/skill_lens/judges/pydantic_ai.py +95 -0
  87. skill_lens-0.2.0/src/skill_lens/models.py +385 -0
  88. skill_lens-0.2.0/src/skill_lens/orchestrator.py +377 -0
  89. skill_lens-0.2.0/src/skill_lens/reporters/__init__.py +0 -0
  90. skill_lens-0.2.0/src/skill_lens/reporters/console.py +196 -0
  91. skill_lens-0.2.0/src/skill_lens/reporters/json_reporter.py +67 -0
  92. skill_lens-0.2.0/src/skill_lens/reporters/junit.py +214 -0
  93. skill_lens-0.2.0/src/skill_lens/reporters/markdown.py +376 -0
  94. skill_lens-0.2.0/src/skill_lens/runners/__init__.py +0 -0
  95. skill_lens-0.2.0/src/skill_lens/runners/base.py +25 -0
  96. skill_lens-0.2.0/src/skill_lens/runners/fake.py +36 -0
  97. skill_lens-0.2.0/src/skill_lens/runners/preflight.py +38 -0
  98. skill_lens-0.2.0/src/skill_lens/runners/pricing.py +33 -0
  99. skill_lens-0.2.0/src/skill_lens/runners/pydantic_ai.py +225 -0
  100. skill_lens-0.2.0/src/skill_lens/runners/tools.py +116 -0
  101. skill_lens-0.2.0/src/skill_lens/scaffold.py +99 -0
  102. skill_lens-0.2.0/src/skill_lens/skills/__init__.py +0 -0
  103. skill_lens-0.2.0/src/skill_lens/skills/baseline.py +108 -0
  104. skill_lens-0.2.0/src/skill_lens/skills/loader.py +107 -0
  105. skill_lens-0.2.0/src/skill_lens/yaml_loading.py +49 -0
  106. skill_lens-0.2.0/tests/cassettes/test_cassettes/test_a_baseline_run_reaches_the_provider_without_the_skill_name.yaml +111 -0
  107. skill_lens-0.2.0/tests/cassettes/test_cassettes/test_a_real_agent_leaves_an_offered_skill_alone_on_an_unrelated_task.yaml +120 -0
  108. skill_lens-0.2.0/tests/cassettes/test_cassettes/test_a_real_agent_reaches_for_an_offered_skill.yaml +367 -0
  109. skill_lens-0.2.0/tests/cassettes/test_cassettes/test_a_real_judge_drives_the_evaluator_end_to_end.yaml +428 -0
  110. skill_lens-0.2.0/tests/cassettes/test_cassettes/test_a_real_judge_grades_a_rubric_with_evidence.yaml +184 -0
  111. skill_lens-0.2.0/tests/cassettes/test_cassettes/test_real_traffic_drives_the_whole_loop.yaml +258 -0
  112. skill_lens-0.2.0/tests/conftest.py +159 -0
  113. skill_lens-0.2.0/tests/fixtures/ci-smoke/SKILL.md +6 -0
  114. skill_lens-0.2.0/tests/fixtures/ci-smoke/evals/ci-smoke.eval.yaml +9 -0
  115. skill_lens-0.2.0/tests/fixtures/ci-smoke-fail/SKILL.md +6 -0
  116. skill_lens-0.2.0/tests/fixtures/ci-smoke-fail/evals/ci-smoke-fail.eval.yaml +10 -0
  117. skill_lens-0.2.0/tests/test_action.py +84 -0
  118. skill_lens-0.2.0/tests/test_arms.py +148 -0
  119. skill_lens-0.2.0/tests/test_assertion_evaluator.py +178 -0
  120. skill_lens-0.2.0/tests/test_baseline_resolution.py +159 -0
  121. skill_lens-0.2.0/tests/test_budget_evaluator.py +155 -0
  122. skill_lens-0.2.0/tests/test_case_loader.py +477 -0
  123. skill_lens-0.2.0/tests/test_cassette_workflow.py +211 -0
  124. skill_lens-0.2.0/tests/test_cassettes.py +223 -0
  125. skill_lens-0.2.0/tests/test_check_docs_updated.py +72 -0
  126. skill_lens-0.2.0/tests/test_cli.py +626 -0
  127. skill_lens-0.2.0/tests/test_cli_init.py +122 -0
  128. skill_lens-0.2.0/tests/test_comparison.py +206 -0
  129. skill_lens-0.2.0/tests/test_config.py +205 -0
  130. skill_lens-0.2.0/tests/test_conftest_replay.py +95 -0
  131. skill_lens-0.2.0/tests/test_docs.py +169 -0
  132. skill_lens-0.2.0/tests/test_examples.py +27 -0
  133. skill_lens-0.2.0/tests/test_fake_judge.py +47 -0
  134. skill_lens-0.2.0/tests/test_fake_runner.py +93 -0
  135. skill_lens-0.2.0/tests/test_framework_isolation.py +37 -0
  136. skill_lens-0.2.0/tests/test_gating.py +201 -0
  137. skill_lens-0.2.0/tests/test_integration_live.py +50 -0
  138. skill_lens-0.2.0/tests/test_judge_evaluator.py +138 -0
  139. skill_lens-0.2.0/tests/test_judge_prompt.py +145 -0
  140. skill_lens-0.2.0/tests/test_junit_reporter.py +246 -0
  141. skill_lens-0.2.0/tests/test_markdown_reporter.py +429 -0
  142. skill_lens-0.2.0/tests/test_models.py +312 -0
  143. skill_lens-0.2.0/tests/test_naming.py +68 -0
  144. skill_lens-0.2.0/tests/test_orchestrator.py +578 -0
  145. skill_lens-0.2.0/tests/test_preflight.py +45 -0
  146. skill_lens-0.2.0/tests/test_pricing.py +40 -0
  147. skill_lens-0.2.0/tests/test_pydantic_ai_judge.py +194 -0
  148. skill_lens-0.2.0/tests/test_pydantic_ai_runner.py +499 -0
  149. skill_lens-0.2.0/tests/test_release_config.py +182 -0
  150. skill_lens-0.2.0/tests/test_release_workflow.py +191 -0
  151. skill_lens-0.2.0/tests/test_reporters.py +470 -0
  152. skill_lens-0.2.0/tests/test_scaffold.py +93 -0
  153. skill_lens-0.2.0/tests/test_shipped_skill.py +119 -0
  154. skill_lens-0.2.0/tests/test_skill_loader.py +210 -0
  155. skill_lens-0.2.0/tests/test_tools.py +133 -0
  156. skill_lens-0.2.0/tests/test_trajectory_evaluator.py +198 -0
  157. skill_lens-0.2.0/tests/test_version.py +6 -0
  158. skill_lens-0.2.0/tests/test_yaml_loading.py +17 -0
  159. skill_lens-0.2.0/uv.lock +1606 -0
@@ -0,0 +1,99 @@
1
+ ---
2
+ name: writing-skill-evals
3
+ description: Use when writing, running, or auditing skill-lens eval suites for an Agent Skill — deciding which cases a skill needs, choosing between assertions, judge rubrics and trajectory checks, and reading a failing case correctly
4
+ ---
5
+
6
+ # Writing skill evals
7
+
8
+ An eval suite is worth exactly what its cases check. The two failure modes are a suite
9
+ that is green because it checks nothing, and a red case that gets the skill edited when
10
+ the eval was wrong. Everything here exists to prevent one of those.
11
+
12
+ ## Workflow
13
+
14
+ 1. **Orient.** Read the target `SKILL.md`. Look for existing evals in `evals/` or
15
+ `*.eval.yaml` beside it, and run `skill-lens list <path>` to see what the tool already
16
+ discovers.
17
+ 2. **Scaffold, or extend.** If step 1 found no suite, run `skill-lens init <skill-dir>` —
18
+ do not hand-roll the file structure, the generated file already carries the triggering
19
+ pair and the placeholders that stop an unfinished suite from running. If a suite
20
+ already exists, do not run `init`: it exits 2 rather than touch an existing file. Read
21
+ `references/auditing.md` and extend the suite you found instead. (`--force` overwrites
22
+ the file outright, which is not what you want when a suite is already there to build
23
+ on.)
24
+ 3. **Mine the skill for claims.** Every "always", "never" and "must" in the instructions
25
+ is a candidate case. The frontmatter `description` is a claim too, and it is precisely
26
+ what the `mode: offered` cases test.
27
+ 4. **Propose the case list and confirm it.** Show the user the cases you intend to write,
28
+ one line each, before writing any YAML. Ask only for what `SKILL.md` cannot tell you:
29
+ which tools exist and what they return, which policy edges are real, what a good answer
30
+ sounds like. Do not ask for what the file already says.
31
+ 5. **Write the cases.** Replace every `TODO(skill-lens)`. Read
32
+ `references/eval-file-syntax.md` for the fields, and `references/case-design.md` for
33
+ patterns to draw from.
34
+ 6. **Validate, then run.** `skill-lens list <path>` is the structural check — it parses
35
+ every case at zero cost and catches a malformed file before anything else runs; it is
36
+ what this repo's own CI self-check uses. `skill-lens run <path>` is a different thing:
37
+ it needs a configured runner, and under the defaults (`FakeRunner`, `FakeJudge`) a
38
+ `mode: offered` case and any case with a `judge:` block come back `errored`, not
39
+ `failed` — the fake runner cannot report a triggering decision and the fake judge does
40
+ not grade. Neither is a statement about the skill. Configure a real runner (and a judge,
41
+ if any case needs one) before reading those results as anything.
42
+ 7. **Triage every red case** by the rule below before editing anything.
43
+
44
+ ## Choosing the check
45
+
46
+ | Use | When |
47
+ | --- | --- |
48
+ | `assertions` | The check is mechanical and stable: an id appears, a traceback does not. |
49
+ | `judge` | The claim is about quality, tone or reasoning. "Explains it plainly" is not a substring. |
50
+ | `trajectory` | The failure is invisible in the output — deciding without looking the order up, calling the tool that was forbidden. |
51
+ | `budget` | Guarding against a regression into a tool-call loop or a runaway answer. |
52
+
53
+ ## Triage: the eval, the skill, or the harness?
54
+
55
+ A red case means one of three different things, and naming which comes before any edit.
56
+ Start with whether the case is `failed` or `errored` — that alone rules one branch out or
57
+ in before you look at anything else.
58
+
59
+ **The eval is wrong** when the case `failed` and the output was actually fine: a regex
60
+ tight enough to feel rigorous but that rejects phrasing a model may legitimately vary, an
61
+ assertion on wording the skill never promised, a budget below what the task honestly
62
+ costs. `examples/greeting/greeting.eval.yaml` in the skill-lens repo documents a real
63
+ instance — a single-sentence regex relaxed after real model output failed it for no good
64
+ reason.
65
+
66
+ **The skill is wrong** when the case `failed` and the output genuinely was not what the
67
+ skill claims: the instruction is missing, or is present but too weak to survive a
68
+ plausible prompt.
69
+
70
+ **The harness is wrong** when the case `errored`. An errored case never ran to a real
71
+ verdict — the runner or the judge broke, or was never configured for what the case asks
72
+ of it. A `mode: offered` case under the default fake runner, or a `judge:` case under the
73
+ default fake judge, errors this way by design: the fake runner cannot report a triggering
74
+ decision, and the fake judge does not grade. An errored case says nothing about the
75
+ skill — fix the configuration (a real runner, a real judge), and change neither the eval
76
+ nor the SKILL.md.
77
+
78
+ Fixing the eval is yours to do. **Changing the target `SKILL.md` is proposed and
79
+ confirmed, never silent** — the user is the author of their skill, and an eval that gets
80
+ its own subject rewritten to match it has stopped measuring anything.
81
+
82
+ ## Rules that are not negotiable
83
+
84
+ - **Ship the negative control.** A suite of triggering positives scores a skill that fires
85
+ on everything at 100%. `mode: offered` cases come in pairs.
86
+ - **Never leave a case with no check.** At least one of `assertions`, `trajectory` or
87
+ `judge` — a case with none of the three passes without checking anything. `mode:
88
+ offered` cases legitimately carry `trajectory.skill_triggered` and no `assertions`;
89
+ that still counts as a check.
90
+ - **Every rubric entry must be independently checkable and evidenced.** skill-lens records
91
+ a check that passes without citing evidence as a failure, so a vague entry costs a case
92
+ rather than buying coverage.
93
+ - **Never leave a `TODO(skill-lens)` behind.** The run will refuse it, which is the point,
94
+ but a suite that cannot run is not a suite.
95
+
96
+ ## Auditing an existing suite
97
+
98
+ Read `references/auditing.md` and work its checklist. Report findings; do not rewrite the
99
+ user's suite unasked.
@@ -0,0 +1,93 @@
1
+ # Evals for the skill that writes evals. Four cases, three concerns:
2
+ # - triggering: does it fire on an eval-writing request, and stay quiet
3
+ # otherwise (the negative control this skill insists on for everyone else).
4
+ # - authoring: does it read the skill before writing the file, and is what it
5
+ # writes a suite rather than a shape -- judged, because "includes a negative
6
+ # control" is a property of the YAML, not a substring of the reply.
7
+ # - auditing: handed a deliberately weak suite, does it name what is missing.
8
+ cases:
9
+ - name: reaches for the skill on an eval-writing request
10
+ mode: offered
11
+ task: Write evals for my order-support skill in ./skills/order-support
12
+ tags: [triggering]
13
+ trajectory:
14
+ skill_triggered: true
15
+
16
+ - name: leaves unrelated work alone
17
+ mode: offered
18
+ task: Rename the `parse` function in utils.py to `parse_input`
19
+ tags: [triggering]
20
+ trajectory:
21
+ skill_triggered: false
22
+
23
+ - name: reads the skill before writing the suite
24
+ task: >-
25
+ Write an eval suite for the skill at ./skills/order-support. Read the
26
+ skill first, then write the file.
27
+ tags: [authoring, judged]
28
+ tools:
29
+ - name: read_file
30
+ description: Read a file from disk
31
+ parameters:
32
+ path: string
33
+ returns: |
34
+ ---
35
+ name: order-support
36
+ description: Handle customer refund requests against the 30-day return policy
37
+ ---
38
+ Always call lookup_order before saying anything about an order's state.
39
+ Never issue a refund for an order delivered more than 30 days ago.
40
+ - name: write_file
41
+ description: Write text to a file on disk
42
+ parameters:
43
+ path: string
44
+ content: string
45
+ returns: '{"ok": true}'
46
+ - name: run_command
47
+ description: Run a shell command and return its output
48
+ parameters:
49
+ command: string
50
+ returns: 'order-support 0 case(s) skills/order-support'
51
+ trajectory:
52
+ order: [read_file, write_file]
53
+ max_calls: 8
54
+ judge:
55
+ expected: >-
56
+ An eval suite covering both sides of the 30-day policy, with a
57
+ mode: offered pair including the negative control, and no placeholders
58
+ left in it.
59
+ rubric:
60
+ - The suite it wrote includes a case where a refund is refused
61
+ - The suite it wrote includes a case where a refund is allowed
62
+ # Quoted: a plain YAML scalar cannot contain ": ".
63
+ - "The suite it wrote includes a mode: offered case expecting skill_triggered true"
64
+ - "The suite it wrote includes a mode: offered case expecting skill_triggered false"
65
+ - No case in the suite it wrote still contains the text TODO
66
+ - Every non-triggering case it wrote has at least one assertion, judge or trajectory check
67
+
68
+ - name: names what a weak suite is missing
69
+ task: Review the eval file at ./skills/greeting/evals/greeting.eval.yaml and tell me what is wrong with it
70
+ tags: [auditing, judged]
71
+ tools:
72
+ - name: read_file
73
+ description: Read a file from disk
74
+ parameters:
75
+ path: string
76
+ returns: |
77
+ cases:
78
+ - name: greets someone
79
+ task: greet Ada
80
+ - name: fires on a greeting request
81
+ mode: offered
82
+ task: say hello to Ada
83
+ trajectory:
84
+ skill_triggered: true
85
+ trajectory:
86
+ called: [read_file]
87
+ judge:
88
+ expected: >-
89
+ A review naming the assertion-free first case and the missing negative
90
+ control for the triggering case.
91
+ rubric:
92
+ - The review says the first case checks nothing because it has no assertions
93
+ - The review says the triggering case has no negative control
@@ -0,0 +1,38 @@
1
+ # Auditing an existing suite
2
+
3
+ Work the checklist, then report findings with the file and case named. Do not rewrite the
4
+ user's suite unasked.
5
+
6
+ ## Checklist
7
+
8
+ - [ ] **Vacuous cases.** Any `loaded` case with no `assertions`, no `trajectory`, no
9
+ `judge`? It passes without checking anything.
10
+ - [ ] **Missing negative control.** Any `mode: offered` positive with no negative
11
+ counterpart? The suite cannot distinguish a well-targeted skill from one that fires
12
+ on everything.
13
+ - [ ] **Claims with no case.** List the skill's "always/never/must" statements and find
14
+ the case for each. Name the ones with none.
15
+ - [ ] **One-sided edges.** A policy case that only proves the refusal, never the approval.
16
+ - [ ] **Over-tight assertions.** `equals` or a `regex` pinning phrasing the skill never
17
+ promised; an assertion that would fail on a legitimately different good answer.
18
+ - [ ] **Fixture assertions.** An assertion whose value comes from a mock tool's `returns`
19
+ rather than from the skill's behavior.
20
+ - [ ] **Unevidenceable rubric entries.** Anything you could not prove by quoting the
21
+ output ("is helpful", "is well structured"), or compound entries hiding which half
22
+ failed.
23
+ - [ ] **Budgets that never bind, or bind too tightly.** A ceiling far above any plausible
24
+ run checks nothing; one at the current spend turns every prompt change red.
25
+ - [ ] **A cost limit as the only budget check** on a model with no pricing entry — the
26
+ check is skipped, so the case fails for having verified nothing.
27
+ - [ ] **Leftover placeholders.** `TODO(skill-lens)` anywhere.
28
+ - [ ] **Tags.** Is there a `smoke` subset a fast CI job could run?
29
+ - [ ] **Cases that error under the configured runner or judge.** An `errored` case never
30
+ ran to a verdict; it says nothing about the skill. Check whether the harness is
31
+ simply unconfigured (default fake runner, default fake judge) or the case declares
32
+ something this runner cannot report (`mode: offered` under a runner with no
33
+ triggering support, for instance).
34
+
35
+ ## Reporting
36
+
37
+ For each finding: the file and case, what is wrong, and the smallest change that fixes it.
38
+ Rank by what would let a broken skill through, not by what is easiest to fix.
@@ -0,0 +1,51 @@
1
+ # Designing the cases
2
+
3
+ ## Deriving cases from the skill
4
+
5
+ Read the instructions and list every claim. Claims look like: "always call X first",
6
+ "never do Y after 30 days", "answer in one short sentence", "ask before writing files".
7
+ Each claim gets at least one case; the interesting ones get two, one either side of the
8
+ line.
9
+
10
+ The frontmatter `description` is the claim that triggering tests: it is the only text an
11
+ agent sees when deciding whether to reach for the skill.
12
+
13
+ ## A minimum suite
14
+
15
+ 1. **The common case** — the prompt this skill exists for, with an assertion on something
16
+ every good answer contains.
17
+ 2. **The edge** — the prompt that reaches the policy line, with `trajectory` if getting
18
+ there requires a tool call.
19
+ 3. **The other side of the edge** — the near-identical prompt where the answer flips. A
20
+ skill that refuses everything passes case 2 alone.
21
+ 4. **The triggering pair** — `mode: offered`, positive and negative.
22
+
23
+ ## Patterns by skill archetype
24
+
25
+ | Archetype | What to check |
26
+ | --- | --- |
27
+ | Policy skill (refunds, approvals) | `trajectory` proving it looked before deciding; `forbidden` on the destructive tool; both sides of the policy line |
28
+ | Tool-using skill | `order` for a required sequence; `max_calls` against loops; a case where the tool returns an error string |
29
+ | Formatting skill | `regex` — but only on structure the skill actually promised; a judge for "reads plainly" |
30
+ | Knowledge skill | `contains` on the fact; `not_contains` on the plausible wrong answer; a judge for reasoning |
31
+
32
+ ## Writing rubric entries
33
+
34
+ Each entry is checked independently and must be evidenced by quoting the output. That
35
+ makes the test for a good entry mechanical: **could you point at the sentence that proves
36
+ it?**
37
+
38
+ - Good: "The reply names order 1234." "The reply states the return window has closed."
39
+ - Bad: "The reply is helpful." "The response is well structured." Nothing can be quoted
40
+ as proof, and an unsupported pass is an LLM judge's characteristic failure mode.
41
+
42
+ Split compound entries. "Names the order and explains the policy" hides which half failed.
43
+
44
+ ## Assertions that age badly
45
+
46
+ - A regex pinning phrasing the skill never promised. Check the structure the skill
47
+ committed to, nothing more.
48
+ - `equals` on anything a model generates freely.
49
+ - Asserting on a number the mock tool returns — that tests the fixture, not the skill.
50
+ - A budget set at the current spend. Leave headroom, or every prompt improvement is a
51
+ red case.
@@ -0,0 +1,100 @@
1
+ # Eval file syntax
2
+
3
+ A file has one top-level `cases:` list. Unknown keys inside a case or an assertion are
4
+ rejected — without that, a typo like `assertion:` would yield a case that passes
5
+ vacuously.
6
+
7
+ ## Case fields
8
+
9
+ | Field | Required | Meaning |
10
+ | --- | --- | --- |
11
+ | `name` | yes | Case name, shown in reports |
12
+ | `task` | yes | The prompt handed to the runner |
13
+ | `assertions` | no | Output checks; a case with none passes vacuously |
14
+ | `tools` | no | Mock tools the agent may call |
15
+ | `trajectory` | no | Which tools must and must not have been called, and in what order |
16
+ | `budget` | no | Ceilings on tokens, cost, and latency |
17
+ | `judge` | no | A rubric for an LLM judge |
18
+ | `mode` | no | `loaded` (default) or `offered` |
19
+ | `tags` | no | Labels for `--tag` filtering |
20
+
21
+ ## Assertion kinds
22
+
23
+ | `kind` | Passes when |
24
+ | --- | --- |
25
+ | `contains` | `value` appears in the output |
26
+ | `not_contains` | `value` does not appear in the output |
27
+ | `regex` | `value` matches anywhere in the output (`re.search`) |
28
+ | `equals` | the stripped output equals `value` exactly |
29
+
30
+ Every assertion must hold for the case to pass. An unknown kind or a malformed regex
31
+ aborts the run as an authoring error rather than being reported as a skill failure.
32
+
33
+ ## Tools
34
+
35
+ Nothing executes. Calling a mock tool records the call and returns `returns` verbatim, so
36
+ the trajectory is genuinely the model's choice and a run has no side effects. Mock tools
37
+ accept any arguments — a hallucinated argument must not surface as an infra error.
38
+
39
+ ```yaml
40
+ tools:
41
+ - name: lookup_order # must be a valid identifier
42
+ description: Look up an order by its id
43
+ parameters:
44
+ order_id: string # string | integer | number | boolean
45
+ returns: '{"id": "1234", "status": "delivered"}'
46
+ ```
47
+
48
+ ## Trajectory
49
+
50
+ ```yaml
51
+ trajectory:
52
+ called: [lookup_order] # must have been called
53
+ forbidden: [issue_refund] # must not have been
54
+ order: [lookup_order, issue_refund] # relative order, not exhaustive
55
+ max_calls: 3
56
+ skill_triggered: true # mode: offered only
57
+ ```
58
+
59
+ Every name in `called`, `forbidden` and `order` must be a tool the case itself declares.
60
+
61
+ ## Budget
62
+
63
+ ```yaml
64
+ budget:
65
+ max_tokens: 2000
66
+ max_cost_usd: 0.01
67
+ max_latency_ms: 20000
68
+ ```
69
+
70
+ An unpriced model makes a cost limit unverifiable, so that check is skipped rather than
71
+ counted as passed — a cost limit as the only budget check then fails the case, because
72
+ nothing was verified.
73
+
74
+ ## Judge
75
+
76
+ ```yaml
77
+ judge:
78
+ expected: A short, plain-language refusal that names the order id.
79
+ rubric:
80
+ - The reply names order 1234
81
+ - The reply explains that the return window has closed
82
+ ```
83
+
84
+ One verdict per rubric entry, each with its evidence; skill-lens derives pass and score
85
+ from those. A check that passes without evidence is recorded as a failure. An empty
86
+ rubric, or a blank entry, is an authoring error. Judging costs money and is opted into
87
+ with `judge = "pydantic-ai"` in `skill-lens.toml`; the default `judge = "fake"` reports a
88
+ judged case as **errored** rather than passing a rubric nobody checked.
89
+
90
+ ## Triggering (`mode: offered`)
91
+
92
+ The skill is not force-loaded; it is registered as a tool named after the skill
93
+ (`order-support` becomes `order_support`) and described by its frontmatter description.
94
+ Calling it delivers the instructions. Check it with `skill_triggered`, not by naming the
95
+ tool in `called:`. Setting `skill_triggered` on a `loaded` case is an authoring error.
96
+
97
+ ## Placeholders
98
+
99
+ `skill-lens init` writes `TODO(skill-lens)` into every field you must supply. A case still
100
+ containing one aborts the run with exit 2, naming the field.
@@ -0,0 +1,60 @@
1
+ # Copilot instructions for skill-lens
2
+
3
+ `skill-lens` is a standalone CLI and library that runs evaluations on Agent Skills
4
+ (`SKILL.md` files). Skills under test and their eval cases are **inputs** — nothing about a
5
+ skill under test is vendored here. The tool runs as a CI gate where the exit code is the
6
+ contract. Full design: [`ARCHITECTURE.md`](../ARCHITECTURE.md).
7
+
8
+ ## The two seams
9
+
10
+ - `Runner.run(skill, case) -> RunResult` — where every agent framework plugs in.
11
+ - `Evaluator.evaluate(case, result) -> EvalScore` — where every scoring strategy plugs in.
12
+
13
+ `models.py` holds every Pydantic model; no other module defines a data shape.
14
+
15
+ ## Invariants — flag any change that breaks one
16
+
17
+ These are decided behaviors with tests asserting them, not accidents.
18
+
19
+ 1. **`errored` is not `failed`.** `failed` = the case ran and scored below the bar (an eval
20
+ signal). `errored` = the runner itself blew up (an infra signal). Runners must **never
21
+ raise** for provider failures — they set `RunResult.error`. Errored cases fail the gate.
22
+ 2. **A run executing zero cases fails the gate.** "Nothing ran" is a broken run.
23
+ 3. **Authoring errors abort the run; they never score as failures.** An unknown assertion
24
+ kind, a bad regex, or an unknown YAML key is a mistake in the user's files and says
25
+ nothing about the skill. They propagate out of the orchestrator and `cli.py` exits 2.
26
+ 4. **Exit codes are the CI contract:** gate passed `0`, gate failed `1`, user or authoring
27
+ error `2`. A JSON-write failure escalates to 2 only when the gate already passed.
28
+ 5. **`extra="forbid"`** on every user-authored model. Without it a typo like `assertion:`
29
+ yields a case that passes vacuously.
30
+ 6. **All file IO pins `encoding="utf-8"`** and re-raises as a typed parse error naming the
31
+ file and field.
32
+ 7. **YAML goes through `skill_lens.yaml_loading.safe_load`**, never `yaml.safe_load`.
33
+ 8. **Secrets come from environment variables only** — never from `skill-lens.toml`.
34
+ 9. **No agent-framework type appears outside `runners/pydantic_ai.py`.**
35
+ 10. **Cost lookup degrades, never raises.** An unpriced model yields `cost_usd = 0.0` and a
36
+ `cost_note`. An unpriceable budget check is *skipped*, not passed.
37
+ 11. **`skill_lens` (underscore) never appears in user-facing output.** The user-facing name
38
+ is `skill-lens` everywhere.
39
+
40
+ ## Do not suggest
41
+
42
+ - Importing an agent framework anywhere outside `runners/pydantic_ai.py`.
43
+ - Replacing `yaml_loading.safe_load` with `yaml.safe_load`.
44
+ - Raising from a runner when a provider call fails.
45
+ - Removing `extra="forbid"` to make a model more permissive.
46
+ - Making a mock tool reject unexpected arguments — a hallucinated argument is an eval
47
+ signal, and raising would surface it as an infra error.
48
+ - Treating a check that could not be performed as passed.
49
+
50
+ ## Conventions
51
+
52
+ - **Test-driven.** The failing test comes first. The default test tier must stay zero-cost,
53
+ offline and deterministic — `pytest` runs with `--block-network`.
54
+ - **Conventional Commits are enforced, not stylistic.** `cz bump` derives the version and
55
+ changelog from history. PRs are squash-merged, so **the PR title becomes the commit on
56
+ `main`** and must be conventional too. Flag a non-conforming PR title in review.
57
+ - **Documentation ships with the change.** A change to a flag, config key, `EvalCase` field
58
+ or assertion kind must update the matching page under `docs/`; a change to a protocol or
59
+ an invariant must update `ARCHITECTURE.md`.
60
+ - Line length 100, `ruff` for lint and format.
@@ -0,0 +1,27 @@
1
+ ---
2
+ applyTo: "src/skill_lens/cli.py,src/skill_lens/gating.py,src/skill_lens/orchestrator.py,src/skill_lens/reporters/**"
3
+ ---
4
+
5
+ # Reviewing the CLI, gate, orchestrator and reporters
6
+
7
+ This is the contract surface. CI depends on the exit code; humans depend on the output.
8
+
9
+ - **Exit codes are the contract:** gate passed `0`, gate failed `1`, user or authoring error
10
+ `2`. A JSON-write failure escalates to 2 **only when the gate itself passed** — it must
11
+ never mask an already-failing gate. Flag any change that widens or reorders these.
12
+ - **A run executing zero cases fails the gate.** "Nothing ran" is a broken run, not a pass —
13
+ otherwise a mistyped path reports success forever. The reason must name the cause: no
14
+ skills found, all skills skipped for having no cases, or every case filtered out by
15
+ `--tag`.
16
+ - **Errored cases fail the gate by default**, so CI never goes green on a run that did not
17
+ happen.
18
+ - **Authoring errors abort the run.** `orchestrator.run_evals` deliberately lets them
19
+ propagate; `cli.py` catches them via `_AUTHORING_ERRORS` and prints the message without a
20
+ traceback. Flag any `try`/`except` in the orchestrator that would swallow one into a
21
+ failing score.
22
+ - **`skill_lens` (underscore) never appears in user-facing output.** The name is
23
+ `skill-lens`: command, config file, distribution, prose.
24
+ - A new runner must be registered in `cli._RUNNERS`, and if it spends money it needs the
25
+ preflight key check before construction so a missing key costs nothing and exits 2.
26
+ - Gate reasons are read by whoever is staring at a red pipeline. Each should say what failed
27
+ and against which threshold.
@@ -0,0 +1,27 @@
1
+ ---
2
+ applyTo: "docs/**,*.md,mkdocs.yml"
3
+ ---
4
+
5
+ # Reviewing documentation
6
+
7
+ - **`README.md` is a landing page.** Reference prose lives in `docs/`. Flag any PR that
8
+ reintroduces command, config or eval-file reference material into the README — it will
9
+ drift.
10
+ - **`ARCHITECTURE.md` has exactly one copy**, at the repository root.
11
+ `docs/architecture.md` includes it with a `pymdownx.snippets` directive. Flag any
12
+ duplication of its content.
13
+ - **`ARCHITECTURE.md` must use absolute links only (full URLs), never relative links.**
14
+ `tests/test_docs.py` resolves its relative links from the repo root, but mkdocs resolves
15
+ them from `docs/` because the file is inlined into `docs/architecture.md` by a snippet —
16
+ a relative link can pass the test and still fail `mkdocs build --strict`.
17
+ - **A new page must be added to `nav:` in `mkdocs.yml`.** `tests/test_docs.py` fails on an
18
+ orphan page, and `mkdocs build --strict` fails on a nav entry with no file.
19
+ - **`docs/superpowers/` is a historical archive** of specs and plans. It is excluded from
20
+ the built site and does not count as documenting a change. Its contents were superseded by
21
+ what shipped — read `src/` as the source of truth.
22
+ - Documentation ships **with** the change: a new flag updates `docs/cli.md`, a new config
23
+ key updates `docs/configuration.md`, a new assertion kind updates `docs/eval-files.md`, a
24
+ new invariant updates `ARCHITECTURE.md`.
25
+ - Do not add `pymdownx.emoji` or mermaid `custom_fences` to `mkdocs.yml`: both need
26
+ `!!python/name:` YAML tags, which break the plain-YAML nav parsing in `tests/test_docs.py`.
27
+ - Relative links must resolve — there is a test for it.
@@ -0,0 +1,34 @@
1
+ ---
2
+ applyTo: "src/skill_lens/evaluators/**"
3
+ ---
4
+
5
+ # Reviewing evaluator code
6
+
7
+ An evaluator turns a `RunResult` into a pass/fail verdict with a score and human-readable
8
+ detail. Its *score* is about the skill; a low score is never used to report a harness
9
+ problem, and a harness problem is never reported as a low score.
10
+
11
+ - **A check that could not be performed is not a pass.** `BudgetEvaluator` *skips* an
12
+ unpriceable cost limit rather than passing it, so a case whose only budget check is an
13
+ unpriced cost limit fails — nothing was verified. `JudgeEvaluator` follows the same rule:
14
+ an unjudged rubric, and a check that passes without citing evidence, are not passes. Flag
15
+ any "if we can't check it, assume it's fine" branch.
16
+ - **`errored` is for infra, and it must set `passed=False`.** An evaluator sets
17
+ `EvalScore.errored` when the harness broke — a judge endpoint returning 500, a verdict
18
+ that does not match its rubric, an offered case on a runner that cannot report triggering
19
+ — never when the skill scored badly. The orchestrator turns any errored score into an
20
+ errored case. A model validator makes `errored=True, passed=True` unconstructable.
21
+ - **No agent framework in this directory.** `JudgeEvaluator` reaches a model only through
22
+ the injected `Judge` protocol. Flag any direct client or framework import.
23
+ - **Eval-side spend goes on `EvalScore.cost_usd`, never on `RunResult`.** `budget:` measures
24
+ the skill's efficiency; what we spent grading it is the harness's cost and is reported
25
+ separately.
26
+ - **Authoring errors propagate; they never become a failing score.** An unknown assertion
27
+ kind, a malformed regex, or a tool name in a `trajectory` block that the case never
28
+ declared is a mistake in the user's files. Scoring it as a failure would be a lie about
29
+ the skill. These raise and `cli.py` exits 2.
30
+ - **New assertion kinds go in `_CHECKS`** in `assertion.py` and must be documented in
31
+ `docs/eval-files.md`. `tests/test_docs.py` fails until both happen.
32
+ - `detail` is read by a human staring at a red CI run. It should name what failed and with
33
+ what value, not just that something did.
34
+ - Every assertion in a case must hold for the case to pass. A case with no assertions passes.
@@ -0,0 +1,25 @@
1
+ ---
2
+ applyTo: "src/skill_lens/models.py,src/skill_lens/config.py,src/skill_lens/yaml_loading.py,src/skill_lens/cases/**,src/skill_lens/skills/**"
3
+ ---
4
+
5
+ # Reviewing models and loaders
6
+
7
+ This is where user-authored files become typed objects. Every mistake here is silent.
8
+
9
+ - **`extra="forbid"` on every user-authored model** — `EvalCase`, `AssertionSpec`,
10
+ `ToolSpec`, `TrajectorySpec`, `BudgetSpec`, `Config`. Without it a typo like `assertion:`
11
+ yields a case that passes vacuously, the worst failure mode an eval tool has. Flag any
12
+ removal or any new user-authored model that omits it.
13
+ - **`models.py` holds every data shape.** Other modules import from it; they do not define
14
+ their own.
15
+ - **All file IO pins `encoding="utf-8"`** and re-raises `OSError`/`UnicodeDecodeError` as a
16
+ typed parse error (`SkillParseError`, `CaseParseError`, `ConfigError`) naming the file and
17
+ the field. A raw traceback reaching the user is a bug.
18
+ - **YAML goes through `yaml_loading.safe_load`.** PyYAML's `SafeLoader` is YAML 1.1 and
19
+ turns bare `yes`/`no`/`on`/`off` into booleans; an assertion `value: yes` is meant as the
20
+ string.
21
+ - **Secrets never come from `skill-lens.toml`.** A config file is committed; a key must not
22
+ be. Flag any new config field that would hold a credential.
23
+ - **Derived values are properties, not stored fields** — `RunResult.tokens`, `errored`, and
24
+ the `RunReport` aggregates. A stored copy can disagree with its source.
25
+ - Skills with no eval files are reported as **skipped**, visibly — never silently dropped.
@@ -0,0 +1,46 @@
1
+ ---
2
+ applyTo: "src/skill_lens/runners/**,src/skill_lens/judges/**"
3
+ ---
4
+
5
+ # Reviewing runner and judge code
6
+
7
+ These two directories are the framework boundary. Everything the core sees is a plain
8
+ `RunResult` or `JudgeVerdict`. The two share a contract, so the rules below apply to both
9
+ unless one is named.
10
+
11
+ - **Never raise for a provider failure.** Timeouts, rate limits, auth errors and malformed
12
+ responses become `RunResult(error=...)` or `JudgeVerdict(error=...)`. Raising turns an
13
+ infra problem into an unhandled crash and loses the errored/failed distinction the gate
14
+ depends on.
15
+ - **The whole capture path belongs inside the guard.** Reading messages, summing usage,
16
+ pricing and constructing the result must all sit inside the `try`, not just the model
17
+ call — a serialisation failure on a real provider's message shape must report errored,
18
+ not crash the run. This was a real bug once.
19
+ - **Only `runners/pydantic_ai.py` and `judges/pydantic_ai.py` may import an agent
20
+ framework.** `runners/tools.py` builds framework-neutral `MockTool`s — a name, a JSON
21
+ schema and a callable — and the adapters wrap them.
22
+ `tests/test_framework_isolation.py` enforces the boundary across the whole package.
23
+ - **A judge reports per-check verdicts only** — never an overall verdict or a blended
24
+ score. skill-lens derives `passed` and `score` from the per-check results, because an
25
+ unsupported PASS hidden inside a single number is invisible. `JudgeOutput` is deliberately
26
+ narrower than `JudgeVerdict` so the model cannot assert its own cost or outcome.
27
+ - **`FakeJudge` unscripted returns an error, not a pass.** That is what makes
28
+ `judge = "fake"` safe as the built-in default: a rubric with no configured judge is
29
+ errored, never a quiet green.
30
+ - **`judges/prompt.py` is pure and deterministic.** Same input, byte-identical output, every
31
+ process — cassettes match on the request body, so any instability makes them unmatchable.
32
+ No `hash()`, no randomness, no time.
33
+ - **Mock tools accept any arguments.** A model hallucinating an argument is an eval signal
34
+ about the skill; rejecting it would surface as an infra error instead.
35
+ - **Nothing executes in a mock tool.** It records the call and returns its canned value, so
36
+ the trajectory is genuinely the model's choice and a run has no side effects.
37
+ - **Pricing never fails a run.** An unpriced model yields `cost_usd = 0.0` plus a
38
+ `cost_note`. Flag any code path where a pricing lookup can raise.
39
+ - **`RunResult.tokens` is derived** from the input/output split. Flag any attempt to set it.
40
+ - **`FakeRunner.run` returns `model_copy(deep=True)`** so a caller cannot corrupt scripted
41
+ state.
42
+ - **Cassettes are replay-only and secret-free.** Recording is a deliberate, key-bearing act.
43
+ A missing cassette skips; a mismatched request fails rather than reaching the network.
44
+ Flag any credential or account-identifying header that could reach a recorded file.
45
+ - A runner that spends money sets `needs_api_key = True` so the preflight check runs before
46
+ any request.
@@ -0,0 +1,23 @@
1
+ ---
2
+ applyTo: "tests/**"
3
+ ---
4
+
5
+ # Reviewing tests
6
+
7
+ - **Test-driven:** the failing test comes first. A PR adding behavior with no test that
8
+ would have failed before it is incomplete.
9
+ - **The default tier is zero-cost, offline and deterministic.** `pytest` runs with
10
+ `--block-network` and deselects `integration`. Flag any default-tier test that could reach
11
+ the network, need a key, or depend on wall-clock time or ordering.
12
+ - **Marker tiers:** unmarked = offline pipeline (`FakeRunner`); `cassette` = replays
13
+ recorded provider traffic, free, selected by default; `integration` = real API spend,
14
+ opt-in only.
15
+ - **`conftest.py` chdirs every test into a fresh `tmp_path`** so config upward-discovery
16
+ cannot pick up an ambient `skill-lens.toml`. A test that reads repository files must
17
+ anchor on `Path(__file__).resolve().parents[1]`, never `Path.cwd()`. Flag any test relying
18
+ on the working directory.
19
+ - **Cassettes must be secret-free.** Both request and response headers are scrubbed, on two
20
+ different vcrpy hooks. Flag anything that could write a credential or an account
21
+ identifier to disk.
22
+ - A test asserting an error path should assert the message a user actually sees, not just
23
+ the exception type.
@@ -0,0 +1,14 @@
1
+ ## What and why
2
+
3
+ <!-- What changes, and what problem it solves. The diff shows what; explain why. -->
4
+
5
+ ## Checklist
6
+
7
+ - [ ] The PR title follows [Conventional Commits](https://www.conventionalcommits.org/) —
8
+ it becomes the commit on `main` when this is squash-merged.
9
+ - [ ] Documentation is updated in the same PR: `docs/` for user-facing behavior,
10
+ `ARCHITECTURE.md` for design or invariants, `mkdocs.yml` for a new page.
11
+ (If genuinely not needed, add the `no-docs-needed` label.)
12
+ - [ ] `uv run pytest` passes.
13
+ - [ ] `uv run ruff check .` and `uv run ruff format --check .` pass.
14
+ - [ ] `uv run mkdocs build --strict` passes if any documentation changed.