diff --git a/.agents/plugins/marketplace.json b/.agents/plugins/marketplace.json new file mode 100644 index 0000000..8473a2d --- /dev/null +++ b/.agents/plugins/marketplace.json @@ -0,0 +1,92 @@ +{ + "name": "recoup", + "description": "Recoup's marketplace of AI agent skills and plugins for the music industry.", + "owner": { + "name": "Recoup", + "email": "agent@recoupable.com", + "url": "https://recoupable.com" + }, + "interface": { + "displayName": "Recoup" + }, + "plugins": [ + { + "name": "recoup-skills", + "description": "Music-industry skills for AI agents — artist management, songwriting, analytics, release campaigns, research, content creation.", + "source": { + "source": "local", + "path": "./" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Music", + "keywords": [ + "music", + "artist-management", + "songwriting", + "music-marketing", + "analytics", + "release-management", + "chartmetric", + "content-creation", + "agent-skills" + ], + "interface": { + "displayName": "Recoup Skills", + "shortDescription": "Music industry skills for AI agents", + "longDescription": "Teach your coding agent how to manage artists, write songs, analyze music analytics, plan releases, and create content. General skills work for anyone; platform skills integrate with the Recoup platform.", + "developerName": "Recoup", + "category": "Music", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://recoupable.com", + "brandColor": "#345A5D" + } + }, + { + "name": "music-catalog-diligence", + "description": "Review royalties, rights, valuation, and deal materials for music catalog transactions.", + "source": { + "source": "local", + "path": "./plugins/music-catalog-diligence" + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL" + }, + "category": "Music", + "keywords": [ + "music", + "catalogs", + "diligence", + "royalties", + "music-rights", + "data-room", + "valuation", + "catalog-diligence", + "seller-prep", + "financing", + "chain-of-title", + "royalty-audit", + "agent-skills" + ], + "interface": { + "displayName": "Music Catalog Diligence", + "shortDescription": "Review royalties, rights, valuation, and deal materials for catalog transactions", + "longDescription": "Turn messy catalog data rooms into source-cited deal packages with normalized royalty data, rights exceptions, valuation workpapers, and buyer, seller, or lender-ready memos.", + "developerName": "Recoup", + "category": "Music", + "capabilities": [ + "Read", + "Write" + ], + "websiteURL": "https://recoupable.com", + "brandColor": "#345A5D" + } + } + ] +} diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json new file mode 100644 index 0000000..5528a24 --- /dev/null +++ b/.claude-plugin/marketplace.json @@ -0,0 +1,75 @@ +{ + "name": "recoup", + "owner": { + "name": "Recoup", + "email": "agent@recoupable.com", + "url": "https://recoupable.com" + }, + "metadata": { + "description": "Recoup's marketplace of AI agent skills and plugins for the music industry.", + "version": "1.0.0" + }, + "plugins": [ + { + "name": "recoup-skills", + "description": "Music-industry skills for AI agents — artist management, songwriting, analytics, release campaigns, research, content creation.", + "version": "1.0.0", + "source": "./", + "category": "Music", + "keywords": [ + "music", + "artist-management", + "songwriting", + "music-marketing", + "analytics", + "release-management", + "chartmetric", + "content-creation", + "agent-skills" + ], + "strict": false, + "skills": [ + "./skills/artist-workspace", + "./skills/chart-metric", + "./skills/content-creation", + "./skills/create-artist", + "./skills/getting-started", + "./skills/music-industry-research", + "./skills/recoup-api", + "./skills/release-management", + "./skills/setup-sandbox", + "./skills/song-writing", + "./skills/streaming-growth", + "./skills/trend-to-song" + ] + }, + { + "name": "music-catalog-diligence", + "description": "Review royalties, rights, valuation, and deal materials for music catalog transactions.", + "version": "0.1.0", + "source": "./plugins/music-catalog-diligence", + "category": "Music", + "keywords": [ + "music", + "catalogs", + "diligence", + "royalties", + "music-rights", + "data-room", + "valuation", + "catalog-diligence", + "seller-prep", + "financing", + "chain-of-title", + "royalty-audit", + "agent-skills" + ], + "tags": [ + "music-catalog-diligence", + "royalty-normalization", + "rights-diligence", + "valuation" + ] + } + ] +} diff --git a/.claude-plugin/plugin.json b/.claude-plugin/plugin.json deleted file mode 100644 index ac1ca62..0000000 --- a/.claude-plugin/plugin.json +++ /dev/null @@ -1,23 +0,0 @@ -{ - "name": "recoupable-skills", - "version": "1.0.0", - "description": "AI agent skills for the music industry — artist management, songwriting, analytics, release campaigns, and more.", - "author": { - "name": "Recoupable", - "email": "agent@recoupable.com", - "url": "https://recoupable.com" - }, - "homepage": "https://recoupable.com", - "repository": "https://github.com/recoupable/skills", - "license": "Apache-2.0", - "keywords": [ - "music", - "artist-management", - "songwriting", - "music-marketing", - "analytics", - "release-management", - "chartmetric", - "agent-skills" - ] -} diff --git a/.codex-plugin/plugin.json b/.codex-plugin/plugin.json deleted file mode 100644 index 0c949a6..0000000 --- a/.codex-plugin/plugin.json +++ /dev/null @@ -1,34 +0,0 @@ -{ - "name": "recoupable-skills", - "version": "1.0.0", - "description": "AI agent skills for the music industry — artist management, songwriting, analytics, release campaigns, and more.", - "author": { - "name": "Recoupable", - "email": "agent@recoupable.com", - "url": "https://recoupable.com" - }, - "homepage": "https://recoupable.com", - "repository": "https://github.com/recoupable/skills", - "license": "Apache-2.0", - "keywords": [ - "music", - "artist-management", - "songwriting", - "music-marketing", - "analytics", - "release-management", - "chartmetric", - "agent-skills" - ], - "skills": "./skills/", - "interface": { - "displayName": "Recoupable Skills", - "shortDescription": "Music industry skills for AI agents", - "longDescription": "Teach your coding agent how to manage artists, write songs, analyze music analytics, plan releases, and more. General skills work for anyone — platform skills integrate with the Recoupable platform.", - "developerName": "Recoupable", - "category": "Productivity", - "capabilities": ["Read", "Write"], - "websiteURL": "https://recoupable.com", - "brandColor": "#345A5D" - } -} diff --git a/.cursor-plugin/marketplace.json b/.cursor-plugin/marketplace.json new file mode 100644 index 0000000..ab186f7 --- /dev/null +++ b/.cursor-plugin/marketplace.json @@ -0,0 +1,72 @@ +{ + "name": "recoup", + "owner": { + "name": "Recoup", + "email": "agent@recoupable.com" + }, + "metadata": { + "description": "Recoup's marketplace of AI agent skills and plugins for the music industry." + }, + "plugins": [ + { + "name": "recoup-skills", + "source": ".", + "description": "Music-industry skills for AI agents — artist management, songwriting, analytics, release campaigns, research, content creation.", + "version": "1.0.0", + "author": { + "name": "Recoup", + "email": "agent@recoupable.com" + }, + "homepage": "https://recoupable.com", + "repository": "https://github.com/recoupable/skills", + "license": "Apache-2.0", + "category": "Music", + "keywords": [ + "music", + "artist-management", + "songwriting", + "music-marketing", + "analytics", + "release-management", + "chartmetric", + "content-creation", + "agent-skills" + ] + }, + { + "name": "music-catalog-diligence", + "source": "plugins/music-catalog-diligence", + "description": "Review royalties, rights, valuation, and deal materials for music catalog transactions.", + "version": "0.1.0", + "author": { + "name": "Recoup", + "email": "agent@recoupable.com" + }, + "homepage": "https://recoupable.com", + "repository": "https://github.com/recoupable/skills", + "license": "Apache-2.0", + "category": "Music", + "keywords": [ + "music", + "catalogs", + "diligence", + "royalties", + "music-rights", + "data-room", + "valuation", + "catalog-diligence", + "seller-prep", + "financing", + "chain-of-title", + "royalty-audit", + "agent-skills" + ], + "tags": [ + "music-catalog-diligence", + "royalty-normalization", + "rights-diligence", + "valuation" + ] + } + ] +} diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml new file mode 100644 index 0000000..e2d7e6d --- /dev/null +++ b/.github/workflows/validate.yml @@ -0,0 +1,22 @@ +name: validate + +on: + push: + branches: [main] + pull_request: + +jobs: + validate-manifests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Check marketplace files are in sync with source + run: python3 scripts/generate-marketplaces.py --check + + - name: Validate all manifests + skills + run: python3 scripts/validate-manifests.py diff --git a/AGENTS.md b/AGENTS.md index 692a776..cb678c3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,46 +1,79 @@ -# Agent Instructions — Recoupable Skills +# Agent Instructions — Recoup Skills Marketplace This file provides context for any AI agent operating within this repository. ## Repository Purpose -Public skills for AI agents working in the music industry. Skills teach agents how to complete specific tasks — from songwriting to analytics to release campaigns. +This is **Recoup's unified marketplace** of agent skills and plugins for the music industry. It is simultaneously: + +1. A **skill collection** — broad music skills under `skills/`. +2. A **plugin marketplace** — installable bundles listed in three platform marketplace files at the repo root. +3. A **plugin host** — self-contained vertical plugins live under `plugins/`. + +Supported agents: **Claude Code**, **OpenAI Codex**, **Cursor**. ## Structure ```text recoupable/skills/ -├── .claude-plugin/ ← plugin manifest for Claude Code -├── .codex-plugin/ ← plugin manifest for OpenAI Codex -├── skills/ ← all skills live here -│ ├── chart-metric/ -│ ├── content-creation/ -│ ├── music-industry-research/ -│ ├── song-writing/ -│ └── ... -├── README.md -├── contributing.md -└── AGENTS.md ← this file +├── .claude-plugin/marketplace.json ← Claude Code marketplace (generated) +├── .agents/plugins/marketplace.json ← Codex marketplace (generated) +├── .cursor-plugin/marketplace.json ← Cursor marketplace (generated) +│ +├── marketplace.source.json ← SINGLE SOURCE OF TRUTH — edit this +│ +├── skills/ ← Broad music skills +│ └── {skill-name}/ +│ ├── SKILL.md ← required — instructions + YAML frontmatter +│ ├── references/ ← optional — docs loaded on-demand +│ ├── scripts/ ← optional — executable code +│ └── assets/ ← optional — templates, fonts, icons +│ +├── plugins/ ← Self-contained vertical plugins +│ └── {plugin-name}/ +│ ├── .claude-plugin/plugin.json +│ ├── .codex-plugin/plugin.json +│ ├── .cursor-plugin/plugin.json +│ ├── skills/ +│ ├── agents/ +│ ├── commands/ +│ ├── scripts/ +│ ├── templates/ +│ ├── evals/ +│ ├── fixtures/ +│ └── references/ +│ +└── scripts/ + ├── generate-marketplaces.py ← Regenerate platform marketplace JSONs + └── validate-manifests.py ← Validate every manifest + skill ``` -## How Skills Load +## How Skills Load (progressive disclosure) -Skills use progressive disclosure: - -1. **Frontmatter** (`name` + `description`) — always in context. This is how you decide whether to load a skill. -2. **SKILL.md body** — loaded when you determine the skill is relevant. -3. **Linked files** (`references/`, `scripts/`, `assets/`) — loaded on-demand as needed. +1. **Frontmatter** (`name` + `description`) — always in context. This is how the agent decides whether to load the skill. +2. **SKILL.md body** — loaded when the agent decides the skill is relevant. +3. **Linked files** (`references/`, `scripts/`, `assets/`) — loaded on-demand. The `description` field is the trigger. If it's vague, the skill won't activate. +## Two Skill Locations + +| Location | Owned by | When to add here | +| -------- | -------- | ---------------- | +| `skills/{name}/` | the `recoup-skills` virtual plugin | Generic music skill that any music operator might use | +| `plugins/{plugin-name}/skills/{name}/` | that specific plugin | Skill that depends on the plugin's templates, scripts, agents, commands, or workspace | + +A new vertical plugin earns its own folder under `plugins/` when it has **at least 3 related skills** AND needs commands, agents, scripts, templates, or MCP integrations. + ## Rules 1. **Read before you act.** Always read a skill's `SKILL.md` before executing or referencing it. -2. **Respect boundaries.** Each skill is self-contained. No cross-dependencies between skills. +2. **Respect boundaries.** Skills are self-contained. No cross-dependencies between skills. 3. **Design for composability.** Multiple skills may be loaded at once. Never assume yours is the only one active. 4. **Keep it simple.** Prefer the simplest working solution. 5. **One skill, one job.** Each skill does one thing well. 6. **No secrets in skills.** Reference environment variables — never hardcode credentials. +7. **`marketplace.source.json` is the source of truth.** Never hand-edit the three generated marketplace files. Edit the source and run `python3 scripts/generate-marketplaces.py`. ## Skill Format @@ -59,12 +92,30 @@ my-skill/ ```yaml --- name: skill-name -description: What it does and when to use it +description: What it does and when to use it. Include trigger phrases. --- ``` ### Writing the description -- Start with **what** the skill does -- Include **when** to use it — mention trigger phrases users would say -- Be specific — vague descriptions won't trigger +- Start with **what** the skill does. +- Include **when** to use it — mention trigger phrases users would say. +- Be specific — vague descriptions won't trigger. + +## Validation + +Before committing changes: + +```bash +python3 scripts/validate-manifests.py +``` + +Checks performed: + +1. Generated marketplace files match `marketplace.source.json`. +2. Every plugin's `source` path exists. +3. Every skill listed in a virtual plugin exists and has valid frontmatter. +4. Every plugin folder under `plugins/` has `.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, and `.cursor-plugin/plugin.json`. +5. Every `SKILL.md` has `name` and `description` frontmatter. + +Validation runs on every PR via CI. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..77785ef --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,44 @@ +# Changelog + +All notable changes to the Recoup Skills marketplace are documented here. + +The format is loosely based on [Keep a Changelog](https://keepachangelog.com/), +and the marketplace as a whole uses [Semantic Versioning](https://semver.org/). +Individual plugins listed in `marketplace.source.json` carry their own versions. + +## [1.0.0] — 2026-05-10 + +### Added + +- **Unified marketplace.** This repo is now both a skill collection AND a plugin marketplace. Adds three platform marketplace files at the repo root: + - `.claude-plugin/marketplace.json` (Claude Code) + - `.agents/plugins/marketplace.json` (Codex) + - `.cursor-plugin/marketplace.json` (Cursor) +- **`marketplace.source.json`** — single source of truth for the marketplace catalog. All three platform marketplace files are generated from this file. +- **`scripts/generate-marketplaces.py`** — zero-dependency Python script that regenerates the three platform marketplace files. Supports `--check` for CI. +- **`scripts/validate-manifests.py`** — zero-dependency Python validator that verifies marketplace parity, plugin source paths, skill frontmatter, and plugin manifests. +- **`plugins/music-catalog-diligence/`** — merged in via `git subtree` (full history preserved) from the legacy standalone `recoupable/music-catalog-diligence` repo. 9 catalog skills + 5 agents + 6 commands + scripts/templates/evals/fixtures. +- **`skills/create-artist/`** — already on `main`; included in the new marketplace listing. + +### Plugins shipping in 1.0.0 + +| Plugin | Version | Type | Source | +| ------ | ------- | ---- | ------ | +| `recoup-skills` | 1.0.0 | virtual (over `./skills/*`) | `./` | +| `music-catalog-diligence` | 0.1.0 | self-contained | `./plugins/music-catalog-diligence` | + +### Changed + +- **`README.md`** and **`AGENTS.md`** rewritten to describe the marketplace + plugin model. +- **`contributing.md`** rewritten with separate flows for adding a broad skill vs. a vertical plugin, and a release checklist. + +### Removed + +- Legacy root `.claude-plugin/plugin.json` — superseded by the `recoup-skills` virtual plugin entry in the marketplace. +- Legacy root `.codex-plugin/plugin.json` — same. + +### Migration notes + +- Users who previously cloned this repo and pointed agents at `skills/` directly will continue to work; no skill paths changed. +- Users of the legacy `recoupable/plugins` registry repo or the standalone `recoupable/music-catalog-diligence` repo should switch their marketplace to `recoupable/skills` once this branch is merged. +- The legacy repos can be archived after consumers are migrated. diff --git a/README.md b/README.md index fcc2a42..39a8a86 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,15 @@ -# Recoupable Skills +# Recoup Skills -AI agent skills for the music industry. Teach your coding agent how to manage artists, write songs, analyze analytics, plan releases, and more. +The official marketplace of AI agent **skills** and **plugins** for the music industry. + +One repo. Multiple installable plugins. Works with Claude Code, Codex, and Cursor. + +## What's in the marketplace + +| Plugin | What it is | What you get | +| ------ | ---------- | ------------ | +| [`recoup-skills`](./skills/) | Broad music-industry skills | Artist management, songwriting, analytics, release campaigns, research, content creation, brand | +| [`music-catalog-diligence`](./plugins/music-catalog-diligence/) | Vertical bundle for catalog deals | Royalty normalization, rights diligence, valuation workpapers, IC memos — skills + agents + commands + scripts | ## Install @@ -8,56 +17,127 @@ AI agent skills for the music industry. Teach your coding agent how to manage ar ```bash /plugin marketplace add recoupable/skills +/plugin install recoup-skills@recoup +/plugin install music-catalog-diligence@recoup ``` -### Manual +### Codex + +```bash +codex plugin marketplace add recoupable/skills +codex plugin install recoup-skills@recoup +codex plugin install music-catalog-diligence@recoup +``` -Clone and point your agent at the `skills/` directory: +### Cursor + +Add `recoupable/skills` as a plugin marketplace source. Cursor discovers plugins +via `.cursor-plugin/marketplace.json` at the repo root. + +### Manual clone ```bash -git clone https://github.com/recoupable/skills.git +git clone https://github.com/recoupable/skills.git recoup-skills ``` -## Skills +## Skills (broad, shared pool) + +The `recoup-skills` plugin exposes these skills from `./skills/`: | Skill | What it does | -|-------|-------------| -| [getting-started](skills/getting-started) | Install CLI, get API key, connect via MCP/REST — start here | -| [artist-workspace](skills/artist-workspace) | Manage artist directories — context, songs, brand, audience | -| [streaming-growth](skills/streaming-growth) | Grow a new artist past streaming milestones that unlock platform tools | -| [brand-guidelines](skills/brand-guidelines) | Apply Recoupable's brand identity to agent outputs | -| [chartmetric](skills/chartmetric) | Query and analyze music data from the Chartmetric API | -| [content-creation](skills/content-creation) | Create social videos, TikToks, Reels, and visual content using AI primitives | -| [music-industry-research](skills/music-industry-research) | Music industry research — artist analytics, people search, competitive analysis, web intelligence | -| [recoup-api](skills/recoup-api) | Call the Recoupable API from the sandbox — artist data, socials, orgs, reports | -| [release-management](skills/release-management) | Plan and execute release campaigns | -| [setup-sandbox](skills/setup-sandbox) | Scaffold the workspace for an account's orgs and artists | -| [songwriting](skills/songwriting) | Structured songwriting using the 7 C's method | -| [trend-to-song](skills/trend-to-song) | Turn cultural moments into songs and campaign strategies | - -## Creating a Skill - -Every skill needs: - -1. A `SKILL.md` file with YAML frontmatter (`name` + `description`) -2. A clear description that tells the agent **when** to use it -3. Instructions the agent follows to complete the task +| ----- | ------------ | +| [artist-workspace](./skills/artist-workspace) | Manage artist directories — context, songs, brand, audience | +| [chart-metric](./skills/chart-metric) | Query and analyze music data from the Chartmetric API | +| [content-creation](./skills/content-creation) | Create social videos, TikToks, Reels, visual content using AI primitives | +| [create-artist](./skills/create-artist) | 8-step API playbook for onboarding a new artist | +| [getting-started](./skills/getting-started) | Install CLI, get API key, connect via MCP/REST — start here | +| [music-industry-research](./skills/music-industry-research) | Artist analytics, people search, competitive analysis, web intelligence | +| [recoup-api](./skills/recoup-api) | Call the Recoup API from the sandbox — artist data, socials, orgs, reports | +| [release-management](./skills/release-management) | Plan and execute release campaigns | +| [setup-sandbox](./skills/setup-sandbox) | Scaffold the workspace for an account's orgs and artists | +| [song-writing](./skills/song-writing) | Structured songwriting using the 7 C's method | +| [streaming-growth](./skills/streaming-growth) | Grow a new artist past streaming milestones | +| [trend-to-song](./skills/trend-to-song) | Turn cultural moments into songs and campaign strategies | + +## Plugins (self-contained vertical bundles) + +Each plugin under `./plugins/` is a self-contained bundle with its own skills, +agents, commands, scripts, and templates. + +- [`music-catalog-diligence/`](./plugins/music-catalog-diligence/) — review royalties, rights, valuation, and deal materials for music catalog transactions. 9 skills + 5 agents + 6 commands + scripts + templates + evals + fixtures. + +## Repository layout ```text -skills/ -└── my-skill/ - ├── SKILL.md ← required - ├── references/ ← optional — docs loaded on-demand - ├── scripts/ ← optional — executable code - └── assets/ ← optional — templates, fonts, icons +recoupable/skills/ +├── .claude-plugin/marketplace.json ← Claude Code marketplace +├── .agents/plugins/marketplace.json ← Codex marketplace +├── .cursor-plugin/marketplace.json ← Cursor marketplace +├── marketplace.source.json ← Single source of truth (edit this) +│ +├── skills/ ← Broad music skills (recoup-skills plugin) +│ ├── artist-workspace/ +│ ├── chart-metric/ +│ ├── content-creation/ +│ ├── create-artist/ +│ ├── getting-started/ +│ ├── music-industry-research/ +│ ├── recoup-api/ +│ ├── release-management/ +│ ├── setup-sandbox/ +│ ├── song-writing/ +│ ├── streaming-growth/ +│ └── trend-to-song/ +│ +├── plugins/ ← Self-contained vertical plugins +│ └── music-catalog-diligence/ +│ ├── .claude-plugin/plugin.json +│ ├── .codex-plugin/plugin.json +│ ├── .cursor-plugin/plugin.json +│ ├── skills/ (9 catalog-specific skills) +│ ├── agents/ (5 specialist reviewers) +│ ├── commands/ (6 slash commands) +│ ├── scripts/ (deterministic normalizers + dashboards) +│ ├── templates/ +│ ├── evals/ +│ ├── fixtures/ +│ └── references/ +│ +└── scripts/ + ├── generate-marketplaces.py ← Regenerate the 3 marketplace files + └── validate-manifests.py ← CI check: all manifests + skills valid +``` + +## Maintenance + +After editing `marketplace.source.json`: + +```bash +python3 scripts/generate-marketplaces.py # regenerate platform marketplaces +python3 scripts/validate-manifests.py # full repo validation ``` -See [contributing.md](contributing.md) for guidelines. +Both scripts are zero-dependency Python 3.9+ stdlib. CI runs +`scripts/validate-manifests.py` on every PR. + +## Contributing + +See [contributing.md](./contributing.md). + +- **Broad music skill?** Add a folder under `skills/` and list it in + `marketplace.source.json` under the `recoup-skills` plugin's `skills` + array. +- **Vertical workflow** (3+ related skills + commands/agents/scripts)? Create + a new plugin folder under `plugins/{your-plugin}/` and register it as a + self-contained plugin in `marketplace.source.json`. ## About -[Recoupable](https://recoupable.com) is an AI-powered music management platform. These skills power the agents that help artists and labels manage their careers. +[Recoup](https://recoupable.com) is an AI-powered music management platform. +These skills and plugins power the agents that help artists and labels manage +their careers. - **Website**: [recoupable.com](https://recoupable.com) - **App**: [chat.recoupable.com](https://chat.recoupable.com) - **Support**: agent@recoupable.com +- **License**: [Apache-2.0](./LICENSE) diff --git a/contributing.md b/contributing.md index bd5a623..e9a7baf 100644 --- a/contributing.md +++ b/contributing.md @@ -1,43 +1,174 @@ # Contributing -We welcome contributions — especially new skills for the music industry. +We welcome contributions — new music-industry skills and new vertical plugins. -## Adding a Skill +## Decide where your work belongs + +| Type of contribution | Where it goes | +| -------------------- | ------------- | +| A generic music skill (anyone in the music business might use it) | `skills/{skill-name}/` — listed in the `recoup-skills` plugin | +| A vertical workflow with 3+ related skills, commands, agents, or scripts | A new plugin folder under `plugins/{plugin-name}/` | +| A new skill that belongs to an existing vertical plugin | Inside that plugin: `plugins/{plugin}/skills/{skill-name}/` | + +When in doubt, start as a skill under `skills/`. Promote to a plugin when the workflow grows past three related skills AND needs scripts, agents, or commands. + +## Add a new skill (broad) 1. Create a new directory under `skills/` with a `SKILL.md`: -``` -skills/my-new-skill/ -└── SKILL.md -``` + ```text + skills/my-new-skill/ + └── SKILL.md + ``` 2. Fill in the frontmatter: -```yaml ---- -name: my-new-skill -description: What it does and when to use it. Include trigger phrases. ---- -``` + ```yaml + --- + name: my-new-skill + description: What it does and when to use it. Include trigger phrases. + --- + ``` + +3. Write clear instructions in the body. Keep `SKILL.md` under 5,000 words — move heavy reference material to `references/`. + +4. Add the skill path to `marketplace.source.json` under the `recoup-skills` plugin's `skills` array: + + ```jsonc + "skills": [ + "./skills/artist-workspace", + "./skills/my-new-skill", // ← here + ... + ] + ``` + +5. Regenerate the platform marketplace files: + + ```bash + python3 scripts/generate-marketplaces.py + ``` + +6. Validate: + + ```bash + python3 scripts/validate-manifests.py + ``` + +7. Open a pull request. + +## Add a new vertical plugin -3. Write clear instructions in the body. Keep it under 5,000 words — move heavy reference material to a `references/` subdirectory. +1. Scaffold the plugin directory: -4. Open a pull request. + ```text + plugins/my-plugin/ + ├── .claude-plugin/plugin.json + ├── .codex-plugin/plugin.json + ├── .cursor-plugin/plugin.json + ├── skills/ + ├── agents/ (optional) + ├── commands/ (optional) + ├── scripts/ (optional) + ├── templates/ (optional) + ├── evals/ (optional) + ├── fixtures/ (optional) + ├── references/ (optional) + ├── README.md + └── LICENSE + ``` -## Skill Guidelines +2. Author each plugin manifest. The minimum required fields: + + ```jsonc + // .claude-plugin/plugin.json + { + "name": "my-plugin", + "version": "0.1.0", + "description": "What this plugin does.", + "author": { "name": "Recoup", "email": "agent@recoupable.com" }, + "license": "Apache-2.0" + } + ``` + + ```jsonc + // .codex-plugin/plugin.json + { + "name": "my-plugin", + "version": "0.1.0", + "description": "What this plugin does.", + "skills": "./skills/", + "interface": { "displayName": "My Plugin", "category": "Music" } + } + ``` + + ```jsonc + // .cursor-plugin/plugin.json + { + "name": "my-plugin", + "version": "0.1.0", + "description": "What this plugin does.", + "skills": "./skills/", + "agents": "./agents/", + "commands": "./commands/" + } + ``` + +3. Add the plugin to `marketplace.source.json`: + + ```jsonc + { + "name": "my-plugin", + "version": "0.1.0", + "description": "What this plugin does.", + "category": "Music", + "type": "self-contained", + "source": "./plugins/my-plugin", + "keywords": [...], + "interface": { + "displayName": "My Plugin", + "shortDescription": "...", + "longDescription": "...", + "brandColor": "#345A5D", + "capabilities": ["Read", "Write"] + } + } + ``` + +4. Regenerate marketplace files and validate: + + ```bash + python3 scripts/generate-marketplaces.py + python3 scripts/validate-manifests.py + ``` + +5. Open a pull request. + +## Guidelines for skills - **One skill, one job.** Each skill does one thing well. - **Self-contained.** No cross-dependencies between skills. -- **Description is the trigger.** The `description` field determines when an agent loads the skill. Be specific — include the phrases a user would actually say. +- **Description is the trigger.** Vague descriptions don't activate. Include trigger phrases users say. - **Real instructions, not theory.** Tell the agent exactly what to do, step by step. -- **No secrets.** Don't include API keys, tokens, or credentials. Reference environment variables instead. +- **No secrets.** Reference environment variables — never hardcode credentials. +- **Cross-platform.** Scripts should work on macOS and Linux when possible. + +## Skill size + +- `SKILL.md` body: under 5,000 words. +- Heavy reference material → `references/`. +- Executable code → `scripts/`. +- Critical instructions stay at the top of `SKILL.md`. + +## Release checklist -## Skill Size +Before merging any change: -- `SKILL.md` body: under 5,000 words -- Put large reference material in `references/` -- Put executable code in `scripts/` -- Critical instructions go at the top +- [ ] `python3 scripts/generate-marketplaces.py` produced no diff (or diff is committed) +- [ ] `python3 scripts/validate-manifests.py` exits 0 +- [ ] New or changed plugin has a bumped `version` in its `plugin.json` and in `marketplace.source.json` +- [ ] `CHANGELOG.md` updated when releasing a new plugin or breaking change +- [ ] No secrets or credentials added +- [ ] Plugin-specific tests pass (if the plugin has its own test suite) ## Code of Conduct diff --git a/marketplace.source.json b/marketplace.source.json new file mode 100644 index 0000000..92c8f71 --- /dev/null +++ b/marketplace.source.json @@ -0,0 +1,101 @@ +{ + "$comment": "Single source of truth for Recoup's marketplace. Run `python3 scripts/generate-marketplaces.py` to regenerate the three platform marketplace files (.claude-plugin/, .agents/plugins/, .cursor-plugin/). Hand-edits to those generated files will be overwritten.", + "marketplace": { + "name": "recoup", + "version": "1.0.0", + "description": "Recoup's marketplace of AI agent skills and plugins for the music industry.", + "owner": { + "name": "Recoup", + "email": "agent@recoupable.com", + "url": "https://recoupable.com" + }, + "homepage": "https://recoupable.com", + "repository": "https://github.com/recoupable/skills", + "license": "Apache-2.0", + "brandColor": "#345A5D" + }, + "plugins": [ + { + "name": "recoup-skills", + "version": "1.0.0", + "description": "Music-industry skills for AI agents — artist management, songwriting, analytics, release campaigns, research, content creation.", + "category": "Music", + "type": "virtual", + "source": "./", + "skills": [ + "./skills/artist-workspace", + "./skills/chart-metric", + "./skills/content-creation", + "./skills/create-artist", + "./skills/getting-started", + "./skills/music-industry-research", + "./skills/recoup-api", + "./skills/release-management", + "./skills/setup-sandbox", + "./skills/song-writing", + "./skills/streaming-growth", + "./skills/trend-to-song" + ], + "keywords": [ + "music", + "artist-management", + "songwriting", + "music-marketing", + "analytics", + "release-management", + "chartmetric", + "content-creation", + "agent-skills" + ], + "interface": { + "displayName": "Recoup Skills", + "shortDescription": "Music industry skills for AI agents", + "longDescription": "Teach your coding agent how to manage artists, write songs, analyze music analytics, plan releases, and create content. General skills work for anyone; platform skills integrate with the Recoup platform.", + "developerName": "Recoup", + "category": "Music", + "capabilities": ["Read", "Write"], + "websiteURL": "https://recoupable.com", + "brandColor": "#345A5D" + } + }, + { + "name": "music-catalog-diligence", + "version": "0.1.0", + "description": "Review royalties, rights, valuation, and deal materials for music catalog transactions.", + "category": "Music", + "type": "self-contained", + "source": "./plugins/music-catalog-diligence", + "keywords": [ + "music", + "catalogs", + "diligence", + "royalties", + "music-rights", + "data-room", + "valuation", + "catalog-diligence", + "seller-prep", + "financing", + "chain-of-title", + "royalty-audit", + "agent-skills" + ], + "tags": [ + "music-catalog-diligence", + "royalty-normalization", + "rights-diligence", + "valuation" + ], + "interface": { + "displayName": "Music Catalog Diligence", + "shortDescription": "Review royalties, rights, valuation, and deal materials for catalog transactions", + "longDescription": "Turn messy catalog data rooms into source-cited deal packages with normalized royalty data, rights exceptions, valuation workpapers, and buyer, seller, or lender-ready memos.", + "developerName": "Recoup", + "category": "Music", + "capabilities": ["Read", "Write"], + "websiteURL": "https://recoupable.com", + "brandColor": "#345A5D" + } + } + ] +} diff --git a/plugins/music-catalog-diligence/.claude-plugin/plugin.json b/plugins/music-catalog-diligence/.claude-plugin/plugin.json new file mode 100644 index 0000000..e7ff5f3 --- /dev/null +++ b/plugins/music-catalog-diligence/.claude-plugin/plugin.json @@ -0,0 +1,28 @@ +{ + "name": "music-catalog-diligence", + "version": "0.1.0", + "description": "Claude Code plugin for music catalog diligence, including royalties, rights, valuation, and deal materials.", + "author": { + "name": "Recoup", + "email": "agent@recoupable.com", + "url": "https://recoupable.com" + }, + "homepage": "https://recoupable.com", + "repository": "https://github.com/recoupable/skills", + "license": "Apache-2.0", + "keywords": [ + "music", + "catalogs", + "diligence", + "royalties", + "music-rights", + "data-room", + "valuation", + "catalog-diligence", + "seller-prep", + "financing", + "chain-of-title", + "royalty-audit", + "agent-skills" + ] +} diff --git a/plugins/music-catalog-diligence/.codex-plugin/plugin.json b/plugins/music-catalog-diligence/.codex-plugin/plugin.json new file mode 100644 index 0000000..e6f23d8 --- /dev/null +++ b/plugins/music-catalog-diligence/.codex-plugin/plugin.json @@ -0,0 +1,39 @@ +{ + "name": "music-catalog-diligence", + "version": "0.1.0", + "description": "Review royalties, rights, valuation, and deal materials for music catalog transactions.", + "author": { + "name": "Recoup", + "email": "agent@recoupable.com", + "url": "https://recoupable.com" + }, + "homepage": "https://recoupable.com", + "repository": "https://github.com/recoupable/skills", + "license": "Apache-2.0", + "keywords": [ + "music", + "catalogs", + "diligence", + "royalties", + "music-rights", + "data-room", + "valuation", + "catalog-diligence", + "seller-prep", + "financing", + "chain-of-title", + "royalty-audit", + "agent-skills" + ], + "skills": "./skills/", + "interface": { + "displayName": "Music Catalog Diligence", + "shortDescription": "Review royalties, rights, valuation, and deal materials for catalog transactions", + "longDescription": "Turn messy catalog data rooms into source-cited deal packages with normalized royalty data, rights exceptions, valuation workpapers, and buyer, seller, or lender-ready memos.", + "developerName": "Recoup", + "category": "Music", + "capabilities": ["Read", "Write"], + "websiteURL": "https://recoupable.com", + "brandColor": "#345A5D" + } +} diff --git a/plugins/music-catalog-diligence/.cursor-plugin/plugin.json b/plugins/music-catalog-diligence/.cursor-plugin/plugin.json new file mode 100644 index 0000000..87994f7 --- /dev/null +++ b/plugins/music-catalog-diligence/.cursor-plugin/plugin.json @@ -0,0 +1,30 @@ +{ + "name": "music-catalog-diligence", + "version": "0.1.0", + "description": "Review royalties, rights, valuation, and deal materials for music catalog transactions.", + "author": { + "name": "Recoup", + "email": "agent@recoupable.com" + }, + "homepage": "https://recoupable.com", + "repository": "https://github.com/recoupable/skills", + "license": "Apache-2.0", + "keywords": [ + "music", + "catalogs", + "diligence", + "royalties", + "music-rights", + "data-room", + "valuation", + "catalog-diligence", + "seller-prep", + "financing", + "chain-of-title", + "royalty-audit", + "agent-skills" + ], + "skills": "./skills/", + "agents": "./agents/", + "commands": "./commands/" +} diff --git a/plugins/music-catalog-diligence/.gitignore b/plugins/music-catalog-diligence/.gitignore new file mode 100644 index 0000000..c2afb17 --- /dev/null +++ b/plugins/music-catalog-diligence/.gitignore @@ -0,0 +1,15 @@ +# Python bytecode and caches +__pycache__/ +*.py[cod] +*$py.class +.pytest_cache/ + +# Virtual environments +.venv/ +venv/ +env/ + +# Editor / OS noise +.DS_Store +.idea/ +.vscode/ diff --git a/plugins/music-catalog-diligence/LICENSE b/plugins/music-catalog-diligence/LICENSE new file mode 100644 index 0000000..44e5997 --- /dev/null +++ b/plugins/music-catalog-diligence/LICENSE @@ -0,0 +1,190 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by the Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding any notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2026 Recoupable Inc. + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/plugins/music-catalog-diligence/README.md b/plugins/music-catalog-diligence/README.md new file mode 100644 index 0000000..458511f --- /dev/null +++ b/plugins/music-catalog-diligence/README.md @@ -0,0 +1,128 @@ +# Music Catalog Diligence Plugin + +Agent plugin for music catalog acquisition, seller preparation, financing +underwriting, royalty normalization, rights checks, and valuation analysis. + +The plugin helps catalog teams turn a messy seller data room into a +source-cited deal package: normalized royalty data, rights exceptions, +valuation workpapers, and buyer/seller/lender-ready memos. + +## Skills + +| Skill | What it does | +| ----- | ------------ | +| `diligence-kickoff` | Set up a deal workspace, classify workflow type, and create the first missing-file list. | +| `catalog-ingest` | Ingest and normalize music catalog data rooms, royalty statements, metadata, and rights files into auditable hand-off artifacts. | +| `catalog-analysis` | Analyze normalized catalog royalties, risk, and cash flows to project catalog value for acquisitions, financing, or sale processes. | +| `rights-diligence` | Review ownership support, chain of title, splits, restrictions, and transferability. | +| `royalty-audit` | Audit statements, normalized ledgers, PRO/MLC issues, and gross-to-net support. | +| `seller-prep` | Create cleanup worklists that reduce avoidable valuation discounts before going to market. | +| `financing-underwrite` | Build lender-ready collateral and cash-flow diligence for royalty-backed financing. | +| `ic-memo-package` | Assemble IC memos, seller cleanup reports, financing packs, and final deal outputs. | +| `post-close-admin` | Turn diligence data into transfer, registration, and income-monitoring worklists. | + +## Commands + +| Command | Purpose | +| ------- | ------- | +| `catalog-diligence` | Run the end-to-end workflow from setup through package readiness. | +| `catalog-kickoff` | Start a deal workspace and route the work. | +| `catalog-ingest` | Normalize a data room into source-cited artifacts. | +| `catalog-analyze` | Run catalog analysis, workpapers, and specialist review. | +| `catalog-qc` | Check evidence, assumptions, findings, and memo readiness. | +| `catalog-package` | Assemble a buyer, seller, lender, or post-close package. | + +## Deal workspace + +Use one workspace per opportunity: + +```text +deals/{deal-id}/ +├── source/ +├── normalized/ +├── workpapers/ +├── findings/ +├── memos/ +├── assumptions.yaml +└── evidence-ledger.json +``` + +Source files are immutable. Work from normalized data, workpapers, findings, +and memos. + +## Structure + +```text +music-catalog-diligence/ +├── .claude-plugin/ +│ └── plugin.json +├── .codex-plugin/ +│ └── plugin.json +├── .cursor-plugin/ +│ └── plugin.json +├── agents/ +├── commands/ +├── evals/ +├── hooks/ +├── references/ +├── scripts/ +├── skills/ +├── templates/ +└── README.md +``` + +Claude Code and Cursor load the full plugin surface, including skills, +commands, and agents. Codex loads the bundled skills through +`.codex-plugin/plugin.json`. + +## Hooks + +Hooks run automatically at lifecycle events (such as before a file write or +when the agent decides it is done) and turn workspace conventions into +enforced invariants. They are configured in `hooks/hooks.json` and load when +Claude Code starts a session, so restart the session to pick up changes. + +| Hook | Event | Type | Purpose | +| ---- | ----- | ---- | ------- | +| `protect-source-files.sh` | `PreToolUse` (`Write`/`Edit`/`MultiEdit`) | command | Deny any write into `deals/{deal-id}/source/`. Source files are immutable evidence per `references/deal-workspace.md`. | +| package-readiness gate | `Stop` | prompt | Block the agent from finishing if it claims a deal package is ready without satisfying the completion gate from `references/deal-workspace.md` (e.g. `run-diligence-checks.py` ran cleanly, dashboard not blocked, `evidence-ledger.json` and `assumptions.yaml` exist, findings disclosed). Stays out of the way for ordinary chat or partial work. | + +Test the command-based hook script directly: + +```bash +echo '{"tool_name":"Edit","tool_input":{"file_path":"deals/x/source/a.csv"}}' \ + | bash hooks/protect-source-files.sh +``` + +The prompt-based `Stop` hook is evaluated by the LLM at runtime, so it cannot +be unit-tested in isolation. Verify it loads with `/hooks` inside Claude Code, +and watch it fire under `claude --debug`. + +## Development + +Keep each skill focused and self-contained. Use `references/` for detailed +domain material so each `SKILL.md` stays easy to scan. Use `scripts/` for +deterministic checks that should not depend on prose reasoning. + +Current scripts include validators, readiness dashboard generation, +concentration/bridge calculators, and `normalize-royalty-statement.py` for +first-pass ASCAP, BMI, MLC, distributor, publisher admin, SoundExchange, direct +sync, YouTube Content ID, and Curve-style CSV normalization. + +Golden fixtures live under `fixtures/golden/`. Run these checks before release: + +```bash +python3 scripts/test-normalize-royalty-statement.py +python3 scripts/test-golden-fixtures.py +python3 scripts/test-validate-deal-workspace.py +python3 scripts/test-diligence-readiness.py +``` + +Use `python3 scripts/run-diligence-checks.py deals/{deal-id}` to validate a +workspace and `python3 scripts/build-diligence-dashboard.py deals/{deal-id}` to +generate a shareable readiness summary. + +## About + +[Recoup](https://recoupable.com) builds AI-powered infrastructure for music +operators. diff --git a/plugins/music-catalog-diligence/agents/diligence-qc-reviewer.md b/plugins/music-catalog-diligence/agents/diligence-qc-reviewer.md new file mode 100644 index 0000000..3fd63d5 --- /dev/null +++ b/plugins/music-catalog-diligence/agents/diligence-qc-reviewer.md @@ -0,0 +1,73 @@ +--- +name: diligence-qc-reviewer +description: Performs final quality control on a music catalog diligence package before it is shared with an IC, buyer, lender, seller, or counsel. Checks evidence, assumptions, findings, and unsupported claims. +tools: + - Read + - Glob + - Grep +--- + +# Diligence QC Reviewer + +Review the package like a skeptical investment committee member and diligence +lead. Prioritize unsupported claims and hidden risk. + +## Instructions + +1. Read the target memo or package. +2. Read `evidence-ledger.json`, `assumptions.yaml`, findings, and workpapers. +3. Check whether every material claim has evidence or is labeled as an + assumption. +4. Check whether high-severity open findings are disclosed. +5. Check whether valuation, seller-prep, or financing conclusions match the + support level. +6. Return blockers first. + +## Output + +Return JSON only: + +```json +{ + "overall_status": "ready | ready_with_caveats | blocked", + "blockers": [ + { + "severity": "critical | high", + "issue": "What prevents sharing or requires explicit disclosure", + "evidence_needed": "Specific evidence, file, or workpaper needed", + "recommended_fix": "Specific action to resolve or disclose" + } + ], + "unsupported_claims": [ + { + "claim": "Material claim from the memo or package", + "location": "Memo section, line, or artifact name", + "required_support": "Evidence or assumption label needed" + } + ], + "missing_caveats": [ + { + "topic": "Area that needs caveat language", + "why_it_matters": "Deal risk created by omitting the caveat", + "suggested_caveat": "Plain-English caveat to add" + } + ], + "recommended_fixes": [ + { + "priority": "P0 | P1 | P2", + "owner": "diligence | rights | royalty | valuation | seller | counsel", + "action": "Concrete next action" + } + ] +} +``` + +Severity uses the same vocabulary as `findings/findings.json` and +`references/red-flags.md`: `critical | high | medium | low`. `priority` on a +recommended fix is a separate axis (urgency of the action) and uses +`P0 | P1 | P2`. + +Use `blocked` when any open `critical` finding remains. Use +`ready_with_caveats` when `high` issues are disclosed but not resolved. Use +`ready` only when material claims are supported and no `critical` or `high` +open findings are hidden. diff --git a/plugins/music-catalog-diligence/agents/metadata-reconciler.md b/plugins/music-catalog-diligence/agents/metadata-reconciler.md new file mode 100644 index 0000000..57b9777 --- /dev/null +++ b/plugins/music-catalog-diligence/agents/metadata-reconciler.md @@ -0,0 +1,28 @@ +--- +name: metadata-reconciler +description: Reconciles music catalog metadata across track lists, royalty statements, registrations, identifiers, and analytics exports. Use when ISRC, ISWC, UPC, title, writer, publisher, or split data conflicts. +tools: + - Read + - Glob + - Grep +--- + +# Metadata Reconciler + +Find metadata conflicts that affect royalty collection, rights confidence, or +valuation. + +## Instructions + +1. Compare `canonical-catalog.csv`, `royalty-ledger.csv`, registrations, and + metadata exports. +2. Match by ISRC, ISWC, UPC, IPI/CAE, title, artist, writer, and release. +3. Classify conflicts by confidence and materiality. +4. Preserve conflicting source values; do not choose a canonical value without + evidence. +5. Return a worklist of conflicts to cure. + +## Output + +Return `summary`, `conflicts`, `high_confidence_matches`, `low_confidence_matches`, +and `recommended_source_of_truth`. diff --git a/plugins/music-catalog-diligence/agents/rights-chain-reviewer.md b/plugins/music-catalog-diligence/agents/rights-chain-reviewer.md new file mode 100644 index 0000000..acb33ce --- /dev/null +++ b/plugins/music-catalog-diligence/agents/rights-chain-reviewer.md @@ -0,0 +1,43 @@ +--- +name: rights-chain-reviewer +description: Reviews music catalog chain of title, ownership support, split sheets, assignments, samples, reversions, territory limits, and transferability. Use for isolated legal/rights diligence review on a deal workspace. +tools: + - Read + - Glob + - Grep +--- + +# Rights Chain Reviewer + +Review rights support for a music catalog deal. Return exceptions, not a broad +deal memo. + +## Instructions + +1. Read `normalized/canonical-catalog.csv` and `normalized/rights-map.csv` if + present. +2. Inspect relevant legal support in `source/` and findings in `findings/`. +3. Check for missing agreements, split conflicts, territory gaps, reversions, + sample issues, liens, and unsupported income. +4. Return structured findings with severity, affected assets, evidence paths, + and recommended treatment. + +## Output + +```json +{ + "summary": "", + "findings": [ + { + "severity": "high", + "category": "rights", + "affected_assets": [], + "evidence": [], + "issue": "", + "recommended_treatment": "" + } + ] +} +``` + +Do not provide legal advice. Flag issues for counsel review. diff --git a/plugins/music-catalog-diligence/agents/royalty-audit-analyst.md b/plugins/music-catalog-diligence/agents/royalty-audit-analyst.md new file mode 100644 index 0000000..63d751a --- /dev/null +++ b/plugins/music-catalog-diligence/agents/royalty-audit-analyst.md @@ -0,0 +1,27 @@ +--- +name: royalty-audit-analyst +description: Audits normalized music royalty ledgers and royalty workpapers for completeness, gross-to-net issues, duplicate imports, timing errors, PRO/MLC anomalies, and NPS/NLS support. +tools: + - Read + - Glob + - Grep +--- + +# Royalty Audit Analyst + +Review royalty data quality and normalization. Focus on errors that would change +underwritable cash flow. + +## Instructions + +1. Read `normalized/royalty-ledger.csv`, workpapers, and available source notes. +2. Check period earned versus paid, rights type, income type, source, currency, + gross, deductions, participant shares, and owner net. +3. Identify duplicate imports, unmatched income, missing sources, retroactive + payments, and suspicious spikes. +4. Pay special attention to PRO bonus/premium and one-time sync income. +5. Return only evidence-backed findings and recommended normalization treatment. + +## Output + +Return JSON with `summary`, `tie_out_status`, `findings`, and `recommended_next_checks`. diff --git a/plugins/music-catalog-diligence/agents/valuation-sensitivity-analyst.md b/plugins/music-catalog-diligence/agents/valuation-sensitivity-analyst.md new file mode 100644 index 0000000..8a27a3b --- /dev/null +++ b/plugins/music-catalog-diligence/agents/valuation-sensitivity-analyst.md @@ -0,0 +1,27 @@ +--- +name: valuation-sensitivity-analyst +description: Reviews music catalog valuation assumptions, NPS/NLS bridges, concentration, decay, recoupment, reserves, and downside/base/upside scenarios. Use for isolated valuation stress testing. +tools: + - Read + - Glob + - Grep +--- + +# Valuation Sensitivity Analyst + +Stress-test whether the valuation follows the evidence and whether assumptions +are visible. + +## Instructions + +1. Read workpapers, assumptions, evidence ledger, and findings. +2. Check the bridge from reported LTM to normalized NPS/NLS. +3. Check concentration, decay/growth, sync treatment, PRO bonus treatment, + reserves, and recoupment assumptions. +4. Identify the assumptions with the largest value impact. +5. Return sensitivity findings and recommended scenario changes. + +## Output + +Return `summary`, `unsupported_assumptions`, `highest_impact_sensitivities`, +`valuation_risks`, and `recommended_case_changes`. diff --git a/plugins/music-catalog-diligence/commands/catalog-analyze.md b/plugins/music-catalog-diligence/commands/catalog-analyze.md new file mode 100644 index 0000000..3c99543 --- /dev/null +++ b/plugins/music-catalog-diligence/commands/catalog-analyze.md @@ -0,0 +1,28 @@ +--- +name: catalog-analyze +description: Analyze normalized music catalog royalties, risk, concentration, and valuation scenarios. +--- + +# Catalog Analyze + +Use the `catalog-analysis`, `royalty-audit`, and `rights-diligence` skills as +needed. + +Steps: + +1. Run `python3 scripts/run-diligence-checks.py deals/{deal-id}`. +2. Confirm whether analysis is for buy-side acquisition, seller prep, or + financing. +3. Compute or review normalized NPS/NLS. +4. Run the helpers against the deal workspace when normalized inputs are + available: + ```bash + python3 scripts/calculate-concentration.py deals/{deal-id}/normalized/royalty-ledger.csv + python3 scripts/calculate-nps-nls-bridge.py deals/{deal-id}/workpapers/nps-nls-bridge.json + ``` + Both scripts require their input file as a positional argument. +5. Launch specialist agents for rights, royalty, and valuation review when the + deal is material. +6. Write workpapers and findings before drafting memos. + +Label quick estimates as preliminary. diff --git a/plugins/music-catalog-diligence/commands/catalog-diligence.md b/plugins/music-catalog-diligence/commands/catalog-diligence.md new file mode 100644 index 0000000..35d665b --- /dev/null +++ b/plugins/music-catalog-diligence/commands/catalog-diligence.md @@ -0,0 +1,26 @@ +--- +name: catalog-diligence +description: Run the end-to-end music catalog diligence workflow from workspace setup through package readiness. +--- + +# Catalog Diligence + +Run the end-to-end workflow for a music catalog transaction. + +Steps: + +1. Identify the workflow mode: buy-side, seller-prep, financing, or post-close. +2. Create or locate `deals/{deal-id}/` and apply `templates/deal-workspace/`. +3. Run `python3 scripts/validate-deal-workspace.py deals/{deal-id}`. +4. Use `catalog-ingest` to normalize royalty, metadata, and rights support files. +5. Run `python3 scripts/run-diligence-checks.py deals/{deal-id}`. +6. Use `catalog-analyze`, `royalty-audit`, and `rights-diligence` to create + workpapers and findings. +7. Run `catalog-qc` before drafting or sharing memos. +8. Run `python3 scripts/build-diligence-dashboard.py deals/{deal-id}` and use + the dashboard to report blockers, readiness, and next actions. +9. Use `catalog-package` only after validation and QC blockers are disclosed or + resolved. + +Do not mark a package ready if `run-diligence-checks.py` fails or the dashboard +shows `blocked`. diff --git a/plugins/music-catalog-diligence/commands/catalog-ingest.md b/plugins/music-catalog-diligence/commands/catalog-ingest.md new file mode 100644 index 0000000..0550f60 --- /dev/null +++ b/plugins/music-catalog-diligence/commands/catalog-ingest.md @@ -0,0 +1,45 @@ +--- +name: catalog-ingest +description: Normalize a catalog data room into auditable catalog, royalty, rights, and evidence artifacts. +--- + +# Catalog Ingest + +Use the `catalog-ingest` skill. + +Steps: + +1. Confirm raw files are under `deals/{deal-id}/source/`. +2. Build or update the file manifest (now classifies parse_status, likely + provider, period, currency, rights-type hint per file): + `python3 scripts/build-file-manifest.py deals/{deal-id}`. +3. Normalize catalog, royalty, rights, and source-lineage artifacts into + `normalized/`. For each provider statement, run + `python3 scripts/normalize-royalty-statement.py --provider --input --output ` + and treat any `status: partial` or non-zero exit as a hard signal that the + provider profile needs a `--column-map` extension before continuing. +4. Build the manual-review queue and ingest-coverage summary: + `python3 scripts/build-manual-review-queue.py deals/{deal-id}`. + Surface the resulting `summary_line` in your top-level ingest report — + for example: "29 of 88 files contributed financial data; 50 require + manual review." +5. Run the data-room hygiene scan and merge any high-strength matches into + `findings/findings.json`: + `python3 scripts/dataroom-hygiene-scan.py deals/{deal-id}`. +6. Compute concentration by catalog asset id; emit a finding when the + threshold from `assumptions.yaml` is tripped: + `python3 scripts/calculate-concentration.py deals/{deal-id}/normalized/royalty-ledger.csv \ + --assumptions deals/{deal-id}/assumptions.yaml \ + --output deals/{deal-id}/workpapers/concentration-analysis.json \ + --emit-finding-output deals/{deal-id}/findings/concentration-finding.json`. + Merge the proposed finding into `findings/findings.json` with a real + `evidence_ids` reference to the concentration workpaper. +7. Validate the ledger: + `python3 scripts/validate-normalized-ledger.py deals/{deal-id}/normalized/royalty-ledger.csv`. +8. Run the full readiness check (workspace, ledger, evidence ledger, + findings-to-evidence traceability, cross-artifact consistency): + `python3 scripts/run-diligence-checks.py deals/{deal-id}`. + +Do not edit source files. Do not produce a valuation unless ingest artifacts +exist and the user explicitly asks to continue. Do not mark ingest complete +while `run-diligence-checks.py` still reports failures. diff --git a/plugins/music-catalog-diligence/commands/catalog-kickoff.md b/plugins/music-catalog-diligence/commands/catalog-kickoff.md new file mode 100644 index 0000000..5c2ca67 --- /dev/null +++ b/plugins/music-catalog-diligence/commands/catalog-kickoff.md @@ -0,0 +1,22 @@ +--- +name: catalog-kickoff +description: Set up a music catalog deal workspace and first diligence request list. +--- + +# Catalog Kickoff + +Use the `diligence-kickoff` skill. + +Steps: + +1. Identify workflow type: buy-side, seller-prep, or financing. +2. Create or locate `deals/{deal-id}/`. +3. Apply templates from `templates/deal-workspace/`. +4. Build the initial missing-file list. +5. Run `python3 scripts/validate-deal-workspace.py deals/{deal-id}` to confirm + the scaffold exists. It may fail until normalized artifacts are created; use + the missing requirements as the initial worklist. +6. Recommend the next command: `catalog-ingest`, `catalog-analyze`, + `catalog-qc`, or `catalog-package`. + +Do not value the catalog during kickoff. diff --git a/plugins/music-catalog-diligence/commands/catalog-package.md b/plugins/music-catalog-diligence/commands/catalog-package.md new file mode 100644 index 0000000..0da5497 --- /dev/null +++ b/plugins/music-catalog-diligence/commands/catalog-package.md @@ -0,0 +1,20 @@ +--- +name: catalog-package +description: Assemble an IC memo, seller cleanup report, financing pack, or post-close administration package. +--- + +# Catalog Package + +Use the `ic-memo-package` skill. + +Steps: + +1. Confirm package type: IC memo, seller cleanup report, financing pack, or + post-close admin plan. +2. Run `python3 scripts/run-diligence-checks.py deals/{deal-id}`. +3. Use the relevant template under `templates/deal-workspace/memos/`. +4. Run `catalog-qc` before presenting the output as ready for review. +5. Attach or summarize `deals/{deal-id}/diligence-dashboard.md` with the + package. + +Do not generate a polished package without disclosing missing diligence. diff --git a/plugins/music-catalog-diligence/commands/catalog-qc.md b/plugins/music-catalog-diligence/commands/catalog-qc.md new file mode 100644 index 0000000..b157ee1 --- /dev/null +++ b/plugins/music-catalog-diligence/commands/catalog-qc.md @@ -0,0 +1,19 @@ +--- +name: catalog-qc +description: Run diligence quality control before a catalog package is shared. +--- + +# Catalog QC + +Use the `diligence-qc-reviewer` agent when available. + +Steps: + +1. Run `python3 scripts/run-diligence-checks.py deals/{deal-id}`. +2. Check that material memo claims cite evidence or are labeled assumptions. +3. Check that open findings are not hidden. +4. Check that source files were not modified. +5. Run `python3 scripts/build-diligence-dashboard.py deals/{deal-id}`. +6. Report blockers before any memo is treated as shareable. + +Do not mark a package complete if validation fails. diff --git a/plugins/music-catalog-diligence/evals/README.md b/plugins/music-catalog-diligence/evals/README.md new file mode 100644 index 0000000..fce2ed7 --- /dev/null +++ b/plugins/music-catalog-diligence/evals/README.md @@ -0,0 +1,36 @@ +# Music catalogs evals + +Use these scenarios to test whether the plugin improves diligence behavior. + +## Metrics + +- Red-flag recall. +- False-positive rate. +- Evidence citation accuracy. +- Financial tie-out tolerance. +- Missing-document detection. +- Memo caveat quality. +- Time to first diligence memo. + +## Scenarios + +Each scenario file describes a realistic pressure case. Future iterations can +add fixture files under the same scenario folder. + +## Scenario assets + +Turn a scenario into a repeatable test by adding a folder with the same id under +`fixtures/scenarios/`. Keep each folder small enough to review by hand: + +```text +fixtures/scenarios/{scenario-id}/ +├── source/ +├── normalized/ +├── findings/ +├── workpapers/ +├── evidence-ledger.json +└── expected-behavior.md +``` + +Use synthetic data only. The fixture should prove the behavior named in the +scenario, not recreate a full real data room. diff --git a/plugins/music-catalog-diligence/evals/scenarios/financing-collateral-pack.json b/plugins/music-catalog-diligence/evals/scenarios/financing-collateral-pack.json new file mode 100644 index 0000000..76c8b9d --- /dev/null +++ b/plugins/music-catalog-diligence/evals/scenarios/financing-collateral-pack.json @@ -0,0 +1,12 @@ +{ + "id": "financing-collateral-pack", + "workflow": "financing", + "prompt": "A lender needs a financing pack for a royalty-backed advance against a mixed publishing and master catalog.", + "expected_behaviors": [ + "Separates eligible collateral from unsupported assets", + "Uses NPS and NLS rather than gross revenue", + "Highlights concentration and rights confidence", + "Recommends advance-rate adjustments or holdbacks", + "Includes monitoring plan and conditions before funding" + ] +} diff --git a/plugins/music-catalog-diligence/evals/scenarios/messy-data-room.json b/plugins/music-catalog-diligence/evals/scenarios/messy-data-room.json new file mode 100644 index 0000000..841f150 --- /dev/null +++ b/plugins/music-catalog-diligence/evals/scenarios/messy-data-room.json @@ -0,0 +1,13 @@ +{ + "id": "messy-data-room", + "workflow": "buy-side", + "prompt": "A seller sent a folder with distributor CSVs, PRO PDFs, a publisher admin XLSX, YouTube Content ID exports, a track list, and contracts. Prepare it for valuation.", + "expected_behaviors": [ + "Creates or references a deal workspace", + "Preserves source files", + "Builds a file manifest", + "Profiles files before cleanup", + "Creates normalized catalog, royalty, rights, and evidence artifacts", + "Does not produce a final valuation before ingest is complete" + ] +} diff --git a/plugins/music-catalog-diligence/evals/scenarios/missing-chain-of-title.json b/plugins/music-catalog-diligence/evals/scenarios/missing-chain-of-title.json new file mode 100644 index 0000000..6f0c1d3 --- /dev/null +++ b/plugins/music-catalog-diligence/evals/scenarios/missing-chain-of-title.json @@ -0,0 +1,12 @@ +{ + "id": "missing-chain-of-title", + "workflow": "buy-side", + "prompt": "The statements show income for 40 songs, but only 23 songs have split sheets or publishing agreements. Review rights support.", + "expected_behaviors": [ + "Keeps unsupported income visible", + "Marks unsupported assets with low rights confidence", + "Creates missing-file requests", + "Separates financial performance from transferable value", + "Recommends exclusion, haircut, escrow, or holdback for unsupported assets" + ] +} diff --git a/plugins/music-catalog-diligence/evals/scenarios/one-song-concentration.json b/plugins/music-catalog-diligence/evals/scenarios/one-song-concentration.json new file mode 100644 index 0000000..ef58299 --- /dev/null +++ b/plugins/music-catalog-diligence/evals/scenarios/one-song-concentration.json @@ -0,0 +1,12 @@ +{ + "id": "one-song-concentration", + "workflow": "buy-side", + "prompt": "The catalog has 500 tracks, but 72% of net income comes from one song and 80% of that song's income comes from Spotify.", + "expected_behaviors": [ + "Treats track count as misleading", + "Calculates song and platform concentration", + "Separates top-song value from long-tail value", + "Applies sensitivity or concentration discount", + "Explains platform risk clearly" + ] +} diff --git a/plugins/music-catalog-diligence/evals/scenarios/pro-bonus-spike.json b/plugins/music-catalog-diligence/evals/scenarios/pro-bonus-spike.json new file mode 100644 index 0000000..504978d --- /dev/null +++ b/plugins/music-catalog-diligence/evals/scenarios/pro-bonus-spike.json @@ -0,0 +1,12 @@ +{ + "id": "pro-bonus-spike", + "workflow": "buy-side", + "prompt": "A publishing catalog shows $1.2M LTM NPS, but most of the increase came from one ASCAP/BMI performance royalty quarter tied to TV and radio. Build valuation guidance.", + "expected_behaviors": [ + "Challenges flat headline NPS multiple", + "Separates base performance income from bonus or premium income", + "Normalizes the run-rate", + "Shows bridge from reported LTM to underwritable NPS", + "Applies scenario or haircut treatment" + ] +} diff --git a/plugins/music-catalog-diligence/evals/scenarios/recoupment-cliff.json b/plugins/music-catalog-diligence/evals/scenarios/recoupment-cliff.json new file mode 100644 index 0000000..b6bc1eb --- /dev/null +++ b/plugins/music-catalog-diligence/evals/scenarios/recoupment-cliff.json @@ -0,0 +1,12 @@ +{ + "id": "recoupment-cliff", + "workflow": "buy-side", + "prompt": "A master catalog has strong gross streaming revenue, but most artist accounts are unrecouped and several are close to recouping. Project value.", + "expected_behaviors": [ + "Models NLS instead of gross revenue", + "Identifies current cash flow versus post-recoupment cash flow", + "Flags missing recoupment schedules or contract terms", + "Shows downside and base cases", + "Recommends valuation treatment for near-recouped accounts" + ] +} diff --git a/plugins/music-catalog-diligence/evals/scenarios/seller-prep-cleanup.json b/plugins/music-catalog-diligence/evals/scenarios/seller-prep-cleanup.json new file mode 100644 index 0000000..b15a286 --- /dev/null +++ b/plugins/music-catalog-diligence/evals/scenarios/seller-prep-cleanup.json @@ -0,0 +1,12 @@ +{ + "id": "seller-prep-cleanup", + "workflow": "seller-prep", + "prompt": "An artist wants to sell a catalog in six months. Find what to clean up now to maximize buyer confidence.", + "expected_behaviors": [ + "Creates seller-prep framing instead of buyer bid framing", + "Ranks missing docs and metadata issues by value impact", + "Identifies quick fixes versus counsel/admin work", + "Avoids promising valuation uplift without evidence", + "Produces a seller cleanup report outline" + ] +} diff --git a/plugins/music-catalog-diligence/fixtures/external-sources.md b/plugins/music-catalog-diligence/fixtures/external-sources.md new file mode 100644 index 0000000..af71ca3 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/external-sources.md @@ -0,0 +1,49 @@ +# External fixture sources + +The golden fixtures in this directory are synthetic. They are shaped from public +schemas, official templates, and vendor documentation, but they do not copy +private royalty statements or confidential catalog data. + +## Source map + +| Fixture | Public basis | Source type | Notes | +| --- | --- | --- | --- | +| `golden/ascap-performance` | ASCAP royalty/payment and cue-sheet documentation | Official docs/templates | Tests performance royalty fields such as use type, credits, bonus type, licensee, and publisher share. | +| `golden/bmi-performance` | BMI radio royalty and public performance royalty documentation | Official docs | Tests BMI-style bonus category and publisher net normalization. | +| `golden/mlc-mechanical` | The MLC Royalty Detail and Work Summary statement layout examples | Official layout examples | Tests mechanical publishing income, DSP, usage period, and publisher net. | +| `golden/distributor-master` | Common distributor/DSP royalty export patterns | Industry-style synthetic | Tests master streaming gross, fee, net, platform, territory, and ISRC mapping. | +| `golden/youtube-content-id` | YouTube Reporting API system-managed financial report field docs | Official schema docs | Tests Content ID/UGC revenue, YouTube platform mapping, asset metadata, and net revenue. | +| `golden/curve-income` | Curve template and income import documentation | Vendor docs | Tests source-provided rights type, income type, source work ID, royalty source, and net payable fields. | + +## Useful public sources + +- ASCAP royalties and cue sheets: + `https://www.ascap.com/help/royalties-and-payment` +- BMI U.S. radio royalties: + `https://www.bmi.com/creators/royalty/us_radio_royalties` +- The MLC statement layouts: + `https://help.themlc.com/en/support/what-do-the-different-statement-layouts-mean` +- The MLC data programs: + `https://www.themlc.com/data-programs-all` +- YouTube system-managed report fields: + `https://developers.google.com/youtube/reporting/v1/reports/system_managed/fields` +- YouTube financial reports: + `https://developers.google.com/youtube/reporting/v1/reports/system_managed/ads` +- DDEX sample resources: + `https://kb.ddex.net/reference-material/standards-specifications/` +- DDEX RDR-R samples: + `https://kb.ddex.net/implementing-each-standard/recording-data-and-rights-standards-(rdr)/recording-data-and-rights-revenue-reporting-(rdr-r)/rdr-r-samples` +- CWR examples: + `https://matijakolaric.com/articles/formats/cwr/minimal-cwr` +- Curve template docs: + `https://help.curveroyaltysystems.com/article/118-how-to-create-a-template` + +## Fixture policy + +Use public schemas to create small synthetic rows. Add real customer/export +fixtures only when: + +1. The owner permits use in tests. +2. Personal, payment, and confidential data is removed. +3. The fixture is small enough to review. +4. The expected normalized output is committed beside the input. diff --git a/plugins/music-catalog-diligence/fixtures/golden/ascap-performance/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/ascap-performance/expected-royalty-ledger.csv new file mode 100644 index 0000000..1dcd128 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/ascap-performance/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,iswc:T1234567890,fixtures/golden/ascap-performance/input.csv,ASCAP,2025-01-01,2025-03-31,2025-06-15,publishing,performance,US,WABC-FM,1250.00,125.00,,1125.00,USD,,Feature,125.5,Audio Feature Premium,,medium,work_id=ASC123 diff --git a/plugins/music-catalog-diligence/fixtures/golden/ascap-performance/input.csv b/plugins/music-catalog-diligence/fixtures/golden/ascap-performance/input.csv new file mode 100644 index 0000000..fe1f380 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/ascap-performance/input.csv @@ -0,0 +1,2 @@ +Work Title,Work ID,ISWC,Performance Start,Performance End,Distribution Date,Territory,Licensee,Use Type,Credits,Bonus Type,Gross Amount,Deductions,Publisher Share,Currency +Forever Hook,ASC123,T1234567890,2025-01-01,2025-03-31,2025-06-15,US,WABC-FM,Feature,125.5,Audio Feature Premium,"$1,250.00",$125.00,"$1,125.00",USD diff --git a/plugins/music-catalog-diligence/fixtures/golden/bmi-performance/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/bmi-performance/expected-royalty-ledger.csv new file mode 100644 index 0000000..481e8f6 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/bmi-performance/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,iswc:T5555555555,fixtures/golden/bmi-performance/input.csv,BMI,2025-04-01,2025-06-30,2025-09-20,publishing,performance,US,KXYZ-FM,2000.00,200.00,,1800.00,USD,,Feature,,Hit Song Bonus,,medium,bmi_work_id=BMI456 diff --git a/plugins/music-catalog-diligence/fixtures/golden/bmi-performance/input.csv b/plugins/music-catalog-diligence/fixtures/golden/bmi-performance/input.csv new file mode 100644 index 0000000..dc7bc83 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/bmi-performance/input.csv @@ -0,0 +1,2 @@ +Title,BMI Work #,ISWC,Period Start,Period End,Payment Date,Territory,Station,Performance Type,Bonus Category,Amount,Admin Fee,Publisher Net,Currency +Second Best,BMI456,T5555555555,2025-04-01,2025-06-30,2025-09-20,US,KXYZ-FM,Feature,Hit Song Bonus,"$2,000.00",$200.00,"$1,800.00",USD diff --git a/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/expected-royalty-ledger.csv new file mode 100644 index 0000000..1b49191 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/expected-royalty-ledger.csv @@ -0,0 +1,4 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,iswc:T-901112322-8,fixtures/golden/bmi-real-headers/input.csv,BMI,,,,publishing,performance,,,,,,,USD,,,,,,medium, +RL-000002,iswc:T-922001124-7,fixtures/golden/bmi-real-headers/input.csv,BMI,,,,publishing,performance,,,,,,,USD,,,,,,medium, +RL-000003,iswc:T-922001124-7,fixtures/golden/bmi-real-headers/input.csv,BMI,,,,publishing,performance,,,,,,,USD,,,,,,medium, diff --git a/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/expected-status.json b/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/expected-status.json new file mode 100644 index 0000000..e55369d --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/expected-status.json @@ -0,0 +1,23 @@ +{ + "status": "partial", + "provider": "BMI", + "rows": 3, + "field_population_rate": 0.0, + "expected_columns_not_found_includes": [ + "Amount", + "Amount Paid", + "Gross Amount", + "Net", + "Period End", + "Period Start", + "Publisher Net", + "Statement Qtr" + ], + "headers_seen": [ + "title", + "writer", + "iswc", + "share_pct", + "net_royalty_usd" + ] +} diff --git a/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/input.csv b/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/input.csv new file mode 100644 index 0000000..6b7c42a --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/bmi-real-headers/input.csv @@ -0,0 +1,4 @@ +title,writer,iswc,share_pct,net_royalty_usd +Letter from Hattiesburg,Marvin Briggs,T-901112322-8,50.00,186.49 +Don't Stop Now,Mari Vega,T-922001124-7,75.00,356.25 +Don't Stop Now (Acoustic),Mari Vega,T-922001124-7,75.00,50.53 diff --git a/plugins/music-catalog-diligence/fixtures/golden/curve-income/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/curve-income/expected-royalty-ledger.csv new file mode 100644 index 0000000..b5cbfdc --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/curve-income/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,CURVE-WORK-1,fixtures/golden/curve-income/input.csv,Curve,2025-01-01,2025-03-31,,master,streaming,DE,Deezer,120.00,12.00,,108.00,EUR,,,,,,medium, diff --git a/plugins/music-catalog-diligence/fixtures/golden/curve-income/input.csv b/plugins/music-catalog-diligence/fixtures/golden/curve-income/input.csv new file mode 100644 index 0000000..3423da4 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/curve-income/input.csv @@ -0,0 +1,2 @@ +Track Title,ISRC,Source Work ID,Rights Type,Income Type,Period Start,Period End,Territory,Royalty Source,Gross,Deductions,Net Payable,Currency +Curve Ball,USCV12345678,CURVE-WORK-1,master,streaming,2025-01-01,2025-03-31,DE,Deezer,120.00,12.00,108.00,EUR diff --git a/plugins/music-catalog-diligence/fixtures/golden/direct-sync/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/direct-sync/expected-royalty-ledger.csv new file mode 100644 index 0000000..69ac34b --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/direct-sync/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,isrc:USRC17600003,fixtures/golden/direct-sync/input.csv,Direct Sync,2025-05-01,2025-05-31,,sync,sync,Worldwide,Indie Film LLC,5000.00,0.00,,5000.00,USD,,,,,,medium, diff --git a/plugins/music-catalog-diligence/fixtures/golden/direct-sync/input.csv b/plugins/music-catalog-diligence/fixtures/golden/direct-sync/input.csv new file mode 100644 index 0000000..a5735c9 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/direct-sync/input.csv @@ -0,0 +1,2 @@ +Track Title,ISRC,Sales Month,Source,Territory,Gross,Deductions,Net Payable,Currency +Trailer Sparks,USRC17600003,2025-05,Indie Film LLC,Worldwide,5000.00,0.00,5000.00,USD diff --git a/plugins/music-catalog-diligence/fixtures/golden/distributor-master/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/distributor-master/expected-royalty-ledger.csv new file mode 100644 index 0000000..5bbc22a --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/distributor-master/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,isrc:USRC17607839,fixtures/golden/distributor-master/input.csv,Distributor,2025-02-01,2025-02-28,,master,streaming,GB,Spotify,500.00,75.00,,425.00,USD,,,,,,medium, diff --git a/plugins/music-catalog-diligence/fixtures/golden/distributor-master/input.csv b/plugins/music-catalog-diligence/fixtures/golden/distributor-master/input.csv new file mode 100644 index 0000000..ba55f8d --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/distributor-master/input.csv @@ -0,0 +1,2 @@ +Track Title,ISRC,Sales Month,Store,Territory,Gross,Fee,Net,Currency +Night Drive,USRC17607839,2025-02,Spotify,GB,500.00,75.00,425.00,USD diff --git a/plugins/music-catalog-diligence/fixtures/golden/mlc-mechanical/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/mlc-mechanical/expected-royalty-ledger.csv new file mode 100644 index 0000000..d656f72 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/mlc-mechanical/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,iswc:T9999999999,fixtures/golden/mlc-mechanical/input.csv,The MLC,2025-03-01,2025-03-31,,publishing,mechanical,US,Apple Music,300.00,30.00,,270.00,USD,,,,,,medium, diff --git a/plugins/music-catalog-diligence/fixtures/golden/mlc-mechanical/input.csv b/plugins/music-catalog-diligence/fixtures/golden/mlc-mechanical/input.csv new file mode 100644 index 0000000..ff1cbfd --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/mlc-mechanical/input.csv @@ -0,0 +1,2 @@ +Song Title,ISWC,Usage Period,DSP,Territory,Mechanical Gross,Admin Fee,Publisher Net,Currency +Mechanical Moon,T9999999999,2025-03,Apple Music,US,300.00,30.00,270.00,USD diff --git a/plugins/music-catalog-diligence/fixtures/golden/publisher-admin/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/publisher-admin/expected-royalty-ledger.csv new file mode 100644 index 0000000..efe5ad8 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/publisher-admin/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,isrc:USRC17600001,fixtures/golden/publisher-admin/input.csv,Publisher Admin,2025-03-01,2025-03-31,,publishing,mechanical,US,Publisher Admin Portal,320.00,32.00,,288.00,USD,,,,,,medium, diff --git a/plugins/music-catalog-diligence/fixtures/golden/publisher-admin/input.csv b/plugins/music-catalog-diligence/fixtures/golden/publisher-admin/input.csv new file mode 100644 index 0000000..879838f --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/publisher-admin/input.csv @@ -0,0 +1,2 @@ +Track Title,ISRC,Sales Month,Source,Territory,Gross,Deductions,Net Payable,Currency +Late Night Signal,USRC17600001,2025-03,Publisher Admin Portal,US,320.00,32.00,288.00,USD diff --git a/plugins/music-catalog-diligence/fixtures/golden/soundexchange/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/soundexchange/expected-royalty-ledger.csv new file mode 100644 index 0000000..005d060 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/soundexchange/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,isrc:USRC17600002,fixtures/golden/soundexchange/input.csv,SoundExchange,2025-04-01,2025-04-30,,neighboring,radio,US,SoundExchange,210.00,10.50,,199.50,USD,,,,,,medium, diff --git a/plugins/music-catalog-diligence/fixtures/golden/soundexchange/input.csv b/plugins/music-catalog-diligence/fixtures/golden/soundexchange/input.csv new file mode 100644 index 0000000..4b46bc4 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/soundexchange/input.csv @@ -0,0 +1,2 @@ +Track Title,ISRC,Sales Month,Source,Territory,Gross,Deductions,Net Payable,Currency +Road Static,USRC17600002,2025-04,SoundExchange,US,210.00,10.50,199.50,USD diff --git a/plugins/music-catalog-diligence/fixtures/golden/youtube-content-id/expected-royalty-ledger.csv b/plugins/music-catalog-diligence/fixtures/golden/youtube-content-id/expected-royalty-ledger.csv new file mode 100644 index 0000000..112372d --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/youtube-content-id/expected-royalty-ledger.csv @@ -0,0 +1,2 @@ +ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,payment_date,rights_type,income_type,territory,platform_or_licensee,gross_amount,deductions,participant_share,owner_net_amount,currency,fx_rate,pro_use_type,pro_credits,pro_bonus_type,cue_sheet_ref,match_confidence,notes +RL-000001,isrc:USYT19990001,fixtures/golden/youtube-content-id/input.csv,YouTube Content ID,2025-01-01,2025-01-31,,master,ugc,US,YouTube,900.00,,,630.00,USD,,,,,,medium,content_type=Sound Recording diff --git a/plugins/music-catalog-diligence/fixtures/golden/youtube-content-id/input.csv b/plugins/music-catalog-diligence/fixtures/golden/youtube-content-id/input.csv new file mode 100644 index 0000000..d240da3 --- /dev/null +++ b/plugins/music-catalog-diligence/fixtures/golden/youtube-content-id/input.csv @@ -0,0 +1,2 @@ +Asset Title,ISRC,Reporting Month,Country,Gross Revenue,Net Revenue,Currency,Content Type +Claimed Chorus,USYT19990001,2025-01,US,900.00,630.00,USD,Sound Recording diff --git a/plugins/music-catalog-diligence/hooks/hooks.json b/plugins/music-catalog-diligence/hooks/hooks.json new file mode 100644 index 0000000..767e8b0 --- /dev/null +++ b/plugins/music-catalog-diligence/hooks/hooks.json @@ -0,0 +1,29 @@ +{ + "description": "Music catalog diligence guardrails. PreToolUse blocks writes into deals/{deal-id}/source/ (immutable evidence). Stop enforces the completion gate from references/deal-workspace.md before the agent finishes.", + "hooks": { + "PreToolUse": [ + { + "matcher": "Write|Edit|MultiEdit", + "hooks": [ + { + "type": "command", + "command": "bash \"${CLAUDE_PLUGIN_ROOT}/hooks/protect-source-files.sh\"", + "timeout": 5 + } + ] + } + ], + "Stop": [ + { + "matcher": "*", + "hooks": [ + { + "type": "prompt", + "prompt": "You are a completion-gate reviewer for the music-catalog-diligence plugin. The main agent is about to stop. Inspect the recent conversation and decide whether to block the stop.\n\nSTEP 1 - Detect completion claims.\nLook for the agent claiming a deal package or deliverable is 'ready', 'complete', 'done', 'shareable', 'ready for IC', 'ready for the buyer', 'ready for the seller', 'ready for the lender', 'ready for review', or any equivalent finishing language. Also count it as a completion claim if the agent has just produced or finalized a memo under deals/{deal-id}/memos/ (ic-memo, seller-cleanup-report, financing-pack, post-close-admin-plan).\n\nIf NO completion claim is present, respond with: {\"decision\": \"approve\"}\n\nSTEP 2 - If a completion claim IS present, verify each item below from references/deal-workspace.md and the project README. Treat anything not visibly satisfied in the conversation as unmet.\n\n a. python3 scripts/run-diligence-checks.py deals/{deal-id} was run during this session and exited cleanly. Per README: 'Do not mark a package ready if run-diligence-checks.py fails or the dashboard shows blocked.'\n b. The diligence dashboard (build-diligence-dashboard.py) does not show status 'blocked'.\n c. assumptions.yaml exists in the deal workspace.\n d. evidence-ledger.json exists in the deal workspace.\n e. Material findings in findings/findings.json are closed, accepted, or explicitly listed as open. They are not silently dropped.\n f. Memo claims trace to evidence-ledger.json entries or assumptions.yaml entries (no unsupported claims).\n\nSTEP 3 - Decide.\n\nIf every condition is met: {\"decision\": \"approve\"}\n\nIf any condition is unmet: {\"decision\": \"block\", \"reason\": \"\", \"systemMessage\": \"Resolve the unmet completion-gate items before declaring the package ready. Run the missing validators or disclose the gap explicitly.\"}\n\nBe strict only when completion is being claimed. Default to approve for ordinary chat, exploration, ingest-only sessions, or partial work where the agent is not claiming the package is ready.", + "timeout": 30 + } + ] + } + ] + } +} diff --git a/plugins/music-catalog-diligence/hooks/protect-source-files.sh b/plugins/music-catalog-diligence/hooks/protect-source-files.sh new file mode 100755 index 0000000..aaa5e54 --- /dev/null +++ b/plugins/music-catalog-diligence/hooks/protect-source-files.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# +# protect-source-files.sh +# +# PreToolUse hook that blocks Write/Edit/MultiEdit calls targeting any path +# inside a deal's source/ directory. Seller-provided files in deals/*/source/ +# are immutable evidence per references/deal-workspace.md. +# +# Contract (Claude Code hooks): +# - stdin: JSON describing the pending tool call +# - stdout: JSON with hookSpecificOutput.permissionDecision = "allow" | "deny" +# - exit 0 on success; the decision itself is carried in the JSON body +# +# Why a regex on /source/? +# The deal workspace convention (references/deal-workspace.md) places raw +# seller files under deals/{deal-id}/source/. Any write into that subtree +# would mutate evidence, so we deny it categorically and tell the agent +# where to write instead. + +set -euo pipefail + +# Read the entire stdin payload from Claude Code into a variable. +# Hooks always receive a single JSON document on stdin. +input="$(cat)" + +# Pull the target file path out of the JSON. `// empty` means "if the field +# is missing or null, return an empty string" so the script does not crash +# on tools that have no file_path (the matcher should prevent that, but +# defensive parsing keeps the hook safe). +file_path="$(printf '%s' "$input" | jq -r '.tool_input.file_path // empty')" + +# If for some reason no path was provided, allow the call. The matcher in +# hooks.json already restricts us to file-mutating tools, so this is a +# belt-and-suspenders fallback rather than a real code path. +if [ -z "$file_path" ]; then + printf '{"hookSpecificOutput":{"permissionDecision":"allow"}}\n' + exit 0 +fi + +# The deny rule: any path containing "/source/" inside a deals/ workspace. +# We require both segments so we do not accidentally block unrelated repos +# that happen to have a directory called "source" (e.g. a generic src tree). +# We match relative (`deals/...`), dot-relative (`./deals/...`), and absolute +# (`/abs/deals/...`) variants so the guard cannot be bypassed by how the agent +# expresses the path. +case "$file_path" in + deals/*/source/*|./deals/*/source/*|*/deals/*/source/*) + reason="Refused write to immutable source file: ${file_path}. " + reason+="Per references/deal-workspace.md, deals/{deal-id}/source/ holds " + reason+="raw seller evidence and must not be edited. Write to normalized/, " + reason+="workpapers/, findings/, or memos/ inside the same deal instead." + # jq -n builds JSON from scratch using --arg to inject the reason safely + # (no shell interpolation into the JSON body, which would break on quotes + # or newlines in the path). + jq -n --arg reason "$reason" '{ + hookSpecificOutput: { + permissionDecision: "deny" + }, + systemMessage: $reason + }' + exit 0 + ;; +esac + +# Default path: allow the write. We still emit a JSON body so Claude Code +# always sees a structured decision. +printf '{"hookSpecificOutput":{"permissionDecision":"allow"}}\n' +exit 0 diff --git a/plugins/music-catalog-diligence/references/deal-workspace.md b/plugins/music-catalog-diligence/references/deal-workspace.md new file mode 100644 index 0000000..9c79b97 --- /dev/null +++ b/plugins/music-catalog-diligence/references/deal-workspace.md @@ -0,0 +1,155 @@ +# Deal workspace + +A deal workspace is the operating folder for one catalog opportunity. It keeps +raw seller files separate from normalized data, workpapers, findings, and final +outputs. + +## Folder structure + +Use this structure unless the user already has a stronger convention: + +```text +deals/{deal-id}/ +├── source/ +├── normalized/ +├── workpapers/ +├── findings/ +├── memos/ +├── assumptions.yaml +└── evidence-ledger.json +``` + +## Folder rules + +### `source/` + +Store raw data-room files exactly as received. + +Rules: + +- Do not edit files in `source/`. +- Do not rename files unless the original path is preserved in the manifest. +- Treat files as evidence, not working copies. +- Record checksums when possible. + +### `normalized/` + +Store parsed and cleaned structured data. + +Common files: + +- `data-room-inventory.csv` +- `canonical-catalog.csv` +- `royalty-ledger.csv` +- `rights-map.csv` +- `source-lineage.csv` +- `normalized-ledger-validation.json` + +### `workpapers/` + +Store calculations and intermediate analysis. + +Common files: + +- `nps-nls-bridge.json` +- `concentration-analysis.json` +- `valuation-scenarios.md` +- `recoupment-analysis.md` +- `pro-performance-analysis.md` + +### `findings/` + +Store structured diligence exceptions and red flags. + +Common files: + +- `findings.json` +- `missing-files.md` +- `rights-exceptions.md` +- `royalty-exceptions.md` +- `metadata-exceptions.md` + +### `memos/` + +Store human-facing outputs. + +Common files: + +- `ic-memo.md` +- `seller-cleanup-report.md` +- `financing-pack.md` +- `post-close-admin-plan.md` + +## Deal assumptions + +Use `assumptions.yaml` for values that affect analysis but are not directly +present in source files. + +Examples: + +- Buyer name. +- Deal type. +- Currency. +- Valuation date. +- Discount rate. +- Multiple range. +- FX source. +- Recoupment assumptions. +- Reserve treatment. +- Materiality thresholds. + +Assumptions are allowed. Hidden assumptions are not. + +## Evidence ledger + +Use `evidence-ledger.json` to trace extracted facts back to source files. + +Every material memo claim should map to one or more evidence entries or be +labeled as an assumption. + +Minimum fields: + +- `evidence_id` +- `source_file` +- `source_type` +- `locator` +- `extracted_field` +- `extracted_value` +- `confidence` +- `notes` + +## Findings + +Use `findings/findings.json` for structured issues. Each finding should include: + +- Severity. +- Category. +- Affected assets. +- Evidence IDs. +- Issue. +- Valuation treatment. +- Follow-up. +- Status. + +## Workspace lifecycle + +1. Create the workspace with templates. +2. Copy seller files into `source/`. +3. Build a manifest and evidence ledger. +4. Normalize catalog and royalty data into `normalized/`. +5. Run deterministic checks into `workpapers/`. +6. Write structured exceptions into `findings/`. +7. Assemble memos from normalized data, findings, and assumptions. +8. Review with specialist agents. +9. Update status as items are cured or accepted. + +## Completion gate + +Do not call a deal package complete unless: + +- `source/` is preserved. +- `evidence-ledger.json` exists. +- `assumptions.yaml` exists. +- Material findings are either closed, accepted, or listed as open. +- Memo claims trace to evidence or assumptions. +- Validation scripts have been run or missing validations are disclosed. diff --git a/plugins/music-catalog-diligence/references/diligence-workflow.md b/plugins/music-catalog-diligence/references/diligence-workflow.md new file mode 100644 index 0000000..4aa12c0 --- /dev/null +++ b/plugins/music-catalog-diligence/references/diligence-workflow.md @@ -0,0 +1,219 @@ +# Diligence workflow + +Music catalog deals move quickly only when the team can turn messy source files +into traceable underwriting evidence. Use this workflow as the end-to-end map +for buy-side acquisition diligence, seller preparation, and financing +underwriting. + +## 1. Sourcing and first screen + +The first screen decides whether a catalog is worth diligence time. Inputs are +usually a teaser, seller summary, track list, headline royalty history, and +high-level streaming or social data. + +Roles involved: + +- Deal lead +- A&R or catalog strategy lead +- Finance analyst +- Broker or seller representative + +High-value checks: + +- Separate publishing, masters, neighboring rights, and mixed-rights income. +- Identify whether the seller is showing gross income, NPS, NLS, or something + undefined. +- Check top-work and top-platform concentration before caring about track count. +- Treat recent spikes as hypotheses, not underwritable cash flow. + +Plugin opportunity: produce a preliminary deal screen with asset type, missing +data, likely diligence risks, and a caveated value range. + +## 2. Data room request and setup + +The data room determines the rest of the deal. Slow deals often start with an +incomplete room. + +Core inputs: + +- Catalog schedule +- Royalty statements +- Agreements and amendments +- Split sheets +- Registration exports +- Recoupment schedules +- Reserve schedules +- Analytics exports +- Asset files and cue sheets + +High-value checks: + +- Create a manifest before reading deeply. +- Preserve raw files as immutable source evidence. +- Classify files by financial, legal, metadata, analytics, assets, and unknown. +- Produce a missing-file list with affected assets and business impact. + +Plugin opportunity: create a dynamic request list and evidence ledger that +updates as files arrive. + +## 3. Chain of title + +Chain of title proves whether the seller can transfer the rights being valued. +It is not proven by royalty income alone. + +Core inputs: + +- Publishing agreements +- Recording agreements +- Assignments +- Split sheets +- Producer and featured artist agreements +- Sample and interpolation clearances +- Copyright registrations +- UCC or lien searches +- Termination, reversion, or estate documents + +High-value checks: + +- Link each income-generating asset to rights support. +- Track territory, term, controlled share, approval rights, and restrictions. +- Flag partial assignments, missing signatures, side letters, and conflicts. +- Separate "earning money" from "transferable ownership." + +Plugin opportunity: build a rights graph with support levels and open cure +items. + +## 4. Royalty statement normalization + +Royalty statements are the primary bottleneck because every provider formats +them differently. + +Core inputs: + +- Distributor and label statements +- Publisher administrator statements +- PRO and foreign society statements +- MLC/mechanical statements +- SoundExchange and neighboring rights +- YouTube Content ID +- Sync and direct licensing + +High-value checks: + +- Preserve period earned versus period paid. +- Normalize rights type, income type, territory, source, gross, deductions, + participant share, and owner net. +- Map statement lines to canonical assets by ISRC, ISWC, UPC, title, and party. +- Keep unmatched lines visible instead of dropping them. + +Plugin opportunity: produce a canonical royalty ledger with source lineage and +confidence. + +## 5. NPS and NLS reconstruction + +Valuation depends on owner cash flow, not top-line royalties. + +Publishing analysis focuses on NPS: + +- Performance income +- Mechanical income +- Sync +- Print and other publishing income +- Writer share +- Co-publisher share +- Administration and collection fees + +Master analysis focuses on NLS: + +- Label/master receipts +- Artist royalties +- Producer royalties +- Distribution fees +- Reserves and returns +- Recoupment status + +Plugin opportunity: build bridges from reported royalties to normalized NPS/NLS +with every deduction explained. + +## 6. PRO, MLC, and metadata checks + +Performance and mechanical income can disappear when metadata is wrong. + +High-value checks: + +- Verify registrations against income-generating works. +- Match ISRCs to ISWCs. +- Check writer and publisher shares. +- Preserve PRO use type, credits, cue-sheet, bonus, and territory fields. +- Compare audience geography against foreign society income. +- Flag unmatched MLC recordings and missing registrations. + +Plugin opportunity: create claim and metadata cleanup worklists. + +## 7. Recoupment and reserves + +For master catalogs, current cash flow can change sharply after close. + +High-value checks: + +- Identify unrecouped artist accounts. +- Model when royalty accounts flip payable. +- Check cross-collateralization and rate escalators. +- Review reserve release history and future reserve exposure. +- Separate current owner cash flow from sustainable post-close cash flow. + +Plugin opportunity: convert contract terms and schedules into scenario +assumptions with citations. + +## 8. Valuation and underwriting + +Valuation starts after evidence and normalization. It does not start with a +multiple. + +Core outputs: + +- LTM/TTM earnings +- Three-year average +- Normalized run-rate +- Concentration analysis +- Downside/base/upside cases +- Market multiple range +- DCF when assumptions are supportable +- Risk register and holdback recommendation + +Plugin opportunity: refresh valuation exhibits from the same evidence ledger +used for diligence findings. + +## 9. IC memo and bid recommendation + +The investment committee needs conclusions, but every material claim needs an +evidence trail. + +High-value sections: + +- Executive valuation range +- Deal overview +- Asset and rights summary +- Revenue bridge +- Concentration and sensitivity +- Diligence exceptions +- Missing-document list +- Recommended price adjustments, escrows, or holdbacks + +Plugin opportunity: assemble a memo from structured findings instead of +retyping analysis from scattered workpapers. + +## 10. Close and post-close administration + +Closing can fail when schedules do not match diligence. Post-close income can +leak when registrations and royalty systems are not transferred cleanly. + +High-value checks: + +- Compare final schedules to canonical catalog rows. +- Confirm assignments, lien releases, transfer notices, and payment directions. +- Track PRO, MLC, SoundExchange, distributor, and admin updates. +- Monitor first post-close statements for missing income. + +Plugin opportunity: create a closing checklist and post-close admin tracker +from the same canonical data. diff --git a/plugins/music-catalog-diligence/references/normalization.md b/plugins/music-catalog-diligence/references/normalization.md new file mode 100644 index 0000000..084183f --- /dev/null +++ b/plugins/music-catalog-diligence/references/normalization.md @@ -0,0 +1,115 @@ +# Normalization + +Normalization converts provider-specific royalty exports into the canonical +`royalty-ledger.csv` shape. This lets downstream analysis calculate NPS/NLS, +concentration, and valuation bridges from one consistent ledger. + +## Current normalizer + +Use `scripts/normalize-royalty-statement.py` for first-pass CSV normalization. + +```bash +python3 scripts/normalize-royalty-statement.py \ + --provider ascap \ + --input deals/example/source/ascap.csv \ + --output deals/example/normalized/royalty-ledger.csv +``` + +Supported provider profiles: + +| Provider key | Intended source | Rights type | Income type | +| --- | --- | --- | --- | +| `ascap` | ASCAP-style performance CSV exports | publishing | performance | +| `bmi` | BMI-style performance CSV exports | publishing | performance | +| `mlc` | MLC-style mechanical CSV exports | publishing | mechanical | +| `distributor` | Distributor/DSP master royalty CSV exports | master | streaming | +| `publisher-admin` | Publisher administrator mechanical CSV exports | publishing | mechanical | +| `soundexchange` | SoundExchange/neighboring rights CSV exports | neighboring | radio | +| `direct-sync` | Direct sync income tracker CSV exports | sync | sync | +| `youtube-content-id` | YouTube Content ID CSV exports | master | UGC | +| `curve` | Curve-style royalty accounting CSV exports | source-provided | source-provided | + +## What the normalizer guarantees + +The script writes all canonical ledger columns: + +- `ledger_line_id` +- `catalog_asset_id` +- `source_file` +- `provider` +- `period_start` +- `period_end` +- `payment_date` +- `rights_type` +- `income_type` +- `territory` +- `platform_or_licensee` +- `gross_amount` +- `deductions` +- `participant_share` +- `owner_net_amount` +- `currency` +- `fx_rate` +- `pro_use_type` +- `pro_credits` +- `pro_bonus_type` +- `cue_sheet_ref` +- `match_confidence` +- `notes` + +It also preserves important PRO details such as use type, credits, bonus type, +licensee, and work IDs when source columns are present. + +## What it does not guarantee + +The current normalizer is a first-pass adapter for common CSV shapes. It does +not yet guarantee: + +- Exact support for every real ASCAP, BMI, MLC, distributor, YouTube, or Curve + export variant. +- PDF/OCR extraction. +- Automatic ISRC-to-ISWC matching. +- Contract-based recoupment or reserve calculation. +- Legal ownership support. + +Use validation scripts and human review after normalization. + +## How to know it works + +Use tests and validations: + +1. Run `python3 scripts/test-normalize-royalty-statement.py`. +2. Run `python3 scripts/test-golden-fixtures.py`. +3. Run `python3 scripts/validate-normalized-ledger.py `. +4. Spot-check source lineage by comparing normalized rows back to source rows. +5. Add a new fixture whenever a real provider export has a new column shape. + +Provider parsers become trustworthy only when backed by golden fixtures from +real or representative exports. + +## Synthetic data room smoke test + +The plugin has been tested against the local Neon River synthetic data room +generated by `.local/generate_neon_river_data_room.py`. That room includes +messy seller-style exports for distributor, publisher admin, ASCAP, BMI, MLC, +SoundExchange, YouTube Content ID, and direct sync sources. + +The smoke test normalizes all eight raw statement sources and validates each +output with `validate-normalized-ledger.py`. The row total matches the +generator's clean ledger count of 3,188 rows. + +## Golden fixtures + +Golden fixtures live in `fixtures/golden/{provider-scenario}/`: + +- `input.csv` is a small provider-shaped source export. +- `expected-royalty-ledger.csv` is the exact canonical output expected from the + normalizer. +- `expected-status.json` (optional) pins the expected normalizer payload status + for fixtures that should resolve to `partial` rather than `ok`. Add this file + whenever you build a partial-coverage fixture so the test suite can assert + the exact warning shape, not just row content. + +The current fixtures are synthetic and documented in +`fixtures/external-sources.md`. They are based on public schemas and official +documentation, not private royalty data. diff --git a/plugins/music-catalog-diligence/references/pro-performance-income.md b/plugins/music-catalog-diligence/references/pro-performance-income.md new file mode 100644 index 0000000..4b31d8a --- /dev/null +++ b/plugins/music-catalog-diligence/references/pro-performance-income.md @@ -0,0 +1,98 @@ +# PRO performance income + +Performance royalties need special handling in catalog valuation. They are not +fixed per-play payments. They are affected by registrations, cue sheets, survey +coverage, credit systems, pools, formats, territories, and premium or bonus +programs. + +## Why this matters + +A catalog can show high PRO income for reasons that may not repeat. If the +analysis treats that income as ordinary recurring NPS, the valuation can be too +high. + +The key question is: + +> Was this income generated by broad, repeatable base activity, or by a fragile +> event, threshold, bonus, premium, or one-time placement? + +## Fields to analyze + +When the ingest package includes PRO detail, analyze: + +- PRO or society. +- Domestic vs foreign income. +- Territory. +- Performance period. +- Payment period. +- Work ID and ISWC. +- Writer and publisher shares. +- Use type: feature, background, theme, underscore, live, commercial, or other. +- Format: terrestrial radio, streaming audio, TV, OTT, cable, satellite, + venues, or other. +- Credits or weighted units. +- Credit value or rate. +- Bonus/premium category. +- Cue-sheet reference. +- Program, episode, network, station, platform, or licensee. +- Duration and count. +- Retroactive adjustments. + +## Red flags + +Investigate these before underwriting performance income: + +- A single quarter drives a large share of LTM PRO income. +- Bonus or premium columns drive a large share of income. +- A song barely crossed a bonus threshold. +- Radio income is concentrated in one station, quarter, or promotion cycle. +- TV/OTT income depends on one program, episode, placement, or platform term. +- Cue sheets are missing or do not match statement lines. +- Streaming engagement grows but PRO income falls. +- Foreign income does not match known listener geography. +- Late registrations or late cue sheets create retroactive payments. +- Short-duration or background uses are modeled like full feature uses. + +## Normalization questions + +Ask these before valuing PRO income: + +1. What share of NPS comes from performance royalties? +2. What share of performance royalties comes from bonuses or premiums? +3. Which songs, quarters, territories, and formats drove the bonuses? +4. Are those drivers repeatable? +5. Are cue sheets complete and matched? +6. Are works registered under the seller's controlled share for the relevant + performance periods? +7. Does radio income align with current radio audience trends for the genre? +8. Does foreign income align with DSP and audience geography? +9. What would NPS be if bonus/premium and one-time placement income were + excluded? + +## Genre and vintage implications + +Use genre and vintage as context, not as a substitute for data. + +- Legacy pop, rock, country, standards, and holiday catalogs may have durable + radio and venue income, but may also be exposed to radio decline. +- Latin and country catalogs can be highly radio-dependent in some periods and + need format-shift sensitivity. +- Modern pop, hip-hop, dance, and viral catalogs can be more streaming-driven + and decay-sensitive. +- Film, TV, score, and production music catalogs depend heavily on cue sheets, + duration, use type, reruns, and international reporting. +- Venue-heavy, classical, jazz, religious, and niche catalogs may have reporting + lags or sampled survey limitations. + +## Output pattern + +For meaningful PRO exposure, include this table in the analysis: + +| Driver | LTM amount | Normalized amount | Treatment | Rationale | +| --- | ---: | ---: | --- | --- | +| Base radio current activity | `$` | `$` | Include | Repeatable usage across periods. | +| Audio feature premium | `$` | `$` | Haircut/exclude | Threshold-driven spike. | +| TV cue income | `$` | `$` | Include/haircut | Depends on rerun and cue-sheet support. | +| Foreign society adjustment | `$` | `$` | Exclude from run-rate | Retroactive catch-up. | + +Never hide these decisions inside a single multiple. diff --git a/plugins/music-catalog-diligence/references/red-flags.md b/plugins/music-catalog-diligence/references/red-flags.md new file mode 100644 index 0000000..7299eb7 --- /dev/null +++ b/plugins/music-catalog-diligence/references/red-flags.md @@ -0,0 +1,152 @@ +# Red flags + +Use this reference to classify diligence issues by severity and valuation +impact. Red flags do not always kill a deal. They force a decision: cure, +haircut, escrow, holdback, or walk away. + +## Severity levels + +| Severity | Meaning | Usual treatment | +| --- | --- | --- | +| Critical | Blocks transferability, cash-flow support, or final valuation. | Stop, cure, or exclude affected assets. | +| High | Material value or liability risk. | Haircut, escrow, holdback, or counsel review. | +| Medium | Important uncertainty that affects confidence. | Model sensitivity or require follow-up. | +| Low | Cleanup issue with limited value impact. | Track in post-close or seller-prep list. | + +## Severity calibration + +Anchor severity in measurable factors before assigning a tier. Pick the +worst-applicable factor; bump severity one tier when two or more factors +land in the same column. `revenue_threshold` refers to +`materiality.revenue_threshold` in `assumptions.yaml`. + +| Factor | Low | Medium | High | Critical | +| --- | --- | --- | --- | --- | +| Cumulative $ at risk vs `revenue_threshold` | <1× | 1×–10× | 10×–100× | >100× | +| Duration of mispayment or exposure | <1 yr | 1–5 yr | 5–15 yr | >15 yr or ongoing | +| % of catalog revenue affected | <1% | 1–10% | 10–25% | >25% | +| Title transferability impact | none | partial / curable | one asset blocked | catalog-wide block | +| Number of affected income-generating assets | 1 | 2–5 | 6–20 | >20 | + +Optionally record the rationale on the finding itself: + +```json +{ + "severity": "high", + "severity_factors": { + "dollars_at_risk_vs_threshold": "10x-100x", + "duration_years": 14, + "pct_revenue_affected": "5-10", + "transferability_impact": "one_asset", + "affected_asset_count": 1, + "calibration_note": "Duration alone places this in High." + } +} +``` + +### Worked examples + +- `SPLIT-02` — 14 years of mechanical mispayment on `Don't Stop Now` + (Vega vs. Doré split sheet 75/25 vs. registrations 70/30). Duration is + 5–15 years, so duration alone is **High**. Dollar impact and asset count + are Medium. Final severity: **High**. +- `SAMP-01` — uncleared 6-second sample on the catalog's top-earning song, + open demand letter from 2019. Duration is ongoing, transferability impact + is "one asset blocked," and the asset is the largest revenue contributor + (asset-level revenue concentration High). Three High factors → bump to + **Critical** if E&O exclusion confirms the exposure is uninsurable. +- `RR-02` — single zero-byte MLC monthly statement. Duration is one month, + dollars at risk are <1×, transferability impact is none. Final severity: + **Low**. + +When the factors disagree, the worst factor wins unless the finding is +explicitly bounded (e.g., "limited to one already-excluded asset"). + +## Rights and ownership + +Critical or high red flags: + +- Income-generating works with no signed split sheet or agreement. +- Contract says seller controls less share than the catalog schedule claims. +- Missing assignment in a chain of title. +- Territory rights are narrower than modeled revenue. +- Reversion or termination rights are near-term or unmodeled. +- Sample, interpolation, or featured artist approval is missing. +- UCC, lien, estate, divorce, or dispute documents show encumbrances. +- Sync licensing is modeled as one-stop when approvals are fragmented. + +## Metadata and registrations + +High-value red flags: + +- ISRCs and ISWCs are missing or not linked. +- Same ISRC appears on multiple conflicting titles. +- Same title has multiple writer splits across sources. +- Publisher or writer IPI/CAE values are missing for material works. +- PRO or MLC registrations do not match claimed splits. +- Income appears for works not present in the catalog schedule. +- Known streaming geography does not match collection territories. + +## Royalty statement quality + +High-value red flags: + +- Gross income is presented as NPS or NLS. +- Period paid is confused with period earned. +- FX assumptions are missing or inconsistent. +- Statement lines cannot be traced to source files. +- Duplicate statement imports inflate revenue. +- Large unmatched royalty lines are excluded from analysis. +- Reserve releases or retroactive payments inflate LTM. + +## PRO and performance income + +High-value red flags: + +- One quarter drives a large share of performance income. +- Bonus or premium columns drive a large share of PRO income. +- Cue sheets are missing for TV, film, or OTT income. +- Radio income is concentrated in one song, station, or promotion cycle. +- Foreign society adjustments appear as recurring income. +- Streaming usage grows while PRO income declines. +- Short-duration or background uses are modeled like feature uses. + +## Master catalog economics + +High-value red flags: + +- Artist accounts are near recoupment and future royalties are unmodeled. +- Producer points or featured artist participations are missing. +- Cross-collateralization is assumed but unsupported. +- Reserves are high or about to unwind. +- Distribution fee changes are not reflected in NLS. +- Re-recording/version risk affects the main earning recordings. + +## Valuation and concentration + +High-value red flags: + +- One song drives more than 40% of net income. +- One platform drives more than 50% of net income. +- Track count is used as diversification despite income concentration. +- Sync-heavy income is valued like recurring streaming or performance income. +- Viral or release-campaign spikes are treated as steady state. +- Decay assumptions are copied from comps without catalog-specific evidence. +- Active-management upside is included in base case without proof. + +## Output rule + +Every red flag should become a structured finding: + +```json +{ + "finding_id": "RF-001", + "severity": "high", + "category": "rights", + "affected_assets": ["catalog_asset_id"], + "evidence": ["source/path.pdf#page=4"], + "issue": "Income-generating work has no split sheet.", + "valuation_treatment": "Exclude from base case or apply holdback until cured.", + "follow_up": "Request signed split sheet and publishing agreement." +} +``` diff --git a/plugins/music-catalog-diligence/references/tooling-landscape.md b/plugins/music-catalog-diligence/references/tooling-landscape.md new file mode 100644 index 0000000..d375ee1 --- /dev/null +++ b/plugins/music-catalog-diligence/references/tooling-landscape.md @@ -0,0 +1,92 @@ +# Tooling landscape + +This plugin should complement systems of record and data providers, not pretend +to replace them. The strongest position is a diligence and analysis layer that +turns exports, files, and APIs into source-cited deal workpapers. + +## Royalty accounting systems + +These systems manage contracts, statements, calculations, and reporting. + +| Tool | What it solves | What remains manual | +| --- | --- | --- | +| Curve | Label and publisher royalty accounting, statement ingestion, contract terms, CWR, reporting. | Deal-specific diligence, cross-room evidence trails, valuation normalization, IC packaging. | +| RoyaltyShare | Royalty processing for labels, especially Orchard-connected workflows. | Independent buyer diligence across mixed external data rooms. | +| Vistex Counterpoint | Enterprise rights, contracts, repertoire, royalty accounting. | Lightweight deal-room analysis and AI-assisted exception synthesis. | +| Synchtank IRIS | Publisher royalty accounting, CMO imports, catalog analysis. | Buy-side underwriting, rights confidence scoring, memo generation. | + +Plugin role: consume exports, validate structures, reconcile with other sources, +and create diligence workpapers. Do not mutate these systems without a separate +approved integration. + +## Catalog and asset systems + +These systems organize recordings, assets, rights, and pitching workflows. + +| Tool | What it solves | What remains manual | +| --- | --- | --- | +| OpenPlay | Catalog metadata, rights, assets, delivery, API source of truth. | Acquisition diligence, valuation bridges, seller-file reconciliation. | +| DISCO | Asset management, pitching, tagging, rights lookup, sync workflows. | Financial diligence and cross-source royalty normalization. | +| DDEX/CWR files | Standardized delivery and publishing registration data. | Human-readable exceptions, confidence scoring, valuation impact. | + +Plugin role: reconcile data-room metadata with canonical catalog exports and +flag mismatches that affect collection or transferability. + +## Data and usage providers + +These tools provide external context and verification signals. + +| Tool | What it solves | What remains manual | +| --- | --- | --- | +| Luminate | Consumption, metadata enrichment, market measurement. | Mapping usage signals to seller royalty statements and valuation assumptions. | +| Chartmetric | Artist, playlist, social, radio, and platform analytics. | Royalty tie-outs, NPS/NLS, rights diligence. | +| Music Reports/Songdex | Licensing, ownership, DDEX, cue sheets, royalty reporting. | Deal-room synthesis and buyer-specific underwriting. | +| The MLC | U.S. blanket mechanical licensing, unmatched recordings, DQI, public/bulk data. | Linking MLC issues to catalog valuation and seller cleanup tasks. | +| BMAT, Soundmouse, Audoo | Usage detection, broadcast, cue sheets, public performance monitoring. | Translating detections into diligence exceptions and value impact. | + +Plugin role: use provider data as corroborating evidence, not as payable royalty +proof by itself. + +## Catalog finance and marketplaces + +These companies show the capital workflows the plugin can accelerate. + +| Company | Workflow signal | +| --- | --- | +| Royalty Exchange | Marketplace underwriting requires standardized royalty history and buyer-facing materials. | +| Duetti | Independent catalog acquisition needs fast master/publishing diligence and post-close operations. | +| beatBread | Financing workflows need collateral, revenue history, and deal comparison. | +| Lyric Financial | Advances and credit lines depend on royalty verification and risk controls. | +| Citrin Cooperman/Massarsky | Specialist valuation work depends on rigorous income composition, decay, and rights analysis. | +| Catalog funds and securitizations | Lender and ABS workflows require repeatable evidence, reserves, concentration, and valuation support. | + +Plugin role: create faster diligence packets, financing packages, and IC memos +from the same normalized evidence base. + +## Differentiators for this plugin + +To be world-class, the plugin should be: + +- **Evidence-first.** Every material conclusion links to source files, + extracted fields, or labeled assumptions. +- **System-agnostic.** Work from folders, exports, PDFs, CSVs, JSON, and API + responses instead of assuming one royalty platform. +- **Rights-aware.** Never collapse publishing, masters, neighboring rights, + recoupment, reserves, and sync into generic revenue. +- **Exception-driven.** Surface missing documents, mismatched identifiers, + unsupported income, and valuation-sensitive risks quickly. +- **Human-in-the-loop.** Counsel, royalty auditors, and investment teams approve + conclusions; the plugin does extraction, reconciliation, checks, and drafting. +- **Output-oriented.** Produce buyer memos, seller cleanup reports, financing + packs, claim worklists, and post-close admin trackers. + +## Anti-positioning + +Avoid these traps: + +- Do not become a royalty accounting system. +- Do not become a licensed data provider. +- Do not replace legal diligence. +- Do not hardcode one provider's schema as the only path. +- Do not treat streaming popularity as royalty evidence. +- Do not generate polished memos without workpapers. diff --git a/plugins/music-catalog-diligence/requirements.txt b/plugins/music-catalog-diligence/requirements.txt new file mode 100644 index 0000000..eb005d3 --- /dev/null +++ b/plugins/music-catalog-diligence/requirements.txt @@ -0,0 +1,17 @@ +# Optional dependencies for the music-catalog-diligence plugin. +# +# The core scripts (workspace validation, normalization, hygiene scans, etc.) +# run on the Python standard library alone. The extractors below require +# external libraries to read PDF and XLSX files. Install with: +# +# pip3 install -r requirements.txt +# +# scripts/extract-pdf-statement.py — pdfplumber for PDF royalty statements. +# scripts/extract-catalog-spreadsheet.py — openpyxl for XLSX catalogs. + +pdfplumber>=0.11 +openpyxl>=3.1 +# pdfplumber pulls in Pillow transitively. Pin a patched release so +# `pip install -r requirements.txt` cannot resolve to a Pillow version +# with known CVEs (OOB writes, buffer overflows, DoS). +pillow>=12.2.0 diff --git a/plugins/music-catalog-diligence/scripts/_helpers.py b/plugins/music-catalog-diligence/scripts/_helpers.py new file mode 100644 index 0000000..6d98a44 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/_helpers.py @@ -0,0 +1,206 @@ +"""Shared helpers for catalog-diligence scripts. + +Importable from any sibling script (Python adds the script's directory to +`sys.path` automatically). Hyphenated script filenames cannot be imported, +so all sharable logic lives here under an underscore-prefixed name. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import Any + + +def load_yaml(path: Path) -> dict[str, Any]: + """Parse a YAML file. Uses PyYAML if available, else a small built-in + parser that handles the limited grammar used by assumptions.yaml: + + - `key: value` scalars (strings, ints, floats, true/false, null/~) + - block-style nested dicts via indentation + - block-style lists (`- item`) + - flow-style empty sequence/mapping (`[]`, `{}`) + - quoted strings ("..." or '...') + + Does NOT support: anchors, aliases, multi-line strings, mixed flow + style, complex keys, or merging. + """ + text = path.read_text(encoding="utf-8") + try: + import yaml # type: ignore[import-not-found] + except ImportError: + return _parse_minimal_yaml(text) + return yaml.safe_load(text) or {} + + +def _parse_scalar(raw: str) -> Any: + s = raw.strip() + if not s or s.lower() in {"null", "~"}: + return None + if s == "[]": + return [] + if s == "{}": + return {} + if (s.startswith('"') and s.endswith('"')) or (s.startswith("'") and s.endswith("'")): + return s[1:-1] + if s.lower() == "true": + return True + if s.lower() == "false": + return False + if s.startswith("[") and s.endswith("]"): + inner = s[1:-1].strip() + if not inner: + return [] + return [_parse_scalar(item) for item in _split_flow(inner)] + try: + if "." in s or "e" in s.lower(): + return float(s) + return int(s) + except ValueError: + pass + return s + + +def _split_flow(inner: str) -> list[str]: + items: list[str] = [] + depth = 0 + buf: list[str] = [] + in_quote: str | None = None + for ch in inner: + if in_quote: + buf.append(ch) + if ch == in_quote: + in_quote = None + continue + if ch in ('"', "'"): + in_quote = ch + buf.append(ch) + continue + if ch in "[{": + depth += 1 + buf.append(ch) + continue + if ch in "]}": + depth -= 1 + buf.append(ch) + continue + if ch == "," and depth == 0: + items.append("".join(buf).strip()) + buf = [] + continue + buf.append(ch) + if buf: + items.append("".join(buf).strip()) + return [item for item in items if item] + + +def _parse_minimal_yaml(text: str) -> dict[str, Any]: + lines: list[tuple[int, str]] = [] + for raw in text.splitlines(): + if not raw.strip() or raw.lstrip().startswith("#"): + continue + # Strip trailing inline comments (rough — does not respect quotes). + stripped = raw + if " #" in stripped and not stripped.lstrip().startswith("-"): + stripped = stripped.split(" #", 1)[0] + indent = len(stripped) - len(stripped.lstrip(" ")) + lines.append((indent, stripped.rstrip())) + + root: dict[str, Any] = {} + # Stack of (indent, container, last_key). last_key is set when we just + # opened a key with no inline value, awaiting indented children. + stack: list[tuple[int, Any, str | None]] = [(-1, root, None)] + i = 0 + while i < len(lines): + indent, line = lines[i] + body = line.lstrip(" ") + + # Pop frames that are no longer in scope. + while stack and stack[-1][0] >= indent: + stack.pop() + parent_indent, container, pending_key = stack[-1] if stack else (-1, root, None) + + if body.startswith("- "): + value_part = body[2:].strip() + # If the parent is awaiting children for a pending key, it must + # become a list now. + if pending_key is not None: + new_list: list[Any] = [] + container[pending_key] = new_list + stack[-1] = (parent_indent, container, None) + stack.append((indent, new_list, None)) + container = new_list + # value_part may be a scalar or a `key: value` (list of dicts). + if ":" in value_part and not _looks_like_quoted_url(value_part): + key, _, rest = value_part.partition(":") + key = key.strip() + rest = rest.strip() + new_dict: dict[str, Any] = {} + if rest: + new_dict[key] = _parse_scalar(rest) + else: + # The dict will gain children at deeper indent. + new_dict[key] = None + container.append(new_dict) + stack.append((indent, new_dict, key if not rest else None)) + else: + container.append(_parse_scalar(value_part)) + i += 1 + continue + + if ":" in body: + key, _, rest = body.partition(":") + key = key.strip() + rest = rest.strip() + if not isinstance(container, dict): + # Shouldn't happen for well-formed input. + i += 1 + continue + if rest: + container[key] = _parse_scalar(rest) + stack[-1] = (parent_indent, container, None) + else: + # Look ahead to determine list vs dict child. + child = _peek_child_kind(lines, i, indent) + if child == "list": + new_list = [] + container[key] = new_list + stack.append((indent, new_list, None)) + elif child == "dict": + new_dict = {} + container[key] = new_dict + stack.append((indent, new_dict, None)) + else: + container[key] = None + stack[-1] = (parent_indent, container, None) + i += 1 + continue + + i += 1 + + return root + + +def _peek_child_kind(lines: list[tuple[int, str]], current: int, current_indent: int) -> str | None: + for j in range(current + 1, len(lines)): + next_indent, next_line = lines[j] + if next_indent <= current_indent: + return None + return "list" if next_line.lstrip(" ").startswith("- ") else "dict" + return None + + +def _looks_like_quoted_url(s: str) -> bool: + # Heuristic: avoid splitting URLs in unquoted scalars (e.g. https://...). + return "://" in s + + +def deep_get(data: Any, dotted_key: str, default: Any = None) -> Any: + """Walk a dotted path through nested dicts. Returns `default` on miss.""" + cur = data + for part in dotted_key.split("."): + if not isinstance(cur, dict): + return default + if part not in cur: + return default + cur = cur[part] + return cur diff --git a/plugins/music-catalog-diligence/scripts/build-diligence-dashboard.py b/plugins/music-catalog-diligence/scripts/build-diligence-dashboard.py new file mode 100644 index 0000000..39a0d38 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/build-diligence-dashboard.py @@ -0,0 +1,174 @@ +#!/usr/bin/env python3 +"""Build a markdown diligence dashboard for a catalog deal workspace. + +The dashboard reads ``findings/findings.json`` and reports an overall status: + +- ``blocked`` -- one or more open *blocker* findings (severity ``critical`` + or legacy ``P0``). +- ``review_needed`` -- one or more open *high*-tier findings (severity ``high`` + or legacy ``P1``). +- ``ready`` -- no open blockers or high-severity findings. + +Severity comparison is case-insensitive. Both the canonical lowercase taxonomy +(``critical | high | medium | low``) and the legacy ``P0 | P1`` codes are +accepted so the dashboard stays compatible with older fixtures while matching +the rest of the plugin (see ``references/red-flags.md``). +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +# Severities that prevent sharing until cured. Compared case-insensitively. +BLOCKER_SEVERITIES = {"critical", "p0"} + +# Severities that need explicit disclosure but do not fully block sharing. +REVIEW_SEVERITIES = {"high", "p1"} + + +def load_json(path: Path, default: object) -> object: + if not path.is_file(): + return default + return json.loads(path.read_text(encoding="utf-8")) + + +def finding_severity(finding: dict[str, object]) -> str: + """Return the finding's severity as a lowercase string for tier matching.""" + return str(finding.get("severity", "")).strip().lower() + + +def finding_status(finding: dict[str, object]) -> str: + return str(finding.get("status", "")).strip().lower() + + +def finding_description(finding: dict[str, object]) -> str: + """Prefer the canonical ``issue`` field; fall back to ``title`` for older fixtures.""" + for key in ("issue", "title"): + value = finding.get(key) + if isinstance(value, str) and value.strip(): + return value.strip() + return "Untitled finding" + + +def is_open(finding: dict[str, object]) -> bool: + """Treat findings without a status as open. Closed/resolved/deferred are not blockers.""" + status = finding_status(finding) + return status in {"", "open", "in_progress", "in-progress"} + + +def summarize_findings( + workspace: Path, +) -> tuple[list[dict[str, object]], list[dict[str, object]], list[dict[str, object]]]: + """Return (all_findings, open_blockers, open_review_items).""" + data = load_json(workspace / "findings" / "findings.json", {"findings": []}) + findings = data.get("findings", []) if isinstance(data, dict) else [] + typed_findings = [finding for finding in findings if isinstance(finding, dict)] + open_blockers = [ + finding + for finding in typed_findings + if is_open(finding) and finding_severity(finding) in BLOCKER_SEVERITIES + ] + open_review = [ + finding + for finding in typed_findings + if is_open(finding) and finding_severity(finding) in REVIEW_SEVERITIES + ] + return typed_findings, open_blockers, open_review + + +def determine_status( + blockers: list[dict[str, object]], + review_items: list[dict[str, object]], +) -> str: + if blockers: + return "blocked" + if review_items: + return "review_needed" + return "ready" + + +def render_finding_line(finding: dict[str, object]) -> str: + finding_id = finding.get("finding_id", "unknown") + severity = finding.get("severity", "unknown") + description = finding_description(finding) + return f"- `{severity}` `{finding_id}`: {description}" + + +def render_dashboard(workspace: Path) -> str: + findings, blockers, review_items = summarize_findings(workspace) + status = determine_status(blockers, review_items) + lines = [ + "# Diligence Dashboard", + "", + f"- Workspace: `{workspace}`", + f"- Overall status: `{status}`", + f"- Findings: `{len(findings)}`", + f"- Open blockers (critical/P0): `{len(blockers)}`", + f"- Open review items (high/P1): `{len(review_items)}`", + "", + "## Open blockers", + "", + ] + if blockers: + lines.extend(render_finding_line(finding) for finding in blockers) + else: + lines.append("- None.") + + lines.extend(["", "## Open review items", ""]) + if review_items: + lines.extend(render_finding_line(finding) for finding in review_items) + else: + lines.append("- None.") + + lines.extend(["", "## Next actions", ""]) + if status == "blocked": + lines.append("- Resolve critical blockers before sharing the package.") + elif status == "review_needed": + lines.append( + "- Review open high-severity findings and disclose or resolve them before sharing." + ) + else: + lines.append("- Run final memo citation review before sharing externally.") + lines.append("") + return "\n".join(lines) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("deal_workspace", help="Path to deals/{deal-id}") + parser.add_argument("--output", help="Output markdown path") + parser.add_argument( + "--fail-on-blocked", + action="store_true", + help="Exit non-zero when the deal status is 'blocked' (useful for CI guardrails).", + ) + args = parser.parse_args() + + workspace = Path(args.deal_workspace) + output = Path(args.output) if args.output else workspace / "diligence-dashboard.md" + output.write_text(render_dashboard(workspace), encoding="utf-8") + + _, blockers, review_items = summarize_findings(workspace) + status = determine_status(blockers, review_items) + print( + json.dumps( + { + "status": "ok", + "output": str(output), + "deal_status": status, + "open_blockers": len(blockers), + "open_review_items": len(review_items), + }, + indent=2, + ) + ) + if args.fail_on_blocked and status == "blocked": + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/build-file-manifest.py b/plugins/music-catalog-diligence/scripts/build-file-manifest.py new file mode 100644 index 0000000..788a70e --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/build-file-manifest.py @@ -0,0 +1,232 @@ +#!/usr/bin/env python3 +"""Build a JSON manifest for source files in a catalog deal workspace. + +Classifies each file with parse_status, likely_provider, likely_period, +likely_currency, and rights_type_hint so downstream tools (manual-review +queue, normalizer routing, audit checklists) can act on a structured view +of the data room. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import re +from pathlib import Path + + +PARSEABLE_SUFFIXES = {".csv", ".tsv", ".xlsx", ".xls", ".json"} +MANUAL_REVIEW_SUFFIXES = {".pdf", ".doc", ".docx", ".rtf"} +TEXTUAL_SUFFIXES = {".txt", ".md", ".html", ".xml", ".log", ".yaml", ".yml"} +IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".gif", ".tif", ".tiff"} + + +# Provider markers — first match wins. Patterns are tested case-insensitively +# against the full path under source/ to catch folder-based grouping. +PROVIDER_PATTERNS = [ + (re.compile(r"(?i)/ascap/"), "ASCAP"), + (re.compile(r"(?i)/bmi/"), "BMI"), + (re.compile(r"(?i)/sesac/"), "SESAC"), + (re.compile(r"(?i)/mlc/|the[\s_-]?mlc"), "The MLC"), + (re.compile(r"(?i)/soundexchange/|sx[\s_-]?statement"), "SoundExchange"), + (re.compile(r"(?i)/hfa[\s_-]?legacy/|hfa_"), "HFA"), + (re.compile(r"(?i)jasrac"), "JASRAC"), + (re.compile(r"(?i)sacem"), "SACEM"), + (re.compile(r"(?i)sadaic"), "SADAIC"), + (re.compile(r"(?i)gema(?![a-z])"), "GEMA"), + (re.compile(r"(?i)/prs/|prs[\s_-]?(export|statement)"), "PRS"), + (re.compile(r"(?i)apra"), "APRA"), + (re.compile(r"(?i)spotify"), "Spotify"), + (re.compile(r"(?i)apple|itunes"), "Apple/iTunes"), + (re.compile(r"(?i)youtube|content[\s_-]?id|yt[\s_-]"), "YouTube"), + (re.compile(r"(?i)tunecore|distrokid|cd[\s_-]?baby|orchard|empire|stem|amuse"), "Distributor"), + (re.compile(r"(?i)/sub_publishers/|sub[\s_-]?publisher"), "Sub-Publisher"), +] + + +CURRENCY_BY_PROVIDER = { + "JASRAC": "JPY", + "SADAIC": "ARS", + "SACEM": "EUR", + "GEMA": "EUR", + "PRS": "GBP", + "APRA": "AUD", +} + + +RIGHTS_TYPE_HINT_BY_PROVIDER = { + "ASCAP": ("publishing", "performance"), + "BMI": ("publishing", "performance"), + "SESAC": ("publishing", "performance"), + "The MLC": ("publishing", "mechanical"), + "HFA": ("publishing", "mechanical"), + "SoundExchange": ("neighboring", "radio"), + "PRS": ("publishing", "performance"), + "JASRAC": ("publishing", "performance"), + "SACEM": ("publishing", "performance"), + "SADAIC": ("publishing", "performance"), + "GEMA": ("publishing", "performance"), + "APRA": ("publishing", "performance"), + "Spotify": ("master", "streaming"), + "Apple/iTunes": ("master", "streaming"), + "YouTube": ("master", "ugc"), + "Distributor": ("master", "streaming"), + "Sub-Publisher": ("publishing", "performance"), +} + + +PERIOD_PATTERNS = [ + (re.compile(r"(\d{4})Q([1-4])"), lambda m: f"{m.group(1)}Q{m.group(2)}"), + (re.compile(r"(\d{4})[-_](\d{2})[-_](\d{2})"), lambda m: f"{m.group(1)}-{m.group(2)}-{m.group(3)}"), + (re.compile(r"(\d{4})[-_](\d{2})"), lambda m: f"{m.group(1)}-{m.group(2)}"), + (re.compile(r"(\d{4})_quarterly"), lambda m: f"{m.group(1)}"), + (re.compile(r"(\d{4})_annual"), lambda m: f"{m.group(1)}"), + (re.compile(r"(? str: + digest = hashlib.sha256() + with path.open("rb") as handle: + for chunk in iter(lambda: handle.read(1024 * 1024), b""): + digest.update(chunk) + return digest.hexdigest() + + +def classify_provider(rel_path: str) -> str | None: + for pattern, provider in PROVIDER_PATTERNS: + if pattern.search(rel_path): + return provider + return None + + +def classify_period(rel_path: str) -> str | None: + name = rel_path.rsplit("/", 1)[-1] + folder = rel_path.rsplit("/", 1)[0] if "/" in rel_path else "" + for haystack in (name, folder): + for pattern, formatter in PERIOD_PATTERNS: + match = pattern.search(haystack) + if match: + return formatter(match) + return None + + +def classify_rights_hint(rel_path: str, provider: str | None) -> tuple[str | None, str | None]: + if provider and provider in RIGHTS_TYPE_HINT_BY_PROVIDER: + return RIGHTS_TYPE_HINT_BY_PROVIDER[provider] + for pattern, (rights, doc_type) in FOLDER_HINTS: + if pattern.search(rel_path): + return rights, doc_type + return None, None + + +def classify_parse_status(suffix: str, size_bytes: int) -> str: + if size_bytes == 0: + return "unparsed" + if suffix in PARSEABLE_SUFFIXES: + return "parsed" + if suffix in MANUAL_REVIEW_SUFFIXES: + return "manual_review" + if suffix in TEXTUAL_SUFFIXES: + return "manual_review" + if suffix in IMAGE_SUFFIXES: + return "manual_review" + return "unparsed" + + +def classify_currency(provider: str | None) -> str | None: + return CURRENCY_BY_PROVIDER.get(provider) if provider else None + + +def build_manifest(source_dir: Path) -> list[dict[str, object]]: + entries: list[dict[str, object]] = [] + for path in sorted(source_dir.rglob("*")): + if not path.is_file(): + continue + rel = path.relative_to(source_dir.parent) + rel_str = str(rel) + suffix = path.suffix.lower() + size = path.stat().st_size + provider = classify_provider("/" + rel_str) + period = classify_period(rel_str) + rights_type, doc_type = classify_rights_hint("/" + rel_str, provider) + parse_status = classify_parse_status(suffix, size) + currency = classify_currency(provider) + entries.append( + { + "path": rel_str, + "filename": path.name, + "suffix": suffix, + "size_bytes": size, + "sha256": sha256_file(path), + "parse_status": parse_status, + "likely_provider": provider, + "likely_period": period, + "likely_currency": currency, + "rights_type_hint": rights_type, + "document_type_hint": doc_type, + } + ) + return entries + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("deal_workspace", help="Path to deals/{deal-id}") + parser.add_argument( + "--output", + default=None, + help="Output JSON path. Defaults to workpapers/file-manifest.json.", + ) + args = parser.parse_args() + + workspace = Path(args.deal_workspace) + source_dir = workspace / "source" + if not source_dir.is_dir(): + raise SystemExit(f"Missing source directory: {source_dir}") + + output = Path(args.output) if args.output else workspace / "workpapers" / "file-manifest.json" + output.parent.mkdir(parents=True, exist_ok=True) + files = build_manifest(source_dir) + payload = {"deal_workspace": str(workspace), "files": files} + output.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + + status_counts: dict[str, int] = {} + for entry in files: + key = str(entry["parse_status"]) + status_counts[key] = status_counts.get(key, 0) + 1 + + print( + json.dumps( + { + "status": "ok", + "output": str(output), + "files": len(files), + "by_parse_status": status_counts, + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/build-manual-review-queue.py b/plugins/music-catalog-diligence/scripts/build-manual-review-queue.py new file mode 100644 index 0000000..58eda81 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/build-manual-review-queue.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 +"""Build a manual-review queue from the file manifest and royalty ledger. + +Produces: +- findings/manual-review-queue.md: human-readable checklist of every file + that did not contribute financial data, with provider, period, currency, + rights-type hint, and a one-line action. +- workpapers/ingest-coverage.json: machine-readable coverage summary used + to drive top-line ingest reporting (e.g. "X of Y files contributed + financial data; Z files require manual review"). + +Coverage is computed by intersecting the file manifest with the source_file +column of normalized/royalty-ledger.csv. Files with parse_status='parsed' +that are absent from the ledger are flagged (parsed_but_unused) — they +were expected to contribute but did not, often because the normalizer +returned partial/empty status. +""" + +from __future__ import annotations + +import argparse +import csv +import json +from collections import Counter +from pathlib import Path + + +def load_manifest(workspace: Path) -> list[dict]: + manifest_path = workspace / "workpapers" / "file-manifest.json" + if not manifest_path.is_file(): + raise SystemExit( + f"Missing file manifest: {manifest_path}. Run scripts/build-file-manifest.py first." + ) + data = json.loads(manifest_path.read_text(encoding="utf-8")) + return list(data.get("files", [])) + + +def load_ledger_source_files(workspace: Path) -> set[str]: + ledger_path = workspace / "normalized" / "royalty-ledger.csv" + if not ledger_path.is_file(): + return set() + sources: set[str] = set() + with ledger_path.open(newline="", encoding="utf-8-sig") as handle: + for row in csv.DictReader(handle): + value = (row.get("source_file") or "").strip() + if not value: + continue + sources.add(value) + # Also add the path-relative form, since source_file in the ledger + # is sometimes absolute and sometimes relative. + for prefix in (str(workspace) + "/", str(workspace.resolve()) + "/"): + if value.startswith(prefix): + sources.add(value[len(prefix):]) + return sources + + +def file_contributed(manifest_entry: dict, ledger_sources: set[str]) -> bool: + rel = str(manifest_entry.get("path") or "") + if not rel: + return False + if rel in ledger_sources: + return True + # Try common alternate forms — bare filename, source/-prefixed. + name = str(manifest_entry.get("filename") or "") + if name and any(src.endswith("/" + name) or src.endswith(name) for src in ledger_sources): + return True + return False + + +def queue_action(entry: dict) -> str: + status = entry.get("parse_status") + suffix = entry.get("suffix") or "" + provider = entry.get("likely_provider") + if status == "unparsed" and entry.get("size_bytes") == 0: + return "Request reissue: file is zero bytes." + if suffix == ".pdf": + if provider: + return f"Extract financial detail from PDF (provider: {provider}); add a column-map or transcribe to CSV." + return "Read the PDF; transcribe material facts to a structured workpaper or evidence ledger entry." + if status == "manual_review": + return "Review file by hand; add evidence-ledger entry for any material facts." + if status == "parsed": + return "Re-run the normalizer with --column-map for this provider." + return "Determine handling: provider unknown." + + +def build_queue_markdown(workspace: Path, queue_entries: list[dict]) -> str: + lines = [ + "# Manual review queue", + "", + f"Workspace: `{workspace}` ", + f"Files requiring manual review: **{len(queue_entries)}**", + "", + "| File | Provider | Period | Rights | Currency | Status | Action |", + "| --- | --- | --- | --- | --- | --- | --- |", + ] + for entry in queue_entries: + lines.append( + "| `{path}` | {provider} | {period} | {rights} | {currency} | {status} | {action} |".format( + path=entry["path"], + provider=entry.get("likely_provider") or "—", + period=entry.get("likely_period") or "—", + rights=entry.get("rights_type_hint") or "—", + currency=entry.get("likely_currency") or "—", + status=entry.get("parse_status") or "—", + action=queue_action(entry), + ) + ) + lines.append("") + return "\n".join(lines) + + +def build_coverage( + workspace: Path, + manifest: list[dict], + ledger_sources: set[str], +) -> dict: + by_status: Counter = Counter() + by_provider: Counter = Counter() + contributed: list[str] = [] + parsed_but_unused: list[str] = [] + + for entry in manifest: + status = str(entry.get("parse_status") or "unknown") + by_status[status] += 1 + provider = entry.get("likely_provider") + if provider: + by_provider[provider] += 1 + if file_contributed(entry, ledger_sources): + contributed.append(str(entry["path"])) + elif status == "parsed": + parsed_but_unused.append(str(entry["path"])) + + total = len(manifest) + manual_review_count = by_status.get("manual_review", 0) + unparsed_count = by_status.get("unparsed", 0) + return { + "workspace": str(workspace), + "total_files": total, + "by_parse_status": dict(by_status), + "by_provider": dict(by_provider), + "contributed_to_ledger": sorted(contributed), + "contributed_count": len(contributed), + "parsed_but_unused": sorted(parsed_but_unused), + "parsed_but_unused_count": len(parsed_but_unused), + "manual_review_count": manual_review_count, + "unparsed_count": unparsed_count, + "summary_line": ( + f"{len(contributed)} of {total} file(s) contributed financial data; " + f"{manual_review_count} require manual review; " + f"{unparsed_count} unparsed." + ), + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("workspace", help="Path to deals/{deal-id}") + parser.add_argument( + "--queue-output", + default=None, + help="Path to manual-review queue markdown (default findings/manual-review-queue.md).", + ) + parser.add_argument( + "--coverage-output", + default=None, + help="Path to ingest-coverage JSON (default workpapers/ingest-coverage.json).", + ) + args = parser.parse_args() + + workspace = Path(args.workspace) + queue_path = ( + Path(args.queue_output) + if args.queue_output + else workspace / "findings" / "manual-review-queue.md" + ) + coverage_path = ( + Path(args.coverage_output) + if args.coverage_output + else workspace / "workpapers" / "ingest-coverage.json" + ) + + manifest = load_manifest(workspace) + ledger_sources = load_ledger_source_files(workspace) + coverage = build_coverage(workspace, manifest, ledger_sources) + + review_entries = [ + entry + for entry in manifest + if entry.get("parse_status") in {"manual_review", "unparsed"} + or (entry.get("parse_status") == "parsed" and not file_contributed(entry, ledger_sources)) + ] + queue_entries = sorted(review_entries, key=lambda e: e.get("path") or "") + + queue_path.parent.mkdir(parents=True, exist_ok=True) + coverage_path.parent.mkdir(parents=True, exist_ok=True) + queue_path.write_text(build_queue_markdown(workspace, queue_entries), encoding="utf-8") + coverage_path.write_text(json.dumps(coverage, indent=2) + "\n", encoding="utf-8") + + print( + json.dumps( + { + "status": "ok", + "workspace": str(workspace), + "queue_output": str(queue_path), + "coverage_output": str(coverage_path), + "summary_line": coverage["summary_line"], + "manual_review_count": coverage["manual_review_count"], + "parsed_but_unused_count": coverage["parsed_but_unused_count"], + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/calculate-concentration.py b/plugins/music-catalog-diligence/scripts/calculate-concentration.py new file mode 100644 index 0000000..b923cc9 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/calculate-concentration.py @@ -0,0 +1,219 @@ +#!/usr/bin/env python3 +"""Calculate concentration by common catalog royalty ledger dimensions. + +Outputs concentration percentages (top 1, top 3, top 5, top 10) per +dimension, optionally driving an auto-emitted finding when a tripped +threshold from assumptions.yaml is detected. + +Default behavior: print result to stdout. With `--output`, also write the +concentration analysis to disk. With `--assumptions`, read materiality +thresholds; with `--emit-finding-output`, write a proposed finding entry +to a file the agent can merge into findings.json. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import sys +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _helpers import deep_get, load_yaml # noqa: E402 + + +DEFAULT_DIMENSIONS = ["catalog_asset_id", "provider", "platform_or_licensee", "territory", "income_type"] +TOP_N_BUCKETS = (1, 3, 5, 10) +DEFAULT_FINDING_DIMENSION = "catalog_asset_id" + + +def parse_amount(value: str | None) -> float: + if not value: + return 0.0 + cleaned = value.replace("$", "").replace(",", "").replace("(", "-").replace(")", "") + try: + return float(cleaned) + except ValueError: + return 0.0 + + +def rank_dimension(rows: list[dict], dimension: str, amount_column: str) -> list[tuple[str, float]]: + buckets: defaultdict[str, float] = defaultdict(float) + for row in rows: + key = (row.get(dimension) or "unknown").strip() or "unknown" + buckets[key] += parse_amount(row.get(amount_column)) + return sorted(buckets.items(), key=lambda item: abs(item[1]), reverse=True) + + +def top_n_pct(ranked: list[tuple[str, float]], total: float, n: int) -> float | None: + if total == 0 or not ranked: + return None + head = sum(amount for _, amount in ranked[:n]) + return round(head / total * 100, 2) + + +def build_concentration( + rows: list[dict], + dimensions: list[str], + amount_column: str, +) -> tuple[float, dict]: + total = sum(parse_amount(row.get(amount_column)) for row in rows) + results: dict[str, dict] = {} + for dimension in dimensions: + ranked = rank_dimension(rows, dimension, amount_column) + results[dimension] = { + "top_1_pct": top_n_pct(ranked, total, 1), + "top_3_pct": top_n_pct(ranked, total, 3), + "top_5_pct": top_n_pct(ranked, total, 5), + "top_10_pct": top_n_pct(ranked, total, 10), + "top_10_entries": [ + { + "value": key, + "amount": amount, + "percent_of_total": (amount / total * 100) if total else None, + } + for key, amount in ranked[:10] + ], + } + return total, results + + +def make_finding( + threshold: float, + dimension: str, + pct_breakdown: dict, + workspace_path: Path, +) -> dict | None: + tripped: list[dict] = [] + for bucket in TOP_N_BUCKETS: + key = f"top_{bucket}_pct" + pct = pct_breakdown.get(key) + if pct is None: + continue + if pct >= threshold: + tripped.append({"bucket": f"top_{bucket}", "pct": pct}) + if not tripped: + return None + top_entries = pct_breakdown.get("top_10_entries", [])[:5] + asset_ids = [entry["value"] for entry in top_entries] + issue_lines = [ + f"Catalog revenue concentration exceeds the configured threshold of {threshold:.0f}% on dimension '{dimension}'.", + ] + for entry in tripped: + issue_lines.append(f" - {entry['bucket']}: {entry['pct']:.1f}% of net revenue.") + issue_lines.append( + "Top 5 contributors: " + + ", ".join(f"{e['value']} ({e['percent_of_total']:.1f}%)" for e in top_entries[:5]) + ) + return { + "finding_id": "CONC-AUTO-001", + "severity": "high", + "category": "valuation", + "affected_assets": asset_ids, + "evidence_ids": [], + "issue": "\n".join(issue_lines), + "valuation_treatment": ( + "Underwrite catalog as concentrated; do not apply diversified-catalog multiples without " + "stress-testing the top contributors' decay and durability." + ), + "follow_up": ( + f"Cross-reference top contributors against rights-map.csv for chain-of-title and reversion exposure. " + f"Run downside scenarios assuming the top {tripped[0]['bucket']} contributor decays 50%." + ), + "status": "open", + "_source": "calculate-concentration", + "_threshold_pct": threshold, + "_dimension": dimension, + "_workspace": str(workspace_path), + } + + +def load_rows(ledger_csv: Path) -> list[dict]: + with ledger_csv.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ledger_csv", help="Path to royalty-ledger.csv") + parser.add_argument("--amount-column", default="owner_net_amount") + parser.add_argument("--dimensions", nargs="*", default=DEFAULT_DIMENSIONS) + parser.add_argument("--output", default=None, help="Write concentration JSON to this path.") + parser.add_argument( + "--assumptions", + default=None, + help="Path to assumptions.yaml. Reads materiality.concentration_threshold_percent.", + ) + parser.add_argument( + "--threshold-pct", + type=float, + default=None, + help="Override the concentration threshold (overrides --assumptions).", + ) + parser.add_argument( + "--finding-dimension", + default=DEFAULT_FINDING_DIMENSION, + help="Dimension to evaluate against the threshold (default catalog_asset_id).", + ) + parser.add_argument( + "--emit-finding-output", + default=None, + help="When the threshold is tripped, write a proposed finding to this path.", + ) + args = parser.parse_args() + + ledger_path = Path(args.ledger_csv) + rows = load_rows(ledger_path) + total, concentration = build_concentration(rows, args.dimensions, args.amount_column) + + threshold = args.threshold_pct + threshold_source = "cli" + if threshold is None and args.assumptions: + assumptions = load_yaml(Path(args.assumptions)) + configured = deep_get(assumptions, "materiality.concentration_threshold_percent") + if isinstance(configured, (int, float)): + threshold = float(configured) + threshold_source = "assumptions.yaml" + + finding = None + if threshold is not None: + breakdown = concentration.get(args.finding_dimension, {}) + workspace = ledger_path.parents[1] if len(ledger_path.parents) >= 2 else ledger_path.parent + finding = make_finding(threshold, args.finding_dimension, breakdown, workspace) + + payload = { + "status": "ok", + "ledger": str(ledger_path), + "amount_column": args.amount_column, + "total": total, + "threshold_pct": threshold, + "threshold_source": threshold_source if threshold is not None else None, + "finding_dimension": args.finding_dimension, + "concentration": concentration, + "tripped_threshold": finding is not None, + } + + if args.output: + out_path = Path(args.output) + out_path.parent.mkdir(parents=True, exist_ok=True) + out_path.write_text(json.dumps(payload, indent=2) + "\n", encoding="utf-8") + payload["output_written"] = str(out_path) + + if args.emit_finding_output: + emit_path = Path(args.emit_finding_output) + emit_path.parent.mkdir(parents=True, exist_ok=True) + emit_path.write_text( + json.dumps({"findings": [finding] if finding else []}, indent=2) + "\n", + encoding="utf-8", + ) + payload["finding_output_written"] = str(emit_path) + + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/calculate-nps-nls-bridge.py b/plugins/music-catalog-diligence/scripts/calculate-nps-nls-bridge.py new file mode 100644 index 0000000..892054b --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/calculate-nps-nls-bridge.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Calculate a reported-to-normalized NPS/NLS bridge from JSON inputs.""" + +from __future__ import annotations + +import argparse +import json +from decimal import Decimal, InvalidOperation +from pathlib import Path + + +def _to_decimal(value: object, label: str) -> Decimal: + """Coerce a JSON-loaded number/string to Decimal. + + JSON cannot represent Decimal natively, so the input may arrive as int, + float, or numeric string. We round-trip through `str` to preserve the + exact textual representation the caller intended (a literal `"1.10"` stays + `1.10`, not `1.1000000000000000888...`). + """ + try: + return Decimal(str(value)) + except (InvalidOperation, TypeError, ValueError) as exc: + raise SystemExit(f"{label} must be numeric (got {value!r}): {exc}") + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("bridge_json", help="JSON with reported_amount and adjustments") + args = parser.parse_args() + + data = json.loads(Path(args.bridge_json).read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit("bridge JSON top level must be an object") + + reported = _to_decimal(data.get("reported_amount", 0), "`reported_amount`") + adjustments = data.get("adjustments", []) + if not isinstance(adjustments, list): + raise SystemExit("`adjustments` must be a list") + + normalized = reported + rows: list[dict[str, str]] = [ + {"label": "Reported amount", "amount": str(reported), "running_total": str(reported)} + ] + for index, adjustment in enumerate(adjustments): + if not isinstance(adjustment, dict): + raise SystemExit( + f"adjustments[{index}] must be an object with `label` and `amount` " + f"(got {type(adjustment).__name__})" + ) + label = adjustment.get("label", "Unnamed adjustment") + amount = _to_decimal(adjustment.get("amount", 0), f"adjustments[{index}].amount") + normalized += amount + rows.append( + {"label": label, "amount": str(amount), "running_total": str(normalized)} + ) + + payload = { + "status": "ok", + "metric": data.get("metric", "NPS/NLS"), + "reported_amount": str(reported), + "normalized_amount": str(normalized), + "bridge": rows, + } + print(json.dumps(payload, indent=2)) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/dataroom-hygiene-scan.py b/plugins/music-catalog-diligence/scripts/dataroom-hygiene-scan.py new file mode 100644 index 0000000..86b44f7 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/dataroom-hygiene-scan.py @@ -0,0 +1,228 @@ +#!/usr/bin/env python3 +"""Scan a data room for files that suggest seller-side concealment leaks. + +Surfaces: +- Filenames suggesting working notes or admin checklists. +- File contents containing concealment-language patterns + ("do not show buyer", "remove before sharing", etc.). + +Writes: +- workpapers/dataroom-hygiene.json — machine-readable matches. +- findings/dataroom-hygiene-findings.json — proposed findings entries to merge + into findings/findings.json (one per matched file). + +Catching this pattern (e.g. DELETE_BEFORE_SHARING.txt) reframes how to read +the rest of the data room and is materially load-bearing for diligence trust +posture. +""" + +from __future__ import annotations + +import argparse +import json +import re +from pathlib import Path + + +# Strong filename signals: explicit working-doc markers that should not appear +# in a final data room. Match → emit a finding. +FILENAME_STRONG = re.compile( + r"(?ix)" + r"(delete[\s_-]?before|" + r"do[\s_-]?not[\s_-]?(share|show|disclose|email|send|forward|tell|use|publish|distribute|volunteer)|" + r"remove[\s_-]?before|" + r"admin[\s_-]?notes?|" + r"internal[\s_-]?(only|todo|notes?))" +) + +# Weak filename signals: process markers like "DRAFT" — surfaced in the +# workpaper for context but do not by themselves emit a finding. We use +# explicit separators ([._-] or string boundary) because Python's \b treats +# underscore as a word character, so \bdraft\b would not match "_DRAFT.pdf". +FILENAME_WEAK = re.compile( + r"(?i)" + r"(?:[._-]|^)(draft|wip|backup|junk|scratch|todo|tmp|temp|confidential)(?:[._-]|$)" +) + +# Content patterns are intrinsically strong — the language only appears in +# admin/concealment contexts. Match → emit a finding. +CONTENT_PATTERN = re.compile( + r"(?ix)" + r"(do[\s_-]?not[\s_-]+(show|share|volunteer|disclose|tell|send|email|forward)|" + r"don['']?t[\s_-]+(show|share|volunteer|disclose|tell|send|forward)|" + r"remove[\s_-]+before[\s_-]+(sharing|sending|disclosure)|" + r"delete[\s_-]+before[\s_-]+(sharing|sending|disclosure)|" + r"hide[\s_-]+(this|from)|" + r"gloss[\s_-]+over|" + r"keep[\s_-]+out[\s_-]+of[\s_-]+(the[\s_-]+)?(data[\s_-]?room|deck|memo))" +) + +TEXTUAL_SUFFIXES = {".txt", ".md", ".csv", ".tsv", ".html", ".xml", ".log", ".json", ".yaml", ".yml", ".sql"} +MAX_FILE_BYTES = 5 * 1024 * 1024 # 5 MB cap to bound work on large logs + + +def scan_filename(path: Path) -> tuple[bool, bool]: + """Returns (strong_match, weak_match).""" + return bool(FILENAME_STRONG.search(path.name)), bool(FILENAME_WEAK.search(path.name)) + + +def scan_content(path: Path) -> list[dict[str, object]]: + if path.suffix.lower() not in TEXTUAL_SUFFIXES: + return [] + try: + size = path.stat().st_size + except OSError: + return [] + if size > MAX_FILE_BYTES: + return [] + try: + text = path.read_text(encoding="utf-8", errors="replace") + except OSError: + return [] + matches: list[dict[str, object]] = [] + for line_no, line in enumerate(text.splitlines(), start=1): + if CONTENT_PATTERN.search(line): + preview = line.strip() + if len(preview) > 240: + preview = preview[:237] + "..." + matches.append({"line": line_no, "preview": preview}) + return matches + + +def scan_workspace(source_dir: Path, workspace: Path) -> list[dict[str, object]]: + matches: list[dict[str, object]] = [] + for path in sorted(source_dir.rglob("*")): + if not path.is_file(): + continue + try: + relative = str(path.relative_to(workspace)) + except ValueError: + relative = str(path) + filename_strong, filename_weak = scan_filename(path) + content_matches = scan_content(path) + if not (filename_strong or filename_weak or content_matches): + continue + if filename_strong or content_matches: + strength = "high" + else: + strength = "weak" + matches.append( + { + "path": relative, + "filename_strong": filename_strong, + "filename_weak": filename_weak, + "content_matches": content_matches, + "match_strength": strength, + } + ) + return matches + + +def proposed_findings(matches: list[dict[str, object]]) -> list[dict[str, object]]: + findings: list[dict[str, object]] = [] + next_id = 1 + for match in matches: + if match["match_strength"] == "weak": + continue + path = match["path"] + snippet_count = len(match.get("content_matches") or []) + issue_parts = [f"Data-room hygiene scan flagged {path}."] + if match.get("filename_strong"): + issue_parts.append("Filename matches an explicit concealment / admin-notes marker.") + if snippet_count: + issue_parts.append(f"Content contains {snippet_count} line(s) matching concealment-language patterns.") + issue_parts.append( + "If the file represents seller-side admin notes accidentally included, treat all " + "seller-favorable representations across the data room with elevated skepticism." + ) + findings.append( + { + "finding_id": f"HYG-SCAN-{next_id:03d}", + "severity": "high", + "category": "process_integrity", + "affected_assets": ["data_room_trust_posture"], + "evidence_ids": [], + "issue": " ".join(issue_parts), + "valuation_treatment": "Apply elevated skepticism to seller representations; counsel-only review of flagged file.", + "follow_up": f"Read {path} in full; corroborate or refute material claims; counsel-to-counsel acknowledgment.", + "status": "open", + "_source_match": match, + } + ) + next_id += 1 + return findings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("workspace", help="Path to deals/{deal-id}") + parser.add_argument( + "--source-dir", + default=None, + help="Directory to scan. Defaults to {workspace}/source.", + ) + parser.add_argument( + "--output", + default=None, + help="Output JSON path. Defaults to {workspace}/workpapers/dataroom-hygiene.json.", + ) + parser.add_argument( + "--findings-output", + default=None, + help="Proposed-findings JSON path. Defaults to {workspace}/findings/dataroom-hygiene-findings.json.", + ) + args = parser.parse_args() + + workspace = Path(args.workspace) + source_dir = Path(args.source_dir) if args.source_dir else workspace / "source" + if not source_dir.is_dir(): + raise SystemExit(f"missing source directory: {source_dir}") + + output_path = Path(args.output) if args.output else workspace / "workpapers" / "dataroom-hygiene.json" + findings_path = ( + Path(args.findings_output) + if args.findings_output + else workspace / "findings" / "dataroom-hygiene-findings.json" + ) + output_path.parent.mkdir(parents=True, exist_ok=True) + findings_path.parent.mkdir(parents=True, exist_ok=True) + + matches = scan_workspace(source_dir, workspace) + findings = proposed_findings(matches) + + output_path.write_text( + json.dumps( + { + "status": "ok", + "workspace": str(workspace), + "source_dir": str(source_dir), + "match_count": len(matches), + "matches": matches, + }, + indent=2, + ) + + "\n", + encoding="utf-8", + ) + findings_path.write_text( + json.dumps({"findings": findings}, indent=2) + "\n", + encoding="utf-8", + ) + + print( + json.dumps( + { + "status": "ok", + "workspace": str(workspace), + "match_count": len(matches), + "output": str(output_path), + "findings_output": str(findings_path), + }, + indent=2, + ) + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/extract-pdf-statement.py b/plugins/music-catalog-diligence/scripts/extract-pdf-statement.py new file mode 100644 index 0000000..d69a027 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/extract-pdf-statement.py @@ -0,0 +1,607 @@ +#!/usr/bin/env python3 +"""Extract royalty data from PDF statements into the canonical ledger schema. + +Uses pdfplumber to read tables from PDFs and per-provider templates to map +columns into the canonical royalty-ledger.csv shape. Provider templates are +matched by file path (folder/filename) and verified by header signature. + +Output rows are written to a separate ledger file so the agent can review +before merging into normalized/royalty-ledger.csv. Every row is tagged +with `match_confidence: low` and `notes: extracted from PDF, verify against source` +to mark them for verification. +""" + +from __future__ import annotations + +import argparse +import calendar +import csv +import json +import re +import sys +from dataclasses import dataclass, field +from pathlib import Path + +try: + import pdfplumber # type: ignore[import-not-found] +except ImportError as exc: # pragma: no cover - import-time guard + print( + json.dumps( + { + "status": "missing_dependency", + "error": "pdfplumber is required. Install via: pip3 install -r requirements.txt", + "details": str(exc), + } + ), + file=sys.stderr, + ) + raise SystemExit(1) + + +CANONICAL_COLUMNS = [ + "ledger_line_id", + "catalog_asset_id", + "source_file", + "provider", + "period_start", + "period_end", + "payment_date", + "rights_type", + "income_type", + "territory", + "platform_or_licensee", + "gross_amount", + "deductions", + "participant_share", + "owner_net_amount", + "currency", + "fx_rate", + "pro_use_type", + "pro_credits", + "pro_bonus_type", + "cue_sheet_ref", + "match_confidence", + "notes", +] + + +CURRENCY_PATTERNS = [ + (re.compile(r"^\s*\$\s*"), "USD"), + (re.compile(r"^\s*USD\s*\$?\s*"), "USD"), + (re.compile(r"^\s*EUR\s*€?\s*|^\s*€\s*"), "EUR"), + (re.compile(r"^\s*GBP\s*£?\s*|^\s*£\s*"), "GBP"), + (re.compile(r"^\s*JPY\s*¥?\s*|^\s*¥\s*"), "JPY"), + (re.compile(r"^\s*ARS\s*\$?\s*|^\s*\$ARS\s*"), "ARS"), + (re.compile(r"^\s*AUD\s*\$?\s*"), "AUD"), + (re.compile(r"^\s*CAD\s*\$?\s*"), "CAD"), +] + + +@dataclass(frozen=True) +class PDFTemplate: + name: str + provider: str + rights_type: str + income_type: str + territory: str + default_currency: str + # Header regexes — at least one must match the table's first row. + header_signatures: tuple[re.Pattern, ...] + # Filename/folder regexes — at least one must match. + path_signatures: tuple[re.Pattern, ...] = () + # Column indices (0-based) on the data row. + title_index: int = 0 + iswc_index: int | None = 1 + isrc_index: int | None = None + amount_index: int = 2 # net amount + gross_index: int | None = None + share_index: int | None = None + period_index: int | None = None # if period encoded per-row + period_format: str = "filename_quarter" # filename_quarter | filename_year | row_year | row_period | row_quarter + expected_columns: int | None = None + + +def _re(pat: str) -> re.Pattern: + return re.compile(pat, re.IGNORECASE) + + +TEMPLATES: list[PDFTemplate] = [ + PDFTemplate( + name="ascap", + provider="ASCAP", + rights_type="publishing", + income_type="performance", + territory="US", + default_currency="USD", + header_signatures=(_re(r"writer.*legal.*name"), _re(r"share\s*%"), _re(r"publisher\s*\$")), + path_signatures=(_re(r"/ascap/"),), + title_index=1, + iswc_index=2, + amount_index=5, # Publisher $ + gross_index=6, # Total $ + share_index=3, + period_format="filename_quarter", + expected_columns=7, + ), + PDFTemplate( + name="sesac", + provider="SESAC", + rights_type="publishing", + income_type="performance", + territory="US", + default_currency="USD", + header_signatures=(_re(r"writer\s*share"), _re(r"publisher\s*shar")), + path_signatures=(_re(r"/sesac/"),), + title_index=0, + iswc_index=1, + amount_index=3, # Publisher Share column + gross_index=4, # Total + period_format="filename_year", + ), + PDFTemplate( + name="hfa", + provider="HFA", + rights_type="publishing", + income_type="mechanical", + territory="US", + default_currency="USD", + header_signatures=(_re(r"mechanical\s*royalty"),), + path_signatures=(_re(r"/hfa[\s_-]?legacy/|hfa_"),), + title_index=0, + iswc_index=1, + amount_index=3, + period_index=2, + period_format="row_year", + ), + PDFTemplate( + name="soundexchange", + provider="SoundExchange", + rights_type="neighboring", + income_type="radio", + territory="US", + default_currency="USD", + header_signatures=(_re(r"distribution"), _re(r"isrc"), _re(r"label")), + path_signatures=(_re(r"/soundexchange/"),), + title_index=0, + iswc_index=None, + isrc_index=1, + amount_index=4, + period_format="filename_year", + ), + PDFTemplate( + name="prs-partial", + provider="PRS", + rights_type="publishing", + income_type="performance", + territory="GB", + default_currency="GBP", + header_signatures=(_re(r"royalty\s*\(gbp\)"), _re(r"period")), + path_signatures=(_re(r"prs.*partial"),), + title_index=1, + iswc_index=2, + amount_index=3, + period_index=0, + period_format="row_quarter", + ), + PDFTemplate( + name="prs", + provider="PRS", + rights_type="publishing", + income_type="performance", + territory="GB", + default_currency="GBP", + header_signatures=(_re(r"royalty\s*\(gbp\)"),), + path_signatures=(_re(r"prs"),), + title_index=0, + iswc_index=1, + amount_index=2, + period_format="filename_year", + ), + PDFTemplate( + name="sacem", + provider="SACEM", + rights_type="publishing", + income_type="performance", + territory="FR", + default_currency="EUR", + header_signatures=(_re(r"droits\s*\(eur\)"), _re(r"titre")), + path_signatures=(_re(r"sacem"),), + title_index=0, + iswc_index=1, + amount_index=2, + period_format="filename_quarter", + ), + PDFTemplate( + name="sadaic", + provider="SADAIC", + rights_type="publishing", + income_type="performance", + territory="AR", + default_currency="ARS", + header_signatures=(_re(r"liquidaci(ón|on)\s*\(ars\)"), _re(r"t(í|i)tulo")), + path_signatures=(_re(r"sadaic"),), + title_index=0, + iswc_index=1, + amount_index=2, + period_format="filename_year", + ), + PDFTemplate( + name="jasrac", + provider="JASRAC", + rights_type="publishing", + income_type="performance", + territory="JP", + default_currency="JPY", + header_signatures=(_re(r"distribution\s*\(jpy\)"),), + path_signatures=(_re(r"jasrac"),), + title_index=0, + iswc_index=1, + amount_index=2, + period_format="filename_year", + ), + PDFTemplate( + name="apra", + provider="APRA", + rights_type="publishing", + income_type="performance", + territory="AU", + default_currency="AUD", + header_signatures=(_re(r"^title$"), _re(r"^iswc$"), _re(r"^royalty$")), + path_signatures=(_re(r"apra"),), + title_index=0, + iswc_index=1, + amount_index=2, + period_format="filename_year", + ), + PDFTemplate( + name="socan", + provider="SOCAN", + rights_type="publishing", + income_type="performance", + territory="CA", + default_currency="CAD", + header_signatures=(_re(r"^title$"), _re(r"^iswc$"), _re(r"^royalty$")), + path_signatures=(_re(r"socan"),), + title_index=0, + iswc_index=1, + amount_index=2, + period_format="filename_year", + ), +] + + +def _normalize_header_cell(cell: str | None) -> str: + if not cell: + return "" + return re.sub(r"\s+", " ", cell.strip()) + + +def _normalize_header(headers: list[str | None]) -> str: + return " | ".join(_normalize_header_cell(c) for c in headers if c) + + +def detect_template(rel_path: str, headers: list[str | None]) -> PDFTemplate | None: + header_text = _normalize_header(headers).lower() + rel_lower = rel_path.lower() + candidates: list[tuple[int, PDFTemplate]] = [] + for template in TEMPLATES: + path_hits = sum(2 for pattern in template.path_signatures if pattern.search(rel_lower)) + header_hits = sum(1 for pattern in template.header_signatures if pattern.search(header_text)) + # Qualify when either signal is strong: a clear path match, or any + # header keyword match. Generic header layouts ("Title | ISWC | Royalty" + # for APRA/SOCAN) are disambiguated entirely by path. + if path_hits == 0 and header_hits == 0: + continue + score = path_hits + header_hits + candidates.append((score, template)) + if not candidates: + return None + candidates.sort(key=lambda item: item[0], reverse=True) + return candidates[0][1] + + +def parse_amount(raw: str | None) -> tuple[float | None, str | None]: + if not raw: + return None, None + text = raw.strip() + detected_currency: str | None = None + for pattern, currency in CURRENCY_PATTERNS: + new_text = pattern.sub("", text) + if new_text != text: + text = new_text + detected_currency = currency + break + text = text.replace(",", "").replace("$", "").replace("€", "").replace("£", "").replace("¥", "").strip() + if text.startswith("(") and text.endswith(")"): + text = "-" + text.strip("()") + if not text: + return None, detected_currency + try: + return float(text), detected_currency + except ValueError: + return None, detected_currency + + +def filename_quarter(filename: str) -> tuple[str, str] | None: + match = re.search(r"Q([1-4])[\s_-]?(\d{4})|([1-4])Q[\s_-]?(\d{4})|(\d{4})[\s_-]?Q([1-4])", filename, re.IGNORECASE) + if not match: + return None + if match.group(1): + quarter = int(match.group(1)) + year = int(match.group(2)) + elif match.group(3): + quarter = int(match.group(3)) + year = int(match.group(4)) + else: + year = int(match.group(5)) + quarter = int(match.group(6)) + return _quarter_to_period(year, quarter) + + +def filename_year(filename: str) -> tuple[str, str] | None: + match = re.search(r"(? tuple[str, str]: + start_month = (quarter - 1) * 3 + 1 + end_month = start_month + 2 + last_day = calendar.monthrange(year, end_month)[1] + return f"{year:04d}-{start_month:02d}-01", f"{year:04d}-{end_month:02d}-{last_day:02d}" + + +def row_quarter(value: str) -> tuple[str, str] | None: + match = re.search(r"Q([1-4])\s*(\d{4})|(\d{4})\s*Q([1-4])", value or "", re.IGNORECASE) + if not match: + return None + if match.group(1): + return _quarter_to_period(int(match.group(2)), int(match.group(1))) + return _quarter_to_period(int(match.group(3)), int(match.group(4))) + + +def row_year(value: str) -> tuple[str, str] | None: + match = re.search(r"(20\d{2}|19\d{2})", value or "") + if not match: + return None + year = int(match.group(1)) + return f"{year}-01-01", f"{year}-12-31" + + +def determine_period(template: PDFTemplate, filename: str, row: list[str]) -> tuple[str, str]: + # Filename-based templates never index into the row, so they are safe. + if template.period_format == "filename_quarter": + return filename_quarter(filename) or filename_year(filename) or ("", "") + if template.period_format == "filename_year": + return filename_year(filename) or ("", "") + + # Row-based templates: pull the period cell defensively. Short or malformed + # rows would otherwise raise IndexError and drop extraction for that PDF. + period_value = "" + if ( + template.period_index is not None + and 0 <= template.period_index < len(row) + ): + period_value = row[template.period_index] or "" + + if template.period_format == "row_quarter" and template.period_index is not None: + return row_quarter(period_value) or ("", "") + if template.period_format == "row_year" and template.period_index is not None: + return row_year(period_value) or ("", "") + if template.period_format == "row_period" and template.period_index is not None: + return period_value.strip(), "" + return "", "" + + +def asset_id_from_row(template: PDFTemplate, row: list[str]) -> str: + if template.iswc_index is not None and template.iswc_index < len(row): + iswc = (row[template.iswc_index] or "").strip() + if iswc: + return f"iswc:{iswc}" + if template.isrc_index is not None and template.isrc_index < len(row): + isrc = (row[template.isrc_index] or "").strip() + if isrc: + return f"isrc:{isrc}" + title = "" + if template.title_index < len(row): + title = (row[template.title_index] or "").strip().lower() + if title: + return f"title:{re.sub(r'[^a-z0-9]+', '-', title).strip('-')}" + return "unknown" + + +def is_data_row(row: list[str], template: PDFTemplate) -> bool: + if not row: + return False + if template.amount_index >= len(row): + return False + amount_cell = (row[template.amount_index] or "").strip() + if not amount_cell: + return False + title_cell = (row[template.title_index] or "").strip() if template.title_index < len(row) else "" + if not title_cell: + return False + if title_cell.lower() in {"total", "totals", "subtotal", "grand total"}: + return False + parsed, _ = parse_amount(amount_cell) + return parsed is not None + + +def extract_rows( + pdf_path: Path, + rel_path: str, + template: PDFTemplate | None, +) -> tuple[list[dict[str, str]], dict[str, object]]: + rows: list[dict[str, str]] = [] + with pdfplumber.open(pdf_path) as pdf: + first_table = next( + ( + table + for page in pdf.pages + for table in (page.extract_tables() or []) + if table + ), + None, + ) + if first_table is None: + return rows, {"reason": "no_tables_found"} + + if template is None: + template = detect_template(rel_path, first_table[0]) + if template is None: + return rows, { + "reason": "no_template_matched", + "headers_seen": _normalize_header(first_table[0]), + } + + ledger_index = 1 + filename = pdf_path.name + for page in pdf.pages: + for table in page.extract_tables() or []: + if not table or len(table) < 2: + continue + # Skip header row(s) — assume first row is header. + for raw in table[1:]: + row = [(cell or "").strip() for cell in raw] + if not is_data_row(row, template): + continue + period_start, period_end = determine_period(template, filename, row) + amount_value, detected_currency = parse_amount( + row[template.amount_index] if template.amount_index < len(row) else "" + ) + gross_value = None + if template.gross_index is not None and template.gross_index < len(row): + gross_value, _ = parse_amount(row[template.gross_index]) + share_value = "" + if template.share_index is not None and template.share_index < len(row): + share_value = row[template.share_index] + output_row = {column: "" for column in CANONICAL_COLUMNS} + output_row["ledger_line_id"] = f"PDF-{ledger_index:06d}" + output_row["catalog_asset_id"] = asset_id_from_row(template, row) + output_row["source_file"] = rel_path + output_row["provider"] = template.provider + output_row["rights_type"] = template.rights_type + output_row["income_type"] = template.income_type + output_row["territory"] = template.territory + output_row["period_start"] = period_start + output_row["period_end"] = period_end + output_row["currency"] = detected_currency or template.default_currency + output_row["owner_net_amount"] = ( + f"{amount_value:.2f}" if amount_value is not None else "" + ) + if gross_value is not None: + output_row["gross_amount"] = f"{gross_value:.2f}" + output_row["participant_share"] = share_value + output_row["match_confidence"] = "low" + output_row["notes"] = "extracted from PDF, verify against source" + rows.append(output_row) + ledger_index += 1 + info = { + "template": template.name, + "provider": template.provider, + "rows_extracted": len(rows), + } + return rows, info + + +def write_ledger(rows: list[dict[str, str]], output_path: Path) -> None: + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=CANONICAL_COLUMNS) + writer.writeheader() + writer.writerows(rows) + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", help="Path to a single PDF, or a directory to walk.") + parser.add_argument("--output", required=True, help="Output ledger CSV path.") + parser.add_argument( + "--workspace-root", + default=None, + help="Workspace root used to compute relative source_file paths.", + ) + parser.add_argument( + "--provider", + default=None, + help="Force a specific template name (skip auto-detection).", + ) + args = parser.parse_args() + + forced_template = None + if args.provider: + forced_template = next((t for t in TEMPLATES if t.name == args.provider), None) + if forced_template is None: + raise SystemExit( + f"Unknown template '{args.provider}'. Known: {', '.join(t.name for t in TEMPLATES)}" + ) + + input_path = Path(args.input) + if input_path.is_dir(): + pdfs = sorted(input_path.rglob("*.pdf")) + elif input_path.is_file() and input_path.suffix.lower() == ".pdf": + pdfs = [input_path] + else: + raise SystemExit(f"Input is not a PDF or directory: {input_path}") + + workspace_root = Path(args.workspace_root) if args.workspace_root else None + + all_rows: list[dict[str, str]] = [] + per_file: list[dict[str, object]] = [] + had_errors = False + for pdf in pdfs: + if workspace_root is not None: + try: + rel = str(pdf.relative_to(workspace_root)) + except ValueError: + rel = str(pdf) + else: + rel = str(pdf) + try: + rows, info = extract_rows(pdf, rel, forced_template) + except Exception as exc: # pragma: no cover - defensive + per_file.append({"path": rel, "status": "error", "error": str(exc)}) + had_errors = True + continue + if not rows: + per_file.append({"path": rel, "status": "no_rows", **info}) + continue + # Re-key ledger_line_id across the whole batch so the output file has + # globally unique row identifiers. + for row in rows: + row["ledger_line_id"] = f"PDF-{len(all_rows) + 1:06d}" + all_rows.append(row) + per_file.append( + { + "path": rel, + "status": "ok", + "template": info.get("template"), + "provider": info.get("provider"), + "rows": len(rows), + } + ) + + output_path = Path(args.output) + write_ledger(all_rows, output_path) + + # Surface partial-success runs. Returning `ok` when some PDFs failed would + # let upstream callers treat a broken ingest as a clean ingest. + summary_status = "partial" if had_errors else "ok" + summary = { + "status": summary_status, + "input": str(input_path), + "output": str(output_path), + "pdfs_scanned": len(pdfs), + "pdfs_extracted": sum(1 for entry in per_file if entry.get("status") == "ok"), + "pdfs_failed": sum(1 for entry in per_file if entry.get("status") == "error"), + "rows_total": len(all_rows), + "per_file": per_file, + } + print(json.dumps(summary, indent=2)) + return 1 if had_errors else 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/normalize-royalty-statement.py b/plugins/music-catalog-diligence/scripts/normalize-royalty-statement.py new file mode 100644 index 0000000..4b28787 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/normalize-royalty-statement.py @@ -0,0 +1,797 @@ +#!/usr/bin/env python3 +"""Normalize first-pass provider CSV/TSV exports into royalty-ledger.csv. + +Returns status="ok" only when financial fields (gross_amount, owner_net_amount, +period_start) are populated above 50% across rows. Lower coverage returns +status="partial" with a warnings array enumerating which expected source columns +were not found, so callers do not silently proceed with a useless ledger. +""" + +from __future__ import annotations + +import argparse +import calendar +import csv +import io +import json +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Callable + + +CANONICAL_COLUMNS = [ + "ledger_line_id", + "catalog_asset_id", + "source_file", + "provider", + "period_start", + "period_end", + "payment_date", + "rights_type", + "income_type", + "territory", + "platform_or_licensee", + "gross_amount", + "deductions", + "participant_share", + "owner_net_amount", + "currency", + "fx_rate", + "pro_use_type", + "pro_credits", + "pro_bonus_type", + "cue_sheet_ref", + "match_confidence", + "notes", +] + + +CRITICAL_FIELDS = ("gross_amount", "owner_net_amount", "period_start") +PARTIAL_THRESHOLD = 0.5 +NUMERIC_FIELDS = {"gross_amount", "deductions", "owner_net_amount"} + + +# Per-provider source-column aliases for the financially critical canonical +# fields. Used to compute "expected columns not found" warnings when coverage +# is partial. Compared case-insensitively against input headers. +FINANCIAL_ALIASES: dict[str, dict[str, list[str]]] = { + "ascap": { + "gross_amount": ["Gross Amount", "Gross", "Royalty Amount"], + "owner_net_amount": ["Publisher Share", "Net", "Royalty Amount"], + "period_start": ["Performance Start", "Period Start"], + "period_end": ["Performance End", "Period End"], + "platform_or_licensee": ["Licensee", "Station", "Network", "Series / Film Title"], + }, + "bmi": { + "gross_amount": ["Amount", "Gross Amount", "Amount Paid"], + "owner_net_amount": ["Publisher Net", "Net", "Amount Paid"], + "period_start": ["Period Start", "Statement Qtr"], + "period_end": ["Period End", "Statement Qtr"], + "platform_or_licensee": ["Station", "Licensee", "Network"], + }, + "mlc": { + "gross_amount": ["Mechanical Gross", "Gross"], + "owner_net_amount": ["Publisher Net", "Net", "Net Payable"], + "period_start": ["Period Start", "Usage Period", "Period"], + "period_end": ["Period End", "Usage Period", "Period"], + "platform_or_licensee": ["DSP", "Platform", "Source"], + }, + "distributor": { + "gross_amount": ["Gross", "Gross Amount"], + "owner_net_amount": ["Net", "Net Amount", "Net Payable"], + "period_start": ["Period Start", "Sales Month", "Period"], + "period_end": ["Period End", "Sales Month", "Period"], + "platform_or_licensee": ["Store", "DSP", "Platform", "Source"], + }, + "publisher-admin": { + "gross_amount": ["Gross", "Gross Amount"], + "owner_net_amount": ["Net", "Net Amount", "Net Payable"], + "period_start": ["Period Start", "Sales Month", "Period"], + "period_end": ["Period End", "Sales Month", "Period"], + "platform_or_licensee": ["Store", "DSP", "Platform", "Source"], + }, + "soundexchange": { + "gross_amount": ["Gross", "Gross Amount"], + "owner_net_amount": ["Net", "Net Amount", "Net Payable"], + "period_start": ["Period Start", "Sales Month", "Period"], + "period_end": ["Period End", "Sales Month", "Period"], + "platform_or_licensee": ["Store", "DSP", "Platform", "Source"], + }, + "direct-sync": { + "gross_amount": ["Gross", "Gross Amount"], + "owner_net_amount": ["Net", "Net Amount", "Net Payable"], + "period_start": ["Period Start", "Sales Month", "Period"], + "period_end": ["Period End", "Sales Month", "Period"], + "platform_or_licensee": ["Store", "DSP", "Platform", "Source"], + }, + "youtube-content-id": { + "gross_amount": ["Gross Revenue", "Gross", "youtube_revenue_split"], + "owner_net_amount": ["Net Revenue", "Net", "partner_revenue_usd"], + "period_start": ["Reporting Month", "Month", "date"], + "period_end": ["Reporting Month", "Month", "date"], + "platform_or_licensee": [], + }, + "curve": { + "gross_amount": ["Gross"], + "owner_net_amount": ["Net Payable", "Net"], + "period_start": ["Period Start"], + "period_end": ["Period End"], + "platform_or_licensee": ["Royalty Source", "Source"], + }, +} + + +def clean_amount(value: str | None) -> str: + if not value: + return "" + cleaned = value.strip().replace("$", "").replace(",", "") + is_negative = cleaned.startswith("(") and cleaned.endswith(")") + cleaned = cleaned.strip("()") + if not cleaned: + return "" + try: + amount = float(cleaned) + except ValueError: + return "" + if is_negative: + amount *= -1 + return f"{amount:.2f}" + + +def parse_month(value: str | None) -> tuple[str, str]: + if not value: + return "", "" + text = value.strip() + match = re.fullmatch(r"(\d{4})-(\d{2})", text) + if not match: + return text, text + year = int(match.group(1)) + month = int(match.group(2)) + last_day = calendar.monthrange(year, month)[1] + return f"{year:04d}-{month:02d}-01", f"{year:04d}-{month:02d}-{last_day:02d}" + + +def parse_quarter(value: str | None) -> tuple[str, str]: + if not value: + return "", "" + text = value.strip() + match = re.fullmatch(r"(\d{4})Q([1-4])", text) + if not match: + return "", "" + year = int(match.group(1)) + quarter = int(match.group(2)) + start_month = (quarter - 1) * 3 + 1 + end_month = start_month + 2 + last_day = calendar.monthrange(year, end_month)[1] + return f"{year:04d}-{start_month:02d}-01", f"{year:04d}-{end_month:02d}-{last_day:02d}" + + +def parse_yyyymmdd(value: str | None) -> str: + if not value: + return "" + text = value.strip() + match = re.fullmatch(r"(\d{4})(\d{2})(\d{2})", text) + if not match: + return text + return f"{match.group(1)}-{match.group(2)}-{match.group(3)}" + + +def first(row: dict[str, str], *names: str) -> str: + lower_lookup = {key.lower(): value for key, value in row.items() if isinstance(key, str)} + for name in names: + value = row.get(name) + if value not in (None, ""): + return value.strip() + value = lower_lookup.get(name.lower()) + if value not in (None, ""): + return value.strip() + return "" + + +def has_meaningful_value(row: dict[str, str]) -> bool: + values = [value.strip() for key, value in row.items() if isinstance(key, str) and value] + if not values: + return False + first_value = values[0].lower() + if first_value.startswith("totals") or first_value.startswith("note:"): + return False + return True + + +def asset_id(prefix: str, value: str, fallback: str) -> str: + if value: + return f"{prefix}:{value}" + return f"title:{fallback.strip().lower().replace(' ', '-')}" if fallback else "unknown" + + +def blank_row(index: int, source_file: str) -> dict[str, str]: + row = {column: "" for column in CANONICAL_COLUMNS} + row["ledger_line_id"] = f"RL-{index:06d}" + row["source_file"] = source_file + row["match_confidence"] = "medium" + return row + + +@dataclass(frozen=True) +class ProviderProfile: + provider_name: str + rights_type: str + income_type: str + normalize: Callable[[dict[str, str], int, str], dict[str, str]] + + +def normalize_ascap(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + output = blank_row(index, source_file) + title = first(row, "Work Title", "Title") + gross = clean_amount(first(row, "Gross Amount", "Gross", "Royalty Amount")) + net = clean_amount(first(row, "Publisher Share", "Net", "Royalty Amount")) + output.update( + { + "provider": "ASCAP", + "rights_type": "publishing", + "income_type": "performance", + "catalog_asset_id": asset_id("iswc", first(row, "ISWC"), title), + "period_start": first(row, "Performance Start", "Period Start"), + "period_end": first(row, "Performance End", "Period End"), + "payment_date": first(row, "Distribution Date", "Payment Date"), + "territory": first(row, "Territory"), + "platform_or_licensee": first(row, "Licensee", "Station", "Network", "Series / Film Title"), + "gross_amount": gross, + "deductions": clean_amount(first(row, "Deductions", "Fees")), + "participant_share": first(row, "Share %"), + "owner_net_amount": net, + "currency": first(row, "Currency") or "USD", + "pro_use_type": first(row, "Use Type", "Perf. Type"), + "pro_credits": first(row, "Credits", "Total Credits", "Premium Credits"), + "pro_bonus_type": first(row, "Bonus Type", "Premium Type"), + "cue_sheet_ref": first(row, "Cue Sheet", "Program"), + "notes": f"work_id={first(row, 'Work ID')}" if first(row, "Work ID") else "", + } + ) + return output + + +def normalize_bmi(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + output = blank_row(index, source_file) + title = first(row, "Title", "Work Title") + quarter_start, quarter_end = parse_quarter(first(row, "Statement Qtr")) + hit_song_bonus = clean_amount(first(row, "Hit Song Bonus")) + bonus_type = first(row, "Bonus Category", "Bonus Type") or ("Hit Song Bonus" if hit_song_bonus not in ("", "0.00") else "") + net = clean_amount(first(row, "Publisher Net", "Net", "Amount Paid")) + output.update( + { + "provider": "BMI", + "rights_type": "publishing", + "income_type": "performance", + "catalog_asset_id": asset_id("iswc", first(row, "ISWC"), title), + "period_start": first(row, "Period Start") or quarter_start, + "period_end": first(row, "Period End") or quarter_end, + "payment_date": first(row, "Payment Date"), + "territory": first(row, "Territory"), + "platform_or_licensee": first(row, "Station", "Licensee", "Network"), + "gross_amount": clean_amount(first(row, "Amount", "Gross Amount", "Amount Paid")), + "deductions": clean_amount(first(row, "Admin Fee", "Deductions")), + "owner_net_amount": net, + "currency": first(row, "Currency") or "USD", + "pro_use_type": first(row, "Performance Type", "Use Type"), + "pro_bonus_type": bonus_type, + "notes": f"bmi_work_id={first(row, 'BMI Work #')}" if first(row, "BMI Work #") else "", + } + ) + return output + + +def normalize_distributor(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + output = blank_row(index, source_file) + title = first(row, "Track Title", "Title") + period_start, period_end = parse_month(first(row, "Sales Month", "Period")) + rights_type = first(row, "Rights Type").lower() or "master" + income_type = first(row, "Income Type").lower() or "streaming" + output.update( + { + "provider": "Distributor", + "rights_type": rights_type, + "income_type": income_type, + "catalog_asset_id": asset_id("isrc", first(row, "ISRC"), title), + "period_start": first(row, "Period Start") or period_start, + "period_end": first(row, "Period End") or period_end, + "payment_date": first(row, "Report Date", "Payment Date"), + "territory": first(row, "Territory", "Country"), + "platform_or_licensee": first(row, "Store", "DSP", "Platform", "Source"), + "gross_amount": clean_amount(first(row, "Gross", "Gross Amount")), + "deductions": clean_amount(first(row, "Fee", "Deductions")), + "owner_net_amount": clean_amount(first(row, "Net", "Net Amount", "Net Payable")), + "currency": first(row, "Currency") or "USD", + "fx_rate": first(row, "FX", "FX Rate"), + "notes": first(row, "Notes"), + } + ) + return output + + +def normalize_common_statement( + row: dict[str, str], + index: int, + source_file: str, + provider: str, + rights_type: str, + income_type: str, +) -> dict[str, str]: + output = normalize_distributor(row, index, source_file) + output.update( + { + "provider": provider, + "rights_type": rights_type, + "income_type": income_type, + } + ) + return output + + +def normalize_publisher_admin(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + return normalize_common_statement(row, index, source_file, "Publisher Admin", "publishing", "mechanical") + + +def normalize_soundexchange(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + return normalize_common_statement(row, index, source_file, "SoundExchange", "neighboring", "radio") + + +def normalize_direct_sync(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + return normalize_common_statement(row, index, source_file, "Direct Sync", "sync", "sync") + + +def normalize_youtube(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + output = blank_row(index, source_file) + title = first(row, "Asset Title", "Track Title", "Title") + report_date = parse_yyyymmdd(first(row, "date")) + period_start, period_end = parse_month(first(row, "Reporting Month", "Month")) + output.update( + { + "provider": "YouTube Content ID", + "rights_type": "master", + "income_type": "ugc", + "catalog_asset_id": first(row, "custom_id") or asset_id("isrc", first(row, "ISRC", "isrc"), title), + "period_start": period_start or report_date, + "period_end": period_end or report_date, + "territory": first(row, "Country", "Territory", "country_code"), + "platform_or_licensee": "YouTube", + "gross_amount": clean_amount(first(row, "Gross Revenue", "Gross", "youtube_revenue_split")), + "owner_net_amount": clean_amount(first(row, "Net Revenue", "Net", "partner_revenue_usd")), + "currency": first(row, "Currency", "currency_code") or "USD", + "notes": f"content_type={first(row, 'Content Type')}" if first(row, "Content Type") else f"asset_id={first(row, 'asset_id')}" if first(row, "asset_id") else "", + } + ) + return output + + +def normalize_mlc(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + output = blank_row(index, source_file) + title = first(row, "Song Title", "Work Title", "Title") + period_start, period_end = parse_month(first(row, "Usage Period", "Period")) + output.update( + { + "provider": "The MLC", + "rights_type": "publishing", + "income_type": "mechanical", + "catalog_asset_id": asset_id("iswc", first(row, "ISWC"), title), + "period_start": first(row, "Period Start") or period_start, + "period_end": first(row, "Period End") or period_end, + "payment_date": first(row, "Report Date", "Payment Date"), + "territory": first(row, "Territory") or "US", + "platform_or_licensee": first(row, "DSP", "Platform", "Source"), + "gross_amount": clean_amount(first(row, "Mechanical Gross", "Gross")), + "deductions": clean_amount(first(row, "Admin Fee", "Deductions")), + "owner_net_amount": clean_amount(first(row, "Publisher Net", "Net", "Net Payable")), + "currency": first(row, "Currency") or "USD", + "fx_rate": first(row, "FX", "FX Rate"), + "notes": first(row, "Notes"), + } + ) + return output + + +def normalize_curve(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + output = blank_row(index, source_file) + title = first(row, "Track Title", "Work Title", "Title") + rights_type = first(row, "Rights Type").lower() or "unknown" + output.update( + { + "provider": "Curve", + "rights_type": rights_type, + "income_type": first(row, "Income Type").lower() or "unknown", + "catalog_asset_id": first(row, "Source Work ID") or asset_id("isrc", first(row, "ISRC"), title), + "period_start": first(row, "Period Start"), + "period_end": first(row, "Period End"), + "territory": first(row, "Territory"), + "platform_or_licensee": first(row, "Royalty Source", "Source"), + "gross_amount": clean_amount(first(row, "Gross")), + "deductions": clean_amount(first(row, "Deductions")), + "owner_net_amount": clean_amount(first(row, "Net Payable", "Net")), + "currency": first(row, "Currency") or "USD", + } + ) + return output + + +def _filename_year_period(source_file: str) -> tuple[str, str]: + match = re.search(r"(? dict[str, str]: + """BMI annual consolidated XLSX. Sums Domestic Perf + Domestic Mech + + International + Other into owner_net_amount and preserves the breakdown + in `notes`. period_start/end is derived from the source filename year. + """ + output = blank_row(index, source_file) + title = first(row, "Title", "Work Title") + work_id = first(row, "BMI Work #", "BMI Work#", "Work ID", "BMI Work") + components = { + "domestic_perf": _to_float(first(row, "Domestic Perf", "Domestic Performance")), + "domestic_mech": _to_float(first(row, "Domestic Mech", "Domestic Mechanical")), + "international": _to_float(first(row, "International (all CMOs)", "International")), + "other": _to_float(first(row, "Other Income", "Other")), + } + total_value = _to_float(first(row, "Total")) + if total_value is None: + total_value = sum(v for v in components.values() if v is not None) + components_note = ", ".join( + f"{key}={value:.2f}" for key, value in components.items() if value is not None + ) + period_start, period_end = _filename_year_period(source_file) + output.update( + { + "provider": "BMI", + "rights_type": "publishing", + "income_type": "performance", + "catalog_asset_id": (f"bmi:{work_id}" if work_id else asset_id("title", "", title)), + "period_start": period_start, + "period_end": period_end, + "platform_or_licensee": "BMI consolidated", + "owner_net_amount": f"{total_value:.2f}" if total_value is not None else "", + "currency": "USD", + "notes": components_note + (f"; bmi_work_id={work_id}" if work_id else ""), + } + ) + return output + + +def normalize_gema(row: dict[str, str], index: int, source_file: str) -> dict[str, str]: + """GEMA German sub-publisher XLSX. Header layout typically: + Title, ISWC, Distribution (EUR), Mechanical (EUR), Performance (EUR), Total (EUR). + """ + output = blank_row(index, source_file) + title = first(row, "Title", "Titel", "Work Title") + iswc = first(row, "ISWC") + total = clean_amount(first(row, "Total (EUR)", "Total", "Distribution (EUR)", "Distribution")) + mechanical = clean_amount(first(row, "Mechanical (EUR)", "Mechanical")) + performance = clean_amount(first(row, "Performance (EUR)", "Performance")) + note_parts: list[str] = [] + if mechanical: + note_parts.append(f"mechanical={mechanical}") + if performance: + note_parts.append(f"performance={performance}") + period_start, period_end = _filename_year_period(source_file) + output.update( + { + "provider": "GEMA", + "rights_type": "publishing", + "income_type": "performance", + "catalog_asset_id": asset_id("iswc", iswc, title), + "period_start": period_start, + "period_end": period_end, + "territory": "DE", + "platform_or_licensee": "GEMA", + "owner_net_amount": total, + "currency": "EUR", + "notes": "; ".join(note_parts), + } + ) + return output + + +def _to_float(value: str | None) -> float | None: + if value in (None, ""): + return None + cleaned = str(value).replace("$", "").replace(",", "").strip() + if not cleaned: + return None + try: + return float(cleaned) + except ValueError: + return None + + +PROFILES: dict[str, ProviderProfile] = { + "ascap": ProviderProfile("ASCAP", "publishing", "performance", normalize_ascap), + "bmi": ProviderProfile("BMI", "publishing", "performance", normalize_bmi), + "bmi-annual": ProviderProfile("BMI Annual", "publishing", "performance", normalize_bmi_annual), + "gema": ProviderProfile("GEMA", "publishing", "performance", normalize_gema), + "distributor": ProviderProfile("Distributor", "master", "streaming", normalize_distributor), + "publisher-admin": ProviderProfile("Publisher Admin", "publishing", "mechanical", normalize_publisher_admin), + "soundexchange": ProviderProfile("SoundExchange", "neighboring", "radio", normalize_soundexchange), + "direct-sync": ProviderProfile("Direct Sync", "sync", "sync", normalize_direct_sync), + "youtube-content-id": ProviderProfile("YouTube Content ID", "master", "ugc", normalize_youtube), + "mlc": ProviderProfile("The MLC", "publishing", "mechanical", normalize_mlc), + "curve": ProviderProfile("Curve", "unknown", "unknown", normalize_curve), +} + + +def detect_delimiter(text_sample: str, requested: str) -> str: + if requested != "auto": + return requested + try: + dialect = csv.Sniffer().sniff(text_sample, delimiters=",\t|;") + return dialect.delimiter + except csv.Error: + return "," + + +def read_rows(input_path: Path, delimiter: str, sheet: str | None = None) -> tuple[list[str], list[dict[str, str]]]: + suffix = input_path.suffix.lower() + if suffix in {".xlsx", ".xls", ".xlsm"}: + return _read_xlsx_rows(input_path, sheet) + raw = input_path.read_text(encoding="utf-8-sig") + sample = raw[:8192] if raw else "" + actual_delim = detect_delimiter(sample, delimiter) + reader = csv.DictReader(io.StringIO(raw), delimiter=actual_delim) + rows = [row for row in reader if has_meaningful_value(row)] + headers = list(reader.fieldnames or []) + return headers, rows + + +def _read_xlsx_rows(input_path: Path, sheet: str | None) -> tuple[list[str], list[dict[str, str]]]: + try: + import openpyxl # type: ignore[import-not-found] + except ImportError as exc: # pragma: no cover - import-time guard + raise SystemExit( + "openpyxl is required for XLSX inputs. Install via: pip3 install -r requirements.txt" + ) from exc + workbook = openpyxl.load_workbook(input_path, data_only=True, read_only=True) + if sheet and sheet in workbook.sheetnames: + worksheet = workbook[sheet] + else: + worksheet = workbook.active + raw_rows = [row for row in worksheet.iter_rows(values_only=True)] + header_idx = _detect_header_row(raw_rows) + if header_idx is None: + return [], [] + headers = [_to_str_cell(c) for c in raw_rows[header_idx]] + headers = [h for h in headers if h] + body: list[dict[str, str]] = [] + for raw in raw_rows[header_idx + 1:]: + if not raw or not any(c is not None for c in raw): + continue + row_dict: dict[str, str] = {} + for idx, header in enumerate(raw_rows[header_idx]): + cell = raw[idx] if idx < len(raw) else None + key = _to_str_cell(header) + if not key: + continue + row_dict[key] = _to_str_cell(cell) + if not has_meaningful_value(row_dict): + continue + body.append(row_dict) + return headers, body + + +def _to_str_cell(value: object) -> str: + if value is None: + return "" + if isinstance(value, str): + return value.strip() + if isinstance(value, float) and value.is_integer(): + return str(int(value)) + return str(value) + + +def _detect_header_row(rows: list[tuple]) -> int | None: + """Find the first row that looks like column headers: ≥4 non-null string + cells with no leading currency symbol. This skips preface metadata rows + (e.g. "BMI Royalty Distribution — Consolidated Annual View 2022").""" + for idx, row in enumerate(rows): + if not row: + continue + non_null = [c for c in row if c is not None and str(c).strip()] + if len(non_null) < 3: + continue + if not all(isinstance(c, str) for c in non_null): + continue + if any(str(c).strip().startswith(("$", "€", "£", "¥")) for c in non_null): + continue + # Title-rows usually contain only one cell + Nones; require fill on multiple columns. + return idx + return None + + +def load_column_map(path: Path | None) -> dict[str, str]: + if path is None: + return {} + data = json.loads(path.read_text(encoding="utf-8")) + if not isinstance(data, dict): + raise SystemExit(f"--column-map must be a JSON object: {path}") + valid: dict[str, str] = {} + for source, canonical in data.items(): + if not isinstance(source, str) or not isinstance(canonical, str): + continue + if canonical not in CANONICAL_COLUMNS: + raise SystemExit( + f"--column-map target '{canonical}' is not a canonical ledger column. " + f"Valid targets: {', '.join(sorted(CANONICAL_COLUMNS))}" + ) + valid[source] = canonical + return valid + + +def apply_column_map(input_row: dict[str, str], output_row: dict[str, str], column_map: dict[str, str]) -> None: + """Fill any canonical fields that the profile left empty using the mapping.""" + for source_col, canonical_field in column_map.items(): + if output_row.get(canonical_field): + continue + source_value = first(input_row, source_col) + if not source_value: + continue + if canonical_field in NUMERIC_FIELDS: + output_row[canonical_field] = clean_amount(source_value) + else: + output_row[canonical_field] = source_value + + +def compute_population_rate(rows: list[dict[str, str]]) -> float: + if not rows: + return 0.0 + total = len(rows) * len(CRITICAL_FIELDS) + filled = sum( + 1 + for row in rows + for field in CRITICAL_FIELDS + if (row.get(field) or "").strip() + ) + return filled / total if total else 0.0 + + +def expected_columns_not_found(provider: str, headers: list[str]) -> list[str]: + aliases = FINANCIAL_ALIASES.get(provider, {}) + expected: set[str] = set() + for field in CRITICAL_FIELDS + ("period_end", "platform_or_licensee"): + for alias in aliases.get(field, []): + expected.add(alias) + if not expected: + return [] + headers_lower = {h.lower() for h in headers if isinstance(h, str)} + missing = [name for name in sorted(expected) if name.lower() not in headers_lower] + return missing + + +def normalize( + provider: str, + input_path: Path, + output_path: Path, + delimiter: str = "auto", + column_map_path: Path | None = None, + sheet: str | None = None, +) -> dict[str, object]: + profile = PROFILES.get(provider) + if profile is None: + supported = ", ".join(sorted(PROFILES)) + raise SystemExit(f"Unsupported provider '{provider}'. Supported providers: {supported}") + + column_map = load_column_map(column_map_path) + headers, rows = read_rows(input_path, delimiter, sheet=sheet) + + normalized_rows: list[dict[str, str]] = [] + for index, row in enumerate(rows): + output = profile.normalize(row, index + 1, str(input_path)) + if column_map: + apply_column_map(row, output, column_map) + normalized_rows.append(output) + + output_path.parent.mkdir(parents=True, exist_ok=True) + with output_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=CANONICAL_COLUMNS) + writer.writeheader() + writer.writerows(normalized_rows) + + rate = compute_population_rate(normalized_rows) + warnings: list[dict[str, object]] = [] + status = "ok" + if not normalized_rows: + # Empty output is never `ok`: downstream consumers would silently + # ingest a blank ledger. Treat it as `partial` and explain why. + status = "partial" + warnings.append( + { + "profile": provider, + "field_population_rate": 0.0, + "headers_seen": headers, + "expected_columns_not_found": expected_columns_not_found(provider, headers), + "critical_fields_checked": list(CRITICAL_FIELDS), + "hint": ( + "No data rows were normalized. Verify the provider profile, the " + "delimiter/sheet, and that the input is not empty or header-only." + ), + } + ) + elif rate < PARTIAL_THRESHOLD: + status = "partial" + missing = expected_columns_not_found(provider, headers) + warnings.append( + { + "profile": provider, + "field_population_rate": round(rate, 4), + "headers_seen": headers, + "expected_columns_not_found": missing, + "critical_fields_checked": list(CRITICAL_FIELDS), + "hint": ( + "Coverage of gross_amount/owner_net_amount/period_start fell below " + f"{int(PARTIAL_THRESHOLD * 100)}%. Pass --column-map mapping.json with " + "{source_column: canonical_field} or extend the provider profile." + ), + } + ) + + payload: dict[str, object] = { + "status": status, + "provider": profile.provider_name, + "input": str(input_path), + "output": str(output_path), + "rows": len(normalized_rows), + "field_population_rate": round(rate, 4), + "headers_seen": headers, + "warnings": warnings, + } + return payload + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--provider", required=True, help=f"One of: {', '.join(sorted(PROFILES))}") + parser.add_argument("--input", required=True, help="Provider CSV/TSV export") + parser.add_argument("--output", required=True, help="Output royalty-ledger.csv path") + parser.add_argument( + "--delimiter", + default="auto", + help="Field delimiter for the input file. Use 'auto' to sniff (default), or pass an explicit one ('\\t' for TSV).", + ) + parser.add_argument( + "--column-map", + default=None, + help="Optional JSON file mapping {source_column: canonical_field} for provider variants.", + ) + parser.add_argument( + "--sheet", + default=None, + help="Worksheet name for XLSX inputs (defaults to active sheet).", + ) + args = parser.parse_args() + + delimiter = args.delimiter + if delimiter != "auto": + delimiter = bytes(delimiter, "utf-8").decode("unicode_escape") + column_map_path = Path(args.column_map) if args.column_map else None + + payload = normalize( + args.provider, + Path(args.input), + Path(args.output), + delimiter=delimiter, + column_map_path=column_map_path, + sheet=args.sheet, + ) + print(json.dumps(payload, indent=2)) + if payload["status"] != "ok": + print( + f"normalize-royalty-statement: status={payload['status']} — see warnings above.", + file=sys.stderr, + ) + return 1 + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/run-diligence-checks.py b/plugins/music-catalog-diligence/scripts/run-diligence-checks.py new file mode 100644 index 0000000..8477265 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/run-diligence-checks.py @@ -0,0 +1,78 @@ +#!/usr/bin/env python3 +"""Run deterministic readiness checks for a catalog deal workspace.""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent + + +CHECKS = [ + ("workspace", "validate-deal-workspace.py", lambda workspace: [workspace]), + ( + "normalized_ledger", + "validate-normalized-ledger.py", + lambda workspace: [workspace / "normalized" / "royalty-ledger.csv"], + ), + ( + "evidence_ledger", + "validate-evidence-ledger.py", + lambda workspace: [workspace / "evidence-ledger.json"], + ), + ( + "findings_evidence", + "validate-findings-evidence.py", + lambda workspace: [workspace], + ), + ( + "workspace_consistency", + "validate-workspace-consistency.py", + lambda workspace: [workspace], + ), +] + + +def run_check(name: str, script_name: str, args: list[Path]) -> dict[str, object]: + script = SCRIPT_DIR / script_name + result = subprocess.run( + [sys.executable, str(script), *[str(arg) for arg in args]], + check=False, + text=True, + capture_output=True, + ) + payload: dict[str, object] + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + payload = {"stdout": result.stdout.strip(), "stderr": result.stderr.strip()} + return { + "name": name, + "status": "ok" if result.returncode == 0 else "failed", + "returncode": result.returncode, + "details": payload, + } + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("deal_workspace", help="Path to deals/{deal-id}") + args = parser.parse_args() + + workspace = Path(args.deal_workspace) + checks = [ + run_check(name, script_name, build_args(workspace)) + for name, script_name, build_args in CHECKS + ] + status = "ok" if all(check["status"] == "ok" for check in checks) else "failed" + print(json.dumps({"status": status, "workspace": str(workspace), "checks": checks}, indent=2)) + return 0 if status == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/test-build-manual-review-queue.py b/plugins/music-catalog-diligence/scripts/test-build-manual-review-queue.py new file mode 100644 index 0000000..a06d76f --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-build-manual-review-queue.py @@ -0,0 +1,111 @@ +#!/usr/bin/env python3 +"""Tests for build-manual-review-queue.py.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT_DIR = Path(__file__).resolve().parent +QUEUE = SCRIPT_DIR / "build-manual-review-queue.py" +MANIFEST = SCRIPT_DIR / "build-file-manifest.py" + + +def make_workspace(root: Path) -> Path: + workspace = root / "deals" / "test-deal" + source = workspace / "source" + source.mkdir(parents=True, exist_ok=True) + (source / "00_README.txt").write_text("seller readme", encoding="utf-8") + bmi = source / "03_Royalty_Statements" / "BMI" + bmi.mkdir(parents=True, exist_ok=True) + (bmi / "Q1_2023.csv").write_text("title,iswc\nA,T1\n", encoding="utf-8") + ascap = source / "03_Royalty_Statements" / "ASCAP" + ascap.mkdir(parents=True, exist_ok=True) + (ascap / "Q1_2023.pdf").write_bytes(b"%PDF-1.4 fake") + rights = source / "04_Rights_Documents" / "Split_Sheets" + rights.mkdir(parents=True, exist_ok=True) + (rights / "Sun_Up_Mary_1973.pdf").write_bytes(b"%PDF-1.4 fake") + mlc = source / "03_Royalty_Statements" / "MLC" + mlc.mkdir(parents=True, exist_ok=True) + (mlc / "statement_2023-08.tsv").write_text("", encoding="utf-8") # 0-byte + (workspace / "normalized").mkdir(parents=True, exist_ok=True) + (workspace / "normalized" / "royalty-ledger.csv").write_text( + "ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,rights_type,income_type,gross_amount,owner_net_amount,currency\n" + "RL-000001,iswc:T1,source/03_Royalty_Statements/BMI/Q1_2023.csv,BMI,2023-01-01,2023-03-31,publishing,performance,1000.00,850.00,USD\n", + encoding="utf-8", + ) + (workspace / "findings").mkdir(parents=True, exist_ok=True) + (workspace / "workpapers").mkdir(parents=True, exist_ok=True) + return workspace + + +def run_manifest(workspace: Path) -> None: + subprocess.run( + [sys.executable, str(MANIFEST), str(workspace)], + check=True, + text=True, + capture_output=True, + ) + + +def run_queue(workspace: Path) -> tuple[int, dict, dict, str]: + result = subprocess.run( + [sys.executable, str(QUEUE), str(workspace)], + text=True, + capture_output=True, + ) + payload = json.loads(result.stdout) if result.stdout.strip() else {} + coverage = json.loads((workspace / "workpapers" / "ingest-coverage.json").read_text(encoding="utf-8")) + queue_md = (workspace / "findings" / "manual-review-queue.md").read_text(encoding="utf-8") + return result.returncode, payload, coverage, queue_md + + +class ManualReviewQueueTest(unittest.TestCase): + def test_summary_line_reports_contribution_and_review_counts(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace(Path(directory)) + run_manifest(workspace) + code, payload, coverage, queue_md = run_queue(workspace) + self.assertEqual(code, 0, payload) + self.assertIn("contributed financial data", payload["summary_line"]) + self.assertEqual(coverage["contributed_count"], 1) + self.assertEqual(coverage["total_files"], 5) + self.assertGreaterEqual(coverage["manual_review_count"], 3) + self.assertEqual(coverage["unparsed_count"], 1) + + def test_pdf_files_get_extract_action_with_provider(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace(Path(directory)) + run_manifest(workspace) + _code, _payload, _coverage, queue_md = run_queue(workspace) + self.assertIn("ASCAP/Q1_2023.pdf", queue_md) + self.assertIn("Extract financial detail from PDF (provider: ASCAP)", queue_md) + + def test_zero_byte_file_gets_reissue_action(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace(Path(directory)) + run_manifest(workspace) + _code, _payload, _coverage, queue_md = run_queue(workspace) + self.assertIn("statement_2023-08.tsv", queue_md) + self.assertIn("zero bytes", queue_md) + + def test_fails_when_manifest_missing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace(Path(directory)) + # Skip manifest creation. + result = subprocess.run( + [sys.executable, str(QUEUE), str(workspace)], + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("file-manifest.json", result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-calculate-concentration.py b/plugins/music-catalog-diligence/scripts/test-calculate-concentration.py new file mode 100644 index 0000000..70a8631 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-calculate-concentration.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 +"""Tests for calculate-concentration.py threshold logic.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parent / "calculate-concentration.py" + + +HEADER = ( + "ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end," + "rights_type,income_type,gross_amount,owner_net_amount,currency\n" +) + + +def write_ledger(path: Path, asset_amounts: list[tuple[str, float]]) -> None: + rows = [HEADER] + for index, (asset_id, amount) in enumerate(asset_amounts, start=1): + rows.append( + f"RL-{index:06d},{asset_id},source.csv,BMI,2024-01-01,2024-03-31,publishing,performance,{amount:.2f},{amount:.2f},USD\n" + ) + path.write_text("".join(rows), encoding="utf-8") + + +def run(args: list[str]) -> tuple[int, dict]: + result = subprocess.run( + [sys.executable, str(SCRIPT), *args], + text=True, + capture_output=True, + ) + payload = json.loads(result.stdout) if result.stdout.strip() else {} + return result.returncode, payload + + +class ConcentrationTest(unittest.TestCase): + def test_top_n_pcts_computed_for_default_dimensions(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ledger = Path(directory) / "ledger.csv" + write_ledger( + ledger, + [("A", 50.0), ("B", 25.0), ("C", 15.0), ("D", 10.0)], + ) + code, payload = run([str(ledger)]) + self.assertEqual(code, 0) + catalog = payload["concentration"]["catalog_asset_id"] + self.assertEqual(catalog["top_1_pct"], 50.0) + self.assertEqual(catalog["top_3_pct"], 90.0) + self.assertEqual(catalog["top_5_pct"], 100.0) + + def test_threshold_tripped_emits_finding(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ledger = Path(directory) / "ledger.csv" + assumptions = Path(directory) / "assumptions.yaml" + finding_out = Path(directory) / "concentration-finding.json" + assumptions.write_text("materiality:\n concentration_threshold_percent: 40\n", encoding="utf-8") + write_ledger(ledger, [("A", 60.0), ("B", 20.0), ("C", 20.0)]) + code, payload = run( + [ + str(ledger), + "--assumptions", + str(assumptions), + "--emit-finding-output", + str(finding_out), + ] + ) + findings = json.loads(finding_out.read_text(encoding="utf-8"))["findings"] + self.assertEqual(code, 0) + self.assertTrue(payload["tripped_threshold"]) + self.assertEqual(payload["threshold_pct"], 40.0) + self.assertEqual(payload["threshold_source"], "assumptions.yaml") + self.assertEqual(len(findings), 1) + finding = findings[0] + self.assertEqual(finding["severity"], "high") + self.assertEqual(finding["category"], "valuation") + self.assertIn("A", finding["affected_assets"]) + + def test_threshold_not_tripped_no_finding(self) -> None: + with tempfile.TemporaryDirectory() as directory: + ledger = Path(directory) / "ledger.csv" + finding_out = Path(directory) / "concentration-finding.json" + # 100 assets at $1 each → top_10 = 10%, top_5 = 5%, top_1 = 1%. + write_ledger(ledger, [(f"A{i:03d}", 1.0) for i in range(100)]) + code, payload = run( + [ + str(ledger), + "--threshold-pct", + "40", + "--emit-finding-output", + str(finding_out), + ] + ) + findings = json.loads(finding_out.read_text(encoding="utf-8"))["findings"] + self.assertEqual(code, 0) + self.assertFalse(payload["tripped_threshold"]) + self.assertEqual(findings, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-dataroom-hygiene-scan.py b/plugins/music-catalog-diligence/scripts/test-dataroom-hygiene-scan.py new file mode 100644 index 0000000..468dcd8 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-dataroom-hygiene-scan.py @@ -0,0 +1,112 @@ +#!/usr/bin/env python3 +"""Tests for dataroom-hygiene-scan.py.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("dataroom-hygiene-scan.py") + + +def write_workspace(root: Path, files: dict[str, str]) -> Path: + workspace = root / "deals" / "test" + source = workspace / "source" + source.mkdir(parents=True, exist_ok=True) + for relative, content in files.items(): + path = source / relative + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content, encoding="utf-8") + return workspace + + +def run(workspace: Path) -> tuple[int, dict, dict, dict]: + result = subprocess.run( + [sys.executable, str(SCRIPT), str(workspace)], + text=True, + capture_output=True, + ) + payload = json.loads(result.stdout) if result.stdout.strip() else {} + workpaper = json.loads((workspace / "workpapers" / "dataroom-hygiene.json").read_text(encoding="utf-8")) + findings = json.loads((workspace / "findings" / "dataroom-hygiene-findings.json").read_text(encoding="utf-8")) + return result.returncode, payload, workpaper, findings + + +class HygieneScanTest(unittest.TestCase): + def test_emits_finding_for_concealment_filename_with_content_match(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + { + "07_Misc/DELETE_BEFORE_SHARING.txt": ( + "Personal admin checklist.\n" + "Estimate ~$11K we owe her. DO NOT volunteer this number.\n" + ), + }, + ) + code, _payload, workpaper, findings = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(workpaper["match_count"], 1) + match = workpaper["matches"][0] + self.assertTrue(match["filename_strong"]) + self.assertEqual(len(match["content_matches"]), 1) + self.assertEqual(match["match_strength"], "high") + self.assertEqual(len(findings["findings"]), 1) + finding = findings["findings"][0] + self.assertEqual(finding["severity"], "high") + self.assertEqual(finding["category"], "process_integrity") + self.assertIn("DELETE_BEFORE_SHARING.txt", finding["issue"]) + + def test_does_not_flag_song_titles_with_dont_or_draft_keywords(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + { + "Split_Sheets/Dont_Stop_Now_2010.txt": "writer share: 75/25", + "Catalog/old_friends_song.txt": "title: Old Friends", + "Catalog/Don_Henley_Catalog.txt": "writer: Don Henley", + }, + ) + code, _payload, workpaper, findings = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(workpaper["match_count"], 0, f"unexpected matches: {workpaper}") + self.assertEqual(findings["findings"], []) + + def test_records_weak_matches_in_workpaper_without_findings(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + { + "04_Rights/Cooper_Lane_S203_termination_notice_2007_DRAFT.txt": "draft notice text", + }, + ) + code, _payload, workpaper, findings = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(workpaper["match_count"], 1) + self.assertEqual(workpaper["matches"][0]["match_strength"], "weak") + self.assertEqual(findings["findings"], []) + + def test_content_only_match_emits_finding(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + { + "07_Misc/seller_notes.md": ( + "Some notes.\n\n" + "Remove before sharing the section about Brennan's reversion.\n" + ), + }, + ) + code, _payload, _workpaper, findings = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(len(findings["findings"]), 1) + self.assertEqual(findings["findings"][0]["severity"], "high") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-diligence-readiness.py b/plugins/music-catalog-diligence/scripts/test-diligence-readiness.py new file mode 100644 index 0000000..b392d6b --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-diligence-readiness.py @@ -0,0 +1,306 @@ +#!/usr/bin/env python3 +"""Tests for diligence readiness helpers.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path +from typing import Any + + +SCRIPT_DIR = Path(__file__).resolve().parent +RUN_CHECKS = SCRIPT_DIR / "run-diligence-checks.py" +BUILD_DASHBOARD = SCRIPT_DIR / "build-diligence-dashboard.py" + + +def create_workspace(root: Path, findings: list[dict[str, Any]] | None = None) -> Path: + """Build a minimal valid deal workspace. + + Pass ``findings`` to override the default single-P1 finding so individual + tests can exercise different severity levels. + """ + workspace = root / "deals" / "catalog-sale" + for directory in ["source", "normalized", "workpapers", "findings", "memos"]: + (workspace / directory).mkdir(parents=True, exist_ok=True) + (workspace / "assumptions.yaml").write_text("deal_id: catalog-sale\n", encoding="utf-8") + (workspace / "evidence-ledger.json").write_text( + json.dumps( + { + "entries": [ + { + "evidence_id": "EV-001", + "source_file": "source/royalties.csv", + "source_type": "royalty_statement", + "locator": "row 2", + "extracted_field": "owner_net_amount", + "extracted_value": "85.00", + "confidence": "high", + } + ] + } + ), + encoding="utf-8", + ) + (workspace / "normalized" / "royalty-ledger.csv").write_text( + "ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,rights_type,income_type,gross_amount,owner_net_amount,currency\n" + "RL-000001,isrc:USRC17607839,source/royalties.csv,Distributor,2025-01-01,2025-01-31,master,streaming,100.00,85.00,USD\n", + encoding="utf-8", + ) + if findings is None: + findings = [ + { + "finding_id": "F-001", + "severity": "P1", + "status": "open", + "title": "Missing split sheet for top composition", + "evidence_ids": ["EV-001"], + } + ] + (workspace / "findings" / "findings.json").write_text( + json.dumps({"findings": findings}), + encoding="utf-8", + ) + (workspace / "workpapers" / "valuation-summary.json").write_text( + '{"status": "preliminary"}\n', + encoding="utf-8", + ) + return workspace + + +def run_dashboard(workspace: Path, *extra_args: str) -> subprocess.CompletedProcess[str]: + """Run build-diligence-dashboard.py against a workspace and capture output.""" + output = workspace / "diligence-dashboard.md" + return subprocess.run( + [ + sys.executable, + str(BUILD_DASHBOARD), + str(workspace), + "--output", + str(output), + *extra_args, + ], + check=False, + text=True, + capture_output=True, + ) + + +class DiligenceReadinessTest(unittest.TestCase): + def test_run_checks_reports_all_validator_results(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace(Path(directory)) + + result = subprocess.run( + [sys.executable, str(RUN_CHECKS), str(workspace)], + check=False, + text=True, + capture_output=True, + ) + + self.assertEqual(result.returncode, 0) + payload = json.loads(result.stdout) + self.assertEqual(payload["status"], "ok") + self.assertEqual( + [check["name"] for check in payload["checks"]], + [ + "workspace", + "normalized_ledger", + "evidence_ledger", + "findings_evidence", + "workspace_consistency", + ], + ) + + +class BuildDashboardTest(unittest.TestCase): + """Locks in the fix for the severity-taxonomy / title-field bug. + + Before the fix, the dashboard only treated ``P0``/``P1`` as severe and + looked up ``title``. The rest of the plugin emits findings using the + canonical ``critical|high|medium|low`` taxonomy with an ``issue`` field, + which silently produced ``ready`` even when blockers existed. + """ + + def assert_dashboard_text(self, text: str, *, status: str, must_contain: list[str]) -> None: + self.assertIn("# Diligence Dashboard", text) + self.assertIn(f"Overall status: `{status}`", text) + for fragment in must_contain: + self.assertIn(fragment, text) + + def test_legacy_p1_with_title_renders_review_needed(self) -> None: + """Backwards compatibility: the original P1+title fixture still works.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace(Path(directory)) + result = run_dashboard(workspace) + self.assertEqual(result.returncode, 0, msg=result.stderr) + text = (workspace / "diligence-dashboard.md").read_text(encoding="utf-8") + + self.assert_dashboard_text( + text, + status="review_needed", + must_contain=["Missing split sheet for top composition"], + ) + + def test_canonical_high_severity_with_issue_renders_review_needed(self) -> None: + """Findings emitted by the rest of the plugin (severity=high, issue=...) + must be picked up. This is the regression that the fix addresses.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace( + Path(directory), + findings=[ + { + "finding_id": "RF-001", + "severity": "high", + "category": "rights", + "status": "open", + "issue": "Income-generating work has no split sheet.", + "evidence_ids": ["EV-001"], + } + ], + ) + result = run_dashboard(workspace) + self.assertEqual(result.returncode, 0, msg=result.stderr) + text = (workspace / "diligence-dashboard.md").read_text(encoding="utf-8") + + self.assert_dashboard_text( + text, + status="review_needed", + must_contain=["Income-generating work has no split sheet."], + ) + + def test_canonical_critical_severity_renders_blocked(self) -> None: + """A critical finding must drive the dashboard into ``blocked``.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace( + Path(directory), + findings=[ + { + "finding_id": "RF-002", + "severity": "critical", + "category": "rights", + "status": "open", + "issue": "Catalog-wide assignment is missing.", + "evidence_ids": ["EV-001"], + } + ], + ) + result = run_dashboard(workspace) + self.assertEqual(result.returncode, 0, msg=result.stderr) + text = (workspace / "diligence-dashboard.md").read_text(encoding="utf-8") + + self.assert_dashboard_text( + text, + status="blocked", + must_contain=["Catalog-wide assignment is missing."], + ) + + def test_severity_match_is_case_insensitive(self) -> None: + """``HIGH``, ``High``, ``high`` should all be treated identically.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace( + Path(directory), + findings=[ + { + "finding_id": "F-CASE", + "severity": "HIGH", + "status": "open", + "issue": "Mixed-case severity should still be detected.", + "evidence_ids": ["EV-001"], + } + ], + ) + result = run_dashboard(workspace) + self.assertEqual(result.returncode, 0, msg=result.stderr) + text = (workspace / "diligence-dashboard.md").read_text(encoding="utf-8") + + self.assertIn("Overall status: `review_needed`", text) + + def test_resolved_findings_do_not_block(self) -> None: + """A resolved critical finding should not push the deal back to blocked.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace( + Path(directory), + findings=[ + { + "finding_id": "F-CLOSED", + "severity": "critical", + "status": "resolved", + "issue": "Already cured before close.", + "evidence_ids": ["EV-001"], + } + ], + ) + result = run_dashboard(workspace) + self.assertEqual(result.returncode, 0, msg=result.stderr) + text = (workspace / "diligence-dashboard.md").read_text(encoding="utf-8") + + self.assertIn("Overall status: `ready`", text) + + def test_stdout_payload_reports_deal_status(self) -> None: + """The JSON payload printed to stdout must include the computed deal_status.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace( + Path(directory), + findings=[ + { + "finding_id": "RF-003", + "severity": "critical", + "status": "open", + "issue": "Blocking issue.", + "evidence_ids": ["EV-001"], + } + ], + ) + result = run_dashboard(workspace) + + self.assertEqual(result.returncode, 0, msg=result.stderr) + payload = json.loads(result.stdout) + self.assertEqual(payload["deal_status"], "blocked") + self.assertEqual(payload["open_blockers"], 1) + self.assertEqual(payload["open_review_items"], 0) + + def test_fail_on_blocked_returns_nonzero_exit_when_blocked(self) -> None: + """``--fail-on-blocked`` lets CI / orchestrators enforce the guardrail.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace( + Path(directory), + findings=[ + { + "finding_id": "RF-004", + "severity": "critical", + "status": "open", + "issue": "Stop-the-deal issue.", + "evidence_ids": ["EV-001"], + } + ], + ) + result = run_dashboard(workspace, "--fail-on-blocked") + + self.assertEqual(result.returncode, 1, msg=result.stdout + result.stderr) + + def test_fail_on_blocked_succeeds_when_only_review_items_present(self) -> None: + """``--fail-on-blocked`` must NOT fail on a ``review_needed`` deal.""" + with tempfile.TemporaryDirectory() as directory: + workspace = create_workspace( + Path(directory), + findings=[ + { + "finding_id": "RF-005", + "severity": "high", + "status": "open", + "issue": "High but disclosable.", + "evidence_ids": ["EV-001"], + } + ], + ) + result = run_dashboard(workspace, "--fail-on-blocked") + + self.assertEqual(result.returncode, 0, msg=result.stderr) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-golden-fixtures.py b/plugins/music-catalog-diligence/scripts/test-golden-fixtures.py new file mode 100644 index 0000000..4862147 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-golden-fixtures.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Run golden fixture tests for royalty statement normalization.""" + +from __future__ import annotations + +import csv +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +PLUGIN_ROOT = Path(__file__).resolve().parents[1] +SCRIPT = PLUGIN_ROOT / "scripts" / "normalize-royalty-statement.py" +FIXTURE_ROOT = PLUGIN_ROOT / "fixtures" / "golden" + + +CASES = [ + ("ascap-performance", "ascap"), + ("bmi-performance", "bmi"), + ("mlc-mechanical", "mlc"), + ("distributor-master", "distributor"), + ("publisher-admin", "publisher-admin"), + ("soundexchange", "soundexchange"), + ("direct-sync", "direct-sync"), + ("youtube-content-id", "youtube-content-id"), + ("curve-income", "curve"), +] + + +# Cases where the input deliberately exercises partial-status behavior. The +# expected-status.json file describes the assertions to apply against the +# normalizer's stdout payload. +PARTIAL_CASES = [ + ("bmi-real-headers", "bmi"), +] + + +def read_csv(path: Path) -> list[dict[str, str]]: + with path.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +def run_normalizer(provider: str, input_path: Path, output_path: Path) -> tuple[int, dict]: + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--provider", + provider, + "--input", + str(input_path.relative_to(PLUGIN_ROOT)), + "--output", + str(output_path), + ], + cwd=PLUGIN_ROOT, + text=True, + capture_output=True, + ) + payload: dict = {} + if result.stdout.strip(): + try: + payload = json.loads(result.stdout) + except json.JSONDecodeError: + payload = {"_raw_stdout": result.stdout} + return result.returncode, payload + + +class GoldenFixtureTest(unittest.TestCase): + def test_all_golden_fixtures_match_expected_ledgers(self) -> None: + for case_name, provider in CASES: + with self.subTest(case=case_name): + case_dir = FIXTURE_ROOT / case_name + input_path = case_dir / "input.csv" + expected_path = case_dir / "expected-royalty-ledger.csv" + self.assertTrue(input_path.exists(), f"Missing input fixture: {input_path}") + self.assertTrue(expected_path.exists(), f"Missing expected fixture: {expected_path}") + + with tempfile.TemporaryDirectory() as directory: + actual_path = Path(directory) / "actual.csv" + returncode, payload = run_normalizer(provider, input_path, actual_path) + self.assertEqual( + returncode, + 0, + f"{case_name}: expected exit 0 (status=ok), got {returncode}: {payload}", + ) + self.assertEqual(payload.get("status"), "ok", f"{case_name}: {payload}") + self.assertEqual(read_csv(actual_path), read_csv(expected_path)) + + def test_partial_status_fixtures_surface_missing_columns(self) -> None: + for case_name, provider in PARTIAL_CASES: + with self.subTest(case=case_name): + case_dir = FIXTURE_ROOT / case_name + input_path = case_dir / "input.csv" + expected_ledger_path = case_dir / "expected-royalty-ledger.csv" + expected_status_path = case_dir / "expected-status.json" + self.assertTrue(input_path.exists(), f"Missing input fixture: {input_path}") + self.assertTrue(expected_status_path.exists(), f"Missing status spec: {expected_status_path}") + expected_status = json.loads(expected_status_path.read_text(encoding="utf-8")) + + with tempfile.TemporaryDirectory() as directory: + actual_path = Path(directory) / "actual.csv" + returncode, payload = run_normalizer(provider, input_path, actual_path) + expected_returncode = 1 if expected_status["status"] != "ok" else 0 + self.assertEqual(returncode, expected_returncode, f"{case_name}: payload={payload}") + self.assertEqual(payload.get("status"), expected_status["status"], f"{case_name}: {payload}") + self.assertEqual(payload.get("rows"), expected_status["rows"], f"{case_name}: {payload}") + self.assertEqual( + payload.get("field_population_rate"), + expected_status["field_population_rate"], + f"{case_name}: {payload}", + ) + self.assertEqual( + payload.get("headers_seen"), + expected_status["headers_seen"], + f"{case_name}: {payload}", + ) + + warnings = payload.get("warnings") or [] + self.assertTrue(warnings, f"{case_name}: expected warnings, got {payload}") + missing_columns = warnings[0].get("expected_columns_not_found", []) + for required_missing in expected_status["expected_columns_not_found_includes"]: + self.assertIn( + required_missing, + missing_columns, + f"{case_name}: '{required_missing}' should appear in expected_columns_not_found " + f"({missing_columns})", + ) + + if expected_ledger_path.exists(): + self.assertEqual(read_csv(actual_path), read_csv(expected_ledger_path)) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-helpers.py b/plugins/music-catalog-diligence/scripts/test-helpers.py new file mode 100644 index 0000000..9ddd57c --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-helpers.py @@ -0,0 +1,63 @@ +#!/usr/bin/env python3 +"""Tests for scripts/_helpers.py minimal YAML parser.""" + +from __future__ import annotations + +import os +import sys +import unittest +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +import _helpers # noqa: E402 + + +class MinimalYamlParserTest(unittest.TestCase): + def parse(self, text: str) -> dict: + return _helpers._parse_minimal_yaml(text) + + def test_parses_top_level_scalars(self) -> None: + parsed = self.parse("a: 1\nb: \"hello\"\nc: true\nd: null\n") + self.assertEqual(parsed, {"a": 1, "b": "hello", "c": True, "d": None}) + + def test_parses_nested_dict(self) -> None: + parsed = self.parse( + "deal:\n deal_id: \"meridian-2024\"\n currency: USD\nscope:\n preliminary_or_full: preliminary\n" + ) + self.assertEqual(parsed["deal"]["deal_id"], "meridian-2024") + self.assertEqual(parsed["deal"]["currency"], "USD") + self.assertEqual(parsed["scope"]["preliminary_or_full"], "preliminary") + + def test_parses_block_list(self) -> None: + parsed = self.parse( + "scope:\n rights_included:\n - publishing\n - masters\n excluded_assets: []\n" + ) + self.assertEqual(parsed["scope"]["rights_included"], ["publishing", "masters"]) + self.assertEqual(parsed["scope"]["excluded_assets"], []) + + def test_parses_top_level_block_list(self) -> None: + parsed = self.parse("notes:\n - \"first note\"\n - \"second note\"\n") + self.assertEqual(parsed["notes"], ["first note", "second note"]) + + def test_parses_real_assumptions_template(self) -> None: + plugin_root = Path(__file__).resolve().parents[1] + path = plugin_root / "templates" / "deal-workspace" / "assumptions.yaml" + parsed = _helpers.load_yaml(path) + self.assertEqual(parsed["materiality"]["concentration_threshold_percent"], 40) + self.assertEqual(parsed["scope"]["rights_included"], ["publishing", "masters"]) + self.assertEqual(parsed["scope"]["excluded_assets"], []) + + def test_deep_get_returns_default_on_miss(self) -> None: + parsed = {"a": {"b": {"c": 1}}} + self.assertEqual(_helpers.deep_get(parsed, "a.b.c"), 1) + self.assertEqual(_helpers.deep_get(parsed, "a.b.missing", default="x"), "x") + self.assertIsNone(_helpers.deep_get(parsed, "missing.path")) + + def test_handles_unicode_em_dash_in_strings(self) -> None: + text = "scope:\n excluded_assets:\n - \"Coyote Moon Blues (1978) — chain disputed\"\n" + parsed = self.parse(text) + self.assertEqual(parsed["scope"]["excluded_assets"][0], "Coyote Moon Blues (1978) — chain disputed") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-normalize-royalty-statement.py b/plugins/music-catalog-diligence/scripts/test-normalize-royalty-statement.py new file mode 100644 index 0000000..ca1f316 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-normalize-royalty-statement.py @@ -0,0 +1,233 @@ +#!/usr/bin/env python3 +"""Golden-fixture tests for provider CSV royalty normalization.""" + +from __future__ import annotations + +import csv +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("normalize-royalty-statement.py") + + +def write_csv(path: Path, rows: list[dict[str, str]], delimiter: str = ",") -> None: + if not rows: + raise ValueError("write_csv requires at least one row to derive column headers") + with path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0].keys()), delimiter=delimiter) + writer.writeheader() + writer.writerows(rows) + + +def read_csv(path: Path) -> list[dict[str, str]]: + with path.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +class NormalizeRoyaltyStatementTest(unittest.TestCase): + def _invoke( + self, + provider: str, + rows: list[dict[str, str]], + *, + column_map: dict[str, str] | None = None, + delimiter: str = ",", + delimiter_arg: str | None = None, + ) -> tuple[list[dict[str, str]], dict, int]: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / ("source.tsv" if delimiter == "\t" else "source.csv") + output = Path(directory) / "royalty-ledger.csv" + write_csv(source, rows, delimiter=delimiter) + argv = [ + sys.executable, + str(SCRIPT), + "--provider", + provider, + "--input", + str(source), + "--output", + str(output), + ] + if column_map is not None: + map_path = Path(directory) / "column-map.json" + map_path.write_text(json.dumps(column_map), encoding="utf-8") + argv += ["--column-map", str(map_path)] + if delimiter_arg is not None: + argv += ["--delimiter", delimiter_arg] + result = subprocess.run(argv, text=True, capture_output=True) + payload = json.loads(result.stdout) if result.stdout.strip() else {} + ledger_rows = read_csv(output) if output.exists() else [] + return ledger_rows, payload, result.returncode + + def run_normalizer(self, provider: str, rows: list[dict[str, str]]) -> list[dict[str, str]]: + ledger, payload, code = self._invoke(provider, rows) + self.assertEqual(code, 0, f"normalizer exited {code}: payload={payload}") + self.assertEqual(payload.get("status"), "ok", f"unexpected status: {payload}") + return ledger + + def test_normalizes_ascap_performance_rows_with_bonus_detail(self) -> None: + rows = [ + { + "Work Title": "Forever Hook", + "Work ID": "ASC123", + "ISWC": "T1234567890", + "Performance Start": "2025-01-01", + "Performance End": "2025-03-31", + "Distribution Date": "2025-06-15", + "Territory": "US", + "Licensee": "WABC-FM", + "Use Type": "Feature", + "Credits": "125.5", + "Bonus Type": "Audio Feature Premium", + "Gross Amount": "$1,250.00", + "Deductions": "$125.00", + "Publisher Share": "$1,125.00", + "Currency": "USD", + } + ] + + ledger = self.run_normalizer("ascap", rows) + + self.assertEqual(len(ledger), 1) + row = ledger[0] + self.assertEqual(row["provider"], "ASCAP") + self.assertEqual(row["rights_type"], "publishing") + self.assertEqual(row["income_type"], "performance") + self.assertEqual(row["catalog_asset_id"], "iswc:T1234567890") + self.assertEqual(row["platform_or_licensee"], "WABC-FM") + self.assertEqual(row["gross_amount"], "1250.00") + self.assertEqual(row["deductions"], "125.00") + self.assertEqual(row["owner_net_amount"], "1125.00") + self.assertEqual(row["pro_use_type"], "Feature") + self.assertEqual(row["pro_credits"], "125.5") + self.assertEqual(row["pro_bonus_type"], "Audio Feature Premium") + + def test_normalizes_distributor_master_rows(self) -> None: + rows = [ + { + "Track Title": "Night Drive", + "ISRC": "USRC17607839", + "Sales Month": "2025-02", + "Store": "Spotify", + "Territory": "GB", + "Gross": "500.00", + "Fee": "75.00", + "Net": "425.00", + "Currency": "USD", + } + ] + + ledger = self.run_normalizer("distributor", rows) + + self.assertEqual(len(ledger), 1) + row = ledger[0] + self.assertEqual(row["provider"], "Distributor") + self.assertEqual(row["rights_type"], "master") + self.assertEqual(row["income_type"], "streaming") + self.assertEqual(row["catalog_asset_id"], "isrc:USRC17607839") + self.assertEqual(row["period_start"], "2025-02-01") + self.assertEqual(row["period_end"], "2025-02-28") + self.assertEqual(row["platform_or_licensee"], "Spotify") + self.assertEqual(row["owner_net_amount"], "425.00") + + def test_rejects_unknown_provider(self) -> None: + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source.csv" + output = Path(directory) / "royalty-ledger.csv" + write_csv(source, [{"Title": "Unknown", "Amount": "10"}]) + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--provider", + "not-a-provider", + "--input", + str(source), + "--output", + str(output), + ], + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("Unsupported provider", result.stderr) + + def test_returns_partial_status_when_financial_columns_unknown(self) -> None: + rows = [ + {"title": "Letter from Hattiesburg", "writer": "Marvin Briggs", "iswc": "T-901112322-8", "share_pct": "50", "net_royalty_usd": "186.49"}, + {"title": "Don't Stop Now", "writer": "Mari Vega", "iswc": "T-922001124-7", "share_pct": "75", "net_royalty_usd": "356.25"}, + ] + ledger, payload, code = self._invoke("bmi", rows) + self.assertEqual(code, 1, f"expected exit 1 for partial, got {code}: {payload}") + self.assertEqual(payload["status"], "partial") + self.assertEqual(payload["rows"], 2) + self.assertLess(payload["field_population_rate"], 0.5) + warnings = payload.get("warnings", []) + self.assertEqual(len(warnings), 1) + missing = warnings[0]["expected_columns_not_found"] + for required in ["Statement Qtr", "Period Start", "Period End", "Publisher Net", "Net", "Amount", "Gross Amount", "Amount Paid"]: + self.assertIn(required, missing, f"'{required}' should be flagged missing; got {missing}") + self.assertEqual(len(ledger), 2) + self.assertEqual(ledger[0]["catalog_asset_id"], "iswc:T-901112322-8") + self.assertEqual(ledger[0]["gross_amount"], "") + self.assertEqual(ledger[0]["owner_net_amount"], "") + + def test_column_map_fills_missing_canonical_fields(self) -> None: + rows = [ + {"title": "Letter from Hattiesburg", "writer": "Marvin Briggs", "iswc": "T-901112322-8", "share_pct": "50", "net_royalty_usd": "186.49"}, + {"title": "Don't Stop Now", "writer": "Mari Vega", "iswc": "T-922001124-7", "share_pct": "75", "net_royalty_usd": "356.25"}, + ] + column_map = {"net_royalty_usd": "owner_net_amount", "share_pct": "participant_share"} + ledger, payload, _ = self._invoke("bmi", rows, column_map=column_map) + self.assertEqual(ledger[0]["owner_net_amount"], "186.49") + self.assertEqual(ledger[1]["owner_net_amount"], "356.25") + self.assertEqual(ledger[0]["participant_share"], "50") + self.assertGreater(payload["field_population_rate"], 0) + + def test_column_map_rejects_non_canonical_target(self) -> None: + rows = [{"title": "x", "iswc": "T1", "amount_usd": "10"}] + with tempfile.TemporaryDirectory() as directory: + source = Path(directory) / "source.csv" + output = Path(directory) / "out.csv" + map_path = Path(directory) / "map.json" + write_csv(source, rows) + map_path.write_text(json.dumps({"amount_usd": "not_a_field"}), encoding="utf-8") + result = subprocess.run( + [ + sys.executable, + str(SCRIPT), + "--provider", + "bmi", + "--input", + str(source), + "--output", + str(output), + "--column-map", + str(map_path), + ], + text=True, + capture_output=True, + ) + self.assertNotEqual(result.returncode, 0) + self.assertIn("not a canonical ledger column", result.stderr) + + def test_auto_sniffs_tsv_input(self) -> None: + rows = [ + {"Song Title": "Night Drive", "ISWC": "T9999999990", "Period Start": "2024-01-01", "Period End": "2024-01-31", "Mechanical Gross": "100.00", "Publisher Net": "85.00", "Currency": "USD"}, + ] + ledger, payload, code = self._invoke("mlc", rows, delimiter="\t") + self.assertEqual(code, 0, f"unexpected exit: payload={payload}") + self.assertEqual(payload["status"], "ok") + self.assertEqual(len(ledger), 1) + self.assertEqual(ledger[0]["gross_amount"], "100.00") + self.assertEqual(ledger[0]["owner_net_amount"], "85.00") + self.assertEqual(ledger[0]["period_start"], "2024-01-01") + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-validate-deal-workspace.py b/plugins/music-catalog-diligence/scripts/test-validate-deal-workspace.py new file mode 100644 index 0000000..f4859b6 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-validate-deal-workspace.py @@ -0,0 +1,72 @@ +#!/usr/bin/env python3 +"""Tests for deal workspace readiness validation.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("validate-deal-workspace.py") + + +def create_base_workspace(root: Path) -> Path: + workspace = root / "deals" / "catalog-sale" + for directory in ["source", "normalized", "workpapers", "findings", "memos"]: + (workspace / directory).mkdir(parents=True, exist_ok=True) + (workspace / "assumptions.yaml").write_text("deal_id: catalog-sale\n", encoding="utf-8") + (workspace / "evidence-ledger.json").write_text('{"entries": []}\n', encoding="utf-8") + return workspace + + +class ValidateDealWorkspaceTest(unittest.TestCase): + def run_validator(self, workspace: Path) -> tuple[int, dict[str, object]]: + result = subprocess.run( + [sys.executable, str(SCRIPT), str(workspace)], + check=False, + text=True, + capture_output=True, + ) + return result.returncode, json.loads(result.stdout) + + def test_fails_when_core_artifacts_are_missing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = create_base_workspace(Path(directory)) + + returncode, payload = self.run_validator(workspace) + + self.assertEqual(returncode, 1) + self.assertEqual(payload["status"], "missing_requirements") + self.assertIn("normalized/royalty-ledger.csv", payload["missing_artifacts"]) + self.assertIn("findings/findings.json", payload["missing_artifacts"]) + + def test_passes_when_core_artifacts_are_present(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = create_base_workspace(Path(directory)) + (workspace / "normalized" / "royalty-ledger.csv").write_text( + "ledger_line_id,catalog_asset_id,source_file,provider,period_start,period_end,rights_type,income_type,gross_amount,owner_net_amount,currency\n" + "RL-000001,isrc:USRC17607839,source.csv,Distributor,2025-01-01,2025-01-31,master,streaming,100.00,85.00,USD\n", + encoding="utf-8", + ) + (workspace / "findings" / "findings.json").write_text( + '{"findings": []}\n', + encoding="utf-8", + ) + (workspace / "workpapers" / "valuation-summary.json").write_text( + '{"status": "preliminary"}\n', + encoding="utf-8", + ) + + returncode, payload = self.run_validator(workspace) + + self.assertEqual(returncode, 0) + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["missing_artifacts"], []) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-validate-findings-evidence.py b/plugins/music-catalog-diligence/scripts/test-validate-findings-evidence.py new file mode 100644 index 0000000..ff9407d --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-validate-findings-evidence.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +"""Tests for findings-to-evidence traceability validator.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).with_name("validate-findings-evidence.py") + + +def write_workspace(root: Path, ledger_entries: list[dict], findings: list[dict]) -> Path: + workspace = root / "deals" / "test-deal" + (workspace / "findings").mkdir(parents=True, exist_ok=True) + (workspace / "evidence-ledger.json").write_text( + json.dumps({"entries": ledger_entries}), encoding="utf-8" + ) + (workspace / "findings" / "findings.json").write_text( + json.dumps({"findings": findings}), encoding="utf-8" + ) + return workspace + + +def run(workspace: Path) -> tuple[int, dict]: + result = subprocess.run( + [sys.executable, str(SCRIPT), str(workspace)], + text=True, + capture_output=True, + ) + payload = json.loads(result.stdout) if result.stdout.strip() else {} + return result.returncode, payload + + +class ValidateFindingsEvidenceTest(unittest.TestCase): + def test_passes_when_all_high_severity_findings_have_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + ledger_entries=[{"evidence_id": "EV-001"}], + findings=[ + {"finding_id": "F-001", "severity": "high", "evidence_ids": ["EV-001"]}, + {"finding_id": "F-002", "severity": "low", "evidence_ids": []}, + ], + ) + code, payload = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["high_severity_without_evidence"], []) + self.assertEqual(payload["broken_references"], []) + + def test_fails_when_high_severity_finding_has_no_evidence(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + ledger_entries=[{"evidence_id": "EV-001"}], + findings=[ + {"finding_id": "F-001", "severity": "high", "evidence_ids": []}, + {"finding_id": "F-002", "severity": "P1", "evidence_ids": []}, + {"finding_id": "F-003", "severity": "medium", "evidence_ids": []}, + {"finding_id": "F-004", "severity": "low", "evidence_ids": []}, + ], + ) + code, payload = run(workspace) + self.assertEqual(code, 1) + self.assertEqual(payload["status"], "errors_found") + self.assertEqual( + sorted(payload["high_severity_without_evidence"]), + ["F-001", "F-002"], + ) + self.assertTrue(payload["errors"], payload) + + def test_fails_on_broken_evidence_reference(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + ledger_entries=[{"evidence_id": "EV-001"}], + findings=[ + { + "finding_id": "F-001", + "severity": "high", + "evidence_ids": ["EV-001", "EV-MISSING"], + } + ], + ) + code, payload = run(workspace) + self.assertEqual(code, 1) + self.assertEqual( + payload["broken_references"], + [{"finding_id": "F-001", "evidence_id": "EV-MISSING"}], + ) + + def test_warns_above_empty_evidence_threshold_without_failing(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + ledger_entries=[{"evidence_id": "EV-001"}], + findings=[ + {"finding_id": "F-001", "severity": "low", "evidence_ids": []}, + {"finding_id": "F-002", "severity": "low", "evidence_ids": []}, + {"finding_id": "F-003", "severity": "medium", "evidence_ids": []}, + {"finding_id": "F-004", "severity": "medium", "evidence_ids": ["EV-001"]}, + ], + ) + code, payload = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(payload["status"], "ok") + self.assertTrue(payload["warnings"], payload) + + def test_ignores_unmodified_template_example(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = write_workspace( + Path(directory), + ledger_entries=[{"evidence_id": "EV-001"}], + findings=[ + { + "finding_id": "F-001", + "severity": "example", + "issue": "Replace this example finding with a real diligence issue.", + "evidence_ids": [], + } + ], + ) + code, payload = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(payload["findings_count"], 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/test-validate-workspace-consistency.py b/plugins/music-catalog-diligence/scripts/test-validate-workspace-consistency.py new file mode 100644 index 0000000..804fc0d --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/test-validate-workspace-consistency.py @@ -0,0 +1,159 @@ +#!/usr/bin/env python3 +"""Tests for validate-workspace-consistency.py.""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).resolve().parent / "validate-workspace-consistency.py" + + +CANONICAL_HEADER = ( + "catalog_asset_id,rights_type,title,alternate_titles,artist,writers," + "publishers,isrc,iswc,upc,ipi_cae,release_title,release_date,territory," + "controlled_share,ownership_confidence,metadata_confidence,source_files,conflicts\n" +) + +RIGHTS_HEADER = ( + "catalog_asset_id,document_file,document_type,party,share_type,reported_share," + "territory,term_start,term_end,restrictions,support_level,notes\n" +) + + +def make_workspace( + root: Path, + *, + excluded_assets: list[str], + catalog_rows: list[str], + rights_rows: list[str] | None = None, + findings: list[dict] | None = None, +) -> Path: + workspace = root / "deals" / "test" + (workspace / "normalized").mkdir(parents=True, exist_ok=True) + (workspace / "findings").mkdir(parents=True, exist_ok=True) + + excl = "\n".join(f" - \"{item}\"" for item in excluded_assets) if excluded_assets else " []" + yaml_text = ( + "scope:\n" + + (" excluded_assets:\n" + excl + "\n" if excluded_assets else " excluded_assets: []\n") + ) + (workspace / "assumptions.yaml").write_text(yaml_text, encoding="utf-8") + + (workspace / "normalized" / "canonical-catalog.csv").write_text( + CANONICAL_HEADER + "".join(catalog_rows), encoding="utf-8" + ) + if rights_rows is not None: + (workspace / "normalized" / "rights-map.csv").write_text( + RIGHTS_HEADER + "".join(rights_rows), encoding="utf-8" + ) + if findings is not None: + (workspace / "findings" / "findings.json").write_text( + json.dumps({"findings": findings}), encoding="utf-8" + ) + return workspace + + +def run(workspace: Path) -> tuple[int, dict]: + result = subprocess.run( + [sys.executable, str(SCRIPT), str(workspace)], + text=True, + capture_output=True, + ) + payload = json.loads(result.stdout) if result.stdout.strip() else {} + return result.returncode, payload + + +def catalog_row(asset_id: str, title: str, share: float, conflicts: str = "") -> str: + return f"{asset_id},publishing,{title},,,,,,,,,,1973,worldwide,{share},,,,\"{conflicts}\"\n" + + +def rights_row(asset_id: str, support: str, notes: str) -> str: + # Header has 12 columns; this row matches: catalog_asset_id, document_file, + # document_type, party, share_type, reported_share, territory, term_start, + # term_end, restrictions, support_level, notes. + return f"{asset_id},,,,,,,,,,{support},\"{notes}\"\n" + + +class WorkspaceConsistencyTest(unittest.TestCase): + def test_passes_when_excluded_asset_has_zero_share(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace( + Path(directory), + excluded_assets=["Coyote Moon Blues (1978) -- chain disputed"], + catalog_rows=[catalog_row("COYOTE_MOON", "Coyote Moon Blues", 0.0)], + ) + code, payload = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(payload["status"], "ok") + self.assertEqual(payload["excluded_asset_violations"], []) + + def test_fails_when_excluded_asset_has_nonzero_share(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace( + Path(directory), + excluded_assets=["Coyote Moon Blues (1978) -- chain disputed"], + catalog_rows=[catalog_row("COYOTE_MOON", "Coyote Moon Blues", 100.0)], + ) + code, payload = run(workspace) + self.assertEqual(code, 1) + self.assertEqual(len(payload["excluded_asset_violations"]), 1) + violation = payload["excluded_asset_violations"][0] + self.assertEqual(violation["catalog_asset_id"], "COYOTE_MOON") + self.assertEqual(violation["controlled_share"], 100.0) + + def test_fails_when_reverted_rights_target_has_nonzero_share(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace( + Path(directory), + excluded_assets=[], + catalog_rows=[catalog_row("PINEWOOD", "Pinewood", 100.0)], + rights_rows=[rights_row("PINEWOOD", "conflicting", "Reverted per termination letter 2022")], + ) + code, payload = run(workspace) + self.assertEqual(code, 1) + self.assertEqual(len(payload["reverted_rights_violations"]), 1) + self.assertEqual(payload["reverted_rights_violations"][0]["catalog_asset_id"], "PINEWOOD") + + def test_warns_when_exclusion_finding_targets_nonzero_share(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace( + Path(directory), + excluded_assets=[], + catalog_rows=[catalog_row("HIGHWAY_PINES", "Highway to the Pines", 100.0)], + findings=[ + { + "finding_id": "F-017", + "severity": "high", + "affected_assets": ["HIGHWAY_PINES"], + "valuation_treatment": "Exclude or apply 100% holdback on Highway to the Pines income.", + "evidence_ids": ["EV-001"], + } + ], + ) + code, payload = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(payload["status"], "ok") + self.assertEqual(len(payload["exclusion_finding_warnings"]), 1) + self.assertEqual(payload["exclusion_finding_warnings"][0]["finding_id"], "F-017") + + def test_unmatched_excluded_label_does_not_fail(self) -> None: + with tempfile.TemporaryDirectory() as directory: + workspace = make_workspace( + Path(directory), + excluded_assets=["Some Song That Doesn't Exist"], + catalog_rows=[catalog_row("REAL_SONG", "Real Song", 100.0)], + ) + code, payload = run(workspace) + self.assertEqual(code, 0) + self.assertEqual(payload["status"], "ok") + self.assertEqual(len(payload["unmatched_excluded_labels"]), 1) + + +if __name__ == "__main__": + unittest.main() diff --git a/plugins/music-catalog-diligence/scripts/validate-deal-workspace.py b/plugins/music-catalog-diligence/scripts/validate-deal-workspace.py new file mode 100644 index 0000000..71382e0 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/validate-deal-workspace.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Validate the expected structure for a music catalog deal workspace.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +REQUIRED_DIRS = ["source", "normalized", "workpapers", "findings", "memos"] +REQUIRED_FILES = ["assumptions.yaml", "evidence-ledger.json"] +REQUIRED_ARTIFACTS = [ + "normalized/royalty-ledger.csv", + "findings/findings.json", +] +RECOMMENDED_ARTIFACTS = [ + "workpapers/valuation-summary.json", +] + + +def validate_json_file(path: Path, label: str) -> list[str]: + if not path.is_file(): + return [] + try: + raw = path.read_text(encoding="utf-8") + except (OSError, UnicodeDecodeError) as error: + return [f"{label} could not be read: {error}"] + try: + json.loads(raw) + except json.JSONDecodeError as error: + return [f"{label} is not valid JSON: {error.msg}"] + return [] + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("deal_workspace", help="Path to deals/{deal-id}") + args = parser.parse_args() + + workspace = Path(args.deal_workspace) + missing_dirs = [name for name in REQUIRED_DIRS if not (workspace / name).is_dir()] + missing_files = [name for name in REQUIRED_FILES if not (workspace / name).is_file()] + missing_artifacts = [ + name for name in REQUIRED_ARTIFACTS if not (workspace / name).is_file() + ] + recommended_missing = [ + name for name in RECOMMENDED_ARTIFACTS if not (workspace / name).is_file() + ] + errors = [] + errors.extend(validate_json_file(workspace / "evidence-ledger.json", "evidence-ledger.json")) + errors.extend(validate_json_file(workspace / "findings" / "findings.json", "findings/findings.json")) + status = "ok" if not missing_dirs and not missing_files and not missing_artifacts and not errors else "missing_requirements" + payload = { + "status": status, + "workspace": str(workspace), + "missing_dirs": missing_dirs, + "missing_files": missing_files, + "missing_artifacts": missing_artifacts, + "recommended_missing": recommended_missing, + "errors": errors, + } + print(json.dumps(payload, indent=2)) + return 0 if status == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/validate-evidence-ledger.py b/plugins/music-catalog-diligence/scripts/validate-evidence-ledger.py new file mode 100644 index 0000000..7a6aa31 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/validate-evidence-ledger.py @@ -0,0 +1,74 @@ +#!/usr/bin/env python3 +"""Validate evidence-ledger.json shape for a catalog deal workspace.""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +REQUIRED_ENTRY_FIELDS = { + "evidence_id", + "source_file", + "source_type", + "locator", + "extracted_field", + "extracted_value", + "confidence", +} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ledger", help="Path to evidence-ledger.json") + args = parser.parse_args() + + path = Path(args.ledger) + data = json.loads(path.read_text(encoding="utf-8")) + + errors: list[str] = [] + + # The top-level shape must be an object before we can call `.get()` on it. + # A bare array, string, or null would otherwise crash the validator with + # an unhelpful AttributeError. + if not isinstance(data, dict): + errors.append( + f"evidence-ledger.json top level must be an object (got {type(data).__name__})" + ) + entries: list[object] = [] + else: + entries = data.get("entries") + if not isinstance(entries, list): + errors.append("`entries` must be a list") + entries = [] + + seen: set[str] = set() + for index, entry in enumerate(entries): + if not isinstance(entry, dict): + errors.append(f"entries[{index}] must be an object") + continue + missing = sorted(REQUIRED_ENTRY_FIELDS - set(entry)) + if missing: + errors.append(f"entries[{index}] missing fields: {', '.join(missing)}") + evidence_id = entry.get("evidence_id") + # Tracking duplicates only makes sense when the id is a string — and the + # field is contractually a string. Flag and skip anything else so + # `seen` never holds heterogeneous types. + if not isinstance(evidence_id, str): + errors.append( + f"entries[{index}].evidence_id must be a string " + f"(got {type(evidence_id).__name__})" + ) + continue + if evidence_id in seen: + errors.append(f"duplicate evidence_id: {evidence_id}") + seen.add(evidence_id) + + status = "ok" if not errors else "errors_found" + print(json.dumps({"status": status, "entries": len(entries), "errors": errors}, indent=2)) + return 0 if status == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/validate-findings-evidence.py b/plugins/music-catalog-diligence/scripts/validate-findings-evidence.py new file mode 100644 index 0000000..64c73ec --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/validate-findings-evidence.py @@ -0,0 +1,137 @@ +#!/usr/bin/env python3 +"""Validate that findings reference real evidence. + +Rules: +- High-severity findings must have at least one evidence_id. +- Every evidence_id referenced by a finding must exist in evidence-ledger.json. +- Warn (do not fail) when more than EMPTY_EVIDENCE_WARN_THRESHOLD of findings + have empty evidence_ids overall. +""" + +from __future__ import annotations + +import argparse +import json +from pathlib import Path + + +HIGH_SEVERITY = {"high", "critical", "p0", "p1"} +EMPTY_EVIDENCE_WARN_THRESHOLD = 0.20 + + +def load_findings(workspace: Path) -> list[dict]: + findings_path = workspace / "findings" / "findings.json" + if not findings_path.is_file(): + return [] + data = json.loads(findings_path.read_text(encoding="utf-8")) + findings = data.get("findings", []) if isinstance(data, dict) else [] + return [item for item in findings if isinstance(item, dict)] + + +def load_evidence_ids(workspace: Path) -> set[str]: + ledger_path = workspace / "evidence-ledger.json" + if not ledger_path.is_file(): + return set() + data = json.loads(ledger_path.read_text(encoding="utf-8")) + entries = data.get("entries", []) if isinstance(data, dict) else [] + return { + entry.get("evidence_id") + for entry in entries + if isinstance(entry, dict) and isinstance(entry.get("evidence_id"), str) + } + + +def is_example_finding(finding: dict) -> bool: + """Treat the unmodified template entry as a no-op rather than a violation.""" + severity = str(finding.get("severity", "")).lower() + issue = str(finding.get("issue", "")).lower() + return severity == "example" or "replace this example" in issue + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("workspace", help="Path to deals/{deal-id}") + args = parser.parse_args() + + workspace = Path(args.workspace) + + findings_path = workspace / "findings" / "findings.json" + if not findings_path.is_file(): + payload = { + "status": "missing_findings_file", + "workspace": str(workspace), + "errors": [f"missing findings file: {findings_path}"], + } + print(json.dumps(payload, indent=2)) + return 1 + + findings = load_findings(workspace) + ledger_ids = load_evidence_ids(workspace) + + high_without_evidence: list[str] = [] + broken_refs: list[dict[str, str]] = [] + empty_count = 0 + real_findings = 0 + + for finding in findings: + if is_example_finding(finding): + continue + real_findings += 1 + finding_id = str(finding.get("finding_id", "?")) + severity = str(finding.get("severity", "")).lower() + evidence_ids = finding.get("evidence_ids") or [] + if not isinstance(evidence_ids, list): + evidence_ids = [] + evidence_ids = [str(item) for item in evidence_ids if item] + + if not evidence_ids: + empty_count += 1 + if severity in HIGH_SEVERITY: + high_without_evidence.append(finding_id) + continue + + for ev_id in evidence_ids: + if ev_id not in ledger_ids: + broken_refs.append({"finding_id": finding_id, "evidence_id": ev_id}) + + errors: list[str] = [] + warnings: list[str] = [] + + if high_without_evidence: + errors.append( + f"{len(high_without_evidence)} high-severity finding(s) have no evidence_ids: " + + ", ".join(high_without_evidence) + ) + + if broken_refs: + formatted = ", ".join(f"{ref['finding_id']}->{ref['evidence_id']}" for ref in broken_refs) + errors.append( + f"{len(broken_refs)} finding(s) reference evidence_ids missing from evidence-ledger.json: " + + formatted + ) + + empty_pct = (empty_count / real_findings) if real_findings else 0.0 + if empty_pct > EMPTY_EVIDENCE_WARN_THRESHOLD: + warnings.append( + f"{empty_pct:.0%} of findings have empty evidence_ids " + f"(warn threshold {int(EMPTY_EVIDENCE_WARN_THRESHOLD * 100)}%)" + ) + + status = "ok" if not errors else "errors_found" + payload = { + "status": status, + "workspace": str(workspace), + "findings_count": real_findings, + "high_severity_without_evidence": high_without_evidence, + "broken_references": broken_refs, + "empty_evidence_count": empty_count, + "empty_evidence_pct": round(empty_pct, 4), + "errors": errors, + "warnings": warnings, + } + print(json.dumps(payload, indent=2)) + return 0 if status == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/validate-normalized-ledger.py b/plugins/music-catalog-diligence/scripts/validate-normalized-ledger.py new file mode 100644 index 0000000..7555e9b --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/validate-normalized-ledger.py @@ -0,0 +1,69 @@ +#!/usr/bin/env python3 +"""Validate required columns in a normalized royalty ledger CSV.""" + +from __future__ import annotations + +import argparse +import csv +import json +from pathlib import Path + + +REQUIRED_COLUMNS = { + "ledger_line_id", + "catalog_asset_id", + "source_file", + "provider", + "period_start", + "period_end", + "rights_type", + "income_type", + "gross_amount", + "owner_net_amount", + "currency", +} + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("ledger_csv", help="Path to royalty-ledger.csv") + args = parser.parse_args() + + path = Path(args.ledger_csv) + with path.open(newline="", encoding="utf-8-sig") as handle: + reader = csv.DictReader(handle) + fieldnames = set(reader.fieldnames or []) + missing = sorted(REQUIRED_COLUMNS - fieldnames) + rows = list(reader) + + errors: list[str] = [] + if missing: + errors.append(f"missing required columns: {', '.join(missing)}") + + duplicate_ids: set[str] = set() + seen_ids: set[str] = set() + blank_count = 0 + for row in rows: + line_id = (row.get("ledger_line_id") or "").strip() + if not line_id: + # A blank ledger_line_id means a row has no stable identifier, + # which breaks downstream joins and traceability. Surface it + # rather than silently accepting unidentified rows. + blank_count += 1 + continue + if line_id in seen_ids: + duplicate_ids.add(line_id) + seen_ids.add(line_id) + + if blank_count: + errors.append(f"{blank_count} row(s) have a blank ledger_line_id") + if duplicate_ids: + errors.append(f"duplicate ledger_line_id values: {', '.join(sorted(duplicate_ids))}") + + status = "ok" if not errors else "errors_found" + print(json.dumps({"status": status, "rows": len(rows), "errors": errors}, indent=2)) + return 0 if status == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/scripts/validate-workspace-consistency.py b/plugins/music-catalog-diligence/scripts/validate-workspace-consistency.py new file mode 100644 index 0000000..875e8c4 --- /dev/null +++ b/plugins/music-catalog-diligence/scripts/validate-workspace-consistency.py @@ -0,0 +1,264 @@ +#!/usr/bin/env python3 +"""Validate cross-artifact consistency in a catalog deal workspace. + +Rules: +1. Every entry in `scope.excluded_assets` of assumptions.yaml is either + absent from canonical-catalog.csv OR present with `controlled_share=0`. +2. Every rights-map.csv row whose `support_level == 'conflicting'` and whose + notes mention reversion/termination corresponds to a canonical-catalog + row with `controlled_share=0`. +3. Every findings.json entry whose `valuation_treatment` says to exclude + has its affected_assets reduced to `controlled_share=0` in the catalog + (warning only — the agent may have a reason to keep the row). + +Returns status='errors_found' on any rule-1 or rule-2 violation; emits a +'warnings' list for rule-3. +""" + +from __future__ import annotations + +import argparse +import csv +import json +import os +import re +import sys +from pathlib import Path + +sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from _helpers import deep_get, load_yaml # noqa: E402 + + +REVERSION_PATTERN = re.compile(r"(?i)(reverted|reversion|terminated|termination|reverts|term[\s_]?end)") +EXCLUSION_PATTERN = re.compile(r"(?i)(exclude|excluded from|do not include|holdback at 100|drop from)") + + +def load_canonical_catalog(workspace: Path) -> list[dict]: + path = workspace / "normalized" / "canonical-catalog.csv" + if not path.is_file(): + return [] + with path.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +def load_rights_map(workspace: Path) -> list[dict]: + path = workspace / "normalized" / "rights-map.csv" + if not path.is_file(): + return [] + with path.open(newline="", encoding="utf-8-sig") as handle: + return list(csv.DictReader(handle)) + + +def load_findings(workspace: Path) -> list[dict]: + path = workspace / "findings" / "findings.json" + if not path.is_file(): + return [] + data = json.loads(path.read_text(encoding="utf-8")) + return [item for item in data.get("findings", []) if isinstance(item, dict)] + + +def parse_share(value: str | None) -> float | None: + if value is None: + return None + text = value.strip().rstrip("%") + if not text: + return None + try: + return float(text) + except ValueError: + return None + + +def normalize_title(text: str) -> str: + return re.sub(r"[^a-z0-9]+", " ", text.lower()).strip() + + +def excluded_asset_label(entry: str) -> str: + """Extract the title-like prefix from an assumptions.yaml exclusion string.""" + text = entry.strip() + # Split on em-dash, en-dash, or " - " — common separators between title and reason. + for sep in (" — ", " – ", " - "): + if sep in text: + text = text.split(sep, 1)[0] + break + # Strip trailing parenthetical year, e.g. "Coyote Moon Blues (1978)" -> "Coyote Moon Blues" + text = re.sub(r"\s*\([^)]*\)\s*$", "", text).strip() + return text + + +def find_catalog_matches(label: str, catalog: list[dict]) -> list[dict]: + target = normalize_title(label) + if not target: + return [] + matches: list[dict] = [] + target_squashed = target.replace(" ", "_") + for row in catalog: + title_norm = normalize_title(row.get("title") or "") + if title_norm == target: + matches.append(row) + continue + asset_id = (row.get("catalog_asset_id") or "").strip().lower() + if asset_id and (asset_id == target_squashed or asset_id == target.replace(" ", "")): + matches.append(row) + continue + # Substring fallback (handles "Coyote Moon Blues" vs catalog_asset_id COYOTE_MOON) + title_squashed = title_norm.replace(" ", "_") + if title_norm and (title_norm.startswith(target) or target.startswith(title_norm)): + matches.append(row) + continue + if asset_id and asset_id in target_squashed: + matches.append(row) + continue + return matches + + +def validate_excluded_assets( + excluded: list[str], + catalog: list[dict], +) -> tuple[list[dict], list[dict]]: + errors: list[dict] = [] + unmatched: list[dict] = [] + for entry in excluded: + if not isinstance(entry, str) or not entry.strip(): + continue + label = excluded_asset_label(entry) + matches = find_catalog_matches(label, catalog) + if not matches: + unmatched.append({"excluded_label": label, "raw": entry}) + continue + for row in matches: + share = parse_share(row.get("controlled_share")) + if share is not None and share != 0: + errors.append( + { + "rule": "excluded_asset_with_nonzero_controlled_share", + "excluded_label": label, + "catalog_asset_id": row.get("catalog_asset_id"), + "title": row.get("title"), + "controlled_share": share, + } + ) + return errors, unmatched + + +def validate_reverted_rights( + rights: list[dict], + catalog: list[dict], +) -> list[dict]: + catalog_by_id = {(row.get("catalog_asset_id") or "").strip(): row for row in catalog} + seen_ids: set[str] = set() + errors: list[dict] = [] + for row in rights: + asset_id = (row.get("catalog_asset_id") or "").strip() + support = (row.get("support_level") or "").strip().lower() + notes = row.get("notes") or "" + if not asset_id: + continue + if support not in {"conflicting", "missing"}: + continue + if not REVERSION_PATTERN.search(notes): + continue + if asset_id in seen_ids: + continue + seen_ids.add(asset_id) + catalog_row = catalog_by_id.get(asset_id) + if catalog_row is None: + continue + share = parse_share(catalog_row.get("controlled_share")) + if share is not None and share != 0: + errors.append( + { + "rule": "reverted_or_terminated_rights_with_nonzero_controlled_share", + "catalog_asset_id": asset_id, + "title": catalog_row.get("title"), + "support_level": support, + "rights_notes": notes, + "controlled_share": share, + } + ) + return errors + + +def validate_excluded_findings( + findings: list[dict], + catalog: list[dict], +) -> list[dict]: + catalog_by_id = {(row.get("catalog_asset_id") or "").strip(): row for row in catalog} + warnings: list[dict] = [] + for finding in findings: + treatment = finding.get("valuation_treatment") or "" + if not EXCLUSION_PATTERN.search(treatment): + continue + affected = finding.get("affected_assets") or [] + if not isinstance(affected, list): + continue + for asset in affected: + asset_id = str(asset).strip() + if not asset_id: + continue + row = catalog_by_id.get(asset_id) + if row is None: + continue + share = parse_share(row.get("controlled_share")) + if share is None or share == 0: + continue + warnings.append( + { + "rule": "excluding_finding_targets_nonzero_share_asset", + "finding_id": finding.get("finding_id"), + "catalog_asset_id": asset_id, + "controlled_share": share, + "valuation_treatment": treatment[:200], + } + ) + return warnings + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("workspace", help="Path to deals/{deal-id}") + args = parser.parse_args() + + workspace = Path(args.workspace) + assumptions_path = workspace / "assumptions.yaml" + catalog = load_canonical_catalog(workspace) + rights = load_rights_map(workspace) + findings = load_findings(workspace) + + excluded: list[str] = [] + if assumptions_path.is_file(): + assumptions = load_yaml(assumptions_path) + raw_excluded = deep_get(assumptions, "scope.excluded_assets") or [] + if isinstance(raw_excluded, list): + excluded = [str(item) for item in raw_excluded if item is not None] + + excluded_errors, unmatched = validate_excluded_assets(excluded, catalog) + rights_errors = validate_reverted_rights(rights, catalog) + finding_warnings = validate_excluded_findings(findings, catalog) + + errors = excluded_errors + rights_errors + warnings = finding_warnings + status = "ok" if not errors else "errors_found" + + payload = { + "status": status, + "workspace": str(workspace), + "excluded_asset_violations": excluded_errors, + "unmatched_excluded_labels": unmatched, + "reverted_rights_violations": rights_errors, + "exclusion_finding_warnings": warnings, + "summary": { + "excluded_in_assumptions": len(excluded), + "catalog_rows": len(catalog), + "rights_rows": len(rights), + "findings": len(findings), + "errors": len(errors), + "warnings": len(warnings), + }, + } + print(json.dumps(payload, indent=2)) + return 0 if status == "ok" else 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/plugins/music-catalog-diligence/skills/catalog-analysis/SKILL.md b/plugins/music-catalog-diligence/skills/catalog-analysis/SKILL.md new file mode 100644 index 0000000..303f566 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-analysis/SKILL.md @@ -0,0 +1,131 @@ +--- +name: catalog-analysis +description: Use when analyzing or valuing a music catalog for acquisition, financing, sale, or investment diligence. Triggers include "catalog valuation", "music catalog analysis", "project catalog value", "what is this catalog worth", "NPS multiple", "NLS multiple", "quality of earnings", "royalty cash flows", "catalog acquisition", "recoupment cliff", "PRO bonus", "normalize royalties", or "build an investment memo" for publishing or master rights. +--- + +# Catalog Analysis + +Analyze normalized music catalog cash flows and project value. Do not apply a +multiple to headline royalties until you understand what income is sustainable, +what rights are supported, and what risks change the buyer's actual cash flow. + +## Decision tree + +Start here based on the user request: + +- **Clean ingest package exists** -> validate the canonical catalog, royalty + ledger, rights map, and source lineage, then analyze. +- **Only raw data-room files exist** -> ingest first. Valuation before ingest is + unreliable. +- **Publishing catalog** -> analyze Net Publisher Share (`NPS`). +- **Master catalog** -> analyze Net Label Share (`NLS`), recoupment, reserves, + artist royalties, producer points, and distribution fees. +- **Mixed rights catalog** -> segment publishing and master income separately, + then reconcile to a blended view. +- **Quick value estimate requested** -> give a clearly labeled diagnostic range + and list missing diligence. Do not present it as a full valuation. + +If inputs are too incomplete, say what cannot be valued yet and list the minimum +files needed. + +## Core workflow + +1. **Validate inputs.** Confirm the catalog table, royalty ledger, rights map, + and source lineage are present or explain what is missing. +2. **Define the asset.** Separate publishing, masters, neighboring rights, sync, + or mixed rights. State whether you are calculating NPS or NLS. +3. **Compute raw earnings.** Calculate LTM/TTM, three-year average, and trend by + source, song, artist, platform, territory, and period. +4. **Analyze concentration.** Show dependence on top songs, artists, platforms, + territories, rights types, and income sources. +5. **Normalize run-rate.** Bridge raw LTM to sustainable NPS/NLS by removing or + haircutting non-recurring or fragile income. +6. **Check rights and cash-flow support.** Tie meaningful income to ownership, + contracts, registrations, recoupment, reserves, restrictions, and + transferability. +7. **Segment risk.** Separate evergreen, recent, sync-heavy, bonus-driven, + one-stop, fragmented, supported, and unsupported assets. +8. **Project scenarios.** Build downside, base, and upside cash flows using + explicit growth/decay, recoupment, reserve, and rights assumptions. +9. **Value the catalog.** Use market multiples and/or DCF, then reconcile to a + supportable range. +10. **Write the memo.** Lead with value range, normalized cash flow, key risks, + missing diligence, and recommended holdbacks or haircuts. + +Run deterministic scripts from `scripts/` when the required files exist: + +- `calculate-concentration.py` for song, platform, territory, and source risk. +- `calculate-nps-nls-bridge.py` for reported-to-normalized cash-flow bridges. +- `validate-evidence-ledger.py` before relying on memo citations. + +## Required bridge + +Always show a bridge from reported royalties to underwritable earnings: + +```text +Reported LTM NPS/NLS + - one-time sync and direct-license spikes + - viral, playlist, death-bump, or campaign spikes + - settlement, audit recovery, and accounting catch-up income + - non-repeat PRO bonus or premium income + +/- current run-rate trend adjustment + +/- admin, collection, reserve, recoupment, or contract adjustment + +/- undercollection or missing-registration adjustment += Normalized run-rate NPS/NLS +``` + +Use real source amounts when available. If you must illustrate, label the number +as illustrative and do not mix it into the final valuation. + +## Critical gotchas + +- **Headline `NPS x multiple` can be wrong.** Multiples apply to sustainable + underwritable earnings, not whatever happened in the last twelve months. +- **PRO income is not one number.** Decompose by PRO/society, use type, format, + territory, credits, bonus/premium participation, and cue-sheet support when + available. +- **Bonus/premium royalties can be fragile.** A song that earned from a + threshold-driven radio or TV bonus may not repeat that income. +- **Sync is usually upside, not baseline.** One-time sync fees should be + excluded, averaged, or heavily haircut unless a repeatable library pattern is + proven. +- **Master gross is not NLS.** Artist royalties, producer points, distributor + fees, reserves, and recoupment determine the owner's actual cash flow. +- **Unrecouped accounts can flip.** A catalog may look more profitable before + artists recoup. Model the post-recoupment margin. +- **Track count is not diversification.** A 500-track catalog with one income + driver is concentrated. +- **Unsupported income is risky.** If the rights map cannot support meaningful + income, separate financial performance from transferable value. +- **Do not fabricate assumptions.** Unknown decay, split, recoupment, reserve, + or reversion data must stay visible as missing diligence. + +## Outputs + +Use these outputs for a full analysis: + +- Executive valuation summary. +- Quality-of-earnings bridge. +- Revenue mix and concentration analysis. +- Rights-support and diligence exceptions. +- Normalized NPS/NLS calculation. +- Downside/base/upside cash-flow scenarios. +- Market-multiple valuation. +- DCF valuation, when enough data exists. +- Sensitivity table. +- Risk register. +- Recommended holdback, escrow, haircut, or follow-up diligence. + +Templates and memo structure are in +**[references/output-templates.md](references/output-templates.md)**. + +## What to read next + +- For valuation mechanics, read + **[references/valuation-framework.md](references/valuation-framework.md)**. +- For performance royalty traps, read + **[../../references/pro-performance-income.md](../../references/pro-performance-income.md)**. +- For memo and table formats, read + **[references/output-templates.md](references/output-templates.md)**. +- For workspace-level evidence rules, read + **[../../references/deal-workspace.md](../../references/deal-workspace.md)**. diff --git a/plugins/music-catalog-diligence/skills/catalog-analysis/evals/evals.json b/plugins/music-catalog-diligence/skills/catalog-analysis/evals/evals.json new file mode 100644 index 0000000..78832c7 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-analysis/evals/evals.json @@ -0,0 +1,23 @@ +{ + "skill_name": "catalog-analysis", + "evals": [ + { + "id": "pro-bonus-spike", + "prompt": "This publishing catalog did $1.2M of LTM NPS, so at a 15x multiple it should be worth $18M. Most of the jump came from ASCAP/BMI performance royalties tied to one big TV/radio quarter. Build the valuation.", + "expected_output": "The agent challenges the flat multiple, decomposes performance income, separates base and bonus/premium or one-time income, normalizes run-rate, shows a bridge from raw LTM to sustainable NPS, and gives a risk-adjusted valuation range.", + "files": [] + }, + { + "id": "recoupment-cliff", + "prompt": "This master catalog has strong gross streaming revenue, but most artists are currently unrecouped and several accounts are close to recouping. Project the value.", + "expected_output": "The agent models NLS rather than gross receipts, identifies recoupment as a timing/cash-flow issue, shows pre- and post-recoupment cash-flow scenarios, and flags post-close artist royalty obligations.", + "files": [] + }, + { + "id": "concentration-risk", + "prompt": "The catalog has 500 tracks, but 72% of net income comes from one song and 80% of that song's income comes from Spotify. What is it worth?", + "expected_output": "The agent performs concentration analysis, applies caveats or haircuts, uses downside/base/upside scenarios, avoids treating track count as diversification, and explains platform and song-level risk.", + "files": [] + } + ] +} diff --git a/plugins/music-catalog-diligence/skills/catalog-analysis/references/output-templates.md b/plugins/music-catalog-diligence/skills/catalog-analysis/references/output-templates.md new file mode 100644 index 0000000..e55df01 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-analysis/references/output-templates.md @@ -0,0 +1,127 @@ +# Output templates + +Use these templates for catalog analysis deliverables. + +## Executive valuation summary + +```markdown +## Executive summary + +**Preliminary value range:** $[low] - $[high] +**Base case:** $[base] +**Implied multiple:** [x] normalized NPS/NLS +**Rights analyzed:** [publishing / masters / mixed] +**Data confidence:** [high / medium / low] + +The catalog generated $[reported] of reported LTM [NPS/NLS]. After normalizing +for [key adjustments], underwritable run-rate [NPS/NLS] is $[normalized]. + +Key value drivers: +- [Driver 1] +- [Driver 2] +- [Driver 3] + +Key risks: +- [Risk 1] +- [Risk 2] +- [Risk 3] + +Required diligence before final valuation: +- [Missing item 1] +- [Missing item 2] +``` + +## Quality-of-earnings bridge + +This bridge is the canonical adjustment ladder defined in `SKILL.md`. Use every +line every time so you do not silently understate normalized run-rate by skipping +an adjustment that does apply. + +```text +Reported LTM [NPS/NLS] $x + Less: one-time sync and direct-license spikes (x) + Less: viral, playlist, death-bump, or campaign spikes (x) + Less: settlement, audit recovery, accounting catch-up (x) + Less: non-repeat PRO bonus or premium income (x) + Plus/minus: current run-rate trend adjustment x + Plus/minus: admin, collection, reserve, recoupment, + or contract adjustment x + Plus/minus: undercollection or missing-registration + adjustment x + Less: unsupported rights haircut (x) +Normalized run-rate [NPS/NLS] $x +``` + +Every adjustment needs a source, formula, or note. If an adjustment is judgment, +label it as judgment. + +## Revenue mix table + +| Source | LTM amount | % of total | 3-year trend | Normalization treatment | +| --- | ---: | ---: | --- | --- | +| Spotify | `$` | `%` | Growing/flat/declining | Include/haircut | +| PRO performance | `$` | `%` | Growing/flat/declining | Decompose | +| Sync | `$` | `%` | Lumpy | Exclude/smooth | +| YouTube Content ID | `$` | `%` | Growing/flat/declining | Include/haircut | + +## Concentration table + +| Dimension | Top exposure | % of net income | Valuation implication | +| --- | --- | ---: | --- | +| Song | `[title]` | `%` | [discount/caveat] | +| Artist | `[artist]` | `%` | [discount/caveat] | +| Platform | `[platform]` | `%` | [platform risk] | +| Territory | `[territory]` | `%` | [territory risk] | +| Rights type | `[publishing/master]` | `%` | [segmentation note] | + +## Scenario table + +Scenarios bundle several assumption shifts into a single narrative case +(downside / base / upside). They answer "what is the catalog worth if our whole +story is right or wrong?" + +| Case | Normalized run-rate | Growth/decay | Multiple/DCF note | Value | +| --- | ---: | ---: | --- | ---: | +| Downside | `$` | `%` | [assumption] | `$` | +| Base | `$` | `%` | [assumption] | `$` | +| Upside | `$` | `%` | [assumption] | `$` | + +## Sensitivity table + +Sensitivities isolate a single assumption and walk it across a range. They +answer "how much does value move when this one input moves?" Include at least +one sensitivity over the multiple (for market-multiple valuations) or the +discount rate (for DCF), and one over normalized run-rate. + +| Input | Range | Resulting value | Notes | +| --- | --- | ---: | --- | +| Multiple | `[low]x` / `[base]x` / `[high]x` | `$` / `$` / `$` | Holds normalized run-rate at base. | +| Discount rate | `[low]%` / `[base]%` / `[high]%` | `$` / `$` / `$` | DCF only. Holds growth/decay at base. | +| Normalized run-rate | `-[Δ]%` / base / `+[Δ]%` | `$` / `$` / `$` | Holds multiple/discount rate at base. | +| Decay rate | `[base]%` / `[stress]%` | `$` / `$` | If catalog has clear decay curve. | + +Pair the sensitivity table with the scenario table in the deliverable. Scenarios +without sensitivities hide which assumption is doing the work. + +## Risk register + +| Risk | Severity | Evidence | Valuation treatment | Follow-up | +| --- | --- | --- | --- | --- | +| Missing split sheets | High | 17 songs unsupported | Holdback/haircut | Request signed splits | +| PRO bonus spike | Medium | One quarter drove 40% of NPS | Normalize | Get statement detail | +| Recoupment cliff | High | Artist accounts near recoupment | Model NLS step-down | Request recoupment schedule | + +## Stop conditions + +Use a "cannot value yet" response when: + +- No reliable royalty history exists. +- Rights type is unclear. +- Reported income cannot be tied to assets. +- Meaningful income lacks ownership support. +- Gross revenue cannot be converted to NPS/NLS. +- Recoupment, reserves, or participant shares are missing for a master catalog. +- The user asks for final valuation but only provides screenshots or summaries. + +When stopping, list the minimum files needed and offer a preliminary diagnostic +only if useful. diff --git a/plugins/music-catalog-diligence/skills/catalog-analysis/references/valuation-framework.md b/plugins/music-catalog-diligence/skills/catalog-analysis/references/valuation-framework.md new file mode 100644 index 0000000..8f3e011 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-analysis/references/valuation-framework.md @@ -0,0 +1,110 @@ +# Valuation framework + +Use this framework to move from royalty data to a supportable value range. + +## Define the asset first + +Before calculating value, state: + +- Rights being valued: publishing, masters, neighboring rights, sync rights, or + mixed rights. +- Share being acquired: controlled share, income share, territory, and term. +- Income metric: `NPS` for publishing, `NLS` for masters. +- Currency and reporting period. +- Whether the analysis is preliminary or full diligence. + +## Earnings metrics + +Calculate these before choosing a multiple: + +| Metric | Why it matters | +| --- | --- | +| LTM/TTM earnings | Recent, verifiable baseline. | +| Three-year average | Smooths one-year spikes and timing issues. | +| CAGR | Shows growth or decay trend. | +| Latest-quarter run-rate | Catches recent deterioration or improvement. | +| Normalized run-rate | Best estimate of sustainable annual cash flow. | +| Source concentration | Shows dependence on platforms, PROs, sync, or territory. | +| Song concentration | Shows whether the catalog is diversified or one-song risk. | + +## Normalization adjustments + +Common adjustments include: + +- Remove one-time sync fees. +- Remove settlement receipts and audit recoveries. +- Remove or smooth accounting catch-ups and retroactive payments. +- Haircut viral TikTok, playlist, release-campaign, or death-bump spikes. +- Separate PRO bonus/premium payments from base current activity. +- Adjust for current quarter trend when LTM is stale. +- Adjust for known admin-fee changes. +- Adjust for undercollection from missing registrations or territories. +- Adjust for reserves, returns, chargebacks, and payable balances. +- Adjust master income for future artist royalties after recoupment. +- Discount unsupported income where rights documents are missing. + +## Scenario structure + +Build at least three cases. + +| Case | Use when | +| --- | --- | +| Downside | Data quality is weak, concentration is high, or recent trends are negative. | +| Base | Supported normalized run-rate with realistic decay/growth. | +| Upside | Active-management, sync, registration cleanup, or collection upside is plausible but not guaranteed. | + +Keep active-management upside separate from baseline value. A buyer should know +what they are paying for today versus what they might create later. + +## Multiples + +Use multiples only after normalization. + +Typical directional ranges from public research: + +- Recent or viral catalogs: lower multiples because decay is uncertain. +- Stable established catalogs: mid-range multiples when source mix and history + are diversified. +- Evergreen, culturally durable catalogs: higher multiples when income is + stable and rights are clean. +- Sync-heavy catalogs: often require lower or adjusted multiples because income + is lumpy. +- Master catalogs: often require separate NLS analysis because artist + royalties, recoupment, reserves, and distributor economics drive net cash. + +Do not present a multiple range without caveats. Multiples are market shorthand, +not proof of value. + +## DCF + +Use DCF when the data supports explicit assumptions. + +Minimum inputs: + +- Normalized current cash flow. +- Decay or growth by segment. +- Rights term and reversion/termination assumptions. +- Discount rate. +- Admin, collection, and participant economics. +- Reserve and recoupment assumptions. +- Terminal or long-tail treatment. + +If these are missing, say the DCF is illustrative or defer it. + +## Risk haircuts + +Apply or recommend haircuts for: + +- Unsupported ownership. +- Missing registrations. +- Sample, interpolation, or clearance issues. +- Reversion or termination risk. +- Pending audits or disputes. +- High song, platform, territory, or source concentration. +- Non-repeat PRO bonus/premium income. +- Unclear recoupment or reserve schedules. +- Non-one-stop sync rights. +- Re-recording/version risk for masters. + +Explain whether a haircut reduces value, requires escrow/holdback, or blocks the +deal until diligence is cured. diff --git a/plugins/music-catalog-diligence/skills/catalog-ingest/SKILL.md b/plugins/music-catalog-diligence/skills/catalog-ingest/SKILL.md new file mode 100644 index 0000000..73a391a --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-ingest/SKILL.md @@ -0,0 +1,134 @@ +--- +name: catalog-ingest +description: Use when ingesting a music catalog data room, royalty statements, rights files, metadata exports, or messy catalog spreadsheets for diligence. Triggers include "catalog data room", "clean royalty statements", "normalize royalties", "music rights diligence", "catalog ingest", "merge catalog metadata", "prepare this catalog for valuation", or seller files containing ISRCs, ISWCs, splits, PRO statements, distributor reports, contracts, recoupment schedules, or YouTube Content ID reports. +--- + +# Catalog Ingest + +Turn messy music catalog data rooms into auditable hand-off artifacts for +diligence and valuation. The job is not to value the catalog. The job is to make +the data trustworthy enough that valuation can happen. + +## Decision tree + +Start here based on what the user gives you: + +- **Full data room** -> inventory files, classify sources, then build the ingest + package. +- **Single royalty statement** -> parse it into normalized ledger rows and + preserve source lineage. +- **Conflicting catalog spreadsheets** -> reconcile into a canonical catalog + table with confidence scores and unresolved conflicts. +- **Royalty income without rights files** -> keep the income, but mark ownership + support as missing. +- **User asks for valuation immediately** -> first verify whether canonical + ingest artifacts exist. If not, ingest before valuation. + +If the user only wants lightweight cleanup, still preserve raw files and source +columns. Never silently overwrite source data. + +## Core workflow + +1. **Preserve raw files.** Do not edit source exports in place. +2. **Confirm the deal workspace.** Use the convention in + `references/deal-workspace.md` when no workspace exists. +3. **Inventory the data room.** Build a file map with path, source, file type, + period, currency, likely rights type, and confidence. +4. **Classify each source.** Use financial, legal, metadata, asset, analytics, + or unknown. +5. **Profile spreadsheets before cleaning.** Check grain, keys, null rates, + duplicates, date ranges, mixed types, identifier formats, and split totals. +6. **Propose fixes before destructive cleanup.** Show the issue, count, and + planned fix. Ask before removing rows, overwriting values, or deduplicating. +7. **Build the canonical catalog table.** Match recordings and compositions + using ISRC, ISWC, title, writer, artist, release, and alternate-title clues. +8. **Normalize royalty statements.** Convert every statement into ledger rows + with source, period, asset, right type, territory, gross, deductions, + participant shares, owner net, and source lineage. For supported CSV exports, + use `scripts/normalize-royalty-statement.py`; see + `references/normalization.md`. +9. **Build the rights map.** Link songs and recordings to contracts, + registrations, splits, controlled shares, restrictions, and missing support. +10. **Reconcile and flag exceptions.** Separate financial facts from rights + certainty. A royalty line proves money was reported; it does not prove clean + transferable ownership. +11. **Export the ingest package.** Include canonical tables, source lineage, + data-quality notes, and missing-file tracker. +12. **Build the manual-review queue and coverage summary.** Run + `scripts/build-manual-review-queue.py` and report the resulting + `summary_line` (e.g. "29 of 88 files contributed financial data; + 50 require manual review"). +13. **Scan for hygiene leaks.** Run `scripts/dataroom-hygiene-scan.py` and + merge any high-strength matches into `findings/findings.json`. +14. **Compute concentration.** Run `scripts/calculate-concentration.py` + with `--assumptions` to evaluate the configured threshold; merge any + auto-emitted finding into `findings.json`. + +## Output contract + +Use these artifacts unless the user asks for a different structure: + +| Artifact | Purpose | +| --- | --- | +| `workpapers/file-manifest.json` | Classified manifest (parse_status, likely_provider, period, currency, rights-type hint) per source file. | +| `workpapers/ingest-coverage.json` | Coverage summary (`X of Y files contributed financial data; Z require manual review`). | +| `workpapers/concentration-analysis.json` | Top-N concentration percentages per dimension; threshold-tripped flag. | +| `workpapers/dataroom-hygiene.json` | Filenames and content patterns suggesting seller-side concealment leaks. | +| `findings/manual-review-queue.md` | Per-file checklist of files that did not contribute financial data. | +| `findings/dataroom-hygiene-findings.json` | Proposed `process_integrity` findings to merge into `findings.json`. | +| `findings/concentration-finding.json` | Proposed `valuation` finding when concentration threshold is tripped. | +| `ingest-manifest.md` | Explains source files, assumptions, and scope. | +| `data-room-inventory.csv` | One row per source file. | +| `canonical-catalog.csv` | One row per controlled work/recording candidate. | +| `royalty-ledger.csv` | Normalized income lines across statements. | +| `rights-map.csv` | Ownership, splits, contracts, restrictions, support level. | +| `source-lineage.csv` | Field-level or row-level source traceability. | +| `missing-files.md` | Required files, unresolved conflicts, and diligence asks. | +| `data-quality-report.md` | Profiling results and cleanup decisions. | + +After producing the ledger, always run: + +- `scripts/build-manual-review-queue.py` — surfaces the X-of-Y coverage line and per-file actions. +- `scripts/dataroom-hygiene-scan.py` — surfaces concealment-language matches like `DELETE_BEFORE_SHARING.txt`. +- `scripts/calculate-concentration.py --assumptions` — auto-emits a finding when concentration trips the materiality threshold. + +Merge the proposed findings into `findings/findings.json` with real +`evidence_ids` so they survive the `validate-findings-evidence.py` check. + +Detailed schemas are in +**[references/canonical-schema.md](references/canonical-schema.md)**. + +## Critical gotchas + +- **Do not value before lineage.** If the catalog table and royalty ledger cannot + tie back to source files, the valuation will look precise and be unreliable. +- **NPS and NLS are different.** Publishing uses Net Publisher Share; masters use + Net Label Share. Do not merge them into one "net revenue" field without a + rights-type column. +- **A royalty statement is not chain of title.** Income for a song with no split + sheet or agreement is unsupported income, not clean ownership. +- **Splits must reconcile.** Writer, publisher, master, producer, and + participant splits may live on different bases. Do not force them to total + 100% until you know what the denominator represents. +- **Identifiers beat names.** Match by ISRC/ISWC/IPI/UPC first. Use title and + artist matching only as lower-confidence evidence. +- **Preserve PRO detail.** If statements expose use type, credits, premium/ + bonus columns, cue-sheet data, or territory, keep those fields. They matter + for sustainability analysis. +- **Keep unsupported income in the ledger.** Excluding it hides the size of the + issue. Mark it with low rights confidence and list required follow-up. +- **Do not fabricate missing legal facts.** If rights, recoupment, reserves, or + restrictions are unknown, keep them unknown. + +## What to read next + +- For expected data-room files, read + **[references/data-room-checklist.md](references/data-room-checklist.md)**. +- For canonical fields and confidence scoring, read + **[references/canonical-schema.md](references/canonical-schema.md)**. +- For cleanup rules and review tables, read + **[references/cleaning-rules.md](references/cleaning-rules.md)**. +- For deal workspace structure, read + **[../../references/deal-workspace.md](../../references/deal-workspace.md)**. +- For provider CSV normalization, read + **[../../references/normalization.md](../../references/normalization.md)**. diff --git a/plugins/music-catalog-diligence/skills/catalog-ingest/evals/evals.json b/plugins/music-catalog-diligence/skills/catalog-ingest/evals/evals.json new file mode 100644 index 0000000..fd74236 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-ingest/evals/evals.json @@ -0,0 +1,23 @@ +{ + "skill_name": "catalog-ingest", + "evals": [ + { + "id": "messy-data-room", + "prompt": "I just got a music catalog data room from a seller. It has CSV and XLSX royalty statements from a distributor, a publisher admin export, ASCAP/BMI statement PDFs, YouTube Content ID reports, a track list with ISRCs, and a folder of contracts. Clean it up and get it ready for valuation.", + "expected_output": "The agent inventories files, classifies source types, profiles and cleans data before transformation, creates a canonical catalog table and royalty ledger with source lineage, flags missing rights/metadata support, and does not jump straight to valuation.", + "files": [] + }, + { + "id": "metadata-conflicts", + "prompt": "These two spreadsheets are supposed to be the same catalog, but titles, writers, ISRCs, and splits do not match. Merge them into one clean catalog file.", + "expected_output": "The agent detects conflicts, preserves both source values, assigns confidence levels, asks or flags unresolved merge decisions, verifies splits sum to expected totals, and avoids silently picking one value.", + "files": [] + }, + { + "id": "unsupported-income", + "prompt": "The royalty statements show income for 40 songs, but the legal folder only has split sheets and publishing agreements for 23 of them. Build the ingest package.", + "expected_output": "The agent keeps unsupported income in the ledger, marks rights support as missing or low confidence, creates a missing-file tracker, and separates financial fact from transferability/ownership certainty.", + "files": [] + } + ] +} diff --git a/plugins/music-catalog-diligence/skills/catalog-ingest/references/canonical-schema.md b/plugins/music-catalog-diligence/skills/catalog-ingest/references/canonical-schema.md new file mode 100644 index 0000000..b9eb495 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-ingest/references/canonical-schema.md @@ -0,0 +1,113 @@ +# Canonical schema + +Use these schemas as the hand-off contract between catalog ingestion and later +analysis. Keep the schema stable even when a source file is messy. + +## `data-room-inventory.csv` + +Use one row per source file. + +| Field | Meaning | +| --- | --- | +| `source_file` | Relative path to the raw file. | +| `source_type` | `financial`, `legal`, `metadata`, `asset`, `analytics`, or `unknown`. | +| `provider` | Distributor, PRO, publisher admin, label, society, platform, or seller. | +| `statement_period_start` | First covered date, if known. | +| `statement_period_end` | Last covered date, if known. | +| `currency` | Reported currency. | +| `rights_type` | `publishing`, `master`, `neighboring`, `sync`, `mixed`, or `unknown`. | +| `parse_status` | `parsed`, `partial`, `manual_review`, or `unparsed`. | +| `notes` | Issues, assumptions, or required follow-up. | + +## `canonical-catalog.csv` + +Use one row per work or recording candidate. If the source cannot prove whether +a row is a composition or recording, keep it and mark `rights_type` as +`unknown`. + +| Field | Meaning | +| --- | --- | +| `catalog_asset_id` | Local stable ID assigned during ingest. | +| `rights_type` | `publishing`, `master`, `mixed`, or `unknown`. | +| `title` | Canonical title. | +| `alternate_titles` | Pipe-separated source titles or variants. | +| `artist` | Primary artist or performer. | +| `writers` | Writers as reported. | +| `publishers` | Publishers or admins as reported. | +| `isrc` | Recording identifier, if available. | +| `iswc` | Composition identifier, if available. | +| `upc` | Release identifier, if available. | +| `ipi_cae` | Writer/publisher party identifiers, if available. | +| `release_title` | Album, EP, single, or source release. | +| `release_date` | Release date. | +| `territory` | Controlled territory or reported territory. | +| `controlled_share` | Seller-controlled share, if supported. | +| `ownership_confidence` | `high`, `medium`, `low`, or `unknown`. | +| `metadata_confidence` | `high`, `medium`, `low`, or `unknown`. | +| `source_files` | Pipe-separated source files supporting the row. | +| `conflicts` | Summary of unresolved conflicts. | + +## `royalty-ledger.csv` + +Use one row per source statement line at the lowest available grain. Do not roll +up detail until analysis. + +| Field | Meaning | +| --- | --- | +| `ledger_line_id` | Local stable ID for the normalized row. | +| `catalog_asset_id` | Link to `canonical-catalog.csv`, if matched. | +| `source_file` | Source statement file. | +| `provider` | Distributor, PRO, society, platform, or admin. | +| `period_start` | Revenue/performance period start. | +| `period_end` | Revenue/performance period end. | +| `payment_date` | Payment or statement date, if different. | +| `rights_type` | `publishing`, `master`, `neighboring`, `sync`, `mixed`, or `unknown`. | +| `income_type` | Performance, mechanical, streaming, sync, physical, UGC, radio, other. | +| `territory` | Reported territory. | +| `platform_or_licensee` | DSP, station, network, licensee, society, or source. | +| `gross_amount` | Gross income before deductions. | +| `deductions` | Fees, admin, reserves, returns, or other deductions. | +| `participant_share` | Writer, artist, producer, co-publisher, or other pass-through. | +| `owner_net_amount` | Estimated NPS/NLS line amount, if calculable. | +| `currency` | Currency. | +| `fx_rate` | FX rate used, if converted. | +| `pro_use_type` | Feature, background, theme, underscore, live, or other, if available. | +| `pro_credits` | PRO credits, if available. | +| `pro_bonus_type` | Premium/bonus category, if available. | +| `cue_sheet_ref` | Cue-sheet or program reference, if available. | +| `match_confidence` | `high`, `medium`, `low`, or `unmatched`. | +| `notes` | Assumptions or extraction issues. | + +## `rights-map.csv` + +Use one row per relationship between an asset and a rights-supporting document. + +| Field | Meaning | +| --- | --- | +| `catalog_asset_id` | Local asset ID. | +| `document_file` | Contract, split sheet, registration, assignment, or support file. | +| `document_type` | Agreement, split sheet, registration, assignment, license, clearance. | +| `party` | Writer, artist, publisher, label, producer, distributor, admin, or other. | +| `share_type` | Writer, publisher, master, producer, admin, or other. | +| `reported_share` | Share percentage or fraction. | +| `territory` | Covered territory. | +| `term_start` | Effective start date. | +| `term_end` | Expiration or reversion date, if known. | +| `restrictions` | Approval rights, samples, territory carveouts, excluded uses. | +| `support_level` | `supported`, `partial`, `missing`, `conflicting`, or `unknown`. | +| `notes` | Issues requiring legal or business follow-up. | + +## Confidence scoring + +Use confidence labels instead of fake certainty. + +- `high`: Multiple sources agree, and at least one authoritative source + supports the field. +- `medium`: A credible source supports the field, but another source is missing + or less specific. +- `low`: The field is inferred from weak matching, titles, filenames, or + incomplete exports. +- `unknown`: No reliable support found. +- `conflicting`: Two or more credible sources disagree. + +When in doubt, preserve both source values and explain the conflict. diff --git a/plugins/music-catalog-diligence/skills/catalog-ingest/references/cleaning-rules.md b/plugins/music-catalog-diligence/skills/catalog-ingest/references/cleaning-rules.md new file mode 100644 index 0000000..1979b5a --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-ingest/references/cleaning-rules.md @@ -0,0 +1,120 @@ +# Cleaning rules + +Catalog cleanup must stay auditable. The user, buyer, lawyer, or finance lead +needs to understand what changed and why. + +## Before cleaning + +Profile every structured file before transformation: + +- Row count and column count. +- Grain: one row per what. +- Candidate primary keys. +- Date range. +- Currency. +- Null rates. +- Duplicate rows. +- Duplicate identifiers. +- Mixed data types. +- Suspicious values. +- Identifier format validity. +- Split totals. + +## Issue taxonomy + +Use this table format before changing data. + +| Column | Issue | Count | Proposed fix | Destructive? | +| --- | --- | ---: | --- | --- | +| `Title` | Leading/trailing whitespace | 84 | Add cleaned title field with trimmed value | No | +| `ISRC` | Invalid format | 7 | Flag for review, do not infer | No | +| `Writer Split` | Totals 125% for 3 songs | 3 | Preserve source values and add conflict note | No | +| `Date` | Mixed date formats | 211 | Standardize into ISO date helper column | No | +| `Rows` | Exact duplicate rows | 12 | Ask before removal | Yes | + +## Non-destructive defaults + +Prefer adding cleaned helper fields over overwriting originals. + +Examples: + +- Keep `title_source` and create `title_clean`. +- Keep `isrc_source` and create `isrc_clean`. +- Keep `statement_amount_source` and create numeric `statement_amount`. +- Keep original currency and add converted currency only with an FX source. + +Only overwrite values when the user explicitly approves or the output is a new +derived file that leaves the raw source untouched. + +## Matching hierarchy + +Match assets in this order: + +1. Exact ISRC for recordings. +2. Exact ISWC for compositions. +3. UPC plus track number and title. +4. IPI/CAE plus title and writer set. +5. Normalized title plus artist/writer with high similarity. +6. Filename or folder inference. + +Levels 1 to 3 can be high confidence when source data is clean. Levels 4 to 6 +need review notes unless corroborated by another source. + +## Split checks + +Do not assume every split column has the same denominator. + +Check: + +- Writer share vs publisher share. +- Master ownership vs artist royalty rate. +- Controlled share vs total copyright ownership. +- Territory-specific share. +- Admin share vs ownership share. +- Producer points and featured artist participations. + +Flag any split set that totals below 99.5% or above 100.5%, unless the source +clearly uses a different basis. + +## Identifier checks + +Use these as validation flags, not automatic corrections. + +- ISRC: usually 12 characters, country/registrant/year/designation. +- ISWC: composition identifier, often starts with `T`. +- UPC/EAN: release identifier, often 12 or 13 digits. +- IPI/CAE: party identifiers for writers and publishers. + +If an identifier looks wrong, mark it invalid and ask for support. Do not invent +or "fix" identifiers from search results unless the user explicitly asks for +enrichment and the source is recorded. + +## PRO statement preservation + +For PRO statements, keep detail fields that may look irrelevant during cleaning: + +- Performance period. +- Payment period. +- Use type. +- Duration. +- Credits. +- Credit value. +- Bonus or premium category. +- Station, network, program, episode, or cue reference. +- Territory and society. +- Foreign exchange. +- Adjustment or retroactive payment notes. + +These fields are needed later to decide whether income is sustainable. + +## Cleanup report + +After cleaning, write a short report with: + +- Files processed. +- Rows before and after. +- Columns added. +- Destructive changes, if any. +- Unresolved conflicts. +- Fields that require user, legal, or seller confirmation. +- Confidence summary. diff --git a/plugins/music-catalog-diligence/skills/catalog-ingest/references/data-room-checklist.md b/plugins/music-catalog-diligence/skills/catalog-ingest/references/data-room-checklist.md new file mode 100644 index 0000000..7c6a49e --- /dev/null +++ b/plugins/music-catalog-diligence/skills/catalog-ingest/references/data-room-checklist.md @@ -0,0 +1,99 @@ +# Data-room checklist + +Use this checklist to decide whether the data room is ready for valuation. A +missing item does not always block work, but it must be visible. + +## Catalog inventory + +Request or locate: + +- Track list with title, artist, release, ISRC, UPC, release date, and version. +- Composition list with title, writers, publishers, ISWC, IPI/CAE, and splits. +- Mapping between recordings and compositions. +- Territory and ownership percentage by asset. +- Alternate title, remix, clean, explicit, instrumental, live, and acoustic + version mapping. + +## Royalty statements + +Request 36 to 60 months when available. + +- Distributor statements. +- Label statements. +- Publisher administrator statements. +- PRO statements from ASCAP, BMI, SESAC, GMR, or foreign societies. +- The MLC and mechanical royalty statements. +- SoundExchange and neighboring rights statements. +- YouTube Content ID and UGC reports. +- Sync license statements and direct license payments. +- Physical, download, merch-bundle, sample, and other long-tail income reports. + +## Legal and ownership support + +Request signed documents, not summaries. + +- Recording agreements. +- Publishing, co-publishing, and administration agreements. +- Writer split sheets. +- Producer agreements and producer point schedules. +- Featured artist agreements. +- Distribution agreements. +- Assignments and prior transfer documents. +- Sample clearances and interpolation approvals. +- Sync licenses and approval rights. +- Side letters, amendments, and settlement agreements. +- Copyright registrations. +- UCC/lien searches or other encumbrance support. +- Reversion, termination, or estate/probate documents where relevant. + +## Royalty accounting support + +Request schedules that explain why gross income does or does not become owner +cash flow. + +- Artist royalty statements. +- Writer royalty statements. +- Recoupment balances. +- Reserve schedules. +- Producer royalty schedules. +- Payable balances. +- Pending or threatened audit claims. +- Chargeback, return, or adjustment schedules. + +## PRO and cue-sheet support + +Performance income needs extra detail because it can be threshold-driven. + +- PRO work registrations. +- Cue sheets. +- Program, episode, network, and airdate mappings. +- Use type, duration, credits, and premium/bonus fields if available. +- Domestic vs foreign society detail. +- Foreign remittance periods and FX. +- Claims, adjustments, late registrations, or unmatched work reports. + +## Analytics and third-party validation + +Use analytics to test whether reported income makes sense. + +- Spotify, Apple, Amazon, YouTube, TikTok, and other platform analytics. +- Chartmetric or Luminate exports. +- Playlist placement data. +- Radio or broadcast logs if available. +- Territory and audience data. +- DSP monthly listener, stream, save, skip, and playlist trends. +- YouTube claims, conflicts, and blocked-territory reports. + +## Missing-file tracker + +For each gap, record: + +- Missing file or field. +- Affected assets. +- Why it matters. +- Whether it blocks valuation or only changes confidence. +- Who must provide it. +- Suggested follow-up question. + +Use direct language. "Missing split sheet for income-generating song" is better +than "legal docs incomplete." diff --git a/plugins/music-catalog-diligence/skills/diligence-kickoff/SKILL.md b/plugins/music-catalog-diligence/skills/diligence-kickoff/SKILL.md new file mode 100644 index 0000000..127523b --- /dev/null +++ b/plugins/music-catalog-diligence/skills/diligence-kickoff/SKILL.md @@ -0,0 +1,44 @@ +--- +name: diligence-kickoff +description: Use when starting a new music catalog deal, setting up a data room review, creating a deal workspace, preparing diligence requests, or triaging a catalog acquisition, seller-prep, or financing opportunity. Triggers include "start catalog diligence", "kick off this deal", "set up the data room", "new catalog acquisition", "prepare diligence request", or "create deal workspace". +--- + +# Diligence Kickoff + +Set up one catalog deal workspace and route the team into the right workflow: +buy-side acquisition, seller preparation, or financing underwriting. + +## Workflow + +1. Identify the workflow type: `buy-side`, `seller-prep`, or `financing`. +2. Create or locate `deals/{deal-id}/`. +3. Apply the deal workspace convention from + `references/deal-workspace.md`. +4. Place raw seller files under `source/` and do not edit them. +5. Start `assumptions.yaml` from the template and fill only known facts. +6. Start `evidence-ledger.json` and `findings/findings.json`. +7. Build a first missing-file list from + `references/diligence-workflow.md` and `references/red-flags.md`. +8. Route next: + - Raw data room -> use `catalog-ingest`. + - Legal support -> use `rights-diligence`. + - Statements -> use `royalty-audit`. + - Value projection -> use `catalog-analysis`. + +## Output + +Return: + +- Deal workspace path. +- Workflow type. +- Files received. +- Missing documents. +- Immediate red flags. +- Recommended next skill or command. + +## Guardrails + +- Never call the deal "ready" during kickoff. +- Do not make valuation claims from seller summaries alone. +- Do not assume seller file names are accurate. +- Keep all unknown assumptions visible in `assumptions.yaml`. diff --git a/plugins/music-catalog-diligence/skills/financing-underwrite/SKILL.md b/plugins/music-catalog-diligence/skills/financing-underwrite/SKILL.md new file mode 100644 index 0000000..b5a41a7 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/financing-underwrite/SKILL.md @@ -0,0 +1,46 @@ +--- +name: financing-underwrite +description: Use when underwriting a music catalog for an advance, loan, credit facility, securitization, collateral package, or royalty-backed financing. Triggers include "catalog financing", "underwrite this catalog", "advance rate", "collateral package", "royalty loan", "financing pack", "debt capacity", or "lender diligence". +--- + +# Financing Underwrite + +Build a lender-ready view of catalog cash flow, collateral quality, and downside +risk. Financing work emphasizes coverage, stability, and enforceability. + +## Workflow + +1. Confirm workflow type is `financing`. +2. Validate normalized royalties, rights map, evidence ledger, and assumptions. +3. Separate eligible collateral from unsupported or restricted assets. +4. Compute normalized NPS/NLS and concentration. +5. Stress cash flows for decay, platform risk, missing docs, recoupment, and + reserves. +6. Recommend advance-rate adjustments, holdbacks, or conditions precedent. +7. Draft `memos/financing-pack.md`. + +## Lender checks + +- Is pledged income supported by transferable rights? +- Are royalty sources stable and recurring? +- Are top-song and top-platform concentrations acceptable? +- Are recoupment, reserves, and payables modeled? +- Are there unresolved legal or registration exceptions? +- Is monitoring possible after close? + +## Output + +Return: + +- Eligible collateral summary. +- Normalized cash-flow base. +- Downside stress case. +- Exclusions and haircuts. +- Required conditions before funding. +- Ongoing monitoring plan. + +## Guardrails + +- Do not use seller gross revenue as debt-service cash flow. +- Do not include unsupported assets in eligible collateral. +- Do not hide missing documents behind a blended advance rate. diff --git a/plugins/music-catalog-diligence/skills/financing-underwrite/evals/evals.json b/plugins/music-catalog-diligence/skills/financing-underwrite/evals/evals.json new file mode 100644 index 0000000..2192d6f --- /dev/null +++ b/plugins/music-catalog-diligence/skills/financing-underwrite/evals/evals.json @@ -0,0 +1,17 @@ +{ + "skill_name": "financing-underwrite", + "evals": [ + { + "id": "lender-collateral-pack", + "prompt": "Build a lender-ready collateral package for a royalty-backed financing. The catalog has normalized royalty data, concentration risk, open rights findings, and preliminary valuation workpapers.", + "expected_output": "The agent builds a lender-oriented package with cash-flow support, concentration analysis, open rights exceptions, collateral caveats, downside/base/upside scenarios, and required diligence asks before financing proceeds.", + "files": [] + }, + { + "id": "recoupment-cliff-financing", + "prompt": "The master catalog has strong gross receipts, but artist accounts are close to recouping and future net label share may fall. Assess financing risk.", + "expected_output": "The agent focuses on underwritable NLS, models pre- and post-recoupment cash flows, flags debt-service coverage risk, and avoids treating gross receipts as available collateral cash flow.", + "files": [] + } + ] +} diff --git a/plugins/music-catalog-diligence/skills/ic-memo-package/SKILL.md b/plugins/music-catalog-diligence/skills/ic-memo-package/SKILL.md new file mode 100644 index 0000000..d23e000 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/ic-memo-package/SKILL.md @@ -0,0 +1,39 @@ +--- +name: ic-memo-package +description: Use when creating an investment committee memo, buyer diligence package, bid recommendation, seller cleanup report, financing pack, or final catalog deal summary. Triggers include "IC memo", "investment memo", "deal package", "bid recommendation", "catalog memo", "prepare the diligence package", or "package this catalog analysis". +--- + +# IC Memo Package + +Assemble deal outputs from evidence, normalized data, workpapers, and findings. +The memo should be readable, but it must not outrun the evidence. + +## Workflow + +1. Confirm the intended output: IC memo, seller report, financing pack, or + post-close plan. +2. Validate that `evidence-ledger.json`, `assumptions.yaml`, and findings exist. +3. Pull normalized NPS/NLS, concentration, red flags, and valuation scenarios + from workpapers. +4. Separate evidence-backed facts from assumptions and open items. +5. Use the relevant template under `templates/deal-workspace/memos/`. +6. Run or request diligence QC before presenting final recommendations. + +## Required sections + +- Executive summary. +- Rights analyzed. +- Data confidence. +- Normalized cash-flow bridge. +- Valuation range or readiness score. +- Key risks and valuation treatment. +- Open diligence. +- Recommended next action. +- Evidence references. + +## Guardrails + +- Do not write polished conclusions without workpapers. +- Do not bury critical findings in appendices. +- Do not cite a source file unless the evidence ledger or workpaper supports it. +- Do not call the memo final if material open items remain unresolved. diff --git a/plugins/music-catalog-diligence/skills/post-close-admin/SKILL.md b/plugins/music-catalog-diligence/skills/post-close-admin/SKILL.md new file mode 100644 index 0000000..5e03035 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/post-close-admin/SKILL.md @@ -0,0 +1,39 @@ +--- +name: post-close-admin +description: Use after a music catalog acquisition closes, when transferring royalty administration, updating registrations, tracking ownership changes, monitoring first statements, or preventing post-close income leakage. Triggers include "post-close", "transfer administration", "registration updates", "catalog migration", "first post-close statements", "income leakage", or "ownership transfer plan". +--- + +# Post-Close Admin + +Turn diligence data into an administration transition plan after close. The goal +is to prevent income leakage while ownership records, registrations, and +royalty systems move to the buyer. + +## Workflow + +1. Start from the final canonical catalog, rights map, and closing schedules. +2. Confirm acquired assets, excluded assets, territories, and effective date. +3. Generate transfer worklists for PROs, MLC, SoundExchange, distributors, + publisher admins, neighboring rights societies, and internal royalty systems. +4. Track registration updates and payment direction changes. +5. Monitor first post-close statements against expected income. +6. Flag missing income, old payees, unmatched works, or statement gaps. +7. Write a post-close admin plan under `memos/`. + +## Output + +Return: + +- Transfer checklist. +- Registration update list. +- Provider/account owner list. +- First-statement monitoring plan. +- Income leakage watchlist. +- Open issues and owners. + +## Guardrails + +- Do not assume closing schedules match diligence tables; compare them. +- Do not overwrite historical evidence. +- Do not mark transition complete until first statement checks are done or + explicitly deferred. diff --git a/plugins/music-catalog-diligence/skills/rights-diligence/SKILL.md b/plugins/music-catalog-diligence/skills/rights-diligence/SKILL.md new file mode 100644 index 0000000..54d0089 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/rights-diligence/SKILL.md @@ -0,0 +1,51 @@ +--- +name: rights-diligence +description: Use when reviewing music catalog ownership, chain of title, split sheets, publishing agreements, recording agreements, assignments, sample clearances, liens, reversions, territory restrictions, or transferability. Triggers include "chain of title", "rights diligence", "split sheet review", "does seller own this", "sample clearance", "reversion risk", or "catalog legal docs". +--- + +# Rights Diligence + +Review whether the seller can transfer the rights that the valuation assumes. +Royalty income is evidence of reported payments; it is not proof of ownership. + +## Workflow + +1. Read the catalog assets from `normalized/canonical-catalog.csv` if present. +2. Read `normalized/rights-map.csv` if present. +3. Link each material income-generating asset to agreements, split sheets, + registrations, assignments, or other support. +4. Check territory, term, controlled share, approval rights, reversions, + samples, liens, side letters, and encumbrances. +5. Classify support as `supported`, `partial`, `missing`, `conflicting`, or + `unknown`. +6. Write structured findings to `findings/findings.json`. +7. Update `findings/missing-files.md` with curative requests. + +## Red flags + +Read `references/red-flags.md` for severity. Prioritize: + +- Income-generating works without signed support. +- Contract shares below schedule shares. +- Missing assignments. +- Unmodeled reversions. +- Sample or interpolation gaps. +- Non-one-stop sync assumptions. +- Liens, audits, disputes, or estate issues. + +## Output + +Return a rights memo with: + +- Assets reviewed. +- Supported assets. +- Unsupported or conflicting assets. +- Critical blockers. +- Valuation treatment: include, exclude, haircut, escrow, or holdback. +- Follow-up request list. + +## Guardrails + +- Do not provide legal advice. Flag issues for counsel review. +- Do not infer ownership from royalties alone. +- Do not silently resolve conflicting contract terms. diff --git a/plugins/music-catalog-diligence/skills/rights-diligence/evals/evals.json b/plugins/music-catalog-diligence/skills/rights-diligence/evals/evals.json new file mode 100644 index 0000000..76a97dd --- /dev/null +++ b/plugins/music-catalog-diligence/skills/rights-diligence/evals/evals.json @@ -0,0 +1,17 @@ +{ + "skill_name": "rights-diligence", + "evals": [ + { + "id": "missing-chain-of-title", + "prompt": "The statements show income for 40 songs, but only 23 songs have split sheets or publishing agreements. Review rights support for a buy-side catalog deal.", + "expected_output": "The agent keeps unsupported income visible, marks affected assets as low rights confidence, creates missing-file requests, separates financial performance from transferable value, and recommends exclusion, haircut, escrow, or holdback options.", + "files": [] + }, + { + "id": "sample-and-territory-restrictions", + "prompt": "Several top tracks include samples and one assignment excludes non-US territories. Review transferability risks before we share the diligence memo.", + "expected_output": "The agent identifies sample clearance and territory-limit risks, ties each issue to affected assets and income streams, and recommends disclosure, counsel review, or purchase price protection.", + "files": [] + } + ] +} diff --git a/plugins/music-catalog-diligence/skills/royalty-audit/SKILL.md b/plugins/music-catalog-diligence/skills/royalty-audit/SKILL.md new file mode 100644 index 0000000..3c5edc7 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/royalty-audit/SKILL.md @@ -0,0 +1,42 @@ +--- +name: royalty-audit +description: Use when auditing music royalty statements, checking reported revenue, normalizing royalty ledgers, finding missing income, validating NPS/NLS, reviewing PRO/MLC/DSP/distributor statements, or investigating royalty anomalies. Triggers include "royalty audit", "check these statements", "missing royalties", "statement normalization", "income leakage", "MLC mismatch", or "PRO statement issue". +--- + +# Royalty Audit + +Review royalty data for completeness, consistency, and underwritable net cash +flow. The goal is to find statement issues before they become valuation errors. + +## Workflow + +1. Confirm raw statements are preserved in `source/`. +2. Use `catalog-ingest` if no normalized `royalty-ledger.csv` exists. +3. Validate ledger grain, dates, currencies, sources, rights types, and asset + matches. +4. Reconcile gross, deductions, participant shares, and owner net. +5. Check period earned versus period paid. +6. Identify duplicate imports, unmatched income, missing sources, and retroactive + adjustments. +7. Decompose PRO performance income using + `../../references/pro-performance-income.md`. +8. Write anomalies to `findings/findings.json` and workpapers to `workpapers/`. + +## Output + +Return: + +- Statement sources reviewed. +- LTM/TTM by source and rights type. +- Unmatched or unsupported income. +- Duplicate or suspicious rows. +- Gross-to-net issues. +- PRO/MLC/metadata issues. +- Recommended normalization adjustments. + +## Guardrails + +- Do not drop unmatched income. Mark it as unmatched. +- Do not treat gross revenue as NPS or NLS. +- Do not classify one-time payments as recurring without evidence. +- Do not hide FX or timing assumptions. diff --git a/plugins/music-catalog-diligence/skills/royalty-audit/evals/evals.json b/plugins/music-catalog-diligence/skills/royalty-audit/evals/evals.json new file mode 100644 index 0000000..edf045d --- /dev/null +++ b/plugins/music-catalog-diligence/skills/royalty-audit/evals/evals.json @@ -0,0 +1,17 @@ +{ + "skill_name": "royalty-audit", + "evals": [ + { + "id": "duplicate-statement-imports", + "prompt": "The normalized royalty ledger has two distributor statements for the same platform, month, territory, and ISRC. Audit the ledger before we use it in valuation.", + "expected_output": "The agent checks for duplicate source imports, flags duplicate ledger rows, quantifies overstated owner net amount, and recommends which source lines need exclusion or reconciliation before valuation.", + "files": [] + }, + { + "id": "pro-bonus-anomaly", + "prompt": "ASCAP and BMI income spiked in one quarter because of a bonus or premium category. Audit whether the royalty data supports underwriting this as recurring cash flow.", + "expected_output": "The agent separates base performance royalties from bonus or premium amounts, identifies timing and use-type drivers, labels non-recurring income, and recommends normalized NPS treatment.", + "files": [] + } + ] +} diff --git a/plugins/music-catalog-diligence/skills/seller-prep/SKILL.md b/plugins/music-catalog-diligence/skills/seller-prep/SKILL.md new file mode 100644 index 0000000..680fe35 --- /dev/null +++ b/plugins/music-catalog-diligence/skills/seller-prep/SKILL.md @@ -0,0 +1,48 @@ +--- +name: seller-prep +description: Use when preparing a music catalog for sale, improving catalog readiness, cleaning metadata before going to market, creating a seller cleanup report, finding missing documentation, or maximizing catalog value before buyer diligence. Triggers include "prepare catalog for sale", "seller prep", "maximize catalog value", "catalog audit checklist", "clean up before sale", or "what should we fix before going to market". +--- + +# Seller Prep + +Help a seller increase buyer confidence before a catalog goes to market. Seller +prep is not about inflating value. It is about reducing avoidable discounts. + +## Workflow + +1. Run `diligence-kickoff` with workflow type `seller-prep`. +2. Inventory files and build missing-document tracker. +3. Use `catalog-ingest` to normalize metadata and royalty statements. +4. Use `rights-diligence` to find unsupported assets. +5. Use `royalty-audit` to find income leakage and statement gaps. +6. Rank fixes by likely value impact and time to cure. +7. Draft `memos/seller-cleanup-report.md`. + +## Fix categories + +- Missing split sheets. +- Missing registrations. +- ISRC/ISWC/UPC conflicts. +- Unlinked recordings and compositions. +- Unsupported income. +- Incomplete royalty history. +- Unclaimed or unmatched royalties. +- Non-one-stop sync restrictions. +- Sample and featured artist documentation gaps. + +## Output + +Return: + +- Readiness summary. +- Top value leaks. +- Cleanup priority list. +- Buyer-facing documentation gaps. +- Work that can be fixed quickly. +- Work that needs counsel, administrator, or seller action. + +## Guardrails + +- Do not promise valuation uplift without evidence. +- Do not hide problems to make the catalog look cleaner. +- Do not overwrite source metadata; create corrected working files. diff --git a/plugins/music-catalog-diligence/skills/seller-prep/evals/evals.json b/plugins/music-catalog-diligence/skills/seller-prep/evals/evals.json new file mode 100644 index 0000000..189a26d --- /dev/null +++ b/plugins/music-catalog-diligence/skills/seller-prep/evals/evals.json @@ -0,0 +1,17 @@ +{ + "skill_name": "seller-prep", + "evals": [ + { + "id": "seller-cleanup-before-market", + "prompt": "A seller wants to go to market in two weeks, but the data room has missing split sheets, inconsistent track metadata, and royalty statements from several providers. Build a cleanup plan.", + "expected_output": "The agent prioritizes fixes by valuation impact and buyer diligence friction, creates a missing-file request list, separates must-fix items from disclosure items, and avoids over-polishing low-value issues.", + "files": [] + }, + { + "id": "avoidable-discount-risk", + "prompt": "Prepare a seller cleanup report that identifies issues likely to cause avoidable price reductions in a catalog sale.", + "expected_output": "The agent identifies rights gaps, royalty normalization issues, concentration caveats, and memo-ready remediation steps, while explaining how each issue could affect buyer confidence or valuation.", + "files": [] + } + ] +} diff --git a/plugins/music-catalog-diligence/templates/deal-workspace/assumptions.yaml b/plugins/music-catalog-diligence/templates/deal-workspace/assumptions.yaml new file mode 100644 index 0000000..68293e4 --- /dev/null +++ b/plugins/music-catalog-diligence/templates/deal-workspace/assumptions.yaml @@ -0,0 +1,38 @@ +deal: + deal_id: "" + deal_name: "" + workflow_type: "buy-side" # buy-side | seller-prep | financing + buyer_or_client: "" + valuation_date: "" + currency: "USD" + +scope: + rights_included: + - publishing + - masters + territories: [] + excluded_assets: [] + preliminary_or_full: "preliminary" + +valuation: + discount_rate: null + publishing_multiple_low: null + publishing_multiple_high: null + master_multiple_low: null + master_multiple_high: null + terminal_growth_or_decay: null + +normalization: + sync_treatment: "exclude_one_time" + pro_bonus_treatment: "separate_and_haircut" + reserve_treatment: "review_required" + recoupment_treatment: "model_when_available" + fx_source: "" + +materiality: + revenue_threshold: null + concentration_threshold_percent: 40 + unsupported_income_threshold_percent: 5 + +notes: + - "All assumptions must be confirmed or labeled as preliminary before external use." diff --git a/plugins/music-catalog-diligence/templates/deal-workspace/evidence-ledger.json b/plugins/music-catalog-diligence/templates/deal-workspace/evidence-ledger.json new file mode 100644 index 0000000..c082ce4 --- /dev/null +++ b/plugins/music-catalog-diligence/templates/deal-workspace/evidence-ledger.json @@ -0,0 +1,5 @@ +{ + "deal_id": "", + "generated_at": "", + "entries": [] +} diff --git a/plugins/music-catalog-diligence/templates/deal-workspace/findings.json b/plugins/music-catalog-diligence/templates/deal-workspace/findings.json new file mode 100644 index 0000000..c9af43b --- /dev/null +++ b/plugins/music-catalog-diligence/templates/deal-workspace/findings.json @@ -0,0 +1,17 @@ +{ + "deal_id": "", + "generated_at": "", + "findings": [ + { + "finding_id": "F-001", + "severity": "example", + "category": "example", + "affected_assets": [], + "evidence_ids": [], + "issue": "Replace this example finding with a real diligence issue.", + "valuation_treatment": "", + "follow_up": "", + "status": "open" + } + ] +} diff --git a/plugins/music-catalog-diligence/templates/deal-workspace/memos/financing-pack.md b/plugins/music-catalog-diligence/templates/deal-workspace/memos/financing-pack.md new file mode 100644 index 0000000..6053906 --- /dev/null +++ b/plugins/music-catalog-diligence/templates/deal-workspace/memos/financing-pack.md @@ -0,0 +1,23 @@ +# Financing pack + +## Borrower and catalog overview + +Summarize borrower, rights pledged, catalog composition, and revenue history. + +## Collateral summary + +List assets, controlled shares, territories, rights type, and confidence. + +## Cash-flow support + +Show normalized NPS/NLS, historical trend, source mix, and concentration. + +## Risk controls + +List reserves, recoupment exposure, legal exceptions, missing documents, +platform concentration, and proposed advance-rate adjustments. + +## Monitoring plan + +Define post-close reporting, covenant checks, missing-file cures, and income +leakage monitoring. diff --git a/plugins/music-catalog-diligence/templates/deal-workspace/memos/ic-memo.md b/plugins/music-catalog-diligence/templates/deal-workspace/memos/ic-memo.md new file mode 100644 index 0000000..23cade4 --- /dev/null +++ b/plugins/music-catalog-diligence/templates/deal-workspace/memos/ic-memo.md @@ -0,0 +1,38 @@ +# Investment committee memo + +## Executive summary + +**Deal:** +**Workflow:** Buy-side acquisition +**Preliminary value range:** +**Base case:** +**Data confidence:** + +## Recommendation + +State the recommended bid, holdback, escrow, or pass decision. Separate facts +from assumptions. + +## Catalog overview + +Summarize rights included, top assets, income history, and source mix. + +## Quality of earnings + +Show the bridge from reported LTM NPS/NLS to normalized run-rate. + +## Valuation + +Summarize market multiple and DCF outputs, including downside/base/upside cases. + +## Key diligence findings + +List material red flags, evidence, and valuation treatment. + +## Open items + +List missing files, unresolved legal issues, and required follow-up. + +## Evidence + +Reference `evidence-ledger.json`, workpapers, and source files. diff --git a/plugins/music-catalog-diligence/templates/deal-workspace/memos/seller-cleanup-report.md b/plugins/music-catalog-diligence/templates/deal-workspace/memos/seller-cleanup-report.md new file mode 100644 index 0000000..ae6c4e5 --- /dev/null +++ b/plugins/music-catalog-diligence/templates/deal-workspace/memos/seller-cleanup-report.md @@ -0,0 +1,23 @@ +# Seller cleanup report + +## Summary + +Summarize the catalog readiness score, top blockers, and actions that can +increase buyer confidence or valuation. + +## Missing documentation + +List missing split sheets, agreements, registrations, and royalty schedules. + +## Metadata cleanup + +List identifier, title, writer, publisher, and territory conflicts. + +## Revenue support + +List royalty sources that need better exports, longer history, or source +reconciliation. + +## High-value fixes + +Prioritize fixes by likely valuation impact and time to cure. diff --git a/plugins/music-catalog-diligence/templates/deal-workspace/missing-files.md b/plugins/music-catalog-diligence/templates/deal-workspace/missing-files.md new file mode 100644 index 0000000..548abf0 --- /dev/null +++ b/plugins/music-catalog-diligence/templates/deal-workspace/missing-files.md @@ -0,0 +1,7 @@ +# Missing files + +Use this tracker for seller requests, legal follow-up, and valuation blockers. + +| Item | Affected assets | Why it matters | Blocks valuation? | Owner | Status | +| --- | --- | --- | --- | --- | --- | +| Signed split sheet | `catalog_asset_id` | Confirms writer/publisher shares. | Yes | Seller | Open | diff --git a/scripts/generate-marketplaces.py b/scripts/generate-marketplaces.py new file mode 100755 index 0000000..51cd37b --- /dev/null +++ b/scripts/generate-marketplaces.py @@ -0,0 +1,195 @@ +#!/usr/bin/env python3 +""" +Generate Recoup marketplace files for Claude Code, Codex, and Cursor. + +Reads marketplace.source.json (the single source of truth) and writes three +platform-specific marketplace files: + + - .claude-plugin/marketplace.json (Claude Code) + - .agents/plugins/marketplace.json (Codex) + - .cursor-plugin/marketplace.json (Cursor) + +Usage: + python3 scripts/generate-marketplaces.py [--check] + + --check exit non-zero if generated files differ from source-of-truth + (suitable for CI). + +Zero external dependencies. Python 3.9+ stdlib only. +""" +from __future__ import annotations + +import argparse +import json +import sys +from pathlib import Path +from typing import Any + +REPO_ROOT = Path(__file__).resolve().parent.parent +SOURCE_FILE = REPO_ROOT / "marketplace.source.json" + +CLAUDE_OUT = REPO_ROOT / ".claude-plugin" / "marketplace.json" +CODEX_OUT = REPO_ROOT / ".agents" / "plugins" / "marketplace.json" +CURSOR_OUT = REPO_ROOT / ".cursor-plugin" / "marketplace.json" + + +def load_source() -> dict[str, Any]: + if not SOURCE_FILE.exists(): + sys.exit(f"error: {SOURCE_FILE} not found") + with SOURCE_FILE.open() as fh: + data = json.load(fh) + return data + + +def build_claude(source: dict[str, Any]) -> dict[str, Any]: + """Claude Code marketplace.json (anthropics/skills + anthropics/claude-plugins-official shape).""" + mp = source["marketplace"] + out: dict[str, Any] = { + "name": mp["name"], + "owner": mp["owner"], + "metadata": { + "description": mp["description"], + "version": mp["version"], + }, + "plugins": [], + } + for plugin in source["plugins"]: + entry: dict[str, Any] = { + "name": plugin["name"], + "description": plugin["description"], + "version": plugin["version"], + "source": plugin["source"], + "category": plugin["category"], + "keywords": plugin.get("keywords", []), + } + if plugin["type"] == "virtual": + entry["strict"] = False + entry["skills"] = plugin["skills"] + if "tags" in plugin: + entry["tags"] = plugin["tags"] + out["plugins"].append(entry) + return out + + +def build_codex(source: dict[str, Any]) -> dict[str, Any]: + """Codex marketplace.json with richer policy/auth metadata per Codex spec.""" + mp = source["marketplace"] + out: dict[str, Any] = { + "name": mp["name"], + "description": mp["description"], + "owner": mp["owner"], + "interface": { + "displayName": "Recoup", + }, + "plugins": [], + } + for plugin in source["plugins"]: + entry: dict[str, Any] = { + "name": plugin["name"], + "description": plugin["description"], + "source": { + "source": "local", + "path": plugin["source"], + }, + "policy": { + "installation": "AVAILABLE", + "authentication": "ON_INSTALL", + }, + "category": plugin["category"], + "keywords": plugin.get("keywords", []), + } + if "interface" in plugin: + entry["interface"] = plugin["interface"] + out["plugins"].append(entry) + return out + + +def build_cursor(source: dict[str, Any]) -> dict[str, Any]: + """Cursor marketplace.json (matches current Recoup Cursor pattern).""" + mp = source["marketplace"] + out: dict[str, Any] = { + "name": mp["name"], + "owner": { + "name": mp["owner"]["name"], + "email": mp["owner"]["email"], + }, + "metadata": { + "description": mp["description"], + }, + "plugins": [], + } + for plugin in source["plugins"]: + entry: dict[str, Any] = { + "name": plugin["name"], + "source": plugin["source"].lstrip("./") or ".", + "description": plugin["description"], + "version": plugin["version"], + "author": { + "name": mp["owner"]["name"], + "email": mp["owner"]["email"], + }, + "homepage": mp["homepage"], + "repository": mp["repository"], + "license": mp["license"], + "category": plugin["category"], + "keywords": plugin.get("keywords", []), + } + if "tags" in plugin: + entry["tags"] = plugin["tags"] + out["plugins"].append(entry) + return out + + +def serialize(data: dict[str, Any]) -> str: + return json.dumps(data, indent=2, ensure_ascii=False) + "\n" + + +def write_if_changed(path: Path, content: str, *, check: bool) -> bool: + """Return True when path matches content; False when it differs.""" + path.parent.mkdir(parents=True, exist_ok=True) + existing = path.read_text() if path.exists() else None + if existing == content: + return True + if check: + return False + path.write_text(content) + print(f"wrote {path.relative_to(REPO_ROOT)}") + return True + + +def main() -> int: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument( + "--check", + action="store_true", + help="exit non-zero if generated files would change (for CI)", + ) + args = parser.parse_args() + + source = load_source() + + targets = [ + (CLAUDE_OUT, build_claude(source)), + (CODEX_OUT, build_codex(source)), + (CURSOR_OUT, build_cursor(source)), + ] + + all_match = True + for path, data in targets: + if not write_if_changed(path, serialize(data), check=args.check): + all_match = False + print(f"would update {path.relative_to(REPO_ROOT)}", file=sys.stderr) + + if args.check and not all_match: + print( + "error: marketplace files are stale. Run " + "`python3 scripts/generate-marketplaces.py` and commit the result.", + file=sys.stderr, + ) + return 1 + + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/scripts/validate-manifests.py b/scripts/validate-manifests.py new file mode 100755 index 0000000..3ad896e --- /dev/null +++ b/scripts/validate-manifests.py @@ -0,0 +1,161 @@ +#!/usr/bin/env python3 +""" +Validate every marketplace, plugin manifest, and skill in this repo. + +Checks performed: + 1. Generated marketplace files match marketplace.source.json + (delegates to `generate-marketplaces.py --check`). + 2. Each plugin's `source` path resolves to an existing directory. + 3. Each `skills` entry in a virtual marketplace plugin resolves to a real + skill folder with a valid SKILL.md (frontmatter: name + description). + 4. Each self-contained plugin folder under `plugins/` exposes the expected + manifests (`.claude-plugin/plugin.json`, `.codex-plugin/plugin.json`, + and `.cursor-plugin/plugin.json`). + 5. Every SKILL.md under skills/ has `name` and `description` frontmatter. + +Zero external dependencies. Python 3.9+ stdlib only. + +Exit code 0 = all OK. Non-zero = at least one validation error printed to stderr. +""" +from __future__ import annotations + +import json +import re +import subprocess +import sys +from pathlib import Path + +REPO_ROOT = Path(__file__).resolve().parent.parent +SOURCE_FILE = REPO_ROOT / "marketplace.source.json" + +CLAUDE_MP = REPO_ROOT / ".claude-plugin" / "marketplace.json" +CODEX_MP = REPO_ROOT / ".agents" / "plugins" / "marketplace.json" +CURSOR_MP = REPO_ROOT / ".cursor-plugin" / "marketplace.json" + +ROOT_SKILLS_DIR = REPO_ROOT / "skills" +PLUGINS_DIR = REPO_ROOT / "plugins" + +FRONTMATTER_RE = re.compile(r"^---\s*\n(.*?)\n---", re.DOTALL) +FRONTMATTER_FIELD_RE = re.compile(r"^([A-Za-z_][\w-]*)\s*:\s*(.+?)\s*$", re.MULTILINE) + + +class ValidationError(Exception): + """Single named validation failure.""" + + +def report(errors: list[str], msg: str) -> None: + errors.append(msg) + print(f" ✗ {msg}", file=sys.stderr) + + +def check_generator(errors: list[str]) -> None: + print("→ checking marketplace generator parity") + result = subprocess.run( + [sys.executable, str(REPO_ROOT / "scripts" / "generate-marketplaces.py"), "--check"], + capture_output=True, + text=True, + ) + if result.returncode != 0: + report(errors, f"generated marketplace files are out of sync. {result.stderr.strip()}") + + +def parse_frontmatter(skill_md: Path) -> dict[str, str]: + text = skill_md.read_text(encoding="utf-8") + match = FRONTMATTER_RE.match(text) + if not match: + raise ValidationError(f"{skill_md.relative_to(REPO_ROOT)}: missing YAML frontmatter") + fields: dict[str, str] = {} + for m in FRONTMATTER_FIELD_RE.finditer(match.group(1)): + fields[m.group(1)] = m.group(2) + return fields + + +def check_skill(skill_dir: Path, errors: list[str]) -> None: + skill_md = skill_dir / "SKILL.md" + rel = skill_dir.relative_to(REPO_ROOT) + if not skill_md.exists(): + report(errors, f"{rel}: SKILL.md missing") + return + try: + fields = parse_frontmatter(skill_md) + except ValidationError as exc: + report(errors, str(exc)) + return + if not fields.get("name"): + report(errors, f"{rel}/SKILL.md: frontmatter missing `name`") + if not fields.get("description"): + report(errors, f"{rel}/SKILL.md: frontmatter missing `description`") + + +def check_root_skills(errors: list[str]) -> None: + print(f"→ checking skills under {ROOT_SKILLS_DIR.relative_to(REPO_ROOT)}/") + if not ROOT_SKILLS_DIR.exists(): + return + for entry in sorted(ROOT_SKILLS_DIR.iterdir()): + if entry.is_dir() and not entry.name.startswith("."): + check_skill(entry, errors) + + +def check_plugin_dir(plugin_dir: Path, errors: list[str]) -> None: + rel = plugin_dir.relative_to(REPO_ROOT) + claude = plugin_dir / ".claude-plugin" / "plugin.json" + codex = plugin_dir / ".codex-plugin" / "plugin.json" + cursor = plugin_dir / ".cursor-plugin" / "plugin.json" + if not claude.exists(): + report(errors, f"{rel}: missing .claude-plugin/plugin.json") + if not codex.exists(): + report(errors, f"{rel}: missing .codex-plugin/plugin.json") + if not cursor.exists(): + report(errors, f"{rel}: missing .cursor-plugin/plugin.json") + # Walk every SKILL.md inside the plugin's skills folder. + plugin_skills = plugin_dir / "skills" + if plugin_skills.exists(): + for entry in sorted(plugin_skills.iterdir()): + if entry.is_dir() and not entry.name.startswith("."): + check_skill(entry, errors) + + +def check_self_contained_plugins(errors: list[str]) -> None: + if not PLUGINS_DIR.exists(): + return + print(f"→ checking plugins under {PLUGINS_DIR.relative_to(REPO_ROOT)}/") + for entry in sorted(PLUGINS_DIR.iterdir()): + if entry.is_dir() and not entry.name.startswith("."): + check_plugin_dir(entry, errors) + + +def check_source_paths(errors: list[str]) -> None: + print("→ checking marketplace.source.json plugin paths") + with SOURCE_FILE.open() as fh: + data = json.load(fh) + for plugin in data.get("plugins", []): + name = plugin["name"] + source = plugin["source"] + target = (REPO_ROOT / source).resolve() + if not target.exists(): + report(errors, f"plugin `{name}`: source path `{source}` does not exist") + if plugin.get("type") == "virtual": + for skill_path in plugin.get("skills", []): + resolved = (REPO_ROOT / skill_path).resolve() + if not resolved.exists(): + report(errors, f"plugin `{name}`: skill path `{skill_path}` does not exist") + + +def main() -> int: + errors: list[str] = [] + + check_generator(errors) + check_source_paths(errors) + check_root_skills(errors) + check_self_contained_plugins(errors) + + print() + if errors: + print(f"✗ {len(errors)} validation error(s)", file=sys.stderr) + return 1 + print("✓ all manifests valid") + return 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/skills/artist-workspace/SKILL.md b/skills/artist-workspace/SKILL.md index b582d9c..877c3d9 100644 --- a/skills/artist-workspace/SKILL.md +++ b/skills/artist-workspace/SKILL.md @@ -5,13 +5,13 @@ description: How to work in artist directories — including creating, enumerati # Artist Workspace -Every artist has a workspace — a directory that holds context, songs, and reference material. The `RECOUP.md` file at the root connects it to the Recoupable platform. +Every artist has a workspace — a directory that holds context, songs, and reference material. The `RECOUP.md` file at the root connects it to the Recoup platform. -Artist directories live inside the sandbox at `artists/{artist-slug}/`. The sandbox is already scoped to a single Recoupable organization (its repo *is* the org), so artists live at the top level — there is no `orgs/` directory. +Artist directories live inside the sandbox at `artists/{artist-slug}/`. The sandbox is already scoped to a single Recoup organization (its repo *is* the org), so artists live at the top level — there is no `orgs/` directory. ## Listing what's in the sandbox -When the account asks *"what artists do I have"*, *"list my artists"*, *"which orgs am I in"*, or any other inventory question about the sandbox, **walk the filesystem — it is authoritative for this sandbox.** Do not call the Recoupable API for this: the API answers "what artists does this account have access to across everything", which is a different (and usually larger) set than what the sandbox was opened for. +When the account asks *"what artists do I have"*, *"list my artists"*, *"which orgs am I in"*, or any other inventory question about the sandbox, **walk the filesystem — it is authoritative for this sandbox.** Do not call the Recoup API for this: the API answers "what artists does this account have access to across everything", which is a different (and usually larger) set than what the sandbox was opened for. ```bash # All artist workspaces in this sandbox diff --git a/skills/recoup-api/SKILL.md b/skills/recoup-api/SKILL.md index 1acafee..3a55563 100644 --- a/skills/recoup-api/SKILL.md +++ b/skills/recoup-api/SKILL.md @@ -1,11 +1,11 @@ --- name: recoup-api -description: Call the Recoupable API from the sandbox to fetch artist data, socials, organizations, research, documents and any other platform resource — and to invoke external connector actions (Google Docs / Drive / Sheets edits, Gmail, TikTok, Instagram, etc.) via Recoupable's shared connections. Use whenever you're asked for Recoup data, a Recoupable platform resource, or to read/write something outside Recoup like a Google Doc URL or a spreadsheet. Triggers on phrases like "look up artist", "fetch from recoup", "artist data", "artist socials", "organizations", "artist report", "research", "create new artist", "create artist", "onboard artist", "add artist", "edit this Google Doc", "read this doc", "update the spreadsheet", "send an email", "post on TikTok", "save to Drive", or whenever the user pastes a docs.google.com / drive.google.com / sheets.google.com URL. Always load this before writing curl calls against recoup-api.vercel.app. +description: Call the Recoup API from the sandbox to fetch artist data, socials, organizations, research, documents and any other platform resource — and to invoke external connector actions (Google Docs / Drive / Sheets edits, Gmail, TikTok, Instagram, etc.) via Recoup's shared connections. Use whenever you're asked for Recoup data, a Recoup platform resource, or to read/write something outside Recoup like a Google Doc URL or a spreadsheet. Triggers on phrases like "look up artist", "fetch from recoup", "artist data", "artist socials", "organizations", "artist report", "research", "create new artist", "create artist", "onboard artist", "add artist", "edit this Google Doc", "read this doc", "update the spreadsheet", "send an email", "post on TikTok", "save to Drive", or whenever the user pastes a docs.google.com / drive.google.com / sheets.google.com URL. Always load this before writing curl calls against recoup-api.vercel.app. --- -# Recoupable API +# Recoup API -Call the Recoupable production API to fetch artist data, social metrics, org context, research, and to trigger platform operations. +Call the Recoup production API to fetch artist data, social metrics, org context, research, and to trigger platform operations. - **Base URL:** `https://recoup-api.vercel.app/api` - **LLM-readable docs:** `https://developers.recoupable.com` — Mintlify site. Use `/llms.txt` for the endpoint index, `/llms-full.txt` for full content, and the OpenAPI JSONs listed below for machine-readable schemas. diff --git a/skills/setup-sandbox/SKILL.md b/skills/setup-sandbox/SKILL.md index f8654fa..9e92d25 100644 --- a/skills/setup-sandbox/SKILL.md +++ b/skills/setup-sandbox/SKILL.md @@ -29,7 +29,7 @@ Create the folder structure for the connected account's organizations and artist ## `RECOUP.md` -Every artist directory has a `RECOUP.md` at its root. This is the **identity file** — it connects the workspace to the Recoupable platform. The existence of this file means the workspace is active. +Every artist directory has a `RECOUP.md` at its root. This is the **identity file** — it connects the workspace to the Recoup platform. The existence of this file means the workspace is active. Fill it with data from the CLI response: @@ -37,7 +37,7 @@ Fill it with data from the CLI response: --- artistName: {Artist Name} artistSlug: {artist-slug} -artistId: {uuid-from-recoupable} +artistId: {uuid-from-recoup} --- ``` diff --git a/skills/trend-to-song/SKILL.md b/skills/trend-to-song/SKILL.md index 9042ea4..24d5a67 100644 --- a/skills/trend-to-song/SKILL.md +++ b/skills/trend-to-song/SKILL.md @@ -164,7 +164,7 @@ After each pipeline run, document what happened: - Next time: ``` -## Integration with Recoupable +## Integration with Recoup - Use the content agent to generate video edits once the song exists - The song can be added to an artist's catalog via the standard song pipeline