@sienklogic/plan-build-run 2.14.0 → 2.16.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 (50) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/dashboard/package.json +4 -1
  3. package/dashboard/public/css/layout.css +237 -82
  4. package/dashboard/public/css/tokens.css +59 -0
  5. package/dashboard/public/js/sidebar-toggle.js +21 -7
  6. package/dashboard/public/js/sse-client.js +99 -0
  7. package/dashboard/public/js/theme-toggle.js +46 -0
  8. package/dashboard/src/app.js +4 -0
  9. package/dashboard/src/middleware/current-phase.js +24 -0
  10. package/dashboard/src/routes/events.routes.js +5 -0
  11. package/dashboard/src/routes/index.routes.js +2 -1
  12. package/dashboard/src/routes/pages.routes.js +94 -6
  13. package/dashboard/src/services/analytics.service.js +143 -0
  14. package/dashboard/src/services/milestone.service.js +50 -4
  15. package/dashboard/src/services/roadmap.service.js +73 -0
  16. package/dashboard/src/services/todo.service.js +11 -2
  17. package/dashboard/src/services/watcher.service.js +1 -1
  18. package/dashboard/src/utils/cache.js +55 -0
  19. package/dashboard/src/views/analytics.ejs +5 -0
  20. package/dashboard/src/views/dependencies.ejs +5 -0
  21. package/dashboard/src/views/error.ejs +16 -9
  22. package/dashboard/src/views/partials/analytics-content.ejs +71 -0
  23. package/dashboard/src/views/partials/breadcrumbs.ejs +14 -0
  24. package/dashboard/src/views/partials/dashboard-content.ejs +1 -0
  25. package/dashboard/src/views/partials/dependencies-content.ejs +16 -0
  26. package/dashboard/src/views/partials/empty-state.ejs +7 -0
  27. package/dashboard/src/views/partials/head.ejs +4 -1
  28. package/dashboard/src/views/partials/header.ejs +9 -0
  29. package/dashboard/src/views/partials/layout-bottom.ejs +1 -10
  30. package/dashboard/src/views/partials/layout-top.ejs +7 -0
  31. package/dashboard/src/views/partials/milestone-detail-content.ejs +1 -0
  32. package/dashboard/src/views/partials/milestones-content.ejs +55 -19
  33. package/dashboard/src/views/partials/phase-content.ejs +1 -0
  34. package/dashboard/src/views/partials/phase-doc-content.ejs +1 -1
  35. package/dashboard/src/views/partials/phases-content.ejs +1 -0
  36. package/dashboard/src/views/partials/roadmap-content.ejs +1 -0
  37. package/dashboard/src/views/partials/sidebar.ejs +88 -43
  38. package/dashboard/src/views/partials/todo-create-content.ejs +1 -0
  39. package/dashboard/src/views/partials/todo-detail-content.ejs +5 -1
  40. package/dashboard/src/views/partials/todos-content.ejs +44 -3
  41. package/package.json +1 -1
  42. package/plugins/copilot-pbr/plugin.json +1 -1
  43. package/plugins/copilot-pbr/skills/build/SKILL.md +5 -0
  44. package/plugins/copilot-pbr/skills/help/SKILL.md +14 -0
  45. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  46. package/plugins/cursor-pbr/skills/build/SKILL.md +5 -0
  47. package/plugins/cursor-pbr/skills/help/SKILL.md +14 -0
  48. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  49. package/plugins/pbr/skills/build/SKILL.md +5 -0
  50. package/plugins/pbr/skills/help/SKILL.md +14 -0
@@ -0,0 +1,99 @@
1
+ /**
2
+ * Custom SSE client with exponential backoff reconnection and state recovery.
3
+ */
4
+ (function () {
5
+ 'use strict';
6
+
7
+ class SSEClient {
8
+ constructor(url) {
9
+ this.baseUrl = url;
10
+ this.lastEventId = null;
11
+ this.backoff = 1000;
12
+ this.maxBackoff = 30000;
13
+ this.reconnectTimer = null;
14
+ this.es = null;
15
+ this.connect();
16
+ }
17
+
18
+ connect() {
19
+ if (this.es) {
20
+ this.es.close();
21
+ }
22
+
23
+ let url = this.baseUrl;
24
+ if (this.lastEventId) {
25
+ const sep = url.includes('?') ? '&' : '?';
26
+ url += sep + 'lastEventId=' + encodeURIComponent(this.lastEventId);
27
+ }
28
+
29
+ this.es = new EventSource(url);
30
+
31
+ this.es.onopen = () => {
32
+ this.backoff = 1000;
33
+ this.updateStatus(true);
34
+ };
35
+
36
+ this.es.onerror = () => {
37
+ this.es.close();
38
+ this.updateStatus(false);
39
+ this.scheduleReconnect();
40
+ };
41
+
42
+ this.es.addEventListener('file-change', (e) => {
43
+ if (e.lastEventId) {
44
+ this.lastEventId = e.lastEventId;
45
+ }
46
+ this.refreshContent();
47
+ });
48
+
49
+ this.es.addEventListener('state-recovery', (e) => {
50
+ if (e.lastEventId) {
51
+ this.lastEventId = e.lastEventId;
52
+ }
53
+ this.refreshContent();
54
+ });
55
+ }
56
+
57
+ scheduleReconnect() {
58
+ if (this.reconnectTimer) {
59
+ clearTimeout(this.reconnectTimer);
60
+ }
61
+ const jitter = this.backoff * (0.5 + Math.random() * 0.5);
62
+ this.reconnectTimer = setTimeout(() => {
63
+ this.connect();
64
+ }, jitter);
65
+ this.backoff = Math.min(this.backoff * 2, this.maxBackoff);
66
+ }
67
+
68
+ updateStatus(connected) {
69
+ const dot = document.getElementById('sse-status');
70
+ if (dot) {
71
+ dot.setAttribute('data-connected', String(connected));
72
+ }
73
+ }
74
+
75
+ refreshContent() {
76
+ const currentPath = window.location.pathname;
77
+ fetch(currentPath, {
78
+ headers: { 'HX-Request': 'true' }
79
+ })
80
+ .then((res) => {
81
+ if (res.ok) return res.text();
82
+ throw new Error('Fetch failed: ' + res.status);
83
+ })
84
+ .then((html) => {
85
+ const target = document.getElementById('main-content');
86
+ if (target) {
87
+ target.innerHTML = html;
88
+ }
89
+ })
90
+ .catch((err) => {
91
+ console.error('SSE content refresh failed:', err.message);
92
+ });
93
+ }
94
+ }
95
+
96
+ document.addEventListener('DOMContentLoaded', () => {
97
+ new SSEClient('/api/events/stream');
98
+ });
99
+ })();
@@ -0,0 +1,46 @@
1
+ /* theme-toggle.js — Toggle light/dark theme via data-theme attribute + localStorage */
2
+ (function () {
3
+ 'use strict';
4
+
5
+ var STORAGE_KEY = 'pbr-theme';
6
+
7
+ function getEffectiveTheme() {
8
+ var explicit = document.documentElement.dataset.theme;
9
+ if (explicit === 'light' || explicit === 'dark') return explicit;
10
+ return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
11
+ }
12
+
13
+ function updateIcon(btn, theme) {
14
+ // Show sun when dark (click to go light), moon when light (click to go dark)
15
+ btn.textContent = theme === 'dark' ? '\u2600\uFE0F' : '\uD83C\uDF19';
16
+ btn.setAttribute('aria-label', theme === 'dark' ? 'Switch to light theme' : 'Switch to dark theme');
17
+ }
18
+
19
+ document.addEventListener('DOMContentLoaded', function () {
20
+ var btn = document.getElementById('theme-toggle');
21
+ if (!btn) return;
22
+
23
+ // Apply stored theme (also done in layout-top inline script for flash prevention)
24
+ var stored = localStorage.getItem(STORAGE_KEY);
25
+ if (stored) {
26
+ document.documentElement.dataset.theme = stored;
27
+ }
28
+
29
+ updateIcon(btn, getEffectiveTheme());
30
+
31
+ btn.addEventListener('click', function () {
32
+ var current = getEffectiveTheme();
33
+ var next = current === 'dark' ? 'light' : 'dark';
34
+ document.documentElement.dataset.theme = next;
35
+ localStorage.setItem(STORAGE_KEY, next);
36
+ updateIcon(btn, next);
37
+ });
38
+
39
+ // Update icon if system preference changes and no explicit preference is stored
40
+ window.matchMedia('(prefers-color-scheme: dark)').addEventListener('change', function () {
41
+ if (!localStorage.getItem(STORAGE_KEY)) {
42
+ updateIcon(btn, getEffectiveTheme());
43
+ }
44
+ });
45
+ });
46
+ })();
@@ -8,6 +8,7 @@ import pagesRouter from './routes/pages.routes.js';
8
8
  import eventsRouter from './routes/events.routes.js';
9
9
  import notFoundHandler from './middleware/notFoundHandler.js';
10
10
  import errorHandler from './middleware/errorHandler.js';
11
+ import currentPhaseMiddleware from './middleware/current-phase.js';
11
12
 
12
13
  const __filename = fileURLToPath(import.meta.url);
13
14
  const __dirname = dirname(__filename);
@@ -63,6 +64,9 @@ export function createApp(config) {
63
64
  next();
64
65
  });
65
66
 
67
+ // Current phase middleware (populates res.locals.currentPhase for templates)
68
+ app.use(currentPhaseMiddleware);
69
+
66
70
  // Routes
67
71
  app.use('/', indexRouter);
68
72
  app.use('/', pagesRouter);
@@ -0,0 +1,24 @@
1
+ import { parseStateFile } from '../services/dashboard.service.js';
2
+
3
+ /**
4
+ * Middleware that reads STATE.md and sets res.locals.currentPhase
5
+ * for use in sidebar and other templates.
6
+ */
7
+ export default async function currentPhaseMiddleware(req, res, next) {
8
+ try {
9
+ const state = await parseStateFile(req.app.locals.projectDir);
10
+ const cp = state.currentPhase;
11
+ if (cp && cp.id > 0) {
12
+ res.locals.currentPhase = {
13
+ number: cp.id,
14
+ name: cp.name,
15
+ status: cp.status
16
+ };
17
+ } else {
18
+ res.locals.currentPhase = null;
19
+ }
20
+ } catch (_err) {
21
+ res.locals.currentPhase = null;
22
+ }
23
+ next();
24
+ }
@@ -22,6 +22,11 @@ router.get('/stream', (req, res) => {
22
22
  // Send initial connection confirmation
23
23
  res.write(': connected\n\n');
24
24
 
25
+ // If client reconnected with a lastEventId, send state-recovery event
26
+ if (req.query.lastEventId) {
27
+ res.write(`event: state-recovery\ndata: {"action":"refresh"}\nid: ${Date.now()}\n\n`);
28
+ }
29
+
25
30
  // Register this client for broadcasts
26
31
  addClient(res);
27
32
 
@@ -16,7 +16,8 @@ router.get('/', async (req, res) => {
16
16
  ...homepageData,
17
17
  ...dashboardData,
18
18
  activePage: 'dashboard',
19
- currentPath: '/'
19
+ currentPath: '/',
20
+ breadcrumbs: []
20
21
  };
21
22
 
22
23
  res.setHeader('Vary', 'HX-Request');
@@ -1,9 +1,10 @@
1
1
  import { Router } from 'express';
2
2
  import { getPhaseDetail, getPhaseDocument } from '../services/phase.service.js';
3
- import { getRoadmapData } from '../services/roadmap.service.js';
3
+ import { getRoadmapData, generateDependencyMermaid } from '../services/roadmap.service.js';
4
4
  import { parseStateFile, derivePhaseStatuses } from '../services/dashboard.service.js';
5
5
  import { listPendingTodos, getTodoDetail, createTodo, completeTodo } from '../services/todo.service.js';
6
6
  import { getAllMilestones, getMilestoneDetail } from '../services/milestone.service.js';
7
+ import { getProjectAnalytics } from '../services/analytics.service.js';
7
8
 
8
9
  const router = Router();
9
10
 
@@ -19,7 +20,8 @@ router.get('/phases', async (req, res) => {
19
20
  activePage: 'phases',
20
21
  currentPath: '/phases',
21
22
  phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
22
- milestones: roadmapData.milestones
23
+ milestones: roadmapData.milestones,
24
+ breadcrumbs: [{ label: 'Phases' }]
23
25
  };
24
26
 
25
27
  res.setHeader('Vary', 'HX-Request');
@@ -48,6 +50,7 @@ router.get('/phases/:phaseId', async (req, res) => {
48
50
  title: `Phase ${phaseId}: ${phaseData.phaseName}`,
49
51
  activePage: 'phases',
50
52
  currentPath: '/phases/' + phaseId,
53
+ breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId }],
51
54
  ...phaseData
52
55
  };
53
56
 
@@ -98,6 +101,7 @@ router.get('/phases/:phaseId/:planId/:docType', async (req, res) => {
98
101
  title: `${docLabel} ${planId} — Phase ${phaseId}: ${doc.phaseName}`,
99
102
  activePage: 'phases',
100
103
  currentPath: `/phases/${phaseId}/${planId}/${docType}`,
104
+ breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId, url: '/phases/' + phaseId }, { label: docLabel + ' ' + planId }],
101
105
  ...doc
102
106
  };
103
107
 
@@ -112,13 +116,20 @@ router.get('/phases/:phaseId/:planId/:docType', async (req, res) => {
112
116
 
113
117
  router.get('/todos', async (req, res) => {
114
118
  const projectDir = req.app.locals.projectDir;
115
- const todos = await listPendingTodos(projectDir);
119
+ const { priority, status, q } = req.query;
120
+ const filters = {};
121
+ if (priority) filters.priority = priority;
122
+ if (status) filters.status = status;
123
+ if (q) filters.q = q;
124
+ const todos = await listPendingTodos(projectDir, filters);
116
125
 
117
126
  const templateData = {
118
127
  title: 'Todos',
119
128
  activePage: 'todos',
120
129
  currentPath: '/todos',
121
- todos
130
+ breadcrumbs: [{ label: 'Todos' }],
131
+ todos,
132
+ filters: { priority: priority || '', status: status || '', q: q || '' }
122
133
  };
123
134
 
124
135
  res.setHeader('Vary', 'HX-Request');
@@ -130,11 +141,40 @@ router.get('/todos', async (req, res) => {
130
141
  }
131
142
  });
132
143
 
144
+ router.post('/todos/bulk-complete', async (req, res) => {
145
+ const projectDir = req.app.locals.projectDir;
146
+ const { priority, status, q } = req.query;
147
+ const filters = {};
148
+ if (priority) filters.priority = priority;
149
+ if (status) filters.status = status;
150
+ if (q) filters.q = q;
151
+
152
+ const todos = await listPendingTodos(projectDir, filters);
153
+ for (const todo of todos) {
154
+ await completeTodo(projectDir, todo.id);
155
+ }
156
+
157
+ if (req.get('HX-Request') === 'true') {
158
+ const remaining = await listPendingTodos(projectDir);
159
+ res.render('partials/todos-content', {
160
+ title: 'Todos',
161
+ activePage: 'todos',
162
+ currentPath: '/todos',
163
+ breadcrumbs: [{ label: 'Todos' }],
164
+ todos: remaining,
165
+ filters: { priority: '', status: '', q: '' }
166
+ });
167
+ } else {
168
+ res.redirect('/todos');
169
+ }
170
+ });
171
+
133
172
  router.get('/todos/new', (req, res) => {
134
173
  const templateData = {
135
174
  title: 'Create Todo',
136
175
  activePage: 'todos',
137
- currentPath: '/todos/new'
176
+ currentPath: '/todos/new',
177
+ breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Create' }]
138
178
  };
139
179
 
140
180
  res.setHeader('Vary', 'HX-Request');
@@ -163,6 +203,7 @@ router.get('/todos/:id', async (req, res) => {
163
203
  title: `Todo ${todo.id}: ${todo.title}`,
164
204
  activePage: 'todos',
165
205
  currentPath: '/todos/' + id,
206
+ breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + id }],
166
207
  ...todo
167
208
  };
168
209
 
@@ -193,6 +234,7 @@ router.post('/todos', async (req, res) => {
193
234
  title: `Todo ${todo.id}: ${todo.title}`,
194
235
  activePage: 'todos',
195
236
  currentPath: '/todos/' + todoId,
237
+ breadcrumbs: [{ label: 'Todos', url: '/todos' }, { label: 'Todo ' + todoId }],
196
238
  ...todo
197
239
  });
198
240
  } else {
@@ -219,6 +261,7 @@ router.post('/todos/:id/done', async (req, res) => {
219
261
  title: 'Todos',
220
262
  activePage: 'todos',
221
263
  currentPath: '/todos',
264
+ breadcrumbs: [{ label: 'Todos' }],
222
265
  todos
223
266
  });
224
267
  } else {
@@ -234,6 +277,7 @@ router.get('/milestones', async (req, res) => {
234
277
  title: 'Milestones',
235
278
  activePage: 'milestones',
236
279
  currentPath: '/milestones',
280
+ breadcrumbs: [{ label: 'Milestones' }],
237
281
  ...milestoneData
238
282
  };
239
283
 
@@ -269,6 +313,7 @@ router.get('/milestones/:version', async (req, res) => {
269
313
  title: `Milestone v${version}`,
270
314
  activePage: 'milestones',
271
315
  currentPath: '/milestones/' + version,
316
+ breadcrumbs: [{ label: 'Milestones', url: '/milestones' }, { label: 'v' + version }],
272
317
  ...detail
273
318
  };
274
319
 
@@ -281,6 +326,48 @@ router.get('/milestones/:version', async (req, res) => {
281
326
  }
282
327
  });
283
328
 
329
+ router.get('/dependencies', async (req, res) => {
330
+ const projectDir = req.app.locals.projectDir;
331
+ const mermaidCode = await generateDependencyMermaid(projectDir);
332
+
333
+ const templateData = {
334
+ title: 'Dependencies',
335
+ activePage: 'dependencies',
336
+ currentPath: '/dependencies',
337
+ breadcrumbs: [{ label: 'Dependencies' }],
338
+ mermaidCode
339
+ };
340
+
341
+ res.setHeader('Vary', 'HX-Request');
342
+
343
+ if (req.get('HX-Request') === 'true') {
344
+ res.render('partials/dependencies-content', templateData);
345
+ } else {
346
+ res.render('dependencies', templateData);
347
+ }
348
+ });
349
+
350
+ router.get('/analytics', async (req, res) => {
351
+ const projectDir = req.app.locals.projectDir;
352
+ const analytics = await getProjectAnalytics(projectDir);
353
+
354
+ const templateData = {
355
+ title: 'Analytics',
356
+ activePage: 'analytics',
357
+ currentPath: '/analytics',
358
+ breadcrumbs: [{ label: 'Analytics' }],
359
+ analytics
360
+ };
361
+
362
+ res.setHeader('Vary', 'HX-Request');
363
+
364
+ if (req.get('HX-Request') === 'true') {
365
+ res.render('partials/analytics-content', templateData);
366
+ } else {
367
+ res.render('analytics', templateData);
368
+ }
369
+ });
370
+
284
371
  router.get('/roadmap', async (req, res) => {
285
372
  const projectDir = req.app.locals.projectDir;
286
373
  const [roadmapData, stateData] = await Promise.all([
@@ -293,7 +380,8 @@ router.get('/roadmap', async (req, res) => {
293
380
  activePage: 'roadmap',
294
381
  currentPath: '/roadmap',
295
382
  phases: derivePhaseStatuses(roadmapData.phases, stateData.currentPhase),
296
- milestones: roadmapData.milestones
383
+ milestones: roadmapData.milestones,
384
+ breadcrumbs: [{ label: 'Roadmap' }]
297
385
  };
298
386
 
299
387
  res.setHeader('Vary', 'HX-Request');
@@ -0,0 +1,143 @@
1
+ import { execFile as execFileCb } from 'node:child_process';
2
+ import { readdir } from 'node:fs/promises';
3
+ import { join } from 'node:path';
4
+ import { promisify } from 'node:util';
5
+ import { TTLCache } from '../utils/cache.js';
6
+
7
+ const execFile = promisify(execFileCb);
8
+
9
+ export const cache = new TTLCache(60_000); // 60s TTL
10
+
11
+ /**
12
+ * Run a git command in the given directory, returning stdout.
13
+ * Returns empty string on failure.
14
+ */
15
+ async function git(projectDir, args) {
16
+ try {
17
+ const { stdout } = await execFile('git', args, {
18
+ cwd: projectDir,
19
+ maxBuffer: 10 * 1024 * 1024
20
+ });
21
+ return stdout;
22
+ } catch {
23
+ return '';
24
+ }
25
+ }
26
+
27
+ /**
28
+ * Compute project analytics from git history and .planning/ files.
29
+ *
30
+ * @param {string} projectDir - Absolute path to the project root
31
+ * @returns {Promise<{phases: Array, summary: object, warning?: string}>}
32
+ */
33
+ export async function getProjectAnalytics(projectDir) {
34
+ const cacheKey = `analytics:${projectDir}`;
35
+ const cached = cache.get(cacheKey);
36
+ if (cached) return cached;
37
+
38
+ const phasesDir = join(projectDir, '.planning', 'phases');
39
+ let phaseDirs = [];
40
+ let warning = null;
41
+
42
+ try {
43
+ const entries = await readdir(phasesDir, { withFileTypes: true });
44
+ phaseDirs = entries
45
+ .filter(e => e.isDirectory() && /^\d{2}-/.test(e.name))
46
+ .sort((a, b) => a.name.localeCompare(b.name));
47
+ } catch (err) {
48
+ if (err.code === 'ENOENT') {
49
+ warning = 'No .planning/phases/ directory found';
50
+ } else {
51
+ warning = `Failed to read phases directory: ${err.message}`;
52
+ }
53
+ }
54
+
55
+ // Get all commit log lines once
56
+ const allLog = await git(projectDir, ['log', '--oneline', '--all']);
57
+ const allLogLines = allLog ? allLog.trim().split('\n').filter(Boolean) : [];
58
+
59
+ // Get numstat for lines changed
60
+ const numstatRaw = await git(projectDir, ['log', '--numstat', '--all', '--format=COMMIT:%s']);
61
+
62
+ const phases = [];
63
+
64
+ for (const dir of phaseDirs) {
65
+ const phaseNum = dir.name.split('-')[0];
66
+ const phaseName = dir.name.split('-').slice(1).map(
67
+ w => w.charAt(0).toUpperCase() + w.slice(1).toLowerCase()
68
+ ).join(' ');
69
+
70
+ // Commit count: match scope pattern (NN-
71
+ const scopePattern = new RegExp(`\\(${phaseNum}-`);
72
+ const commitCount = allLogLines.filter(line => scopePattern.test(line)).length;
73
+
74
+ // Phase duration from git dates
75
+ const dateOutput = await git(projectDir, [
76
+ 'log', '--format=%aI', '--', `.planning/phases/${dir.name}/`
77
+ ]);
78
+ const dates = dateOutput.trim().split('\n').filter(Boolean).map(d => new Date(d));
79
+ let duration = null;
80
+ if (dates.length >= 2) {
81
+ const earliest = new Date(Math.min(...dates));
82
+ const latest = new Date(Math.max(...dates));
83
+ const days = Math.round((latest - earliest) / (1000 * 60 * 60 * 24));
84
+ duration = `${days}d`;
85
+ } else if (dates.length === 1) {
86
+ duration = '0d';
87
+ }
88
+
89
+ // Plan count
90
+ let planCount = 0;
91
+ try {
92
+ const files = await readdir(join(phasesDir, dir.name));
93
+ planCount = files.filter(f => /^(?:\d{2}-\d{2}-)?PLAN.*\.md$/i.test(f)).length;
94
+ } catch {
95
+ // ignore
96
+ }
97
+
98
+ // Lines changed: parse numstat output for commits matching this phase scope
99
+ let linesChanged = 0;
100
+ if (numstatRaw) {
101
+ let currentCommitMatches = false;
102
+ for (const line of numstatRaw.split('\n')) {
103
+ if (line.startsWith('COMMIT:')) {
104
+ currentCommitMatches = scopePattern.test(line);
105
+ } else if (currentCommitMatches && line.trim()) {
106
+ const parts = line.split('\t');
107
+ if (parts.length >= 2) {
108
+ const added = parseInt(parts[0], 10) || 0;
109
+ const deleted = parseInt(parts[1], 10) || 0;
110
+ linesChanged += added + deleted;
111
+ }
112
+ }
113
+ }
114
+ }
115
+
116
+ phases.push({
117
+ phaseId: phaseNum,
118
+ phaseName,
119
+ commitCount,
120
+ duration,
121
+ planCount,
122
+ linesChanged
123
+ });
124
+ }
125
+
126
+ // Aggregate summary
127
+ const totalCommits = phases.reduce((s, p) => s + p.commitCount, 0);
128
+ const totalPhases = phases.length;
129
+ const durations = phases.filter(p => p.duration).map(p => parseInt(p.duration, 10));
130
+ const avgDuration = durations.length
131
+ ? `${Math.round(durations.reduce((s, d) => s + d, 0) / durations.length)}d`
132
+ : 'N/A';
133
+ const totalLinesChanged = phases.reduce((s, p) => s + p.linesChanged, 0);
134
+
135
+ const result = {
136
+ phases,
137
+ summary: { totalCommits, totalPhases, avgDuration, totalLinesChanged },
138
+ ...(warning ? { warning } : {})
139
+ };
140
+
141
+ cache.set(cacheKey, result);
142
+ return result;
143
+ }
@@ -2,6 +2,9 @@ import { readdir } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
3
  import { readMarkdownFile } from '../repositories/planning.repository.js';
4
4
  import { getRoadmapData } from './roadmap.service.js';
5
+ import { TTLCache } from '../utils/cache.js';
6
+
7
+ export const cache = new TTLCache(300_000); // 300s TTL
5
8
 
6
9
  /**
7
10
  * Scan .planning/milestones/ for archived milestone files.
@@ -14,6 +17,10 @@ import { getRoadmapData } from './roadmap.service.js';
14
17
  * @returns {Promise<Array<{version: string, name: string, date: string, duration: string, files: string[]}>>}
15
18
  */
16
19
  export async function listArchivedMilestones(projectDir) {
20
+ const cacheKey = `milestones:${projectDir}`;
21
+ const cached = cache.get(cacheKey);
22
+ if (cached) return cached;
23
+
17
24
  const milestonesDir = join(projectDir, '.planning', 'milestones');
18
25
 
19
26
  let entries;
@@ -64,8 +71,9 @@ export async function listArchivedMilestones(projectDir) {
64
71
  versionMap.get(version).files.push(entry.name);
65
72
  }
66
73
 
67
- // Try to parse STATS.md for each version to get name/date/duration
74
+ // Try to parse STATS.md for each version to get name/date/duration/stats
68
75
  for (const [version, milestone] of versionMap) {
76
+ milestone.stats = { phaseCount: 0, commitCount: 0, deliverables: [] };
69
77
  let statsPath;
70
78
  if (milestone.format === 'directory') {
71
79
  if (milestone.files.includes('STATS.md')) {
@@ -80,22 +88,53 @@ export async function listArchivedMilestones(projectDir) {
80
88
 
81
89
  if (statsPath) {
82
90
  try {
83
- const { frontmatter } = await readMarkdownFile(statsPath);
91
+ const { frontmatter, html } = await readMarkdownFile(statsPath);
84
92
  milestone.name = frontmatter.milestone || frontmatter.name || `v${version}`;
85
93
  milestone.date = frontmatter.completed || frontmatter.date || '';
86
94
  milestone.duration = frontmatter.duration || '';
95
+ milestone.stats.phaseCount = frontmatter.phases_completed || frontmatter.phase_count || 0;
96
+ milestone.stats.commitCount = frontmatter.total_commits || frontmatter.commit_count || 0;
97
+ milestone.stats.statsHtml = html || '';
87
98
  } catch (_e) {
88
99
  milestone.name = `v${version}`;
89
100
  }
90
101
  } else {
91
102
  milestone.name = `v${version}`;
92
103
  }
104
+
105
+ // Try to read deliverables from archived ROADMAP.md
106
+ if (milestone.format === 'directory' && milestone.files.includes('ROADMAP.md')) {
107
+ try {
108
+ const { frontmatter: rmFm } = await readMarkdownFile(join(milestonesDir, `v${version}`, 'ROADMAP.md'));
109
+ if (Array.isArray(rmFm.phases)) {
110
+ milestone.stats.deliverables = rmFm.phases.map(p => typeof p === 'string' ? p : (p.name || p.title || ''));
111
+ }
112
+ } catch (_e) { /* ignore */ }
113
+ }
114
+
115
+ // Try phases/ subdirectory for deliverables if none found yet
116
+ if (milestone.format === 'directory' && milestone.stats.deliverables.length === 0) {
117
+ try {
118
+ const phasesDir = join(milestonesDir, `v${version}`, 'phases');
119
+ const phaseDirs = await readdir(phasesDir, { withFileTypes: true });
120
+ milestone.stats.deliverables = phaseDirs
121
+ .filter(d => d.isDirectory())
122
+ .map(d => d.name)
123
+ .sort();
124
+ if (milestone.stats.phaseCount === 0) {
125
+ milestone.stats.phaseCount = milestone.stats.deliverables.length;
126
+ }
127
+ } catch (_e) { /* no phases dir */ }
128
+ }
93
129
  }
94
130
 
95
131
  // Sort by version descending (newest first) — strip internal format field
96
- return [...versionMap.values()]
132
+ const result = [...versionMap.values()]
97
133
  .sort((a, b) => b.version.localeCompare(a.version, undefined, { numeric: true }))
98
134
  .map(({ format: _f, ...rest }) => rest);
135
+
136
+ cache.set(cacheKey, result);
137
+ return result;
99
138
  }
100
139
 
101
140
  /**
@@ -105,15 +144,22 @@ export async function listArchivedMilestones(projectDir) {
105
144
  * @returns {Promise<{active: Array, archived: Array}>}
106
145
  */
107
146
  export async function getAllMilestones(projectDir) {
147
+ const allCacheKey = `all-milestones:${projectDir}`;
148
+ const allCached = cache.get(allCacheKey);
149
+ if (allCached) return allCached;
150
+
108
151
  const [roadmapData, archived] = await Promise.all([
109
152
  getRoadmapData(projectDir),
110
153
  listArchivedMilestones(projectDir)
111
154
  ]);
112
155
 
113
- return {
156
+ const allResult = {
114
157
  active: roadmapData.milestones || [],
115
158
  archived
116
159
  };
160
+
161
+ cache.set(allCacheKey, allResult);
162
+ return allResult;
117
163
  }
118
164
 
119
165
  /**