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.
- bantamkit/__init__.py +32 -0
- bantamkit/agent.py +458 -0
- bantamkit/assets/contracts/default.yaml +90 -0
- bantamkit/assets/evals/devteam/manifest.yaml +351 -0
- bantamkit/assets/evals/devteam/repo/HISTORY.md +18 -0
- bantamkit/assets/evals/devteam/repo/README.md +12 -0
- bantamkit/assets/evals/devteam/repo/docs/architecture.md +17 -0
- bantamkit/assets/evals/devteam/repo/docs/runbook.md +10 -0
- bantamkit/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- bantamkit/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- bantamkit/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- bantamkit/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- bantamkit/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- bantamkit/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- bantamkit/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- bantamkit/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- bantamkit/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- bantamkit/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- bantamkit/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- bantamkit/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- bantamkit/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- bantamkit/assets/evals/fixtures/.gitkeep +0 -0
- bantamkit/assets/evals/fixtures/catalog.json +6 -0
- bantamkit/assets/evals/perturbations/task-completion.yaml +576 -0
- bantamkit/assets/evals/tasks/.gitkeep +0 -0
- bantamkit/assets/evals/tasks/extract-contact.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-invoice.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-order.yaml +15 -0
- bantamkit/assets/evals/tasks/extract-schedule.yaml +14 -0
- bantamkit/assets/evals/tasks/extract-versions.yaml +17 -0
- bantamkit/assets/evals/tasks/nav-prod-port.yaml +84 -0
- bantamkit/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- bantamkit/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-db-port.yaml +17 -0
- bantamkit/assets/evals/tasks/recall-deploy.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- bantamkit/assets/evals/tasks/recall-oncall.yaml +13 -0
- bantamkit/assets/evals/tasks/recall-org-quota.yaml +18 -0
- bantamkit/assets/evals/tasks/recall-owner.yaml +13 -0
- bantamkit/assets/evals/tasks/shop-basket-total.yaml +10 -0
- bantamkit/assets/evals/tasks/shop-cheapest.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-compare.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-stock-total.yaml +9 -0
- bantamkit/assets/evals/tasks/shop-total.yaml +9 -0
- bantamkit/assets/profiles/default.yaml +31 -0
- bantamkit/assets/profiles/patient.yaml +31 -0
- bantamkit/assets/rubrics/.gitkeep +0 -0
- bantamkit/assets/rubrics/code-quality.yaml +20 -0
- bantamkit/assets/rubrics/grounded-completion.yaml +37 -0
- bantamkit/assets/rubrics/task-completion.yaml +28 -0
- bantamkit/assets/schemas/shiftwork-checkpoint.json +188 -0
- bantamkit/assets/skills/.gitkeep +0 -0
- bantamkit/assets/skills/file-graph.md +7 -0
- bantamkit/assets/skills/memory.md +35 -0
- bantamkit/assets/tools/.gitkeep +0 -0
- bantamkit/assets/tools/bantamkit_read.json +48 -0
- bantamkit/assets/tools/bantamkit_status.json +25 -0
- bantamkit/assets/tools/build_identity.json +17 -0
- bantamkit/assets/tools/document_list.json +12 -0
- bantamkit/assets/tools/document_read.json +31 -0
- bantamkit/assets/tools/file_graph.json +12 -0
- bantamkit/assets/tools/memory_compact.json +31 -0
- bantamkit/assets/tools/memory_recall.json +38 -0
- bantamkit/assets/tools/memory_save.json +61 -0
- bantamkit/assets/tools/shiftwork_clock_in.json +25 -0
- bantamkit/assets/tools/shiftwork_clock_out.json +60 -0
- bantamkit/assets/tools/shiftwork_status.json +25 -0
- bantamkit/assets/tools/skill_audit.json +70 -0
- bantamkit/assets/tools/validate_json.json +31 -0
- bantamkit/assets.py +67 -0
- bantamkit/budget.py +114 -0
- bantamkit/client.py +329 -0
- bantamkit/contract.py +522 -0
- bantamkit/criticreplay.py +3241 -0
- bantamkit/critique.py +301 -0
- bantamkit/docread.py +1744 -0
- bantamkit/evalrun.py +2003 -0
- bantamkit/eventlog.py +282 -0
- bantamkit/filegraph.py +218 -0
- bantamkit/loopguard.py +101 -0
- bantamkit/mcpreport.py +763 -0
- bantamkit/mcpserver.py +1334 -0
- bantamkit/memory/__init__.py +28 -0
- bantamkit/memory/__main__.py +291 -0
- bantamkit/memory/component.py +569 -0
- bantamkit/memory/divergence.py +744 -0
- bantamkit/memory/layers.py +257 -0
- bantamkit/memory/store.py +940 -0
- bantamkit/pdfread.py +1402 -0
- bantamkit/profile.py +46 -0
- bantamkit/shiftwork.py +212 -0
- bantamkit/skillaudit.py +853 -0
- bantamkit/statusline.py +313 -0
- bantamkit/structured.py +125 -0
- bantamkit/textutil.py +30 -0
- bantamkit-0.27.0.dist-info/METADATA +207 -0
- bantamkit-0.27.0.dist-info/RECORD +119 -0
- bantamkit-0.27.0.dist-info/WHEEL +4 -0
- bantamkit-0.27.0.dist-info/entry_points.txt +2 -0
bantamkit/skillaudit.py
ADDED
|
@@ -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
|
+
)
|