Reference implementation for the Phoenix Architecture. Work in progress. aicoding.leaflet.pub/
ai coding crazy
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

refactor: split Architecture from Runtime Target

Architecture defines the SYSTEM SHAPE (communication pattern, data
ownership, component grain, evaluation surface) — language agnostic.

Runtime Target defines the COMPILATION TARGET (language, framework,
packages, templates, shared files) — implements an architecture.

Hierarchy: Spec → Architecture → Runtime Target → Generated Code

'web-api' architecture + 'node-typescript' runtime replaces the
monolithic 'sqlite-web-api'. Legacy name still works via resolveTarget.

Next: add moduleTemplate to runtime target for template-based
generation (guaranteed structure, LLM fills in business logic only).

+1060 -952
+9
examples/todo-app/package.json
··· 1 1 { 2 2 "name": "todo-app", 3 3 "version": "0.1.0", 4 + "description": "Generated by Phoenix VCS — 1 services", 4 5 "type": "module", 6 + "scripts": { 7 + "build": "tsc", 8 + "typecheck": "tsc --noEmit", 9 + "test": "vitest run", 10 + "test:watch": "vitest", 11 + "dev": "tsx watch src/server.ts", 12 + "start": "tsx src/server.ts" 13 + }, 5 14 "dependencies": { 6 15 "hono": "^4.6.0", 7 16 "@hono/node-server": "^1.13.0",
+11
examples/todo-app/src/generated/index.ts
··· 1 + /** 2 + * Phoenix VCS — Generated Service Registry 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + */ 6 + 7 + export * as todos from './todos/index.js'; 8 + 9 + export const services = [ 10 + { name: 'Todos', dir: 'todos', port: 3000, modules: 3 }, 11 + ] as const;
+30
examples/todo-app/src/generated/todos/__tests__/todos.test.ts
··· 1 + /** 2 + * Todos — Generated Tests 3 + * AUTO-GENERATED by Phoenix VCS 4 + */ 5 + 6 + import { describe, it, expect } from 'vitest'; 7 + import projects from '../projects.js'; 8 + import tasks from '../tasks.js'; 9 + import web_experience from '../web-experience.js'; 10 + 11 + describe('Todos modules', () => { 12 + describe('Projects', () => { 13 + it('exports a Hono router as default', () => { 14 + expect(projects).toBeDefined(); 15 + expect(typeof projects.fetch).toBe('function'); 16 + }); 17 + }); 18 + describe('Tasks', () => { 19 + it('exports a Hono router as default', () => { 20 + expect(tasks).toBeDefined(); 21 + expect(typeof tasks.fetch).toBe('function'); 22 + }); 23 + }); 24 + describe('Web Experience', () => { 25 + it('exports a Hono router as default', () => { 26 + expect(web_experience).toBeDefined(); 27 + expect(typeof web_experience.fetch).toBe('function'); 28 + }); 29 + }); 30 + });
+10
examples/todo-app/src/generated/todos/index.ts
··· 1 + /** 2 + * Todos 3 + * 4 + * AUTO-GENERATED by Phoenix VCS 5 + * Barrel export for all Todos modules. 6 + */ 7 + 8 + export * as projects from './projects.js'; 9 + export * as tasks from './tasks.js'; 10 + export * as webExperience from './web-experience.js';
+17 -23
examples/todo-app/src/generated/todos/projects.ts
··· 39 39 WHERE completed = 0 40 40 GROUP BY project_id 41 41 ) t ON p.id = t.project_id 42 - ORDER BY p.name 42 + ORDER BY p.created_at DESC 43 43 `).all(); 44 44 return c.json(projects); 45 45 }); ··· 61 61 ) t ON p.id = t.project_id 62 62 WHERE p.id = ? 63 63 `).get(c.req.param('id'), c.req.param('id')); 64 - 65 64 if (!project) return c.json({ error: 'Project not found' }, 404); 66 65 return c.json(project); 67 66 }); ··· 74 73 } catch { 75 74 return c.json({ error: 'Invalid JSON' }, 400); 76 75 } 77 - 76 + 78 77 const result = CreateProjectSchema.safeParse(body); 79 - if (!result.success) { 80 - return c.json({ error: result.error.issues[0].message }, 400); 81 - } 82 - 78 + if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 79 + 83 80 const { name, color } = result.data; 84 - 81 + 85 82 try { 86 83 const info = db.prepare('INSERT INTO projects (name, color) VALUES (?, ?)').run(name, color); 87 84 const project = db.prepare(` ··· 92 89 WHERE p.id = ? 93 90 `).get(info.lastInsertRowid); 94 91 return c.json(project, 201); 95 - } catch (error: unknown) { 96 - if (error && typeof error === 'object' && 'code' in error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { 92 + } catch (error: any) { 93 + if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { 97 94 return c.json({ error: 'Project name already exists' }, 400); 98 95 } 99 96 throw error; ··· 105 102 const id = c.req.param('id'); 106 103 const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id); 107 104 if (!existing) return c.json({ error: 'Project not found' }, 404); 108 - 105 + 109 106 let body; 110 107 try { 111 108 body = await c.req.json(); 112 109 } catch { 113 110 return c.json({ error: 'Invalid JSON' }, 400); 114 111 } 115 - 112 + 116 113 const result = UpdateProjectSchema.safeParse(body); 117 - if (!result.success) { 118 - return c.json({ error: result.error.issues[0].message }, 400); 119 - } 120 - 114 + if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 115 + 121 116 const updates = result.data; 122 - 117 + 123 118 try { 124 119 if (updates.name !== undefined) { 125 120 db.prepare('UPDATE projects SET name = ? WHERE id = ?').run(updates.name, id); ··· 127 122 if (updates.color !== undefined) { 128 123 db.prepare('UPDATE projects SET color = ? WHERE id = ?').run(updates.color, id); 129 124 } 130 - 125 + 131 126 const updated = db.prepare(` 132 127 SELECT 133 128 p.*, ··· 143 138 ) t ON p.id = t.project_id 144 139 WHERE p.id = ? 145 140 `).get(id, id); 146 - 147 141 return c.json(updated); 148 - } catch (error: unknown) { 149 - if (error && typeof error === 'object' && 'code' in error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { 142 + } catch (error: any) { 143 + if (error && error.code === 'SQLITE_CONSTRAINT_UNIQUE') { 150 144 return c.json({ error: 'Project name already exists' }, 400); 151 145 } 152 146 throw error; ··· 158 152 const id = c.req.param('id'); 159 153 const existing = db.prepare('SELECT * FROM projects WHERE id = ?').get(id); 160 154 if (!existing) return c.json({ error: 'Project not found' }, 404); 161 - 155 + 162 156 // Check for tasks in this project 163 157 const taskCount = db.prepare('SELECT COUNT(*) as count FROM tasks WHERE project_id = ?').get(id) as { count: number }; 164 158 if (taskCount.count > 0) { 165 159 return c.json({ error: 'Cannot delete project that contains tasks' }, 400); 166 160 } 167 - 161 + 168 162 db.prepare('DELETE FROM projects WHERE id = ?').run(id); 169 163 return c.body(null, 204); 170 164 });
+34 -30
examples/todo-app/src/generated/todos/tasks.ts
··· 20 20 title: z.string().min(1, 'Title is required').max(500, 'Title must not exceed 500 characters'), 21 21 description: z.string().max(5000, 'Description must not exceed 5000 characters').optional().default(''), 22 22 priority: z.enum(['urgent', 'high', 'normal', 'low']).default('normal'), 23 - due_date: z.string().nullable().optional().refine((date) => { 23 + due_date: z.string().refine((date) => { 24 24 if (!date) return true; 25 25 const parsed = new Date(date); 26 26 return !isNaN(parsed.getTime()); 27 - }, 'Due date must be a valid date'), 27 + }, 'Due date must be a valid date').optional(), 28 28 project_id: z.number().int().nullable().optional(), 29 29 }); 30 30 ··· 32 32 title: z.string().min(1, 'Title is required').max(500, 'Title must not exceed 500 characters').optional(), 33 33 description: z.string().max(5000, 'Description must not exceed 5000 characters').optional(), 34 34 priority: z.enum(['urgent', 'high', 'normal', 'low']).optional(), 35 - due_date: z.string().nullable().optional().refine((date) => { 36 - if (date === null || date === undefined) return true; 35 + due_date: z.string().refine((date) => { 36 + if (!date) return true; 37 37 const parsed = new Date(date); 38 38 return !isNaN(parsed.getTime()); 39 - }, 'Due date must be a valid date'), 39 + }, 'Due date must be a valid date').nullable().optional(), 40 40 completed: z.number().int().min(0).max(1).optional(), 41 41 project_id: z.number().int().nullable().optional(), 42 42 }); 43 43 44 44 const router = new Hono(); 45 45 46 + // Stats endpoint - must come before /:id route 47 + router.get('/stats', (c) => { 48 + const stats = db.prepare(` 49 + SELECT 50 + COUNT(*) as total_tasks, 51 + SUM(completed) as completed_tasks, 52 + COUNT(CASE WHEN due_date IS NOT NULL AND due_date < date('now') AND completed = 0 THEN 1 END) as overdue_tasks, 53 + ROUND( 54 + CASE 55 + WHEN COUNT(*) = 0 THEN 0 56 + ELSE (CAST(SUM(completed) AS FLOAT) / COUNT(*)) * 100 57 + END, 58 + 1 59 + ) as completion_percentage 60 + FROM tasks 61 + `).get(); 62 + 63 + return c.json(stats); 64 + }); 65 + 46 66 // List tasks with filtering and sorting 47 67 router.get('/', (c) => { 48 68 let sql = ` 49 - SELECT tasks.*, projects.name as project_name 69 + SELECT tasks.*, 70 + CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 50 71 FROM tasks 51 72 LEFT JOIN projects ON tasks.project_id = projects.id 52 73 `; ··· 76 97 sql += ' WHERE ' + conditions.join(' AND '); 77 98 } 78 99 100 + // Sort by urgency and overdue status first 79 101 sql += ` ORDER BY 80 - tasks.completed ASC, 81 102 CASE tasks.priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END, 82 103 CASE WHEN tasks.due_date IS NOT NULL AND tasks.due_date < date('now') AND tasks.completed = 0 THEN 0 ELSE 1 END, 83 104 tasks.created_at DESC ··· 90 111 // Get single task 91 112 router.get('/:id', (c) => { 92 113 const task = db.prepare(` 93 - SELECT tasks.*, projects.name as project_name 114 + SELECT tasks.*, 115 + CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 94 116 FROM tasks 95 117 LEFT JOIN projects ON tasks.project_id = projects.id 96 118 WHERE tasks.id = ? ··· 130 152 `).run(title, description, priority, due_date || null, project_id || null); 131 153 132 154 const task = db.prepare(` 133 - SELECT tasks.*, projects.name as project_name 155 + SELECT tasks.*, 156 + CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 134 157 FROM tasks 135 158 LEFT JOIN projects ON tasks.project_id = projects.id 136 159 WHERE tasks.id = ? ··· 189 212 } 190 213 191 214 const updated = db.prepare(` 192 - SELECT tasks.*, projects.name as project_name 215 + SELECT tasks.*, 216 + CASE WHEN projects.name IS NOT NULL THEN projects.name ELSE NULL END as project_name 193 217 FROM tasks 194 218 LEFT JOIN projects ON tasks.project_id = projects.id 195 219 WHERE tasks.id = ? ··· 208 232 209 233 db.prepare('DELETE FROM tasks WHERE id = ?').run(id); 210 234 return c.body(null, 204); 211 - }); 212 - 213 - // Stats endpoint 214 - router.get('/stats', (c) => { 215 - const stats = db.prepare(` 216 - SELECT 217 - COUNT(*) as total_tasks, 218 - SUM(completed) as completed_tasks, 219 - SUM(CASE WHEN due_date IS NOT NULL AND due_date < date('now') AND completed = 0 THEN 1 ELSE 0 END) as overdue_tasks, 220 - ROUND( 221 - CASE 222 - WHEN COUNT(*) = 0 THEN 0 223 - ELSE (SUM(completed) * 100.0 / COUNT(*)) 224 - END, 225 - 1 226 - ) as completion_percentage 227 - FROM tasks 228 - `).get(); 229 - 230 - return c.json(stats); 231 235 }); 232 236 233 237 export default router;
+446 -413
examples/todo-app/src/generated/todos/web-experience.ts
··· 20 20 21 21 body { 22 22 font-family: system-ui, -apple-system, sans-serif; 23 - background-color: #fafafa; 23 + background-color: #f8f9fa; 24 24 color: #333; 25 25 line-height: 1.5; 26 26 } ··· 32 32 } 33 33 34 34 .header { 35 - margin-bottom: 30px; 35 + background: white; 36 + border-radius: 8px; 37 + padding: 24px; 38 + margin-bottom: 20px; 39 + box-shadow: 0 1px 3px rgba(0,0,0,0.1); 36 40 } 37 41 38 - h1 { 39 - font-size: 2rem; 40 - font-weight: 600; 41 - color: #1a1a1a; 42 - margin-bottom: 10px; 42 + .header h1 { 43 + margin-bottom: 20px; 44 + color: #2563eb; 43 45 } 44 46 45 47 .add-task-form { 46 - background: white; 47 - border-radius: 8px; 48 - padding: 20px; 49 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 50 - margin-bottom: 30px; 48 + display: grid; 49 + gap: 16px; 51 50 } 52 51 53 52 .form-row { 54 - display: flex; 55 - gap: 15px; 56 - margin-bottom: 15px; 57 - align-items: flex-start; 53 + display: grid; 54 + grid-template-columns: 1fr 1fr; 55 + gap: 16px; 58 56 } 59 57 60 58 .form-group { 61 - flex: 1; 59 + display: flex; 60 + flex-direction: column; 61 + gap: 4px; 62 + } 63 + 64 + .form-group.full-width { 65 + grid-column: 1 / -1; 62 66 } 63 67 64 68 label { 65 - display: block; 66 - margin-bottom: 5px; 67 69 font-weight: 500; 68 - color: #555; 70 + color: #374151; 69 71 } 70 72 71 73 input, select, textarea { 72 - width: 100%; 73 74 padding: 8px 12px; 74 - border: 1px solid #ddd; 75 - border-radius: 4px; 75 + border: 1px solid #d1d5db; 76 + border-radius: 6px; 76 77 font-size: 14px; 77 78 } 78 79 79 80 textarea { 80 81 resize: vertical; 81 - min-height: 60px; 82 + min-height: 80px; 82 83 } 83 84 84 85 .description-toggle { 85 86 background: none; 86 87 border: none; 87 - color: #666; 88 + color: #6b7280; 88 89 cursor: pointer; 89 90 font-size: 12px; 90 91 text-decoration: underline; 91 - margin-bottom: 10px; 92 92 } 93 93 94 94 .description-group { 95 95 display: none; 96 96 } 97 97 98 - .description-group.expanded { 99 - display: block; 98 + .description-group.visible { 99 + display: flex; 100 + flex-direction: column; 101 + gap: 4px; 100 102 } 101 103 102 104 .btn { 103 - background: #007bff; 104 - color: white; 105 - border: none; 106 105 padding: 10px 20px; 107 - border-radius: 4px; 106 + border: none; 107 + border-radius: 6px; 108 108 cursor: pointer; 109 - font-size: 14px; 110 109 font-weight: 500; 110 + transition: background-color 0.2s; 111 111 } 112 112 113 - .btn:hover { 114 - background: #0056b3; 113 + .btn-primary { 114 + background-color: #2563eb; 115 + color: white; 116 + } 117 + 118 + .btn-primary:hover { 119 + background-color: #1d4ed8; 115 120 } 116 121 117 - .filters { 122 + .sidebar { 118 123 background: white; 119 124 border-radius: 8px; 120 - padding: 15px; 125 + padding: 20px; 121 126 margin-bottom: 20px; 122 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 127 + box-shadow: 0 1px 3px rgba(0,0,0,0.1); 123 128 } 124 129 125 - .filter-row { 130 + .sidebar h3 { 131 + margin-bottom: 16px; 132 + color: #374151; 133 + } 134 + 135 + .project-list { 136 + list-style: none; 137 + } 138 + 139 + .project-item { 126 140 display: flex; 127 - gap: 15px; 128 141 align-items: center; 129 - flex-wrap: wrap; 142 + gap: 8px; 143 + padding: 8px 12px; 144 + border-radius: 6px; 145 + cursor: pointer; 146 + transition: background-color 0.2s; 147 + } 148 + 149 + .project-item:hover { 150 + background-color: #f3f4f6; 151 + } 152 + 153 + .project-item.active { 154 + background-color: #dbeafe; 155 + color: #1d4ed8; 156 + } 157 + 158 + .project-dot { 159 + width: 12px; 160 + height: 12px; 161 + border-radius: 50%; 162 + } 163 + 164 + .project-count { 165 + margin-left: auto; 166 + background-color: #e5e7eb; 167 + color: #6b7280; 168 + padding: 2px 6px; 169 + border-radius: 10px; 170 + font-size: 12px; 171 + } 172 + 173 + .main-content { 174 + background: white; 175 + border-radius: 8px; 176 + padding: 24px; 177 + box-shadow: 0 1px 3px rgba(0,0,0,0.1); 130 178 } 131 179 132 - .filter-buttons { 180 + .filters { 133 181 display: flex; 134 - gap: 5px; 182 + gap: 12px; 183 + margin-bottom: 20px; 184 + flex-wrap: wrap; 135 185 } 136 186 137 187 .filter-btn { 138 188 padding: 6px 12px; 139 - border: 1px solid #ddd; 189 + border: 1px solid #d1d5db; 140 190 background: white; 141 - border-radius: 4px; 191 + border-radius: 6px; 142 192 cursor: pointer; 143 - font-size: 13px; 193 + font-size: 14px; 144 194 } 145 195 146 196 .filter-btn.active { 147 - background: #007bff; 197 + background-color: #2563eb; 148 198 color: white; 149 - border-color: #007bff; 150 - } 151 - 152 - .filter-select { 153 - padding: 6px 10px; 154 - border: 1px solid #ddd; 155 - border-radius: 4px; 156 - font-size: 13px; 199 + border-color: #2563eb; 157 200 } 158 201 159 202 .task-list { 160 - background: white; 161 - border-radius: 8px; 162 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 203 + list-style: none; 163 204 } 164 205 165 206 .task-item { 166 - padding: 15px 20px; 167 - border-bottom: 1px solid #eee; 207 + display: flex; 208 + align-items: center; 209 + gap: 12px; 210 + padding: 16px; 211 + border: 1px solid #e5e7eb; 212 + border-radius: 8px; 213 + margin-bottom: 8px; 168 214 position: relative; 169 - cursor: pointer; 170 - } 171 - 172 - .task-item:last-child { 173 - border-bottom: none; 215 + transition: all 0.2s; 174 216 } 175 217 176 218 .task-item:hover { 177 - background: #f8f9fa; 219 + box-shadow: 0 2px 8px rgba(0,0,0,0.1); 178 220 } 179 221 180 222 .task-item.completed { ··· 186 228 } 187 229 188 230 .task-item.overdue { 189 - border-left: 4px solid #dc3545; 190 - } 191 - 192 - .task-header { 193 - display: flex; 194 - align-items: center; 195 - gap: 10px; 196 - margin-bottom: 8px; 231 + border-left: 4px solid #dc2626; 197 232 } 198 233 199 234 .task-checkbox { ··· 202 237 cursor: pointer; 203 238 } 204 239 240 + .task-content { 241 + flex: 1; 242 + min-width: 0; 243 + } 244 + 205 245 .task-title { 206 246 font-weight: 500; 207 - flex: 1; 208 - cursor: text; 247 + margin-bottom: 4px; 248 + cursor: pointer; 209 249 } 210 250 211 - .task-title.editing { 212 - background: white; 213 - border: 1px solid #007bff; 214 - border-radius: 3px; 215 - padding: 2px 6px; 251 + .task-meta { 252 + display: flex; 253 + gap: 8px; 254 + align-items: center; 255 + flex-wrap: wrap; 216 256 } 217 257 218 258 .priority-badge { 219 259 padding: 2px 8px; 220 260 border-radius: 12px; 221 - font-size: 11px; 261 + font-size: 12px; 222 262 font-weight: 500; 223 - text-transform: uppercase; 224 263 } 225 264 226 265 .priority-urgent { 227 - background: #dc3545; 228 - color: white; 266 + background-color: #fee2e2; 267 + color: #dc2626; 229 268 } 230 269 231 270 .priority-high { 232 - background: #fd7e14; 233 - color: white; 271 + background-color: #fed7aa; 272 + color: #ea580c; 234 273 } 235 274 236 275 .priority-normal { 237 - background: #007bff; 238 - color: white; 276 + background-color: #dbeafe; 277 + color: #2563eb; 239 278 } 240 279 241 280 .priority-low { 242 - background: #6c757d; 243 - color: white; 244 - } 245 - 246 - .task-meta { 247 - display: flex; 248 - gap: 15px; 249 - font-size: 13px; 250 - color: #666; 251 - align-items: center; 281 + background-color: #f3f4f6; 282 + color: #6b7280; 252 283 } 253 284 254 285 .project-name { 255 - color: #007bff; 286 + font-size: 12px; 287 + color: #6b7280; 256 288 } 257 289 258 290 .due-date { 259 - color: #666; 260 - } 261 - 262 - .due-date.overdue { 263 - color: #dc3545; 264 - font-weight: 500; 291 + font-size: 12px; 292 + color: #6b7280; 265 293 } 266 294 267 295 .overdue-badge { 268 - background: #dc3545; 296 + background-color: #dc2626; 269 297 color: white; 270 298 padding: 2px 6px; 271 - border-radius: 3px; 272 - font-size: 10px; 273 - font-weight: 500; 299 + border-radius: 10px; 300 + font-size: 11px; 274 301 } 275 302 276 303 .delete-btn { 277 304 position: absolute; 278 - right: 15px; 305 + right: 12px; 279 306 top: 50%; 280 307 transform: translateY(-50%); 281 - background: #dc3545; 308 + background-color: #dc2626; 282 309 color: white; 283 310 border: none; 284 311 border-radius: 4px; 285 - padding: 6px 10px; 312 + padding: 4px 8px; 313 + cursor: pointer; 286 314 font-size: 12px; 287 - cursor: pointer; 288 315 opacity: 0; 289 316 transition: opacity 0.2s; 290 317 } ··· 293 320 opacity: 1; 294 321 } 295 322 296 - .delete-btn:hover { 297 - background: #c82333; 298 - } 299 - 300 - .task-description { 301 - margin-top: 8px; 302 - color: #666; 303 - font-size: 14px; 304 - cursor: text; 323 + .stats { 324 + background-color: #f8f9fa; 325 + padding: 16px; 326 + border-radius: 6px; 327 + margin-bottom: 20px; 305 328 } 306 329 307 - .task-description.editing { 308 - background: white; 309 - border: 1px solid #007bff; 310 - border-radius: 3px; 311 - padding: 6px; 312 - resize: vertical; 313 - min-height: 60px; 330 + .stats-grid { 331 + display: grid; 332 + grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); 333 + gap: 16px; 314 334 } 315 335 316 - .empty-state { 336 + .stat-item { 317 337 text-align: center; 318 - padding: 40px 20px; 319 - color: #666; 320 338 } 321 339 322 - .sidebar { 323 - background: white; 324 - border-radius: 8px; 325 - padding: 20px; 326 - margin-bottom: 20px; 327 - box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); 328 - } 329 - 330 - .sidebar h3 { 331 - margin-bottom: 15px; 332 - color: #333; 340 + .stat-value { 341 + font-size: 24px; 342 + font-weight: bold; 343 + color: #2563eb; 333 344 } 334 345 335 - .project-list { 336 - list-style: none; 346 + .stat-label { 347 + font-size: 12px; 348 + color: #6b7280; 349 + text-transform: uppercase; 337 350 } 338 351 339 - .project-item { 340 - display: flex; 352 + .edit-form { 353 + display: none; 354 + grid-template-columns: 1fr auto; 355 + gap: 8px; 341 356 align-items: center; 342 - gap: 10px; 343 - padding: 8px 0; 344 - cursor: pointer; 345 - border-radius: 4px; 346 - padding-left: 8px; 347 - padding-right: 8px; 348 357 } 349 358 350 - .project-item:hover { 351 - background: #f8f9fa; 359 + .edit-form.visible { 360 + display: grid; 352 361 } 353 362 354 - .project-item.active { 355 - background: #e3f2fd; 356 - color: #1976d2; 363 + .edit-form input { 364 + padding: 4px 8px; 365 + font-size: 14px; 357 366 } 358 367 359 - .project-color { 360 - width: 12px; 361 - height: 12px; 362 - border-radius: 50%; 368 + .edit-buttons { 369 + display: flex; 370 + gap: 4px; 363 371 } 364 372 365 - .project-name-sidebar { 366 - flex: 1; 373 + .edit-btn { 374 + padding: 4px 8px; 375 + border: none; 376 + border-radius: 4px; 377 + cursor: pointer; 378 + font-size: 12px; 367 379 } 368 380 369 - .task-count { 370 - background: #e9ecef; 371 - color: #495057; 372 - padding: 2px 6px; 373 - border-radius: 10px; 374 - font-size: 11px; 375 - font-weight: 500; 381 + .edit-save { 382 + background-color: #059669; 383 + color: white; 376 384 } 377 385 378 - .main-content { 379 - display: grid; 380 - grid-template-columns: 250px 1fr; 381 - gap: 20px; 386 + .edit-cancel { 387 + background-color: #6b7280; 388 + color: white; 382 389 } 383 390 384 391 @media (max-width: 768px) { 385 - .main-content { 392 + .form-row { 386 393 grid-template-columns: 1fr; 387 394 } 388 395 389 - .form-row { 396 + .filters { 390 397 flex-direction: column; 391 398 } 392 399 393 - .filter-row { 400 + .task-meta { 394 401 flex-direction: column; 395 402 align-items: flex-start; 396 403 } ··· 401 408 <div class="container"> 402 409 <div class="header"> 403 410 <h1>Task Manager</h1> 404 - </div> 405 - 406 - <div class="add-task-form"> 407 - <form id="addTaskForm"> 411 + <form class="add-task-form" id="addTaskForm"> 412 + <div class="form-group full-width"> 413 + <label for="title">Task Title</label> 414 + <input type="text" id="title" name="title" required> 415 + </div> 416 + 417 + <button type="button" class="description-toggle" onclick="toggleDescription()"> 418 + + Add Description 419 + </button> 420 + 421 + <div class="form-group full-width description-group" id="descriptionGroup"> 422 + <label for="description">Description</label> 423 + <textarea id="description" name="description"></textarea> 424 + </div> 425 + 408 426 <div class="form-row"> 409 427 <div class="form-group"> 410 - <label for="taskTitle">Title</label> 411 - <input type="text" id="taskTitle" name="title" required> 412 - </div> 413 - <div class="form-group" style="flex: 0 0 150px;"> 414 - <label for="taskPriority">Priority</label> 415 - <select id="taskPriority" name="priority"> 428 + <label for="priority">Priority</label> 429 + <select id="priority" name="priority"> 416 430 <option value="normal">Normal</option> 417 431 <option value="low">Low</option> 418 432 <option value="high">High</option> 419 433 <option value="urgent">Urgent</option> 420 434 </select> 421 435 </div> 422 - </div> 423 - 424 - <button type="button" class="description-toggle" onclick="toggleDescription()">+ Add description</button> 425 - 426 - <div class="description-group" id="descriptionGroup"> 436 + 427 437 <div class="form-group"> 428 - <label for="taskDescription">Description</label> 429 - <textarea id="taskDescription" name="description" placeholder="Optional description..."></textarea> 438 + <label for="project">Project</label> 439 + <select id="project" name="project"> 440 + <option value="">Inbox</option> 441 + </select> 430 442 </div> 431 443 </div> 432 444 433 - <div class="form-row"> 434 - <div class="form-group"> 435 - <label for="taskProject">Project</label> 436 - <select id="taskProject" name="project_id"> 437 - <option value="">Inbox (No project)</option> 438 - </select> 439 - </div> 440 - <div class="form-group" style="flex: 0 0 150px;"> 441 - <label for="taskDueDate">Due Date</label> 442 - <input type="date" id="taskDueDate" name="due_date"> 443 - </div> 444 - <div class="form-group" style="flex: 0 0 auto;"> 445 - <label>&nbsp;</label> 446 - <button type="submit" class="btn">Add Task</button> 447 - </div> 445 + <div class="form-group"> 446 + <label for="dueDate">Due Date</label> 447 + <input type="date" id="dueDate" name="dueDate"> 448 448 </div> 449 + 450 + <button type="submit" class="btn btn-primary">Add Task</button> 449 451 </form> 450 452 </div> 451 453 454 + <div class="sidebar"> 455 + <h3>Projects</h3> 456 + <ul class="project-list" id="projectList"> 457 + <li class="project-item active" data-project-id=""> 458 + <div class="project-dot" style="background-color: #6b7280;"></div> 459 + <span>Inbox</span> 460 + <span class="project-count" id="inbox-count">0</span> 461 + </li> 462 + </ul> 463 + </div> 464 + 452 465 <div class="main-content"> 453 - <div class="sidebar"> 454 - <h3>Projects</h3> 455 - <ul class="project-list" id="projectList"> 456 - <li class="project-item active" data-project-id=""> 457 - <div class="project-color" style="background: #6c757d;"></div> 458 - <span class="project-name-sidebar">Inbox</span> 459 - <span class="task-count" id="inboxCount">0</span> 460 - </li> 461 - </ul> 462 - </div> 463 - 464 - <div class="main-area"> 465 - <div class="filters"> 466 - <div class="filter-row"> 467 - <div class="filter-buttons"> 468 - <button class="filter-btn active" data-status="all">All</button> 469 - <button class="filter-btn" data-status="active">Active</button> 470 - <button class="filter-btn" data-status="completed">Completed</button> 471 - </div> 472 - <select class="filter-select" id="priorityFilter"> 473 - <option value="">All priorities</option> 474 - <option value="urgent">Urgent</option> 475 - <option value="high">High</option> 476 - <option value="normal">Normal</option> 477 - <option value="low">Low</option> 478 - </select> 466 + <div class="stats" id="stats"> 467 + <div class="stats-grid"> 468 + <div class="stat-item"> 469 + <div class="stat-value" id="totalTasks">0</div> 470 + <div class="stat-label">Total</div> 471 + </div> 472 + <div class="stat-item"> 473 + <div class="stat-value" id="activeTasks">0</div> 474 + <div class="stat-label">Active</div> 475 + </div> 476 + <div class="stat-item"> 477 + <div class="stat-value" id="completedTasks">0</div> 478 + <div class="stat-label">Completed</div> 479 479 </div> 480 - </div> 481 - 482 - <div class="task-list" id="taskList"> 483 - <div class="empty-state"> 484 - <p>No tasks yet. Add your first task above!</p> 480 + <div class="stat-item"> 481 + <div class="stat-value" id="overdueTasks">0</div> 482 + <div class="stat-label">Overdue</div> 485 483 </div> 486 484 </div> 487 485 </div> 486 + 487 + <div class="filters"> 488 + <button class="filter-btn active" data-filter="all">All</button> 489 + <button class="filter-btn" data-filter="active">Active</button> 490 + <button class="filter-btn" data-filter="completed">Completed</button> 491 + 492 + <select class="filter-btn" id="priorityFilter"> 493 + <option value="">All Priorities</option> 494 + <option value="urgent">Urgent</option> 495 + <option value="high">High</option> 496 + <option value="normal">Normal</option> 497 + <option value="low">Low</option> 498 + </select> 499 + </div> 500 + 501 + <ul class="task-list" id="taskList"> 502 + </ul> 488 503 </div> 489 504 </div> 490 505 491 506 <script> 492 507 let currentProjectId = ''; 493 - let currentStatus = 'all'; 494 - let currentPriority = ''; 508 + let currentStatusFilter = 'all'; 509 + let currentPriorityFilter = ''; 495 510 let projects = []; 496 511 let tasks = []; 497 512 498 - function toggleDescription() { 499 - const group = document.getElementById('descriptionGroup'); 500 - const toggle = document.querySelector('.description-toggle'); 501 - if (group.classList.contains('expanded')) { 502 - group.classList.remove('expanded'); 503 - toggle.textContent = '+ Add description'; 504 - } else { 505 - group.classList.add('expanded'); 506 - toggle.textContent = '- Remove description'; 507 - } 508 - } 509 - 510 513 async function loadProjects() { 511 514 try { 512 515 const response = await fetch('/projects'); 513 516 projects = await response.json(); 514 - updateProjectDropdown(); 515 - updateProjectSidebar(); 517 + renderProjects(); 518 + loadProjectOptions(); 516 519 } catch (error) { 517 520 console.error('Failed to load projects:', error); 518 521 } 519 522 } 520 523 521 - function updateProjectDropdown() { 522 - const select = document.getElementById('taskProject'); 523 - select.innerHTML = '<option value="">Inbox (No project)</option>'; 524 - projects.forEach(project => { 525 - const option = document.createElement('option'); 526 - option.value = project.id; 527 - option.textContent = project.name; 528 - select.appendChild(option); 529 - }); 524 + async function loadTasks() { 525 + try { 526 + let url = '/tasks'; 527 + const params = new URLSearchParams(); 528 + 529 + if (currentProjectId) { 530 + params.append('project_id', currentProjectId); 531 + } else if (currentProjectId === '') { 532 + params.append('project_id', 'null'); 533 + } 534 + 535 + if (currentStatusFilter === 'active') { 536 + params.append('completed', '0'); 537 + } else if (currentStatusFilter === 'completed') { 538 + params.append('completed', '1'); 539 + } 540 + 541 + if (currentPriorityFilter) { 542 + params.append('priority', currentPriorityFilter); 543 + } 544 + 545 + if (params.toString()) { 546 + url += '?' + params.toString(); 547 + } 548 + 549 + const response = await fetch(url); 550 + tasks = await response.json(); 551 + renderTasks(); 552 + updateStats(); 553 + updateProjectCounts(); 554 + } catch (error) { 555 + console.error('Failed to load tasks:', error); 556 + } 530 557 } 531 558 532 - function updateProjectSidebar() { 533 - const list = document.getElementById('projectList'); 534 - const inboxItem = list.querySelector('[data-project-id=""]'); 559 + function renderProjects() { 560 + const projectList = document.getElementById('projectList'); 561 + const inboxItem = projectList.querySelector('[data-project-id=""]'); 535 562 536 - // Remove existing project items 537 - list.querySelectorAll('[data-project-id]:not([data-project-id=""])').forEach(item => item.remove()); 563 + // Remove existing project items (keep inbox) 564 + const existingProjects = projectList.querySelectorAll('[data-project-id]:not([data-project-id=""])'); 565 + existingProjects.forEach(item => item.remove()); 538 566 539 567 projects.forEach(project => { 540 568 const li = document.createElement('li'); 541 569 li.className = 'project-item'; 542 570 li.dataset.projectId = project.id; 543 - li.onclick = () => selectProject(project.id); 544 - 545 - const taskCount = tasks.filter(t => t.project_id === project.id && !t.completed).length; 546 - 547 571 li.innerHTML = \` 548 - <div class="project-color" style="background: \${project.color};"></div> 549 - <span class="project-name-sidebar">\${project.name}</span> 550 - <span class="task-count">\${taskCount}</span> 572 + <div class="project-dot" style="background-color: \${project.color};"></div> 573 + <span>\${project.name}</span> 574 + <span class="project-count" id="project-\${project.id}-count">0</span> 551 575 \`; 552 - 553 - list.appendChild(li); 576 + li.addEventListener('click', () => selectProject(project.id)); 577 + projectList.appendChild(li); 554 578 }); 579 + } 580 + 581 + function loadProjectOptions() { 582 + const projectSelect = document.getElementById('project'); 583 + projectSelect.innerHTML = '<option value="">Inbox</option>'; 555 584 556 - // Update inbox count 557 - const inboxCount = tasks.filter(t => !t.project_id && !t.completed).length; 558 - document.getElementById('inboxCount').textContent = inboxCount; 585 + projects.forEach(project => { 586 + const option = document.createElement('option'); 587 + option.value = project.id; 588 + option.textContent = project.name; 589 + projectSelect.appendChild(option); 590 + }); 559 591 } 560 592 561 593 function selectProject(projectId) { ··· 563 595 564 596 // Update active state 565 597 document.querySelectorAll('.project-item').forEach(item => { 566 - item.classList.toggle('active', item.dataset.projectId === projectId); 598 + item.classList.toggle('active', item.dataset.projectId == projectId); 567 599 }); 568 600 569 601 loadTasks(); 570 - } 571 - 572 - async function loadTasks() { 573 - try { 574 - const params = new URLSearchParams(); 575 - if (currentProjectId) params.set('project_id', currentProjectId); 576 - if (currentStatus === 'active') params.set('completed', '0'); 577 - if (currentStatus === 'completed') params.set('completed', '1'); 578 - if (currentPriority) params.set('priority', currentPriority); 579 - 580 - const response = await fetch('/tasks?' + params.toString()); 581 - tasks = await response.json(); 582 - renderTasks(); 583 - updateProjectSidebar(); 584 - } catch (error) { 585 - console.error('Failed to load tasks:', error); 586 - } 587 602 } 588 603 589 604 function renderTasks() { 590 - const container = document.getElementById('taskList'); 605 + const taskList = document.getElementById('taskList'); 606 + taskList.innerHTML = ''; 591 607 592 - if (tasks.length === 0) { 593 - container.innerHTML = '<div class="empty-state"><p>No tasks found.</p></div>'; 594 - return; 595 - } 596 - 597 - const now = new Date().toISOString().split('T')[0]; 598 - 599 - container.innerHTML = tasks.map(task => { 600 - const isOverdue = task.due_date && task.due_date < now && !task.completed; 601 - const project = projects.find(p => p.id === task.project_id); 608 + tasks.forEach(task => { 609 + const li = document.createElement('li'); 610 + li.className = \`task-item \${task.completed ? 'completed' : ''} \${isOverdue(task) ? 'overdue' : ''}\`; 602 611 603 - return \` 604 - <div class="task-item \${task.completed ? 'completed' : ''} \${isOverdue ? 'overdue' : ''}" data-task-id="\${task.id}"> 605 - <div class="task-header"> 606 - <input type="checkbox" class="task-checkbox" \${task.completed ? 'checked' : ''} 607 - onchange="toggleTask(\${task.id}, this.checked)"> 608 - <div class="task-title" onclick="editTitle(\${task.id}, this)">\${task.title}</div> 609 - <div class="priority-badge priority-\${task.priority}">\${task.priority}</div> 610 - <button class="delete-btn" onclick="deleteTask(\${task.id})">Delete</button> 612 + const projectName = task.project_name ? \`<span class="project-name">\${task.project_name}</span>\` : ''; 613 + const dueDate = task.due_date ? \`<span class="due-date">Due: \${formatDate(task.due_date)}</span>\` : ''; 614 + const overdueBadge = isOverdue(task) ? '<span class="overdue-badge">Overdue</span>' : ''; 615 + 616 + li.innerHTML = \` 617 + <input type="checkbox" class="task-checkbox" \${task.completed ? 'checked' : ''} 618 + onchange="toggleTask(\${task.id}, this.checked)"> 619 + <div class="task-content"> 620 + <div class="task-title" onclick="editTask(\${task.id})">\${task.title}</div> 621 + <div class="edit-form" id="edit-\${task.id}"> 622 + <input type="text" value="\${task.title}" onkeydown="handleEditKeydown(event, \${task.id})"> 623 + <div class="edit-buttons"> 624 + <button class="edit-btn edit-save" onclick="saveEdit(\${task.id})">Save</button> 625 + <button class="edit-btn edit-cancel" onclick="cancelEdit(\${task.id})">Cancel</button> 626 + </div> 611 627 </div> 612 628 <div class="task-meta"> 613 - \${project ? \`<span class="project-name">\${project.name}</span>\` : ''} 614 - \${task.due_date ? \`<span class="due-date \${isOverdue ? 'overdue' : ''}">\${formatDate(task.due_date)}</span>\` : ''} 615 - \${isOverdue ? '<span class="overdue-badge">OVERDUE</span>' : ''} 629 + <span class="priority-badge priority-\${task.priority}">\${task.priority}</span> 630 + \${projectName} 631 + \${dueDate} 632 + \${overdueBadge} 616 633 </div> 617 - \${task.description ? \`<div class="task-description" onclick="editDescription(\${task.id}, this)">\${task.description}</div>\` : ''} 618 634 </div> 635 + <button class="delete-btn" onclick="deleteTask(\${task.id})">Delete</button> 619 636 \`; 620 - }).join(''); 637 + 638 + taskList.appendChild(li); 639 + }); 640 + } 641 + 642 + function isOverdue(task) { 643 + if (!task.due_date || task.completed) return false; 644 + const today = new Date().toISOString().split('T')[0]; 645 + return task.due_date < today; 621 646 } 622 647 623 648 function formatDate(dateStr) { 624 - const date = new Date(dateStr + 'T00:00:00'); 625 - return date.toLocaleDateString(); 649 + return new Date(dateStr).toLocaleDateString(); 626 650 } 627 651 628 652 async function toggleTask(taskId, completed) { ··· 634 658 }); 635 659 loadTasks(); 636 660 } catch (error) { 637 - console.error('Failed to update task:', error); 661 + console.error('Failed to toggle task:', error); 638 662 } 639 663 } 640 664 ··· 649 673 } 650 674 } 651 675 652 - function editTitle(taskId, element) { 653 - if (element.classList.contains('editing')) return; 676 + function editTask(taskId) { 677 + const titleEl = document.querySelector(\`[onclick="editTask(\${taskId})"]\`); 678 + const editForm = document.getElementById(\`edit-\${taskId}\`); 654 679 655 - const originalText = element.textContent; 656 - element.classList.add('editing'); 657 - element.contentEditable = true; 658 - element.focus(); 680 + titleEl.style.display = 'none'; 681 + editForm.classList.add('visible'); 682 + editForm.querySelector('input').focus(); 683 + } 684 + 685 + function cancelEdit(taskId) { 686 + const titleEl = document.querySelector(\`[onclick="editTask(\${taskId})"]\`); 687 + const editForm = document.getElementById(\`edit-\${taskId}\`); 659 688 660 - const saveEdit = async () => { 661 - const newTitle = element.textContent.trim(); 662 - element.classList.remove('editing'); 663 - element.contentEditable = false; 664 - 665 - if (newTitle && newTitle !== originalText) { 666 - try { 667 - await fetch(\`/tasks/\${taskId}\`, { 668 - method: 'PATCH', 669 - headers: { 'Content-Type': 'application/json' }, 670 - body: JSON.stringify({ title: newTitle }) 671 - }); 672 - loadTasks(); 673 - } catch (error) { 674 - console.error('Failed to update task:', error); 675 - element.textContent = originalText; 676 - } 677 - } else { 678 - element.textContent = originalText; 679 - } 680 - }; 681 - 682 - element.addEventListener('blur', saveEdit, { once: true }); 683 - element.addEventListener('keydown', (e) => { 684 - if (e.key === 'Enter') { 685 - e.preventDefault(); 686 - element.blur(); 687 - } else if (e.key === 'Escape') { 688 - element.textContent = originalText; 689 - element.blur(); 690 - } 691 - }); 689 + titleEl.style.display = 'block'; 690 + editForm.classList.remove('visible'); 691 + } 692 + 693 + function handleEditKeydown(event, taskId) { 694 + if (event.key === 'Enter') { 695 + saveEdit(taskId); 696 + } else if (event.key === 'Escape') { 697 + cancelEdit(taskId); 698 + } 692 699 } 693 700 694 - function editDescription(taskId, element) { 695 - if (element.classList.contains('editing')) return; 701 + async function saveEdit(taskId) { 702 + const editForm = document.getElementById(\`edit-\${taskId}\`); 703 + const newTitle = editForm.querySelector('input').value.trim(); 696 704 697 - const originalText = element.textContent; 698 - element.classList.add('editing'); 699 - element.contentEditable = true; 700 - element.focus(); 705 + if (!newTitle) return; 701 706 702 - const saveEdit = async () => { 703 - const newDescription = element.textContent.trim(); 704 - element.classList.remove('editing'); 705 - element.contentEditable = false; 707 + try { 708 + await fetch(\`/tasks/\${taskId}\`, { 709 + method: 'PATCH', 710 + headers: { 'Content-Type': 'application/json' }, 711 + body: JSON.stringify({ title: newTitle }) 712 + }); 713 + loadTasks(); 714 + } catch (error) { 715 + console.error('Failed to update task:', error); 716 + } 717 + } 718 + 719 + function updateStats() { 720 + const total = tasks.length; 721 + const active = tasks.filter(t => !t.completed).length; 722 + const completed = tasks.filter(t => t.completed).length; 723 + const overdue = tasks.filter(t => isOverdue(t)).length; 724 + 725 + document.getElementById('totalTasks').textContent = total; 726 + document.getElementById('activeTasks').textContent = active; 727 + document.getElementById('completedTasks').textContent = completed; 728 + document.getElementById('overdueTasks').textContent = overdue; 729 + } 730 + 731 + async function updateProjectCounts() { 732 + try { 733 + const response = await fetch('/tasks'); 734 + const allTasks = await response.json(); 706 735 707 - if (newDescription !== originalText) { 708 - try { 709 - await fetch(\`/tasks/\${taskId}\`, { 710 - method: 'PATCH', 711 - headers: { 'Content-Type': 'application/json' }, 712 - body: JSON.stringify({ description: newDescription }) 713 - }); 714 - loadTasks(); 715 - } catch (error) { 716 - console.error('Failed to update task:', error); 717 - element.textContent = originalText; 718 - } 719 - } else { 720 - element.textContent = originalText; 721 - } 722 - }; 736 + // Update inbox count 737 + const inboxCount = allTasks.filter(t => !t.project_id && !t.completed).length; 738 + document.getElementById('inbox-count').textContent = inboxCount; 739 + 740 + // Update project counts 741 + projects.forEach(project => { 742 + const count = allTasks.filter(t => t.project_id === project.id && !t.completed).length; 743 + const countEl = document.getElementById(\`project-\${project.id}-count\`); 744 + if (countEl) countEl.textContent = count; 745 + }); 746 + } catch (error) { 747 + console.error('Failed to update project counts:', error); 748 + } 749 + } 750 + 751 + function toggleDescription() { 752 + const group = document.getElementById('descriptionGroup'); 753 + const toggle = document.querySelector('.description-toggle'); 723 754 724 - element.addEventListener('blur', saveEdit, { once: true }); 725 - element.addEventListener('keydown', (e) => { 726 - if (e.key === 'Escape') { 727 - element.textContent = originalText; 728 - element.blur(); 729 - } 730 - }); 755 + if (group.classList.contains('visible')) { 756 + group.classList.remove('visible'); 757 + toggle.textContent = '+ Add Description'; 758 + } else { 759 + group.classList.add('visible'); 760 + toggle.textContent = '- Remove Description'; 761 + } 731 762 } 732 763 733 764 // Event listeners ··· 739 770 title: formData.get('title'), 740 771 description: formData.get('description') || '', 741 772 priority: formData.get('priority'), 742 - project_id: formData.get('project_id') ? parseInt(formData.get('project_id')) : null, 743 - due_date: formData.get('due_date') || null 773 + project_id: formData.get('project') ? parseInt(formData.get('project')) : null, 774 + due_date: formData.get('dueDate') || null 744 775 }; 745 776 746 777 try { ··· 751 782 }); 752 783 753 784 e.target.reset(); 754 - document.getElementById('descriptionGroup').classList.remove('expanded'); 755 - document.querySelector('.description-toggle').textContent = '+ Add description'; 785 + document.getElementById('descriptionGroup').classList.remove('visible'); 786 + document.querySelector('.description-toggle').textContent = '+ Add Description'; 756 787 loadTasks(); 757 788 } catch (error) { 758 789 console.error('Failed to create task:', error); 759 790 } 760 791 }); 761 792 762 - // Filter buttons 763 - document.querySelectorAll('.filter-btn').forEach(btn => { 793 + // Filter event listeners 794 + document.querySelectorAll('.filter-btn[data-filter]').forEach(btn => { 764 795 btn.addEventListener('click', () => { 765 - document.querySelectorAll('.filter-btn').forEach(b => b.classList.remove('active')); 796 + currentStatusFilter = btn.dataset.filter; 797 + document.querySelectorAll('.filter-btn[data-filter]').forEach(b => b.classList.remove('active')); 766 798 btn.classList.add('active'); 767 - currentStatus = btn.dataset.status; 768 799 loadTasks(); 769 800 }); 770 801 }); 771 802 772 - // Priority filter 773 803 document.getElementById('priorityFilter').addEventListener('change', (e) => { 774 - currentPriority = e.target.value; 804 + currentPriorityFilter = e.target.value; 775 805 loadTasks(); 776 806 }); 777 807 778 - // Initialize 779 - loadProjects().then(() => loadTasks()); 808 + // Inbox click handler 809 + document.querySelector('[data-project-id=""]').addEventListener('click', () => selectProject('')); 810 + 811 + // Initial load 812 + loadProjects(); 813 + loadTasks(); 780 814 </script> 781 815 </body> 782 816 </html>`); ··· 784 818 785 819 export default router; 786 820 787 - /** @internal Phoenix VCS traceability — do not remove. */ 788 821 export const _phoenix = { 789 822 iu_id: '4cf8044124318345f799117c458dd89849b86274c5b49683e030c938686d4fed', 790 823 name: 'Web Experience',
+18
examples/todo-app/src/server.ts
··· 1 + import { serve } from '@hono/node-server'; 2 + import { app, mount } from './app.js'; 3 + import { runMigrations } from './db.js'; 4 + 5 + // Generated route modules 6 + import projects from './generated/todos/projects.js'; 7 + import tasks from './generated/todos/tasks.js'; 8 + import web_experience from './generated/todos/web-experience.js'; 9 + 10 + // Mount routes 11 + mount('/projects', projects); 12 + mount('/tasks', tasks); 13 + mount('', web_experience); 14 + 15 + const port = parseInt(process.env.PORT ?? '3000', 10); 16 + runMigrations(); 17 + console.log(`Server running at http://localhost:${port}`); 18 + serve({ fetch: app.fetch, port });
+51 -6
src/architectures/index.ts
··· 1 1 /** 2 - * Architecture Registry — built-in architecture targets. 2 + * Architecture & Runtime Target Registry 3 3 */ 4 4 5 - import type { Architecture } from '../models/architecture.js'; 6 - import { sqliteWebApi } from './sqlite-web-api.js'; 5 + import type { Architecture, RuntimeTarget, ResolvedTarget } from '../models/architecture.js'; 6 + import { webApi } from './web-api.js'; 7 + import { nodeTypescript } from './node-typescript.js'; 8 + 9 + // ─── Architecture registry ────────────────────────────────────────────────── 7 10 8 11 const ARCHITECTURES: Record<string, Architecture> = { 9 - 'sqlite-web-api': sqliteWebApi, 12 + 'web-api': webApi, 13 + }; 14 + 15 + // ─── Runtime target registry ──────────────────────────────────────────────── 16 + 17 + const RUNTIME_TARGETS: Record<string, RuntimeTarget> = { 18 + 'node-typescript': nodeTypescript, 10 19 }; 11 20 12 - export function getArchitecture(name: string): Architecture | null { 13 - return ARCHITECTURES[name] ?? null; 21 + // ─── Public API ───────────────────────────────────────────────────────────── 22 + 23 + /** 24 + * Resolve a target string like "web-api/node-typescript" or legacy "sqlite-web-api" 25 + * into a full ResolvedTarget. 26 + */ 27 + export function resolveTarget(target: string): ResolvedTarget | null { 28 + // Handle legacy name 29 + if (target === 'sqlite-web-api') { 30 + return resolveTarget('web-api/node-typescript'); 31 + } 32 + 33 + // Try "arch/runtime" format 34 + if (target.includes('/')) { 35 + const [archName, rtName] = target.split('/'); 36 + const arch = ARCHITECTURES[archName]; 37 + const rt = RUNTIME_TARGETS[rtName]; 38 + if (arch && rt) return { architecture: arch, runtime: rt }; 39 + return null; 40 + } 41 + 42 + // Try as architecture name, use first available runtime 43 + const arch = ARCHITECTURES[target]; 44 + if (arch && arch.runtimeTargets.length > 0) { 45 + const rt = RUNTIME_TARGETS[arch.runtimeTargets[0]]; 46 + if (rt) return { architecture: arch, runtime: rt }; 47 + } 48 + 49 + return null; 14 50 } 15 51 16 52 export function listArchitectures(): string[] { 17 53 return Object.keys(ARCHITECTURES); 18 54 } 55 + 56 + export function listRuntimeTargets(): string[] { 57 + return Object.keys(RUNTIME_TARGETS); 58 + } 59 + 60 + /** @deprecated — use resolveTarget instead */ 61 + export function getArchitecture(name: string): ResolvedTarget | null { 62 + return resolveTarget(name); 63 + }
+256
src/architectures/node-typescript.ts
··· 1 + /** 2 + * Runtime Target: node-typescript 3 + * 4 + * Compiles web-api architecture to Node.js + TypeScript. 5 + * Stack: Hono (HTTP) + better-sqlite3 (DB) + Zod (validation) 6 + */ 7 + 8 + import type { RuntimeTarget } from '../models/architecture.js'; 9 + 10 + // ─── Module template (LLM fills in marked sections) ───────────────────────── 11 + 12 + const MODULE_TEMPLATE = `import { Hono } from 'hono'; 13 + import { db, registerMigration } from '../../db.js'; 14 + import { z } from 'zod'; 15 + 16 + // ─── Database migrations ──────────────────────────────────────────────────── 17 + /* __MIGRATIONS__ */ 18 + 19 + // ─── Validation schemas ───────────────────────────────────────────────────── 20 + /* __SCHEMAS__ */ 21 + 22 + // ─── Routes ───────────────────────────────────────────────────────────────── 23 + const router = new Hono(); 24 + 25 + /* __ROUTES__ */ 26 + 27 + export default router; 28 + 29 + /* __PHOENIX_METADATA__ */ 30 + `; 31 + 32 + // ─── Shared files ─────────────────────────────────────────────────────────── 33 + 34 + const DB_FILE = `import Database from 'better-sqlite3'; 35 + import { existsSync, mkdirSync } from 'node:fs'; 36 + import { dirname } from 'node:path'; 37 + 38 + const DB_PATH = process.env.DB_PATH ?? 'data/app.db'; 39 + 40 + const dir = dirname(DB_PATH); 41 + if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); 42 + 43 + const db = new Database(DB_PATH); 44 + db.pragma('journal_mode = WAL'); 45 + db.pragma('foreign_keys = ON'); 46 + 47 + const migrations: Array<{ name: string; sql: string }> = []; 48 + 49 + export function registerMigration(name: string, sql: string): void { 50 + migrations.push({ name, sql }); 51 + } 52 + 53 + export function runMigrations(): void { 54 + for (const m of migrations) { 55 + db.exec(m.sql); 56 + } 57 + } 58 + 59 + export { db }; 60 + export type { Database }; 61 + `; 62 + 63 + const APP_FILE = `import { Hono } from 'hono'; 64 + import { logger } from 'hono/logger'; 65 + import { cors } from 'hono/cors'; 66 + 67 + const app = new Hono(); 68 + 69 + app.use('*', logger()); 70 + app.use('*', cors()); 71 + 72 + app.get('/health', (c) => c.json({ status: 'ok', uptime: process.uptime() })); 73 + 74 + app.onError((err, c) => { 75 + console.error('Unhandled error:', err.message, err.stack); 76 + return c.json({ error: err.message }, 500); 77 + }); 78 + 79 + export function mount(path: string, router: Hono): void { 80 + app.route(path, router); 81 + } 82 + 83 + export { app }; 84 + `; 85 + 86 + // ─── Prompt extension ─────────────────────────────────────────────────────── 87 + 88 + const PROMPT_EXTENSION = ` 89 + ## Runtime: Node.js + TypeScript (Hono + better-sqlite3 + Zod) 90 + 91 + You are filling in sections of a module template. The imports, router, and exports are already provided. 92 + You MUST output ONLY the content for the marked sections, in this exact format: 93 + 94 + \`\`\` 95 + __MIGRATIONS__ 96 + registerMigration('tablename', \` 97 + CREATE TABLE IF NOT EXISTS tablename ( 98 + id INTEGER PRIMARY KEY AUTOINCREMENT, 99 + ...columns... 100 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 101 + ) 102 + \`); 103 + 104 + __SCHEMAS__ 105 + const CreateSchema = z.object({ ... }); 106 + const UpdateSchema = z.object({ ... }); 107 + 108 + __ROUTES__ 109 + router.get('/', (c) => { ... }); 110 + router.post('/', async (c) => { ... }); 111 + router.get('/:id', (c) => { ... }); 112 + router.patch('/:id', async (c) => { ... }); 113 + router.delete('/:id', (c) => { ... }); 114 + \`\`\` 115 + 116 + ### Rules 117 + - Use better-sqlite3 synchronous API: db.prepare(sql).run(), .get(), .all() 118 + - Use parameterized queries ALWAYS — never interpolate user input into SQL 119 + - In SQL, use single quotes for string literals: datetime('now'). NEVER double quotes. 120 + - ALWAYS use snake_case for column names and JSON response keys 121 + - Nullable FK fields: z.number().int().nullable().optional() 122 + - FK validation: if (fk_id != null) { check exists } (loose equality) 123 + - LEFT JOIN to include related resource names (e.g., project_name) 124 + - Query parameter filtering: build WHERE clause dynamically from c.req.query() 125 + - Return created/updated resource after mutation 126 + - 200=read, 201=create, 204=delete, 400=validation, 404=not found 127 + 128 + ### Web interface modules 129 + - Return c.html() with a complete HTML document 130 + - Use fetch('/resource-name') to call sibling API modules (no /api/ prefix) 131 + - Include ALL CSS and JavaScript inline 132 + `; 133 + 134 + // ─── Code examples ────────────────────────────────────────────────────────── 135 + 136 + const CODE_EXAMPLES = ` 137 + ## Example: CRUD module sections for a "notes" resource 138 + 139 + \`\`\` 140 + __MIGRATIONS__ 141 + registerMigration('notes', \` 142 + CREATE TABLE IF NOT EXISTS notes ( 143 + id INTEGER PRIMARY KEY AUTOINCREMENT, 144 + title TEXT NOT NULL, 145 + body TEXT NOT NULL DEFAULT '', 146 + category_id INTEGER REFERENCES categories(id), 147 + created_at TEXT NOT NULL DEFAULT (datetime('now')) 148 + ) 149 + \`); 150 + 151 + __SCHEMAS__ 152 + const CreateNoteSchema = z.object({ 153 + title: z.string().min(1).max(200), 154 + body: z.string().optional().default(''), 155 + category_id: z.number().int().nullable().optional(), 156 + }); 157 + 158 + const UpdateNoteSchema = z.object({ 159 + title: z.string().min(1).max(200).optional(), 160 + body: z.string().optional(), 161 + category_id: z.number().int().nullable().optional(), 162 + }); 163 + 164 + __ROUTES__ 165 + router.get('/', (c) => { 166 + let sql = 'SELECT notes.*, categories.name as category_name FROM notes LEFT JOIN categories ON notes.category_id = categories.id'; 167 + const conditions: string[] = []; 168 + const params: unknown[] = []; 169 + const categoryId = c.req.query('category_id'); 170 + if (categoryId !== undefined) { conditions.push('notes.category_id = ?'); params.push(Number(categoryId)); } 171 + if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND '); 172 + sql += ' ORDER BY notes.created_at DESC'; 173 + return c.json(db.prepare(sql).all(...params)); 174 + }); 175 + 176 + router.get('/:id', (c) => { 177 + const note = db.prepare('SELECT notes.*, categories.name as category_name FROM notes LEFT JOIN categories ON notes.category_id = categories.id WHERE notes.id = ?').get(c.req.param('id')); 178 + if (!note) return c.json({ error: 'Not found' }, 404); 179 + return c.json(note); 180 + }); 181 + 182 + router.post('/', async (c) => { 183 + let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 184 + const result = CreateNoteSchema.safeParse(body); 185 + if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 186 + const { title, body: noteBody, category_id } = result.data; 187 + if (category_id != null) { 188 + if (!db.prepare('SELECT id FROM categories WHERE id = ?').get(category_id)) return c.json({ error: 'Category not found' }, 400); 189 + } 190 + const info = db.prepare('INSERT INTO notes (title, body, category_id) VALUES (?, ?, ?)').run(title, noteBody, category_id ?? null); 191 + const note = db.prepare('SELECT notes.*, categories.name as category_name FROM notes LEFT JOIN categories ON notes.category_id = categories.id WHERE notes.id = ?').get(info.lastInsertRowid); 192 + return c.json(note, 201); 193 + }); 194 + 195 + router.patch('/:id', async (c) => { 196 + const id = c.req.param('id'); 197 + if (!db.prepare('SELECT id FROM notes WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 198 + let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 199 + const result = UpdateNoteSchema.safeParse(body); 200 + if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 201 + const u = result.data; 202 + if (u.title !== undefined) db.prepare('UPDATE notes SET title = ? WHERE id = ?').run(u.title, id); 203 + if (u.body !== undefined) db.prepare('UPDATE notes SET body = ? WHERE id = ?').run(u.body, id); 204 + if (u.category_id !== undefined) db.prepare('UPDATE notes SET category_id = ? WHERE id = ?').run(u.category_id, id); 205 + return c.json(db.prepare('SELECT notes.*, categories.name as category_name FROM notes LEFT JOIN categories ON notes.category_id = categories.id WHERE notes.id = ?').get(id)); 206 + }); 207 + 208 + router.delete('/:id', (c) => { 209 + const id = c.req.param('id'); 210 + if (!db.prepare('SELECT id FROM notes WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 211 + db.prepare('DELETE FROM notes WHERE id = ?').run(id); 212 + return c.body(null, 204); 213 + }); 214 + \`\`\` 215 + `; 216 + 217 + // ─── Export ───────────────────────────────────────────────────────────────── 218 + 219 + export const nodeTypescript: RuntimeTarget = { 220 + name: 'node-typescript', 221 + description: 'Node.js + TypeScript — Hono, better-sqlite3, Zod', 222 + language: 'typescript', 223 + 224 + packages: { 225 + 'hono': '^4.6.0', 226 + '@hono/node-server': '^1.13.0', 227 + 'better-sqlite3': '^11.7.0', 228 + 'zod': '^3.24.0', 229 + }, 230 + 231 + devPackages: { 232 + 'typescript': '^5.4.0', 233 + 'vitest': '^2.0.0', 234 + '@types/node': '^22.0.0', 235 + '@types/better-sqlite3': '^7.6.0', 236 + 'tsx': '^4.0.0', 237 + }, 238 + 239 + moduleTemplate: MODULE_TEMPLATE, 240 + promptExtension: PROMPT_EXTENSION, 241 + codeExamples: CODE_EXAMPLES, 242 + 243 + sharedFiles: { 244 + 'src/db.ts': DB_FILE, 245 + 'src/app.ts': APP_FILE, 246 + }, 247 + 248 + packageExtras: { 249 + scripts: { 250 + dev: 'tsx watch src/server.ts', 251 + start: 'tsx src/server.ts', 252 + build: 'tsc', 253 + test: 'vitest run', 254 + }, 255 + }, 256 + };
-411
src/architectures/sqlite-web-api.ts
··· 1 - /** 2 - * Architecture Target: sqlite-web-api 3 - * 4 - * A lightweight REST API backed by SQLite. 5 - * Stack: Hono (HTTP) + better-sqlite3 (DB) + Zod (validation) 6 - */ 7 - 8 - import type { Architecture } from '../models/architecture.js'; 9 - 10 - // ─── Shared files (written once, not generated by LLM) ───────────────────── 11 - 12 - const DB_FILE = `import Database from 'better-sqlite3'; 13 - import { existsSync, mkdirSync } from 'node:fs'; 14 - import { dirname } from 'node:path'; 15 - 16 - const DB_PATH = process.env.DB_PATH ?? 'data/app.db'; 17 - 18 - // Ensure directory exists 19 - const dir = dirname(DB_PATH); 20 - if (!existsSync(dir)) mkdirSync(dir, { recursive: true }); 21 - 22 - const db = new Database(DB_PATH); 23 - 24 - // Enable WAL mode for better concurrent read performance 25 - db.pragma('journal_mode = WAL'); 26 - db.pragma('foreign_keys = ON'); 27 - 28 - /** 29 - * Run migrations. Each migration is idempotent (CREATE TABLE IF NOT EXISTS). 30 - * Modules register their migrations at import time. 31 - */ 32 - const migrations: Array<{ name: string; sql: string }> = []; 33 - 34 - export function registerMigration(name: string, sql: string): void { 35 - migrations.push({ name, sql }); 36 - } 37 - 38 - export function runMigrations(): void { 39 - for (const m of migrations) { 40 - db.exec(m.sql); 41 - } 42 - } 43 - 44 - export { db }; 45 - export type { Database }; 46 - `; 47 - 48 - const APP_FILE = `import { Hono } from 'hono'; 49 - import { logger } from 'hono/logger'; 50 - import { cors } from 'hono/cors'; 51 - 52 - const app = new Hono(); 53 - 54 - app.use('*', logger()); 55 - app.use('*', cors()); 56 - 57 - app.get('/health', (c) => c.json({ status: 'ok', uptime: process.uptime() })); 58 - 59 - // Error handler — log details and return JSON 60 - app.onError((err, c) => { 61 - console.error('Unhandled error:', err.message, err.stack); 62 - return c.json({ error: err.message }, 500); 63 - }); 64 - 65 - /** 66 - * Mount a route module. Call this for each generated module. 67 - */ 68 - export function mount(path: string, router: Hono): void { 69 - app.route(path, router); 70 - } 71 - 72 - export { app }; 73 - `; 74 - 75 - const SERVER_FILE = `import { serve } from '@hono/node-server'; 76 - import { app } from './app.js'; 77 - import { runMigrations } from './db.js'; 78 - 79 - // Import generated route modules (populated by scaffold) 80 - // __ROUTE_IMPORTS__ 81 - 82 - const port = parseInt(process.env.PORT ?? '3000', 10); 83 - 84 - // Run database migrations before starting 85 - runMigrations(); 86 - 87 - console.log(\`Server running at http://localhost:\${port}\`); 88 - serve({ fetch: app.fetch, port }); 89 - `; 90 - 91 - // ─── LLM prompt extension ─────────────────────────────────────────────────── 92 - 93 - const SYSTEM_PROMPT_EXTENSION = ` 94 - ## Architecture: sqlite-web-api 95 - 96 - You are generating a route handler module for a Hono REST API backed by SQLite. 97 - 98 - ### Translating user requirements to implementation 99 - - The spec describes what USERS do, not API endpoints. YOU must derive the REST endpoints, database schema, and SQL queries from the user behaviors. 100 - - "Users can create X" → POST endpoint with validation + INSERT query 101 - - "Users can view X" → GET endpoint with SELECT query (consider JOINs for related data) 102 - - "Users can edit X" → PATCH endpoint with UPDATE query 103 - - "Users can delete X" → DELETE endpoint with safety checks 104 - - "Users can filter by Y" → query parameters on GET endpoints (?status=active&priority=urgent) 105 - - "Show X sorted by Y" → ORDER BY clause in SQL 106 - - "X must be visually highlighted" → this is a UI concern handled by the web interface module, not the API 107 - - "Expose a programmatic interface" → this is what you're building: the REST API IS the programmatic interface 108 - 109 - ### CRITICAL import rules — follow EXACTLY, no exceptions 110 - 111 - Your file MUST start with these EXACT three import lines: 112 - \`\`\` 113 - import { Hono } from 'hono'; 114 - import { db, registerMigration } from '../../db.js'; 115 - import { z } from 'zod'; 116 - \`\`\` 117 - 118 - ### FORBIDDEN — these will cause the build to fail 119 - - FORBIDDEN: \`import Database from 'better-sqlite3'\` — NEVER import better-sqlite3 directly 120 - - FORBIDDEN: \`new Database(...)\` — NEVER create a database instance 121 - - FORBIDDEN: \`const db = ...\` — the db variable comes from the import above 122 - - Use ONLY the \`db\` import from \`../../db.js\` for ALL database operations 123 - 124 - ### Module structure 125 - - Export a Hono router instance as the DEFAULT export: \`export default router;\` 126 - - Register your table schema via \`registerMigration('tablename', 'CREATE TABLE IF NOT EXISTS ...')\` at module scope. 127 - - Include ALL CRUD routes for the resource in a single module (GET list, GET by id, POST create, PATCH update, DELETE). 128 - - Use better-sqlite3 synchronous API: db.prepare(sql).run(), .get(), .all() 129 - - Use parameterized queries ALWAYS — never interpolate user input into SQL. 130 - - In SQL strings, ALWAYS use single quotes for string literals: date('now'), datetime('now'). NEVER use double quotes — SQLite treats double quotes as column identifiers. 131 - - Parse request bodies with \`await c.req.json()\` and validate with Zod .safeParse(). 132 - 133 - ### Response conventions 134 - - 200 for successful reads 135 - - 201 for successful creates (return the created resource) 136 - - 204 for successful deletes (return c.body(null, 204)) 137 - - 400 for validation errors: \`{ error: "message" }\` 138 - - 404 for not found: \`{ error: "Not found" }\` 139 - 140 - ### Data model 141 - - Use integer primary keys with AUTOINCREMENT. 142 - - ALWAYS use snake_case for column names: category_id, created_at, updated_at — NEVER categoryid or createdat. 143 - - ALWAYS use snake_case for JSON response keys: category_name, created_at — NEVER categoryname or createdat. 144 - - Include \`created_at TEXT NOT NULL DEFAULT (datetime('now'))\` for timestamps. 145 - - Foreign key columns must match the referenced table: \`category_id INTEGER REFERENCES categories(id)\` 146 - - Nullable foreign key fields in Zod schemas MUST use \`.nullable().optional()\` — both null and undefined mean "no reference". Example: \`project_id: z.number().int().nullable().optional()\` 147 - - When validating FK references before INSERT, check \`if (project_id != null)\` (loose equality) to skip validation for BOTH null and undefined. Never use \`!== undefined\` alone. 148 - 149 - ### Stats / aggregate endpoints 150 - - If the spec describes a stats or aggregate endpoint, implement it as a route on the same router. 151 - - Use SQL aggregate functions (COUNT, SUM, AVG) with GROUP BY. 152 - - Return the JSON structure EXACTLY as the spec describes, using snake_case keys. 153 - 154 - ### Web interface / HTML pages 155 - - If the spec describes a web interface or HTML page, generate a Hono route that returns \`c.html()\` with a complete HTML string. 156 - - The web module ONLY serves HTML at GET /. It must NOT define any API routes. 157 - - The JavaScript in the HTML must call the OTHER modules' endpoints using their mount paths. For example, if there is a "Tasks" module mounted at /tasks and a "Projects" module mounted at /projects, the JavaScript uses fetch('/tasks'), fetch('/projects'), etc. 158 - - The mount paths follow the pattern: /lowercase-module-name (e.g., "Tasks" → /tasks, "Quick Stats" → /quick-stats, "Projects" → /projects). 159 - - Include ALL CSS and JavaScript inline in the HTML — no external files or build steps. 160 - - After any create/update/delete action, refresh the displayed data by re-fetching. 161 - - Use modern vanilla JavaScript (no frameworks). Use template literals for HTML generation. 162 - - The HTML must be a complete document with <!DOCTYPE html>, <head>, and <body>. 163 - - Do NOT prefix API calls with /api/. The endpoints are at the root level: /tasks, /projects, etc. 164 - `; 165 - 166 - const CODE_EXAMPLES = ` 167 - ## Architecture Pattern Examples 168 - 169 - ### Example 1: CRUD route module for a "notes" resource 170 - 171 - \`\`\`typescript 172 - import { Hono } from 'hono'; 173 - import { z } from 'zod'; 174 - import { db, registerMigration } from '../../db.js'; 175 - 176 - // Register table migration 177 - registerMigration('notes', \` 178 - CREATE TABLE IF NOT EXISTS notes ( 179 - id INTEGER PRIMARY KEY AUTOINCREMENT, 180 - title TEXT NOT NULL, 181 - body TEXT NOT NULL DEFAULT '', 182 - created_at TEXT NOT NULL DEFAULT (datetime('now')) 183 - ) 184 - \`); 185 - 186 - const CreateNoteSchema = z.object({ 187 - title: z.string().min(1).max(200), 188 - body: z.string().optional().default(''), 189 - }); 190 - 191 - const UpdateNoteSchema = z.object({ 192 - title: z.string().min(1).max(200).optional(), 193 - body: z.string().optional(), 194 - }); 195 - 196 - const router = new Hono(); 197 - 198 - // List all notes 199 - router.get('/', (c) => { 200 - const notes = db.prepare('SELECT * FROM notes ORDER BY created_at DESC').all(); 201 - return c.json(notes); 202 - }); 203 - 204 - // Get single note 205 - router.get('/:id', (c) => { 206 - const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(c.req.param('id')); 207 - if (!note) return c.json({ error: 'Note not found' }, 404); 208 - return c.json(note); 209 - }); 210 - 211 - // Create note 212 - router.post('/', async (c) => { 213 - const result = CreateNoteSchema.safeParse(await c.req.json()); 214 - if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 215 - const { title, body } = result.data; 216 - const info = db.prepare('INSERT INTO notes (title, body) VALUES (?, ?)').run(title, body); 217 - const note = db.prepare('SELECT * FROM notes WHERE id = ?').get(info.lastInsertRowid); 218 - return c.json(note, 201); 219 - }); 220 - 221 - // Update note 222 - router.patch('/:id', async (c) => { 223 - const id = c.req.param('id'); 224 - const existing = db.prepare('SELECT * FROM notes WHERE id = ?').get(id); 225 - if (!existing) return c.json({ error: 'Note not found' }, 404); 226 - const result = UpdateNoteSchema.safeParse(await c.req.json()); 227 - if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 228 - const updates = result.data; 229 - if (updates.title !== undefined) db.prepare('UPDATE notes SET title = ? WHERE id = ?').run(updates.title, id); 230 - if (updates.body !== undefined) db.prepare('UPDATE notes SET body = ? WHERE id = ?').run(updates.body, id); 231 - const updated = db.prepare('SELECT * FROM notes WHERE id = ?').get(id); 232 - return c.json(updated); 233 - }); 234 - 235 - // Delete note 236 - router.delete('/:id', (c) => { 237 - const id = c.req.param('id'); 238 - const existing = db.prepare('SELECT * FROM notes WHERE id = ?').get(id); 239 - if (!existing) return c.json({ error: 'Note not found' }, 404); 240 - db.prepare('DELETE FROM notes WHERE id = ?').run(id); 241 - return c.body(null, 204); 242 - }); 243 - 244 - export default router; 245 - 246 - export const _phoenix = { 247 - iu_id: 'example', 248 - name: 'Notes', 249 - risk_tier: 'medium', 250 - canon_ids: [], 251 - } as const; 252 - \`\`\` 253 - 254 - ### Example 2: Resource with foreign keys, JOINs, filtering, and cascade protection 255 - 256 - \`\`\`typescript 257 - import { Hono } from 'hono'; 258 - import { db, registerMigration } from '../../db.js'; 259 - import { z } from 'zod'; 260 - 261 - // Register migrations for BOTH tables this module touches 262 - registerMigration('projects', \` 263 - CREATE TABLE IF NOT EXISTS projects ( 264 - id INTEGER PRIMARY KEY AUTOINCREMENT, 265 - name TEXT NOT NULL UNIQUE, 266 - created_at TEXT NOT NULL DEFAULT (datetime('now')) 267 - ) 268 - \`); 269 - 270 - registerMigration('tasks', \` 271 - CREATE TABLE IF NOT EXISTS tasks ( 272 - id INTEGER PRIMARY KEY AUTOINCREMENT, 273 - title TEXT NOT NULL, 274 - done INTEGER NOT NULL DEFAULT 0, 275 - project_id INTEGER REFERENCES projects(id), 276 - created_at TEXT NOT NULL DEFAULT (datetime('now')) 277 - ) 278 - \`); 279 - 280 - const CreateTaskSchema = z.object({ 281 - title: z.string().min(1).max(200), 282 - project_id: z.number().int().optional(), 283 - }); 284 - 285 - const UpdateTaskSchema = z.object({ 286 - title: z.string().min(1).max(200).optional(), 287 - done: z.number().int().min(0).max(1).optional(), 288 - project_id: z.number().int().nullable().optional(), 289 - }); 290 - 291 - const router = new Hono(); 292 - 293 - // List with LEFT JOIN and optional query filters 294 - router.get('/', (c) => { 295 - let sql = 'SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id'; 296 - const conditions: string[] = []; 297 - const params: unknown[] = []; 298 - 299 - const done = c.req.query('done'); 300 - if (done !== undefined) { conditions.push('tasks.done = ?'); params.push(Number(done)); } 301 - 302 - const projectId = c.req.query('project_id'); 303 - if (projectId !== undefined) { conditions.push('tasks.project_id = ?'); params.push(Number(projectId)); } 304 - 305 - if (conditions.length > 0) sql += ' WHERE ' + conditions.join(' AND '); 306 - sql += ' ORDER BY tasks.created_at DESC'; 307 - 308 - return c.json(db.prepare(sql).all(...params)); 309 - }); 310 - 311 - // Get single with JOIN 312 - router.get('/:id', (c) => { 313 - const row = db.prepare('SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id WHERE tasks.id = ?').get(c.req.param('id')); 314 - if (!row) return c.json({ error: 'Not found' }, 404); 315 - return c.json(row); 316 - }); 317 - 318 - // Create with FK validation 319 - router.post('/', async (c) => { 320 - let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 321 - const result = CreateTaskSchema.safeParse(body); 322 - if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 323 - const { title, project_id } = result.data; 324 - if (project_id !== undefined) { 325 - const proj = db.prepare('SELECT id FROM projects WHERE id = ?').get(project_id); 326 - if (!proj) return c.json({ error: 'Project not found' }, 400); 327 - } 328 - const info = db.prepare('INSERT INTO tasks (title, project_id) VALUES (?, ?)').run(title, project_id ?? null); 329 - const task = db.prepare('SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id WHERE tasks.id = ?').get(info.lastInsertRowid); 330 - return c.json(task, 201); 331 - }); 332 - 333 - // Update 334 - router.patch('/:id', async (c) => { 335 - const id = c.req.param('id'); 336 - if (!db.prepare('SELECT id FROM todos WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 337 - let body; try { body = await c.req.json(); } catch { return c.json({ error: 'Invalid JSON' }, 400); } 338 - const result = UpdateTaskSchema.safeParse(body); 339 - if (!result.success) return c.json({ error: result.error.issues[0].message }, 400); 340 - const u = result.data; 341 - if (u.title !== undefined) db.prepare('UPDATE tasks SET title = ? WHERE id = ?').run(u.title, id); 342 - if (u.done !== undefined) db.prepare('UPDATE tasks SET done = ? WHERE id = ?').run(u.done, id); 343 - if (u.project_id !== undefined) db.prepare('UPDATE tasks SET project_id = ? WHERE id = ?').run(u.project_id, id); 344 - return c.json(db.prepare('SELECT tasks.*, projects.name as project_name FROM tasks LEFT JOIN projects ON tasks.project_id = projects.id WHERE tasks.id = ?').get(id)); 345 - }); 346 - 347 - // Delete 348 - router.delete('/:id', (c) => { 349 - const id = c.req.param('id'); 350 - if (!db.prepare('SELECT id FROM tasks WHERE id = ?').get(id)) return c.json({ error: 'Not found' }, 404); 351 - db.prepare('DELETE FROM tasks WHERE id = ?').run(id); 352 - return c.body(null, 204); 353 - }); 354 - 355 - export default router; 356 - export const _phoenix = { iu_id: 'example2', name: 'Tasks', risk_tier: 'medium', canon_ids: [] } as const; 357 - \`\`\` 358 - 359 - ### Key patterns: 360 - 1. \`import { db, registerMigration } from '../../db.js'\` — ALWAYS use this exact import 361 - 2. registerMigration() for ALL tables the module touches, including referenced tables 362 - 3. LEFT JOIN to include related resource names (e.g., category_name, project_name) 363 - 4. Query parameter filtering with dynamic WHERE clause building 364 - 5. Foreign key validation: check referenced row exists before INSERT 365 - 6. Cascade protection: check for dependent rows before DELETE of parent resource 366 - 7. Zod schemas for create/update validation — use z.enum() for fixed sets like priority levels 367 - 8. Return the created/updated resource with JOINed data after mutation 368 - 9. Export default router + export _phoenix metadata 369 - 10. For sorting by priority/urgency, use a CASE expression: ORDER BY CASE priority WHEN 'urgent' THEN 0 WHEN 'high' THEN 1 WHEN 'normal' THEN 2 WHEN 'low' THEN 3 END 370 - 11. For "overdue" logic: WHERE due_date < date('now') AND completed = 0 371 - `; 372 - 373 - // ─── Architecture definition ──────────────────────────────────────────────── 374 - 375 - export const sqliteWebApi: Architecture = { 376 - name: 'sqlite-web-api', 377 - description: 'REST API backed by SQLite — Hono + better-sqlite3 + Zod', 378 - runtime: 'node', 379 - 380 - packages: { 381 - 'hono': '^4.6.0', 382 - '@hono/node-server': '^1.13.0', 383 - 'better-sqlite3': '^11.7.0', 384 - 'zod': '^3.24.0', 385 - }, 386 - 387 - devPackages: { 388 - 'typescript': '^5.4.0', 389 - 'vitest': '^2.0.0', 390 - '@types/node': '^22.0.0', 391 - '@types/better-sqlite3': '^7.6.0', 392 - 'tsx': '^4.0.0', 393 - }, 394 - 395 - systemPromptExtension: SYSTEM_PROMPT_EXTENSION, 396 - codeExamples: CODE_EXAMPLES, 397 - 398 - sharedFiles: { 399 - 'src/db.ts': DB_FILE, 400 - 'src/app.ts': APP_FILE, 401 - }, 402 - 403 - packageJsonExtras: { 404 - scripts: { 405 - dev: 'tsx watch src/server.ts', 406 - start: 'tsx src/server.ts', 407 - build: 'tsc', 408 - test: 'vitest run', 409 - }, 410 - }, 411 - };
+63
src/architectures/web-api.ts
··· 1 + /** 2 + * Architecture: web-api 3 + * 4 + * An API-driven web application. Components communicate via REST endpoints. 5 + * Each resource owns its data mutations. Components are independently 6 + * testable via HTTP endpoint contracts. 7 + * 8 + * This architecture is language/runtime agnostic. 9 + */ 10 + 11 + import type { Architecture } from '../models/architecture.js'; 12 + 13 + export const webApi: Architecture = { 14 + name: 'web-api', 15 + description: 'API-driven web application — REST endpoints, resource-oriented, independently testable', 16 + 17 + communicationPattern: 'rest', 18 + dataOwnership: 'per-component', 19 + evaluationSurface: 'http-endpoints', 20 + 21 + systemPrompt: `## Architecture: API-driven Web Application 22 + 23 + This system is an API-driven web application with the following architectural constraints: 24 + 25 + ### Communication 26 + - Components communicate via REST HTTP endpoints 27 + - Each resource has its own set of endpoints (CRUD) 28 + - Standard HTTP status codes: 200 (ok), 201 (created), 204 (no content), 400 (bad request), 404 (not found) 29 + - All responses are JSON. Errors: { "error": "message" } 30 + 31 + ### Data Ownership 32 + - Each resource module owns exclusive mutation authority over its database table(s) 33 + - Cross-resource queries use JOINs for read-only access 34 + - Foreign key relationships must be validated before mutation (check referenced row exists) 35 + - Cascade protection: cannot delete a parent resource that has dependent children 36 + 37 + ### Component Grain 38 + - One module per resource (e.g., tasks, projects, categories) 39 + - Each module is independently deployable and testable 40 + - A web UI module serves HTML and calls the resource modules via fetch() 41 + 42 + ### Evaluation Surface 43 + - Every module is testable via HTTP endpoint contracts 44 + - Create → verify response has ID and matches input 45 + - Read → verify response shape matches schema 46 + - Update → verify changes are persisted 47 + - Delete → verify resource is gone 48 + - Validation → verify 400 for invalid input 49 + - Not found → verify 404 for missing resources 50 + 51 + ### Translating user requirements to implementation 52 + - "Users can create X" → POST endpoint with validation 53 + - "Users can view X" → GET endpoint with SELECT query (JOINs for related data) 54 + - "Users can edit X" → PATCH endpoint with UPDATE query 55 + - "Users can delete X" → DELETE endpoint with safety checks 56 + - "Users can filter by Y" → query parameters on GET endpoints 57 + - "Show X sorted by Y" → ORDER BY in query 58 + - "X must be visually highlighted" → UI concern, not API 59 + - "Expose a programmatic interface" → the REST API IS the programmatic interface 60 + `, 61 + 62 + runtimeTargets: ['node-typescript'], 63 + };
+13 -13
src/cli.ts
··· 58 58 import { resolveProvider, describeAvailability } from './llm/resolve.js'; 59 59 60 60 // Architectures 61 - import { getArchitecture, listArchitectures } from './architectures/index.js'; 62 - import type { Architecture } from './models/architecture.js'; 61 + import { resolveTarget, listArchitectures } from './architectures/index.js'; 62 + import type { ResolvedTarget } from './models/architecture.js'; 63 63 64 64 // Audit & Fowler gaps 65 65 import { auditIU, auditAll } from './audit.js'; ··· 255 255 // Save architecture choice if specified 256 256 const archArg = args?.find(a => a.startsWith('--arch='))?.split('=')[1]; 257 257 if (archArg) { 258 - const arch = getArchitecture(archArg); 258 + const arch = resolveTarget(archArg); 259 259 if (!arch) { 260 260 console.log(red(`✖ Unknown architecture: ${archArg}`)); 261 261 console.log(` Available: ${listArchitectures().join(', ')}`); ··· 378 378 379 379 // Load architecture from config 380 380 const configPath = join(phoenixDir, 'config.json'); 381 - let arch: Architecture | null = null; 381 + let arch: ResolvedTarget | null = null; 382 382 if (existsSync(configPath)) { 383 383 try { 384 384 const config = JSON.parse(readFileSync(configPath, 'utf8')); 385 385 if (config.architecture) { 386 - arch = getArchitecture(config.architecture); 387 - if (arch) console.log(` ${dim('Architecture:')} ${cyan(arch.name)} — ${arch.description}`); 386 + arch = resolveTarget(config.architecture); 387 + if (arch) console.log(` ${dim('Architecture:')} ${cyan(arch.architecture.name)} / ${cyan(arch.runtime.name)}`); 388 388 } 389 389 } catch { /* ignore */ } 390 390 } ··· 392 392 // Write shared architecture files BEFORE code generation 393 393 // so the typecheck-retry loop can resolve imports like ../../db.js 394 394 if (arch) { 395 - for (const [filePath, content] of Object.entries(arch.sharedFiles)) { 395 + for (const [filePath, content] of Object.entries(arch.runtime.sharedFiles)) { 396 396 const fullPath = join(projectRoot, filePath); 397 397 mkdirSync(dirname(fullPath), { recursive: true }); 398 398 writeFileSync(fullPath, content, 'utf8'); ··· 402 402 name: basename(projectRoot), 403 403 version: '0.1.0', 404 404 type: 'module', 405 - dependencies: arch.packages, 406 - devDependencies: arch.devPackages, 405 + dependencies: arch.runtime.packages, 406 + devDependencies: arch.runtime.devPackages, 407 407 }; 408 408 const pkgPath = join(projectRoot, 'package.json'); 409 409 writeFileSync(pkgPath, JSON.stringify(earlyPkg, null, 2) + '\n', 'utf8'); ··· 418 418 canonNodes, 419 419 allIUs: ius, 420 420 projectRoot, 421 - architecture: arch, 421 + target: arch, 422 422 onProgress: (iu, status, msg) => { 423 423 if (status === 'start') process.stdout.write(` ⏳ ${iu.name}…`); 424 424 else if (status === 'done') process.stdout.write(` ${green('✔')}\n`); ··· 1058 1058 1059 1059 // Load architecture 1060 1060 const configPath = join(phoenixDir, 'config.json'); 1061 - let regenArch: Architecture | null = null; 1061 + let regenArch: ResolvedTarget | null = null; 1062 1062 if (existsSync(configPath)) { 1063 1063 try { 1064 1064 const cfg = JSON.parse(readFileSync(configPath, 'utf8')); 1065 - if (cfg.architecture) regenArch = getArchitecture(cfg.architecture); 1065 + if (cfg.architecture) regenArch = resolveTarget(cfg.architecture); 1066 1066 } catch { /* ignore */ } 1067 1067 } 1068 1068 ··· 1071 1071 canonNodes, 1072 1072 allIUs: ius, 1073 1073 projectRoot, 1074 - architecture: regenArch, 1074 + target: regenArch, 1075 1075 onProgress: (iu, status, msg) => { 1076 1076 if (status === 'start') process.stdout.write(` ⏳ ${iu.name}…`); 1077 1077 else if (status === 'done') process.stdout.write(` ${green('✔')}\n`);
+18 -23
src/llm/prompt.ts
··· 7 7 8 8 import type { ImplementationUnit } from '../models/iu.js'; 9 9 import type { CanonicalNode } from '../models/canonical.js'; 10 - import type { Architecture } from '../models/architecture.js'; 10 + import type { ResolvedTarget } from '../models/architecture.js'; 11 11 12 12 export const SYSTEM_PROMPT = `You are a senior TypeScript engineer generating production-quality module implementations for Phoenix VCS. 13 13 ··· 35 35 /** 36 36 * Get the system prompt, optionally extended with architecture-specific rules. 37 37 */ 38 - export function getSystemPrompt(arch?: Architecture | null): string { 39 - if (!arch) return SYSTEM_PROMPT; 38 + export function getSystemPrompt(target?: ResolvedTarget | null): string { 39 + if (!target) return SYSTEM_PROMPT; 40 + const arch = target.architecture; 41 + const rt = target.runtime; 40 42 41 - const allowedPkgs = Object.keys(arch.packages).map(p => `'${p}'`).join(', '); 43 + const allowedPkgs = Object.keys(rt.packages).map(p => `'${p}'`).join(', '); 42 44 43 - // Build a fresh system prompt for architecture mode — don't try to patch the generic one 44 - return `You are a senior TypeScript engineer generating production-quality module implementations. 45 + // Build system prompt from architecture + runtime 46 + return `You are a senior ${rt.language} engineer generating production-quality module implementations. 45 47 46 48 Rules: 47 - - Output ONLY the TypeScript module code. No markdown fences, no explanation. 48 - - The module must be a valid ES module (.ts) that compiles under strict mode. 49 - - Export all public functions and types. 50 - - Use descriptive types (not \`any\` or \`unknown\` where a real type is appropriate). 51 49 - Implement the actual logic described in the requirements — not stubs or TODOs. 52 50 - Keep the code clean, readable, and minimal. No over-engineering. 53 - - Include the _phoenix metadata constant exactly as specified. 54 - - You MUST import from these packages: ${allowedPkgs}. Use them as shown in the architecture examples below. 55 - - You may also use Node.js built-in modules (node:crypto, node:path, etc.). 51 + - You MUST import from these packages: ${allowedPkgs}. Use them as shown in the examples below. 56 52 - Do NOT import any other packages. Do NOT re-implement functionality that the allowed packages provide. 57 - - Do NOT define your own Hono, Database, or Zod types — import them from the packages. 58 - - The code must compile under TypeScript strict mode (strict: true, no implicit any). 59 - - If the requirements describe validation rules, use Zod schemas. 60 - ${arch.systemPromptExtension}`; 53 + 54 + ${arch.systemPrompt} 55 + ${rt.promptExtension}`; 61 56 } 62 57 63 58 /** ··· 67 62 iu: ImplementationUnit, 68 63 canonNodes: CanonicalNode[], 69 64 siblingModules?: string[], 70 - arch?: Architecture | null, 65 + target?: ResolvedTarget | null, 71 66 ): string { 72 67 const lines: string[] = []; 73 68 ··· 75 70 lines.push(''); 76 71 77 72 // For architecture mode, inject the mandatory imports at the top of the prompt 78 - if (arch) { 73 + if (target) { 79 74 lines.push('## MANDATORY: Your module MUST start with these exact imports'); 80 75 lines.push('```'); 81 76 lines.push(`import { Hono } from 'hono';`); ··· 126 121 } 127 122 128 123 // Related context: DEFINITION and CONTEXT nodes from the same spec not in this IU 129 - if (arch) { 124 + if (target) { 130 125 const otherNodes = canonNodes.filter(n => 131 126 !iu.source_canon_ids.includes(n.canon_id) && 132 127 (n.type === 'DEFINITION' || n.type === 'CONTEXT') ··· 152 147 153 148 // Context: sibling modules with mount paths for architecture mode 154 149 if (siblingModules && siblingModules.length > 0) { 155 - if (arch) { 150 + if (target) { 156 151 lines.push(`## Other API modules (do NOT import them — call their HTTP endpoints from JavaScript):`); 157 152 for (const m of siblingModules) { 158 153 const lowerName = m.toLowerCase(); ··· 185 180 lines.push(''); 186 181 187 182 // Architecture patterns (few-shot examples) 188 - if (arch?.codeExamples) { 189 - lines.push(arch.codeExamples); 183 + if (target?.runtime.codeExamples) { 184 + lines.push(target.runtime.codeExamples); 190 185 lines.push(''); 191 186 } 192 187
+57 -12
src/models/architecture.ts
··· 1 1 /** 2 - * Architecture Target — defines how canonical requirements compile to code. 2 + * Architecture & Runtime Target 3 + * 4 + * Architecture defines the SYSTEM SHAPE — communication patterns, data ownership, 5 + * component grain, evaluation surfaces. Language/runtime agnostic. 3 6 * 4 - * An architecture target provides the stable patterns, frameworks, and conventions 5 - * that generated code is compiled *into*. The spec says WHAT, the architecture says HOW. 7 + * Runtime Target defines the COMPILATION TARGET — language, frameworks, templates, 8 + * packages. Implements an architecture in a specific stack. 9 + * 10 + * Hierarchy: 11 + * Spec (what users want) 12 + * → Architecture (what kind of system) 13 + * → Runtime Target (what language/framework) 14 + * → Generated Code 6 15 */ 7 16 17 + // ─── Architecture (system shape, language-agnostic) ───────────────────────── 18 + 8 19 export interface Architecture { 9 - /** Unique name, e.g., 'sqlite-web-api' */ 20 + /** Unique name, e.g., 'web-api' */ 21 + name: string; 22 + /** Human description */ 23 + description: string; 24 + 25 + /** How components communicate: 'rest', 'graphql', 'grpc', 'events', 'cli' */ 26 + communicationPattern: string; 27 + /** How data is owned: 'per-component', 'shared-db', 'event-sourced' */ 28 + dataOwnership: string; 29 + /** How to verify components: 'http-endpoints', 'unit-tests', 'cli-output' */ 30 + evaluationSurface: string; 31 + 32 + /** Architecture-level prompt: describes system shape for the LLM (no language specifics) */ 33 + systemPrompt: string; 34 + 35 + /** Available runtime targets for this architecture */ 36 + runtimeTargets: string[]; 37 + } 38 + 39 + // ─── Runtime Target (language/framework specific) ─────────────────────────── 40 + 41 + export interface RuntimeTarget { 42 + /** Unique name, e.g., 'node-typescript' */ 10 43 name: string; 11 44 /** Human description */ 12 45 description: string; 13 - /** Runtime platform */ 14 - runtime: string; 46 + /** Language: 'typescript', 'python', 'go', etc. */ 47 + language: string; 48 + 15 49 /** Production dependencies: package name → version range */ 16 50 packages: Record<string, string>; 17 - /** Dev dependencies: package name → version range */ 51 + /** Dev dependencies */ 18 52 devPackages: Record<string, string>; 19 - /** Appended to the LLM system prompt — architectural rules and constraints */ 20 - systemPromptExtension: string; 21 - /** Few-shot code examples showing the exact patterns to follow */ 53 + 54 + /** Module template — the LLM fills in marked sections, structure is guaranteed */ 55 + moduleTemplate: string; 56 + /** LLM prompt extension — language/framework-specific rules */ 57 + promptExtension: string; 58 + /** Few-shot code examples showing the exact patterns */ 22 59 codeExamples: string; 60 + 23 61 /** Shared boilerplate files: relative path → file content */ 24 62 sharedFiles: Record<string, string>; 25 - /** Extra package.json fields (scripts, etc.) */ 26 - packageJsonExtras: Record<string, unknown>; 63 + /** Extra package.json / pyproject.toml fields */ 64 + packageExtras: Record<string, unknown>; 65 + } 66 + 67 + // ─── Resolved target (what the pipeline actually uses) ────────────────────── 68 + 69 + export interface ResolvedTarget { 70 + architecture: Architecture; 71 + runtime: RuntimeTarget; 27 72 }
+8 -8
src/regen.ts
··· 18 18 import type { IUManifest, RegenMetadata, FileManifestEntry } from './models/manifest.js'; 19 19 import type { LLMProvider } from './llm/provider.js'; 20 20 import { buildPrompt, getSystemPrompt } from './llm/prompt.js'; 21 - import type { Architecture } from './models/architecture.js'; 21 + import type { ResolvedTarget } from './models/architecture.js'; 22 22 import { sha256 } from './semhash.js'; 23 23 24 24 const TOOLCHAIN_VERSION = 'phoenix-regen/0.1.0'; ··· 39 39 /** Project root directory (for typecheck-and-retry). */ 40 40 projectRoot?: string; 41 41 /** Architecture target (e.g., sqlite-web-api). */ 42 - architecture?: Architecture | null; 42 + target?: ResolvedTarget | null; 43 43 /** Callback for progress reporting. */ 44 44 onProgress?: (iu: ImplementationUnit, status: 'start' | 'done' | 'error', message?: string) => void; 45 45 } ··· 58 58 if (ctx?.llm && ctx.canonNodes) { 59 59 ctx.onProgress?.(iu, 'start', `Generating ${iu.name} via ${ctx.llm.name}…`); 60 60 try { 61 - content = await generateWithLLM(iu, ctx.llm, ctx.canonNodes, ctx.allIUs, ctx.projectRoot, ctx.architecture); 61 + content = await generateWithLLM(iu, ctx.llm, ctx.canonNodes, ctx.allIUs, ctx.projectRoot, ctx.target); 62 62 ctx.onProgress?.(iu, 'done'); 63 63 } catch (err) { 64 64 const msg = err instanceof Error ? err.message : String(err); 65 65 ctx.onProgress?.(iu, 'error', msg); 66 66 // Fall back to stub on LLM failure 67 - content = ctx.architecture ? generateArchStub(iu) : generateModule(iu); 67 + content = ctx.target ? generateArchStub(iu) : generateModule(iu); 68 68 } 69 69 } else { 70 - content = ctx?.architecture ? generateArchStub(iu) : generateModule(iu); 70 + content = ctx?.target ? generateArchStub(iu) : generateModule(iu); 71 71 } 72 72 73 73 files.set(outputPath, content); ··· 131 131 canonNodes: CanonicalNode[], 132 132 allIUs?: ImplementationUnit[], 133 133 projectRoot?: string, 134 - arch?: Architecture | null, 134 + target?: ResolvedTarget | null, 135 135 ): Promise<string> { 136 136 // Find sibling modules in the same service 137 137 const iuDir = iu.output_files[0]?.split('/').slice(0, -1).join('/'); ··· 139 139 ?.filter(other => other.iu_id !== iu.iu_id && other.output_files[0]?.startsWith(iuDir || '')) 140 140 .map(other => other.name) ?? []; 141 141 142 - const systemPrompt = getSystemPrompt(arch); 143 - const prompt = buildPrompt(iu, canonNodes, siblings, arch); 142 + const systemPrompt = getSystemPrompt(target); 143 + const prompt = buildPrompt(iu, canonNodes, siblings, target); 144 144 145 145 let code = cleanCodeResponse(await llm.generate(prompt, { 146 146 system: systemPrompt,
+19 -13
src/scaffold.ts
··· 10 10 */ 11 11 12 12 import type { ImplementationUnit } from './models/iu.js'; 13 - import type { Architecture } from './models/architecture.js'; 13 + import type { ResolvedTarget } from './models/architecture.js'; 14 14 import { sha256 } from './semhash.js'; 15 15 16 16 export interface ServiceDescriptor { ··· 71 71 export function generateScaffold( 72 72 services: ServiceDescriptor[], 73 73 projectName: string = 'phoenix-project', 74 - arch?: Architecture | null, 74 + target?: ResolvedTarget | null, 75 75 ): ScaffoldResult { 76 76 const files = new Map<string, string>(); 77 77 78 78 // Architecture shared files (db.ts, app.ts, etc.) 79 - if (arch) { 80 - for (const [path, content] of Object.entries(arch.sharedFiles)) { 79 + if (target) { 80 + const arch = target.architecture; 81 + const rt = target.runtime; 82 + for (const [path, content] of Object.entries(rt.sharedFiles)) { 81 83 files.set(path, content); 82 84 } 83 85 ··· 129 131 generateServiceIndex(svc), 130 132 ); 131 133 132 - if (!arch) { 134 + if (!target) { 133 135 // Only generate per-service servers when no architecture is set 134 136 files.set( 135 137 `src/generated/${svc.dir}/server.ts`, ··· 140 142 // Service tests 141 143 files.set( 142 144 `src/generated/${svc.dir}/__tests__/${svc.dir}.test.ts`, 143 - arch ? generateArchTests(svc) : generateServiceTests(svc), 145 + target ? generateArchTests(svc) : generateServiceTests(svc), 144 146 ); 145 147 } 146 148 ··· 148 150 files.set('src/generated/index.ts', generateRootIndex(services)); 149 151 150 152 // Project config 151 - files.set('package.json', generatePackageJson(services, projectName, arch)); 153 + files.set('package.json', generatePackageJson(services, projectName, target)); 152 154 files.set('tsconfig.json', generateTsConfig()); 153 155 files.set('vitest.config.ts', generateVitestConfig()); 154 156 ··· 776 778 function generatePackageJson( 777 779 services: ServiceDescriptor[], 778 780 projectName: string, 779 - arch?: Architecture | null, 781 + target?: ResolvedTarget | null, 780 782 ): string { 781 783 let scripts: Record<string, string> = { 782 784 build: 'tsc', ··· 785 787 'test:watch': 'vitest', 786 788 }; 787 789 788 - if (arch) { 790 + if (target) { 791 + const arch = target.architecture; 792 + const rt = target.runtime; 789 793 // Architecture provides its own scripts 790 - const archScripts = (arch.packageJsonExtras?.scripts ?? {}) as Record<string, string>; 794 + const archScripts = (rt.packageExtras?.scripts ?? {}) as Record<string, string>; 791 795 scripts = { ...scripts, ...archScripts }; 792 796 } else { 793 797 // Add start script per service (build first, then run) ··· 807 811 scripts, 808 812 }; 809 813 810 - if (arch) { 811 - pkg.dependencies = arch.packages; 812 - pkg.devDependencies = arch.devPackages; 814 + if (target) { 815 + const arch = target.architecture; 816 + const rt = target.runtime; 817 + pkg.dependencies = rt.packages; 818 + pkg.devDependencies = rt.devPackages; 813 819 } else { 814 820 pkg.devDependencies = { 815 821 typescript: '^5.4.0',