bantamkit 0.27.0__py3-none-any.whl

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 (119) hide show
  1. bantamkit/__init__.py +32 -0
  2. bantamkit/agent.py +458 -0
  3. bantamkit/assets/contracts/default.yaml +90 -0
  4. bantamkit/assets/evals/devteam/manifest.yaml +351 -0
  5. bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
  6. bantamkit/assets/evals/devteam/repo/README.md +12 -0
  7. bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
  8. bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
  9. bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
  10. bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
  11. bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
  12. bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
  13. bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
  14. bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
  15. bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
  16. bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
  17. bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
  18. bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
  19. bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
  20. bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
  21. bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
  22. bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
  23. bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
  24. bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
  25. bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
  26. bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
  27. bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
  28. bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
  29. bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
  30. bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
  31. bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
  32. bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
  33. bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
  34. bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
  35. bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
  36. bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
  37. bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
  38. bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
  39. bantamkit/assets/evals/fixtures/.gitkeep +0 -0
  40. bantamkit/assets/evals/fixtures/catalog.json +6 -0
  41. bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
  42. bantamkit/assets/evals/tasks/.gitkeep +0 -0
  43. bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
  44. bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
  45. bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
  46. bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
  47. bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
  48. bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
  49. bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
  50. bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
  51. bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
  52. bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
  53. bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
  54. bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
  55. bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
  56. bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
  57. bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
  58. bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
  59. bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
  60. bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
  61. bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
  62. bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
  63. bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
  64. bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
  65. bantamkit/assets/profiles/default.yaml +31 -0
  66. bantamkit/assets/profiles/patient.yaml +31 -0
  67. bantamkit/assets/rubrics/.gitkeep +0 -0
  68. bantamkit/assets/rubrics/code-quality.yaml +20 -0
  69. bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
  70. bantamkit/assets/rubrics/task-completion.yaml +28 -0
  71. bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
  72. bantamkit/assets/skills/.gitkeep +0 -0
  73. bantamkit/assets/skills/file-graph.md +7 -0
  74. bantamkit/assets/skills/memory.md +35 -0
  75. bantamkit/assets/tools/.gitkeep +0 -0
  76. bantamkit/assets/tools/bantamkit_read.json +48 -0
  77. bantamkit/assets/tools/bantamkit_status.json +25 -0
  78. bantamkit/assets/tools/build_identity.json +17 -0
  79. bantamkit/assets/tools/document_list.json +12 -0
  80. bantamkit/assets/tools/document_read.json +31 -0
  81. bantamkit/assets/tools/file_graph.json +12 -0
  82. bantamkit/assets/tools/memory_compact.json +31 -0
  83. bantamkit/assets/tools/memory_recall.json +38 -0
  84. bantamkit/assets/tools/memory_save.json +61 -0
  85. bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
  86. bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
  87. bantamkit/assets/tools/shiftwork_status.json +25 -0
  88. bantamkit/assets/tools/skill_audit.json +70 -0
  89. bantamkit/assets/tools/validate_json.json +31 -0
  90. bantamkit/assets.py +67 -0
  91. bantamkit/budget.py +114 -0
  92. bantamkit/client.py +329 -0
  93. bantamkit/contract.py +522 -0
  94. bantamkit/criticreplay.py +3241 -0
  95. bantamkit/critique.py +301 -0
  96. bantamkit/docread.py +1744 -0
  97. bantamkit/evalrun.py +2003 -0
  98. bantamkit/eventlog.py +282 -0
  99. bantamkit/filegraph.py +218 -0
  100. bantamkit/loopguard.py +101 -0
  101. bantamkit/mcpreport.py +763 -0
  102. bantamkit/mcpserver.py +1334 -0
  103. bantamkit/memory/__init__.py +28 -0
  104. bantamkit/memory/__main__.py +291 -0
  105. bantamkit/memory/component.py +569 -0
  106. bantamkit/memory/divergence.py +744 -0
  107. bantamkit/memory/layers.py +257 -0
  108. bantamkit/memory/store.py +940 -0
  109. bantamkit/pdfread.py +1402 -0
  110. bantamkit/profile.py +46 -0
  111. bantamkit/shiftwork.py +212 -0
  112. bantamkit/skillaudit.py +853 -0
  113. bantamkit/statusline.py +313 -0
  114. bantamkit/structured.py +125 -0
  115. bantamkit/textutil.py +30 -0
  116. bantamkit-0.27.0.dist-info/METADATA +207 -0
  117. bantamkit-0.27.0.dist-info/RECORD +119 -0
  118. bantamkit-0.27.0.dist-info/WHEEL +4 -0
  119. bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
@@ -0,0 +1,853 @@
1
+ """Price the skill catalogue a session pays for, and name the collisions in it.
2
+
3
+ A skill's `description:` frontmatter is loaded into the agent's context in EVERY session;
4
+ its body is read only when the skill is invoked. So the descriptions are the standing bill,
5
+ and the two questions an operator can actually act on are "what does the bill come to" and
6
+ "which two skills are competing for the same request". This module answers both from nothing
7
+ but a directory of `SKILL.md` files and what the caller hands in.
8
+
9
+ **The collision metric is EXACT match on literal quoted phrases, and a similarity score was
10
+ measured and refuted.** Jaccard over description tokens was run across 820 real pairs
11
+ (2026-09-04): the maximum score any pair reached was 0.239, and the two collisions a person
12
+ would call real ranked 118th and 791st of 820 — a metric whose top of the ranking is noise
13
+ cannot be thresholded into a finding. Exact phrases, on the same 41-skill corpus, found four
14
+ shared phrases and every one of them was a genuine ambiguity. So there is no score here at
15
+ all, and adding one back is a regression however plausible it reads.
16
+
17
+ **The apostrophe is the whole difficulty.** `'race condition'` is a quoted phrase and `don't`
18
+ is a contraction, and a reader that treats every `'` as a delimiter reports the junk between
19
+ two contractions as a shared phrase — measured on the fixture tree, where the text between
20
+ `don'` and `'t catch` is byte-identical in two skills that share no phrase at all. So a `'`
21
+ delimits only when it is NOT flanked by letters on both sides. `"` has no such problem and is
22
+ always a delimiter.
23
+
24
+ **A whole-value quoted scalar is YAML's quoting, not the author's.** `description: "Use when
25
+ ..."` writes the WHOLE value as a quoted YAML scalar: the host's parser strips those two quotes
26
+ before the description ever reaches a session, so they are not bytes anyone pays for and the
27
+ text between them is not a trigger phrase. A reader that takes them literally answers two bytes
28
+ too many and turns the entire description into one giant phrase, which hides any real quoted
29
+ phrase inside it. Measured 2026-09-05 over this machine's own plugin cache: 17 of the 31 enabled
30
+ skills are written that way — the majority shape, not an edge case. So a value is unwrapped
31
+ before it is counted or scanned, and only quotes INSIDE the value delimit a phrase.
32
+
33
+ Unwrapping is deliberately narrow, because the eager version loses more than the literal one.
34
+ A value is a whole-value scalar only when it opens with a quote AND that quote's own closing
35
+ quote is the value's last character — `"a" and "b"` opens and ends with `"` and is NOT one
36
+ scalar, and stripping it would destroy both real phrases in it. Closing is judged by YAML's two
37
+ escape rules and no others: inside `"` a backslash escapes the next character, inside `'` a
38
+ doubled `''` is one apostrophe. A value that OPENS with a quote and never closes is a LITERAL,
39
+ not an unwrap and not a new finding — there is no end point to unwrap to, guessing one would
40
+ delete a byte the reader cannot prove is YAML's, and `frontmatter-malformed` names failures of
41
+ the BLOCK, not of one value. The unpaired quote then opens no phrase, which is already the rule.
42
+
43
+ **A router quotes its siblings by design.** A skill whose frontmatter carries `router: true`
44
+ routes a request to whichever sibling owns it, so it necessarily quotes their phrases; left in
45
+ the index it collides with every skill it routes to and the finding floods. It is dropped from
46
+ the phrase index ENTIRELY — it neither raises a finding nor joins one — and it is still
47
+ COUNTED, because its description is in every session's bill like any other. The mechanism is a
48
+ declaration and not a heuristic on purpose: a name pattern (`*-router`) or a phrase-count
49
+ threshold would exempt an ordinary skill by accident.
50
+
51
+ **Count over what the host has switched on, never over the cache directory.** The plugin cache
52
+ holds disabled plugins and stale version directories, and both look exactly like live skills on
53
+ disk. Measured 2026-09-04 on this machine: scanning the cache answered 41 skills / 19,065 B
54
+ against a true 34 / 14,515 — every headline number inflated, in the direction that makes the
55
+ tool look useful. `enabled` is therefore the caller's, read from the host's settings, and a
56
+ plugin absent from it is an omission carrying the bytes enabling it would cost.
57
+
58
+ **One version per plugin, resolved BEFORE anything is counted.** A plugin cache holds every
59
+ version directory a plugin has ever been installed at, and the host serves exactly one of them.
60
+ So the resolution is per (marketplace, plugin) and it happens FIRST: one version directory wins,
61
+ its skills are the plugin's skills, and every `SKILL.md` under any other version directory of
62
+ that plugin is omitted. Deduping by the (marketplace, plugin, name) TRIPLE instead — which is
63
+ what this module did until 2026-09-05 — merges versions rather than choosing between them: a
64
+ name present in both versions is displaced correctly, but a name the winning version DROPPED
65
+ has nothing to displace it and is counted anyway. Measured that day on this machine's own
66
+ cache, where `kkskills-essentials` holds `0.4.0` (14 skills) and `0.5.0` (5, because nine were
67
+ moved out to another plugin): the triple rule answered 31 skills / 9,280 bytes where the host
68
+ serves 22 / 3,796. A skill deleted in the newer release was resurrected by its own audit.
69
+
70
+ **The version that wins is the directory name that sorts LAST in BYTE ORDER, and that is still
71
+ not a semver comparison.** The order has to be TOTAL and computed identically by two runtimes,
72
+ and it has to work on names that are not versions at all: this machine's cache spells
73
+ `frontend-design`'s nine directories as content hashes (`0120fb83da5d` … `ed404106fcd8`) plus
74
+ the literal `unknown`, where semver has nothing to compare. Byte order is total over every one
75
+ of those, free, and already ported. What it gets wrong is stated rather than hidden: `10.0.0`
76
+ loses to `9.0.0`, and among names that are not versions the winner is arbitrary — deterministic
77
+ and arbitrary, not correct. Both losers are named in an omission record, so an operator can
78
+ always see which directory was read.
79
+
80
+ **…and byte order is the FALLBACK, because the caller can just say.** The host records the
81
+ directory it serves as `installPath` in `installed_plugins.json`, the same file `enabled` is
82
+ read out of. `versions` carries that in — `{"<plugin>@<marketplace>": "<version directory>"}`
83
+ — and where it names a plugin, the guess is not made. It is the same class of argument
84
+ `enabled` is and it exists for the same reason: the caller is the authority on its own host,
85
+ and this module still reads `root` and nothing else. Measured 2026-09-05: byte order picks
86
+ `unknown` for `frontend-design` on this machine where the host serves `1dd995193ba2`, so the
87
+ installed directory is thrown away as a duplicate — harmless only because all nine copies
88
+ happen to carry a byte-identical description, which is a fact about that plugin and not about
89
+ the rule.
90
+
91
+ **A version directory exists on disk, not in the skills that survived reading.** The
92
+ candidates are the directories spelled `<marketplace>/<plugin>/<version>/skills`, whatever is
93
+ under them. Choosing among the skills that came through `_load` and `_apply_enabled` instead
94
+ makes an EMPTY newer version invisible — it contributes no skill, so it is not a candidate,
95
+ and an older directory wins in silence with nothing in the document to say so. Measured
96
+ 2026-09-05, both runtimes did exactly that and both agreed, which is why the differential
97
+ could not see it.
98
+
99
+ **A version directory that did not win yields two kinds of omission, and they are two subjects.**
100
+ A file whose name IS in the resolved version is a `duplicate-skill`: the operator is looking at
101
+ the right copy and the record says which stale one was skipped. A file whose name is NOT in the
102
+ resolved version is a `stale-version`: it exists on disk, it is not in any session's bill, and
103
+ nothing else in the document would say so. Folding the second into the first would inflate the
104
+ duplicate count by every skill a release removed and make the resurrection defect invisible —
105
+ which is exactly how it survived. `size` differs in meaning between them too: a duplicate's
106
+ bytes are already paid by the copy that stands in for it, a `stale-version`'s are paid by
107
+ nobody.
108
+
109
+ **Deterministic, because two runtimes have to agree about it.** This module reads `root` and
110
+ nothing else: no `~/.claude`, no transcripts, no clock. Call counts arrive in `usage` from the
111
+ caller, the switched-on plugins in `enabled`, and the served version directories in
112
+ `versions` — three facts about the host that a directory of files cannot answer.
113
+
114
+ **Nothing is dropped in silence.** Counted skills plus omissions account for every `SKILL.md`
115
+ found. An omission is `{subject, count, size, what}` — the discipline `docread.Omission` set,
116
+ with only the fields these five subjects can fill: there is no column to name and no second
117
+ count to carry, and inventing empty slots for them would make the record harder to read, not
118
+ more uniform. `size` is the description bytes the omission cost the catalogue, `0` where that
119
+ is not knowable (a file that would not decode, a block that would not parse).
120
+ """
121
+
122
+ from __future__ import annotations
123
+
124
+ import json
125
+ import os
126
+ from dataclasses import dataclass, field
127
+ from pathlib import Path
128
+
129
+ from bantamkit.client import BantamError
130
+
131
+ # The file a skill IS. Case-sensitive: the host loads this name and so does this reader.
132
+ SKILL_FILE = "SKILL.md"
133
+
134
+ # The literal segment a plugin's skills live under. Part of the path shape below, spelled
135
+ # once so the shape check and the docstring cannot drift.
136
+ SKILLS_SEGMENT = "skills"
137
+
138
+ # `<marketplace>/<plugin>/<version>/skills/<name>/SKILL.md` — six segments relative to the
139
+ # root. Any other shape is a skill outside a plugin.
140
+ PLUGIN_PATH_SEGMENTS = 6
141
+
142
+ # `<marketplace>/<plugin>/<version>/skills` — the first four of those segments, and the
143
+ # DIRECTORY that declares a version directory exists. Version resolution is over these, not
144
+ # over the files under them: a version directory that holds no readable `SKILL.md` still
145
+ # exists, and the host still serves it. See `_resolve_versions`.
146
+ VERSION_PATH_SEGMENTS = 4
147
+
148
+ # The subjects an `Omission` can carry. Stable tokens, because a renderer switches on them
149
+ # and a caller filters on them.
150
+ OMIT_NOT_ENABLED = "plugin-not-enabled"
151
+ OMIT_DUPLICATE = "duplicate-skill"
152
+ OMIT_STALE_VERSION = "stale-version"
153
+ OMIT_UNREADABLE = "unreadable-file"
154
+ OMIT_UNPARSED = "unparsed-frontmatter"
155
+
156
+ #: Reporting order for omissions. Fixed, so two runtimes emit the same document. The two
157
+ #: version subjects are adjacent because they are the two halves of one rule.
158
+ OMISSION_ORDER = (
159
+ OMIT_NOT_ENABLED,
160
+ OMIT_DUPLICATE,
161
+ OMIT_STALE_VERSION,
162
+ OMIT_UNREADABLE,
163
+ OMIT_UNPARSED,
164
+ )
165
+
166
+ # The finding kinds. Severity is fixed per kind and is NOT an argument: a severity a caller
167
+ # can set is a severity that means something different in every report that carries it.
168
+ KIND_SHARED_PHRASE = "shared-trigger-phrase"
169
+ KIND_OVER_BUDGET = "catalogue-over-budget"
170
+ KIND_NEVER_INVOKED = "never-invoked"
171
+ KIND_FRONTMATTER = "frontmatter-malformed"
172
+ KIND_NAME_MISMATCH = "name-mismatch"
173
+
174
+ SEVERITY = {
175
+ KIND_SHARED_PHRASE: "high",
176
+ KIND_OVER_BUDGET: "high",
177
+ KIND_NEVER_INVOKED: "low",
178
+ KIND_FRONTMATTER: "medium",
179
+ KIND_NAME_MISMATCH: "medium",
180
+ }
181
+
182
+ #: Reporting order for findings, and the order the contract lists them in.
183
+ FINDING_ORDER = (
184
+ KIND_SHARED_PHRASE,
185
+ KIND_OVER_BUDGET,
186
+ KIND_NEVER_INVOKED,
187
+ KIND_FRONTMATTER,
188
+ KIND_NAME_MISMATCH,
189
+ )
190
+
191
+ #: `check` selects a FAMILY, not a kind: an operator asking about the bill wants both the
192
+ #: overage and the skills nobody ever called, and one asking about frontmatter wants both the
193
+ #: block that will not parse and the name that does not match its directory. Five kinds in
194
+ #: three families plus `all`, which is the default.
195
+ CHECK_FAMILIES = {
196
+ "all": frozenset(FINDING_ORDER),
197
+ "phrase": frozenset({KIND_SHARED_PHRASE}),
198
+ "budget": frozenset({KIND_OVER_BUDGET, KIND_NEVER_INVOKED}),
199
+ "frontmatter": frozenset({KIND_FRONTMATTER, KIND_NAME_MISMATCH}),
200
+ }
201
+
202
+ # The three ways frontmatter is malformed, as tokens rather than sentences. The first two
203
+ # also cost the skill its place in the count; the third does not.
204
+ BAD_NO_BLOCK = "no-frontmatter"
205
+ BAD_UNTERMINATED = "unterminated-frontmatter"
206
+ BAD_NO_DESCRIPTION = "no-description"
207
+
208
+ # The frontmatter delimiter, and the keys this reader consults. Every other key is parsed and
209
+ # ignored — parsing it is what makes `router: true` findable without a YAML dependency.
210
+ FRONTMATTER_FENCE = "---"
211
+ KEY_NAME = "name"
212
+ KEY_DESCRIPTION = "description"
213
+ KEY_ROUTER = "router"
214
+
215
+ # The values YAML spells `true` with. A `router:` carrying anything else is not a router.
216
+ TRUE_VALUES = frozenset({"true", "True", "TRUE", "yes", "Yes", "YES", "on", "On", "ON"})
217
+
218
+ # The two characters a YAML scalar can be wrapped in whole. Order is the order a value is
219
+ # tested in and cannot matter: a value opens with at most one of them.
220
+ SCALAR_QUOTES = ('"', "'")
221
+
222
+ # The escape character inside a DOUBLE-quoted scalar, and the only two sequences this reader
223
+ # resolves — `\"` for a quote and `\\` for a backslash. Every other `\x` is left as written,
224
+ # because inventing YAML's full escape table is a second thing two runtimes would have to
225
+ # agree about character for character, and no `SKILL.md` frontmatter uses one.
226
+ BACKSLASH = "\\"
227
+ DOUBLE_ESCAPES = frozenset({'"', BACKSLASH})
228
+
229
+
230
+ class SkillAuditError(BantamError):
231
+ """The audit could not be run at all: the message names what was seen at `root`.
232
+
233
+ Reserved for a failure of the SCAN. A file that will not decode, a block that will not
234
+ parse and a plugin that is switched off are all recorded as omissions and counted, never
235
+ raised: an audit that refuses because one of twenty-six files is malformed has told the
236
+ operator nothing about the other twenty-five.
237
+ """
238
+
239
+
240
+ @dataclass(frozen=True)
241
+ class Omission:
242
+ """A `SKILL.md` that was found and not counted, as a COUNT and the fact behind it.
243
+
244
+ - `subject` — one of the `OMIT_*` tokens above.
245
+ - `count` — how many files. Never an estimate.
246
+ - `size` — the UTF-8 description bytes those files would have added to the catalogue;
247
+ `0` when that is not knowable, which is exactly the two cases where the description
248
+ could not be read at all.
249
+ - `what` — the paths, relative to the root, in scan order. What a caller needs to act on
250
+ the omission rather than merely be told it happened.
251
+ """
252
+
253
+ subject: str
254
+ count: int
255
+ size: int = 0
256
+ what: str = ""
257
+
258
+ def as_dict(self) -> dict:
259
+ return {"subject": self.subject, "count": self.count, "size": self.size, "what": self.what}
260
+
261
+
262
+ @dataclass(frozen=True)
263
+ class Finding:
264
+ """One thing wrong with the catalogue.
265
+
266
+ `severity` is not stored independently — it is `SEVERITY[kind]`, fixed per kind. `detail`
267
+ is a machine fact and not a sentence: the phrase that collided, the arithmetic of the
268
+ overage, the token naming which of three frontmatter failures it was, the frontmatter
269
+ name that does not match. Wording for a person is Layer 2's job, not this module's.
270
+ """
271
+
272
+ kind: str
273
+ skills: tuple[str, ...]
274
+ detail: str
275
+
276
+ @property
277
+ def severity(self) -> str:
278
+ return SEVERITY[self.kind]
279
+
280
+ def as_dict(self) -> dict:
281
+ return {
282
+ "kind": self.kind,
283
+ "severity": self.severity,
284
+ "skills": list(self.skills),
285
+ "detail": self.detail,
286
+ }
287
+
288
+
289
+ @dataclass(frozen=True)
290
+ class Audit:
291
+ """The whole answer. `skills` plus every omission's `count` is every `SKILL.md` found."""
292
+
293
+ roots: tuple[str, ...]
294
+ skills: int
295
+ catalogue_bytes: int
296
+ findings: tuple[Finding, ...] = ()
297
+ omissions: tuple[Omission, ...] = ()
298
+
299
+ def as_dict(self) -> dict:
300
+ return {
301
+ "roots": list(self.roots),
302
+ "skills": self.skills,
303
+ "catalogue_bytes": self.catalogue_bytes,
304
+ "findings": [f.as_dict() for f in self.findings],
305
+ "omissions": [o.as_dict() for o in self.omissions],
306
+ }
307
+
308
+ def as_json(self) -> str:
309
+ """Two-space indent and no ASCII escaping, so `JSON.stringify(doc, null, 2)` in the
310
+ Node port produces the same bytes and a conformance case can compare them."""
311
+ return json.dumps(self.as_dict(), indent=2, ensure_ascii=False)
312
+
313
+
314
+ @dataclass
315
+ class _Skill:
316
+ """One `SKILL.md` as it was found, before anything has been decided about it."""
317
+
318
+ relpath: str
319
+ directory: str
320
+ marketplace: str = ""
321
+ plugin: str = ""
322
+ version: str = ""
323
+ description: str = ""
324
+ name: str = ""
325
+ router: bool = False
326
+ #: `None` while the file is still a candidate; one of the `OMIT_*` tokens once it is not.
327
+ omitted: str | None = None
328
+ #: `BAD_*` when the frontmatter is malformed, `None` when it is not.
329
+ malformed: str | None = None
330
+ phrases: tuple[str, ...] = field(default_factory=tuple)
331
+
332
+ @property
333
+ def in_plugin(self) -> bool:
334
+ return bool(self.plugin)
335
+
336
+ @property
337
+ def id(self) -> str:
338
+ """`<plugin>:<name>` — the spelling the host uses and the key `usage` is looked up by.
339
+
340
+ `<name>` alone for a skill outside a plugin. The name is always the DIRECTORY's, never
341
+ the frontmatter's: the host loads by directory, so a frontmatter `name:` that disagrees
342
+ is the thing that is wrong (`name-mismatch`) rather than a second identity.
343
+ """
344
+ return f"{self.plugin}:{self.directory}" if self.in_plugin else self.directory
345
+
346
+ @property
347
+ def plugin_id(self) -> str:
348
+ """`<plugin>@<marketplace>`, the way settings.json spells an enabled plugin."""
349
+ return f"{self.plugin}@{self.marketplace}"
350
+
351
+ @property
352
+ def plugin_key(self) -> tuple[str, str]:
353
+ """The unit ONE version directory is resolved for. A skill outside a plugin keys on
354
+ two empty strings, so every such skill shares one group whose resolved version is the
355
+ empty string — which keeps them all, because none of them has a version at all."""
356
+ return (self.marketplace, self.plugin)
357
+
358
+ @property
359
+ def dedupe_key(self) -> tuple[str, str, str]:
360
+ return (self.marketplace, self.plugin, self.directory)
361
+
362
+ @property
363
+ def bytes(self) -> int:
364
+ return len(self.description.encode("utf-8"))
365
+
366
+
367
+ def _relative_parts(path: Path, root: Path) -> list[str]:
368
+ return list(path.relative_to(root).parts)
369
+
370
+
371
+ def _scan_tree(root: Path) -> tuple[list[Path], list[tuple[str, str, str]]]:
372
+ """One walk, two answers: every `SKILL.md` under `root`, and every VERSION DIRECTORY.
373
+
374
+ `os.walk` yields directory entries in whatever order the filesystem hands them over, and
375
+ two machines hand them over differently. Sorting is what makes the scan order — and
376
+ therefore the omission `what` lists and the duplicate tie-break's "last wins" — the same
377
+ everywhere. `followlinks` stays off: a symlink loop is not a skill catalogue.
378
+
379
+ THE SECOND ANSWER IS WHY THIS FUNCTION IS NOT JUST A FILE LIST. A version directory
380
+ declares itself by holding a `skills/` directory — `<marketplace>/<plugin>/<version>/
381
+ skills`, the first four segments of the path shape a skill has — and it declares itself
382
+ whether or not anything under it can be read. Resolving over the files instead makes an
383
+ EMPTY newer version invisible, so an older directory wins in silence; see
384
+ `_resolve_versions`. A directory reached by following a symlink is not walked into on
385
+ either runtime, so it declares nothing on either.
386
+ """
387
+ found: list[Path] = []
388
+ versions: list[tuple[str, str, str]] = []
389
+ for dirpath, dirnames, filenames in os.walk(root, onerror=None):
390
+ dirnames.sort()
391
+ parts = _relative_parts(Path(dirpath), root)
392
+ if len(parts) == VERSION_PATH_SEGMENTS and parts[3] == SKILLS_SEGMENT:
393
+ versions.append((parts[0], parts[1], parts[2]))
394
+ if SKILL_FILE in filenames:
395
+ found.append(Path(dirpath) / SKILL_FILE)
396
+ found.sort(key=lambda p: p.parts)
397
+ versions.sort()
398
+ return found, versions
399
+
400
+
401
+ def _walk(root: Path) -> list[Path]:
402
+ """Every `SKILL.md` under `root`, in a fixed order. `_scan_tree`'s first answer."""
403
+ return _scan_tree(root)[0]
404
+
405
+
406
+ def _scalar_close(value: str, quote: str) -> int:
407
+ """Index of the quote that CLOSES a scalar opened at index 0, or `-1` when none does.
408
+
409
+ YAML has exactly two escape rules for this and this reader implements exactly two: inside a
410
+ `"` scalar a backslash escapes whatever follows it, so `\\"` does not close; inside a `'`
411
+ scalar a doubled `''` is one literal apostrophe, so it does not close either. A quote that
412
+ is never closed returns `-1`, which is what makes an unterminated value a literal.
413
+ """
414
+ index = 1
415
+ while index < len(value):
416
+ char = value[index]
417
+ if quote == '"' and char == BACKSLASH:
418
+ index += 2
419
+ continue
420
+ if char == quote:
421
+ if quote == "'" and value[index + 1 : index + 2] == quote:
422
+ index += 2
423
+ continue
424
+ return index
425
+ index += 1
426
+ return -1
427
+
428
+
429
+ def _unescape(content: str, quote: str) -> str:
430
+ """The content of a quoted scalar as the host's parser would hand it over.
431
+
432
+ Only the escapes `_scalar_close` honours are resolved, so the two functions cannot disagree
433
+ about what was inside the scalar and what closed it.
434
+ """
435
+ if quote == "'":
436
+ return content.replace("''", "'")
437
+ out: list[str] = []
438
+ index = 0
439
+ while index < len(content):
440
+ char = content[index]
441
+ if char == BACKSLASH and content[index + 1 : index + 2] in DOUBLE_ESCAPES:
442
+ out.append(content[index + 1])
443
+ index += 2
444
+ continue
445
+ out.append(char)
446
+ index += 1
447
+ return "".join(out)
448
+
449
+
450
+ def unwrap_scalar(value: str) -> str:
451
+ """A whole-value quoted YAML scalar without its quotes; every other value unchanged.
452
+
453
+ `"Use when a test is \\"flaky in prod\\""` is one scalar and unwraps. `"a" and "b"` opens
454
+ and ends with `"` and is NOT one — its first quote closes at index 2 — so it is left alone
455
+ and both of its phrases survive. `"never closed` never closes and is left alone too.
456
+ """
457
+ for quote in SCALAR_QUOTES:
458
+ if len(value) < 2 or not value.startswith(quote):
459
+ continue
460
+ if _scalar_close(value, quote) == len(value) - 1:
461
+ return _unescape(value[1:-1], quote)
462
+ return value
463
+
464
+
465
+ def _parse_frontmatter(text: str) -> tuple[dict[str, str] | None, str | None]:
466
+ """The `---` block as a flat mapping, or `None` and the token saying why not.
467
+
468
+ A deliberately small subset of YAML, and the smallness is the point: the alternative is a
469
+ parser dependency that the Node half would have to match bug for bug. What is supported is
470
+ what a `SKILL.md` frontmatter actually uses — top-level `key: value` pairs, and a value
471
+ FOLDED over following indented lines, which is joined with single spaces the way YAML's
472
+ folded scalar is. A line that is neither ends the fold and is skipped.
473
+
474
+ A leading BOM is stripped before the first line is examined: an editor that writes one has
475
+ not thereby made the file's frontmatter malformed.
476
+
477
+ Every value is unwrapped once the block closes, AFTER the fold and never during it: a
478
+ scalar quoted whole may be folded over several lines, so its closing quote is not known
479
+ until the last of them has been joined on. Unwrapping happens here rather than at
480
+ `description:` alone because it is a fact about YAML scalars — `name: "s"` is the name `s`
481
+ and `router: "true"` is a router — and one rule in one place is one rule for the Node half
482
+ to port.
483
+ """
484
+ if text.startswith(""):
485
+ text = text[1:]
486
+ lines = [line[:-1] if line.endswith("\r") else line for line in text.split("\n")]
487
+ if not lines or lines[0].strip() != FRONTMATTER_FENCE:
488
+ return None, BAD_NO_BLOCK
489
+ fields: dict[str, str] = {}
490
+ current: str | None = None
491
+ for line in lines[1:]:
492
+ if line.strip() == FRONTMATTER_FENCE:
493
+ return {key: unwrap_scalar(value) for key, value in fields.items()}, None
494
+ if not line.strip():
495
+ current = None
496
+ continue
497
+ if line[:1].isspace():
498
+ if current is not None:
499
+ joined = f"{fields[current]} {line.strip()}".strip()
500
+ fields[current] = joined
501
+ continue
502
+ key, sep, value = line.partition(":")
503
+ if not sep or not key or key.strip() != key:
504
+ current = None
505
+ continue
506
+ current = key
507
+ fields[key] = value.strip()
508
+ return None, BAD_UNTERMINATED
509
+
510
+
511
+ def _is_letter(text: str, index: int) -> bool:
512
+ return 0 <= index < len(text) and text[index].isalpha()
513
+
514
+
515
+ def _delimiters(text: str, quote: str) -> list[int]:
516
+ """The positions where `quote` opens or closes a phrase.
517
+
518
+ `"` always delimits. `'` delimits only where it is not flanked by letters on BOTH sides,
519
+ which is what separates `'race condition'` from `don't`. Measured on the fixture tree: the
520
+ naive rule reports a third `shared-trigger-phrase` over the junk string between two
521
+ contractions, so this is the one line that separates a correct reader from a plausible one.
522
+ """
523
+ positions = []
524
+ for index, char in enumerate(text):
525
+ if char != quote:
526
+ continue
527
+ if quote == "'" and _is_letter(text, index - 1) and _is_letter(text, index + 1):
528
+ continue
529
+ positions.append(index)
530
+ return positions
531
+
532
+
533
+ def _has_content(phrase: str) -> bool:
534
+ """A phrase with no letter and no digit in it is not a trigger phrase.
535
+
536
+ `" - "` between two quoted phrases is punctuation; reporting two skills that both use a
537
+ dash as a collision is the metric failing, not a finding.
538
+ """
539
+ return any(ch.isalnum() for ch in phrase)
540
+
541
+
542
+ def phrases(text: str) -> tuple[str, ...]:
543
+ """Every literal quoted phrase in `text`, in order, deduplicated.
544
+
545
+ Delimiters are paired sequentially — first with second, third with fourth — per quote
546
+ character, and an unpaired trailing delimiter opens nothing.
547
+ """
548
+ found: list[str] = []
549
+ for quote in ('"', "'"):
550
+ positions = _delimiters(text, quote)
551
+ for i in range(0, len(positions) - 1, 2):
552
+ phrase = text[positions[i] + 1 : positions[i + 1]]
553
+ if _has_content(phrase) and phrase not in found:
554
+ found.append(phrase)
555
+ return tuple(found)
556
+
557
+
558
+ def _load(path: Path, root: Path) -> _Skill:
559
+ """One file read, decoded and parsed. Every failure lands in a field, none of them raise."""
560
+ parts = _relative_parts(path, root)
561
+ relpath = "/".join(parts)
562
+ skill = _Skill(relpath=relpath, directory=parts[-2] if len(parts) > 1 else path.parent.name)
563
+ if len(parts) == PLUGIN_PATH_SEGMENTS and parts[3] == SKILLS_SEGMENT:
564
+ skill.marketplace, skill.plugin, skill.version = parts[0], parts[1], parts[2]
565
+ try:
566
+ text = path.read_bytes().decode("utf-8")
567
+ except (UnicodeDecodeError, OSError):
568
+ # A byte no strict decoder accepts. NOT repaired with `U+FFFD` and not skipped in
569
+ # silence: what the catalogue would have paid for it is unknowable, so `size` is 0 and
570
+ # the path is the record.
571
+ skill.omitted = OMIT_UNREADABLE
572
+ return skill
573
+ fields, bad = _parse_frontmatter(text)
574
+ if fields is None:
575
+ skill.omitted = OMIT_UNPARSED
576
+ skill.malformed = bad
577
+ return skill
578
+ skill.name = fields.get(KEY_NAME, "")
579
+ skill.router = fields.get(KEY_ROUTER, "") in TRUE_VALUES
580
+ if KEY_DESCRIPTION not in fields:
581
+ # The block parses and carries no description. This one IS a skill — the host loads
582
+ # nothing from it per session, so it costs zero bytes — and the operator is still told.
583
+ skill.malformed = BAD_NO_DESCRIPTION
584
+ return skill
585
+ skill.description = fields[KEY_DESCRIPTION]
586
+ skill.phrases = phrases(skill.description)
587
+ return skill
588
+
589
+
590
+ def _apply_enabled(found: list[_Skill], enabled: list[str] | None) -> None:
591
+ """Switch off every skill under a plugin the host does not have enabled.
592
+
593
+ `enabled` omitted means every skill counts; `enabled` given and EMPTY means no plugin is
594
+ on, which is a real state and not the same thing. A skill outside a plugin is never
595
+ excluded here: a plugin list cannot speak to a skill that belongs to no plugin, and
596
+ dropping one would under-report a personal skills directory by every file in it.
597
+ """
598
+ if enabled is None:
599
+ return
600
+ allowed = frozenset(enabled)
601
+ for skill in found:
602
+ if skill.omitted is None and skill.in_plugin and skill.plugin_id not in allowed:
603
+ skill.omitted = OMIT_NOT_ENABLED
604
+
605
+
606
+ def _resolve_versions(
607
+ found: list[_Skill],
608
+ version_dirs: list[tuple[str, str, str]],
609
+ versions: dict[str, str] | None = None,
610
+ ) -> None:
611
+ """Resolve ONE version directory per (marketplace, plugin), and omit every other one.
612
+
613
+ This runs BEFORE `_apply_dedupe` and it is the whole fix for the resurrection defect: the
614
+ winning version's skills are the plugin's skills, and a name absent from it is absent. It
615
+ is never merged in from another directory, however many versions of the plugin the cache
616
+ still holds. See the module docstring for the measurement that forced this.
617
+
618
+ The winner is the version directory name that sorts LAST in byte order. The loser's
619
+ subject depends on whether the resolved version has a skill of the same name to stand in
620
+ for it — `duplicate-skill` when it does, `stale-version` when it does not.
621
+
622
+ **THE CANDIDATES ARE DIRECTORIES ON DISK, NOT SURVIVING SKILLS.** This function used to
623
+ choose the winner from the skills that had already come through `_load` and
624
+ `_apply_enabled`, which is the resurrection defect arriving through the other door: a
625
+ version directory holding no `SKILL.md` at all, or only files that would not decode or
626
+ would not parse, contributed no skill, so it was never a candidate and an OLDER directory
627
+ won in silence — no omission, no finding, and no mention anywhere in the document of the
628
+ directory the host is actually serving. Measured 2026-09-05 with `mk/kit/2.0.0/skills/`
629
+ empty beside a populated `1.0.0`: both runtimes answered from `1.0.0` and said nothing.
630
+ They agreed, so the differential could not see it either. `version_dirs` comes from
631
+ `_scan_tree` and names every directory that exists, whatever is under it.
632
+
633
+ The losing case is then visible where every other skipped file already is: each skill
634
+ under a directory that did not win gets an omission record, and when the winner serves
635
+ nothing at all every one of them is a `stale-version` — which is the honest reading. They
636
+ are on disk, they are in nobody's bill, and the plugin's counted skills are zero.
637
+
638
+ **`versions` IS HOST TRUTH AND IT OVERRIDES THE BYTE ORDER.** Byte order is total, free
639
+ and identical on two runtimes, and it is still a GUESS — `10.0.0` loses to `9.0.0`, and
640
+ among names that are not versions the winner is arbitrary. The host does not guess: it
641
+ records the directory it serves as `installPath` in `installed_plugins.json`, in the same
642
+ file the caller already reads `enabled` out of. So a caller that knows may say so, keyed
643
+ the way `enabled` is keyed, and this function stops guessing for that plugin. Measured
644
+ 2026-09-05 on this machine: byte order picks `unknown` for `frontend-design` where the
645
+ host serves `1dd995193ba2`, and the installed directory is discarded as a duplicate —
646
+ harmless only because all nine copies carry a byte-identical description.
647
+
648
+ An entry naming a plugin with no version directory under `root` does nothing: there is
649
+ nothing to resolve. An entry naming a directory that is not there resolves to it anyway
650
+ and every directory that IS there loses, which is the honest answer — the host serves a
651
+ directory this root does not hold, so this root serves none of that plugin's skills, and
652
+ every file it does hold is named in a `stale-version` record. Neither is a refusal, for
653
+ the reason `enabled` does not refuse an unknown plugin id either: the caller is the
654
+ authority on its own host, and this tool reads `root` and nothing else.
655
+
656
+ A skill outside a plugin keys on `("", "")` with an empty version, so every one of them is
657
+ in the resolved version by construction and none is ever omitted here. No real path can
658
+ produce that key — every path segment is non-empty — so it is seeded rather than found,
659
+ and it is skipped by the override loop because it names no plugin.
660
+ """
661
+ resolved: dict[tuple[str, str], str] = {("", ""): ""}
662
+ for marketplace, plugin, version in version_dirs:
663
+ held = resolved.get((marketplace, plugin))
664
+ if held is None or version > held:
665
+ resolved[(marketplace, plugin)] = version
666
+ named = versions or {}
667
+ for marketplace, plugin in list(resolved):
668
+ if not plugin:
669
+ continue
670
+ told = named.get(f"{plugin}@{marketplace}")
671
+ if told is not None:
672
+ resolved[(marketplace, plugin)] = told
673
+ # The names the resolved version actually SERVES, which is a different question from
674
+ # which directory won: a winner whose files were all unreadable, unparsable or switched
675
+ # off serves nothing and is in no entry here. `.get(..., set())` below is that case, and
676
+ # it is the one this function used to be unable to reach at all.
677
+ kept: dict[tuple[str, str], set[str]] = {}
678
+ for skill in found:
679
+ if skill.omitted is None and skill.version == resolved[skill.plugin_key]:
680
+ kept.setdefault(skill.plugin_key, set()).add(skill.directory)
681
+ for skill in found:
682
+ if skill.omitted is not None or skill.version == resolved[skill.plugin_key]:
683
+ continue
684
+ stands_in = skill.directory in kept.get(skill.plugin_key, set())
685
+ skill.omitted = OMIT_DUPLICATE if stands_in else OMIT_STALE_VERSION
686
+
687
+
688
+ def _apply_dedupe(found: list[_Skill]) -> None:
689
+ """One skill per (marketplace, plugin, name) INSIDE the resolved version; last one wins.
690
+
691
+ Only reachable for skills outside a plugin: inside one version directory a name is a
692
+ directory name and the filesystem has already made it unique. Two files claiming the same
693
+ bare name are still a duplicate, and saying so is better than counting a personal skill
694
+ twice. Scan order decides, which is path order and therefore the same on both runtimes.
695
+ """
696
+ winners: dict[tuple[str, str, str], _Skill] = {}
697
+ for skill in found:
698
+ if skill.omitted is not None:
699
+ continue
700
+ held = winners.get(skill.dedupe_key)
701
+ if held is not None:
702
+ held.omitted = OMIT_DUPLICATE
703
+ winners[skill.dedupe_key] = skill
704
+
705
+
706
+ def _scan(
707
+ root: str | Path,
708
+ enabled: list[str] | None = None,
709
+ versions: dict[str, str] | None = None,
710
+ ) -> list[_Skill]:
711
+ """The scan, decided but not yet reported: every `SKILL.md` found, with `enabled`, the
712
+ version resolution and the dedupe already applied.
713
+
714
+ One seam rather than four calls in a fixed order, because the order IS the rule — the
715
+ version resolution has to run after `enabled` and before the dedupe — and a caller that
716
+ reproduces it by hand can get it wrong, or can miss an argument the four grow later.
717
+ `runtime-ts/src/skillaudit.ts` exports the same seam under the same name for the same
718
+ reason: the per-skill byte table is the only oracle that separates a headline that is
719
+ right from one that is right for two cancelling reasons.
720
+ """
721
+ base = Path(root)
722
+ files, version_dirs = _scan_tree(base)
723
+ found = [_load(path, base) for path in files]
724
+ _apply_enabled(found, enabled)
725
+ _resolve_versions(found, version_dirs, versions)
726
+ _apply_dedupe(found)
727
+ return found
728
+
729
+
730
+ def _shared_phrase_findings(counted: list[_Skill]) -> list[Finding]:
731
+ """Phrases held by two or more skills. Routers are not in this index at all."""
732
+ index: dict[str, list[str]] = {}
733
+ for skill in counted:
734
+ if skill.router:
735
+ continue
736
+ for phrase in skill.phrases:
737
+ holders = index.setdefault(phrase, [])
738
+ if skill.id not in holders:
739
+ holders.append(skill.id)
740
+ return [
741
+ Finding(KIND_SHARED_PHRASE, tuple(sorted(holders)), phrase)
742
+ for phrase, holders in sorted(index.items())
743
+ if len(holders) > 1
744
+ ]
745
+
746
+
747
+ def _omission_records(found: list[_Skill]) -> list[Omission]:
748
+ """One record per subject, carrying the count, the bytes and the paths."""
749
+ records = []
750
+ for subject in OMISSION_ORDER:
751
+ members = [s for s in found if s.omitted == subject]
752
+ if not members:
753
+ continue
754
+ records.append(
755
+ Omission(
756
+ subject=subject,
757
+ count=len(members),
758
+ size=sum(s.bytes for s in members),
759
+ what=", ".join(s.relpath for s in members),
760
+ )
761
+ )
762
+ return records
763
+
764
+
765
+ def audit(
766
+ root: str | Path,
767
+ enabled: list[str] | None = None,
768
+ usage: dict[str, int] | None = None,
769
+ check: str = "all",
770
+ budget: int | None = None,
771
+ versions: dict[str, str] | None = None,
772
+ ) -> Audit:
773
+ """Walk `root` and answer the whole audit.
774
+
775
+ `usage` omitted is not the same as `usage` empty. An empty map is a measurement saying
776
+ nothing was called; an absent map is no measurement at all, and reporting every skill as
777
+ `never-invoked` on the strength of it would be an assertion about data this tool was never
778
+ given. So `never-invoked` fires only when `usage` is supplied — the same discipline
779
+ `catalogue-over-budget` follows for `budget`, and for the same reason.
780
+
781
+ An empty `root` is refused rather than resolved. `Path("")` is `Path(".")` on the
782
+ reference and `statSync('')` throws on the port, so the two runtimes answered a
783
+ CWD-relative audit and a refusal for the same input — and the reference's half of that
784
+ contradicts the determinism this tool's contract claims.
785
+
786
+ `versions` is the same kind of argument `enabled` is: HOST TRUTH the caller supplies
787
+ rather than a fact this tool can read off `root`. It names, per `<plugin>@<marketplace>`,
788
+ the version directory the host actually serves; a plugin absent from it falls back to the
789
+ byte order, which is deterministic and — among names that are not versions — arbitrary.
790
+ See `_resolve_versions`.
791
+
792
+ `skills`, `catalogue_bytes` and `omissions` are reported whatever `check` says: they are
793
+ the measurement, and `check` selects which family of FINDINGS is worth reporting on top
794
+ of it.
795
+ """
796
+ if check not in CHECK_FAMILIES:
797
+ raise SkillAuditError(
798
+ f"unknown check {check!r}; this tool checks: {', '.join(sorted(CHECK_FAMILIES))}"
799
+ )
800
+ if budget is not None and budget < 0:
801
+ raise SkillAuditError(f"budget must not be negative; got {budget}")
802
+ if str(root) == "":
803
+ # `Path("")` is `Path(".")`, so an empty root audits whatever directory the SERVER
804
+ # happens to be standing in — a different answer per host, from an argument that
805
+ # named no directory at all, out of a tool whose whole claim is that it reads `root`
806
+ # and nothing else. `""` passes JSON-schema `string` and pydantic `str` alike, so it
807
+ # reaches this function; it is refused here, where the other argument failures are.
808
+ raise SkillAuditError("root must not be empty; name the directory of skills to scan")
809
+ base = Path(root)
810
+ if not base.exists():
811
+ raise SkillAuditError(f"no such directory: {root}")
812
+ if not base.is_dir():
813
+ raise SkillAuditError(f"{root} is a file, not a directory of skills")
814
+
815
+ found = _scan(base, enabled, versions)
816
+ counted = [s for s in found if s.omitted is None]
817
+ catalogue_bytes = sum(s.bytes for s in counted)
818
+
819
+ wanted = CHECK_FAMILIES[check]
820
+ findings: list[Finding] = []
821
+ if KIND_SHARED_PHRASE in wanted:
822
+ findings.extend(_shared_phrase_findings(counted))
823
+ if KIND_OVER_BUDGET in wanted and budget is not None and catalogue_bytes > budget:
824
+ over = catalogue_bytes - budget
825
+ detail = f"{catalogue_bytes} > {budget}, over by {over}"
826
+ findings.append(Finding(KIND_OVER_BUDGET, (), detail))
827
+ if KIND_NEVER_INVOKED in wanted and usage is not None:
828
+ findings.extend(
829
+ Finding(KIND_NEVER_INVOKED, (skill.id,), "0 calls")
830
+ for skill in sorted(counted, key=lambda s: s.id)
831
+ if usage.get(skill.id, 0) == 0
832
+ )
833
+ if KIND_FRONTMATTER in wanted:
834
+ findings.extend(
835
+ Finding(KIND_FRONTMATTER, (skill.id,), skill.malformed)
836
+ for skill in sorted(found, key=lambda s: s.id)
837
+ if skill.malformed is not None
838
+ )
839
+ if KIND_NAME_MISMATCH in wanted:
840
+ findings.extend(
841
+ Finding(KIND_NAME_MISMATCH, (skill.id,), skill.name)
842
+ for skill in sorted(counted, key=lambda s: s.id)
843
+ if skill.name and skill.name != skill.directory
844
+ )
845
+ findings.sort(key=lambda f: FINDING_ORDER.index(f.kind))
846
+
847
+ return Audit(
848
+ roots=(str(root),),
849
+ skills=len(counted),
850
+ catalogue_bytes=catalogue_bytes,
851
+ findings=tuple(findings),
852
+ omissions=tuple(_omission_records(found)),
853
+ )