Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
49 changes: 49 additions & 0 deletions .claude/skills/add-feature/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
---
name: add-feature
description: Add a capability to Tutor end to end, following the test-first port, adapter, service, route, api client, feature slice recipe.
---

# Add a Feature to Tutor

The recipe for adding a capability that needs new server behavior, from domain types down to the UI. Test commits precede or accompany implementation commits at every step. That ordering is deliberate and should stay visible in git history.

## When to use, when not

Use this when the feature reaches new server behavior or a new external dependency. Skip it for a pure UI tweak with no new I/O. That is just a component edit, no port, service, or route involved.

## Decide whether a new port is needed

Only add a port when the feature reaches a genuinely new external dependency, meaning a new service, a new binary, or a new file format. Reusing an existing port is the common case. There are 15 today (`text-generation`, `key-vault`, `image-generation`, `book-repository`, `artifact-store`, `speech-synthesis`, `audio-assembly`, `diagram-renderer`, `epub-import`, `epub-export`, `background-tasks`, `job-journal`, `library-migrator`, `clock`, `os-file-manager`). Check `server/ports/README.md` for the current list before assuming you need a new one.

## Steps, in TDD order

1. Define or extend the domain types in `shared/`. Persisted entities are Zod schemas in `shared/domain.ts`. Request bodies go in `shared/contracts.ts`. Response shapes go in `shared/responses.ts`. SSE event unions go in `shared/events.ts`. `shared/` is flat, there is no subfolder per feature.

2. Declare the port interface in `server/ports/<capability>.ts`, named for the capability and never for the vendor.

3. Write the contract test `server/ports/<capability>.contract.ts` and the in-memory fake `server/ports/<capability>.fake.ts`, then `server/ports/<capability>.fake.test.ts`, which runs the contract against the fake. This is RED first, there is no adapter yet.

4. Write the adapter `server/adapters/<technology>-<capability>.ts` and run the same contract against it. That turns the contract GREEN and is what stops the fake drifting from the real thing. `fs-book-repository.ts`, `kokoro-speech-synthesis.ts`, and `system-clock.ts` are worked examples of this naming. When the technology name already implies the capability's own prefix, the capability half gets shortened instead of duplicated, as in `epub2-import.ts` for the `epub-import` port. A couple of adapters, such as `os-file-manager.ts`, carry no technology prefix at all because there is only one plausible implementation. Match the port's name unless one of those two cases applies.

5. Write the service unit test `server/services/<verb-noun>.test.ts` against the fake. RED.

6. Write the service `server/services/<verb-noun>.ts` as a `createX(deps)` factory, where `deps` is an object of ports by name, for example `{ ai, books, clock }`. GREEN. See `server/services/create-book.ts` for the shape.

7. Add the thin route in the matching `server/routes/*.ts` module (`library.ts`, `reading.ts`, `assessment.ts`, `authoring.ts`, and others by domain area, not one file per entity). Parse the body with the Zod schema from `shared/contracts.ts` through `parseBody()` in `server/http/parse.ts`, then delegate to the service. Register any new port and service through `server/composition-root.ts`, adding a field to the `Ports` interface and wiring it in `createPorts()`. Then run `pnpm docs:routes` and commit the regenerated `docs/api-routes.md`.

8. Add one function to the matching `client/api/*.ts` module with its mocked-fetch test, then the feature hook and component under `client/features/<feature>/` (see `audiobook`, `chat`, `creation`, `library`, `markdown`, `profile`, `progress`, `quiz`, `reader`, `settings` for the existing slices, each with its own `components/` and `hooks/`).

## Conventions checklist

- No raw `fetch` or `new EventSource` outside `client/api/`. Both are lint errors, enforced in `eslint.config.mjs`, not just conventions.
- No SDK or vendor import outside `server/adapters/`.
- No `fs` in `server/services/` or any domain module.
- No magic strings where a named constant already exists.
- JSDoc on new exported symbols, stating a constraint the signature cannot show on its own.
- Tests colocated beside the file they cover, as `*.test.ts`.
- Test commits before implementation commits.
- Domain names taken from `CONTEXT.md`, never invented fresh.

## Finish

Run the `verify` skill before calling the feature done.
57 changes: 57 additions & 0 deletions .claude/skills/verify/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
---
name: verify
description: Verify the Tutor repo is green before a commit, a PR, or a handoff. Runs this project's test, typecheck, lint, generated-docs drift, and server boot checks, then reports a pass or fail table.
---

# Verify Tutor

Runs the checks that gate a commit, a PR, or a handoff to another agent in this repo. This is not a general test runner. It is the specific set of commands this project uses to call a change safe.

## When to use

- Before committing.
- Before opening a PR.
- Before handing work to another agent.
- After any change that touches the server routes.

## Steps

Run these in order. Each names the exact command and what to expect.

1. `pnpm test`. Runs the full Vitest suite. Expect every test to pass, with no failures reported.

2. `pnpm typecheck`. Runs `tsc --noEmit`. Expect no output.

3. `pnpm lint`. Runs ESLint across the project with `--max-warnings 0`. Expect no output. Zero warnings is the bar, not zero errors.

4. `pnpm docs:routes && git diff --exit-code docs/api-routes.md`. Regenerates `docs/api-routes.md` from the live route definitions, then fails if the regenerated file differs from what is committed. If it fails, the fix is to commit the regenerated file, not to hand-edit the doc.

5. Boot check. Starts the real server, hits the health endpoint, then stops the server. Run as separate commands so the server can be killed cleanly:

```
pnpm dev:server &
SERVER_PID=$!
sleep 2
curl -s http://127.0.0.1:3147/api/health
kill $SERVER_PID
```

Expect the curl output to be `{"status":"ok"}`. This binds port 3147, so if an already-running dev server is holding that port, this step fails with `EADDRINUSE`. That is a real signal to go check for a stray server, not noise to ignore or retry past.

Optionally, `pnpm e2e` is a heavier Playwright suite that builds the app and drives a real browser. It is not part of the default set above because of that cost, but run it when a change touches end-to-end reader or generation flows.

## Failure handling

Fix every failure, then rerun the full set from step 1. Never report the first failure and stop partway. If a failing check is out of scope for the current change, say so explicitly in the report, with the actual output as evidence, rather than silently skipping it.

## Report format

Report a markdown table, one row per step, Evidence always a real line copied from the command's actual output, never a paraphrase or a guess.

| Check | Command | Result | Evidence |
|-------|---------|--------|----------|
| Tests | `pnpm test` | Pass | `Test Files 136 passed (136)` |
| Typecheck | `pnpm typecheck` | Pass | (no output) |
| Lint | `pnpm lint` | Pass | (no output) |
| Docs drift | `pnpm docs:routes && git diff --exit-code docs/api-routes.md` | Pass | (no diff) |
| Boot check | `pnpm dev:server` + `curl .../api/health` | Pass | `{"status":"ok"}` |
13 changes: 13 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,19 @@ jobs:

- run: pnpm test

# docs/api-routes.md is generated from the Fastify route registry, so it
# can only be wrong if someone changed a route and did not regenerate it.
# This step is what makes that impossible to merge. It runs last because
# it boots the server, and a failure here is a stale file rather than
# broken code.
- name: Check the generated API routes doc is current
run: |
pnpm docs:routes
git diff --exit-code docs/api-routes.md || {
echo "::error file=docs/api-routes.md::docs/api-routes.md is stale. Run 'pnpm docs:routes' and commit the result."
exit 1
}

# The end-to-end journey suite, in its own job so it runs alongside verify
# rather than behind it. `pnpm test` stays the fast one and never waits on a
# browser. See e2e/README.md for what the journeys cover and why the server
Expand Down
11 changes: 11 additions & 0 deletions .mcp.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{
"mcpServers": {
"tutor": {
"command": "pnpm",
"args": ["mcp:dev"],
"env": {
"TUTOR_API_URL": "http://127.0.0.1:3147"
}
}
}
}
146 changes: 146 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
# Architecture

Tutor is one Electron desktop app for one reader on one machine. It generates a book chapter by chapter, and each chapter is shaped by the feedback and quiz results of the one before it. Everything below follows from that, and from there being exactly one writer and no cloud.

Start here, then follow the links. Domain words are defined in [CONTEXT.md](CONTEXT.md), decisions and their costs in [docs/adr/](docs/adr/README.md), and the HTTP surface in [docs/api-routes.md](docs/api-routes.md), which is generated from the route registry rather than written by hand.

## 1. What talks to what

```mermaid
flowchart LR
reader([Reader])
subgraph app["Tutor.app"]
electron["electron/<br/>main and preload"]
client["client/<br/>React renderer"]
server["server/<br/>embedded Fastify"]
end
library[("On-disk library<br/>Markdown and YAML")]
providers["AI providers<br/>Anthropic, OpenAI, Google"]
local["Kokoro TTS and ffmpeg<br/>on this machine"]

reader --> client
electron --> client
electron --> server
client -->|"HTTP and SSE on 127.0.0.1"| server
server --> library
server -->|"the reader's own API key"| providers
server --> local
```

The Fastify server is embedded in the Electron main process rather than deployed anywhere. It binds `127.0.0.1` on a free port at launch, or 3147 when run standalone with `pnpm dev:server`. The library is plain Markdown and YAML under the OS data directory, which is [ADR 0001](docs/adr/0001-filesystem-as-the-database.md). Narration is synthesized locally instead of by a metered cloud service, which is [ADR 0003](docs/adr/0003-local-kokoro-tts.md).

## 2. The server hexagon

```mermaid
flowchart LR
subgraph core["server core"]
direction TB
routes["routes/<br/>parse and delegate"]
services["services/<br/>application logic"]
domain["domain/<br/>pure rules"]
routes --> services
services --> domain
end

subgraph ports["ports/ (15 interfaces)"]
direction TB
pStore["BookRepository<br/>ArtifactStore<br/>LibraryMigrator<br/>JobJournal<br/>KeyVault"]
pAi["TextGeneration<br/>ImageGeneration"]
pMedia["SpeechSynthesis<br/>AudioAssembly<br/>DiagramRenderer<br/>EpubImport<br/>EpubExport"]
pSys["BackgroundTasks<br/>Clock<br/>OsFileManager"]
end

subgraph adapters["adapters/ (the only I/O)"]
direction TB
aStore["fs-*.ts<br/>file-key-vault.ts"]
aAi["ai-sdk-text-generation.ts<br/>http-image-generation.ts"]
aMedia["kokoro-speech-synthesis.ts<br/>ffmpeg-audio-assembly.ts<br/>kroki- and electron-diagram-renderer.ts<br/>epub2-import.ts, epub-gen-export.ts"]
aSys["in-memory- and journalled-background-tasks.ts<br/>system-clock.ts, os-file-manager.ts"]
end

services --> pStore
services --> pAi
services --> pMedia
services --> pSys
pStore --> aStore
pAi --> aAi
pMedia --> aMedia
pSys --> aSys
```

Nothing in the core names an adapter. [`server/composition-root.ts`](server/composition-root.ts) is the one place a real adapter is chosen, and `buildServer(overrides)` lets a test or the Electron shell substitute one. Every port ships an in-memory fake and a shared contract test, and every adapter that can be exercised without spending money or downloading a model runs that same contract, which is what stops a fake from drifting into a convenient fiction. See [`server/ports/README.md`](server/ports/README.md) and [`server/adapters/README.md`](server/adapters/README.md) for the full mapping, and [ADR 0005](docs/adr/0005-ai-sdk-behind-a-port.md) for why the AI SDK sits behind one.

## 3. How a request travels

```mermaid
flowchart LR
components["features/<br/>components"] --> hooks["features/<br/>hooks"]
hooks --> store["store/<br/>Redux slices"]
hooks --> api["api/<br/>the one HTTP client"]
api -->|"HTTP and SSE"| routes["routes/"]
routes --> services["services/"]
services --> ports["ports/"]
ports --> adapters["adapters/"]
```

Components render and hooks decide. Every call to the server goes through [`client/api/`](client/README.md), and a raw `fetch` or `new EventSource` anywhere else is an ESLint error rather than a convention, because the client previously held eighty four scattered fetch calls and two competing reconnect policies.

## 4. The adaptive loop

```mermaid
sequenceDiagram
actor Reader
participant Client as client/
participant Server as server/
participant AI as TextGeneration
participant Disk as library

Reader->>Client: topic and prompt
Client->>Server: POST /api/books
Server->>AI: draft the table of contents
Server->>Disk: meta.yml, toc.yml
Server-->>Client: SSE, status toc_review
Reader->>Client: approve the TOC
Client->>Server: PUT /api/books/:id/toc
Server->>AI: generate chapter 1
Server-->>Client: SSE chunks as they stream
Server->>Disk: chapters/01.md
Reader->>Client: read, then submit feedback
Client->>Server: POST /api/books/:id/chapters/1/feedback
Server->>Disk: feedback/01.yml
Server->>AI: generate chapter 2 in the background
Reader->>Client: answer the quiz while it generates
Note over Server,AI: chapter 2 is shaped by chapter 1's feedback and quiz result
```

Chapters are generated one at a time rather than up front, and the quiz exists partly to cover the generation latency. That is [ADR 0002](docs/adr/0002-just-in-time-chapter-generation.md). If the app is closed mid-generation the work is not lost, because jobs are journalled to disk and resumed at the next boot, which is [ADR 0008](docs/adr/0008-persisted-job-journal.md).

## 5. The dependency rule

```mermaid
flowchart TD
client["client/"] --> shared["shared/"]
server["server/"] --> shared
electron["electron/"] --> shared
client -. "ESLint error" .-> server
shared -. "ESLint error" .-> client
```

`shared/` is the dependency root and imports neither side. It holds the Zod schemas, the status predicates, the HTTP contract types, and the SSE event unions, so the two halves of the app validate against the same definitions. This is one package shaped like a monorepo rather than real workspaces, which is [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md), and the folders are already package-shaped if that ever needs to change.

## Deliberately out of scope

Observability and telemetry, a security-hardening pass, and release engineering were all considered and declined. The app runs locally on one machine, holds one reader's data, has no cloud component, and has no multi-user surface, so each of those would add machinery with nothing to protect or measure. The reasoning is recorded in [ADR 0004](docs/adr/0004-single-package-monorepo-shaped.md) rather than left as an unexplained gap.

## Where to read next

| Area | Start at |
|---|---|
| Server, routes, services, ports, adapters | [`server/README.md`](server/README.md) |
| React renderer and feature slices | [`client/README.md`](client/README.md) |
| Types both sides depend on | [`shared/README.md`](shared/README.md) |
| Electron shell and packaging | [`electron/README.md`](electron/README.md) |
| End-to-end journeys | [`e2e/README.md`](e2e/README.md) |
| Every decision and what it cost | [`docs/adr/`](docs/adr/README.md) |
| Domain vocabulary | [`CONTEXT.md`](CONTEXT.md) |
| Generated HTTP surface | [`docs/api-routes.md`](docs/api-routes.md) |
Loading
Loading