UNPKG

@withpica/mcp-server

Version:

MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog

1,368 lines • 81.9 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
import { formatAsText, formatSuccess } from "@withpica/mcp-utils";
import { withBillingGate } from "@withpica/mcp-utils";
import { computeProposalListGapHints } from "./gap-hints.js";
export class EnrichmentTools {
    pica;
    constructor(pica) {
        this.pica = pica;
    }
    getTools() {
        return [
            // ============================================================
            // ADR-179: outcome-shaped resolver (replaces 5 per-source tools)
            // ============================================================
            {
                definition: {
                    name: "pica_resolve_recording",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Resolve a recording against every eligible external source in one call — Spotify, YouTube, MusicBrainz, Discogs. " +
                        "First recording-side enrichment tool in the MCP. Returns a structured receipt: " +
                        "`applied[]` (identifier-matched Tier A writes), `proposals[]` (Tier B fuzzy matches — reserved for future cascade rules), " +
                        "`errors[]` (per-source failures with retry hints), " +
                        "`overwrote[]` (a higher-authority source replaced a lower-ranked value — old/new source shown), and " +
                        "`skipped_lower_authority[]` (a source was NOT allowed to write because the existing value out-ranked it, equalled it, or was a human/unprovenanced value). " +
                        "Partial failure is not an error — isError is set only when EVERY eligible source errored. " +
                        "Writes are governed by a field-level authority gate: higher-authority sources win, human-entered and pre-provenance values are locked. " +
                        "Spotify adapter fetches Spotify track metadata (triggered by the canonical spotify_track_id on the recording) " +
                        "and fills preview_url, duration_ms, isrc, and the URL set. " +
                        "YouTube adapter fetches video metadata (triggered by youtube_video_id) and classifies version_type when it is empty. " +
                        "MusicBrainz adapter fills musicbrainz_recording_id + duration_ms from an ISRC lookup; Discogs is release-grain only. " +
                        "Prefer pica_resolve_work when the target is the song rather than one recorded take. " +
                        "→ then: pica_recordings_inspect (see what changed)",
                    workflows: ["enrichment-resolve-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            recording_id: {
                                type: "string",
                                description: "The recording ID to resolve",
                            },
                            sources: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: ["spotify", "youtube", "musicbrainz", "discogs"],
                                },
                                description: "Optional whitelist of sources to run. Omit to fan out to every eligible source.",
                            },
                            include_fuzzy: {
                                type: "boolean",
                                description: "Default true. Set false to skip Tier B fuzzy-match rules.",
                            },
                            force: {
                                type: "boolean",
                                description: "Default false. Force re-enrichment. Safe by construction — the authority gate never clobbers a human / higher-authority / unprovenanced value, so a forced re-run only fills gaps and upgrades lower-ranked automated values.",
                            },
                        },
                        required: ["recording_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Recording resolver", this.resolveRecording.bind(this)),
            },
            {
                definition: {
                    name: "pica_resolve_release",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Resolve a release against Spotify (v1: Spotify-only; `sources[]` reserved for future sources). " +
                        "Writes release-grain fields — UPC, release date, artwork, title, label — where Spotify is authoritative. " +
                        "Returns a structured receipt: " +
                        "`applied[]` (fields written), " +
                        "`errors[]` (per-source failures with retry hints), " +
                        "`overwrote[]` (a higher-authority source replaced a lower-ranked value — old/new source shown), and " +
                        "`skipped_lower_authority[]` (a source was NOT allowed to write because the existing value out-ranked it, equalled it, or was a human/unprovenanced value). " +
                        "Partial failure is not an error — isError is set only when EVERY eligible source errored. " +
                        "Writes are governed by a field-level authority gate: higher-authority sources win, human-entered and pre-provenance values are locked. " +
                        "A release resolve ALWAYS re-checks Spotify and gap-fills empty fields via the authority gate; `force` is accepted for parity but has no distinct effect here. " +
                        "For one track rather than the whole release, use pica_resolve_recording. " +
                        "→ then: pica_releases_inspect (see what changed)",
                    workflows: ["enrichment-resolve-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            release_id: {
                                type: "string",
                                description: "The release ID to resolve",
                            },
                            sources: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: ["spotify"],
                                },
                                description: "Optional whitelist of sources to run. v1 supports 'spotify' only. Omit to run every eligible source.",
                            },
                            force: {
                                type: "boolean",
                                description: "Accepted for parity with other resolve tools; has no distinct effect for releases (no idempotence stamps exist for releases — every call re-checks Spotify and gap-fills via the authority gate).",
                            },
                        },
                        required: ["release_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Release resolver", this.resolveRelease.bind(this)),
            },
            {
                definition: {
                    name: "pica_resolve_person",
                    scope: "write:people",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Use when the user asks: 'find more information about this person', 'enrich this person', 'look up this artist's identifiers', 'pull their details from external sources'. " +
                        "Resolve a person against every eligible identity-graph source in one call — ISNI, MusicBrainz, Wikidata (downstream of both). " +
                        "Returns a structured receipt: " +
                        "`applied[]` (identifier-matched Tier A writes — wikidata_id, awards, biography), " +
                        "`proposals[]` (fuzzy Tier B matches filed to the review queue), " +
                        "`errors[]` (per-source failures with retry hints). Partial failure is not an error — isError is set only when EVERY " +
                        "eligible source errored. " +
                        "Sources list is optional; omit to fan out to every source whose trigger identifiers are present on the person. " +
                        "Narrow to a single source with e.g. `sources: ['isni']` to call just one. " +
                        "Prefer pica_resolve_work when enriching a song rather than a person. " +
                        "→ then: pica_enrichment_proposals_list (review fuzzy matches), pica_people_inspect (see what changed), pica_enrichment_proposal_apply (accept a proposal)",
                    workflows: ["enrichment-resolve-required"],
                    // Flipped specialist → creator-entry 2026-07-08: the founder asked
                    // for exactly this capability in natural language and could not
                    // find it — the telemetry ADR-226 Phase 4 was waiting for.
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            person_id: {
                                type: "string",
                                description: "The person ID to resolve",
                            },
                            sources: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: ["isni", "musicbrainz"],
                                },
                                description: "Optional whitelist of sources to run. Omit to fan out to every eligible source.",
                            },
                            include_fuzzy: {
                                type: "boolean",
                                description: "Default true. Set false to skip Tier B fuzzy-match rules (no review proposals queued).",
                            },
                        },
                        required: ["person_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Person resolver", this.resolvePerson.bind(this)),
            },
            {
                definition: {
                    name: "pica_resolve_work",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Use when the user asks: 'find more information about this song', 'enrich this song', 'look this song up in the MLC', 'pull this track's metadata from external sources'. " +
                        "Resolve a work against every eligible external source in one call — MLC, Spotify, YouTube, MusicBrainz, Discogs. " +
                        "Returns a structured receipt: " +
                        "`applied[]` (identifier-matched Tier A writes), `proposals[]` (fuzzy Tier B matches filed to the review queue), " +
                        "`errors[]` (per-source failures with retry hints), " +
                        "`overwrote[]` (a higher-authority source replaced a lower-ranked value — old/new source shown), and " +
                        "`skipped_lower_authority[]` (a source was NOT allowed to write because the existing value out-ranked it, equalled it, or was a human/unprovenanced value). " +
                        "Partial failure is not an error — isError is set only when EVERY eligible source errored. " +
                        "Writes are governed by a field-level authority gate: higher-authority sources win, human-entered and pre-provenance values are locked. " +
                        "Sources list is optional; omit to fan out to every source whose trigger identifiers are present on the work. " +
                        "Narrow to a single source with e.g. `sources: ['spotify']` to call just one. " +
                        "Prefer pica_resolve_recording when the target is a specific recorded take rather than the song. " +
                        "Set `force: true` to re-run past the idempotence stamps (non-destructive — the gate protects human / higher-authority / unprovenanced values). " +
                        "→ then: pica_enrichment_proposals_list (review fuzzy matches), pica_works_inspect (see what changed), pica_enrichment_proposal_apply (accept a proposal)",
                    workflows: ["enrichment-resolve-required"],
                    // Flipped specialist → creator-entry 2026-07-08 (same telemetry
                    // trigger as pica_resolve_person above).
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            work_id: {
                                type: "string",
                                description: "The work ID to resolve",
                            },
                            sources: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: ["mlc", "spotify", "youtube", "musicbrainz", "discogs"],
                                },
                                description: "Optional whitelist of sources to run. Omit to fan out to every eligible source.",
                            },
                            include_fuzzy: {
                                type: "boolean",
                                description: "Default true. Set false to skip Tier B fuzzy-match rules (no review proposals queued).",
                            },
                            force: {
                                type: "boolean",
                                description: "Default false. Force re-enrichment — clears the per-source idempotence stamps so rules re-fire. Safe by construction: the authority gate never clobbers a human / higher-authority / unprovenanced value.",
                            },
                        },
                        required: ["work_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Work resolver", this.resolveWork.bind(this)),
            },
            {
                definition: {
                    name: "pica_enrichment_candidates",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Find works and people that are candidates for enrichment (missing external identifiers or metadata). " +
                        "Prefer pica_enrichment_proposals_list when reviewing findings already queued for review. " +
                        "→ then: pica_resolve_work (fan out across every eligible source), pica_resolve_person (same for people)",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {},
                    },
                },
                executor: this.enrichmentCandidates.bind(this),
            },
            {
                definition: {
                    name: "pica_run_work_cascade",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Re-evaluate a work against every cascade rule and fire those whose preconditions are satisfied. " +
                        "Use after a tool or direct edit that bypassed the cascade, or to retry enrichment after adding a missing identifier. " +
                        "Idempotent: rules whose output is already present skip. Runs fire-and-forget; check results via pica_works_inspect. " +
                        "→ then: pica_works_inspect (see what changed), pica_cascade_health (spot-check failures)",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            work_id: {
                                type: "string",
                                description: "The work ID to re-evaluate",
                            },
                        },
                        required: ["work_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Work cascade run", this.runWorkCascade.bind(this)),
            },
            {
                definition: {
                    name: "pica_run_person_cascade",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Re-evaluate a person against every cascade rule. Fires ipi_to_mlc_works (pulls MLC writer/publisher splits), " +
                        "mbid/isni→wikidata crosswalks, wikidata_to_awards, wikidata_to_biography, and the Google contact suggestion. " +
                        "Idempotent. Fire-and-forget. → then: pica_people_inspect (see what the cascade wrote)",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            person_id: {
                                type: "string",
                                description: "The person ID to re-evaluate",
                            },
                        },
                        required: ["person_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Person cascade run", this.runPersonCascade.bind(this)),
            },
            {
                definition: {
                    name: "pica_cascade_health",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Snapshot of the in-process enrichment cascade counters. " +
                        "Returns per-rule failure counts bucketed by reason (circuit_open, quota_exhausted, empty_result, timeout, auth_error, rate_limit, schema_error, network_error, unknown) " +
                        "plus outcome totals (wrote / no_match / already_enriched / error / proposed / suppressed). " +
                        "Process-global and not persistent across serverless cold starts — treat as a spot-check, not a historical ledger. " +
                        "Use when cascade enrichment looks unhealthy or after shipping new rules.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {},
                    },
                },
                executor: this.cascadeHealth.bind(this),
            },
            // ============================================================
            // ADR-163: Tier B enrichment proposal review tools
            // ============================================================
            {
                definition: {
                    name: "pica_enrichment_proposals_list",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "List pending enrichment proposals awaiting review (Tier B fuzzy matches). " +
                        "These are matches from fuzzy-match cascade rules (MLC writer splits, " +
                        "Spotify artist+title, YouTube recording classification) that the cascade " +
                        "holds for human review instead of writing through automatically. " +
                        "Also carries pica's own QUESTIONS about uploaded audio (entity_type 'audio_file', " +
                        "source 'filename_reading'): when a file's name does not say whether it is the master, " +
                        "a version, an instrumental, a stem or a demo, pica suggests a file_type. The owner answers: " +
                        "pica_enrichment_proposal_apply with their chosen file_type (master, version, instrumental, " +
                        "demo or stem — the suggestion is only a default), or reject to keep the current type. " +
                        "Ask the user about each file, one at a time; do not guess for them and never bulk-apply these. " +
                        "Only pending proposals are returned — applied, rejected, and expired " +
                        "rows are invisible. Findings the catalogue has since ANSWERED by another " +
                        "route are excluded too: they stay `pending` in the table because nothing " +
                        "re-asks the question, but applying one refuses. `excluded_stale` reports " +
                        "how many and why, so a short page is distinguishable from a filtered one. " +
                        "Default order: newest first, limit 20 (max 100). " +
                        "Pass review_shape=true to get the same interpreted, grouped view the user sees at " +
                        "/inspect/found (read through the shared found-source registry, so it stays in step " +
                        "with the page). " +
                        "→ then: pica_enrichment_proposal_apply (accept a proposal), " +
                        "pica_enrichment_proposal_reject (dismiss a proposal), " +
                        "pica_enrichment_proposals_bulk_apply (accept a reviewed batch)",
                    workflows: ["enrichment-resolve-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            entity_type: {
                                type: "string",
                                enum: ["work", "person", "recording", "audio_file"],
                                description: "Filter to proposals anchored on a specific entity type. " +
                                    "'audio_file' = pica's own questions about uploaded audio whose filename did not say what it is (source 'filename_reading').",
                            },
                            entity_id: {
                                type: "string",
                                description: "Filter to proposals anchored on a specific entity (pairs with entity_type)",
                            },
                            rule_id: {
                                type: "string",
                                description: "Filter by cascade rule id (e.g. 'ipi_to_mlc_works', 'artist_title_to_spotify', 'artist_title_to_youtube')",
                            },
                            source: {
                                type: "string",
                                description: "Filter by source ('mlc', 'spotify', 'youtube', 'wikidata', etc.)",
                            },
                            limit: {
                                type: "integer",
                                description: "Max results, 1..100 (default 20)",
                            },
                            offset: {
                                type: "integer",
                                description: "Pagination offset (default 0)",
                            },
                            min_confidence: {
                                type: "number",
                                description: "Only return proposals with confidence_score >= this value (0–100)",
                            },
                            max_confidence: {
                                type: "number",
                                description: "Only return proposals with confidence_score <= this value (0–100). " +
                                    "Combine with pica_enrichment_proposals_bulk_reject to clear low-confidence noise.",
                            },
                            created_after: {
                                type: "string",
                                description: "Only return proposals created at or after this ISO 8601 timestamp. " +
                                    "Use to scope results to the batch you just triggered.",
                            },
                            review_shape: {
                                type: "boolean",
                                description: "Also return `groups` — the SAME interpreted view the user sees at " +
                                    "/inspect/found: grouped by source and confidence band, with before/after " +
                                    "field changes, plain-language evidence lines, external links, and per-item " +
                                    "`actionable` / `bulkEligible`. Prefer this when REVIEWING findings with a " +
                                    "user, so you describe what they would see rather than raw columns. " +
                                    "⚠️ `groups` is the WHOLE queue and is NOT narrowed by the filters above " +
                                    "(those apply to `proposals` only). Costs extra lookups, so leave it off " +
                                    "when you just need ids or a count.",
                            },
                        },
                    },
                },
                executor: this.listEnrichmentProposals.bind(this),
            },
            {
                definition: {
                    name: "pica_enrichment_proposal_apply",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Apply a pending Tier B enrichment proposal. " +
                        "For update proposals (e.g. MLC writer splits on a work), drift " +
                        "detection runs first: if the entity has changed since the proposal " +
                        "was created, the response is { status: 'drift_detected', conflicts } " +
                        "and nothing is written. On drift, show the conflicts to the user " +
                        "before retrying with force=true. For create proposals (e.g. creating " +
                        "a YouTube recording on a work), a uniqueness check runs instead and " +
                        "cannot be forced. " +
                        "Prefer pica_enrichment_proposals_bulk_apply when accepting a reviewed batch; this one takes one id. " +
                        "FILE-TYPE QUESTIONS (entity_type 'audio_file', source 'filename_reading'): pass the type the USER " +
                        "chose as file_type — one of master, version, instrumental, demo, stem — and, for a stem, an optional " +
                        "stem_label (e.g. 'drums', max 60 characters). Omit file_type to accept pica's suggestion. Ask the user " +
                        "about each file and use their answer; never choose for them and never apply these in bulk. " +
                        "An invalid file_type, a stem_label on a non-stem, or file_type on any other kind of proposal is " +
                        "refused with INVALID_FILE_TYPE_ANSWER and nothing is written. " +
                        "→ then: pica_works_inspect (see the resolved work) " +
                        "or pica_recordings_inspect (see the resolved recording)",
                    workflows: ["enrichment-resolve-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            proposal_id: {
                                type: "string",
                                description: "The proposal id to apply",
                            },
                            force: {
                                type: "boolean",
                                description: "Bypass drift detection (update proposals only). Only set after the user has reviewed the conflicts from a prior drift_detected response.",
                            },
                            resolution_note: {
                                type: "string",
                                description: "Optional note recorded on the resolved row (free-form text)",
                            },
                            file_type: {
                                type: "string",
                                enum: ["master", "version", "instrumental", "demo", "stem"],
                                description: "File-type questions only (entity_type 'audio_file'): the type the USER chose for this file. " +
                                    "Omit to accept pica's suggestion. Ask the user — never pick for them.",
                            },
                            stem_label: {
                                type: "string",
                                maxLength: 60,
                                description: "With file_type 'stem' only: a short name for the stem (e.g. 'drums', 'lead vocal'), as the user gave it.",
                            },
                        },
                        required: ["proposal_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Enrichment proposal apply", this.applyEnrichmentProposal.bind(this)),
            },
            {
                definition: {
                    name: "pica_enrichment_proposal_reject",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "draft",
                    description: "Reject a pending Tier B enrichment proposal. " +
                        "Content-hash suppression permanently blocks re-proposal of the " +
                        "same exact content — the cascade will only re-propose if the " +
                        "source data genuinely changes (different MLC record, different " +
                        "Spotify match). No cooldown, no timer. Use when the proposal is " +
                        "wrong (homonym collision, stale source data, etc.) and you want " +
                        "the cascade to stop suggesting it. " +
                        "Prefer pica_enrichment_proposals_bulk_reject when clearing many at once; this one takes one id.",
                    workflows: ["enrichment-resolve-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            proposal_id: {
                                type: "string",
                                description: "The proposal id to reject",
                            },
                            resolution_note: {
                                type: "string",
                                description: "Optional reason recorded on the rejected row (e.g. 'wrong writer — homonym')",
                            },
                        },
                        required: ["proposal_id"],
                    },
                },
                executor: withBillingGate(this.pica, "Enrichment proposal reject", this.rejectEnrichmentProposal.bind(this)),
            },
            // ============================================================
            // ADR-264 C3: Bulk-reject enrichment proposals
            // ============================================================
            {
                definition: {
                    name: "pica_enrichment_proposals_bulk_reject",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "draft",
                    description: "Reject multiple pending enrichment proposals in one call — by explicit ids OR a filter " +
                        "(rule_id / source / max_confidence). Caps at 100 per call. Same effect per proposal as " +
                        "pica_enrichment_proposal_reject (permanent content-hash suppression). Use after reviewing " +
                        "pica_enrichment_proposals_list with min/max_confidence filters to clear stale low-confidence noise.",
                    workflows: ["enrichment-resolve-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            proposal_ids: {
                                type: "array",
                                items: { type: "string" },
                                description: "Explicit list of proposal ids to reject (max 100). Mutually exclusive with filter.",
                            },
                            filter: {
                                type: "object",
                                description: "Filter-based selection — rejects all matching pending proposals (max 100). " +
                                    "Mutually exclusive with proposal_ids.",
                                properties: {
                                    rule_id: {
                                        type: "string",
                                        description: "Only reject proposals from this cascade rule",
                                    },
                                    source: {
                                        type: "string",
                                        description: "Only reject proposals from this source ('mlc', 'spotify', 'youtube', etc.)",
                                    },
                                    max_confidence: {
                                        type: "number",
                                        description: "Only reject proposals with confidence_score <= this value (0–100)",
                                    },
                                },
                            },
                            resolution_note: {
                                type: "string",
                                description: "Optional reason recorded on all rejected rows (e.g. 'batch clear — source data stale')",
                            },
                            dry_run: {
                                type: "boolean",
                                description: "When true, returns { dry_run: true, would_affect, count } without rejecting anything. Use to preview the batch before committing.",
                            },
                        },
                    },
                    previewMode: "dry_run",
                },
                executor: withBillingGate(this.pica, "Enrichment proposals bulk reject", this.bulkRejectEnrichmentProposals.bind(this)),
            },
            {
                definition: {
                    name: "pica_enrichment_proposals_bulk_apply",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "draft",
                    description: "Apply multiple pending enrichment proposals in one call, by explicit ids. Caps at 100 " +
                        "per call. Same effect per proposal as pica_enrichment_proposal_apply. Use after " +
                        "reviewing pica_enrichment_proposals_list — typically filtered with min_confidence — to " +
                        "accept a reviewed batch without one round trip per proposal. " +
                        "⚠️ PARTIAL SUCCESS IS NORMAL: each id is applied independently, so a non-empty `failed` " +
                        "beside a non-zero `applied` is the expected shape. Report both to the user; never call a " +
                        "partial a success. Ids that are no longer pending arrive in `failed` with their reason. " +
                        "Never include pica's file-type questions (entity_type 'audio_file') — each is the user's answer " +
                        "about one file; use pica_enrichment_proposal_apply with their file_type instead. " +
                        "→ then: pica_dashboard_briefing (surface the delta)",
                    workflows: ["enrichment-resolve-required"],
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            proposal_ids: {
                                type: "array",
                                items: { type: "string" },
                                description: "Explicit list of proposal ids to apply (max 100). Required — there is deliberately " +
                                    "no filter form, because a mis-filtered bulk REJECT costs a re-proposal while a " +
                                    "mis-filtered bulk APPLY writes to the catalogue.",
                            },
                            dry_run: {
                                type: "boolean",
                                description: "When true, returns { dry_run: true, would_affect, not_pending } and writes NOTHING. " +
                                    "would_affect is an UPPER BOUND: an id pending now can still be refused at write time " +
                                    "if the entity has drifted since the finding was made. Preview before committing a batch.",
                            },
                        },
                        required: ["proposal_ids"],
                    },
                    previewMode: "dry_run",
                },
                executor: withBillingGate(this.pica, "Enrichment proposals bulk apply", this.bulkApplyEnrichmentProposals.bind(this)),
            },
            // ============================================================
            // ADR-178: Agent-proposed enrichment from open-web research
            // ============================================================
            {
                definition: {
                    name: "pica_enrichment_propose",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "File an enrichment proposal sourced from open-web research. " +
                        "Use when you've found a fact about a work, recording, or person " +
                        "from an external site (Audiomack, Songdata, MusicBrainz page, " +
                        "label portal, artist bio, etc.) and want PICA to hold it pending " +
                        "review. Every proposed field MUST cite at least one source URL — " +
                        "unsourced claims are rejected. The server hard-codes source and " +
                        "rule_id to 'agent_research'; you do not set them. " +
                        "IN-SESSION APPROVAL IS THE CANONICAL FLOW: if the user is in " +
                        "this chat, ask for their approval directly, then call " +
                        "pica_enrichment_proposal_apply after their confirmation. Do not " +
                        "wait for the dashboard. The proposal row is the receipt, not " +
                        "the workflow. " +
                        "→ then: pica_enrichment_proposal_apply (apply after user approves), " +
                        "pica_enrichment_proposal_reject (dismiss if user disagrees)",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            entity_type: {
                                type: "string",
                                enum: ["work", "person", "recording", "agreement"],
                                description: "For update: what kind of entity this proposal anchors on. For create: what TYPE is being created (recording / agreement / person). Work + publisher creation are deferred.",
                            },
                            entity_id: {
                                type: "string",
                                description: "The entity id in the caller's org. Required for update proposals; omit for create — parent_entity_id carries the attach point instead.",
                            },
                            proposed_fields: {
                                type: "object",
                                description: "The fields to propose writing. Every key here must appear in at least one sources[].fields array — otherwise MISSING_SOURCE is returned. Example: { tempo_bpm: 110, musical_key: 'D minor' }",
                                additionalProperties: true,
                            },
                            sources: {
                                type: "array",
                                description: "Per-field URL citations. Each entry names the fields it sources. One URL can cite multiple fields; one field can be cited by multiple URLs. Non-HTTP URIs (gmail:// gdrive:// notion:// file:// telegram:// user://) are accepted verbatim for sources behind paywalls or in private channels.",
                                items: {
                                    type: "object",
                                    properties: {
                                        url: {
                                            type: "string",
                                            description: "The URL the fact was retrieved from",
                                        },
                                        fields: {
                                            type: "array",
                                            items: { type: "string" },
                                            description: "Which proposed_fields keys this URL sources (non-empty)",
                                        },
                                        accessed_at: {
                                            type: "string",
                                            description: "ISO 8601 timestamp of when you accessed the URL",
                                        },
                                    },
                                    required: ["url", "fields", "accessed_at"],
                                },
                            },
                            confidence: {
                                type: "number",
                                description: "Self-reported confidence 0..1 (optional). Surface cross-source agreement, match uncertainty, or source quality here.",
                            },
                            rationale: {
                                type: "string",
                                description: "Freeform explanation of how the facts were cross-referenced. Shown on the review surface alongside the sources.",
                            },
                            proposal_action: {
                                type: "string",
                                enum: ["update", "create"],
                                description: "Defaults to 'update'. 'create' spawns a new recording / agreement / person on a parent entity; pair with parent_entity_type + parent_entity_id. Review queue is mandatory — no silent auto-apply.",
                            },
                            parent_entity_type: {
                                type: "string",
                                enum: ["work", "recording", "agreement", "person"],
                                description: "For create proposals: the parent entity type. Recording creates MUST set parent_entity_type='work'; agreement creates may optionally link to a work; person creates can omit parent.",
                            },
                            parent_entity_id: {
                                type: "string",
                                description: "For create proposals: the parent entity id. Paired with parent_entity_type.",
                            },
                        },
                        required: ["entity_type", "proposed_fields", "sources"],
                    },
                },
                executor: withBillingGate(this.pica, "Enrichment agent-research propose", this.proposeAgentResearch.bind(this)),
            },
            // ============================================================
            // ADR-289 Wave C2 — undo a wrong MusicBrainz match
            // ============================================================
            {
                definition: {
                    name: "pica_enrichment_clear",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    description: "Undo a MusicBrainz enrichment match on a work — permanently clears the ISWC and/or PRO " +
                        "IDs a prior MusicBrainz match wrote, but ONLY the ones that actually came from " +
                        "MusicBrainz (manually-entered values, or values from another source, are left alone). " +
                        "This cannot be undone: the cleared fields go back to null, not to whatever they held " +
                        "before the match. Use when a MusicBrainz match was wrong (homonym, wrong recording of " +
                        "the same title). No external calls are made — this only edits the work's own row. " +
                        "For a proposal not yet applied, use pica_enrichment_proposal_reject. " +
                        "→ then: pica_resolve_work (re-run enrichment), pica_works_inspect (confirm the fields cleared)",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Specialist context; not yet covered by the Creator Question Atlas — pending telemetry-driven addition per ADR-226 Phase 4 substrate writes.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            work_id: {
                                type: "string",
                                description: "The work to clear MusicBrainz enrichment from",
                            },
                            clear_iswc: {
                                type: "boolean",
                                description: "Clear the ISWC if it came from MusicBrainz. Default true.",
                            },
                            clear_pro_ids: {
                                type: "boolean",
                                description: "Clear PRO IDs that came from MusicBrainz — PRO IDs sourced elsewhere are kept. Default true.",
                            },
                            reason: {
                                type: "string",
                                description: "Why this is being cleared (e.g. 'wrong MusicBrainz match — homonym'). Recorded on the result, not persisted on the work.",
                            },
                        },
                        required: ["work_id", "reason"],
                    },
                },
                executor: this.clearEnrichment.bind(this),
            },
        ];
    }
    async enrichmentCandidates(_args) {
        const result = await this.pica.enrichment.getCandidates();
        return formatAsText(result);
    }
    /**
     * ADR-179 `pica_resolve_work` executor — fan a work out across
     * eligible enrichment sources and return the structured receipt.
     *
     * `isError` is set only when every source that ran errored. Empty
     * applied[] + empty proposals[] + empty errors[] is a valid result
     * (e.g. work has no trigger identifiers) and surfaces as a
     * non-error response with a human-readable note.
     */
    async resolveWork(args) {
        const workId = args.work_id;
        const sources = args.sources;
        const includeFuzzy = typeof args.include_fuzzy === "boolean" ? args.include_fuzzy : undefined;
        const force = args.force === true ? true : undefined;
        const result = await this.pica.enrichment.resolveWork(workId, {
            sources,
            includeFuzzy,
            force,
        });
        // isError semantics per ADR-179 Decision 1: partial failure is not
        // an error. Only when every source that actually ran errored do we
        // flip the flag.
        const ranCount = result.sources_run.length;
        const errorCount = result.errors.length;
        const allErrored = ranCount > 0 && errorCount >= ranCount;
        const summary = `resolve work ${workId} — ` +
            `${result.applied.length} applied, ` +
            `${result.proposals.length} proposals, ` +
            `${result.overwrote?.length ?? 0} overwrote, ` +
            `${result.skipped_lower_authority?.length ?? 0} kept (authority), ` +
            `${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
        return {
            content: [{ type: "text", text: summary }],
            structuredContent: result,
            isError: allErrored,
        };
    }
    /**
     * ADR-179 Phase 3 `pica_resolve_recording` executor — fan a recording
     * out across eligible platform sources and return the structured
     * receipt. Same isError semantics as resolveWork / resolvePerson.
     */
    async resolveRecording(args) {
        const recordingId = args.recording_id;
        // ops_issue f577d83c: the agent passed an ISRC instead of a recording_id, so
        // recording_id was undefined and the SDK built
        // /admin/recordings/undefined/resolve → "invalid input syntax for type uuid:
        // undefined" → 500. resolve_recording is keyed by the recording UUID and
        // fills isrc FROM Spotify; it does NOT resolve BY isrc. Reject a
        // missing/blank id with an actionable message instead of a raw uuid 500.
        if (typeof recordingId !== "string" || recordingId.trim().length === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: JSON.stringify({
                            error: "MISSING_REQUIRED",
                            message: "recording_id is required (a recording UUID). pica_resolve_recording resolves a recording by its id and does not look up by ISRC — find the recording first (e.g. pica_recordings_query) and pass its id.",
                        }, null, 2),
                    },
                ],
                isError: true,
            };
        }
        const sources = args.sources;
        const includeFuzzy = typeof args.include_fuzzy === "boolean" ? args.include_fuzzy : undefined;
        const force = args.force === true ? true : undefined;
        const result = await this.pica.enrichment.resolveRecording(recordingId, {
            sources,
            includeFuzzy,
            force,
        });
        const ranCount = result.sources_run.length;
        const errorCount = result.errors.length;
        const allErrored = ranCount > 0 && errorCount >= ranCount;
        const summary = `resolve recording ${recordingId} — ` +
            `${result.applied.length} applied, ` +
            `${result.proposals.length} proposals, ` +
            `${result.overwrote?.length ?? 0} overwrote, ` +
            `${result.skipped_lower_authority?.length ?? 0} kept (authority), ` +
            `${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
        return {
            content: [{ type: "text", text: summary }],
            structuredContent: result,
            isError: allErrored,
        };
    }
    /**
     * `pica_resolve_release` executor — fan a release out against Spotify
     * and return the structured receipt. Same isError semantics as
     * resolveWork / resolveRecording.
     */
    async resolveRelease(args) {
        const releaseId = args.release_id;
        if (typeof releaseId !== "string" || releaseId.trim().length === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: JSON.stringify({
                            error: "MISSING_REQUIRED",
                            message: "release_id is required (a release UUID). pica_resolve_release resolves a release by its id — find the release first (e.g. pica_releases_query) and pass its id.",
                        }, null, 2),
                    },
                ],
                isError: true,
            };
        }
        const sources = args.sources;
        const force = args.force === true ? true : undefined;
        const result = await this.pica.enrichment.resolveRelease(releaseId, {
            sources,
            force,
        });
        const ranCount = result.sources_run.length;
        const errorCount = result.errors.length;
        const allErrored = ranCount > 0 && errorCount >= ranCount;
        const summary = `resolve release ${releaseId} — ` +
            `${result.applied.length} applied, ` +
            `${result.proposals.length} proposals, ` +
            `${result.overwrote?.length ?? 0} overwrote, ` +
            `${result.skipped_lower_authority?.length ?? 0} kept (authority), ` +
            `${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
        return {
            content: [{ type: "text", text: summary }],
            structuredContent: result,
            isError: allErrored,
        };
    }
    /**
     * ADR-179 Phase 2 `pica_resolve_person` executor — fan a person out
     * across eligible identity-graph sources and return the structured
     * receipt. Mirrors `resolveWork`'s isError semantics: partial failure
     * is a non-error; isError only flips when every source that actually
     * ran errored.
     */
    async resolvePerson(args) {
        const personId = args.person_id;
        const sources = args.sources;
        const includeFuzzy = typeof args.include_fuzzy === "boolean" ? args.include_fuzzy : undefined;
        const result = await this.pica.enrichment.resolvePerson(personId, {
            sources,
            includeFuzzy,
        });
        const ranCount = result.sources_run.length;
        const errorCount = result.errors.length;
        const allErrored = ranCount > 0 && errorCount >= ranCount;
        const summary = `resolve person ${personId} — ` +
            `${result.applied.length} applied, ` +
            `${result.proposals.length} proposals, ` +
            `${errorCount} errors (ran: ${result.sources_run.join(", ") || "none"})`;
        return {
            content: [{ type: "text", text: summary }],
            structuredContent: result,
            isError: allErrored,
        };
    }
    async runWorkCascade(args) {
        const result = await this.pica.enrichment.runWorkCascade(args.work_id);
        return formatSuccess(`Cascade re-evaluation queued for work ${args.work_id}`, result);
    }
    async runPersonCascade(args) {
        const result = await this.pica.enrichment.runPersonCascade(args.person_id);
        return formatSuccess(`Cascade re-evaluation queued for person ${args.person_id}`, result);
    }
    async cascadeHealth(_args) {
        const result = await this.pica.enrichment.getCascadeHealth();
        return formatAsText(result);
    }
    // ============================================================
    // ADR-163 Tier B proposal executors
    // ============================================================
    async listEnrichmentProposals(args) {
        const result = await this.pica.enrichment.listEnrichmentProposals({
            entity_type: args.entity_type,
            entity_id: args.entity_id,
            rule_id: args.rule_id,
            source: args.source,
            limit: args.limit,
            offset: args.offset,
            // ADR-264 C1: confidence + recency filters (sdk@>=1.37.1)
            min_confidence: args.min_confidence,
            max_confidence: args.max_confidence,
            created_after: args.created_after,
            review_shape: args.review_shape === true,
        });
        // The route returns {success, data: {proposals, count, filters}} and the
        // SDK's request() narrows to `.data` — so this tool receives the OBJECT
        // {proposals, count, filters}, never a bare array. The previous
        // Array.isArray(result) guard therefore rejected EVERY real response as
        // UNEXPECTED_RESPONSE_SHAPE (100% failure since 2026-06-10; ops_issues
        // 18f25b41/20c76911). Unwrap the envelope's proposals array — a bare
        // array is still accepted defensively — and keep the B1 (ADR-264-WS1b)
        // guard for genuinely bad shapes so failures surface instead of
        // becoming a phantom "? pending" count.
        const envelope = result;
        const proposals = Array.isArray(result)
            ? result
            : Array.isArray(envelope?.proposals)
                ? envelope.proposals
                : null;
        if (proposals === null) {
            const errorPayload = {
                error_code: "UNEXPECTED_RESPONSE_SHAPE",
                message: "enrichment proposals list returned an unexpected shape — treat as failure, retry once",
                received_type: typeof result,
                retry_eligible: true,
            };
            return {
                content: [
                    {
                        type: "text",
                        text: JSON.stringify(errorPayload, null, 2),
                    },
                ],
                structuredContent: errorPayload,
                isError: true,
            };
        }
        // ADR-193 Surface 2 — gap hints on empty-list so the agent is
        // reminded it can seed proposals from peer-MCP evidence rather
        // than leaving the user to type.
        const gap_hints = computeProposalListGapHints(proposals);
        /**
         * ⚠️ The interpreted view has to be carried across this seam EXPLICITLY.
         *
         * The unwrap above narrows the route envelope to its `proposals` array,
         * which is correct for every other caller and silently wrong for
         * `review_shape=true`: the route runs `getFoundGroups` (three table
         * lookups plus evidence and writers), returns `groups` alongside the
         * proposals, and this executor used to drop them on the floor. The agent
         * paid the cost of the interpreted read, was told by the tool description
         * it was getting "the SAME interpreted view the user sees at
         * /inspect/found", and received raw columns.
         *
         * Nothing could catch that below this line — the route's unit tests pass
         * (the route is right), the tool's unit tests passed (they never asked for
         * the shape), and TypeScript cannot see a field dropped from an `any`.
         * Only an over-the-wire parity check does: `scenarios/catalog/
         * found-agent-parity.spec.ts` compares both doors on one org in one run.
         *
         * Still opt-in: `groups` is present only when the route sent it, so an
         * unflagged call stays byte-identical to a bare proposals array.
         */
        const groups = Array.isArray(result)
            ? undefined
            : envelope?.groups;
        const payload = groups !== undefined || gap_hints.length > 0
            ? {
                proposals,
                ...(gap_hints.length > 0 ? { gap_hints } : {}),
                ...(groups !== undefined ? { groups } : {}),
            }
            : proposals;
        const groupCount = Array.isArray(groups) ? groups.length : 0;
        const summary = proposals.length === 0
            ? "No pending proposals"
            : `${proposals.length} pending proposal(s)` +
                (groupCount > 0 ? ` in ${groupCount} reviewable group(s)` : "");
        return formatSuccess(summary, payload);
    }
    async applyEnrichmentProposal(args) {
        const proposalId = args.proposal_id;
        const result = await this.pica.enrichment.applyEnrichmentProposal(proposalId, {
            force: args.force === true,
            resolution_note: args.resolution_note,
            // Passed through untouched: the route validates both and refuses a
            // bad answer with INVALID_FILE_TYPE_ANSWER, so a typo reaches the
            // agent by name instead of being dropped here and the suggestion
            // applied in its place.
            ...(args.file_type !== undefined ? { file_type: args.file_type } : {}),
            ...(args.stem_label !== undefined
                ? { stem_label: args.stem_label }
                : {}),
        });
        // Accepting an MLC-writer `ask` puts a real co-writer in the catalogue who
        // is not a PICA user. Say so, once, at the only moment the system knows it.
        //
        // ⚠️ This SENDS NOTHING. It names the tool and hands over the ids; the
        // decision and the sending stay with the user.
        //
        // ⚠️ On the missing address: pica_collaborators_invite needs an email or a
        // PICA handle, and PICA holds neither for most of these people (64 of 83
        // IPI-carrying people are not users; 18 have an address on file, measured
        // 2026-08-18). That is a gap in PICA's table, not in the world — the
        // address is usually in the USER's own contacts or mail, which THIS agent
        // can reach and PICA cannot. Ask the user before looking, and never put an
        // address into PICA that the user did not choose to give.
        const invitable = result.invitableWriter;
        if (!invitable) {
            return formatSuccess(`Enrichment proposal ${proposalId} — apply result`, result);
        }
        return formatSuccess(`Enrichment proposal ${proposalId} — apply result`, {
            ...result,
            next_action: {
                why: `${invitable.name} is now credited on this work and carries IPI ` +
                    `${invitable.ipi}, but has no PICA account. They cannot see or ` +
                    `confirm this credit until someone tells them.`,
                tool: "pica_collaborators_invite",
                arguments: {
                    work_id: invitable.workId,
                    name: invitable.name,
                    credit_type: "writer",
                },
                needs: "an `email` OR a PICA `handle`. PICA has neither on file for this " +
                    "person. If the user has the address in their own contacts or mail, " +
                    "ask them first, then pass it — do not go looking unprompted. If " +
                    "they have neither, say so plainly rather than inventing one.",
                do_not: "Do not call this without the user asking. An invite is contact " +
                    "with a real person who has not heard of PICA.",
            },
        });
    }
    async rejectEnrichmentProposal(args) {
        const proposalId = args.proposal_id;
        const result = await this.pica.enrichment.rejectEnrichmentProposal(proposalId, {
            resolution_note: args.resolution_note,
        });
        return formatSuccess(`Enrichment proposal ${proposalId} rejected`, result);
    }
    async bulkRejectEnrichmentProposals(args) {
        // dry_run preview — returns what would be affected without mutating.
        if (args.dry_run === true) {
            // ids path: the count is known immediately from the provided list.
            if (Array.isArray(args.proposal_ids)) {
                return formatSuccess("Dry run — no proposals rejected", {
                    dry_run: true,
                    would_affect: args.proposal_ids,
                    count: args.proposal_ids.length,
                });
            }
            // filter path: perform a real list call to get the actual matching ids.
            const filter = args.filter;
            const listResult = await this.pica.enrichment.listEnrichmentProposals({
                rule_id: filter?.rule_id,
                source: filter?.source,
                max_confidence: filter?.max_confidence,
                limit: 100,
            });
            // The SDK unwraps the route's {success, data} envelope, so listResult
            // is {proposals, count, filters} — the previous `.data` normalisation
            // never matched anything and the filter dry-run always previewed 0 ids
            // (same 2026-07-11 envelope-unwrap class as the list tool above).
            // Accept a raw array defensively; keep `.data` as a legacy fallback.
            const listEnvelope = listResult;
            const proposals = Array.isArray(listResult)
                ? listResult
                : (listEnvelope.proposals ?? listEnvelope.data ?? []);
            const ids = proposals.map((p) => p.id);
            return formatSuccess("Dry run — no proposals rejected", {
                dry_run: true,
                would_affect: ids,
                filter: filter ?? null,
                count: ids.length,
            });
        }
        // Fix 5: direct typed SDK call — no `as any` cast needed now that the
        // worktree node_modules resolve @withpica/mcp-sdk via the workspace symlink
        // (npm install --workspaces --ignore-scripts + mcp:build:all was run).
        const result = await this.pica.enrichment.bulkRejectProposals({
            proposal_ids: args.proposal_ids,
            filter: args.filter
                ? {
                    rule_id: args.filter.rule_id,
                    source: args.filter.source,
                    max_confidence: args.filter.max_confidence,
                }
                : undefined,
            resolution_note: args.resolution_note,
        });
        const { rejected, skipped } = result;
        const summary = `Bulk-rejected ${rejected} proposal(s)` +
            (skipped?.length > 0
                ? ` (${skipped.length} skipped — not pending or not found)`
                : "");
        return formatSuccess(summary, result);
    }
    /**
     * pica_enrichment_proposals_bulk_apply — the accept side of the
     * /inspect/found queue, in one call.
     *
     * ⚠️ The summary names the FAILURES as well as the successes, deliberately.
     * Applying loops per id across four different update/create paths, so
     * partial success is the normal outcome, and a summary reading only
     * "applied N" would let an agent report a clean success while proposals the
     * user expected to land silently did not. On 2026-08-14 one org applied 53
     * proposals one at a time and 8 of them declined as duplicates — under a
     * bulk call with a success-only summary, that user would have been told 53
     * were applied.
     */
    async bulkApplyEnrichmentProposals(args) {
        const result = await this.pica.enrichment.bulkApplyProposals({
            proposal_ids: args.proposal_ids,
            ...(args.dry_run === true ? { dry_run: true } : {}),
        });
        // A preview has no `applied` — reporting "Bulk-applied undefined" would be
        // the exact silent-drop this tool's contract test exists to catch.
        if (result.dry_run === true) {
            const blocked = result.not_pending?.length ?? 0;
            return formatSuccess(`Dry run — ${result.would_affect ?? 0} proposal(s) WOULD apply, nothing written` +
                (blocked > 0
                    ? `; ${blocked} would not (see not_pending[] for the reason on each)`
                    : ""), result);
        }
        const { applied, failed } = result;
        const failedCount = failed?.length ?? 0;
        // Accepting a batch of asks puts real co-writers in the catalogue who are
        // not PICA users. Bulk is the realistic path — twenty findings get accepted
        // together — so a fact only the single apply surfaced was a fact nobody saw.
        //
        // ⚠️ SENDS NOTHING. Same contract as the single apply: it names the tool and
        // the people; the decision and the sending stay with the user.
        const invitable = result.invitableWriters ?? [];
        const summary = `Bulk-applied ${applied} proposal(s)` +
            (failedCount > 0
                ? ` — ${failedCount} did NOT apply (see failed[] for the reason on each)`
                : "") +
            (invitable.length > 0
                ? `; ${invitable.length} credited writer(s) are not on PICA — see next_action`
                : "");
        if (invitable.length === 0) {
            return formatSuccess(summary, result);
        }
        return formatSuccess(summary, {
            ...result,
            next_action: {
                why: `${invitable.length} writer(s) credited by this batch carry an ` +
                    `industry ID and have no PICA account. They cannot see or confirm ` +
                    `these credits until someone tells them.`,
                tool: "pica_collaborators_invite",
                one_call_per_person: invitable.map((w) => ({
                    work_id: w.workId,
                    name: w.name,
                    credit_type: "writer",
                })),
                needs: "each invite needs an `email` OR a PICA `handle`. PICA has neither " +
                    "for these people. If the user has the addresses in their own " +
                    "contacts or mail, ask them first, then pass them — do not go " +
                    "looking unprompted, and never write an address into PICA the user " +
                    "did not choose to give.",
                do_not: "Do not send these without the user asking, and do not send them as " +
                    "a batch just because they arrived as one. Each is contact with a " +
                    "real person who has not heard of PICA.",
            },
        });
    }
    /**
     * ADR-178: pica_enrichment_propose.
     *
     * Delegates to PicaClient.enrichment.proposeAgentResearch(). Errors
     * surface from the SDK as `ApiError` with the raw JSON response in
     * the message. We extract the service-layer error code (MISSING_SOURCE,
     * DUPLICATE_SUPPRESSED, etc.) from the parsed body and surface it
     * in structuredContent so the agent can reason about the failure
     * without parsing free-text.
     */
    async proposeAgentResearch(args) {
        try {
            const result = await this.pica.enrichment.proposeAgentResearch({
                entity_type: args.entity_type,
                entity_id: args.entity_id,
                proposed_fields: args.proposed_fields,
                sources: args.sources,
                confidence_score: args.confidence ?? null,
                rationale: args.rationale,
                proposal_action: args.proposal_action,
                parent_entity_type: args.parent_entity_type,
                parent_entity_id: args.parent_entity_id,
            });
            return formatSuccess(`Enrichment proposal ${result.proposal_id} filed (pending review)`, result);
        }
        catch (err) {
            const parsed = extractApiErrorBody(err);
            const code = parsed?.code ??
                (err instanceof Error && err.message.includes("409")
                    ? "DUPLICATE_SUPPRESSED"
                    : "REQUEST_FAILED");
            const humanMessage = parsed?.error ?? (err instanceof Error ? err.message : "unknown error");
            return {
                content: [
                    {
                        type: "text",
                        text: `enrichment_propose failed — ${code}: ${humanMessage}`,
                    },
                ],
                structuredContent: {
                    code,
                    error: humanMessage,
                    details: parsed?.details ?? null,
                },
                isError: true,
            };
        }
    }
    /**
     * pica_enrichment_clear executor. Mirrors the retired bespoke assistant's
     * handleClearMusicBrainzEnrichmentConfirmed field-by-field: reads the
     * work's enrichment_metadata to check each field's actual source before
     * clearing it (a manually-entered or non-MusicBrainz-sourced value is
     * never touched), then writes only the cleared keys through the same
     * pica_works_update backing route (PATCH /admin/works/:id) — no
     * dedicated route, no external API call.
     *
     * Deliberate deviation from the bespoke original (whole-branch review,
     * ADR-277 provenance is load-bearing): on a partial PRO clear, the bespoke
     * handler nulled the ENTIRE enrichment_metadata.pro_ids block, wiping the
     * surviving orgs' individual_sources provenance even though their actual
     * pro_ids values were correctly kept. This version writes the surviving
     * orgs' individual_sources back so their provenance is never lost.
     */
    async clearEnrichment(args) {
        const workId = args.work_id;
        if (typeof workId !== "string" || workId.trim().length === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: JSON.stringify({
                            error: "MISSING_REQUIRED",
                            message: "work_id is required (a work UUID).",
                        }, null, 2),
                    },
                ],
                isError: true,
            };
        }
        const reason = args.reason;
        if (typeof reason !== "string" || reason.trim().length === 0) {
            return {
                content: [
                    {
                        type: "text",
                        text: JSON.stringify({
                            error: "MISSING_REQUIRED",
                            message: "reason is required — record why this MusicBrainz-sourced data is being cleared.",
                        }, null, 2),
                    },
                ],
                isError: true,
            };
        }
        const clearIswc = args.clear_iswc !== false;
        const clearProIds = args.clear_pro_ids !== false;
        const work = await this.pica.works.get(workId);
        // enrichment_metadata / pro_ids aren't on the shared SDK Work type (they
        // are internal enrichment satellite fields, not part of the read
        // contract external agents rely on) — read them defensively via a cast,
        // same pattern as every other `as unknown as {...}` in this codebase.
        const workRecord = work;
        const enrichmentMetadata = workRecord.enrichment_metadata || {};
        const updates = {};
        const clearedFields = [];
        const iswcMeta = enrichmentMetadata.iswc;
        if (clearIswc && iswcMeta?.source === "musicbrainz") {
            updates.iswc = null;
            clearedFields.push("iswc");
            updates.enrichment_metadata = { ...enrichmentMetadata, iswc: null };
        }
        const proIdsMeta = enrichmentMetadata.pro_ids;
        if (clearProIds && proIdsMeta?.source === "musicbrainz") {
            const currentProIds = workRecord.pro_ids || {};
            const individualSources = proIdsMeta.individual_sources || {};
            const remainingProIds = {};
            const clearedProOrgs = [];
            for (const [org, id] of Object.entries(currentProIds)) {
                if (individualSources[org]?.source !== "musicbrainz") {
                    remainingProIds[org] = id;
                }
                else {
                    clearedProOrgs.push(org);
                }
            }
            if (clearedProOrgs.length > 0) {
                const hasSurvivingProIds = Object.keys(remainingProIds).length > 0;
                updates.pro_ids = hasSurvivingProIds ? remainingProIds : null;
                clearedFields.push(`pro_ids (${clearedProOrgs.join(", ")})`);
                const currentEnrichmentMetadata = updates.enrichment_metadata ||
                    enrichmentMetadata;
                if (hasSurvivingProIds) {
                    // ADR-277: provenance is load-bearing. A partial clear must keep
                    // the surviving (non-musicbrainz-sourced) orgs' individual_sources
                    // entries intact — only drop the entries for the orgs we actually
                    // cleared. Also demote the parent-level `source` off a bare
                    // "musicbrainz" claim since pro_ids is no longer wholesale
                    // musicbrainz-sourced once at least one org survives.
                    const survivingIndividualSources = {};
                    for (const org of Object.keys(remainingProIds)) {
                        if (individualSources[org]) {
                            survivingIndividualSources[org] = individualSources[org];
                        }
                    }
                    updates.enrichment_metadata = {
                        ...currentEnrichmentMetadata,
                        pro_ids: {
                            ...proIdsMeta,
                            source: "mixed",
                            individual_sources: survivingIndividualSources,
                        },
                    };
                }
                else {
                    updates.enrichment_metadata = {
                        ...currentEnrichmentMetadata,
                        pro_ids: null,
                    };
                }
            }
        }
        if (Object.keys(updates).length === 0) {
            return formatSuccess(`No MusicBrainz-sourced fields to clear on work ${workId}`, { work_id: workId, cleared_fields: [], reason });
        }
        await this.pica.works.update(workId, updates);
        return formatSuccess(`Cleared MusicBrainz enrichment from work ${workId}: ${clearedFields.join(", ")}`, { work_id: workId, cleared_fields: clearedFields, reason });
    }
}
/**
 * Extract a structured error body from an SDK thrown error. The SDK's
 * ApiError wraps the response body text in the message as
 * "API request failed: {status} {body}". We pull the trailing JSON
 * object out and parse it. Returns null on anything we can't parse —
 * the caller falls back to the raw message.
 */
function extractApiErrorBody(err) {
    if (!(err instanceof Error))
        return null;
    const match = err.message.match(/\{[\s\S]*\}/);
    if (!match)
        return null;
    try {
        return JSON.parse(match[0]);
    }
    catch {
        return null;
    }
}
//# sourceMappingURL=enrichment.js.map