Claude Skill

suede-release-linter

Suede Labs skill that lints a local music or media release folder and scores it for release readiness: missing files, weak or malformed metadata, artwork and stem problems, split gaps, rights blockers, and platform-delivery issues, produced by a bundled offline script as a scored

LLM Mart · 0 points · 18 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download JasonColapietro-suede-creator-skills-skills_suede-release-linter-f192517.zip · 29 KB
Part of jasoncolapietro/suede-creator-skills — 70 skills

Install

skills CLI npx skills add https://github.com/JasonColapietro/suede-creator-skills/tree/main/skills/suede-release-linter
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install jasoncolapietro-suede-creator-skills@llmmart
Git git clone https://github.com/JasonColapietro/suede-creator-skills.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole jasoncolapietro/suede-creator-skills collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

Release Metadata Linter

Gate policy — advisory, not blocking

Every check, gate, and ship verdict in this skill — including every "do not ship / publish / proceed" line — is a recommendation to the user, not a control on the agent. Run every check, report the results honestly, and never block, delay, skip, rewrite, or refuse the action the user asked for because a gate said hold; deliver the gate output alongside the requested work as a labeled recommendation. A failed gate changes what you report, never what you do. Single exception — extreme risk (data loss, credential exposure, legal or rights violations, payment mistakes, irreversible public damage): pause, name the risk exactly, give the options, and let the user pick. Their choice is final.

Overview

Audit a music or media project folder and produce a practical release-readiness report. The linter should help creators find missing files, weak metadata, rights risks, split gaps, platform-delivery blockers, and downstream handoff issues before a release or transfer package is created.

Core principle: report what is present, missing, or unknown. Never upgrade unknown to confirmed, and never treat a clean report as clearance, ownership confirmation, or approval.

Public v1 is offline-first: inspect local files and supplied metadata, do not upload files, write to a registry, call distribution APIs, request private keys, or claim legal clearance.

Workflow

  1. Identify the source folder or supplied files.
  2. Ask for the output location if it is not obvious.
  3. Read references/lint-rules.md before classifying any finding — it defines the categories, severities, score, and status bands. Do not assign severities from memory.
  4. If working on a local folder, run scripts/lint_release.py to generate release-lint-report.md and release-lint-report.json. Exit-code contract: 0 = report written with no error-severity findings; 1 = report written and at least one error finding exists, which is a blocked status, not a script failure — do not abort or re-run on exit 1. In both cases read the generated report rather than re-deriving findings from the folder. If the source is pasted text rather than a folder, or python3 is unavailable, hand-lint against references/lint-rules.md, produce the same report shape from assets/release-lint-report.template.md, and label the report text-only.
  5. Read references/fix-guidance.md when turning findings into specific next actions.
  6. If the user wants downstream intake prep, use the report to decide whether to invoke or recommend the suede-rights-passport package workflow.
  7. Do not invent release metadata. Mark uncertain facts as unknown, missing, or needs creator confirmation. Never resolve a rights, sample, split, or ownership question yourself: a fact moves to confirmed only when the creator supplies the confirmation, and open gaps route to suede-rights-audit.
  8. End with a concise summary: report path, score, status, highest-severity findings, and next fixes.

Quick Start

python3 /path/to/suede-release-linter/scripts/lint_release.py \
  /path/to/music-project \
  --output /path/to/release-lint-output

If the source folder contains a metadata file, pass it explicitly:

python3 /path/to/suede-release-linter/scripts/lint_release.py \
  /path/to/music-project \
  --metadata /path/to/music-project/metadata.json \
  --output /path/to/release-lint-output

Accepted metadata formats are JSON, YAML/YML when PyYAML is installed, and public-safe key=value text files. Do not point metadata at real .env, credential, wallet, or deployment config files.

Safety defaults:

  • Hidden files, dependency folders, build outputs, caches, and secret-like files are skipped by default.
  • Unrecognized file types are skipped unless --include-other is passed.
  • Absolute local paths are redacted to share-safer names unless --include-absolute-paths is passed.
  • Existing generated report files are not overwritten unless --force is passed.
  • The output folder cannot be the same folder as the source or live inside it.
  • YAML metadata requires PyYAML: python3 -m pip install PyYAML.

What To Check

Read each bundled reference at the moment it is needed, not up front:

  • references/lint-rules.md: before classifying findings, or when hand-linting without the script — categories, severity levels, score, and status bands.
  • references/metadata-fields.md: when metadata is missing, malformed, or being authored — recommended fields, accepted aliases, and confirmation values.
  • references/fix-guidance.md: when turning findings into next actions or a fix plan.
  • references/passport-context.md: when the user asks how the lint report relates to Suede review or the Suede Creator Passport.

The script writes:

  • release-lint-report.md: human-readable report.
  • release-lint-report.json: machine-readable findings.

Use the bundled assets when repairing or hand-writing reports:

  • assets/release-lint-report.template.md
  • assets/release-lint-report.template.json
  • assets/metadata.example.json

Fixtures

Two synthetic release folders under scripts/fixtures/ (all names and metadata fake — no real personal data) exist only to regression-check the script. Read scripts/fixtures/README.md when changing scripts/lint_release.py; a normal lint of a user's folder never touches them.

Public Safety Rules

  • Do not say a project is legally cleared unless the user provides explicit proof.
  • Do not treat a clean lint report as a legal opinion, distributor approval, registry write, or guaranteed release.
  • Do not ask for private keys, seed phrases, unreleased account secrets, or full payment credentials.
  • Do not include private implementation details, private endpoints, internal provider names, or non-public pricing.
  • Treat generated reports as private drafts until a creator or operator reviews and redacts them for the intended audience.
  • Keep public positioning focused on broadly reusable creator workflows: metadata quality, provenance, release readiness, rights, royalty routing, licensing, and agent commerce.

Completion Checklist

Before reporting a lint result:

  • Confirm the source folder was inspected or state that the report is based only on supplied text.
  • Confirm whether metadata was discovered, supplied, or missing.
  • Report the score and severity counts.
  • List all error findings and the most important warning findings.
  • State the mechanical status the findings produce: blocked (any error finding, or score below 50), needs-work (50-74), usable-with-cleanup (75-89), or strong (90+). Never soften a blocked status in prose.
  • Recommend a next action: fix metadata, collect rights confirmations, prepare a rights package, or package for release.

Red flags — stop

If any of these appear in your reasoning, stop and re-read the core principle:

  • "The folder looks complete — skip the script." Run it. Eyeballing is not linting.
  • "The artist obviously owns it." Ownership status comes from the creator, not from the folder.
  • "One unconfirmed split won't block anything." Split errors block royalty routing and licensing by rule.
  • "Round the score up; it's close." The score is arithmetic, not judgment.
  • "A clean report means it's cleared." A clean report means fewer prep blockers. Nothing more.

Downstream Review Context

A clean release-lint report is a portable review artifact. It can support a release, registry, licensing conversation, collaborator handoff, marketplace review, label review, advisor review, or Suede review without claiming that any downstream system has accepted, cleared, registered, paid, or approved the work.

Routing

  • Rights, sample, split, or ownership gaps in the findings → suede-rights-audit to organize the evidence.
  • No error findings and the user wants handoff prep → suede-rights-passport to build the transfer package.
  • Track headed for film/TV/ads → suede-sync-packaging.
  • The release needs a rollout → suede-campaign-in-a-box.

Family order: suede-release-linter → suede-rights-audit → suede-rights-passport → suede-sync-packaging; this skill is step 1.

Files (suede-creator-skills)
  • agents
    • openai.yaml 359 B
      interface:
        display_name: "Release Metadata Linter"
        short_description: "Audit creator release files and rights gaps."
        brand_color: "#D71920"
        default_prompt: "Use $suede-release-linter to audit this creator project for release readiness — reporting findings without clearing rights or confirming ownership."
      
      policy:
        allow_implicit_invocation: true
      
  • assets
    • metadata.example.json 876 B
      {
        "title": "Example Song",
        "artist": "Example Artist",
        "release_date": "2026-06-01",
        "genre": "Alternative",
        "language": "en",
        "explicit": false,
        "description": "A short public-safe release description.",
        "contributors": [
          {
            "name": "Example Artist",
            "role": "writer, performer, producer",
            "master_percent": 100,
            "publishing_percent": 100,
            "confirmed": true
          }
        ],
        "rights": {
          "owner_claim": "Example Artist",
          "ownership_status": "confirmed",
          "contains_samples": "no",
          "sample_clearance_status": "not-needed",
          "cover_or_interpolation": "no",
          "release_history": "unreleased"
        },
        "suede": {
          "intended_services": [
            "rights-review",
            "mastering",
            "registry-readiness"
          ],
          "wallet_address": "",
          "registry_status": "not-registered",
          "agent_commerce": false
        }
      }
      
    • release-lint-report.template.json 431 B
      {
        "schema_version": "0.1.0",
        "package_type": "music-release-lint-report",
        "generated_at": null,
        "source_root": "",
        "metadata_source": "",
        "score": 0,
        "status": "blocked",
        "summary": {
          "title": "unknown",
          "artist": "unknown",
          "asset_counts": {
            "audio": 0,
            "video": 0,
            "artwork": 0,
            "lyrics": 0,
            "stem": 0,
            "document": 0,
            "other": 0
          }
        },
        "findings": []
      }
      
    • release-lint-report.template.md 874 B
      # Release Lint Report
      
      ## Summary
      
      - Project: unknown
      - Artist: unknown
      - Source: unknown
      - Metadata source: unknown
      - Score: 0
      - Status: blocked
      
      ## Severity Counts
      
      | Severity | Count |
      | --- | ---: |
      | Error | 0 |
      | Warning | 0 |
      | Info | 0 |
      
      ## Findings
      
      | Severity | Category | Code | Message | Fix |
      | --- | --- | --- | --- | --- |
      | error | metadata | missing-title | Title is missing. | Add official title. |
      
      ## Asset Inventory
      
      | Category | Count | Notes |
      | --- | ---: | --- |
      | Audio | 0 | none found |
      | Video | 0 | none found |
      | Artwork | 0 | none found |
      | Lyrics | 0 | none found |
      | Stems | 0 | none found |
      | Documents | 0 | none found |
      | Other | 0 | none found |
      
      ## Suede Readiness
      
      Blocked until error findings are resolved.
      
      ## Recommended Next Action
      
      Fix high-severity metadata, rights, and file blockers before preparing a Suede Rights Passport.
      
  • references
    • fix-guidance.md 1.6 KB
      # Fix Guidance
      
      ## Core Metadata
      
      Missing title or artist blocks release readiness. Ask the creator for the official public title and artist name before generating public-facing copy, registry entries, or delivery manifests.
      
      ## Primary Media
      
      If no primary media exists, ask which file is the final master. If only MP3/M4A is present, recommend a WAV/AIFF/FLAC master for high-quality delivery and Suede optimization.
      
      ## Artwork
      
      If artwork is missing, request cover art before distribution or catalog publication. If dimensions are weak, ask for a square high-resolution image.
      
      ## Lyrics And Stems
      
      Missing lyrics usually does not block release, but it reduces lyric sync, video, and app readiness. Missing stems does not always block release, but it limits remix, sync, licensing, and optimization workflows.
      
      ## Credits And Splits
      
      Missing credits or splits block royalty routing and licensing. If split totals are not 100, ask for a corrected split sheet. If contributors are unconfirmed, mark the project as blocked for royalty routing.
      
      ## Rights
      
      Unknown sample, cover, interpolation, loop, or beat lease status is a high-risk blocker. The next action is creator/legal confirmation, not technical optimization.
      
      ## Suede Next-Step Mapping
      
      - Strong score, no errors: prepare a Suede Rights Passport or transfer package.
      - Metadata warnings only: clean metadata, then prepare the transfer package.
      - Rights errors: collect rights confirmations before Suede optimization.
      - Media errors: locate final master and source files before Suede optimization.
      - Split errors: collect contributor confirmations before royalty routing or licensing.
      
    • lint-rules.md 2.5 KB
      # Release Lint Rules
      
      ## Severity Levels
      
      - `error`: Recommends blocking release, Suede intake, registry, licensing, royalty routing, or agent commerce. The status is a report to the creator, not an action lock; the creator decides what proceeds.
      - `warning`: Does not usually warrant a blocked recommendation, but should be fixed before public release or monetization.
      - `info`: Useful improvement or optional cleanup.
      
      ## Score
      
      Start at 100.
      
      - Each `error` subtracts 15 points.
      - Each `warning` subtracts 5 points.
      - Each `info` subtracts 1 point.
      - Minimum score is 0.
      
      Score bands:
      
      - `90-100`: strong
      - `75-89`: usable with cleanup
      - `50-74`: needs work
      - `0-49`: blocked
      
      ## Required Categories
      
      ### Core Release Metadata
      
      Error when title or artist/creator name is missing.
      
      Warning when release date, genre, language, description, tags, explicit-content status, or public URLs are missing.
      
      ### Primary Media
      
      Error when no primary audio/video file exists.
      
      Warning when only low-quality or compressed audio is present and no WAV/AIFF/FLAC master exists.
      
      Info when multiple possible masters are present and the final master is not obvious.
      
      ### Artwork
      
      Warning when no artwork image is present.
      
      Warning when artwork exists but is not square or is smaller than common distribution expectations. The script only checks dimensions when Pillow is installed.
      
      ### Lyrics And Timed Text
      
      Info when no lyrics are present.
      
      Warning when the release is clearly vocal music and no lyrics are present. Agents should infer this only from filenames or provided metadata, not guess from audio content.
      
      ### Stems
      
      Info when no stems are present.
      
      Warning when the user intends remix, sync, licensing, agent commerce, or Suede optimization and no stems are present.
      
      ### Credits And Splits
      
      Error when contributors, credits, or split details are missing.
      
      Error when split totals are present but do not add to 100 for master or publishing.
      
      Warning when contributors exist but confirmation status is missing.
      
      ### Rights And Licenses
      
      Error when sample, cover, interpolation, loop, or third-party beat status is unknown.
      
      Error when a project claims samples exist but clearance status is missing or uncleared.
      
      Warning when release/distribution history is unknown.
      
      ### Suede Intake
      
      Error when ownership, contributors, splits, or sample status blocks registry/licensing/royalty routing.
      
      Warning when provenance notes, file hashes, wallet/payment destinations, or intended Suede services are missing.
      
      Info when the project is a good candidate for `suede-rights-passport`.
      
    • metadata-fields.md 1.5 KB
      # Recommended Metadata Fields
      
      The linter accepts partial metadata. Missing recommended fields become findings.
      
      ## Minimal JSON Shape
      
      ```json
      {
        "title": "Song Title",
        "artist": "Artist Name",
        "release_date": "2026-06-01",
        "genre": "Alternative",
        "language": "en",
        "explicit": false,
        "description": "Short release description.",
        "contributors": [
          {
            "name": "Creator Name",
            "role": "writer, performer, producer",
            "master_percent": 100,
            "publishing_percent": 100,
            "confirmed": true
          }
        ],
        "rights": {
          "owner_claim": "Creator Name",
          "ownership_status": "confirmed",
          "contains_samples": "no",
          "sample_clearance_status": "not-needed",
          "cover_or_interpolation": "no",
          "release_history": "unreleased"
        },
        "suede": {
          "intended_services": ["rights-review", "mastering", "registry-readiness"],
          "wallet_address": "",
          "registry_status": "not-registered",
          "agent_commerce": false
        }
      }
      ```
      
      ## Accepted Aliases
      
      The linter should treat common aliases as equivalent when possible:
      
      - `artist`, `artist_name`, `creator`, `creator_name`
      - `track_title`, `song_title`, `title`, `project_title`
      - `contributors`, `credits`, `collaborators`
      - `splits`, `split_sheet`
      - `rights`, `license`, `licenses`
      - `contains_samples`, `samples`, `sample_status`
      - `cover_or_interpolation`, `cover`, `interpolation`
      
      ## Confirmation Values
      
      Use these normalized values in reports:
      
      - `confirmed`
      - `needs-confirmation`
      - `unknown`
      - `disputed`
      - `not-applicable`
      
    • passport-context.md 707 B
      # Suede Creator Passport Context
      
      The Suede Creator Passport is a forward-looking Suede-native record of creator
      work: registered IP, declared rights, release linting, rights packages, signed
      transfers, and other verified Suede activity.
      
      This public linter does not issue stamps, allocate rewards, gate access, write
      to a registry, or promise legal clearance. It creates an offline
      release-readiness report that Suede can review later:
      
      - `release-lint-report.md`
      - `release-lint-report.json`
      
      Use careful language. A clean lint report means the release folder has fewer
      metadata and rights-prep blockers. It does not mean a distributor, registry,
      platform, buyer, or legal reviewer has approved the work.
      
  • scripts
    • fixtures
      • sample-blocked-project
        • notes
          • project-notes.txt 223 B
            Synthetic fixture folder for suede-release-linter testing.
            
            This project deliberately omits required assets and metadata so the linter
            produces real, reproducible blocking errors. Not a real release. No real
            personal data.
            
        • metadata.json 839 B
          {
            "release_date": "",
            "genre": "",
            "language": "",
            "description": "",
            "contributors": [
              {
                "name": "Fixture Contributor One",
                "role": "writer",
                "master_percent": 50,
                "publishing_percent": 60,
                "confirmed": true
              },
              {
                "name": "Fixture Contributor Two",
                "role": "producer",
                "master_percent": 30,
                "publishing_percent": 50,
                "confirmed": false
              }
            ],
            "rights": {
              "owner_claim": "",
              "ownership_status": "unknown",
              "contains_samples": "yes",
              "sample_clearance_status": "unknown",
              "cover_or_interpolation": "unknown",
              "release_history": "unknown"
            },
            "suede": {
              "intended_services": ["sync", "remix"],
              "wallet_address": "",
              "provenance_notes": "",
              "registry_status": "not-registered",
              "agent_commerce": false
            }
          }
          
      • sample-clean-project
        • artwork
          • cover-art.jpg 39.7 KB · in bundle
        • audio
          • paper-moons-master.wav 15.7 KB · in bundle
        • lyrics
          • paper-moons-lyrics.txt 186 B
            [Verse 1]
            Placeholder lyric line one, synthetic fixture text.
            Placeholder lyric line two, for linter testing only.
            
            [Chorus]
            This is not a real song.
            This is a fixture, plain and clear.
            
        • stems
          • bass-stem.wav 15.7 KB · in bundle
          • drums-stem.wav 15.7 KB · in bundle
          • vocals-stem.wav 15.7 KB · in bundle
        • metadata.json 1.2 KB
          {
            "title": "Paper Moons",
            "artist": "Nova Ember",
            "release_date": "2026-08-14",
            "genre": "Indie Pop",
            "language": "en",
            "explicit": false,
            "description": "A synthetic fixture release used to sanity-check the suede-release-linter script. Not a real song.",
            "contributors": [
              {
                "name": "Nova Ember",
                "role": "writer, performer, producer",
                "master_percent": 70,
                "publishing_percent": 70,
                "confirmed": true
              },
              {
                "name": "Kai Rivers",
                "role": "co-producer, mixer",
                "master_percent": 30,
                "publishing_percent": 30,
                "confirmed": true
              }
            ],
            "rights": {
              "owner_claim": "Nova Ember",
              "ownership_status": "confirmed",
              "contains_samples": "no",
              "sample_clearance_status": "not-needed",
              "cover_or_interpolation": "no",
              "release_history": "unreleased"
            },
            "suede": {
              "intended_services": ["rights-review", "mastering", "registry-readiness"],
              "wallet_address": "example-fixture-wallet-0000",
              "provenance_notes": "Written and recorded entirely by the fixture contributors in a home studio session for linter testing purposes; no third-party material used.",
              "registry_status": "not-registered",
              "agent_commerce": false
            }
          }
          
      • README.md 2.1 KB
        # Release-lint fixtures
        
        Two synthetic release folders that sanity-check whether `scripts/lint_release.py`
        still categorizes correctly after any change to it. All names, contributors, and
        metadata in both fixtures are fake — no real personal data. This is maintainer
        documentation: an agent linting a real user folder never needs it.
        
        - `sample-clean-project/`: a small release folder (metadata, a WAV master,
          square 1600x1600 artwork, a lyrics file, three stems) shaped to score cleanly
          against `references/lint-rules.md`.
        - `sample-blocked-project/`: a release folder deliberately missing title,
          artist, primary media, artwork, ownership confirmation, and valid split
          totals, with samples indicated but clearance unconfirmed — it triggers real
          `error`-severity findings.
        - `sample-clean-project.expected.md` / `.expected.json` and
          `sample-blocked-project.expected.md` / `.expected.json`: the actual
          `release-lint-report.md` / `.json` output produced by running the script
          against each fixture, committed as a regression baseline.
        
        ## Re-checking the linter
        
        Run it from the skill folder and diff against the committed expected output:
        
        ```bash
        python3 scripts/lint_release.py scripts/fixtures/sample-clean-project \
          --output /tmp/suede-lint-check-clean
        diff scripts/fixtures/sample-clean-project.expected.md \
          /tmp/suede-lint-check-clean/release-lint-report.md
        
        python3 scripts/lint_release.py scripts/fixtures/sample-blocked-project \
          --output /tmp/suede-lint-check-blocked
        diff scripts/fixtures/sample-blocked-project.expected.md \
          /tmp/suede-lint-check-blocked/release-lint-report.md
        ```
        
        ## Expected results
        
        The clean fixture should score `99` with `0` errors, `0` warnings, and the
        status `strong` (the single unavoidable `info` finding is the
        `rights-passport-candidate` note the script always appends), exiting `0`. The
        blocked fixture should score `0` with `7` errors, `11` warnings, `2` info
        findings, and the status `blocked`, exiting `1`.
        
        The `.md` reports diff byte-for-byte on a clean re-run. The `.json` reports
        differ only in the `generated_at` timestamp line, since that field is set to the
        current time on every run.
        
      • sample-blocked-project.expected.json 5.8 KB
        {
          "schema_version": "0.1.0",
          "package_type": "music-release-lint-report",
          "generated_at": "2026-07-03T03:18:09.751608+00:00",
          "source_root": "sample-blocked-project",
          "metadata_source": "metadata.json",
          "score": 0,
          "status": "blocked",
          "severity_counts": {
            "error": 7,
            "warning": 11,
            "info": 2
          },
          "summary": {
            "title": "unknown",
            "artist": "unknown",
            "asset_counts": {
              "audio": 0,
              "video": 0,
              "artwork": 0,
              "lyrics": 0,
              "stem": 0,
              "document": 2,
              "other": 0
            }
          },
          "assets": [
            {
              "relative_path": "metadata.json",
              "category": "document",
              "role": "document",
              "suffix": ".json",
              "size_bytes": 839,
              "mime_guess": "application/json"
            },
            {
              "relative_path": "notes/project-notes.txt",
              "category": "document",
              "role": "document",
              "suffix": ".txt",
              "size_bytes": 223,
              "mime_guess": "text/plain"
            }
          ],
          "findings": [
            {
              "severity": "error",
              "category": "metadata",
              "code": "missing-title",
              "message": "Title is missing.",
              "fix": "Add the official public title."
            },
            {
              "severity": "error",
              "category": "metadata",
              "code": "missing-artist",
              "message": "Artist or creator name is missing.",
              "fix": "Add the official artist or creator name."
            },
            {
              "severity": "warning",
              "category": "metadata",
              "code": "missing-release-date",
              "message": "Release date is missing.",
              "fix": "Add release date metadata."
            },
            {
              "severity": "warning",
              "category": "metadata",
              "code": "missing-genre",
              "message": "Genre is missing.",
              "fix": "Add genre metadata."
            },
            {
              "severity": "warning",
              "category": "metadata",
              "code": "missing-language",
              "message": "Language is missing.",
              "fix": "Add language metadata."
            },
            {
              "severity": "warning",
              "category": "metadata",
              "code": "missing-description",
              "message": "Description is missing.",
              "fix": "Add description metadata."
            },
            {
              "severity": "warning",
              "category": "metadata",
              "code": "missing-explicit-status",
              "message": "Explicit-content status is missing.",
              "fix": "Add explicit true/false status."
            },
            {
              "severity": "error",
              "category": "media",
              "code": "missing-primary-media",
              "message": "No primary audio or video file was found.",
              "fix": "Add or identify the final master or primary media file."
            },
            {
              "severity": "warning",
              "category": "artwork",
              "code": "missing-artwork",
              "message": "No artwork image was found.",
              "fix": "Add release artwork before public distribution or catalog publication."
            },
            {
              "severity": "info",
              "category": "lyrics",
              "code": "missing-lyrics",
              "message": "No lyric or timed-text file was found.",
              "fix": "Add lyrics or timed lyrics if the release uses vocals."
            },
            {
              "severity": "warning",
              "category": "stems",
              "code": "missing-stems",
              "message": "No stems were found.",
              "fix": "Add stems or plan stem separation for remix, sync, licensing, or Suede optimization."
            },
            {
              "severity": "error",
              "category": "splits",
              "code": "invalid-master-split-total",
              "message": "Master splits add to 80, not 100.",
              "fix": "Correct master split percentages."
            },
            {
              "severity": "error",
              "category": "splits",
              "code": "invalid-publishing-split-total",
              "message": "Publishing splits add to 110, not 100.",
              "fix": "Correct publishing split percentages."
            },
            {
              "severity": "warning",
              "category": "credits",
              "code": "contributors-unconfirmed",
              "message": "One or more contributors lack confirmation.",
              "fix": "Collect contributor confirmations before royalty routing or licensing."
            },
            {
              "severity": "error",
              "category": "rights",
              "code": "ownership-unconfirmed",
              "message": "Ownership claim is missing or unconfirmed.",
              "fix": "Confirm master and publishing ownership."
            },
            {
              "severity": "error",
              "category": "rights",
              "code": "sample-clearance-missing",
              "message": "Samples, covers, interpolations, or third-party material are indicated but clearance is not confirmed.",
              "fix": "Provide clearance details or remove uncleared material before licensing."
            },
            {
              "severity": "warning",
              "category": "rights",
              "code": "release-history-unknown",
              "message": "Prior release, registration, minting, or licensing history is unknown.",
              "fix": "Confirm whether the work has already been distributed, registered, minted, licensed, or sold."
            },
            {
              "severity": "warning",
              "category": "suede",
              "code": "missing-wallet-or-payment-destination",
              "message": "No creator wallet or payment destination was provided.",
              "fix": "Add a public wallet or payment-routing note if Suede royalty routing or agent commerce is intended."
            },
            {
              "severity": "warning",
              "category": "suede",
              "code": "missing-provenance-notes",
              "message": "No provenance or creation-history notes were provided.",
              "fix": "Add creation history, source notes, or chain-of-custody details."
            },
            {
              "severity": "info",
              "category": "suede",
              "code": "rights-passport-candidate",
              "message": "Project can be prepared with a Suede Rights Passport after blockers are resolved.",
              "fix": "Use suede-rights-passport once error findings are fixed or clearly documented."
            }
          ],
          "recommended_next_action": "Fix error findings before preparing a Suede Rights Passport, registering rights, licensing, or routing royalties."
        }
        
      • sample-blocked-project.expected.md 3.7 KB
        # Release Lint Report
        
        Private draft: review and redact before publishing, committing, or sharing
        outside the intended Suede intake workflow.
        
        ## Summary
        
        - Project: unknown
        - Artist: unknown
        - Source: sample-blocked-project
        - Metadata source: metadata.json
        - Score: 0
        - Status: blocked
        
        ## Severity Counts
        
        | Severity | Count |
        | --- | ---: |
        | Error | 7 |
        | Warning | 11 |
        | Info | 2 |
        
        ## Findings
        
        | Severity | Category | Code | Message | Fix |
        | --- | --- | --- | --- | --- |
        | error | metadata | `missing-title` | Title is missing. | Add the official public title. |
        | error | metadata | `missing-artist` | Artist or creator name is missing. | Add the official artist or creator name. |
        | warning | metadata | `missing-release-date` | Release date is missing. | Add release date metadata. |
        | warning | metadata | `missing-genre` | Genre is missing. | Add genre metadata. |
        | warning | metadata | `missing-language` | Language is missing. | Add language metadata. |
        | warning | metadata | `missing-description` | Description is missing. | Add description metadata. |
        | warning | metadata | `missing-explicit-status` | Explicit-content status is missing. | Add explicit true/false status. |
        | error | media | `missing-primary-media` | No primary audio or video file was found. | Add or identify the final master or primary media file. |
        | warning | artwork | `missing-artwork` | No artwork image was found. | Add release artwork before public distribution or catalog publication. |
        | info | lyrics | `missing-lyrics` | No lyric or timed-text file was found. | Add lyrics or timed lyrics if the release uses vocals. |
        | warning | stems | `missing-stems` | No stems were found. | Add stems or plan stem separation for remix, sync, licensing, or Suede optimization. |
        | error | splits | `invalid-master-split-total` | Master splits add to 80, not 100. | Correct master split percentages. |
        | error | splits | `invalid-publishing-split-total` | Publishing splits add to 110, not 100. | Correct publishing split percentages. |
        | warning | credits | `contributors-unconfirmed` | One or more contributors lack confirmation. | Collect contributor confirmations before royalty routing or licensing. |
        | error | rights | `ownership-unconfirmed` | Ownership claim is missing or unconfirmed. | Confirm master and publishing ownership. |
        | error | rights | `sample-clearance-missing` | Samples, covers, interpolations, or third-party material are indicated but clearance is not confirmed. | Provide clearance details or remove uncleared material before licensing. |
        | warning | rights | `release-history-unknown` | Prior release, registration, minting, or licensing history is unknown. | Confirm whether the work has already been distributed, registered, minted, licensed, or sold. |
        | warning | suede | `missing-wallet-or-payment-destination` | No creator wallet or payment destination was provided. | Add a public wallet or payment-routing note if Suede royalty routing or agent commerce is intended. |
        | warning | suede | `missing-provenance-notes` | No provenance or creation-history notes were provided. | Add creation history, source notes, or chain-of-custody details. |
        | info | suede | `rights-passport-candidate` | Project can be prepared with a Suede Rights Passport after blockers are resolved. | Use suede-rights-passport once error findings are fixed or clearly documented. |
        
        ## Asset Inventory
        
        | Category | Count |
        | --- | ---: |
        | audio | 0 |
        | video | 0 |
        | artwork | 0 |
        | lyrics | 0 |
        | stem | 0 |
        | document | 2 |
        | other | 0 |
        
        ## Suede Readiness
        
        Blocked until error findings are resolved.
        
        ## Recommended Next Action
        
        Fix error findings before preparing a Suede Rights Passport, registering rights, licensing, or routing royalties.
        
      • sample-clean-project.expected.json 2.3 KB
        {
          "schema_version": "0.1.0",
          "package_type": "music-release-lint-report",
          "generated_at": "2026-07-03T03:18:09.588075+00:00",
          "source_root": "sample-clean-project",
          "metadata_source": "metadata.json",
          "score": 99,
          "status": "strong",
          "severity_counts": {
            "error": 0,
            "warning": 0,
            "info": 1
          },
          "summary": {
            "title": "Paper Moons",
            "artist": "Nova Ember",
            "asset_counts": {
              "audio": 1,
              "video": 0,
              "artwork": 1,
              "lyrics": 1,
              "stem": 3,
              "document": 1,
              "other": 0
            }
          },
          "assets": [
            {
              "relative_path": "artwork/cover-art.jpg",
              "category": "artwork",
              "role": "cover-art",
              "suffix": ".jpg",
              "size_bytes": 40628,
              "mime_guess": "image/jpeg"
            },
            {
              "relative_path": "audio/paper-moons-master.wav",
              "category": "audio",
              "role": "master",
              "suffix": ".wav",
              "size_bytes": 16044,
              "mime_guess": "audio/x-wav"
            },
            {
              "relative_path": "lyrics/paper-moons-lyrics.txt",
              "category": "lyrics",
              "role": "lyrics",
              "suffix": ".txt",
              "size_bytes": 186,
              "mime_guess": "text/plain"
            },
            {
              "relative_path": "metadata.json",
              "category": "document",
              "role": "document",
              "suffix": ".json",
              "size_bytes": 1250,
              "mime_guess": "application/json"
            },
            {
              "relative_path": "stems/bass-stem.wav",
              "category": "stem",
              "role": "stem",
              "suffix": ".wav",
              "size_bytes": 16044,
              "mime_guess": "audio/x-wav"
            },
            {
              "relative_path": "stems/drums-stem.wav",
              "category": "stem",
              "role": "stem",
              "suffix": ".wav",
              "size_bytes": 16044,
              "mime_guess": "audio/x-wav"
            },
            {
              "relative_path": "stems/vocals-stem.wav",
              "category": "stem",
              "role": "stem",
              "suffix": ".wav",
              "size_bytes": 16044,
              "mime_guess": "audio/x-wav"
            }
          ],
          "findings": [
            {
              "severity": "info",
              "category": "suede",
              "code": "rights-passport-candidate",
              "message": "Project can be prepared with a Suede Rights Passport after blockers are resolved.",
              "fix": "Use suede-rights-passport once error findings are fixed or clearly documented."
            }
          ],
          "recommended_next_action": "Prepare a Suede Rights Passport transfer package."
        }
        
      • sample-clean-project.expected.md 1013 B
        # Release Lint Report
        
        Private draft: review and redact before publishing, committing, or sharing
        outside the intended Suede intake workflow.
        
        ## Summary
        
        - Project: Paper Moons
        - Artist: Nova Ember
        - Source: sample-clean-project
        - Metadata source: metadata.json
        - Score: 99
        - Status: strong
        
        ## Severity Counts
        
        | Severity | Count |
        | --- | ---: |
        | Error | 0 |
        | Warning | 0 |
        | Info | 1 |
        
        ## Findings
        
        | Severity | Category | Code | Message | Fix |
        | --- | --- | --- | --- | --- |
        | info | suede | `rights-passport-candidate` | Project can be prepared with a Suede Rights Passport after blockers are resolved. | Use suede-rights-passport once error findings are fixed or clearly documented. |
        
        ## Asset Inventory
        
        | Category | Count |
        | --- | ---: |
        | audio | 1 |
        | video | 0 |
        | artwork | 1 |
        | lyrics | 1 |
        | stem | 3 |
        | document | 1 |
        | other | 0 |
        
        ## Suede Readiness
        
        Strong candidate for Suede Rights Passport preparation.
        
        ## Recommended Next Action
        
        Prepare a Suede Rights Passport transfer package.
        
    • lint_release.py 29.4 KB
      #!/usr/bin/env python3
      """Lint a music/media project folder for release and Suede intake readiness."""
      
      from __future__ import annotations
      
      import argparse
      import json
      import mimetypes
      import os
      from collections import Counter
      from datetime import datetime, timezone
      from pathlib import Path
      from typing import Any
      
      
      AUDIO_EXTS = {".wav", ".wave", ".aiff", ".aif", ".flac", ".mp3", ".m4a", ".aac", ".ogg", ".opus"}
      LOSSLESS_AUDIO_EXTS = {".wav", ".wave", ".aiff", ".aif", ".flac"}
      VIDEO_EXTS = {".mp4", ".mov", ".m4v", ".webm", ".avi", ".mkv"}
      IMAGE_EXTS = {".jpg", ".jpeg", ".png", ".webp", ".gif", ".tif", ".tiff"}
      LYRIC_EXTS = {".lrc", ".srt", ".vtt"}
      TEXT_EXTS = {".txt", ".md"}
      DOC_EXTS = {
          ".pdf",
          ".doc",
          ".docx",
          ".rtf",
          ".txt",
          ".md",
          ".csv",
          ".tsv",
          ".json",
          ".xls",
          ".xlsx",
      }
      
      SKIP_NAMES = {".ds_store", "thumbs.db"}
      DENY_DIR_NAMES = {
          ".aws",
          ".azure",
          ".cache",
          ".config",
          ".docker",
          ".gnupg",
          ".gcloud",
          ".git",
          ".hg",
          ".kube",
          ".mypy_cache",
          ".next",
          ".nuxt",
          ".pytest_cache",
          ".ruff_cache",
          ".ssh",
          ".svn",
          ".turbo",
          ".venv",
          "__pycache__",
          "build",
          "coverage",
          "dist",
          "env",
          "node_modules",
          "venv",
      }
      DENY_FILENAMES = {
          ".env",
          ".env.development",
          ".env.local",
          ".env.production",
          ".env.test",
          ".envrc",
          ".netrc",
          ".npmrc",
          ".pypirc",
          "credentials",
          "credentials.json",
          "id_ed25519",
          "id_rsa",
          "kubeconfig",
          "secrets.json",
          "wallet.json",
      }
      DENY_SUFFIXES = {".env", ".key", ".pem", ".p12", ".pfx"}
      SECRET_NAME_TOKENS = {
          "access_token",
          "api_key",
          "apikey",
          "auth_token",
          "client_secret",
          "credential",
          "credentials",
          "password",
          "private_key",
          "private-key",
          "privatekey",
          "refresh_token",
          "secret",
          "seed_phrase",
          "seed-phrase",
          "service_account",
      }
      GENERATED_FILENAMES = {"release-lint-report.md", "release-lint-report.json"}
      METADATA_CANDIDATES = {
          "metadata.json",
          "release.json",
          "suede-intake.json",
          "metadata.yaml",
          "metadata.yml",
          "release.yaml",
          "release.yml",
      }
      STEM_KEYWORDS = {
          "stem",
          "stems",
          "vocals",
          "vocal",
          "vox",
          "drums",
          "drum",
          "bass",
          "guitar",
          "keys",
          "piano",
          "instrumental",
          "inst",
      }
      
      
      class Finding(dict):
          def __init__(self, severity: str, category: str, code: str, message: str, fix: str) -> None:
              super().__init__(
                  severity=severity,
                  category=category,
                  code=code,
                  message=message,
                  fix=fix,
              )
      
      
      def parse_args() -> argparse.Namespace:
          parser = argparse.ArgumentParser(description="Lint a music release folder.")
          parser.add_argument("source", help="Source folder containing release assets.")
          parser.add_argument(
              "--metadata",
              default="",
              help="Optional metadata file: JSON, YAML/YML with PyYAML, or key=value text.",
          )
          parser.add_argument(
              "--output",
              default="release-lint-output",
              help="Output folder for release-lint-report.md/json. Must be outside the source folder.",
          )
          parser.add_argument(
              "--include-hidden",
              action="store_true",
              help="Include hidden files and folders. Secret-like files are still skipped.",
          )
          parser.add_argument(
              "--include-other",
              action="store_true",
              help="Include unrecognized file types in the inventory.",
          )
          parser.add_argument(
              "--include-absolute-paths",
              action="store_true",
              help="Write absolute local paths into reports. Off by default for public-safe sharing.",
          )
          parser.add_argument(
              "--force",
              action="store_true",
              help="Overwrite existing generated report files in the output folder.",
          )
          return parser.parse_args()
      
      
      def load_yaml_if_available(text: str) -> dict[str, Any]:
          try:
              import yaml  # type: ignore
          except Exception as exc:  # pragma: no cover - dependency-dependent
              raise ValueError("YAML metadata requires PyYAML") from exc
          data = yaml.safe_load(text)
          return data if isinstance(data, dict) else {}
      
      
      def load_key_value(text: str) -> dict[str, Any]:
          data: dict[str, Any] = {}
          for raw_line in text.splitlines():
              line = raw_line.strip()
              if not line or line.startswith("#"):
                  continue
              if "=" not in line:
                  continue
              key, value = line.split("=", 1)
              data[key.strip()] = value.strip().strip('"').strip("'")
          return data
      
      
      def load_metadata(path: Path | None) -> tuple[dict[str, Any], str]:
          if path is None:
              return {}, ""
          text = path.read_text(encoding="utf-8")
          suffix = path.suffix.lower()
          if suffix == ".json":
              data = json.loads(text)
              if not isinstance(data, dict):
                  raise ValueError(f"Metadata JSON must be an object: {path}")
              return data, str(path)
          if suffix in {".yaml", ".yml"}:
              return load_yaml_if_available(text), str(path)
          return load_key_value(text), str(path)
      
      
      def discover_metadata(source: Path, explicit: str) -> Path | None:
          if explicit:
              path = Path(explicit).expanduser().resolve()
              if not path.exists():
                  raise SystemExit(f"Metadata file not found: {path}")
              if is_denied_path(path, source):
                  raise SystemExit("Refusing to use a secret-like metadata path. Use a public-safe metadata JSON, YAML, or key=value text file.")
              return path
          for name in METADATA_CANDIDATES:
              candidate = source / name
              if candidate.exists() and candidate.is_file():
                  return candidate
          return None
      
      
      def normalize_key(data: dict[str, Any], aliases: list[str], default: Any = None) -> Any:
          for key in aliases:
              if key in data and data[key] not in (None, ""):
                  return data[key]
          return default
      
      
      def nested(data: dict[str, Any], key: str, default: Any = None) -> Any:
          value = data.get(key, default)
          return value if value not in (None, "") else default
      
      
      def classify_file(path: Path, source: Path) -> tuple[str, str]:
          ext = path.suffix.lower()
          name = path.name.lower()
          try:
              context_parts = path.relative_to(source).parts
          except ValueError:
              context_parts = (path.name,)
          context = "/".join(part.lower() for part in context_parts)
      
          if ext in AUDIO_EXTS:
              if any(keyword in context for keyword in STEM_KEYWORDS) and "master" not in context and "final" not in context:
                  return "stem", "stem"
              if "master" in context or "final" in context:
                  return "audio", "master"
              if "mix" in context:
                  return "audio", "mix"
              return "audio", "audio"
          if ext in VIDEO_EXTS:
              return "video", "video"
          if ext in LYRIC_EXTS or (ext in TEXT_EXTS and "lyric" in name):
              return "lyrics", "lyrics"
          if ext in IMAGE_EXTS:
              if "cover" in context or "artwork" in context:
                  return "artwork", "cover-art"
              return "artwork", "image"
          if ext in DOC_EXTS:
              if "split" in context:
                  return "document", "split-sheet"
              if "license" in context or "licence" in context:
                  return "document", "license"
              if "contract" in context:
                  return "document", "contract"
              return "document", "document"
          return "other", "unknown"
      
      
      def is_under(child: Path, parent: Path) -> bool:
          try:
              child.resolve().relative_to(parent.resolve())
              return True
          except ValueError:
              return False
      
      
      def is_hidden_path(path: Path, source: Path) -> bool:
          try:
              relative = path.relative_to(source)
          except ValueError:
              relative = path
          return any(part.startswith(".") for part in relative.parts)
      
      
      def is_denied_path(path: Path, source: Path) -> bool:
          try:
              relative = path.relative_to(source)
              within_source = True
          except ValueError:
              relative = path
              within_source = False
          lowered_parts = [part.lower() for part in relative.parts]
          # Only apply the directory-name denylist to ancestors inside the source
          # folder. For an explicit out-of-source --metadata path, the absolute path
          # may pass through benign dirs (.cache, .config, .next, ...) that would
          # otherwise trigger a false "secret-like path" rejection.
          if within_source and any(part in DENY_DIR_NAMES for part in lowered_parts[:-1]):
              return True
          filename = lowered_parts[-1]
          if filename in DENY_FILENAMES or filename.startswith(".env"):
              return True
          if path.suffix.lower() in DENY_SUFFIXES:
              return True
          normalized = filename.replace(".", "_")
          return any(token in normalized for token in SECRET_NAME_TOKENS)
      
      
      def display_path(path: Path, source: Path, include_absolute: bool) -> str:
          if include_absolute:
              return str(path)
          try:
              return path.relative_to(source).as_posix()
          except ValueError:
              return path.name
      
      
      def validate_output(source: Path, output: Path, force: bool) -> None:
          if output == source:
              raise SystemExit("Output folder must be separate from the source folder.")
          if is_under(output, source):
              raise SystemExit("Output folder must be outside the source folder.")
          existing = [output / filename for filename in GENERATED_FILENAMES if (output / filename).exists()]
          if existing and not force:
              names = ", ".join(path.name for path in existing)
              raise SystemExit(f"Refusing to overwrite existing report files ({names}); pass --force to replace them.")
      
      
      def inventory_files(source: Path, output: Path, include_hidden: bool, include_other: bool) -> list[dict[str, Any]]:
          assets = []
          for path in sorted(source.rglob("*")):
              if not path.is_file():
                  continue
              if path.name.lower() in SKIP_NAMES:
                  continue
              if is_under(path, output):
                  continue
              if is_denied_path(path, source):
                  continue
              if not include_hidden and is_hidden_path(path, source):
                  continue
              category, role = classify_file(path, source)
              if category == "other" and not include_other:
                  continue
              assets.append(
                  {
                      "relative_path": path.relative_to(source).as_posix(),
                      "category": category,
                      "role": role,
                      "suffix": path.suffix.lower(),
                      "size_bytes": path.stat().st_size,
                      "mime_guess": mimetypes.guess_type(path.name)[0] or "application/octet-stream",
                  }
              )
          return assets
      
      
      def optional_image_dimensions(path: Path) -> tuple[int, int] | None:
          try:
              from PIL import Image  # type: ignore
          except Exception:
              return None
          try:
              with Image.open(path) as image:
                  return image.size
          except Exception:
              return None
      
      
      def maybe_number(value: Any) -> float | None:
          try:
              if value in (None, ""):
                  return None
              return float(value)
          except (TypeError, ValueError):
              return None
      
      
      def boolish(value: Any) -> bool | None:
          if isinstance(value, bool):
              return value
          if isinstance(value, str):
              lowered = value.strip().lower()
              if lowered in {"true", "yes", "y", "1", "confirmed"}:
                  return True
              if lowered in {"false", "no", "n", "0", "unknown", "unconfirmed"}:
                  return False
          return None
      
      
      def contributor_sums(contributors: Any) -> tuple[float | None, float | None, bool]:
          if not isinstance(contributors, list) or not contributors:
              return None, None, False
          master_total = 0.0
          publishing_total = 0.0
          master_seen = False
          publishing_seen = False
          all_confirmed = True
          for contributor in contributors:
              if not isinstance(contributor, dict):
                  all_confirmed = False
                  continue
              master = maybe_number(
                  normalize_key(contributor, ["master_percent", "master_pct", "master", "master_split"])
              )
              publishing = maybe_number(
                  normalize_key(
                      contributor,
                      ["publishing_percent", "publishing_pct", "publishing", "publishing_split"],
                  )
              )
              if master is not None:
                  master_seen = True
                  master_total += master
              if publishing is not None:
                  publishing_seen = True
                  publishing_total += publishing
              confirmed = boolish(normalize_key(contributor, ["confirmed", "confirmation", "approved"]))
              if confirmed is not True:
                  all_confirmed = False
          return (
              master_total if master_seen else None,
              publishing_total if publishing_seen else None,
              all_confirmed,
          )
      
      
      def scoped_value(metadata: dict[str, Any], scope_name: str, keys: str | list[str], default: Any = None) -> Any:
          aliases = [keys] if isinstance(keys, str) else keys
          scoped = metadata.get(scope_name)
          if isinstance(scoped, dict):
              for key in aliases:
                  if key in scoped and scoped[key] not in (None, ""):
                      return scoped[key]
          for key in aliases:
              if key in metadata and metadata[key] not in (None, ""):
                  return metadata[key]
          return default
      
      
      def rights_value(metadata: dict[str, Any], keys: str | list[str], default: Any = None) -> Any:
          return scoped_value(metadata, "rights", keys, default)
      
      
      def project_value(metadata: dict[str, Any], keys: str | list[str], default: Any = None) -> Any:
          return scoped_value(metadata, "project", keys, default)
      
      
      def creator_value(metadata: dict[str, Any], keys: str | list[str], default: Any = None) -> Any:
          return scoped_value(metadata, "creator", keys, default)
      
      
      def suede_value(metadata: dict[str, Any], keys: str | list[str], default: Any = None) -> Any:
          return scoped_value(metadata, "suede", keys, default)
      
      
      def confirmed_status(value: Any) -> bool:
          bool_value = boolish(value)
          if bool_value is True:
              return True
          return str(value).strip().lower() in {"confirmed", "claimed", "cleared", "yes", "true"}
      
      
      def negative_status(value: Any) -> bool:
          return str(value).strip().lower() in {
              "no",
              "none",
              "false",
              "not-needed",
              "not_needed",
              "not-applicable",
              "not_applicable",
              "n/a",
          }
      
      
      def positive_status(value: Any) -> bool:
          return boolish(value) is True or str(value).strip().lower() in {"1", "y", "yes", "true", "contains-samples", "contains_samples", "sampled"}
      
      
      def unknown_status(value: Any) -> bool:
          return str(value).strip().lower() in {"unknown", "", "unconfirmed", "needs-confirmation"}
      
      
      def add(findings: list[Finding], severity: str, category: str, code: str, message: str, fix: str) -> None:
          findings.append(Finding(severity, category, code, message, fix))
      
      
      def lint(metadata: dict[str, Any], assets: list[dict[str, Any]], source: Path) -> list[Finding]:
          findings: list[Finding] = []
          counts = Counter(asset["category"] for asset in assets)
      
          title = project_value(metadata, ["title", "track_title", "song_title", "project_title"])
          artist = project_value(
              metadata,
              ["artist", "artist_name", "creator_name"],
              creator_value(metadata, ["name", "artist", "artist_name", "creator_name"]),
          )
          if not title:
              add(findings, "error", "metadata", "missing-title", "Title is missing.", "Add the official public title.")
          if not artist:
              add(findings, "error", "metadata", "missing-artist", "Artist or creator name is missing.", "Add the official artist or creator name.")
      
          for key, label in [
              ("release_date", "Release date"),
              ("genre", "Genre"),
              ("language", "Language"),
              ("description", "Description"),
          ]:
              if not nested(metadata, key):
                  add(findings, "warning", "metadata", f"missing-{key.replace('_', '-')}", f"{label} is missing.", f"Add {label.lower()} metadata.")
      
          if "explicit" not in metadata and "explicit_content" not in metadata:
              add(findings, "warning", "metadata", "missing-explicit-status", "Explicit-content status is missing.", "Add explicit true/false status.")
      
          if counts["audio"] + counts["video"] == 0:
              add(findings, "error", "media", "missing-primary-media", "No primary audio or video file was found.", "Add or identify the final master or primary media file.")
      
          audio_assets = [asset for asset in assets if asset["category"] == "audio"]
          if audio_assets and not any(asset["suffix"] in LOSSLESS_AUDIO_EXTS for asset in audio_assets):
              add(findings, "warning", "media", "missing-lossless-master", "Audio exists but no WAV, AIFF, or FLAC master was found.", "Add a lossless master for high-quality delivery and optimization.")
      
          possible_masters = [
              asset
              for asset in audio_assets
              if asset["role"] == "master" or "master" in asset["relative_path"].lower() or "final" in asset["relative_path"].lower()
          ]
          if len(possible_masters) > 1:
              add(findings, "info", "media", "multiple-possible-masters", "Multiple possible masters were found.", "Mark one file as the final master.")
      
          if counts["artwork"] == 0:
              add(findings, "warning", "artwork", "missing-artwork", "No artwork image was found.", "Add release artwork before public distribution or catalog publication.")
          else:
              for asset in assets:
                  if asset["category"] != "artwork":
                      continue
                  dimensions = optional_image_dimensions(source / asset["relative_path"])
                  if dimensions is None:
                      continue
                  width, height = dimensions
                  if width != height or width < 1400 or height < 1400:
                      add(
                          findings,
                          "warning",
                          "artwork",
                          "weak-artwork-dimensions",
                          f"Artwork `{asset['relative_path']}` is {width}x{height}.",
                          "Use square high-resolution artwork when possible.",
                      )
      
          if counts["lyrics"] == 0:
              add(findings, "info", "lyrics", "missing-lyrics", "No lyric or timed-text file was found.", "Add lyrics or timed lyrics if the release uses vocals.")
      
          intended_services = suede_value(metadata, "intended_services", [])
          intended_text = " ".join(intended_services) if isinstance(intended_services, list) else str(intended_services)
          needs_stems = any(token in intended_text.lower() for token in ["remix", "sync", "licens", "commerce", "optimization", "stems"])
          if counts["stem"] == 0:
              severity = "warning" if needs_stems else "info"
              add(findings, severity, "stems", "missing-stems", "No stems were found.", "Add stems or plan stem separation for remix, sync, licensing, or Suede optimization.")
      
          contributors = rights_value(metadata, ["contributors", "credits", "collaborators"])
          if not contributors:
              add(findings, "error", "credits", "missing-contributors", "Contributor or credit details are missing.", "Add contributors, roles, and confirmation status.")
          master_total, publishing_total, all_confirmed = contributor_sums(contributors)
          if master_total is not None and round(master_total, 4) != 100:
              add(findings, "error", "splits", "invalid-master-split-total", f"Master splits add to {master_total:g}, not 100.", "Correct master split percentages.")
          if publishing_total is not None and round(publishing_total, 4) != 100:
              add(findings, "error", "splits", "invalid-publishing-split-total", f"Publishing splits add to {publishing_total:g}, not 100.", "Correct publishing split percentages.")
          if contributors and not all_confirmed:
              add(findings, "warning", "credits", "contributors-unconfirmed", "One or more contributors lack confirmation.", "Collect contributor confirmations before royalty routing or licensing.")
      
          owner_claim = rights_value(
              metadata,
              ["owner_claim", "owner", "rights_owner", "master_owner", "publishing_owner"],
          )
          ownership_status = rights_value(
              metadata,
              ["ownership_status", "owner_status", "ownership_confirmed", "owner_confirmed"],
              "unknown",
          )
          if not owner_claim or not confirmed_status(ownership_status):
              add(findings, "error", "rights", "ownership-unconfirmed", "Ownership claim is missing or unconfirmed.", "Confirm master and publishing ownership.")
      
          contains_samples_value = rights_value(
              metadata,
              ["contains_samples", "samples", "sample_status", "third_party_material"],
              "unknown",
          )
          cover_or_interpolation_value = rights_value(
              metadata,
              ["cover_or_interpolation", "cover", "interpolation_status"],
              "unknown",
          )
          sample_clearance = str(
              rights_value(metadata, ["sample_clearance_status", "sample_clearance", "clearance_status"], "unknown")
          ).lower()
          if unknown_status(contains_samples_value) and unknown_status(cover_or_interpolation_value):
              add(findings, "error", "rights", "sample-status-unknown", "Sample, cover, interpolation, loop, or beat lease status is unknown.", "Confirm third-party material and clearance status.")
          third_party_indicated = positive_status(contains_samples_value) or positive_status(cover_or_interpolation_value)
          if third_party_indicated and sample_clearance not in {"cleared", "not-needed", "not_needed", "not_applicable", "not-applicable"}:
              add(findings, "error", "rights", "sample-clearance-missing", "Samples, covers, interpolations, or third-party material are indicated but clearance is not confirmed.", "Provide clearance details or remove uncleared material before licensing.")
      
          release_history = rights_value(
              metadata,
              "release_history",
              project_value(metadata, ["release_status", "release_history"], metadata.get("release_status", "unknown")),
          )
          if unknown_status(release_history):
              add(findings, "warning", "rights", "release-history-unknown", "Prior release, registration, minting, or licensing history is unknown.", "Confirm whether the work has already been distributed, registered, minted, licensed, or sold.")
      
          wallet = suede_value(
              metadata,
              ["wallet_address", "wallet", "payment_destination", "payment_address", "royalty_destination"],
              creator_value(
                  metadata,
                  ["wallet_address", "wallet", "payment_destination", "payment_address", "royalty_destination"],
                  metadata.get("wallet_address", ""),
              ),
          )
          if not wallet:
              add(findings, "warning", "suede", "missing-wallet-or-payment-destination", "No creator wallet or payment destination was provided.", "Add a public wallet or payment-routing note if Suede royalty routing or agent commerce is intended.")
      
          provenance_notes = suede_value(
              metadata,
              ["provenance_notes", "creation_notes", "chain_of_custody", "source_notes"],
              scoped_value(
                  metadata,
                  "provenance",
                  ["provenance_notes", "creation_notes", "chain_of_custody", "source_notes"],
                  metadata.get("provenance_notes", ""),
              ),
          )
          if not provenance_notes:
              add(findings, "warning", "suede", "missing-provenance-notes", "No provenance or creation-history notes were provided.", "Add creation history, source notes, or chain-of-custody details.")
      
          add(findings, "info", "suede", "rights-passport-candidate", "Project can be prepared with a Suede Rights Passport after blockers are resolved.", "Use suede-rights-passport once error findings are fixed or clearly documented.")
          return findings
      
      
      def score(findings: list[Finding]) -> int:
          value = 100
          for finding in findings:
              if finding["severity"] == "error":
                  value -= 15
              elif finding["severity"] == "warning":
                  value -= 5
              else:
                  value -= 1
          return max(value, 0)
      
      
      def status_for(score_value: int, severity_counts: Counter) -> str:
          if severity_counts["error"]:
              return "blocked"
          if score_value >= 90:
              return "strong"
          if score_value >= 75:
              return "usable-with-cleanup"
          if score_value >= 50:
              return "needs-work"
          return "blocked"
      
      
      def write_json(path: Path, data: dict[str, Any]) -> None:
          path.write_text(json.dumps(data, indent=2, ensure_ascii=True) + "\n", encoding="utf-8")
      
      
      def markdown_table_findings(findings: list[Finding]) -> str:
          rows = ["| Severity | Category | Code | Message | Fix |", "| --- | --- | --- | --- | --- |"]
          for finding in findings:
              rows.append(
                  f"| {finding['severity']} | {finding['category']} | `{finding['code']}` | "
                  f"{finding['message']} | {finding['fix']} |"
              )
          return "\n".join(rows)
      
      
      def markdown_asset_counts(counts: Counter) -> str:
          rows = ["| Category | Count |", "| --- | ---: |"]
          for category in ["audio", "video", "artwork", "lyrics", "stem", "document", "other"]:
              rows.append(f"| {category} | {counts[category]} |")
          return "\n".join(rows)
      
      
      def write_markdown_report(path: Path, report: dict[str, Any]) -> None:
          counts = report["summary"]["asset_counts"]
          severity_counts = report["severity_counts"]
          title = report["summary"]["title"]
          artist = report["summary"]["artist"]
          readiness = "Blocked until error findings are resolved."
          if report["status"] == "strong":
              readiness = "Strong candidate for Suede Rights Passport preparation."
          elif report["status"] == "usable-with-cleanup":
              readiness = "Usable with cleanup before final release or Suede intake."
      
          content = f"""# Release Lint Report
      
      Private draft: review and redact before publishing, committing, or sharing
      outside the intended Suede intake workflow.
      
      ## Summary
      
      - Project: {title}
      - Artist: {artist}
      - Source: {report['source_root']}
      - Metadata source: {report['metadata_source'] or 'none detected'}
      - Score: {report['score']}
      - Status: {report['status']}
      
      ## Severity Counts
      
      | Severity | Count |
      | --- | ---: |
      | Error | {severity_counts['error']} |
      | Warning | {severity_counts['warning']} |
      | Info | {severity_counts['info']} |
      
      ## Findings
      
      {markdown_table_findings(report['findings'])}
      
      ## Asset Inventory
      
      {markdown_asset_counts(Counter(counts))}
      
      ## Suede Readiness
      
      {readiness}
      
      ## Recommended Next Action
      
      {report['recommended_next_action']}
      """
          path.write_text(content, encoding="utf-8")
      
      
      def recommend_next_action(status: str, severity_counts: Counter) -> str:
          if severity_counts["error"]:
              return "Fix error findings before preparing a Suede Rights Passport, registering rights, licensing, or routing royalties."
          if status == "strong":
              return "Prepare a Suede Rights Passport transfer package."
          return "Clean warning findings, then prepare a Suede Rights Passport transfer package."
      
      
      def main() -> int:
          args = parse_args()
          source = Path(args.source).expanduser().resolve()
          output = Path(args.output).expanduser().resolve()
          if not source.exists() or not source.is_dir():
              raise SystemExit(f"Source folder not found: {source}")
          validate_output(source, output, args.force)
          output.mkdir(parents=True, exist_ok=True)
      
          metadata_path = discover_metadata(source, args.metadata)
          try:
              metadata, _metadata_source = load_metadata(metadata_path)
          except Exception as exc:
              raise SystemExit(f"Could not load metadata: {exc}") from exc
          metadata_source = display_path(metadata_path, source, args.include_absolute_paths) if metadata_path else ""
          assets = inventory_files(source, output, args.include_hidden, args.include_other)
          findings = lint(metadata, assets, source)
          severity_counts = Counter(finding["severity"] for finding in findings)
          score_value = score(findings)
          status = status_for(score_value, severity_counts)
          asset_counts = Counter(asset["category"] for asset in assets)
      
          title = project_value(metadata, ["title", "track_title", "song_title", "project_title"], "unknown")
          artist = project_value(
              metadata,
              ["artist", "artist_name", "creator_name"],
              creator_value(metadata, ["name", "artist", "artist_name", "creator_name"], "unknown"),
          )
      
          report = {
              "schema_version": "0.1.0",
              "package_type": "music-release-lint-report",
              "generated_at": datetime.now(timezone.utc).isoformat(),
              "source_root": str(source) if args.include_absolute_paths else source.name,
              "metadata_source": metadata_source,
              "score": score_value,
              "status": status,
              "severity_counts": {
                  "error": severity_counts["error"],
                  "warning": severity_counts["warning"],
                  "info": severity_counts["info"],
              },
              "summary": {
                  "title": title,
                  "artist": artist,
                  "asset_counts": {
                      "audio": asset_counts["audio"],
                      "video": asset_counts["video"],
                      "artwork": asset_counts["artwork"],
                      "lyrics": asset_counts["lyrics"],
                      "stem": asset_counts["stem"],
                      "document": asset_counts["document"],
                      "other": asset_counts["other"],
                  },
              },
              "assets": assets,
              "findings": findings,
              "recommended_next_action": recommend_next_action(status, severity_counts),
          }
      
          write_json(output / "release-lint-report.json", report)
          write_markdown_report(output / "release-lint-report.md", report)
      
          print(f"Created release lint report: {output}")
          print(f"Score: {score_value}")
          print(
              "Findings: "
              f"{severity_counts['error']} error, "
              f"{severity_counts['warning']} warning, "
              f"{severity_counts['info']} info"
          )
          return 1 if severity_counts["error"] else 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • CARD.md 5.1 KB
    # Skill Card — Release Metadata Linter
    
    <!-- Generated by scripts/build-skill-cards.mjs — do not hand-edit. -->
    <!-- Regenerate with: npm run build:cards -->
    
    Release record for the `suede-release-linter` skill, following the NVIDIA skill-card template (<https://docs.nvidia.com/skills/skill-cards>). It tells a reviewer what the skill does, who owns it, what it needs, what could go wrong, and what evidence backs the release — without requiring them to open the source first.
    
    ## Description
    
    Suede Labs skill that lints a local music or media release folder and scores it for release readiness: missing files, weak or malformed metadata, artwork and stem problems, split gaps, rights blockers, and platform-delivery issues, produced by a bundled offline script as a scored markdown and JSON report.
    
    Status: production. Ships in the `suede-skills` plugin (the full pack) at release 0.19.0; loads as a Claude Code / Codex agent skill from this directory's [SKILL.md](./SKILL.md).
    
    ## Owner
    
    Jason Colapietro, Suede Labs AI (<https://github.com/JasonColapietro>). Security contact: `info@suedeai.ai` per [SECURITY.md](../../SECURITY.md).
    
    ## License / Terms of Use
    
    MIT ([LICENSE](../../LICENSE)). The pack's combined license expression is `MIT AND BSD-3-Clause`; this skill bundles no third-party licensed material of its own.
    
    ## Use Case
    
    Target users: developers and creators running the skill inside a Claude Code or Codex CLI session.
    
    Use when a creator has one or more release folders of tracks, artwork, and stems and wants to know what is missing before distributing, delivering, or handing it off; when metadata quality or a delivery rejection is the question; or when a release-readiness score is asked for. Reports what is present, missing, or unknown; never upgrades unknown to confirmed and never means legal clearance.
    
    Out of scope — organizing the evidence behind a rights or split gap the report surfaces (use suede-rights-audit); building the transfer package (use suede-rights-passport); a sync one-sheet (use suede-sync-packaging).
    
    ## Deployment Geography
    
    Global. The skill is a prompt-and-script package that runs locally inside the invoking agent session; it pins no region-specific service of its own.
    
    ## Requirements / Dependencies
    
    - A Claude Code or Codex CLI session with the `suede-skills` plugin installed (install options: <https://skills.suedeai.ai/>).
    - Bundled files loaded relative to this directory: `agents/` (1 file), `scripts/` (15 files), `references/` (4 files), `assets/` (3 files).
    - Credentials: none are bundled or required by the skill files. Any tool or API credentials come from the host session; never paste credentials into skill files, prompts, or outputs.
    
    ## Known Risks and Mitigations
    
    - Risk: an agent treats a quality gate as autonomous authority. Mitigation: every gate in the pack is advisory — it changes what is reported, never what the user decided; only extreme-risk findings (data loss, credential exposure, legal/rights violations, payment mistakes, irreversible public damage) pause for the user's explicit choice.
    - Risk: a skill instruction is used to act outside its mandate. Mitigation: the hard limits in the skill body's "Public Safety Rules" section, quoted below.
    
    From "Public Safety Rules":
    
    - Do not say a project is legally cleared unless the user provides explicit proof.
    - Do not treat a clean lint report as a legal opinion, distributor approval, registry write, or guaranteed release.
    - Do not ask for private keys, seed phrases, unreleased account secrets, or full payment credentials.
    - Do not include private implementation details, private endpoints, internal provider names, or non-public pricing.
    - Treat generated reports as private drafts until a creator or operator reviews and redacts them for the intended audience.
    - Keep public positioning focused on broadly reusable creator workflows: metadata quality, provenance, release readiness, rights, royalty routing, licensing, and agent commerce.
    
    ## References
    
    - Skill source: [`skills/suede-release-linter/SKILL.md`](./SKILL.md)
    - Rendered reference page: <https://skills.suedeai.ai/skills/suede-release-linter.html>
    - Security policy and reviewed scanner exceptions: [SECURITY.md](../../SECURITY.md) and [`.plugin-scanner.toml`](../../.plugin-scanner.toml) at the repo root
    
    ## Skill Output
    
    Markdown analysis and recommendations returned in the agent's response. The skill publishes, posts, and sends nothing without the user's explicit authorization; delivery decisions stay with the user.
    
    ## Skill Version
    
    0.19.0 — the pack is single-versioned, so every skill releases together; see [VERSION](../../VERSION) and [CITATION.cff](../../CITATION.cff) for the release identifier this card describes.
    
    ## Ethical Considerations
    
    - The skill produces recommendations for a human decision-maker. Publishing, sending, payment, and rights decisions stay with the user.
    - Its gates require verifiable claims and honest reporting; do not use the skill to fabricate claims, evidence, metrics, or attribution.
    - Report suspected misuse or a security concern privately per [SECURITY.md](../../SECURITY.md); do not open a public issue for it.
    
  • SKILL.md 9 KB
    ---
    name: suede-release-linter
    description: "Suede Labs skill that lints a local music or media release folder and scores it for release readiness: missing files, weak or malformed metadata, artwork and stem problems, split gaps, rights blockers, and platform-delivery issues, produced by a bundled offline script as a scored markdown and JSON report. Use when a creator has one or more release folders of tracks, artwork, and stems and wants to know what is missing before distributing, delivering, or handing it off; when metadata quality or a delivery rejection is the question; or when a release-readiness score is asked for. Reports what is present, missing, or unknown; never upgrades unknown to confirmed and never means legal clearance. NOT FOR: organizing the evidence behind a rights or split gap the report surfaces (use suede-rights-audit); building the transfer package (use suede-rights-passport); a sync one-sheet (use suede-sync-packaging)."
    ---
    
    # Release Metadata Linter
    
    ## Gate policy — advisory, not blocking
    
    Every check, gate, and ship verdict in this skill — including every "do not
    ship / publish / proceed" line — is a **recommendation to the user, not a
    control on the agent**. Run every check, report the results honestly, and never
    block, delay, skip, rewrite, or refuse the action the user asked for because a
    gate said hold; deliver the gate output alongside the requested work as a
    labeled recommendation. A failed gate changes what you report, never what you
    do. Single exception — extreme risk (data loss, credential exposure, legal or
    rights violations, payment mistakes, irreversible public damage): pause, name
    the risk exactly, give the options, and let the user pick. Their choice is final.
    
    ## Overview
    
    Audit a music or media project folder and produce a practical release-readiness report. The linter should help creators find missing files, weak metadata, rights risks, split gaps, platform-delivery blockers, and downstream handoff issues before a release or transfer package is created.
    
    **Core principle:** report what is present, missing, or unknown. Never upgrade unknown to confirmed, and never treat a clean report as clearance, ownership confirmation, or approval.
    
    Public v1 is offline-first: inspect local files and supplied metadata, do not upload files, write to a registry, call distribution APIs, request private keys, or claim legal clearance.
    
    ## Workflow
    
    1. Identify the source folder or supplied files.
    2. Ask for the output location if it is not obvious.
    3. Read `references/lint-rules.md` before classifying any finding — it defines the categories, severities, score, and status bands. Do not assign severities from memory.
    4. If working on a local folder, run `scripts/lint_release.py` to generate
       `release-lint-report.md` and `release-lint-report.json`. Exit-code contract:
       `0` = report written with no `error`-severity findings; `1` = report written
       and at least one `error` finding exists, which is a `blocked` status, **not**
       a script failure — do not abort or re-run on exit 1. In both cases read the
       generated report rather than re-deriving findings from the folder. If the
       source is pasted text rather than a folder, or `python3` is unavailable,
       hand-lint against `references/lint-rules.md`, produce the same report shape
       from `assets/release-lint-report.template.md`, and label the report text-only.
    5. Read `references/fix-guidance.md` when turning findings into specific next actions.
    6. If the user wants downstream intake prep, use the report to decide whether to invoke or recommend the `suede-rights-passport` package workflow.
    7. Do not invent release metadata. Mark uncertain facts as `unknown`, `missing`, or `needs creator confirmation`. Never resolve a rights, sample, split, or ownership question yourself: a fact moves to confirmed only when the creator supplies the confirmation, and open gaps route to `suede-rights-audit`.
    8. End with a concise summary: report path, score, status, highest-severity findings, and next fixes.
    
    ## Quick Start
    
    ```bash
    python3 /path/to/suede-release-linter/scripts/lint_release.py \
      /path/to/music-project \
      --output /path/to/release-lint-output
    ```
    
    If the source folder contains a metadata file, pass it explicitly:
    
    ```bash
    python3 /path/to/suede-release-linter/scripts/lint_release.py \
      /path/to/music-project \
      --metadata /path/to/music-project/metadata.json \
      --output /path/to/release-lint-output
    ```
    
    Accepted metadata formats are JSON, YAML/YML when PyYAML is installed, and
    public-safe key=value text files. Do not point metadata at real `.env`,
    credential, wallet, or deployment config files.
    
    Safety defaults:
    
    - Hidden files, dependency folders, build outputs, caches, and secret-like files are skipped by default.
    - Unrecognized file types are skipped unless `--include-other` is passed.
    - Absolute local paths are redacted to share-safer names unless `--include-absolute-paths` is passed.
    - Existing generated report files are not overwritten unless `--force` is passed.
    - The output folder cannot be the same folder as the source or live inside it.
    - YAML metadata requires PyYAML: `python3 -m pip install PyYAML`.
    
    ## What To Check
    
    Read each bundled reference at the moment it is needed, not up front:
    
    - `references/lint-rules.md`: before classifying findings, or when hand-linting without the script — categories, severity levels, score, and status bands.
    - `references/metadata-fields.md`: when metadata is missing, malformed, or being authored — recommended fields, accepted aliases, and confirmation values.
    - `references/fix-guidance.md`: when turning findings into next actions or a fix plan.
    - `references/passport-context.md`: when the user asks how the lint report relates to Suede review or the Suede Creator Passport.
    
    The script writes:
    
    - `release-lint-report.md`: human-readable report.
    - `release-lint-report.json`: machine-readable findings.
    
    Use the bundled assets when repairing or hand-writing reports:
    
    - `assets/release-lint-report.template.md`
    - `assets/release-lint-report.template.json`
    - `assets/metadata.example.json`
    
    ## Fixtures
    
    Two synthetic release folders under `scripts/fixtures/` (all names and metadata
    fake — no real personal data) exist only to regression-check the script. Read
    `scripts/fixtures/README.md` when changing `scripts/lint_release.py`; a normal
    lint of a user's folder never touches them.
    
    ## Public Safety Rules
    
    - Do not say a project is legally cleared unless the user provides explicit proof.
    - Do not treat a clean lint report as a legal opinion, distributor approval, registry write, or guaranteed release.
    - Do not ask for private keys, seed phrases, unreleased account secrets, or full payment credentials.
    - Do not include private implementation details, private endpoints, internal provider names, or non-public pricing.
    - Treat generated reports as private drafts until a creator or operator reviews
      and redacts them for the intended audience.
    - Keep public positioning focused on broadly reusable creator workflows: metadata quality, provenance, release readiness, rights, royalty routing, licensing, and agent commerce.
    
    ## Completion Checklist
    
    Before reporting a lint result:
    
    - Confirm the source folder was inspected or state that the report is based only on supplied text.
    - Confirm whether metadata was discovered, supplied, or missing.
    - Report the score and severity counts.
    - List all `error` findings and the most important `warning` findings.
    - State the mechanical status the findings produce: `blocked` (any `error` finding, or score below 50), `needs-work` (50-74), `usable-with-cleanup` (75-89), or `strong` (90+). Never soften a `blocked` status in prose.
    - Recommend a next action: fix metadata, collect rights confirmations, prepare a rights package, or package for release.
    
    ## Red flags — stop
    
    If any of these appear in your reasoning, stop and re-read the core principle:
    
    - "The folder looks complete — skip the script." Run it. Eyeballing is not
      linting.
    - "The artist obviously owns it." Ownership status comes from the creator, not
      from the folder.
    - "One unconfirmed split won't block anything." Split errors block royalty
      routing and licensing by rule.
    - "Round the score up; it's close." The score is arithmetic, not judgment.
    - "A clean report means it's cleared." A clean report means fewer prep
      blockers. Nothing more.
    
    ## Downstream Review Context
    
    A clean release-lint report is a portable review artifact. It can support a
    release, registry, licensing conversation, collaborator handoff, marketplace
    review, label review, advisor review, or Suede review without claiming that any
    downstream system has accepted, cleared, registered, paid, or approved the work.
    
    ## Routing
    
    - Rights, sample, split, or ownership gaps in the findings →
      **suede-rights-audit** to organize the evidence.
    - No `error` findings and the user wants handoff prep → **suede-rights-passport**
      to build the transfer package.
    - Track headed for film/TV/ads → **suede-sync-packaging**.
    - The release needs a rollout → **suede-campaign-in-a-box**.
    
    Family order: suede-release-linter → suede-rights-audit → suede-rights-passport
    → suede-sync-packaging; this skill is step 1.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related