@withpica/mcp-server
Version:
MCP Server for withPICA — enables AI assistants to interact with your withPICA music catalog
638 lines • 44.4 kB
JavaScript
// 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