efaimo 0.1.2 → 0.2.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 +63 -1
- package/README.md +68 -6
- package/SKILL.md +14 -7
- package/dist/cli.js +162 -22
- package/dist/core/engine.d.ts +2 -1
- package/dist/core/engine.js +4 -0
- package/dist/core/types.d.ts +121 -0
- package/dist/find/bm25.d.ts +77 -0
- package/dist/find/bm25.js +107 -0
- package/dist/find/find.d.ts +120 -0
- package/dist/find/find.js +403 -0
- package/dist/find/tokenize.d.ts +64 -0
- package/dist/find/tokenize.js +116 -0
- package/dist/index.d.ts +6 -3
- package/dist/index.js +13 -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 +12 -1
- package/dist/reporters/pretty.js +174 -9
- package/dist/rules/find/index.d.ts +4 -0
- package/dist/rules/find/index.js +262 -0
- 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 +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,67 @@ 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.2.0] - 2026-09-02
|
|
8
|
+
|
|
9
|
+
### Added
|
|
10
|
+
|
|
11
|
+
- **`efaimo find`**: does anything actually surface these tools? A host can
|
|
12
|
+
mark tools `defer_loading: true`, which keeps their definitions out of the
|
|
13
|
+
context window until a search returns them, and Anthropic recommends that
|
|
14
|
+
once definitions pass ~10k tokens. Under deferral a tool nothing surfaces
|
|
15
|
+
costs no context and provides no capability. Two numbers, offline and
|
|
16
|
+
deterministic, no API key:
|
|
17
|
+
- `distinct`, the headline: how many tools own a term no other tool in the
|
|
18
|
+
catalog has. A tool with none cannot be matched by any query that does not
|
|
19
|
+
also match a competitor, which follows from the index rather than from a
|
|
20
|
+
model of how anyone searches. Measured on `@playwright/mcp` 0.0.78: 22 of
|
|
21
|
+
24, with `browser_close` and `browser_navigate` owning nothing.
|
|
22
|
+
- `probe`, secondary and labelled: a simulated BM25 search over the four
|
|
23
|
+
fields the tool search documentation names as searchable. It reads 100% on
|
|
24
|
+
both the official reference server and playwright, so the output says on
|
|
25
|
+
the page that it is a floor test and not a ranking.
|
|
26
|
+
- New ungraded rule family E141-E145, `--min-distinct` as the CI gate,
|
|
27
|
+
`--top` to change the simulated result window, `--json` / `--md`.
|
|
28
|
+
Reasoning in ADR-030; method and its limits in docs/METHODOLOGY.md.
|
|
29
|
+
- `rulesVersion` in every JSON envelope, because the tool version does not
|
|
30
|
+
identify a ruleset: a patch release can change what a rule fires on, and a
|
|
31
|
+
published grade is only reproducible next to the rules that produced it. The
|
|
32
|
+
rule inventory is pinned by a test (ADR-032).
|
|
33
|
+
- `--judge-model` and scenario key `judge_model` for `efaimo test`, so the
|
|
34
|
+
model under test no longer has to grade its own answers. Subject and judge
|
|
35
|
+
are routed independently and may be different providers.
|
|
36
|
+
- `--no-timestamp` on `weigh`, `check` and `find`.
|
|
37
|
+
|
|
38
|
+
### Fixed
|
|
39
|
+
|
|
40
|
+
- **`efaimo test --live` could not run on its own default model.** The runner
|
|
41
|
+
sent `temperature` on every request, and Claude removed sampling parameters
|
|
42
|
+
from the 4.7 line onward, so `claude-sonnet-5` rejected the first call with a
|
|
43
|
+
400 that is correctly not retried. Sampling parameters now go only to models
|
|
44
|
+
that still accept them, the request body is a pure function with a test
|
|
45
|
+
against it, and where the judge cannot be pinned the report says so instead
|
|
46
|
+
of implying a determinism it does not have. ADR-031.
|
|
47
|
+
- `efaimo test` reports a Fisher exact p on the **unparseable** counts as well
|
|
48
|
+
as on the passes, and calls a run `inconclusive` when the excluded trials are
|
|
49
|
+
skewed across the arms: two pass rates computed over different populations
|
|
50
|
+
cannot be subtracted.
|
|
51
|
+
- `--anthropic` names the model it measured against, in the output and in the
|
|
52
|
+
JSON. Claude model lines do not share a tokenizer, so a count without its
|
|
53
|
+
model cannot be reproduced. This is the rule `--window` already followed for
|
|
54
|
+
the context-window denominator.
|
|
55
|
+
- `weigh --out` no longer writes `generatedAt` into the baseline. A baseline
|
|
56
|
+
exists to be compared against, and a field that differs on every write is the
|
|
57
|
+
one field a comparison must ignore.
|
|
58
|
+
|
|
59
|
+
### Changed
|
|
60
|
+
|
|
61
|
+
- docs/RULES.md now cites Anthropic's tool search documentation for E125's
|
|
62
|
+
lower threshold, which previously carried no source at all (the MCP quality
|
|
63
|
+
table has never had a source column). The thresholds themselves are
|
|
64
|
+
unchanged, and the citation covers the 30 boundary only: Anthropic writes
|
|
65
|
+
that tool selection "degrades once you exceed 30-50 available tools", which
|
|
66
|
+
says nothing about the 60 boundary, and the table says so.
|
|
67
|
+
|
|
7
68
|
## [0.1.2] - 2026-08-03
|
|
8
69
|
|
|
9
70
|
The 0.1.2 code backlog: edge-case fixes surfaced by the five-agent review, none
|
|
@@ -197,7 +258,8 @@ First release.
|
|
|
197
258
|
Documented rule set (`docs/RULES.md`), token methodology (`docs/METHODOLOGY.md`),
|
|
198
259
|
and integration guide (`docs/INTEGRATIONS.md`).
|
|
199
260
|
|
|
200
|
-
[unreleased]: https://github.com/efaimo-ai/efaimo/compare/v0.
|
|
261
|
+
[unreleased]: https://github.com/efaimo-ai/efaimo/compare/v0.2.0...HEAD
|
|
262
|
+
[0.2.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.2.0
|
|
201
263
|
[0.1.2]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.2
|
|
202
264
|
[0.1.1]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.1
|
|
203
265
|
[0.1.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.0
|
package/README.md
CHANGED
|
@@ -176,6 +176,56 @@ grade A (95) quality: 0 errors 1 warning 0 info
|
|
|
176
176
|
|
|
177
177
|
</details>
|
|
178
178
|
|
|
179
|
+
### Can a search even single your tools out?
|
|
180
|
+
|
|
181
|
+
A host can mark tools `defer_loading: true`, which keeps their definitions out of
|
|
182
|
+
the context window until a search returns them; Anthropic recommends turning that
|
|
183
|
+
on once tool definitions pass ~10k tokens. Under deferral, a tool nothing
|
|
184
|
+
surfaces costs no context and provides no capability, and neither `weigh` nor
|
|
185
|
+
`check` looks at that.
|
|
186
|
+
|
|
187
|
+
```bash
|
|
188
|
+
npx efaimo find "npx -y my-mcp-server"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
Two numbers come back, and they are different kinds of claim.
|
|
192
|
+
|
|
193
|
+
**`distinct`** is a property of your catalog: how many tools own a term no other
|
|
194
|
+
tool has. A tool that owns none cannot be matched by any query that does not also
|
|
195
|
+
match a competitor. That follows from the index, not from a model of how anyone
|
|
196
|
+
searches, and it is the number `--min-distinct` gates on in CI.
|
|
197
|
+
|
|
198
|
+
```text
|
|
199
|
+
distinct 22/24 (91.7%) tools that own a word no other tool has
|
|
200
|
+
probe 24/24 (100%) returned by a simulated search for their own description
|
|
201
|
+
|
|
202
|
+
words tool vocabulary no other tool has
|
|
203
|
+
0 browser_close none: every word also on browser_click, browser_drag, browser_drop
|
|
204
|
+
0 browser_navigate none: every word also on browser_tabs, browser_navigate_back, browser_network_requests
|
|
205
|
+
1 browser_evaluate evaluate
|
|
206
|
+
1 browser_hover hover
|
|
207
|
+
2 browser_resize window, resize
|
|
208
|
+
```
|
|
209
|
+
|
|
210
|
+
*(That is `@playwright/mcp` 0.0.78, a well-maintained server, verbatim. Two of
|
|
211
|
+
its twenty-four tools have no word of their own, and two more own only the word
|
|
212
|
+
in their own name, which nobody describing a task would type.)*
|
|
213
|
+
|
|
214
|
+
Four public servers measured this way, with the commands that reproduce each
|
|
215
|
+
row, are in [the findability report](./research/findability/REPORT.md). The
|
|
216
|
+
weakest was Notion's official server at 16 of 24, where `API-get-user` and
|
|
217
|
+
`API-get-users` are different tools and nothing in their vocabulary separates
|
|
218
|
+
them.
|
|
219
|
+
|
|
220
|
+
**`probe`** is a simulated BM25 search over the four fields tool search matches
|
|
221
|
+
on, and it is labelled a simulation because it is one. It reads 100% on both the
|
|
222
|
+
official reference server and playwright, so it is a floor test that catches an
|
|
223
|
+
empty description or a literal duplicate, not a ranking. `find` says that on
|
|
224
|
+
every run rather than letting a saturated number look like a good score.
|
|
225
|
+
|
|
226
|
+
There is no findability grade, on purpose:
|
|
227
|
+
[docs/METHODOLOGY.md](./docs/METHODOLOGY.md) has the method, its limits, and why.
|
|
228
|
+
|
|
179
229
|
## From an agent
|
|
180
230
|
|
|
181
231
|
`efaimo mcp` runs efaimo as a small, read-only MCP server, so an agent can lint or
|
|
@@ -252,8 +302,16 @@ And a mitigation you cannot measure is a mitigation you are trusting on faith:
|
|
|
252
302
|
`weigh` reports the definitions your server actually ships, which is the input
|
|
253
303
|
every mitigation operates on.
|
|
254
304
|
|
|
255
|
-
|
|
256
|
-
|
|
305
|
+
**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
|
|
306
|
+
it: a deferred tool that no search returns costs nothing and does nothing, so
|
|
307
|
+
the thing worth measuring becomes whether each tool can be picked out of the
|
|
308
|
+
catalog at all. `efaimo find` measures that, and on four public servers it
|
|
309
|
+
found two to eight tools apiece that own no word their neighbours lack.
|
|
310
|
+
Deferral is what makes findability matter, not what makes it moot.
|
|
311
|
+
|
|
312
|
+
If deferred loading covers your whole setup, run `weigh` and `find` once and
|
|
313
|
+
see what it costs you and what it hides; those answers are worth more than our
|
|
314
|
+
opinion.
|
|
257
315
|
|
|
258
316
|
## Install
|
|
259
317
|
|
|
@@ -296,6 +354,7 @@ More recipes (pre-commit, GitLab, editor audit, programmatic use):
|
|
|
296
354
|
| **S101 to S106** | skills: frontmatter and trigger quality, trigger collisions, context budget, reference integrity, injection hygiene |
|
|
297
355
|
| **E101 to E118** | MCP 2026-07-28 readiness: deprecated primitives, statelessness, `server/discover`, `resultType`, cache fields, transport |
|
|
298
356
|
| **E121 to E128, E130** | MCP quality: description quality, annotations, schema hygiene, tool-count and token-cost budgets |
|
|
357
|
+
| **E141 to E146** | MCP findability: exclusive vocabulary, whether a search returns the tool, indistinguishable pairs, names that carry no domain word |
|
|
299
358
|
|
|
300
359
|
`E000` is not a finding about your server: it means one of efaimo's own rules
|
|
301
360
|
threw and was skipped, so the report is incomplete. It is never scored.
|
|
@@ -303,15 +362,18 @@ threw and was skipped, so the report is incomplete. It is never scored.
|
|
|
303
362
|
Quality (E12x-E13x) and skill (S) findings set the letter grade; readiness
|
|
304
363
|
findings (E101-E118) are reported as an ungraded migration diff, which
|
|
305
364
|
`--strict-readiness` can turn into a non-zero exit
|
|
306
|
-
without touching the grade.
|
|
365
|
+
without touching the grade. Findability findings (E14x) belong to `efaimo find`
|
|
366
|
+
and never touch a grade either.
|
|
307
367
|
Every finding carries a stable id you can suppress or link. See
|
|
308
368
|
[docs/RULES.md](./docs/RULES.md).
|
|
309
369
|
|
|
310
370
|
## Roadmap
|
|
311
371
|
|
|
312
|
-
- Harden `efaimo test`: a
|
|
313
|
-
|
|
314
|
-
|
|
372
|
+
- Harden `efaimo test`: a length-matched placebo arm (the control currently gets
|
|
373
|
+
no system prompt at all, which confounds the skill's content with having any
|
|
374
|
+
system prompt), multi-turn tool-use trials, and judge calibration, so the
|
|
375
|
+
experimental harness earns unqualified trust. A separately chosen judge model
|
|
376
|
+
and confidence intervals on the delta are done.
|
|
315
377
|
- Readiness rules for the two 2026-07-28 changes that have none yet: the
|
|
316
378
|
`Mcp-Method` / `Mcp-Name` request headers (SEP-2243), and Tasks moving out of
|
|
317
379
|
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.2.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
|
|
package/dist/cli.js
CHANGED
|
@@ -13,12 +13,14 @@ import { findSkills } from "./skills/parse.js";
|
|
|
13
13
|
import { diffServerWeigh } from "./weigh/diff.js";
|
|
14
14
|
import { DEFAULT_CONTEXT_WINDOW, formatWindowShare, setContextWindow } from "./weigh/window.js";
|
|
15
15
|
import { checkMcpRepoOnly, checkMcpTarget, checkSkillSet } from "./check/check.js";
|
|
16
|
-
import {
|
|
16
|
+
import { analyzeFind, DEFAULT_TOP_K } from "./find/find.js";
|
|
17
|
+
import { runFindRules } from "./core/engine.js";
|
|
18
|
+
import { renderCheckPretty, renderDiffPretty, renderFindFindingsPretty, renderFindPretty, renderScenarioPlan, renderServerWeighPretty, renderSkillSetPretty, renderSkillWeighPretty, renderTestReportPretty, setColor, } from "./reporters/pretty.js";
|
|
17
19
|
import { parseScenario, runScenario } from "./testing/harness.js";
|
|
18
|
-
import { anthropicRunner } from "./testing/anthropicRunner.js";
|
|
20
|
+
import { acceptsSamplingParams, anthropicRunner } from "./testing/anthropicRunner.js";
|
|
19
21
|
import { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
|
|
20
22
|
import { toJsonEnvelope } from "./reporters/json.js";
|
|
21
|
-
import { renderCheckMarkdown, renderDiffMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
|
|
23
|
+
import { renderCheckMarkdown, renderDiffMarkdown, renderFindMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
|
|
22
24
|
import { gradeBadgeSpec, makeBadgeSvg, toShieldsEndpoint, weighBadgeSpec } from "./reporters/badge.js";
|
|
23
25
|
import { loadDotEnv } from "./util/dotenv.js";
|
|
24
26
|
// Load a local .env before any command reads a key. Shell env always wins.
|
|
@@ -36,6 +38,7 @@ examples:
|
|
|
36
38
|
$ npx efaimo weigh --client claude-code
|
|
37
39
|
$ npx efaimo check --mcp "npx -y my-mcp-server" # incl. 2026-07-28 readiness
|
|
38
40
|
$ npx efaimo check --skill ./skills/
|
|
41
|
+
$ npx efaimo find "npx -y my-mcp-server" # would a search surface these tools?
|
|
39
42
|
$ npx efaimo weigh "npx -y my-server" --out base.json && npx efaimo weigh "npx -y my-server" --diff base.json
|
|
40
43
|
`);
|
|
41
44
|
function collectPairs(sep) {
|
|
@@ -104,6 +107,13 @@ function anthropicKeyFor(opts) {
|
|
|
104
107
|
fail("--anthropic needs ANTHROPIC_API_KEY in the environment or a .env file");
|
|
105
108
|
return { apiKey, model: typeof opts.anthropic === "string" ? opts.anthropic : undefined };
|
|
106
109
|
}
|
|
110
|
+
// `--no-timestamp` is commander's negated form, so `opts.timestamp` is true
|
|
111
|
+
// unless the flag is passed. Kept in one helper because three commands take it
|
|
112
|
+
// and the polarity is easy to invert by accident.
|
|
113
|
+
function envelopeOpts(opts) {
|
|
114
|
+
return { timestamp: opts.timestamp !== false };
|
|
115
|
+
}
|
|
116
|
+
const NO_TIMESTAMP_HELP = "omit generatedAt from JSON output (for committed or diffed artifacts)";
|
|
107
117
|
program
|
|
108
118
|
.command("weigh")
|
|
109
119
|
.description("measure the context cost of MCP tool definitions or Agent Skills")
|
|
@@ -116,6 +126,7 @@ program
|
|
|
116
126
|
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
117
127
|
.option("--json", "print JSON")
|
|
118
128
|
.option("--md", "print Markdown")
|
|
129
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
119
130
|
.option("--out <file>", "write the JSON result to a file (usable as a --diff baseline)")
|
|
120
131
|
.option("--diff <baseline>", "compare against a baseline JSON from --out")
|
|
121
132
|
.option("--max-tokens <n>", "exit 1 if the primary total exceeds n tokens")
|
|
@@ -212,14 +223,18 @@ program
|
|
|
212
223
|
const single = targets.length === 1 && results.length === 1 ? results[0] : undefined;
|
|
213
224
|
const budgetTotal = results.reduce((s, r) => s + (r.kind === "mcp" ? r.totals.claudeStyle : r.totals.metadata + r.totals.body), 0);
|
|
214
225
|
if (opts.out && single) {
|
|
215
|
-
|
|
226
|
+
// Never stamped. A baseline exists to be compared against, so a field
|
|
227
|
+
// that differs on every write is the one field a comparison must ignore,
|
|
228
|
+
// and a committed baseline that diffs on nothing but the clock trains
|
|
229
|
+
// reviewers to skim exactly the file they should read.
|
|
230
|
+
fs.writeFileSync(opts.out, toJsonEnvelope("weigh", single, { timestamp: false }));
|
|
216
231
|
console.error(pc.dim(`baseline written: ${opts.out}`));
|
|
217
232
|
}
|
|
218
233
|
else if (opts.out) {
|
|
219
234
|
console.error(pc.yellow("--out skipped: it writes a single baseline, but multiple targets were requested"));
|
|
220
235
|
}
|
|
221
236
|
if (opts.json) {
|
|
222
|
-
console.log(toJsonEnvelope("weigh", single ?? results));
|
|
237
|
+
console.log(toJsonEnvelope("weigh", single ?? results, envelopeOpts(opts)));
|
|
223
238
|
}
|
|
224
239
|
else if (opts.md) {
|
|
225
240
|
console.log(results.map(renderWeighMarkdown).join("\n\n---\n\n"));
|
|
@@ -286,6 +301,7 @@ program
|
|
|
286
301
|
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
287
302
|
.option("--json", "print JSON")
|
|
288
303
|
.option("--md", "print Markdown")
|
|
304
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
289
305
|
.option("--badge [file]", "write a grade badge SVG + shields endpoint JSON")
|
|
290
306
|
.option("--anthropic [model]", "use exact Claude token counts where relevant")
|
|
291
307
|
.option("--window <tokens>", "context window the share is reported against", String(DEFAULT_CONTEXT_WINDOW))
|
|
@@ -334,7 +350,7 @@ program
|
|
|
334
350
|
}
|
|
335
351
|
if (skillSet) {
|
|
336
352
|
if (opts.json)
|
|
337
|
-
console.log(toJsonEnvelope("check", skillSet));
|
|
353
|
+
console.log(toJsonEnvelope("check", skillSet, envelopeOpts(opts)));
|
|
338
354
|
else if (opts.md)
|
|
339
355
|
console.log(renderSkillSetMarkdown(skillSet));
|
|
340
356
|
else
|
|
@@ -350,7 +366,7 @@ program
|
|
|
350
366
|
if (!report)
|
|
351
367
|
return;
|
|
352
368
|
if (opts.json)
|
|
353
|
-
console.log(toJsonEnvelope("check", report));
|
|
369
|
+
console.log(toJsonEnvelope("check", report, envelopeOpts(opts)));
|
|
354
370
|
else if (opts.md)
|
|
355
371
|
console.log(renderCheckMarkdown(report));
|
|
356
372
|
else
|
|
@@ -405,41 +421,165 @@ program
|
|
|
405
421
|
process.exitCode = 1;
|
|
406
422
|
}
|
|
407
423
|
});
|
|
424
|
+
program
|
|
425
|
+
.command("find")
|
|
426
|
+
.description("would a search surface these tools? (findability under deferred tool loading)")
|
|
427
|
+
.argument("[target]", "stdio command | http(s) URL")
|
|
428
|
+
.option("--stdio", "treat target as a stdio command string")
|
|
429
|
+
.option("--header <header>", 'HTTP header "Key: Value" (repeatable)', collectPairs(":"))
|
|
430
|
+
.option("--env <pair>", "KEY=VALUE for stdio servers (repeatable)", collectPairs("="))
|
|
431
|
+
.option("--timeout <seconds>", "connect timeout in seconds", "45")
|
|
432
|
+
.option("--top <n>", `result window to simulate (Anthropic's documented default is ${DEFAULT_TOP_K}; the probe is vacuous when tools <= top, so gate on --min-distinct rather than on this)`, String(DEFAULT_TOP_K))
|
|
433
|
+
.option("--min-distinct <pct>", "exit 1 if fewer than pct percent of tools own a word no other tool has")
|
|
434
|
+
.option("--json", "print JSON")
|
|
435
|
+
.option("--md", "print Markdown")
|
|
436
|
+
.option("--no-timestamp", NO_TIMESTAMP_HELP)
|
|
437
|
+
.option("--no-color", "disable colors")
|
|
438
|
+
.addHelpText("after", "\nA tool marked defer_loading is kept out of the context window until a search finds it, so a tool\n" +
|
|
439
|
+
"nothing surfaces costs nothing and does nothing. Two numbers come back. `distinct` is a property of\n" +
|
|
440
|
+
"the catalog: a tool that owns no word the others lack cannot be matched by any query that does not\n" +
|
|
441
|
+
"also match a competitor. `probe` is a simulated BM25 search, offline and deterministic, and it\n" +
|
|
442
|
+
"saturates on well-formed catalogs, so it is a floor test rather than a ranking.\n" +
|
|
443
|
+
"No API key, no network beyond connecting to the server. docs/METHODOLOGY.md has the method.")
|
|
444
|
+
.action(async (targetArg, opts) => {
|
|
445
|
+
colorSetup(opts);
|
|
446
|
+
if (!targetArg)
|
|
447
|
+
fail("nothing to search: pass an MCP server (stdio command or URL)");
|
|
448
|
+
const timeoutMs = seconds(opts.timeout) * 1000;
|
|
449
|
+
const topK = parseNumberOpt(opts.top, "--top", { min: 1 }) ?? DEFAULT_TOP_K;
|
|
450
|
+
// A window is a count of results, so a fraction of one is not a stricter
|
|
451
|
+
// setting, it is a nonsensical one: `--top 2.5` printed "result window 2.5"
|
|
452
|
+
// while `rank <= 2.5` behaved as 2, so the number in the report and the
|
|
453
|
+
// number in the comparison disagreed.
|
|
454
|
+
if (!Number.isInteger(topK))
|
|
455
|
+
fail(`--top must be a whole number of results, got "${opts.top}"`);
|
|
456
|
+
const minDistinct = parseNumberOpt(opts.minDistinct, "--min-distinct", { min: 0 });
|
|
457
|
+
if (minDistinct !== undefined && minDistinct > 100)
|
|
458
|
+
fail(`--min-distinct is a percentage, got "${opts.minDistinct}"`);
|
|
459
|
+
const target = resolveTarget(targetArg, { forceStdio: opts.stdio, env: opts.env, headers: opts.header });
|
|
460
|
+
if (target.kind === "skillset" || target.kind === "repo") {
|
|
461
|
+
fail(`"${target.label}" is a ${target.kind === "skillset" ? "skill path" : "source directory"}; find needs a live MCP server (stdio command or URL). ` +
|
|
462
|
+
`Skills have no searchable tool catalog; their trigger-overlap check is S103 in \`efaimo check --skill\`.`);
|
|
463
|
+
}
|
|
464
|
+
console.error(pc.dim(`connecting to ${target.label} ...`));
|
|
465
|
+
const intro = await introspectServer(target, { timeoutMs });
|
|
466
|
+
// Weighed only to answer one question: is this catalog big enough that a
|
|
467
|
+
// host would defer it. No API key is involved; the count is the local
|
|
468
|
+
// o200k estimate the rest of the tool uses.
|
|
469
|
+
const weigh = await weighServer(intro);
|
|
470
|
+
const result = analyzeFind(target.label, intro.tools, {
|
|
471
|
+
topK,
|
|
472
|
+
definitionTokens: weigh.totals.claudeStyle,
|
|
473
|
+
});
|
|
474
|
+
const findings = runFindRules({ find: result });
|
|
475
|
+
if (opts.json) {
|
|
476
|
+
console.log(toJsonEnvelope("find", { ...result, findings }, envelopeOpts(opts)));
|
|
477
|
+
}
|
|
478
|
+
else if (opts.md) {
|
|
479
|
+
console.log(renderFindMarkdown(result, findings));
|
|
480
|
+
}
|
|
481
|
+
else {
|
|
482
|
+
console.log(renderFindPretty(result));
|
|
483
|
+
console.log(renderFindFindingsPretty(result, findings));
|
|
484
|
+
}
|
|
485
|
+
// Gated on `distinct`, not on the probe. The probe is 100% by construction
|
|
486
|
+
// whenever the catalog fits inside the result window, so a gate on it
|
|
487
|
+
// would pass without examining anything for every small server. Exclusive
|
|
488
|
+
// vocabulary can fail at two tools and up.
|
|
489
|
+
//
|
|
490
|
+
// At ONE tool it cannot: every term is trivially exclusive and the figure
|
|
491
|
+
// is 100% for any tool whatsoever. Exit 2 rather than 0, because "this
|
|
492
|
+
// gate cannot be evaluated here" is a different fact from "this gate
|
|
493
|
+
// passed", and a CI run that silently passes on a catalog nothing was
|
|
494
|
+
// measured against is the failure this whole command was built around.
|
|
495
|
+
if (minDistinct !== undefined && result.distinctVacuous) {
|
|
496
|
+
fail(`--min-distinct cannot be evaluated on a ${result.toolCount}-tool catalog: with nothing to be distinct from, ` +
|
|
497
|
+
`every term is trivially exclusive and the figure is 100% for any tool. Drop the gate for this server.`);
|
|
498
|
+
}
|
|
499
|
+
if (minDistinct !== undefined && result.distinct.pct < minDistinct) {
|
|
500
|
+
console.error(pc.red(`distinct ${result.distinct.pct}% is below --min-distinct ${minDistinct}% ` +
|
|
501
|
+
`(${result.distinct.total - result.distinct.count} of ${result.distinct.total} tools own no word the others lack)`));
|
|
502
|
+
process.exitCode = 1;
|
|
503
|
+
}
|
|
504
|
+
});
|
|
408
505
|
program
|
|
409
506
|
.command("test")
|
|
410
507
|
.description("does a skill actually improve task completion? (experimental A/B outcome harness)")
|
|
411
508
|
.argument("<scenario>", "a scenario YAML file (see examples/scenario.example.yaml)")
|
|
412
509
|
.option("--live", "run for real (spends tokens; Claude models need ANTHROPIC_API_KEY, GPT models need OPENAI_API_KEY)")
|
|
413
510
|
.option("--model <model>", "override the scenario's model (e.g. gpt-4o-mini, claude-sonnet-5)")
|
|
511
|
+
.option("--judge-model <model>", "grade with a different model than the one under test (removes self-preference)")
|
|
414
512
|
.option("--json", "print JSON")
|
|
415
513
|
.option("--no-color", "disable colors")
|
|
416
|
-
.addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls
|
|
514
|
+
.addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls.\n" +
|
|
515
|
+
"The judge defaults to the model under test, which means a model grades its own answers; set\n" +
|
|
516
|
+
"judge_model in the scenario or --judge-model here to separate them. Subject and judge may be\n" +
|
|
517
|
+
"different providers, in which case both keys are required.")
|
|
417
518
|
.action(async (file, opts) => {
|
|
418
519
|
colorSetup(opts);
|
|
419
520
|
const parsed = parseScenario(file);
|
|
420
|
-
|
|
521
|
+
// --model moves the judge too WHEN the judge was never pinned separately.
|
|
522
|
+
// Without this, overriding the subject on a scenario that has no
|
|
523
|
+
// `judge_model` would silently leave the judge on the scenario's original
|
|
524
|
+
// model: a cross-model judge nobody asked for, in a run whose whole point
|
|
525
|
+
// is that the comparison is controlled.
|
|
526
|
+
const judgeFollowsSubject = !parsed.judgeModelExplicit;
|
|
527
|
+
const model = opts.model ?? parsed.model;
|
|
528
|
+
const judgeModel = opts.judgeModel ?? (judgeFollowsSubject ? model : parsed.judgeModel);
|
|
529
|
+
const scenario = { ...parsed, model, judgeModel };
|
|
421
530
|
if (!opts.live) {
|
|
422
531
|
console.log(renderScenarioPlan(scenario));
|
|
423
532
|
return;
|
|
424
533
|
}
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
534
|
+
// Subject and judge are routed independently, so a Claude subject can be
|
|
535
|
+
// graded by a GPT judge (or the reverse). Every distinct provider in play
|
|
536
|
+
// needs its own key, and the error says which model asked for it.
|
|
537
|
+
const runners = new Map();
|
|
538
|
+
for (const [role, m] of [["model", scenario.model], ["judge model", scenario.judgeModel]]) {
|
|
539
|
+
const provider = providerForModel(m);
|
|
540
|
+
if (provider === "unknown") {
|
|
541
|
+
fail(`${role} "${m}" is not supported: efaimo test runs Claude (claude-*) and OpenAI (gpt-*, o*) models`);
|
|
542
|
+
}
|
|
543
|
+
if (runners.has(provider))
|
|
544
|
+
continue;
|
|
545
|
+
const envVar = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
|
|
546
|
+
const apiKey = process.env[envVar];
|
|
547
|
+
if (!apiKey)
|
|
548
|
+
fail(`${role} "${m}" needs ${envVar} in the environment or a .env file`);
|
|
549
|
+
if (dotEnvKeys.has(envVar))
|
|
550
|
+
console.error(pc.dim(`using ${envVar} from .env`));
|
|
551
|
+
runners.set(provider, provider === "openai" ? openaiRunner(apiKey) : anthropicRunner(apiKey));
|
|
428
552
|
}
|
|
429
|
-
const
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
553
|
+
const runner = (req) => {
|
|
554
|
+
const r = runners.get(providerForModel(req.model));
|
|
555
|
+
// Only the two validated models can reach here today. A future arm that
|
|
556
|
+
// introduces a third would otherwise die as "undefined is not a
|
|
557
|
+
// function" with no idea which model it was.
|
|
558
|
+
if (!r)
|
|
559
|
+
throw new Error(`no runner for model "${req.model}"; efaimo test runs Claude and OpenAI models`);
|
|
560
|
+
return r(req);
|
|
561
|
+
};
|
|
562
|
+
// Say what the measurement cannot pin down, in the report, every time.
|
|
563
|
+
const extraNotes = [];
|
|
564
|
+
if (scenario.judgeModel === scenario.model) {
|
|
565
|
+
extraNotes.push(`the judge is the same model as the subject, so part of any measured effect is a model preferring its own output. Set judge_model (or --judge-model) to a different model to remove that.`);
|
|
566
|
+
}
|
|
567
|
+
if (providerForModel(scenario.judgeModel) === "anthropic" && !acceptsSamplingParams(scenario.judgeModel)) {
|
|
568
|
+
extraNotes.push(`judge sampling is not pinned: ${scenario.judgeModel} does not accept temperature, so the judge is sampled at its default and its own variance is part of this measurement.`);
|
|
569
|
+
}
|
|
570
|
+
const models = scenario.judgeModel === scenario.model ? scenario.model : `${scenario.model} judged by ${scenario.judgeModel}`;
|
|
571
|
+
console.error(pc.dim(`running ~${scenario.trials * 4} API calls against ${models} ...`));
|
|
572
|
+
const report = await runScenario(scenario, runner, { extraNotes });
|
|
438
573
|
if (opts.json)
|
|
439
574
|
console.log(toJsonEnvelope("test", report));
|
|
440
575
|
else
|
|
441
576
|
console.log(renderTestReportPretty(report));
|
|
442
|
-
|
|
577
|
+
// `inconclusive` fails too. It means an arm produced no scoreable trial, or
|
|
578
|
+
// the judge refused on one arm far more than the other, and in both cases
|
|
579
|
+
// the measurement did not happen. Exiting 0 there would report a green for
|
|
580
|
+
// precisely the runs where the instrument broke down, which is this
|
|
581
|
+
// project's documented dominant failure mode wearing a p-value.
|
|
582
|
+
if (report.verdict === "hurts" || report.verdict === "inconclusive")
|
|
443
583
|
process.exitCode = 1;
|
|
444
584
|
});
|
|
445
585
|
program
|
package/dist/core/engine.d.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import type { Finding, McpRuleContext } from "./types.js";
|
|
1
|
+
import type { Finding, FindRuleContext, McpRuleContext } from "./types.js";
|
|
2
2
|
/** Run a list of rules, isolating each: a broken rule must never break the audit. */
|
|
3
3
|
export declare function runRules<Ctx>(rules: readonly {
|
|
4
4
|
check(ctx: Ctx): Finding[];
|
|
5
5
|
}[], ctx: Ctx): Finding[];
|
|
6
6
|
export declare function runMcpRules(ctx: McpRuleContext): Finding[];
|
|
7
|
+
export declare function runFindRules(ctx: FindRuleContext): Finding[];
|
package/dist/core/engine.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { MCP_RULES } from "../rules/mcp/index.js";
|
|
2
|
+
import { FIND_RULES } from "../rules/find/index.js";
|
|
2
3
|
/** Run a list of rules, isolating each: a broken rule must never break the audit. */
|
|
3
4
|
export function runRules(rules, ctx) {
|
|
4
5
|
const findings = [];
|
|
@@ -33,3 +34,6 @@ export function runRules(rules, ctx) {
|
|
|
33
34
|
export function runMcpRules(ctx) {
|
|
34
35
|
return runRules(MCP_RULES, ctx);
|
|
35
36
|
}
|
|
37
|
+
export function runFindRules(ctx) {
|
|
38
|
+
return runRules(FIND_RULES, ctx);
|
|
39
|
+
}
|