Ctrl K
ComunidadGuías…

scripts/evaluate-slack-presentation.mjs

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
#!/usr/bin/env node
// Slack presentation usage calibration. Offline (default): corpus contract
// plus a deterministic faux-model smoke through the Flue boundary. --live:
// the real presentation tools and guide against real models, per case, with
// a private report and a gate on the calibration targets.

import { displayToolAcknowledgement } from '../src/slack/ui/display-tools.ts';
import {
  SLACK_ASK_USER_ACKNOWLEDGEMENT,
  SLACK_OFFER_ACTIONS_ACKNOWLEDGEMENT,
  SLACK_REQUEST_FORM_ACKNOWLEDGEMENT,
} from '../src/slack/ui/interactive-tools.ts';
import { SlackQuestionPostedToolDeniedError } from '../src/slack/presentation-tool-policy.ts';
import { randomUUID } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import { mkdirSync, readFileSync, renameSync, writeFileSync } from 'node:fs';
import { homedir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import { parseArgs, parseEnv } from 'node:util';

import {
  fauxAssistantMessage,
  fauxProvider,
  fauxText,
  fauxToolCall,
  getCurrentSystemPrompt,
  getCurrentTools,
} from '@earendil-works/pi-ai';
import {
  init,
  useDataWriter,
  useInstruction,
  useModel,
  useResponseFinish,
  useTool,
} from '@flue/runtime';
import { start } from '@flue/runtime/node';
import * as v from 'valibot';

import { thinkingLevelForModel } from '../src/config/workers-ai-models.ts';
import { SLACK_ACTION_LINK_INSTRUCTION } from '../src/slack/message-format.ts';
import {
  createSlackPresentTableTool,
  SLACK_PRESENT_TABLE_TOOL_NAME,
} from '../src/slack/table-presentation.ts';
import {
  SLACK_PRESENTATION_GUIDE,
  SLACK_PRESENTATION_TOOL_DEFINITIONS,
  validateAskUser,
  validateOfferActions,
  validatePresentCards,
  validatePresentChart,
  validatePresentDetails,
  validateRequestForm,
} from '../src/slack/ui/presentation-tools.ts';
import { assertNodeVersion } from './lib/node-version.mjs';
import { outsideGit } from './lib/private-evidence.mjs';
import {
  AGENT_PERSONA,
  BLOCKING_TOOL_NAMES,
  CATEGORIES,
  DISPLAY_TOOL_NAMES,
  INTERACTIVE_TOOL_NAMES,
  MAX_DISPLAY_PER_REPLY,
  MAX_INTERACTIVE_PER_REPLY,
  PRESENTATION_TOOL_NAMES,
  STUB_TOOLS,
  STUB_TOOL_NAMES,
  TARGETS,
  ALL_EVAL_TOOLS,
  effectiveCase,
  evalHostInstructions,
  formatSummaryTable,
  presentationSurfaceDigest,
  renderSlackTurnPrompt,
  scoreRun,
  selectCases,
  slackPresentationGuideVersion,
  stubToolOutput,
  summarizeRuns,
  validateCorpus,
} from './lib/slack-presentation-evaluation.mjs';

const CORPUS_PATH = new URL('../evals/slack-presentation/cases.json', import.meta.url);
const EVAL_DATA_NAME = 'slackPresentationEval';
const FAUX_MODEL = 'faux/slack-presentation-eval';
const MODEL_ENV = 'SLACK_PRESENTATION_EVAL_MODEL';
const SUGGESTED_MODELS = [
  'openai/gpt-5.6-terra',
  'anthropic/claude-sonnet-5',
  'cloudflare-workers-ai/@cf/openai/gpt-oss-120b',
];
// Only provider credentials are ever read from --env-file; nothing is printed.
const PROVIDER_ENV = {
  openai: ['OPENAI_API_KEY'],
  anthropic: ['ANTHROPIC_API_KEY'],
  openrouter: ['OPENROUTER_API_KEY'],
  'cloudflare-workers-ai': ['CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID'],
};
const LOADABLE_ENV = new Set(Object.values(PROVIDER_ENV).flat());
const ABANDON_AFTER_FAILURES = 3;

// The production acknowledgements and refusal, so the gate measures what ships.
const QUESTION_POSTED = SLACK_ASK_USER_ACKNOWLEDGEMENT;
const AFTER_QUESTION = new SlackQuestionPostedToolDeniedError().message;
const DISPLAY_KINDS = { present_cards: 'cards', present_chart: 'chart', present_details: 'details' };
const ACKNOWLEDGEMENTS = {
  ask_user: SLACK_ASK_USER_ACKNOWLEDGEMENT,
  offer_actions: SLACK_OFFER_ACTIONS_ACKNOWLEDGEMENT,
  request_form: SLACK_REQUEST_FORM_ACKNOWLEDGEMENT,
};

const VALIDATORS = {
  ask_user: validateAskUser,
  offer_actions: validateOfferActions,
  present_cards: validatePresentCards,
  present_chart: validatePresentChart,
  present_details: validatePresentDetails,
  request_form: validateRequestForm,
};

// ── The evaluated Agent ────────────────────────────────────────────────────

/** Host-side state for one case run: statuses per tool call and the budget. */
class CaseRecorder {
  constructor() {
    this.byCall = new Map();
    this.interactive = 0;
    this.display = 0;
    this.questionPosted = false;
  }

  mark(toolCallId, status, extra = {}) {
    this.byCall.set(toolCallId, { status, ...extra });
  }

  /** Enforce the reply latch and the component budget before validation. */
  admit(name, toolCallId) {
    let message;
    let status = 'rejected_budget';
    if (this.questionPosted) {
      message = AFTER_QUESTION;
      status = 'rejected_after_question';
    } else if (INTERACTIVE_TOOL_NAMES.includes(name) && this.interactive >= MAX_INTERACTIVE_PER_REPLY) {
      message = 'This reply already has one of ask_user, request_form or offer_actions; use at most one per reply.';
    } else if (DISPLAY_TOOL_NAMES.includes(name) && this.display >= MAX_DISPLAY_PER_REPLY) {
      message = `This reply already has ${MAX_DISPLAY_PER_REPLY} display components; put anything else in prose.`;
    }
    if (message) {
      this.mark(toolCallId, status, { error: message });
      throw new Error(message);
    }
  }

  accept(name, toolCallId, spec) {
    if (INTERACTIVE_TOOL_NAMES.includes(name)) this.interactive += 1;
    if (DISPLAY_TOOL_NAMES.includes(name)) this.display += 1;
    if (BLOCKING_TOOL_NAMES.includes(name)) this.questionPosted = true;
    this.mark(toolCallId, 'accepted', { spec });
  }

  remaining() {
    return `Remaining in this reply: ${MAX_INTERACTIVE_PER_REPLY - this.interactive} of ask_user, request_form or offer_actions; ${MAX_DISPLAY_PER_REPLY - this.display} display component(s).`;
  }
}

const RUNS = new Map();

function errorText(error) {
  return (error instanceof Error ? error.message : String(error)).slice(0, 600);
}

/** A failed run's reason: AgentRunError carries the settlement error as a plain `cause`. */
function runErrorText(error) {
  const cause = error?.cause;
  const reason = cause?.message ?? cause?.meta?.reason ??
    (cause && typeof cause === 'object' ? JSON.stringify(cause) : cause);
  return errorText(reason ? `${errorText(error)}: ${reason}` : error);
}

function presentationTool(definition, run, writeComponent) {
  const validate = VALIDATORS[definition.name];
  return {
    name: definition.name,
    description: definition.description,
    input: definition.input,
    output: v.string(),
    run: ({ data, toolCallId }) => {
      run.recorder.admit(definition.name, toolCallId);
      let spec;
      try {
        spec = validate(data);
      } catch (error) {
        // SlackPresentationInputError carries the teaching message verbatim.
        run.recorder.mark(toolCallId, 'rejected_validation', { error: errorText(error) });
        throw error;
      }
      run.recorder.accept(definition.name, toolCallId, spec);
      writeComponent({ tool: definition.name, spec });
      return ACKNOWLEDGEMENTS[definition.name] ??
        (DISPLAY_KINDS[definition.name]
          ? displayToolAcknowledgement(DISPLAY_KINDS[definition.name], Math.max(0, MAX_DISPLAY_PER_REPLY - run.recorder.display))
          : BLOCKING_TOOL_NAMES.includes(definition.name) ? QUESTION_POSTED : `Recorded. ${run.recorder.remaining()}`);
    },
  };
}

/** The production present_table tool, inside the same latch and budget. */
function tableTool(run, writeComponent) {
  let recorded;
  const table = createSlackPresentTableTool((presentation) => { recorded = presentation; });
  return {
    ...table,
    run: (context) => {
      run.recorder.admit(SLACK_PRESENT_TABLE_TOOL_NAME, context.toolCallId);
      let result;
      try {
        result = table.run(context);
      } catch (error) {
        run.recorder.mark(context.toolCallId, 'rejected_validation', { error: errorText(error) });
        throw error;
      }
      run.recorder.accept(SLACK_PRESENT_TABLE_TOOL_NAME, context.toolCallId, recorded);
      writeComponent({ tool: SLACK_PRESENT_TABLE_TOOL_NAME, spec: recorded });
      return result;
    },
  };
}

function stubTool(name, run) {
  const stub = STUB_TOOLS[name];
  return {
    name,
    description: stub.description,
    input: stub.input,
    run: ({ toolCallId }) => {
      if (run.recorder.questionPosted) {
        run.recorder.mark(toolCallId, 'rejected_after_question', { error: AFTER_QUESTION });
        throw new Error(AFTER_QUESTION);
      }
      run.recorder.mark(toolCallId, 'ok');
      return { output: stubToolOutput(run.entry, name) };
    },
  };
}

function SlackPresentationEvalAgent({ id }) {
  const run = RUNS.get(id);
  if (!run) throw new Error(`No Slack presentation evaluation run is registered for ${id}.`);
  const thinkingLevel = thinkingLevelForModel(run.model);
  useModel(run.model, thinkingLevel ? { thinkingLevel } : {});
  useResponseFinish(({ response }) => ({ slackPresentationEval: { usage: normalizeUsage(response.usage) } }));
  const writeComponent = useDataWriter(EVAL_DATA_NAME);
  for (const instruction of evalHostInstructions({ actionLinkInstruction: SLACK_ACTION_LINK_INSTRUCTION, tools: run.tools })) {
    useInstruction(instruction);
  }
  for (const name of STUB_TOOL_NAMES) useTool(stubTool(name, run));
  if (run.tools.includes('present_table')) useTool(tableTool(run, writeComponent));
  for (const definition of SLACK_PRESENTATION_TOOL_DEFINITIONS) {
    if (run.tools.includes(definition.name)) useTool(presentationTool(definition, run, writeComponent));
  }
  return AGENT_PERSONA;
}
SlackPresentationEvalAgent.agentName = 'slack-presentation-eval';

/** One fresh conversation per case run; returns ordered calls and the final text. */
async function runCase(entry, model, { repetition = 0, timeoutMs = 240_000, tools = ALL_EVAL_TOOLS } = {}) {
  const id = `slack-presentation-${randomUUID()}`;
  const run = { entry, model, tools, recorder: new CaseRecorder() };
  RUNS.set(id, run);
  const handle = init(SlackPresentationEvalAgent, { id });
  const inputs = [];
  const outcomes = new Map();
  const startedAt = performance.now();
  const base = { caseId: entry.id, category: entry.category, model, repetition };
  try {
    const receipt = await handle.dispatch(renderSlackTurnPrompt(entry));
    const reply = await handle.read(receipt, {
      signal: AbortSignal.timeout(timeoutMs),
      onEvent(chunk) {
        if (chunk.type === 'tool-input') {
          inputs.push({ id: chunk.toolCallId, tool: chunk.toolName, input: chunk.input });
        } else if (chunk.type === 'tool-output') {
          outcomes.set(chunk.toolCallId, { ok: true });
        } else if (chunk.type === 'tool-output-error') {
          outcomes.set(chunk.toolCallId, { ok: false, error: chunk.errorText });
        }
      },
    });
    const calls = inputs.map(({ id: callId, tool, input }) => {
      const recorded = run.recorder.byCall.get(callId);
      const outcome = outcomes.get(callId);
      // Flue rejects an input its schema refuses before run() is ever called.
      const status = recorded?.status ??
        (outcome?.ok === false ? 'rejected_schema' : outcome?.ok ? 'ok' : 'unsettled');
      // Prefer the error text the model actually saw over the host's record.
      const error = outcome?.ok === false ? String(outcome.error).slice(0, 600) : recorded?.error;
      return {
        tool,
        status,
        ...(error ? { error } : {}),
        input,
        ...(recorded?.spec ? { spec: recorded.spec } : {}),
      };
    });
    const finalText = reply.text ?? '';
    const result = {
      ...base,
      durationMs: Math.round(performance.now() - startedAt),
      calls,
      finalText,
      components: reply.data?.[EVAL_DATA_NAME] ?? [],
      usage: normalizeUsage(reply.metadata?.slackPresentationEval?.usage),
    };
    return { ...result, score: scoreRun(effectiveCase(entry, tools), result) };
  } catch (error) {
    await handle.abort().catch(() => {});
    return {
      ...base,
      durationMs: Math.round(performance.now() - startedAt),
      calls: [],
      finalText: '',
      error: runErrorText(error),
    };
  } finally {
    RUNS.delete(id);
  }
}

function normalizeUsage(value) {
  const number = (candidate) => Number.isFinite(candidate) && candidate >= 0 ? candidate : 0;
  return {
    input: number(value?.input),
    output: number(value?.output),
    cacheRead: number(value?.cacheRead),
    cacheWrite: number(value?.cacheWrite),
    totalTokens: number(value?.totalTokens),
  };
}

// ── Offline: corpus contract + deterministic Flue smoke ────────────────────

function assert(condition, message) {
  if (!condition) throw new Error(message);
}

function caseById(corpus, id) {
  const entry = corpus.cases.find((candidate) => candidate.id === id);
  assert(entry, `Smoke case ${id} is missing from the corpus.`);
  return entry;
}

function statuses(result) {
  return result.calls.map(({ tool, status }) => `${tool}:${status}`);
}

async function runDeterministicSmoke(corpus) {
  const stats = validateCorpus(corpus);
  let rejected = false;
  try {
    validateCorpus({ ...corpus, cases: corpus.cases.map((entry, index) => index === 0 ? { ...entry, strongSignal: true } : entry) });
  } catch (error) {
    rejected = /strongSignal/.test(errorText(error));
  }
  assert(rejected, 'Corpus validation accepted a strong signal on a no-component case.');
  for (const entry of corpus.cases) {
    const prompt = renderSlackTurnPrompt(entry);
    assert(prompt.includes(entry.request.text), `${entry.id}: the rendered Slack turn lost the request.`);
  }

  const faux = fauxProvider({ models: [{ id: 'slack-presentation-eval', reasoning: false }] });
  const flue = await start({ agents: [SlackPresentationEvalAgent], providers: [faux.provider] });
  const results = [];
  const scenario = async (caseId, responses) => {
    faux.setResponses(responses);
    const result = await runCase(caseById(corpus, caseId), FAUX_MODEL, { timeoutMs: 30_000 });
    assert(!result.error, `${caseId}: smoke run failed: ${result.error}`);
    assert(faux.getPendingResponseCount() === 0, `${caseId}: the smoke run did not consume every scripted response.`);
    results.push(result);
    return result;
  };
  try {
    // 1. Restraint: a plain answer. The request must carry the guide, the
    // production table rule and every presentation tool across the boundary.
    const plain = await scenario('plain-answer-refund-window', [
      (context) => {
        const tools = new Set(getCurrentTools(context.messages).map(({ name }) => name));
        assert(getCurrentSystemPrompt(context.messages).includes(SLACK_PRESENTATION_GUIDE), 'The presentation guide did not reach the model.');
        for (const name of [...PRESENTATION_TOOL_NAMES, ...STUB_TOOL_NAMES]) assert(tools.has(name), `Tool ${name} did not reach the model.`);
        return fauxAssistantMessage([fauxToolCall('search_docs', { query: 'refund window annual plans' })], { stopReason: 'toolUse' });
      },
      fauxAssistantMessage('Annual plans get a full refund within 30 days of purchase. After that there are no refunds.'),
    ]);
    assert(statuses(plain).join() === 'search_docs:ok', `Plain answer statuses: ${statuses(plain)}`);
    assert(!plain.score.overUse && plain.score.expectNone, 'A plain answer was scored as over-use.');

    // 2. A real decision: a teaching rejection, the retry, then the latch.
    const decision = await scenario('decision-hotfix-prod-or-staging', [
      fauxAssistantMessage([fauxToolCall('ask_user', { question: 'Where should I deploy it?', options: [{ label: 'Production' }] })], { stopReason: 'toolUse' }),
      fauxAssistantMessage([fauxToolCall('ask_user', {
        question: 'Where should I deploy the billing-service hotfix?',
        options: [{ label: 'Staging first', recommended: true }, { label: 'Production now', destructive: true }],
      })], { stopReason: 'toolUse' }),
      fauxAssistantMessage([fauxToolCall('deploy_service', { service: 'billing-service', environment: 'production' })], { stopReason: 'toolUse' }),
      fauxAssistantMessage('One decision before I deploy.'),
    ]);
    assert(statuses(decision).join() === 'ask_user:rejected_validation,ask_user:accepted,deploy_service:rejected_after_question',
      `Decision statuses: ${statuses(decision)}`);
    assert(/2–25 options/.test(decision.calls[0].error ?? ''), 'The teaching error did not reach the recorded call.');
    assert(decision.score.strongHit && decision.score.firstTryRejected, 'Decision scoring missed the hit or the first-try error.');
    assert(decision.components.length === 1 && decision.components[0].tool === 'ask_user', 'The accepted component did not cross the Flue boundary.');

    // 3. A Flue schema rejection (unknown key), then the production table.
    const rows = Array.from({ length: 11 }, (_, index) => [5100 + index, `Ticket ${index + 1}`, 'Orchard Labs']);
    const table = await scenario('rows-p2-queue', [
      fauxAssistantMessage([fauxToolCall('present_table', { title: 'P2', caption: 'Open P2 tickets', presentation: 'static', columns: [{ header: 'ID' }], rows: [[1], [2]] })], { stopReason: 'toolUse' }),
      fauxAssistantMessage([fauxToolCall('present_table', {
        caption: 'Open P2 tickets', presentation: 'explore',
        columns: [{ header: 'Ticket', type: 'number' }, { header: 'Subject' }, { header: 'Customer' }], rows,
      })], { stopReason: 'toolUse' }),
      fauxAssistantMessage('Eleven P2 tickets are open; three are unassigned.'),
    ]);
    assert(statuses(table).join() === 'present_table:rejected_schema,present_table:accepted', `Table statuses: ${statuses(table)}`);
    assert(table.score.strongHit && !table.score.repeats, 'Table scoring is wrong.');

    // 4. Over-use with prose repetition on chit-chat.
    const chat = await scenario('chit-chat-capabilities', [
      fauxAssistantMessage([fauxToolCall('present_cards', { cards: [{ title: 'Support tickets' }, { title: 'Revenue metrics' }, { title: 'Pull requests' }] })], { stopReason: 'toolUse' }),
      fauxAssistantMessage('I can help with support tickets, pull requests and revenue metrics.'),
    ]);
    assert(chat.score.overUse && chat.score.repeats, 'Over-use or prose repetition was not detected.');

    // 5. The interactive budget.
    const budget = await scenario('next-steps-overnight-triage', [
      fauxAssistantMessage([fauxToolCall('offer_actions', { actions: [{ label: 'Draft urgent replies' }] })], { stopReason: 'toolUse' }),
      fauxAssistantMessage([fauxToolCall('offer_actions', { actions: [{ label: 'Open the queue', url: 'https://support.kestrel.example/queue' }] })], { stopReason: 'toolUse' }),
      fauxAssistantMessage([fauxText('Two tickets are urgent: 5220 and 5223.')]),
    ]);
    assert(statuses(budget).join() === 'offer_actions:accepted,offer_actions:rejected_budget', `Budget statuses: ${statuses(budget)}`);

    // 6. Asking permission for what was explicitly requested.
    const trap = await scenario('outward-explicit-delete-ticket', [
      fauxAssistantMessage([fauxToolCall('ask_user', { question: 'Delete ticket 4990 permanently?', options: [{ label: 'Delete it', destructive: true }, { label: 'Keep it' }] })], { stopReason: 'toolUse' }),
      fauxAssistantMessage(''),
    ]);
    assert(trap.score.permissionAsk && trap.score.overUse, 'A permission ask on an explicit request was not caught.');
  } finally {
    await flue.stop();
  }

  const summary = summarizeRuns(results);
  const m = summary.metrics;
  assert(m.overUse.count === 2 && m.overUse.total === 3, `Over-use metric is wrong: ${JSON.stringify(m.overUse)}`);
  assert(m.strongSignalRecall.count === 2 && m.strongSignalRecall.total === 2, 'Strong-signal recall metric is wrong.');
  assert(m.firstTrySchemaError.count === 2 && m.firstTrySchemaError.total === 5, `First-try metric is wrong: ${JSON.stringify(m.firstTrySchemaError)}`);
  assert(m.permissionAsks.count === 1 && m.budgetRejections === 1 && m.afterQuestionCalls === 1, 'Trap, budget or latch metric is wrong.');
  assert(!summary.gate.passed && summary.gate.failures.includes('over-use') && summary.gate.failures.includes('permission asks'),
    'The gate did not fail on over-use and permission asks.');
  return {
    kind: 'slack-presentation offline check: corpus contract and deterministic faux-model smoke; no real model',
    corpusVersion: corpus.corpusVersion,
    guideVersion: corpus.guideVersion,
    surfaceDigest: presentationSurfaceDigest(),
    corpus: stats,
    smoke: results.map((result) => ({ caseId: result.caseId, calls: statuses(result) })),
    passed: true,
  };
}

// ── Live ───────────────────────────────────────────────────────────────────

function loadProviderEnv(files) {
  const loaded = [];
  for (const file of files) {
    const parsed = parseEnv(readFileSync(resolve(file), 'utf8'));
    for (const [name, value] of Object.entries(parsed)) {
      if (!LOADABLE_ENV.has(name) || !value || process.env[name]) continue;
      process.env[name] = value;
      loaded.push(name);
    }
  }
  return [...new Set(loaded)].sort();
}

/** The Workers AI binding exists only on Cloudflare; Node reaches it over REST. */
function normalizeModel(model) {
  return model.startsWith('cloudflare/@cf/') ? `cloudflare-workers-ai/${model.slice('cloudflare/'.length)}` : model;
}

function missingCredentials(model) {
  const provider = model.slice(0, model.indexOf('/'));
  const required = PROVIDER_ENV[provider];
  if (!required) return [`unknown provider "${provider}" (supported: ${Object.keys(PROVIDER_ENV).join(', ')})`];
  return required.filter((name) => !process.env[name]);
}

function defaultOutputPath(createdAt) {
  const stamp = createdAt.replace(/[:.]/g, '-');
  return join(homedir(), '.chickpea', 'evals', 'slack-presentation', stamp, 'report.json');
}

function outputPath(option, createdAt) {
  const target = option ? resolve(option) : defaultOutputPath(createdAt);
  const file = target.endsWith('.json') ? target : join(target, 'report.json');
  return outsideGit(file);
}

function writePrivateJson(path, value) {
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
  const temporary = `${path}.${randomUUID()}.pending`;
  writeFileSync(temporary, `${JSON.stringify(value, null, 2)}\n`, { flag: 'wx', mode: 0o600 });
  renameSync(temporary, path);
}

function sourceFingerprint() {
  try {
    const root = new URL('..', import.meta.url).pathname;
    const head = execFileSync('git', ['-C', root, 'rev-parse', 'HEAD'], { encoding: 'utf8' }).trim();
    const dirty = execFileSync('git', ['-C', root, 'status', '--porcelain'], { encoding: 'utf8' }).trim().length > 0;
    return { head, dirty };
  } catch {
    return { head: null, dirty: null };
  }
}

function clip(text, length = 160) {
  const flat = String(text ?? '').replace(/\s+/g, ' ').trim();
  return flat.length > length ? `${flat.slice(0, length - 1)}…` : flat;
}

/** The concrete failures behind each metric, for iterating the guide. */
function failureDetails(runs) {
  const scored = runs.filter((run) => !run.error);
  return {
    overUse: scored.filter((run) => run.score.overUse).map((run) => ({ caseId: run.caseId, components: run.score.componentsAccepted })),
    strongMisses: scored.filter((run) => run.score.strongHit === false).map((run) => ({
      caseId: run.caseId, used: run.score.componentsAccepted, attempted: run.score.componentsAttempted, finalText: clip(run.finalText),
    })),
    firstTrySchemaErrors: scored.filter((run) => run.score.firstTryRejected).map((run) => {
      const call = run.calls.find(({ tool }) => PRESENTATION_TOOL_NAMES.includes(tool));
      return { caseId: run.caseId, tool: call?.tool, status: call?.status, error: clip(call?.error, 240) };
    }),
    proseRepetition: scored.filter((run) => run.score.repeats).map((run) => ({ caseId: run.caseId, repetition: run.score.repetition })),
    permissionAsks: scored.filter((run) => run.score.permissionAsk).map((run) => ({ caseId: run.caseId })),
    proseProceedAsks: scored.filter((run) => run.score.proseProceedAsk).map((run) => ({ caseId: run.caseId, finalText: clip(run.finalText) })),
    decideAsks: scored.filter((run) => run.score.decideAsk).map((run) => ({ caseId: run.caseId })),
    forbidden: scored.filter((run) => run.score.forbiddenUsed.length).map((run) => ({ caseId: run.caseId, tools: run.score.forbiddenUsed })),
    offList: scored.filter((run) => run.score.offList.length).map((run) => ({ caseId: run.caseId, tools: run.score.offList })),
    emptyReplies: scored.filter((run) => run.score.emptyReply).map((run) => ({ caseId: run.caseId })),
    errors: runs.filter((run) => run.error).map((run) => ({ caseId: run.caseId, error: clip(run.error, 240) })),
  };
}

function printFailures(model, details) {
  const lines = [];
  const add = (label, items, render) => {
    if (items.length) lines.push(`  ${label} (${items.length}): ${items.map(render).join('; ')}`);
  };
  add('over-use', details.overUse, (item) => `${item.caseId} [${item.components.join(',')}]`);
  add('strong-signal misses', details.strongMisses, (item) => `${item.caseId} [${item.used.join(',') || 'none'}]`);
  add('first-try schema errors', details.firstTrySchemaErrors, (item) => `${item.caseId} ${item.tool}: ${clip(item.error, 90)}`);
  add('prose repetition', details.proseRepetition, (item) => item.caseId);
  add('permission asks', details.permissionAsks, (item) => item.caseId);
  add('prose proceed-asks (report only)', details.proseProceedAsks, (item) => item.caseId);
  add('low-stakes asks (report only)', details.decideAsks, (item) => item.caseId);
  add('forbidden tools', details.forbidden, (item) => `${item.caseId} [${item.tools.join(',')}]`);
  add('off-list components (report only)', details.offList, (item) => `${item.caseId} [${item.tools.join(',')}]`);
  add('errors', details.errors, (item) => `${item.caseId}: ${clip(item.error, 90)}`);
  process.stdout.write(`${model}\n${lines.length ? lines.join('\n') : '  no failures'}\n`);
}

async function runPool(jobs, concurrency, worker) {
  let next = 0;
  const lanes = Array.from({ length: Math.min(concurrency, jobs.length) }, async () => {
    while (next < jobs.length) {
      const job = jobs[next++];
      await worker(job);
    }
  });
  await Promise.all(lanes);
}

async function runLive(corpus, options) {
  const createdAt = new Date().toISOString();
  const currentGuide = slackPresentationGuideVersion();
  validateCorpus(corpus, { requireCurrentGuide: false });
  if (corpus.guideVersion !== currentGuide) {
    process.stderr.write(`Note: the corpus was recorded for guide ${corpus.guideVersion}; the current guide is ${currentGuide}. Results are reported against the current guide.\n`);
  }
  const loadedEnv = loadProviderEnv(options.envFiles);
  if (loadedEnv.length) process.stdout.write(`Loaded provider credentials by name: ${loadedEnv.join(', ')}\n`);
  const requested = (options.models.length ? options.models : (process.env[MODEL_ENV] ?? '').split(','))
    .map((model) => model.trim())
    .filter(Boolean)
    .map(normalizeModel);
  assert(requested.length > 0, `Pass --model or set ${MODEL_ENV} (comma-separated). Suggested: ${SUGGESTED_MODELS.join(',')}`);
  assert(new Set(requested).size === requested.length, 'Models must be unique.');
  const models = [];
  const skipped = [];
  for (const model of requested) {
    const missing = missingCredentials(model);
    if (missing.length) {
      skipped.push({ model, missing });
      process.stderr.write(`Skipping ${model}: missing ${missing.join(', ')}\n`);
    } else {
      models.push(model);
    }
  }
  const cases = selectCases(corpus, { caseIds: options.caseIds, categories: options.categories });
  const path = outputPath(options.output, createdAt);
  const report = {
    kind: 'slack-presentation usage calibration: synthetic Slack turns with stub domain tools; no Slack delivery or rendering',
    schemaVersion: 1,
    createdAt,
    finishedAt: null,
    source: sourceFingerprint(),
    corpus: {
      corpusVersion: corpus.corpusVersion,
      recordedGuideVersion: corpus.guideVersion,
      guideVersion: currentGuide,
      surfaceDigest: presentationSurfaceDigest(),
      selectedCases: cases.map(({ id }) => id),
    },
    settings: { models, tools: options.tools, repeat: options.repeat, concurrency: options.concurrency, timeoutMs: options.timeoutMs },
    skippedModels: skipped,
    abandonedModels: [],
    targets: TARGETS,
    models: [],
    gate: { passed: false, failures: ['incomplete'] },
    runs: [],
  };
  writePrivateJson(path, report);
  process.stdout.write(`Private report: ${path}\n`);
  if (models.length === 0) {
    report.gate = { passed: false, failures: skipped.map(({ model, missing }) => `${model}: missing ${missing.join(', ')}`) };
    report.finishedAt = new Date().toISOString();
    writePrivateJson(path, report);
    return report;
  }

  const { bootstrapRuntimeProviders } = await import('../src/runtime-bootstrap.ts');
  bootstrapRuntimeProviders();
  const flue = await start({ agents: [SlackPresentationEvalAgent] });
  // Interleave models so each provider's requests spread across the run.
  const jobs = [];
  for (let repetition = 0; repetition < options.repeat; repetition += 1) {
    for (const entry of cases) for (const model of models) jobs.push({ entry, model, repetition });
  }
  let done = 0;
  // A model whose first runs all fail (bad credentials, unknown id) is
  // abandoned instead of failing every remaining case the same way.
  const health = new Map(models.map((model) => [model, { ok: 0, failed: 0, abandoned: 0, lastError: undefined }]));
  try {
    await runPool(jobs, options.concurrency, async ({ entry, model, repetition }) => {
      const state = health.get(model);
      if (state.ok === 0 && state.failed >= ABANDON_AFTER_FAILURES) {
        state.abandoned += 1;
        done += 1;
        return;
      }
      const result = await runCase(entry, model, { repetition, timeoutMs: options.timeoutMs, tools: options.tools });
      if (result.error) {
        state.failed += 1;
        state.lastError = result.error;
      } else {
        state.ok += 1;
      }
      report.runs.push(result);
      done += 1;
      const used = result.error ? `ERROR ${clip(result.error, 80)}` : (result.score.componentsAccepted.join(',') || 'none');
      const calls = result.calls.map(({ tool, status }) => status === 'ok' || status === 'accepted' ? tool : `${tool}(${status})`).join(' ');
      process.stdout.write(`[${done}/${jobs.length}] ${model} ${entry.id}: ${used}${calls ? ` | ${calls}` : ''}\n`);
      writePrivateJson(path, report);
    });
  } finally {
    await flue.stop();
  }

  const byCase = new Map(corpus.cases.map((entry) => [entry.id, entry]));
  report.runs.sort((left, right) =>
    left.model.localeCompare(right.model) || left.caseId.localeCompare(right.caseId) || left.repetition - right.repetition);
  for (const model of models) {
    const runs = report.runs.filter((run) => run.model === model);
    const summary = summarizeRuns(runs);
    const perCategory = Object.fromEntries(CATEGORIES.map((category) => {
      const categoryRuns = runs.filter((run) => byCase.get(run.caseId)?.category === category);
      return [category, {
        runs: categoryRuns.length,
        withComponent: categoryRuns.filter((run) => run.score?.componentsAccepted.length).length,
      }];
    }));
    report.models.push({ model, summary, perCategory, failures: failureDetails(runs) });
  }
  report.abandonedModels = [...health].filter(([, state]) => state.abandoned > 0)
    .map(([model, state]) => ({ model, abandonedRuns: state.abandoned, lastError: state.lastError }));
  const failures = [
    ...skipped.map(({ model, missing }) => `${model}: missing ${missing.join(', ')}`),
    ...report.abandonedModels.map(({ model, abandonedRuns, lastError }) =>
      `${model}: abandoned ${abandonedRuns} run(s) after ${ABANDON_AFTER_FAILURES} failures (${clip(lastError, 120)})`),
    ...report.models.filter(({ summary }) => !summary.gate.passed)
      .map(({ model, summary }) => `${model}: ${summary.gate.failures.join(', ')}`),
  ];
  report.gate = { passed: failures.length === 0, failures };
  report.finishedAt = new Date().toISOString();
  writePrivateJson(path, report);

  process.stdout.write(`\n${formatSummaryTable(report.models)}\n\n`);
  for (const { model, failures: details } of report.models) printFailures(model, details);
  for (const { model, missing } of skipped) process.stdout.write(`${model}: not run, missing ${missing.join(', ')}\n`);
  for (const { model, abandonedRuns, lastError } of report.abandonedModels) {
    process.stdout.write(`${model}: abandoned ${abandonedRuns} run(s) after ${ABANDON_AFTER_FAILURES} failures: ${clip(lastError, 200)}\n`);
  }
  process.stdout.write(`\nPrivate report: ${path}\n`);
  return report;
}

// ── CLI ────────────────────────────────────────────────────────────────────

function integerOption(value, name, minimum, maximum, fallback) {
  if (value === undefined) return fallback;
  const number = Number(value);
  assert(Number.isInteger(number) && number >= minimum && number <= maximum, `${name} must be an integer from ${minimum} to ${maximum}.`);
  return number;
}

function parseOptions(argv) {
  const { values } = parseArgs({
    args: argv,
    options: {
      live: { type: 'boolean' },
      help: { type: 'boolean' },
      model: { type: 'string', multiple: true },
      case: { type: 'string', multiple: true },
      category: { type: 'string', multiple: true },
      repeat: { type: 'string' },
      concurrency: { type: 'string' },
      'timeout-ms': { type: 'string' },
      output: { type: 'string' },
      'env-file': { type: 'string', multiple: true },
      tools: { type: 'string' },
    },
  });
  const liveOnly = ['model', 'case', 'category', 'repeat', 'concurrency', 'timeout-ms', 'env-file', 'tools'];
  if (!values.live) {
    const used = liveOnly.filter((name) => values[name] !== undefined);
    assert(used.length === 0, `--${used[0]} requires --live.`);
  }
  return {
    live: values.live === true,
    help: values.help === true,
    models: (values.model ?? []).flatMap((model) => model.split(',')),
    caseIds: values.case ?? [],
    categories: values.category ?? [],
    repeat: integerOption(values.repeat, '--repeat', 1, 5, 1),
    concurrency: integerOption(values.concurrency, '--concurrency', 1, 16, 4),
    timeoutMs: integerOption(values['timeout-ms'], '--timeout-ms', 10_000, 900_000, 240_000),
    output: values.output,
    envFiles: values['env-file'] ?? [],
    tools: values.tools === undefined
      ? [...ALL_EVAL_TOOLS]
      : (() => {
          const tools = values.tools.split(',').map((tool) => tool.trim()).filter(Boolean);
          for (const tool of tools) assert(ALL_EVAL_TOOLS.includes(tool), `--tools names unknown tool ${tool}.`);
          return tools;
        })(),
  };
}

function printHelp() {
  process.stdout.write([
    'Usage: npm run evaluate:slack-presentation -- [--output PATH]',
    '       npm run evaluate:slack-presentation -- --live [--model ID ...] [--case ID ...] [--category NAME ...]',
    '         [--repeat N] [--concurrency N] [--timeout-ms MS] [--output PATH] [--env-file PATH ...]',
    '',
    'Offline (default): validates evals/slack-presentation/cases.json and runs a deterministic faux-model',
    'smoke through the Flue boundary. No network, no credentials.',
    '',
    `Live: runs each selected case in a fresh conversation per model. Models come from --model (repeatable or`,
    `comma-separated) or ${MODEL_ENV}. Suggested: ${SUGGESTED_MODELS.join(', ')}.`,
    'Credentials come from the environment (OPENAI_API_KEY, ANTHROPIC_API_KEY, CLOUDFLARE_API_TOKEN +',
    'CLOUDFLARE_ACCOUNT_ID); --env-file loads only those names and prints no values. A model whose',
    'credentials are missing is skipped and fails the gate. The report holds prompts\' answers and tool',
    'inputs, so it is private evidence: it defaults to ~/.chickpea/evals/slack-presentation/<time>/report.json',
    'and is refused inside any Git checkout. Exits 1 when a calibration target fails.',
    '',
    `Targets: over-use ≤ ${TARGETS.overUseRateMax * 100}%, strong-signal recall ≥ ${TARGETS.strongSignalRecallMin * 100}%,`,
    `first-try schema errors ≤ ${TARGETS.firstTrySchemaErrorRateMax * 100}%, prose repetition ≤ ${TARGETS.proseRepetitionRateMax * 100}%, permission asks = 0.`,
    '',
  ].join('\n'));
}

async function main() {
  process.env.DO_NOT_TRACK ??= '1';
  const options = parseOptions(process.argv.slice(2));
  if (options.help) {
    printHelp();
    return;
  }
  assertNodeVersion();
  const corpus = JSON.parse(readFileSync(CORPUS_PATH, 'utf8'));
  if (options.live) {
    const report = await runLive(corpus, options);
    if (!report.gate.passed) process.exitCode = 1;
    return;
  }
  const report = await runDeterministicSmoke(corpus);
  const serialized = `${JSON.stringify(report, null, 2)}\n`;
  if (options.output) {
    const path = outsideGit(resolve(options.output));
    writePrivateJson(path, report);
    process.stdout.write(`Wrote the offline check to ${path}\n`);
  } else {
    process.stdout.write(serialized);
  }
}

main().catch((error) => {
  process.stderr.write(`Slack presentation evaluation failed: ${errorText(error)}\n`);
  process.exitCode = 1;
});