efaimo 0.1.1 → 0.1.2

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 CHANGED
@@ -4,6 +4,65 @@ 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.1.2] - 2026-08-03
8
+
9
+ The 0.1.2 code backlog: edge-case fixes surfaced by the five-agent review, none
10
+ of which changes a published number or the documented command surface, plus the
11
+ security and CI hardening already staged.
12
+
13
+ ### Fixed
14
+
15
+ - `check --mcp <directory>` with no source files and no MCP SDK dependency now
16
+ fails instead of returning a clean A(100): an empty scan is a failure, never a
17
+ pass.
18
+ - S101: a skill with no frontmatter reports its missing `name` and `description`
19
+ as errors, so a total absence no longer grades higher than a broken one.
20
+ - E105: a server that exits on a bare stateless `tools/list` is flagged. The
21
+ crash outcome was unhandled, so a crashing server looked more migrated than
22
+ one that answers with a not-initialized error.
23
+ - E124: the "N of M parameters lack descriptions" summary is no longer dropped
24
+ by the display cap on schemas with five or more oversized enums.
25
+ - S106: the escape check is lexical (the resolved path), not a `..` substring,
26
+ so a file merely named `notes..md` is not flagged as escaping.
27
+ - A Poetry `mcp = "^2.0"` constraint in `pyproject.toml` is read as the 2.x
28
+ line, not legacy; the version regex had captured the `=` assignment.
29
+ - `weigh --diff` with a skill baseline fails with a clear message instead of a
30
+ raw TypeError; a server baseline is required.
31
+ - `--max-tokens` and `--allow-increase` reject non-positive / negative values
32
+ instead of turning the gate into an always-fail with a nonsense message.
33
+ - `efaimo test --live` against an OpenAI o-series reasoning model no longer
34
+ sends an unsupported `temperature` (a 400) and gives it token headroom.
35
+ - The live test harness retries a transient 429 or 5xx with backoff instead of
36
+ discarding every already-paid trial on the first blip.
37
+ - The test report prints a clean note instead of "95% interval NaN to NaN" when
38
+ an arm produces no scoreable trial.
39
+ - A dead stdio target fails in about one `--timeout`, not two: the legacy
40
+ handshake and the stateless fallback now share the budget.
41
+ - Internal: dropped a dead exported constant and the unused per-pattern
42
+ `severity` on the injection heuristics (every hit is info + ungraded by
43
+ design).
44
+
45
+ ### Security
46
+
47
+ - The lockfile was re-resolved so the dev/CI tree carries
48
+ `@hono/node-server` 2.0.12 instead of the 1.19.x line that
49
+ GHSA-frvp-7c67-39w9 covers (moderate, path traversal in `serve-static`, in
50
+ the half of the MCP SDK this CLI never executes). A fresh `npx efaimo`
51
+ install already resolved the patched line; this aligns the committed tree
52
+ with what users actually get, and `pnpm audit --prod` is clean again.
53
+
54
+ ### Changed
55
+
56
+ - `@modelcontextprotocol/sdk` floor moved within-range from ^1.29.0 to ^1.30.0
57
+ (what a fresh install resolves anyway). The full suite (136 tests) is green.
58
+ - CI: the reference-server smoke is pinned to
59
+ `@modelcontextprotocol/server-everything@2026.7.4`. Per-commit CI should be
60
+ deterministic; news about the reference server's latest belongs to a
61
+ scheduled run, not to a push.
62
+ - CI: `spec-drift.yml` and the example workflow now pin actions by commit SHA
63
+ like the other workflows, and the example declares least-privilege
64
+ `permissions`.
65
+
7
66
  ## [0.1.1] - 2026-08-02
8
67
 
9
68
  Everything below was on `main` and unpublished. That gap was itself the
@@ -32,7 +91,7 @@ that notices the next revision by itself.
32
91
  hostile server could put ESC[1A ESC[2K in a tool name and rewrite the grade
33
92
  line printed above its own finding, so the reader saw "grade A (100), 0
34
93
  errors" while the contradiction was erased. `--no-color` did not help; that
35
- only suppresses colours efaimo adds.
94
+ only suppresses colors efaimo adds.
36
95
  - `.env` loading is restricted to `ANTHROPIC_API_KEY` and `OPENAI_API_KEY`.
37
96
  It previously set ANY key from a working-directory `.env`, so a cloned
38
97
  repository could set `NODE_TLS_REJECT_UNAUTHORIZED=0` and silently disable
@@ -40,8 +99,8 @@ that notices the next revision by itself.
40
99
  key.
41
100
  - Skill references that resolve outside the skill directory are no longer read
42
101
  by `weigh`. S106 already warned about them, but only after the read.
43
- - `SKILL.md` is read through the same 512KB-class cap as referenced files
44
- (2MB), instead of an uncapped read.
102
+ - `SKILL.md` is read through a capped read (2MB) on the same guarded path that
103
+ caps referenced files at 512KB, instead of an uncapped read.
45
104
  - The published composite Action no longer interpolates its inputs into a bash
46
105
  `run:` body, which was script injection in the consumer's runner.
47
106
 
@@ -101,7 +160,7 @@ that notices the next revision by itself.
101
160
  extend `CacheableResult`, so that surface is required now, is measured now,
102
161
  and the message names whichever surface failed.
103
162
 
104
- ## [0.1.0]
163
+ ## [0.1.0] - 2026-07-18
105
164
 
106
165
  First release.
107
166
 
@@ -114,7 +173,7 @@ First release.
114
173
  broken or auth-gated server is skipped with a reason, not fatal.
115
174
  `--out`/`--diff`/`--max-tokens`/`--allow-increase` for CI budget gates,
116
175
  `--badge` for a shields endpoint.
117
- - `efaimo check --mcp`: a quality grade (E121-E130: descriptions, schemas,
176
+ - `efaimo check --mcp`: a quality grade (E121-E128, E130: descriptions, schemas,
118
177
  annotations, cost) plus a separate, ungraded 2026-07-28 migration diff
119
178
  (E101-E118: what the stateless spec breaks and how to fix it, each item naming
120
179
  its SEP). Speaks both the legacy handshake and bare stateless requests, so
@@ -138,5 +197,7 @@ First release.
138
197
  Documented rule set (`docs/RULES.md`), token methodology (`docs/METHODOLOGY.md`),
139
198
  and integration guide (`docs/INTEGRATIONS.md`).
140
199
 
200
+ [unreleased]: https://github.com/efaimo-ai/efaimo/compare/v0.1.2...HEAD
201
+ [0.1.2]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.2
141
202
  [0.1.1]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.1
142
203
  [0.1.0]: https://github.com/efaimo-ai/efaimo/releases/tag/v0.1.0
package/README.md CHANGED
@@ -53,7 +53,7 @@ hygiene. Run it on a folder and every skill gets its own grade, not one aggregat
53
53
  **We graded 36 public Agent Skills**: every skill in `anthropics/skills`,
54
54
  `anthropics/claude-cookbooks`, and `obra/superpowers`, at pinned commits. 97%
55
55
  score an A, but even this curated set has real issues: Anthropic's own
56
- `claude-api` scores a C with an over-limit description and ~18k-token
56
+ `claude-api` scores a C (73) with an over-limit description and ~18k-token
57
57
  instructions, and the median skill's instructions run ~1,632 tokens loaded on
58
58
  every trigger. Full report, the corpus manifest, and the two commands that
59
59
  reproduce it: [the Skills Quality Index](./research/skills-index/REPORT.md).
package/SKILL.md CHANGED
@@ -3,7 +3,7 @@ name: efaimo
3
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.
4
4
  license: Apache-2.0
5
5
  metadata:
6
- version: "0.1.1"
6
+ version: "0.1.2"
7
7
  homepage: "https://efaimo.ai"
8
8
  ---
9
9
 
@@ -26,8 +26,8 @@ you would be willing to run yourself. A remote target (`https://...`) is not
26
26
  executed and is the safe option for something you do not trust. `--client`
27
27
  reads the MCP config files in the current directory as well as your user
28
28
  config, so running it inside a repository you did not write means starting the
29
- servers that repository configured. This warning lives in `SECURITY.md` and did
30
- not live here, which is the wrong way round: the policy is where humans look,
29
+ servers that repository configured. Until 2026-08-02 this warning lived only in
30
+ `SECURITY.md`, which was the wrong way round: the policy is where humans look,
31
31
  and this file is where agents take orders.
32
32
 
33
33
  - **Weigh context cost of an MCP server**
@@ -50,6 +50,14 @@ export async function checkMcpTarget(target, opts = {}) {
50
50
  }
51
51
  export function checkMcpRepoOnly(repoPath, label) {
52
52
  const repo = scanRepo(repoPath);
53
+ // An empty result is a failure, never a pass. With no source files read and no
54
+ // dependency manifest naming an MCP SDK, there was nothing to examine here, and
55
+ // a clean A(100) would be a grade for a directory we never looked inside. Fail
56
+ // loudly, the way checkSkillSet does for a missing SKILL.md.
57
+ if (repo.filesScanned === 0 && !repo.sdk) {
58
+ throw new Error(`no MCP server source found under "${repoPath}": no code files and no SDK dependency. ` +
59
+ `Point --mcp at the server's run command, or at a directory that contains its source.`);
60
+ }
53
61
  const emptyIntro = {
54
62
  targetLabel: label,
55
63
  transport: "stdio",
package/dist/cli.js CHANGED
@@ -82,12 +82,18 @@ function seconds(value, label = "--timeout") {
82
82
  fail(`${label} must be a positive number of seconds, got "${value}"`);
83
83
  return n;
84
84
  }
85
- function parseNumberOpt(value, label) {
85
+ function parseNumberOpt(value, label, bound) {
86
86
  if (value === undefined)
87
87
  return undefined;
88
88
  const n = Number(value);
89
89
  if (!Number.isFinite(n))
90
90
  fail(`${label} must be a number, got "${value}"`);
91
+ // A budget or a percentage below its floor is not a stricter gate, it is a
92
+ // nonsensical one: `--max-tokens -5` turned "over budget" into always-true
93
+ // with a negative ceiling in the message. Reject it at the boundary.
94
+ if (bound && (bound.exclusive ? n <= bound.min : n < bound.min)) {
95
+ fail(`${label} must be ${bound.exclusive ? "greater than" : "at least"} ${bound.min}, got "${value}"`);
96
+ }
91
97
  return n;
92
98
  }
93
99
  function anthropicKeyFor(opts) {
@@ -122,8 +128,8 @@ program
122
128
  colorSetup(opts);
123
129
  windowSetup(opts);
124
130
  const timeoutMs = seconds(opts.timeout) * 1000;
125
- const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens");
126
- const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase");
131
+ const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens", { min: 0, exclusive: true });
132
+ const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase", { min: 0 });
127
133
  const anthropic = anthropicKeyFor(opts);
128
134
  const targets = [];
129
135
  if (opts.client) {
@@ -239,8 +245,14 @@ program
239
245
  fail("--diff works on a single MCP server result");
240
246
  const baselineRaw = JSON.parse(fs.readFileSync(opts.diff, "utf8"));
241
247
  const baseline = ("data" in baselineRaw && baselineRaw.data ? baselineRaw.data : baselineRaw);
242
- if (!baseline?.totals)
243
- fail(`"${opts.diff}" is not a weigh baseline (write one with --out)`);
248
+ // A skill weigh baseline also carries a `totals` object, so a bare
249
+ // truthiness check passed it through and diffServerWeigh then died on the
250
+ // missing tool array with a raw TypeError. A server baseline is the one
251
+ // whose totals hold the three serializations; require the Claude-style
252
+ // total specifically so a skill baseline is rejected with a clear message.
253
+ if (!baseline?.totals || typeof baseline.totals.claudeStyle !== "number") {
254
+ fail(`"${opts.diff}" is not an MCP server weigh baseline; write one with: efaimo weigh <server> --out ${opts.diff}`);
255
+ }
244
256
  const d = diffServerWeigh(baseline, single);
245
257
  console.log("");
246
258
  console.log(opts.md ? renderDiffMarkdown(d) : renderDiffPretty(d, { maxTokens, allowIncreasePct }));
@@ -23,14 +23,22 @@ export async function introspectServer(target, opts = {}) {
23
23
  stderr: "pipe",
24
24
  });
25
25
  transport = stdio;
26
+ // Split the budget across the two attempts so a dead server fails in about
27
+ // one timeout, not two: the legacy handshake got the whole timeout AND the
28
+ // stateless fallback then re-waited the whole timeout again, so `--timeout 8`
29
+ // took ~16s wall. The handshake takes the larger share; the fallback gets
30
+ // what is left, with a floor so a genuinely stateless server still has time
31
+ // to answer a bare request.
32
+ const startedAt = Date.now();
26
33
  try {
27
- await withTimeout(client.connect(stdio), timeoutMs, "connect (stdio)");
34
+ await withTimeout(client.connect(stdio), Math.max(1, Math.round(timeoutMs * 0.6)), "connect (stdio)");
28
35
  }
29
36
  catch (e) {
30
37
  await safeClose(client, stdio);
31
38
  // A 2026-07-28 stateless server has no initialize, so the SDK handshake
32
39
  // fails by design; try bare stateless requests before giving up.
33
- const rc = await introspectStateless(target, { timeoutMs });
40
+ const remaining = Math.max(timeoutMs - (Date.now() - startedAt), Math.round(timeoutMs * 0.25));
41
+ const rc = await introspectStateless(target, { timeoutMs: remaining });
34
42
  if (rc)
35
43
  return rc;
36
44
  throw connectError(e, target.label, stderrTail);
@@ -103,7 +103,9 @@ export function renderTestReportPretty(r) {
103
103
  ` with skill ${r.withSkill.passes}/${r.withSkill.scored} pass (${r.withSkill.passRate.toFixed(0)}%)`,
104
104
  ` without skill ${r.withoutSkill.passes}/${r.withoutSkill.scored} pass (${r.withoutSkill.passRate.toFixed(0)}%)`,
105
105
  ` delta ${paint(pc.bold, `${sign}${r.deltaPoints} points`)} ${paint(verdictColor, r.verdict)}`,
106
- ` significance p = ${r.p < 0.0001 ? "<0.0001" : r.p.toFixed(4)} 95% CI ${r.ci.lo >= 0 ? "+" : ""}${r.ci.lo} to ${r.ci.hi >= 0 ? "+" : ""}${r.ci.hi} points`,
106
+ Number.isFinite(r.ci.lo)
107
+ ? ` significance p = ${r.p < 0.0001 ? "<0.0001" : r.p.toFixed(4)} 95% CI ${r.ci.lo >= 0 ? "+" : ""}${r.ci.lo} to ${r.ci.hi >= 0 ? "+" : ""}${r.ci.hi} points`
108
+ : ` significance not computable: an arm produced no scoreable trial`,
107
109
  "",
108
110
  ];
109
111
  for (const n of r.notes)
@@ -1,8 +1,7 @@
1
- import type { Finding, Severity } from "../core/types.js";
1
+ import type { Finding } from "../core/types.js";
2
2
  interface InjectionPattern {
3
3
  key: string;
4
4
  re: RegExp;
5
- severity: Severity;
6
5
  label: string;
7
6
  }
8
7
  /**
@@ -9,19 +9,16 @@ export const INJECTION_PATTERNS = [
9
9
  {
10
10
  key: "override-instructions",
11
11
  re: /ignore\s+(?:all\s+|any\s+)?(?:previous|prior|earlier|above)\s+(?:instructions|messages|rules|prompts)/i,
12
- severity: "error",
13
12
  label: "attempts to override prior instructions",
14
13
  },
15
14
  {
16
15
  key: "hide-from-user",
17
16
  re: /do\s+not\s+(?:tell|reveal|mention|inform|show|disclose)\b[\s\S]{0,40}\b(?:user|human|operator)/i,
18
- severity: "error",
19
17
  label: "instructs the agent to hide behavior from the user",
20
18
  },
21
19
  {
22
20
  key: "act-without-consent",
23
21
  re: /without\s+(?:telling|asking|notifying|informing)\s+(?:the\s+)?(?:user|human)/i,
24
- severity: "error",
25
22
  label: "instructs the agent to act without user consent",
26
23
  },
27
24
  {
@@ -29,51 +26,43 @@ export const INJECTION_PATTERNS = [
29
26
  // "post" and "token" dropped: they match API documentation ("Token counting
30
27
  // POST /v1/...") far more than exfiltration.
31
28
  re: /\b(?:api[_-]?key|secret|password|credential)s?\b[\s\S]{0,40}\b(?:send|forward|exfiltrat|upload|leak|transmit|include\s+it)\b/i,
32
- severity: "error",
33
29
  label: "references sending credentials or secrets somewhere",
34
30
  },
35
31
  {
36
32
  key: "sensitive-file-read",
37
33
  re: /\b(?:read|open|cat|print|upload)\b[\s\S]{0,40}(?:\.env\b|\.ssh\b|id_rsa|credentials|\.aws\b|\.npmrc\b)/i,
38
- severity: "error",
39
34
  label: "references reading sensitive local files",
40
35
  },
41
36
  {
42
37
  key: "disable-safety",
43
38
  re: /\b(?:disable|bypass|ignore|remove)\s+(?:safety|guardrails?|filters?|restrictions?|policy|policies)\b/i,
44
- severity: "error",
45
39
  label: "asks to bypass safety or policy",
46
40
  },
47
41
  {
48
42
  key: "cross-tool-steering",
49
43
  re: /before\s+(?:using|calling|running)\b[^.?!]{0,40}?\btools?\b[\s,]{1,4}(?:always\s+|first\s+|please\s+)?(?:call|run|use|fetch|invoke)\b/i,
50
- severity: "warn",
51
44
  label: "steers the agent to call another tool first (cross-tool steering)",
52
45
  },
53
46
  {
54
47
  key: "templated-exfil-url",
55
48
  // Bounded runs (no adjacent unbounded same-class quantifiers) to avoid ReDoS.
56
49
  re: /https?:\/\/[^\s"'<>]{1,150}[?&][^\s"'<>]{0,150}(?:\$\{[^}]{1,60}\}|\{\{[^}]{1,60}\}\}|\{[^}]{1,60}\})/,
57
- severity: "warn",
58
50
  label: "URL with a templated query parameter (possible exfiltration channel)",
59
51
  },
60
52
  {
61
53
  key: "zero-width-chars",
62
54
  // eslint-disable-next-line no-control-regex
63
55
  re: /[​‌‍⁠]/,
64
- severity: "warn",
65
56
  label: "zero-width or invisible characters present",
66
57
  },
67
58
  {
68
59
  key: "hidden-html-comment",
69
60
  re: /<!--[^>]{0,240}\b(?:must|always|never|call|run|fetch|send|ignore)\b[^>]{0,240}-->/i,
70
- severity: "warn",
71
61
  label: "HTML comment containing imperative instructions (hidden from rendered view)",
72
62
  },
73
63
  {
74
64
  key: "persona-override",
75
65
  re: /\byou\s+are\s+now\b|\bpretend\s+to\s+be\b|\bnew\s+system\s+prompt\b/i,
76
- severity: "warn",
77
66
  label: "persona or system-prompt override language",
78
67
  },
79
68
  ];
@@ -166,6 +166,23 @@ const e105 = {
166
166
  },
167
167
  ];
168
168
  }
169
+ if (bare.kind === "exit") {
170
+ // A crash on the bare request is not a timeout and not a clean answer: the
171
+ // server did not serve a stateless tools/list, so a 2026-07-28 client
172
+ // cannot talk to it. Left unhandled, this outcome produced NO finding, so
173
+ // a crashing server looked one item MORE migrated than one that answers
174
+ // with a not-initialized error.
175
+ return [
176
+ {
177
+ ruleId: "E105",
178
+ severity: "warn",
179
+ title: "requires the removed initialize handshake",
180
+ message: `the server process exited on a bare stateless tools/list (${bare.errorMessage ?? "no reply"}), so it does not answer requests without a prior initialize`,
181
+ detail: `${SPEC} removes initialize and sessions; a server that exits on a bare request cannot serve a ${SPEC} client. A server that answers, even with an error, is judged by E106/E107/E118 instead.`,
182
+ fixHint: "upgrade to a 2.x SDK, or answer requests without a prior initialize instead of exiting",
183
+ },
184
+ ];
185
+ }
169
186
  if (bare.kind === "timeout") {
170
187
  return [
171
188
  {
@@ -526,7 +543,10 @@ const e124 = {
526
543
  }
527
544
  }
528
545
  if (totalParams > 5 && undocumented / totalParams > 0.3) {
529
- findings.push({
546
+ // Prepend, not append: capList keeps the first 5, and on the worst schemas
547
+ // (5+ oversized enums) an appended summary was exactly the line that got
548
+ // dropped. The aggregate is the headline, so it leads.
549
+ findings.unshift({
530
550
  ruleId: "E124",
531
551
  severity: "warn",
532
552
  title: "schema issues",
@@ -16,8 +16,10 @@ const s101 = {
16
16
  const push = (severity, message, fixHint) => findings.push({ ruleId: "S101", severity, title: "frontmatter does not match the spec", message, target: label, fixHint });
17
17
  if (skill.parseError) {
18
18
  push("error", `${skill.file}: ${skill.parseError}`, "SKILL.md must start with a --- YAML frontmatter block");
19
- if (!skill.frontmatterRaw)
20
- return findings;
19
+ // No early return on empty frontmatter. Returning here after one error let
20
+ // a skill with NO frontmatter grade higher than one with BROKEN frontmatter
21
+ // (the required-field checks below never ran), so total absence outscored a
22
+ // typo by two errors. Absent fields ARE missing fields; report them too.
21
23
  }
22
24
  const name = skill.frontmatter.name;
23
25
  if (typeof name !== "string" || !name.trim()) {
@@ -254,7 +256,7 @@ const s106 = {
254
256
  fixHint: "agents will fail mid-task when they try to open it",
255
257
  });
256
258
  }
257
- else if (ref.raw.includes("..")) {
259
+ else if (ref.escapes) {
258
260
  findings.push({
259
261
  ruleId: "S106",
260
262
  severity: "warn",
Binary file
@@ -63,4 +63,5 @@ export declare function armSystems(scenario: Scenario): {
63
63
  withoutSkill: string | undefined;
64
64
  withSkill: string;
65
65
  };
66
+ export declare function withRetry(runner: Runner, tries?: number, baseDelayMs?: number): Runner;
66
67
  export declare function runScenario(scenario: Scenario, runner: Runner): Promise<TestReport>;
@@ -71,6 +71,32 @@ async function judgeOne(runner, scenario, answer) {
71
71
  return "fail";
72
72
  return "unparseable";
73
73
  }
74
+ // A live run makes trials * 2 arms * 2 (answer + judge) API calls. One
75
+ // transient 429 or 5xx among them used to throw and discard every trial already
76
+ // paid for. Retry the transient failures with backoff; a non-429 4xx (bad key,
77
+ // unknown model) is a real error and is not retried.
78
+ function isTransient(err) {
79
+ const m = String(err?.message ?? err);
80
+ return (/\b(429|500|502|503|504)\b/.test(m) ||
81
+ /timeout|ETIMEDOUT|ECONNRESET|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|fetch failed|network/i.test(m));
82
+ }
83
+ export function withRetry(runner, tries = 4, baseDelayMs = 500) {
84
+ return async (req) => {
85
+ let lastErr;
86
+ for (let attempt = 0; attempt < tries; attempt++) {
87
+ try {
88
+ return await runner(req);
89
+ }
90
+ catch (e) {
91
+ lastErr = e;
92
+ if (attempt === tries - 1 || !isTransient(e))
93
+ throw e;
94
+ await new Promise((r) => setTimeout(r, baseDelayMs * 2 ** attempt));
95
+ }
96
+ }
97
+ throw lastErr;
98
+ };
99
+ }
74
100
  async function runArm(runner, scenario, system) {
75
101
  let passes = 0;
76
102
  let unparseable = 0;
@@ -95,8 +121,11 @@ async function runArm(runner, scenario, system) {
95
121
  }
96
122
  export async function runScenario(scenario, runner) {
97
123
  const systems = armSystems(scenario);
98
- const withoutSkill = await runArm(runner, scenario, systems.withoutSkill);
99
- const withSkill = await runArm(runner, scenario, systems.withSkill);
124
+ // Wrap once so both the subject answers and the judge calls survive a
125
+ // transient blip instead of throwing away the whole (paid-for) scenario.
126
+ const r = withRetry(runner);
127
+ const withoutSkill = await runArm(r, scenario, systems.withoutSkill);
128
+ const withSkill = await runArm(r, scenario, systems.withSkill);
100
129
  const deltaPoints = Math.round((withSkill.passRate - withoutSkill.passRate) * 10) / 10;
101
130
  // The verdict is a significance test, not a threshold on the gap.
102
131
  //
@@ -108,9 +137,14 @@ export async function runScenario(scenario, runner) {
108
137
  // measuring sample size more than it was measuring the skill.
109
138
  const p = fisherExactTwoSided(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
110
139
  const ci = deltaInterval(withSkill.passes, withSkill.scored, withoutSkill.passes, withoutSkill.scored);
140
+ // With no scoreable trial in an arm the pass rate and its interval are
141
+ // 0/0 = NaN; say so plainly instead of printing "NaN to NaN points".
142
+ const bothScored = withSkill.scored > 0 && withoutSkill.scored > 0;
111
143
  const notes = [
112
- `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
113
- `95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`,
144
+ bothScored
145
+ ? `${scenario.trials} trials per arm. Two-sided Fisher exact p = ${p < 0.0001 ? "<0.0001" : p.toFixed(4)}; ` +
146
+ `95% interval on the delta ${ci.lo >= 0 ? "+" : ""}${ci.lo} to ${ci.hi >= 0 ? "+" : ""}${ci.hi} points.`
147
+ : `${scenario.trials} trials per arm. An arm produced no scoreable trial, so no p-value or delta interval can be computed.`,
114
148
  ];
115
149
  const unscored = withSkill.unparseable + withoutSkill.unparseable;
116
150
  if (unscored) {
@@ -8,20 +8,27 @@ export function openaiRunner(apiKey) {
8
8
  if (req.system)
9
9
  messages.push({ role: "system", content: req.system });
10
10
  messages.push({ role: "user", content: req.user });
11
+ // o-series reasoning models (o1, o3, o4, ...) reject any non-default
12
+ // temperature with a 400, and a chat-sized 1024 cap starves them: their
13
+ // hidden reasoning tokens count against max_completion_tokens, so a small
14
+ // cap can return empty content. So omit temperature and give them headroom.
15
+ const isReasoning = /^o[0-9]/i.test(req.model);
16
+ const body = {
17
+ model: req.model,
18
+ messages,
19
+ max_completion_tokens: isReasoning ? 8192 : 1024,
20
+ };
21
+ // temperature: 0 for the judge, 1 for the subject (see
22
+ // RunnerRequest.deterministic), but only where the model accepts it.
23
+ if (!isReasoning)
24
+ body.temperature = req.deterministic ? 0 : 1;
11
25
  const res = await fetch("https://api.openai.com/v1/chat/completions", {
12
26
  method: "POST",
13
27
  headers: {
14
28
  "content-type": "application/json",
15
29
  authorization: `Bearer ${apiKey}`,
16
30
  },
17
- // temperature: 0 for the judge, 1 for the subject. See
18
- // RunnerRequest.deterministic in harness.ts.
19
- body: JSON.stringify({
20
- model: req.model,
21
- temperature: req.deterministic ? 0 : 1,
22
- messages,
23
- max_completion_tokens: 1024,
24
- }),
31
+ body: JSON.stringify(body),
25
32
  redirect: "error",
26
33
  signal: AbortSignal.timeout(60000),
27
34
  });
@@ -28,5 +28,3 @@ export declare function deltaInterval(withPass: number, withTrials: number, with
28
28
  lo: number;
29
29
  hi: number;
30
30
  };
31
- /** The smallest per-arm trial count at which a total separation (n/n vs 0/n) reaches p < 0.05. */
32
- export declare const MIN_TRIALS_FOR_SIGNIFICANCE = 4;
@@ -87,5 +87,3 @@ export function deltaInterval(withPass, withTrials, withoutPass, withoutTrials)
87
87
  const hi = d + Math.sqrt((w.hi - p1) ** 2 + (p2 - o.lo) ** 2);
88
88
  return { lo: Math.round(Math.max(-1, lo) * 1000) / 10, hi: Math.round(Math.min(1, hi) * 1000) / 10 };
89
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/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.1";
2
+ export declare const VERSION = "0.1.2";
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.1";
2
+ export const VERSION = "0.1.2";
@@ -127,7 +127,7 @@ attached. The default is 20 trials per arm: below roughly that, a partial
127
127
  result cannot reach significance at all.
128
128
 
129
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.
130
+ trials. At that size 5/5 against 4/5 is +20 points and p = 1.0000.
131
131
 
132
132
  Two things the judge does, which matter for reading a result:
133
133
 
package/docs/RULES.md CHANGED
@@ -22,11 +22,11 @@ rules are heuristics and say so in their detail line.
22
22
 
23
23
  | id | sev | what it catches | source of truth |
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
+ | E101 | warn | depends on a pre-`2026-07-28` SDK line (TS `@modelcontextprotocol/sdk` 1.x, Python `mcp` 1.x) instead of the stateless successors (TS `@modelcontextprotocol/server` 2.x, Python `mcp` 2.x) | changelog / SDK releases |
26
26
  | E102 | warn | source uses **Sampling** (`sampling/createMessage`), deprecated (SEP-2577) | 2026-07-28 changelog |
27
27
  | E103 | warn | source uses **Roots** (`roots/list`), deprecated (SEP-2577) | 2026-07-28 changelog |
28
28
  | E104 | warn | server declares the **logging** capability or source uses MCP Logging, deprecated (SEP-2577); `logging/setLevel` is removed | 2026-07-28 changelog |
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
+ | E105 | warn | the server rejects a bare stateless `tools/list` with a not-initialized error, or exits on it, so it still requires the removed `initialize` handshake. A timeout is reported as unverified statelessness rather than as this warn, and a server that answers (even with another error) is judged by E106/E107/E118 instead | SEP-2567 / stateless core |
30
30
  | E106 | warn | `server/discover` is not implemented (MUST in 2026-07-28, SEP-2575) | 2026-07-28 changelog |
31
31
  | E107 | info | results omit the required `resultType` field (`"complete"` \| `"input_required"`) | 2026-07-28 changelog |
32
32
  | E108 | info | source relies on removed SSE resumability (`Last-Event-ID`) | 2026-07-28 changelog |
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "efaimo",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Audit what your agent loads: quality and context cost for MCP servers and Agent Skills",
5
5
  "keywords": [
6
6
  "mcp",
@@ -63,7 +63,7 @@
63
63
  "prepublishOnly": "npm run typecheck && npm run build && npm run test"
64
64
  },
65
65
  "dependencies": {
66
- "@modelcontextprotocol/sdk": "^1.29.0",
66
+ "@modelcontextprotocol/sdk": "^1.30.0",
67
67
  "commander": "^15.0.0",
68
68
  "cross-spawn": "^7.0.6",
69
69
  "gpt-tokenizer": "^3.4.0",