@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.
- package/CHANGELOG.md +42 -0
- package/dashboard/package.json +3 -2
- package/dashboard/src/middleware/errorHandler.js +12 -2
- package/dashboard/src/repositories/planning.repository.js +24 -12
- package/dashboard/src/routes/pages.routes.js +182 -4
- package/dashboard/src/server.js +4 -0
- package/dashboard/src/services/audit.service.js +42 -0
- package/dashboard/src/services/dashboard.service.js +1 -12
- package/dashboard/src/services/local-llm-metrics.service.js +81 -0
- package/dashboard/src/services/quick.service.js +62 -0
- package/dashboard/src/services/roadmap.service.js +1 -11
- package/dashboard/src/utils/strip-bom.js +8 -0
- package/dashboard/src/views/audit-detail.ejs +5 -0
- package/dashboard/src/views/audits.ejs +5 -0
- package/dashboard/src/views/partials/analytics-content.ejs +61 -0
- package/dashboard/src/views/partials/audit-detail-content.ejs +12 -0
- package/dashboard/src/views/partials/audits-content.ejs +34 -0
- package/dashboard/src/views/partials/quick-content.ejs +40 -0
- package/dashboard/src/views/partials/quick-detail-content.ejs +29 -0
- package/dashboard/src/views/partials/sidebar.ejs +16 -0
- package/dashboard/src/views/partials/todos-content.ejs +13 -3
- package/dashboard/src/views/quick-detail.ejs +5 -0
- package/dashboard/src/views/quick.ejs +5 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/agents/debugger.agent.md +15 -0
- package/plugins/copilot-pbr/agents/integration-checker.agent.md +9 -2
- package/plugins/copilot-pbr/agents/planner.agent.md +19 -0
- package/plugins/copilot-pbr/agents/researcher.agent.md +20 -0
- package/plugins/copilot-pbr/agents/synthesizer.agent.md +12 -0
- package/plugins/copilot-pbr/agents/verifier.agent.md +22 -2
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/references/config-reference.md +89 -0
- package/plugins/copilot-pbr/references/plan-format.md +22 -0
- package/plugins/copilot-pbr/skills/health/SKILL.md +8 -1
- package/plugins/copilot-pbr/skills/help/SKILL.md +4 -4
- package/plugins/copilot-pbr/skills/milestone/SKILL.md +12 -12
- package/plugins/copilot-pbr/skills/status/SKILL.md +37 -1
- package/plugins/copilot-pbr/templates/INTEGRATION-REPORT.md.tmpl +18 -2
- package/plugins/copilot-pbr/templates/VERIFICATION-DETAIL.md.tmpl +2 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/agents/debugger.md +15 -0
- package/plugins/cursor-pbr/agents/integration-checker.md +9 -2
- package/plugins/cursor-pbr/agents/planner.md +19 -0
- package/plugins/cursor-pbr/agents/researcher.md +20 -0
- package/plugins/cursor-pbr/agents/synthesizer.md +12 -0
- package/plugins/cursor-pbr/agents/verifier.md +22 -2
- package/plugins/cursor-pbr/references/config-reference.md +89 -0
- package/plugins/cursor-pbr/references/plan-format.md +22 -0
- package/plugins/cursor-pbr/skills/health/SKILL.md +8 -1
- package/plugins/cursor-pbr/skills/help/SKILL.md +4 -4
- package/plugins/cursor-pbr/skills/milestone/SKILL.md +12 -12
- package/plugins/cursor-pbr/skills/status/SKILL.md +37 -1
- package/plugins/cursor-pbr/templates/INTEGRATION-REPORT.md.tmpl +18 -2
- package/plugins/cursor-pbr/templates/VERIFICATION-DETAIL.md.tmpl +2 -1
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/agents/debugger.md +15 -0
- package/plugins/pbr/agents/integration-checker.md +9 -2
- package/plugins/pbr/agents/planner.md +19 -0
- package/plugins/pbr/agents/researcher.md +20 -0
- package/plugins/pbr/agents/synthesizer.md +12 -0
- package/plugins/pbr/agents/verifier.md +22 -2
- package/plugins/pbr/references/config-reference.md +89 -0
- package/plugins/pbr/references/plan-format.md +22 -0
- package/plugins/pbr/scripts/check-config-change.js +33 -0
- package/plugins/pbr/scripts/check-plan-format.js +52 -4
- package/plugins/pbr/scripts/check-subagent-output.js +43 -3
- package/plugins/pbr/scripts/config-schema.json +48 -0
- package/plugins/pbr/scripts/local-llm/client.js +214 -0
- package/plugins/pbr/scripts/local-llm/health.js +217 -0
- package/plugins/pbr/scripts/local-llm/metrics.js +252 -0
- package/plugins/pbr/scripts/local-llm/operations/classify-artifact.js +76 -0
- package/plugins/pbr/scripts/local-llm/operations/classify-error.js +75 -0
- package/plugins/pbr/scripts/local-llm/operations/score-source.js +72 -0
- package/plugins/pbr/scripts/local-llm/operations/summarize-context.js +62 -0
- package/plugins/pbr/scripts/local-llm/operations/validate-task.js +59 -0
- package/plugins/pbr/scripts/local-llm/router.js +101 -0
- package/plugins/pbr/scripts/local-llm/shadow.js +60 -0
- package/plugins/pbr/scripts/local-llm/threshold-tuner.js +118 -0
- package/plugins/pbr/scripts/pbr-tools.js +120 -3
- package/plugins/pbr/scripts/post-write-dispatch.js +2 -2
- package/plugins/pbr/scripts/progress-tracker.js +29 -3
- package/plugins/pbr/scripts/session-cleanup.js +36 -1
- package/plugins/pbr/scripts/validate-task.js +30 -1
- package/plugins/pbr/skills/health/SKILL.md +8 -1
- package/plugins/pbr/skills/help/SKILL.md +4 -4
- package/plugins/pbr/skills/milestone/SKILL.md +12 -12
- package/plugins/pbr/skills/status/SKILL.md +38 -2
- package/plugins/pbr/templates/INTEGRATION-REPORT.md.tmpl +18 -2
- package/plugins/pbr/templates/VERIFICATION-DETAIL.md.tmpl +2 -1
- 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
|
|
package/dashboard/package.json
CHANGED
|
@@ -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, '<')
|
|
11
|
+
.replace(/>/g, '>')
|
|
12
|
+
.replace(/"/g, '"')
|
|
13
|
+
.replace(/'/g, ''');
|
|
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 {
|
|
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
|
|
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;
|
package/dashboard/src/server.js
CHANGED
|
@@ -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.
|