From bbbb2f14a1c439922970dedde93ada1781b5bc99 Mon Sep 17 00:00:00 2001 From: Chris Alfano Date: Tue, 19 May 2026 13:40:25 -0400 Subject: [PATCH 1/2] chore: install specops + backend-fastify + frontend-shadcn skills MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Brings the three skills used to build this project under version control in .agents/skills/, with .claude/skills/ symlinks for Claude Code to discover. skills-lock.json pins each skill's source + content hash so they sync deterministically. - specops — spec-driven workflow + plans protocol (replaces the manual recap of these conventions that lives in .claude/CLAUDE.md; that trimming follows in the next commit) - backend-fastify — Fastify + TS patterns, plugin order, env validation - frontend-shadcn — Vite + React 19 + shadcn/ui + Tailwind v4 + React Router v7 patterns Co-Authored-By: Claude Opus 4.7 (1M context) --- .agents/skills/backend-fastify/SKILL.md | 171 +++++ .../backend-fastify/references/api-design.md | 470 +++++++++++++ .../references/authentication.md | 377 +++++++++++ .../backend-fastify/references/gotchas.md | 458 +++++++++++++ .../references/mcp-integration.md | 601 +++++++++++++++++ .../backend-fastify/references/patterns.md | 430 ++++++++++++ .../backend-fastify/references/setup-guide.md | 638 ++++++++++++++++++ .agents/skills/frontend-shadcn/SKILL.md | 126 ++++ .../frontend-shadcn/references/maplibre.md | 523 ++++++++++++++ .../frontend-shadcn/references/mcp-tools.md | 113 ++++ .../frontend-shadcn/references/patterns.md | 309 +++++++++ .../frontend-shadcn/references/setup-guide.md | 572 ++++++++++++++++ .agents/skills/specops/SKILL.md | 258 +++++++ .../specops/references/audit-spec-drift.md | 1 + .../specops/references/plans-protocol.md | 341 ++++++++++ .../specops/references/spec-drift-auditor.md | 86 +++ .agents/skills/specops/scripts/lib/plans.js | 217 ++++++ .agents/skills/specops/scripts/package.json | 1 + .agents/skills/specops/scripts/plans-dag | 173 +++++ .agents/skills/specops/scripts/plans-next | 211 ++++++ .claude/skills/backend-fastify | 1 + .claude/skills/frontend-shadcn | 1 + .claude/skills/specops | 1 + skills-lock.json | 23 + 24 files changed, 6102 insertions(+) create mode 100644 .agents/skills/backend-fastify/SKILL.md create mode 100644 .agents/skills/backend-fastify/references/api-design.md create mode 100644 .agents/skills/backend-fastify/references/authentication.md create mode 100644 .agents/skills/backend-fastify/references/gotchas.md create mode 100644 .agents/skills/backend-fastify/references/mcp-integration.md create mode 100644 .agents/skills/backend-fastify/references/patterns.md create mode 100644 .agents/skills/backend-fastify/references/setup-guide.md create mode 100644 .agents/skills/frontend-shadcn/SKILL.md create mode 100644 .agents/skills/frontend-shadcn/references/maplibre.md create mode 100644 .agents/skills/frontend-shadcn/references/mcp-tools.md create mode 100644 .agents/skills/frontend-shadcn/references/patterns.md create mode 100644 .agents/skills/frontend-shadcn/references/setup-guide.md create mode 100644 .agents/skills/specops/SKILL.md create mode 100644 .agents/skills/specops/references/audit-spec-drift.md create mode 100644 .agents/skills/specops/references/plans-protocol.md create mode 100644 .agents/skills/specops/references/spec-drift-auditor.md create mode 100644 .agents/skills/specops/scripts/lib/plans.js create mode 100644 .agents/skills/specops/scripts/package.json create mode 100755 .agents/skills/specops/scripts/plans-dag create mode 100755 .agents/skills/specops/scripts/plans-next create mode 120000 .claude/skills/backend-fastify create mode 120000 .claude/skills/frontend-shadcn create mode 120000 .claude/skills/specops create mode 100644 skills-lock.json diff --git a/.agents/skills/backend-fastify/SKILL.md b/.agents/skills/backend-fastify/SKILL.md new file mode 100644 index 0000000..f74eaa8 --- /dev/null +++ b/.agents/skills/backend-fastify/SKILL.md @@ -0,0 +1,171 @@ +--- +name: backend-fastify +description: Backend development using Fastify + TypeScript. Use when creating new backend APIs, adding routes, implementing services, working with plugins, or configuring environment variables. +--- + +# Backend Fastify Stack + +High-performance Node.js backend stack: + +- **Fastify 5.x** - Web framework +- **TypeScript** - Type safety +- **tsx** - Development with watch mode +- **pino-pretty** - Pretty logging for development +- **@fastify/env** - Environment variable validation with JSON Schema +- **@fastify/cors** - CORS support +- **fastify-plugin** - Plugin system + +## Environment Setup + +Use [asdf](https://asdf-vm.com/) to manage Node.js versions: + +```bash +# Install Node.js plugin (one-time) +asdf plugin add nodejs + +# Set project Node.js version +asdf set nodejs latest:22 +``` + +This creates a `.tool-versions` file in the project root that ensures consistent Node.js versions across the team. + +## Reference Files + +| File | When to Use | +|------|-------------| +| [setup-guide.md](references/setup-guide.md) | Starting a new backend project from scratch | +| [patterns.md](references/patterns.md) | Implementing routes, services, schema validation | +| [authentication.md](references/authentication.md) | Adding JWT auth, authorization, protected routes | +| [api-design.md](references/api-design.md) | Swagger/OpenAPI integration, response format, errors | +| [mcp-integration.md](references/mcp-integration.md) | Integrating MCP server for AI agent access | +| [gotchas.md](references/gotchas.md) | Debugging issues, common mistakes and fixes | + +## Quick Reference + +### Commands + +```bash +# Dev server with watch mode +npm run dev + +# Build for production +npm run build + +# Run production build +npm start + +# Type check +npm run type-check +``` + +### Key Imports + +```typescript +// Fastify types +import Fastify, { FastifyInstance, FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' + +// Common plugins +import fastifyEnv from '@fastify/env' +import cors from '@fastify/cors' +``` + +### Configuration Access + +Always access configuration through `fastify.config`, never `process.env` directly: + +```typescript +// CORRECT - type-safe, validated at startup +const port = fastify.config.PORT +const apiKey = fastify.config.API_KEY + +// WRONG - no validation, no type safety +const port = process.env.PORT // Don't do this +``` + +### Response Format + +```typescript +// Standard response structure +{ + success: boolean + data?: T + error?: string + metadata?: { timestamp: Date } +} +``` + +### Plugin Pattern + +```typescript +import fp from 'fastify-plugin' + +export default fp(async (fastify, opts) => { + // Plugin logic here + fastify.decorate('something', value) +}, '5.x') +``` + +### Route Pattern + +```typescript +import { FastifyPluginAsync } from 'fastify' + +const routes: FastifyPluginAsync = async (fastify, opts) => { + fastify.get('/', async (request, reply) => { + return { success: true, data: 'example' } + }) +} + +export default routes +``` + +### Service Pattern + +```typescript +// 1. Create service class +export class MyService { + constructor(private fastify: FastifyInstance) {} + + async doWork() { + // Access config through fastify instance + const apiKey = this.fastify.config.API_KEY + this.fastify.log.info('Service method called') + } +} + +// 2. Declare module augmentation +declare module 'fastify' { + interface FastifyInstance { + myService: MyService + } +} + +// 3. Initialize and decorate in app.ts +fastify.decorate('myService', new MyService(fastify)) +``` + +### Project Structure + +``` +backend/ +├── src/ +│ ├── plugins/ # Fastify plugins (env, auth, etc.) +│ ├── routes/ # HTTP route handlers +│ ├── services/ # Business logic classes +│ ├── utils/ # Shared utilities +│ ├── app.ts # Plugin registration & setup +│ └── index.ts # Server entry point +├── package.json +├── tsconfig.json +├── .env.example +└── .gitignore +``` + +### Common Gotchas + +- **Plugin order matters**: Register env plugin first, then services, then routes +- **Config access**: Use `fastify.config.VAR` not `process.env.VAR` +- **Server ready**: Call `await server.ready()` before accessing config in index.ts +- **Path normalization**: Centralize path utilities, handle root '/' as special case +- **Package management**: Use `npm install ` not manual `package.json` edits diff --git a/.agents/skills/backend-fastify/references/api-design.md b/.agents/skills/backend-fastify/references/api-design.md new file mode 100644 index 0000000..3687cf9 --- /dev/null +++ b/.agents/skills/backend-fastify/references/api-design.md @@ -0,0 +1,470 @@ +# API Design + +Patterns for designing consistent, well-documented APIs with Fastify. + +## Response Format + +Use a consistent response structure across all endpoints: + +```typescript +// Success response +{ + success: true, + data: T, + metadata?: { + timestamp: string, + count?: number, + page?: number, + totalPages?: number + } +} + +// Error response +{ + success: false, + error: string, + details?: Record // Field-level validation errors +} +``` + +### Implementation + +```typescript +// Helper types +interface SuccessResponse { + success: true + data: T + metadata?: ResponseMetadata +} + +interface ErrorResponse { + success: false + error: string + details?: Record +} + +interface ResponseMetadata { + timestamp: string + count?: number + page?: number + totalPages?: number +} + +type ApiResponse = SuccessResponse | ErrorResponse + +// Helper functions +function successResponse(data: T, metadata?: Partial): SuccessResponse { + return { + success: true, + data, + metadata: metadata ? { timestamp: new Date().toISOString(), ...metadata } : undefined + } +} + +function errorResponse(error: string, details?: Record): ErrorResponse { + return { + success: false, + error, + details + } +} +``` + +### Usage in Routes + +```typescript +fastify.get('/users', async (request, reply) => { + const users = await fastify.userService.findAll() + return successResponse(users, { count: users.length }) +}) + +fastify.get<{ Params: { id: string } }>('/users/:id', async (request, reply) => { + const { id } = request.params + const user = await fastify.userService.findById(id) + + if (!user) { + reply.code(404) + return errorResponse('User not found') + } + + return successResponse(user) +}) +``` + +## Error Handling + +### Typed Error Responses + +```typescript +fastify.post('/users', async (request, reply) => { + try { + const user = await fastify.userService.create(request.body) + reply.code(201) + return successResponse(user) + } catch (error) { + if (error instanceof ValidationError) { + reply.code(400) + return errorResponse('Validation failed', error.fields) + } + + if (error instanceof DuplicateError) { + reply.code(409) + return errorResponse('User already exists') + } + + // Log unexpected errors + request.log.error(error, 'Failed to create user') + reply.code(500) + return errorResponse('Internal server error') + } +}) +``` + +### HTTP Status Codes + +| Code | Usage | +|------|-------| +| 200 | Successful GET, PUT, PATCH | +| 201 | Successful POST (resource created) | +| 204 | Successful DELETE (no content) | +| 400 | Bad request / validation error | +| 401 | Authentication required | +| 403 | Insufficient permissions | +| 404 | Resource not found | +| 409 | Conflict (duplicate resource) | +| 500 | Internal server error | + +## Swagger/OpenAPI Integration + +### Installation + +```bash +npm install @fastify/swagger @fastify/swagger-ui +``` + +### Configuration in app.ts + +```typescript +import { FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' + +export const app: FastifyPluginAsync = async (fastify, opts) => { + await fastify.register(envPlugin) + + // Register Swagger + await fastify.register(import('@fastify/swagger'), { + openapi: { + openapi: '3.0.0', + info: { + title: 'My API', + description: 'API documentation', + version: '1.0.0' + }, + servers: [{ + url: `http://${fastify.config.HOST}:${fastify.config.PORT}`, + description: 'Development server' + }], + tags: [ + { name: 'users', description: 'User management' }, + { name: 'orders', description: 'Order management' }, + { name: 'health', description: 'Health checks' } + ], + components: { + securitySchemes: { + bearerAuth: { + type: 'http', + scheme: 'bearer', + bearerFormat: 'JWT' + } + } + } + } + }) + + // Register Swagger UI + await fastify.register(import('@fastify/swagger-ui'), { + routePrefix: '/docs', + uiConfig: { + docExpansion: 'list', + deepLinking: true + } + }) + + // Register routes after swagger + await fastify.register(userRoutes, { prefix: '/api/users' }) +} +``` + +### Route Schema with Swagger Metadata + +```typescript +fastify.get('/users', { + schema: { + description: 'List all users', + tags: ['users'], + security: [{ bearerAuth: [] }], + querystring: { + type: 'object', + properties: { + page: { type: 'integer', minimum: 1, default: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 } + } + }, + response: { + 200: { + description: 'Successful response', + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'array', + items: { + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' }, + createdAt: { type: 'string', format: 'date-time' } + } + } + }, + metadata: { + type: 'object', + properties: { + count: { type: 'integer' }, + page: { type: 'integer' }, + totalPages: { type: 'integer' } + } + } + } + } + } + } +}, async (request, reply) => { + // Implementation +}) +``` + +## CORS Configuration + +### Environment-Based CORS + +```typescript +// In app.ts +await fastify.register(import('@fastify/cors'), { + origin: fastify.config.NODE_ENV === 'production' + ? fastify.config.ALLOWED_ORIGINS?.split(',') || false + : true, + credentials: true, + methods: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE', 'OPTIONS'], + allowedHeaders: ['Content-Type', 'Authorization'] +}) +``` + +### Environment Config for CORS + +```typescript +// In env.ts schema +properties: { + ALLOWED_ORIGINS: { + type: 'string', + description: 'Comma-separated list of allowed origins for CORS' + } +} +``` + +## Pagination + +### Query Parameters + +```typescript +interface PaginationQuery { + page?: number + limit?: number +} + +fastify.get<{ Querystring: PaginationQuery }>('/items', { + schema: { + querystring: { + type: 'object', + properties: { + page: { type: 'integer', minimum: 1, default: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 } + } + } + } +}, async (request, reply) => { + const { page = 1, limit = 20 } = request.query + const offset = (page - 1) * limit + + const { items, total } = await fastify.itemService.findPaginated(offset, limit) + const totalPages = Math.ceil(total / limit) + + return successResponse(items, { + count: items.length, + page, + totalPages + }) +}) +``` + +## Query Building + +### Dynamic WHERE Clauses + +For services that build dynamic queries: + +```typescript +interface QueryFilters { + status?: string + category?: string + minPrice?: number + maxPrice?: number +} + +async findFiltered(filters: QueryFilters) { + const whereClauses: string[] = [] + const params: Record = {} + + if (filters.status) { + whereClauses.push('status = @status') + params.status = filters.status + } + + if (filters.category) { + whereClauses.push('category = @category') + params.category = filters.category + } + + if (filters.minPrice !== undefined) { + whereClauses.push('price >= @minPrice') + params.minPrice = filters.minPrice + } + + if (filters.maxPrice !== undefined) { + whereClauses.push('price <= @maxPrice') + params.maxPrice = filters.maxPrice + } + + const whereClause = whereClauses.length > 0 + ? `WHERE ${whereClauses.join(' AND ')}` + : '' + + const query = ` + SELECT * FROM items + ${whereClause} + ORDER BY created_at DESC + ` + + return this.db.query(query, params) +} +``` + +## Validation Patterns + +### Reusable Schema Definitions + +```typescript +// src/schemas/common.ts +export const paginationSchema = { + type: 'object', + properties: { + page: { type: 'integer', minimum: 1, default: 1 }, + limit: { type: 'integer', minimum: 1, maximum: 100, default: 20 } + } +} + +export const successResponseSchema = { + type: 'object', + properties: { + success: { type: 'boolean', const: true }, + data: {}, // Override in specific routes + metadata: { + type: 'object', + properties: { + timestamp: { type: 'string' }, + count: { type: 'integer' } + } + } + } +} + +export const errorResponseSchema = { + type: 'object', + properties: { + success: { type: 'boolean', const: false }, + error: { type: 'string' }, + details: { + type: 'object', + additionalProperties: { + type: 'array', + items: { type: 'string' } + } + } + } +} +``` + +### Using Shared Schemas + +```typescript +import { paginationSchema, errorResponseSchema } from '../schemas/common' + +fastify.get('/items', { + schema: { + querystring: paginationSchema, + response: { + 200: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'array', + items: { $ref: '#/components/schemas/Item' } + } + } + }, + 400: errorResponseSchema + } + } +}) +``` + +## Health Check Endpoint + +Standard health check for monitoring: + +```typescript +// src/routes/health.ts +import { FastifyPluginAsync } from 'fastify' + +const healthRoutes: FastifyPluginAsync = async (fastify, opts) => { + fastify.get('/', { + schema: { + description: 'Health check endpoint', + tags: ['health'], + response: { + 200: { + type: 'object', + properties: { + status: { type: 'string' }, + timestamp: { type: 'string' }, + service: { type: 'string' }, + version: { type: 'string' }, + environment: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + return { + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'my-api', + version: '1.0.0', + environment: fastify.config.NODE_ENV + } + }) +} + +export default healthRoutes +``` diff --git a/.agents/skills/backend-fastify/references/authentication.md b/.agents/skills/backend-fastify/references/authentication.md new file mode 100644 index 0000000..4ed408c --- /dev/null +++ b/.agents/skills/backend-fastify/references/authentication.md @@ -0,0 +1,377 @@ +# Authentication Patterns + +Patterns for implementing JWT authentication and authorization in Fastify backends. + +## Dependencies + +```bash +npm install jsonwebtoken +npm install -D @types/jsonwebtoken +``` + +## Environment Configuration + +Add auth-related config to your env plugin: + +```typescript +// src/plugins/env.ts +const schema = { + type: 'object', + required: ['JWT_SECRET'], + properties: { + JWT_SECRET: { + type: 'string', + minLength: 32, + description: 'Secret key for JWT signing' + }, + JWT_ISSUER: { + type: 'string', + default: 'my-app' + }, + JWT_AUDIENCE: { + type: 'string', + default: 'my-app-users' + }, + JWT_EXPIRES_IN: { + type: 'string', + default: '24h' + } + } +} + +declare module 'fastify' { + interface FastifyInstance { + config: { + JWT_SECRET: string + JWT_ISSUER: string + JWT_AUDIENCE: string + JWT_EXPIRES_IN: string + // ... other config + } + } +} +``` + +## Auth Middleware + +### Bearer Token Extraction + +```typescript +// src/middleware/auth.ts +import { FastifyRequest } from 'fastify' + +function extractBearerToken(request: FastifyRequest): string | null { + const authHeader = request.headers.authorization + if (!authHeader) return null + + const parts = authHeader.split(' ') + if (parts.length !== 2 || parts[0].toLowerCase() !== 'bearer') { + return null + } + return parts[1] +} +``` + +### JWT Payload Type + +```typescript +export interface JWTPayload { + sub: string // User ID + email: string + groups: string[] // Roles/permissions + iat: number // Issued at + exp: number // Expiration +} + +// Extend FastifyRequest to include user +declare module 'fastify' { + interface FastifyRequest { + user?: JWTPayload + } +} +``` + +### Required Authentication + +Use when endpoint requires a valid token: + +```typescript +import jwt from 'jsonwebtoken' +import { FastifyRequest, FastifyReply } from 'fastify' + +export async function verifyJWT( + request: FastifyRequest, + reply: FastifyReply +): Promise { + const token = extractBearerToken(request) + + if (!token) { + reply.code(401).send({ + success: false, + error: 'Authentication required' + }) + return + } + + try { + // Access config through request.server, not process.env + const config = request.server.config + const decoded = jwt.verify(token, config.JWT_SECRET, { + issuer: config.JWT_ISSUER, + audience: config.JWT_AUDIENCE + }) as JWTPayload + + request.user = decoded + } catch (error) { + request.log.debug({ error }, 'JWT verification failed') + reply.code(401).send({ + success: false, + error: 'Invalid or expired token' + }) + } +} +``` + +### Optional Authentication + +Use when endpoint works with or without authentication: + +```typescript +export async function optionalAuth( + request: FastifyRequest, + reply: FastifyReply +): Promise { + const token = extractBearerToken(request) + + if (!token) { + // No token is fine, continue without user + return + } + + try { + const config = request.server.config + const decoded = jwt.verify(token, config.JWT_SECRET, { + issuer: config.JWT_ISSUER, + audience: config.JWT_AUDIENCE + }) as JWTPayload + + request.user = decoded + } catch (error) { + // Invalid token with optional auth - log but continue + request.log.debug({ error }, 'Optional auth token invalid, continuing without user') + } +} +``` + +### Group-Based Authorization + +Require specific groups/roles: + +```typescript +export function requireGroups(requiredGroups: string[]) { + return async function ( + request: FastifyRequest, + reply: FastifyReply + ): Promise { + // First verify the JWT + await verifyJWT(request, reply) + + // If verifyJWT sent a response, stop + if (reply.sent) return + + // Check if user has at least one required group + const hasRequiredGroup = requiredGroups.some( + group => request.user!.groups.includes(group) + ) + + if (!hasRequiredGroup) { + reply.code(403).send({ + success: false, + error: 'Insufficient permissions', + required: requiredGroups, + actual: request.user!.groups + }) + return + } + } +} +``` + +## Using Auth in Routes + +### Protected Route + +```typescript +import { verifyJWT } from '../middleware/auth' + +const routes: FastifyPluginAsync = async (fastify, opts) => { + // Single route protection + fastify.get('/profile', { + preHandler: verifyJWT + }, async (request, reply) => { + // request.user is guaranteed to exist + const userId = request.user!.sub + return { success: true, data: { userId } } + }) +} +``` + +### Route with Role Requirement + +```typescript +import { requireGroups } from '../middleware/auth' + +const adminRoutes: FastifyPluginAsync = async (fastify, opts) => { + fastify.delete<{ Params: { id: string } }>('/users/:id', { + preHandler: requireGroups(['admin']) + }, async (request, reply) => { + // Only admins can reach here + const { id } = request.params + await fastify.userService.delete(id) + return { success: true } + }) +} +``` + +### Optional Auth Route + +```typescript +import { optionalAuth } from '../middleware/auth' + +const routes: FastifyPluginAsync = async (fastify, opts) => { + fastify.get('/items', { + preHandler: optionalAuth + }, async (request, reply) => { + const items = await fastify.itemService.findAll() + + // Customize response based on auth status + if (request.user) { + // Authenticated users see more details + return { success: true, data: items } + } else { + // Anonymous users see limited data + return { success: true, data: items.map(i => ({ id: i.id, name: i.name })) } + } + }) +} +``` + +## Global Auth Hook + +Apply optional auth globally, then require it on specific routes: + +```typescript +// In app.ts +export const app: FastifyPluginAsync = async (fastify, opts) => { + await fastify.register(envPlugin) + + // Global optional auth (skips health checks) + fastify.addHook('onRequest', async (request, reply) => { + if (request.raw.url?.startsWith('/api/health')) { + return + } + await optionalAuth(request, reply) + }) + + // Register routes - they can check request.user or use preHandler for required auth + await fastify.register(publicRoutes, { prefix: '/api' }) + await fastify.register(protectedRoutes, { prefix: '/api' }) +} +``` + +## Token Generation + +Service for creating tokens: + +```typescript +// src/services/auth-service.ts +import jwt from 'jsonwebtoken' +import bcrypt from 'bcrypt' +import { FastifyInstance } from 'fastify' +import { JWTPayload } from '../middleware/auth' + +export class AuthService { + constructor(private fastify: FastifyInstance) {} + + generateToken(user: { id: string; email: string; groups: string[] }): string { + const config = this.fastify.config + + const payload: Omit = { + sub: user.id, + email: user.email, + groups: user.groups + } + + return jwt.sign(payload, config.JWT_SECRET, { + issuer: config.JWT_ISSUER, + audience: config.JWT_AUDIENCE, + expiresIn: config.JWT_EXPIRES_IN + }) + } + + async validateCredentials(email: string, password: string): Promise { + // Note: Requires userService to be decorated on fastify instance before AuthService is used + const user = await this.fastify.userService.findByEmail(email) + if (!user) return null + + const valid = await this.comparePassword(password, user.passwordHash) + if (!valid) return null + + return user + } + + private async comparePassword(plain: string, hash: string): Promise { + return bcrypt.compare(plain, hash) + } +} +``` + +## Login Route + +```typescript +const authRoutes: FastifyPluginAsync = async (fastify, opts) => { + fastify.post<{ Body: { email: string; password: string } }>('/login', { + schema: { + body: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string', minLength: 1 } + } + } + } + }, async (request, reply) => { + const { email, password } = request.body + + const user = await fastify.authService.validateCredentials(email, password) + + if (!user) { + reply.code(401) + return { success: false, error: 'Invalid credentials' } + } + + const token = fastify.authService.generateToken({ + id: user.id, + email: user.email, + groups: user.groups + }) + + return { + success: true, + data: { + token, + user: { id: user.id, email: user.email } + } + } + }) +} +``` + +## Security Considerations + +1. **Never log tokens**: Ensure JWT tokens are not logged in request/response hooks +2. **Use strong secrets**: JWT_SECRET should be at least 32 characters, randomly generated +3. **Set appropriate expiration**: Balance security vs user experience +4. **Validate issuer/audience**: Prevents token reuse across different applications +5. **Use HTTPS**: Always require HTTPS in production to protect tokens in transit diff --git a/.agents/skills/backend-fastify/references/gotchas.md b/.agents/skills/backend-fastify/references/gotchas.md new file mode 100644 index 0000000..5cb88ed --- /dev/null +++ b/.agents/skills/backend-fastify/references/gotchas.md @@ -0,0 +1,458 @@ +# Common Gotchas + +Issues frequently encountered in Fastify development and how to resolve them. + +## Configuration Access + +### Problem: Using process.env Directly + +```typescript +// WRONG - bypasses validation, no type safety +const apiKey = process.env.API_KEY +const port = parseInt(process.env.PORT || '3000') +``` + +### Solution: Always Use fastify.config + +```typescript +// CORRECT - validated at startup, type-safe +const apiKey = fastify.config.API_KEY +const port = fastify.config.PORT +``` + +In services, access config through the fastify instance: + +```typescript +export class MyService { + constructor(private fastify: FastifyInstance) {} + + async doWork() { + // Access config through fastify, never process.env + const apiKey = this.fastify.config.API_KEY + } +} +``` + +--- + +## Server Ready State + +### Problem: Accessing Config Before Ready + +```typescript +// WRONG - config may not be loaded yet +const server = Fastify({ ... }) +server.register(app) +console.log(server.config.PORT) // undefined or error +``` + +### Solution: Wait for server.ready() + +```typescript +// CORRECT +const server = Fastify({ ... }) +server.register(app) + +const start = async () => { + await server.ready() // Wait for plugins to load + const port = server.config.PORT // Now safe to access + await server.listen({ port, host: server.config.HOST }) +} + +start() +``` + +--- + +## Plugin Registration Order + +### Problem: Routes Registered Before Dependencies + +```typescript +// WRONG - routes can't access services +export const app: FastifyPluginAsync = async (fastify, opts) => { + await fastify.register(userRoutes) // Error: userService undefined + await fastify.register(envPlugin) + fastify.decorate('userService', new UserService(fastify)) +} +``` + +### Solution: Correct Registration Order + +```typescript +// CORRECT - env → services → routes +export const app: FastifyPluginAsync = async (fastify, opts) => { + // 1. Environment configuration FIRST + await fastify.register(envPlugin) + + // 2. Initialize services + const userService = new UserService(fastify) + fastify.decorate('userService', userService) + + // 3. Register routes LAST + await fastify.register(userRoutes) +} +``` + +--- + +## App Architecture + +### Problem: Factory Function Instead of Plugin + +```typescript +// WRONG - harder to compose and test +export async function buildApp() { + const fastify = Fastify({ ... }) + // setup... + return fastify +} +``` + +### Solution: Use FastifyPluginAsync Pattern + +```typescript +// CORRECT - composable plugin pattern +export const app: FastifyPluginAsync = async (fastify, opts) => { + // Plugin logic here +} + +export default fp(app, '5.x') + +// In index.ts +const server = Fastify({ logger: { ... } }) +server.register(app) +``` + +--- + +## Route Prefix Consistency + +### Problem: Inconsistent API Paths + +```typescript +// WRONG - mixing prefixed and non-prefixed +fastify.get('/health', ...) // /health +fastify.get('/api/users', ...) // /api/users +await fastify.register(routes, { prefix: '/api' }) // Confusing +``` + +### Solution: Consistent Prefix Strategy + +```typescript +// CORRECT - all API routes under /api +await fastify.register(healthRoutes, { prefix: '/api/health' }) +await fastify.register(userRoutes, { prefix: '/api/users' }) +await fastify.register(orderRoutes, { prefix: '/api/orders' }) +``` + +--- + +## Path Handling + +### Problem: Inconsistent Path Normalization + +```typescript +// WRONG - duplicated path logic, inconsistent handling +// In route A: +const fullPath = library ? `${library}/${path}` : path + +// In route B: +const fullPath = library + '/' + path + +// In route C: +const fullPath = [library, path].filter(Boolean).join('/') +``` + +### Solution: Centralized Path Utilities + +```typescript +// src/utils/path-utils.ts +import path from 'path' + +export function trimSlashes(p: string): string { + return p.replace(/^\/+|\/+$/g, '') +} + +export function normalizePath(basePath: string | undefined, filePath: string): string { + const normalizedBase = basePath && basePath !== '/' ? trimSlashes(basePath) : '' + const normalizedFile = trimSlashes(filePath) + + if (!normalizedBase) { + return normalizedFile + } + + return path.join(normalizedBase, normalizedFile) +} + +// Use everywhere: +import { normalizePath } from '../utils/path-utils' +const fullPath = normalizePath(library, path) +``` + +--- + +## Object Enumeration + +### Problem: Object.entries() Missing Properties + +```typescript +// WRONG - may miss properties on some objects +const children = await someLibrary.getChildren() +for (const [name, child] of Object.entries(children)) { + // May not enumerate all properties +} +``` + +### Solution: Use for...in for External Objects + +```typescript +// CORRECT - enumerates all enumerable properties +const children = await someLibrary.getChildren() +for (const name in children) { + const child = children[name] + // Processes all properties +} +``` + +--- + +## Schema Drift + +### Problem: Swagger Schema Out of Sync + +```typescript +// Schema says one thing... +schema: { + response: { + 200: { + properties: { + id: { type: 'string' }, + name: { type: 'string' } + } + } + } +} + +// ...but implementation returns something else +return { + id: item.id, + name: item.name, + createdAt: item.createdAt // Not in schema! +} +``` + +### Solution: Keep Schemas in Sync + +1. Define response types that match schemas +2. Use TypeScript to enforce consistency +3. Test actual responses against schemas + +```typescript +interface ItemResponse { + id: string + name: string + createdAt: string +} + +// Schema matches the type +schema: { + response: { + 200: { + properties: { + id: { type: 'string' }, + name: { type: 'string' }, + createdAt: { type: 'string' } + } + } + } +} + +// Implementation returns typed response +const response: ItemResponse = { + id: item.id, + name: item.name, + createdAt: item.createdAt.toISOString() +} +return response +``` + +--- + +## Logging Noise + +### Problem: Health Checks Flooding Logs + +```typescript +// Every health check probe logs: +// [12:00:01] incoming request GET /api/health +// [12:00:01] request completed 200 +// [12:00:02] incoming request GET /api/health +// [12:00:02] request completed 200 +// ... repeated every second +``` + +### Solution: Filter Health Checks from Logging + +```typescript +fastify.addHook('onRequest', (req, reply, done) => { + // Skip logging for health checks + if (req.raw.url?.startsWith('/api/health')) { + done() + return + } + + req.log.info({ /* request details */ }, 'incoming request') + done() +}) + +fastify.addHook('onResponse', (req, reply, done) => { + if (req.raw.url?.startsWith('/api/health')) { + done() + return + } + + req.log.info({ /* response details */ }, 'request completed') + done() +}) +``` + +--- + +## Error Handling + +### Problem: Unhandled Errors Crash Server + +```typescript +// WRONG - unhandled promise rejection +fastify.get('/data', async (request, reply) => { + const data = await externalApi.fetch() // May throw + return data +}) +``` + +### Solution: Proper Error Handling + +```typescript +// CORRECT - handle errors gracefully +fastify.get('/data', async (request, reply) => { + try { + const data = await externalApi.fetch() + return { success: true, data } + } catch (error) { + request.log.error(error, 'Failed to fetch data') + reply.code(500) + return { + success: false, + error: error instanceof Error ? error.message : 'Unknown error' + } + } +}) +``` + +--- + +## Service Dependencies + +### Problem: Circular Service Dependencies + +```typescript +// WRONG - circular dependency +class ServiceA { + constructor(private serviceB: ServiceB) {} +} + +class ServiceB { + constructor(private serviceA: ServiceA) {} +} + +// Can't instantiate either first +``` + +### Solution: Use Setter Injection + +```typescript +// CORRECT - setter injection breaks the cycle +class ServiceA { + private serviceB: ServiceB | null = null + + constructor(private fastify: FastifyInstance) {} + + setServiceB(serviceB: ServiceB) { + this.serviceB = serviceB + } +} + +class ServiceB { + constructor(private fastify: FastifyInstance) {} +} + +// In app.ts +const serviceA = new ServiceA(fastify) +const serviceB = new ServiceB(fastify) +serviceA.setServiceB(serviceB) +``` + +--- + +## TypeScript Declaration Merging + +### Problem: TypeScript Doesn't Know About Decorations + +```typescript +// Error: Property 'userService' does not exist on type 'FastifyInstance' +const user = await fastify.userService.findById(id) +``` + +### Solution: Declare Module Augmentation + +```typescript +// At the top of app.ts or in a types file +declare module 'fastify' { + interface FastifyInstance { + userService: UserService + config: { + PORT: number + HOST: string + // ... all config properties + } + } +} +``` + +--- + +## Testing Considerations + +### Problem: Testing Routes Without Full Server + +```typescript +// WRONG - starts actual server +const server = await buildApp() +await server.listen({ port: 3000 }) +// test... +await server.close() +``` + +### Solution: Use inject() for Testing + +```typescript +// CORRECT - no actual server needed +import { app } from './app' + +test('GET /api/health returns healthy', async () => { + const fastify = Fastify() + await fastify.register(app) + await fastify.ready() + + const response = await fastify.inject({ + method: 'GET', + url: '/api/health' + }) + + expect(response.statusCode).toBe(200) + expect(JSON.parse(response.body)).toMatchObject({ + status: 'healthy' + }) +}) +``` diff --git a/.agents/skills/backend-fastify/references/mcp-integration.md b/.agents/skills/backend-fastify/references/mcp-integration.md new file mode 100644 index 0000000..f919b14 --- /dev/null +++ b/.agents/skills/backend-fastify/references/mcp-integration.md @@ -0,0 +1,601 @@ +# MCP Server Integration + +Patterns for integrating a Model Context Protocol (MCP) server with Fastify backends. + +## Overview + +MCP enables AI agents to interact with your backend through a standardized protocol. This guide covers integrating MCP using the `fastify-mcp-server` plugin. + +## Dependencies + +```bash +npm install @modelcontextprotocol/sdk fastify-mcp-server +``` + +**Package versions:** + +- `@modelcontextprotocol/sdk` - MCP SDK for server creation +- `fastify-mcp-server` - Official Fastify plugin + +## Basic Setup + +### 1. Create MCP Server + +```typescript +// src/mcp/server.ts +import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' +import { z } from 'zod' + +export function createMCPServer(services: AppServices) { + const server = new McpServer( + { + name: 'my-api-server', + version: '1.0.0', + }, + { + instructions: ` +# My API Server + +This MCP server provides tools for interacting with the API. + +## Available Tools +- list_items: List all items +- get_item: Get a specific item by ID +- create_item: Create a new item + ` + } + ) + + // Register tools (see Tool Definitions section) + registerTools(server, services) + + return server +} +``` + +### 2. Register MCP Plugin + +```typescript +// src/mcp/index.ts +import { FastifyInstance } from 'fastify' +import FastifyMcpServer, { getMcpDecorator } from 'fastify-mcp-server' +import { createMCPServer } from './server.js' +import { TokenVerifier } from './auth-verifier.js' + +export async function registerMCPPlugin(fastify: FastifyInstance) { + const mcpServer = createMCPServer({ + itemService: fastify.itemService, + userService: fastify.userService, + }) + + // Create token verifier for authentication + const tokenVerifier = new TokenVerifier(fastify) + + // Register the MCP plugin + await fastify.register(FastifyMcpServer, { + server: mcpServer.server, + endpoint: '/mcp', // MCP endpoint (NOT under /api prefix) + bearerMiddlewareOptions: { + verifier: tokenVerifier, + }, + }) + + // Set up session management + setupSessionManagement(fastify) +} + +function setupSessionManagement(fastify: FastifyInstance) { + const mcpDecorator = getMcpDecorator(fastify) + const sessionManager = mcpDecorator.getSessionManager() + + sessionManager.on('sessionCreated', (sessionId: string) => { + fastify.log.info(`MCP session created: ${sessionId}`) + }) + + sessionManager.on('sessionDestroyed', (sessionId: string) => { + fastify.log.info(`MCP session destroyed: ${sessionId}`) + // Clean up any session-specific state + }) + + sessionManager.on('transportError', (sessionId: string, error: Error) => { + fastify.log.error({ err: error, sessionId }, 'MCP transport error') + }) + + // Graceful shutdown + fastify.addHook('onClose', async () => { + fastify.log.info('Shutting down MCP sessions...') + await mcpDecorator.shutdown() + }) +} +``` + +### 3. Register in app.ts + +```typescript +// src/app.ts +import { FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' +import { registerMCPPlugin } from './mcp/index.js' + +export const app: FastifyPluginAsync = async (fastify, opts) => { + // Register environment config first + await fastify.register(envPlugin) + + // Initialize services + // ... + + // Register CORS with MCP headers + await fastify.register(import('@fastify/cors'), { + origin: fastify.config.NODE_ENV === 'production' ? false : true, + credentials: true, + exposedHeaders: ['Mcp-Session-Id', 'X-Request-Id'], + allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id', 'X-Request-Id'] + }) + + // Register API routes + await fastify.register(healthRoutes, { prefix: '/api/health' }) + await fastify.register(itemRoutes, { prefix: '/api/items' }) + + // Register MCP plugin AFTER API routes + await fastify.register(registerMCPPlugin) +} + +export default fp(app, '5.x') +``` + +## Authentication + +### Token Verifier Interface + +```typescript +// src/mcp/auth-verifier.ts +import { FastifyInstance } from 'fastify' +import jwt from 'jsonwebtoken' + +export interface OAuthTokenVerifier { + verifyAccessToken(token: string): Promise +} + +export interface AuthInfo { + token: string + clientId: string + scopes: string[] + expiresAt?: number + extra?: Record +} +``` + +### JWT Token Verifier Implementation + +```typescript +export class TokenVerifier implements OAuthTokenVerifier { + constructor(private fastify: FastifyInstance) {} + + private mapGroupsToScopes(groups: string[]): string[] { + const scopes = new Set() + + // All authenticated users get read access + scopes.add('api:read') + + // Admins get all scopes + if (groups.includes('admin')) { + scopes.add('api:write') + scopes.add('api:admin') + } + + // Developers get write access + if (groups.includes('developers')) { + scopes.add('api:write') + } + + return Array.from(scopes) + } + + async verifyAccessToken(token: string): Promise { + try { + const config = this.fastify.config + const decoded = jwt.verify(token, config.JWT_SECRET, { + issuer: config.JWT_ISSUER, + audience: config.JWT_AUDIENCE + }) as { sub: string; email: string; groups: string[]; exp: number } + + const scopes = this.mapGroupsToScopes(decoded.groups || []) + + return { + token, + clientId: decoded.email, + scopes, + expiresAt: decoded.exp, + extra: { + email: decoded.email, + groups: decoded.groups, + }, + } + } catch (error) { + throw new Error('Token verification failed') + } + } +} +``` + +### Scope Mapping + +| User Group | Granted Scopes | +|------------|----------------| +| All authenticated | `api:read` | +| `developers` | `api:read`, `api:write` | +| `admin` | `api:read`, `api:write`, `api:admin` | + +## Tool Definitions + +### Basic Tool Pattern + +```typescript +// src/mcp/server.ts +import { z } from 'zod' + +function registerTools(server: McpServer, services: AppServices) { + // Read operation + server.registerTool( + 'list_items', + { + description: 'List all items with optional filtering', + inputSchema: { + category: z.string().optional().describe('Filter by category'), + limit: z.number().optional().describe('Maximum items to return') + } + }, + async ({ category, limit }, { authInfo, sessionId }) => { + try { + const items = await services.itemService.findAll({ + category, + limit: limit || 100 + }) + + return { + content: [{ + type: 'text', + text: items.map(item => `${item.id}: ${item.name}`).join('\n') + }] + } + } catch (error) { + return { + content: [{ + type: 'text', + text: `Error: ${error instanceof Error ? error.message : 'Unknown error'}` + }], + isError: true + } + } + } + ) +} +``` + +### Tool with Authentication Context + +```typescript +server.registerTool( + 'create_item', + { + description: 'Create a new item (requires write access)', + inputSchema: { + name: z.string().describe('Item name'), + content: z.string().describe('Item content'), + category: z.string().optional().describe('Item category') + } + }, + async ({ name, content, category }, { authInfo, sessionId }) => { + // Check scopes + if (!authInfo?.scopes.includes('api:write')) { + return { + content: [{ + type: 'text', + text: 'Error: Write access required. Please authenticate with appropriate permissions.' + }], + isError: true + } + } + + try { + // Forward auth token to downstream services if needed + const headers: Record = {} + if (authInfo?.token) { + headers['Authorization'] = `Bearer ${authInfo.token}` + } + + const item = await services.itemService.create( + { name, content, category }, + { headers } + ) + + return { + content: [{ + type: 'text', + text: `Created item: ${item.id} (${item.name})` + }] + } + } catch (error) { + return { + content: [{ + type: 'text', + text: `Error creating item: ${error instanceof Error ? error.message : 'Unknown error'}` + }], + isError: true + } + } + } +) +``` + +### Tool Response Formats + +```typescript +// Text response +return { + content: [{ + type: 'text', + text: 'Operation completed successfully' + }] +} + +// Error response +return { + content: [{ + type: 'text', + text: 'Error: Something went wrong' + }], + isError: true +} + +// Multiple content items +return { + content: [ + { type: 'text', text: '# Results\n\n' }, + { type: 'text', text: formattedData } + ] +} +``` + +## Session State Management + +Track state across multiple tool calls within a session: + +```typescript +// src/mcp/index.ts +type SessionState = Map + +interface SessionData { + branch?: string + preferences?: Record + lastActivity: Date +} + +const sessionState: SessionState = new Map() + +export async function registerMCPPlugin(fastify: FastifyInstance) { + // Pass session state to server creation + const mcpServer = createMCPServer(services, sessionState) + + // ... register plugin ... + + // Clean up on session destroy + const mcpDecorator = getMcpDecorator(fastify) + const sessionManager = mcpDecorator.getSessionManager() + + sessionManager.on('sessionDestroyed', (sessionId: string) => { + sessionState.delete(sessionId) + fastify.log.info(`Cleaned up state for session: ${sessionId}`) + }) +} +``` + +### Using Session State in Tools + +```typescript +server.registerTool( + 'set_preference', + { + description: 'Set a preference for this session', + inputSchema: { + key: z.string().describe('Preference key'), + value: z.string().describe('Preference value') + } + }, + async ({ key, value }, { sessionId }) => { + const state = sessionState.get(sessionId || 'default') || { + lastActivity: new Date() + } + + state.preferences = state.preferences || {} + state.preferences[key] = value + state.lastActivity = new Date() + + sessionState.set(sessionId || 'default', state) + + return { + content: [{ + type: 'text', + text: `Preference '${key}' set to '${value}'` + }] + } + } +) +``` + +## CORS Configuration + +MCP requires specific headers to be exposed: + +```typescript +await fastify.register(import('@fastify/cors'), { + origin: fastify.config.NODE_ENV === 'production' + ? fastify.config.ALLOWED_ORIGINS?.split(',') + : true, + credentials: true, + // MCP-specific headers + exposedHeaders: ['Mcp-Session-Id', 'X-Request-Id'], + allowedHeaders: ['Content-Type', 'Authorization', 'Mcp-Session-Id', 'X-Request-Id'] +}) +``` + +## Environment Configuration + +Add MCP-related config to your env plugin: + +```typescript +// src/plugins/env.ts +const schema = { + type: 'object', + required: ['JWT_SECRET'], + properties: { + // ... existing config ... + + // MCP-specific (optional) + MCP_MAX_SESSIONS: { + type: 'number', + default: 100, + description: 'Maximum concurrent MCP sessions' + }, + MCP_SESSION_TIMEOUT: { + type: 'number', + default: 3600000, // 1 hour in ms + description: 'Session timeout in milliseconds' + } + } +} +``` + +## Testing MCP Endpoints + +### Using curl + +```bash +# Initialize session +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Accept: application/json" \ + -d '{ + "jsonrpc": "2.0", + "id": 1, + "method": "initialize", + "params": { + "protocolVersion": "2024-11-05", + "capabilities": {}, + "clientInfo": { "name": "test-client", "version": "1.0.0" } + } + }' + +# Call a tool +curl -X POST http://localhost:3001/mcp \ + -H "Content-Type: application/json" \ + -H "Authorization: Bearer $TOKEN" \ + -H "Mcp-Session-Id: $SESSION_ID" \ + -d '{ + "jsonrpc": "2.0", + "id": 2, + "method": "tools/call", + "params": { + "name": "list_items", + "arguments": { "limit": 10 } + } + }' +``` + +### Session Header Flow + +1. Client sends request without `Mcp-Session-Id` +2. Server creates session and returns `Mcp-Session-Id` in response headers +3. Client includes `Mcp-Session-Id` in subsequent requests +4. Server maintains state for that session + +## Project Structure + +``` +backend/ +├── src/ +│ ├── mcp/ +│ │ ├── index.ts # Plugin registration, session management +│ │ ├── server.ts # MCP server creation, tool definitions +│ │ └── auth-verifier.ts # Token verification +│ ├── plugins/ +│ │ └── env.ts +│ ├── routes/ +│ ├── services/ +│ ├── app.ts # Main app with MCP registration +│ └── index.ts +└── package.json +``` + +## Common Patterns + +### Error Handling in Tools + +```typescript +async (inputs, context) => { + try { + const result = await performOperation(inputs) + return formatSuccessResponse(result) + } catch (error) { + if (error instanceof ValidationError) { + return { + content: [{ type: 'text', text: `Validation error: ${error.message}` }], + isError: true + } + } + if (error instanceof NotFoundError) { + return { + content: [{ type: 'text', text: `Not found: ${error.message}` }], + isError: true + } + } + // Log unexpected errors + context.logger?.error(error, 'Unexpected error in tool') + return { + content: [{ type: 'text', text: 'An unexpected error occurred' }], + isError: true + } + } +} +``` + +### Forwarding Auth to Downstream Services + +```typescript +async ({ id }, { authInfo, sessionId }) => { + const headers: Record = {} + + // Forward bearer token + if (authInfo?.token) { + headers['Authorization'] = `Bearer ${authInfo.token}` + } + + // Forward session context + if (sessionId) { + headers['X-Session-Id'] = sessionId + } + + const result = await downstreamService.fetch(id, { headers }) + return formatResponse(result) +} +``` + +### Scope-Based Access Control + +```typescript +function requireScope(scope: string) { + return (authInfo: AuthInfo | undefined): boolean => { + if (!authInfo) return false + return authInfo.scopes.includes(scope) + } +} + +// In tool +if (!requireScope('api:write')(authInfo)) { + return { + content: [{ type: 'text', text: 'Write access required' }], + isError: true + } +} +``` diff --git a/.agents/skills/backend-fastify/references/patterns.md b/.agents/skills/backend-fastify/references/patterns.md new file mode 100644 index 0000000..a4ab188 --- /dev/null +++ b/.agents/skills/backend-fastify/references/patterns.md @@ -0,0 +1,430 @@ +# Development Patterns + +Patterns for building features in Fastify + TypeScript backends. + +## Typed Route Handlers + +Use generic type parameters for type-safe request handling: + +```typescript +interface ReadQuerystring { + path: string + ref?: string + metadataOnly?: boolean +} + +fastify.get<{ Querystring: ReadQuerystring }>('/api/read', { + schema: { + querystring: { + type: 'object', + required: ['path'], + properties: { + path: { type: 'string', description: 'File path to read' }, + ref: { type: 'string', default: 'main' }, + metadataOnly: { type: 'boolean', default: false } + } + } + } +}, async (request, reply) => { + const { path, ref = 'main', metadataOnly } = request.query + // TypeScript knows the types +}) +``` + +### POST with Body Types + +```typescript +interface CreateBody { + name: string + content: string + tags?: string[] +} + +fastify.post<{ Body: CreateBody }>('/api/items', { + schema: { + body: { + type: 'object', + required: ['name', 'content'], + properties: { + name: { type: 'string', minLength: 1 }, + content: { type: 'string' }, + tags: { type: 'array', items: { type: 'string' } } + } + } + } +}, async (request, reply) => { + const { name, content, tags = [] } = request.body +}) +``` + +### Route with Path Parameters + +```typescript +interface ItemParams { + id: string +} + +fastify.get<{ Params: ItemParams }>('/api/items/:id', async (request, reply) => { + const { id } = request.params +}) +``` + +## JSON Schema Validation + +Fastify validates requests against JSON Schema automatically: + +```typescript +const routes: FastifyPluginAsync = async (fastify, opts) => { + fastify.post('/api/users', { + schema: { + body: { + type: 'object', + required: ['email', 'password'], + properties: { + email: { type: 'string', format: 'email' }, + password: { type: 'string', minLength: 8 }, + role: { type: 'string', enum: ['user', 'admin'], default: 'user' } + } + }, + response: { + 201: { + type: 'object', + properties: { + success: { type: 'boolean' }, + data: { + type: 'object', + properties: { + id: { type: 'string' }, + email: { type: 'string' } + } + } + } + }, + 400: { + type: 'object', + properties: { + success: { type: 'boolean' }, + error: { type: 'string' } + } + } + } + } + }, async (request, reply) => { + // request.body is validated before handler runs + }) +} +``` + +## Service Architecture + +### Single-Responsibility Services + +Each service handles one domain: + +```typescript +// src/services/user-service.ts +import { FastifyInstance } from 'fastify' + +export class UserService { + constructor(private fastify: FastifyInstance) {} + + async findById(id: string) { + // Access config through fastify instance, not process.env + const dbUrl = this.fastify.config.DATABASE_URL + this.fastify.log.debug({ id }, 'Finding user by ID') + // ... implementation + } + + async create(email: string, password: string) { + // ... implementation + } + + async updateRole(id: string, role: string) { + // ... implementation + } +} +``` + +### Service Registration in app.ts + +```typescript +import { FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' +import { UserService } from './services/user-service' +import { EmailService } from './services/email-service' + +declare module 'fastify' { + interface FastifyInstance { + userService: UserService + emailService: EmailService + } +} + +export const app: FastifyPluginAsync = async (fastify, opts) => { + // 1. Register environment configuration FIRST + await fastify.register(envPlugin) + + // 2. Initialize services (order matters if they depend on each other) + const userService = new UserService(fastify) + const emailService = new EmailService(fastify) + + // 3. Decorate fastify instance + fastify.decorate('userService', userService) + fastify.decorate('emailService', emailService) + + // 4. Register routes (they can now access services) + await fastify.register(userRoutes, { prefix: '/api/users' }) +} + +export default fp(app, '5.x') +``` + +### Inter-Service Communication + +When services need to communicate: + +```typescript +export class OrderService { + private userService: UserService | null = null + + constructor(private fastify: FastifyInstance) {} + + setUserService(userService: UserService) { + this.userService = userService + } + + async createOrder(userId: string, items: Item[]) { + if (!this.userService) { + throw new Error('UserService not configured') + } + const user = await this.userService.findById(userId) + // ... create order + } +} + +// In app.ts +const userService = new UserService(fastify) +const orderService = new OrderService(fastify) +orderService.setUserService(userService) +``` + +## Structured Logging + +### Request/Response Logging Hooks + +```typescript +// In app.ts +fastify.addHook('onRequest', (req, reply, done) => { + // Skip health checks to reduce log noise + if (req.raw.url?.startsWith('/api/health')) { + done() + return + } + + req.log.info({ + reqId: req.id, + req: { + method: req.raw.method, + url: req.raw.url, + host: req.headers.host, + remoteAddress: req.ip + } + }, 'incoming request') + done() +}) + +fastify.addHook('onResponse', (req, reply, done) => { + if (req.raw.url?.startsWith('/api/health')) { + done() + return + } + + req.log.info({ + reqId: req.id, + res: { statusCode: reply.statusCode }, + responseTime: reply.elapsedTime + }, 'request completed') + done() +}) +``` + +### Logging in Services + +```typescript +export class PaymentService { + constructor(private fastify: FastifyInstance) {} + + async processPayment(orderId: string, amount: number) { + this.fastify.log.info({ orderId, amount }, 'Processing payment') + + try { + // ... process + this.fastify.log.info({ orderId }, 'Payment successful') + } catch (error) { + this.fastify.log.error({ orderId, error }, 'Payment failed') + throw error + } + } +} +``` + +## Caching Patterns + +### In-Memory Caching + +```typescript +export class ConfigService { + private cache: Map = new Map() + private readonly TTL = 5 * 60 * 1000 // 5 minutes + + constructor(private fastify: FastifyInstance) {} + + async get(key: string, fetcher: () => Promise): Promise { + const cached = this.cache.get(key) + + if (cached && cached.expires > Date.now()) { + this.fastify.log.debug({ key }, 'Cache hit') + return cached.value as T + } + + this.fastify.log.debug({ key }, 'Cache miss, fetching') + const value = await fetcher() + this.cache.set(key, { value, expires: Date.now() + this.TTL }) + return value + } + + invalidate(key: string) { + this.cache.delete(key) + } + + clear() { + this.cache.clear() + } +} +``` + +### Query Result Caching + +```typescript +export class DataService { + private routesCache: Route[] | null = null + + constructor(private fastify: FastifyInstance) {} + + async getRoutes(): Promise { + if (this.routesCache) { + return this.routesCache + } + + const routes = await this.fetchRoutesFromDatabase() + this.routesCache = routes + return routes + } + + invalidateRoutesCache() { + this.routesCache = null + } +} +``` + +## Path Utilities + +Centralize path handling to avoid inconsistencies: + +```typescript +// src/utils/path-utils.ts +import path from 'path' + +export function trimSlashes(p: string): string { + return p.replace(/^\/+|\/+$/g, '') +} + +export function normalizeLibraryPath(library: string | undefined): string { + if (!library || library === '/') { + return '' + } + return trimSlashes(library) +} + +export function buildFullPath(library: string | undefined, filePath: string): string { + const normalizedLibrary = normalizeLibraryPath(library) + const normalizedPath = trimSlashes(filePath) + + if (!normalizedLibrary) { + return normalizedPath + } + + return path.join(normalizedLibrary, normalizedPath) +} +``` + +## Route Organization + +### Route Module Pattern + +```typescript +// src/routes/users.ts +import { FastifyPluginAsync } from 'fastify' + +const userRoutes: FastifyPluginAsync = async (fastify, opts) => { + const userService = fastify.userService + + fastify.get('/', async (request, reply) => { + const users = await userService.findAll() + return { success: true, data: users } + }) + + fastify.get<{ Params: { id: string } }>('/:id', async (request, reply) => { + const { id } = request.params + const user = await userService.findById(id) + if (!user) { + reply.code(404) + return { success: false, error: 'User not found' } + } + return { success: true, data: user } + }) + + fastify.post<{ Body: { email: string; password: string } }>('/', async (request, reply) => { + const { email, password } = request.body + const user = await userService.create(email, password) + reply.code(201) + return { success: true, data: user } + }) +} + +export default userRoutes +``` + +### Route Registration with Prefixes + +```typescript +// In app.ts +await fastify.register(userRoutes, { prefix: '/api/users' }) +await fastify.register(orderRoutes, { prefix: '/api/orders' }) +await fastify.register(healthRoutes, { prefix: '/api/health' }) +``` + +## Package Management + +Always use npm commands to manage dependencies: + +- Always use `npm install ` to add dependencies +- Never manually edit `package.json` to add packages +- This ensures the latest compatible versions are installed and `package-lock.json` stays in sync + +### Adding Dependencies + +```bash +# Runtime dependencies +npm install fastify-plugin @fastify/cors pino + +# Development dependencies +npm install -D @types/node typescript +``` + +### Why This Matters + +- `npm install` automatically resolves to the latest compatible version +- Manual edits can introduce version mismatches +- `package-lock.json` won't be properly updated with manual edits +- Dependency tree conflicts are harder to debug after manual edits diff --git a/.agents/skills/backend-fastify/references/setup-guide.md b/.agents/skills/backend-fastify/references/setup-guide.md new file mode 100644 index 0000000..7003bc5 --- /dev/null +++ b/.agents/skills/backend-fastify/references/setup-guide.md @@ -0,0 +1,638 @@ +# Fastify Backend Setup Guide + +This guide documents the complete process for bootstrapping a Fastify backend following the loop project patterns. + +## Prerequisites + +- Node.js 22.x +- npm + +### Node.js Version Management with asdf + +Use [asdf](https://asdf-vm.com/) to manage Node.js versions consistently across the team: + +```bash +# Install Node.js plugin (one-time setup) +asdf plugin add nodejs + +# Set project Node.js version (creates .tool-versions file) +asdf set nodejs latest:22 +``` + +The `.tool-versions` file created by `asdf set` ensures all team members use the same Node.js version. + +## Stack Overview + +- **Fastify 5.x** - High-performance web framework +- **TypeScript** - Type safety +- **tsx** - Development with watch mode +- **pino-pretty** - Pretty logging for development +- **@fastify/env** - Environment variable validation with JSON Schema +- **@fastify/cors** - CORS support +- **fastify-plugin** - Plugin system + +## Step-by-Step Setup + +### 1. Initialize Backend Package + +```bash +mkdir -p backend && cd backend +npm init -y +``` + +Update `package.json` scripts: + +```json +{ + "name": "backend", + "version": "1.0.0", + "description": "Backend API server", + "main": "dist/index.js", + "scripts": { + "dev": "tsx watch src/index.ts", + "build": "tsc", + "start": "node dist/index.js", + "type-check": "tsc --noEmit" + } +} +``` + +**Commit:** `feat(backend): initialize backend package` + +--- + +### 2. Install Dependencies + +```bash +npm install fastify fastify-plugin @fastify/env @fastify/cors pino-pretty +npm install -D typescript tsx @types/node +``` + +**Commit:** `build(backend): install Fastify and dependencies` + +--- + +### 3. Create TypeScript Configuration + +**Create `tsconfig.json`:** + +```json +{ + "compilerOptions": { + "target": "ES2022", + "module": "commonjs", + "lib": ["ES2022"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} +``` + +**Create `.gitignore`:** + +``` +node_modules/ +dist/ +.env +*.log +.DS_Store +``` + +**Commit:** `config(backend): add TypeScript configuration and gitignore` + +--- + +### 4. Create Directory Structure + +```bash +mkdir -p src/{plugins,routes,services} +``` + +--- + +### 5. Create Environment Plugin + +**Create `src/plugins/env.ts`:** + +```typescript +import fp from 'fastify-plugin' +import fastifyEnv from '@fastify/env' + +const schema = { + type: 'object', + required: [], // Add required env vars here + properties: { + PORT: { + type: 'number', + default: 3001 + }, + HOST: { + type: 'string', + default: '0.0.0.0' + }, + NODE_ENV: { + type: 'string', + enum: ['development', 'production', 'test'], + default: 'development' + }, + LOG_LEVEL: { + type: 'string', + enum: ['fatal', 'error', 'warn', 'info', 'debug', 'trace'], + default: 'info' + }, + } +} + +// TypeScript declaration merging for type safety +declare module 'fastify' { + interface FastifyInstance { + config: { + PORT: number + HOST: string + NODE_ENV: 'development' | 'production' | 'test' + LOG_LEVEL: 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace' + } + } +} + +export default fp(async (fastify) => { + await fastify.register(fastifyEnv, { + schema, + dotenv: true // Load .env file + }) +}) +``` + +**Create `.env.example`:** + +``` +PORT=3001 +HOST=0.0.0.0 +NODE_ENV=development +LOG_LEVEL=info +``` + +**Key patterns:** + +- JSON Schema validation ensures type safety at startup +- Declaration merging provides TypeScript type safety +- `dotenv: true` automatically loads `.env` file +- Failed validation prevents server from starting + +**Commit:** `feat(backend): add environment configuration plugin` + +--- + +### 6. Create a Basic Service + +Services encapsulate business logic and are decorated onto the Fastify instance. + +**Example `src/services/example-service.ts`:** + +```typescript +import { FastifyInstance } from 'fastify' + +export class ExampleService { + constructor(private fastify: FastifyInstance) {} + + // Service methods here + async doSomething() { + this.fastify.log.info('Service method called') + return { success: true } + } +} +``` + +--- + +### 7. Create Routes + +Routes are Fastify plugins that define HTTP endpoints. + +**Create `src/routes/health.ts`:** + +```typescript +import { FastifyPluginAsync } from 'fastify' + +const healthRoutes: FastifyPluginAsync = async (fastify, opts) => { + fastify.get('/', async (request, reply) => { + return { + status: 'healthy', + timestamp: new Date().toISOString(), + service: 'backend-service', + version: '1.0.0', + environment: fastify.config.NODE_ENV + } + }) +} + +export default healthRoutes +``` + +**Commit:** `feat(backend): add health check route` + +--- + +### 8. Create app.ts + +The app file registers all plugins, services, and routes. + +**Create `src/app.ts`:** + +```typescript +import { FastifyPluginAsync } from 'fastify' +import fp from 'fastify-plugin' +import cors from '@fastify/cors' + +// Import plugins +import envPlugin from './plugins/env' + +// Import services +import { ExampleService } from './services/example-service' + +// Import routes +import healthRoutes from './routes/health' + +// TypeScript declaration merging for services +declare module 'fastify' { + interface FastifyInstance { + exampleService: ExampleService + } +} + +export const app: FastifyPluginAsync = async (fastify, opts) => { + // 1. Register environment configuration FIRST + await fastify.register(envPlugin) + + // Update log level from config + fastify.log.level = fastify.config.LOG_LEVEL + + // 2. Initialize services + const exampleService = new ExampleService(fastify) + + // 3. Decorate fastify instance with services + fastify.decorate('exampleService', exampleService) + + // 4. Register CORS + await fastify.register(cors, { + origin: fastify.config.NODE_ENV === 'production' ? false : true, + credentials: true + }) + + // 5. Add custom logging hooks (excluding health checks) + fastify.addHook('onRequest', (req, reply, done) => { + if (req.raw.url?.startsWith('/api/health')) { + done() + return + } + + req.log.info({ + reqId: req.id, + req: { + method: req.raw.method, + url: req.raw.url, + host: req.headers.host, + remoteAddress: req.ip + } + }, 'incoming request') + done() + }) + + fastify.addHook('onResponse', (req, reply, done) => { + if (req.raw.url?.startsWith('/api/health')) { + done() + return + } + + req.log.info({ + reqId: req.id, + res: { statusCode: reply.statusCode }, + responseTime: reply.elapsedTime + }, 'request completed') + done() + }) + + // 6. Register routes with /api prefix + await fastify.register(healthRoutes, { prefix: '/api/health' }) + + // 7. Log successful startup + fastify.addHook('onReady', async () => { + fastify.log.info('Backend initialized successfully') + fastify.log.info(`Environment: ${fastify.config.NODE_ENV}`) + fastify.log.info('Available API endpoints:') + fastify.log.info(' GET /api/health - Health check') + }) +} + +export default fp(app, '5.x') +``` + +**Key patterns:** + +- Register env plugin FIRST before anything else +- Initialize services and decorate Fastify instance for type-safe access +- Custom logging hooks skip health checks to reduce noise +- Routes registered with `/api` prefix +- `onReady` hook logs startup info + +**Commit:** `feat(backend): add app.ts with plugin registration` + +--- + +### 9. Create index.ts Entry Point + +**Create `src/index.ts`:** + +```typescript +import Fastify from 'fastify' +import { app } from './app' + +const server = Fastify({ + logger: { + level: 'info', // Will be updated after env config loads + transport: { + target: 'pino-pretty', + options: { + translateTime: 'HH:MM:ss Z', + ignore: 'pid,hostname' + } + } + }, + disableRequestLogging: true // We use custom hooks in app.ts +}) + +// Register the app +server.register(app) + +// Graceful shutdown handlers +const gracefulShutdown = async (signal: string) => { + server.log.info(`Received ${signal}, shutting down gracefully`) + try { + await server.close() + server.log.info('Server closed successfully') + process.exit(0) + } catch (error) { + server.log.error(error, 'Error during shutdown') + process.exit(1) + } +} + +process.on('SIGTERM', () => gracefulShutdown('SIGTERM')) +process.on('SIGINT', () => gracefulShutdown('SIGINT')) + +// Start the server +const start = async () => { + try { + // Wait for ready so env config is loaded + await server.ready() + + const port = server.config.PORT + const host = server.config.HOST + + await server.listen({ port, host }) + + server.log.info(`Backend listening at http://${host}:${port}`) + server.log.info(`Environment: ${server.config.NODE_ENV}`) + + if (server.config.NODE_ENV !== 'production') { + server.log.info(`API endpoints: http://localhost:${port}/api/`) + server.log.info(`Health check: http://localhost:${port}/api/health`) + } + } catch (err) { + server.log.error(err) + process.exit(1) + } +} + +start() +``` + +**Key patterns:** + +- pino-pretty for readable dev logs +- `disableRequestLogging: true` because we use custom hooks +- Graceful shutdown handlers for SIGTERM/SIGINT +- `await server.ready()` before accessing config +- Pretty startup logging + +**Commit:** `feat(backend): add index.ts entry point with graceful shutdown` + +--- + +## Running the Backend + +```bash +# Development with watch mode +npm run dev + +# Build for production +npm run build + +# Run production build +npm start + +# Type check only +npm run type-check +``` + +--- + +## VSCode Debugging + +Configure VSCode to debug the backend with breakpoints, variable inspection, and step-through execution. + +### Launch Configuration + +**Create `.vscode/launch.json`:** + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Backend", + "type": "node", + "request": "launch", + "program": "${workspaceFolder}/backend/src/index.ts", + "cwd": "${workspaceFolder}/backend", + "runtimeExecutable": "npx", + "runtimeArgs": ["tsx"], + "envFile": "${workspaceFolder}/backend/.env", + "console": "integratedTerminal", + "sourceMaps": true, + "skipFiles": ["/**", "**/node_modules/**"] + } + ] +} +``` + +**Key patterns:** + +- `runtimeExecutable: "npx"` with `runtimeArgs: ["tsx"]` runs TypeScript directly without pre-compilation +- `envFile` loads environment variables from `.env` file +- `sourceMaps: true` enables breakpoints in TypeScript source files +- `skipFiles` excludes Node internals and dependencies from step-through +- `console: "integratedTerminal"` shows pino-pretty formatted logs + +### Using the Debugger + +1. **Set breakpoints** - Click the gutter next to line numbers in any `.ts` file +2. **Start debugging** - Press `F5` or select "Debug Backend" from the Run and Debug panel +3. **Debug controls** - Use Continue (F5), Step Over (F10), Step Into (F11), Step Out (Shift+F11) +4. **Inspect variables** - Hover over variables or use the Variables panel + +**Commit:** `config(backend): add VSCode debugging configuration` + +--- + +## Vite Proxy Configuration (Frontend Integration) + +To proxy frontend API requests to the backend during development: + +**Update `vite.config.ts`:** + +```typescript +export default defineConfig({ + // ... other config + server: { + proxy: { + "/api": { + target: "http://localhost:3001", + changeOrigin: true, + }, + }, + }, +}) +``` + +**Commit:** `config: add Vite proxy for backend API requests` + +--- + +## Key Fastify Patterns + +### Service Pattern + +```typescript +// 1. Create service class +export class MyService { + constructor(private fastify: FastifyInstance) {} + + async doWork() { + // Access config: this.fastify.config.SOME_VAR + // Access logger: this.fastify.log.info('...') + } +} + +// 2. Declare module augmentation +declare module 'fastify' { + interface FastifyInstance { + myService: MyService + } +} + +// 3. Initialize and decorate in app.ts +const myService = new MyService(fastify) +fastify.decorate('myService', myService) + +// 4. Use in routes +fastify.get('/example', async (request, reply) => { + return fastify.myService.doWork() +}) +``` + +### Route Pattern + +```typescript +import { FastifyPluginAsync } from 'fastify' + +const routes: FastifyPluginAsync = async (fastify, opts) => { + fastify.get('/', async (request, reply) => { + return { data: 'example' } + }) + + fastify.post<{ Body: MyType }>('/', async (request, reply) => { + const { field } = request.body + return { success: true } + }) +} + +export default routes +``` + +### Plugin Pattern + +```typescript +import fp from 'fastify-plugin' + +export default fp(async (fastify, opts) => { + // Plugin logic here + fastify.decorate('something', value) +}, '5.x') // Fastify version constraint +``` + +--- + +## Common Gotchas + +### Environment Variables Must Be Declared + +All env vars must be in the schema or they won't be accessible. Use `required: []` array for mandatory vars. + +### Services Need Declaration Merging + +Without declaration merging, TypeScript won't know about decorated services. + +### Plugin Registration Order Matters + +Always register env plugin first, then services, then routes. + +### Use await server.ready() + +Access `server.config` only after `await server.ready()` in index.ts. + +### Logging Hooks vs Built-in Logging + +Use `disableRequestLogging: true` and custom hooks to control what gets logged. + +--- + +## Project Structure + +``` +backend/ +├── src/ +│ ├── plugins/ +│ │ └── env.ts # Environment config with validation +│ ├── routes/ +│ │ └── health.ts # HTTP endpoints +│ ├── services/ +│ │ └── example-service.ts # Business logic +│ ├── app.ts # Plugin registration & setup +│ └── index.ts # Server entry point +├── dist/ # Compiled output (gitignored) +├── node_modules/ # Dependencies (gitignored) +├── package.json +├── tsconfig.json +├── .env # Environment variables (gitignored) +├── .env.example # Template for .env +└── .gitignore +``` + +--- + +## Additional Resources + +- [Fastify Documentation](https://fastify.dev/) +- [Pino Logger](https://getpino.io/) +- [JSON Schema](https://json-schema.org/) diff --git a/.agents/skills/frontend-shadcn/SKILL.md b/.agents/skills/frontend-shadcn/SKILL.md new file mode 100644 index 0000000..072755b --- /dev/null +++ b/.agents/skills/frontend-shadcn/SKILL.md @@ -0,0 +1,126 @@ +--- +name: frontend-shadcn +description: Frontend development using Vite + React + shadcn/ui + Tailwind CSS + React Router v7. Use when creating new frontend projects, adding UI components, implementing routing, styling with Tailwind, or working with shadcn/ui component library. +--- + +# Frontend ShadCN Stack + +Modern React frontend stack: + +- **Vite** - Build tooling +- **React 19** - UI framework +- **TypeScript** - Type safety +- **Tailwind CSS v4** - Utility-first styling (`@tailwindcss/vite` plugin) +- **shadcn/ui** - Component library (New York style) +- **React Router v7** - Client-side routing + +## Environment Setup + +Use [asdf](https://asdf-vm.com/) to manage Node.js versions: + +```bash +# Install Node.js plugin (one-time) +asdf plugin add nodejs + +# Set project Node.js version +asdf set nodejs latest:22 +``` + +This creates a `.tool-versions` file in the project root that ensures consistent Node.js versions across the team. + +## Reference Files + +| File | When to Use | +|------|-------------| +| [setup-guide.md](references/setup-guide.md) | Starting a new project from scratch | +| [patterns.md](references/patterns.md) | Implementing features, understanding architecture | +| [maplibre.md](references/maplibre.md) | Working with MapLibre GL JS maps | +| [mcp-tools.md](references/mcp-tools.md) | Looking up docs, adding components via MCP | + +## Quick Reference + +### Commands + +```bash +# Add shadcn component +npx shadcn@latest add -y + +# Dev server +npm run dev + +# Type check +npm run type-check +``` + +### Key Imports + +```typescript +// React Router v7 - use 'react-router' NOT 'react-router-dom' +import { Routes, Route, Link, useLocation, useSearchParams } from 'react-router' + +// Path alias - @/ maps to src/ +import { Button } from '@/components/ui/button' +import { cn } from '@/lib/utils' +``` + +### Conditional Classes + +```typescript +import { cn } from '@/lib/utils' + +
+``` + +### Common Components + +```bash +# Layout +npx shadcn@latest add sidebar card separator -y + +# Forms +npx shadcn@latest add button input form select checkbox -y + +# Feedback +npx shadcn@latest add dialog alert toast -y + +# Navigation +npx shadcn@latest add dropdown-menu tabs tooltip -y +``` + +### Project Structure + +``` +src/ +├── components/ +│ ├── ui/ # shadcn/ui components (auto-generated) +│ ├── AppShell.tsx # Main layout with header +│ └── AppSidebar.tsx +├── pages/ # Route page components +├── hooks/ # Custom hooks +├── lib/ +│ └── utils.ts # cn() helper +├── App.tsx # Route definitions +├── main.tsx # Entry point with BrowserRouter +└── index.css # Tailwind + shadcn theme +``` + +### Tailwind Patterns + +```typescript +// Common utility patterns +"flex items-center gap-4" // Flexbox with gap +"bg-muted text-muted-foreground" // Muted backgrounds +"border-b bg-background" // Borders and backgrounds +"h-screen overflow-auto" // Full height scrolling +"space-y-4" // Vertical spacing +``` + +### Common Gotchas + +- **Package management**: Use `npm install ` not manual `package.json` edits +- **shadcn components**: Use `npx shadcn@latest add -y` +- **React Router imports**: Use `react-router` NOT `react-router-dom` diff --git a/.agents/skills/frontend-shadcn/references/maplibre.md b/.agents/skills/frontend-shadcn/references/maplibre.md new file mode 100644 index 0000000..40164b6 --- /dev/null +++ b/.agents/skills/frontend-shadcn/references/maplibre.md @@ -0,0 +1,523 @@ +# MapLibre GL JS Patterns + +Patterns for building interactive maps with MapLibre GL JS in React applications. + +## Setup + +```bash +npm install maplibre-gl +``` + +```typescript +import maplibregl from 'maplibre-gl' +import 'maplibre-gl/dist/maplibre-gl.css' +``` + +## Map Initialization + +### Single Instance Pattern (React) + +Create the map once on mount, never recreate. Use refs for stable instance access: + +```typescript +const mapContainer = useRef(null) +const map = useRef(null) +const [mapLoaded, setMapLoaded] = useState(false) + +useEffect(() => { + if (!mapContainer.current || map.current) return + + map.current = new maplibregl.Map({ + container: mapContainer.current, + style: 'https://api.maptiler.com/maps/streets/style.json?key=YOUR_KEY', + center: [-75.1652, 39.9526], + zoom: 12, + }) + + map.current.addControl(new maplibregl.NavigationControl()) + + map.current.on('load', () => { + setMapLoaded(true) + }) + + return () => { + map.current?.remove() + map.current = null + } +}, []) // Empty deps - initialize once +``` + +### Centralized Config + +Extract constants and initialization to a config file: + +```typescript +// mapConfig.ts +export const DEFAULT_CENTER: [number, number] = [-75.1652, 39.9526] +export const DEFAULT_ZOOM = 12 +export const ZOOM_THRESHOLD = 13 // For scale-dependent rendering + +export function initializeMap(container: HTMLElement) { + const map = new maplibregl.Map({ + container, + style: 'https://api.maptiler.com/maps/openstreetmap/style.json?key=YOUR_KEY', + center: DEFAULT_CENTER, + zoom: DEFAULT_ZOOM + }) + map.addControl(new maplibregl.NavigationControl()) + return map +} +``` + +## Layer Management + +### Layer Ordering + +Add layers in visual order (bottom to top): base fill, hover, selection, lines, labels. + +```typescript +// Add source first +map.addSource('regions', { + type: 'geojson', + data: geojsonData +}) + +// Base fill layer +map.addLayer({ + id: 'regions-fill', + type: 'fill', + source: 'regions', + paint: { + 'fill-color': '#627BC1', + 'fill-opacity': 0.8 + } +}) + +// Hover layer (filter-based for efficiency) +map.addLayer({ + id: 'regions-hover', + type: 'line', + source: 'regions', + paint: { + 'line-color': '#000000', + 'line-width': 2 + }, + filter: ['==', ['get', 'id'], ''] // Empty = hidden +}) + +// Selection layer +map.addLayer({ + id: 'regions-selection', + type: 'fill', + source: 'regions', + paint: { + 'fill-color': '#ff474c', + 'fill-opacity': 0.6 + }, + filter: ['==', ['get', 'id'], ''] +}) + +// Labels last (always on top) +map.addLayer({ + id: 'regions-labels', + type: 'symbol', + source: 'regions', + minzoom: 13, // Only show at higher zoom + layout: { + 'text-field': ['get', 'name'], + 'text-size': 14, + 'text-allow-overlap': true + }, + paint: { + 'text-color': '#ffffff', + 'text-halo-color': '#000000', + 'text-halo-width': 1 + } +}) +``` + +### Safe Layer Removal + +Always check existence before removing: + +```typescript +function removeLayerSafely(map: maplibregl.Map, layerId: string, sourceId: string) { + if (map.getLayer(layerId)) { + map.removeLayer(layerId) + } + if (map.getSource(sourceId)) { + map.removeSource(sourceId) + } +} +``` + +### Style Load Guard + +Wait for style to load before manipulating layers: + +```typescript +if (!map.isStyleLoaded()) { + map.once('style.load', () => { + addLayers(map) + }) + return +} +addLayers(map) +``` + +## Source Management + +### Dynamic GeoJSON Updates + +Use `setData()` to update source data: + +```typescript +function updateSourceWithCounts(map: maplibregl.Map, stats: Record) { + const source = map.getSource('regions') as maplibregl.GeoJSONSource + if (!source) return + + // Get current data and update properties + const currentData = source._data as GeoJSON.FeatureCollection + const updatedFeatures = currentData.features.map(feature => ({ + ...feature, + properties: { + ...feature.properties, + count: Number(stats[feature.properties?.id] ?? 0) + } + })) + + source.setData({ + type: 'FeatureCollection', + features: updatedFeatures + }) +} +``` + +## Event Handling + +### Click and Hover with Refs + +Store callbacks in refs to avoid stale closures: + +```typescript +const onFeatureClickRef = useRef(onFeatureClick) +useEffect(() => { + onFeatureClickRef.current = onFeatureClick +}, [onFeatureClick]) + +// In layer setup effect: +const handleClick = (e: maplibregl.MapLayerMouseEvent) => { + if (!e.features?.length) return + const feature = e.features[0] + onFeatureClickRef.current?.(feature.properties?.id, feature) +} + +const handleMouseMove = (e: maplibregl.MapLayerMouseEvent) => { + map.getCanvas().style.cursor = 'pointer' + if (e.features?.length) { + const id = e.features[0].properties?.id + map.setFilter('regions-hover', ['==', ['get', 'id'], id]) + } +} + +const handleMouseLeave = () => { + map.getCanvas().style.cursor = '' + map.setFilter('regions-hover', ['==', ['get', 'id'], '']) +} + +map.on('click', 'regions-fill', handleClick) +map.on('mousemove', 'regions-fill', handleMouseMove) +map.on('mouseleave', 'regions-fill', handleMouseLeave) + +// Cleanup +return () => { + map.off('click', 'regions-fill', handleClick) + map.off('mousemove', 'regions-fill', handleMouseMove) + map.off('mouseleave', 'regions-fill', handleMouseLeave) +} +``` + +### Background Click for Deselection + +```typescript +const handleMapBackgroundClick = (e: maplibregl.MapMouseEvent) => { + const features = map.queryRenderedFeatures(e.point, { + layers: ['regions-fill'] + }) + if (features.length === 0) { + onFeatureClickRef.current?.(null, null) // Clear selection + } +} + +map.on('click', handleMapBackgroundClick) +``` + +## Popup Management + +### Shared Popup Pattern + +For multi-map scenarios, share a popup ref to ensure only one popup is visible: + +```typescript +interface Props { + sharedPopupRef?: React.MutableRefObject +} + +function MapComponent({ sharedPopupRef }: Props) { + const localPopupRef = useRef(null) + const popupRef = sharedPopupRef || localPopupRef + + const createPopup = useCallback(( + map: maplibregl.Map, + lngLat: [number, number], + feature: GeoJSON.Feature + ) => { + // CRITICAL: Clear ref before removing old popup + // This prevents old popup's close handler from clearing selection + const oldPopup = popupRef.current + popupRef.current = null + oldPopup?.remove() + + const popup = new maplibregl.Popup({ + closeButton: true, + closeOnClick: false, // Handle manually + }) + .setLngLat(lngLat) + .setHTML(buildPopupHTML(feature)) + .addTo(map) + + popupRef.current = popup + + popup.on('close', () => { + if (popupRef.current === popup) { + onFeatureClickRef.current?.(null, null) + popupRef.current = null + } + }) + }, []) +} +``` + +### Popup HTML Builder + +```typescript +const buildPopupHTML = (feature: GeoJSON.Feature): string => { + const props = feature.properties + return ` +
+

${props?.name || 'Unknown'}

+

Value: ${props?.value?.toFixed(1) ?? 'N/A'}

+
+ ` +} +``` + +## Custom Markers + +### DOM Element Marker + +```typescript +function createPulsingMarker() { + const el = document.createElement('div') + el.style.cssText = ` + width: 50px; height: 50px; + display: flex; align-items: center; justify-content: center; + ` + + const dot = document.createElement('div') + dot.style.cssText = ` + width: 12px; height: 12px; + background-color: #3388ff; + border-radius: 50%; + ` + el.appendChild(dot) + + // Add animated rings + for (let i = 0; i < 3; i++) { + const ring = document.createElement('div') + ring.style.cssText = ` + position: absolute; + border: 2px solid #3388ff; + border-radius: 50%; + animation: pulse${i + 1} 2s infinite; + ` + el.appendChild(ring) + } + + return el +} + +const marker = new maplibregl.Marker({ + element: createPulsingMarker(), + anchor: 'center' +}) + .setLngLat([lng, lat]) + .addTo(map) +``` + +### SDF Icons for Dynamic Coloring + +Generate SDF images for icons that can be dynamically colored: + +```bash +npx image-sdf icon.png --spread 10 --downscale 4 --color white > icon.sdf.png +``` + +```typescript +// Load SDF image +const image = await map.loadImage('/icon.sdf.png') +map.addImage('my-icon', image.data, { sdf: true }) + +// Use in layer with dynamic color +map.addLayer({ + id: 'markers', + type: 'symbol', + source: 'points', + layout: { + 'icon-image': 'my-icon', + 'icon-size': 0.5, + }, + paint: { + 'icon-color': ['get', 'color'], // Color from feature property + } +}) +``` + +## Data-Driven Styling + +### Threshold-Based Colors + +```typescript +const OTP_THRESHOLDS = { good: 83, fair: 70 } +const OTP_COLORS = { good: '#10b981', fair: '#f59e0b', poor: '#ef4444' } + +const colorExpression: maplibregl.ExpressionSpecification = [ + 'case', + ['>=', ['get', 'pct_on_time'], OTP_THRESHOLDS.good], OTP_COLORS.good, + ['>=', ['get', 'pct_on_time'], OTP_THRESHOLDS.fair], OTP_COLORS.fair, + OTP_COLORS.poor, +] + +map.addLayer({ + id: 'segments', + type: 'line', + source: 'data', + paint: { + 'line-color': colorExpression, + 'line-width': 4, + } +}) +``` + +### Logarithmic Color Scale + +For skewed data distributions: + +```typescript +function getLogColorScale(minCount: number, maxCount: number) { + const colors = ['#e5f5e0', '#c7e9c0', '#a1d99b', '#74c476', '#41ab5d', '#238b45', '#006d2c'] + const stops: [number, string][] = [[0, '#f5f5f5']] // Zero = gray + + if (minCount === maxCount) { + stops.push([maxCount, colors[colors.length - 1]]) + return stops + } + + const logMin = Math.log(Math.max(0.1, minCount)) + const logMax = Math.log(maxCount) + const step = (logMax - logMin) / (colors.length - 1) + + colors.forEach((color, i) => { + const value = Math.round(Math.exp(logMin + step * i) * 100) / 100 + stops.push([value, color]) + }) + + return stops +} + +// Apply to layer +map.setPaintProperty('regions-fill', 'fill-color', [ + 'case', + ['==', ['to-number', ['get', 'count']], 0], '#f5f5f5', + ['interpolate', ['linear'], ['to-number', ['get', 'count']], ...colorStops.flat()] +]) +``` + +### Null/Zero Value Handling + +```typescript +const fillColorExpression = [ + 'case', + ['any', + ['==', ['typeof', ['get', 'count']], 'null'], + ['==', ['to-number', ['get', 'count']], 0] + ], + '#f5f5f5', // Gray for null/zero + colorExpression +] +``` + +## Selection and Dimming + +### Dim Non-Selected Features + +```typescript +useEffect(() => { + if (!map || !mapLoaded) return + + const opacityProp = isPointLayer ? 'circle-opacity' : 'line-opacity' + + if (selectedFeatureKey) { + map.setPaintProperty(LAYER_ID, opacityProp, [ + 'case', + ['==', ['get', 'id'], selectedFeatureKey], + 0.8, // Selected keeps full opacity + 0.2 // Non-selected dimmed + ]) + } else { + map.setPaintProperty(LAYER_ID, opacityProp, 0.8) + } +}, [selectedFeatureKey, mapLoaded]) +``` + +## Performance Patterns + +### Debounce Frequent Operations + +```typescript +function debounce void>(fn: T, delay = 300) { + let timeoutId: ReturnType + return function (this: any, ...args: Parameters) { + clearTimeout(timeoutId) + timeoutId = setTimeout(() => fn.apply(this, args), delay) + } +} + +// Usage: debounce fitBounds calls +const debouncedFitBounds = debounce((bounds: maplibregl.LngLatBounds) => { + map.fitBounds(bounds, { padding: 50 }) +}, 300) +``` + +### Query Features by Coordinates + +```typescript +function findFeatureAtCoordinates(map: maplibregl.Map, lngLat: maplibregl.LngLat) { + const features = map.queryRenderedFeatures( + map.project([lngLat.lng, lngLat.lat]), + { layers: ['regions-fill'] } + ) + return features[0] ?? null +} +``` + +## Issues & Resolutions + +| Issue | Cause | Solution | +|-------|-------|----------| +| **Popup conflicts between dual maps** | Each map creates its own popup | Use a shared `popupRef` passed as prop | +| **SDF image loading errors** | Using `createImageBitmap` instead of map API | Use `map.loadImage()` and access `.data` property | +| **Map fitting jank on resize** | `fitBounds` called too frequently | Debounce fitBounds calls | +| **Stale closure in callbacks** | React callback identity changes | Store callbacks in refs, update ref in separate effect | +| **Race conditions with style** | Manipulating layers before style loads | Guard with `isStyleLoaded()` + listen for `style.load` event | +| **Layer removal errors** | Removing non-existent layers | Always check `getLayer()` before `removeLayer()` | diff --git a/.agents/skills/frontend-shadcn/references/mcp-tools.md b/.agents/skills/frontend-shadcn/references/mcp-tools.md new file mode 100644 index 0000000..ec315a2 --- /dev/null +++ b/.agents/skills/frontend-shadcn/references/mcp-tools.md @@ -0,0 +1,113 @@ +# MCP Tools for Frontend Development + +Two MCP servers enhance frontend development workflows: **shadcn** for UI components and **context7** for library documentation. + +> **Note:** These MCP servers are bundled with this plugin via `.claude-plugin/marketplace.json`. They will be automatically available when using Claude Code with this plugin installed. + +## Research Workflow + +Before implementing new features or installing unfamiliar libraries: + +1. **Research** with context7 MCP - Get up-to-date documentation +2. **Understand** with shadcn MCP - Find component examples +3. **Install** with npm/npx - Add the dependency +4. **Implement** - Build the feature + +## shadcn MCP Server + +Use for discovering, understanding, and installing shadcn/ui components. + +### Search for Components + +``` +mcp__shadcn__search_items_in_registries(registries: ["@shadcn"], query: "sidebar") +``` + +Common searches: `sidebar`, `card`, `dialog`, `dropdown`, `table`, `form`, `button` + +### Get Component Examples + +``` +mcp__shadcn__get_item_examples_from_registries(registries: ["@shadcn"], query: "button-demo") +``` + +Pattern: Use `{component}-demo` to find usage examples. + +### Get Install Command + +``` +mcp__shadcn__get_add_command_for_items(items: ["@shadcn/sidebar"]) +``` + +Returns the exact `npx shadcn@latest add ...` command. + +### Workflow Example + +``` +1. mcp__shadcn__search_items_in_registries(registries: ["@shadcn"], query: "data table") +2. mcp__shadcn__get_item_examples_from_registries(registries: ["@shadcn"], query: "data-table-demo") +3. mcp__shadcn__get_add_command_for_items(items: ["@shadcn/table"]) +4. Run: npx shadcn@latest add table -y +``` + +### After Adding Components + +Use `mcp__shadcn__get_audit_checklist` to verify the component was added correctly and understand any additional setup needed. + +## context7 MCP Server + +Use for accessing up-to-date documentation for React, TypeScript, and other libraries. + +### Resolve Library ID + +First, get the context7 library ID: + +``` +mcp__context7__resolve-library-id(libraryName: "react-router", query: "routing") +``` + +Returns: `/remix-run/react-router` + +### Query Library Docs + +Then fetch specific documentation: + +``` +mcp__context7__query-docs( + libraryId: "/remix-run/react-router", + query: "useSearchParams hook usage" +) +``` + +### Common Libraries + +| Library | context7 ID | Common Topics | +|---------|-------------|---------------| +| React Router v7 | `/remix-run/react-router` | BrowserRouter, useLocation, useSearchParams | +| React | `/facebook/react` | hooks, context, suspense | +| Tailwind CSS | `/tailwindlabs/tailwindcss` | utilities, configuration | +| MapLibre GL | `/maplibre/maplibre-gl-js` | Map, markers, layers | + +### Workflow Example + +``` +1. mcp__context7__resolve-library-id(libraryName: "react-router", query: "routing") + → Returns: /remix-run/react-router + +2. mcp__context7__query-docs( + libraryId: "/remix-run/react-router", + query: "nested routes configuration" + ) + → Returns: Documentation on nested routing patterns +``` + +## When to Use Each + +| Scenario | Tool | +|----------|------| +| Adding a new UI component | shadcn MCP | +| Understanding component API | shadcn MCP (examples) | +| Learning React Router patterns | context7 MCP | +| Checking latest library syntax | context7 MCP | +| Finding component install command | shadcn MCP | +| Debugging library behavior | context7 MCP (mode: "info") | diff --git a/.agents/skills/frontend-shadcn/references/patterns.md b/.agents/skills/frontend-shadcn/references/patterns.md new file mode 100644 index 0000000..9421e92 --- /dev/null +++ b/.agents/skills/frontend-shadcn/references/patterns.md @@ -0,0 +1,309 @@ +# Development Patterns + +Patterns for building features in Vite + React + shadcn/ui + Tailwind + React Router v7 projects. + +## Project Structure + +``` +src/ +├── components/ +│ ├── ui/ # shadcn/ui components (auto-generated) +│ ├── AppShell.tsx # Main layout with header and outlet +│ └── AppSidebar.tsx # Navigation sidebar +├── pages/ # Route page components (organized by feature) +│ ├── dashboard/ +│ └── settings/ +├── hooks/ # Custom React hooks +├── lib/ +│ └── utils.ts # cn() helper and shared utilities +├── App.tsx # Route definitions +├── main.tsx # Entry point with BrowserRouter +└── index.css # Tailwind + shadcn theme variables +``` + +**Conventions:** + +- Use `pages/` directory for page components organized by feature area +- Use shadcn/ui components from `components/ui/` for consistent styling +- Utility functions go in `lib/` directory +- Path alias `@/*` maps to `./src/*` + +## Routing Architecture + +### React Router v7 Setup + +```typescript +// main.tsx - Entry point +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' +import './index.css' +import App from './App' + +createRoot(document.getElementById('root')!).render( + + + + + , +) +``` + +```typescript +// App.tsx - Route definitions +import { Routes, Route } from 'react-router' +import { SidebarProvider } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/AppSidebar' +import { AppShell } from '@/components/AppShell' + +function App() { + return ( + + + + + + }> + } /> + } /> + + + ) +} +``` + +### Key Imports + +All routing imports come from `react-router` (NOT `react-router-dom`): + +```typescript +import { + BrowserRouter, // Wrap app in main.tsx + Routes, Route, // Define routes in App.tsx + Link, // Navigation links + Outlet, // Render child routes + useLocation, // Get current path + useParams, // Get route params + useSearchParams, // Get/set query params +} from 'react-router' +``` + +### Nested Routes with Outlet + +```typescript +// Parent route renders layout + Outlet +}> + {/* Child routes render into Outlet */} + } /> + } /> + +``` + +## State Management + +### URL-Based State + +Use `useSearchParams` for state that should persist in the URL: + +```typescript +const [searchParams, setSearchParams] = useSearchParams() + +// Read value +const environment = searchParams.get('env') + +// Update value +setSearchParams({ env: 'production' }) +``` + +### Route-Based Logic + +Use `useLocation` for determining active states: + +```typescript +const location = useLocation() +const isActive = location.pathname === '/dashboard' +``` + +### Query Parameter Preservation + +Create a helper to preserve query params across navigation: + +```typescript +function createNavLink(path: string, searchParams: URLSearchParams) { + const params = searchParams.toString() + return params ? `${path}?${params}` : path +} +``` + +## Component Patterns + +### Layout System + +**AppShell** - Main content wrapper with header: + +```typescript +import { Outlet } from 'react-router' +import { SidebarTrigger } from '@/components/ui/sidebar' + +export function AppShell() { + return ( +
+
+ +
+
+
+ +
+
+ ) +} +``` + +**AppSidebar** - Navigation with active states: + +```typescript +import { useLocation, Link } from 'react-router' +import { + Sidebar, SidebarContent, SidebarGroup, + SidebarGroupContent, SidebarGroupLabel, SidebarHeader, + SidebarMenu, SidebarMenuButton, SidebarMenuItem, +} from '@/components/ui/sidebar' + +export function AppSidebar() { + const location = useLocation() + + return ( + + + App Name + + + + Navigation + + + + + + + Dashboard + + + + + + + + + ) +} +``` + +### Page Components + +Consistent structure with header and content sections: + +```typescript +export function DashboardPage() { + return ( +
+
+

Dashboard

+

Overview of your data

+
+ +
+ {/* Content cards */} +
+
+ ) +} +``` + +### Collapsible Sidebar Sections + +```typescript +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { ChevronDown } from 'lucide-react' + + + + + + + Section + + + + + + {/* Sub items */} + + + + +``` + +## UI Patterns + +### Common Layouts + +- **Two-column layouts** for complex forms +- **Card-based layouts** for dashboards and listings +- **Consistent navigation** with back links and breadcrumbs + +### Design Conventions + +**shadcn/ui components:** + +- Button, Input, Badge, Card, Tabs +- Sidebar, Dialog, Command, Table +- Tooltip, Dropdown Menu, Select + +**Tailwind patterns:** + +- `bg-muted`, `text-muted-foreground` - Muted backgrounds/text +- `space-y-4`, `gap-4` - Consistent spacing +- `flex-1` - Flexible sizing +- `border-b`, `border-r` - Borders + +### Color-Coded Status + +```typescript +// Status badges with semantic colors +Active // Primary color +Pending // Muted +Error // Red +Draft // Outlined +``` + +## Development Workflow + +### Before Starting Dev Server + +Check if a dev server is already running on port 5173: + +```bash +lsof -i :5173 +``` + +### Testing Changes + +1. Run the dev server and verify navigation works +2. Ensure query parameter preservation across route transitions +3. Verify active states for all sidebar menu items +4. Check conditional rendering for context-dependent sections + +### Package Management + +- Always use `npm install ` to add dependencies +- Never manually edit `package.json` to add packages +- Use `npx shadcn@latest add -y` for shadcn components + +### Theming Considerations + +- Project typically uses light theme only (configure dark mode if needed) +- CSS variables defined in `index.css` control theme colors +- Avoid adding `dark:` classes unless dark mode is implemented diff --git a/.agents/skills/frontend-shadcn/references/setup-guide.md b/.agents/skills/frontend-shadcn/references/setup-guide.md new file mode 100644 index 0000000..5c2349f --- /dev/null +++ b/.agents/skills/frontend-shadcn/references/setup-guide.md @@ -0,0 +1,572 @@ +# Vite + React + Tailwind + shadcn/ui + React Router v7 Stack Setup Guide + +This guide documents the complete process for bootstrapping a modern React application with this stack. Based on actual implementation experience and reference patterns. + +## Prerequisites + +- Node.js 22.x +- npm + +### Node.js Version Management with asdf + +Use [asdf](https://asdf-vm.com/) to manage Node.js versions consistently across the team: + +```bash +# Install Node.js plugin (one-time setup) +asdf plugin add nodejs + +# Set project Node.js version (creates .tool-versions file) +asdf set nodejs latest:22 +``` + +The `.tool-versions` file created by `asdf set` ensures all team members use the same Node.js version. + +## Step-by-Step Setup + +### 1. Initialize Vite Project + +```bash +npm create vite@latest . -- --template react-ts +npm install +``` + +**Commit:** `feat: initialize Vite project with React + TypeScript` + +This creates the base React 19 + TypeScript project with Vite 7.x. + +--- + +### 2. Install Tailwind CSS + +```bash +npm install tailwindcss @tailwindcss/vite +``` + +**Note:** Tailwind v4 uses `@tailwindcss/vite` plugin instead of PostCSS config. + +**Commit:** `build: install Tailwind CSS with @tailwindcss/vite plugin` + +--- + +### 3. Configure Tailwind and Path Aliases + +**Install @types/node for path resolution:** + +```bash +npm install -D @types/node +``` + +**Update `vite.config.ts`:** + +```typescript +import path from "path" +import tailwindcss from "@tailwindcss/vite" +import react from "@vitejs/plugin-react" +import { defineConfig } from "vite" + +export default defineConfig({ + plugins: [react(), tailwindcss()], + resolve: { + alias: { + "@": path.resolve(__dirname, "./src"), + }, + }, +}) +``` + +**Update `src/index.css`** (replace all content): + +```css +@import "tailwindcss"; +``` + +**Update `tsconfig.json`** (add compilerOptions): + +```json +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ], + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} +``` + +**Update `tsconfig.app.json`** (add to compilerOptions): + +```json +{ + "compilerOptions": { + // ... existing options ... + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + } +} +``` + +**Commit:** `config: configure Tailwind CSS and TypeScript path aliases` + +--- + +### 4. Initialize shadcn/ui + +```bash +npx shadcn@latest init --base-color neutral +``` + +This will: + +- Create `components.json` +- Update `src/index.css` with CSS variables and theme +- Create `src/lib/utils.ts` with `cn()` helper +- Install required dependencies (clsx, tailwind-merge, etc.) + +**Interactive prompts (if not using --base-color):** + +- Style: New York +- Base color: Neutral (or your preference) +- CSS variables: Yes + +**Commit:** `feat: initialize shadcn/ui with Neutral color scheme` + +--- + +### 5. Install React Router v7 + +**IMPORTANT:** React Router v7 uses the package name `react-router`, NOT `react-router-dom`. + +```bash +npm install react-router +``` + +**Commit:** `build: install React Router v7` + +--- + +### 6. Set Up React Router + +**Update `src/main.tsx`:** + +```tsx +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import { BrowserRouter } from 'react-router' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + + + , +) +``` + +**Update `src/App.tsx`:** + +```tsx +import { Routes, Route } from 'react-router' + +function App() { + return ( + + Home
} /> + + ) +} + +export default App +``` + +**Key imports from `react-router`:** + +- `BrowserRouter` - Wrap app in main.tsx +- `Routes`, `Route` - Define routes in App.tsx +- `Link` - Navigation links +- `useLocation` - Get current path +- `useParams` - Get route params +- `useSearchParams` - Get/set query params +- `Outlet` - Render child routes + +--- + +### 7. Add shadcn/ui Components + +Add components as needed: + +```bash +npx shadcn@latest add sidebar -y +npx shadcn@latest add card -y +npx shadcn@latest add button -y +npx shadcn@latest add collapsible -y +# etc. +``` + +The sidebar component includes many dependencies automatically: + +- button, separator, sheet, tooltip, input, skeleton + +**Commit each component addition separately or batch related ones.** + +--- + +### 8. Create App Shell Structure + +**Create `src/components/AppShell.tsx`:** + +```tsx +import { Outlet } from 'react-router' +import { SidebarTrigger } from '@/components/ui/sidebar' + +export function AppShell() { + return ( +
+
+ +
+
+
+ +
+
+ ) +} +``` + +**Create `src/components/AppSidebar.tsx`:** + +```tsx +import { useLocation, Link } from "react-router"; +import { + Sidebar, + SidebarContent, + SidebarGroup, + SidebarGroupContent, + SidebarGroupLabel, + SidebarHeader, + SidebarMenu, + SidebarMenuButton, + SidebarMenuItem, +} from "@/components/ui/sidebar"; +import { Home } from "lucide-react"; + +export function AppSidebar() { + const location = useLocation(); + + return ( + + + App Name + + + + + Navigation + + + + + + + Home + + + + + + + + + ); +} +``` + +**Update `src/App.tsx` with layout:** + +```tsx +import { Routes, Route } from 'react-router' +import { SidebarProvider } from '@/components/ui/sidebar' +import { AppSidebar } from '@/components/AppSidebar' +import { AppShell } from '@/components/AppShell' + +function App() { + return ( + + + + + + }> + } /> + } /> + + + ) +} +``` + +--- + +### 9. Optional: Add MapLibre GL JS + +```bash +npm install maplibre-gl +``` + +**Commit:** `build: install MapLibre GL JS` + +**Basic map component:** + +```tsx +import { useEffect, useRef } from 'react'; +import maplibregl from 'maplibre-gl'; +import 'maplibre-gl/dist/maplibre-gl.css'; + +export function Map() { + const mapContainer = useRef(null); + const map = useRef(null); + + useEffect(() => { + if (!mapContainer.current || map.current) return; + + map.current = new maplibregl.Map({ + container: mapContainer.current, + style: { + version: 8, + sources: { + osm: { + type: 'raster', + tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], + tileSize: 256, + }, + }, + layers: [{ id: 'osm', type: 'raster', source: 'osm' }], + }, + center: [-104.99, 39.74], + zoom: 10, + }); + + return () => { + map.current?.remove(); + map.current = null; + }; + }, []); + + return
; +} +``` + +**Important:** Import the CSS file for proper map styling. + +--- + +## File Structure After Setup + +``` +├── .claude/ +│ └── CLAUDE.md # Project documentation +├── src/ +│ ├── components/ +│ │ ├── ui/ # shadcn/ui components (auto-generated) +│ │ ├── AppShell.tsx +│ │ └── AppSidebar.tsx +│ ├── hooks/ +│ │ └── use-mobile.ts # From shadcn sidebar +│ ├── lib/ +│ │ └── utils.ts # cn() helper +│ ├── pages/ # Route components +│ ├── App.tsx +│ ├── main.tsx +│ └── index.css # Tailwind + shadcn theme +├── components.json # shadcn/ui config +├── tsconfig.json +├── tsconfig.app.json +├── vite.config.ts +└── package.json +``` + +--- + +## Key Patterns + +### Sidebar Navigation with Active State + +```tsx + + + + Label + + +``` + +### Collapsible Sidebar Sections + +```tsx +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/ui/collapsible"; + + + + + + + Section + + + + + + {/* Sub items */} + + + + +``` + +### Nested Routes with Outlet + +```tsx +// Parent route renders layout + Outlet +}> + {/* Child routes render into Outlet */} + } /> + } /> + +``` + +--- + +## VSCode Debugging + +Configure VSCode to debug the Vite dev server and React application. + +### Launch Configuration + +**Create `.vscode/launch.json`:** + +```json +{ + "version": "0.2.0", + "configurations": [ + { + "name": "Debug Dev Server", + "type": "node", + "request": "launch", + "cwd": "${workspaceFolder}", + "runtimeExecutable": "npx", + "runtimeArgs": ["vite"], + "skipFiles": ["/**", "**/node_modules/**"], + "console": "integratedTerminal" + }, + { + "name": "Debug in Chrome", + "type": "chrome", + "request": "launch", + "url": "http://localhost:5173", + "webRoot": "${workspaceFolder}/src", + "sourceMaps": true + } + ] +} +``` + +**Key patterns:** + +- "Debug Dev Server" runs Vite with Node.js debugging for server-side breakpoints +- "Debug in Chrome" launches Chrome with DevTools protocol for client-side React debugging +- `webRoot` maps to `src/` for accurate source map resolution +- Default Vite port is `5173` + +### Using the Debugger + +**For client-side React debugging:** + +1. Start the dev server: `npm run dev` +2. Select "Debug in Chrome" and press `F5` +3. Set breakpoints in React components (`.tsx` files) +4. Breakpoints pause execution in Chrome with VSCode controls + +**Commit:** `config: add VSCode debugging configuration` + +--- + +## Commit Message Format + +``` +: + +[Command: ] +[- Additional context bullet points] +``` + +**Types:** + +- `feat` - New feature +- `fix` - Bug fix +- `build` - Dependency changes +- `config` - Configuration changes +- `docs` - Documentation +- `chore` - Maintenance + +**Examples:** + +``` +feat: initialize Vite project with React + TypeScript + +Command: npm create vite@latest . -- --template react-ts && npm install +``` + +``` +config: configure Tailwind CSS and TypeScript path aliases + +Command: npm install -D @types/node +- Updated src/index.css with Tailwind import +- Added baseUrl and paths to tsconfig.json and tsconfig.app.json +- Updated vite.config.ts with tailwindcss plugin and path aliases +``` + +--- + +## Common Issues + +### TypeScript Path Alias Errors + +Ensure `baseUrl` and `paths` are in BOTH `tsconfig.json` and `tsconfig.app.json`. + +### Unused Variable Errors + +TypeScript is strict by default. Use `_varName` prefix for intentionally unused variables. + +### React Router Import Errors + +Use `react-router` not `react-router-dom` for v7. + +### Tailwind Classes Not Working + +Ensure `@import "tailwindcss";` is at the top of `index.css`. + +### shadcn Components Not Styled + +The `index.css` must have the CSS variables that `npx shadcn init` adds. + +--- + +## shadcn/ui Component Reference + +Common components to add: + +```bash +npx shadcn@latest add button card sidebar collapsible dropdown-menu avatar separator sheet tooltip input skeleton badge tabs dialog alert +``` + +Search for components and get examples using the bundled MCP tools. See [mcp-tools.md](mcp-tools.md) for usage details. diff --git a/.agents/skills/specops/SKILL.md b/.agents/skills/specops/SKILL.md new file mode 100644 index 0000000..6e409b9 --- /dev/null +++ b/.agents/skills/specops/SKILL.md @@ -0,0 +1,258 @@ +--- +name: specops +description: Spec-driven development workflow where specs are the source of truth, paired with a plan protocol for tracking work-in-flight as a micro-DAG. Use this skill whenever starting new features, planning implementation, writing specs, reviewing code against specs, working in or with a `plans/` directory, closing out a plan, or when the user mentions "spec", "specs/", "spec-first", "plans/", "plan protocol", "closeout commit", or asks how something should work or what to work on next. Also use when creating a new project that will use this development methodology, or when onboarding someone to a spec-driven codebase. +--- + +# Spec-Driven Development (SpecOps) + +## Philosophy + +Specs declare the complete desired state of the software. Implementation follows spec. All work begins with a spec update. + +This is not documentation-driven development (where docs describe what was built). This is specification-driven development — the spec describes what *should exist*, and the implementation is brought into conformance with it. The spec leads; the code follows. + +### Why this matters + +When agents or developers implement features, they make hundreds of micro-decisions. Without a spec, each decision is a guess that may or may not match the user's intent. With a spec, those decisions are already made — the implementer's job is execution, not invention. This is especially powerful for AI-assisted development where multiple agents may work on different parts of the same system. + +### The core loop + +``` +1. Spec change → propose what should be true +2. Accept → reviewer agrees on desired state +3. Implement → bring code into conformance +4. Verify → compare running software to spec +``` + +Code without a corresponding spec is unspecified behavior — it may exist for practical reasons, but nothing guarantees it. Spec without corresponding code is a known gap — track it. + +## How to write specs + +### The right level of detail + +Specs declare **what** must be true, not **how** to implement it. + +**Right level — declarative state + rules:** +> "Each row shows: from_stop_name, to_stop_name, pathway_mode label, completion fraction (populated field count / applicable field count). Sort: incomplete first, then alphabetical by from_stop_name. A pathway is 'on this level' when both its from_stop and to_stop share the same level_id." + +This tells an implementer *what* must be true without dictating *how*. It's testable — you can look at the screen and verify conformance. + +**Too vague — feature narratives:** +> "The task list shows pathways grouped by level with completion tracking." + +An agent reading this still has to make hundreds of decisions. Which fields? What sort order? What happens when data is missing? + +**Too detailed — implementation pseudocode:** +> "Query pathways WHERE from_stop.level_id == to_stop.level_id, LEFT JOIN field_notes, ORDER BY field_complete ASC, render each as a `
  • ` with..." + +This is just writing the code twice. The spec rots the moment implementation diverges. + +### What specs should cover + +- **Display rules** — what data appears and under what conditions +- **Data requirements** — where data comes from (API, local DB, derived) +- **Actions** — what the user can do and what each action causes +- **Navigation** — where you can go from here, where you came from +- **Business rules** — calculations, state machines, validation logic +- **API contracts** — request/response shapes, auth, error cases + +### What specs should NOT cover + +- **Visual design** — colors, spacing, fonts. That's wireframes + theme constants. +- **Widget/component decomposition** — how screens break into classes. Implementation decision. +- **Test cases** — tests derive from specs but aren't the spec. +- **Variable names, file paths** — implementation details that change freely. + +## Spec directory structure + +Organize specs by what they describe, not by when they were written: + +``` +specs/ +├── README.md # Workflow docs, directory layout, format conventions +├── architecture.md # Tech stack, project structure, foundational decisions +├── data-model.md # Schema, field definitions, relationships +├── api/ # One file per endpoint or endpoint group +│ ├── conventions.md # Auth, versioning, error envelope, content types +│ └── .md +├── screens/ # One file per screen/route +│ └── .md +└── behaviors/ # Cross-cutting rules that span multiple screens + └── .md +``` + +**screens/** — one file per screen/route. What the user sees and can do at that URL. + +**behaviors/** — rules that span multiple screens. When a screen spec says "completion fraction", the completion behavior spec defines how it's calculated. + +**api/** — the contract between client and server. Both sides implement to these specs. + +## Spec file templates + +### Screen spec + +```markdown +# Screen: + +## Route +The path/URL for this screen. + +## Data Requirements +What data this screen needs and where it comes from. + +## Display Rules +Declarative description of what appears and under what conditions. +This is what a reviewer checks the implementation against. + +## Actions +What the user can do and what each action causes. + +## Navigation +Where you can go from here, where you came from. +``` + +### Behavior spec + +```markdown +# Behavior: + +## Rule +The invariant or rule, stated declaratively. + +## Applies To +Which screens or components this behavior affects. + +## Details +Edge cases, calculations, timing, error handling. +``` + +### API spec + +```markdown +# API: + +## Endpoint +Method, path, auth requirements. + +## Request +Parameters, body shape with field types. + +## Response +Success body shape with field types. Error cases. + +## Notes +Caching, idempotency, offline implications. +``` + +## How agents use specs + +When implementing a feature or fixing a bug: + +1. **Read the relevant spec first.** Every screen, endpoint, and behavior has a spec file. Read it before writing code. +2. **The spec answers "what", not "how".** It says what data appears, what actions exist, what rules apply. It does not dictate widget trees or class hierarchies. +3. **If the spec is ambiguous, clarify the spec** — don't guess and code. Propose a spec amendment. +4. **If the spec is wrong, fix the spec** — don't work around it in code. +5. **When done, check your work against the spec** — every display rule, every action, every conditional. + +### When starting a new feature + +1. Write or update the spec files first +2. Get the spec reviewed and accepted +3. Then implement to match the spec +4. Verify the running software matches what the spec says + +### When fixing a bug + +1. Check if the behavior is specified — if so, the spec is right and the code is wrong +2. If the behavior is unspecified, decide: should the spec be updated to cover this case, or is the fix obvious enough to just code? +3. For non-trivial fixes, update the spec first so the fix is documented + +### When reviewing code + +Compare the implementation against the spec, not against your own ideas of how it should work. The spec is the acceptance criteria. + +## Plans: the work DAG that bridges specs to code + +Specs describe **state** (what should be true forever). Plans describe **motion** (how we're getting there next). Every chunk of feature work starts with a plan file in `plans/` declaring its scope, the specs it implements, its dependencies on other plans, and concrete validation criteria. The plan files together form a micro-DAG that is the project's working plan. + +Plans are temporal — once merged, they freeze as historical record. Their merged-PR links plus completed validation criteria are the project's working memory of what got built, how, and what was deferred. + +A plan's frontmatter: + +```yaml +--- +status: planned # planned | in-progress | done | blocked | cancelled +depends: [other-plan-slug] +specs: # spec files in THIS repo that this plan implements + - specs/architecture.md +upstream-specs: # (optional) specs in OTHER repos this plan consumes + - other-repo:specs/behaviors/transactions.md +issues: [128] +pr: 42 # set at closeout +--- +``` + +A plan's body has a fixed template: **Scope**, **Implements**, **Approach**, **Validation** (load-bearing checkbox list — converts "in-progress" to "done"), **Risks / unknowns**, **Notes** (populated at closeout), **Follow-ups** (populated at closeout). + +The full protocol — frontmatter schema, body template, status lifecycle, the closeout-commit ritual, the Follow-ups taxonomy (Issue / Deferred to plan / Tracked as / None), and the deferral-absorption rule — is in [references/plans-protocol.md](references/plans-protocol.md). Read it before authoring or closing out a plan. + +### Querying the plans DAG + +`plans/README.md` deliberately does **not** maintain a hand-drawn DAG or a status table — they'd rot the moment someone forgot to update them. Two scripts query the authoritative frontmatter on demand: + +- **`scripts/plans-dag `** — emits a Mermaid graph of the DAG with nodes styled by status. Add `--fence` to wrap in a Markdown code fence; `--direction LR` for horizontal layout. +- **`scripts/plans-next `** — prints plans ordered by readiness. Ready plans first (sorted so the plan that unblocks the most downstream work appears first), then blocked plans with their unfinished deps called out. + +Both are zero-dependency Node.js scripts — usable the moment the skill is checked out. See `scripts/lib/plans.js` for the shared parser if extending. + +## Setting up spec-driven development in a new project + +1. Create a `specs/` directory at the project root +2. Write `specs/README.md` documenting the workflow and directory layout +3. Write `specs/architecture.md` with foundational tech decisions +4. For each feature area, create the relevant spec files before coding +5. Reference the specs directory in your project's CLAUDE.md or README +6. Establish the convention: PRs that add features should include spec updates +7. Set up the spec drift auditor (see below) +8. Set up the plans protocol (see below) + +### Setting up the spec drift auditor + +The spec drift auditor is a specialized agent that does an exhaustive comparison of your `specs/` directory against the actual implementation, producing tables of gaps, undocumented implementations, and conflicts. To set it up in a project: + +1. **Copy the agent definition** from this skill's `references/spec-drift-auditor.md` into your project at `.claude/agents/spec-drift-auditor.md`. Customize the "Methodology" phases to match your project's structure — for example, update Phase 3 ("Inventory the Implementation") to list the specific directories and key files in your codebase (source directories, migration paths, frontend code, infrastructure files, etc.). + +2. **Copy the command definition** from this skill's `references/audit-spec-drift.md` into your project at `.claude/commands/audit-spec-drift.md`. This gives users a `/audit-spec-drift` slash command that launches the auditor agent. + +3. **Reference in CLAUDE.md** — add a note to the project's CLAUDE.md mentioning the auditor is available, e.g.: + + ``` + ## Spec Drift Auditing + Run `/audit-spec-drift` to launch a comprehensive audit comparing specs/ against the implementation. + ``` + +The reference files are located at: + +- `references/spec-drift-auditor.md` — the agent definition (goes in `.claude/agents/`) +- `references/audit-spec-drift.md` — the command definition (goes in `.claude/commands/`) + +Note: the auditor checks the `specs:` field of plan files and the `specs/` tree. It does **not** check `upstream-specs:` (those are owned by other repos by design — see the plans protocol). + +### Setting up the plans protocol + +The plans protocol gives a project a structured way to track work-in-flight without it rotting. To set it up: + +1. **Create the `plans/` directory** at the project root. +2. **Write `plans/README.md`** that briefly states what plans are (motion vs state) and points at [references/plans-protocol.md](references/plans-protocol.md) for the full spec. Resist the urge to maintain a DAG drawing or status table inside it — both rot. The scripts below regenerate that view on demand. +3. **Document the protocol in the project's CLAUDE.md** — add a Plans section summarizing the workflow (statuses, closeout commit, Follow-ups taxonomy) and link to `plans/README.md`. The reference doc in this skill is the canonical source; the project CLAUDE.md just needs enough for someone working in the repo to find their way without re-reading the whole reference. +4. **Run the scripts in-place from the skill.** `scripts/plans-dag` and `scripts/plans-next` are invoked from the skill's install directory against the project's plans/ directory — there's nothing to copy, symlink, or vendor. Claude resolves the skill path automatically when specops triggers; the invocation from the project root is just `/scripts/plans-dag plans/` (or `plans-next`). Both scripts are zero-dep Node.js so they work the moment the skill is checked out. +5. **Establish the convention** in the team: a new chunk of work starts with a plan file; the last commit before merge flips it to `done`. Quick-reference checklist for closeout is in [references/plans-protocol.md](references/plans-protocol.md#quick-checklist-for-a-closeout-pr). + +## Keeping specs alive + +Specs rot when they diverge from reality. Prevent this by: + +- Making spec updates part of the PR process — if the code changes behavior, the spec should change too +- Periodically auditing specs against the running software +- Treating spec-code divergence as a bug, not technical debt +- Having agents read specs before implementing, which creates a natural feedback loop when specs are wrong diff --git a/.agents/skills/specops/references/audit-spec-drift.md b/.agents/skills/specops/references/audit-spec-drift.md new file mode 100644 index 0000000..39893cb --- /dev/null +++ b/.agents/skills/specops/references/audit-spec-drift.md @@ -0,0 +1 @@ +Use the spec-drift-auditor agent to do a comprehensive audit of how well the codebase implementation matches the specs/ directory. Report unimplemented spec features, undocumented implementation details, and conflicts between specs and code. diff --git a/.agents/skills/specops/references/plans-protocol.md b/.agents/skills/specops/references/plans-protocol.md new file mode 100644 index 0000000..1aa8c76 --- /dev/null +++ b/.agents/skills/specops/references/plans-protocol.md @@ -0,0 +1,341 @@ +# The Plan Protocol + +If `specs/` is the architecture document (timeless: what should be true), `plans/` is the project plan (motion: how we get there). Each plan declares a scope, the specs it implements, its dependencies, and concrete validation criteria. Together, the plan files form a **micro-DAG of work** that bridges specs to running code. + +This doc defines the protocol. It is meant to be portable across projects that adopt the [specops](../SKILL.md) workflow. + +## What plans are — and are not + +Plans are temporal. They describe a chunk of work to be done now: scope, dependencies, approach, and how we'll know it's finished. Once merged, a plan freezes — its merged-PR link plus its completed validation criteria become the project's working memory of what got built, how, and what was left for later. + +| Plans are | Plans are not | +| ---------------------------------------------- | -------------------------------------------------------------- | +| A micro-DAG of work bridging specs → code | Specs (which are timeless, frozen by review, not by merge) | +| One scope-bounded chunk of work per file | Commits (a plan produces several commits, usually one PR) | +| Dependency-aware (`depends:` is load-bearing) | Tickets (flatter, more granular, live in your issue tracker) | +| Validated by checkbox criteria at close | Roadmap entries ("ship X by Y" — that's a different artifact) | + +Specs answer **what must be true**. Plans answer **how we're getting there next**. + +## Directory layout + +``` +plans/ +├── README.md # Protocol index — usually just points to this reference +└── .md # One file per plan +``` + +- **Location**: `plans/` at repo root. +- **Filenames**: kebab-case slugs, descriptive (e.g., `storage-foundation.md`, `github-oauth.md`, `auth-jwt-substrate.md`). No numeric prefixes — the slug *is* the plan ID, referenced by `depends:` entries in other plans. +- **No maintained DAG drawing or status table** in `plans/README.md`. The per-plan frontmatter is the single source of truth for both status and graph shape; a redrawn DAG or status dashboard would rot the moment anyone forgot to update both. Use the [`plans` script](#visualization-and-status) to query both on demand from the authoritative frontmatter. For ad-hoc queries: + - In flight: `grep -l '^status: in-progress' plans/*.md` + - Done: `grep -l '^status: done' plans/*.md` + - Trace dependencies: `grep '^depends:' plans/*.md` or open the plan whose downstream you're tracing + +## Frontmatter schema + +Every plan begins with YAML frontmatter: + +```yaml +--- +status: planned # planned | in-progress | done | blocked | cancelled +depends: [other-plan-slug, another-plan-slug] +specs: # spec files in THIS repo that this plan implements + - specs/architecture.md + - specs/behaviors/storage.md +upstream-specs: # (optional) specs in OTHER repos this plan consumes + - other-repo:specs/behaviors/transactions.md +awaits: # (optional) external blockers — see "External blockers" below + - "other-org/library@v1.0 — consumed via Foo / Bar / openThing API" +issues: [128, 129] # (optional) related issue numbers +pr: 42 # (optional) merged PR — added at closeout, knowable only after `gh pr create` +--- +``` + +Field semantics: + +- **`status`** — see [Status lifecycle](#status-lifecycle) below. +- **`depends`** — list of plan slugs (filenames without `.md`) that must be `done` before this plan can start. Empty list (`[]`) for plans with no prerequisites. Update mid-stream if a new prerequisite is discovered. +- **`specs`** — specs in *this* repo that this plan implements. The [spec-drift auditor](./spec-drift-auditor.md) treats these as a contract: the implementation must match. +- **`upstream-specs`** *(optional)* — specs in *other* repos that this plan consumes. Format: `:`. **Informational only** — the spec-drift auditor does *not* check these; we don't promise to implement specs we don't own. The distinction exists so dangling references to dependency specs don't show up as drift findings. +- **`awaits`** *(optional)* — list of external blockers. Each entry is a one-line pointer + reason for an upstream release, vendor delivery, partner decision, or anything else not represented by an in-repo plan. Distinct from `depends:` (in-repo plan slugs) and `upstream-specs:` (specs owned by another repo). See [External blockers](#external-blockers). +- **`issues`** *(optional)* — related issue numbers in your tracker. +- **`pr`** *(optional)* — the PR that closed the plan. Set only when `status: done`. + +## Body template + +```markdown +# Plan: + +## Scope +Bounded statement of what's in and what's explicitly out. Out-of-scope items should +point to where they will land (other plan, future spec, deferred). + +## Implements +Bullet list mapping each spec file to the specific behaviors/endpoints implemented. +Use sub-headings for "Own specs" / "Upstream specs" when both are present. + +## Approach +Step-by-step strategy. Code sketches, key algorithms, module layout, interfaces. +Detailed enough that someone else could pick it up; not so detailed it's just the +code written twice. + +## Validation +- [ ] Concrete, testable criterion 1 +- [ ] Concrete, testable criterion 2 +- [ ] ... +The load-bearing section. Converts `in-progress` to `done`. Each box flips to `[x]` +only when verified. Never silently rewrite a criterion to match what was built — +that's an amendment in its own earlier commit. + +## Risks / unknowns +- **Named risk** — short description and how it'll be mitigated or watched. +Not prescriptive; helps the implementer know what to watch for. + +## Notes +(Populated at closeout. Non-actionable carry-forwards: decisions made, gotchas +discovered, dependency surprises, version pins worth remembering.) + +## Follow-ups +(Populated at closeout. See "Follow-ups taxonomy" below.) +``` + +## Status lifecycle + +``` +planned ──► in-progress ──► done (frozen) + │ + └──► blocked (waiting on a prerequisite or external event) + └──► cancelled (work abandoned) +``` + +Transitions are commits with specific message conventions: + +- **Start work**: `chore(plans): mark <slug> in-progress` + - Skippable for tiny plans — going straight to `done` at the end is fine. +- **Close**: `chore(plans): mark <slug> done (PR #<n>)` (see [The closeout commit](#the-closeout-commit) below). + +`blocked` and `cancelled` are edge cases — most plans go directly from `planned` to `done`. + +**Parallel work**: multiple plans can be `in-progress` simultaneously across contributors. One plan per contributor at a time is the norm — keeps each plan's branch and PR coherent — but the protocol doesn't forbid more. + +**Splitting a plan**: if a `planned` plan grows too big, rename it and add the new plan(s) with `depends:` updated to reflect the new partitioning. Do this as its own PR (not mid-implementation) so the DAG change is reviewable on its own. If the plan is already `in-progress`, finish or abandon it before splitting — don't restructure under your own feet. + +<a id="external-blockers"></a> + +## External blockers (`awaits:`) + +A plan often depends on something outside its repo's DAG — an upstream library release, a partner sign-off, a vendor delivery, a customer decision. Capture each as a one-line entry in the optional `awaits:` frontmatter field: + +```yaml +awaits: + - "JarvusInnovations/gitsheets@v1.0 — consumed via Repository / Sheet / openStore" + - "https://github.com/example-org/api-vendor/issues/42 — need their decision on the auth header format before we can wire the client" + - "staff decision on Slack channel rename (#governance)" +``` + +Each entry is free-form text — a URL, a `repo@tag`, "vendor X delivery Q3 2026", or any other pointer specific enough that a future reader (or `grep`) can chase it. Each entry should carry enough context to make the block self-explanatory; a trailing em-dash clause for the *why* keeps entries useful when grep surfaces them out-of-context. Keep entries short; one line each. + +### Why `awaits:` exists alongside `depends:` and `upstream-specs:` + +| Field | Points at | DAG-traversal | Auditor reads | +| ---------------- | ---------------------------------------------- | :-----------: | :-----------: | +| `depends` | In-repo plan slugs that must be `done` first | ✓ | n/a | +| `upstream-specs` | Specs in another repo this plan consumes | — | — | +| `awaits` | Anything else external — releases, decisions, vendor deliveries | — | — | + +These three fields are **orthogonal axes**, not mutually exclusive categories. The same upstream relationship can appear in two fields at once. Canonical example: a plan that consumes gitsheets carries both `upstream-specs: [gitsheets:specs/behaviors/transactions.md, ...]` (the specs we'll implement against) *and* `awaits: ["JarvusInnovations/gitsheets@v1.0 — ..."]` (the release we're waiting for). The first describes what we promise to conform to; the second describes what has to ship before we can. Both are true simultaneously. + +Without `awaits:`, external blockers had to live in prose inside the body — invisible to scripts, ungreppable across the project, and at risk of being lost when the plan freezes. + +### Relationship to `status` + +`awaits:` is the *structural fact* of an external block. `status:` is the *lifecycle state* of the plan. They're independent: + +- `status: planned` + non-empty `awaits:` — we haven't started; we already know an external block exists. Work may begin once the block clears, or partially if some of the plan's scope is independent of the block. +- `status: in-progress` + non-empty `awaits:` — we've done what we can without the awaited thing; the rest of the plan is paused until it lands. +- `status: blocked` + non-empty `awaits:` — work cannot proceed at all; `awaits:` says why. +- `status: blocked` + empty `awaits:` — smell. A blocked plan should always say what's blocking it (either via `awaits:`, or unfinished `depends:`, or both). `plans-next` and `plans-dag` emit a stderr warning when they see `status: blocked` with no `awaits:` *and* no unfinished `depends:` — the strongest form of this smell, where nothing structural explains the block. + +### Resolution + +When the awaited thing happens, delete the matching entry from `awaits:`. Git history is the audit trail. If a plan closes with entries still present, add a Notes entry explaining why (rare — usually means the plan worked around the block, or the block stopped being load-bearing). + +### How the scripts treat it + +`plans-next` treats `awaits:` as a blocker independent of `depends:`. A plan with non-empty `awaits:` never appears under "Ready to work on" — it surfaces under a separate **Awaiting external** section, with each `awaits:` entry called out. If the plan is *also* blocked by unfinished `depends:`, both reasons are shown. + +`plans-dag` styles nodes with non-empty `awaits:` distinctly (dashed border) so the external dependency is visible in the rendered graph. + +## The closeout commit + +The last commit on the implementation branch, before merge, does **five things in one shot** under the message `chore(plans): mark <slug> done (PR #<n>)`: + +1. **Frontmatter**: flip `status` to `done`, add `pr: <PR number>` (knowable once `gh pr create` returns). +2. **Validation checklist**: flip each `- [ ]` to `- [x]` for criteria you actually verified. If a criterion can't be verified at merge time (depends on a downstream plan, requires production deploy, etc.), **leave it unchecked** and add a one-line Notes entry explaining why and where it'll close out. **Never silently rewrite a criterion to match what you ended up doing** — that's a plan amendment in its own earlier commit. +3. **Notes** section: non-actionable carry-forwards — decisions, surprises, gotchas, learnings. Things future-you would want to know. +4. **Follow-ups** section: actionable items that didn't ship with this plan (see taxonomy below). +5. **Any downstream plans referenced by `Deferred to`** entries get edited in the *same commit* to absorb the deferral (see [Follow-ups taxonomy](#follow-ups-taxonomy)). + +After merge: the plan is frozen. Historical record, no further edits. + +## Follow-ups taxonomy + +Each Follow-up entry takes one of four shapes. Pick deliberately — the shape encodes who owns the work next. + +### `Issue [#N](link) — short description` + +Use when the work is actionable but **not owned by any planned-or-in-progress plan**. File the issue first (e.g., via your issue tracker CLI) and link it. Example: + +```markdown +- Issue [#48](https://github.com/org/repo/issues/48) — add CI check that EnvSchema and `.env.example` stay in lockstep +``` + +### `Deferred to [`<plan>`](<plan>.md) — short description` + +Use when an **unstarted (`status: planned`) downstream plan** should own the work. **The same closeout commit must also edit that downstream plan to absorb the deferral** — typically a new bullet under Approach and a new criterion under Validation, cross-linked back to the deferring plan. + +Example (in the closing plan's Follow-ups): + +```markdown +- Deferred to [`api-skeleton`](api-skeleton.md) — `.env.example` lands when `EnvSchema` is introduced +``` + +…and in the *same* commit, `api-skeleton.md` grows: + +```markdown +## Approach +… +Ship a `.env.example` file at the repo root that enumerates every `EnvSchema` field… + +## Validation +… +- [ ] `.env.example` exists at the repo root with one entry per `EnvSchema` field (deferred from [`workspace`](workspace.md)) +``` + +**Why the absorption rule**: without it, "Deferred to <plan>" is just a pointer that doesn't oblige anyone to do anything — the downstream plan can ship without ever absorbing the deferred work, and the deferral rots in place. Requiring same-commit absorption converts the pointer into a binding commitment. + +If the downstream plan is already `in-progress` or `done`, use the `Issue` shape instead. **Never modify a plan that's actively being implemented or already frozen.** + +### `Tracked as: <free-form pointer>` + +Use for anything that's neither an issue nor another plan — waiting on community input, a vendor response, a design decision pending review. Example: + +```markdown +- Tracked as: waiting on upstream maintainer to ship v2.1 (see thread linked in #channel) +``` + +### `None.` + +Use when there are no follow-ups. Be explicit. A future reader can see the section was considered, not just absent. + +```markdown +## Follow-ups + +None. +``` + +## Relationship to `specs/` + +Plans implement specs. Concretely: + +- **A plan's `specs:` field lists what it implements.** The implementation must match those specs — the spec-drift auditor will hold it accountable. +- **Specs come first.** If you realize a spec needs to change mid-plan, the spec change is its own PR before the plan continues. Don't quietly drift the spec to match what you ended up coding. +- **Plans don't propose specs.** Specs are decided through their own review process. A plan's job is execution against an already-agreed-on state. +- **The spec-drift auditor reads `specs/`, never `plans/`.** Plans rot fast and are expected to. Specs are the auditable source of truth. + +## After spec-complete + +Plans don't go away once the initial DAG completes. They become the standing workflow for every future feature: + +1. Update or add the relevant specs (own PR). +2. Add a new plan declaring how to bring code to the spec. +3. Implement, close out, freeze. + +Completed plans stay as historical record. Their merged-PR links plus completed-validation criteria are the project's working memory. + +<a id="visualization-and-status"></a> + +## Visualization and status + +`plans/README.md` deliberately does not maintain a hand-drawn DAG or status table — they'd rot the moment someone forgot to update them. Instead, two scripts query the authoritative frontmatter on demand: + +### `plans-dag` — DAG visualization + +```sh +# print Mermaid syntax to stdout +scripts/plans-dag plans/ + +# wrap in a code fence ready to paste into a Markdown doc +scripts/plans-dag plans/ --fence + +# horizontal layout +scripts/plans-dag plans/ --direction LR +``` + +Reads each plan's `status`, `depends`, `pr`, and `awaits` fields and emits a Mermaid `graph` with nodes styled by status (`planned`, `in-progress`, `done`, `blocked`). Plans with non-empty `awaits:` get a dashed border so external blockers are visible at a glance. Use it in code review, in stand-ups, or pasted (and dated) into a wiki snapshot. Don't commit a static rendering into `plans/README.md` — the next status flip will make it lie. + +### `plans-next` — what to work on next + +```sh +# show plans that are ready to start (deps met) and plans blocked on unfinished deps +scripts/plans-next plans/ + +# include in-progress plans in the listing +scripts/plans-next plans/ --include-in-progress +``` + +Skips `done` and `cancelled` plans, then lists what's left in three sections: + +- **Ready** — `depends:` all `done` AND `awaits:` empty. +- **Awaiting external** — `awaits:` non-empty (regardless of `depends:` state). Each `awaits:` entry is shown beneath the plan so the blocker is clear. +- **Blocked by unfinished deps** — `depends:` has unfinished entries AND `awaits:` is empty. Each unfinished dep is called out. + +Within each section, plans are topologically sorted so the plans that unblock the most downstream work appear first. + +Both scripts share `lib/plans.js` — a frontmatter parser and DAG walker. See [`scripts/`](../scripts/) in this skill. + +## Worked example: closing a plan + +Suppose `workspace.md` ships scaffolding and discovers `.env.example` is naturally owned by the future `api-skeleton` plan. At PR-merge time, the closeout commit does this in one shot: + +```diff +--- a/plans/workspace.md ++++ b/plans/workspace.md +@@ -1,7 +1,8 @@ + --- +-status: planned ++status: done + depends: [] + specs: + - specs/architecture.md + issues: [] ++pr: 9 + --- +@@ Validation +-- [ ] `git clone … && npm install && npm run dev` works on a fresh machine ++- [x] `git clone … && npm install && npm run dev` works on a fresh machine +… (each verified box flipped) +@@ end of file ++## Notes ++ ++- ESM-only dep landmines, dedupe quirk in Vite — workarounds documented inline. ++- Pinned versions as of cutover commit: <list>. ++ ++## Follow-ups ++ ++- Deferred to [`api-skeleton`](api-skeleton.md) — `.env.example` lands when `EnvSchema` is introduced. +``` + +…and in the *same* commit, `api-skeleton.md` grows a bullet in Approach and a Validation criterion absorbing the deferral. One commit, message `chore(plans): mark workspace done (PR #9)`. After merge, `workspace.md` is frozen forever. + +## Quick checklist for a closeout PR + +- [ ] Frontmatter: `status: done`, `pr: <n>` added +- [ ] Any `awaits:` entries either resolved (deleted) or explicitly justified in Notes (rare — usually means the block stopped being load-bearing or was worked around) +- [ ] Every Validation box reflects reality (`[x]` only if verified; unverified stays `[ ]` with a Notes entry) +- [ ] Notes section populated (decisions, gotchas, version pins — not action items) +- [ ] Follow-ups section populated (Issue / Deferred to plan / Tracked as / None) +- [ ] Every `Deferred to <plan>` has an accompanying edit to that downstream plan, in the same commit, and the downstream plan is still `planned` +- [ ] Commit message: `chore(plans): mark <slug> done (PR #<n>)` +- [ ] No silent rewrites of Validation criteria to match what was built diff --git a/.agents/skills/specops/references/spec-drift-auditor.md b/.agents/skills/specops/references/spec-drift-auditor.md new file mode 100644 index 0000000..82e5cfc --- /dev/null +++ b/.agents/skills/specops/references/spec-drift-auditor.md @@ -0,0 +1,86 @@ +--- +name: spec-drift-auditor +description: "Use this agent when you need a comprehensive audit of how well the codebase implementation matches the specs/ directory. This includes finding unimplemented spec features, undocumented implementation details, and conflicts between specs and code.\n\nExamples:\n\n<example>\nContext: The user wants to check if the codebase is in sync with specs after a series of changes.\nuser: \"Let's audit the specs against the implementation\"\nassistant: \"I'll use the spec-drift-auditor agent to do a thorough comparison of specs/ against the entire codebase.\"\n<commentary>\nSince the user wants a comprehensive spec-vs-implementation audit, use the Agent tool to launch the spec-drift-auditor agent.\n</commentary>\n</example>\n\n<example>\nContext: The user is about to start a new feature and wants to understand current spec coverage.\nuser: \"Before we start on the new dashboard feature, can you check if there's any drift between our specs and what's actually implemented?\"\nassistant: \"Great idea — let me launch the spec-drift-auditor agent to do a full audit before we begin.\"\n<commentary>\nSince the user wants to understand spec-implementation alignment before starting new work, use the Agent tool to launch the spec-drift-auditor agent.\n</commentary>\n</example>\n\n<example>\nContext: After a large refactor, the user wants to verify nothing was missed.\nuser: \"We just finished the auth module refactor. Are the specs still accurate?\"\nassistant: \"Let me run the spec-drift-auditor to check for any gaps or conflicts after the refactor.\"\n<commentary>\nSince the user wants to verify spec accuracy after a refactor, use the Agent tool to launch the spec-drift-auditor agent.\n</commentary>\n</example>" +tools: Bash, Glob, Grep, Read, WebFetch, WebSearch +model: sonnet +color: pink +--- + +You are an elite software specification auditor with deep expertise in spec-driven development, API design, database schema analysis, and full-stack application architecture. You have an obsessive attention to detail and a talent for systematically comparing documentation against implementation to surface every discrepancy, no matter how subtle. + +## Your Mission + +Conduct an exhaustive audit comparing everything in `specs/` against the actual implementation in the repository. You will produce three clearly formatted tables identifying all gaps, undocumented implementations, and conflicts. + +## Methodology + +### Phase 1: Inventory the Specs + +1. Start by reading `specs/README.md` to understand the spec index and organization. +2. Read EVERY file in `specs/` thoroughly. For each spec, extract: + - Entities/models defined (fields, types, constraints) + - API endpoints (routes, methods, request/response shapes) + - Database tables and columns + - Business logic rules and workflows + - Frontend views and components + - Infrastructure requirements + - Search behavior, validation rules + - Any other specified behavior + +### Phase 2: Review Commits Since Last Release + +1. Identify the most recent release tag and review all commits since then: + - Run `git tag --sort=-v:refname | head -1` to find the latest release tag. + - Run `git log --oneline <that-tag>..HEAD` to list all subsequent commits. + - Run `git show --stat` for each commit (or `git diff <that-tag>..HEAD`) to understand what changed. + - Pay special attention to implementation changes that may have introduced drift without corresponding spec updates — these are the highest-signal findings. + - Note any patterns (e.g., a migration changed a column type but the spec still documents the old type). +2. If no release tags exist, skip this phase and note it in the report. + +### Phase 3: Inventory the Implementation + +1. Systematically examine the implementation: + - Read source files to understand actual API routes, entity definitions, business logic + - Read migration or schema files to understand actual database schema + - Read frontend code for components and views + - Check configuration files for dependencies and scripts + +### Phase 4: Cross-Reference and Analyze + +1. For every item defined in specs, check if it exists in implementation and whether it matches. +2. For every significant implementation detail, check if it's covered in specs. +3. Identify conflicts where both exist but disagree. + +## Output Format + +Produce your report with these three tables: + +### Table 1: Specified but Not Implemented + +| Spec File | Item | Description | Proposed Resolution | +|-----------|------|-------------|--------------------| + +For each row, clearly identify what the spec says should exist, where it should be, and recommend either implementing it or updating the spec to remove it (with reasoning). + +### Table 2: Implemented but Not Specified + +| Implementation File | Item | Description | Proposed Resolution | +|--------------------|------|-------------|--------------------| + +For each row, identify the undocumented implementation, what it does, and recommend either adding it to the appropriate spec or removing/deprecating it (with reasoning). + +### Table 3: Spec-Implementation Conflicts + +| Spec File | Implementation File | Item | Spec Says | Implementation Does | Proposed Resolution | +|-----------|---------------------|------|-----------|--------------------|-----------------| + +For each row, clearly describe the discrepancy and recommend which side should be updated (with reasoning based on which seems more correct/intentional). + +## Important Guidelines + +- **Be exhaustive.** Check every endpoint, every field, every table column, every parameter. Do not sample — audit everything. +- **Be precise.** Reference specific file paths and line numbers where possible. Quote spec text and code when describing conflicts. +- **Be practical.** Your proposed resolutions should consider what seems intentional vs accidental. If implementation has evolved beyond the spec, usually the spec needs updating. If a spec feature was clearly planned but not built, flag it for implementation. +- **Distinguish severity.** Note when a gap is trivial (e.g., slightly different field name casing) vs significant (e.g., entire endpoint missing). +- **Group logically.** Within each table, group items by domain/module for readability. +- **Include a summary** at the top with counts: X items specified but not implemented, Y items implemented but not specified, Z conflicts found. diff --git a/.agents/skills/specops/scripts/lib/plans.js b/.agents/skills/specops/scripts/lib/plans.js new file mode 100644 index 0000000..8b843b6 --- /dev/null +++ b/.agents/skills/specops/scripts/lib/plans.js @@ -0,0 +1,217 @@ +// Shared helpers for the plans-* scripts. +// +// Parses YAML frontmatter from plan files using narrow regexes — only the +// fields the plan protocol defines (status, depends, specs, upstream-specs, +// issues, pr). Keeping this dependency-free is a deliberate constraint: the +// scripts must work the moment the skill is checked out, with no `npm install`. + +const fs = require('fs'); +const path = require('path'); + +const VALID_STATUSES = ['planned', 'in-progress', 'done', 'blocked', 'cancelled']; + +function readFrontmatter(filePath) { + const text = fs.readFileSync(filePath, 'utf8'); + const match = text.match(/^---\r?\n([\s\S]*?)\r?\n---/); + if (!match) return null; + return match[1]; +} + +function parseScalar(block, key) { + const re = new RegExp(`^${key}:\\s*(.+?)\\s*$`, 'm'); + const m = block.match(re); + if (!m) return null; + return m[1].replace(/^["']|["']$/g, ''); +} + +function parseInlineList(block, key) { + const re = new RegExp(`^${key}:\\s*\\[(.*?)\\]\\s*$`, 'm'); + const m = block.match(re); + if (!m) return null; + return m[1] + .split(',') + .map((s) => s.trim().replace(/^["']|["']$/g, '')) + .filter(Boolean); +} + +function parseBlockList(block, key) { + // YAML block list: `key:` followed by ` - item` lines until a non-indented line. + const lines = block.split(/\r?\n/); + const startRe = new RegExp(`^${key}:\\s*$`); + let i = lines.findIndex((l) => startRe.test(l)); + if (i === -1) return null; + const items = []; + for (i += 1; i < lines.length; i += 1) { + const line = lines[i]; + const m = line.match(/^\s+-\s+(.+?)\s*$/); + if (!m) break; + items.push(m[1].replace(/^["']|["']$/g, '')); + } + return items; +} + +function parseList(block, key) { + const inline = parseInlineList(block, key); + if (inline !== null) return inline; + const blockList = parseBlockList(block, key); + if (blockList !== null) return blockList; + return []; +} + +function parsePlan(filePath) { + const fm = readFrontmatter(filePath); + if (fm === null) return null; + const slug = path.basename(filePath, '.md'); + const status = parseScalar(fm, 'status') || 'unknown'; + const depends = parseList(fm, 'depends'); + const awaits = parseList(fm, 'awaits'); + const pr = parseScalar(fm, 'pr'); + return { + slug, + file: filePath, + status, + depends, + awaits, + pr: pr ? Number(pr) : null, + }; +} + +// Load every plan in `dir`. Skips README.md and files starting with `_`. +// Returns { plans: Map<slug, plan>, warnings: string[] }. +function loadPlans(dir) { + const stat = fs.statSync(dir); + if (!stat.isDirectory()) { + throw new Error(`not a directory: ${dir}`); + } + const entries = fs + .readdirSync(dir) + .filter((n) => n.endsWith('.md')) + .filter((n) => n !== 'README.md') + .filter((n) => !n.startsWith('_')); + + const plans = new Map(); + const warnings = []; + for (const name of entries) { + const full = path.join(dir, name); + const plan = parsePlan(full); + if (plan === null) { + warnings.push(`${name}: no YAML frontmatter, skipping`); + continue; + } + if (!VALID_STATUSES.includes(plan.status)) { + warnings.push(`${plan.slug}: unknown status "${plan.status}"`); + } + plans.set(plan.slug, plan); + } + + // Warn on dangling depends (referenced plan doesn't exist). + for (const plan of plans.values()) { + for (const dep of plan.depends) { + if (!plans.has(dep)) { + warnings.push(`${plan.slug}: depends on "${dep}" which has no plan file`); + } + } + } + + // Warn on undocumented blocks: status: blocked with no awaits and no + // unfinished depends leaves the blocker unstated. The plan protocol + // calls this a smell; surface it so authors fix it. + for (const plan of plans.values()) { + if (plan.status !== 'blocked') continue; + if (plan.awaits.length > 0) continue; + const hasOpenDeps = plan.depends.some((dep) => { + const d = plans.get(dep); + return d && d.status !== 'done' && d.status !== 'cancelled'; + }); + if (!hasOpenDeps) { + warnings.push( + `${plan.slug}: status: blocked with no awaits: and no unfinished depends — what's blocking it?`, + ); + } + } + + return { plans, warnings }; +} + +// Kahn topological sort. Returns { order: slug[], cycles: slug[][] }. +// `cycles` lists slugs that couldn't be ordered because they're in a cycle. +function topoSort(plans) { + const inDegree = new Map(); + const dependents = new Map(); + for (const slug of plans.keys()) { + inDegree.set(slug, 0); + dependents.set(slug, []); + } + for (const plan of plans.values()) { + for (const dep of plan.depends) { + if (!plans.has(dep)) continue; + inDegree.set(plan.slug, inDegree.get(plan.slug) + 1); + dependents.get(dep).push(plan.slug); + } + } + const ready = []; + for (const [slug, deg] of inDegree.entries()) { + if (deg === 0) ready.push(slug); + } + ready.sort(); + const order = []; + while (ready.length > 0) { + const slug = ready.shift(); + order.push(slug); + for (const child of dependents.get(slug)) { + inDegree.set(child, inDegree.get(child) - 1); + if (inDegree.get(child) === 0) { + ready.push(child); + ready.sort(); + } + } + } + const cycles = []; + for (const [slug, deg] of inDegree.entries()) { + if (deg > 0) cycles.push(slug); + } + return { order, cycles }; +} + +// For each plan, the set of (open) downstream plans it transitively unblocks. +// "Open" = not done and not cancelled. +function computeDownstreamCounts(plans) { + const dependents = new Map(); + for (const slug of plans.keys()) dependents.set(slug, []); + for (const plan of plans.values()) { + for (const dep of plan.depends) { + if (dependents.has(dep)) dependents.get(dep).push(plan.slug); + } + } + const isOpen = (slug) => { + const p = plans.get(slug); + return p && p.status !== 'done' && p.status !== 'cancelled'; + }; + const memo = new Map(); + function walk(slug, seen) { + if (memo.has(slug)) return memo.get(slug); + if (seen.has(slug)) return new Set(); + seen.add(slug); + const reachable = new Set(); + for (const child of dependents.get(slug)) { + if (isOpen(child)) reachable.add(child); + for (const r of walk(child, seen)) reachable.add(r); + } + seen.delete(slug); + memo.set(slug, reachable); + return reachable; + } + const counts = new Map(); + for (const slug of plans.keys()) { + counts.set(slug, walk(slug, new Set()).size); + } + return counts; +} + +module.exports = { + VALID_STATUSES, + parsePlan, + loadPlans, + topoSort, + computeDownstreamCounts, +}; diff --git a/.agents/skills/specops/scripts/package.json b/.agents/skills/specops/scripts/package.json new file mode 100644 index 0000000..a3c15a7 --- /dev/null +++ b/.agents/skills/specops/scripts/package.json @@ -0,0 +1 @@ +{ "type": "commonjs" } diff --git a/.agents/skills/specops/scripts/plans-dag b/.agents/skills/specops/scripts/plans-dag new file mode 100755 index 0000000..aa4f68c --- /dev/null +++ b/.agents/skills/specops/scripts/plans-dag @@ -0,0 +1,173 @@ +#!/usr/bin/env node +// plans-dag — emit a Mermaid graph of a plans/ directory, styled by status. +// +// Usage: +// plans-dag [plans-dir] [options] +// +// Arguments: +// plans-dir Path to plans/ directory (default: ./plans) +// +// Options: +// --direction TB|LR Graph direction (default: TB) +// --fence Wrap output in a ```mermaid ... ``` Markdown fence +// --include-cancelled Render status: cancelled nodes (hidden by default) +// -h, --help Show this help + +const path = require('path'); +const { loadPlans } = require('./lib/plans'); + +function printHelp() { + process.stdout.write(`plans-dag — emit a Mermaid graph of a plans/ directory. + +Usage: + plans-dag [plans-dir] [options] + +Arguments: + plans-dir Path to plans/ directory (default: ./plans) + +Options: + --direction TB|LR Graph direction (default: TB) + --fence Wrap output in a \`\`\`mermaid ... \`\`\` Markdown fence + --include-cancelled Render status: cancelled nodes (hidden by default) + -h, --help Show this help + +Each plan becomes a node labelled with its slug. Edges follow the depends: +frontmatter field. Nodes are styled by status: + planned light gray + in-progress amber + done green (PR number appended if pr: is set) + blocked red + cancelled dashed gray (hidden unless --include-cancelled) + +Plans with non-empty awaits: get a dashed border (overlaid on the status +color) so external blockers are visible. +`); +} + +function parseArgs(argv) { + const out = { dir: './plans', direction: 'TB', fence: false, includeCancelled: false }; + let sawDir = false; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === '-h' || a === '--help') { + out.help = true; + } else if (a === '--fence') { + out.fence = true; + } else if (a === '--include-cancelled') { + out.includeCancelled = true; + } else if (a === '--direction') { + out.direction = argv[++i]; + } else if (a.startsWith('--direction=')) { + out.direction = a.slice('--direction='.length); + } else if (a.startsWith('-')) { + die(`unknown option: ${a}`); + } else if (!sawDir) { + out.dir = a; + sawDir = true; + } else { + die(`unexpected positional argument: ${a}`); + } + } + if (!['TB', 'LR', 'BT', 'RL'].includes(out.direction)) { + die(`--direction must be TB, LR, BT, or RL (got ${out.direction})`); + } + return out; +} + +function die(msg) { + process.stderr.write(`plans-dag: ${msg}\n`); + process.exit(2); +} + +// Mermaid node ID — letters, digits, underscores. Slugs use hyphens; replace. +function nodeId(slug) { + return slug.replace(/[^A-Za-z0-9_]/g, '_'); +} + +function nodeShape(plan) { + // Mermaid syntax: `id["label"]` rectangle, `id("label")` rounded, `id(["label"])` stadium. + const id = nodeId(plan.slug); + let label = plan.slug; + if (plan.status === 'done' && plan.pr) label += `<br/>PR #${plan.pr}`; + label = label.replace(/"/g, '"'); + if (plan.status === 'blocked') return `${id}(["${label}"])`; // stadium + if (plan.status === 'cancelled') return `${id}["${label}"]`; // rectangle (dashed via classDef) + return `${id}("${label}")`; // rounded +} + +function statusClass(status) { + switch (status) { + case 'planned': return 'planned'; + case 'in-progress': return 'inProgress'; + case 'done': return 'done'; + case 'blocked': return 'blocked'; + case 'cancelled': return 'cancelled'; + default: return 'unknown'; + } +} + +function render(plans, opts) { + const lines = []; + lines.push(`graph ${opts.direction}`); + // Node declarations + const shown = new Map(); + for (const plan of plans.values()) { + if (plan.status === 'cancelled' && !opts.includeCancelled) continue; + shown.set(plan.slug, plan); + lines.push(` ${nodeShape(plan)}`); + } + // Edges (only between shown nodes; dangling deps are surfaced as a stderr warning) + for (const plan of shown.values()) { + for (const dep of plan.depends) { + if (!shown.has(dep)) continue; + lines.push(` ${nodeId(dep)} --> ${nodeId(plan.slug)}`); + } + } + // classDef styling + lines.push(''); + lines.push(' classDef planned fill:#f3f4f6,stroke:#9ca3af,color:#111827'); + lines.push(' classDef inProgress fill:#fef3c7,stroke:#d97706,color:#78350f'); + lines.push(' classDef done fill:#d1fae5,stroke:#059669,color:#064e3b'); + lines.push(' classDef blocked fill:#fee2e2,stroke:#dc2626,color:#7f1d1d'); + lines.push(' classDef cancelled fill:#e5e7eb,stroke:#9ca3af,color:#6b7280,stroke-dasharray:5 5'); + lines.push(' classDef awaits stroke-dasharray:3 3,stroke-width:2px'); + // Apply classes — status first, then awaits overlay for nodes with external blockers + for (const plan of shown.values()) { + lines.push(` class ${nodeId(plan.slug)} ${statusClass(plan.status)}`); + if (plan.awaits && plan.awaits.length > 0) { + lines.push(` class ${nodeId(plan.slug)} awaits`); + } + } + return lines.join('\n') + '\n'; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { + printHelp(); + return; + } + const dir = path.resolve(opts.dir); + let result; + try { + result = loadPlans(dir); + } catch (e) { + die(e.message); + } + if (result.plans.size === 0) { + die(`no plan files found in ${dir} (looked for *.md, excluding README.md and _*.md)`); + } + for (const w of result.warnings) { + process.stderr.write(`plans-dag: warning: ${w}\n`); + } + const body = render(result.plans, opts); + if (opts.fence) { + process.stdout.write('```mermaid\n'); + process.stdout.write(body); + process.stdout.write('```\n'); + } else { + process.stdout.write(body); + } +} + +main(); diff --git a/.agents/skills/specops/scripts/plans-next b/.agents/skills/specops/scripts/plans-next new file mode 100755 index 0000000..c380565 --- /dev/null +++ b/.agents/skills/specops/scripts/plans-next @@ -0,0 +1,211 @@ +#!/usr/bin/env node +// plans-next — print plans ordered by readiness. +// +// Reads a plans/ directory and prints: +// - Ready: plans whose deps are all `done` AND `awaits:` is empty, sorted +// so plans that unblock the most downstream work appear first. +// - Awaiting external: plans with non-empty `awaits:`, each external +// blocker called out. +// - Blocked by unfinished deps: plans whose `awaits:` is empty but have +// at least one unfinished dep, each incomplete dep called out. +// - Blocked (status: blocked): plans whose lifecycle has been explicitly +// blocked, each awaits entry called out so the trigger is visible. +// +// `done` and `cancelled` plans are skipped (terminal states, nothing to act on). +// `in-progress` plans are skipped by default (use --include-in-progress) — +// someone's already on them, so they're not part of "what's next." +// `blocked` plans ARE shown by default — a blocker that needs unblocking is +// actionable; surfacing it is the point. + +const path = require('path'); +const { loadPlans, topoSort, computeDownstreamCounts } = require('./lib/plans'); + +function printHelp() { + process.stdout.write(`plans-next — print plans ordered by readiness. + +Usage: + plans-next [plans-dir] [options] + +Arguments: + plans-dir Path to plans/ directory (default: ./plans) + +Options: + --include-in-progress Include status: in-progress plans (someone's + already on them — opt in to see them) + --slugs-only Print only ready slugs, one per line (machine-friendly) + -h, --help Show this help + +Output (default): four sections — Ready (deps all done AND awaits empty), +Awaiting external (awaits non-empty), Blocked by unfinished deps (awaits empty +but one or more deps open, each unfinished dep called out), and Blocked +(status: blocked) (lifecycle explicitly blocked, each awaits entry shown). +Plans done/cancelled are skipped. Ready plans are sorted by transitive +open-downstream count (the plan that unblocks the most work appears first); +ties broken alphabetically. +`); +} + +function parseArgs(argv) { + const out = { + dir: './plans', + includeInProgress: false, + slugsOnly: false, + }; + let sawDir = false; + for (let i = 0; i < argv.length; i += 1) { + const a = argv[i]; + if (a === '-h' || a === '--help') out.help = true; + else if (a === '--include-in-progress') out.includeInProgress = true; + else if (a === '--slugs-only') out.slugsOnly = true; + else if (a.startsWith('-')) die(`unknown option: ${a}`); + else if (!sawDir) { out.dir = a; sawDir = true; } + else die(`unexpected positional argument: ${a}`); + } + return out; +} + +function die(msg) { + process.stderr.write(`plans-next: ${msg}\n`); + process.exit(2); +} + +function classify(plan, plans) { + // Returns { state, openDeps: string[] } where state is one of: + // 'ready' | 'in-progress' | 'awaiting' | 'blocked-by-deps' | 'blocked-status' + // `awaiting` and `blocked-by-deps` are mutually exclusive in classification; + // `awaiting` takes precedence when both apply (a plan with non-empty awaits + // is never "Ready" regardless of dep state, and surfaces under Awaiting). + const openDeps = []; + for (const dep of plan.depends) { + const depPlan = plans.get(dep); + if (!depPlan) { + openDeps.push(`${dep} (no plan file)`); + continue; + } + if (depPlan.status !== 'done' && depPlan.status !== 'cancelled') { + openDeps.push(`${dep} [${depPlan.status}]`); + } + } + if (plan.status === 'in-progress') return { state: 'in-progress', openDeps }; + if (plan.status === 'blocked') return { state: 'blocked-status', openDeps }; + if (plan.awaits && plan.awaits.length > 0) return { state: 'awaiting', openDeps }; + if (openDeps.length > 0) return { state: 'blocked-by-deps', openDeps }; + return { state: 'ready', openDeps }; +} + +function main() { + const opts = parseArgs(process.argv.slice(2)); + if (opts.help) { printHelp(); return; } + + const dir = path.resolve(opts.dir); + let result; + try { result = loadPlans(dir); } catch (e) { die(e.message); } + if (result.plans.size === 0) { + die(`no plan files found in ${dir} (looked for *.md, excluding README.md and _*.md)`); + } + for (const w of result.warnings) { + process.stderr.write(`plans-next: warning: ${w}\n`); + } + + const plans = result.plans; + const downstream = computeDownstreamCounts(plans); + const { order, cycles } = topoSort(plans); + if (cycles.length > 0) { + process.stderr.write(`plans-next: warning: cycle detected among: ${cycles.join(', ')}\n`); + } + + const ready = []; + const inProgress = []; + const awaiting = []; + const blockedByDeps = []; + const blockedByStatus = []; + const done = []; + const cancelled = []; + + for (const slug of order.length ? order : [...plans.keys()]) { + const plan = plans.get(slug); + if (plan.status === 'done') { done.push(plan); continue; } + if (plan.status === 'cancelled') { cancelled.push(plan); continue; } + const c = classify(plan, plans); + if (c.state === 'ready') ready.push({ plan, openDeps: c.openDeps }); + else if (c.state === 'in-progress') inProgress.push({ plan, openDeps: c.openDeps }); + else if (c.state === 'awaiting') awaiting.push({ plan, openDeps: c.openDeps }); + else if (c.state === 'blocked-status') blockedByStatus.push({ plan, openDeps: c.openDeps }); + else blockedByDeps.push({ plan, openDeps: c.openDeps }); + } + + // Sort Ready: by downstream count (desc), then alphabetical. + ready.sort((a, b) => { + const da = downstream.get(a.plan.slug) || 0; + const db = downstream.get(b.plan.slug) || 0; + if (db !== da) return db - da; + return a.plan.slug.localeCompare(b.plan.slug); + }); + + if (opts.slugsOnly) { + for (const r of ready) process.stdout.write(`${r.plan.slug}\n`); + return; + } + + const out = []; + const fmtCount = (slug) => { + const n = downstream.get(slug) || 0; + return n > 0 ? ` (unblocks ${n})` : ''; + }; + + out.push('Ready to work on'); + out.push('================'); + if (ready.length === 0) out.push('(none)'); + else for (const r of ready) out.push(` - ${r.plan.slug}${fmtCount(r.plan.slug)}`); + out.push(''); + + if (opts.includeInProgress && inProgress.length > 0) { + out.push('In progress'); + out.push('==========='); + for (const r of inProgress) { + out.push(` - ${r.plan.slug}${fmtCount(r.plan.slug)}`); + for (const a of (r.plan.awaits || [])) out.push(` ⌛ ${a}`); + } + out.push(''); + } + + out.push('Awaiting external'); + out.push('================='); + if (awaiting.length === 0) out.push('(none)'); + else for (const r of awaiting) { + out.push(` - ${r.plan.slug}${fmtCount(r.plan.slug)}`); + for (const a of r.plan.awaits) out.push(` ⌛ ${a}`); + for (const d of r.openDeps) out.push(` ↳ ${d}`); + } + out.push(''); + + out.push('Blocked by unfinished deps'); + out.push('=========================='); + if (blockedByDeps.length === 0) out.push('(none)'); + else for (const r of blockedByDeps) { + out.push(` - ${r.plan.slug}${fmtCount(r.plan.slug)}`); + for (const d of r.openDeps) out.push(` ↳ ${d}`); + } + out.push(''); + + out.push('Blocked (status: blocked)'); + out.push('========================='); + if (blockedByStatus.length === 0) out.push('(none)'); + else for (const r of blockedByStatus) { + out.push(` - ${r.plan.slug}`); + for (const a of (r.plan.awaits || [])) out.push(` ⌛ ${a}`); + for (const d of r.openDeps) out.push(` ↳ ${d}`); + } + out.push(''); + + out.push( + `Summary: ${ready.length} ready · ${inProgress.length} in-progress · ` + + `${awaiting.length} awaiting · ` + + `${blockedByDeps.length} blocked-by-deps · ${blockedByStatus.length} blocked · ` + + `${done.length} done · ${cancelled.length} cancelled` + ); + + process.stdout.write(out.join('\n') + '\n'); +} + +main(); diff --git a/.claude/skills/backend-fastify b/.claude/skills/backend-fastify new file mode 120000 index 0000000..5afb19b --- /dev/null +++ b/.claude/skills/backend-fastify @@ -0,0 +1 @@ +../../.agents/skills/backend-fastify \ No newline at end of file diff --git a/.claude/skills/frontend-shadcn b/.claude/skills/frontend-shadcn new file mode 120000 index 0000000..a5b1275 --- /dev/null +++ b/.claude/skills/frontend-shadcn @@ -0,0 +1 @@ +../../.agents/skills/frontend-shadcn \ No newline at end of file diff --git a/.claude/skills/specops b/.claude/skills/specops new file mode 120000 index 0000000..08ccfbd --- /dev/null +++ b/.claude/skills/specops @@ -0,0 +1 @@ +../../.agents/skills/specops \ No newline at end of file diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..13883bd --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,23 @@ +{ + "version": 1, + "skills": { + "backend-fastify": { + "source": "JarvusInnovations/agent-skills", + "sourceType": "github", + "skillPath": "skills/backend-fastify/SKILL.md", + "computedHash": "b71ec0d6b294c209f5e2c5f4719231fa1133e95b552b467b71bf2b9d4aecd5ed" + }, + "frontend-shadcn": { + "source": "JarvusInnovations/agent-skills", + "sourceType": "github", + "skillPath": "skills/frontend-shadcn/SKILL.md", + "computedHash": "381582503c409ecc54e910d83cabb3763cb37323f3483d0ea2fcd274fe70c5d3" + }, + "specops": { + "source": "JarvusInnovations/agent-skills", + "sourceType": "github", + "skillPath": "skills/specops/SKILL.md", + "computedHash": "9ff7ad69d19a04111f64600741c3f02cdb8730f2b7fc3ff51f04deb5e5fca48e" + } + } +} From ae497005e8982d48fca89e6da0849f093eae758a Mon Sep 17 00:00:00 2001 From: Chris Alfano <chris@jarv.us> Date: Tue, 19 May 2026 13:41:44 -0400 Subject: [PATCH 2/2] docs(claude): slim CLAUDE.md, defer spec/plan workflow to specops skill MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CLAUDE.md was duplicating ~70% of what the specops skill (just installed) already covers — the spec-driven philosophy and the plans-protocol ritual. With the skill present, that duplication will rot and confuse. Trims that content out and replaces it with a short "Skills available in this repo" inventory pointing at the three skills now installed. Also: - Fix stale claims: importer "not yet implemented" (it's been built and rewritten via gitsheets transact; merged); `codeforphilly-data-snapshot` (no longer the model — single repo `codeforphilly-data` with branches empty/fixture/legacy-import/published) - Add "Three repos in this project" diagram so a fresh contributor can map this repo to `codeforphilly-data` and `cfp-sandbox-cluster` - Add "Runtime data flow" callout for the push daemon + reconciler + hot-reload webhook — significant runtime architecture that wasn't surfaced anywhere a new agent would find it - Add "Working laterally on the data repo" callout reminding agents to read the data repo's own `.claude/CLAUDE.md` + the gitsheets skill it ships when reaching across - Add "Local setup" walkthrough — `.env.example`, clone data repo as sibling, where `CFP_DATA_REPO_PATH` should point - Add "CI" subsection explaining the `@cfp/shared` pre-emptive build (a gotcha that bit us before) - Document the merge convention (rebase locally + `gh pr merge --merge`, never `--rebase` or `--squash`) - Document the Co-Authored-By trailer - Rephrase "Per the user's global rules" → "Project conventions" Net: 153 lines → 102 lines, none of which duplicate any installed skill. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> --- .claude/CLAUDE.md | 192 ++++++++++++++++++++-------------------------- 1 file changed, 82 insertions(+), 110 deletions(-) diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index d37777d..b114970 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -2,152 +2,124 @@ A modernization of [laddr](https://github.com/CodeForPhilly/laddr) (the platform behind [codeforphilly.org](https://codeforphilly.org)) onto a Fastify + Vite/React + gitsheets stack. -## Spec-driven - -**`specs/` is the source of truth.** Before writing or changing code, read the relevant spec; if the spec doesn't cover what you're about to do, update the spec first. - -Workflow: - -1. Spec change → propose what should be true -2. Reviewer agrees on desired state -3. Implement to match the spec -4. Verify running software matches the spec - -Start at [specs/README.md](../specs/README.md). The index of what's where: - -- [specs/architecture.md](../specs/architecture.md) — stack, repo layout, deploy -- [specs/data-model.md](../specs/data-model.md) — entities, fields, relationships -- [specs/deferred.md](../specs/deferred.md) — features intentionally out of scope (do NOT silently implement these) -- [specs/api/](../specs/api/) — endpoint contracts (one file per resource group) -- [specs/screens/](../specs/screens/) — one file per route — what the user sees, what they can do -- [specs/behaviors/](../specs/behaviors/) — cross-cutting rules referenced from multiple screens/APIs - -## Spec drift auditing - -Run `/audit-spec-drift` to launch a comprehensive audit comparing `specs/` against the implementation. Use it before starting major work, after large refactors, and as part of the release checklist. - -## Plans - -`plans/` is the micro-DAG of work that bridges `specs/` to the running code. If specs describe **state** (what should be true forever), plans describe **motion** (how we get there). Start a feature with a plan; the plan declares its scope, the specs it implements, its dependencies, and concrete validation criteria. - -Plans index: [plans/README.md](../plans/README.md). Workflow: - -1. **Add a plan** when starting a new chunk of work. `status: planned` + `depends:` set. -2. **Move to `in-progress`** when you start, as the first commit on the branch (`chore(plans): mark <slug> in-progress`). Skippable for tiny plans — going straight to `done` at the end is fine. Multiple plans can be in-progress in parallel across people, but one plan per contributor at a time is the norm. -3. **Move to `done` as the last commit on the branch, before merge.** That commit does the following, all in one shot, with message `chore(plans): mark <slug> done (PR #<n>)`: - - Frontmatter: `status` → `done`, add `pr: <PR number>` (knowable once `gh pr create` returns) - - **Validation checklist: flip each `- [ ]` to `- [x]` for criteria you verified.** If a criterion can't be verified at merge time (depends on a downstream plan, needs production deploy, etc.), leave it unchecked and add a one-line note in the Notes section explaining why and where it'll close out. Never silently rewrite a validation criterion to match what you ended up doing — that's a plan amendment in its own earlier commit. - - **Notes** section: non-actionable carry-forwards — decisions, surprises, gotchas, learnings. Things future-you would want to know. - - **Follow-ups** section: actionable items that didn't ship with this plan. Each entry is one of: - - `Issue [#N](link) — short description` — when actionable and not owned by an existing planned-or-in-progress plan, file the issue first (`gh-axi issue create`) and link it - - `Deferred to [`<other-plan>`](<other-plan>.md) — short description` — when an unstarted (`status: planned`) downstream plan should own the work. **The same closeout commit must also edit that downstream plan to absorb the deferral** — typically a new bullet under Approach and a new criterion under Validation. If the downstream plan is already `in-progress` or `done`, use the Issue shape instead; never modify a plan that's actively being implemented or already frozen. - - `Tracked as: <free-form pointer>` — for anything else (waiting on community input, vendor response, etc.) - - `None.` — explicit when there's nothing, so a future reader can see the section was considered, not just absent - - The plan is frozen after merge — historical record, no further edits -4. **Update `depends:`** as the DAG sharpens — a plan can discover it needs a new prereq mid-stream. -5. **Specs come first.** A plan implements specs that already exist. If you realize specs need to change mid-plan, the spec change is its own PR before the plan continues. -6. **Splitting a plan**: rename and add the new one with `depends:` updated. - -A plan's frontmatter: - -```yaml ---- -status: planned # planned | in-progress | done | blocked | cancelled -depends: [other-plan-slug] -specs: # spec files in THIS repo that this plan implements - - specs/architecture.md - - specs/behaviors/storage.md -upstream-specs: # (optional) specs in OTHER repos this plan consumes - - gitsheets:specs/behaviors/transactions.md - - gitsheets:specs/api/errors.md -awaits: # (optional) external blockers — releases, decisions, deliveries - - "JarvusInnovations/gitsheets@v1.0 — Repository / Sheet / openStore API" -issues: [128, 129] # related GitHub issues (optional) -pr: 42 # merged PR once done (optional) ---- -``` +## Skills available in this repo + +These auto-trigger by topic — you don't load them manually. Mentioned here so you know what's already covered and don't duplicate. + +| Skill | Triggers on | What it covers | +|---|---|---| +| [`specops`](./skills/specops/SKILL.md) | `specs/`, `plans/`, "spec", "closeout commit", new features | Spec-driven workflow (specs are source of truth), plans-as-micro-DAG protocol, closeout commit ritual, follow-ups taxonomy, spec-drift auditor | +| [`backend-fastify`](./skills/backend-fastify/SKILL.md) | New routes, services, plugins, env vars | Fastify 5 patterns, plugin ordering, `@fastify/env` validation, error handling | +| [`frontend-shadcn`](./skills/frontend-shadcn/SKILL.md) | New screens, components, routing, styling | Vite + React 19 + shadcn/ui + Tailwind v4 + React Router v7 patterns | + +Skills are version-pinned via `skills-lock.json`. To update: `agent-skills` CLI. -`specs:` is for specs we own — the spec-drift-auditor matches them against implementation. `upstream-specs:` is for specs owned by dependencies (e.g., gitsheets) that this plan consumes; they're informational only and the spec-drift-auditor doesn't check them. Use the `<repo>:<path>` form so it's obvious where to look. +### Working laterally on the data repo -`awaits:` captures external blockers — upstream library releases, vendor deliveries, partner decisions. Each entry is a one-line free-form pointer (URL, `repo@tag`, "vendor X delivery", etc., with a trailing em-dash clause for the *why* so the entry self-explains when grep surfaces it). The three structural fields (`depends:`, `upstream-specs:`, `awaits:`) are **orthogonal axes**, not mutually exclusive categories — the same upstream relationship can carry multiple. The example above is the canonical case: gitsheets appears in `upstream-specs:` (the specs we'll implement against) *and* in `awaits:` (the v1.0 release we're waiting for). Different axes of the same upstream. `awaits:` is also independent of `status:`: a `planned` plan can carry `awaits:` from day one; a `blocked` plan should always have `awaits:` populated to say why (the scripts warn if `status: blocked` is set with neither `awaits:` nor unfinished `depends:`). Resolution is just deleting the entry when the awaited thing happens. `plans-next` never lists a plan with non-empty `awaits:` under "Ready" — it surfaces in an "Awaiting external" section. Full convention: [`agent-skills/skills/specops/references/plans-protocol.md`](https://github.com/JarvusInnovations/agent-skills/blob/main/skills/specops/references/plans-protocol.md#external-blockers). +When you `cd` into a clone of [`CodeForPhilly/codeforphilly-data`](https://github.com/CodeForPhilly/codeforphilly-data) (typically a sibling — see [Local setup](#local-setup) below), **read its `.claude/CLAUDE.md` first** — that repo has its own conventions and ships a `gitsheets` skill at `.claude/skills/gitsheets/` covering library use, transactions, path templates, indices. Don't write TOML records or shell out to git there by hand. -A plan's body follows the template in [plans/README.md](../plans/README.md): Scope, Implements, Approach, Validation, Risks/unknowns, Notes, Follow-ups. The Validation section is the load-bearing part — it converts "in-progress" to "done." +## Three repos in this project + +``` +codeforphilly-ng (this repo) ─── Fastify API + Vite SPA + Docker image + │ + │ runtime reads/writes via gitsheets + ▼ +codeforphilly-data ─── Public data store. Branches: + │ empty — .gitsheets/ configs + tooling only + │ fixture — small hand-curated test data + │ legacy-import — full snapshot from laddr + │ published — runtime-served (fixture/legacy + │ merge target). Hot-reload webhook + │ fires on push. + │ +cfp-sandbox-cluster ─── GitOps repo (hologit-projected) that pulls + │ this repo's `deploy/kustomize/` upstream + │ and applies it via Kustomize. See + │ `docs/operations/deploy.md`. +``` -**Plans are not specs.** They're project-management artifacts. Plans rot fast — once a plan is `done`, it's a historical record; don't keep editing it. The `spec-drift-auditor` reads `specs/`, not `plans/`. +Operator docs in [`docs/operations/`](../docs/operations/): `deploy.md` for the cluster topology, `sandbox-deploy.md` for the manual procedure, `runbook.md` for incident response (including the hot-reload webhook). ## Stack - **Backend** — Fastify 5.x + TypeScript. Single replica, in-process write mutex. - **Public storage** — [gitsheets](https://github.com/JarvusInnovations/gitsheets) (TOML records in a git repo). Public-by-design — civic transparency. No persistent OLTP. See [specs/behaviors/storage.md](../specs/behaviors/storage.md). -- **Private storage** — S3-compatible bucket holding two `.jsonl` files (private profiles + legacy password hashes). Boot-load + in-memory; PUT on mutation. See [specs/behaviors/private-storage.md](../specs/behaviors/private-storage.md). +- **Private storage** — S3-compatible bucket holding `.jsonl` files (private profiles + legacy password hashes). Boot-load + in-memory; PUT on mutation. See [specs/behaviors/private-storage.md](../specs/behaviors/private-storage.md). Real production private data never lands on a dev machine. - **Schemas** — Zod in `packages/shared`, consumed by both web and api, validating records in both stores. - **Full-text search** — in-memory SQLite FTS5 (or MiniSearch fallback), rebuilt at boot from gitsheets state. -- **Auth** — GitHub OAuth as the sole primary identity provider; stateless JWT sessions. We are also the SAML IdP for codeforphilly.slack.com. See [specs/api/auth.md](../specs/api/auth.md), [specs/api/saml.md](../specs/api/saml.md). +- **Auth** — GitHub OAuth as the sole primary identity provider; stateless JWT sessions. We're also the SAML IdP for codeforphilly.slack.com. See [specs/api/auth.md](../specs/api/auth.md), [specs/api/saml.md](../specs/api/saml.md). - **Frontend** — Vite + React 19 + shadcn/ui + Tailwind v4 + React Router v7. -See [specs/architecture.md](../specs/architecture.md) for the full stack rationale. +Full rationale: [specs/architecture.md](../specs/architecture.md). -Per the user's global rules: `npm` workspaces (not bun), `asdf` manages the Node version, commit lockfiles. +### Runtime data flow -### Three persistence surfaces +The API holds the full public dataset in memory at boot. Writes go through a single in-process mutex, transact into gitsheets, and `stateApply` into the in-memory state synchronously — reads and writes share one source of truth. -1. **Public gitsheets data repo** (`codeforphilly-data` — pushed publicly) — projects, members' public fields, tags, etc. -2. **Private bucket** — emails, newsletter prefs, legacy password hashes during migration. Production-only; devs use a local filesystem backend with seeded fakes. -3. **Public snapshot** (`codeforphilly-data-snapshot`) — anonymized, contributor-cloneable copy of the public data. PII-free by construction. +Two background concerns keep that store in sync with the world: -**Real production private data never lands on a dev machine** — see [specs/behaviors/private-storage.md](../specs/behaviors/private-storage.md). +- **Push daemon** — `apps/api/src/plugins/push-daemon.ts`. Pushes new commits up to `origin/<CFP_DATA_BRANCH>` continuously with retry/backoff. +- **Reconcile + hot reload** — `apps/api/src/store/reconcile.ts` + `apps/api/src/plugins/reconcile.ts`. Runs at boot to fast-forward / rebase / escape-hatch the local clone against origin. `POST /api/_internal/reload-data` (the hot-reload webhook, called by a GH Action on push to `published`) runs the same path mid-life and atomically rebuilds the in-memory state in place. See [specs/behaviors/storage.md#hot-reload](../specs/behaviors/storage.md#hot-reload). + +## Project conventions + +- TypeScript everywhere. `strict: true`. No `.js` in `src/`. +- Field names: `camelCase` in TS and in TOML records. No casing translation. +- IDs: UUIDv7. **Slugs** (not IDs) in user-facing URLs. +- Timestamps: ISO 8601 UTC strings (e.g., `"2026-05-15T18:42:00Z"`) — in requests, responses, and on disk. +- Every endpoint uses the response envelope from [specs/api/conventions.md](../specs/api/conventions.md). +- Markdown is rendered server-side. Clients never run a markdown library on user content. See [specs/behaviors/markdown-rendering.md](../specs/behaviors/markdown-rendering.md). +- Mutations go through the in-process write mutex in [specs/behaviors/storage.md](../specs/behaviors/storage.md). Don't write to the data repo from anywhere else. ## Tooling -- **`gh-axi`** for all GitHub operations (issues, PRs, runs, releases, repos, labels, search). It wraps `gh` with terse output and contextual suggestions. Don't use bare `gh`. -- **GitHub Actions** — when authoring or modifying a workflow, run `gh-axi repo view <owner>/<repo>` on each action's repo to confirm the latest recommended version and usage before writing the workflow. -- **`asdf`** manages tool versions. Never edit `.tool-versions` directly — use `asdf set nodejs latest:22` (or the equivalent) and then `asdf install`. If a tool isn't available despite being in `.tool-versions`, run `asdf install`. -- **`jq`** for processing JSON in any shell pipeline. Don't write inline Python/Node/Ruby to filter JSON. -- **`npm`** for packages. Never hand-edit `package.json` or `package-lock.json` — use `npm install <pkg>`, `npm install <pkg>@<version>`, `npm uninstall <pkg>`, `npm run <script>` so versions and the lockfile stay coherent. Always commit `package-lock.json` for reproducible builds. +- **`gh-axi`** for GitHub operations (issues, PRs, runs, releases). Wraps `gh` with terse output. Don't use bare `gh`. +- **`asdf`** for tool versions. Never edit `.tool-versions` directly — use `asdf set nodejs latest:22`, then `asdf install`. +- **`jq`** for JSON in shell pipelines. No inline Python/Node/Ruby for JSON. +- **`npm`** (workspaces, not bun, not pnpm). Never hand-edit `package.json` / `package-lock.json` — use `npm install <pkg>`, `npm install <pkg>@<version>`, `npm uninstall <pkg>`. Commit lockfiles. + +### CI -## Commands (once scaffolded) +`.github/workflows/ci.yml` runs `npm ci` → builds `@cfp/shared` → type-check → lint → test → build. The pre-emptive `@cfp/shared` build matters because the workspace's exports map points at `dist/` (not `src/`); other workspaces' type-check can't resolve `@cfp/shared/schemas` until shared is compiled. + +## Local setup + +1. `asdf install` — picks up Node from `.tool-versions` +2. Clone the data repo as a sibling: `git clone git@github.com:CodeForPhilly/codeforphilly-data.git ../codeforphilly-data` (checkout `fixture` for a small seed, or `published` for the full laddr import) +3. `cp .env.example .env` and edit — point `CFP_DATA_REPO_PATH` at your sibling clone (absolute path recommended; relative paths resolve from `apps/api/`, not repo root) +4. `npm install` +5. `npm run dev` — api + web concurrently ```bash npm install # install all workspaces -npm run dev # api + web concurrently with watch +npm run dev # api + web concurrently npm run build # build all workspaces npm run type-check # tsc --noEmit across workspaces npm run lint # eslint +npm run -w apps/api dev # api only +npm run -w apps/web dev # web only ``` -Per-workspace: - -```bash -npm run -w apps/api dev -npm run -w apps/web dev -``` - -## Authorship conventions - -- TypeScript everywhere. `strict: true`. No `.js` in `src/`. -- Field names: `camelCase` in TS and in TOML records. No casing translation. -- IDs: UUIDv7. Slugs (not IDs) in user-facing URLs. -- Timestamps: ISO 8601 UTC strings (e.g., `"2026-05-15T18:42:00Z"`) — in requests, responses, and on disk. -- Use the response envelope from [specs/api/conventions.md](../specs/api/conventions.md) for every endpoint. -- Markdown is rendered server-side. Clients never run a markdown library on user content. See [specs/behaviors/markdown-rendering.md](../specs/behaviors/markdown-rendering.md). -- Mutations go through the in-process write mutex documented in [specs/behaviors/storage.md](../specs/behaviors/storage.md). Don't write to the data repo from anywhere else. - ## Source control -- **Conventional commits** — `type(scope): description` (e.g., `feat(api): add help-wanted endpoints`, `fix(web): correct stage badge color`, `docs(specs): clarify slug rules`). -- **Logical sets per commit** — group related changes together; commit often as soon as each set is ready. When multiple uncommitted change-sets exist, commit them separately in a logical order rather than mashing together. -- **Always `git status` before staging.** Stage specific files or directories — never `git add -A` or `git add .` (which can sweep in `.env`, credentials, large binaries, or unrelated work). -- **Generated changes commit first.** When a command modifies files (`npm install`, `npx shadcn@latest add ...`), commit those generated changes in a dedicated commit with the exact command in the body. Then make manual edits in a separate follow-up commit. -- **Don't commit suspected secrets** — `.env`, anything in `*.local.*`, credentials, private keys. Warn explicitly if asked to commit one of these. +- **Conventional commits**: `type(scope): description`. Subject in imperative voice, ≤72 chars. Body wraps at ~72 and explains *why* — readers can already see *what* from the diff. +- **Co-Authored-By trailer** on every commit when working with an agent: `Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>`. +- **Logical sets per commit** — group related changes; commit often. When multiple uncommitted change-sets exist, separate them. +- **Always `git status` before staging.** Stage specific files. Never `git add -A` or `git add .` — they sweep in `.env`, credentials, large binaries, or unrelated work. +- **Generated changes commit first.** `npm install`, `npx shadcn@latest add ...`, etc. — separate commit with the exact command in the body. Manual edits in a follow-up commit. +- **Don't commit secrets** — `.env`, `*.local.*`, credentials, private keys. Warn explicitly if asked. +- **Merging PRs**: rebase locally onto `main` first (preserves atomic commit history), then `gh pr merge --merge` (never `--rebase` or `--squash` — merge commits group multi-commit PRs in `git log --first-parent`). ## Migration context -We are migrating from a MySQL-backed PHP/Emergence app to a gitsheets-backed Node app. Every user-facing URL stays the same. See: +We're migrating from a MySQL-backed PHP/Emergence app to gitsheets-backed Node. Every user-facing URL stays the same. Key specs: - [specs/behaviors/slug-handles.md](../specs/behaviors/slug-handles.md) — slug format and uniqueness -- [specs/behaviors/legacy-id-mapping.md](../specs/behaviors/legacy-id-mapping.md) — `legacyId` column and URL redirects -- The one-shot importer lives at `apps/api/scripts/import-laddr.ts` (not yet implemented) +- [specs/behaviors/legacy-id-mapping.md](../specs/behaviors/legacy-id-mapping.md) — `legacyId` field + URL redirects + +The importer lives at `apps/api/scripts/import-laddr.ts` and writes snapshot commits to the `legacy-import` branch of the data repo. It's re-runnable; each run fully replaces the previous tree. ## When in doubt -Pick the spec that mentions what you're working on. If multiple specs apply (e.g., a project detail screen calls multiple endpoints), read each. If you can't find a spec, the answer is to write one — not to make up behavior. +Read the spec that mentions what you're working on. If multiple specs apply (e.g., a project detail screen calls multiple endpoints), read each. **If you can't find a spec, the answer is to write one — not to make up behavior.**