diff --git a/.gitignore b/.gitignore index f8dbdb9..07fc409 100644 --- a/.gitignore +++ b/.gitignore @@ -19,4 +19,3 @@ data-pipeline/stage-4-merge/output/ data-pipeline/db/pipeline.db data-pipeline/reports/ data-pipeline/.env -.aider* diff --git a/apps/api/src/controllers/gameController.test.ts b/apps/api/src/controllers/gameController.test.ts index 83b5672..d45298a 100644 --- a/apps/api/src/controllers/gameController.test.ts +++ b/apps/api/src/controllers/gameController.test.ts @@ -1,7 +1,6 @@ import { describe, it, expect, vi, beforeEach } from "vitest"; import request from "supertest"; import type { GameSession, AnswerResult } from "@lila/shared"; -import { auth } from "../lib/auth.js"; type SuccessResponse = { success: true; data: T }; type ErrorResponse = { success: false; error: string }; @@ -49,36 +48,6 @@ vi.mock("better-auth/node", () => ({ toNodeHandler: vi.fn().mockReturnValue(vi.fn()), })); -const mockGetSession = vi.mocked(auth.api.getSession); - -function setAuthenticatedUser(userId: string) { - mockGetSession.mockResolvedValue({ - session: { - id: "session-1", - userId, - token: "fake-token", - expiresAt: new Date(Date.now() + 1000 * 60 * 60), - createdAt: new Date(), - updatedAt: new Date(), - ipAddress: null, - userAgent: null, - }, - user: { - id: userId, - name: "Test User", - email: "test@test.com", - emailVerified: false, - image: null, - createdAt: new Date(), - updatedAt: new Date(), - }, - }); -} - -function setGuestUser() { - mockGetSession.mockResolvedValue(null); -} - import { getGameTerms, getDistractors } from "@lila/db"; import { createApp } from "../app.js"; @@ -107,7 +76,6 @@ const fakeTerms = [ beforeEach(() => { vi.clearAllMocks(); - setAuthenticatedUser("user-1"); // default: authenticated mockGetGameTerms.mockResolvedValue(fakeTerms); mockGetDistractors.mockResolvedValue(["wrong1", "wrong2", "wrong3"]); }); @@ -253,87 +221,3 @@ describe("POST /api/v1/game/answer", () => { expect(body.success).toBe(false); }); }); - -describe("POST /api/v1/game/start — guest", () => { - beforeEach(() => { - setGuestUser(); - }); - - it("returns 200 for a guest with valid game settings", async () => { - const res = await request(app).post("/api/v1/game/start").send(validBody); - const body = res.body as GameStartResponse; - expect(res.status).toBe(200); - expect(body.success).toBe(true); - expect(body.data.sessionId).toBeDefined(); - expect(body.data.questions).toHaveLength(3); - }); - - it("creates a session without userId for guests", async () => { - const startRes = await request(app) - .post("/api/v1/game/start") - .send(validBody); - const startBody = startRes.body as GameStartResponse; - const { sessionId, questions } = startBody.data; - - // Guest can answer — no 404 from userId mismatch - const res = await request(app) - .post("/api/v1/game/answer") - .send({ - sessionId, - questionId: questions[0]!.questionId, - selectedOptionId: 0, - }); - expect(res.status).toBe(200); - }); -}); - -describe("POST /api/v1/game/answer — guest", () => { - beforeEach(() => { - setGuestUser(); - }); - - it("allows a guest to submit an answer", async () => { - const startRes = await request(app) - .post("/api/v1/game/start") - .send(validBody); - const startBody = startRes.body as GameStartResponse; - const { sessionId, questions } = startBody.data; - const question = questions[0]!; - - const res = await request(app) - .post("/api/v1/game/answer") - .send({ - sessionId, - questionId: question.questionId, - selectedOptionId: 0, - }); - const body = res.body as GameAnswerResponse; - expect(res.status).toBe(200); - expect(body.success).toBe(true); - expect(body.data.questionId).toBe(question.questionId); - }); - - it("returns 404 when a guest tries to answer an authenticated user's session", async () => { - // First: create session as authenticated user - setAuthenticatedUser("user-1"); - const startRes = await request(app) - .post("/api/v1/game/start") - .send(validBody); - const startBody = startRes.body as GameStartResponse; - const { sessionId, questions } = startBody.data; - - // Then: try to answer as guest - setGuestUser(); - const res = await request(app) - .post("/api/v1/game/answer") - .send({ - sessionId, - questionId: questions[0]!.questionId, - selectedOptionId: 0, - }); - const body = res.body as ErrorResponse; - expect(res.status).toBe(404); - expect(body.success).toBe(false); - expect(body.error).toContain("Game session not found"); - }); -}); diff --git a/apps/api/src/controllers/gameController.ts b/apps/api/src/controllers/gameController.ts index ea68cce..2a0416e 100644 --- a/apps/api/src/controllers/gameController.ts +++ b/apps/api/src/controllers/gameController.ts @@ -16,11 +16,10 @@ export const createGameController = (store: GameSessionStore) => ({ if (!gameSettings.success) { throw new ValidationError("Invalid game settings"); } - const userId = req.session?.user.id ?? null; const gameQuestions = await createGameSession( gameSettings.data, store, - userId, + req.session.user.id, ); res.json({ success: true, data: gameQuestions }); } catch (error) { @@ -38,8 +37,11 @@ export const createGameController = (store: GameSessionStore) => ({ if (!submission.success) { throw new ValidationError("Invalid answer submission"); } - const userId = req.session?.user.id ?? null; - const result = await evaluateAnswer(submission.data, store, userId); + const result = await evaluateAnswer( + submission.data, + store, + req.session.user.id, + ); res.json({ success: true, data: result }); } catch (error) { next(error); diff --git a/apps/api/src/gameSessionStore/GameSessionStore.ts b/apps/api/src/gameSessionStore/GameSessionStore.ts index 271d733..3e6c5d2 100644 --- a/apps/api/src/gameSessionStore/GameSessionStore.ts +++ b/apps/api/src/gameSessionStore/GameSessionStore.ts @@ -1,6 +1,6 @@ export type GameSessionData = { answers: Map; - userId: string | null; + userId: string; }; export interface GameSessionStore { diff --git a/apps/api/src/middleware/authMiddleware.test.ts b/apps/api/src/middleware/authMiddleware.test.ts deleted file mode 100644 index db79ea2..0000000 --- a/apps/api/src/middleware/authMiddleware.test.ts +++ /dev/null @@ -1,103 +0,0 @@ -import express from "express"; -import request from "supertest"; -import { describe, it, expect, vi, beforeEach } from "vitest"; -import type { Session, User } from "better-auth"; - -vi.mock("../lib/auth.js", () => ({ auth: { api: { getSession: vi.fn() } } })); - -vi.mock("better-auth/node", () => ({ - fromNodeHeaders: vi.fn().mockReturnValue({}), -})); - -import { auth } from "../lib/auth.js"; -import { requireAuth, optionalAuth } from "./authMiddleware.js"; - -const mockGetSession = vi.mocked(auth.api.getSession); - -function createOptionalAuthApp() { - const app = express(); - app.use(optionalAuth); - app.get("/test", (req, res) => { - res - .status(200) - .json({ - hasSession: !!req.session, - userId: req.session?.user?.id ?? null, - }); - }); - return app; -} - -describe("optionalAuth", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("allows the request through when no session exists (guest)", async () => { - mockGetSession.mockResolvedValue(null); - - const app = createOptionalAuthApp(); - const res = await request(app).get("/test"); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ hasSession: false, userId: null }); - }); - - it("attaches session to req when user is authenticated", async () => { - mockGetSession.mockResolvedValue({ - session: { id: "session-1" } as Session, - user: { id: "user-1" } as User, - }); - - const app = createOptionalAuthApp(); - const res = await request(app).get("/test"); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ hasSession: true, userId: "user-1" }); - }); - - it("allows the request through even when getSession throws", async () => { - mockGetSession.mockRejectedValue(new Error("auth service down")); - - const app = createOptionalAuthApp(); - const res = await request(app).get("/test"); - - expect(res.status).toBe(200); - expect(res.body).toEqual({ hasSession: false, userId: null }); - }); -}); - -describe("requireAuth", () => { - beforeEach(() => { - vi.clearAllMocks(); - }); - - it("returns 401 when no session exists", async () => { - mockGetSession.mockResolvedValue(null); - - const app = express(); - app.use(requireAuth); - app.get("/test", (_req, res) => res.status(200).json({ ok: true })); - - const res = await request(app).get("/test"); - expect(res.status).toBe(401); - expect(res.body).toEqual({ success: false, error: "Unauthorized" }); - }); - - it("allows the request through when session exists", async () => { - mockGetSession.mockResolvedValue({ - session: { id: "session-1" } as Session, - user: { id: "user-1" } as User, - }); - - const app = express(); - app.use(requireAuth); - app.get("/test", (req, res) => { - res.status(200).json({ userId: req.session?.user?.id }); - }); - - const res = await request(app).get("/test"); - expect(res.status).toBe(200); - expect(res.body).toEqual({ userId: "user-1" }); - }); -}); diff --git a/apps/api/src/middleware/authMiddleware.ts b/apps/api/src/middleware/authMiddleware.ts index 7a161f1..744c5e4 100644 --- a/apps/api/src/middleware/authMiddleware.ts +++ b/apps/api/src/middleware/authMiddleware.ts @@ -20,23 +20,3 @@ export const requireAuth = async ( next(); }; - -export const optionalAuth = async ( - req: Request, - _res: Response, - next: NextFunction, -) => { - try { - const session = await auth.api.getSession({ - headers: fromNodeHeaders(req.headers), - }); - - if (session) { - req.session = session; - } - } catch (err) { - console.warn("Auth check failed, continuing as guest:", err); - } - - next(); -}; diff --git a/apps/api/src/middleware/rateLimiters.ts b/apps/api/src/middleware/rateLimiters.ts index 4dfcc79..2f2eaf6 100644 --- a/apps/api/src/middleware/rateLimiters.ts +++ b/apps/api/src/middleware/rateLimiters.ts @@ -1,4 +1,4 @@ -import rateLimit, { ipKeyGenerator } from "express-rate-limit"; +import rateLimit from "express-rate-limit"; import type { Request } from "express"; // TODO: When Valkey is wired up, swap the default in-memory store for @@ -33,8 +33,7 @@ export const gameLimiter = rateLimit({ limit: 150, standardHeaders: "draft-8", legacyHeaders: false, - keyGenerator: (req: Request) => - req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"), + keyGenerator: (req: Request) => req.session!.user.id, message: { success: false, error: "Too many requests, please try again later.", @@ -46,8 +45,7 @@ export const lobbyLimiter = rateLimit({ limit: 20, standardHeaders: "draft-8", legacyHeaders: false, - keyGenerator: (req: Request) => - req.session?.user.id ?? ipKeyGenerator(req.ip ?? "unknown"), + keyGenerator: (req: Request) => req.session!.user.id, message: { success: false, error: "Too many requests, please try again later.", diff --git a/apps/api/src/routes/gameRouter.ts b/apps/api/src/routes/gameRouter.ts index 9bc3e53..9e29a5d 100644 --- a/apps/api/src/routes/gameRouter.ts +++ b/apps/api/src/routes/gameRouter.ts @@ -1,7 +1,7 @@ import express from "express"; import type { Router } from "express"; import { createGameController } from "../controllers/gameController.js"; -import { optionalAuth } from "../middleware/authMiddleware.js"; +import { requireAuth } from "../middleware/authMiddleware.js"; import { gameLimiter } from "../middleware/rateLimiters.js"; import type { GameSessionStore } from "../gameSessionStore/index.js"; @@ -9,11 +9,11 @@ export const createGameRouter = (store: GameSessionStore): Router => { const router = express.Router(); const controller = createGameController(store); - router.use(optionalAuth); + router.use(requireAuth); router.use(gameLimiter); router.post("/start", controller.createGame as express.RequestHandler); router.post("/answer", controller.submitAnswer as express.RequestHandler); - + return router; }; diff --git a/apps/api/src/services/gameService.test.ts b/apps/api/src/services/gameService.test.ts index 97bf075..160d816 100644 --- a/apps/api/src/services/gameService.test.ts +++ b/apps/api/src/services/gameService.test.ts @@ -332,89 +332,3 @@ describe("evaluateAnswer", () => { ).rejects.toMatchObject({ statusCode: 422 }); }); }); - -// Add to existing gameService.test.ts - -describe("createGameSession — guest", () => { - let store: InMemoryGameSessionStore; - - beforeEach(() => { - store = new InMemoryGameSessionStore(); - }); - - it("creates a session with userId null for guests", async () => { - const session = await createGameSession(validRequest, store, null); - - expect(session.sessionId).toBeDefined(); - expect(session.questions).toHaveLength(3); - }); - - it("stores userId as null in the session store", async () => { - const session = await createGameSession(validRequest, store, null); - const stored = await store.get(session.sessionId); - - expect(stored).not.toBeNull(); - expect(stored!.userId).toBeNull(); - }); -}); - -describe("evaluateAnswer — guest", () => { - let store: InMemoryGameSessionStore; - - beforeEach(() => { - store = new InMemoryGameSessionStore(); - }); - - it("allows a guest to answer their own session", async () => { - const session = await createGameSession(validRequest, store, null); - const question = session.questions[0]!; - const correctText = fakeTerms[0]!.targetText; - const correctOption = question.options.find((o) => o.text === correctText)!; - - const result = await evaluateAnswer( - { - sessionId: session.sessionId, - questionId: question.questionId, - selectedOptionId: correctOption.optionId, - }, - store, - null, - ); - - expect(result.isCorrect).toBe(true); - }); - - it("throws NotFoundError when guest tries to answer an authenticated session", async () => { - const authSession = await createGameSession(validRequest, store, "user-1"); - const question = authSession.questions[0]!; - - await expect( - evaluateAnswer( - { - sessionId: authSession.sessionId, - questionId: question.questionId, - selectedOptionId: 0, - }, - store, - null, - ), - ).rejects.toThrow("Game session not found"); - }); - - it("throws NotFoundError when authenticated user tries to answer a guest session", async () => { - const guestSession = await createGameSession(validRequest, store, null); - const question = guestSession.questions[0]!; - - await expect( - evaluateAnswer( - { - sessionId: guestSession.sessionId, - questionId: question.questionId, - selectedOptionId: 0, - }, - store, - "user-1", - ), - ).rejects.toThrow("Game session not found"); - }); -}); diff --git a/apps/api/src/services/gameService.ts b/apps/api/src/services/gameService.ts index 550fde2..a31014a 100644 --- a/apps/api/src/services/gameService.ts +++ b/apps/api/src/services/gameService.ts @@ -19,7 +19,7 @@ import { shuffleArray } from "../lib/utils.js"; export const createGameSession = async ( request: GameRequest, store: GameSessionStore, - userId: string | null, + userId: string, ): Promise => { const terms = await getGameTerms( request.source_language, @@ -87,15 +87,11 @@ export const createGameSession = async ( export const evaluateAnswer = async ( submission: AnswerSubmission, store: GameSessionStore, - userId: string | null, + userId: string, ): Promise => { const session = await store.get(submission.sessionId); - if (!session) { - throw new NotFoundError(`Game session not found: ${submission.sessionId}`); - } - - if (session.userId !== userId) { + if (!session || session.userId !== userId) { throw new NotFoundError(`Game session not found: ${submission.sessionId}`); } diff --git a/apps/api/src/types/express.d.ts b/apps/api/src/types/express.d.ts index a310394..da7633a 100644 --- a/apps/api/src/types/express.d.ts +++ b/apps/api/src/types/express.d.ts @@ -18,7 +18,3 @@ declare module "ws" { export type AuthenticatedRequest = Request & { session: { session: Session; user: User }; }; - -export type GuestRequest = Request & { - session?: { session: Session; user: User }; -}; diff --git a/apps/web/src/components/game/ScoreScreen.tsx b/apps/web/src/components/game/ScoreScreen.tsx index 5206209..ac64da2 100644 --- a/apps/web/src/components/game/ScoreScreen.tsx +++ b/apps/web/src/components/game/ScoreScreen.tsx @@ -1,17 +1,9 @@ import type { AnswerResult } from "@lila/shared"; import { ConfettiBurst } from "../ui/ConfettiBurst"; -type ScoreScreenProps = { - results: AnswerResult[]; - onPlayAgain: () => void; - showAuthPrompt?: boolean; -}; +type ScoreScreenProps = { results: AnswerResult[]; onPlayAgain: () => void }; -export const ScoreScreen = ({ - results, - onPlayAgain, - showAuthPrompt = false, -}: ScoreScreenProps) => { +export const ScoreScreen = ({ results, onPlayAgain }: ScoreScreenProps) => { const score = results.filter((r) => r.isCorrect).length; const total = results.length; const percentage = Math.round((score / total) * 100); @@ -66,34 +58,12 @@ export const ScoreScreen = ({ ))} - {showAuthPrompt && ( -
-

- Want to save your progress and compete with friends? -

- - Create an account - - -
- )} - - {!showAuthPrompt && ( - - )} + ); }; diff --git a/apps/web/src/routes/play.tsx b/apps/web/src/routes/play.tsx index f2c85ee..bc4cde3 100644 --- a/apps/web/src/routes/play.tsx +++ b/apps/web/src/routes/play.tsx @@ -1,5 +1,5 @@ -import { createFileRoute } from "@tanstack/react-router"; -import { useState, useCallback, useEffect } from "react"; +import { createFileRoute, redirect } from "@tanstack/react-router"; +import { useState, useCallback } from "react"; import type { GameSession, GameRequest, AnswerResult } from "@lila/shared"; import { QuestionCard } from "../components/game/QuestionCard"; import { ScoreScreen } from "../components/game/ScoreScreen"; @@ -18,13 +18,6 @@ function Play() { const [currentQuestionIndex, setCurrentQuestionIndex] = useState(0); const [results, setResults] = useState([]); const [currentResult, setCurrentResult] = useState(null); - const [isAuthenticated, setIsAuthenticated] = useState(null); - - useEffect(() => { - void authClient.getSession().then(({ data }) => { - setIsAuthenticated(!!data); - }); - }, []); const startGame = useCallback(async (settings: GameRequest) => { setIsLoading(true); @@ -107,11 +100,7 @@ function Play() { if (currentQuestionIndex >= gameSession.questions.length) { return (
- +
); } @@ -140,4 +129,10 @@ function Play() { export const Route = createFileRoute("/play")({ component: Play, errorComponent: RouteError, + beforeLoad: async () => { + const { data: session } = await authClient.getSession(); + if (!session) { + throw redirect({ to: "/", search: { modal: "auth", redirect: "/play" } }); + } + }, }); diff --git a/documentation/ai-context/99-current-task.md b/documentation/ai-context/99-current-task.md deleted file mode 100644 index 24a16eb..0000000 --- a/documentation/ai-context/99-current-task.md +++ /dev/null @@ -1,78 +0,0 @@ -# 99 — Current Task - -## Task Description - -Implement guest play flow so users can try a 3-round singleplayer quiz without creating an account. After completing the quiz, optionally prompt them to sign up to save progress. - -## Context - -**Which parts of the codebase does this touch?** - -- [x] Frontend (`apps/web/`) -- [x] Backend API (`apps/api/`) -- [ ] Database schema (`packages/db/`) — no schema changes needed -- [x] Shared schemas (`packages/shared/`) — may need GuestGameRequestSchema -- [ ] WebSocket protocol (`apps/api/src/ws/`) — not touched (guest play is singleplayer only) -- [ ] Data pipeline (`data-pipeline/`) -- [ ] Infrastructure / deployment (`docker-compose.yml`, Caddyfile, etc.) -- [x] Documentation - -**Relevant files I already know about:** - -- `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path -- `apps/api/src/controllers/gameController.ts` — needs guest variant of start/answer -- `apps/api/src/services/gameService.ts` — may need guest session logic -- `apps/api/src/routes/gameRouter.ts` — route definitions -- `apps/web/src/routes/play.tsx` — singleplayer route -- `apps/web/src/components/game/GameSetup.tsx` — start quiz UI -- `packages/shared/src/schemas/game.ts` — request/response schemas -- `apps/web/src/components/auth/AuthModal.tsx` — post-game auth prompt - -## Constraints & Requirements - -**Must have:** - -- [ ] Guest users can start and complete a singleplayer quiz (3 or 10 rounds) -- [ ] No login required to reach `/play` or call `POST /api/v1/game/start` -- [ ] Server-side answer evaluation still works (correct answer never sent to frontend) -- [ ] Guest sessions are ephemeral (no database storage of guest progress) -- [ ] After quiz completion, show a friendly "Save your progress?" prompt with auth options - -**Nice to have:** - -- [ ] Guest sessions stored in-memory with a TTL (e.g., 24h) so refreshing the page doesn't lose the current quiz -- [ ] Post-game prompt includes a "Continue as guest" option to play again without signing up - -**Must NOT break:** - -- [x] Existing auth flow (logged-in users still work normally) -- [x] WebSocket protocol (if applicable) -- [x] Database schema (additive changes only unless migration planned) -- [x] Zod schemas in `packages/shared` (no silent drift) - -**Known blockers or open questions:** - -- [ ] Should guest sessions use the same `GameSessionStore` interface with a guest flag, or a separate store? -- [ ] Should the post-game auth prompt be a modal or a redirect to a dedicated page? - -## Definition of Done - -- [ ] Code implemented and tested -- [ ] No TypeScript errors (`pnpm typecheck` passes) -- [ ] Tests pass (`pnpm test`) -- [ ] Manual verification in dev environment (both logged-in and guest flows) -- [ ] Commit message follows convention -- [ ] Feature branch merged to main - -## Post-Work Checklist - -After the task is complete, ask the LLM: - -> "Review the post-work checklist in prompts/meta.md. Which documentation files need updates based on what we just changed?" - -Expected doc updates: - -- `documentation/STATUS.md` — Guest play is now live -- `documentation/ai-context/03-api-contract.md` — New guest endpoint or schema changes -- `packages/shared/src/schemas/game.ts` — If GuestGameRequestSchema added -- `README.md` — Quickstart may mention guest play diff --git a/documentation/ai-context/99-current-task-blueprint.md b/documentation/ai-context/99-current-task.md similarity index 72% rename from documentation/ai-context/99-current-task-blueprint.md rename to documentation/ai-context/99-current-task.md index 9bc1325..cd28365 100644 --- a/documentation/ai-context/99-current-task-blueprint.md +++ b/documentation/ai-context/99-current-task.md @@ -34,7 +34,6 @@ Example: "Implement guest play flow so users can try a 3-round quiz without crea [List files you've identified. The LLM may ask for additional ones.] Example: - - `apps/api/src/controllers/gameController.ts` — needs guest variant - `apps/api/src/middleware/authMiddleware.ts` — needs optional auth path - `packages/shared/src/schemas/game.ts` — needs GuestGameRequestSchema @@ -44,24 +43,20 @@ Example: ## Constraints & Requirements **Must have:** - - [ ] - [ ] **Nice to have:** - - [ ] - [ ] **Must NOT break:** - - [ ] Existing auth flow (logged-in users still work normally) - [ ] WebSocket protocol (if applicable) - [ ] Database schema (additive changes only unless migration planned) - [ ] Zod schemas in `packages/shared` (no silent drift) **Known blockers or open questions:** - - [ ] --- @@ -85,18 +80,17 @@ After the task is complete, ask the LLM: The LLM should check: -| File | Check if... | -| ---------------------------------- | ----------------------------------------------------------------------- | -| `documentation/STATUS.md` | Task changes what's working or what's blocked | -| `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one | -| `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences | -| `documentation/ARCHITECTURE.md` | Task changes monorepo structure, data flow, or layer boundaries | -| `documentation/ai-context/*.md` | Task changes schemas, endpoints, protocol, or pipeline stages | -| `packages/shared/src/schemas/*.ts` | Task changes request/response shapes or WS message types | -| `README.md` | Task changes quickstart steps, stack, or current status | +| File | Check if... | +|------|-------------| +| `documentation/STATUS.md` | Task changes what's working or what's blocked | +| `documentation/BACKLOG.md` | Task completes a backlog item or creates a new one | +| `documentation/DECISIONS.md` | Task involved choosing between alternatives with long-term consequences | +| `documentation/ARCHITECTURE.md` | Task changes monorepo structure, data flow, or layer boundaries | +| `documentation/ai-context/*.md` | Task changes schemas, endpoints, protocol, or pipeline stages | +| `packages/shared/src/schemas/*.ts` | Task changes request/response shapes or WS message types | +| `README.md` | Task changes quickstart steps, stack, or current status | **Expected output format:** - ``` - FILE: [filename] — REASON: [what changed and why the doc needs updating] ```