bantamkit-mcp 0.26.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +398 -0
- package/assets/contracts/default.yaml +90 -0
- package/assets/evals/devteam/manifest.yaml +351 -0
- package/assets/evals/devteam/repo/HISTORY.md +18 -0
- package/assets/evals/devteam/repo/README.md +12 -0
- package/assets/evals/devteam/repo/docs/architecture.md +17 -0
- package/assets/evals/devteam/repo/docs/runbook.md +10 -0
- package/assets/evals/devteam/repo/issues/142-settlement-timeout.md +23 -0
- package/assets/evals/devteam/repo/patches/0009-retry-budget.patch +38 -0
- package/assets/evals/devteam/repo/src/ledger/__init__.py +3 -0
- package/assets/evals/devteam/repo/src/ledger/config.py +35 -0
- package/assets/evals/devteam/repo/src/ledger/errors.py +13 -0
- package/assets/evals/devteam/repo/src/ledger/posting.py +12 -0
- package/assets/evals/devteam/repo/src/ledger/registry.py +7 -0
- package/assets/evals/devteam/repo/src/ledger/report.py +9 -0
- package/assets/evals/devteam/repo/src/ledger/retry.py +17 -0
- package/assets/evals/devteam/repo/src/ledger/settle.py +16 -0
- package/assets/evals/devteam/repo/src/ledger/validate.py +14 -0
- package/assets/evals/devteam/repo/tests/test_posting.py +13 -0
- package/assets/evals/devteam/repo/tests/test_settle.py +9 -0
- package/assets/evals/devteam/tasks/dt-error-contract.yaml +186 -0
- package/assets/evals/devteam/tasks/dt-handler-map.yaml +183 -0
- package/assets/evals/devteam/tasks/dt-patch-before-after.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-retry-attempts.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-settlement-config.yaml +185 -0
- package/assets/evals/devteam/tasks/dt-symbol-home.yaml +181 -0
- package/assets/evals/devteam/tasks/dt-trace-blame.yaml +182 -0
- package/assets/evals/devteam/tasks/dt-unread-key.yaml +180 -0
- package/assets/evals/document/tasks/doc-large-in-137.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-in-359.yaml +44 -0
- package/assets/evals/document/tasks/doc-large-in-372.yaml +38 -0
- package/assets/evals/document/tasks/doc-large-out-11764.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-4137.yaml +37 -0
- package/assets/evals/document/tasks/doc-large-out-8022.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-137.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-261.yaml +37 -0
- package/assets/evals/document/tasks/doc-small-388.yaml +37 -0
- package/assets/evals/fixtures/.gitkeep +0 -0
- package/assets/evals/fixtures/catalog.json +6 -0
- package/assets/evals/perturbations/task-completion.yaml +576 -0
- package/assets/evals/tasks/.gitkeep +0 -0
- package/assets/evals/tasks/extract-contact.yaml +14 -0
- package/assets/evals/tasks/extract-invoice.yaml +14 -0
- package/assets/evals/tasks/extract-order.yaml +15 -0
- package/assets/evals/tasks/extract-schedule.yaml +14 -0
- package/assets/evals/tasks/extract-versions.yaml +17 -0
- package/assets/evals/tasks/nav-prod-port.yaml +84 -0
- package/assets/evals/tasks/nav-release-bundle.yaml +87 -0
- package/assets/evals/tasks/recall-audit-retention.yaml +17 -0
- package/assets/evals/tasks/recall-cache-ttl.yaml +13 -0
- package/assets/evals/tasks/recall-db-port.yaml +17 -0
- package/assets/evals/tasks/recall-deploy.yaml +13 -0
- package/assets/evals/tasks/recall-env-endpoint.yaml +18 -0
- package/assets/evals/tasks/recall-oncall-rotation.yaml +21 -0
- package/assets/evals/tasks/recall-oncall.yaml +13 -0
- package/assets/evals/tasks/recall-org-quota.yaml +18 -0
- package/assets/evals/tasks/recall-owner.yaml +13 -0
- package/assets/evals/tasks/shop-basket-total.yaml +10 -0
- package/assets/evals/tasks/shop-cheapest.yaml +9 -0
- package/assets/evals/tasks/shop-compare.yaml +9 -0
- package/assets/evals/tasks/shop-gadget-value.yaml +9 -0
- package/assets/evals/tasks/shop-stock-total.yaml +9 -0
- package/assets/evals/tasks/shop-total.yaml +9 -0
- package/assets/profiles/default.yaml +31 -0
- package/assets/profiles/patient.yaml +31 -0
- package/assets/rubrics/.gitkeep +0 -0
- package/assets/rubrics/code-quality.yaml +20 -0
- package/assets/rubrics/grounded-completion.yaml +37 -0
- package/assets/rubrics/task-completion.yaml +28 -0
- package/assets/schemas/shiftwork-checkpoint.json +188 -0
- package/assets/skills/.gitkeep +0 -0
- package/assets/skills/file-graph.md +7 -0
- package/assets/skills/memory.md +35 -0
- package/assets/tools/.gitkeep +0 -0
- package/assets/tools/bantamkit_read.json +48 -0
- package/assets/tools/bantamkit_status.json +25 -0
- package/assets/tools/build_identity.json +17 -0
- package/assets/tools/document_list.json +12 -0
- package/assets/tools/document_read.json +31 -0
- package/assets/tools/file_graph.json +12 -0
- package/assets/tools/memory_compact.json +31 -0
- package/assets/tools/memory_recall.json +38 -0
- package/assets/tools/memory_save.json +61 -0
- package/assets/tools/shiftwork_clock_in.json +25 -0
- package/assets/tools/shiftwork_clock_out.json +60 -0
- package/assets/tools/shiftwork_status.json +25 -0
- package/assets/tools/skill_audit.json +70 -0
- package/assets/tools/validate_json.json +31 -0
- package/dist/assets.d.ts +48 -0
- package/dist/assets.js +109 -0
- package/dist/charsets.d.ts +17 -0
- package/dist/charsets.js +580 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.js +280 -0
- package/dist/contract.d.ts +145 -0
- package/dist/contract.js +608 -0
- package/dist/docread.d.ts +357 -0
- package/dist/docread.js +3315 -0
- package/dist/errors.d.ts +11 -0
- package/dist/errors.js +14 -0
- package/dist/eventlog.d.ts +137 -0
- package/dist/eventlog.js +294 -0
- package/dist/htmlentities.d.ts +2 -0
- package/dist/htmlentities.js +16 -0
- package/dist/index.d.ts +23 -0
- package/dist/index.js +13 -0
- package/dist/mcp/identity.d.ts +25 -0
- package/dist/mcp/identity.js +275 -0
- package/dist/mcp/pyargs.d.ts +80 -0
- package/dist/mcp/pyargs.js +362 -0
- package/dist/mcp/sdkjson.d.ts +56 -0
- package/dist/mcp/sdkjson.js +91 -0
- package/dist/mcp/server.d.ts +45 -0
- package/dist/mcp/server.js +740 -0
- package/dist/mcp/status.d.ts +210 -0
- package/dist/mcp/status.js +357 -0
- package/dist/mcp/transport.d.ts +68 -0
- package/dist/mcp/transport.js +111 -0
- package/dist/mcpreport.d.ts +214 -0
- package/dist/mcpreport.js +758 -0
- package/dist/memory/cli.d.ts +2 -0
- package/dist/memory/cli.js +396 -0
- package/dist/memory/component.d.ts +256 -0
- package/dist/memory/component.js +497 -0
- package/dist/memory/factfile.d.ts +120 -0
- package/dist/memory/factfile.js +406 -0
- package/dist/memory/layers.d.ts +92 -0
- package/dist/memory/layers.js +410 -0
- package/dist/memory/pyfs.d.ts +521 -0
- package/dist/memory/pyfs.js +1608 -0
- package/dist/memory/pyyaml.d.ts +155 -0
- package/dist/memory/pyyaml.js +868 -0
- package/dist/memory/store.d.ts +478 -0
- package/dist/memory/store.js +1034 -0
- package/dist/pyargparse.d.ts +141 -0
- package/dist/pyargparse.js +813 -0
- package/dist/pyjson.d.ts +110 -0
- package/dist/pyjson.js +524 -0
- package/dist/pyjsonschema.d.ts +50 -0
- package/dist/pyjsonschema.js +951 -0
- package/dist/shiftwork.d.ts +60 -0
- package/dist/shiftwork.js +324 -0
- package/dist/skillaudit.d.ts +172 -0
- package/dist/skillaudit.js +914 -0
- package/dist/statusline.d.ts +106 -0
- package/dist/statusline.js +281 -0
- package/package.json +45 -0
|
@@ -0,0 +1,914 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Price the skill catalogue a session pays for, and name the collisions in it.
|
|
3
|
+
*
|
|
4
|
+
* The Node half of `runtime-py/src/bantamkit/skillaudit.py`. Same tool, same name, same
|
|
5
|
+
* input schema, same output shape — and the same JSON BYTES, which is the only claim
|
|
6
|
+
* anybody can rerun: `Audit.asJson` is `JSON.stringify(doc, null, 2)` and the reference's
|
|
7
|
+
* is `json.dumps(..., indent=2, ensure_ascii=False)`, compared over the committed fixture
|
|
8
|
+
* by `tools/conformance/suites/skillaudit.mjs`.
|
|
9
|
+
*
|
|
10
|
+
* A skill's `description:` frontmatter is loaded into the agent's context in EVERY session;
|
|
11
|
+
* its body is read only when the skill is invoked. So the descriptions are the standing bill.
|
|
12
|
+
*
|
|
13
|
+
* **The collision metric is EXACT match on literal quoted phrases, and a similarity score was
|
|
14
|
+
* measured and refuted.** Jaccard over description tokens was run across 820 real pairs
|
|
15
|
+
* (2026-09-04): the maximum any pair reached was 0.239, and the two collisions a person would
|
|
16
|
+
* call real ranked 118th and 791st of 820. There is no score here at all, and adding one back
|
|
17
|
+
* is a regression however plausible it reads.
|
|
18
|
+
*
|
|
19
|
+
* **The apostrophe is the whole difficulty.** `'race condition'` is a quoted phrase and
|
|
20
|
+
* `don't` is a contraction, so a `'` delimits only when it is NOT flanked by letters on both
|
|
21
|
+
* sides. `"` has no such problem and is always a delimiter.
|
|
22
|
+
*
|
|
23
|
+
* **A whole-value quoted scalar is YAML's quoting, not the author's.** The host's parser
|
|
24
|
+
* strips those quotes before a session sees the description, so they are not `catalogueBytes`
|
|
25
|
+
* and only quotes INSIDE the value delimit a phrase. The rule lives in the PARSER, after the
|
|
26
|
+
* fold, so it applies to every key: `name: "s"` is the name `s` and `router: "true"` is a
|
|
27
|
+
* router.
|
|
28
|
+
*
|
|
29
|
+
* **One version per plugin, resolved BEFORE anything is counted.** A plugin cache holds every
|
|
30
|
+
* version directory a plugin was ever installed at and the host serves exactly one, so ONE
|
|
31
|
+
* version directory is resolved per (marketplace, plugin) first and its skills are the
|
|
32
|
+
* plugin's skills. Deduping by the (marketplace, plugin, name) triple instead merges versions
|
|
33
|
+
* rather than choosing between them, and a name the winning version DROPPED then has nothing
|
|
34
|
+
* to displace it: measured 2026-09-05 on a real cache, 31 skills / 9,280 bytes against a host
|
|
35
|
+
* serving 22 / 3,396. The winner is the version directory name that sorts LAST in byte order —
|
|
36
|
+
* total, free, identical on both runtimes, and workable on names that are not versions at all
|
|
37
|
+
* (this machine spells nine of them as content hashes plus the literal `unknown`). What it
|
|
38
|
+
* gets wrong is stated, not hidden: `10.0.0` loses to `9.0.0`, and among non-version names the
|
|
39
|
+
* winner is deterministic but arbitrary. A loser whose name IS in the resolved version is a
|
|
40
|
+
* `duplicate-skill`; one whose name is NOT is a `stale-version`, which is the subject that
|
|
41
|
+
* makes a removed skill visible instead of resurrected.
|
|
42
|
+
*
|
|
43
|
+
* **Byte order is the FALLBACK, because the caller can just say.** The host records the
|
|
44
|
+
* directory it serves as `installPath` in `installed_plugins.json`, the same file `enabled` is
|
|
45
|
+
* read out of; `versions` carries it in, keyed the way `enabled` is keyed, and where it names
|
|
46
|
+
* a plugin the guess is not made. Measured 2026-09-05: byte order picks `unknown` for
|
|
47
|
+
* `frontend-design` where the host serves `1dd995193ba2`.
|
|
48
|
+
*
|
|
49
|
+
* **A version directory exists on disk, not in the skills that survived reading.** The
|
|
50
|
+
* candidates are directories spelled `<marketplace>/<plugin>/<version>/skills`, whatever is
|
|
51
|
+
* under them; choosing among surviving skills instead makes an EMPTY newer version invisible
|
|
52
|
+
* and an older one win in silence. Both runtimes did that until 2026-09-05 and both agreed,
|
|
53
|
+
* which is why the differential could not see it.
|
|
54
|
+
*
|
|
55
|
+
* **Nothing is dropped in silence.** Counted skills plus omissions account for every
|
|
56
|
+
* `SKILL.md` found, and an omission is `{subject, count, size, what}`.
|
|
57
|
+
*
|
|
58
|
+
* PORTING NOTES — the four places JavaScript is not Python, and what is done about each:
|
|
59
|
+
*
|
|
60
|
+
* 1. `sorted()` is a CODE POINT sort and `Array.prototype.sort` is a UTF-16 one, which
|
|
61
|
+
* disagree above U+FFFF. Every ordering here goes through `cmpCodepoint`.
|
|
62
|
+
* 2. `str.strip()` is not `String.trim()` — they disagree on six code points — so the
|
|
63
|
+
* frontmatter parser uses `pyStrip`, the same set `memory/factfile.ts` measured.
|
|
64
|
+
* 3. `bytes.decode("utf-8")` is STRICT and `Buffer.toString('utf8')` substitutes
|
|
65
|
+
* `U+FFFD`. A `TextDecoder` with `fatal: true` is what makes `bad-bytes` an
|
|
66
|
+
* `unreadable-file` record rather than a description nobody wrote. `ignoreBOM: true`
|
|
67
|
+
* keeps the BOM the parser then strips itself, exactly where Python strips it.
|
|
68
|
+
* 4. A `budget` is a Python `int` and has no ceiling, so it travels as a `bigint` and the
|
|
69
|
+
* overage arithmetic is done in `bigint`. `catalogueBytes` is a byte count and a
|
|
70
|
+
* `number`.
|
|
71
|
+
*/
|
|
72
|
+
import { readdirSync, readFileSync, statSync } from 'node:fs';
|
|
73
|
+
import { basename, dirname, join } from 'node:path';
|
|
74
|
+
import { BantamError } from './errors.js';
|
|
75
|
+
import { reprValue } from './pyjson.js';
|
|
76
|
+
// The file a skill IS. Case-sensitive: the host loads this name and so does this reader.
|
|
77
|
+
export const SKILL_FILE = 'SKILL.md';
|
|
78
|
+
// The literal segment a plugin's skills live under.
|
|
79
|
+
const SKILLS_SEGMENT = 'skills';
|
|
80
|
+
// `<marketplace>/<plugin>/<version>/skills/<name>/SKILL.md` — six segments relative to the
|
|
81
|
+
// root. Any other shape is a skill outside a plugin.
|
|
82
|
+
const PLUGIN_PATH_SEGMENTS = 6;
|
|
83
|
+
// `<marketplace>/<plugin>/<version>/skills` — the first four of those, and the DIRECTORY that
|
|
84
|
+
// declares a version directory exists. Version resolution is over these, not over the files
|
|
85
|
+
// under them: a version directory that holds no readable `SKILL.md` still exists, and the host
|
|
86
|
+
// still serves it. See `resolveVersions`.
|
|
87
|
+
const VERSION_PATH_SEGMENTS = 4;
|
|
88
|
+
// The subjects an `Omission` can carry. Stable tokens, because a renderer switches on them
|
|
89
|
+
// and a caller filters on them.
|
|
90
|
+
export const OMIT_NOT_ENABLED = 'plugin-not-enabled';
|
|
91
|
+
export const OMIT_DUPLICATE = 'duplicate-skill';
|
|
92
|
+
export const OMIT_STALE_VERSION = 'stale-version';
|
|
93
|
+
export const OMIT_UNREADABLE = 'unreadable-file';
|
|
94
|
+
export const OMIT_UNPARSED = 'unparsed-frontmatter';
|
|
95
|
+
/**
|
|
96
|
+
* Reporting order for omissions. Fixed, so two runtimes emit the same document. The two
|
|
97
|
+
* version subjects are adjacent because they are the two halves of one rule.
|
|
98
|
+
*/
|
|
99
|
+
export const OMISSION_ORDER = [
|
|
100
|
+
OMIT_NOT_ENABLED,
|
|
101
|
+
OMIT_DUPLICATE,
|
|
102
|
+
OMIT_STALE_VERSION,
|
|
103
|
+
OMIT_UNREADABLE,
|
|
104
|
+
OMIT_UNPARSED,
|
|
105
|
+
];
|
|
106
|
+
// The finding kinds. Severity is fixed per kind and is NOT an argument.
|
|
107
|
+
export const KIND_SHARED_PHRASE = 'shared-trigger-phrase';
|
|
108
|
+
export const KIND_OVER_BUDGET = 'catalogue-over-budget';
|
|
109
|
+
export const KIND_NEVER_INVOKED = 'never-invoked';
|
|
110
|
+
export const KIND_FRONTMATTER = 'frontmatter-malformed';
|
|
111
|
+
export const KIND_NAME_MISMATCH = 'name-mismatch';
|
|
112
|
+
export const SEVERITY = {
|
|
113
|
+
[KIND_SHARED_PHRASE]: 'high',
|
|
114
|
+
[KIND_OVER_BUDGET]: 'high',
|
|
115
|
+
[KIND_NEVER_INVOKED]: 'low',
|
|
116
|
+
[KIND_FRONTMATTER]: 'medium',
|
|
117
|
+
[KIND_NAME_MISMATCH]: 'medium',
|
|
118
|
+
};
|
|
119
|
+
/** Reporting order for findings, and the order the contract lists them in. */
|
|
120
|
+
export const FINDING_ORDER = [
|
|
121
|
+
KIND_SHARED_PHRASE,
|
|
122
|
+
KIND_OVER_BUDGET,
|
|
123
|
+
KIND_NEVER_INVOKED,
|
|
124
|
+
KIND_FRONTMATTER,
|
|
125
|
+
KIND_NAME_MISMATCH,
|
|
126
|
+
];
|
|
127
|
+
/**
|
|
128
|
+
* `check` selects a FAMILY, not a kind: an operator asking about the bill wants both the
|
|
129
|
+
* overage and the skills nobody ever called, and one asking about frontmatter wants both the
|
|
130
|
+
* block that will not parse and the name that does not match its directory.
|
|
131
|
+
*/
|
|
132
|
+
export const CHECK_FAMILIES = {
|
|
133
|
+
all: FINDING_ORDER,
|
|
134
|
+
phrase: [KIND_SHARED_PHRASE],
|
|
135
|
+
budget: [KIND_OVER_BUDGET, KIND_NEVER_INVOKED],
|
|
136
|
+
frontmatter: [KIND_FRONTMATTER, KIND_NAME_MISMATCH],
|
|
137
|
+
};
|
|
138
|
+
// The three ways frontmatter is malformed, as tokens rather than sentences. The first two
|
|
139
|
+
// also cost the skill its place in the count; the third does not.
|
|
140
|
+
export const BAD_NO_BLOCK = 'no-frontmatter';
|
|
141
|
+
export const BAD_UNTERMINATED = 'unterminated-frontmatter';
|
|
142
|
+
export const BAD_NO_DESCRIPTION = 'no-description';
|
|
143
|
+
// The frontmatter delimiter, and the keys this reader consults. Every other key is parsed and
|
|
144
|
+
// ignored — parsing it is what makes `router: true` findable without a YAML dependency.
|
|
145
|
+
const FRONTMATTER_FENCE = '---';
|
|
146
|
+
const KEY_NAME = 'name';
|
|
147
|
+
const KEY_DESCRIPTION = 'description';
|
|
148
|
+
const KEY_ROUTER = 'router';
|
|
149
|
+
/** The values YAML spells `true` with. A `router:` carrying anything else is not a router. */
|
|
150
|
+
const TRUE_VALUES = new Set(['true', 'True', 'TRUE', 'yes', 'Yes', 'YES', 'on', 'On', 'ON']);
|
|
151
|
+
/** The two characters a YAML scalar can be wrapped in whole. A value opens with at most one. */
|
|
152
|
+
const SCALAR_QUOTES = ['"', "'"];
|
|
153
|
+
// The escape character inside a DOUBLE-quoted scalar, and the only two sequences this reader
|
|
154
|
+
// resolves — `\"` for a quote and `\\` for a backslash. Every other `\x` is left as written,
|
|
155
|
+
// because inventing YAML's full escape table is a second thing two runtimes would have to
|
|
156
|
+
// agree about character for character.
|
|
157
|
+
const BACKSLASH = '\\';
|
|
158
|
+
const DOUBLE_ESCAPES = new Set(['"', BACKSLASH]);
|
|
159
|
+
/**
|
|
160
|
+
* The audit could not be run at all: the message names what was seen at `root`.
|
|
161
|
+
*
|
|
162
|
+
* Reserved for a failure of the SCAN. A file that will not decode, a block that will not
|
|
163
|
+
* parse and a plugin that is switched off are all recorded as omissions and counted, never
|
|
164
|
+
* raised: an audit that refuses because one of twenty-six files is malformed has told the
|
|
165
|
+
* operator nothing about the other twenty-five.
|
|
166
|
+
*/
|
|
167
|
+
export class SkillAuditError extends BantamError {
|
|
168
|
+
}
|
|
169
|
+
// -------------------------------------------------------------- Python string semantics
|
|
170
|
+
/**
|
|
171
|
+
* `str.strip()`, which is NOT `String.prototype.trim()` — measured, they disagree on six
|
|
172
|
+
* code points (`memory/factfile.ts`). A frontmatter line's indent is stripped with this.
|
|
173
|
+
*/
|
|
174
|
+
const PY_SPACE = new Set([
|
|
175
|
+
0x09, 0x0a, 0x0b, 0x0c, 0x0d, 0x1c, 0x1d, 0x1e, 0x1f, 0x20, 0x85, 0xa0, 0x1680, 0x2000,
|
|
176
|
+
0x2001, 0x2002, 0x2003, 0x2004, 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028,
|
|
177
|
+
0x2029, 0x202f, 0x205f, 0x3000,
|
|
178
|
+
]);
|
|
179
|
+
function pyStrip(text) {
|
|
180
|
+
const chars = [...text];
|
|
181
|
+
let start = 0;
|
|
182
|
+
let end = chars.length;
|
|
183
|
+
while (start < end && PY_SPACE.has(chars[start].codePointAt(0)))
|
|
184
|
+
start += 1;
|
|
185
|
+
while (end > start && PY_SPACE.has(chars[end - 1].codePointAt(0)))
|
|
186
|
+
end -= 1;
|
|
187
|
+
return chars.slice(start, end).join('');
|
|
188
|
+
}
|
|
189
|
+
/** `line[:1].isspace()`: whether the line is a FOLD rather than a key. */
|
|
190
|
+
function startsWithSpace(line) {
|
|
191
|
+
const first = line.codePointAt(0);
|
|
192
|
+
return first !== undefined && PY_SPACE.has(first);
|
|
193
|
+
}
|
|
194
|
+
/** `sorted()` on strings: by code point, which is not what `Array.sort` does above U+FFFF. */
|
|
195
|
+
function cmpCodepoint(a, b) {
|
|
196
|
+
const ia = a[Symbol.iterator]();
|
|
197
|
+
const ib = b[Symbol.iterator]();
|
|
198
|
+
for (;;) {
|
|
199
|
+
const x = ia.next();
|
|
200
|
+
const y = ib.next();
|
|
201
|
+
if (x.done && y.done)
|
|
202
|
+
return 0;
|
|
203
|
+
if (x.done)
|
|
204
|
+
return -1;
|
|
205
|
+
if (y.done)
|
|
206
|
+
return 1;
|
|
207
|
+
const cx = x.value.codePointAt(0);
|
|
208
|
+
const cy = y.value.codePointAt(0);
|
|
209
|
+
if (cx !== cy)
|
|
210
|
+
return cx - cy;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** `sorted(paths, key=lambda p: p.parts)`: a tuple comparison, segment by segment. */
|
|
214
|
+
function cmpParts(a, b) {
|
|
215
|
+
const shared = Math.min(a.length, b.length);
|
|
216
|
+
for (let i = 0; i < shared; i += 1) {
|
|
217
|
+
const order = cmpCodepoint(a[i], b[i]);
|
|
218
|
+
if (order !== 0)
|
|
219
|
+
return order;
|
|
220
|
+
}
|
|
221
|
+
return a.length - b.length;
|
|
222
|
+
}
|
|
223
|
+
/** `str.isalpha()` for ONE character: `L*` and nothing wider. */
|
|
224
|
+
const LETTER = /\p{L}/u;
|
|
225
|
+
/** `str.isalnum()`: `L*` plus every numeric category. */
|
|
226
|
+
const ALNUM = /[\p{L}\p{N}]/u;
|
|
227
|
+
/**
|
|
228
|
+
* A `SKILL.md` that was found and not counted, as a COUNT and the fact behind it.
|
|
229
|
+
*
|
|
230
|
+
* `size` is the UTF-8 description bytes those files would have added to the catalogue, `0`
|
|
231
|
+
* where that is not knowable — exactly the two cases where the description could not be read
|
|
232
|
+
* at all. `what` is the paths, relative to the root, in scan order.
|
|
233
|
+
*/
|
|
234
|
+
export class Omission {
|
|
235
|
+
subject;
|
|
236
|
+
count;
|
|
237
|
+
size;
|
|
238
|
+
what;
|
|
239
|
+
constructor(subject, count, size = 0, what = '') {
|
|
240
|
+
this.subject = subject;
|
|
241
|
+
this.count = count;
|
|
242
|
+
this.size = size;
|
|
243
|
+
this.what = what;
|
|
244
|
+
}
|
|
245
|
+
asDict() {
|
|
246
|
+
return { subject: this.subject, count: this.count, size: this.size, what: this.what };
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
/**
|
|
250
|
+
* One thing wrong with the catalogue.
|
|
251
|
+
*
|
|
252
|
+
* `severity` is not stored independently — it is `SEVERITY[kind]`, fixed per kind. `detail`
|
|
253
|
+
* is a machine fact and not a sentence. Wording for a person is Layer 2's job.
|
|
254
|
+
*/
|
|
255
|
+
export class Finding {
|
|
256
|
+
kind;
|
|
257
|
+
skills;
|
|
258
|
+
detail;
|
|
259
|
+
constructor(kind, skills, detail) {
|
|
260
|
+
this.kind = kind;
|
|
261
|
+
this.skills = skills;
|
|
262
|
+
this.detail = detail;
|
|
263
|
+
}
|
|
264
|
+
get severity() {
|
|
265
|
+
return SEVERITY[this.kind];
|
|
266
|
+
}
|
|
267
|
+
asDict() {
|
|
268
|
+
return { kind: this.kind, severity: this.severity, skills: [...this.skills], detail: this.detail };
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
/** The whole answer. `skills` plus every omission's `count` is every `SKILL.md` found. */
|
|
272
|
+
export class Audit {
|
|
273
|
+
roots;
|
|
274
|
+
skills;
|
|
275
|
+
catalogueBytes;
|
|
276
|
+
findings;
|
|
277
|
+
omissions;
|
|
278
|
+
constructor(roots, skills, catalogueBytes, findings = [], omissions = []) {
|
|
279
|
+
this.roots = roots;
|
|
280
|
+
this.skills = skills;
|
|
281
|
+
this.catalogueBytes = catalogueBytes;
|
|
282
|
+
this.findings = findings;
|
|
283
|
+
this.omissions = omissions;
|
|
284
|
+
}
|
|
285
|
+
asDict() {
|
|
286
|
+
return {
|
|
287
|
+
roots: [...this.roots],
|
|
288
|
+
skills: this.skills,
|
|
289
|
+
catalogue_bytes: this.catalogueBytes,
|
|
290
|
+
findings: this.findings.map((f) => f.asDict()),
|
|
291
|
+
omissions: this.omissions.map((o) => o.asDict()),
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
/**
|
|
295
|
+
* Two-space indent and no ASCII escaping, so the reference's
|
|
296
|
+
* `json.dumps(..., indent=2, ensure_ascii=False)` produces the same bytes.
|
|
297
|
+
*/
|
|
298
|
+
asJson() {
|
|
299
|
+
return JSON.stringify(this.asDict(), null, 2);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
const inPlugin = (s) => s.plugin !== '';
|
|
303
|
+
/**
|
|
304
|
+
* `<plugin>:<name>` — the spelling the host uses and the key `usage` is looked up by.
|
|
305
|
+
*
|
|
306
|
+
* The name is always the DIRECTORY's, never the frontmatter's: the host loads by directory,
|
|
307
|
+
* so a frontmatter `name:` that disagrees is the thing that is wrong (`name-mismatch`)
|
|
308
|
+
* rather than a second identity.
|
|
309
|
+
*/
|
|
310
|
+
const skillId = (s) => (inPlugin(s) ? `${s.plugin}:${s.directory}` : s.directory);
|
|
311
|
+
/** `<plugin>@<marketplace>`, the way settings.json spells an enabled plugin. */
|
|
312
|
+
const pluginId = (s) => `${s.plugin}@${s.marketplace}`;
|
|
313
|
+
const descriptionBytes = (s) => Buffer.byteLength(s.description, 'utf8');
|
|
314
|
+
// ------------------------------------------------------------------------------ the scan
|
|
315
|
+
/**
|
|
316
|
+
* One walk, two answers: every `SKILL.md` under `root` as relative path parts, and every
|
|
317
|
+
* VERSION DIRECTORY, both in a fixed order.
|
|
318
|
+
*
|
|
319
|
+
* `readdirSync` hands back directory entries in whatever order the filesystem chose, and two
|
|
320
|
+
* machines choose differently. Sorting is what makes the scan order — and therefore the
|
|
321
|
+
* omission `what` lists and the duplicate tie-break's "last wins" — the same everywhere.
|
|
322
|
+
* Symlinked directories are listed and NOT descended into, which is `os.walk`'s
|
|
323
|
+
* `followlinks=False`; a symlink loop is not a skill catalogue. A directory that will not
|
|
324
|
+
* open is skipped in silence, which is `onerror=None`.
|
|
325
|
+
*
|
|
326
|
+
* THE SECOND ANSWER IS WHY THIS IS NOT JUST A FILE LIST. A version directory declares itself
|
|
327
|
+
* by holding a `skills/` directory, and it declares itself whether or not anything under it
|
|
328
|
+
* can be read. Resolving over the files instead makes an EMPTY newer version invisible, so an
|
|
329
|
+
* older directory wins in silence; see `resolveVersions`. Only a directory this walk DESCENDS
|
|
330
|
+
* INTO is recorded, which is exactly the set `os.walk` yields as a `dirpath`, so a symlinked
|
|
331
|
+
* `skills/` declares nothing on either runtime.
|
|
332
|
+
*/
|
|
333
|
+
function scanTree(root) {
|
|
334
|
+
const found = [];
|
|
335
|
+
const versions = [];
|
|
336
|
+
const pending = [[]];
|
|
337
|
+
while (pending.length > 0) {
|
|
338
|
+
const rel = pending.pop();
|
|
339
|
+
if (rel.length === VERSION_PATH_SEGMENTS && rel[3] === SKILLS_SEGMENT) {
|
|
340
|
+
versions.push([rel[0], rel[1], rel[2]]);
|
|
341
|
+
}
|
|
342
|
+
let entries;
|
|
343
|
+
try {
|
|
344
|
+
entries = readdirSync(join(root, ...rel), { withFileTypes: true });
|
|
345
|
+
}
|
|
346
|
+
catch {
|
|
347
|
+
continue;
|
|
348
|
+
}
|
|
349
|
+
for (const entry of entries) {
|
|
350
|
+
const link = entry.isSymbolicLink();
|
|
351
|
+
// `os.scandir`'s `is_dir()` FOLLOWS a symlink (and answers False when the stat fails),
|
|
352
|
+
// which is what decides whether the name lands in `dirnames` or in `filenames`.
|
|
353
|
+
let directory = entry.isDirectory();
|
|
354
|
+
if (link) {
|
|
355
|
+
try {
|
|
356
|
+
directory = statSync(join(root, ...rel, entry.name)).isDirectory();
|
|
357
|
+
}
|
|
358
|
+
catch {
|
|
359
|
+
directory = false;
|
|
360
|
+
}
|
|
361
|
+
}
|
|
362
|
+
if (directory) {
|
|
363
|
+
if (!link)
|
|
364
|
+
pending.push([...rel, entry.name]);
|
|
365
|
+
}
|
|
366
|
+
else if (entry.name === SKILL_FILE) {
|
|
367
|
+
found.push([...rel, entry.name]);
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
found.sort(cmpParts);
|
|
372
|
+
versions.sort(cmpParts);
|
|
373
|
+
return { files: found, versions };
|
|
374
|
+
}
|
|
375
|
+
// -------------------------------------------------------------- the whole-value scalar
|
|
376
|
+
/**
|
|
377
|
+
* Index of the quote that CLOSES a scalar opened at index 0, or `-1` when none does.
|
|
378
|
+
*
|
|
379
|
+
* YAML has exactly two escape rules for this and this reader implements exactly two: inside a
|
|
380
|
+
* `"` scalar a backslash escapes whatever follows it, so `\"` does not close; inside a `'`
|
|
381
|
+
* scalar a doubled `''` is one literal apostrophe, so it does not close either. A quote that
|
|
382
|
+
* is never closed returns `-1`, which is what makes an unterminated value a literal.
|
|
383
|
+
*/
|
|
384
|
+
function scalarClose(value, quote) {
|
|
385
|
+
let index = 1;
|
|
386
|
+
while (index < value.length) {
|
|
387
|
+
const char = value[index];
|
|
388
|
+
if (quote === '"' && char === BACKSLASH) {
|
|
389
|
+
index += 2;
|
|
390
|
+
continue;
|
|
391
|
+
}
|
|
392
|
+
if (char === quote) {
|
|
393
|
+
if (quote === "'" && value.slice(index + 1, index + 2) === quote) {
|
|
394
|
+
index += 2;
|
|
395
|
+
continue;
|
|
396
|
+
}
|
|
397
|
+
return index;
|
|
398
|
+
}
|
|
399
|
+
index += 1;
|
|
400
|
+
}
|
|
401
|
+
return -1;
|
|
402
|
+
}
|
|
403
|
+
/**
|
|
404
|
+
* The content of a quoted scalar as the host's parser would hand it over.
|
|
405
|
+
*
|
|
406
|
+
* Only the escapes `scalarClose` honours are resolved, so the two functions cannot disagree
|
|
407
|
+
* about what was inside the scalar and what closed it.
|
|
408
|
+
*/
|
|
409
|
+
function unescape(content, quote) {
|
|
410
|
+
if (quote === "'")
|
|
411
|
+
return content.split("''").join("'");
|
|
412
|
+
let out = '';
|
|
413
|
+
let index = 0;
|
|
414
|
+
while (index < content.length) {
|
|
415
|
+
const char = content[index];
|
|
416
|
+
if (char === BACKSLASH && DOUBLE_ESCAPES.has(content.slice(index + 1, index + 2))) {
|
|
417
|
+
out += content[index + 1];
|
|
418
|
+
index += 2;
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
out += char;
|
|
422
|
+
index += 1;
|
|
423
|
+
}
|
|
424
|
+
return out;
|
|
425
|
+
}
|
|
426
|
+
/**
|
|
427
|
+
* A whole-value quoted YAML scalar without its quotes; every other value unchanged.
|
|
428
|
+
*
|
|
429
|
+
* `"Use when a test is \"flaky in prod\""` is one scalar and unwraps. `"a" and "b"` opens and
|
|
430
|
+
* ends with `"` and is NOT one — its first quote closes at index 2 — so it is left alone and
|
|
431
|
+
* both of its phrases survive. `"never closed` never closes and is left alone too.
|
|
432
|
+
*/
|
|
433
|
+
export function unwrapScalar(value) {
|
|
434
|
+
for (const quote of SCALAR_QUOTES) {
|
|
435
|
+
if (value.length < 2 || !value.startsWith(quote))
|
|
436
|
+
continue;
|
|
437
|
+
if (scalarClose(value, quote) === value.length - 1)
|
|
438
|
+
return unescape(value.slice(1, -1), quote);
|
|
439
|
+
}
|
|
440
|
+
return value;
|
|
441
|
+
}
|
|
442
|
+
// ------------------------------------------------------------------------ the frontmatter
|
|
443
|
+
/**
|
|
444
|
+
* The `---` block as a flat mapping, or `null` and the token saying why not.
|
|
445
|
+
*
|
|
446
|
+
* A deliberately small subset of YAML, and the smallness is the point: the alternative is a
|
|
447
|
+
* parser dependency that this half would have to match bug for bug. What is supported is what
|
|
448
|
+
* a `SKILL.md` frontmatter actually uses — top-level `key: value` pairs, and a value FOLDED
|
|
449
|
+
* over following indented lines, joined with single spaces the way YAML's folded scalar is.
|
|
450
|
+
*
|
|
451
|
+
* A leading BOM is stripped before the first line is examined: an editor that writes one has
|
|
452
|
+
* not thereby made the file's frontmatter malformed.
|
|
453
|
+
*
|
|
454
|
+
* Every value is unwrapped once the block closes, AFTER the fold and never during it: a
|
|
455
|
+
* scalar quoted whole may be folded over several lines, so its closing quote is not known
|
|
456
|
+
* until the last of them has been joined on. Unwrapping happens HERE rather than at
|
|
457
|
+
* `description:` alone because it is a fact about YAML scalars, and one rule in one place is
|
|
458
|
+
* one rule for either runtime to get wrong.
|
|
459
|
+
*/
|
|
460
|
+
function parseFrontmatter(text) {
|
|
461
|
+
let body = text;
|
|
462
|
+
if (body.startsWith(''))
|
|
463
|
+
body = body.slice(1);
|
|
464
|
+
const lines = body.split('\n').map((line) => (line.endsWith('\r') ? line.slice(0, -1) : line));
|
|
465
|
+
if (lines.length === 0 || pyStrip(lines[0]) !== FRONTMATTER_FENCE)
|
|
466
|
+
return [null, BAD_NO_BLOCK];
|
|
467
|
+
const fields = new Map();
|
|
468
|
+
let current = null;
|
|
469
|
+
for (const line of lines.slice(1)) {
|
|
470
|
+
if (pyStrip(line) === FRONTMATTER_FENCE) {
|
|
471
|
+
const unwrapped = new Map();
|
|
472
|
+
for (const [key, value] of fields)
|
|
473
|
+
unwrapped.set(key, unwrapScalar(value));
|
|
474
|
+
return [unwrapped, null];
|
|
475
|
+
}
|
|
476
|
+
if (pyStrip(line) === '') {
|
|
477
|
+
current = null;
|
|
478
|
+
continue;
|
|
479
|
+
}
|
|
480
|
+
if (startsWithSpace(line)) {
|
|
481
|
+
if (current !== null)
|
|
482
|
+
fields.set(current, pyStrip(`${fields.get(current)} ${pyStrip(line)}`));
|
|
483
|
+
continue;
|
|
484
|
+
}
|
|
485
|
+
// `str.partition(":")`: the FIRST colon, and no separator means no key at all.
|
|
486
|
+
const cut = line.indexOf(':');
|
|
487
|
+
const key = cut === -1 ? line : line.slice(0, cut);
|
|
488
|
+
const value = cut === -1 ? '' : line.slice(cut + 1);
|
|
489
|
+
if (cut === -1 || key === '' || pyStrip(key) !== key) {
|
|
490
|
+
current = null;
|
|
491
|
+
continue;
|
|
492
|
+
}
|
|
493
|
+
current = key;
|
|
494
|
+
fields.set(key, pyStrip(value));
|
|
495
|
+
}
|
|
496
|
+
return [null, BAD_UNTERMINATED];
|
|
497
|
+
}
|
|
498
|
+
// ----------------------------------------------------------------------------- phrases
|
|
499
|
+
/**
|
|
500
|
+
* `_is_letter(text, index)` on the reference, and `text` is a CODE POINT array here for the
|
|
501
|
+
* reason porting note 1 gives: `str[i]` in Python is the i-th code point, `str[i]` in
|
|
502
|
+
* JavaScript is the i-th UTF-16 code UNIT, and above U+FFFF those are not the same character.
|
|
503
|
+
* Indexing units hands `\p{L}` a lone surrogate, which is not a letter in any category, so a
|
|
504
|
+
* non-BMP letter beside an apostrophe stops flanking it and the apostrophe becomes a delimiter
|
|
505
|
+
* the reference suppressed. Measured on `trigger-kit/1.0.0/skills/astral-a` and `astral-b`:
|
|
506
|
+
* unit indexing invents a `shared-trigger-phrase` over `并发` that the reference does not emit.
|
|
507
|
+
*/
|
|
508
|
+
function isLetter(chars, index) {
|
|
509
|
+
if (index < 0 || index >= chars.length)
|
|
510
|
+
return false;
|
|
511
|
+
return LETTER.test(chars[index]);
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* The positions where `quote` opens or closes a phrase, in CODE POINTS.
|
|
515
|
+
*
|
|
516
|
+
* `"` always delimits. `'` delimits only where it is not flanked by letters on BOTH sides,
|
|
517
|
+
* which is what separates `'race condition'` from `don't`. Measured on the fixture tree: the
|
|
518
|
+
* naive rule reports a third `shared-trigger-phrase` over the junk string between two
|
|
519
|
+
* contractions, so this is the one line that separates a correct reader from a plausible one.
|
|
520
|
+
*/
|
|
521
|
+
function delimiters(chars, quote) {
|
|
522
|
+
const positions = [];
|
|
523
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
524
|
+
if (chars[index] !== quote)
|
|
525
|
+
continue;
|
|
526
|
+
if (quote === "'" && isLetter(chars, index - 1) && isLetter(chars, index + 1))
|
|
527
|
+
continue;
|
|
528
|
+
positions.push(index);
|
|
529
|
+
}
|
|
530
|
+
return positions;
|
|
531
|
+
}
|
|
532
|
+
/**
|
|
533
|
+
* A phrase with no letter and no digit in it is not a trigger phrase: `" - "` between two
|
|
534
|
+
* quoted phrases is punctuation, and reporting two skills that both use a dash as a collision
|
|
535
|
+
* is the metric failing, not a finding.
|
|
536
|
+
*/
|
|
537
|
+
const hasContent = (phrase) => ALNUM.test(phrase);
|
|
538
|
+
/**
|
|
539
|
+
* Every literal quoted phrase in `text`, in order, deduplicated.
|
|
540
|
+
*
|
|
541
|
+
* Delimiters are paired sequentially — first with second, third with fourth — per quote
|
|
542
|
+
* character, and an unpaired trailing delimiter opens nothing.
|
|
543
|
+
*/
|
|
544
|
+
export function phrases(text) {
|
|
545
|
+
// `list(text)`: every index below is a CODE POINT index, and every slice is rejoined from
|
|
546
|
+
// code points, because the reference indexes and slices code points. See `isLetter`.
|
|
547
|
+
const chars = [...text];
|
|
548
|
+
const found = [];
|
|
549
|
+
for (const quote of SCALAR_QUOTES) {
|
|
550
|
+
const positions = delimiters(chars, quote);
|
|
551
|
+
for (let i = 0; i + 1 < positions.length; i += 2) {
|
|
552
|
+
const phrase = chars.slice(positions[i] + 1, positions[i + 1]).join('');
|
|
553
|
+
if (hasContent(phrase) && !found.includes(phrase))
|
|
554
|
+
found.push(phrase);
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return found;
|
|
558
|
+
}
|
|
559
|
+
// -------------------------------------------------------------------------- one file
|
|
560
|
+
/** Python's `bytes.decode("utf-8")`: strict, and the BOM left where the parser expects it. */
|
|
561
|
+
const STRICT_UTF8 = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true });
|
|
562
|
+
/**
|
|
563
|
+
* `Path.name`, which is `basename` with every trailing `.` component dropped first.
|
|
564
|
+
*
|
|
565
|
+
* `PurePath` DROPS `.` components when it parses a path, so `Path('D/.')` and `Path('D/./')`
|
|
566
|
+
* are both the path `D` and their name is `D` — where `basename` answers `'.'` for the first
|
|
567
|
+
* and `'.'` for the second. Handling only the bare `.` (which is what this did until
|
|
568
|
+
* 2026-09-05) left `D/.` naming `''` on this side and `D` on the reference, and that name is
|
|
569
|
+
* the key `usage` is looked up by, so a caller's call counts would silently miss.
|
|
570
|
+
*
|
|
571
|
+
* Every other case follows from `basename`, and the loop stops where `dirname` stops moving:
|
|
572
|
+
* `Path('.')` and `Path('./.')` are both `''`, `Path('/')` is `''`, and `..` keeps its
|
|
573
|
+
* spelling on both sides — `Path('a/..').name` is `'..'`, not `'a'`, because `PurePath`
|
|
574
|
+
* parsing does not resolve it.
|
|
575
|
+
*
|
|
576
|
+
* It matters for exactly one input — a `SKILL.md` sitting directly at the root — where the
|
|
577
|
+
* root's own name is the only identity the file has.
|
|
578
|
+
*/
|
|
579
|
+
function pyPathName(path) {
|
|
580
|
+
let text = path;
|
|
581
|
+
for (;;) {
|
|
582
|
+
const base = basename(text);
|
|
583
|
+
if (base !== '.')
|
|
584
|
+
return base;
|
|
585
|
+
const parent = dirname(text);
|
|
586
|
+
if (parent === text)
|
|
587
|
+
return '';
|
|
588
|
+
text = parent;
|
|
589
|
+
}
|
|
590
|
+
}
|
|
591
|
+
/** One file read, decoded and parsed. Every failure lands in a field, none of them raise. */
|
|
592
|
+
function load(root, parts) {
|
|
593
|
+
const relpath = parts.join('/');
|
|
594
|
+
const skill = {
|
|
595
|
+
relpath,
|
|
596
|
+
// `parts[-2]` — or, for a `SKILL.md` sitting directly at the root, the root's own name.
|
|
597
|
+
directory: parts.length > 1 ? parts[parts.length - 2] : pyPathName(root),
|
|
598
|
+
marketplace: '',
|
|
599
|
+
plugin: '',
|
|
600
|
+
version: '',
|
|
601
|
+
description: '',
|
|
602
|
+
name: '',
|
|
603
|
+
router: false,
|
|
604
|
+
omitted: null,
|
|
605
|
+
malformed: null,
|
|
606
|
+
phrases: [],
|
|
607
|
+
};
|
|
608
|
+
if (parts.length === PLUGIN_PATH_SEGMENTS && parts[3] === SKILLS_SEGMENT) {
|
|
609
|
+
skill.marketplace = parts[0];
|
|
610
|
+
skill.plugin = parts[1];
|
|
611
|
+
skill.version = parts[2];
|
|
612
|
+
}
|
|
613
|
+
let text;
|
|
614
|
+
try {
|
|
615
|
+
text = STRICT_UTF8.decode(readFileSync(join(root, ...parts)));
|
|
616
|
+
}
|
|
617
|
+
catch {
|
|
618
|
+
// A byte no strict decoder accepts, or a file that would not open. NOT repaired with
|
|
619
|
+
// `U+FFFD` and not skipped in silence: what the catalogue would have paid for it is
|
|
620
|
+
// unknowable, so `size` is 0 and the path is the record.
|
|
621
|
+
skill.omitted = OMIT_UNREADABLE;
|
|
622
|
+
return skill;
|
|
623
|
+
}
|
|
624
|
+
const [fields, bad] = parseFrontmatter(text);
|
|
625
|
+
if (fields === null) {
|
|
626
|
+
skill.omitted = OMIT_UNPARSED;
|
|
627
|
+
skill.malformed = bad;
|
|
628
|
+
return skill;
|
|
629
|
+
}
|
|
630
|
+
skill.name = fields.get(KEY_NAME) ?? '';
|
|
631
|
+
skill.router = TRUE_VALUES.has(fields.get(KEY_ROUTER) ?? '');
|
|
632
|
+
if (!fields.has(KEY_DESCRIPTION)) {
|
|
633
|
+
// The block parses and carries no description. This one IS a skill — the host loads
|
|
634
|
+
// nothing from it per session, so it costs zero bytes — and the operator is still told.
|
|
635
|
+
skill.malformed = BAD_NO_DESCRIPTION;
|
|
636
|
+
return skill;
|
|
637
|
+
}
|
|
638
|
+
skill.description = fields.get(KEY_DESCRIPTION);
|
|
639
|
+
skill.phrases = phrases(skill.description);
|
|
640
|
+
return skill;
|
|
641
|
+
}
|
|
642
|
+
// -------------------------------------------------------------------- enabled and dedupe
|
|
643
|
+
/**
|
|
644
|
+
* Switch off every skill under a plugin the host does not have enabled.
|
|
645
|
+
*
|
|
646
|
+
* `enabled` omitted means every skill counts; `enabled` given and EMPTY means no plugin is
|
|
647
|
+
* on, which is a real state and not the same thing. A skill outside a plugin is never
|
|
648
|
+
* excluded here: a plugin list cannot speak to a skill that belongs to no plugin, and
|
|
649
|
+
* dropping one would under-report a personal skills directory by every file in it.
|
|
650
|
+
*/
|
|
651
|
+
function applyEnabled(found, enabled) {
|
|
652
|
+
if (enabled === null)
|
|
653
|
+
return;
|
|
654
|
+
const allowed = new Set(enabled);
|
|
655
|
+
for (const skill of found) {
|
|
656
|
+
if (skill.omitted === null && inPlugin(skill) && !allowed.has(pluginId(skill))) {
|
|
657
|
+
skill.omitted = OMIT_NOT_ENABLED;
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
/**
|
|
662
|
+
* Resolve ONE version directory per (marketplace, plugin), and omit every other one.
|
|
663
|
+
*
|
|
664
|
+
* This runs BEFORE `applyDedupe` and it is the whole fix for the resurrection defect: the
|
|
665
|
+
* winning version's skills are the plugin's skills, and a name absent from it is absent — it
|
|
666
|
+
* is never merged in from another directory, however many versions the cache still holds.
|
|
667
|
+
*
|
|
668
|
+
* The winner is the version directory name that sorts LAST in byte order. The loser's subject
|
|
669
|
+
* depends on whether the resolved version has a skill of the same name to stand in for it:
|
|
670
|
+
* `duplicate-skill` when it does, `stale-version` when it does not.
|
|
671
|
+
*
|
|
672
|
+
* **THE CANDIDATES ARE DIRECTORIES ON DISK, NOT SURVIVING SKILLS.** This function used to
|
|
673
|
+
* choose the winner from the skills that had come through `load` and `applyEnabled`, which is
|
|
674
|
+
* the resurrection defect arriving through the other door: a version directory holding no
|
|
675
|
+
* `SKILL.md`, or only files that would not decode or would not parse, contributed no skill, so
|
|
676
|
+
* it was never a candidate and an OLDER directory won in silence — no omission, no finding,
|
|
677
|
+
* and no mention of the directory the host actually serves. Measured 2026-09-05 with
|
|
678
|
+
* `mk/kit/2.0.0/skills/` empty beside a populated `1.0.0`: BOTH runtimes answered from
|
|
679
|
+
* `1.0.0` and said nothing, so the differential could not see it either. `versionDirs` comes
|
|
680
|
+
* from `scanTree` and names every directory that exists, whatever is under it.
|
|
681
|
+
*
|
|
682
|
+
* The losing case is then visible where every other skipped file already is: each skill under
|
|
683
|
+
* a directory that did not win gets an omission record, and when the winner serves nothing at
|
|
684
|
+
* all every one of them is a `stale-version`.
|
|
685
|
+
*
|
|
686
|
+
* **`versions` IS HOST TRUTH AND IT OVERRIDES THE BYTE ORDER.** Byte order is total, free and
|
|
687
|
+
* identical on two runtimes, and it is still a GUESS: `10.0.0` loses to `9.0.0`, and among
|
|
688
|
+
* names that are not versions the winner is arbitrary. The host does not guess — it records
|
|
689
|
+
* the directory it serves as `installPath` in `installed_plugins.json`, the same file the
|
|
690
|
+
* caller already reads `enabled` out of — so a caller that knows may say so, keyed the way
|
|
691
|
+
* `enabled` is keyed. Measured 2026-09-05: byte order picks `unknown` for `frontend-design`
|
|
692
|
+
* where the host serves `1dd995193ba2`, and the installed directory is discarded as a
|
|
693
|
+
* duplicate, harmless only because all nine copies carry a byte-identical description.
|
|
694
|
+
*
|
|
695
|
+
* An entry naming a plugin with no version directory under `root` does nothing; one naming a
|
|
696
|
+
* directory that is not there resolves to it anyway and every directory that IS there loses,
|
|
697
|
+
* which is the honest answer and is named in `stale-version` records. Neither is a refusal,
|
|
698
|
+
* for the reason `enabled` does not refuse an unknown plugin id either.
|
|
699
|
+
*
|
|
700
|
+
* A skill outside a plugin keys on two empty strings with an empty version, so every one of
|
|
701
|
+
* them is in the resolved version by construction and none is ever omitted here. No real path
|
|
702
|
+
* can produce that key — every path segment is non-empty — so it is seeded, not found, and the
|
|
703
|
+
* override loop skips it because it names no plugin.
|
|
704
|
+
*/
|
|
705
|
+
function resolveVersions(found, versionDirs, versions) {
|
|
706
|
+
// The same key spelling the dedupe uses, one field shorter. The separator is a NUL byte —
|
|
707
|
+
// written `\0` here, because the character itself is INVISIBLE in a comment and the
|
|
708
|
+
// sentence then reads as if a space were the separator. NUL cannot occur in a path
|
|
709
|
+
// segment on any platform, so it is the one separator that cannot collide.
|
|
710
|
+
const pluginKey = (s) => `${s.marketplace}${s.plugin}`;
|
|
711
|
+
const resolved = new Map([[``, '']]);
|
|
712
|
+
for (const [marketplace, plugin, version] of versionDirs) {
|
|
713
|
+
const key = `${marketplace}${plugin}`;
|
|
714
|
+
const held = resolved.get(key);
|
|
715
|
+
if (held === undefined || cmpCodepoint(version, held) > 0)
|
|
716
|
+
resolved.set(key, version);
|
|
717
|
+
}
|
|
718
|
+
if (versions !== null) {
|
|
719
|
+
for (const [marketplace, plugin] of [...resolved.keys()].map((k) => k.split('\x00'))) {
|
|
720
|
+
if (plugin === '')
|
|
721
|
+
continue;
|
|
722
|
+
const told = versions.get(`${plugin}@${marketplace}`);
|
|
723
|
+
if (told !== undefined)
|
|
724
|
+
resolved.set(`${marketplace}${plugin}`, told);
|
|
725
|
+
}
|
|
726
|
+
}
|
|
727
|
+
// The names the resolved version actually SERVES, which is a different question from which
|
|
728
|
+
// directory won: a winner whose files were all unreadable, unparsable or switched off serves
|
|
729
|
+
// nothing and is in no entry here. The `?? EMPTY` below is that case, and it is the one this
|
|
730
|
+
// function used to be unable to reach at all — it read `kept.get(key)!` and would have
|
|
731
|
+
// thrown on it.
|
|
732
|
+
const kept = new Map();
|
|
733
|
+
for (const skill of found) {
|
|
734
|
+
if (skill.omitted !== null || skill.version !== resolved.get(pluginKey(skill)))
|
|
735
|
+
continue;
|
|
736
|
+
let names = kept.get(pluginKey(skill));
|
|
737
|
+
if (names === undefined) {
|
|
738
|
+
names = new Set();
|
|
739
|
+
kept.set(pluginKey(skill), names);
|
|
740
|
+
}
|
|
741
|
+
names.add(skill.directory);
|
|
742
|
+
}
|
|
743
|
+
const EMPTY = new Set();
|
|
744
|
+
for (const skill of found) {
|
|
745
|
+
if (skill.omitted !== null || skill.version === resolved.get(pluginKey(skill)))
|
|
746
|
+
continue;
|
|
747
|
+
const standsIn = (kept.get(pluginKey(skill)) ?? EMPTY).has(skill.directory);
|
|
748
|
+
skill.omitted = standsIn ? OMIT_DUPLICATE : OMIT_STALE_VERSION;
|
|
749
|
+
}
|
|
750
|
+
}
|
|
751
|
+
/**
|
|
752
|
+
* One skill per (marketplace, plugin, name) INSIDE the resolved version; the last one wins.
|
|
753
|
+
*
|
|
754
|
+
* Only reachable for skills outside a plugin: inside one version directory a name is a
|
|
755
|
+
* directory name and the filesystem has already made it unique. Two files claiming the same
|
|
756
|
+
* bare name are still a duplicate, and saying so is better than counting a personal skill
|
|
757
|
+
* twice. Scan order decides, which is path order and therefore the same on both runtimes.
|
|
758
|
+
*/
|
|
759
|
+
function applyDedupe(found) {
|
|
760
|
+
const winners = new Map();
|
|
761
|
+
for (const skill of found) {
|
|
762
|
+
if (skill.omitted !== null)
|
|
763
|
+
continue;
|
|
764
|
+
// The key is the (marketplace, plugin, name) TRIPLE, and a Map takes one string. The
|
|
765
|
+
// separator is a NUL byte — written `\0` here, because the character itself is INVISIBLE
|
|
766
|
+
// in a comment and the sentence then reads as if a space were the separator. NUL cannot
|
|
767
|
+
// occur in a path segment on any platform, so it is the one separator that cannot collide.
|
|
768
|
+
const key = [skill.marketplace, skill.plugin, skill.directory].join('');
|
|
769
|
+
const held = winners.get(key);
|
|
770
|
+
if (held !== undefined)
|
|
771
|
+
held.omitted = OMIT_DUPLICATE;
|
|
772
|
+
winners.set(key, skill);
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
// ------------------------------------------------------------------------- the findings
|
|
776
|
+
/** Phrases held by two or more skills. Routers are not in this index at all. */
|
|
777
|
+
function sharedPhraseFindings(counted) {
|
|
778
|
+
const index = new Map();
|
|
779
|
+
for (const skill of counted) {
|
|
780
|
+
if (skill.router)
|
|
781
|
+
continue;
|
|
782
|
+
for (const phrase of skill.phrases) {
|
|
783
|
+
let holders = index.get(phrase);
|
|
784
|
+
if (holders === undefined) {
|
|
785
|
+
holders = [];
|
|
786
|
+
index.set(phrase, holders);
|
|
787
|
+
}
|
|
788
|
+
const id = skillId(skill);
|
|
789
|
+
if (!holders.includes(id))
|
|
790
|
+
holders.push(id);
|
|
791
|
+
}
|
|
792
|
+
}
|
|
793
|
+
return [...index.keys()]
|
|
794
|
+
.sort(cmpCodepoint)
|
|
795
|
+
.filter((phrase) => index.get(phrase).length > 1)
|
|
796
|
+
.map((phrase) => new Finding(KIND_SHARED_PHRASE, [...index.get(phrase)].sort(cmpCodepoint), phrase));
|
|
797
|
+
}
|
|
798
|
+
/** One record per subject, carrying the count, the bytes and the paths. */
|
|
799
|
+
function omissionRecords(found) {
|
|
800
|
+
const records = [];
|
|
801
|
+
for (const subject of OMISSION_ORDER) {
|
|
802
|
+
const members = found.filter((s) => s.omitted === subject);
|
|
803
|
+
if (members.length === 0)
|
|
804
|
+
continue;
|
|
805
|
+
records.push(new Omission(subject, members.length, members.reduce((sum, s) => sum + descriptionBytes(s), 0), members.map((s) => s.relpath).join(', ')));
|
|
806
|
+
}
|
|
807
|
+
return records;
|
|
808
|
+
}
|
|
809
|
+
/**
|
|
810
|
+
* The scan, decided but not yet reported: every `SKILL.md` found, with `enabled`, the version
|
|
811
|
+
* resolution and the dedupe already applied. `skillaudit._scan` on the reference.
|
|
812
|
+
*
|
|
813
|
+
* ONE seam rather than four calls in a fixed order, because the order IS the rule — the
|
|
814
|
+
* version resolution runs after `enabled` and before the dedupe — and a caller reproducing it
|
|
815
|
+
* by hand can get it wrong or miss an argument the four grow later. The per-skill byte table
|
|
816
|
+
* is the only oracle that can tell a headline that is right from a headline that is right for
|
|
817
|
+
* two cancelling reasons: `audit` is the product surface, this is how a test asks which skill
|
|
818
|
+
* paid what.
|
|
819
|
+
*/
|
|
820
|
+
function decide(root, enabled, versions) {
|
|
821
|
+
const tree = scanTree(root);
|
|
822
|
+
const found = tree.files.map((parts) => load(root, parts));
|
|
823
|
+
applyEnabled(found, enabled);
|
|
824
|
+
resolveVersions(found, tree.versions, versions);
|
|
825
|
+
applyDedupe(found);
|
|
826
|
+
return found;
|
|
827
|
+
}
|
|
828
|
+
export function scan(root, enabled = null, versions = null) {
|
|
829
|
+
return decide(root, enabled, versions).map((s) => ({
|
|
830
|
+
id: skillId(s),
|
|
831
|
+
relpath: s.relpath,
|
|
832
|
+
directory: s.directory,
|
|
833
|
+
marketplace: s.marketplace,
|
|
834
|
+
plugin: s.plugin,
|
|
835
|
+
version: s.version,
|
|
836
|
+
description: s.description,
|
|
837
|
+
name: s.name,
|
|
838
|
+
router: s.router,
|
|
839
|
+
omitted: s.omitted,
|
|
840
|
+
malformed: s.malformed,
|
|
841
|
+
phrases: s.phrases,
|
|
842
|
+
bytes: descriptionBytes(s),
|
|
843
|
+
}));
|
|
844
|
+
}
|
|
845
|
+
export function audit(root, options = {}) {
|
|
846
|
+
const enabled = options.enabled ?? null;
|
|
847
|
+
const usage = options.usage ?? null;
|
|
848
|
+
const check = options.check ?? 'all';
|
|
849
|
+
const budget = options.budget === undefined || options.budget === null ? null : BigInt(options.budget);
|
|
850
|
+
const versions = options.versions ?? null;
|
|
851
|
+
if (!Object.prototype.hasOwnProperty.call(CHECK_FAMILIES, check)) {
|
|
852
|
+
throw new SkillAuditError(`unknown check ${reprValue({ t: 'str', v: check })}; this tool checks: ` +
|
|
853
|
+
`${Object.keys(CHECK_FAMILIES).sort(cmpCodepoint).join(', ')}`);
|
|
854
|
+
}
|
|
855
|
+
if (budget !== null && budget < 0n)
|
|
856
|
+
throw new SkillAuditError(`budget must not be negative; got ${budget}`);
|
|
857
|
+
// `Path("")` is `Path(".")` on the reference, so an empty root audited whatever directory
|
|
858
|
+
// the SERVER happened to be standing in — a different answer per host, out of a tool whose
|
|
859
|
+
// whole claim is that it reads `root` and nothing else — while `statSync('')` throws here
|
|
860
|
+
// and answered `no such directory: `. `''` passes JSON-schema `string` and pydantic `str`
|
|
861
|
+
// alike, so it reaches the module; both refuse it in the same sentence now.
|
|
862
|
+
if (root === '')
|
|
863
|
+
throw new SkillAuditError('root must not be empty; name the directory of skills to scan');
|
|
864
|
+
let stats;
|
|
865
|
+
try {
|
|
866
|
+
stats = statSync(root);
|
|
867
|
+
}
|
|
868
|
+
catch {
|
|
869
|
+
throw new SkillAuditError(`no such directory: ${root}`);
|
|
870
|
+
}
|
|
871
|
+
if (!stats.isDirectory())
|
|
872
|
+
throw new SkillAuditError(`${root} is a file, not a directory of skills`);
|
|
873
|
+
const found = decide(root, enabled, versions);
|
|
874
|
+
const counted = found.filter((s) => s.omitted === null);
|
|
875
|
+
const catalogueBytes = counted.reduce((sum, s) => sum + descriptionBytes(s), 0);
|
|
876
|
+
const wanted = new Set(CHECK_FAMILIES[check]);
|
|
877
|
+
const findings = [];
|
|
878
|
+
if (wanted.has(KIND_SHARED_PHRASE))
|
|
879
|
+
findings.push(...sharedPhraseFindings(counted));
|
|
880
|
+
if (wanted.has(KIND_OVER_BUDGET) && budget !== null && BigInt(catalogueBytes) > budget) {
|
|
881
|
+
const over = BigInt(catalogueBytes) - budget;
|
|
882
|
+
findings.push(new Finding(KIND_OVER_BUDGET, [], `${catalogueBytes} > ${budget}, over by ${over}`));
|
|
883
|
+
}
|
|
884
|
+
const byId = (a, b) => cmpCodepoint(skillId(a), skillId(b));
|
|
885
|
+
if (wanted.has(KIND_NEVER_INVOKED) && usage !== null) {
|
|
886
|
+
for (const skill of [...counted].sort(byId)) {
|
|
887
|
+
// `usage.get(id, 0) == 0` on the reference, which never raises: a float compares, and
|
|
888
|
+
// `0.0 == 0` is True. This used to be `BigInt(calls ?? 0) === 0n`, which throws an
|
|
889
|
+
// UNCAUGHT `RangeError` on any fractional value where the reference simply answers.
|
|
890
|
+
// Not reachable through MCP — `pyargs` refuses a non-integer first — but `audit` is an
|
|
891
|
+
// exported entry point and the conformance suite calls it directly.
|
|
892
|
+
const calls = usage.get(skillId(skill)) ?? 0;
|
|
893
|
+
const uncalled = typeof calls === 'bigint' ? calls === 0n : calls === 0;
|
|
894
|
+
if (uncalled)
|
|
895
|
+
findings.push(new Finding(KIND_NEVER_INVOKED, [skillId(skill)], '0 calls'));
|
|
896
|
+
}
|
|
897
|
+
}
|
|
898
|
+
if (wanted.has(KIND_FRONTMATTER)) {
|
|
899
|
+
for (const skill of [...found].sort(byId)) {
|
|
900
|
+
if (skill.malformed !== null)
|
|
901
|
+
findings.push(new Finding(KIND_FRONTMATTER, [skillId(skill)], skill.malformed));
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
if (wanted.has(KIND_NAME_MISMATCH)) {
|
|
905
|
+
for (const skill of [...counted].sort(byId)) {
|
|
906
|
+
if (skill.name !== '' && skill.name !== skill.directory) {
|
|
907
|
+
findings.push(new Finding(KIND_NAME_MISMATCH, [skillId(skill)], skill.name));
|
|
908
|
+
}
|
|
909
|
+
}
|
|
910
|
+
}
|
|
911
|
+
// `list.sort` is stable on both sides, so within a kind the order above survives.
|
|
912
|
+
findings.sort((a, b) => FINDING_ORDER.indexOf(a.kind) - FINDING_ORDER.indexOf(b.kind));
|
|
913
|
+
return new Audit([root], counted.length, catalogueBytes, findings, omissionRecords(found));
|
|
914
|
+
}
|