UNPKG

@withpica/mcp-server

Version:

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

638 lines • 44.4 kB
// Copyright (c) 2025-2026 Withpica Ltd. All rights reserved. import { formatSuccess, formatStructuredList, CLAIM_CREDIT_WRITE_PROPERTIES, CLAIM_CREDIT_REQUIRED, CLAIM_CUSTODY_WRITE_PROPERTIES, CLAIM_CUSTODY_REQUIRED, CLAIM_ARTIST_WRITE_PROPERTIES, CLAIM_ARTIST_REQUIRED, } from "@withpica/mcp-utils"; /** * ADR-267 Rule 2 — one ready-to-say sentence, lowercase en-GB, curiosity * register only: briefing counts are unverified gaps, so no loss framing. * Additive: derived entirely from critical_issues_total. * * `isEmptyCatalog` (org has 0 works AND 0 recordings) overrides ALL other * branches, including a nonzero critical_issues_total — an empty catalog * isn't a clean one, it's one with no on-ramp yet, and there's nothing more * useful to say to an agent sitting in front of an empty org than how to * import first (ADR-294 Decision 2, import-first invariant; originally * P0-2, 2026-07-04 discoverability audit). Names the fastest concrete next * tool rather than leaving the agent to guess, and is explicit that only * album/playlist links are supported — artist links are not. */ /** The empty-catalog import lead (ADR-294 Decision 2) — exported so tests * assert the exact string (toBe) instead of scattered substring matches. */ export const EMPTY_CATALOG_USER_MESSAGE = "your catalog is empty. paste a spotify album or playlist link (or a youtube link) and it imports right now: pica_import_streaming_link / pica_import_youtube_link. artist links are not supported, ask for an album or playlist. csv or statements: load the import-my-catalog skill."; /** The empty-audio masters lead (audio-into-activation punch list item 7, * 2026-07-25) — the catalog exists but is silent, and agents can't upload * files, so the concrete next step is asking the user to upload, then * matching. Unlike the empty-catalog lead this only REPLACES THE ALL-CLEAR: * zero audio is the steady state for most orgs and blocks nothing, so it * must never displace real actionable issues (reviewer, 2026-07-25). * Exported so tests assert the exact string (toBe), same as the const * above. */ export const EMPTY_AUDIO_USER_MESSAGE = 'the catalog is in but no audio masters are attached — nothing can play, be identified or analysed yet. ask the user to upload their masters in /inspect (the top-bar upload takes a whole folder); pica then matches files to recordings automatically. review matches with pica_audio_query {"unassigned": true} and attach them with pica_audio_bulk_assign_suggested.'; export function composeBriefingUserMessage(criticalIssuesTotal, isEmptyCatalog = false, isEmptyAudio = false) { if (isEmptyCatalog) { return EMPTY_CATALOG_USER_MESSAGE; } if (criticalIssuesTotal === null) return null; if (criticalIssuesTotal === 0 && isEmptyAudio) { return EMPTY_AUDIO_USER_MESSAGE; } if (criticalIssuesTotal === 0) { return "nothing in your catalog needs attention right now — all clear."; } if (criticalIssuesTotal === 1) { return "1 thing in your catalog is worth a look when you're ready — want to start there?"; } return `${criticalIssuesTotal} things in your catalog are worth a look when you're ready — want to start with the one most worth your time?`; } export class DashboardTools { pica; constructor(pica) { this.pica = pica; } getTools() { return [ // --- Discoveries --- { definition: { name: "pica_discoveries_query", scope: "read:catalog", audiences: ["stdio", "http", "public"], tier: "read", description: "List pending discoveries in your catalog across credits, custody, " + "and artist links. Each row includes a `type` field " + "('credit' | 'custody' | 'artist') plus the discovery-specific " + "fields. Use the row id with the matching pica_claim_* tool to " + "resolve. Sorted by created_at DESC.", workflows: ["claim-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: { status: { type: "string", enum: ["all", "pending", "reviewed"], description: "Filter by status: 'all' for everything, 'pending' for items needing review, 'reviewed' for already-handled (default: all)", }, limit: { type: "number", minimum: 1, maximum: 100, description: "Max results (default 50)", }, }, }, }, executor: this.queryDiscoveries.bind(this), }, { definition: { name: "pica_discoveries_review", scope: "write:catalog", audiences: ["stdio", "http", "public"], tier: "write", description: "Accept or reject a pending discovery decided IN-CHAT (the user tells you accept/reject here " + "and now). Accepted discoveries update your catalog automatically. To claim a discovery AS the " + "user instead, use the matching pica_claim_* tool named in pica_discoveries_query's row.", workflows: ["claim-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: { discovery_id: { type: "string", description: "Discovery UUID from pica_discoveries_query.", }, status: { type: "string", enum: ["accepted", "rejected"], description: "Accept or reject the discovery", }, }, required: ["discovery_id", "status"], }, }, executor: this.reviewDiscovery.bind(this), }, // --- ADR-189 Phase 4: Activation-loop claim tools --- { definition: { name: "pica_claim_credit", scope: "admin", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'I want to claim my credit on X', 'add me as a writer on X', 'credit me on this song'. " + "Claim a discovered songwriting / publishing credit into your catalog. " + "Marks the pending discovery as claimed (a status change — no credit is " + "written), giving you 'view' access to the linked work. " + "Rate-limited to 20 claims per hour. Idempotency: re-claiming an already-resolved " + "discovery returns DISCOVERY_ALREADY_RESOLVED — re-run discovery first. " + "For a discovered ownership candidate rather than a credit, use pica_claim_custody.", annotations: { title: "claim discovered credit", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, workflows: ["claim-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: CLAIM_CREDIT_WRITE_PROPERTIES, required: [...CLAIM_CREDIT_REQUIRED], }, }, executor: this.claimCredit.bind(this), }, { // ADR-226 Phase 3 — Atlas authoritative. If post-rewrite eval still // shadows this with pica_custody_claim, Phase 7 lint will flag for // custody domain owner to confirm canonical. definition: { name: "pica_claim_custody", scope: "admin", audiences: ["stdio", "http", "public"], tier: "write", description: "Use when the user asks: 'claim custody', 'take over X'. " + "Claim a discovered custody/ownership candidate (composition or master side) for a work. " + "Turns the pending discovery into a pending custody claim and schedules a +72h " + "silent-consent auto-approve. The current custodian keeps the catalog entry " + "until the T+72h window elapses. " + "Rate-limited to 20 claims per hour. " + "Idempotency: re-claiming an already-resolved discovery returns DISCOVERY_ALREADY_RESOLVED -- " + "re-run discovery first. " + "For a discovered songwriting or publishing credit instead, use pica_claim_credit.", annotations: { title: "claim discovered custody", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, workflows: ["claim-required"], vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: CLAIM_CUSTODY_WRITE_PROPERTIES, required: [...CLAIM_CUSTODY_REQUIRED], }, }, executor: this.claimCustody.bind(this), }, { definition: { name: "pica_claim_artist", scope: "admin", audiences: ["stdio", "http", "public"], tier: "write", description: "Claim a discovered artist link on a pending discovery. " + "INSTANT path: identity evidence was validated at discovery time, so this " + "records the artist claim on the work and adds you as a performer on the " + "recording in one go — no +72h waiting window. Requires your identity to " + "already be linked via pica_update_my_identity. " + "Rate-limited to 20 claims per hour. " + "Refuses with ADMIN_REVIEW_IN_PROGRESS when an open review already exists " + "for the same work and person — resolve the review first. " + "Idempotency: re-claiming an already-resolved discovery returns " + "DISCOVERY_ALREADY_RESOLVED -- re-run discovery first. " + "For a songwriting or publishing credit instead, use pica_claim_credit.", annotations: { title: "claim discovered artist", readOnlyHint: false, destructiveHint: false, idempotentHint: false, openWorldHint: false, }, workflows: ["claim-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: CLAIM_ARTIST_WRITE_PROPERTIES, required: [...CLAIM_ARTIST_REQUIRED], }, }, executor: this.claimArtist.bind(this), }, // --- Dashboard --- { definition: { name: "pica_dashboard_attention", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'what's wrong with my songs?', 'what's broken?', 'what needs fixing?', 'what should I do?', 'anything urgent?'. " + "What needs my attention? Shows items requiring action — " + "incomplete works, missing credits, unregistered works, " + "pending audio analysis, unenriched writers, disputed credits, " + "and catalog-hygiene items (placeholder credits, artefact rows, concatenated entries, publishers stored as people). " + "Each row includes class, entity_id, entity_type, entity_label, failure_mode, urgency {score, components, model_version}, drill_down_skill. " + "works_without_registrations rows additionally carry evidence {state, verified_via?, checked_at?}: " + "'unverified_absent' = registration status unknown — confirm with pica_works_verify_registrations, or record what you know; " + "'verified_absent' = PICA checked the MLC and found no registration (verified_via + checked_at present); " + "'user_confirmed_absent' = the user confirmed the work is unregistered. " + "Use the class filter to narrow to a single issue type (specialist skills use this to start work from the top of their own queue). " + "For inbox/respond items (bookings, license inquiries, contacts), use the door='respond' param instead.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { class: { type: "string", enum: [ "works_without_credits", "works_without_registrations", "audio_pending", "unenriched_writers", "recordings_without_splits", "disputed_credits", "placeholder_credits", "people_records_need_cleanup", "concatenated_entries", "publishers_in_people_table", "unconnected_collaborators", "messy_work_titles", ], description: "Filter to a single issue class. Omit to get the highest-urgency items across all classes. Each class targets a specific entity type and pattern — read scopes before assuming under-detection: " + "works_without_credits (works with zero credits — no work_collaborators rows); " + "works_without_registrations (released works with no PRO registration evidence); " + "audio_pending (audio_files awaiting analysis); " + "unenriched_writers (people credited as writer/composer with no IPI/ISNI/MBID); " + "recordings_without_splits (recordings with no active recording_splits row — a recording whose shares have all ended has no current owner); " + "disputed_credits (credits with attestation_status='disputed' — work_collaborators / recording_credits); " + "placeholder_credits (people rows where record_kind='placeholder' — stage names or generic role tags with no further identity); " + "people_records_need_cleanup (people rows where record_kind='artefact' and the name contains no join character); " + "concatenated_entries (people rows where record_kind='artefact' AND the name field contains '/', '&', or ',' — multi-person rows merged into one record; targets people only, NOT work titles — use messy_work_titles for those); " + "publishers_in_people_table (people rows whose name matches a corporate-pattern heuristic); " + "unconnected_collaborators (people credited on the org's works with no linked PICA account); " + "messy_work_titles (works whose title is >150 chars or contains a newline — lyrics/notes pasted into the title field).", }, limit: { type: "number", minimum: 1, maximum: 200, description: "Max rows (default 20)", }, door: { type: "string", enum: ["respond", "catalog"], description: "Legacy: focus area for inbox-style items. Use 'respond' for bookings/license/contacts, 'catalog' for catalog quality. When class is supplied, door is ignored.", }, }, }, }, executor: this.attentionItems.bind(this), }, { definition: { name: "pica_dashboard_briefing", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'what's new?', 'any updates?', 'catch me up', 'what's been happening?'. " + "Get your daily briefing — a summary of catalog health, " + "recent activity, upcoming deadlines, and recommended actions. " + "Includes an `organisation` block identifying which organisation/account this briefing reads — `id`, `name`, and `works` + `recordings` counts (the account your data lives in, NOT the /inspect UI). Surface it (e.g. 'organisation: <name> — <works> works') so the user can confirm they're connected to the right catalog; a near-empty count on an organisation the user doesn't recognise means the connection is bound to the wrong organisation (reconnect under the correct one). " + "Includes an `agent_activity` block with the count of AI tool calls in the last 7 days broken down by tier (read | draft | write | destructive). " + "Includes an `agent_velocity` block with workflow_outcomes events in the last 7 days — `count_7d` total, `by_kind` map (workflow_completed / milestone_hit / gate_shadow_miss / rate_limit_hit etc.), and `top_tools` (top 5 by occurrence). Complements `agent_activity`: where activity is call-count-by-tier, velocity is what-got-done-by-kind. " + "Includes an `unconnected_people` block with a `count` headline plus a `breakdown` decomposing into `ready` (have contact details), `needs_lookup` (cross-tabbed by record_kind so the placeholder/artefact distribution is visible), and `needs_cleanup` (non-individual rows). " + "Includes a `carbon` block with catalog total streams + kg CO2e via DIMPACT 2024 methodology when streams have been tracked (omitted for first-month creators with zero analytics). Full breakdown lives on pica_carbon_footprint. " + "Includes a `narrative` block — `opener` (one short sentence), `highlights` (1-3 observation strings drawn from the top insights), and `next_question` (a calibrated open question with an `or` alternative). Use it when rendering the briefing conversationally; the structured blocks above remain canonical for programmatic consumers. " + "Includes a `found` block — `{ total, newSince, groups[] }`: matches PICA has discovered and is HOLDING for the user's review at /inspect/found (a recording of theirs on YouTube, a writer split filed at the MLC). This is the one part of the briefing that gives rather than asks, so surface it BEFORE the problem counts when total > 0 — e.g. 'pica found 12 things waiting for you'. Then: pica_enrichment_proposals_list to show them, pica_enrichment_proposals_bulk_apply to accept a reviewed batch. `total: 0` means checked-and-empty; a missing block means the briefing could not check. `notCounted: [source keys]` (when present) names checks that run only when /inspect/found is opened, because counting them reads the whole catalogue (e.g. `duplicates` = people listed twice): never report those as none, say they are checked on the found page. " + "`held: { count, entities }` is what pica found but is HOLDING because the org is past its free runway or over its tab: say it plainly — 'pica found 14 more things across 6 works, held' when `found.total > 0`, otherwise 'pica found 14 things across 6 works, held' — and point at residency at /inspect/found; never call it an error. " + "`billing` (present only while billing enforcement is on): when `billing.tab` is a string, say the tab total once and plainly, and that settling it clears what pica is holding; when `billing.pay` names a tool, offer to call it for the user: it mints a pay link and never charges on its own. never call any of this an error. " + "May include a `joined_pica` block — `{ count, people[{ person_id, name, since }], next_tool, note }`: people this catalogue holds who joined PICA and have not been invited to claim their profile yet (owners and admins only). Mention them and offer to prepare invites with pica_profile_claims_invite; from a connected app the invite is sent only after an owner or admin approves it. " + "May include a `stated_goal` block — `goal` (the org's living outcome statement, saved by agents via pica_memory_save under the reserved org-scoped `org_intent` key), `updated_at`, and `onboarding_intent` (the coarse signup-time intent). When present, open by continuing the goal, never re-asking it; when absent, the first-session skill's Phase 0 covers how to learn it.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: {}, }, }, executor: this.briefing.bind(this), }, // --- Catalog health (ADR-272) --- { definition: { name: "pica_catalog_health", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'how healthy is my catalog?', 'what should I clean up first?', 'how complete is my data?', 'what's messy?'. " + "Returns an org-scoped catalog health verdict across two dimensions — " + "completeness (missing identifiers / fields) and cleanliness (placeholder, " + "artefact, and concatenated rows). " + "Returns `score` (the mean of the non-null dimension rates) and `dimensionScores` (0-100 pass rates — entities passing ÷ entities considered; `dimensionConsidered` carries each denominator; or null when a dimension " + "could not be assessed), `assessed` / `notAssessed` (check keys that ran " + "vs. could not — an unassessed check is honest UNKNOWN, never a clean pass), " + "and a severity-then-count-ranked `items` punch-list. Each item carries " + "{grain, issue, dimension, severity, count, sampleIds (capped), truncated, " + "needsScope, actionableByCaller} — act on the actionableByCaller=true items " + "first. Reports only — for concrete fix-actions, use pica_catalog_health_plan.", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: {}, }, }, executor: this.catalogHealth.bind(this), }, // --- Catalog health runbook (ADR-273) --- { definition: { name: "pica_catalog_health_plan", scope: "read:analytics", audiences: ["stdio", "http", "public"], tier: "read", description: "Use when the user asks: 'what can be fixed automatically?', 'give me a fix plan for my catalog', 'what actions will improve my health score?', 'preview what you'd change'. " + "Preview a scope-gated plan to drive catalog health toward a target score; returns concrete fix-actions (auto vs propose), what's not actionable, and a planToken. " + "Mutates nothing — pair with pica_catalog_health_fix to apply. " + "Each action carries actionId, issue, actionKind, disposition (auto | propose), destructive (boolean), needsScope, actionableByCaller, entityRefs[], and preview. " + "Pass `targetScore` (0-100) to focus on the gap between current and target; omit to get the full plan. " + "Pass `only` (array of actionKind strings) to restrict which action classes are planned. " + "Plans can include merge_pair actions for duplicate releases (same UPC) and normalize_work_splits actions (proportionally rescale a work's collaborator splits to sum 100 — propose-only, preview shows per-collaborator before→after).", workflows: "infrastructure", vernacular_kind: "creator-entry", inputSchema: { type: "object", properties: { targetScore: { type: "number", description: "Target health score (0-100). Plan prioritises actions that close the gap. Omit to plan all actionable issues.", }, only: { type: "array", items: { type: "string" }, description: 'Restrict the plan to these actionKind values (e.g. ["apply_template_splits", "resolve_person"]). Omit to plan all action classes.', }, }, }, }, executor: this.catalogHealthPlan.bind(this), }, { definition: { name: "pica_catalog_health_fix", scope: "write:catalog", audiences: ["stdio", "http"], tier: "write", description: "Apply accepted actions from a pica_catalog_health_plan. " + "`planToken` is required (from the plan response). " + '`accept` is a list of actionIds to apply — use the shorthand "all_safe_auto" to apply all auto-disposition non-destructive actions without naming them individually. ' + 'Destructive actions (destructive=true) must be named individually in `accept`; they are never applied via the "all_safe_auto" shorthand. ' + "Returns honest per-action met/unmet results plus a fresh catalog health verdict. " + "Per-action scope gating and decide-before-mutate: if the caller lacks the required scope for an action, that action is skipped with reason=scope_denied rather than failing the whole call. " + "normalize_work_splits recomputes the rescale from live rows at apply time; merge_pair release grain merges via the same engine as pica_merge_duplicates. " + "resolve_person actions need a human choice: pass them in `selections` as {actionId, chosenPersonId} where chosenPersonId is one of the action's preview.candidates[].personId — a bare id in `accept` is skipped as stale because nothing was chosen.", workflows: "infrastructure", vernacular_kind: "specialist", vernacular_reason: "Invoked after pica_catalog_health_plan to apply selected fix-actions; not a catalog-browsing entry point.", inputSchema: { type: "object", properties: { planToken: { type: "string", description: "Token from a prior pica_catalog_health_plan call. Plans expire; if stale, call pica_catalog_health_plan again.", }, accept: { type: "array", items: { type: "string" }, description: 'List of actionIds to apply, or the shorthand "all_safe_auto" (applies all auto-disposition non-destructive actions). Destructive actions must be named individually. Pass [] when every action you want is in selections.', }, selections: { type: "array", description: "Choices for actions that need one. resolve_person: {actionId, chosenPersonId} — chosenPersonId must be a personId from that action's preview.candidates; anything else is refused (guard_blocked). A selection accepts its action, so it need not be repeated in accept.", items: { type: "object", properties: { actionId: { type: "string", description: "The resolve_person action id from the plan.", }, chosenPersonId: { type: "string", description: "The candidate person to link the credit to (preview.candidates[].personId).", }, }, required: ["actionId", "chosenPersonId"], }, }, }, required: ["planToken", "accept"], }, }, executor: this.catalogHealthFix.bind(this), }, ]; } async claimCredit(args) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const pica = this.pica; const result = await pica.discoveries.claimCredit(args.id); return formatSuccess(result.is_first_claim ? "first credit claimed — welcome to your catalog" : "credit claimed", result); } async claimCustody(args) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const pica = this.pica; const result = await pica.discoveries.claimCustody(args.id); return formatSuccess(result.is_first_claim ? "first custody claim submitted — auto-approves in 72h unless disputed" : "custody claim submitted — auto-approves in 72h unless disputed", result); } async claimArtist(args) { // eslint-disable-next-line @typescript-eslint/no-explicit-any const pica = this.pica; const result = await pica.discoveries.claimArtist(args.id); return formatSuccess(result.is_first_claim ? "first artist claim confirmed — welcome to your catalog" : "artist claim confirmed", result); } async queryDiscoveries(args) { const status = args.status || "all"; // "pending" delegates to the dedicated pending endpoint if (status === "pending") { const result = await this.pica.dashboard.pendingDiscoveries(); const items = result?.data || result || []; return formatStructuredList(Array.isArray(items) ? items : [], "pending discovery"); } // "all" or "reviewed" use the general discoveries endpoint const result = await this.pica.dashboard.discoveries({ unread: status === "all" ? undefined : false, limit: args.limit, }); const items = result?.data || result || []; const unreadCount = result?.unreadCount ?? 0; return formatStructuredList(Array.isArray(items) ? items : [], "discovery", { unreadCount }); } async reviewDiscovery(args) { const result = await this.pica.dashboard.reviewDiscovery(args.discovery_id, args.status); return formatSuccess(`Discovery ${args.discovery_id} ${args.status}`, result); } async attentionItems(args) { // ADR-242: When no door is supplied (default), route to the new // actionable-items endpoint which is backed by the shared // getActionableIssues() source of truth. This ensures coherence with // pica_dashboard_briefing.critical_issues_total and // pica_count_explain.sources[].count. // // AC-6 requires that an org with critical_issues_total=0 returns an // empty rows array — so we do NOT fall through to the legacy // attention-items endpoint when actionable-items returns empty. The // legacy endpoint serves a different surface (booking/license/contact // inbox items under door='respond') and would violate that invariant. // // Callers who want the legacy inbox/catalog flow must pass door // explicitly. When class is supplied, door is ignored. if (args.class || !args.door) { const result = await this.pica.dashboard.actionableItems({ class: args.class, limit: args.limit, }); const rows = result?.data?.rows || result?.rows || []; return formatStructuredList(Array.isArray(rows) ? rows : [], "actionable item"); } // Legacy path: door-based inbox/catalog items. Only reached when the // caller passed door explicitly (without class). const result = await this.pica.dashboard.attentionItems({ door: args.door, limit: args.limit, }); const items = result?.data?.items || result?.items || []; return formatStructuredList(Array.isArray(items) ? items : [], "attention item"); } async briefing(_args) { const result = await this.pica.dashboard.briefing(); const insights = result?.data?.insights || result?.insights || result; // ADR-230 AC-8 — surface the agent_activity by-tier block // alongside insights. The route returns it under `data.agent_activity`; // include both shapes to tolerate the SDK passing through a flatter // payload from older deploys during rollout. const agentActivity = result?.data?.agent_activity ?? result?.agent_activity ?? null; // DIMPACT carbon section — present only when the org has tracked // streams. Same dual-shape tolerance as agent_activity for rollout. const carbon = result?.data?.carbon ?? result?.carbon ?? null; // ADR-242 coherence field — sum of all actionable issue counts from // the shared getActionableIssues() source of truth. const criticalIssuesTotalRaw = result?.data?.critical_issues_total ?? result?.critical_issues_total ?? null; const criticalIssuesTotal = typeof criticalIssuesTotalRaw === "number" ? criticalIssuesTotalRaw : null; // ADR-244 PR (b) §3.5 — unconnected-people headline + column-derived // breakdown. Same dual-shape tolerance as agent_activity for rollout. const unconnectedPeople = result?.data?.unconnected_people ?? result?.unconnected_people ?? null; // ADR-247 PR (b) — agent_velocity block (workflow_outcomes events, // by kind + top tools, trailing 7d). Complements agent_activity. const agentVelocity = result?.data?.agent_velocity ?? result?.agent_velocity ?? null; // Conversational narrative wrapper (#2) — opener + highlights[] + // next_question. Composed server-side from the same blocks above so // agents can render the briefing as a conversation rather than a // data dump. Dual-shape tolerance for rollout. const narrative = result?.data?.narrative ?? result?.narrative ?? null; // Organisation identity block — which org/account this briefing reads (id + // name + works/recordings counts). Lets the agent state "organisation: // <name> (<n> works)" so a wrong-org OAuth binding is obvious on the first // reply instead of looking like an empty catalog. Dual-shape rollout // tolerance. (Named "organisation", not "workspace", to avoid colliding // with the retired /workspace → /inspect UI surface.) const organisation = result?.data?.organisation ?? result?.organisation ?? null; // Item 5b (agent intake) — the org's stated goal (reserved // assistant_memory org_intent) + onboarding cold-start prior. Surfacing // it here is what lets first-session Phase 0 continue the goal instead // of re-asking. Dual-shape rollout tolerance like the blocks above. const statedGoal = result?.data?.stated_goal ?? result?.stated_goal ?? null; // Registration integrity floor (spec 2026-07-09, Task 7 route wiring, // Task 9 tool wiring) — the persistent nag surface. The route always // sends the key (never omitted): `null` means the assessor itself // failed (route-side warn-logged), a populated block means "assessed, // here's what's outstanding." Dual-shape rollout tolerance, same as // the other blocks above. const integrity = result?.data?.integrity ?? result?.integrity ?? null; // What PICA found and is holding for review — `{ total, newSince, // groups[], notCounted? }` from getFoundSummary, which reads through the // FOUND_SOURCES registry (so a second source appears here without touching // this file), except an on-demand source (FoundSource.onDemandOnly), which // is named in `notCounted` instead of counted. // // The briefing answers "what needs your attention" and this was the one // thing it never mentioned, despite being the only thing PICA does that // GIVES rather than asks: 1,069 findings sat unseen across 34 orgs on // 2026-08-16, and the single org that ever drained a batch found the queue // on its own through this very connector, with no prompt from anywhere. // Dual-shape rollout tolerance like the blocks above. const found = result?.data?.found ?? result?.found ?? null; // ADR-267 Rule 1 channel (b) — one ready-to-say sentence, lowercase // en-GB, curiosity register only (counts are unverified gaps, no loss // framing per Rule 2). Omitted entirely when criticalIssuesTotal is // null so older deploys that don't send the total stay clean. const isEmptyCatalog = organisation?.works === 0 && organisation?.recordings === 0; // Catalog present but silent — zero masters attached (item 7). Strict // `=== 0` keeps dual-shape rollout tolerance: older deploys that don't // send audio_files leave it undefined and degrade to the count branches. const isEmptyAudio = !isEmptyCatalog && organisation?.audio_files === 0; const userMessage = composeBriefingUserMessage(criticalIssuesTotal, isEmptyCatalog, isEmptyAudio); return formatSuccess("Daily briefing", { ...(organisation ? { organisation } : {}), insights, ...(criticalIssuesTotal !== null ? { critical_issues_total: criticalIssuesTotal } : {}), ...(userMessage ? { user_message: userMessage } : {}), ...(agentActivity ? { agent_activity: agentActivity } : {}), ...(agentVelocity ? { agent_velocity: agentVelocity } : {}), ...(unconnectedPeople ? { unconnected_people: unconnectedPeople } : {}), ...(carbon ? { carbon } : {}), ...(statedGoal ? { stated_goal: statedGoal } : {}), ...(narrative ? { narrative } : {}), // Always present when the route sent it, INCLUDING total: 0 — the // difference between "nothing waiting" and "we did not look" is exactly // what an agent needs to say something honest. `notCounted` (passed // through) names the checks that were not looked at here. ...(found ? { found } : {}), integrity, }); } /** * ADR-272 — catalog health verdict. The SDK `health.catalogHealth()` * already unwraps the route's `{ success, data }` envelope to the verdict * object (BaseResource.request → `data.data || data`), so the result IS * the verdict and is returned as-is under formatSuccess's `data`. */ async catalogHealth(_args) { const verdict = await this.pica.health.catalogHealth(); return formatSuccess("Catalog health verdict", verdict); } /** * ADR-273 — catalog health plan. Returns a scope-gated set of fix-actions * plus a planToken. Mutates nothing; pair with catalogHealthFix to apply. */ async catalogHealthPlan(args) { const plan = await this.pica.health.catalogHealthPlan({ targetScore: args.targetScore, only: args.only, }); return formatSuccess("Catalog health plan", plan); } /** * ADR-273 — catalog health fix. Applies accepted actions from a prior plan. * Returns honest per-action met/unmet plus a fresh verdict. */ async catalogHealthFix(args) { const result = await this.pica.health.catalogHealthFix({ planToken: args.planToken, accept: args.accept, ...(Array.isArray(args.selections) && args.selections.length > 0 ? { selections: args.selections } : {}), }); return formatSuccess("Catalog health fix applied", result); } } //# sourceMappingURL=dashboard.js.map