UNPKG

@withpica/mcp-server

Version:

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

1,747 lines • 98.6 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved.
/**
 * Works Tools — MCP tools for managing musical works.
 *
 * ADR-174 Phase 2 item 2 reconciliation: the write surface covers only
 * real columns on `public.works`. Seven phantom fields previously
 * accepted via `additionalProperties: true` have been removed:
 *   - `label` (no column; recordings/releases store label via org FK)
 *   - `spotify_url` (lives on recordings/releases)
 *   - `album_name`, `album_release_date`, `album_art_url`, `track_number`,
 *     `upc` (all album/release metadata — handled via `pica_releases_*`)
 *
 * Five add-candidates surfaced per the audit at
 * docs/audits/2026-04-14-mcp-db-reconciliation.md §1:
 *   tempo, key, display_artist, published, tunecode.
 *
 * Agents that need to set label metadata call `pica_releases_create`
 * or `pica_releases_update` with `label_organization_id`, resolving
 * the name via `pica_labels_query` first.
 */
import { ApiError, EntityNotFoundError, } from "@withpica/mcp-sdk";
import { formatSuccess, formatDeletion, formatStructuredList, withBillingGate, buildDecision, mapGapsToHints, pushSectionError, } from "@withpica/mcp-utils";
import { buildCustodyHints } from "./custody-hints.js";
import { typedError } from "./error-envelope.js";
import { computeWorkGapHints } from "./gap-hints.js";
import { buildIntegrityPressure } from "./integrity-hints.js";
import { detectVersionOfExistingWork, buildVersionWarning, parseVersionTitle, } from "./version-guard.js";
import { WORK_TYPE_VALUES } from "./catalog-enums.js";
import { VERBOSE_PROPERTY, RECEIPT_NOTE, changedFields, newHintsOnly, slimIntegrity, takeVerbose, } from "./write-receipt.js";
/**
 * Canonical write-schema for `works`. Every property below must
 * correspond to a real column on `public.works` — verified against
 * `lib/db/database.types.ts`. Keep this list tight: reintroducing a
 * phantom field is the exact regression ADR-174 Phase 2 exists to
 * prevent. The HTTP route at `app/api/admin/works/[id]/route.ts`
 * carries a broader allow-list that includes admin-UI-posted satellite
 * fields; this list is the narrower MCP-agent contract.
 */
export const WORK_WRITE_PROPERTIES = {
    title: {
        type: "string",
        description: "Work title",
    },
    work_type: {
        type: "string",
        description: "Type of work",
        enum: [...WORK_TYPE_VALUES],
    },
    work_status: {
        type: "string",
        description: "Work status (state machine validates transitions — demo → unreleased → released, etc.)",
        enum: ["demo", "unreleased", "released", "pitched", "placed", "archived"],
    },
    iswc: {
        type: "string",
        description: "International Standard Musical Work Code (composition)",
    },
    isrc: {
        type: "string",
        description: "International Standard Recording Code. Bridges publishing (works) and masters (recordings).",
    },
    tunecode: {
        type: "string",
        description: "PRS tunecode (e.g. 851348GT)",
    },
    // ADR-282: replaces per-society columns (prs_work_id) with a generic array
    // so no single society is privileged by a dedicated field.
    society_registrations: {
        type: "array",
        description: "Society registrations for this work, e.g. [{ society_code: 'PRS', registered: true, external_id: 'GB123' }]. " +
            "society_code identifies the collecting society (PRS, PPL, MCPS, MLC, GEMA, ASCAP, …); " +
            "no society is privileged by a dedicated field.",
        items: {
            type: "object",
            properties: {
                society_code: {
                    type: "string",
                    description: "collecting society code, e.g. PRS, PPL, MLC, GEMA",
                },
                registered: {
                    type: "boolean",
                    description: "whether registered with this society",
                },
                external_id: {
                    type: "string",
                    description: "the society's identifier for this entity (optional)",
                },
            },
            required: ["society_code", "registered"],
        },
    },
    spotify_track_uri: {
        type: "string",
        description: "Spotify track URI (e.g. spotify:track:0b8ZtRm9u3xSzRazABeHFF).",
    },
    duration_seconds: {
        type: "number",
        description: "Duration in seconds",
    },
    tempo: {
        type: "number",
        description: "BPM (beats per minute)",
    },
    key: {
        type: "string",
        description: "Musical key (e.g. 'C major', 'Am')",
    },
    genre: {
        type: "array",
        items: { type: "string" },
        description: "Genre tags (e.g. ['R&B', 'Soul'])",
    },
    mood: {
        type: "array",
        items: { type: "string" },
        description: "Mood tags (e.g. ['Uplifting', 'Reflective']). Stored on the work's primary recording (audio traits), recording-grain.",
    },
    primary_artist: {
        type: "string",
        description: "Primary artist name (freetext display value)",
    },
    primary_artist_id: {
        type: "string",
        description: "Primary artist person ID (FK to people)",
    },
    disambiguation: {
        type: ["string", "null"],
        maxLength: 200,
        description: "A short note that tells apart two DIFFERENT songs with the same title and primary artist — e.g. 'theme', '2019 song', 'live-only composition' (MusicBrainz's term). " +
            "Set it ONLY when two genuinely different songs share title + artist; the cue is a WORK_ALREADY_EXISTS refusal for a song you know is not the existing one. " +
            "Leave it unset otherwise — it is part of the duplicate check, so an invented note would let a real duplicate in. " +
            "If the two are the same song, use or merge the existing work instead. Not a version label: recording versions (live, remix, acoustic) belong on pica_recordings_create. " +
            "null or '' clears it.",
    },
    original_work_id: {
        type: ["string", "null"],
        description: "Set ONLY on a MODIFIED VERSION: the id of the work this one modifies (CWR version type MOD). " +
            "A remix is by default NOT a separate work — it is a new recording of the same song (pica_recordings_create with work_id of the original and version_type 'remix', remixer credited on the recording). " +
            "Only when the remix adds writers the original does not have (new lyrics, melody or section) is it its own work with its own ISWC and writer shares; then set original_work_id to the original's id. " +
            "Must be a work in the same catalogue, not this work. null clears the link.",
    },
    display_artist: {
        type: "string",
        description: "Display-friendly artist string (e.g. 'Jane Doe ft. John Smith')",
    },
    publisher_name: {
        type: "string",
        description: "Publisher name (legacy freetext). Prefer linking via publisher identity where possible.",
    },
    lyrics: {
        type: "string",
        description: "Lyrics text",
    },
    lyrics_language: {
        type: "string",
        description: "Language of lyrics (ISO 639-1, e.g. 'en')",
    },
    published: {
        type: "boolean",
        description: "Whether the work is published on the public directory",
    },
    notes: {
        type: "string",
        description: "Internal notes. Setting this REPLACES the whole field — to add a line, use append_notes on pica_works_update instead.",
    },
    metadata: {
        type: "object",
        description: "Free-form JSONB snapshot stored on the work (e.g. a DAW session capture: tracks, devices, samples, tempo). Stored verbatim on works.metadata; not individually queried.",
    },
    // Declared jsonb extension points — ADR-174 Phase 2c inverse-phantom fix.
    // These keys are not columns on public.works; the service routes them to
    // the `work_licensing` satellite via extractSatelliteFields() (see
    // lib/services/works/constants.ts WORK_SATELLITE_FIELDS). Same shape as
    // the parallel fields on `pica_recordings_update` (recordings.ts:193).
    ai_disclosure: {
        type: "object",
        description: "AI disclosure object (UK AI Copyright compliance). Fields: composition, performance, production, mastering ('none'|'assisted'|'generated'), tools_used (string[]), disclosure_date, disclosed_by.",
    },
    provenance_attestation: {
        type: "object",
        description: "Human creation proof. Fields: human_created (bool), attested_by, attested_at, attestation_method, supporting_evidence, ip_address, signature_hash, certificate_id.",
    },
    training_rights: {
        type: "object",
        description: "B2B ML licensing compliance object. Fields: available_for_training (bool), training_terms, minimum_fee, excluded_uses, permitted_uses, geographic_restrictions, requires_attribution, requires_notification, license_duration, updated_at, updated_by.",
    },
};
/**
 * Compact one-line orientation for the `dossier` section of pica_works_inspect.
 * The full dossier rides `structuredContent.data.dossier`; this string just
 * gives the agent the shape at a glance so it doesn't have to parse the JSON.
 * Returns null when no dossier was assembled (section not requested, or the
 * assembly failed and degraded to null).
 */
function summariseDossier(dossier) {
    if (!dossier || typeof dossier !== "object")
        return null;
    const d = dossier;
    const title = d.work?.title ?? "this work";
    const count = (v) => (Array.isArray(v) ? v.length : 0);
    const parts = [
        `${count(d.recordings)} recording(s)`,
        `${count(d.releases)} release(s)`,
        `${count(d.compositionCredits)} composition credit(s)`,
        `${count(d.people)} people`,
    ];
    const attestation = d.provenance?.attestationStatus ??
        (d.provenance?.hasProvenanceCertificate ? "certified" : "not attested");
    const aiDisclosed = d.aiDisclosure?.disclosed ? "disclosed" : "not disclosed";
    const consentState = d.consent?.workEffective?.state ?? "none";
    const consentLevel = d.consent?.workEffective?.level
        ? ` (${d.consent.workEffective.level})`
        : "";
    const completeness = typeof d.work?.completenessScore === "number"
        ? `${d.work.completenessScore}%`
        : "n/a";
    return (`Dossier for "${title}": ${parts.join(", ")}. ` +
        `Provenance: ${attestation}. AI disclosure: ${aiDisclosed}. ` +
        `Consent: ${consentState}${consentLevel}. Completeness: ${completeness}. ` +
        `(Full payload under structuredContent.data.dossier; PDF / multi-work ZIP: pica_export_song_dossiers.)`);
}
/**
 * Page caps for the two health_filter branches of pica_works_query — the
 * routes' own maxima (app/api/admin/quality/works-health/route.ts and
 * .../completeness/low-score/route.ts). Hand-copied (this package cannot
 * import the app); no test ties them — change both together.
 */
const WORKS_HEALTH_MAX_PAGE = 50;
const LOW_COMPLETENESS_MAX_PAGE = 200;
export class WorksTools {
    pica;
    constructor(pica) {
        this.pica = pica;
    }
    /**
     * Get all works tools
     */
    getTools() {
        return [
            {
                definition: {
                    name: "pica_works_query",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Find works by any criteria. Use when the user asks: 'what's " +
                        "pending registration?', 'unregistered works', 'works registered " +
                        "with ASCAP', 'least-complete works', 'works ready for outreach', " +
                        "'works with no statement data', 'most-streamed works'. Search " +
                        "by title, ISWC, or artist, or filter by " +
                        "registration status, the collection society a work is registered " +
                        "with (society_code, any territory), health, whether a collecting-" +
                        "society statement is linked (has_statement_data), completeness range " +
                        "(completeness_score_lt / completeness_score_gt; sort " +
                        "completeness_score:asc for least-complete first), or streaming " +
                        "activity (streaming_activity_gt / streaming_activity_lt; sort " +
                        "streaming_activity:desc for the most-active works). Each row " +
                        "carries its completeness score and a per-category breakdown " +
                        "(identifiers, registrations, metadata, platforms, " +
                        "external_benchmark), missing_field_count (count of unfilled " +
                        "completeness fields), and streaming_activity_score (max-rollup " +
                        "over recordings); sort missing_field_count:desc for the " +
                        "works with the most gaps. Narrow by date with created_from / " +
                        "created_to (ISO YYYY-MM-DD; for a year use 2013-01-01…2013-12-31), " +
                        "or by presence with has_iswc / has_publisher / has_lyrics " +
                        "(has_lyrics answers 'how many of my songs have lyrics?' — " +
                        "count from the returned total, do not sample a work). → then: " +
                        "pica_works_inspect (one work in detail), or load the " +
                        "register-my-works skill (the registration workflow)",
                    workflows: "infrastructure",
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            query: {
                                type: "string",
                                description: "Search by title, ISWC, or artist name. An artist name also finds works linked to that artist through a recording's artist or a credited person, even when the work's primary_artist is empty or spelled differently (appended after the direct matches, up to 300).",
                            },
                            health_filter: {
                                type: "string",
                                enum: ["needs_attention", "low_completeness"],
                                description: "Filter by health status — needs_attention (cross-org reconciliation problems: unreconciled composition/master chains, split discrepancies) or low_completeness (below completeness score threshold: missing ISWC, credits, etc.). Both honour limit/offset. low_completeness reports `total` = works below the threshold by their stored score (max 200 per page). needs_attention checks at most 50 works per call and `offset` counts works CHECKED, not rows returned — a page can hold fewer rows than limit while more remain, so continue from next_offset until it is null; its `total` is set only when one call from offset 0 checks every candidate, otherwise it is null and the answer is the rows counted across pages.",
                            },
                            has_statement_data: {
                                type: "boolean",
                                description: "Filter to works that have (true) or do not have (false) statement-derived data — derived from whether the work carries a collecting-society external id (any society, any territory).",
                            },
                            completeness_score_lt: {
                                type: "number",
                                description: "Return only works whose cached completeness score is strictly less than this value (0-100).",
                            },
                            completeness_score_gt: {
                                type: "number",
                                description: "Return only works whose cached completeness score is strictly greater than this value (0-100).",
                            },
                            missing_field_count_gt: {
                                type: "number",
                                description: "Return only works whose cached missing-field count is strictly greater than this (gap-finding). Pair with sort missing_field_count:desc for the works with the most gaps.",
                            },
                            missing_field_count_lt: {
                                type: "number",
                                description: "Return only works whose cached missing-field count is strictly less than this.",
                            },
                            streaming_activity_gt: {
                                type: "number",
                                description: "Return only works whose cached streaming activity score (max-rollup over recordings) is strictly greater than this. Pair with sort streaming_activity:desc for the most-active works.",
                            },
                            streaming_activity_lt: {
                                type: "number",
                                description: "Return only works whose cached streaming activity score is strictly less than this (surface low-activity catalog).",
                            },
                            sort: {
                                type: "string",
                                enum: [
                                    "completeness_score:asc",
                                    "completeness_score:desc",
                                    "missing_field_count:asc",
                                    "missing_field_count:desc",
                                    "streaming_activity:asc",
                                    "streaming_activity:desc",
                                ],
                                description: "Order results by completeness score, missing-field count, or streaming activity. `:asc` returns least-complete / fewest-gaps / lowest-activity first; `:desc` returns most-complete / most-gaps / highest-activity first. Use missing_field_count:desc to surface the works with the most gaps; streaming_activity:desc for the most-played works.",
                            },
                            society_code: {
                                type: "string",
                                description: "Filter to works registered with a collection society, by its code (case-insensitive, e.g. 'ASCAP', 'PRS', 'PPL', 'MCPS', 'MLC', 'GEMA', 'SACEM'). Works for any territory's society — a US (ASCAP) work is found the same way as a UK (PRS) one. A work counts as registered with the society when it has a registration marked registered or carrying that society's external id.",
                            },
                            work_type: {
                                type: "string",
                                enum: [...WORK_TYPE_VALUES],
                                description: "Filter by work type. 'instrumental' answers 'my instrumentals'.",
                            },
                            work_status: {
                                type: "string",
                                enum: [
                                    "demo",
                                    "unreleased",
                                    "released",
                                    "pitched",
                                    "placed",
                                    "archived",
                                ],
                                description: "Filter by work status. 'unreleased' answers 'unreleased songs'.",
                            },
                            genre: {
                                type: "string",
                                description: "Filter to works tagged with this genre (e.g. 'R&B'). Combine with available_for_licensing for 'R&B I can license'.",
                            },
                            mood: {
                                type: "string",
                                description: "Filter to works whose primary recording carries this mood tag.",
                            },
                            tempo_min: {
                                type: "number",
                                description: "Filter to works whose primary recording tempo (BPM) is at least this.",
                            },
                            tempo_max: {
                                type: "number",
                                description: "Filter to works whose primary recording tempo (BPM) is at most this.",
                            },
                            primary_artist: {
                                type: "string",
                                description: "Exact primary-artist name match. Misses works whose primary_artist is empty — use `query` to include works linked by recording artist or credits.",
                            },
                            artist: {
                                type: "string",
                                description: "Exact primary-artist name match (alias). Misses works whose primary_artist is empty — use `query` to include works linked by recording artist or credits.",
                            },
                            label: {
                                type: "string",
                                description: "Filter by derived label (primary recording label, else primary release parent_label).",
                            },
                            available_for_licensing: {
                                type: "boolean",
                                description: "Filter to works available (true) / not available (false) for licensing.",
                            },
                            release_format: {
                                type: "string",
                                enum: ["single", "ep", "album", "compilation"],
                                description: "Filter by the primary release's format.",
                            },
                            is_featured: {
                                type: "boolean",
                                description: "Filter to featured (true) / non-featured works.",
                            },
                            published: {
                                type: "boolean",
                                description: "Filter by directory-published state.",
                            },
                            created_from: {
                                type: "string",
                                description: "Only works added/composed on or after this ISO date (YYYY-MM-DD). For a year pass created_from=2013-01-01 & created_to=2013-12-31.",
                            },
                            created_to: {
                                type: "string",
                                description: "Only works added/composed on or before this ISO date (YYYY-MM-DD).",
                            },
                            has_iswc: {
                                type: "boolean",
                                description: "Filter to works that have (true) or lack (false) an ISWC.",
                            },
                            has_publisher: {
                                type: "boolean",
                                description: "Filter to works that have (true) or lack (false) a publisher_name.",
                            },
                            has_lyrics: {
                                type: "boolean",
                                description: "Filter to works that have (true) or lack (false) a stored lyric sheet (works.lyrics; blank counts as lacking). Use with limit:1 and read `total` to COUNT works with lyrics — never infer lyric coverage from a sampled work. Note works.lyrics_language is a separate tag and does not imply lyrics are present.",
                            },
                            limit: {
                                type: "number",
                                description: "Max results (default: 50)",
                            },
                            offset: {
                                type: "number",
                                description: "Pagination offset",
                            },
                            light: {
                                type: "boolean",
                                description: "Return a lightweight projection per work (id, title, primary_artist, iswc, tunecode, isrc, work_status, completeness_score, missing_field_count) instead of the full ~1KB row. Use for wide bulk reads — e.g. reconciling a PRO/CWR export against the catalogue — so a large page fits under the response cap. Use pica_works_inspect for full detail on a specific work.",
                            },
                        },
                    },
                },
                executor: this.queryWorks.bind(this),
            },
            {
                definition: {
                    name: "pica_works_inspect",
                    scope: "read:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "read",
                    description: "Get the full picture on one work — its details, gaps, and " +
                        "quality data (the song itself, not its album or project). Use " +
                        "when the user asks: 'tell me about this song', 'show me this " +
                        "song', 'what do you know about this work?'. Omit sections to get " +
                        "everything. sections=['enrichment'] shows where each field came " +
                        "from, the completeness score and breakdown, and the status of " +
                        "any running lookups (Spotify, MLC, ACRCloud, audio analysis) in " +
                        "one response. sections=['dossier'] returns the full song dossier — " +
                        "everything PICA holds about the song (recordings, releases, " +
                        "credits, people, provenance, AI disclosure, consent) in one " +
                        "structured payload. " +
                        "To find works by title, ISWC, or status, use pica_works_query. " +
                        "→ then: " +
                        "pica_credits_update (fix credits), pica_resolve_work (fill " +
                        "identifiers + metadata), pica_run_work_cascade (retry)",
                    workflows: "infrastructure",
                    vernacular_kind: "creator-entry",
                    // id/work_id/iswc are interchangeable entry points, so none can be
                    // JSON-Schema `required`; this lets pica_execute hard-reject an
                    // empty/stripped call with WRAPPED_ARGS_MISSING instead of a
                    // confusing "invalid id" domain error (feedback 6d516171). iswc
                    // resolves to the work server-side (in-app assistant roadblock
                    // 2026-07-05: models pass identifiers where an id is expected).
                    requiredOneOf: [["id", "work_id", "iswc"]],
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Work ID. Also accepted as work_id (alias for read-after-write flows from write tools that use work_id).",
                            },
                            work_id: {
                                type: "string",
                                description: "Work ID (alias for id — accepted because the write tools use work_id).",
                            },
                            iswc: {
                                type: "string",
                                description: "ISWC of the work — resolved to the work for you (e.g. T-034.524.680-1). Use when you have the code but not the id.",
                            },
                            sections: {
                                type: "array",
                                items: {
                                    type: "string",
                                    enum: [
                                        "basic",
                                        "credits",
                                        "recordings",
                                        "agreements",
                                        "health",
                                        "completeness",
                                        "registration",
                                        "score",
                                        "provenance",
                                        "field_provenance",
                                        "enrichment",
                                        "production_assets",
                                        "releases",
                                        "licensing",
                                        "dossier",
                                    ],
                                },
                                description: 'Which data sections to include. Omit for all sections. Use ["basic"] for lightweight lookup. "health" is a short summary of THIS work\'s completeness (score, missing critical fields, suggestions) — opt-in, since "completeness" carries the full breakdown; for works across the catalogue needing attention use pica_works_query health_filter. "licensing" surfaces the AI-disclosure / training-rights / provenance-attestation compliance fields (work_licensing satellite) in one scoped view. "dossier" assembles the full song dossier (recordings, releases, credits, people, provenance, AI disclosure, consent) — heavy, so it is opt-in only (never part of the default all-sections response); for the PDF, or several works in one ZIP, use pica_export_song_dossiers.',
                            },
                        },
                        required: [],
                    },
                    // ADR-199 — work status state machine mirrored from
                    // lib/utils/state-machine.ts workStatusMachine. Build-step
                    // assertion (Phase D) catches drift between this map and the
                    // DB CHECK constraint on works.status.
                    validTransitionsFrom: {
                        demo: ["unreleased", "pitched", "archived"],
                        unreleased: ["released", "pitched", "archived"],
                        pitched: ["placed", "unreleased", "released", "archived"],
                        placed: ["released", "archived"],
                        released: ["archived"],
                        archived: ["unreleased", "demo"],
                    },
                    nextSteps: [
                        {
                            tool: "pica_credits_update",
                            reason: "Fix or fill writer/composer credits when the inspect surfaces gaps.",
                            when: "on_success",
                        },
                        {
                            tool: "pica_resolve_work",
                            reason: "Fill identifier gaps (ISWC, ISRC) and metadata via the enrichment cascade.",
                            when: "on_success",
                        },
                    ],
                },
                executor: this.inspectWork.bind(this),
            },
            {
                definition: {
                    name: "pica_works_create",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Add a new song to the catalog. Use when the user asks: 'add this " +
                        "song', 'create a new work', 'register this track'. Before " +
                        "saving, it's worth knowing: who wrote it — the user alone, or " +
                        "with others? If someone else co-wrote it, what's their role, and " +
                        "should PICA send it to them to verify? That sets custody and " +
                        "splits. And was the writing, performance, production, or " +
                        "mastering human-made, AI-assisted, or AI-generated? (Those four " +
                        "are set independently on ai_disclosure.) Always run " +
                        "pica_works_query first to check for a duplicate — two same-title " +
                        "works cause split and registration trouble. If a refusal says the " +
                        "work already exists but it really is a different song with the same " +
                        "title and artist, retry with disambiguation (e.g. 'theme'). A remix is " +
                        "usually a new recording of the original song (pica_recordings_create, " +
                        "version_type 'remix'), not a new work; only a remix that added writers " +
                        "is its own work, linked with original_work_id. Album, artwork, " +
                        "track number, UPC, and Spotify URL live on the release " +
                        "(pica_releases_create/update), not here; label too, via the " +
                        "release (resolve the label name with pica_labels_query first). " +
                        "→ before: pica_works_query (check duplicates) → then: " +
                        "pica_credits_update (add writers), pica_works_inspect (check " +
                        "gaps), pica_resolve_work (fill identifiers)",
                    workflows: ["work-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: {
                            ...WORK_WRITE_PROPERTIES,
                            create_twin: {
                                type: "boolean",
                                description: "default true — creating a work also creates its linked master recording (and vice versa); pass false to create one grain only",
                            },
                        },
                        required: ["title", "work_type"],
                    },
                    nextSteps: [
                        {
                            tool: "pica_credits_update",
                            reason: "Add writer/composer credits to the new work — required before registration.",
                            when: "on_success",
                        },
                        {
                            tool: "pica_works_inspect",
                            reason: "Read back the created work to confirm fields persisted and surface any gaps.",
                            when: "on_success",
                        },
                        {
                            tool: "pica_agreement_types_create",
                            reason: "If this work is subject to a work-for-hire or producer agreement, create it now — terms set before registration avoid renegotiation later.",
                            when: "on_success",
                        },
                    ],
                },
                executor: withBillingGate(this.pica, "work creation", this.createWork.bind(this)),
            },
            {
                definition: {
                    name: "pica_works_update",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Update an existing musical work. Only real `works` columns are accepted — " +
                        "album and release metadata (album_name, album_art_url, track_number, upc, spotify_url) must go through pica_releases_create/update, not here. " +
                        "Writer credits and shares (collaborators) are not set here either — pica_credits_update (batch) or pica_credit_add write work_collaborators, the home every health check and export reads. " +
                        "Audio-trait fields (energy, vocal_type, available_for_licensing, danceability) live on a " +
                        "related table (a 'satellite' — extra fields off the main works row), not exposed through " +
                        "MCP; set them through a release or recording path. " +
                        "Three jsonb compliance fields (ai_disclosure, provenance_attestation, training_rights) route " +
                        "to that same work_licensing satellite table and ARE set-able here. " +
                        "original_work_id links a modified version (a remix that added writers) to the work it modifies. " +
                        "Answers with a compact receipt — the id, each field you sent with the value now stored, and only the gaps this write opened; pass verbose: true for the full work. " +
                        "→ then: pica_works_inspect (verify changes persisted)",
                    workflows: ["work-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: {
                            id: {
                                type: "string",
                                description: "Work ID (required)",
                            },
                            ...WORK_WRITE_PROPERTIES,
                            ...VERBOSE_PROPERTY,
                            append_notes: {
                                type: "string",
                                maxLength: 10000,
                                description: "Text to add to the end of the work's notes, on a new line, without reading or rewriting what is there. " +
                                    "Safe when someone else edits the work at the same time (a conflict is retried, never overwritten). " +
                                    "Cannot be sent together with notes.",
                            },
                        },
                        required: ["id"],
                    },
                    nextSteps: [
                        {
                            tool: "pica_works_inspect",
                            reason: "Read back the work after update to confirm every field round-tripped — silent persistence drops are real.",
                            when: "on_success",
                        },
                    ],
                },
                executor: withBillingGate(this.pica, "work update", this.updateWork.bind(this)),
            },
            {
                definition: {
                    name: "pica_works_delete",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    // ADR-257 WS-2 — delegated-agent (OAuth/grant) callers cannot hold
                    // destructive:* scope; they propose, the org owner approves out-of-band,
                    // PICA replays via human-approval-executor. Session + first-party-key
                    // callers fall through to the normal destructive path unchanged.
                    approvalMode: "human",
                    description: "Delete a musical work. It is automatically unlinked from any " +
                        "releases it appears on (the release keeps its track). If the work " +
                        "still has dependent records (recordings, royalty payments, version " +
                        "links, claims, signed split sheets) it is archived instead — hidden from the catalog but " +
                        "preserved — and the result tells you exactly what to remove to " +
                        "delete it permanently. A work with registry identifiers " +
                        "(ISRC/ISWC/MLC) is always archived, never destroyed. The result " +
                        "status is 'deleted' or 'archived'. " +
                        "Prefer pica_works_bulk_delete when removing several works in one call.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Work ID",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: withBillingGate(this.pica, "work deletion", this.deleteWork.bind(this)),
            },
            {
                definition: {
                    name: "pica_works_verify",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Writes nothing: PICA keeps no single 'verified' flag on a work. Returns " +
                        "NO_WORK_VERIFIED_FLAG naming what the person can confirm themselves, signed in, " +
                        "on /inspect — the human-made stamp, their own writer credit, their own master " +
                        "share. Not a registration check (that's pica_works_verify_registrations). To " +
                        "review a work, use pica_works_inspect.",
                    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: {
                            id: {
                                type: "string",
                                description: "Work UUID from pica_works_query.",
                            },
                        },
                        required: ["id"],
                    },
                },
                executor: withBillingGate(this.pica, "work verification", this.verifyWork.bind(this)),
            },
            {
                definition: {
                    name: "pica_works_attest",
                    scope: "write:catalog",
                    audiences: ["stdio", "http"],
                    tier: "write",
                    description: "Put a human-made stamp on a work — a signed statement that a " +
                        "person created it. Use when the user asks: 'attest that I wrote " +
                        "this', 'stamp this work as human-made', 'sign off on " +
                        "provenance', 'mark this human-created for sync'. This is the " +
                        "human counterpart to ai_disclosure: it records who signed, when, " +
                        "how, and an optional signature hash (the raw signature itself is " +
                        "never stored). Sync libraries and provenance certificates use it " +
                        "to prove human authorship. To change or undo one, use the " +
                        "provenance revocation tools. Only a signed-in person can make " +
                        "this statement: an API key, connected app or agent is refused " +
                        "with ATTESTATION_REQUIRES_HUMAN_SESSION — do not retry; tell the " +
                        "user to use 'stamp as human-made' at /inspect/works/<id>#human-made. " +
                        "After stamping, read it back with " +
                        "pica_works_inspect (sections: ['provenance']). → then: " +
                        "pica_works_inspect (verify it persisted), " +
                        "pica_provenance_certificate (generate a PDF)",
                    workflows: ["work-required"],
                    vernacular_kind: "creator-entry",
                    inputSchema: {
                        type: "object",
                        properties: {
                            id: {
                                type: "string",
                                description: "Work ID to attest.",
                            },
                            method: {
                                type: "string",
                                enum: [
                                    "self_declaration",
                                    "collaborator_verified",
                                    "third_party",
                                ],
                                description: "Attestation method. Default: 'self_declaration'. 'collaborator_verified' signals a co-writer has confirmed authorship; 'third_party' signals an external witness (manager, lawyer, sync agent).",
                            },
                            supporting_evidence: {
                                type: "object",
                                description: "Optional flags that strengthen the attestation. session_files / stems_available / creation_timeline are booleans; witness_collaborators is an array of person UUIDs. Evidence strength is computed automatically (basic / moderate / strong) and surfaced in certificate PDFs.",
                                properties: {
                                    session_files: { type: "boolean" },
                                    stems_available: { type: "boolean" },
                                    creation_timeline: { type: "boolean" },
                                    witness_collaborators: {
                                        type: "array",
                                        items: { type: "string" },
                                    },
                                },
                            },
                            signature: {
                                type: "string",
                                description: "Optional raw signature payload (e.g. typed name + date). Hashed to signature_hash with sha256 before storage — the raw value is NEVER persisted. Omit when stamping without a signed-name record.",
                            },
                        },
                        required: ["id"],
                    },
                    nextSteps: [
                        {
                            tool: "pica_works_inspect",
                            reason: "Read the provenance section back with sections:['provenance'] to confirm the new attestation entry landed.",
                            when: "on_success",
                        },
                        {
                            tool: "pica_provenance_certificate",
                            reason: "Generate a PDF certificate from the attestation — sync libraries and ML training-data buyers consume this.",
                            when: "on_success",
                        },
                    ],
                },
                executor: withBillingGate(this.pica, "work provenance attestation", this.attestWork.bind(this)),
            },
            {
                // ADR-264 WS2 — verify-before-assert for registration gaps.
                definition: {
                    name: "pica_works_verify_registrations",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "write",
                    description: "Use when the user asks: 'verify my registration status with the MLC', 'double-check these " +
                        "registration gaps', 'confirm these works are unregistered'. Verify the registration status of up to 25 works. " +
                        "mode 'check' looks each work up at the MLC (by ISWC, else title + writer IPI, else title + " +
                        "primary-artist name) and records what the lookup PROVED: a found registration is written as a " +
                        "positive society fact; a clean not-found is recorded as a verified-absent assertion with a " +
                        "lookup receipt. Lookup errors and works with no usable identifiers write NOTHING (results " +
                        "'lookup_failed' / 'insufficient_identifiers' — an error or blind search is not evidence). " +
                        "mode 'confirm_unregistered' records the user's own confirmation instead (no lookup; " +
                        "society_code optional — omitted means 'not registered with any society'). " +
                        "Idempotent — re-running refreshes checked_at. " +
                        "This is a registration check, not a human sign-off — a person's own statement that a human made the work is pica_works_attest (signed-in only).",
                    workflows: ["work-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: {
                            work_ids: {
                                type: "array",
                                items: { type: "string" },
                                description: "Work UUIDs to verify (1-25 per call).",
                            },
                            mode: {
                                type: "string",
                                enum: ["check", "confirm_unregistered"],
                                description: "'check' = run the MLC lookup; 'confirm_unregistered' = record the user's own assertion without a lookup.",
                            },
                            society_code: {
                                type: "string",
                                description: "confirm_unregistered only — scope the confirmation to one society (e.g. 'PRS'). Omit to record 'not registered with any society'.",
                            },
                        },
                        required: ["work_ids", "mode"],
                    },
                    annotations: {
                        title: "verify work registrations",
                        readOnlyHint: false,
                        destructiveHint: false,
                        idempotentHint: true,
                        openWorldHint: true,
                    },
                    nextSteps: [
                        {
                            tool: "pica_dashboard_attention",
                            reason: "Re-read the works_without_registrations rows — their evidence states now reflect what the verification proved.",
                            when: "on_success",
                        },
                    ],
                },
                executor: withBillingGate(this.pica, "work registration verification", this.verifyRegistrations.bind(this)),
            },
            {
                definition: {
                    name: "pica_works_bulk_delete",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    description: "Delete multiple works at once. Each work follows the same rule as " +
                        "pica_works_delete: unlinked from its releases and deleted, or " +
                        "archived if it has registry identifiers or dependent records.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Destructive operation; invoked by exact name after explicit user confirmation in a specialist flow.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            ids: {
                                type: "array",
                                description: "Array of work IDs to delete",
                                items: {
                                    type: "string",
                                },
                            },
                            dry_run: {
                                type: "boolean",
                                description: "When true, returns { dry_run: true, would_affect, target_count } without deleting any work. Preview before committing — bulk deletes cascade to credits and agreement links and cannot be undone for works without verified identifiers.",
                            },
                        },
                        required: ["ids"],
                    },
                    previewMode: "dry_run",
                },
                executor: withBillingGate(this.pica, "bulk work deletion", this.bulkDeleteWorks.bind(this)),
            },
            {
                definition: {
                    name: "pica_works_bulk_move_to_performances",
                    scope: "write:catalog",
                    audiences: ["stdio", "http", "public"],
                    tier: "destructive",
                    description: "Reclassify multiple works as live performances and DELETE the source work rows. " +
                        "Use when the catalog has works that are actually performances of existing repertoire " +
                        "(covers, live renditions). Each work becomes a new live-performance record with " +
                        "title/artist mapped over; the source work is then deleted. Per-row results " +
                        "are returned — partial success is possible. " +
                        "Prefer pica_works_bulk_delete when the works should simply go, with no performance record created.",
                    workflows: "infrastructure",
                    vernacular_kind: "specialist",
                    vernacular_reason: "Destructive reclassification; invoked by exact name after explicit user confirmation in a specialist flow.",
                    inputSchema: {
                        type: "object",
                        properties: {
                            ids: {
                                type: "array",
                                description: "Array of work IDs to reclassify as live performances",
                                items: { type: "string" },
                            },
                            dry_run: {
                                type: "boolean",
                                description: "When true, returns { dry_run: true, would_affect, target_count } without creating any performance row or deleting any work. Strongly recommended — this operation deletes work rows after creating the performance counterparts.",
                            },
                        },
                        required: ["ids"],
                    },
                    previewMode: "dry_run",
                },
                executor: withBillingGate(this.pica, "bulk work-to-performance reclassification", this.bulkMoveToPerformances.bind(this)),
            },
        ];
    }
    /**
     * Query works — unified search, list, and health filter
     */
    async queryWorks(args) {
        // `verified` used to filter rows on `work.is_verified`, a column no
        // applied migration adds to `works`, so it matched nothing whichever way
        // it was set. Refuse
        // it by name rather than ignore it: an agent that asked for "verified
        // works" would otherwise read the whole catalogue as the answer.
        if (args.verified !== undefined) {
            return typedError("NO_WORK_VERIFIED_FLAG", "pica_works_query has no 'verified' filter: PICA does not keep a single 'verified' flag on a work. " +
                "To find works missing what a registration needs, use health_filter: 'low_completeness' or " +
                "completeness_score_lt; to review one work's confirmed and unconfirmed records, use pica_works_inspect.");
        }
        const limit = args.limit || 50;
        const offset = args.offset || 0;
        // Health filter routes to health/completeness APIs.
        //
        // ⚠️ Both branches used to call the bare SDK method with no arguments,
        // so `limit`/`offset` were ignored (the routes' own default of 20 won)
        // and `total` was the length of that page — an agent asking for 100
        // low-completeness works got 20 and was told there were 20 (ops issue
        // 314325eb, 2026-09-25). Both now thread limit/offset through and report
        // only the totals the server can actually vouch for.
        if (args.health_filter === "needs_attention") {
            // Each candidate is a full cross-org chain assembly, so the route caps
            // a page at 50 candidates; `offset` pages candidates, not results.
            const page = await this.pica.health.getWorksHealthPage({
                limit: Math.min(limit, WORKS_HEALTH_MAX_PAGE),
                offset,
                unreconciledOnly: true,
            });
            const works = page.data;
            const meta = page.meta;
            // No meta (an older server) means nothing is known about the rest of
            // the set: treat it as "more may remain" rather than "done".
            const hasMore = meta ? meta.has_more : works.length > 0;
            // A total is only claimed when THIS call, starting at offset 0, checked
            // every candidate and the server said so (meta.has_more false).
            // Otherwise the page length is not passed off as the total.
            const wholeSetScanned = offset === 0 && meta !== undefined && !hasMore;
            return formatStructuredList(works, "work", {
                filter: "needs_attention",
                total: wholeSetScanned ? works.length : null,
                ...(wholeSetScanned
                    ? {}
                    : {
                        total_note: hasMore
                            ? "no total yet — the number needing attention is known only after every candidate work is checked; keep paging with next_offset and count the rows across pages"
                            : "this was the last page — the number needing attention is the rows counted across the pages you fetched",
                    }),
                limit: meta?.limit ?? Math.min(limit, WORKS_HEALTH_MAX_PAGE),
                offset,
                scanned: meta?.scanned ?? null,
                candidates_total: meta?.candidates_total ?? null,
                hasMore,
                next_offset: meta?.next_offset ?? null,
            });
        }
        if (args.health_filter === "low_completeness") {
            const page = await this.pica.health.getLowScoreWorksPage({
                limit: Math.min(limit, LOW_COMPLETENESS_MAX_PAGE),
                offset,
            });
            const works = page.data;
            const meta = page.meta;
            const appliedLimit = meta?.limit ?? Math.min(limit, LOW_COMPLETENESS_MAX_PAGE);
            const total = meta?.total ?? null;
            const hasMore = meta?.has_more ??
                (total === null ? works.length >= appliedLimit : false);
            return formatStructuredList(works, "work", {
                filter: "low_completeness",
                threshold: meta?.threshold,
                total,
                limit: appliedLimit,
                offset,
                hasMore,
                next_offset: hasMore ? offset + appliedLimit : null,
            });
        }
        // ADR-244 PR (c) §3.8 / §3.9 — queryable + sortable signals threaded
        // through the SDK. The index does the work on the cached column
        // `works.completeness_score`; has_statement_data is derived per row
        // from work_registrations and applied as a post-flatten filter.
        const hasStatementData = args.has_statement_data;
        const completenessScoreLt = args.completeness_score_lt;
        const completenessScoreGt = args.completeness_score_gt;
        // ADR-270 Phase (a) — missing field count range filters.
        const missingFieldCountGt = args.missing_field_count_gt;
        const missingFieldCountLt = args.missing_field_count_lt;
        // ADR-270 Phase (b) — streaming activity score range filters.
        const streamingActivityGt = args.streaming_activity_gt;
        const streamingActivityLt = args.streaming_activity_lt;
        const sort = args.sort;
        // Society-agnostic registration filter — "works registered with ASCAP"
        // answered off the society side table, no society-named column referenced.
        const societyCode = args.society_code;
        // Always route through .search() when ADR-244 PR (c) filters/sort are
        // present (and on any text-query call) so the SDK applies them.
        const result = await this.pica.works.search({
            query: args.query,
            limit,
            offset,
            has_statement_data: hasStatementData,
            completeness_score_lt: completenessScoreLt,
            completeness_score_gt: completenessScoreGt,
            missing_field_count_gt: missingFieldCountGt,
            missing_field_count_lt: missingFieldCountLt,
            streaming_activity_gt: streamingActivityGt,
            streaming_activity_lt: streamingActivityLt,
            sort,
            society_code: societyCode,
            work_type: args.work_type,
            work_status: args.work_status,
            genre: args.genre,
            mood: args.mood,
            tempo_min: args.tempo_min,
            tempo_max: args.tempo_max,
            primary_artist: args.primary_artist,
            artist: args.artist,
            label: args.label,
            available_for_licensing: args.available_for_licensing,
            release_format: args.release_format,
            is_featured: args.is_featured,
            published: args.published,
            created_from: args.created_from,
            created_to: args.created_to,
            has_iswc: args.has_iswc,
            has_lyrics: args.has_lyrics,
            has_publisher: args.has_publisher,
        });
        let items = result.data || result;
        if (!Array.isArray(items))
            items = [];
        // `light` projection — a full works row is ~1KB (completeness components,
        // streaming sub-scores, AI disclosure, provenance, album-art URLs), so a
        // 100-row page can blow the 100KB MCP response cap. When an agent only
        // needs identity/registration fields (e.g. reconciling a PRO export against
        // the catalogue), `light: true` returns just the scalars so a wide page
        // fits comfortably. (Founder feedback a03bfd1b.) Build a separate array so
        // the typed `items: Work[]` isn't reassigned to the projected shape.
        const outItems = args.light
            ? items.map((w) => ({
                id: w.id,
                title: w.title,
                primary_artist: w.primary_artist,
                iswc: w.iswc ?? null,
                tunecode: w.tunecode ?? null,
                isrc: w.isrc ?? null,
                work_status: w.work_status,
                completeness_score: w.completeness_score,
                missing_field_count: w.missing_field_count,
            }))
            : items;
        return formatStructuredList(outItems, "work", {
            query: args.query,
            total: result.total || outItems.length,
            limit,
            hasMore: result.hasMore,
            ...(args.light ? { projection: "light" } : {}),
        });
    }
    /**
     * Inspect work — unified detail view with selectable sections
     */
    /**
     * Resolve an ISWC to a work id via the works search (which matches ISWC
     * server-side — see applyWorksFilters). Takes the first EXACT (dash/dot
     * stripped, case-insensitive) match. Throws EntityNotFoundError (→ NOT_FOUND)
     * when nothing matches, so inspect surfaces a hard not-found (ADR-198).
     */
    async resolveWorkIdByIswc(iswc) {
        const norm = (v) => v.replace(/[-.\s]/g, "").toUpperCase();
        const target = norm(iswc);
        // Query with the NORMALIZED value: works.iswc is stored dash/dot-free and
        // the works search matches iswc via a raw ILIKE substring (no stripping), so
        // a formatted input (e.g. "T-034.524.680-1") would miss the stored
        // "T0345246801" and false-NOT_FOUND. We still exact-match below.
        const { data } = await this.pica.works.search({ query: target, limit: 50 });
        const match = data.find((w) => w.iswc && norm(w.iswc) === target);
        if (!match) {
            throw new EntityNotFoundError(`No work found with ISWC ${iswc}. Use pica_search_all to locate it, or pica_works_query to browse.`);
        }
        return match.id;
    }
    async inspectWork(args) {
        // Accept work_id as an alias for id — write tools use work_id, so agents
        // doing read-after-write naturally pass work_id here. Without this alias
        // args.id would be undefined and credits.listForWork(undefined) would
        // silently return [] (ADR-198 silent-empty footgun).
        let workId = (args.id ?? args.work_id);
        // Accept iswc as an entry point — resolve it to the work id (throws
        // EntityNotFoundError → NOT_FOUND when no work carries that ISWC).
        if (!workId && typeof args.iswc === "string" && args.iswc.trim()) {
            workId = await this.resolveWorkIdByIswc(args.iswc.trim());
        }
        if (!workId) {
            return {
                content: [
                    { type: "text", text: "id (or work_id or iswc) is required." },
                ],
                isError: true,
            };
        }
        const sections = args.sections;
        const allSections = !sections || sections.length === 0;
        // Sections kept OFF the default `allSections` surface but still callable
        // via explicit `sections`:
        //  - `health` is a summary of THIS work's completeness (the same
        //    single-work read as `completeness`), so on the default surface it
        //    would only repeat that section. Until 2026-09-26 it read the
        //    ORG-WIDE works-health scan instead — ~25s, and about other works.
        //  - `score` (ADR-201 closeout, 2026-04-27) is the org-wide PICA score;
        //    it takes no workId.
        //  - `dossier` (2026-07-12) assembles the whole song (recordings,
        //    releases, credits, people, provenance, consent) — a heavy superset of
        //    the other sections, redundant on the default surface and worth its
        //    own explicit opt-in.
        const OPT_IN_ONLY = new Set(["health", "score", "dossier"]);
        const has = (s) => allSections ? !OPT_IN_ONLY.has(s) : sections.includes(s);
        const result = {};
        const hints = [];
        // ADR-224 — section-level failures push to errors[] and the host
        // log pipeline (via pushSectionError) regardless of flag state.
        // The flag controls whether the public response advertises errors[]
        // and whether failed sections set the field to null vs the legacy
        // empty-fallback. Default OFF in Phase 1; flips to default-on at
        // Phase 4 after the soak window catches any caller depending on
        // the empty-array shape.
        const useStructured = process.env.ADR_224_STRUCTURED_ERRORS === "1";
        const errors = [];
        const TOOL_NAME = "pica_works_inspect";
        // Basic — always fetched (either via entityContext.getWorkFull or works.get).
        // Wrapped in try/catch so a primary-entity fetch error degrades into a
        // partial response with a sibling `*_error` field instead of throwing past
        // the whole inspect call (mirrors the recordings_inspect pattern at
        // mcp-server/src/tools/recordings.ts:450). 404 stays a hard error via
        // EntityNotFoundError propagation so the agent sees an explicit not-found
        // signal instead of a 200-with-empty-payload (ADR-198 doctrine).
        if (allSections) {
            try {
                const full = await this.pica.entityContext.getWorkFull(workId);
                const data = full;
                result.work = data?.work || data;
                result.credits = data?.credits;
                result.recordings = data?.recordings;
                result.agreements = data?.agreements;
            }
            catch (error) {
                if (error instanceof EntityNotFoundError)
                    throw error;
                // A 400 on the PRIMARY entity fetch is an invalid work id (upstream
                // VALIDATION_ERROR — e.g. a non-UUID id), not a degraded section.
                // Fail-softing it produced a success envelope with every section
                // null and the 400 buried in work_error — no isError, no recovery
                // hint (mcp-layered-discovery holdout red, 2026-07-18 cleanup).
                // Propagate like the 404 above so formatError surfaces a proper
                // error envelope + the pica_works_inspect recovery hint.
                if (error instanceof ApiError && error.status === 400)
                    throw error;
                if (!useStructured) {
                    result.work_error =
                        error instanceof Error ? error.message : "Failed to fetch work";
                }
                else {
                    result.work = null;
                }
                pushSectionError(errors, TOOL_NAME, "basic", error, workId);
            }
        }
        else if (has("basic")) {
            try {
                const work = await this.pica.works.get(workId);
                result.work = work;
                // Enrich with ISRC prefix intelligence if work has ISRC
                if (work?.isrc) {
                    try {
                        result.prefix_intel = await this.pica.recordings.prefixLookup(work.isrc);
                    }
                    catch {
                        /* best-effort */
                    }
                }
            }
            catch (error) {
                if (error instanceof EntityNotFoundError)
                    throw error;
                // A 400 on the PRIMARY entity fetch is an invalid work id (upstream
                // VALIDATION_ERROR — e.g. a non-UUID id), not a degraded section.
                // Fail-softing it produced a success envelope with every section
                // null and the 400 buried in work_error — no isError, no recovery
                // hint (mcp-layered-discovery holdout red, 2026-07-18 cleanup).
                // Propagate like the 404 above so formatError surfaces a proper
                // error envelope + the pica_works_inspect recovery hint.
                if (error instanceof ApiError && error.status === 400)
                    throw error;
                if (!useStructured) {
                    result.work_error =
                        error instanceof Error ? error.message : "Failed to fetch work";
                }
                else {
                    result.work = null;
                }
                pushSectionError(errors, TOOL_NAME, "basic", error, workId);
            }
        }
        // ADR-201 closeout (2026-04-27): the section sub-calls used to run
        // sequentially. With ~8 calls each taking ~1s and one (the org-wide
        // works-health scan `health` read until 2026-09-26)
        // sometimes 25s+, the function would frequently exceed Vercel's 30s
        // maxDuration on the default `allSections` path. Fan out everything
        // in parallel — each branch still owns its own try/catch fallback so
        // a single failing sub-call doesn't poison the whole inspect.
        const sectionTasks = [];
        // Targeted basic sections — only when caller listed them explicitly.
        if (!allSections) {
            if (has("credits") && !result.credits) {
                sectionTasks.push((async () => {
                    try {
                        result.credits = await this.pica.credits.listForWork(workId);
                    }
                    catch (e) {
                        result.credits = useStructured ? null : [];
                        pushSectionError(errors, TOOL_NAME, "credits", e, workId);
                    }
                })());
            }
            if (has("recordings") && !result.recordings) {
                sectionTasks.push((async () => {
                    try {
                        result.recordings = await this.pica.recordings.getByWork(workId);
                    }
                    catch (e) {
                        result.recordings = useStructured ? null : [];
                        pushSectionError(errors, TOOL_NAME, "recordings", e, workId);
                    }
                })());
            }
            if (has("agreements") && !result.agreements) {
                sectionTasks.push((async () => {
                    try {
                        result.agreements = await this.pica.agreements.list();
                    }
                    catch (e) {
                        result.agreements = useStructured ? null : [];
                        pushSectionError(errors, TOOL_NAME, "agreements", e, workId);
                    }
                })());
            }
        }
        // Quality sections — fan out in parallel. `health` and `completeness`
        // share ONE read of this work's completeness route.
        let completenessRead = null;
        const readCompleteness = () => (completenessRead ??= this.pica.health.getWorkCompleteness(workId));
        if (has("health")) {
            sectionTasks.push((async () => {
                try {
                    const c = await readCompleteness();
                    result.health = {
                        scope: "work",
                        work_id: workId,
                        score: c?.score ?? null,
                        missing_field_count: c?.missing_field_count ?? null,
                        missing_critical: c?.missingCritical ?? [],
                        suggestions: c?.suggestions ?? [],
                    };
                }
                catch (e) {
                    result.health = null;
                    pushSectionError(errors, TOOL_NAME, "health", e, workId);
                }
            })());
        }
        if (has("completeness")) {
            sectionTasks.push((async () => {
                try {
                    const completeness = await readCompleteness();
                    result.completeness = completeness;
                    hints.push(...mapGapsToHints(completeness?.gaps || completeness?.missing || []));
                    // ops_issue 1bc63a6d — only an instrumental/PA is attached. The
                    // candidates (unassigned files whose name matches the title) sit
                    // in completeness.audio.gap; they are for the user to confirm.
                    if (completeness?.audio?.checked === false) {
                        hints.push({
                            gap: "audio_check_unavailable",
                            suggestion: "PICA could not check this work's attached audio just now, so an 'only an instrumental is attached' gap may be missing from this answer.",
                            severity: "nice_to_have",
                        });
                    }
                    const vocalGap = completeness?.audio?.gap;
                    if (vocalGap && typeof vocalGap.label === "string") {
                        const ids = Array.isArray(vocalGap.candidates)
                            ? vocalGap.candidates
                                .map((c) => c.audio_file_id)
                                .filter(Boolean)
                            : [];
                        hints.push({
                            gap: "only_instrumental_attached",
                            suggestion: ids.length > 0
                                ? `${vocalGap.label} (${ids.join(", ")}). If the user confirms one is the vocal master, it can be attached with pica_audio_assign.`
                                : `${vocalGap.label}.`,
                            severity: "important",
                        });
                    }
                }
                catch (e) {
                    result.completeness = null;
                    pushSectionError(errors, TOOL_NAME, "completeness", e, workId);
                }
            })());
        }
        if (has("registration")) {
            sectionTasks.push((async () => {
                try {
                    result.registration =
                        await this.pica.registration.getWorkCascadeStatus(workId);
                }
                catch (e) {
                    result.registration = null;
                    pushSectionError(errors, TOOL_NAME, "registration", e, workId);
                }
            })());
        }
        if (has("score")) {
            sectionTasks.push((async () => {
                try {
                    result.score = await this.pica.picaScore.get();
                }
                catch (e) {
                    result.score = null;
                    pushSectionError(errors, TOOL_NAME, "score", e, workId);
                }
            })());
        }
        if (has("provenance")) {
            sectionTasks.push((async () => {
                try {
                    result.provenance =
                        await this.pica.analytics.provenanceWork(workId);
                }
                catch (e) {
                    result.provenance = null;
                    pushSectionError(errors, TOOL_NAME, "provenance", e, workId);
                }
            })());
        }
        // ADR-277 WS2 — per-field provenance: each non-null field's stakes tier +
        // attestation / corroboration flags. Included on the default all-sections
        // surface (like `provenance`), and selectable on its own via `sections`. A
        // derive failure degrades the section to null, never the whole inspect call.
        if (has("field_provenance")) {
            sectionTasks.push((async () => {
                try {
                    result.field_provenance =
                        await this.pica.health.fieldProvenanceWork(workId);
                }
                catch (e) {
                    result.field_provenance = null;
                    pushSectionError(errors, TOOL_NAME, "field_provenance", e, workId);
                }
            })());
        }
        if (has("enrichment")) {
            sectionTasks.push((async () => {
                try {
                    result.enrichment =
                        await this.pica.enrichment.getWorkEnrichmentStatus(workId);
                }
                catch (e) {
                    result.enrichment = null;
                    pushSectionError(errors, TOOL_NAME, "enrichment", e, workId);
                }
            })());
        }
        // ADR-156 op #3 Session B Task 5 — surface linked physical assets
        // so the inspect tool is discoverable from the work side.
        if (has("production_assets")) {
            sectionTasks.push((async () => {
                try {
                    result.production_assets =
                        await this.pica.works.listProductionAssets(workId);
                }
                catch (e) {
                    result.production_assets = useStructured ? null : [];
                    pushSectionError(errors, TOOL_NAME, "production_assets", e, workId);
                }
            })());
        }
        // ADR-289 Wave B1 item 1 — licensing/compliance fields, scoped separately
        // from the audit-trail `provenance` section. These fields already live
        // flat on the work object (work_licensing satellite, flattened by
        // flattenWorkSatellites server-side) — reuse `result.work` if a "basic"
        // fetch already ran (allSections, or "basic" explicitly requested
        // alongside "licensing"), otherwise fetch fresh.
        if (has("licensing")) {
            sectionTasks.push((async () => {
                try {
                    const work = result.work ??
                        (await this.pica.works.get(workId));
                    result.licensing = {
                        is_licensable: work?.is_licensable ?? null,
                        available_for_licensing: work?.available_for_licensing ?? null,
                        licensing_terms: work?.licensing_terms ?? null,
                        licensing_price_usd: work?.licensing_price_usd ?? null,
                        licensing_notes: work?.licensing_notes ?? null,
                        ai_disclosure: work?.ai_disclosure ?? null,
                        training_rights: work?.training_rights ?? null,
                        provenance_attestation: work?.provenance_attestation ?? null,
                    };
                }
                catch (e) {
                    result.licensing = null;
                    pushSectionError(errors, TOOL_NAME, "licensing", e, workId);
                }
            })());
        }
        // ADR-173 Decision 2 — every release this work appears on, with
        // its track position. Distinct from the `recordings` section,
        // which lists recordings regardless of release context.
        if (has("releases")) {
            sectionTasks.push((async () => {
                try {
                    result.releases = await this.pica.works.listReleases(workId);
                }
                catch (e) {
                    result.releases = useStructured ? null : [];
                    pushSectionError(errors, TOOL_NAME, "releases", e, workId);
                }
            })());
        }
        // Song dossier (2026-07-12, spec 2026-07-11 §7) — the full one-call
        // assembly of everything PICA holds about the song. Opt-in only (in
        // OPT_IN_ONLY above), so this branch only runs when the caller lists
        // "dossier" explicitly. The whole assembly degrades to null on failure —
        // never poisons the rest of the inspect. The PDF comes from
        // pica_export_song_dossiers.
        if (has("dossier")) {
            sectionTasks.push((async () => {
                try {
                    result.dossier = await this.pica.works.dossier(workId);
                }
                catch (e) {
                    result.dossier = null;
                    pushSectionError(errors, TOOL_NAME, "dossier", e, workId);
                }
            })());
        }
        await Promise.all(sectionTasks);
        // Compact text summary for the dossier section — the full payload rides
        // structuredContent.data.dossier; this one-liner orients the agent
        // without making it parse the JSON. Appended to the tool message below.
        const dossierSummary = summariseDossier(result.dossier);
        // ADR-193 Surface 2 — proactive peer-MCP hints when the flag is on
        // and a real gap exists. Additive on top of the CompletionHint path.
        const gap_hints = computeWorkGapHints(result);
        const withHints = gap_hints.length > 0 ? { ...result, gap_hints } : result;
        // ADR-224 — append errors[] to the public response only when the
        // structured-errors flag is on. In legacy mode, section failures are
        // still pushed to the errors[] array (so stderr observability fires
        // via pushSectionError) but the array is dropped from the response
        // shape so the breaking change ([] → null on failed sections) doesn't
        // ship until Phase 4's flip.
        const enriched = useStructured ? { ...withHints, errors } : withHints;
        return formatSuccess(`Work details${sections ? ` (sections: ${sections.join(", ")})` : ""}` +
            (dossierSummary ? `\n\n${dossierSummary}` : ""), enriched, hints);
    }
    /**
     * Create work
     */
    async createWork(args) {
        const work = await this.pica.works.create(args);
        // Generate completion hints for the newly created work
        const hints = [];
        if (work?.id) {
            try {
                const completeness = await this.pica.health.getWorkCompleteness(work.id);
                hints.push(...mapGapsToHints(completeness?.gaps || completeness?.missing || []));
            }
            catch {
                // Best-effort — don't fail the create
            }
            // Hint about AI disclosure if not set
            if (!args.ai_disclosure) {
                hints.push({
                    gap: "no_ai_disclosure",
                    suggestion: "Set AI disclosure — is this work human-made, AI-assisted, or AI-generated? (pica_works_update with ai_disclosure: none/assisted/generated)",
                    severity: "important",
                });
            }
        }
        // ADR-158 Plan C Phase 2 — append custody-claim hints so every new work
        // prompts the rights conversation by default.
        hints.push(...buildCustodyHints(work));
        // ADR-280 advisory version-as-work guard — detect if the new title looks
        // like a version of an existing work and suggest the correct modelling.
        // BEST-EFFORT: any error → proceed with normal success, no warning.
        // The guard runs AFTER the work is created and never blocks or rolls back.
        //
        // FIX (2026-06-27): replaced works.list({limit:500}) with a stem-targeted
        // works.search({query:stem, limit:50}). The /admin/works route caps list at
        // 100 rows, so a 438-work catalog silently skips the parent and the guard
        // never fires. A title-stem search returns a small, relevant set regardless
        // of catalog size, and includes the parent even on large orgs.
        let versionWarning;
        if (work?.id) {
            try {
                const parsed = parseVersionTitle(String(args.title ?? ""));
                if (parsed) {
                    const searchRes = await this.pica.works.search({
                        query: parsed.stem,
                        limit: 50,
                    });
                    const rawWorks = Array.isArray(searchRes)
                        ? searchRes
                        : (searchRes?.data ?? []);
                    const existing = rawWorks
                        .filter((w) => w.id !== work.id)
                        .map((w) => ({
                        id: String(w.id),
                        title: String(w.title ?? ""),
                        iswc: (w.iswc ?? null),
                        artistId: null, // SDK search() returns primary_artist (name), not ID
                    }));
                    const match = detectVersionOfExistingWork({
                        title: String(args.title ?? ""),
                        artistId: args.primary_artist_id ?? null,
                    }, existing);
                    if (match)
                        versionWarning = buildVersionWarning(match, String(work.id));
                }
            }
            catch {
                // Advisory only — never fail a create over a warning
            }
        }
        // Registration integrity floor (spec 2026-07-09, Task 9) — assess the
        // just-created work and attach both the rendered hints and the raw
        // gap block. Best-effort: never blocks or fails the create.
        // No dedup guard needed vs the getWorkCompleteness hints above (unlike
        // import.ts): the completeness route returns WorkCompletenessResult
        // (breakdown / missingCritical / suggestions — it has no `gaps` or
        // `missing` keys), so `mapGapsToHints(completeness?.gaps ||
        // completeness?.missing || [])` always maps [] and can never emit a
        // writer/credits hint that would duplicate WORK_NO_WRITER.
        const pressure = await buildIntegrityPressure(this.pica, "work", work.id);
        hints.push(...pressure.hints);
        // Spreadsheet-moment auto-twin (2026-07-10) — the SDK merges `twin`/
        // `twin_error` onto the create() response (see WorksResource.create).
        // Surface a completion hint when a twin recording was actually minted
        // so the agent knows a second grain exists and how to opt out next time.
        if (work.twin) {
            hints.push({
                gap: "twin_recording_created",
                suggestion: `also created the linked recording ${work.twin.id} — pass create_twin: false to skip`,
                severity: "nice_to_have",
            });
        }
        const result = formatSuccess("Work created successfully", { ...work, integrity: pressure.integrity }, hints);
        if (versionWarning && result.structuredContent) {
            Object.assign(result.structuredContent, versionWarning);
        }
        return result;
    }
    /**
     * Update work
     */
    async updateWork(args) {
        const { verbose, rest } = takeVerbose(args);
        const { id, ...updates } = rest;
        // Compact receipt (ops 61ce1dcc): read the gaps open BEFORE the write so
        // the answer can name only the ones this write opened. Skipped when the
        // caller asked for the full record, which repeats every gap anyway.
        const before = verbose
            ? null
            : await buildIntegrityPressure(this.pica, "work", id);
        const work = await this.pica.works.update(id, updates);
        // Registration integrity floor (Task 9) — same pressure rendering as
        // createWork, so an update that clears (or introduces) a gap keeps the
        // agent informed without a separate inspect round-trip.
        const pressure = await buildIntegrityPressure(this.pica, "work", work.id);
        if (verbose) {
            return formatSuccess("Work updated successfully", { ...work, integrity: pressure.integrity }, pressure.hints);
        }
        const openBefore = before?.integrity
            ? new Set(before.integrity.gaps.map((g) => g.code))
            : null;
        const { changed, unconfirmed } = changedFields(updates, work);
        return formatSuccess("Work updated successfully", {
            id: work?.id ?? id,
            changed,
            ...(unconfirmed.length > 0 ? { unconfirmed } : {}),
            integrity: slimIntegrity(pressure.integrity, openBefore),
            receipt: RECEIPT_NOTE,
        }, newHintsOnly(before?.integrity ? before.hints : null, pressure.hints));
    }
    /**
     * Delete work
     */
    async deleteWork(args) {
        const outcome = await this.pica.works.delete(args.id);
        return formatDeletion("work", args.id, outcome);
    }
    /**
     * pica_works_verify — writes nothing, and says so.
     *
     * It used to PATCH `is_verified: true` onto the work. `works` has no such
     * column (no applied migration adds one), so the route's allowed-field check
     * refused it on every call — its only two calls ever both failed (DonnySavv,
     * feedback 86e26f75, 2026-09-28). PICA keeps no work-level "metadata
     * verified" flag, and inventing one would record an agreement nobody made.
     * What a person CAN confirm lives on specific records, each refused to
     * agents and each done by the person on /inspect — this names them.
     */
    async verifyWork(args) {
        const workId = typeof args.id === "string" ? args.id.trim() : "";
        if (!workId) {
            return typedError("VALIDATION_ERROR", "id is required.");
        }
        return typedError("NO_WORK_VERIFIED_FLAG", "PICA does not keep a single 'verified' flag on a work, so nothing was written. " +
            "What a person can confirm, each while signed in to PICA (an assistant cannot do these for them): " +
            `(1) the human-made stamp — "stamp as human-made" at /inspect/works/${workId}#human-made; ` +
            `(2) their own writer credit — confirm it in the credits table on /inspect/works/${workId}; ` +
            '(3) their own master ownership share — "confirm my share" under ownership splits at ' +
            "/inspect/recordings/<recording_id>#ownership-splits. " +
            "To review what is missing or unconfirmed on the work, use pica_works_inspect.");
    }
    /**
     * Bulk delete works
     */
    async bulkDeleteWorks(args) {
        if (args.dry_run === true) {
            const ids = Array.isArray(args.ids) ? args.ids : [];
            return formatSuccess(`Dry run — would delete ${ids.length} work(s)`, {
                dry_run: true,
                would_affect: ids,
                target_count: ids.length,
            });
        }
        await this.pica.works.bulkDelete(args.ids);
        return formatSuccess(`${args.ids.length} works deleted successfully`);
    }
    /**
     * Bulk reclassify works as live performances + delete source work rows
     */
    async bulkMoveToPerformances(args) {
        if (args.dry_run === true) {
            const ids = Array.isArray(args.ids) ? args.ids : [];
            return formatSuccess(`Dry run — would reclassify ${ids.length} work(s) as live performances and delete the source rows`, {
                dry_run: true,
                would_affect: ids,
                target_count: ids.length,
            });
        }
        const result = await this.pica.works.bulkMoveToPerformances(args.ids);
        return formatSuccess(`Reclassified ${args.ids.length} work(s) to performances`, result);
    }
    /**
     * Stamp a human-creation provenance attestation. Wraps the SDK call;
     * the route handles authUserId → user_profiles.id resolution and the
     * signature hashing.
     */
    async attestWork(args) {
        const workId = args.id;
        if (!workId) {
            return {
                content: [{ type: "text", text: "id is required." }],
                isError: true,
            };
        }
        try {
            const result = await this.pica.works.attest(workId, {
                method: args.method,
                supporting_evidence: args.supporting_evidence,
                signature: args.signature,
            });
            const att = result.attestation;
            const methodSummary = att?.attestation_method ?? "self_declaration";
            const sigSummary = att?.signature_hash
                ? ` with signature ${att.signature_hash.slice(0, 12)}…`
                : "";
            return formatSuccess(`Provenance attestation stamped (${methodSummary})${sigSummary}`, result);
        }
        catch (err) {
            const errorMessage = err instanceof Error ? err.message : String(err);
            return {
                content: [
                    {
                        type: "text",
                        text: `Failed to stamp provenance attestation: ${errorMessage}`,
                    },
                ],
                isError: true,
            };
        }
    }
    // ADR-264 WS2 — verify-before-assert for registration gaps.
    async verifyRegistrations(args) {
        const result = await this.pica.works.verifyRegistrations({
            work_ids: args.work_ids,
            mode: args.mode,
            ...(args.society_code ? { society_code: args.society_code } : {}),
        });
        const s = (result?.summary ?? {});
        const parts = [];
        if (s.found_registered)
            parts.push(`${s.found_registered} found registered (positive fact written)`);
        if (s.verified_absent)
            parts.push(`${s.verified_absent} verified absent`);
        if (s.confirmed)
            parts.push(`${s.confirmed} confirmed unregistered`);
        if (s.already_registered)
            parts.push(`${s.already_registered} already registered`);
        if (s.lookup_failed)
            parts.push(`${s.lookup_failed} lookup failed (no assertion written)`);
        if (s.insufficient_identifiers)
            parts.push(`${s.insufficient_identifiers} lacked identifiers to search by`);
        if (s.not_found)
            parts.push(`${s.not_found} not found in your catalog`);
        // ADR-267 Rule 3 — when a check leaves works unverified, frame the
        // confirm_unregistered follow-up as a calibrated question instead of
        // leaving the agent to invent one. confirm mode IS the resolution, so
        // it never carries a decision.
        const unresolved = args.mode === "check"
            ? (s.lookup_failed ?? 0) + (s.insufficient_identifiers ?? 0)
            : 0;
        const decision = unresolved > 0
            ? buildDecision({
                question: `we couldn't verify ${unresolved} of these with the MLC — how would you like to leave them?`,
                options: [
                    {
                        label: "leave them as open questions",
                        consequence: "nothing is recorded — you can run the check again another time",
                    },
                    {
                        label: "record your own confirmation",
                        consequence: "they're marked unregistered on your word rather than a lookup — run this verification again in confirm mode",
                    },
                ],
                safe_default: "leave them as open questions",
            })
            : null;
        return formatSuccess(parts.length > 0
            ? `Registration verification: ${parts.join(", ")}.`
            : "Registration verification ran — no works processed.", decision ? { ...result, decision } : result);
    }
}
//# sourceMappingURL=works.js.map