efaimo 0.1.2 → 0.3.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/CHANGELOG.md +160 -1
- package/README.md +75 -6
- package/SKILL.md +14 -7
- package/dist/check/diff.d.ts +124 -0
- package/dist/check/diff.js +165 -0
- package/dist/cli.js +237 -22
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +144 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +122 -0
- package/dist/find/find.js +405 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +7 -3
- package/dist/index.js +14 -3
- package/dist/reporters/json.d.ts +19 -1
- package/dist/reporters/json.js +13 -2
- package/dist/reporters/markdown.d.ts +2 -1
- package/dist/reporters/markdown.js +109 -11
- package/dist/reporters/pretty.d.ts +25 -1
- package/dist/reporters/pretty.js +228 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- package/dist/rules/skill/index.js +32 -1
- package/dist/rules/version.d.ts +26 -0
- package/dist/rules/version.js +26 -0
- package/dist/testing/anthropicRunner.d.ts +30 -0
- package/dist/testing/anthropicRunner.js +43 -8
- package/dist/testing/harness.d.ts +32 -1
- package/dist/testing/harness.js +28 -3
- package/dist/testing/stats.d.ts +16 -0
- package/dist/testing/stats.js +26 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/anthropicExact.d.ts +12 -1
- package/dist/weigh/anthropicExact.js +9 -1
- package/dist/weigh/weigh.js +10 -3
- package/docs/INTEGRATIONS.md +15 -1
- package/docs/METHODOLOGY.md +110 -6
- package/docs/RULES.md +40 -3
- package/examples/scenario.crm.yaml +8 -0
- package/examples/scenario.example.yaml +8 -0
- package/package.json +3 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,164 @@ All notable changes to efaimo are documented here. Format follows
|
|
|
4
4
|
[Keep a Changelog](https://keepachangelog.com/en/1.1.0/); this project uses
|
|
5
5
|
[Semantic Versioning](https://semver.org/).
|
|
6
6
|
|
|
7
|
+
## [0.3.0] - 2026-09-03
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`check --out` and `check --diff`**: what moved between two
|
|
12
|
+
audits of the same subject. Same grammar as `weigh --out` / `weigh --diff`,
|
|
13
|
+
which is why it is a flag rather than a sixth subcommand. Nothing published
|
|
14
|
+
says whether skills and MCP servers are getting heavier or whether their
|
|
15
|
+
quality holds, because that needs the same thing measured twice and nothing
|
|
16
|
+
made the comparison cheap.
|
|
17
|
+
|
|
18
|
+
A delta is exactly the shape a broken measurement takes, so the controls a
|
|
19
|
+
careful person would run by hand are enforced instead of documented:
|
|
20
|
+
|
|
21
|
+
- **Rules drift is a hard stop.** Different `rulesVersion` between the two
|
|
22
|
+
runs means a moved grade cannot be attributed to the subject, because the
|
|
23
|
+
ruler moved too. `--allow-rules-drift` proceeds and marks
|
|
24
|
+
every grade line unattributable.
|
|
25
|
+
- **An empty pairing fails.** Nothing in common is two reports about
|
|
26
|
+
different subjects, not "no change".
|
|
27
|
+
- **The three token costs are never summed.** Metadata is carried
|
|
28
|
+
permanently, the body loads on trigger, referenced files load on demand;
|
|
29
|
+
one combined percentage would describe none of them.
|
|
30
|
+
- **A dominant mover is named beside its total**, because when one subject is
|
|
31
|
+
most of a change, the total is about that subject and not the population.
|
|
32
|
+
|
|
33
|
+
`--fail-on-regression` exits 1 when any subject present in
|
|
34
|
+
both runs scored lower.
|
|
35
|
+
|
|
36
|
+
Measured on the public skills corpus, 2026-07-17 pinned against 2026-09-03
|
|
37
|
+
HEAD, same ruleset both sides: metadata -0.8%, body +7.5%, referenced files
|
|
38
|
+
+61.4% with one skill at 96.5% of that movement; grades 0 improved and 4
|
|
39
|
+
worsened.
|
|
40
|
+
|
|
41
|
+
- **`efaimo find` takes several servers**. Pass more than one
|
|
42
|
+
and their catalogs are merged into one and measured together, each tool
|
|
43
|
+
labelled with where it came from. A flag-shaped change rather than a new
|
|
44
|
+
subcommand, because the engine already took a flat tool array and only the
|
|
45
|
+
CLI surface was single-target.
|
|
46
|
+
|
|
47
|
+
The reason it is worth having: a server's author keeps their own tool names
|
|
48
|
+
apart as a matter of course, nobody coordinates across the several servers a
|
|
49
|
+
person actually installs, and the model sees one flat list with no
|
|
50
|
+
indication of origin. A single-server run is structurally unable to see the
|
|
51
|
+
collision that actually bites.
|
|
52
|
+
|
|
53
|
+
The origin is carried for the report and deliberately kept out of the index.
|
|
54
|
+
Prefixing names with their server would hand every tool a term no other tool
|
|
55
|
+
has, and a catalog of indistinguishable tools would score a perfect 100.
|
|
56
|
+
There is a test for exactly that, and it had to be rewritten once: its first
|
|
57
|
+
origin labels were "server-one" and "server-two", whose only surviving token
|
|
58
|
+
is "server" for both, so the sabotage it existed to catch passed.
|
|
59
|
+
|
|
60
|
+
Measured live on `server-everything@2026.7.4` plus `@playwright/mcp@0.0.78`:
|
|
61
|
+
37 tools merged, 35 distinct (94.6%). No cross-server collision between
|
|
62
|
+
those two, which is the right answer for a filesystem server and a browser
|
|
63
|
+
server, and the two tools owning nothing are playwright's own already
|
|
64
|
+
documented `browser_close` and `browser_navigate`.
|
|
65
|
+
|
|
66
|
+
### Fixed
|
|
67
|
+
|
|
68
|
+
- **S102 no longer passes filler**. The rule read
|
|
69
|
+
`description.length < 20` and then asked only whether one of four trigger
|
|
70
|
+
words appeared anywhere, so `Useful for various tasks.` scored a clean
|
|
71
|
+
A (100) in silence because "for" sits inside "for various tasks". It was
|
|
72
|
+
testing for the presence of a trigger WORD rather than for the presence of a
|
|
73
|
+
trigger. A skill whose whole body was one sentence also scored 100.
|
|
74
|
+
|
|
75
|
+
Two floors replace it, both grounded on the 38 public skills in
|
|
76
|
+
`research/skills-index/manifest.json` measured 2026-09-03, where the shortest
|
|
77
|
+
real description is 68 characters and the thinnest carries 4 distinct content
|
|
78
|
+
terms: under 40 characters, or fewer than 4 distinct terms surviving the
|
|
79
|
+
stopword list, is a warning. The second is the same measurement `find` makes
|
|
80
|
+
on tool catalogs, since a description with no distinguishing content cannot
|
|
81
|
+
be matched to a task whatever words it contains.
|
|
82
|
+
|
|
83
|
+
Impact on the corpus: none. The 38 skills produce zero S102 findings before
|
|
84
|
+
and after, and the grade distribution is unchanged. This was found by
|
|
85
|
+
sabotaging the grader while reviewing two new skills, not by a report.
|
|
86
|
+
|
|
87
|
+
Note that this changes grades in principle, so it is deliberately sequenced
|
|
88
|
+
after the 2026-09-03 delta measurement, whose reproduction is pinned to
|
|
89
|
+
`v0.2.0`.
|
|
90
|
+
|
|
91
|
+
### Known
|
|
92
|
+
|
|
93
|
+
- **`check --skill <dir>` does not find every skill under a tree**
|
|
94
|
+
_(present in 0.2.0 as well)_. It walks
|
|
95
|
+
`<root>/<name>/SKILL.md` and `<root>/skills/<name>/SKILL.md` but misses skills
|
|
96
|
+
inside a dot directory (`.claude/skills/<name>/`) and skills one level deeper
|
|
97
|
+
(`skills/custom_skills/<name>/`). On the corpus above it found 34 where
|
|
98
|
+
`scripts/skills-index.mjs` found 38, all four in the same repository. Two of
|
|
99
|
+
this tool's own walkers disagreeing about the same tree is a defect whichever
|
|
100
|
+
is right; a `--diff` built on the narrower walk silently compares an
|
|
101
|
+
incomplete population. Not fixed here because the fix is a design decision
|
|
102
|
+
about depth and dot directories, separate from this change.
|
|
103
|
+
|
|
104
|
+
## [0.2.0] - 2026-09-02
|
|
105
|
+
|
|
106
|
+
### Added
|
|
107
|
+
|
|
108
|
+
- **`efaimo find`**: does anything actually surface these tools? A host can
|
|
109
|
+
mark tools `defer_loading: true`, which keeps their definitions out of the
|
|
110
|
+
context window until a search returns them, and Anthropic recommends that
|
|
111
|
+
once definitions pass ~10k tokens. Under deferral a tool nothing surfaces
|
|
112
|
+
costs no context and provides no capability. Two numbers, offline and
|
|
113
|
+
deterministic, no API key:
|
|
114
|
+
- `distinct`, the headline: how many tools own a term no other tool in the
|
|
115
|
+
catalog has. A tool with none cannot be matched by any query that does not
|
|
116
|
+
also match a competitor, which follows from the index rather than from a
|
|
117
|
+
model of how anyone searches. Measured on `@playwright/mcp` 0.0.78: 22 of
|
|
118
|
+
24, with `browser_close` and `browser_navigate` owning nothing.
|
|
119
|
+
- `probe`, secondary and labelled: a simulated BM25 search over the four
|
|
120
|
+
fields the tool search documentation names as searchable. It reads 100% on
|
|
121
|
+
both the official reference server and playwright, so the output says on
|
|
122
|
+
the page that it is a floor test and not a ranking.
|
|
123
|
+
- New ungraded rule family E141-E145, `--min-distinct` as the CI gate,
|
|
124
|
+
`--top` to change the simulated result window, `--json` / `--md`.
|
|
125
|
+
Reasoning in ADR-030; method and its limits in docs/METHODOLOGY.md.
|
|
126
|
+
- `rulesVersion` in every JSON envelope, because the tool version does not
|
|
127
|
+
identify a ruleset: a patch release can change what a rule fires on, and a
|
|
128
|
+
published grade is only reproducible next to the rules that produced it. The
|
|
129
|
+
rule inventory is pinned by a test (ADR-032).
|
|
130
|
+
- `--judge-model` and scenario key `judge_model` for `efaimo test`, so the
|
|
131
|
+
model under test no longer has to grade its own answers. Subject and judge
|
|
132
|
+
are routed independently and may be different providers.
|
|
133
|
+
- `--no-timestamp` on `weigh`, `check` and `find`.
|
|
134
|
+
|
|
135
|
+
### Fixed
|
|
136
|
+
|
|
137
|
+
- **`efaimo test --live` could not run on its own default model.** The runner
|
|
138
|
+
sent `temperature` on every request, and Claude removed sampling parameters
|
|
139
|
+
from the 4.7 line onward, so `claude-sonnet-5` rejected the first call with a
|
|
140
|
+
400 that is correctly not retried. Sampling parameters now go only to models
|
|
141
|
+
that still accept them, the request body is a pure function with a test
|
|
142
|
+
against it, and where the judge cannot be pinned the report says so instead
|
|
143
|
+
of implying a determinism it does not have. ADR-031.
|
|
144
|
+
- `efaimo test` reports a Fisher exact p on the **unparseable** counts as well
|
|
145
|
+
as on the passes, and calls a run `inconclusive` when the excluded trials are
|
|
146
|
+
skewed across the arms: two pass rates computed over different populations
|
|
147
|
+
cannot be subtracted.
|
|
148
|
+
- `--anthropic` names the model it measured against, in the output and in the
|
|
149
|
+
JSON. Claude model lines do not share a tokenizer, so a count without its
|
|
150
|
+
model cannot be reproduced. This is the rule `--window` already followed for
|
|
151
|
+
the context-window denominator.
|
|
152
|
+
- `weigh --out` no longer writes `generatedAt` into the baseline. A baseline
|
|
153
|
+
exists to be compared against, and a field that differs on every write is the
|
|
154
|
+
one field a comparison must ignore.
|
|
155
|
+
|
|
156
|
+
### Changed
|
|
157
|
+
|
|
158
|
+
- docs/RULES.md now cites Anthropic's tool search documentation for E125's
|
|
159
|
+
lower threshold, which previously carried no source at all (the MCP quality
|
|
160
|
+
table has never had a source column). The thresholds themselves are
|
|
161
|
+
unchanged, and the citation covers the 30 boundary only: Anthropic writes
|
|
162
|
+
that tool selection "degrades once you exceed 30-50 available tools", which
|
|
163
|
+
says nothing about the 60 boundary, and the table says so.
|
|
164
|
+
|
|
7
165
|
## [0.1.2] - 2026-08-03
|
|
8
166
|
|
|
9
167
|
The 0.1.2 code backlog: edge-case fixes surfaced by the five-agent review, none
|
|
@@ -197,7 +355,8 @@ First release.
|
|
|
197
355
|
Documented rule set (`docs/RULES.md`), token methodology (`docs/METHODOLOGY.md`),
|
|
198
356
|
and integration guide (`docs/INTEGRATIONS.md`).
|
|
199
357
|
|
|
200
|
-
[
|
|
358
|
+
[0.3.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.3.0
|
|
359
|
+
[0.2.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.2.0
|
|
201
360
|
[0.1.2]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.2
|
|
202
361
|
[0.1.1]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.1
|
|
203
362
|
[0.1.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -58,6 +58,13 @@ instructions, and the median skill's instructions run ~1,632 tokens loaded on
|
|
|
58
58
|
every trigger. Full report, the corpus manifest, and the two commands that
|
|
59
59
|
reproduce it: [the Skills Quality Index](./research/skills-index/REPORT.md).
|
|
60
60
|
|
|
61
|
+
That index is a photograph. [Measured twice](./research/skills-delta-2026-09-03/REPORT.md)
|
|
62
|
+
re-runs the same corpus seven weeks later with the same ruleset: the cost that
|
|
63
|
+
sits in context permanently moved -0.8%, the body +7.0%, and referenced files,
|
|
64
|
+
which load out of sight and which no rule here counts, +61.4%. Grades moved one
|
|
65
|
+
way only, 0 improved against 4 worse. The uncomfortable part points inward: the
|
|
66
|
+
two columns this tool has thresholds for are the two that held.
|
|
67
|
+
|
|
61
68
|
### Does the skill actually help? (experimental)
|
|
62
69
|
|
|
63
70
|
Linting tells you a skill is well-formed. It does not tell you the skill makes the
|
|
@@ -176,6 +183,56 @@ grade A (95) quality: 0 errors 1 warning 0 info
|
|
|
176
183
|
|
|
177
184
|
</details>
|
|
178
185
|
|
|
186
|
+
### Can a search even single your tools out?
|
|
187
|
+
|
|
188
|
+
A host can mark tools `defer_loading: true`, which keeps their definitions out of
|
|
189
|
+
the context window until a search returns them; Anthropic recommends turning that
|
|
190
|
+
on once tool definitions pass ~10k tokens. Under deferral, a tool nothing
|
|
191
|
+
surfaces costs no context and provides no capability, and neither `weigh` nor
|
|
192
|
+
`check` looks at that.
|
|
193
|
+
|
|
194
|
+
```bash
|
|
195
|
+
npx efaimo find "npx -y my-mcp-server"
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
Two numbers come back, and they are different kinds of claim.
|
|
199
|
+
|
|
200
|
+
**`distinct`** is a property of your catalog: how many tools own a term no other
|
|
201
|
+
tool has. A tool that owns none cannot be matched by any query that does not also
|
|
202
|
+
match a competitor. That follows from the index, not from a model of how anyone
|
|
203
|
+
searches, and it is the number `--min-distinct` gates on in CI.
|
|
204
|
+
|
|
205
|
+
```text
|
|
206
|
+
distinct 22/24 (91.7%) tools that own a word no other tool has
|
|
207
|
+
probe 24/24 (100%) returned by a simulated search for their own description
|
|
208
|
+
|
|
209
|
+
words tool vocabulary no other tool has
|
|
210
|
+
0 browser_close none: every word also on browser_click, browser_drag, browser_drop
|
|
211
|
+
0 browser_navigate none: every word also on browser_tabs, browser_navigate_back, browser_network_requests
|
|
212
|
+
1 browser_evaluate evaluate
|
|
213
|
+
1 browser_hover hover
|
|
214
|
+
2 browser_resize window, resize
|
|
215
|
+
```
|
|
216
|
+
|
|
217
|
+
*(That is `@playwright/mcp` 0.0.78, a well-maintained server, verbatim. Two of
|
|
218
|
+
its twenty-four tools have no word of their own, and two more own only the word
|
|
219
|
+
in their own name, which nobody describing a task would type.)*
|
|
220
|
+
|
|
221
|
+
Four public servers measured this way, with the commands that reproduce each
|
|
222
|
+
row, are in [the findability report](./research/findability/REPORT.md). The
|
|
223
|
+
weakest was Notion's official server at 16 of 24, where `API-get-user` and
|
|
224
|
+
`API-get-users` are different tools and nothing in their vocabulary separates
|
|
225
|
+
them.
|
|
226
|
+
|
|
227
|
+
**`probe`** is a simulated BM25 search over the four fields tool search matches
|
|
228
|
+
on, and it is labelled a simulation because it is one. It reads 100% on both the
|
|
229
|
+
official reference server and playwright, so it is a floor test that catches an
|
|
230
|
+
empty description or a literal duplicate, not a ranking. `find` says that on
|
|
231
|
+
every run rather than letting a saturated number look like a good score.
|
|
232
|
+
|
|
233
|
+
There is no findability grade, on purpose:
|
|
234
|
+
[docs/METHODOLOGY.md](./docs/METHODOLOGY.md) has the method, its limits, and why.
|
|
235
|
+
|
|
179
236
|
## From an agent
|
|
180
237
|
|
|
181
238
|
`efaimo mcp` runs efaimo as a small, read-only MCP server, so an agent can lint or
|
|
@@ -252,8 +309,16 @@ And a mitigation you cannot measure is a mitigation you are trusting on faith:
|
|
|
252
309
|
`weigh` reports the definitions your server actually ships, which is the input
|
|
253
310
|
every mitigation operates on.
|
|
254
311
|
|
|
255
|
-
|
|
256
|
-
|
|
312
|
+
**And there is a fourth reason we did not have when this section was written.** Deferring a tool does not remove the question, it replaces
|
|
313
|
+
it: a deferred tool that no search returns costs nothing and does nothing, so
|
|
314
|
+
the thing worth measuring becomes whether each tool can be picked out of the
|
|
315
|
+
catalog at all. `efaimo find` measures that, and on four public servers it
|
|
316
|
+
found two to eight tools apiece that own no word their neighbours lack.
|
|
317
|
+
Deferral is what makes findability matter, not what makes it moot.
|
|
318
|
+
|
|
319
|
+
If deferred loading covers your whole setup, run `weigh` and `find` once and
|
|
320
|
+
see what it costs you and what it hides; those answers are worth more than our
|
|
321
|
+
opinion.
|
|
257
322
|
|
|
258
323
|
## Install
|
|
259
324
|
|
|
@@ -296,6 +361,7 @@ More recipes (pre-commit, GitLab, editor audit, programmatic use):
|
|
|
296
361
|
| **S101 to S106** | skills: frontmatter and trigger quality, trigger collisions, context budget, reference integrity, injection hygiene |
|
|
297
362
|
| **E101 to E118** | MCP 2026-07-28 readiness: deprecated primitives, statelessness, `server/discover`, `resultType`, cache fields, transport |
|
|
298
363
|
| **E121 to E128, E130** | MCP quality: description quality, annotations, schema hygiene, tool-count and token-cost budgets |
|
|
364
|
+
| **E141 to E146** | MCP findability: exclusive vocabulary, whether a search returns the tool, indistinguishable pairs, names that carry no domain word |
|
|
299
365
|
|
|
300
366
|
`E000` is not a finding about your server: it means one of efaimo's own rules
|
|
301
367
|
threw and was skipped, so the report is incomplete. It is never scored.
|
|
@@ -303,15 +369,18 @@ threw and was skipped, so the report is incomplete. It is never scored.
|
|
|
303
369
|
Quality (E12x-E13x) and skill (S) findings set the letter grade; readiness
|
|
304
370
|
findings (E101-E118) are reported as an ungraded migration diff, which
|
|
305
371
|
`--strict-readiness` can turn into a non-zero exit
|
|
306
|
-
without touching the grade.
|
|
372
|
+
without touching the grade. Findability findings (E14x) belong to `efaimo find`
|
|
373
|
+
and never touch a grade either.
|
|
307
374
|
Every finding carries a stable id you can suppress or link. See
|
|
308
375
|
[docs/RULES.md](./docs/RULES.md).
|
|
309
376
|
|
|
310
377
|
## Roadmap
|
|
311
378
|
|
|
312
|
-
- Harden `efaimo test`: a
|
|
313
|
-
|
|
314
|
-
|
|
379
|
+
- Harden `efaimo test`: a length-matched placebo arm (the control currently gets
|
|
380
|
+
no system prompt at all, which confounds the skill's content with having any
|
|
381
|
+
system prompt), multi-turn tool-use trials, and judge calibration, so the
|
|
382
|
+
experimental harness earns unqualified trust. A separately chosen judge model
|
|
383
|
+
and confidence intervals on the delta are done.
|
|
315
384
|
- Readiness rules for the two 2026-07-28 changes that have none yet: the
|
|
316
385
|
`Mcp-Method` / `Mcp-Name` request headers (SEP-2243), and Tasks moving out of
|
|
317
386
|
core into the `io.modelcontextprotocol/tasks` extension (SEP-2663).
|
package/SKILL.md
CHANGED
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: efaimo
|
|
3
|
-
description: Audit MCP servers and Agent Skills for quality and context-window cost. Use when the user wants to check an MCP server's token cost, lint a skill, verify 2026-07-28 MCP spec readiness, or add an efaimo badge or CI gate.
|
|
3
|
+
description: Audit MCP servers and Agent Skills for quality and context-window cost. Use when the user wants to check an MCP server's token cost, lint a skill, verify 2026-07-28 MCP spec readiness, find out whether a search can single out a server's tools under deferred tool loading, or add an efaimo badge or CI gate.
|
|
4
4
|
license: Apache-2.0
|
|
5
5
|
metadata:
|
|
6
|
-
version: "0.
|
|
6
|
+
version: "0.3.0"
|
|
7
7
|
homepage: "https://efaimo.ai"
|
|
8
8
|
---
|
|
9
9
|
|
|
@@ -49,6 +49,11 @@ and this file is where agents take orders.
|
|
|
49
49
|
- **Lint a skill**
|
|
50
50
|
`npx efaimo check --skill ./skills/` validates frontmatter, trigger quality,
|
|
51
51
|
context budget, file references, and injection patterns.
|
|
52
|
+
- **Check whether a search can single a tool out**
|
|
53
|
+
`npx efaimo find "npx -y <server>"` reports how many tools own a term no
|
|
54
|
+
other tool in the catalog has. A tool that owns none cannot be matched by any
|
|
55
|
+
query that does not also match a competitor, which matters once a host defers
|
|
56
|
+
tool loading. `--min-distinct 100` is the CI gate.
|
|
52
57
|
- **Gate a pull request on context cost**
|
|
53
58
|
Save a baseline with `npx efaimo weigh "<server>" --out base.json`, then in CI
|
|
54
59
|
run `npx efaimo weigh "<server>" --diff base.json --allow-increase 10` to fail
|
|
@@ -56,11 +61,13 @@ and this file is where agents take orders.
|
|
|
56
61
|
|
|
57
62
|
## Reading the output
|
|
58
63
|
|
|
59
|
-
Findings carry a stable rule id (E1xx MCP readiness, E12x-E13x MCP quality,
|
|
60
|
-
skills) and a severity. The letter grade
|
|
61
|
-
2026-07-28 readiness items appear
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
Findings carry a stable rule id (E1xx MCP readiness, E12x-E13x MCP quality,
|
|
65
|
+
E14x findability, S1xx skills) and a severity. The letter grade
|
|
66
|
+
covers quality and skill findings only; 2026-07-28 readiness items appear
|
|
67
|
+
separately as an ungraded migration diff, and findability is a
|
|
68
|
+
separate command with no grade at all. Token numbers are o200k estimates unless
|
|
69
|
+
`--anthropic` is used, which names the model it measured against; see the
|
|
70
|
+
methodology doc for the full method.
|
|
64
71
|
|
|
65
72
|
## Notes
|
|
66
73
|
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What moved between two runs of the same audit.
|
|
3
|
+
*
|
|
4
|
+
* Why this exists. Everything else here measures a subject once. Nobody
|
|
5
|
+
* publishes whether MCP servers and Agent Skills are getting heavier, or
|
|
6
|
+
* whether their quality holds, because that needs the same thing measured
|
|
7
|
+
* twice and nothing made that comparison cheap enough to bother. Two pinned
|
|
8
|
+
* runs is the smallest artifact that can answer it.
|
|
9
|
+
*
|
|
10
|
+
* The reason it is a flag on `check` rather than a shell pipeline is that a delta
|
|
11
|
+
* is exactly the shape a broken measurement takes. Change the ruler between
|
|
12
|
+
* the two runs and every subject appears to move; pair the wrong items and
|
|
13
|
+
* everything looks new. Both mistakes produce a plausible number and neither
|
|
14
|
+
* announces itself, so the controls a careful person would run by hand are
|
|
15
|
+
* enforced here instead of documented:
|
|
16
|
+
*
|
|
17
|
+
* 1. RULES DRIFT IS A HARD STOP. If the two reports carry different
|
|
18
|
+
* `rulesVersion` values, a grade that moved cannot be attributed to the
|
|
19
|
+
* subject, because the ruler moved too. `--allow-rules-drift` proceeds and
|
|
20
|
+
* every grade line is then marked unattributable, which is the honest
|
|
21
|
+
* output rather than a silent one.
|
|
22
|
+
* 2. AN EMPTY PAIRING IS A FAILURE. If nothing pairs, that is not "no
|
|
23
|
+
* change", it is two reports about different things, and reporting "0
|
|
24
|
+
* changed" would be the same vacuous pass an empty grep gives.
|
|
25
|
+
* 3. THE THREE TOKEN COSTS ARE NEVER SUMMED. Metadata sits in context
|
|
26
|
+
* permanently, the body loads on trigger, referenced files load on demand.
|
|
27
|
+
* Adding them produces a single headline percentage that is arithmetically
|
|
28
|
+
* true and tells the reader the wrong thing, because almost all of the
|
|
29
|
+
* movement usually lives in the column that costs the least to carry.
|
|
30
|
+
* 4. A DOMINANT MOVER IS NAMED. When one item is most of a total's movement,
|
|
31
|
+
* the total describes that item and not the population, so it is called
|
|
32
|
+
* out beside the total rather than left for the reader to discover.
|
|
33
|
+
*/
|
|
34
|
+
export interface CheckDiffItem {
|
|
35
|
+
name: string;
|
|
36
|
+
score: number;
|
|
37
|
+
letter: string;
|
|
38
|
+
errors: number;
|
|
39
|
+
warns: number;
|
|
40
|
+
infos: number;
|
|
41
|
+
ruleIds: string[];
|
|
42
|
+
metadata?: number;
|
|
43
|
+
body?: number;
|
|
44
|
+
referenced?: number;
|
|
45
|
+
}
|
|
46
|
+
export interface CheckEnvelope {
|
|
47
|
+
tool?: string;
|
|
48
|
+
version?: string;
|
|
49
|
+
rulesVersion?: string;
|
|
50
|
+
kind?: string;
|
|
51
|
+
data?: unknown;
|
|
52
|
+
}
|
|
53
|
+
export interface GradeMove {
|
|
54
|
+
name: string;
|
|
55
|
+
before: {
|
|
56
|
+
score: number;
|
|
57
|
+
letter: string;
|
|
58
|
+
};
|
|
59
|
+
after: {
|
|
60
|
+
score: number;
|
|
61
|
+
letter: string;
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export interface CostMove {
|
|
65
|
+
name: string;
|
|
66
|
+
before: number;
|
|
67
|
+
after: number;
|
|
68
|
+
delta: number;
|
|
69
|
+
}
|
|
70
|
+
export interface CostColumn {
|
|
71
|
+
column: "metadata" | "body" | "referenced";
|
|
72
|
+
when: string;
|
|
73
|
+
before: number;
|
|
74
|
+
after: number;
|
|
75
|
+
delta: number;
|
|
76
|
+
pct: number;
|
|
77
|
+
moved: CostMove[];
|
|
78
|
+
/** Set when a single item is more than half of the absolute movement. */
|
|
79
|
+
dominatedBy?: {
|
|
80
|
+
name: string;
|
|
81
|
+
delta: number;
|
|
82
|
+
shareOfMovement: number;
|
|
83
|
+
};
|
|
84
|
+
}
|
|
85
|
+
export interface CheckDiffResult {
|
|
86
|
+
kind: string;
|
|
87
|
+
versions: {
|
|
88
|
+
before: string;
|
|
89
|
+
after: string;
|
|
90
|
+
};
|
|
91
|
+
rulesVersions: {
|
|
92
|
+
before?: string;
|
|
93
|
+
after?: string;
|
|
94
|
+
};
|
|
95
|
+
rulesDrift: boolean;
|
|
96
|
+
paired: number;
|
|
97
|
+
added: string[];
|
|
98
|
+
removed: string[];
|
|
99
|
+
improved: GradeMove[];
|
|
100
|
+
worsened: GradeMove[];
|
|
101
|
+
held: number;
|
|
102
|
+
findingCounts: {
|
|
103
|
+
before: number;
|
|
104
|
+
after: number;
|
|
105
|
+
};
|
|
106
|
+
costs: CostColumn[];
|
|
107
|
+
notes: string[];
|
|
108
|
+
}
|
|
109
|
+
export declare class CheckDiffRefused extends Error {
|
|
110
|
+
}
|
|
111
|
+
/**
|
|
112
|
+
* Both shapes `check` emits, flattened to one list.
|
|
113
|
+
*
|
|
114
|
+
* A single target puts the report at `data` itself; a set puts one report per
|
|
115
|
+
* entry in `data.perSkill` and the token counts in a parallel `data.weigh`
|
|
116
|
+
* list. Pairing the two lists by name rather than by position matters: they
|
|
117
|
+
* are built by different passes and nothing guarantees the orders agree, and
|
|
118
|
+
* a positional join would silently attribute one skill's tokens to another.
|
|
119
|
+
*/
|
|
120
|
+
export declare function flattenCheck(envelope: CheckEnvelope): CheckDiffItem[];
|
|
121
|
+
export interface CheckDiffOptions {
|
|
122
|
+
allowRulesDrift?: boolean;
|
|
123
|
+
}
|
|
124
|
+
export declare function diffCheck(before: CheckEnvelope, after: CheckEnvelope, opts?: CheckDiffOptions): CheckDiffResult;
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
export class CheckDiffRefused extends Error {
|
|
2
|
+
}
|
|
3
|
+
const asRecord = (v) => v && typeof v === "object" && !Array.isArray(v) ? v : undefined;
|
|
4
|
+
const num = (v) => (typeof v === "number" && Number.isFinite(v) ? v : 0);
|
|
5
|
+
/**
|
|
6
|
+
* Both shapes `check` emits, flattened to one list.
|
|
7
|
+
*
|
|
8
|
+
* A single target puts the report at `data` itself; a set puts one report per
|
|
9
|
+
* entry in `data.perSkill` and the token counts in a parallel `data.weigh`
|
|
10
|
+
* list. Pairing the two lists by name rather than by position matters: they
|
|
11
|
+
* are built by different passes and nothing guarantees the orders agree, and
|
|
12
|
+
* a positional join would silently attribute one skill's tokens to another.
|
|
13
|
+
*/
|
|
14
|
+
export function flattenCheck(envelope) {
|
|
15
|
+
const data = asRecord(envelope.data);
|
|
16
|
+
if (!data)
|
|
17
|
+
return [];
|
|
18
|
+
const weighByName = new Map();
|
|
19
|
+
const weigh = asRecord(data.weigh);
|
|
20
|
+
if (Array.isArray(weigh?.perSkill)) {
|
|
21
|
+
for (const w of weigh.perSkill) {
|
|
22
|
+
const r = asRecord(w);
|
|
23
|
+
if (r && typeof r.name === "string")
|
|
24
|
+
weighByName.set(r.name, r);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
const fromReport = (name, report) => {
|
|
28
|
+
const grade = asRecord(report.grade) ?? {};
|
|
29
|
+
const counts = asRecord(report.counts) ?? {};
|
|
30
|
+
const findings = Array.isArray(report.findings) ? report.findings : [];
|
|
31
|
+
const w = weighByName.get(name);
|
|
32
|
+
return {
|
|
33
|
+
name,
|
|
34
|
+
score: num(grade.score),
|
|
35
|
+
letter: typeof grade.letter === "string" ? grade.letter : "?",
|
|
36
|
+
errors: num(counts.error),
|
|
37
|
+
warns: num(counts.warn),
|
|
38
|
+
infos: num(counts.info),
|
|
39
|
+
ruleIds: findings.map((f) => f.ruleId).filter((r) => typeof r === "string"),
|
|
40
|
+
...(w
|
|
41
|
+
? {
|
|
42
|
+
metadata: num(w.metadataTokens),
|
|
43
|
+
body: num(w.bodyTokens),
|
|
44
|
+
referenced: num(w.refFileTokens),
|
|
45
|
+
}
|
|
46
|
+
: {}),
|
|
47
|
+
};
|
|
48
|
+
};
|
|
49
|
+
if (Array.isArray(data.perSkill)) {
|
|
50
|
+
const out = [];
|
|
51
|
+
for (const entry of data.perSkill) {
|
|
52
|
+
const e = asRecord(entry);
|
|
53
|
+
const report = asRecord(e?.report);
|
|
54
|
+
const name = typeof e?.name === "string" ? e.name : undefined;
|
|
55
|
+
if (name && report)
|
|
56
|
+
out.push(fromReport(name, report));
|
|
57
|
+
}
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
const target = typeof data.target === "string" ? data.target : undefined;
|
|
61
|
+
if (target && asRecord(data.grade))
|
|
62
|
+
return [fromReport(target, data)];
|
|
63
|
+
return [];
|
|
64
|
+
}
|
|
65
|
+
export function diffCheck(before, after, opts = {}) {
|
|
66
|
+
for (const [label, env] of [
|
|
67
|
+
["before", before],
|
|
68
|
+
["after", after],
|
|
69
|
+
]) {
|
|
70
|
+
if (env.tool !== "efaimo") {
|
|
71
|
+
throw new CheckDiffRefused(`the ${label} file is not an efaimo report (its "tool" field is ${JSON.stringify(env.tool)}); diff compares two runs of this tool, so pass JSON produced by --json`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
if (before.kind !== after.kind) {
|
|
75
|
+
throw new CheckDiffRefused(`these are different kinds of report (${before.kind} and ${after.kind}); comparing them would pair things that are not the same measurement`);
|
|
76
|
+
}
|
|
77
|
+
if (before.kind !== "check") {
|
|
78
|
+
throw new CheckDiffRefused(`diff currently compares "check" reports only, and these are "${before.kind}"; run \`efaimo check ... --json\` at both points`);
|
|
79
|
+
}
|
|
80
|
+
const rulesDrift = Boolean(before.rulesVersion && after.rulesVersion && before.rulesVersion !== after.rulesVersion);
|
|
81
|
+
if (rulesDrift && !opts.allowRulesDrift) {
|
|
82
|
+
throw new CheckDiffRefused(`the two runs used different rulesets (${before.rulesVersion} and ${after.rulesVersion}). A grade that moved cannot be attributed to the subject, because the ruler moved too. Re-run both points with one version of efaimo, or pass --allow-rules-drift to compare anyway and have every grade line marked unattributable.`);
|
|
83
|
+
}
|
|
84
|
+
const b = new Map(flattenCheck(before).map((i) => [i.name, i]));
|
|
85
|
+
const a = new Map(flattenCheck(after).map((i) => [i.name, i]));
|
|
86
|
+
const paired = [...b.keys()].filter((k) => a.has(k)).sort();
|
|
87
|
+
if (!paired.length) {
|
|
88
|
+
// An empty intersection and "nothing changed" are the same output, and
|
|
89
|
+
// only one of them is worth printing.
|
|
90
|
+
throw new CheckDiffRefused(`nothing pairs between the two reports (${b.size} items before, ${a.size} after, 0 in common). That is not "no change", it is two reports about different subjects.`);
|
|
91
|
+
}
|
|
92
|
+
const improved = [];
|
|
93
|
+
const worsened = [];
|
|
94
|
+
let held = 0;
|
|
95
|
+
for (const name of paired) {
|
|
96
|
+
const x = b.get(name);
|
|
97
|
+
const y = a.get(name);
|
|
98
|
+
const move = { name, before: { score: x.score, letter: x.letter }, after: { score: y.score, letter: y.letter } };
|
|
99
|
+
if (y.score > x.score)
|
|
100
|
+
improved.push(move);
|
|
101
|
+
else if (y.score < x.score)
|
|
102
|
+
worsened.push(move);
|
|
103
|
+
else
|
|
104
|
+
held++;
|
|
105
|
+
}
|
|
106
|
+
const COLUMNS = [
|
|
107
|
+
["metadata", "always in context", (i) => i.metadata],
|
|
108
|
+
["body", "loads on trigger", (i) => i.body],
|
|
109
|
+
["referenced", "loads on demand", (i) => i.referenced],
|
|
110
|
+
];
|
|
111
|
+
const costs = [];
|
|
112
|
+
for (const [column, when, pick] of COLUMNS) {
|
|
113
|
+
if (paired.some((n) => pick(b.get(n)) === undefined || pick(a.get(n)) === undefined))
|
|
114
|
+
continue;
|
|
115
|
+
const beforeTotal = paired.reduce((s, n) => s + (pick(b.get(n)) ?? 0), 0);
|
|
116
|
+
const afterTotal = paired.reduce((s, n) => s + (pick(a.get(n)) ?? 0), 0);
|
|
117
|
+
const moved = paired
|
|
118
|
+
.map((n) => ({ name: n, before: pick(b.get(n)) ?? 0, after: pick(a.get(n)) ?? 0 }))
|
|
119
|
+
.filter((m) => m.before !== m.after)
|
|
120
|
+
.map((m) => ({ ...m, delta: m.after - m.before }))
|
|
121
|
+
.sort((x, y) => y.delta - x.delta);
|
|
122
|
+
const totalAbs = moved.reduce((s, m) => s + Math.abs(m.delta), 0);
|
|
123
|
+
const biggest = [...moved].sort((x, y) => Math.abs(y.delta) - Math.abs(x.delta))[0];
|
|
124
|
+
const share = biggest && totalAbs ? Math.abs(biggest.delta) / totalAbs : 0;
|
|
125
|
+
costs.push({
|
|
126
|
+
column,
|
|
127
|
+
when,
|
|
128
|
+
before: beforeTotal,
|
|
129
|
+
after: afterTotal,
|
|
130
|
+
delta: afterTotal - beforeTotal,
|
|
131
|
+
pct: beforeTotal ? ((afterTotal - beforeTotal) / beforeTotal) * 100 : 0,
|
|
132
|
+
moved,
|
|
133
|
+
...(share > 0.5 && biggest
|
|
134
|
+
? { dominatedBy: { name: biggest.name, delta: biggest.delta, shareOfMovement: share * 100 } }
|
|
135
|
+
: {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
const notes = [
|
|
139
|
+
"the three token columns are reported separately on purpose: metadata is carried permanently, the body loads on trigger, and referenced files load on demand, so a single summed percentage would describe none of them",
|
|
140
|
+
];
|
|
141
|
+
if (rulesDrift) {
|
|
142
|
+
notes.push(`RULES DRIFT ACCEPTED (${before.rulesVersion} -> ${after.rulesVersion}): grade movement below cannot be attributed to the subjects, because the ruleset changed between the two runs`);
|
|
143
|
+
}
|
|
144
|
+
if (before.version !== after.version) {
|
|
145
|
+
notes.push(`the two runs used efaimo ${before.version} and ${after.version}; the rulesets matched, so grades are comparable, but token counts can move if the tokenizer or the walk changed`);
|
|
146
|
+
}
|
|
147
|
+
return {
|
|
148
|
+
kind: before.kind,
|
|
149
|
+
versions: { before: before.version ?? "?", after: after.version ?? "?" },
|
|
150
|
+
rulesVersions: { before: before.rulesVersion, after: after.rulesVersion },
|
|
151
|
+
rulesDrift,
|
|
152
|
+
paired: paired.length,
|
|
153
|
+
added: [...a.keys()].filter((k) => !b.has(k)).sort(),
|
|
154
|
+
removed: [...b.keys()].filter((k) => !a.has(k)).sort(),
|
|
155
|
+
improved,
|
|
156
|
+
worsened,
|
|
157
|
+
held,
|
|
158
|
+
findingCounts: {
|
|
159
|
+
before: [...b.values()].reduce((s, i) => s + i.errors + i.warns + i.infos, 0),
|
|
160
|
+
after: [...a.values()].reduce((s, i) => s + i.errors + i.warns + i.infos, 0),
|
|
161
|
+
},
|
|
162
|
+
costs,
|
|
163
|
+
notes,
|
|
164
|
+
};
|
|
165
|
+
}
|