LeetCode interview-prep campaign (8 weeks, 2026-08-17 → Oct 11) run as a repo: solution files under `work/`, a Blume docs site under `apps/docs/`, GitHub Actions that reconcile issues and publish docs, and a Cloudflare Worker (`apps/api/`) that runs the spaced-repetition system (SRS) — daily digest email, one-tap logging, weekly review issues, live charts. Issues model the curriculum: ~24 `topic` issues (`Topic NN — Name`) own ~161 `problem` sub-issues titled `LC <num> · <Name>`, with difficulty and set carried **only** by labels (`set:core|optional|deferred`, `diff:*`) — never by the title; milestones are phases; a user-level GitHub Project ("Interview Prep", #2) mirrors review state.
- **Reconcile, don't react.** `close-solved.ts`, `close-topics.ts`, and the Worker's catalog sync recompute desired state from scratch each run: re-runs are no-ops, backfills need no special casing, closing is one-directional. `close-solved` posts its *whole* implemented set to `/admin/solved`, not just the issues it closed this run.
- **Solving has three doors, one write path.** A digest one-tap, a `/done` comment, and a solution landing in `work/` all end in `logAttempt()`. Closing an issue is *not* one of them: the catalog reconcile never reads issue state, so a `work/` push that skipped the email would otherwise leave `stage='new'` — invisible to the charts, the digest's solved ticks, and the drill/gate pools. Hence `/admin/solved`, which takes `{lc, bucket}` pairs (`source='commit'`): the bucket names the rung the file settles — `work/1` settles `new`, `work/3` settles `+3`, `work/7` settles `+7` — so a pushed re-solve advances the ladder instead of vanishing, and re-posting the whole implemented set writes nothing because the rung named has already been left behind. That stage check is the entire idempotency mechanism; no index, no dedupe table, no state file. `close-topics` needs no such call — D1 stores no topic completion, only problem rows.
- **The rest day is structural, not cosmetic.** Sunday is never booked: topics run Mon–Fri (`apps/api/data/schedule.json`), the gate is Saturday, and the ladder's windows are 3 and 7 precisely because those return a solve to a working day. Every scheduled date — ladder review, levelled overflow, deferred-Hard release, review-issue window — comes out of `workingDay()` in `apps/api/src/srs.ts`, which slides the one exception (a Thursday solve's `+3`) forward to Monday. Forward, never back: a review may slip later than its interval, never shorten it. Never mint a scheduled date with bare `addDays()`, and never "fix" a Sunday landing downstream — the digest's Sunday rest branch is a courtesy, not the mechanism. `apps/api/src/srs.test.ts` sweeps the campaign calendar to keep this honest.
- **One schedule, one answer to "what is open today".** D1's ladder IS the schedule; `dueToday()` (`POST /admin/due`) is its read side and `DUE_WHERE` is the single SQL definition of "due", shared by the digest, the review issue and the docs queue chart. `spaced-repetition.yml` renders that answer into today's `Spaced Repetition — <Month D, YYYY>` issue — grouped by rung, `WINDOW_CAP` (3) required per rung with the overflow listed as optional, keyed by the ET date in the title so a re-run rewrites one body. It schedules nothing, stores nothing, and writes nothing on Sunday. Without `SRS_ADMIN_KEY` it FAILS rather than inventing a second schedule from issue close dates — that reconstruction was the drift, and it is gone. NEVER add a JSON state file (`.github/problems.json` and friends) or a second "when is this due" rule: D1 answers it, with one writer (`logAttempt`) and git-free history in `attempts`.
- **The picker owns the local review loop; the buckets are its state.** `work/` holds three buckets (`1` = first solve, `3`/`7` = blind re-solves, named after the ladder rungs) and `bun run pick` has three sections — Tab cycles them. Pools are reconciled from the filesystem plus one `git log` (`apps/cli/work.ts`): a first solve dates from the ET date of the commit that added its `work/1` file — the same push that closes the problem issue and starts the +3/+7 windows — and a window stops being owed the moment its bucket holds a file. Uncapped, unlike the review issue's `WINDOW_CAP`: that issue is one day's assignment, the picker is everything still owed. An empty scaffold under `work/1` owes nothing (it is unsolved), which is why `isImplemented()` lives in `apps/cli/source.ts` and is shared with `close-solved.ts`. leetcode-cli reads its output directory from `~/.leetcode/workspaces/<active>/config.json` and nowhere else — no flag, no env var — so `pick.ts` MERGES `workDir` into that file around the child (never replays saved bytes: the CLI owns the file too) and leaves it pointed at `work/1`, then treats the scaffold's existence as the acceptance test, since leetcode-cli exits 0 even when an expired session made it write nothing. The picker itself writes no state: pushing the re-solve is what moves the ladder, via `close-solved` → `/admin/solved` with that file's bucket.
- **The README charts are live, never committed.** They are Worker endpoints reading D1 per request (`Cache-Control: max-age=300`, honored by GitHub Camo), so a solved push moves them within ~5 minutes with no commit and no workflow of their own.
- **D1 owns SRS state; GitHub issues own the catalog; `apps/api/data/schedule.json` owns the calendar.** The catalog reconcile never invents rows and never overwrites SRS columns (`stage`, `next_review`). `Target Date` is the *only* Project field the Worker mirrors (`apps/api/src/mirror.ts`): failures are warnings, never lost D1 writes; topic rows' `Target Date` is never written. SRS stage and first-attempt result live **only** in D1 — do not re-add them as Project fields. The Project's `Set`/`Difficulty` single-selects are a projection of the issue labels, reconciled by `bun run sync-project-fields` — a best-effort final step of `close-solved.yml`, never the Worker.
- **The plan names days; the samplers fill the gaps.** `apps/api/data/schedule.json` maps a date to a `ScheduledDay` (`apps/api/src/srs.ts`): `topics` (one or more topic issues — a doubled day lists two, and the digest renders each with its core list then the optional "if time" list), `problems` (a set day: fixed numbers, rendered like a topic), `gate` (Saturday's blind set, verbatim — `createReviewIssue` refuses to create the issue if any number is not a catalog row, so gate problems must exist as `set:optional` sub-issues of their topic first), or `sweep` (every problem whose last attempt failed, taps to re-log, no drills). Every number a `gate` or `problems` entry names is in `RESERVED`, and the drill/gate samplers never draw it — that is what keeps a planned gate problem unseen until its Saturday. Dates absent from the plan still get the ladder's reviews and drills; a Saturday without a `gate` entry falls back to sampling this week's optional pools.
- **Determinism = idempotency.** Drill/gate sampling uses a seeded PRNG (`rng()` in `apps/api/src/srs.ts`, FNV-1a → mulberry32, seed = date / ISO week); the digest is keyed by ET date in `email_log`; one-tap links are unique on (problem, date, kind).
- **DST-proof crons:** each event has two UTC crons; code fires only on the computed ET hour (`etHour`), unit-checked in `apps/api/src/srs.test.ts`.
- **Chained workflows:** `GITHUB_TOKEN`-driven issue closes fire no `issues` events, so `close-topics.yml` chains off `workflow_run` of Close Solved instead. The Worker's webhook uses its own `GH_PAT`, so its events flow normally.
- **One "finished topic" rule, two consumers.** `apps/cli/topics.ts` (`coreSet()`: every `set:core` sub-issue closed, zero core = never) is read by `close-topics.ts` (closes the topic issue) and `release-topics.ts` (the `release` job of `close-solved.yml`). The release reads the sub-issues, not the topic issue, so the push that finishes a topic releases it in the same run instead of waiting for Close Topics to chain after it. Version = [timever](https://gist.github.com/twolfson/de1b004dd22536b8e668) (`YYYYMMDD.HHMMSS.NNNNNNNNN`, UTC) of the instant the last core problem closed; the tag is annotated and backdated to that instant, on the last first-parent main commit before it; the body is a changelog snapshot at that instant (a one-sentence summary first — Blume's changelog makes it the page's meta description — then closed on the finish day / earlier / open at release, with solution links at the tag). A release is found by its `<!-- release-topics: #N -->` body marker, never by tag name, so re-closing a core problem never mints a second one; an existing release keeps its tag and has its name (the topic title) and body rewritten whenever the render changes. The docs `/changelog` is these releases (`githubReleases` in `apps/docs/blume.config.ts`, fetched at build with `GITHUB_TOKEN`), dated by the timever tag, not `published_at` (which the API cannot backdate): `releasesDatedByTag` rewrites each timever release's `published_at` before Blume maps it, via `custom(githubReleasesSource(…))` because the config-level `githubReleases()` takes no `fetchImpl`. Changing the tag format breaks the changelog dates.
| `work/<1\|3\|7>/<Difficulty>/<Category>/<num>.<slug>.{js,py}` | Solutions, e.g. `work/1/Easy/Array/1.two-sum.py`. The top directory is the spaced-repetition bucket: `1` = first solve, `3`/`7` = the blind re-solves of that problem 3 and 7 days later. Machine-parsed header comment (title / `Difficulty:` / URL / `─` rule / statement) — preserve its exact shape |
| `apps/docs/` | Blume site (`blume.config.ts`, `content/`, `components/` + `components.ts`, `islands/`, `public/`). `content/(0N-<phase>)/<topic>/<num>-<slug>.mdx` plus one `meta.ts` per phase and per topic folder, all generated by sync (a phase's once, then hand-owned): the phase folder is a `(group)` (sidebar section, no route segment), the topic folder is the README campaign topic slug (`tree-dfs/`, `heap-priority-queue/`) and a real route segment (`/tree-dfs/invert-binary-tree`), the `meta.ts` files carry the exact labels, phase/topic numbers and icons so the sidebar reads in curriculum order with each topic a collapsible group. A problem page's badge row (between the description and the body) is rendered, not written: the MDX `wrapper` (`components/ProblemBadges.astro`, registered as `mdx.wrapper` in `components.ts` — Blume's page template renders the title and description itself with no slot before the body, so the wrapper, which MDX hands no route, reads it off `Astro.url`) reads the page's `topics:` (LeetCode's topic tags, from sync) and `video:` (NeetCode YouTube id, from `bun run videos`) frontmatter — both declared via `frontmatter.extend` in `blume.config.ts`, hand-rolled Standard Schemas, no zod — and links a tag the campaign teaches to its topic landing page (filled), any other tag to its guide page `/guides/<slug>` (outlined; LeetCode's problem list if the guide is gone, gray and iconless), and the video as a red `Explanation` badge. Every badge in the row is a pill. A tag badge carries the icon its target shows in the sidebar (read off `data.navigation`: the topic `meta.ts` icon, the guide's `icon:`) and the tag's own colour from `lib/tag-colors.ts` — unique per tag site-wide, assigned greedily over every tag with a page so tags that share problem pages sit furthest apart in OKLab, in a lightness band that keeps ≥ 3:1 contrast in both themes (Blume's Badge takes one hex for both). The reverse direction is the `PageFooter` slot (`components/BacklinksList.astro`): an Obsidian-style `<num> <name>` list under every topic landing and guide page of the problems whose badges link there, plus a topic's own folder members (so a topic no tag names, `hybrid-structures`, still lists its problems), in a native `<details>` open on load whose top-right chevron collapses it (no JS); problem pages are recognised by their `<num>. <name>` title and render no list. Which tags the campaign teaches is ONE map, `apps/docs/topic-landings.ts` (tag slug → landing routes), and where a badge points is ONE function beside it, `tagTarget()` — read by both components (so badges and backlinks cannot disagree) and the map by sync. `content/guides/` (the `Guides` header tab) is one page per tag outside that map, filed by hand into `(group)` categories — `(1-data-structures)/`, `(2-algorithms)/`, `(3-techniques)/` (hand-written `meta.ts` each; no route segment, so every guide stays `/guides/<slug>`). Sync finds a guide by slug anywhere under `guides/` and creates a placeholder only when none exists or it is empty — at the `guides/` root, for you to file into a category and give a Lucide `icon:` — the one every badge linking to it shows — that no topic and no other guide uses (sync warns while it is missing, a `TOPIC_ICONS` value, or another guide's) — and after that owns exactly one key of it: `sidebar.badge`, the sidebar count, rewritten every run to the guide's backlink count (problem pages carrying the tag — equal to the `BacklinksList` rows, since a guide's tag has no landing; dropped at zero). Everything else is hand-owned (a guide whose tag no page carries any more is left alone, badge dropped), with frontmatter `list:` — the tag's LeetCode problem list, rendered by the same wrapper as an orange `LeetCode list` badge; sync also writes `guides/meta.ts`. A hand-written `(0N-<phase>)/<topic>/index.mdx` (`hidden: true`) is that topic's landing page: Blume ≥ 1.7.3 links the sidebar group row to it (`sliding-window/index.mdx` → `/sliding-window`) and the hidden flag drops the duplicate row. Two kinds of cu
| `.github/workflows/` | deploy (docs → Pages with `GITHUB_TOKEN` for the releases-backed changelog, Worker → Cloudflare with the `OPENROUTER_API_KEY` Actions secret put into it first, on every main push), close-solved (issues + `/admin/solved` + Project `Set`/`Difficulty`, needs `SRS_ADMIN_KEY` and `PROJECT_PAT`; then the `release` job: timever tag + release per finished topic, `contents: write`), close-topics, sync-d1 (issue edits → `/admin/reconcile`), spaced-repetition (daily 06:00 ET cron → today's review issue; `GITHUB_TOKEN` only, no secrets) |
| `.githooks/` | Tracked git hooks; `bun install` points `core.hooksPath` here (root `prepare` script, no husky). `pre-commit` runs `bun run sync` and stages only what sync changed (pages, `apps/docs/redirects.ts`), so a solution, its page and its `/<num>` short link land in one commit; it refuses to run while `work/1` has anything unstaged (sync reads the tree, so the page would not match the commit), and leaves pre-existing dirt under `apps/docs/content` (in-progress `## Approach` prose) unstaged. `git commit --no-verify` for a deliberate partial commit |
- **Scripts are either entries or libraries.** Entries (`apps/cli/close-*.ts`) use shebang + top-level `await`. Libraries (`apps/cli/github.ts`, `apps/cli/report.ts`, everything in `apps/api/src/`) are side-effect-free on import — nothing touches network/credentials until a factory (`github()`, `projectMirror()`) is called.
- **Shared plumbing:** `github()` gives `repo`, `api()` (throws `METHOD path -> status body`), `list()` (paginating async generator), `closeIssue()` (comment **first**, then close — a failed PATCH still leaves a trace). `report.ts` gives `printTable`/`markdownTable`/`writeStepSummary`.
- **Narrow API payloads with guards**, not inline casts: `if (!("number" in value) || typeof value.number !== "number") return;` (see `readProblemIssue` in `close-solved.ts`). Named-const casts only with a reason.
- **Dry-run everything:** reconcilers take `--dry-run`/`DRY_RUN=1`; Worker admin routes take `?dry=1&force=1&date=` (the `SRS_TODAY` equivalent) and `wrangler dev` runs against local D1 — exercise logic there before touching production state.
- **Dates:** always ET calendar strings (`YYYY-MM-DD`), arithmetic anchored at noon UTC (`atNoon` in `apps/api/src/srs.ts`) to dodge DST. Never `new Date()` math directly.
- **Style:** section-divider comments (`// ── name ───`), file-top doc comments explaining the *why* and invariants, 2-space JSON with trailing newline, `Map` for dynamic keys / `Record` for static tables, no tiny one-expression wrapper functions.
- **Known intentional duplication:** `sync.ts` keeps its own header split instead of sharing `apps/cli/source.ts`, because `sync.ts` runs its whole pipeline on import and so cannot be imported from. Don't "deduplicate" it. `close-solved.ts` and `work.ts` do share it — the stub test decides both "close the issue" and "owes a review", and the two must never disagree.
-`apps/api/src/srs.ts` — SRS domain: the ladder (`new → +3 → +7 → retired`; fail resets to `+3`; first-ever log enters at `+3`), `WINDOWS`/`STAGES`/`TEMPERATURES`/`DIFFICULTIES`/`SETS` (the one vocabulary shared by the charts, the `work/<n>` buckets, the review issues and the stats dashboard), ET date math, `workingDay()` (no scheduled date is ever a Sunday), `temperatureOf()` (days since a concept was last exercised → `hot`/`fresh`/`fading`/`cold`, bands derived from `WINDOWS` so they cannot drift; read-side only, nothing is scheduled off it), seeded sampling, `logAttempt()` (the ONE write path — email taps, webhook, gate scoring all converge here), and overload leveling: at most `REVIEW_CAP` (3) reviews surface per day — `levelReviews()` (run by `sendDigest` on real sends only, never dry) gives everything past the cap a concrete future WORKING day, ≤ 3 per day, oldest first, instead of letting the due pile grow.
-`apps/api/src/index.ts` — router + cron dispatch; `apps/api/wrangler.jsonc` — bindings (`DB`, `EMAIL`), crons, vars; secrets `GH_PAT`/`WEBHOOK_SECRET`/`LINK_KEY` via `wrangler secret put`, and `OPENROUTER_API_KEY`, whose one copy is the repo's Actions secret (deploy.yml puts it before every deploy — rotate it there).
-`apps/api/src/ask.ts` — `POST /ask`, the docs assistant's backend. Blume's own assistant route is server-rendered and cannot ship in the static Pages build (`BLUME_SERVER_FEATURE_REQUIRED`), so `blume.config.ts` sets `ai.assistant.endpoint` to this route instead of a `provider`. It speaks the Blume island's wire contract (`{ messages, page: { path } }` in, a plain-text stream out, a 503 "The assistant is not configured…" when the key is missing), validates like Blume's generated route (1–40 user/assistant messages, 24 000 chars, 64 KB — no caller system prompt), grounds on the published `llms-full.txt` (current page first, then keyword-ranked excerpts in Blume's 6 × 2 000 / 10 000-char budget, cached 5 min), and streams OpenRouter's chat completions over plain `fetch` — no AI SDK — for the `ASK_MODEL` var. `:free` models share an upstream pool and often answer 429, which the island shows as its rate-limit notice.
-`apps/api/src/digest.ts` / `apps/api/src/email.tsx` — the daily digest, split data/presentation. `digest.ts` reads D1 into a `DigestData`; `email.tsx` owns every colour and every sentence, and renders both the HTML and (via its own `plainDigest`, not React Email's `plainText` mode, which flattens the tables) the text alternative. The retrieval rules hold by construction: `DigestRow` has no title and no issue field, so a review or drill line *cannot* leak the topic or a solution link. Subject is `(Day N/56) LeetCode Daily Digest`.
-`apps/api/src/png.ts` — hand-rolled PNG encoder (RGB8, one IDAT, zlib via `CompressionStream("deflate")`, CRC32, 5×7 bitmap font). It exists because every major email client refuses remote SVG, so `/chart/heatmap.png` rasters the heatmap for the digest while `/chart/heatmap.svg` keeps serving the README byte-for-byte. Both come from one `heatmapCells()` so the two pictures cannot drift.
-`apps/cli/sync.ts` — work→docs contract: on existing pages only the frontmatter `icon:` and the `## Solution` section are regenerated, and `topics:` (LeetCode's `topicTags` as `{ name, slug }`, fetched by GraphQL) is filled once — on create, or on any run that finds it missing — never refreshed (delete the key to refetch; offline, the page is written without it and the next sync retries). `## Solution` is the latter bounded by `solutionEnd()` at the next unfenced top-level heading; human prose before it and any sections after it are never touched. Never hand-write solution pages or edit inside `## Solution`. Two classifications, two homes: the sidebar group is the README topic (`readmeTopics()`, first listing wins for a problem under two topics; a warm-up outside the plan groups under its category at `order: 0`, ahead of topic 1) and the `work/1/<Difficulty>/<Category>/` category, which only places an unplanned problem — the page's topic badges are LeetCode's tags, not either classification. Topics nest under their README phase (`## Phase N — Name` headings, shortened via `PHASE_ALIASES`): `content/(0N-<phase>)/<topic>/`. The topic `meta.ts` is script-owned and rewritten every run (label, curriculum order, `TOPIC_ICONS` icon, `display: "group"`); the phase `meta.ts` is written once when missing (`Phase N: Name`, `order: N`) and hand-owned after — rename its title freely. Never rewriting it loses nothing: its order and name are also the folder name, so a README change to either lands in a new folder with a fresh meta. The phase folder is a Blume `(group)` — no route segment, so `/tree-dfs` is unchanged and a landing page there is one level deep, never at `/` — and the topic folder is the real segment; never put a topic `index.mdx` directly inside a `(group)`. A page is `rename`d when its topic or phase changes; a folder every page has left is deleted (climbing into an emptied phase folder) unless it still holds a hand-written `index.mdx` (sync never writes, moves, or deletes one — so page-relative asset paths in a moved landing page are yours to fix). Slugs come from `slugify()`, which glues a leading number to the next token (`1d-dynamic-programming`) because Blume strips `<digits>-` prefixes from routes. The hand-owned parts have their own owners: `## Approach` (a Mermaid flowchart tracing the page's code, placed *before*`## Solution` so a re-sync keeps it) is written per the `problem-approach` skill (`/problem-approach` fills every page missing one); the frontmatter `video:` (the NeetCode walkthrough, rendered as the red `Explanation` badge) is added by `bun run videos --write` (`apps/cli/videos.ts`).
-`apps/cli/work.ts` — the `work/` layout: bucket paths, `isSolution()`/`solvedInBucket()` (the stub test, shared with `close-solved.ts`), `firstSolved()` (one `git log`, ET dates, keyed by LC number so past layout moves don't matter), and `reviewQueues()` (what each window still owes). Everything that needs to know where a solution file lives, or when it was solved, goes through here.
-`apps/api/src/stats.ts` / `apps/cli/stats.ts` / `apps/cli/tui.ts` / `apps/cli/api.ts` / `apps/docs/lib/stats.ts` — the read side. `buildStats()` is the ONE aggregation: phases, ladder, difficulty, `attempting` (open misses, via `MISS_WHERE` in `srs.ts` — the sweep day's definition), per-topic temperature, gates, the 14-day due queue, `heat` (every campaign day, zeros and future days included — the TUI windows its tail rather than asking a second question), `recent` (the ten problems most recently solved, dated by each one's first pass) and `year` (the 365 days ending today — always the calendar year to date — each split by difficulty into attempts and first solves; the docs cards window and bucket it themselves). Two readers consume it, the docs `/progress` islands and `bun run stats`, so its shape is their contract: change the Worker and both readers together (additive fields a reader ignores are safe). `tui.ts` is hand-rolled ANSI (bars, sparklines, heat cells, alternate screen) on charts.ts's palette, and every chart differs by GLYPH as well as by colour so a piped or `NO_COLOR` dashboard still reads. The TUI recomputes nothing: the only local fact it shows is the `work/` solution count, which is inventory, not schedule.
-`README.md` live charts are Worker endpoints (`/chart/*.svg`, `/badge/gate.svg`, 5-min Camo cache). The docs `/progress` page is five LeetCode-style islands — `SolvedRing` (difficulty gauge + "Attempting"), `SubmissionHeatmap` and `SubmissionChart` sharing one row (the heatmap: the last six months in month blocks, the README's colour ramp as theme.css `--heat-0..4`, its attempts / active days / max streak counted from the cells it draws; the chart: this calendar year as stacked Easy/Med./Hard bars per month or week, first solves or every attempt, via two shadcn `ToggleGroup`s), `TopicBubbles` (the campaign topics circle-packed by `d3-hierarchy`, filled by solved share), `RecentSolves` (the ten most recent, linked through `redirects.ts`) — composed from shadcn/ui (`components/ui/{card,chart,skeleton,toggle,toggle-group}.tsx`, Recharts under `chart`, Radix under the toggles) on Blume's tokens: `apps/docs/theme.css` maps shadcn's names onto `--blume-*` (never `accent`, which is Blume's brand colour — vendored components that use it, like `toggle.tsx`, are retokened to `background`/`muted`) and gives shadcn's `data-slot` parts their border colour. They share `lib/stats.ts` (`useStats()`), so the page makes ONE `/api/stats` request however many islands hydrate. shadcn sources live outside `islands/` (every `.tsx` there becomes an island) and import `lib/utils` relatively — Blume's Vite has no `@/` alias; `components.json` + `tsconfig.json` exist for the shadcn CLI and `tsc`, so after `shadcn add` rewrite `@/…` imports to relative ones — and check `package.json`: the CLI has twice installed an unrelated `cn` package for `lib/utils` and skipped real deps (`class-variance-authority`). Blume's `.prose :where(a, p, ol)` rules ignore `not-prose`, so island markup overrides them with `!` utilities, as Blume's own components do.
-`apps/docs/blume.config.ts` — site base `/leetcode`; imports `apps/docs/redirects.ts`, sync-generated (`/<num>` → `/<topic>/<slug>`, one row per page, exact root-relative paths — Blume adds the base and, for the static Pages build, emits a meta-refresh page per rule plus `blume-redirects.json`); `README.md` campaign table doubles as topic-map input to `sync.ts` (`readmeTopics()`, `TOPIC_ALIASES`, `PHASE_ALIASES`).
- Old `.github/srs/` JSON state, its `srs-*` Actions, and the one-shot `import-srs.ts` importer are RETIRED and deleted — do not resurrect; git history is the record.
- **Bun only**: run scripts with `bun apps/cli/<name>.ts` (or the root `bun run` aliases), install with `bun install --frozen-lockfile`. One workspace (`apps/*`), one root `bun.lock`. Node 22 appears only in `deploy.yml` because Blume requires it.
- **Near-zero runtime npm dependencies** — SVG, PNG, terminal ANSI, HMAC (WebCrypto), GraphQL are hand-rolled; Actions scripts use Bun builtins + `fetch` only. Two exceptions, each confined to one surface. The digest email: `apps/api/src/email.tsx` renders React Email (`react`, `react-dom`, `@react-email/components`, `@react-email/render`) inside the Worker, because hand-rolling table-based email HTML that survives Gmail *and* Outlook is not worth owning. It costs ~235 KB gzipped of a 3 MB budget, needs no `nodejs_compat` (it resolves to `renderToReadableStream`), and requires `"jsx": "react-jsx"` in `apps/api/tsconfig.json`. And the docs `/progress` islands: shadcn/ui (`recharts`, `radix-ui`, `class-variance-authority`, `clsx`, `tailwind-merge`) plus `d3-hierarchy` for the bubble packing, client-side in `apps/docs` only. Do not extend either exception to any other module.
- Repo-wide: no test framework — `bun run test` submits one solution to LeetCode's judge. Exception: `apps/api/src/srs.test.ts` (`bun:test`) proves the DST cron guards with fixed dates; run via `bun run api:test`.
- QA is dry-runs + local state: Worker logic against `wrangler dev` + local D1 with `?dry=1&date=`; reconcilers with `--dry-run`; idempotency check = run twice, `cmp` output.
- Docs changes: `bunx blume build --isolated` (from `apps/docs/`) must pass with no new warnings (then `rm -rf apps/docs/.blume-verify`); after sync, review only *created* pages (prose transforms are heuristic).