efaimo 0.1.1 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/CHANGELOG.md +128 -5
  2. package/README.md +69 -7
  3. package/SKILL.md +16 -9
  4. package/dist/check/check.js +8 -0
  5. package/dist/cli.js +179 -27
  6. package/dist/clients/introspect.js +10 -2
  7. package/dist/core/engine.d.ts +2 -1
  8. package/dist/core/engine.js +4 -0
  9. package/dist/core/types.d.ts +121 -0
  10. package/dist/find/bm25.d.ts +77 -0
  11. package/dist/find/bm25.js +107 -0
  12. package/dist/find/find.d.ts +120 -0
  13. package/dist/find/find.js +403 -0
  14. package/dist/find/tokenize.d.ts +64 -0
  15. package/dist/find/tokenize.js +116 -0
  16. package/dist/index.d.ts +6 -3
  17. package/dist/index.js +13 -3
  18. package/dist/reporters/json.d.ts +19 -1
  19. package/dist/reporters/json.js +13 -2
  20. package/dist/reporters/markdown.d.ts +2 -1
  21. package/dist/reporters/markdown.js +109 -11
  22. package/dist/reporters/pretty.d.ts +12 -1
  23. package/dist/reporters/pretty.js +177 -10
  24. package/dist/rules/find/index.d.ts +4 -0
  25. package/dist/rules/find/index.js +262 -0
  26. package/dist/rules/injection.d.ts +1 -2
  27. package/dist/rules/injection.js +0 -11
  28. package/dist/rules/mcp/index.js +21 -1
  29. package/dist/rules/skill/index.js +5 -3
  30. package/dist/rules/version.d.ts +26 -0
  31. package/dist/rules/version.js +26 -0
  32. package/dist/targets/repoScan.js +0 -0
  33. package/dist/testing/anthropicRunner.d.ts +30 -0
  34. package/dist/testing/anthropicRunner.js +43 -8
  35. package/dist/testing/harness.d.ts +33 -1
  36. package/dist/testing/harness.js +66 -7
  37. package/dist/testing/openaiRunner.js +15 -8
  38. package/dist/testing/stats.d.ts +16 -2
  39. package/dist/testing/stats.js +26 -2
  40. package/dist/version.d.ts +1 -1
  41. package/dist/version.js +1 -1
  42. package/dist/weigh/anthropicExact.d.ts +12 -1
  43. package/dist/weigh/anthropicExact.js +9 -1
  44. package/dist/weigh/weigh.js +10 -3
  45. package/docs/INTEGRATIONS.md +15 -1
  46. package/docs/METHODOLOGY.md +111 -7
  47. package/docs/RULES.md +42 -5
  48. package/examples/scenario.crm.yaml +8 -0
  49. package/examples/scenario.example.yaml +8 -0
  50. package/package.json +2 -2
package/dist/cli.js CHANGED
@@ -13,12 +13,14 @@ import { findSkills } from "./skills/parse.js";
13
13
  import { diffServerWeigh } from "./weigh/diff.js";
14
14
  import { DEFAULT_CONTEXT_WINDOW, formatWindowShare, setContextWindow } from "./weigh/window.js";
15
15
  import { checkMcpRepoOnly, checkMcpTarget, checkSkillSet } from "./check/check.js";
16
- import { renderCheckPretty, renderDiffPretty, renderScenarioPlan, renderServerWeighPretty, renderSkillSetPretty, renderSkillWeighPretty, renderTestReportPretty, setColor, } from "./reporters/pretty.js";
16
+ import { analyzeFind, DEFAULT_TOP_K } from "./find/find.js";
17
+ import { runFindRules } from "./core/engine.js";
18
+ import { renderCheckPretty, renderDiffPretty, renderFindFindingsPretty, renderFindPretty, renderScenarioPlan, renderServerWeighPretty, renderSkillSetPretty, renderSkillWeighPretty, renderTestReportPretty, setColor, } from "./reporters/pretty.js";
17
19
  import { parseScenario, runScenario } from "./testing/harness.js";
18
- import { anthropicRunner } from "./testing/anthropicRunner.js";
20
+ import { acceptsSamplingParams, anthropicRunner } from "./testing/anthropicRunner.js";
19
21
  import { openaiRunner, providerForModel } from "./testing/openaiRunner.js";
20
22
  import { toJsonEnvelope } from "./reporters/json.js";
21
- import { renderCheckMarkdown, renderDiffMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
23
+ import { renderCheckMarkdown, renderDiffMarkdown, renderFindMarkdown, renderSkillSetMarkdown, renderWeighMarkdown } from "./reporters/markdown.js";
22
24
  import { gradeBadgeSpec, makeBadgeSvg, toShieldsEndpoint, weighBadgeSpec } from "./reporters/badge.js";
23
25
  import { loadDotEnv } from "./util/dotenv.js";
24
26
  // Load a local .env before any command reads a key. Shell env always wins.
@@ -36,6 +38,7 @@ examples:
36
38
  $ npx efaimo weigh --client claude-code
37
39
  $ npx efaimo check --mcp "npx -y my-mcp-server" # incl. 2026-07-28 readiness
38
40
  $ npx efaimo check --skill ./skills/
41
+ $ npx efaimo find "npx -y my-mcp-server" # would a search surface these tools?
39
42
  $ npx efaimo weigh "npx -y my-server" --out base.json && npx efaimo weigh "npx -y my-server" --diff base.json
40
43
  `);
41
44
  function collectPairs(sep) {
@@ -82,12 +85,18 @@ function seconds(value, label = "--timeout") {
82
85
  fail(`${label} must be a positive number of seconds, got "${value}"`);
83
86
  return n;
84
87
  }
85
- function parseNumberOpt(value, label) {
88
+ function parseNumberOpt(value, label, bound) {
86
89
  if (value === undefined)
87
90
  return undefined;
88
91
  const n = Number(value);
89
92
  if (!Number.isFinite(n))
90
93
  fail(`${label} must be a number, got "${value}"`);
94
+ // A budget or a percentage below its floor is not a stricter gate, it is a
95
+ // nonsensical one: `--max-tokens -5` turned "over budget" into always-true
96
+ // with a negative ceiling in the message. Reject it at the boundary.
97
+ if (bound && (bound.exclusive ? n <= bound.min : n < bound.min)) {
98
+ fail(`${label} must be ${bound.exclusive ? "greater than" : "at least"} ${bound.min}, got "${value}"`);
99
+ }
91
100
  return n;
92
101
  }
93
102
  function anthropicKeyFor(opts) {
@@ -98,6 +107,13 @@ function anthropicKeyFor(opts) {
98
107
  fail("--anthropic needs ANTHROPIC_API_KEY in the environment or a .env file");
99
108
  return { apiKey, model: typeof opts.anthropic === "string" ? opts.anthropic : undefined };
100
109
  }
110
+ // `--no-timestamp` is commander's negated form, so `opts.timestamp` is true
111
+ // unless the flag is passed. Kept in one helper because three commands take it
112
+ // and the polarity is easy to invert by accident.
113
+ function envelopeOpts(opts) {
114
+ return { timestamp: opts.timestamp !== false };
115
+ }
116
+ const NO_TIMESTAMP_HELP = "omit generatedAt from JSON output (for committed or diffed artifacts)";
101
117
  program
102
118
  .command("weigh")
103
119
  .description("measure the context cost of MCP tool definitions or Agent Skills")
@@ -110,6 +126,7 @@ program
110
126
  .option("--timeout <seconds>", "connect timeout in seconds", "45")
111
127
  .option("--json", "print JSON")
112
128
  .option("--md", "print Markdown")
129
+ .option("--no-timestamp", NO_TIMESTAMP_HELP)
113
130
  .option("--out <file>", "write the JSON result to a file (usable as a --diff baseline)")
114
131
  .option("--diff <baseline>", "compare against a baseline JSON from --out")
115
132
  .option("--max-tokens <n>", "exit 1 if the primary total exceeds n tokens")
@@ -122,8 +139,8 @@ program
122
139
  colorSetup(opts);
123
140
  windowSetup(opts);
124
141
  const timeoutMs = seconds(opts.timeout) * 1000;
125
- const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens");
126
- const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase");
142
+ const maxTokens = parseNumberOpt(opts.maxTokens, "--max-tokens", { min: 0, exclusive: true });
143
+ const allowIncreasePct = parseNumberOpt(opts.allowIncrease, "--allow-increase", { min: 0 });
127
144
  const anthropic = anthropicKeyFor(opts);
128
145
  const targets = [];
129
146
  if (opts.client) {
@@ -206,14 +223,18 @@ program
206
223
  const single = targets.length === 1 && results.length === 1 ? results[0] : undefined;
207
224
  const budgetTotal = results.reduce((s, r) => s + (r.kind === "mcp" ? r.totals.claudeStyle : r.totals.metadata + r.totals.body), 0);
208
225
  if (opts.out && single) {
209
- fs.writeFileSync(opts.out, toJsonEnvelope("weigh", single));
226
+ // Never stamped. A baseline exists to be compared against, so a field
227
+ // that differs on every write is the one field a comparison must ignore,
228
+ // and a committed baseline that diffs on nothing but the clock trains
229
+ // reviewers to skim exactly the file they should read.
230
+ fs.writeFileSync(opts.out, toJsonEnvelope("weigh", single, { timestamp: false }));
210
231
  console.error(pc.dim(`baseline written: ${opts.out}`));
211
232
  }
212
233
  else if (opts.out) {
213
234
  console.error(pc.yellow("--out skipped: it writes a single baseline, but multiple targets were requested"));
214
235
  }
215
236
  if (opts.json) {
216
- console.log(toJsonEnvelope("weigh", single ?? results));
237
+ console.log(toJsonEnvelope("weigh", single ?? results, envelopeOpts(opts)));
217
238
  }
218
239
  else if (opts.md) {
219
240
  console.log(results.map(renderWeighMarkdown).join("\n\n---\n\n"));
@@ -239,8 +260,14 @@ program
239
260
  fail("--diff works on a single MCP server result");
240
261
  const baselineRaw = JSON.parse(fs.readFileSync(opts.diff, "utf8"));
241
262
  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)`);
263
+ // A skill weigh baseline also carries a `totals` object, so a bare
264
+ // truthiness check passed it through and diffServerWeigh then died on the
265
+ // missing tool array with a raw TypeError. A server baseline is the one
266
+ // whose totals hold the three serializations; require the Claude-style
267
+ // total specifically so a skill baseline is rejected with a clear message.
268
+ if (!baseline?.totals || typeof baseline.totals.claudeStyle !== "number") {
269
+ fail(`"${opts.diff}" is not an MCP server weigh baseline; write one with: efaimo weigh <server> --out ${opts.diff}`);
270
+ }
244
271
  const d = diffServerWeigh(baseline, single);
245
272
  console.log("");
246
273
  console.log(opts.md ? renderDiffMarkdown(d) : renderDiffPretty(d, { maxTokens, allowIncreasePct }));
@@ -274,6 +301,7 @@ program
274
301
  .option("--timeout <seconds>", "connect timeout in seconds", "45")
275
302
  .option("--json", "print JSON")
276
303
  .option("--md", "print Markdown")
304
+ .option("--no-timestamp", NO_TIMESTAMP_HELP)
277
305
  .option("--badge [file]", "write a grade badge SVG + shields endpoint JSON")
278
306
  .option("--anthropic [model]", "use exact Claude token counts where relevant")
279
307
  .option("--window <tokens>", "context window the share is reported against", String(DEFAULT_CONTEXT_WINDOW))
@@ -322,7 +350,7 @@ program
322
350
  }
323
351
  if (skillSet) {
324
352
  if (opts.json)
325
- console.log(toJsonEnvelope("check", skillSet));
353
+ console.log(toJsonEnvelope("check", skillSet, envelopeOpts(opts)));
326
354
  else if (opts.md)
327
355
  console.log(renderSkillSetMarkdown(skillSet));
328
356
  else
@@ -338,7 +366,7 @@ program
338
366
  if (!report)
339
367
  return;
340
368
  if (opts.json)
341
- console.log(toJsonEnvelope("check", report));
369
+ console.log(toJsonEnvelope("check", report, envelopeOpts(opts)));
342
370
  else if (opts.md)
343
371
  console.log(renderCheckMarkdown(report));
344
372
  else
@@ -393,41 +421,165 @@ program
393
421
  process.exitCode = 1;
394
422
  }
395
423
  });
424
+ program
425
+ .command("find")
426
+ .description("would a search surface these tools? (findability under deferred tool loading)")
427
+ .argument("[target]", "stdio command | http(s) URL")
428
+ .option("--stdio", "treat target as a stdio command string")
429
+ .option("--header <header>", 'HTTP header "Key: Value" (repeatable)', collectPairs(":"))
430
+ .option("--env <pair>", "KEY=VALUE for stdio servers (repeatable)", collectPairs("="))
431
+ .option("--timeout <seconds>", "connect timeout in seconds", "45")
432
+ .option("--top <n>", `result window to simulate (Anthropic's documented default is ${DEFAULT_TOP_K}; the probe is vacuous when tools <= top, so gate on --min-distinct rather than on this)`, String(DEFAULT_TOP_K))
433
+ .option("--min-distinct <pct>", "exit 1 if fewer than pct percent of tools own a word no other tool has")
434
+ .option("--json", "print JSON")
435
+ .option("--md", "print Markdown")
436
+ .option("--no-timestamp", NO_TIMESTAMP_HELP)
437
+ .option("--no-color", "disable colors")
438
+ .addHelpText("after", "\nA tool marked defer_loading is kept out of the context window until a search finds it, so a tool\n" +
439
+ "nothing surfaces costs nothing and does nothing. Two numbers come back. `distinct` is a property of\n" +
440
+ "the catalog: a tool that owns no word the others lack cannot be matched by any query that does not\n" +
441
+ "also match a competitor. `probe` is a simulated BM25 search, offline and deterministic, and it\n" +
442
+ "saturates on well-formed catalogs, so it is a floor test rather than a ranking.\n" +
443
+ "No API key, no network beyond connecting to the server. docs/METHODOLOGY.md has the method.")
444
+ .action(async (targetArg, opts) => {
445
+ colorSetup(opts);
446
+ if (!targetArg)
447
+ fail("nothing to search: pass an MCP server (stdio command or URL)");
448
+ const timeoutMs = seconds(opts.timeout) * 1000;
449
+ const topK = parseNumberOpt(opts.top, "--top", { min: 1 }) ?? DEFAULT_TOP_K;
450
+ // A window is a count of results, so a fraction of one is not a stricter
451
+ // setting, it is a nonsensical one: `--top 2.5` printed "result window 2.5"
452
+ // while `rank <= 2.5` behaved as 2, so the number in the report and the
453
+ // number in the comparison disagreed.
454
+ if (!Number.isInteger(topK))
455
+ fail(`--top must be a whole number of results, got "${opts.top}"`);
456
+ const minDistinct = parseNumberOpt(opts.minDistinct, "--min-distinct", { min: 0 });
457
+ if (minDistinct !== undefined && minDistinct > 100)
458
+ fail(`--min-distinct is a percentage, got "${opts.minDistinct}"`);
459
+ const target = resolveTarget(targetArg, { forceStdio: opts.stdio, env: opts.env, headers: opts.header });
460
+ if (target.kind === "skillset" || target.kind === "repo") {
461
+ fail(`"${target.label}" is a ${target.kind === "skillset" ? "skill path" : "source directory"}; find needs a live MCP server (stdio command or URL). ` +
462
+ `Skills have no searchable tool catalog; their trigger-overlap check is S103 in \`efaimo check --skill\`.`);
463
+ }
464
+ console.error(pc.dim(`connecting to ${target.label} ...`));
465
+ const intro = await introspectServer(target, { timeoutMs });
466
+ // Weighed only to answer one question: is this catalog big enough that a
467
+ // host would defer it. No API key is involved; the count is the local
468
+ // o200k estimate the rest of the tool uses.
469
+ const weigh = await weighServer(intro);
470
+ const result = analyzeFind(target.label, intro.tools, {
471
+ topK,
472
+ definitionTokens: weigh.totals.claudeStyle,
473
+ });
474
+ const findings = runFindRules({ find: result });
475
+ if (opts.json) {
476
+ console.log(toJsonEnvelope("find", { ...result, findings }, envelopeOpts(opts)));
477
+ }
478
+ else if (opts.md) {
479
+ console.log(renderFindMarkdown(result, findings));
480
+ }
481
+ else {
482
+ console.log(renderFindPretty(result));
483
+ console.log(renderFindFindingsPretty(result, findings));
484
+ }
485
+ // Gated on `distinct`, not on the probe. The probe is 100% by construction
486
+ // whenever the catalog fits inside the result window, so a gate on it
487
+ // would pass without examining anything for every small server. Exclusive
488
+ // vocabulary can fail at two tools and up.
489
+ //
490
+ // At ONE tool it cannot: every term is trivially exclusive and the figure
491
+ // is 100% for any tool whatsoever. Exit 2 rather than 0, because "this
492
+ // gate cannot be evaluated here" is a different fact from "this gate
493
+ // passed", and a CI run that silently passes on a catalog nothing was
494
+ // measured against is the failure this whole command was built around.
495
+ if (minDistinct !== undefined && result.distinctVacuous) {
496
+ fail(`--min-distinct cannot be evaluated on a ${result.toolCount}-tool catalog: with nothing to be distinct from, ` +
497
+ `every term is trivially exclusive and the figure is 100% for any tool. Drop the gate for this server.`);
498
+ }
499
+ if (minDistinct !== undefined && result.distinct.pct < minDistinct) {
500
+ console.error(pc.red(`distinct ${result.distinct.pct}% is below --min-distinct ${minDistinct}% ` +
501
+ `(${result.distinct.total - result.distinct.count} of ${result.distinct.total} tools own no word the others lack)`));
502
+ process.exitCode = 1;
503
+ }
504
+ });
396
505
  program
397
506
  .command("test")
398
507
  .description("does a skill actually improve task completion? (experimental A/B outcome harness)")
399
508
  .argument("<scenario>", "a scenario YAML file (see examples/scenario.example.yaml)")
400
509
  .option("--live", "run for real (spends tokens; Claude models need ANTHROPIC_API_KEY, GPT models need OPENAI_API_KEY)")
401
510
  .option("--model <model>", "override the scenario's model (e.g. gpt-4o-mini, claude-sonnet-5)")
511
+ .option("--judge-model <model>", "grade with a different model than the one under test (removes self-preference)")
402
512
  .option("--json", "print JSON")
403
513
  .option("--no-color", "disable colors")
404
- .addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls.")
514
+ .addHelpText("after", "\nWithout --live this validates the scenario and prints the plan, making no API calls.\n" +
515
+ "The judge defaults to the model under test, which means a model grades its own answers; set\n" +
516
+ "judge_model in the scenario or --judge-model here to separate them. Subject and judge may be\n" +
517
+ "different providers, in which case both keys are required.")
405
518
  .action(async (file, opts) => {
406
519
  colorSetup(opts);
407
520
  const parsed = parseScenario(file);
408
- const scenario = opts.model ? { ...parsed, model: opts.model } : parsed;
521
+ // --model moves the judge too WHEN the judge was never pinned separately.
522
+ // Without this, overriding the subject on a scenario that has no
523
+ // `judge_model` would silently leave the judge on the scenario's original
524
+ // model: a cross-model judge nobody asked for, in a run whose whole point
525
+ // is that the comparison is controlled.
526
+ const judgeFollowsSubject = !parsed.judgeModelExplicit;
527
+ const model = opts.model ?? parsed.model;
528
+ const judgeModel = opts.judgeModel ?? (judgeFollowsSubject ? model : parsed.judgeModel);
529
+ const scenario = { ...parsed, model, judgeModel };
409
530
  if (!opts.live) {
410
531
  console.log(renderScenarioPlan(scenario));
411
532
  return;
412
533
  }
413
- const provider = providerForModel(scenario.model);
414
- if (provider === "unknown") {
415
- fail(`model "${scenario.model}" is not supported: efaimo test runs Claude (claude-*) and OpenAI (gpt-*, o*) models`);
534
+ // Subject and judge are routed independently, so a Claude subject can be
535
+ // graded by a GPT judge (or the reverse). Every distinct provider in play
536
+ // needs its own key, and the error says which model asked for it.
537
+ const runners = new Map();
538
+ for (const [role, m] of [["model", scenario.model], ["judge model", scenario.judgeModel]]) {
539
+ const provider = providerForModel(m);
540
+ if (provider === "unknown") {
541
+ fail(`${role} "${m}" is not supported: efaimo test runs Claude (claude-*) and OpenAI (gpt-*, o*) models`);
542
+ }
543
+ if (runners.has(provider))
544
+ continue;
545
+ const envVar = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
546
+ const apiKey = process.env[envVar];
547
+ if (!apiKey)
548
+ fail(`${role} "${m}" needs ${envVar} in the environment or a .env file`);
549
+ if (dotEnvKeys.has(envVar))
550
+ console.error(pc.dim(`using ${envVar} from .env`));
551
+ runners.set(provider, provider === "openai" ? openaiRunner(apiKey) : anthropicRunner(apiKey));
552
+ }
553
+ const runner = (req) => {
554
+ const r = runners.get(providerForModel(req.model));
555
+ // Only the two validated models can reach here today. A future arm that
556
+ // introduces a third would otherwise die as "undefined is not a
557
+ // function" with no idea which model it was.
558
+ if (!r)
559
+ throw new Error(`no runner for model "${req.model}"; efaimo test runs Claude and OpenAI models`);
560
+ return r(req);
561
+ };
562
+ // Say what the measurement cannot pin down, in the report, every time.
563
+ const extraNotes = [];
564
+ if (scenario.judgeModel === scenario.model) {
565
+ extraNotes.push(`the judge is the same model as the subject, so part of any measured effect is a model preferring its own output. Set judge_model (or --judge-model) to a different model to remove that.`);
566
+ }
567
+ if (providerForModel(scenario.judgeModel) === "anthropic" && !acceptsSamplingParams(scenario.judgeModel)) {
568
+ extraNotes.push(`judge sampling is not pinned: ${scenario.judgeModel} does not accept temperature, so the judge is sampled at its default and its own variance is part of this measurement.`);
416
569
  }
417
- const envVar = provider === "openai" ? "OPENAI_API_KEY" : "ANTHROPIC_API_KEY";
418
- const apiKey = process.env[envVar];
419
- if (!apiKey)
420
- fail(`model "${scenario.model}" needs ${envVar} in the environment or a .env file`);
421
- if (dotEnvKeys.has(envVar))
422
- console.error(pc.dim(`using ${envVar} from .env`));
423
- const runner = provider === "openai" ? openaiRunner(apiKey) : anthropicRunner(apiKey);
424
- console.error(pc.dim(`running ~${scenario.trials * 4} ${provider} API calls against ${scenario.model} ...`));
425
- const report = await runScenario(scenario, runner);
570
+ const models = scenario.judgeModel === scenario.model ? scenario.model : `${scenario.model} judged by ${scenario.judgeModel}`;
571
+ console.error(pc.dim(`running ~${scenario.trials * 4} API calls against ${models} ...`));
572
+ const report = await runScenario(scenario, runner, { extraNotes });
426
573
  if (opts.json)
427
574
  console.log(toJsonEnvelope("test", report));
428
575
  else
429
576
  console.log(renderTestReportPretty(report));
430
- if (report.verdict === "hurts")
577
+ // `inconclusive` fails too. It means an arm produced no scoreable trial, or
578
+ // the judge refused on one arm far more than the other, and in both cases
579
+ // the measurement did not happen. Exiting 0 there would report a green for
580
+ // precisely the runs where the instrument broke down, which is this
581
+ // project's documented dominant failure mode wearing a p-value.
582
+ if (report.verdict === "hurts" || report.verdict === "inconclusive")
431
583
  process.exitCode = 1;
432
584
  });
433
585
  program
@@ -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);
@@ -1,6 +1,7 @@
1
- import type { Finding, McpRuleContext } from "./types.js";
1
+ import type { Finding, FindRuleContext, McpRuleContext } from "./types.js";
2
2
  /** Run a list of rules, isolating each: a broken rule must never break the audit. */
3
3
  export declare function runRules<Ctx>(rules: readonly {
4
4
  check(ctx: Ctx): Finding[];
5
5
  }[], ctx: Ctx): Finding[];
6
6
  export declare function runMcpRules(ctx: McpRuleContext): Finding[];
7
+ export declare function runFindRules(ctx: FindRuleContext): Finding[];
@@ -1,4 +1,5 @@
1
1
  import { MCP_RULES } from "../rules/mcp/index.js";
2
+ import { FIND_RULES } from "../rules/find/index.js";
2
3
  /** Run a list of rules, isolating each: a broken rule must never break the audit. */
3
4
  export function runRules(rules, ctx) {
4
5
  const findings = [];
@@ -33,3 +34,6 @@ export function runRules(rules, ctx) {
33
34
  export function runMcpRules(ctx) {
34
35
  return runRules(MCP_RULES, ctx);
35
36
  }
37
+ export function runFindRules(ctx) {
38
+ return runRules(FIND_RULES, ctx);
39
+ }
@@ -155,6 +155,13 @@ export interface ServerWeighResult {
155
155
  instructionsTokens: number;
156
156
  /** Exact count via Anthropic count_tokens API, when a key was provided. */
157
157
  anthropicExactTotal?: number;
158
+ /**
159
+ * The model `anthropicExactTotal` was measured against. Always set when the
160
+ * total is: Claude model lines do not share a tokenizer, so the figure is
161
+ * exact for this model and approximate for any other, and a count printed
162
+ * without its model cannot be reproduced.
163
+ */
164
+ anthropicExactModel?: string;
158
165
  notes: string[];
159
166
  }
160
167
  export interface SkillWeighEntry {
@@ -178,6 +185,111 @@ export interface SkillSetWeighResult {
178
185
  notes: string[];
179
186
  }
180
187
  export type WeighResult = ServerWeighResult | SkillSetWeighResult;
188
+ /** One tool's result in the findability report (`efaimo find`). */
189
+ export interface ToolFindEntry {
190
+ name: string;
191
+ /**
192
+ * Terms this tool has that no other tool in the catalog has.
193
+ *
194
+ * The primary signal, and the only one here that is not a model of anything:
195
+ * if this is empty, every word the tool contains also appears on some other
196
+ * tool, so no query can match it without also matching a competitor. That is
197
+ * a property of the catalog, provable by inspection, with no assumption
198
+ * about how anyone searches. Capped for display.
199
+ */
200
+ ownTerms: string[];
201
+ /** How many exclusive terms there are in total, before the display cap. */
202
+ ownTermCount: number;
203
+ /**
204
+ * How many of them survive if the searcher does not know the tool's name.
205
+ *
206
+ * Names are searchable, so a tool whose only exclusive word is its own name
207
+ * is findable in principle. It is not findable by anyone describing a task,
208
+ * which is the situation deferred loading creates. E146 is this number
209
+ * reaching zero while `ownTermCount` does not.
210
+ */
211
+ ownOutsideNameCount: number;
212
+ /** When ownTermCount is 0: tools whose vocabulary covers this one's (capped). */
213
+ sharedWith: string[];
214
+ /**
215
+ * The terms this tool's own description offers a searcher, most distinctive
216
+ * first. Never includes the tool's name: the point of the probe is that the
217
+ * person searching does not know it.
218
+ */
219
+ query: string[];
220
+ /** 1-based rank for that query. Undefined when the description offers no searchable word. */
221
+ rank?: number;
222
+ /** BM25 score for its own query, kept so a reader can check the arithmetic. */
223
+ score: number;
224
+ /** Tools that scored higher for this tool's own query (capped at 3). */
225
+ outrankedBy: string[];
226
+ /** Tools whose score is indistinguishable from this one's, so only the tie-break separates them. Capped for display. */
227
+ tiedWith: string[];
228
+ /** How many there are in total, before the display cap. */
229
+ tiedWithCount: number;
230
+ /** Rank falls inside the simulated result window. The per-tool form of `probe`. */
231
+ reachable: boolean;
232
+ /** Every token of the name is a generic word (E144). */
233
+ genericName: boolean;
234
+ }
235
+ export interface FindResult {
236
+ kind: "find";
237
+ label: string;
238
+ toolCount: number;
239
+ /** The whole method, carried with the numbers so a report is self-describing. */
240
+ method: {
241
+ tokenizer: string;
242
+ bm25: {
243
+ k1: number;
244
+ b: number;
245
+ };
246
+ queryTerms: number;
247
+ topK: number;
248
+ };
249
+ perTool: ToolFindEntry[];
250
+ /**
251
+ * The headline: how many tools own at least one word no other tool has.
252
+ * A measured proportion, deliberately not a letter grade (ADR-030).
253
+ */
254
+ distinct: {
255
+ count: number;
256
+ total: number;
257
+ pct: number;
258
+ };
259
+ /**
260
+ * Secondary: how many tools a simulated BM25 search for their own
261
+ * description returns inside the result window.
262
+ *
263
+ * Named `probe` in the JSON, in the output and in the docs, all three. It
264
+ * was `reach` in one of them and `probe` in the others for a day, which is
265
+ * how someone scripting a gate on the number they saw printed ends up
266
+ * looking for a field that does not exist.
267
+ *
268
+ * Kept, and kept second, because it is the only number here that models the
269
+ * actual mechanism, and because it saturates: 100% on every real server
270
+ * measured. Treat it as a floor test that catches catastrophe, not as a
271
+ * ranking. See docs/METHODOLOGY.md.
272
+ */
273
+ probe: {
274
+ returned: number;
275
+ total: number;
276
+ pct: number;
277
+ };
278
+ /** toolCount <= topK, which makes the probe figure 100% by construction. */
279
+ windowCoversCatalog: boolean;
280
+ /**
281
+ * toolCount < 2, which makes the DISTINCT figure 100% by construction: with
282
+ * one tool every term it has is trivially exclusive. `--min-distinct` cannot
283
+ * be evaluated in that state and refuses rather than passing.
284
+ */
285
+ distinctVacuous: boolean;
286
+ definitionTokens?: number;
287
+ /** Anthropic's guidance recommends tool search for a catalog this shape. */
288
+ deferRecommended: boolean;
289
+ /** Which of the checkable conditions fired, in words. Empty when none did. */
290
+ deferBecause: string[];
291
+ notes: string[];
292
+ }
181
293
  export interface SkillInfo {
182
294
  dir: string;
183
295
  file: string;
@@ -248,6 +360,9 @@ export interface SkillRuleContext {
248
360
  set: SkillSet;
249
361
  weigh?: SkillSetWeighResult;
250
362
  }
363
+ export interface FindRuleContext {
364
+ find: FindResult;
365
+ }
251
366
  export interface McpRule {
252
367
  id: string;
253
368
  title: string;
@@ -260,3 +375,9 @@ export interface SkillRule {
260
375
  surface: "skill";
261
376
  check(ctx: SkillRuleContext): Finding[];
262
377
  }
378
+ export interface FindRule {
379
+ id: string;
380
+ title: string;
381
+ surface: "find";
382
+ check(ctx: FindRuleContext): Finding[];
383
+ }
@@ -0,0 +1,77 @@
1
+ /**
2
+ * A small, exact BM25 index.
3
+ *
4
+ * `efaimo find` needs to answer "would a search over this catalog surface this
5
+ * tool", and the built-in tool search is server-side: its parameters are not
6
+ * published, so nothing here can be a reproduction. It is the textbook
7
+ * Robertson/Sparck-Jones BM25 with its conventional parameters, run offline on
8
+ * the four fields Anthropic documents as searchable, and both the formula and
9
+ * the parameters are printed in the output. A reader can disagree with the
10
+ * model and still check the arithmetic.
11
+ *
12
+ * Two properties this file exists to guarantee:
13
+ *
14
+ * - **Determinism.** Ties break on the document id by code point, never by
15
+ * `localeCompare`, whose collation depends on the host's ICU data. A tool
16
+ * that reports other people's nondeterminism (E112) must not have any of its
17
+ * own, and "it ranked differently on CI" is exactly the bug that would be
18
+ * impossible to reproduce.
19
+ * - **No silent empties.** `score()` on an empty query returns an empty array
20
+ * rather than the whole catalog at score 0. Callers treat that as a finding,
21
+ * not as a pass.
22
+ */
23
+ export interface Bm25Doc {
24
+ id: string;
25
+ terms: string[];
26
+ }
27
+ export interface Bm25Hit {
28
+ id: string;
29
+ score: number;
30
+ /**
31
+ * The document's position in the corpus.
32
+ *
33
+ * Present because `id` is not a key. MCP does not forbid two tools sharing a
34
+ * name, and a caller that located its own row with `findIndex(h => h.id ===
35
+ * name)` got the better-scoring twin's rank, score and tie set: a tool that
36
+ * really sat eighth reported rank 1, and the headline moved in the flattering
37
+ * direction. Match on this instead.
38
+ */
39
+ index: number;
40
+ }
41
+ export interface Bm25Params {
42
+ k1: number;
43
+ b: number;
44
+ }
45
+ /** The conventional defaults. Printed in every report so the method travels with the number. */
46
+ export declare const DEFAULT_BM25: Bm25Params;
47
+ export declare class Bm25Index {
48
+ readonly params: Bm25Params;
49
+ readonly size: number;
50
+ private readonly ids;
51
+ private readonly tf;
52
+ private readonly len;
53
+ private readonly df;
54
+ private readonly avgdl;
55
+ constructor(docs: readonly Bm25Doc[], params?: Bm25Params);
56
+ /** BM25 IDF. Always positive, so a term present in every document still contributes. */
57
+ idf(term: string): number;
58
+ /** Document frequency: how many tools contain this term at all. */
59
+ documentFrequency(term: string): number;
60
+ termFrequency(index: number, term: string): number;
61
+ /**
62
+ * Score every document against a query, highest first.
63
+ *
64
+ * Duplicate query terms are collapsed: a query is a set of keywords here,
65
+ * not a passage, and counting a repeated term twice would silently weight it.
66
+ */
67
+ score(query: readonly string[]): Bm25Hit[];
68
+ }
69
+ /**
70
+ * Are two scores the same as far as a ranker is concerned?
71
+ *
72
+ * Relative, because BM25 scores are unbounded: an absolute epsilon that is
73
+ * right at score 2 is meaningless at score 40. Two tools this close are
74
+ * separated only by the tie-break, which means the search picks between them
75
+ * on the alphabet rather than on relevance.
76
+ */
77
+ export declare function scoresTie(a: number, bScore: number, rel?: number): boolean;