/**
 * Composition Root — the single place that knows about all concrete adapters.
 *
 * Creates a fully wired AppContext from resolved configuration.
 * This function is called ONCE per CLI invocation, at the top of each
 * command's action handler.
 *
 * Design principle: no DI container, no decorators, no reflection.
 * Just a plain factory function that builds a plain object.
 *
 * Why this exists:
 * - Before: each command constructs its own adapters (scattered coupling)
 * - After: one factory, one place to change adapter wiring
 *
 * @see packages/core/src/ports/context.ts — AppContext interface
 * @see docs/archive/exec-plans/ports-and-adapters/phase-7-composition-root.md
 */
import { promises as fs } from "node:fs";
import path from "node:path";
import { ARTIFACT_EXPORT_PHASE_ID, DIAGNOSIS_CARD_GENERATORS, InMemoryPluginRegistry, NoOpArtifactWriter, NoOpProgressReporter, createDiagnosisRunner, createLLMClient, generateRunId, isArtifactType, modelId, } from "./_vendor/ailf-core/index.js";
import { DEFAULT_GCS_ARTIFACT_BUCKET } from "./_vendor/ailf-shared/index.js";
import { JudgmentAttributionSchema } from "./adapters/attribution/per-entry-attribution-writer.js";
import { AccumulatingArtifactWriter } from "./artifact-capture/accumulating-artifact-writer.js";
import { ApiGatewayArtifactWriter } from "./artifact-capture/api-gateway-artifact-writer.js";
import { BatchingApiGatewayArtifactWriter } from "./artifact-capture/batching-api-gateway-artifact-writer.js";
import { FanoutArtifactWriter } from "./artifact-capture/fanout-artifact-writer.js";
import { GcsArtifactWriter } from "./artifact-capture/gcs-artifact-writer.js";
import { InstrumentedArtifactWriter } from "./artifact-capture/instrumented-artifact-writer.js";
import { LocalFilesystemArtifactWriter } from "./artifact-capture/local-fs-artifact-writer.js";
import { resolveUploadConcurrency, setDefaultUploadConcurrency, } from "./artifact-capture/parallel-emit.js";
import { UploadMetrics } from "./artifact-capture/upload-metrics.js";
import { ContentLakeCacheAdapter } from "./adapters/cache/content-lake-cache.js";
import { AnthropicLLMClient, OpenAILLMClient } from "./adapters/llm/index.js";
import { runBorderlineConsensus, } from "./pipeline/borderline-consensus-runner.js";
import { loadExternalPresets } from "./pipeline/compiler/preset-loader.js";
import { FilesystemCache } from "./adapters/cache/filesystem-cache.js";
import { PromptfooEvalAdapter } from "./adapters/eval-runners/promptfoo-eval-adapter.js";
import { DtsPackageSurface } from "./adapters/package-surface/index.js";
import { ConsoleLogger, JsonLogger, QuietLogger, } from "./adapters/loggers/index.js";
import { ConsoleProgressReporter } from "./adapters/progress/console-progress-reporter.js";
import { CompositeTaskSource, ContentLakeTaskSource, RepoTaskSource, } from "./adapters/task-sources/index.js";
import { resolveVendoredSubdir } from "./pipeline/compiler/config-loader.js";
import { createAgentHarnessBase, createKnowledgeProbeBase, createLiteracyModeBase, createMcpServerModeBase, } from "./pipeline/compiler/mode-bases/index.js";
import { PRESET_FACTORY_BY_SOURCE } from "./pipeline/compiler/presets/index.js";
import { DEFAULT_SOURCE_KIND, } from "./adapters/doc-fetchers/source-kinds.js";
import { tryLoadConfigFile } from "./pipeline/compiler/config-loader.js";
import { getAilfSanityClient } from "./sanity/client.js";
import { ReportStore } from "./report-store.js";
import { loadSinks } from "./sinks/index.js";
import { createNotificationService } from "./notifications/index.js";
/**
 * Create a fully wired AppContext from resolved configuration.
 *
 * Every adapter is constructed here and nowhere else (outside of tests).
 * Swapping an adapter is a one-line change in this function.
 */
export function createAppContext(config) {
    // Logger — selected by env var preferences
    const logger = createLogger();
    // Progress reporter — console-backed for the default logger; no-op for
    // JSON/quiet modes and tests where interactive output is inappropriate.
    const progress = createProgressReporter();
    // Cache — filesystem, optionally decorated with Content Lake fallback
    const cache = config.noCache ? undefined : createCache(config);
    // Task source — selected by config.taskSourceType
    const taskSource = createTaskSource(config);
    // Plugin registry — mode bases, assertions, presets, doc fetcher.
    // External presets from config are loaded and registered after built-ins.
    const externalPresets = config.presets && config.presets.length > 0
        ? loadExternalPresets(config.presets, config.rootDir)
        : undefined;
    const resolvedSource = resolveSourceEntry(config.source, config.rootDir);
    const registry = createRegistry(config.rootDir, resolvedSource, externalPresets);
    // Doc fetcher — provided by the registered preset's factory
    const docFetcherFactory = registry.getDocFetcherFactory();
    const docFetcher = docFetcherFactory ? docFetcherFactory() : undefined;
    // Eval runner — Promptfoo subprocess
    const evalRunner = new PromptfooEvalAdapter(config.rootDir);
    // Package-surface resolver for the W0198 symbol-resolution preflight.
    // Reads installed `.d.ts` from the eval package's node_modules chain
    // (anchored at `config.rootDir`). Phase 1 wires the adapter; later
    // phases consume it.
    const packageSurfaceResolver = new DtsPackageSurface({
        resolveFromDir: config.rootDir,
    });
    // Report store — Sanity Content Lake (for publish + auto-compare)
    const reportStore = createReportStore(config);
    // Sinks — loaded from config/sinks
    const sinks = loadSinks();
    // Team-channel notifications (D0055 Phase 4) — built only when the @AILF
    // Slack App bot token is present, mirroring `loadSinks`'s "no env, no
    // delivery" gate. Teams are read from `ailf-prod-private` via the AILF
    // client (the report store's token works for this read). Without the token,
    // `ctx.notifications` is undefined and the publish step's dispatch is a no-op.
    const webhookSigningSecret = process.env.AILF_WEBHOOK_SIGNING_SECRET;
    const notifications = process.env.SLACK_BOT_TOKEN || webhookSigningSecret
        ? createNotificationService({
            client: getAilfSanityClient({
                token: process.env.AILF_REPORT_SANITY_API_TOKEN ??
                    process.env.SANITY_API_TOKEN ??
                    undefined,
            }),
            ...(process.env.SLACK_BOT_TOKEN
                ? { token: process.env.SLACK_BOT_TOKEN }
                : {}),
            ...(webhookSigningSecret ? { webhookSigningSecret } : {}),
        })
        : undefined;
    // Artifact writer — writes run artifacts + manifest to GCS at known
    // `runs/{runId}/…` paths (D0032). Auto-detects the right adapter from
    // available credentials; defaults bucket to "ailf-artifacts". Set
    // artifactUpload: false to opt out entirely.
    // W0053 — writers receive a progress reporter scoped to a single
    // `artifact-export` phase so the CLI can render per-batch updates.
    const artifactWriter = createArtifactWriter(config, logger, {
        reporter: progress,
        phaseId: ARTIFACT_EXPORT_PHASE_ID,
    });
    // Generate the pipeline's RunId once; every downstream step reads it
    // from the context (D0032).
    const runId = generateRunId();
    logger.debug(`Pipeline runId: ${runId}`);
    // LLM client (D0051) — wired when an API key is present. The grader path
    // does NOT consume this; D0051 defers grader migration as a follow-up.
    // Env mapping happens here so `createLLMClient` stays pure and testable.
    // D-01: factory hoisted to @sanity/ailf-core; adapter ctors injected here.
    const llmAdapters = {
        newAnthropicClient: (opts) => new AnthropicLLMClient(opts),
        newOpenAIClient: (opts) => new OpenAILLMClient(opts),
    };
    const llmKeys = {
        anthropicApiKey: process.env.ANTHROPIC_API_KEY,
        openaiApiKey: process.env.OPENAI_API_KEY,
    };
    const llmClient = createLLMClient(config, llmKeys, {
        logger,
        adapters: llmAdapters,
    });
    return {
        artifactWriter,
        cache,
        config,
        docFetcher,
        evalRunner,
        ...(llmClient ? { llmClient } : {}),
        logger,
        ...(notifications ? { notifications } : {}),
        ...(webhookSigningSecret ? { webhookSigningSecret } : {}),
        packageSurfaceResolver,
        progress,
        registry,
        reportStore,
        runId,
        sinks,
        taskSource,
    };
}
// ---------------------------------------------------------------------------
// Sub-factories (extracted to keep createAppContext readable)
// ---------------------------------------------------------------------------
function createLogger() {
    if (process.env.AILF_LOG_FORMAT === "json")
        return new JsonLogger();
    if (process.env.AILF_LOG_LEVEL === "quiet" ||
        process.env.AILF_QUIET === "1") {
        return new QuietLogger();
    }
    return new ConsoleLogger({
        verbose: process.env.AILF_LOG_LEVEL === "verbose" ||
            process.env.AILF_VERBOSE === "1",
    });
}
/**
 * Select a ProgressReporter adapter. Matches the logger environment — JSON
 * and quiet loggers get a no-op reporter so machine-readable output stays
 * clean; interactive sessions get the console adapter with verbose mirroring.
 */
function createProgressReporter() {
    if (process.env.AILF_LOG_FORMAT === "json")
        return new NoOpProgressReporter();
    if (process.env.AILF_LOG_LEVEL === "quiet" ||
        process.env.AILF_QUIET === "1") {
        return new NoOpProgressReporter();
    }
    return new ConsoleProgressReporter({
        verbose: process.env.AILF_LOG_LEVEL === "verbose" ||
            process.env.AILF_VERBOSE === "1",
    });
}
/**
 * Shared GCS bucket for report artifacts. Canonical default lives in
 * `@sanity/ailf-shared` so the gateway (`packages/api/src/routes/runs.ts`)
 * and the dashboard read the same value. The gateway's signing credentials
 * are scoped to this bucket, so alternate names require reconfiguring the
 * gateway in addition to setting `AILF_GCS_ARTIFACT_BUCKET`.
 */
const DEFAULT_ARTIFACT_BUCKET = DEFAULT_GCS_ARTIFACT_BUCKET;
/**
 * D0033 M4 default root for local artifacts when `--artifacts-dir` is unset.
 * Mirrors the pre-W0050 capture root so existing dev tooling (Studio
 * retrieval, CI archivers) keeps finding files at the same path prefix.
 */
const DEFAULT_LOCAL_ARTIFACTS_DIR = ".ailf/results/captures";
/**
 * Selects the `ArtifactWriter` wiring per D0033 M4:
 *
 *   1. `--no-artifacts` (`config.artifactsDisabled === true`, or legacy
 *      `config.artifactUpload === false`) → `NoOpArtifactWriter`.
 *   2. Otherwise: always attach `LocalFilesystemArtifactWriter` under
 *      `--artifacts-dir` (default `.ailf/results/captures`).
 *   3. When a remote backend is reachable (ADC, GCLOUD_PROJECT, or an
 *      AILF API key + URL), layer it via `FanoutArtifactWriter([local, gcs])`.
 *      Local is listed first so a local success + remote failure still
 *      produces a non-null ref.
 *
 * Always returns a writer — pipeline code can assume `ctx.artifactWriter`
 * is present. Producers post-W0050 drop their `if (ctx.artifactWriter)`
 * guards in Slice 6.
 *
 * Exported for unit-test access; not part of the public package API.
 */
export function createArtifactWriter(config, logger, progress, writerSource = "pipeline") {
    // Legacy `artifactUpload: false` still disables — treat as an alias for
    // the canonical `artifactsDisabled: true` until W0052 removes it.
    if (config.artifactsDisabled === true || config.artifactUpload === false) {
        logger.debug("Artifact writer: NoOpArtifactWriter (--no-artifacts / artifactsDisabled / artifactUpload=false)");
        return new NoOpArtifactWriter();
    }
    const exclude = resolveExcludeList(config.artifactsExclude, logger);
    const rootDir = config.artifactsDir ?? DEFAULT_LOCAL_ARTIFACTS_DIR;
    // W0056 — opt-in measurement of the upload path. The collector is passed
    // to the remote writer (where sign/PUT/compose phases live) AND wraps the
    // final writer to record caller-observed `emit`/`writeManifest` totals.
    // `summarize()` fires from the decorator's `writeManifest` hook.
    const metrics = process.env.AILF_UPLOAD_METRICS === "1"
        ? new UploadMetrics({
            logger,
            detailFile: `${rootDir}/upload-metrics/run-${Date.now()}.ndjson`,
        })
        : null;
    // W0053: progress attaches to the OUTERMOST of (local-only | fanout). When
    // fanout is wired, the delegates stay silent so we don't double-count the
    // same caller-visible write across two backends.
    const remote = createRemoteArtifactWriter(config, logger, metrics, writerSource);
    const local = new LocalFilesystemArtifactWriter({
        rootDir,
        exclude,
        writerSource,
        ...(remote ? {} : { progress }),
    });
    // W0064 — when a remote backend is wired, list it first so its ArtifactRef
    // wins the fanout's firstNonNull() selection and the published manifest
    // points at a cross-machine-readable store. Local stays attached as the
    // resilience tier: if the remote leg fails, firstNonNull falls through to
    // local and the pipeline still produces a non-null ref.
    const base = remote
        ? new FanoutArtifactWriter([remote, local], { progress })
        : local;
    if (!remote) {
        logger.debug(`Artifact writer: LocalFilesystemArtifactWriter only (rootDir=${rootDir})`);
    }
    else {
        logger.debug(`Artifact writer: FanoutArtifactWriter([${remote.constructor.name}, local=${rootDir}])`);
    }
    // Wrap in the accumulator so FinalizeRunStep can build a populated
    // RunManifest without each producer bookkeeping its own ArtifactRefs
    // (W0051 Slice 3 revisit — Option B of the "manifest empty on real runs"
    // fix).
    const accumulating = new AccumulatingArtifactWriter(base);
    return metrics
        ? new InstrumentedArtifactWriter(accumulating, metrics)
        : accumulating;
}
/**
 * Validate the exclude list against the registry. Unknown types are dropped
 * with a warning — a typo'd CLI flag shouldn't silently match nothing.
 */
function resolveExcludeList(raw, logger) {
    if (!raw || raw.length === 0)
        return [];
    const valid = [];
    for (const name of raw) {
        if (isArtifactType(name)) {
            valid.push(name);
        }
        else {
            logger.warn(`--artifacts-exclude: "${name}" is not a known artifact type — ignored`);
        }
    }
    return valid;
}
/**
 * The optional remote-backend writer layered on top of the local writer.
 * Returns null when no credentials are available — the local writer stays
 * the sole backend for that run, which is the D0033 M4 default for laptops
 * and CI without GCS creds.
 */
function createRemoteArtifactWriter(config, logger, metrics, writerSource = "pipeline") {
    const bucket = config.artifactGcsBucket ?? DEFAULT_ARTIFACT_BUCKET;
    const hasGcsCredentials = Boolean(process.env.GOOGLE_APPLICATION_CREDENTIALS || process.env.GCLOUD_PROJECT);
    if (hasGcsCredentials) {
        // W0056 Phase 1: the GCS-direct path measured 0 failures at
        // concurrency 8 with a 60 % pipeline-time reduction. Flip parallelism
        // on by default on this path. `AILF_PARALLEL_UPLOAD=0` still forces
        // serial as an escape hatch.
        setDefaultUploadConcurrency(8);
        logger.debug(`Artifact remote backend: GcsArtifactWriter (ADC, bucket=${bucket}, defaultConcurrency=8)`);
        return new GcsArtifactWriter({
            bucket,
            writerSource,
            ...(metrics ? { metrics } : {}),
        });
    }
    if (config.apiKey && config.apiUrl) {
        // W0058 Phase 2: batching writer is the default on the API Gateway path.
        // Prototype B (W0056) showed batch signing + client-side parallelism
        // eliminates the 429 storm that single-URL parallelism triggered on the
        // Vercel signing endpoint, at parity with the GCS-direct parallel path
        // once the sign+PUT overlap optimization lands. Flip the default to 8
        // concurrency; `AILF_PARALLEL_UPLOAD=0` forces serial as a rollback
        // escape hatch and auto-selects the legacy single-URL writer.
        setDefaultUploadConcurrency(8);
        const concurrency = resolveUploadConcurrency();
        if (concurrency > 1) {
            logger.debug(`Artifact remote backend: BatchingApiGatewayArtifactWriter (via ${config.apiUrl}, bucket=${bucket}, putConcurrency=${concurrency})`);
            // D0034: neither API Gateway writer supports NDJSON `appendNdjson`.
            // Traces that flow through `appendNdjson` are dropped on this path.
            // Surface the gap once at startup instead of ambushing users with a
            // silent null ref at emit time.
            logger.warn("Artifacts: API Gateway path selected without GCS ADC — " +
                "trace (NDJSON) artifacts will be skipped (D0034). Set " +
                "GOOGLE_APPLICATION_CREDENTIALS or GCLOUD_PROJECT to capture traces.");
            return new BatchingApiGatewayArtifactWriter({
                apiBaseUrl: config.apiUrl,
                apiKey: config.apiKey,
                bucket,
                putConcurrency: concurrency,
                writerSource,
                ...(metrics ? { metrics } : {}),
            });
        }
        logger.debug(`Artifact remote backend: ApiGatewayArtifactWriter (via ${config.apiUrl}, bucket=${bucket}, serial — AILF_PARALLEL_UPLOAD=0 override)`);
        return new ApiGatewayArtifactWriter({
            apiBaseUrl: config.apiUrl,
            apiKey: config.apiKey,
            bucket,
            writerSource,
            ...(metrics ? { metrics } : {}),
        });
    }
    return null;
}
function createCache(config) {
    const local = new FilesystemCache(config.rootDir);
    if (config.noRemoteCache)
        return local;
    // Only add Content Lake decorator when a report store token exists
    const token = process.env.AILF_REPORT_SANITY_API_TOKEN ?? process.env.SANITY_API_TOKEN;
    if (!token)
        return local;
    return new ContentLakeCacheAdapter(local, createReportStore(config));
}
/**
 * Build the `TaskSource` adapter wired by the composition root for a
 * given `ResolvedConfig`. Exported for test access — composition-root
 * wiring is a contract worth asserting directly.
 */
export function createTaskSource(config) {
    // "repo" mode — use ONLY repo tasks, no Content Lake or YAML merge.
    // This is the correct mode for API-triggered inline-task evaluations
    // where the caller sent their own task definitions. Without this,
    // CompositeTaskSource would merge the caller's tasks with ALL Content
    // Lake tasks, running the entire suite instead of just the caller's.
    if (config.taskSourceType === "repo") {
        if (!config.repoTasksPath) {
            throw new Error('taskSourceType "repo" requires --repo-tasks-path to be set');
        }
        return new RepoTaskSource(config.repoTasksPath);
    }
    // "content-lake" — Studio-authored ailf.task documents only.
    // AILF documents live in the private dataset (D0043) — route through
    // the AILF client factory so reads target `ailf-prod-private`.
    if (config.taskSourceType === "content-lake") {
        return new ContentLakeTaskSource(getAilfSanityClient({
            token: process.env.AILF_REPORT_SANITY_API_TOKEN ??
                process.env.SANITY_API_TOKEN ??
                undefined,
        }));
    }
    // Unset — AILF-bundled defaults from `tasks/${mode}/`, optionally
    // augmented with the caller's `--repo-tasks-path` (W0146). The
    // bundled directory is allowed to be missing so test rootDirs and
    // modes that ship no defaults degrade gracefully to the augment
    // source (or empty).
    const bundledDir = resolveVendoredSubdir(config.rootDir, `tasks/${config.mode}`);
    const bundled = new RepoTaskSource(bundledDir, { allowMissing: true });
    if (config.repoTasksPath) {
        return new CompositeTaskSource([
            bundled,
            new RepoTaskSource(config.repoTasksPath),
        ]);
    }
    return bundled;
}
// ---------------------------------------------------------------------------
// Layer 0: Framework built-in assertions
// ---------------------------------------------------------------------------
/**
 * Generic Promptfoo assertion types available to all evaluation modes.
 *
 * These are evaluation primitives (text matching, JSON validation, LLM grading)
 * that aren't specific to any mode or domain. They're registered before any
 * preset so every mode has access to them.
 *
 * `compatibleModes` is omitted — when undefined, the assertion is compatible
 * with all modes. Mode-specific assertions can be registered by presets with
 * explicit mode whitelists.
 */
export const FRAMEWORK_ASSERTIONS = [
    {
        type: "contains",
        label: "Contains text",
        handlerModule: "promptfoo:builtin",
    },
    {
        type: "contains-all",
        label: "Contains all texts",
        handlerModule: "promptfoo:builtin",
    },
    {
        type: "contains-any",
        label: "Contains any text",
        handlerModule: "promptfoo:builtin",
    },
    { type: "equals", label: "Exact match", handlerModule: "promptfoo:builtin" },
    { type: "regex", label: "Regex match", handlerModule: "promptfoo:builtin" },
    { type: "is-json", label: "Valid JSON", handlerModule: "promptfoo:builtin" },
    {
        type: "javascript",
        label: "JavaScript assertion",
        handlerModule: "promptfoo:builtin",
    },
    {
        type: "llm-rubric",
        label: "LLM-graded rubric",
        handlerModule: "promptfoo:builtin",
    },
    {
        type: "similar",
        label: "Semantic similarity",
        handlerModule: "promptfoo:builtin",
    },
];
/**
 * Build and populate the plugin registry.
 *
 * Registration follows the five-layer model:
 *
 * Layer 0:   Framework built-in assertions (generic Promptfoo builtins)
 * Layer 0.5: Mode bases (shared evaluation methodology per mode)
 * Layer 1:   Domain presets (domain-specific config targeting a mode base)
 *
 * Mode bases define HOW you evaluate (rubrics, scoring, prompts).
 * Domain presets define WHAT you evaluate (sources, features, doc fetcher)
 * and target a mode base by ID. When a preset is registered, it inherits
 * its mode base's defaults and can optionally override them.
 */
function createRegistry(rootDir, resolvedSource, externalPresets) {
    const registry = new InMemoryPluginRegistry();
    // Layer 0: Framework built-in assertions (available to all modes)
    for (const assertion of FRAMEWORK_ASSERTIONS) {
        registry.registerAssertion(assertion);
    }
    // Layer 0.5: Mode bases (evaluation methodology)
    registry.registerModeBase(createLiteracyModeBase());
    registry.registerModeBase(createMcpServerModeBase());
    registry.registerModeBase(createKnowledgeProbeBase());
    registry.registerModeBase(createAgentHarnessBase());
    const factory = PRESET_FACTORY_BY_SOURCE[resolvedSource.kind];
    registry.registerPreset(factory({ rootDir, source: resolvedSource }));
    // Layer 1+: External domain presets (from config.presets)
    if (externalPresets) {
        for (const preset of externalPresets) {
            registry.registerPreset(preset);
        }
    }
    return registry;
}
/**
 * Resolve the active source from `config/sources.ts` (W0215).
 *
 * Matches by `name` when a source is requested. With no `--source`, picks the
 * entry explicitly tagged `default: true`; when none is tagged it falls back to
 * the built-in Sanity default rather than an arbitrary first entry.
 * `config/sources` is additive over the implicit Sanity source
 * (production/branch/local live in the preset, not here), so a lone non-default
 * entry must not silently become the default. Untagged entries default to
 * `kind: "sanity-content-lake"` (backward compat). When no config file exists,
 * returns a stub Sanity entry — preset factories that ignore the source (Sanity)
 * still work; factories that require source fields (Drive) fail loudly at
 * construction with a clear missing-field error.
 *
 * Exported for test access.
 */
export function resolveSourceEntry(sourceName, rootDir) {
    // Every no-match path resolves to the built-in Sanity default: a missing
    // config file, an unknown requested name, and — critically — a no-`--source`
    // run where no entry is tagged `default: true`. `config/sources` is additive
    // over the implicit Sanity source, so a lone non-default entry must not become
    // the default; only an explicit `default: true` (or `--source <name>`) selects
    // a non-Sanity source. Drive sources also demand `name`/`rootFolderId` we could
    // not synthesize from nothing, so Sanity is the only safe fallback.
    const fallback = { kind: DEFAULT_SOURCE_KIND };
    const loaded = tryLoadConfigFile("sources", rootDir);
    if (!loaded || !Array.isArray(loaded.data))
        return fallback;
    const entries = loaded.data;
    const match = sourceName
        ? entries.find((e) => e.name === sourceName)
        : entries.find((e) => e.default);
    if (!match)
        return fallback;
    if (match.kind)
        return match;
    return {
        ...match,
        kind: DEFAULT_SOURCE_KIND,
    };
}
function createReportStore(config) {
    return new ReportStore({
        dataset: process.env.AILF_REPORT_DATASET ??
            config?.reportStoreDataset ??
            undefined,
        projectId: process.env.AILF_REPORT_PROJECT_ID ??
            config?.reportStoreProjectId ??
            undefined,
        token: process.env.AILF_REPORT_SANITY_API_TOKEN ??
            process.env.SANITY_API_TOKEN ??
            undefined,
    });
}
// ---------------------------------------------------------------------------
// Borderline-consensus wiring (Plan 03-04 / GRAD-04)
// ---------------------------------------------------------------------------
/**
 * Severity boundaries from `packages/eval/config/thresholds.ts`
 * (severity.critical/warning/info `composite-below` at L50/54/58 — 30, 50,
 * 60). The borderline detector flags a judgment when its score is within
 * ±5 of any of these. Composition-root reads them ONCE and threads the
 * typed `readonly number[]` into `runBorderlineConsensus` rather than
 * re-deriving them at each call site (Pitfall 5 — single source of truth
 * for the scale).
 */
export const BORDERLINE_SEVERITY_THRESHOLDS = [
    30, 50, 60,
];
/**
 * Default replications per borderline judgment when the caller's
 * `RepoConfig.execution.borderlineReplications` is unset (locked answer
 * #4 in plan 03-04). Three replications + the original score = four
 * scores per consistency record, which is the minimum that produces a
 * non-degenerate stdDev / median split.
 */
export const DEFAULT_BORDERLINE_REPLICATIONS = 3;
/**
 * Factory for the borderline-consensus runner. Returns a function that
 * applies the severity-threshold and replication defaults from
 * composition-root, leaving the live grader entry point (the `regrade`
 * callback) and the candidate `judgments` array as runtime inputs.
 *
 * The pipeline-side caller (currently `pipeline/calculate-scores.ts`'s
 * post-extraction junction) supplies the `regrade` callback that maps a
 * `GraderJudgment` to a fresh score via the response/rubric text from
 * the original Promptfoo result. See the runner's header for the
 * rationale on injecting the regrader rather than calling `gradeOnce`
 * inline (Pitfall 6 — preserve the runner's purity wrt the existing
 * grader-comparison split).
 */
export function createBorderlineConsensusRunner(opts) {
    const replications = opts.borderlineReplications ?? DEFAULT_BORDERLINE_REPLICATIONS;
    return (args) => runBorderlineConsensus({
        judgments: args.judgments,
        ...(args.logger ? { logger: args.logger } : {}),
        regrade: args.regrade,
        replications,
        thresholds: BORDERLINE_SEVERITY_THRESHOLDS,
    });
}
// ---------------------------------------------------------------------------
// Diagnosis runner wiring (Plan 05-05 / D0048)
// ---------------------------------------------------------------------------
/**
 * Returns the full 8-card `CardRegistry` backed by `DIAGNOSIS_CARD_GENERATORS`
 * from `@sanity/ailf-core`. Exposed as a function (not a module-level const)
 * so the composition root remains the single-seam factory and tests can assert
 * the call site (AI-SPEC §3 Pitfall 1 — no module-scope mutables).
 */
export function buildDiagnosisRegistry() {
    return DIAGNOSIS_CARD_GENERATORS;
}
/**
 * Default local artifacts root (mirrors `createArtifactWriter` default above).
 */
const DIAGNOSIS_LOCAL_ARTIFACTS_DIR = ".ailf/results/captures";
/**
 * Load all per-entry attribution objects for a given `runId` from the local
 * filesystem at `{artifactsDir}/runs/{runId}/attribution/*.json`.
 *
 * Reads every `.json` file in the run's `attribution/` directory, JSON-parses
 * it, and Zod-validates each entry through `JudgmentAttributionSchema` (Phase-4
 * canonical schema — D0045). Malformed entries are skipped with a warning.
 *
 * Returns an empty array when the directory does not exist (expected on runs
 * without Phase-4 attribution data — the runner treats this as Landmine-11
 * "no data" and both attribution cards return `status: "missing"`).
 */
async function loadAttributionsFromLocalFs(runId, artifactsDir, logger) {
    const attrDir = path.join(artifactsDir, "runs", runId, "attribution");
    let entries;
    try {
        entries = (await fs.readdir(attrDir)).filter((f) => f.endsWith(".json"));
    }
    catch {
        // Directory missing — no attribution data for this run.
        return [];
    }
    const results = [];
    for (const filename of entries) {
        const filePath = path.join(attrDir, filename);
        let raw;
        try {
            const bytes = await fs.readFile(filePath, "utf8");
            raw = JSON.parse(bytes);
        }
        catch (err) {
            logger?.warn("loadAttributions: failed to read/parse attribution file", {
                file: filePath,
                error: err instanceof Error ? err.message : String(err),
            });
            continue;
        }
        const parsed = JudgmentAttributionSchema.safeParse(raw);
        if (!parsed.success) {
            logger?.warn("loadAttributions: attribution file failed schema validation", {
                file: filePath,
                errors: parsed.error.flatten(),
            });
            continue;
        }
        results.push(parsed.data);
    }
    return results;
}
/**
 * Build a fully-wired `DiagnosisRunner` from an `AppContext`.
 *
 * Wires the full 8-card registry, `loadAttributions` bound to the local
 * filesystem (Phase-4 per-entry attribution objects at
 * `{artifactsDir}/runs/{runId}/attribution/*.json`), and the real
 * `diagnosisWriter` that emits the Diagnosis through a post-hoc-flagged
 * artifact writer AND patches the report doc's
 * `summary.artifactManifest.diagnosis` slot. Two steps because `ailf interpret`
 * runs after the report doc has already been published — the pipeline path's
 * publish-report-step.ts:187 lifts the in-memory run manifest into the doc at
 * end-of-run, but that step never fires for a deferred command.
 *
 * The post-hoc writer is built with `writerSource: "post-hoc"` so the D0050
 * guard accepts the diagnosis descriptor (`writePolicy: "post-hoc"`). Without
 * this, every emit would be rejected at runtime.
 *
 * `diagnosisReader` is still a no-op shim: the Studio data path uses the
 * artifact-manifest entry (populated by the writer + patch) plus a signed-URL
 * fetch, so reader-side cache wiring is deferred to a follow-up W-item.
 * Without the reader, `ailf interpret --refresh` cache hits are not yet served
 * from GCS — they recompute.
 *
 * Plan-06 API/CLI consumers import this function from the composition root
 * and pass `ctx` from `createAppContext(config)`.
 */
export function getDiagnosisRunner(ctx, opts) {
    const artifactsDir = ctx.config.artifactsDir ?? DIAGNOSIS_LOCAL_ARTIFACTS_DIR;
    // Post-hoc artifact writer — built with the same fanout/remote/local layering
    // as the pipeline writer but flagged so the D0050 guard accepts post-hoc
    // descriptors. Construction is per-runner so the AccumulatingArtifactWriter's
    // internal manifest doesn't carry state between unrelated interpret runs.
    // Tests inject their own writer via opts.artifactWriter; the production
    // CLI / pipeline callers never pass it.
    const postHocArtifactWriter = opts?.artifactWriter ??
        createArtifactWriter(ctx.config, ctx.logger, undefined, "post-hoc");
    // No-op reader — see JSDoc above. The Studio data path is manifest-driven,
    // not reader-driven, so the writer + patch alone unblock Phase 7.
    const diagnosisReader = async (_path) => null;
    // Real writer — two-step persistence:
    //  1. Emit the diagnosis payload through the post-hoc writer; the descriptor's
    //     `objectPath: diagnosisPathBuilder()` derives the storage path from
    //     `{runId, reportId, compoundVersion}`.
    //  2. Patch the published report doc's `summary.artifactManifest.diagnosis`
    //     slot with the returned ArtifactRef, so Studio's slim-shape GROQ
    //     projection surfaces the entry. (The pipeline path runs this lift via
    //     publish-report-step.ts; that step never fires for a deferred command,
    //     hence the explicit patch here.)
    //
    // Errors are caught and logged rather than thrown — the diagnosis runner
    // separates "compute" from "persist". Failed persistence should not panic
    // the runner; the computed cards still surface to API/CLI callers in-memory.
    // ReportStore.patchArtifactManifest is itself non-fatal on Sanity failure,
    // so it does not need its own try/catch.
    const diagnosisWriter = async (_descriptorPath, diagnosis) => {
        let ref;
        try {
            // Anchor the diagnosis to the REPORT's run, not the post-hoc CLI's
            // session run. `ctx.runId` is freshly generated per interpret
            // invocation; the report doc's `provenance.runId` is what Studio
            // and the signing endpoint look up. Using `assoc(ctx, ...)` would
            // bind `run` to ctx.runId — the path would be writeable but
            // unreachable from the Studio side.
            ref = await postHocArtifactWriter.emit("diagnosis", { run: diagnosis.runId, report: diagnosis.reportId }, diagnosis);
        }
        catch (error) {
            ctx.logger.warn("diagnosis-emit-failed", {
                reportId: diagnosis.reportId,
                error: error instanceof Error ? error.message : String(error),
            });
            return;
        }
        if (!ref)
            return;
        if (!ctx.reportStore) {
            ctx.logger.warn("diagnosis-emit: no reportStore on context", {
                reportId: diagnosis.reportId,
            });
            return;
        }
        await ctx.reportStore.patchArtifactManifest(diagnosis.reportId, "diagnosis", ref);
    };
    return createDiagnosisRunner({
        llm: ctx.llmClient,
        model: modelId("anthropic:claude-opus-4-6"),
        logger: ctx.logger,
        progress: ctx.progress,
        registry: buildDiagnosisRegistry(),
        diagnosisReader,
        diagnosisWriter,
        loadAttributions: (runId) => loadAttributionsFromLocalFs(runId, artifactsDir, ctx.logger),
    });
}
