docs: unify agent instructions into a single index, add handoff notes (#2980)

- AGENTS.md is the single documentation index; the Claude, Copilot, Cursor,
  and Codex entrypoints route to it and name the standing rules directly so
  they can't be missed.
- Split standing rules (read every session) from task-specific docs (read on
  demand); scope release-review's Copilot applyTo to release artifacts so it
  no longer loads on every interaction.
- Add project-notes.instructions.md (non-obvious project knowledge and
  conventions for handoff) and README_AGENTS.md (the wiring map).
- Move dev mocks + DB seed to tools/dev/ (fix seed-db repo-root resolution);
  add the seeded-DB + Playwright step to the release-review checklist.
This commit is contained in:
enoch85
2026-05-25 22:24:21 +02:00
committed by GitHub
parent 6ba3040af8
commit 8ab6fc10f1
12 changed files with 565 additions and 38 deletions
+2 -2
View File
@@ -1,3 +1,3 @@
At the start of every conversation, read `.github/instructions/implementation.instructions.md` and follow it for implementation-specific guidance.
At the start of every conversation, read [AGENTS.md](../../AGENTS.md) first — it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overview before changing cross-module boundaries.
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](../../.github/instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](../../.github/instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific — read them when the task calls for it (AGENTS.md lists which).
-8
View File
@@ -1,8 +0,0 @@
---
description: Implementation-specific guidance
alwaysApply: true
---
At the start of every conversation, read `.github/instructions/implementation.instructions.md` and follow it for implementation-specific guidance.
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overview before changing cross-module boundaries.
+4 -2
View File
@@ -1,6 +1,8 @@
---
description: Project overview and coding standards
description: Project entrypoint and standing rules
alwaysApply: true
---
At the start of every conversation, read `AGENTS.md` for the full project overview, technology stack, coding standards, and contribution guidelines. Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system architecture overview.
At the start of every conversation, read [AGENTS.md](../../AGENTS.md) first — it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](../../.github/instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](../../.github/instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific — read them when the task calls for it (AGENTS.md lists which).
+2 -4
View File
@@ -1,5 +1,3 @@
At the start of every conversation, read `.github/instructions/implementation.instructions.md` and follow it for implementation-specific guidance.
At the start of every conversation, read [AGENTS.md](../AGENTS.md) first — it indexes all project docs: implementation guidance, conventions, architecture, and the release-review checklist.
Also read `AGENTS.md` for repository conventions, workspace commands, tech stack, and coding standards.
Read [ARCHITECTURE.md](../ARCHITECTURE.md) for the system architecture overview.
Before writing any code, read and follow the standing rules: [.github/instructions/implementation.instructions.md](instructions/implementation.instructions.md) and [.github/instructions/project-notes.instructions.md](instructions/project-notes.instructions.md). Other docs in `.github/instructions/` are task-specific — read them when the task calls for it (AGENTS.md lists which).
@@ -0,0 +1,423 @@
---
applyTo: "**"
---
# Maintainerr — Project Notes & Handoff Knowledge
Hard-won, non-obvious knowledge about this codebase that isn't derivable from the
code, git history, or [ARCHITECTURE.md](../../ARCHITECTURE.md). Accumulated while
working on the rule engine, media-server integrations, the Tailwind v4 migration,
and the build/migration toolchain. Agent-neutral — meant to be read by Claude,
GitHub Copilot, and Codex.
Read [ARCHITECTURE.md](../../ARCHITECTURE.md) for the system map and
[AGENTS.md](../../AGENTS.md) for the command/workflow reference first; this file
covers the "why" and the traps those don't.
---
## Conventions (apply to all committed artifacts)
These are project conventions, established through review feedback. They apply to
code, comments, tests, fixtures, docs, commit messages, and PR bodies — not to
conversational chat.
- **Call the request service "Seerr" — never "Overseerr" or "Jellyseerr".**
Maintainerr abstracts both behind a single "Seerr" layer
(`modules/api/seerr-api/`, `SeerrApiService`). Naming one leaks implementation
and implies favoritism. Even when verifying behavior against a specific
upstream's source, write "Seerr" in the output. Use a product-specific name
only in code provably specific to it (none exists today).
- **Never use real media titles** (movies, shows, books, games) in any committed
artifact. Use generic placeholders: "Sample Series", "Franchise A Collection",
"a movie collection". Brand/franchise names create IP-association noise and
date the code.
- **Prefer manual string ops over regex.** Default to `charCodeAt`, `slice`,
`startsWith`/`endsWith`, `.toLowerCase()`, char-index while-loops. Use regex
only when manual ops would be materially more complex (multi-line, lookahead).
For trailing/leading slashes or whitespace, write a small char-by-char helper
rather than `.replace(/\/+$/, '')`. Rationale: explicit, no hidden engine cost,
no surprises on empty input or unicode.
- **Prefer the codebase's existing idiom over adding a dependency.** When a
feature request or issue names a specific library (e.g. `p-limit` for bounded
concurrency), check first whether the repo already solves it. It does for
bounded concurrency: chunk + `Promise.all` (see `addBatchToCollection` and the
metadata-refresh loop). Only add a dep when the existing idiom genuinely can't
do the job, and say why. Such library suggestions in issues are often
AI-sourced — don't take them at face value.
- **Scope discipline.** Separate the actual blocker from cosmetic noise and fix
only what's needed. Before expanding scope (new deps, transformer swaps,
cross-cutting refactors), stop and confirm — don't turn a warning fix into an
architecture change. A known-benign warning can be left as a visible reminder
for a future dedicated overhaul rather than masked.
- **But finish the job within the area you're touching.** If you're already in a
subsystem and spot a _real_ correctness bug adjacent to your change, fix it and
add coverage — "pre-existing" is not an excuse to leave a known bug. The
distinction from scope discipline: real-bug-in-scope → fix it; benign-noise →
leave it. For data changes, prefer **minimal behavioral change** — preserve how
existing data evaluates today, making values explicit rather than flipping
behavior.
### Working-style preferences of the prior maintainer
Kept for continuity; the next maintainer may adjust these.
- **PRs:** brief body (what changed and why, a few bullets). No "Test plan"
section. No Claude Code attribution footer or `Co-Authored-By: Claude`.
Validation is done manually + via CI.
- **Updating a PR branch:** rebase onto latest origin first (PR branches drift —
one was 20 commits behind by the time edits finished), fold into one clean
commit (`git reset --soft` then a single commit), run the **full** repo suite
(`yarn turbo test`, not just affected specs), then push. Verify
`git rev-list --left-right --count origin/<branch>...HEAD` is `0 N` (clean
fast-forward, no force-push). Keep diffs minimal — don't rename existing
variables without cause.
---
## UI (apps/ui)
### Tailwind CSS v4 (CSS-first)
The UI is on **Tailwind v4, CSS-first** — there is **no `tailwind.config.js`**.
- Built via the `@tailwindcss/vite` plugin (not PostCSS). No `postcss.config`,
no `autoprefixer`, no `@tailwindcss/aspect-ratio`.
- `apps/ui/styles/globals.css` is the single source of truth: `@import
'tailwindcss'`, `@plugin "@tailwindcss/forms" { strategy: base; }`,
`@plugin "@tailwindcss/typography";`, and a `@theme` block defining the custom
palettes (error, success, info→zinc, warning→amber, maintainerr,
maintainerrdark), `--font-sans`, `--breakpoint-xs`. Custom transition
utilities: `@utility transition-width` / `transition-max-height`.
- Prettier uses `tailwindStylesheet: './styles/globals.css'` (not
`tailwindConfig`).
**v4 constraints (apply whenever you write UI):**
- `bg-opacity-*` / `text-opacity-*` don't exist → use the `/<n>` slash syntax
(`bg-zinc-900/80`). The old opacity classes render fully opaque, silently.
- `@tailwindcss/forms` is loaded with `strategy: base` on purpose — its default
strategy registers a `.form-input` utility that collides with (and, under v4
layer ordering, beats) the app's own `.form-input` component class. Don't
change the strategy.
- Checkbox focus ring = blue ring + **white offset ring**; `focus:ring-0` alone
leaves the white offset — you also need `focus:ring-offset-0`.
- `@tailwindcss/typography` prose colors must be set via `--tw-prose-*` vars in an
**unlayered** `.prose` rule (layered rules lose to the plugin's own `.prose`).
- v4 class names: `shadow-sm` (not `shadow`), `rounded-sm` (not `rounded`),
`outline-hidden` (not `outline-none`), `bg-linear-to-*` (not `bg-gradient-to-*`).
- **Checkbox standard:** there is one `.checkbox` class in globals.css
(`@layer components`). Every `<input type="checkbox">` uses
`className="checkbox"` and nothing else — it bundles size/rounding/maintainerr
fill/zinc border + the no-ring focus fix. Don't reintroduce per-checkbox inline
styling.
**v4.x features in use:**
- `color-scheme: dark` is set on `html` (base layer) — native controls render
dark; don't re-add per-control dark hacks.
- The `ul.cards-vertical` grid breakpoint is a **`@container` query** (440px);
Overview/Content wraps the grid in `<div className="@container">`. It sizes to
the wrapper, not the viewport.
- `field-sizing-content` on the rule-group Description (AddModal) and overlay Text
(PropertiesPanel) textareas (auto-grow).
- `text-shadow-sm` on MediaCard poster-overlaid year/title/summary.
- **Default border color is `currentColor`** (v4 Preflight `border:0 solid`;
there is no compat shim). If you add a bordered element, give it an explicit
`border-*` color — the app standard divider is `border-zinc-700`.
### UI implementation conventions
- **Always prefer the shared UI primitives first.** Before building bespoke form
or settings chrome, check `apps/ui/src/components/Forms/` and
`apps/ui/src/components/Common/`. Today that means using shared field
components such as `Input`, `InputGroup`, `InputAdornment`, `FieldJoin`,
`Select`, `SelectGroup`, `SelectAdornment`, plus shared action controls like
`SaveButton` and `TestingButton`, instead of recreating equivalent markup and
Tailwind classes inline. If something is missing, extend the shared primitive
rather than introducing a one-off version in a page component.
- **DRY** — no one-off duplicated feedback/loading patterns. Use
`apps/ui/src/components/Settings/useSettingsFeedback.tsx` for inline page
feedback (not toasts) on normal settings saves. For joined field layouts and
prefix/suffix adornments, compose the `FieldJoin` / `SelectGroup` /
`*Adornment` components above rather than repeating their Tailwind classes
inline.
- **Avoid `useEffect` and `useCallback`** — prefer derived values, event
handlers, and library-native reactive APIs (e.g. react-hook-form's `values`
option to sync a form to loaded data instead of `useEffect(reset)`). An effect
that resets/derives state from an unstable reference (a hook returning a fresh
object each render) causes infinite render loops → vitest OOM/SIGTERM in CI.
- **Loading spinners:** full `LoadingSpinner` = delayed (only when a wait is
expected); `SmallLoadingSpinner` = immediate inline feedback.
- **Layout stability:** reserve space for late-loading UI; keep tab/card structure
stable; placeholders must not change active state or shift surrounding UI.
- Favor reusable, consistent components and solid React patterns; avoid
unnecessary abstraction; match existing patterns to avoid regressions.
---
## Rule engine (apps/server, modules/rules)
### Comparator: keep EXISTS / value-comparison orthogonal
Value-comparison operators (BEFORE, AFTER, EQUALS, NOT_EQUALS, …) stay
**fail-closed** when the operand is missing. Existence is handled separately by
**EXISTS / NOT_EXISTS**. Do **not** silently expand a comparison to also mean
"never happened."
Why it matters: special-casing BEFORE-on-null for temporal properties
(`lastViewedAt`, `sw_lastWatched`) makes `NOT_EQUALS <date>` match every
never-watched item — a semantic expansion, not a fix. The correct layer for
"never watched" is the **getter contract** (null = confirmed absent,
undefined = error), not the comparator.
Users get "never watched OR older than X days" by composing
`NOT_EXISTS OR BEFORE X` explicitly. Push back on rule-engine fixes that change
what BEFORE/AFTER/EQUALS/NOT_EQUALS do for null/undefined inputs.
### Section operator semantics
Each rule section combines with the previous via the operator on the section's
**first** rule. An unset operator is stored as `null`, so the comparator must
null-guard before coercing — `+null === 0` is `true` in JS, which would
otherwise read an unset operator as AND. Within-section default is OR;
section-boundary default is AND. YAML export/import must use a **null check**
(not a truthy check) for the operator, since AND is `0` and would be dropped.
### Rule evaluation performance
- **Operand resolution runs in bounded-parallel batches.**
`rule.comparator.service.executeRule` resolves firstVal/secondVal for all items
in chunks via `Promise.all` (the codebase idiom — not `p-limit`), then runs the
mutation loop. Knob: `RULE_EVALUATION_CONCURRENCY` in `rules.constants.ts`
(default **8**). Keep this as a **single global batching layer** — do not nest
it inside getters, or you get N×N fan-out.
- **Don't raise the concurrency.** 8 is deliberate: higher values over-drive a
constrained all-in-one NAS's Tautulli history queries past the request timeout,
triggering axios retries that pin CPU and stall the run. There is intentionally
no user-facing knob.
- **`ArrLookupCache`** (`modules/rules/helpers/arr-lookup-cache.ts`): a run-scoped
memo created in the executor for the eval loop only, never passed to
`handleCollection`/actions, so empty-show cleanup still reads fresh. Used only
by the sonarr/radarr getters (others already cache at the API layer); the API
lookup itself stays `getWithoutCache`.
- **Do not retain full comparison stats for every scanned item.**
`RuleExecutorService` should keep detailed `IComparisonStatistics` only for
items that may be newly added to a collection. Holding per-item stats across
the full library can OOM large runs, and removal paths do not need fully
populated reasons.
- All rule caches are in-memory and `cacheManager.flushAll()` runs at every
rule-group run start (persistent metadata caches like tmdb/tvdb are exempt) —
runs are cold-start by design; only SQLite persists.
### Streamystats watchlist rules — Jellyfin only
`Application.STREAMYSTATS` (enum id **8**, see `packages/contracts/src/rules/`)
is the Jellyfin analog of Tautulli-for-Plex. Two properties:
`isInWatchlist` (BOOL) and `watchlistedByUsers` (TEXT_LIST). There is
intentionally no `isPromoted` property — promoted-only-private lists are
unreachable with the auth Maintainerr has (see below).
Non-obvious facts:
- A Streamystats "watchlist" is a user-created **named curated list** (tables
`watchlists` / `watchlist_items`), NOT a Plex-style personal want-to-watch
flag. Its `itemId` equals the Jellyfin item ID, so membership maps directly.
Do not reuse the Plex watchlist property semantics.
- The watchlist endpoints authenticate with
`Authorization: MediaBrowser Token="<jellyfin_api_key>"`, **not** `Bearer`
(only the item-details endpoint accepts Bearer). `getWatchlistMembership()`
overrides the Authorization header per request.
- Maintainerr authenticates with a Jellyfin **server API key**, which Streamystats
resolves to a `system-api-key` pseudo-user → **only PUBLIC watchlists are
reachable**. Private/promoted-only lists are intentionally invisible.
- The membership snapshot is cached in the shared `streamystats` NodeCache (key
`watchlist-membership`), which is `flushAll()`'d between rule-group runs — the
correct lifecycle (no hand-rolled `Date.now()` memo). TTL/key constants live in
`modules/api/streamystats-api/streamystats-api.constants.ts`.
- The getter returns `undefined` (transient skip) when membership is null;
`false`/`[]` only when genuinely fetched. This prevents a failed lookup from
flipping negative list comparisons and matching protected items.
- Gating mirrors Tautulli: UI gate folded into the Jellyfin line in RuleInput
`shouldFilterApplication`; server gate in `getRuleConstants()` removes the
Application when `streamystats_url` / `jellyfin_api_key` are unset. Emby stays
unsupported (Streamystats is Jellyfin-only).
### Rule regression harness
`apps/server/test/rules-test-matrix.e2e.ts` boots a real Nest app + real
`RuleComparatorServiceFactory` / `RulesController` / `RulesService` and POSTs to
`/api/rules/test`, with `MediaServerFactory` + `ValueGetterService` mocked so the
app behaves as if the media server is live and returning values. Each scenario
lists `rules` (sections + operators) and `values` (shifted per getter call, in
rule order). It is a **script that prints JSON** (`yarn workspace
@maintainerr/server test:e2e`), meant for cross-refactor comparison — add
scenarios here to regression-test comparator / section-combine behavior end-to-end
through the HTTP path. Use this when you want "real results" without standing up a
mock HTTP media server. (For a fuller live setup, see the dev mocks below.)
---
## Cross-server media abstraction
- **Emby/Jellyfin set a movie's `parentId` to its library-folder id** (Emby's
`getParentId` falls back to `item.ParentId`); Plex leaves a top-level movie's
parent empty. So any logic that infers "this item is a season/episode" from
`parentId`/`grandparentId` **presence** misclassifies Emby/Jellyfin movies.
**Always switch on the server-agnostic `item.type`**
(`'movie' | 'show' | 'season' | 'episode'`), never on
`parentId`/`grandparentId` truthiness. (`notifications.service.ts` `getTitle`
is one place this matters.)
- **Emby user-scoped reads differ from writes.** Collection metadata reads and
full-library size scans should prefer `/Users/{userId}/Items...` when
`emby_user_id` is configured; the plain `/Items/...` path can miss or 404 in
user-authenticated flows even though the update write endpoint remains
`POST /Items/{itemId}`.
---
## Contracts migration direction (@maintainerr/contracts)
When extracting transport shapes (Zod schemas, request/response DTOs) into
`@maintainerr/contracts`, **design fresh contract-owned DTOs** that describe the
actual API payload. Do **not** promote server-side interfaces verbatim — contracts
must stay transport-only.
- Server interfaces (e.g. `ICollection` in
`modules/collections/interfaces/collection.interface.ts`) carry server-only
concerns (entity refs, `media?: CollectionMedia[]`). Hoisting those into
contracts would force contracts to depend on server concerns — wrong direction.
- Audit referenced types before moving a schema. If they pull in entity classes or
ORM-shaped fields, don't promote as-is; define a plain DTO/Zod schema in
contracts capturing only the payload, then adapt the server type to map into or
extend it.
- Types already transport-oriented (e.g. `CollectionMediaChange` in
`modules/collections/interfaces/collection-media.interface.ts`) are clean
promotion candidates. This is a multi-PR effort — untangle ownership first, then
migrate schemas.
- **Build contracts before trusting downstream errors.** When shared types change
in `packages/contracts`, run the contracts build first. Server/UI diagnostics
can disagree while one side still resolves generated `dist` types and the
other resolves source files.
---
## Build, test & migrations
### Jest transform & circular imports
Server tests run through **`@swc/jest`** (not ts-jest). The codebase has
circular dependencies — `forwardRef(() => X)` constructor injections plus
bidirectional TypeORM entity relations — kept SWC-safe via TypeORM `Relation<>`
wrappers on relation props and type-only import aliases on `forwardRef`-injected
constructor params (DI tokens unchanged). **If you add a cross-module import that
closes a cycle, follow that pattern**, or SWC's strict CommonJS live bindings
will turn it into a runtime TDZ `ReferenceError: Cannot access 'X' before
initialization`.
`apps/server` also runs under **Yarn PnP**, so debug scripts cannot assume
`node_modules/jest/bin/jest.js` exists locally. For direct Jest-under-Node
entrypoints, use `yarn node $(yarn bin jest) ...`.
### Writing DATA migrations (backfills, no schema change)
- `migration:generate` **cannot** produce them — it diffs entity metadata vs DB
and reports "No changes in database schema were found." Scaffold with
`migration:create src/database/migrations/<Name>`.
- Write `up()` using TypeORM's **QueryBuilder**
(`queryRunner.manager.createQueryBuilder()…`), **not** raw
`queryRunner.query('SELECT/UPDATE…')` — the implementation rules forbid manually
crafted SQL.
- Migration **spec files must NOT live under `src/database/migrations/`** — the
datasource glob (`src/database/migrations/**/*.ts`) makes the `migration:run`
CLI compile them with ts-node and fail on jest globals. Put them in a sibling
dir like `src/database/migration-tests/` (jest rootDir=src still finds them).
- Test data migrations with **in-memory SQLite**: `new DataSource({
type:'better-sqlite3', database:':memory:', entities:[], synchronize:false })`,
create the table, run `migration.up(queryRunner)`, assert. QueryBuilder-on-table-
name needs no entity registration.
- `apps/server/.gitignore` ignores `/dist-test` (output of `test:e2e` tsc) — don't
`git add -A` blindly after `test:e2e`.
### TypeORM migration CLI workaround
`yarn workspace @maintainerr/server migration:run` / `migration:generate` (the
`typeorm-ts-node-commonjs` CLI) does **not** run out-of-the-box: ts-node errors
with `TS5011` (`apps/server/tsconfig.json` sets `declaration:true`/`outDir` but no
explicit `rootDir`) and "Cannot find name 'process'" (no node types for ts-node).
Workaround:
```jsonc
// apps/server/tsconfig.migrate.json (throwaway; safe to delete, gitignore-able)
{
"extends": "./tsconfig.json",
"compilerOptions": {
"declaration": false,
"declarationMap": false,
"composite": false,
"incremental": false,
"sourceMap": false,
"rootDir": "./",
"types": ["node"],
},
}
```
Then: `cd apps/server && TS_NODE_PROJECT=./tsconfig.migrate.json yarn migration:run`
(or `migration:generate <path>`).
**Verifying a migration:** the app uses `synchronize:false` + `migrationsRun:true`
(`typeOrmConfig.ts`), so `migration:run` ≡ `yarn dev` for schema. Definitive sync
check: build the branch DB from empty (`rm data/maintainerr.sqlite*` then
`migration:run`), then `migration:generate` must report **"No changes in database
schema were found."** `data/maintainerr.sqlite` is gitignored.
---
## Local dev: seeded DB + mock media servers
For end-to-end checks of media-server-dependent flows (rules, collections,
overview, storage) without a real Plex/Jellyfin — and to drive the UI with
Playwright against deterministic data. Full workflow is in
[AGENTS.md](../../AGENTS.md); the scripts live in `tools/dev/`:
- `tools/dev/fake-jellyfin.mjs` — stateless mock Jellyfin (`:8096`). Answers the
real `@jellyfin/sdk` paths the adapter calls: `GET /System/Info/Public`,
`GET /Users` (must include a `Policy.IsAdministrator` user), `GET
/Library/MediaFolders` (library ids must be `jellyfin-movies`/`jellyfin-shows`
to match the seed), and `GET /Items?…&ids=<id>` (the LIST form — getMetadata
hydration uses this, not `/Items/{id}`). Item images 302-redirect to picsum.
- `tools/dev/fake-plex.mjs` — stateless mock Plex (`:32400`); covers the Plex-only
getter paths.
- `tools/dev/seed-db.mjs` — the only DB-touching script. Resets and seeds
collections / rule groups (with rules covering ~all properties) / settings /
notifications / exclusions / overlays into `data/maintainerr.sqlite`. Target a
server with `MEDIA_SERVER=plex|jellyfin` (default jellyfin). Run with `yarn dev`
**stopped** (SQLite is single-writer), then restart.
**Key limitation:** a DB-only seed does **not** populate the collection-detail
media grid or Overview — `CollectionsService.hydrateCollectionMediaWithMetadata`
hydrates each row against the **live** media server and drops any id it can't
resolve. You need the matching mock running for grids to render and for rule
evaluation to run end-to-end via `POST /api/rules/test {rulegroupId, mediaId}`.
After editing server code, **restart `yarn dev`** — a long-lived dev server serves
stale getter logic. Watchlist / plex.tv user enrichment can't be mocked locally
(they hit plex.tv) and degrade gracefully.
---
## Tooling: MCP servers
The workspace configures MCP servers (kept in sync across `.codex/config.toml`,
`.mcp.json`, and `.vscode/mcp.json`):
- **github** (HTTP, read-only): use for GitHub queries (issues, PRs, repo
metadata) instead of shelling out to `gh` when an MCP tool is available.
- **playwright** (stdio, `--headless --isolated`): use for browser-driven testing
/ verification of UI changes instead of asking for manual verification. Save
screenshots under `.playwright-mcp/`.
@@ -1,5 +1,9 @@
---
applyTo: "**"
# Task-specific: not auto-loaded on every interaction. Copilot's applyTo is a
# file-path glob and can't express "during a release review", so this triggers
# on release artifacts (changelogs, release workflows) as a proxy. For an
# explicit release audit, read this file directly — AGENTS.md links it.
applyTo: "CHANGELOG.md,apps/*/CHANGELOG.md,.github/workflows/release_*.yml"
---
## Release review — how to audit a release candidate before tagging
@@ -159,6 +163,36 @@ scope is broad.
A green build and test run is necessary but not sufficient — tests only
catch regressions that someone thought to write a test for.
### 5a. Exercise the affected flows end-to-end (seeded DB + Playwright)
Automated suites do not cover rendering, navigation, or the
media-server-dependent flows (rules, collections, overview, calendar,
storage). Always drive those in a real browser before signing off — and
always against the **seeded dev DB + mock media server**, never a hand-set
or empty database, so every reviewer hits the same deterministic dataset.
1. Start the matching mock media server:
- `node tools/dev/fake-jellyfin.mjs` (`:8096`), or
- `node tools/dev/fake-plex.mjs` (`:32400`) for the Plex-only getter paths.
2. Stop `yarn dev` (SQLite is single-writer), seed, then restart:
- `node tools/dev/seed-db.mjs` (Jellyfin, default) or
`MEDIA_SERVER=plex node tools/dev/seed-db.mjs`.
- Re-run the seed after any DB-shape migration in the release so the
dataset matches the migrated schema.
3. Drive the UI with **Playwright** (the `playwright` MCP server) — do not
rely on eyeballing screenshots alone. At minimum, for the areas the diff
touches: load the page, perform the changed interaction, and assert on
the resulting DOM/network. Capture a screenshot of each flow you touched
for the report.
4. For server-side rule/getter changes, confirm live output through the
seeded stack: `POST /api/rules/test {"mediaId","rulegroupId"}` or
`POST /api/rules/:id/execute`. After editing server code, **restart
`yarn dev`** — a long-lived dev server serves stale getter logic.
Note what you exercised (and what you could not — e.g. plex.tv watchlist
enrichment can't be mocked locally) in the report. A flow you did not drive
is an untested flow; say so rather than implying coverage.
### 6. Write the report
Use severity levels, in this order:
+20 -5
View File
@@ -20,6 +20,21 @@ Maintainerr is a media management application that helps users automatically man
For the broader system architecture map, see [ARCHITECTURE.md](ARCHITECTURE.md).
## Documentation map
**Standing rules — read before writing any code (they apply to all work):**
- [implementation.instructions.md](.github/instructions/implementation.instructions.md) — implementation rules and API-doc references.
- [project-notes.instructions.md](.github/instructions/project-notes.instructions.md) — non-obvious project knowledge, conventions, and gotchas (rule engine, Tailwind v4, migrations, naming) that isn't derivable from the code or git history.
**Task-specific — read only when the task calls for it (don't load them every session):**
- [release-review.instructions.md](.github/instructions/release-review.instructions.md) — when auditing a release candidate before tagging.
- [ARCHITECTURE.md](ARCHITECTURE.md) — before changing cross-module boundaries.
When you add a doc, list it under the matching heading here. For how each agent
(Claude, Copilot, Cursor, Codex) loads these docs, see [README_AGENTS.md](README_AGENTS.md).
## Repository Structure
This is a **TypeScript monorepo** managed with **Turborepo** and **Yarn workspaces**:
@@ -289,15 +304,15 @@ These specifications provide comprehensive type definitions and endpoint documen
### Local dev mocks & seeding (manual / Playwright testing)
For end-to-end checks of media-server-dependent flows (rules, collections,
overview, calendar, storage) without a real Plex/Jellyfin, the `dev/` folder has
three scripts that **complement Playwright** — Playwright drives the UI, these
overview, calendar, storage) without a real Plex/Jellyfin, the `tools/dev/` folder
has three scripts that **complement Playwright** — Playwright drives the UI, these
provide the backend data:
- `dev/fake-jellyfin.mjs` — stateless mock Jellyfin (`:8096`).
- `dev/fake-plex.mjs` — stateless mock Plex (`:32400`); covers the Plex-only
- `tools/dev/fake-jellyfin.mjs` — stateless mock Jellyfin (`:8096`).
- `tools/dev/fake-plex.mjs` — stateless mock Plex (`:32400`); covers the Plex-only
getter paths (smart collections, watch history, accounts, ratings,
shows/seasons/episodes) that the Jellyfin mock can't.
- `dev/seed-db.mjs` — the **only** DB-touching script. Seeds settings,
- `tools/dev/seed-db.mjs` — the **only** DB-touching script. Seeds settings,
collections, and rule groups **with rules** covering ~all rule properties, plus
notifications, cron, logs, exclusions, and overlays. Target a server with
`MEDIA_SERVER=plex|jellyfin` (default `jellyfin`).
+5
View File
@@ -25,6 +25,7 @@ Maintainerr/
|-- docs/ # Feature-level technical notes
|-- docker/ # Docker helper configuration
|-- tools/ # Release and maintenance scripts
| `-- dev/ # Local dev mocks (fake Plex/Jellyfin) and DB seed
|-- .codex/config.toml # Codex project MCP server config
|-- .mcp.json # Claude Code project MCP server config
|-- .vscode/mcp.json # VS Code MCP server config mirror
@@ -224,6 +225,10 @@ Testing conventions:
- Project MCP server config lives in `.codex/config.toml`, `.mcp.json`, and
`.vscode/mcp.json`; keep them in sync. The GitHub MCP server is read-only,
and Playwright screenshots should be saved under `.playwright-mcp/`.
- End-to-end checks of media-server-dependent flows use the dev mocks and DB
seed under `tools/dev/` (`fake-plex.mjs` / `fake-jellyfin.mjs` +
`seed-db.mjs`) to drive the UI with Playwright against deterministic data;
see `AGENTS.md` for the workflow.
See `CONTRIBUTING.md` for setup, branching, and pull request expectations.
+57
View File
@@ -0,0 +1,57 @@
# Agent instruction wiring
How this repo's AI coding agents (Claude, GitHub Copilot, Cursor, Codex) load
their instructions. Each agent **auto-loads a different entrypoint**, but they
all converge on **`AGENTS.md`** as the single documentation index, and each
entrypoint *also names the two standing rules directly* so they can't be missed.
This file is the single source for the wiring; `AGENTS.md` links here.
```
CLAUDE
auto-loads → .claude/rules/implementation.md
├─→ AGENTS.md ........................ (doc index)
│ ├─→ implementation.instructions.md [standing]
│ ├─→ project-notes.instructions.md [standing]
│ ├─→ release-review.instructions.md [task-specific]
│ └─→ ARCHITECTURE.md [task-specific]
└─→ implementation.instructions.md + project-notes.instructions.md
(named directly → read before any code, can't be missed)
also: SessionStart hook injects AGENTS.md (belt-and-suspenders)
COPILOT
auto-loads → .github/copilot-instructions.md
├─→ AGENTS.md → (same index as above)
└─→ implementation.instructions.md + project-notes.instructions.md (named)
also auto-applies via applyTo:"**" → implementation.instructions.md, project-notes.instructions.md
release-review → applyTo scoped to CHANGELOGs/release workflows (not every file)
CURSOR
auto-loads → .cursor/rules/project.mdc (alwaysApply: true)
├─→ AGENTS.md → (same index as above)
└─→ implementation.instructions.md + project-notes.instructions.md (named)
CODEX
auto-loads → AGENTS.md (the index itself)
├─→ implementation.instructions.md [standing]
├─→ project-notes.instructions.md [standing]
├─→ release-review.instructions.md [task-specific]
└─→ ARCHITECTURE.md [task-specific]
all four entrypoints ──────────────→ AGENTS.md (single doc index)
project-notes.instructions.md ──→ ARCHITECTURE.md, AGENTS.md ✓
implementation.instructions.md ─→ ARCHITECTURE.md ✓
```
## Rules of the structure (keep it working)
- **`AGENTS.md` is the single index.** Add any new doc to its "Documentation map".
- **Standing rules** (read before any code): `implementation.instructions.md` and
`project-notes.instructions.md``applyTo: "**"` and named in every entrypoint.
- **Task-specific** (read on demand, not every session): `release-review.instructions.md`
(Copilot `applyTo` scoped to release artifacts) and `ARCHITECTURE.md`.
- **Each agent entrypoint is a thin router** to `AGENTS.md` + the two standing rules.
When you change the wiring, update all four entrypoints together:
`.claude/rules/implementation.md`, `.github/copilot-instructions.md`,
`.cursor/rules/project.mdc`, and `AGENTS.md`.
@@ -15,11 +15,11 @@
*
* Usage
* -----
* node dev/fake-jellyfin.mjs # listens on :8096 (matches dev seed)
* FAKE_JELLYFIN_PORT=8096 node dev/fake-jellyfin.mjs
* FAKE_JELLYFIN_LOG=1 node dev/fake-jellyfin.mjs # log every request
* node tools/dev/fake-jellyfin.mjs # listens on :8096 (matches dev seed)
* FAKE_JELLYFIN_PORT=8096 node tools/dev/fake-jellyfin.mjs
* FAKE_JELLYFIN_LOG=1 node tools/dev/fake-jellyfin.mjs # log every request
*
* The dev seed (dev/seed-db.mjs) already points settings.jellyfin_url at
* The dev seed (tools/dev/seed-db.mjs) already points settings.jellyfin_url at
* http://localhost:8096 with a fixed api key + user id, so no settings change is
* needed just start this before (or alongside) `yarn dev`.
*/
@@ -26,9 +26,9 @@
*
* Usage
* -----
* node dev/fake-plex.mjs # listens on :32400
* FAKE_PLEX_PORT=32400 node dev/fake-plex.mjs
* FAKE_PLEX_LOG=1 node dev/fake-plex.mjs # log every request
* node tools/dev/fake-plex.mjs # listens on :32400
* FAKE_PLEX_PORT=32400 node tools/dev/fake-plex.mjs
* FAKE_PLEX_LOG=1 node tools/dev/fake-plex.mjs # log every request
*
* Point Maintainerr at it (settings): plex_hostname=localhost, plex_port=32400,
* plex_ssl=0, any non-empty plex_auth_token, media_server_type='plex'. The
+10 -9
View File
@@ -10,8 +10,8 @@
*
* This is the only one of the three dev scripts that touches the DB; the
* companion mocks are stateless HTTP servers:
* - dev/fake-jellyfin.mjs (mock Jellyfin, :8096) pairs with MEDIA_SERVER=jellyfin
* - dev/fake-plex.mjs (mock Plex, :32400) pairs with MEDIA_SERVER=plex
* - tools/dev/fake-jellyfin.mjs (mock Jellyfin, :8096) pairs with MEDIA_SERVER=jellyfin
* - tools/dev/fake-plex.mjs (mock Plex, :32400) pairs with MEDIA_SERVER=plex
*
* Notes
* -----
@@ -31,10 +31,10 @@
*
* Usage
* -----
* 1. Start the matching mock (dev/fake-jellyfin.mjs or dev/fake-plex.mjs).
* 1. Start the matching mock (tools/dev/fake-jellyfin.mjs or tools/dev/fake-plex.mjs).
* 2. Stop `yarn dev` (SQLite allows a single writer).
* 3. node dev/seed-db.mjs # Jellyfin (default)
* MEDIA_SERVER=plex node dev/seed-db.mjs # Plex
* 3. node tools/dev/seed-db.mjs # Jellyfin (default)
* MEDIA_SERVER=plex node tools/dev/seed-db.mjs # Plex
* 4. Start `yarn dev` and open http://localhost:3000/collections
*/
import { createRequire } from "node:module";
@@ -42,7 +42,8 @@ import { fileURLToPath } from "node:url";
import { dirname, resolve } from "node:path";
const __dirname = dirname(fileURLToPath(import.meta.url));
const repoRoot = resolve(__dirname, "..");
// This script lives in tools/dev/, so the repo root is two levels up.
const repoRoot = resolve(__dirname, "..", "..");
// better-sqlite3 lives in the server workspace; resolve it from there.
const require = createRequire(resolve(repoRoot, "apps/server/package.json"));
const Database = require("better-sqlite3");
@@ -69,8 +70,8 @@ const TARGET = process.env.MEDIA_SERVER === "plex" ? "plex" : "jellyfin";
const APP = TARGET === "plex" ? 0 : 6; // Application id: Plex=0, Jellyfin=6
const LIB =
TARGET === "plex"
? { movie: "1", show: "2" } // dev/fake-plex.mjs section ids
: { movie: "jellyfin-movies", show: "jellyfin-shows" }; // dev/fake-jellyfin.mjs
? { movie: "1", show: "2" } // tools/dev/fake-plex.mjs section ids
: { movie: "jellyfin-movies", show: "jellyfin-shows" }; // tools/dev/fake-jellyfin.mjs
// ruleTypeId: NUMBER=0 DATE=1 TEXT=2 BOOL=3 TEXT_LIST=4. Property ids/types
// differ per server, so keep one map each (mirrors RuleConstants).
@@ -163,7 +164,7 @@ const run = db.transaction(() => {
// 2) Configure the active media server + metadata + integrations.
if (TARGET === "plex") {
// Points at dev/fake-plex.mjs. The fixed machine id lets the primary
// Points at tools/dev/fake-plex.mjs. The fixed machine id lets the primary
// connection succeed without plex.tv re-discovery.
db.prepare(
`UPDATE settings SET