## Project Overview Producer Pal is an AI music composition tool that integrates with Ableton Live through a Max for Live device using the Model Context Protocol (MCP). Written entirely in TypeScript. ## How to Write This applies to everything you write: code comments, docs, commit messages, and your replies. - **Be brief.** Say it once, in the fewest words that stay accurate. Leave out the history, the alternatives you rejected, and the measurements — unless a reader needs them to make a decision. - **Use plain language.** Write for a human in a hurry. Prefer an ordinary word over a technical one, a short sentence over a clause pile. Don't write to prove you understood the details. - **Shorten long comments in code you're already touching.** If a comment is longer than the code it explains, rewrite it smaller and plainer. Don't go hunting through the codebase for comments to fix — only fix what you're editing anyway. - **Never cut the load-bearing part.** An assumption that causes a bug if it's wrong stays. So does a "don't do the obvious thing here, because X" warning. Trim the story around the fact, not the fact. - **A code comment stands on its own.** Say the reason in the comment; don't send the reader to an ADR for it. ADRs get rewritten, merged and renumbered, and a pointer to a numbered rule inside one is the first thing a rewrite breaks. A bare `(ADR-00NN)` after a complete thought is fine — it attributes without the comment depending on it. - **Tool descriptions and results are the tightest of all** — the Producer Pal Skills, `.def.ts` descriptions, and tool results all spend the user's context window. Keep them short, clear, and limited to what the model needs. ## Essential Commands ```bash npm run build:debug # dev build — always use this for development/testing npm run fix # auto-fix formatting and lint npm run check # lint + typecheck + format + tests # npm run lint / typecheck / format / test also run individually npm run ui:build # chat UI production build npm run ui:test # stubbed webui Playwright suite (no Ableton or API keys) npm run docs:dev # docs site (VitePress, producer-pal.org) ``` ## Architecture Portal script → Max for Live Device (MCP Server) → Live API Key entry points: - MCP Server: `src/mcp-server/mcp-server.ts` - Max V8 code: `src/live-api-adapter/live-api-adapter.ts` - Portal: `src/portal/producer-pal-portal.ts` - Chat UI: `webui/src/main.tsx` - Claude Desktop extension: `claude-desktop-extension/manifest.template.json` - Tools: `src/tools/**/*.ts` - Chat CLI: `evals/chat/index.ts` - Evaluation scenarios: `evals/scenarios/index.ts` See `dev/Architecture.md` for system design and `dev/Chat-UI.md` for the web UI. ## Critical Coding Rules - **License headers**: every source file starts with this block (after any shebang). `examples/**` is exempt — those get copied into user projects. ```typescript // Producer Pal // Copyright (C) // AI assistance: () // SPDX-License-Identifier: GPL-3.0-or-later ``` Editing an existing file: **append** yourself to the end of the `Copyright` list and the AI tool to the end of `AI assistance`. Both read oldest-first, so never reorder them. - **File naming**: React components are PascalCase (`ChatHeader.tsx`); everything else is kebab-case (`use-chat.ts`). - **Function organization**: the first exported function is the main one, named after the file (`updateClip()` in `update-clip.ts`). Helpers go below it. - **No barrel files**: no `index.ts` or other pure re-export files. - **Imports**: `src/` imports need `.ts` extensions; `webui/` never uses extensions. Use the `#src/`, `#webui/`, `#evals/` aliases to cross between top-level modules — a relative import must stay inside its own module (`src/notation`, `src/tools`, …), and `webui/` bans `..` entirely. Enforced by `src/test/meta/import-restrictions.test.ts`. - **Null checks**: prefer `== null` over `=== null` or `=== undefined`. - **Live API**: use the `src/live-api-adapter/live-api-extensions.ts` interface, not raw `.get("property")?.[0]`. Build paths with `livePath` from `src/shared/live-api-path-builders.ts` — never hardcode a path string. On a runtime `LiveAPI`, reach child objects with `api.child("name")` (chainable), never by concatenating onto `api.path`. Never call `new LiveAPI()` — only `LiveAPI.from()` tracks the object for release, and an untracked one leaves a Live path listener armed for the life of the device. - **Never hold a `LiveAPI` across requests** — not in module state, not in a cache, not in a callback that outlives the call. Objects are released when the request ends and reused by the next one, so a stale reference silently points at a different Live object. Build them where you use them. See `src/live-api-adapter/live-api-release.ts`. - **Tool design follows `dev/Principles.md`** — addressing, multi-target, relocation, partial completion, observability, warnings, destruction, vocabulary, spelling, efficiency. Read it before changing a tool's inputs, outputs, or failure behavior. Anything about a target goes in that target's result entry; a warning is only for what no result can carry, and is appended to the response as a `WARNING:` block the model reads. See ADR-0035 for the calls that are refused up front instead. Never cite a principle by number outside that file — numbering shifts as principles merge and split; state the idea instead. - **A warning belongs to the request that raised it.** V8 buffers warnings per-request and appends them to that request's own response, and it has no async context to do that automatically. So: adding an `await` to `handleRequest` needs a matching `resumeWarningCapture()` on every path back out, `catch` included; any new promise V8 can suspend on needs `suspendWarningCapture()`; and a `void`-ed async call is a suspension point too, so it needs `detachWarningCapture()`. Miss any of them and warnings silently land on another request's response. Warnings raised with no request in flight go to the Max console instead — don't try to route them into a response. See `src/shared/max/v8-warning-capture.ts`. - **Tool schemas**: use `z.coerce.string()` for ID params and `z.coerce.number()` for numeric ones — models send both strings and numbers, and the MCP SDK validates before our handler runs. For choosing a param's shape and writing per-mode descriptions, see `dev/Tool-Schemas.md`. - **String length caps of 2000+**: never let them reach the JSON Schema as `maxLength` — llama.cpp-based clients compile it into a grammar repetition and then reject every tool call, for every tool. Use `boundedString()` and state the limit in the param description. See ADR-0021. - **The filesystem is Node-side only**: the V8 runtime (`src/live-api-adapter/`) has no filesystem, and shipped `src/**` can't shell out. All `node:fs` work lives in `src/mcp-server/`. User-content features (`~/.producer-pal` overrides, global context, custom system prompt) are MCP/REST concerns that never touch the Live API. See `dev/Architecture.md` → Runtime Boundary. - **Generated parsers**: `generated-*-parser.js` files are gitignored and built from the `.peggy` grammars. Never commit them; regenerate (`npm run parser:build`) after editing a grammar. - **Note-value grammar is duplicated on purpose** across both `.peggy` grammars and the regexes in `src/notation/barbeat/time/barbeat-time.ts` — don't extract a shared fragment. Parity tests hold the sites in step; adding a parse site means updating every site and the parity test. Same deal for Stark's `DrumPitchName`. See ADR-0003. - **Exact dependency versions**: no `^`/`~`/ranges anywhere in package.json. - **The issue tracker is not durable storage** — assume any tracker (Linear, GitHub issues) will be deleted someday, and that not every contributor can read it. A ticket is a to-do, not a record. Anything worth keeping goes in the repo: code comments for local reasoning, `dev/` docs for how a system works, `dev/decisions/` ADRs for why a settled choice went that way, and user-facing docs when it changes what users see. A commit or PR that only points at a ticket has lost the information. - **No Linear ticket references anywhere in the repo** — this is a public repo with private ticket numbers. Never write `AJM-NNN` in a tracked file or a commit message; explain the reasoning instead. `npm run check` scans both tracked files and your commits on this branch, but only locally, so run it before pushing. PR titles and bodies are fine. - **GitHub issues go in the commit message, not the release PR body** — put `Resolves #NNNN` in the commit that fixes it. `dev -> main` merges onto the default branch, so the issue closes when the release lands. One keyword per issue: extra `Refs #NNNN` on supporting commits just add permanent timeline events to a public issue. Don't name an issue you aren't fixing. - **Keep the Skills and specs current**: the Producer Pal Skills (`src/skills/fragments/`) need updating whenever notation or tool behavior changes under them. The grammar specs in `dev/specs/` have no test guarding them, so update them by hand when you change grammar syntax. - **File size limits** (blank and comment lines don't count): 325 lines per source file, 650 for a whole test suite; 115 lines per function; `max-depth` 4; `complexity` 20. When a file gets close, extract cohesive helpers into `{feature}-helpers.ts` beside it — don't compress code to squeak under the limit. Once a directory has 2+ helper files, move them into `helpers/`. Split test files as `{feature}-{area}.test.ts`, and give a feature its own `tests/` directory once it has 3+ test files. - **Write lint suppressions with the `eslint-` prefix**, not `oxlint-`. Both work, but the rule requiring a `-- reason` on every directive only sees the `eslint-` spelling. See `dev/Linting.md`. - **DRY**: no duplicate function bodies (oxlint catches them), keep shared constants in one place, and treat repeated patterns as a missing abstraction. ## Type Checking `src/`, `scripts/`, `evals/`, and `webui/` are all type-checked. Prefer explicit return types on exported functions. Every exported function declaration needs a JSDoc block with `@param`/`@returns` descriptions — no types, since TypeScript already has them. **TypeScript 6 and 7 are installed side-by-side.** TypeScript 7 ships no programmatic API (it's the Go port; a new API is expected in 7.1), but oxlint's `jsPlugins` bridge needs one, so `package.json` follows the upstream-recommended aliasing: ```json "@typescript/native": "npm:typescript@7.0.2", "typescript": "npm:@typescript/typescript6@6.0.2" ``` The practical consequences: - `tsc` is **TypeScript 7** — this is what `npm run typecheck` runs. `tsc6` is the 6.0.2 compiler, kept only so the bridge resolves; don't typecheck with it. - `import ts from "typescript"` gets the **6.0.2 API**, which is why `scripts/stats/loc.ts` and `src/test/helpers/vi-mock-scan-test-helpers.ts` still use the compiler API normally. - TS 7 reports overload-mismatch errors on the **failing argument**, not the call expression, so a `@ts-expect-error` for one goes directly above the offending argument (see `duplicate-mocks-test-helpers.ts`). That placement is TS-7-only — `tsc6` will call it unused. - Version pins may be npm aliases; `src/test/package-json-versions.test.ts` accepts `npm:@` but still rejects ranges. ## Testing - Run `npm run check` after any code change. **Before claiming done**: `npm run fix`, then `npm run check`, then `npm run check:build`. If you touched `webui/**`, also run `npm run ui:test` — `check` doesn't include it. - `npm run build:debug` is the dev build. It force-enables the Direct Live API tool, which a release build gates off — harmless for evals and e2e, since it stays off until `POST /config { liveApiEnabled }` turns it on. The `code` param and the work-in-progress warp params are NOT on by default: they go straight into the clip tool schemas with no runtime gate, so a model would reach for a feature no release build ships. Opt in per build when you're working on one (`ENABLE_CODE_EXEC=true npm run build:debug`), and rebuild plain afterwards. `npm run check:build` overwrites the device with a release build, so re-run `build:debug` afterwards or the device in Live is the wrong one. - **Debugging**: import `console` from `src/shared/max/v8-max-console.ts` and use `console.warn()` — it shows up in the CLI and in the live MCP response. `console.log()` and `console.error()` don't. - **Coverage gaps**: `npm run check` prints totals only; the per-file breakdown is in `coverage/coverage-summary.txt`. Function coverage must be 100%; mark a genuinely untestable function with `/* v8 ignore start -- reason */`. Before ignoring or deleting a branch as unreachable, try to write the test — reading the code is not enough to prove it, and the attempt is what tells you whether the guard is dead or you just hadn't found the input. - See `dev/Testing.md` for what counts as a test file, webui test gotchas, and the mock registry. CLI tools and test Live Sets are in `dev/Development-Tools.md`. ### MCP E2E Testing E2E tests live in `e2e/mcp/` and drive a real Ableton Live. See `e2e/mcp/README.md`. **Always ask before running them** — they open a Live Set without saving the current one, which can destroy work in progress. **Always run a single file.** The full suite takes several minutes. ```bash npm run e2e:mcp -- ppal-update-clip-arrangement-splitting ``` **A new track is not always empty.** Live applies the user's default track preset (User Library → `Defaults/Creating Tracks/`) to every track it creates, and that preset varies per machine — one dev's new MIDI track arrives bare, another's already has a Channel EQ and a Utility on it. So never hardcode a device index for a device the test just created: use `createTestDeviceAt()`, which returns the path the device actually landed at. A test that assumes `d1` passes for whoever wrote it and fails for everyone else. The same goes for any other per-machine Live preference a test might lean on. **Save test Live Sets from `MIN_LIVE_VERSION`**, not from whatever Live you have installed. Live can't open a Set saved by a newer version, so a Set authored above the minimum makes every test that opens it unrunnable on the oldest Live we support. `src/test/meta/versions/live-set-versions.test.ts` catches it. ## Protected Files (Require User Approval) These encode standards the project is held to — **don't relax or rewrite any of them without asking:** - `dev/Principles.md` — the first principles for tool design. It states the intended future state, so editing one to match today's code retires a goal silently. - `src/test/lint-suppression-limits.test.ts` — per-tree caps on lint-disable, `@ts-expect-error`, and v8-ignore comments. - `src/test/helpers/comment-limits.ts` — per-tree caps on comment lines and comment-block length, enforced by `src/test/comment-limits.test.ts`. - `vitest.config.ts` (thresholds) — coverage. - `config/.jscpd*.json` (`threshold`) — code duplication. ## Documentation Internal docs live in `dev/` — the filenames are descriptive, so `ls dev/` to find one. The main ones: `dev/Principles.md` (first principles for tool design — read first), `dev/Architecture.md` (system design), `dev/Coding-Standards.md` (full style guide + Live API reference), `dev/Testing.md`, `dev/Tool-Schemas.md`, `dev/Linting.md`, `dev/specs/` (bar|beat and transform grammars), `dev/Development-Tools.md`, and `dev/decisions/` (ADRs — why settled choices went the way they did, especially the rejections). `DEVELOPERS.md` covers dev setup; `CONTRIBUTING.md` covers contributing. **Keep a doc small enough to read whole.** Past ~20 KB, split it: an index with the concepts, plus one file per lookup-table chunk in a sibling directory (`dev/mutation-baselines/`, `dev/specialized-devices/`). Catalogs and per-scope results are the parts to move out; the reasoning stays in the index. ### For agents: reading without burning context This repo is big — 900+ source files and ~1.3 MB of `dev/` docs. Reading whole files is the main way a session runs out of room. - **Search docs, don't read them.** `grep -n -A15` for the term you need. Read a `dev/` doc end-to-end only if it's under ~15 KB. - **Read tests and long sources in ranges.** `sed -n '120,190p'` the block you care about; test files here run to 800+ lines. - **Coverage:** `grep` the file you touched out of `coverage/coverage-summary.txt` — don't print the whole thing.