@sienklogic/plan-build-run 2.22.2 → 2.24.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 (90) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/dashboard/package.json +3 -2
  3. package/dashboard/src/middleware/errorHandler.js +12 -2
  4. package/dashboard/src/repositories/planning.repository.js +24 -12
  5. package/dashboard/src/routes/pages.routes.js +182 -4
  6. package/dashboard/src/server.js +4 -0
  7. package/dashboard/src/services/audit.service.js +42 -0
  8. package/dashboard/src/services/dashboard.service.js +1 -12
  9. package/dashboard/src/services/local-llm-metrics.service.js +81 -0
  10. package/dashboard/src/services/quick.service.js +62 -0
  11. package/dashboard/src/services/roadmap.service.js +1 -11
  12. package/dashboard/src/utils/strip-bom.js +8 -0
  13. package/dashboard/src/views/audit-detail.ejs +5 -0
  14. package/dashboard/src/views/audits.ejs +5 -0
  15. package/dashboard/src/views/partials/analytics-content.ejs +61 -0
  16. package/dashboard/src/views/partials/audit-detail-content.ejs +12 -0
  17. package/dashboard/src/views/partials/audits-content.ejs +34 -0
  18. package/dashboard/src/views/partials/quick-content.ejs +40 -0
  19. package/dashboard/src/views/partials/quick-detail-content.ejs +29 -0
  20. package/dashboard/src/views/partials/sidebar.ejs +16 -0
  21. package/dashboard/src/views/partials/todos-content.ejs +13 -3
  22. package/dashboard/src/views/quick-detail.ejs +5 -0
  23. package/dashboard/src/views/quick.ejs +5 -0
  24. package/package.json +1 -1
  25. package/plugins/copilot-pbr/agents/debugger.agent.md +15 -0
  26. package/plugins/copilot-pbr/agents/integration-checker.agent.md +9 -2
  27. package/plugins/copilot-pbr/agents/planner.agent.md +19 -0
  28. package/plugins/copilot-pbr/agents/researcher.agent.md +20 -0
  29. package/plugins/copilot-pbr/agents/synthesizer.agent.md +12 -0
  30. package/plugins/copilot-pbr/agents/verifier.agent.md +22 -2
  31. package/plugins/copilot-pbr/plugin.json +1 -1
  32. package/plugins/copilot-pbr/references/config-reference.md +89 -0
  33. package/plugins/copilot-pbr/references/plan-format.md +22 -0
  34. package/plugins/copilot-pbr/skills/health/SKILL.md +8 -1
  35. package/plugins/copilot-pbr/skills/help/SKILL.md +4 -4
  36. package/plugins/copilot-pbr/skills/milestone/SKILL.md +12 -12
  37. package/plugins/copilot-pbr/skills/status/SKILL.md +37 -1
  38. package/plugins/copilot-pbr/templates/INTEGRATION-REPORT.md.tmpl +18 -2
  39. package/plugins/copilot-pbr/templates/VERIFICATION-DETAIL.md.tmpl +2 -1
  40. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  41. package/plugins/cursor-pbr/agents/debugger.md +15 -0
  42. package/plugins/cursor-pbr/agents/integration-checker.md +9 -2
  43. package/plugins/cursor-pbr/agents/planner.md +19 -0
  44. package/plugins/cursor-pbr/agents/researcher.md +20 -0
  45. package/plugins/cursor-pbr/agents/synthesizer.md +12 -0
  46. package/plugins/cursor-pbr/agents/verifier.md +22 -2
  47. package/plugins/cursor-pbr/references/config-reference.md +89 -0
  48. package/plugins/cursor-pbr/references/plan-format.md +22 -0
  49. package/plugins/cursor-pbr/skills/health/SKILL.md +8 -1
  50. package/plugins/cursor-pbr/skills/help/SKILL.md +4 -4
  51. package/plugins/cursor-pbr/skills/milestone/SKILL.md +12 -12
  52. package/plugins/cursor-pbr/skills/status/SKILL.md +37 -1
  53. package/plugins/cursor-pbr/templates/INTEGRATION-REPORT.md.tmpl +18 -2
  54. package/plugins/cursor-pbr/templates/VERIFICATION-DETAIL.md.tmpl +2 -1
  55. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  56. package/plugins/pbr/agents/debugger.md +15 -0
  57. package/plugins/pbr/agents/integration-checker.md +9 -2
  58. package/plugins/pbr/agents/planner.md +19 -0
  59. package/plugins/pbr/agents/researcher.md +20 -0
  60. package/plugins/pbr/agents/synthesizer.md +12 -0
  61. package/plugins/pbr/agents/verifier.md +22 -2
  62. package/plugins/pbr/references/config-reference.md +89 -0
  63. package/plugins/pbr/references/plan-format.md +22 -0
  64. package/plugins/pbr/scripts/check-config-change.js +33 -0
  65. package/plugins/pbr/scripts/check-plan-format.js +52 -4
  66. package/plugins/pbr/scripts/check-subagent-output.js +43 -3
  67. package/plugins/pbr/scripts/config-schema.json +48 -0
  68. package/plugins/pbr/scripts/local-llm/client.js +214 -0
  69. package/plugins/pbr/scripts/local-llm/health.js +217 -0
  70. package/plugins/pbr/scripts/local-llm/metrics.js +252 -0
  71. package/plugins/pbr/scripts/local-llm/operations/classify-artifact.js +76 -0
  72. package/plugins/pbr/scripts/local-llm/operations/classify-error.js +75 -0
  73. package/plugins/pbr/scripts/local-llm/operations/score-source.js +72 -0
  74. package/plugins/pbr/scripts/local-llm/operations/summarize-context.js +62 -0
  75. package/plugins/pbr/scripts/local-llm/operations/validate-task.js +59 -0
  76. package/plugins/pbr/scripts/local-llm/router.js +101 -0
  77. package/plugins/pbr/scripts/local-llm/shadow.js +60 -0
  78. package/plugins/pbr/scripts/local-llm/threshold-tuner.js +118 -0
  79. package/plugins/pbr/scripts/pbr-tools.js +120 -3
  80. package/plugins/pbr/scripts/post-write-dispatch.js +2 -2
  81. package/plugins/pbr/scripts/progress-tracker.js +29 -3
  82. package/plugins/pbr/scripts/session-cleanup.js +36 -1
  83. package/plugins/pbr/scripts/validate-task.js +30 -1
  84. package/plugins/pbr/skills/health/SKILL.md +8 -1
  85. package/plugins/pbr/skills/help/SKILL.md +4 -4
  86. package/plugins/pbr/skills/milestone/SKILL.md +12 -12
  87. package/plugins/pbr/skills/status/SKILL.md +38 -2
  88. package/plugins/pbr/templates/INTEGRATION-REPORT.md.tmpl +18 -2
  89. package/plugins/pbr/templates/VERIFICATION-DETAIL.md.tmpl +2 -1
  90. package/dashboard/src/views/coming-soon.ejs +0 -11
package/CHANGELOG.md CHANGED
@@ -5,6 +5,48 @@ All notable changes to Plan-Build-Run will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [2.24.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.23.0...plan-build-run-v2.24.0) (2026-02-24)
9
+
10
+
11
+ ### Features
12
+
13
+ * **35-05:** add Audit Reports view with /audits and /audits/:filename routes ([33dfae7](https://github.com/SienkLogic/plan-build-run/commit/33dfae7fff02cbba93e250211e54b26d565f4f76))
14
+ * **35-05:** GREEN - implement audit.service.js with listAuditReports and getAuditReport ([c79179a](https://github.com/SienkLogic/plan-build-run/commit/c79179a75a386096e74589f13fea4a56174b1870))
15
+ * **quick-003:** add data-flow to plan-format reference, verification template, and integration report template ([e73a31e](https://github.com/SienkLogic/plan-build-run/commit/e73a31e0c1eb9535014de4b60924bd98ced2f8cb))
16
+ * **quick-003:** add data-flow verification to planner, verifier, and integration-checker agents ([e37e192](https://github.com/SienkLogic/plan-build-run/commit/e37e19297e038c38aecd7d04e93c007928242f0f))
17
+
18
+
19
+ ### Bug Fixes
20
+
21
+ * **quick-003:** pass data.session_id to LLM operations instead of undefined ([df4d168](https://github.com/SienkLogic/plan-build-run/commit/df4d1682b50935b53ddcc665b8dcdc394b8b277e))
22
+
23
+ ## [2.23.0](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.22.2...plan-build-run-v2.23.0) (2026-02-24)
24
+
25
+
26
+ ### Features
27
+
28
+ * **28-01:** add local LLM foundation — client, health, metrics, config schema, hook integrations, tests ([44b5a77](https://github.com/SienkLogic/plan-build-run/commit/44b5a773fbb22d77ac0dbf3325201980dd1e9635))
29
+ * **29-01:** integrate local LLM into hooks — artifact classification, task validation, error classification, CLI ([785a708](https://github.com/SienkLogic/plan-build-run/commit/785a708039b0c363589e7151c40df989bb2a7959))
30
+ * **30-01:** add metrics display — session summaries, status skill, CLI, dashboard analytics ([d4ae4a4](https://github.com/SienkLogic/plan-build-run/commit/d4ae4a400932a324f61872169c1dae73cc2923ce))
31
+ * **30-03:** add local-llm-metrics.service.js with getLlmMetrics and Vitest tests ([fc1dd8f](https://github.com/SienkLogic/plan-build-run/commit/fc1dd8f6fbada707543dea848e7355ff52896667))
32
+ * **30-03:** wire getLlmMetrics into /analytics route and add Local LLM Offload section to EJS template ([5fcb61e](https://github.com/SienkLogic/plan-build-run/commit/5fcb61e25848395a4d59f4c555bbb8cf913efb72))
33
+ * **31-01:** add adaptive router — complexity heuristic, confidence gate, 3 routing strategies ([7905462](https://github.com/SienkLogic/plan-build-run/commit/7905462a5dc11063813c1c31400a17f4d2314a23))
34
+ * **32-01:** add agent support — source scoring, error classification, context summarization, prompt injection ([3693660](https://github.com/SienkLogic/plan-build-run/commit/36936609200596fe04cbf27f6eff41566733a74e))
35
+ * **33-01:** add shadow mode, threshold tuner, comprehensive tests, docs, cross-plugin sync ([dbacfed](https://github.com/SienkLogic/plan-build-run/commit/dbacfed98bd6819babad2f07df588cf545d5b99c))
36
+ * **34-01:** add config.features.source_scoring feature flag guard to score-source.js ([a0945a2](https://github.com/SienkLogic/plan-build-run/commit/a0945a2ffd1067ab3644063a8deee4e06a42ea9e))
37
+ * **34-01:** wire runShadow() into router.js post-call path for all 3 routing strategies ([f233948](https://github.com/SienkLogic/plan-build-run/commit/f23394810ab0f647f8eb1ba1d8f78a5eb7d048d8))
38
+ * **35-01:** GREEN - add escapeHtml helper and use it in HTMX error handler path ([a1830c9](https://github.com/SienkLogic/plan-build-run/commit/a1830c9230ba7ab1ea693a7ff2d36e786d5878d3))
39
+ * **35-01:** GREEN - add sanitize-html post-processing to planning.repository ([6d8122b](https://github.com/SienkLogic/plan-build-run/commit/6d8122b83cf1ed62101a3e9fc2f436caf6b0858e))
40
+ * **35-03:** add Quick Tasks view with /quick and /quick/:id routes ([22abe29](https://github.com/SienkLogic/plan-build-run/commit/22abe29e3951ea8e4fbd045116f6216326ee3907))
41
+
42
+
43
+ ### Documentation
44
+
45
+ * **34-01:** mark all LLM-01 through LLM-34 traceability entries as Verified ([4311497](https://github.com/SienkLogic/plan-build-run/commit/4311497e1e91f55a575da4468924d21903457cdf))
46
+ * **quick-002:** add .active-skill stale detection to health Check 10 ([3f95b16](https://github.com/SienkLogic/plan-build-run/commit/3f95b16461478bf4271ef4f7ce94582188869bae))
47
+ * **quick-002:** fix NEXT UP banner indentation in milestone SKILL.md ([2983000](https://github.com/SienkLogic/plan-build-run/commit/2983000bd4df5e95c63cdc8f79601c7894883dbb))
48
+ * **quick-002:** replace arrow-list with bullet style in help SKILL.md ([ad78663](https://github.com/SienkLogic/plan-build-run/commit/ad7866375c6f7fd7dda3e195413df95f3ee191bc))
49
+
8
50
  ## [2.22.2](https://github.com/SienkLogic/plan-build-run/compare/plan-build-run-v2.22.1...plan-build-run-v2.22.2) (2026-02-24)
9
51
 
10
52
 
@@ -19,16 +19,17 @@
19
19
  ],
20
20
  "license": "MIT",
21
21
  "dependencies": {
22
- "@rollup/rollup-win32-x64-msvc": "^4.57.1",
23
22
  "chokidar": "^5.0.0",
24
23
  "commander": "^12.1.0",
25
24
  "ejs": "^3.1.10",
26
25
  "express": "^5.1.0",
27
26
  "gray-matter": "^4.0.3",
28
27
  "helmet": "^8.1.0",
29
- "marked": "^17.0.1"
28
+ "marked": "^17.0.1",
29
+ "sanitize-html": "^2.13.0"
30
30
  },
31
31
  "devDependencies": {
32
+ "@rollup/rollup-win32-x64-msvc": "^4.57.1",
32
33
  "@vitest/coverage-v8": "^4.0.18",
33
34
  "memfs": "^4.56.10",
34
35
  "supertest": "^7.2.2",
@@ -3,6 +3,16 @@
3
3
  * MUST have exactly 4 parameters for Express to recognize it as an error handler.
4
4
  */
5
5
 
6
+ function escapeHtml(str) {
7
+ if (!str) return '';
8
+ return str
9
+ .replace(/&/g, '&')
10
+ .replace(/</g, '&lt;')
11
+ .replace(/>/g, '&gt;')
12
+ .replace(/"/g, '&quot;')
13
+ .replace(/'/g, '&#39;');
14
+ }
15
+
6
16
  // eslint-disable-next-line no-unused-vars
7
17
  export default function errorHandler(err, req, res, next) {
8
18
  // If headers already sent, delegate to Express default handler
@@ -40,9 +50,9 @@ export default function errorHandler(err, req, res, next) {
40
50
 
41
51
  // Render response
42
52
  if (isHtmx) {
43
- let html = `<h1>Error ${status}</h1><p>${templateData.message}</p>`;
53
+ let html = `<h1>Error ${status}</h1><p>${escapeHtml(templateData.message)}</p>`;
44
54
  if (templateData.stack) {
45
- html += `<pre><code>${templateData.stack}</code></pre>`;
55
+ html += `<pre><code>${escapeHtml(templateData.stack)}</code></pre>`;
46
56
  }
47
57
  html += '<p><a href="/">Return to Dashboard</a></p>';
48
58
  return res.status(status).send(html);
@@ -2,20 +2,11 @@ import { readFile, readdir } from 'node:fs/promises';
2
2
  import { join, resolve, relative, normalize } from 'node:path';
3
3
  import matter from 'gray-matter';
4
4
  import { marked } from 'marked';
5
+ import sanitizeHtml from 'sanitize-html';
6
+ import { stripBOM } from '../utils/strip-bom.js';
5
7
 
6
8
  marked.setOptions({ gfm: true, breaks: false });
7
9
 
8
- /**
9
- * Strip UTF-8 BOM (Byte Order Mark) if present.
10
- * Windows editors (Notepad, older VS Code) may prepend BOM to UTF-8 files.
11
- * gray-matter will fail to detect frontmatter delimiters if BOM is present.
12
- * @param {string} content - Raw file content
13
- * @returns {string} Content without BOM
14
- */
15
- function stripBOM(content) {
16
- return content.replace(/^\uFEFF/, '');
17
- }
18
-
19
10
  /**
20
11
  * Validate that a resolved path stays within the base directory.
21
12
  * Prevents path traversal attacks (e.g., ../../etc/passwd).
@@ -76,9 +67,30 @@ export async function readMarkdownFile(filePath) {
76
67
 
77
68
  const html = marked.parse(content);
78
69
 
70
+ const sanitizedHtml = sanitizeHtml(html, {
71
+ allowedTags: sanitizeHtml.defaults.allowedTags.concat([
72
+ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
73
+ 'img', 'details', 'summary', 'del', 'ins',
74
+ 'sup', 'sub', 'abbr', 'kbd', 'mark'
75
+ ]),
76
+ allowedAttributes: {
77
+ ...sanitizeHtml.defaults.allowedAttributes,
78
+ 'a': ['href', 'name', 'target', 'rel'],
79
+ 'img': ['src', 'alt', 'title', 'width', 'height'],
80
+ 'code': ['class'],
81
+ 'pre': ['class'],
82
+ 'span': ['class'],
83
+ 'div': ['class'],
84
+ 'td': ['align'],
85
+ 'th': ['align', 'scope'],
86
+ 'h1': ['id'], 'h2': ['id'], 'h3': ['id'],
87
+ 'h4': ['id'], 'h5': ['id'], 'h6': ['id']
88
+ }
89
+ });
90
+
79
91
  return {
80
92
  frontmatter: data,
81
- html,
93
+ html: sanitizedHtml,
82
94
  rawContent: content
83
95
  };
84
96
  }
@@ -2,10 +2,13 @@ import { Router } from 'express';
2
2
  import { getPhaseDetail, getPhaseDocument } from '../services/phase.service.js';
3
3
  import { getRoadmapData, generateDependencyMermaid } from '../services/roadmap.service.js';
4
4
  import { parseStateFile, derivePhaseStatuses } from '../services/dashboard.service.js';
5
- import { listPendingTodos, getTodoDetail, createTodo, completeTodo } from '../services/todo.service.js';
5
+ import { listPendingTodos, getTodoDetail, createTodo, completeTodo, listDoneTodos } from '../services/todo.service.js';
6
6
  import { getAllMilestones, getMilestoneDetail } from '../services/milestone.service.js';
7
7
  import { getProjectAnalytics } from '../services/analytics.service.js';
8
- import { listNotes } from '../services/notes.service.js';
8
+ import { getLlmMetrics } from '../services/local-llm-metrics.service.js';
9
+ import { listNotes, getNoteBySlug } from '../services/notes.service.js';
10
+ import { listQuickTasks, getQuickTask } from '../services/quick.service.js';
11
+ import { listAuditReports, getAuditReport } from '../services/audit.service.js';
9
12
 
10
13
  const router = Router();
11
14
 
@@ -188,6 +191,27 @@ router.get('/todos/new', (req, res) => {
188
191
  }
189
192
  });
190
193
 
194
+ router.get('/todos/done', async (req, res) => {
195
+ const projectDir = req.app.locals.projectDir;
196
+ const todos = await listDoneTodos(projectDir);
197
+
198
+ const templateData = {
199
+ title: 'Completed Todos',
200
+ activePage: 'todos',
201
+ currentPath: '/todos/done',
202
+ breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Completed' }],
203
+ todos
204
+ };
205
+
206
+ res.setHeader('Vary', 'HX-Request');
207
+
208
+ if (req.get('HX-Request') === 'true') {
209
+ res.render('partials/todos-done-content', templateData);
210
+ } else {
211
+ res.render('todos-done', templateData);
212
+ }
213
+ });
214
+
191
215
  router.get('/todos/:id', async (req, res) => {
192
216
  const { id } = req.params;
193
217
 
@@ -358,14 +382,18 @@ router.get('/dependencies', async (req, res) => {
358
382
 
359
383
  router.get('/analytics', async (req, res) => {
360
384
  const projectDir = req.app.locals.projectDir;
361
- const analytics = await getProjectAnalytics(projectDir);
385
+ const [analytics, llmMetrics] = await Promise.all([
386
+ getProjectAnalytics(projectDir),
387
+ getLlmMetrics(projectDir)
388
+ ]);
362
389
 
363
390
  const templateData = {
364
391
  title: 'Analytics',
365
392
  activePage: 'analytics',
366
393
  currentPath: '/analytics',
367
394
  breadcrumbs: [{ label: 'Analytics' }],
368
- analytics
395
+ analytics,
396
+ llmMetrics
369
397
  };
370
398
 
371
399
  res.setHeader('Vary', 'HX-Request');
@@ -398,6 +426,42 @@ router.get('/notes', async (req, res) => {
398
426
  }
399
427
  });
400
428
 
429
+ router.get('/notes/:slug', async (req, res) => {
430
+ const { slug } = req.params;
431
+
432
+ // Validate slug: lowercase alphanumeric and dashes only
433
+ if (!/^[a-z0-9-]+$/.test(slug)) {
434
+ const err = new Error('Invalid note slug format');
435
+ err.status = 404;
436
+ throw err;
437
+ }
438
+
439
+ const projectDir = req.app.locals.projectDir;
440
+ const note = await getNoteBySlug(projectDir, slug);
441
+
442
+ if (!note) {
443
+ const err = new Error(`Note "${slug}" not found`);
444
+ err.status = 404;
445
+ throw err;
446
+ }
447
+
448
+ const templateData = {
449
+ title: note.title,
450
+ activePage: 'notes',
451
+ currentPath: '/notes/' + slug,
452
+ breadcrumbs: [{ label: 'Notes', url: '/notes' }, { label: note.title }],
453
+ ...note
454
+ };
455
+
456
+ res.setHeader('Vary', 'HX-Request');
457
+
458
+ if (req.get('HX-Request') === 'true') {
459
+ res.render('partials/note-detail-content', templateData);
460
+ } else {
461
+ res.render('note-detail', templateData);
462
+ }
463
+ });
464
+
401
465
  router.get('/roadmap', async (req, res) => {
402
466
  const projectDir = req.app.locals.projectDir;
403
467
  const [roadmapData, stateData] = await Promise.all([
@@ -423,4 +487,118 @@ router.get('/roadmap', async (req, res) => {
423
487
  }
424
488
  });
425
489
 
490
+ router.get('/quick', async (req, res) => {
491
+ const projectDir = req.app.locals.projectDir;
492
+ const tasks = await listQuickTasks(projectDir);
493
+
494
+ const templateData = {
495
+ title: 'Quick Tasks',
496
+ activePage: 'quick',
497
+ currentPath: '/quick',
498
+ breadcrumbs: [{ label: 'Quick Tasks' }],
499
+ tasks
500
+ };
501
+
502
+ res.setHeader('Vary', 'HX-Request');
503
+
504
+ if (req.get('HX-Request') === 'true') {
505
+ res.render('partials/quick-content', templateData);
506
+ } else {
507
+ res.render('quick', templateData);
508
+ }
509
+ });
510
+
511
+ router.get('/quick/:id', async (req, res) => {
512
+ const { id } = req.params;
513
+
514
+ // Validate ID format: must be exactly three digits
515
+ if (!/^\d{3}$/.test(id)) {
516
+ const err = new Error('Quick Task ID must be a three-digit number (e.g., 001, 005, 042)');
517
+ err.status = 404;
518
+ throw err;
519
+ }
520
+
521
+ const projectDir = req.app.locals.projectDir;
522
+ const task = await getQuickTask(projectDir, id);
523
+
524
+ if (!task) {
525
+ const err = new Error(`Quick task ${id} not found`);
526
+ err.status = 404;
527
+ throw err;
528
+ }
529
+
530
+ const templateData = {
531
+ title: `Quick Task ${task.id}: ${task.title}`,
532
+ activePage: 'quick',
533
+ currentPath: '/quick/' + id,
534
+ breadcrumbs: [{ label: 'Quick Tasks', url: '/quick' }, { label: 'Task ' + id }],
535
+ ...task
536
+ };
537
+
538
+ res.setHeader('Vary', 'HX-Request');
539
+
540
+ if (req.get('HX-Request') === 'true') {
541
+ res.render('partials/quick-detail-content', templateData);
542
+ } else {
543
+ res.render('quick-detail', templateData);
544
+ }
545
+ });
546
+
547
+ router.get('/audits', async (req, res) => {
548
+ const projectDir = req.app.locals.projectDir;
549
+ const reports = await listAuditReports(projectDir);
550
+
551
+ const templateData = {
552
+ title: 'Audit Reports',
553
+ activePage: 'audits',
554
+ currentPath: '/audits',
555
+ breadcrumbs: [{ label: 'Audit Reports' }],
556
+ reports
557
+ };
558
+
559
+ res.setHeader('Vary', 'HX-Request');
560
+
561
+ if (req.get('HX-Request') === 'true') {
562
+ res.render('partials/audits-content', templateData);
563
+ } else {
564
+ res.render('audits', templateData);
565
+ }
566
+ });
567
+
568
+ router.get('/audits/:filename', async (req, res) => {
569
+ const { filename } = req.params;
570
+
571
+ // Validate filename: safe characters only, must end in .md
572
+ if (!/^[\w.-]+\.md$/.test(filename)) {
573
+ const err = new Error('Invalid audit report filename');
574
+ err.status = 404;
575
+ throw err;
576
+ }
577
+
578
+ const projectDir = req.app.locals.projectDir;
579
+ const report = await getAuditReport(projectDir, filename);
580
+
581
+ if (!report) {
582
+ const err = new Error(`Audit report "${filename}" not found`);
583
+ err.status = 404;
584
+ throw err;
585
+ }
586
+
587
+ const templateData = {
588
+ title: report.title,
589
+ activePage: 'audits',
590
+ currentPath: '/audits/' + filename,
591
+ breadcrumbs: [{ label: 'Audit Reports', url: '/audits' }, { label: report.title }],
592
+ ...report
593
+ };
594
+
595
+ res.setHeader('Vary', 'HX-Request');
596
+
597
+ if (req.get('HX-Request') === 'true') {
598
+ res.render('partials/audit-detail-content', templateData);
599
+ } else {
600
+ res.render('audit-detail', templateData);
601
+ }
602
+ });
603
+
426
604
  export default router;
@@ -1,6 +1,8 @@
1
1
  import { createApp } from './app.js';
2
2
  import { createWatcher } from './services/watcher.service.js';
3
3
  import { broadcast } from './services/sse.service.js';
4
+ import { cache as milestoneCache } from './services/milestone.service.js';
5
+ import { cache as analyticsCache } from './services/analytics.service.js';
4
6
 
5
7
  export function startServer(config) {
6
8
  const app = createApp(config);
@@ -8,6 +10,8 @@ export function startServer(config) {
8
10
 
9
11
  // Start file watcher for live updates
10
12
  const watcher = createWatcher(projectDir, (event) => {
13
+ milestoneCache.invalidateAll();
14
+ analyticsCache.invalidateAll();
11
15
  broadcast('file-change', event);
12
16
  });
13
17
 
@@ -0,0 +1,42 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readMarkdownFile } from '../repositories/planning.repository.js';
4
+
5
+ export async function listAuditReports(projectDir) {
6
+ const auditsDir = join(projectDir, '.planning', 'audits');
7
+ let entries;
8
+ try {
9
+ entries = await readdir(auditsDir);
10
+ } catch (err) {
11
+ if (err.code === 'ENOENT') return [];
12
+ throw err;
13
+ }
14
+ const mdFiles = entries.filter(f => f.endsWith('.md')).sort().reverse();
15
+ const reports = [];
16
+ for (const filename of mdFiles) {
17
+ const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/);
18
+ const date = dateMatch ? dateMatch[1] : null;
19
+ const slug = dateMatch ? dateMatch[2] : filename.replace(/\.md$/, '');
20
+ const title = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
21
+ reports.push({ filename, date, slug, title });
22
+ }
23
+ return reports;
24
+ }
25
+
26
+ export async function getAuditReport(projectDir, filename) {
27
+ if (!/^[\w.-]+\.md$/.test(filename)) return null;
28
+ if (filename.includes('/') || filename.includes('\\') || filename.includes('..')) return null;
29
+
30
+ const auditsDir = join(projectDir, '.planning', 'audits');
31
+ try {
32
+ const { frontmatter, html } = await readMarkdownFile(join(auditsDir, filename));
33
+ const dateMatch = filename.match(/^(\d{4}-\d{2}-\d{2})-(.+)\.md$/);
34
+ const date = dateMatch ? dateMatch[1] : null;
35
+ const slug = dateMatch ? dateMatch[2] : filename.replace(/\.md$/, '');
36
+ const title = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
37
+ return { filename, date, slug, title, frontmatter, html };
38
+ } catch (err) {
39
+ if (err.code === 'ENOENT') return null;
40
+ throw err;
41
+ }
42
+ }
@@ -1,17 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
-
4
- /**
5
- * Strip UTF-8 BOM from file content.
6
- * Duplicated from planning.repository.js intentionally --
7
- * this service reads raw text, not via the repository layer.
8
- *
9
- * @param {string} content - Raw file content
10
- * @returns {string} Content without BOM
11
- */
12
- function stripBOM(content) {
13
- return content.replace(/^\uFEFF/, '');
14
- }
3
+ import { stripBOM } from '../utils/strip-bom.js';
15
4
 
16
5
  /**
17
6
  * Parse STATE.md to extract project status information.
@@ -0,0 +1,81 @@
1
+ import { readFile } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Read and aggregate local LLM metrics from .planning/logs/local-llm-metrics.jsonl.
6
+ *
7
+ * @param {string} projectDir - Absolute path to the project root
8
+ * @returns {Promise<{summary: object, byOperation: Array, baseline: object}|null>}
9
+ * Returns null if the file does not exist, is empty, or has no valid entries.
10
+ */
11
+ export async function getLlmMetrics(projectDir) {
12
+ try {
13
+ const filePath = join(projectDir, '.planning', 'logs', 'local-llm-metrics.jsonl');
14
+
15
+ let raw;
16
+ try {
17
+ raw = await readFile(filePath, 'utf8');
18
+ } catch (err) {
19
+ if (err.code === 'ENOENT') return null;
20
+ throw err;
21
+ }
22
+
23
+ // Parse valid JSON lines, skip malformed
24
+ const entries = [];
25
+ for (const line of raw.split('\n')) {
26
+ const trimmed = line.trim();
27
+ if (!trimmed) continue;
28
+ try {
29
+ entries.push(JSON.parse(trimmed));
30
+ } catch {
31
+ // skip malformed lines
32
+ }
33
+ }
34
+
35
+ if (entries.length === 0) return null;
36
+
37
+ // Compute aggregate summary
38
+ const total_calls = entries.length;
39
+ const fallback_count = entries.filter(e => e.fallback_used === true).length;
40
+ const fallback_rate_pct = Math.round(fallback_count / total_calls * 100);
41
+ const avg_latency_ms = Math.round(
42
+ entries.reduce((sum, e) => sum + (e.latency_ms || 0), 0) / total_calls
43
+ );
44
+ const tokens_saved = entries.reduce((sum, e) => sum + (e.tokens_saved_frontier || 0), 0);
45
+ const cost_saved_usd = parseFloat((tokens_saved * (3.0 / 1_000_000)).toFixed(4));
46
+
47
+ const summary = {
48
+ total_calls,
49
+ fallback_count,
50
+ fallback_rate_pct,
51
+ avg_latency_ms,
52
+ tokens_saved,
53
+ cost_saved_usd
54
+ };
55
+
56
+ // Compute byOperation: group entries by operation field
57
+ const opMap = new Map();
58
+ for (const e of entries) {
59
+ const op = e.operation || 'unknown';
60
+ if (!opMap.has(op)) {
61
+ opMap.set(op, { operation: op, calls: 0, fallbacks: 0, tokens_saved: 0 });
62
+ }
63
+ const rec = opMap.get(op);
64
+ rec.calls += 1;
65
+ if (e.fallback_used === true) rec.fallbacks += 1;
66
+ rec.tokens_saved += e.tokens_saved_frontier || 0;
67
+ }
68
+
69
+ const byOperation = Array.from(opMap.values()).sort((a, b) => b.calls - a.calls);
70
+
71
+ // Compute baseline (LLM-15)
72
+ const baseline = {
73
+ hook_invocations: total_calls,
74
+ estimated_frontier_tokens_without_local: tokens_saved
75
+ };
76
+
77
+ return { summary, byOperation, baseline };
78
+ } catch {
79
+ return null;
80
+ }
81
+ }
@@ -0,0 +1,62 @@
1
+ import { readdir } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ import { readMarkdownFile } from '../repositories/planning.repository.js';
4
+
5
+ export async function listQuickTasks(projectDir) {
6
+ const quickDir = join(projectDir, '.planning', 'quick');
7
+ let entries;
8
+ try {
9
+ entries = await readdir(quickDir, { withFileTypes: true });
10
+ } catch (err) {
11
+ if (err.code === 'ENOENT') return [];
12
+ throw err;
13
+ }
14
+
15
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
16
+ const tasks = [];
17
+ for (const dirName of dirs) {
18
+ const match = dirName.match(/^(\d{3})-(.+)$/);
19
+ if (!match) continue;
20
+ const [, id, slug] = match;
21
+ const title = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
22
+ let status = 'in-progress';
23
+ try {
24
+ const summaryPath = join(quickDir, dirName, 'SUMMARY.md');
25
+ const { frontmatter } = await readMarkdownFile(summaryPath);
26
+ if (frontmatter.status) status = frontmatter.status;
27
+ } catch { /* No SUMMARY.md yet */ }
28
+ tasks.push({ id, slug, dirName, title, status });
29
+ }
30
+ return tasks;
31
+ }
32
+
33
+ export async function getQuickTask(projectDir, id) {
34
+ const quickDir = join(projectDir, '.planning', 'quick');
35
+ let entries;
36
+ try {
37
+ entries = await readdir(quickDir, { withFileTypes: true });
38
+ } catch (err) {
39
+ if (err.code === 'ENOENT') return null;
40
+ throw err;
41
+ }
42
+ const dirEntry = entries.filter(e => e.isDirectory()).find(e => e.name.startsWith(id + '-'));
43
+ if (!dirEntry) return null;
44
+
45
+ const taskDir = join(quickDir, dirEntry.name);
46
+ const match = dirEntry.name.match(/^(\d{3})-(.+)$/);
47
+ const slug = match ? match[2] : dirEntry.name;
48
+ const title = slug.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
49
+
50
+ let planHtml = null, summaryHtml = null, summaryFrontmatter = {};
51
+ try {
52
+ const plan = await readMarkdownFile(join(taskDir, 'PLAN.md'));
53
+ planHtml = plan.html;
54
+ } catch { /* missing */ }
55
+ try {
56
+ const summary = await readMarkdownFile(join(taskDir, 'SUMMARY.md'));
57
+ summaryHtml = summary.html;
58
+ summaryFrontmatter = summary.frontmatter;
59
+ } catch { /* missing */ }
60
+
61
+ return { id, slug, dirName: dirEntry.name, title, status: summaryFrontmatter.status || 'in-progress', planHtml, summaryHtml };
62
+ }
@@ -1,17 +1,7 @@
1
1
  import { readFile, readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { parseRoadmapFile } from './dashboard.service.js';
4
-
5
- /**
6
- * Strip UTF-8 BOM from file content.
7
- * Duplicated intentionally -- this service reads raw text, not via the repository layer.
8
- *
9
- * @param {string} content - Raw file content
10
- * @returns {string} Content without BOM
11
- */
12
- function stripBOM(content) {
13
- return content.replace(/^\uFEFF/, '');
14
- }
4
+ import { stripBOM } from '../utils/strip-bom.js';
15
5
 
16
6
  /**
17
7
  * Count the number of PLAN.md files in a phase directory.
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Strip UTF-8 BOM (Byte Order Mark) if present.
3
+ * @param {string} content
4
+ * @returns {string}
5
+ */
6
+ export function stripBOM(content) {
7
+ return content.replace(/^\uFEFF/, '');
8
+ }
@@ -0,0 +1,5 @@
1
+ <%- include('partials/layout-top', { title: title, activePage: 'audits' }) %>
2
+
3
+ <%- include('partials/audit-detail-content') %>
4
+
5
+ <%- include('partials/layout-bottom') %>
@@ -0,0 +1,5 @@
1
+ <%- include('partials/layout-top', { title: 'Audit Reports', activePage: 'audits' }) %>
2
+
3
+ <%- include('partials/audits-content') %>
4
+
5
+ <%- include('partials/layout-bottom') %>