@sienklogic/plan-build-run 2.32.0 → 2.33.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/CHANGELOG.md +14 -0
  2. package/dashboard/src/index.tsx +8 -2
  3. package/package.json +2 -2
  4. package/plugins/copilot-pbr/plugin.json +1 -1
  5. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  6. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  7. package/plugins/pbr/scripts/enforce-pbr-workflow.js +19 -3
  8. package/dashboard/src/app.js +0 -91
  9. package/dashboard/src/middleware/current-phase.js +0 -25
  10. package/dashboard/src/middleware/errorHandler.js +0 -62
  11. package/dashboard/src/middleware/notFoundHandler.js +0 -9
  12. package/dashboard/src/routes/events.routes.js +0 -94
  13. package/dashboard/src/routes/index.routes.js +0 -35
  14. package/dashboard/src/routes/pages.routes.js +0 -853
  15. package/dashboard/src/views/analytics.ejs +0 -5
  16. package/dashboard/src/views/audit-detail.ejs +0 -5
  17. package/dashboard/src/views/audits.ejs +0 -5
  18. package/dashboard/src/views/config.ejs +0 -5
  19. package/dashboard/src/views/dependencies.ejs +0 -5
  20. package/dashboard/src/views/error.ejs +0 -20
  21. package/dashboard/src/views/index.ejs +0 -5
  22. package/dashboard/src/views/logs.ejs +0 -3
  23. package/dashboard/src/views/milestone-detail.ejs +0 -5
  24. package/dashboard/src/views/milestones.ejs +0 -5
  25. package/dashboard/src/views/note-detail.ejs +0 -3
  26. package/dashboard/src/views/notes.ejs +0 -5
  27. package/dashboard/src/views/partials/activity-feed.ejs +0 -27
  28. package/dashboard/src/views/partials/analytics-content.ejs +0 -241
  29. package/dashboard/src/views/partials/audit-detail-content.ejs +0 -14
  30. package/dashboard/src/views/partials/audits-content.ejs +0 -36
  31. package/dashboard/src/views/partials/breadcrumbs.ejs +0 -18
  32. package/dashboard/src/views/partials/config-content.ejs +0 -219
  33. package/dashboard/src/views/partials/dashboard-content.ejs +0 -124
  34. package/dashboard/src/views/partials/dependencies-content.ejs +0 -50
  35. package/dashboard/src/views/partials/empty-state.ejs +0 -12
  36. package/dashboard/src/views/partials/footer.ejs +0 -9
  37. package/dashboard/src/views/partials/head.ejs +0 -31
  38. package/dashboard/src/views/partials/header.ejs +0 -18
  39. package/dashboard/src/views/partials/layout-bottom.ejs +0 -8
  40. package/dashboard/src/views/partials/layout-top.ejs +0 -17
  41. package/dashboard/src/views/partials/log-entries-content.ejs +0 -17
  42. package/dashboard/src/views/partials/logs-content.ejs +0 -131
  43. package/dashboard/src/views/partials/milestone-detail-content.ejs +0 -20
  44. package/dashboard/src/views/partials/milestones-content.ejs +0 -127
  45. package/dashboard/src/views/partials/note-detail-content.ejs +0 -24
  46. package/dashboard/src/views/partials/notes-content.ejs +0 -28
  47. package/dashboard/src/views/partials/phase-content.ejs +0 -226
  48. package/dashboard/src/views/partials/phase-doc-content.ejs +0 -36
  49. package/dashboard/src/views/partials/phase-timeline.ejs +0 -27
  50. package/dashboard/src/views/partials/phases-content.ejs +0 -137
  51. package/dashboard/src/views/partials/quick-content.ejs +0 -42
  52. package/dashboard/src/views/partials/quick-detail-content.ejs +0 -30
  53. package/dashboard/src/views/partials/requirements-content.ejs +0 -44
  54. package/dashboard/src/views/partials/research-content.ejs +0 -56
  55. package/dashboard/src/views/partials/research-detail-content.ejs +0 -25
  56. package/dashboard/src/views/partials/roadmap-content.ejs +0 -197
  57. package/dashboard/src/views/partials/sidebar.ejs +0 -98
  58. package/dashboard/src/views/partials/todo-create-content.ejs +0 -59
  59. package/dashboard/src/views/partials/todo-detail-content.ejs +0 -43
  60. package/dashboard/src/views/partials/todos-content.ejs +0 -110
  61. package/dashboard/src/views/partials/todos-done-content.ejs +0 -46
  62. package/dashboard/src/views/phase-detail.ejs +0 -5
  63. package/dashboard/src/views/phase-doc.ejs +0 -5
  64. package/dashboard/src/views/phases.ejs +0 -5
  65. package/dashboard/src/views/quick-detail.ejs +0 -5
  66. package/dashboard/src/views/quick.ejs +0 -5
  67. package/dashboard/src/views/requirements.ejs +0 -3
  68. package/dashboard/src/views/research-detail.ejs +0 -3
  69. package/dashboard/src/views/research.ejs +0 -3
  70. package/dashboard/src/views/roadmap.ejs +0 -5
  71. package/dashboard/src/views/todo-create.ejs +0 -5
  72. package/dashboard/src/views/todo-detail.ejs +0 -5
  73. package/dashboard/src/views/todos-done.ejs +0 -3
  74. package/dashboard/src/views/todos.ejs +0 -5
@@ -1,853 +0,0 @@
1
- import { Router } from 'express';
2
- import { getPhaseDetail, getPhaseDocument } from '../services/phase.service.js';
3
- import { getRoadmapData, generateDependencyMermaid } from '../services/roadmap.service.js';
4
- import { parseStateFile, derivePhaseStatuses } from '../services/dashboard.service.js';
5
- import { listPendingTodos, getTodoDetail, createTodo, completeTodo, listDoneTodos } from '../services/todo.service.js';
6
- import { getAllMilestones, getMilestoneDetail } from '../services/milestone.service.js';
7
- import { getProjectAnalytics } from '../services/analytics.service.js';
8
- import { getLlmMetrics } from '../services/local-llm-metrics.service.js';
9
- import { listNotes, getNoteBySlug } from '../services/notes.service.js';
10
- import { listResearchDocs, listCodebaseDocs, getResearchDocBySlug } from '../services/research.service.js';
11
- import { listQuickTasks, getQuickTask } from '../services/quick.service.js';
12
- import { listAuditReports, getAuditReport } from '../services/audit.service.js';
13
- import { readConfig, writeConfig } from '../services/config.service.js';
14
- import { getRequirementsData } from '../services/requirements.service.js';
15
- import { listLogFiles, readLogPage } from '../services/log.service.js';
16
-
17
- /**
18
- * Merge flat HTML form fields back into a nested config object.
19
- * Form field names use dot-notation: "features.autoVerify", "models.default".
20
- * Boolean checkboxes arrive as "on"/"off" or are absent when unchecked.
21
- * @param {object} existing - current config object from disk
22
- * @param {object} form - req.body from express.urlencoded
23
- * @returns {object}
24
- */
25
- function mergeFormIntoConfig(existing, form) {
26
- const result = JSON.parse(JSON.stringify(existing)); // deep clone
27
- for (const [key, value] of Object.entries(form)) {
28
- const parts = key.split('.');
29
- let target = result;
30
- for (let i = 0; i < parts.length - 1; i++) {
31
- if (target[parts[i]] == null || typeof target[parts[i]] !== 'object') {
32
- target[parts[i]] = {};
33
- }
34
- target = target[parts[i]];
35
- }
36
- const leaf = parts[parts.length - 1];
37
- // Coerce booleans: checkboxes send "on", absent means false
38
- if (typeof existing?.[parts[0]]?.[leaf] === 'boolean' || (parts.length === 2 && typeof (existing?.[parts[0]] ?? {})[leaf] === 'boolean')) {
39
- target[leaf] = value === 'on' || value === 'true';
40
- } else if (typeof target[leaf] === 'number') {
41
- target[leaf] = Number(value);
42
- } else {
43
- target[leaf] = value;
44
- }
45
- }
46
- // Uncheck all feature booleans not present in form (unchecked checkboxes are absent)
47
- if (result.features && typeof result.features === 'object') {
48
- for (const k of Object.keys(result.features)) {
49
- if (typeof result.features[k] === 'boolean' && !(`features.${k}` in form)) {
50
- result.features[k] = false;
51
- }
52
- }
53
- }
54
- if (result.gates && typeof result.gates === 'object') {
55
- for (const k of Object.keys(result.gates)) {
56
- if (typeof result.gates[k] === 'boolean' && !(`gates.${k}` in form)) {
57
- result.gates[k] = false;
58
- }
59
- }
60
- }
61
- if (result.safety && typeof result.safety === 'object') {
62
- for (const k of Object.keys(result.safety)) {
63
- if (typeof result.safety[k] === 'boolean' && !(`safety.${k}` in form)) {
64
- result.safety[k] = false;
65
- }
66
- }
67
- }
68
- return result;
69
- }
70
-
71
- const router = Router();
72
-
73
- router.get('/phases', async (req, res) => {
74
- const projectDir = req.app.locals.projectDir;
75
- const [roadmapData, stateData] = await Promise.all([
76
- getRoadmapData(projectDir),
77
- parseStateFile(projectDir)
78
- ]);
79
-
80
- const templateData = {
81
- title: 'Phases',
82
- activePage: 'phases',
83
- currentPath: '/phases',
84
- phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
85
- milestones: roadmapData.milestones,
86
- breadcrumbs: [{ label: 'Phases' }]
87
- };
88
-
89
- res.setHeader('Vary', 'HX-Request');
90
-
91
- if (req.get('HX-Request') === 'true') {
92
- res.render('partials/phases-content', templateData);
93
- } else {
94
- res.render('phases', templateData);
95
- }
96
- });
97
-
98
- router.get('/phases/:phaseId', async (req, res) => {
99
- const { phaseId } = req.params;
100
-
101
- // Validate phaseId: two digits, optionally followed by decimal (e.g., 01, 05, 3.1)
102
- if (!/^\d{1,2}(\.\d+)?$/.test(phaseId)) {
103
- const err = new Error('Phase ID must be a number (e.g., 01, 05, 3.1)');
104
- err.status = 404;
105
- throw err;
106
- }
107
-
108
- const projectDir = req.app.locals.projectDir;
109
- const [phaseData, roadmapData] = await Promise.all([
110
- getPhaseDetail(projectDir, phaseId),
111
- getRoadmapData(projectDir)
112
- ]);
113
-
114
- const phaseIdNum = parseInt(phaseId, 10);
115
- const allPhases = roadmapData.phases || [];
116
- const currentIdx = allPhases.findIndex(p => String(p.id) === String(phaseIdNum));
117
- const prevPhase = currentIdx > 0 ? allPhases[currentIdx - 1] : null;
118
- const nextPhase = currentIdx >= 0 && currentIdx < allPhases.length - 1
119
- ? allPhases[currentIdx + 1]
120
- : null;
121
-
122
- const templateData = {
123
- title: `Phase ${phaseId}: ${phaseData.phaseName}`,
124
- activePage: 'phases',
125
- currentPath: '/phases/' + phaseId,
126
- breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId }],
127
- prevPhase,
128
- nextPhase,
129
- ...phaseData
130
- };
131
-
132
- res.setHeader('Vary', 'HX-Request');
133
-
134
- if (req.get('HX-Request') === 'true') {
135
- res.render('partials/phase-content', templateData);
136
- } else {
137
- res.render('phase-detail', templateData);
138
- }
139
- });
140
-
141
- router.get('/phases/:phaseId/:planId/:docType', async (req, res) => {
142
- const { phaseId, planId, docType } = req.params;
143
-
144
- // Validate phaseId
145
- if (!/^\d{1,2}(\.\d+)?$/.test(phaseId)) {
146
- const err = new Error('Phase ID must be a number (e.g., 01, 05, 3.1)');
147
- err.status = 404;
148
- throw err;
149
- }
150
-
151
- // Validate planId: NN-NN format
152
- if (!/^\d{2}-\d{2}$/.test(planId)) {
153
- const err = new Error('Plan ID must be in NN-NN format (e.g., 04-01)');
154
- err.status = 404;
155
- throw err;
156
- }
157
-
158
- // Validate docType
159
- if (docType !== 'plan' && docType !== 'summary' && docType !== 'verification') {
160
- const err = new Error('Document type must be "plan", "summary", or "verification"');
161
- err.status = 404;
162
- throw err;
163
- }
164
-
165
- const projectDir = req.app.locals.projectDir;
166
- const doc = await getPhaseDocument(projectDir, phaseId, planId, docType);
167
-
168
- if (!doc) {
169
- const labels = { plan: 'Plan', summary: 'Summary', verification: 'Verification' };
170
- const err = new Error(`${labels[docType] || docType} ${planId} not found for phase ${phaseId}`);
171
- err.status = 404;
172
- throw err;
173
- }
174
-
175
- const docLabel = docType === 'plan' ? 'Plan' : docType === 'verification' ? 'Verification' : 'Summary';
176
- const templateData = {
177
- title: `${docLabel} ${planId} — Phase ${phaseId}: ${doc.phaseName}`,
178
- activePage: 'phases',
179
- currentPath: `/phases/${phaseId}/${planId}/${docType}`,
180
- breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId, url: '/phases/' + phaseId }, { label: docLabel + ' ' + planId }],
181
- ...doc
182
- };
183
-
184
- res.setHeader('Vary', 'HX-Request');
185
-
186
- if (req.get('HX-Request') === 'true') {
187
- res.render('partials/phase-doc-content', templateData);
188
- } else {
189
- res.render('phase-doc', templateData);
190
- }
191
- });
192
-
193
- router.get('/todos', async (req, res) => {
194
- const projectDir = req.app.locals.projectDir;
195
- const { priority, status, q } = req.query;
196
- const filters = {};
197
- if (priority) filters.priority = priority;
198
- if (status) filters.status = status;
199
- if (q) filters.q = q;
200
- const todos = await listPendingTodos(projectDir, filters);
201
-
202
- const templateData = {
203
- title: 'Todos',
204
- activePage: 'todos',
205
- currentPath: '/todos',
206
- breadcrumbs: [{ label: 'Todos' }],
207
- todos,
208
- filters: { priority: priority || '', status: status || '', q: q || '' }
209
- };
210
-
211
- res.setHeader('Vary', 'HX-Request');
212
-
213
- if (req.get('HX-Request') === 'true') {
214
- res.render('partials/todos-content', templateData);
215
- } else {
216
- res.render('todos', templateData);
217
- }
218
- });
219
-
220
- router.post('/todos/bulk-complete', async (req, res) => {
221
- const projectDir = req.app.locals.projectDir;
222
- const { priority, status, q } = req.query;
223
- const filters = {};
224
- if (priority) filters.priority = priority;
225
- if (status) filters.status = status;
226
- if (q) filters.q = q;
227
-
228
- const todos = await listPendingTodos(projectDir, filters);
229
- for (const todo of todos) {
230
- await completeTodo(projectDir, todo.id);
231
- }
232
-
233
- if (req.get('HX-Request') === 'true') {
234
- const remaining = await listPendingTodos(projectDir);
235
- res.render('partials/todos-content', {
236
- title: 'Todos',
237
- activePage: 'todos',
238
- currentPath: '/todos',
239
- breadcrumbs: [{ label: 'Todos' }],
240
- todos: remaining,
241
- filters: { priority: '', status: '', q: '' }
242
- });
243
- } else {
244
- res.redirect('/todos');
245
- }
246
- });
247
-
248
- router.get('/todos/new', (req, res) => {
249
- const templateData = {
250
- title: 'Create Todo',
251
- activePage: 'todos',
252
- currentPath: '/todos/new',
253
- breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Create' }]
254
- };
255
-
256
- res.setHeader('Vary', 'HX-Request');
257
-
258
- if (req.get('HX-Request') === 'true') {
259
- res.render('partials/todo-create-content', templateData);
260
- } else {
261
- res.render('todo-create', templateData);
262
- }
263
- });
264
-
265
- router.get('/todos/done', async (req, res) => {
266
- const projectDir = req.app.locals.projectDir;
267
- const todos = await listDoneTodos(projectDir);
268
-
269
- const templateData = {
270
- title: 'Completed Todos',
271
- activePage: 'todos',
272
- currentPath: '/todos/done',
273
- breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Completed' }],
274
- todos
275
- };
276
-
277
- res.setHeader('Vary', 'HX-Request');
278
-
279
- if (req.get('HX-Request') === 'true') {
280
- res.render('partials/todos-done-content', templateData);
281
- } else {
282
- res.render('todos-done', templateData);
283
- }
284
- });
285
-
286
- router.get('/todos/:id', async (req, res) => {
287
- const { id } = req.params;
288
-
289
- // Validate ID format: must be exactly three digits
290
- if (!/^\d{3}$/.test(id)) {
291
- const err = new Error('Todo ID must be a three-digit number (e.g., 001, 005, 042)');
292
- err.status = 404;
293
- throw err;
294
- }
295
-
296
- const projectDir = req.app.locals.projectDir;
297
- const todo = await getTodoDetail(projectDir, id);
298
-
299
- const templateData = {
300
- title: `Todo ${todo.id}: ${todo.title}`,
301
- activePage: 'todos',
302
- currentPath: '/todos/' + id,
303
- breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + id }],
304
- ...todo
305
- };
306
-
307
- res.setHeader('Vary', 'HX-Request');
308
-
309
- if (req.get('HX-Request') === 'true') {
310
- res.render('partials/todo-detail-content', templateData);
311
- } else {
312
- res.render('todo-detail', templateData);
313
- }
314
- });
315
-
316
- router.post('/todos', async (req, res) => {
317
- const { title, priority, phase, description } = req.body;
318
- const projectDir = req.app.locals.projectDir;
319
-
320
- const todoId = await createTodo(projectDir, {
321
- title,
322
- priority,
323
- phase: phase || '',
324
- description
325
- });
326
-
327
- if (req.get('HX-Request') === 'true') {
328
- // For HTMX: fetch the new todo and render its detail as a fragment
329
- const todo = await getTodoDetail(projectDir, todoId);
330
- res.render('partials/todo-detail-content', {
331
- title: `Todo ${todo.id}: ${todo.title}`,
332
- activePage: 'todos',
333
- currentPath: '/todos/' + todoId,
334
- breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + todoId }],
335
- ...todo
336
- });
337
- } else {
338
- res.redirect(`/todos/${todoId}`);
339
- }
340
- });
341
-
342
- router.post('/todos/:id/done', async (req, res) => {
343
- const { id } = req.params;
344
-
345
- if (!/^\d{3}$/.test(id)) {
346
- const err = new Error('Todo ID must be a three-digit number');
347
- err.status = 404;
348
- throw err;
349
- }
350
-
351
- const projectDir = req.app.locals.projectDir;
352
- await completeTodo(projectDir, id);
353
-
354
- if (req.get('HX-Request') === 'true') {
355
- // For HTMX: re-render the full todo list as a fragment
356
- const todos = await listPendingTodos(projectDir);
357
- res.render('partials/todos-content', {
358
- title: 'Todos',
359
- activePage: 'todos',
360
- currentPath: '/todos',
361
- breadcrumbs: [{ label: 'Todos' }],
362
- todos
363
- });
364
- } else {
365
- res.redirect('/todos');
366
- }
367
- });
368
-
369
- router.get('/milestones', async (req, res) => {
370
- const projectDir = req.app.locals.projectDir;
371
- const [milestoneData, roadmapData, stateData] = await Promise.all([
372
- getAllMilestones(projectDir),
373
- getRoadmapData(projectDir),
374
- parseStateFile(projectDir)
375
- ]);
376
-
377
- const phases = derivePhaseStatuses(roadmapData.phases, stateData.currentPhase);
378
-
379
- const templateData = {
380
- title: 'Milestones',
381
- activePage: 'milestones',
382
- currentPath: '/milestones',
383
- breadcrumbs: [{ label: 'Milestones' }],
384
- phases,
385
- ...milestoneData
386
- };
387
-
388
- res.setHeader('Vary', 'HX-Request');
389
-
390
- if (req.get('HX-Request') === 'true') {
391
- res.render('partials/milestones-content', templateData);
392
- } else {
393
- res.render('milestones', templateData);
394
- }
395
- });
396
-
397
- router.get('/milestones/:version', async (req, res) => {
398
- const { version } = req.params;
399
-
400
- // Validate version: alphanumeric with dots and dashes
401
- if (!/^[\w.-]+$/.test(version)) {
402
- const err = new Error('Invalid milestone version format');
403
- err.status = 404;
404
- throw err;
405
- }
406
-
407
- const projectDir = req.app.locals.projectDir;
408
- const detail = await getMilestoneDetail(projectDir, version);
409
-
410
- if (detail.sections.length === 0) {
411
- const err = new Error(`No archived files found for milestone v${version}`);
412
- err.status = 404;
413
- throw err;
414
- }
415
-
416
- const templateData = {
417
- title: `Milestone v${version}`,
418
- activePage: 'milestones',
419
- currentPath: '/milestones/' + version,
420
- breadcrumbs: [{ label: 'Milestones', url: '/milestones' }, { label: 'v' + version }],
421
- ...detail
422
- };
423
-
424
- res.setHeader('Vary', 'HX-Request');
425
-
426
- if (req.get('HX-Request') === 'true') {
427
- res.render('partials/milestone-detail-content', templateData);
428
- } else {
429
- res.render('milestone-detail', templateData);
430
- }
431
- });
432
-
433
- router.get('/dependencies', async (req, res) => {
434
- const projectDir = req.app.locals.projectDir;
435
- const mermaidCode = await generateDependencyMermaid(projectDir);
436
-
437
- const templateData = {
438
- title: 'Dependencies',
439
- activePage: 'dependencies',
440
- currentPath: '/dependencies',
441
- breadcrumbs: [{ label: 'Dependencies' }],
442
- mermaidCode
443
- };
444
-
445
- res.setHeader('Vary', 'HX-Request');
446
-
447
- if (req.get('HX-Request') === 'true') {
448
- res.render('partials/dependencies-content', templateData);
449
- } else {
450
- res.render('dependencies', templateData);
451
- }
452
- });
453
-
454
- router.get('/analytics', async (req, res) => {
455
- const projectDir = req.app.locals.projectDir;
456
- const [analytics, llmMetrics] = await Promise.all([
457
- getProjectAnalytics(projectDir),
458
- getLlmMetrics(projectDir)
459
- ]);
460
-
461
- const templateData = {
462
- title: 'Analytics',
463
- activePage: 'analytics',
464
- currentPath: '/analytics',
465
- breadcrumbs: [{ label: 'Analytics' }],
466
- analytics,
467
- llmMetrics
468
- };
469
-
470
- res.setHeader('Vary', 'HX-Request');
471
-
472
- if (req.get('HX-Request') === 'true') {
473
- res.render('partials/analytics-content', templateData);
474
- } else {
475
- res.render('analytics', templateData);
476
- }
477
- });
478
-
479
- router.get('/notes', async (req, res) => {
480
- const projectDir = req.app.locals.projectDir;
481
- const notes = await listNotes(projectDir);
482
-
483
- const templateData = {
484
- title: 'Notes',
485
- activePage: 'notes',
486
- currentPath: '/notes',
487
- breadcrumbs: [{ label: 'Notes' }],
488
- notes
489
- };
490
-
491
- res.setHeader('Vary', 'HX-Request');
492
-
493
- if (req.get('HX-Request') === 'true') {
494
- res.render('partials/notes-content', templateData);
495
- } else {
496
- res.render('notes', templateData);
497
- }
498
- });
499
-
500
- router.get('/notes/:slug', async (req, res) => {
501
- const { slug } = req.params;
502
-
503
- // Validate slug: lowercase alphanumeric and dashes only
504
- if (!/^[a-z0-9-]+$/.test(slug)) {
505
- const err = new Error('Invalid note slug format');
506
- err.status = 404;
507
- throw err;
508
- }
509
-
510
- const projectDir = req.app.locals.projectDir;
511
- const note = await getNoteBySlug(projectDir, slug);
512
-
513
- if (!note) {
514
- const err = new Error(`Note "${slug}" not found`);
515
- err.status = 404;
516
- throw err;
517
- }
518
-
519
- const templateData = {
520
- title: note.title,
521
- activePage: 'notes',
522
- currentPath: '/notes/' + slug,
523
- breadcrumbs: [{ label: 'Notes', url: '/notes' }, { label: note.title }],
524
- ...note
525
- };
526
-
527
- res.setHeader('Vary', 'HX-Request');
528
-
529
- if (req.get('HX-Request') === 'true') {
530
- res.render('partials/note-detail-content', templateData);
531
- } else {
532
- res.render('note-detail', templateData);
533
- }
534
- });
535
-
536
- router.get('/research', async (req, res) => {
537
- const projectDir = req.app.locals.projectDir;
538
- const [researchDocs, codebaseDocs] = await Promise.all([
539
- listResearchDocs(projectDir),
540
- listCodebaseDocs(projectDir)
541
- ]);
542
-
543
- const templateData = {
544
- title: 'Research',
545
- activePage: 'research',
546
- currentPath: '/research',
547
- breadcrumbs: [{ label: 'Research' }],
548
- researchDocs,
549
- codebaseDocs
550
- };
551
-
552
- res.setHeader('Vary', 'HX-Request');
553
- if (req.get('HX-Request') === 'true') {
554
- res.render('partials/research-content', templateData);
555
- } else {
556
- res.render('research', templateData);
557
- }
558
- });
559
-
560
- router.get('/research/:slug', async (req, res) => {
561
- const { slug } = req.params;
562
-
563
- // Validate slug: lowercase alphanumeric, dashes, and dots only
564
- if (!/^[a-z0-9._-]+$/.test(slug)) {
565
- const err = new Error('Invalid research document slug format');
566
- err.status = 404;
567
- throw err;
568
- }
569
-
570
- const projectDir = req.app.locals.projectDir;
571
- const doc = await getResearchDocBySlug(projectDir, slug);
572
-
573
- if (!doc) {
574
- const err = new Error(`Research document "${slug}" not found`);
575
- err.status = 404;
576
- throw err;
577
- }
578
-
579
- const templateData = {
580
- title: doc.title,
581
- activePage: 'research',
582
- currentPath: '/research/' + slug,
583
- breadcrumbs: [{ label: 'Research', url: '/research' }, { label: doc.title }],
584
- ...doc
585
- };
586
-
587
- res.setHeader('Vary', 'HX-Request');
588
- if (req.get('HX-Request') === 'true') {
589
- res.render('partials/research-detail-content', templateData);
590
- } else {
591
- res.render('research-detail', templateData);
592
- }
593
- });
594
-
595
- router.get('/requirements', async (req, res) => {
596
- const projectDir = req.app.locals.projectDir;
597
- const { sections, totalCount, coveredCount } = await getRequirementsData(projectDir);
598
-
599
- const templateData = {
600
- title: 'Requirements',
601
- activePage: 'requirements',
602
- currentPath: '/requirements',
603
- breadcrumbs: [{ label: 'Requirements' }],
604
- sections,
605
- totalCount,
606
- coveredCount,
607
- uncoveredCount: totalCount - coveredCount
608
- };
609
-
610
- res.setHeader('Vary', 'HX-Request');
611
- if (req.get('HX-Request') === 'true') {
612
- res.render('partials/requirements-content', templateData);
613
- } else {
614
- res.render('requirements', templateData);
615
- }
616
- });
617
-
618
- router.get('/roadmap', async (req, res) => {
619
- const projectDir = req.app.locals.projectDir;
620
- const [roadmapData, stateData] = await Promise.all([
621
- getRoadmapData(projectDir),
622
- parseStateFile(projectDir)
623
- ]);
624
-
625
- const templateData = {
626
- title: 'Roadmap',
627
- activePage: 'roadmap',
628
- currentPath: '/roadmap',
629
- phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
630
- milestones: roadmapData.milestones,
631
- breadcrumbs: [{ label: 'Roadmap' }]
632
- };
633
-
634
- res.setHeader('Vary', 'HX-Request');
635
-
636
- if (req.get('HX-Request') === 'true') {
637
- res.render('partials/roadmap-content', templateData);
638
- } else {
639
- res.render('roadmap', templateData);
640
- }
641
- });
642
-
643
- router.get('/quick', async (req, res) => {
644
- const projectDir = req.app.locals.projectDir;
645
- const tasks = await listQuickTasks(projectDir);
646
-
647
- const templateData = {
648
- title: 'Quick Tasks',
649
- activePage: 'quick',
650
- currentPath: '/quick',
651
- breadcrumbs: [{ label: 'Quick Tasks' }],
652
- tasks
653
- };
654
-
655
- res.setHeader('Vary', 'HX-Request');
656
-
657
- if (req.get('HX-Request') === 'true') {
658
- res.render('partials/quick-content', templateData);
659
- } else {
660
- res.render('quick', templateData);
661
- }
662
- });
663
-
664
- router.get('/quick/:id', async (req, res) => {
665
- const { id } = req.params;
666
-
667
- // Validate ID format: must be exactly three digits
668
- if (!/^\d{3}$/.test(id)) {
669
- const err = new Error('Quick Task ID must be a three-digit number (e.g., 001, 005, 042)');
670
- err.status = 404;
671
- throw err;
672
- }
673
-
674
- const projectDir = req.app.locals.projectDir;
675
- const task = await getQuickTask(projectDir, id);
676
-
677
- if (!task) {
678
- const err = new Error(`Quick task ${id} not found`);
679
- err.status = 404;
680
- throw err;
681
- }
682
-
683
- const templateData = {
684
- title: `Quick Task ${task.id}: ${task.title}`,
685
- activePage: 'quick',
686
- currentPath: '/quick/' + id,
687
- breadcrumbs: [{ label: 'Quick Tasks', url: '/quick' }, { label: 'Task ' + id }],
688
- ...task
689
- };
690
-
691
- res.setHeader('Vary', 'HX-Request');
692
-
693
- if (req.get('HX-Request') === 'true') {
694
- res.render('partials/quick-detail-content', templateData);
695
- } else {
696
- res.render('quick-detail', templateData);
697
- }
698
- });
699
-
700
- router.get('/audits', async (req, res) => {
701
- const projectDir = req.app.locals.projectDir;
702
- const reports = await listAuditReports(projectDir);
703
-
704
- const templateData = {
705
- title: 'Audit Reports',
706
- activePage: 'audits',
707
- currentPath: '/audits',
708
- breadcrumbs: [{ label: 'Audit Reports' }],
709
- reports
710
- };
711
-
712
- res.setHeader('Vary', 'HX-Request');
713
-
714
- if (req.get('HX-Request') === 'true') {
715
- res.render('partials/audits-content', templateData);
716
- } else {
717
- res.render('audits', templateData);
718
- }
719
- });
720
-
721
- router.get('/audits/:filename', async (req, res) => {
722
- const { filename } = req.params;
723
-
724
- // Validate filename: safe characters only, must end in .md
725
- if (!/^[\w.-]+\.md$/.test(filename)) {
726
- const err = new Error('Invalid audit report filename');
727
- err.status = 404;
728
- throw err;
729
- }
730
-
731
- const projectDir = req.app.locals.projectDir;
732
- const report = await getAuditReport(projectDir, filename);
733
-
734
- if (!report) {
735
- const err = new Error(`Audit report "${filename}" not found`);
736
- err.status = 404;
737
- throw err;
738
- }
739
-
740
- const templateData = {
741
- title: report.title,
742
- activePage: 'audits',
743
- currentPath: '/audits/' + filename,
744
- breadcrumbs: [{ label: 'Audit Reports', url: '/audits' }, { label: report.title }],
745
- ...report
746
- };
747
-
748
- res.setHeader('Vary', 'HX-Request');
749
-
750
- if (req.get('HX-Request') === 'true') {
751
- res.render('partials/audit-detail-content', templateData);
752
- } else {
753
- res.render('audit-detail', templateData);
754
- }
755
- });
756
-
757
- router.get('/config', async (req, res) => {
758
- const projectDir = req.app.locals.projectDir;
759
- const config = await readConfig(projectDir);
760
-
761
- const templateData = {
762
- title: 'Config',
763
- activePage: 'config',
764
- currentPath: '/config',
765
- breadcrumbs: [{ label: 'Config' }],
766
- config: config ?? {}
767
- };
768
-
769
- res.setHeader('Vary', 'HX-Request');
770
-
771
- if (req.get('HX-Request') === 'true') {
772
- res.render('partials/config-content', templateData);
773
- } else {
774
- res.render('config', templateData);
775
- }
776
- });
777
-
778
- router.get('/logs', async (req, res) => {
779
- const projectDir = req.app.locals.projectDir;
780
- const { file, page, type, q } = req.query;
781
-
782
- const logFiles = await listLogFiles(projectDir);
783
-
784
- // Determine selected file (first in list if not specified)
785
- const selectedFile = file || (logFiles.length > 0 ? logFiles[0].name : null);
786
-
787
- let logData = null;
788
- if (selectedFile) {
789
- // Validate: no path traversal, must be a .jsonl filename
790
- if (/^[\w.-]+\.jsonl$/.test(selectedFile)) {
791
- const { join } = await import('node:path');
792
- const filePath = join(projectDir, '.planning', 'logs', selectedFile);
793
- logData = await readLogPage(filePath, {
794
- page: parseInt(page, 10) || 1,
795
- pageSize: 100,
796
- typeFilter: type || '',
797
- q: q || ''
798
- });
799
- }
800
- }
801
-
802
- const templateData = {
803
- title: 'Logs',
804
- activePage: 'logs',
805
- currentPath: '/logs',
806
- breadcrumbs: [{ label: 'Logs' }],
807
- logFiles,
808
- selectedFile,
809
- logData,
810
- filters: { type: type || '', q: q || '', page: parseInt(page, 10) || 1 }
811
- };
812
-
813
- res.setHeader('Vary', 'HX-Request');
814
- if (req.get('HX-Request') === 'true') {
815
- // If the request is for a different file/filter, re-render only the entries fragment
816
- if (req.query.fragment === 'entries') {
817
- res.render('partials/log-entries-content', templateData);
818
- } else {
819
- res.render('partials/logs-content', templateData);
820
- }
821
- } else {
822
- res.render('logs', templateData);
823
- }
824
- });
825
-
826
- router.post('/api/config', async (req, res) => {
827
- const projectDir = req.app.locals.projectDir;
828
-
829
- // Accept either JSON body (raw editor) or form-encoded (hybrid form)
830
- let incoming = req.body;
831
-
832
- // If the request carries a `rawJson` field, parse it as the full config
833
- if (typeof incoming.rawJson === 'string') {
834
- try {
835
- incoming = JSON.parse(incoming.rawJson);
836
- } catch {
837
- return res.status(400).send('<span class="config-feedback config-feedback--error">Invalid JSON</span>');
838
- }
839
- } else {
840
- // Merge form fields into existing config to avoid clobbering unrendered keys
841
- const existing = await readConfig(projectDir) ?? {};
842
- incoming = mergeFormIntoConfig(existing, incoming);
843
- }
844
-
845
- try {
846
- await writeConfig(projectDir, incoming);
847
- res.send('<span class="config-feedback config-feedback--success">Saved</span>');
848
- } catch (err) {
849
- res.status(400).send(`<span class="config-feedback config-feedback--error">${err.message}</span>`);
850
- }
851
- });
852
-
853
- export default router;