efaimo 0.1.0 → 0.1.1
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 +97 -2
- package/README.md +88 -30
- package/SKILL.md +13 -1
- package/dist/check/check.js +6 -2
- package/dist/cli.js +78 -4
- package/dist/clients/rawprobe.d.ts +21 -5
- package/dist/clients/rawprobe.js +101 -43
- package/dist/clients/stateless.js +17 -14
- package/dist/core/engine.js +20 -2
- package/dist/core/grade.js +7 -1
- package/dist/core/types.d.ts +22 -3
- package/dist/reporters/markdown.js +16 -4
- package/dist/reporters/pretty.js +36 -25
- package/dist/rules/injection.js +7 -0
- package/dist/rules/mcp/index.d.ts +14 -0
- package/dist/rules/mcp/index.js +68 -24
- package/dist/skills/parse.js +39 -3
- package/dist/targets/clientConfigs.d.ts +7 -0
- package/dist/targets/clientConfigs.js +4 -1
- package/dist/testing/anthropicRunner.js +2 -0
- package/dist/testing/harness.d.ts +21 -0
- package/dist/testing/harness.js +62 -11
- package/dist/testing/openaiRunner.js +8 -1
- package/dist/testing/stats.d.ts +32 -0
- package/dist/testing/stats.js +91 -0
- package/dist/util/dotenv.d.ts +9 -1
- package/dist/util/dotenv.js +11 -1
- package/dist/util/safeText.d.ts +8 -0
- package/dist/util/safeText.js +48 -0
- package/dist/version.d.ts +1 -1
- package/dist/version.js +1 -1
- package/dist/weigh/weigh.js +3 -1
- package/dist/weigh/window.d.ts +26 -0
- package/dist/weigh/window.js +44 -0
- package/docs/METHODOLOGY.md +70 -0
- package/docs/RULES.md +29 -23
- package/examples/contoso-crm-import/SKILL.md +21 -0
- package/examples/csv-cleanup/SKILL.md +17 -0
- package/examples/scenario.crm.yaml +22 -0
- package/examples/scenario.example.yaml +17 -0
- package/package.json +3 -1
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
// The statistics behind an `efaimo test` verdict.
|
|
2
|
+
//
|
|
3
|
+
// The verdict used to be a bare threshold on the difference in pass rates:
|
|
4
|
+
// >= +15 points "helps", <= -15 "hurts", exit code 1 on "hurts". With the
|
|
5
|
+
// default 5 trials per arm, 5/5 against 4/5 is +20 points and would have been
|
|
6
|
+
// reported as "helps" with a two-sided Fisher p of 1.0000 - the least
|
|
7
|
+
// significant result the test can produce, labelled as a finding. At 8 trials
|
|
8
|
+
// per arm the 95% interval on a difference of proportions is roughly +-49
|
|
9
|
+
// points wide, so the whole +-15 band sits inside the noise for every trial
|
|
10
|
+
// count the tool supports below about 25 per arm.
|
|
11
|
+
//
|
|
12
|
+
// A tool whose argument is that people publish numbers they cannot support
|
|
13
|
+
// should not do that itself. So the verdict is now gated on a p-value, the
|
|
14
|
+
// interval is reported, and both are printed rather than hidden.
|
|
15
|
+
//
|
|
16
|
+
// Fisher's exact test, not chi-squared: the counts here are single digits, and
|
|
17
|
+
// chi-squared's approximation is not trustworthy at that size.
|
|
18
|
+
/** ln(n!) via lgamma, so the 2x2 table math stays exact enough at small n. */
|
|
19
|
+
function lnFactorial(n) {
|
|
20
|
+
let s = 0;
|
|
21
|
+
for (let i = 2; i <= n; i++)
|
|
22
|
+
s += Math.log(i);
|
|
23
|
+
return s;
|
|
24
|
+
}
|
|
25
|
+
/** Probability of exactly this 2x2 table under the hypergeometric null. */
|
|
26
|
+
function lnHypergeom(a, b, c, d) {
|
|
27
|
+
const n = a + b + c + d;
|
|
28
|
+
return (lnFactorial(a + b) + lnFactorial(c + d) + lnFactorial(a + c) + lnFactorial(b + d) -
|
|
29
|
+
lnFactorial(n) - lnFactorial(a) - lnFactorial(b) - lnFactorial(c) - lnFactorial(d));
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* Two-sided Fisher exact test on
|
|
33
|
+
* [withPass, withFail]
|
|
34
|
+
* [withoutPass, withoutFail]
|
|
35
|
+
*
|
|
36
|
+
* Sums the probability of every table at least as extreme as the observed one,
|
|
37
|
+
* which is the standard two-sided construction and needs no normal
|
|
38
|
+
* approximation.
|
|
39
|
+
*/
|
|
40
|
+
export function fisherExactTwoSided(withPass, withTrials, withoutPass, withoutTrials) {
|
|
41
|
+
const a = withPass, b = withTrials - withPass;
|
|
42
|
+
const c = withoutPass, d = withoutTrials - withoutPass;
|
|
43
|
+
if (withTrials <= 0 || withoutTrials <= 0)
|
|
44
|
+
return 1;
|
|
45
|
+
const obs = lnHypergeom(a, b, c, d);
|
|
46
|
+
const rowTop = a + b, rowBot = c + d, colPass = a + c;
|
|
47
|
+
const lo = Math.max(0, colPass - rowBot);
|
|
48
|
+
const hi = Math.min(rowTop, colPass);
|
|
49
|
+
// A tiny epsilon so floating point does not drop the observed table itself.
|
|
50
|
+
const EPS = 1e-9;
|
|
51
|
+
let p = 0;
|
|
52
|
+
for (let x = lo; x <= hi; x++) {
|
|
53
|
+
const l = lnHypergeom(x, rowTop - x, colPass - x, rowBot - (colPass - x));
|
|
54
|
+
if (l <= obs + EPS)
|
|
55
|
+
p += Math.exp(l);
|
|
56
|
+
}
|
|
57
|
+
return Math.min(1, p);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Wilson score interval for one proportion. Chosen over the normal-approximation
|
|
61
|
+
* interval because that one produces a zero-width interval at 0/n and n/n,
|
|
62
|
+
* which is exactly where these runs land (8/8 and 0/8 both occur in the two
|
|
63
|
+
* committed example runs).
|
|
64
|
+
*/
|
|
65
|
+
export function wilson(passes, trials, z = 1.96) {
|
|
66
|
+
if (trials <= 0)
|
|
67
|
+
return { lo: 0, hi: 1 };
|
|
68
|
+
const p = passes / trials;
|
|
69
|
+
const z2 = z * z;
|
|
70
|
+
const denom = 1 + z2 / trials;
|
|
71
|
+
const centre = p + z2 / (2 * trials);
|
|
72
|
+
const half = z * Math.sqrt((p * (1 - p)) / trials + z2 / (4 * trials * trials));
|
|
73
|
+
return { lo: Math.max(0, (centre - half) / denom), hi: Math.min(1, (centre + half) / denom) };
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Interval on the DIFFERENCE of two proportions, in percentage points, by the
|
|
77
|
+
* Newcombe method: combine each arm's Wilson interval. Reported so a reader can
|
|
78
|
+
* see how wide the uncertainty actually is instead of inferring it from a
|
|
79
|
+
* point estimate.
|
|
80
|
+
*/
|
|
81
|
+
export function deltaInterval(withPass, withTrials, withoutPass, withoutTrials) {
|
|
82
|
+
const w = wilson(withPass, withTrials);
|
|
83
|
+
const o = wilson(withoutPass, withoutTrials);
|
|
84
|
+
const p1 = withPass / withTrials, p2 = withoutPass / withoutTrials;
|
|
85
|
+
const d = p1 - p2;
|
|
86
|
+
const lo = d - Math.sqrt((p1 - w.lo) ** 2 + (o.hi - p2) ** 2);
|
|
87
|
+
const hi = d + Math.sqrt((w.hi - p1) ** 2 + (p2 - o.lo) ** 2);
|
|
88
|
+
return { lo: Math.round(Math.max(-1, lo) * 1000) / 10, hi: Math.round(Math.min(1, hi) * 1000) / 10 };
|
|
89
|
+
}
|
|
90
|
+
/** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
|
|
91
|
+
export const MIN_TRIALS_FOR_SIGNIFICANCE = 4;
|
package/dist/util/dotenv.d.ts
CHANGED
|
@@ -9,4 +9,12 @@
|
|
|
9
9
|
* secrets are not forwarded to spawned MCP servers: minimalChildEnv() passes an
|
|
10
10
|
* allowlist that excludes API keys.
|
|
11
11
|
*/
|
|
12
|
-
|
|
12
|
+
/**
|
|
13
|
+
* The only keys this loader may set. It used to set ANY key found in a .env in
|
|
14
|
+
* the CURRENT DIRECTORY, which means a repository you cloned could inject
|
|
15
|
+
* NODE_TLS_REJECT_UNAUTHORIZED=0 and silently disable certificate verification
|
|
16
|
+
* for every later HTTPS call, including the ones carrying your API key. The
|
|
17
|
+
* file header already said only two keys were wanted; this enforces it.
|
|
18
|
+
*/
|
|
19
|
+
export declare const ALLOWED: Set<string>;
|
|
20
|
+
export declare function loadDotEnv(dir?: string, allowed?: ReadonlySet<string>): string[];
|
package/dist/util/dotenv.js
CHANGED
|
@@ -11,7 +11,15 @@ import { resolve } from "node:path";
|
|
|
11
11
|
* secrets are not forwarded to spawned MCP servers: minimalChildEnv() passes an
|
|
12
12
|
* allowlist that excludes API keys.
|
|
13
13
|
*/
|
|
14
|
-
|
|
14
|
+
/**
|
|
15
|
+
* The only keys this loader may set. It used to set ANY key found in a .env in
|
|
16
|
+
* the CURRENT DIRECTORY, which means a repository you cloned could inject
|
|
17
|
+
* NODE_TLS_REJECT_UNAUTHORIZED=0 and silently disable certificate verification
|
|
18
|
+
* for every later HTTPS call, including the ones carrying your API key. The
|
|
19
|
+
* file header already said only two keys were wanted; this enforces it.
|
|
20
|
+
*/
|
|
21
|
+
export const ALLOWED = new Set(["ANTHROPIC_API_KEY", "OPENAI_API_KEY"]);
|
|
22
|
+
export function loadDotEnv(dir = process.cwd(), allowed = ALLOWED) {
|
|
15
23
|
let text;
|
|
16
24
|
try {
|
|
17
25
|
text = readFileSync(resolve(dir, ".env"), "utf8");
|
|
@@ -32,6 +40,8 @@ export function loadDotEnv(dir = process.cwd()) {
|
|
|
32
40
|
const key = line.slice(0, eq).trim();
|
|
33
41
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(key))
|
|
34
42
|
continue;
|
|
43
|
+
if (!allowed.has(key))
|
|
44
|
+
continue;
|
|
35
45
|
if (process.env[key] !== undefined)
|
|
36
46
|
continue;
|
|
37
47
|
let val = line.slice(eq + 1).trim();
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Strip terminal control characters from text that originated outside efaimo.
|
|
3
|
+
* Tab and newline survive because layout depends on them; everything else that
|
|
4
|
+
* can move a cursor, clear a line, or address the terminal is removed.
|
|
5
|
+
*/
|
|
6
|
+
export declare function safeText(s: string): string;
|
|
7
|
+
/** Same, for a value that may be undefined. */
|
|
8
|
+
export declare function safeTextOpt(s: string | undefined): string | undefined;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
// Text that came from the thing being audited must not be able to drive the
|
|
2
|
+
// terminal it is reported in.
|
|
3
|
+
//
|
|
4
|
+
// Tool names, titles, descriptions, server instructions, stderr tails and
|
|
5
|
+
// repo-scan excerpts all originate with the target. They were interpolated
|
|
6
|
+
// into stdout raw, so a hostile server could emit ESC[1A ESC[2K inside a tool
|
|
7
|
+
// name and rewrite the grade line printed above its own finding: the auditor
|
|
8
|
+
// reads "grade A (100), 0 errors" while the finding that contradicts it is
|
|
9
|
+
// erased. OSC 8 forges a hyperlink; OSC 52 writes the reader's clipboard.
|
|
10
|
+
// `--no-color` did not help, because that only suppresses the colours efaimo
|
|
11
|
+
// itself adds.
|
|
12
|
+
//
|
|
13
|
+
// This matters out of proportion to its severity. An auditing tool whose
|
|
14
|
+
// verdict can be rewritten by the thing it audits has a credibility problem
|
|
15
|
+
// before it has a security one, and the HTTP transport is exactly where a user
|
|
16
|
+
// expects no code execution and therefore no consequences.
|
|
17
|
+
//
|
|
18
|
+
// Applied at the RENDER boundary, never on ingestion: escape bytes in a
|
|
19
|
+
// description are real bytes that a model would receive and really do cost
|
|
20
|
+
// tokens, so `weigh` has to keep counting them. What must not happen is that
|
|
21
|
+
// they reach a terminal as control codes.
|
|
22
|
+
//
|
|
23
|
+
// JSON output needs no pass here: JSON.stringify already encodes control
|
|
24
|
+
// characters as escape sequences, which are inert text.
|
|
25
|
+
// C0 except tab and newline, DEL, and the C1 block. C1 is included because a
|
|
26
|
+
// lone 0x9B is CSI in a terminal that decodes single-byte C1, which is the
|
|
27
|
+
// same attack without an ESC byte to grep for.
|
|
28
|
+
// Built from code points rather than written as literals so this file does not
|
|
29
|
+
// itself contain the bytes it exists to remove; the same reason gates.mjs next
|
|
30
|
+
// door builds its dash class with String.fromCharCode.
|
|
31
|
+
const CONTROLS = new RegExp("[" +
|
|
32
|
+
"\\u0000-\\u0008" + // C0 up to backspace (tab 0x09 and newline 0x0A kept)
|
|
33
|
+
"\\u000B-\\u001F" + // vertical tab through unit separator, ESC included
|
|
34
|
+
"\\u007F" + // DEL
|
|
35
|
+
"\\u0080-\\u009F" + // C1, single-byte CSI/OSC among them
|
|
36
|
+
"]", "g");
|
|
37
|
+
/**
|
|
38
|
+
* Strip terminal control characters from text that originated outside efaimo.
|
|
39
|
+
* Tab and newline survive because layout depends on them; everything else that
|
|
40
|
+
* can move a cursor, clear a line, or address the terminal is removed.
|
|
41
|
+
*/
|
|
42
|
+
export function safeText(s) {
|
|
43
|
+
return s.replace(CONTROLS, "");
|
|
44
|
+
}
|
|
45
|
+
/** Same, for a value that may be undefined. */
|
|
46
|
+
export function safeTextOpt(s) {
|
|
47
|
+
return s === undefined ? undefined : safeText(s);
|
|
48
|
+
}
|
package/dist/version.d.ts
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by test/meta.test.ts. */
|
|
2
|
-
export declare const VERSION = "0.1.
|
|
2
|
+
export declare const VERSION = "0.1.1";
|
package/dist/version.js
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
/** Kept in sync with package.json by test/meta.test.ts. */
|
|
2
|
-
export const VERSION = "0.1.
|
|
2
|
+
export const VERSION = "0.1.1";
|
package/dist/weigh/weigh.js
CHANGED
|
@@ -69,7 +69,9 @@ export async function weighSkills(set) {
|
|
|
69
69
|
let refFileTokens = 0;
|
|
70
70
|
let refFileCount = 0;
|
|
71
71
|
for (const ref of skill.referencedPaths) {
|
|
72
|
-
|
|
72
|
+
// Never read outside the skill root. S106 warns about these, but only
|
|
73
|
+
// after weigh had already opened the file and counted its tokens.
|
|
74
|
+
if (ref.escapes || !ref.exists || isBinaryPath(ref.resolved))
|
|
73
75
|
continue;
|
|
74
76
|
const text = readTextSafe(ref.resolved);
|
|
75
77
|
if (text === undefined)
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context window a token count is reported as a share of.
|
|
3
|
+
*
|
|
4
|
+
* There is no single correct denominator: it is a property of the model the
|
|
5
|
+
* host happens to be running, not of the server being measured. Every current
|
|
6
|
+
* frontier Claude model (Fable 5, Opus 5, Opus 4.8/4.7/4.6, Sonnet 5, Sonnet
|
|
7
|
+
* 4.6) is 1M; Haiku 4.5 is 200K, as are many non-Claude and local models, where the
|
|
8
|
+
* same tool definitions cost 5x the share shown here.
|
|
9
|
+
*
|
|
10
|
+
* So the share is always printed with the window it was computed against, and
|
|
11
|
+
* `--window` lets the caller name their own. The absolute token count is the
|
|
12
|
+
* number efaimo actually stands behind; this is a readability aid on top of it.
|
|
13
|
+
*
|
|
14
|
+
* Note that no rule grades on this share -- E128's thresholds are absolute
|
|
15
|
+
* token counts, so changing the window never moves a grade.
|
|
16
|
+
*/
|
|
17
|
+
export declare const DEFAULT_CONTEXT_WINDOW = 1000000;
|
|
18
|
+
export declare function setContextWindow(tokens: number): void;
|
|
19
|
+
export declare function getContextWindow(): number;
|
|
20
|
+
/** "1M", "200k", "128k", "4,096" -- how the window is named in output. */
|
|
21
|
+
export declare function formatWindow(tokens?: number): string;
|
|
22
|
+
/**
|
|
23
|
+
* "~0.1% of a 1M window" -- always names the denominator, so the reader is
|
|
24
|
+
* never left guessing which model it assumed.
|
|
25
|
+
*/
|
|
26
|
+
export declare function formatWindowShare(tokens: number, window?: number): string;
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The context window a token count is reported as a share of.
|
|
3
|
+
*
|
|
4
|
+
* There is no single correct denominator: it is a property of the model the
|
|
5
|
+
* host happens to be running, not of the server being measured. Every current
|
|
6
|
+
* frontier Claude model (Fable 5, Opus 5, Opus 4.8/4.7/4.6, Sonnet 5, Sonnet
|
|
7
|
+
* 4.6) is 1M; Haiku 4.5 is 200K, as are many non-Claude and local models, where the
|
|
8
|
+
* same tool definitions cost 5x the share shown here.
|
|
9
|
+
*
|
|
10
|
+
* So the share is always printed with the window it was computed against, and
|
|
11
|
+
* `--window` lets the caller name their own. The absolute token count is the
|
|
12
|
+
* number efaimo actually stands behind; this is a readability aid on top of it.
|
|
13
|
+
*
|
|
14
|
+
* Note that no rule grades on this share -- E128's thresholds are absolute
|
|
15
|
+
* token counts, so changing the window never moves a grade.
|
|
16
|
+
*/
|
|
17
|
+
export const DEFAULT_CONTEXT_WINDOW = 1_000_000;
|
|
18
|
+
let contextWindow = DEFAULT_CONTEXT_WINDOW;
|
|
19
|
+
export function setContextWindow(tokens) {
|
|
20
|
+
if (!Number.isFinite(tokens) || tokens <= 0) {
|
|
21
|
+
throw new Error(`--window must be a positive number of tokens, got ${tokens}`);
|
|
22
|
+
}
|
|
23
|
+
contextWindow = tokens;
|
|
24
|
+
}
|
|
25
|
+
export function getContextWindow() {
|
|
26
|
+
return contextWindow;
|
|
27
|
+
}
|
|
28
|
+
/** "1M", "200k", "128k", "4,096" -- how the window is named in output. */
|
|
29
|
+
export function formatWindow(tokens = contextWindow) {
|
|
30
|
+
if (tokens >= 1_000_000 && tokens % 1_000_000 === 0)
|
|
31
|
+
return `${tokens / 1_000_000}M`;
|
|
32
|
+
if (tokens >= 1_000 && tokens % 1_000 === 0)
|
|
33
|
+
return `${tokens / 1_000}k`;
|
|
34
|
+
return tokens.toLocaleString("en-US");
|
|
35
|
+
}
|
|
36
|
+
/**
|
|
37
|
+
* "~0.1% of a 1M window" -- always names the denominator, so the reader is
|
|
38
|
+
* never left guessing which model it assumed.
|
|
39
|
+
*/
|
|
40
|
+
export function formatWindowShare(tokens, window = contextWindow) {
|
|
41
|
+
const pct = (tokens / window) * 100;
|
|
42
|
+
const shown = pct < 0.1 && pct > 0 ? "<0.1" : pct.toFixed(1);
|
|
43
|
+
return `~${shown}% of a ${formatWindow(window)} window`;
|
|
44
|
+
}
|
package/docs/METHODOLOGY.md
CHANGED
|
@@ -48,6 +48,29 @@ wrapped total. For the other two serializations no wrapper exists, and summing
|
|
|
48
48
|
per-tool counts may differ from the total by a few tokens, because a tokenizer
|
|
49
49
|
can merge characters across element boundaries when tools are concatenated.
|
|
50
50
|
|
|
51
|
+
## The context window we compare against
|
|
52
|
+
|
|
53
|
+
Alongside the absolute token count, efaimo prints the share of a context window
|
|
54
|
+
it represents. There is no correct denominator for that: the window belongs to
|
|
55
|
+
whichever model the host is running, not to the server being measured. Current
|
|
56
|
+
frontier Claude models (Fable 5, Opus 5, Opus 4.8/4.7/4.6, Sonnet 5, Sonnet
|
|
57
|
+
4.6) are **1M**, so that is the default. Haiku 4.5 is 200k, as are many
|
|
58
|
+
non-Claude and local models, where identical tool definitions cost five times
|
|
59
|
+
the share.
|
|
60
|
+
|
|
61
|
+
Because the denominator is an assumption rather than a measurement, efaimo
|
|
62
|
+
always names it in the output ("~4.3% of a 1M window") and lets you set your
|
|
63
|
+
own with `--window` (since 0.1.1; 0.1.0 hardcoded 200k and printed `of a 200k window`):
|
|
64
|
+
|
|
65
|
+
```bash
|
|
66
|
+
npx efaimo weigh "npx -y my-server" --window 200000
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
**No rule grades on this share.** The cost thresholds (E127, E128) are absolute
|
|
70
|
+
token counts, so changing `--window` changes what you read and never changes a
|
|
71
|
+
grade. The absolute number is the one efaimo stands behind; the share is a
|
|
72
|
+
readability aid on top of it.
|
|
73
|
+
|
|
51
74
|
## Skills
|
|
52
75
|
|
|
53
76
|
For Agent Skills efaimo reports the three progressive-disclosure levels defined by
|
|
@@ -81,3 +104,50 @@ prompt-cache hits and reproducibility.
|
|
|
81
104
|
efaimo estimates the **cost of tool/skill definitions sitting in context**. It does
|
|
82
105
|
not measure per-call argument/result tokens, host framing, or model-side reasoning.
|
|
83
106
|
For end-to-end token accounting, instrument your agent run directly.
|
|
107
|
+
|
|
108
|
+
## `efaimo test`: what the verdict means
|
|
109
|
+
|
|
110
|
+
The verdict is a significance test, not a threshold on the size of the gap.
|
|
111
|
+
|
|
112
|
+
Each arm runs N trials of the same task, one with the skill in the system
|
|
113
|
+
prompt and one without, and an LLM judge returns PASS or FAIL per trial. The
|
|
114
|
+
two arms form a 2x2 table, and the report carries:
|
|
115
|
+
|
|
116
|
+
- a **two-sided Fisher exact p**. Fisher rather than chi-squared because the
|
|
117
|
+
counts are single digits, where the chi-squared approximation is not
|
|
118
|
+
trustworthy;
|
|
119
|
+
- a **95% Newcombe interval on the delta**, built from each arm's Wilson
|
|
120
|
+
interval. Wilson rather than the normal approximation because runs land on
|
|
121
|
+
the boundaries (8/8, 0/8), where the normal interval has zero width and
|
|
122
|
+
would claim certainty from eight trials.
|
|
123
|
+
|
|
124
|
+
`helps` and `hurts` require **p < 0.05**. Anything else is `no measurable
|
|
125
|
+
effect`, however large the gap looks, and the report says so with the p
|
|
126
|
+
attached. The default is 20 trials per arm: below roughly that, a partial
|
|
127
|
+
result cannot reach significance at all.
|
|
128
|
+
|
|
129
|
+
Until 2026-08-02 the rule was `>= +15 points helps`, with a default of 5
|
|
130
|
+
trials. At that size 5/5 against 4/5 is +20 points and p = 1.0000. See ADR-028.
|
|
131
|
+
|
|
132
|
+
Two things the judge does, which matter for reading a result:
|
|
133
|
+
|
|
134
|
+
- it runs at **temperature 0**, so the same answer grades the same way twice.
|
|
135
|
+
The subject arm stays at temperature 1, because normal behaviour is the thing
|
|
136
|
+
being measured;
|
|
137
|
+
- a reply that is neither PASS nor FAIL is **excluded and counted separately**,
|
|
138
|
+
not scored as a failure. A refusal or an API error is not evidence about the
|
|
139
|
+
skill.
|
|
140
|
+
|
|
141
|
+
### What this design still cannot tell you
|
|
142
|
+
|
|
143
|
+
Named here rather than left for a reader to discover:
|
|
144
|
+
|
|
145
|
+
- **The judge is the same model as the subject.** A separate judge model would
|
|
146
|
+
be stronger.
|
|
147
|
+
- **The control arm receives no system prompt at all**, so "the skill's
|
|
148
|
+
content" is confounded with "having any system prompt". A length-matched
|
|
149
|
+
placebo is the fix and is not implemented.
|
|
150
|
+
- **Two scenarios is a demonstration, not a benchmark.** The committed runs
|
|
151
|
+
show the method works, not that skills in general do or do not help.
|
|
152
|
+
|
|
153
|
+
Treat a single scenario as evidence about that scenario.
|
package/docs/RULES.md
CHANGED
|
@@ -7,43 +7,49 @@ Grade starts at 100; each error costs 15, each warning 5, each info 1.
|
|
|
7
7
|
`A >= 90, B >= 80, C >= 70, D >= 60, else F`.
|
|
8
8
|
|
|
9
9
|
MCP checks split into **2026-07-28 readiness** (E101-E118) and **quality**
|
|
10
|
-
(E121-E130); skill checks are S1xx. **Only quality and skill findings are
|
|
10
|
+
(E121-E128 and E130, with no E129); skill checks are S1xx. **Only quality and skill findings are
|
|
11
11
|
graded.** Readiness findings are reported as a separate, ungraded **migration
|
|
12
|
-
diff**: the
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
12
|
+
diff**: the two answer different questions. The grade is what a model
|
|
13
|
+
experiences from this tool surface; readiness is whether a 2026-07-28 client can
|
|
14
|
+
talk to this server. Combining them scored the official reference server a C(79)
|
|
15
|
+
for a tool surface that is an A(95), which is why they are two numbers printed
|
|
16
|
+
next to each other rather than one. Readiness severities still order the diff by
|
|
17
|
+
urgency, but the diff never affects the grade or the badge, and never affects
|
|
18
|
+
the exit code unless you ask for it with `--strict-readiness`. Source-pattern
|
|
19
|
+
rules are heuristics and say so in their detail line.
|
|
19
20
|
|
|
20
21
|
## MCP readiness (E101-E118)
|
|
21
22
|
|
|
22
23
|
| id | sev | what it catches | source of truth |
|
|
23
24
|
|---|---|---|---|
|
|
24
25
|
| E101 | warn | depends on a pre-`2026-07-28` SDK line (TS `@modelcontextprotocol/sdk` 1.x, Python `mcp` 1.x) instead of the 2.x stateless line | changelog / SDK releases |
|
|
25
|
-
| E102 | warn | source uses **Sampling** (`sampling/createMessage`), deprecated (SEP-2577) |
|
|
26
|
-
| E103 | warn | source uses **Roots** (`roots/list`), deprecated (SEP-2577) |
|
|
27
|
-
| E104 | warn | server declares the **logging** capability or source uses MCP Logging, deprecated (SEP-2577); `logging/setLevel` is removed |
|
|
26
|
+
| E102 | warn | source uses **Sampling** (`sampling/createMessage`), deprecated (SEP-2577) | 2026-07-28 changelog |
|
|
27
|
+
| E103 | warn | source uses **Roots** (`roots/list`), deprecated (SEP-2577) | 2026-07-28 changelog |
|
|
28
|
+
| E104 | warn | server declares the **logging** capability or source uses MCP Logging, deprecated (SEP-2577); `logging/setLevel` is removed | 2026-07-28 changelog |
|
|
28
29
|
| E105 | warn | the server does not answer a bare stateless `tools/list` (timeout, crash, or a not-initialized error), so it requires the removed `initialize` handshake. A server that answers (even with an error) is judged by E106/E107/E118 instead | SEP-2567 / stateless core |
|
|
29
|
-
| E106 | warn | `server/discover` is not implemented (MUST in
|
|
30
|
-
| E107 | info | results omit the required `resultType` field (`"complete"` \| `"input_required"`) |
|
|
31
|
-
| E108 | info | source relies on removed SSE resumability (`Last-Event-ID`) |
|
|
30
|
+
| E106 | warn | `server/discover` is not implemented (MUST in 2026-07-28, SEP-2575) | 2026-07-28 changelog |
|
|
31
|
+
| E107 | info | results omit the required `resultType` field (`"complete"` \| `"input_required"`) | 2026-07-28 changelog |
|
|
32
|
+
| E108 | info | source relies on removed SSE resumability (`Last-Event-ID`) | 2026-07-28 changelog |
|
|
32
33
|
| E109 | info | auth advertises deprecated DCR (RFC 7591) with no detected CIMD support | PR #2858 / CIMD |
|
|
33
|
-
| E110 | warn | source uses legacy elicitation (`elicitation/create`); replaced by MRTR `input_required` results (SEP-2322) |
|
|
34
|
+
| E110 | warn | source uses legacy elicitation (`elicitation/create`); replaced by MRTR `input_required` results (SEP-2322) | 2026-07-28 changelog |
|
|
34
35
|
| E111 | info | in-process session-state patterns; statelessness expects server-minted handles in tool args | stateless core |
|
|
35
|
-
| E112 | warn | `tools/list` order is nondeterministic across connections (hurts prompt-cache hits) |
|
|
36
|
+
| E112 | warn | `tools/list` order is nondeterministic across connections (hurts prompt-cache hits) | 2026-07-28 changelog |
|
|
36
37
|
| E113 | info | no Server Card found at `/.well-known/mcp` (discovery metadata; heuristic path) | roadmap / Server Card WG |
|
|
37
|
-
| E114 | info | source uses the removed `ping` utility |
|
|
38
|
-
| E115 | info | source uses `resources/subscribe`, replaced by `subscriptions/listen` |
|
|
38
|
+
| E114 | info | source uses the removed `ping` utility | 2026-07-28 changelog |
|
|
39
|
+
| E115 | info | source uses `resources/subscribe`, replaced by `subscriptions/listen` | 2026-07-28 changelog |
|
|
39
40
|
| E116 | warn | server prints non-JSON noise on stdout (breaks stdio framing) | transport basics |
|
|
40
|
-
| E117 | warn | only the deprecated HTTP+SSE transport worked; Streamable HTTP failed |
|
|
41
|
-
| E118 | warn | `tools/list` result omits the
|
|
41
|
+
| E117 | warn | only the deprecated HTTP+SSE transport worked; Streamable HTTP failed | 2026-07-28 changelog |
|
|
42
|
+
| E118 | warn | a `tools/list` or `server/discover` result omits the required `ttlMs`/`cacheScope` cache fields (SEP-2549, CacheableResult; also required on prompts/list, resources/list, resources/read, resources/templates/list). Both surfaces efaimo calls are measured and the message names the ones that failed. `server/discover` counts only since the published spec made `DiscoverResult` extend `CacheableResult`; under the locked RC it did not | 2026-07-28 changelog |
|
|
42
43
|
|
|
43
|
-
## MCP quality (E121-E130)
|
|
44
|
+
## MCP quality (E121-E128, E130)
|
|
45
|
+
|
|
46
|
+
There is no E129. The range used to be written "E121-E130", which implies a
|
|
47
|
+
contiguous block and invites a lookup that finds nothing; ids are stable and
|
|
48
|
+
never reused, so a gap is permanent and is better stated than smoothed over.
|
|
44
49
|
|
|
45
50
|
| id | sev | what it catches |
|
|
46
51
|
|---|---|---|
|
|
52
|
+
| E000 | warn | a rule threw and was skipped, so this report is incomplete. **Reported but never scored**: a broken rule is our defect, not the target's, and must not cost it points. It exists because `runRules` used to swallow the exception silently, which made a crashing rule indistinguishable from a passing one and silently RAISED the grade |
|
|
47
53
|
| E121 | error/warn | tool description missing, a placeholder, or under ~20 chars |
|
|
48
54
|
| E122 | warn | description misses 3+ of 4 quality axes (length, when-to-use, params documented, mentions result), mirrors Glama's tool-definition-quality dimensions, computed locally |
|
|
49
55
|
| E123 | warn/info | no tools declare annotations; a destructive-looking tool lacks `destructiveHint` |
|
|
@@ -52,7 +58,7 @@ so in their detail line.
|
|
|
52
58
|
| E126 | warn/info | tool names mix conventions or exceed 64 chars |
|
|
53
59
|
| E127 | warn | a single tool definition costs 800+ tokens (needs `weigh` data) |
|
|
54
60
|
| E128 | warn | total tool-definition tax over ~10k / ~25k tokens, estimated (needs `weigh` data); hard CI failure is left to the `--max-tokens`/`--diff` gate |
|
|
55
|
-
| E130 | info | instruction-injection patterns in a tool description or server instructions (shallow heuristic, never a security verdict; use a dedicated scanner for depth) |
|
|
61
|
+
| E130 | info | instruction-injection patterns in a tool description or server instructions (shallow heuristic, never a security verdict; use a dedicated scanner for depth). **Reported but never scored** |
|
|
56
62
|
|
|
57
63
|
## Skills (S101-S106)
|
|
58
64
|
|
|
@@ -62,7 +68,7 @@ so in their detail line.
|
|
|
62
68
|
| S102 | warn/info | description too thin or never says when to use the skill | agentskills.io |
|
|
63
69
|
| S103 | error/warn | duplicate skill names, or two skills with heavily overlapping descriptions (trigger collision) | internal |
|
|
64
70
|
| S104 | warn/info | metadata over ~100 tokens, body over ~5k tokens or 500 lines, or long body with no progressive disclosure | agentskills.io |
|
|
65
|
-
| S105 | info | instruction-injection patterns in the skill body or description (shallow heuristic, never a security verdict) | internal |
|
|
71
|
+
| S105 | info | instruction-injection patterns in the skill body or description (shallow heuristic, never a security verdict). **Reported but never scored** | internal |
|
|
66
72
|
| S106 | error/warn/info | referenced file missing, escaping the skill dir (`..`), or nested more than one level deep | agentskills.io |
|
|
67
73
|
|
|
68
74
|
## Notes on heuristics
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: contoso-crm-import
|
|
3
|
+
description: Format a contact list as a Contoso CRM bulk-import file. Use when the user asks to prepare, convert, or export contacts for Contoso CRM import.
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# Contoso CRM bulk-import format
|
|
8
|
+
|
|
9
|
+
Convert a contact list into the exact file Contoso CRM accepts on import. These
|
|
10
|
+
rules are an internal convention and are not guessable; follow them exactly.
|
|
11
|
+
|
|
12
|
+
## Output rules
|
|
13
|
+
1. Semicolon-delimited. No header row.
|
|
14
|
+
2. Four columns, in this order: member_id;display_name;email;source
|
|
15
|
+
3. member_id: the first three letters of the last name, uppercased, then a hyphen,
|
|
16
|
+
then a row number zero-padded to four digits starting at 0001 (0001, 0002, ...).
|
|
17
|
+
4. display_name: the full name exactly as given.
|
|
18
|
+
5. email: lowercased.
|
|
19
|
+
6. source: always the literal value import-2026.
|
|
20
|
+
|
|
21
|
+
Output only the rows.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: csv-cleanup
|
|
3
|
+
description: Clean and validate CSV data. Use when the user asks to deduplicate, trim, or sanity-check a CSV before import.
|
|
4
|
+
license: Apache-2.0
|
|
5
|
+
---
|
|
6
|
+
|
|
7
|
+
# CSV cleanup
|
|
8
|
+
|
|
9
|
+
Use this when the user wants to tidy CSV data.
|
|
10
|
+
|
|
11
|
+
## Steps
|
|
12
|
+
1. Detect the delimiter (comma, tab, or semicolon) from the header row.
|
|
13
|
+
2. Trim leading and trailing whitespace from every cell.
|
|
14
|
+
3. Drop rows that are exact duplicates of an earlier row.
|
|
15
|
+
4. Report the final row and column counts, and how many duplicates were removed.
|
|
16
|
+
|
|
17
|
+
Always show the cleaned result and a one-line summary of what changed.
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# efaimo test scenario: a skill that encodes a convention the model cannot guess.
|
|
2
|
+
# Contrast with scenario.example.yaml, where a capable model needs no skill.
|
|
3
|
+
# Real run (spends tokens): npx efaimo test examples/scenario.crm.yaml --live
|
|
4
|
+
name: contoso-crm-import helps on an unknowable format
|
|
5
|
+
skill: ./contoso-crm-import
|
|
6
|
+
model: claude-sonnet-5
|
|
7
|
+
trials: 8
|
|
8
|
+
task: |
|
|
9
|
+
Prepare these contacts for Contoso CRM import:
|
|
10
|
+
|
|
11
|
+
Ada Lovelace, ADA@Example.com
|
|
12
|
+
Grace Hopper, Grace.Hopper@NAVY.MIL
|
|
13
|
+
Alan Turing, a.turing@Bletchley.uk
|
|
14
|
+
judge: |
|
|
15
|
+
PASS if the answer contains these three rows (ignore any surrounding prose or code
|
|
16
|
+
fences), each semicolon-delimited as member_id;display_name;email;source:
|
|
17
|
+
LOV-0001;Ada Lovelace;ada@example.com;import-2026
|
|
18
|
+
HOP-0002;Grace Hopper;grace.hopper@navy.mil;import-2026
|
|
19
|
+
TUR-0003;Alan Turing;a.turing@bletchley.uk;import-2026
|
|
20
|
+
The member_id (first three letters of the last name uppercased, a hyphen, then a
|
|
21
|
+
four-digit zero-padded row number), the lowercased email, and the literal source
|
|
22
|
+
value import-2026 must all be exact. Any other format is FAIL.
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# efaimo test scenario: does the csv-cleanup skill actually help?
|
|
2
|
+
# Dry run (no API calls): npx efaimo test examples/scenario.example.yaml
|
|
3
|
+
# Real run (spends tokens): npx efaimo test examples/scenario.example.yaml --live
|
|
4
|
+
name: csv-cleanup helps on a messy CSV
|
|
5
|
+
skill: ./csv-cleanup
|
|
6
|
+
model: claude-sonnet-5
|
|
7
|
+
trials: 8
|
|
8
|
+
task: |
|
|
9
|
+
Clean this CSV and summarize what you changed:
|
|
10
|
+
|
|
11
|
+
name , email ,city
|
|
12
|
+
Ada ,ada@example.com, London
|
|
13
|
+
Ada ,ada@example.com, London
|
|
14
|
+
Grace,grace@example.com ,New York
|
|
15
|
+
judge: |
|
|
16
|
+
PASS only if the answer (a) removes the duplicate Ada row, (b) trims the stray
|
|
17
|
+
whitespace around cells, and (c) states the final row count. Otherwise FAIL.
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "efaimo",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.1",
|
|
4
4
|
"description": "Audit what your agent loads: quality and context cost for MCP servers and Agent Skills",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"mcp",
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
},
|
|
47
47
|
"files": [
|
|
48
48
|
"dist",
|
|
49
|
+
"examples",
|
|
49
50
|
"SKILL.md",
|
|
50
51
|
"NOTICE",
|
|
51
52
|
"CHANGELOG.md",
|
|
@@ -57,6 +58,7 @@
|
|
|
57
58
|
"build": "tsc -p tsconfig.build.json",
|
|
58
59
|
"typecheck": "tsc --noEmit",
|
|
59
60
|
"test": "vitest run",
|
|
61
|
+
"spec:drift": "node scripts/spec-drift.mjs",
|
|
60
62
|
"test:watch": "vitest",
|
|
61
63
|
"prepublishOnly": "npm run typecheck && npm run build && npm run test"
|
|
62
64
|
},
|