# Agent rules for the `linker` repo (reddit → spotify/bandcamp) A Firefox extension + a tiny Node resolver that turn artist/band names in reddit comments into Spotify/Bandcamp links. This file is the repo-local doctrine; the work is tracked in the **towl** work-item store under project **`reddit-spotify-linker`**. ## Stack & layout - `service/` — zero-dependency Node resolver (Node 18+, built-in `fetch`/`http`). `resolver.js` = the thin HTTP server that wires the real Spotify search + ollama classify into resolve-core; `resolve-core.js` = the pure resolve orchestration (`resolveAll`/`resolveOne`/`pickDirect`/`cacheKey`, taking injected search/classify fns — unit-testable without network); `lib.js` = pure, side-effect-free logic (the exact-name match precision lever + link shaping) so it's unit-testable; `llm.js` = pure ollama prompt/parse helpers (the optional local-LLM gate, HTTP-only — no in-process model runtime); `lib.test.js` + `llm.test.js` + `resolver.test.js` = `node:test`. The only music API is Spotify (MusicBrainz removed). The resolver confirms **both artists and albums**: one `type=artist,album` Spotify search per candidate, exact case-insensitive name match against either; each result carries `kind: "artist" | "album"`. `odesli.js` = the optional Odesli (song.link) enrichment client (injected `fetch`, pure `parseOdesli` core, HTTP-only — no new deps); `odesli.test.js` + `odesli.fixture.json` (a captured real response) cover it. - `extension/` — Firefox **MV2** content script (`content.js` + `extract.js` + `manifest.json` + `styles.css`). No build step. `extract.js` is the pure candidate extractor (Title-Case runs + cue phrases for artists; quoted strings + album cue phrases for albums) — node-testable via `extract.test.cjs`. Album links render italicised (`.rsl-album`). - **shreddit shadow DOM:** `collectTextNodes` is shadow-piercing. A TreeWalker can't cross a shadow boundary, so it walks the chosen `root`'s light DOM AND recursively descends into the **open** shadow roots of elements within that subtree. New reddit's `shreddit-*` components may encapsulate comment bodies in a shadow root; this reaches them whether reddit slots (light DOM) or encapsulates (shadow DOM). Scope is two-layer: `getRoot` tries the narrow comment containers first (`shreddit-comment-tree`, then old reddit's `.commentarea`) before the broad `main`/`body`, so on a new-reddit comment page the root is the comment tree and the walk stays within it; and when `getRoot` falls back to a broad root (no comment tree present) the recursion descends only into comment-component shadow hosts (`tagName` starting `SHREDDIT-COMMENT`), so reddit's chrome shadow roots (nav, sidebar, composer) are never walked. (A single union `querySelector` could not do this — it returns the first match in document order, and `
` is an ancestor of the comment tree, so it would always win.) It is a no-op on old reddit (no shadow roots). Slotted light-DOM nodes are not double-collected (a shadow walker sees the shadow tree's own nodes, not a ``'s assigned light nodes; a Set de-dupes belt-and-braces). **Closed** shadow roots are unreachable from a content script — a documented limitation. - `scripts/check.sh` — the local check gate. ## How work lands (IMPORTANT — this is NOT the towl repo) `linker` is hosted at **git.yapplesauce.com/paul/linker**, a **plain repo**: there is **no PR pipeline and no branch protection**. You **push to `main` directly** (via the user's PAT). Do **not** use `towl-orchestrate` / worktree+PR here. 1. Make a small, reversible change. 2. Run the gate: `bash scripts/check.sh` (must print `ALL CHECKS PASSED`). 3. Commit citing the towl ref (e.g. `(task/odesli-direct-links)`), push `main`. 4. Stamp the SHA on the towl item: `stamp_commit { ref, sha, repo: "linker" }`. 5. Close the towl item. **NEVER touch the `towl` or `stickball` repos from work scoped to this project.** ## Testing (zero-dep) `node --test` over `service/*.test.js` exercises `lib.js` (norm + the exact-name match + link builders), `llm.js` (the classifier prompt/parse), and `resolve-core.js` (the album/artist orchestration — kindHint tie-break, kind-aware cache key, allowFallback gating — driven with injected fake search/classify fns, no network). Keep pure logic in `lib.js`/`resolve-core.js` (not `resolver.js`, which has the server side effect) so it stays testable without opening a port. `extension/extract.test.cjs` covers the pure extractor (artist/cue/album candidates) and must be extended with each detection change. **Add/extend a test with every logic change.** The extension's in-browser behavior is **not** headlessly testable in this environment — validate `content.js` with `node --check` and a documented manual check on a real reddit thread (`about:debugging` → load temporary add-on). ## Code stewards (towl plans under objective/linker-stewardship) Standing, language-appropriate stewards (adapted from towl's SCOUT→FILE doctrine; run them like any other steward — e.g. via a `/loop`). Each files findings into towl and fixes small things directly. - **quality** (`plan/steward-linker-quality`) — correctness + regressions; **owns and maintains `scripts/check.sh` and the `node:test` suite**. - **design** (`plan/steward-linker-design`) — detection precision/recall, the injected-link UX, README accuracy, reddit-DOM drift. - **security** (`plan/steward-linker-security`) — DOM injection (build nodes via `createElement`/`textContent`, **never `innerHTML`**; http(s) hrefs only), minimal extension permissions, bounded external API calls (no SSRF), secrets never logged/committed (`.env` is gitignored). ## Standing decisions - Firefox **MV2** stays — Mozilla has no deprecation timeline; do not migrate to MV3 speculatively. - **Optional local LLM is the recommended better-matching path** (human override, feedback 2026-06-18 — supersedes the old "no ML/NER" rule). The resolver may call ollama over HTTP (`OLLAMA_URL` enables it, `OLLAMA_MODEL` defaults `qwen2.5:3b`) to classify which candidates are real artists/albums. It stays **zero-NPM-dep**: HTTP only, no in-process model runtime. The heuristic + Spotify exact-match is the zero-dep default when no LLM is configured. - **Optional Odesli (song.link) enrichment** is opt-in, default OFF (`ODESLI=1`, or `ODESLI_URL` to override the endpoint — mirrors the ollama opt-in). When ON, an **album** result that already carries a DIRECT Spotify url is enriched with the album's direct Bandcamp (+ Apple/YouTube) links as ADDITIVE fields (`bandcamp`/`appleMusic`/`youtube`) — the existing `name`/`kind`/`spotify`/ `google`/`primary` shape is unchanged, so off the behavior is byte-identical and an older extension keeps rendering. The gate is `shouldEnrich` (pure, unit-tested): **album-kind results only** — Odesli rejects ARTIST urls (405 UNSUPPORTED_URL), so artist results are skipped entirely (no wasted round-trip on the common case). Only the Spotify-creds path produces a url to enrich; the no-creds Google fallback is untouched. Bounded (`AbortSignal.timeout`) + cached per Spotify url + graceful (Odesli down/garbage keeps the current result). The input is our own validated Spotify album url against the fixed host (no SSRF). - Detection precision lever (zero-dep default): a candidate links **only** when Spotify returns an **artist or album** whose name matches it case-insensitively. A confirmed name links to its Spotify artist/album page if available, else a " bandcamp" Google fallback. Album candidates are over-generated from precise signals only (quoted strings + album cue phrases) because album free-text is noisier than artist names — the Spotify exact-match (and/or LLM `kind=album`) is the filter.