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.
Files changed (41) hide show
  1. package/CHANGELOG.md +97 -2
  2. package/README.md +88 -30
  3. package/SKILL.md +13 -1
  4. package/dist/check/check.js +6 -2
  5. package/dist/cli.js +78 -4
  6. package/dist/clients/rawprobe.d.ts +21 -5
  7. package/dist/clients/rawprobe.js +101 -43
  8. package/dist/clients/stateless.js +17 -14
  9. package/dist/core/engine.js +20 -2
  10. package/dist/core/grade.js +7 -1
  11. package/dist/core/types.d.ts +22 -3
  12. package/dist/reporters/markdown.js +16 -4
  13. package/dist/reporters/pretty.js +36 -25
  14. package/dist/rules/injection.js +7 -0
  15. package/dist/rules/mcp/index.d.ts +14 -0
  16. package/dist/rules/mcp/index.js +68 -24
  17. package/dist/skills/parse.js +39 -3
  18. package/dist/targets/clientConfigs.d.ts +7 -0
  19. package/dist/targets/clientConfigs.js +4 -1
  20. package/dist/testing/anthropicRunner.js +2 -0
  21. package/dist/testing/harness.d.ts +21 -0
  22. package/dist/testing/harness.js +62 -11
  23. package/dist/testing/openaiRunner.js +8 -1
  24. package/dist/testing/stats.d.ts +32 -0
  25. package/dist/testing/stats.js +91 -0
  26. package/dist/util/dotenv.d.ts +9 -1
  27. package/dist/util/dotenv.js +11 -1
  28. package/dist/util/safeText.d.ts +8 -0
  29. package/dist/util/safeText.js +48 -0
  30. package/dist/version.d.ts +1 -1
  31. package/dist/version.js +1 -1
  32. package/dist/weigh/weigh.js +3 -1
  33. package/dist/weigh/window.d.ts +26 -0
  34. package/dist/weigh/window.js +44 -0
  35. package/docs/METHODOLOGY.md +70 -0
  36. package/docs/RULES.md +29 -23
  37. package/examples/contoso-crm-import/SKILL.md +21 -0
  38. package/examples/csv-cleanup/SKILL.md +17 -0
  39. package/examples/scenario.crm.yaml +22 -0
  40. package/examples/scenario.example.yaml +17 -0
  41. 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;
@@ -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
- export declare function loadDotEnv(dir?: string): string[];
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[];
@@ -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
- export function loadDotEnv(dir = process.cwd()) {
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.0";
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.0";
2
+ export const VERSION = "0.1.1";
@@ -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
- if (!ref.exists || isBinaryPath(ref.resolved))
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
+ }
@@ -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 stateless revision they target is a locked Release Candidate
13
- (2026-05-21) that does not finalize until 2026-07-28, and not yet migrating to
14
- an unreleased spec is a to-do, not a defect. Readiness severities still order
15
- the diff by urgency, but the diff never affects the grade, the badge, or the
16
- exit code (including `--strict`). Once the spec ratifies, expect readiness to
17
- start counting in a minor release. Source-pattern rules are heuristics and say
18
- so in their detail line.
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) | draft changelog |
26
- | E103 | warn | source uses **Roots** (`roots/list`), deprecated (SEP-2577) | draft changelog |
27
- | E104 | warn | server declares the **logging** capability or source uses MCP Logging, deprecated (SEP-2577); `logging/setLevel` is removed | draft changelog |
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 the RC, SEP-2575) | draft changelog |
30
- | E107 | info | results omit the required `resultType` field (`"complete"` \| `"input_required"`) | draft changelog |
31
- | E108 | info | source relies on removed SSE resumability (`Last-Event-ID`) | draft changelog |
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) | draft changelog |
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) | draft changelog |
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 | draft changelog |
38
- | E115 | info | source uses `resources/subscribe`, replaced by `subscriptions/listen` | draft changelog |
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 | draft changelog |
41
- | E118 | warn | `tools/list` result omits the now-required `ttlMs`/`cacheScope` cache fields (SEP-2549, CacheableResult; also required on prompts/list, resources/list, resources/read, resources/templates/list, server/discover) | draft changelog |
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.0",
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
  },