Source: task/spotify-token-resilience (plan/steward-linker-quality) — cold-cache fan-out fired up to 100 concurrent token POSTs (429 storm) and search didn't refresh-retry on a 401.
- service/spotify.js (new): makeSpotifyToken() single-flights the token fetch via an in-flight-promise cache so N concurrent cold/expired lookups share ONE POST /api/token; clears the promise on settle (success or failure) so a rejected fetch never poisons the cache. makeSpotifySearch() refreshes-and-retries once on a 401 and honors a bounded (capped) Retry-After on a 429, else throws (resolve-core nulls the candidate, as before).
- service/resolver.js: wires the factories with the real fetch + Date.now; value-cache + (expires_in-60)*1000 expiry math unchanged; external behavior (result shapes, USE_SPOTIFY degradation, /health, /resolve) identical.
- service/spotify.test.js (new): hermetic node:test coverage — single-flight (100 concurrent -> 1 fetch), re-fetch after expiry, failure clears in-flight, 401 refresh-retry + persistent-401 throw, bounded 429 backoff. Zero new deps.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/getroot-comment-tree-scope (plan/steward-linker-design) — getRoot's union querySelector resolved to <main> on new reddit, so the shadow-piercing walk over-collected reddit chrome.
- getRoot now tries shreddit-comment-tree, then .commentarea, then main/body sequentially (narrow-first) instead of one union querySelector, so the comment tree wins when present.
- collectInto takes an allowShadow flag: a narrow comment root pierces every open shadow host; a broad fallback root descends only into comment-component hosts (tagName SHREDDIT-COMMENT*), so chrome shadow roots (nav/sidebar/composer) are never walked.
- README + AGENTS shadow-DOM notes updated to describe the two-layer scoping.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/content-scan-efficiency (plan/steward-linker-quality) — the observer re-scanned on unrelated churn and the regex ran twice per node per scan.
- scan() now caches each text node's allMatchesIn() result in a per-scan
Map<node, matches>; wrapNode reuses that array instead of re-running the
regex battery, so the regex runs once per node per scan, not twice.
- The MutationObserver stays broad (documentElement, subtree) but
short-circuits: it only schedules a scan when a mutation batch actually
ADDED an element/text node. SPA navigation and "load more" add nodes and
still scan; removal-only / attribute / characterData churn (vote counts,
timers, hovercards) no longer fires the 600ms full scan.
- Behavior-preserving: same matches, same wrapping, same injected-link set;
rsl-link idempotency skip + running/pending serialization latch + debounce
all intact.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/handle-shreddit-shadow-dom (plan/steward-linker-design) — the TreeWalker only walked light DOM, so new-reddit comment bodies inside shadow roots were missed.
- collectTextNodes now walks the comment-tree root's light DOM AND recursively descends into the open shadow roots of elements within that subtree, so comment text is reached whether reddit slots it (light DOM) or encapsulates it (shadow DOM).
- Scoped to getRoot()'s comment tree (never the whole document's shadow roots); bounded by a depth cap; 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 <slot>'s assigned light nodes); a Set de-dupes by node identity belt-and-braces.
- All existing guards preserved (inSkippable / rsl-link skip). README + AGENTS document the support.
- Limitation: CLOSED shadow roots are unreachable from a content script and stay unlinked.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/chunk-large-thread-candidates (plan/steward-linker-quality) — content.js sent all candidates in one call, the server capped at 100, and names past the cap were permanently null-cached and never retried — silently breaking large threads.
- content.js: resolve the per-scan ask set in batches of BATCH (100, <= server MAX_CANDIDATES) via a new pure Extract.chunk helper, so every candidate is actually answered and none is dropped by the resolver's slice.
- content.js: cache only keys actually present in a response (hasOwnProperty); cap-overflow / partial-error / resolver-down names are left out of `resolved` and retried next scan instead of stuck at null.
- extract.js: add pure chunk(arr, size) on the RSLExtract global; extract.test.cjs covers exact-multiple, remainder, under-size, empty, and lossless in-order concatenation.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/album-cue-lower-run-converge (plan/steward-linker-design) — the 16d8f74 LOWER_RUN both leaked lowercase prose and truncated digit-led titles; this converges the precision/recall balance.
- Weak cue (album|record|ep|lp): admit a capital/digit-led run OR a SINGLE lowercase token only (LOWER_ONE, guarded by a negative lookahead so a second lowercase word reads as prose and is rejected) — closes the 'makes me cry' / 'changed everything' prose leak.
- Strong cue (called|titled|named): the ONLY place a multi-word lowercase run (LOWER_RUN) is admitted — 'an album called good kid maad city' → 'good kid maad city'.
- New DIGIT_RUN (digit lead absorbs trailing lowercase tokens, bounded 1-4 words, tried before TITLE_RUN) fixes the digit truncation — '36 chambers'/'1989 deluxe'/'808s and heartbreak' capture in full.
- Position-mapping invariant enforced in the album push helper; bounded regexes (ReDoS test on a multi-KB pathological input); comprehensive BOTH-direction tests (precision: non-stop-set lowercase leads emit nothing; recall: digit continuation, single token, strong cue).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/agents-md-resolve-core-layout (plan/steward-linker-quality) — layout inventory omitted resolve-core.js / resolver.test.js after the orchestration was extracted.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/album-cue-recall-rebalance (plan/steward-linker-design) — the a35dd95 precision tightening over-corrected, dropping real number/lowercase-led and abbreviation-period album titles.
- Loosen the album-cue lead token to admit a DIGIT (and, after an explicit
"the album X" cue, a lowercase-styled run), so number-led ("the album 1989",
"1989 LP", "36 Chambers") and lowercase-styled ("good kid maad city") titles
survive; ALBUM_LEAD_STOP still rejects an is/was/by/the/a/an lead.
- Narrow QUOTED_RE's mid-span reject to SENTENCE-ENDING punctuation only (a .!?
before whitespace/EOL, abbreviation periods exempted), so "M.A.A.D City",
"Mr. Bungle", "Sgt. Pepper's" survive while prose quotes still reject; raise
the word cap 6 → 8.
- Add the curly quotes ”“ (U+201D/U+201C) to TRAILING_PUNCT so an LP/EP run that
grabbed a trailing curly quote ("Reek of Putrefaction”") is trimmed, with the
span index/length kept so text.substr(index,length)===name still holds.
- Bound the "the album X" branch to the same 1-4-token run as the LP/EP branch,
so "the album Money Store dropped" yields "Money Store", not the sentence tail.
- Tests assert BOTH directions: recovered recall AND retained precision, plus the
curly-quote-trim position-mapping invariant.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/album-cue-precision (plan/steward-linker-design) — album cue regexes over-generated junk candidates (no left anchors), feeding noise to the resolver/LLM and risking fallback mis-links.
- X LP/EP branch: anchor the capture to a 1-4 capitalized/quoted title run (mirrors CAND_RE) so 'I think their new LP' no longer yields the sentence prefix.
- 'the album X' branch: require the name to start with a capital/quote and reject a leading verb/article (is|was|by|the|a|an) so 'the album by Carcass' / 'the album is great' yield no candidate.
- QUOTED_RE: reject prose quotes — a span with mid-string sentence punctuation (. ! ?), over ~6 words, or a lone stopword interjection ('lol') — so prose quotes never become album candidates.
- Preserve position mapping (trim a captured leading quote and shift the index) and dedupe identical spans across the two signals; recall of real titles ('Reign in Blood', 'Human Jerky') unchanged.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/resolver-security-hardening (plan/steward-linker-security) — defense-in-depth: client trusted resolver href, outbound fetches had no timeout, CORS was blanket-*.
- F1: extension/content.js makeLink re-validates primary.url scheme via a new
pure Extract.isSafeHttpUrl helper (in extract.js, unit-tested); a non-http(s)
url (javascript:/data:/garbage) is dropped and the plain text is kept.
- F2: each of the three resolver outbound fetches (Spotify token, Spotify
search, ollama /api/chat) now carries signal: AbortSignal.timeout(5000) so a
hung upstream fails fast (caught per-candidate -> null), zero-dep.
- F3: resolver CORS reflects the request Origin only for moz-extension://* or
null (the extension's background-script origin), with Vary: Origin, instead
of blanket access-control-allow-origin: *.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/resolver-orchestration-testable (plan/steward-linker-quality) — new album orchestration in resolver.js had zero node:test coverage.
- Extract the pure album/artist orchestration into service/resolve-core.js
(resolveAll/resolveOne/pickDirect/cacheKey) taking injected search/classify
fns; resolver.js becomes a thin server wiring the real Spotify search +
ollama classify. No behavior change.
- Add service/resolver.test.js: self-titled artist+album tie-break, LLM-album
-> Google fallback, kind-aware cache separation, plus gate/drop/error paths.
- scripts/check.sh: node --check now also matches *.cjs (extract.test.cjs).
- AGENTS.md Testing: note resolve-core coverage + the extract.test.cjs extractor.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: task/album-detection (objective/linker-improvements) — project brief asks for artist OR album linking; MVP linked artists only.
- service/lib.js: pickAlbum (the exact-name gate, named for the album path) + linkResult now carries kind:"artist"|"album" (additive; defaults to artist so older clients keep working).
- service/resolver.js: one Spotify type=artist,album search per candidate, exact-match against either; returns {url,kind}; cache key includes the kind hint. resolveOne/resolveAll thread the LLM kind hint through.
- service/llm.js: parseClassification now returns Map(name -> "artist"|"album") so album-kind candidates route to the album path (.has() still works like the old Set).
- extension/extract.js: albumMatchesIn over two precise signals (quoted strings + album cue phrases "the album X", "X LP/EP"), folded into allMatchesIn.
- extension/content.js + styles.css: album links render italicised (.rsl-album) with an album tooltip; artist behavior unchanged.
- Tests: new pure-logic coverage in lib.test.js / llm.test.js / extract.test.cjs (album shaping, Map-kind routing, album candidate extraction). scripts/check.sh green.
- README.md + AGENTS.md: document album linking.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Source: human-feedback note 01KVDYV3A35B57N15CRBQDXPCH (project/reddit-spotify-linker,
2026-06-18) — "remove the musicbrainz integration and just use spotify"; a google search
for "<name> bandcamp" fallback; and "would a local llm be better able to categorise these
... include instructions for running something suitable via ollama (16GB M2)".
- Remove MusicBrainz entirely (viaMusicBrainz + url-rels + rate-gate; lib relUrls/mbResult).
Spotify is now the only music API; it both gates by exact-name match and supplies the
direct artist link.
- Optional ollama LLM gate (OLLAMA_URL enables it, OLLAMA_MODEL default qwen2.5:3b) called
over plain HTTP -> stays zero npm-dep. It classifies which candidates are real
artists/albums (the matching-quality lever). Pure prompt/parse logic in new llm.js
(unit-tested in llm.test.js); the fetch + resolveAll gate wiring live in resolver.js.
- A confirmed name links to its direct Spotify page when available, else a Google search for
"<name> bandcamp". New "google" primary platform rendered by the extension (styles.css
blue accent + platform-aware link title in content.js).
- README: drop MusicBrainz; document the two gates + a runnable local-LLM setup for a 16GB
MacBook M2. AGENTS.md: flip the old "no ML/NER" standing decision per the human override.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
relUrls() picked MusicBrainz link relations by substring (u.includes("bandcamp.com")) with
no scheme check, so a non-http(s) resource or a look-alike host (https://evilbandcamp.com,
javascript:...bandcamp.com...) could be returned and become an injected link's href. Now:
isHttpUrl() accepts http/https only, and matching is by PARSED hostname (=== host ||
endsWith "."+host). Resolver-only; extension unaffected; pure + unit-tested (18 total green).
Source: plan/steward-linker-security SCOUT 2026-06-17.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Catch artist names written in lowercase right after a cue phrase ("check out devourment",
"by cattle decapitation", "fan of ...") — the Title-Case/ALL-CAPS pass missed those.
- extension/extract.js (new): pure extraction (matchesIn + new cueMatchesIn + allMatchesIn),
UMD so it loads as a content script AND require()s in node for tests. The cue pass uses the
regex `d` flag to map matches back to positions for in-place wrapping; a small cue-stoplist
drops "the/this/new/..." right after the cue.
- extension/content.js: extraction now comes from globalThis.RSLExtract.allMatchesIn
(Title-Case + cue union), keeping the existing position-based wrapping.
- extension/manifest.json: load extract.js before content.js.
- extension/extract.test.cjs (new) + scripts/check.sh: 6 node:test cases for the extraction
logic; check.sh now runs the extension tests too (16 total, green).
- README: updated the detection limitation note.
Deferred (design item 2): merging names split across inline formatting (cross-node) — more
involved; left as a follow-up.
Verified: scripts/check.sh green (16 tests). In-browser behavior is logic-verified only (no
headless Firefox); additive to the pending background-fix re-test — the [rsl] logs distinguish
"candidates found" from "resolve failed".
Source: task/smarter-candidate-extraction (objective/linker-improvements).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The extension linked nothing and the resolver saw no requests: the content script
fetched http://localhost:8787 directly from the HTTPS reddit page, which reddit's CSP /
mixed-content blocks. Move the network call to a background script (extension origin,
exempt) and add stage logging so failures are visible.
- extension/background.js (new): does the resolver fetch on message; sets the toolbar badge.
- extension/content.js: talks to background via runtime messaging (no page-context fetch);
[rsl] console logs at boot/root/candidates/resolve/scan; single primary link.
- extension/manifest.json: background.scripts + browser_action (icon.svg, title).
- extension/{styles.css,icon.svg}: platform-coloured link (green=spotify, teal=bandcamp) + toolbar icon.
- service/lib.js: primaryOf()/isSearchUrl() — link Spotify when the artist is on Spotify,
else Bandcamp, else Spotify search; spotifyResult/mbResult attach `primary` (unit-tested).
- README: link behaviour, toolbar, Troubleshooting.
Verified: scripts/check.sh green (10 tests); resolver returns `primary` end-to-end. The
in-browser fix is logic-verified only (no headless Firefox here) — reload the temp add-on
and watch the page console `[rsl]` logs to confirm.
Source: project human-feedback 2026-06-16 (notes 01KV8VC58QMYBDY6VVVJHR8YNY + 01KV91AMJCN2C4GFCKMKQRGNXE).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The Odesli/Songlink API rejects artist URLs (405 UNSUPPORTED_URL — it is song/album
only), so the planned Odesli hop is not viable for artist linking. Pivoted to
MusicBrainz `inc=url-rels`, which returns the artist's official Bandcamp + Spotify
URLs directly, zero-cred:
- service/lib.js: relUrls() extracts direct bandcamp/spotify from MB relations;
mbResult() prefers them, falls back to search URLs per-platform (replaces searchResult).
- service/resolver.js: viaMusicBrainz now does search -> url-rels enrichment (both
throttled through one gate); resolveOne calls it directly.
- service/lib.test.js: +3 tests (relUrls + mbResult); 9 total, green.
- README: document the direct-link behavior.
Verified zero-cred end to end: "Cattle Decapitation" -> open.spotify.com/artist/... +
cattledecapitation.bandcamp.com/ (both direct, not search); nonsense -> null.
Source: task/odesli-direct-links (objective/linker-improvements); approach changed from
Odesli to MB url-rels after the API probe; goal (direct Bandcamp) achieved.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Stand up code stewards + testing for the linker, adapted to its JS/Node stack:
- service/lib.js: extract the resolver's pure logic (norm + exact-name match + link
builders) so it is unit-testable without the server side effect; resolver.js imports it.
- service/lib.test.js: node:test coverage of the precision lever (7 tests).
- scripts/check.sh: zero-dep gate (node --check + JSON validation + node --test).
- AGENTS.md: repo doctrine — plain-repo push flow (NOT towl's PR pipeline), the three
stewards (quality/design/security), the test gate, standing decisions (MV2 stays; no ML).
Source: issue/linker-code-stewards-and-test-infra (inbox human-feedback 2026-06-16 —
"should have a set of code stewards ... ensure a testing infra gets setup and maintained").
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Firefox MV2 content script that detects artist/band names in reddit comment
threads and rewrites them inline into Spotify + Bandcamp links, backed by a tiny
zero-dependency Node resolver that confirms each candidate against the Spotify Web
API (direct links) or MusicBrainz (zero-setup, search links). Includes build +
install instructions in the README.
Source: project/reddit-spotify-linker (user 2026-06-12) + user message 2026-06-14
"Full vibe code, small service, minimal code."
Closes task/resolver-service, closes task/firefox-extension, closes task/docs-and-publish
(plan/mvp-build, objective/ship-mvp-linker).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>