@sienklogic/plan-build-run 2.24.0 → 2.26.1

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 (58) hide show
  1. package/CHANGELOG.md +58 -0
  2. package/README.md +62 -13
  3. package/dashboard/package.json +1 -2
  4. package/dashboard/public/css/layout.css +128 -21
  5. package/dashboard/public/css/status-colors.css +14 -2
  6. package/dashboard/public/css/tokens.css +36 -0
  7. package/dashboard/src/middleware/current-phase.js +2 -1
  8. package/dashboard/src/routes/events.routes.js +49 -0
  9. package/dashboard/src/routes/pages.routes.js +250 -1
  10. package/dashboard/src/services/config.service.js +140 -0
  11. package/dashboard/src/services/dashboard.service.js +156 -11
  12. package/dashboard/src/services/log.service.js +105 -0
  13. package/dashboard/src/services/notes.service.js +16 -0
  14. package/dashboard/src/services/phase.service.js +58 -9
  15. package/dashboard/src/services/requirements.service.js +130 -0
  16. package/dashboard/src/services/research.service.js +137 -0
  17. package/dashboard/src/services/todo.service.js +30 -0
  18. package/dashboard/src/views/config.ejs +5 -0
  19. package/dashboard/src/views/logs.ejs +3 -0
  20. package/dashboard/src/views/note-detail.ejs +3 -0
  21. package/dashboard/src/views/partials/activity-feed.ejs +12 -0
  22. package/dashboard/src/views/partials/config-content.ejs +196 -0
  23. package/dashboard/src/views/partials/dashboard-content.ejs +71 -46
  24. package/dashboard/src/views/partials/log-entries-content.ejs +17 -0
  25. package/dashboard/src/views/partials/logs-content.ejs +131 -0
  26. package/dashboard/src/views/partials/note-detail-content.ejs +22 -0
  27. package/dashboard/src/views/partials/notes-content.ejs +7 -1
  28. package/dashboard/src/views/partials/phase-content.ejs +181 -146
  29. package/dashboard/src/views/partials/phase-timeline.ejs +16 -0
  30. package/dashboard/src/views/partials/requirements-content.ejs +44 -0
  31. package/dashboard/src/views/partials/research-content.ejs +49 -0
  32. package/dashboard/src/views/partials/research-detail-content.ejs +23 -0
  33. package/dashboard/src/views/partials/sidebar.ejs +63 -26
  34. package/dashboard/src/views/partials/todos-done-content.ejs +44 -0
  35. package/dashboard/src/views/requirements.ejs +3 -0
  36. package/dashboard/src/views/research-detail.ejs +3 -0
  37. package/dashboard/src/views/research.ejs +3 -0
  38. package/dashboard/src/views/todos-done.ejs +3 -0
  39. package/package.json +1 -1
  40. package/plugins/copilot-pbr/agents/dev-sync.agent.md +114 -0
  41. package/plugins/copilot-pbr/hooks/hooks.json +12 -0
  42. package/plugins/copilot-pbr/plugin.json +1 -1
  43. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  44. package/plugins/cursor-pbr/agents/dev-sync.md +113 -0
  45. package/plugins/cursor-pbr/hooks/hooks.json +10 -0
  46. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  47. package/plugins/pbr/agents/dev-sync.md +120 -0
  48. package/plugins/pbr/hooks/hooks.json +10 -0
  49. package/plugins/pbr/scripts/config-schema.json +4 -1
  50. package/plugins/pbr/scripts/local-llm/health.js +4 -1
  51. package/plugins/pbr/scripts/local-llm/operations/classify-commit.js +68 -0
  52. package/plugins/pbr/scripts/local-llm/operations/classify-file-intent.js +73 -0
  53. package/plugins/pbr/scripts/local-llm/operations/triage-test-output.js +72 -0
  54. package/plugins/pbr/scripts/post-bash-triage.js +132 -0
  55. package/plugins/pbr/scripts/post-write-dispatch.js +44 -0
  56. package/plugins/pbr/scripts/pre-bash-dispatch.js +17 -11
  57. package/plugins/pbr/scripts/status-line.js +50 -5
  58. package/plugins/pbr/scripts/validate-commit.js +66 -2
@@ -7,8 +7,66 @@ import { getAllMilestones, getMilestoneDetail } from '../services/milestone.serv
7
7
  import { getProjectAnalytics } from '../services/analytics.service.js';
8
8
  import { getLlmMetrics } from '../services/local-llm-metrics.service.js';
9
9
  import { listNotes, getNoteBySlug } from '../services/notes.service.js';
10
+ import { listResearchDocs, listCodebaseDocs, getResearchDocBySlug } from '../services/research.service.js';
10
11
  import { listQuickTasks, getQuickTask } from '../services/quick.service.js';
11
12
  import { listAuditReports, getAuditReport } from '../services/audit.service.js';
13
+ import { readConfig, writeConfig } from '../services/config.service.js';
14
+ import { getRequirementsData } from '../services/requirements.service.js';
15
+ import { listLogFiles, readLogPage } from '../services/log.service.js';
16
+
17
+ /**
18
+ * Merge flat HTML form fields back into a nested config object.
19
+ * Form field names use dot-notation: "features.autoVerify", "models.default".
20
+ * Boolean checkboxes arrive as "on"/"off" or are absent when unchecked.
21
+ * @param {object} existing - current config object from disk
22
+ * @param {object} form - req.body from express.urlencoded
23
+ * @returns {object}
24
+ */
25
+ function mergeFormIntoConfig(existing, form) {
26
+ const result = JSON.parse(JSON.stringify(existing)); // deep clone
27
+ for (const [key, value] of Object.entries(form)) {
28
+ const parts = key.split('.');
29
+ let target = result;
30
+ for (let i = 0; i < parts.length - 1; i++) {
31
+ if (target[parts[i]] == null || typeof target[parts[i]] !== 'object') {
32
+ target[parts[i]] = {};
33
+ }
34
+ target = target[parts[i]];
35
+ }
36
+ const leaf = parts[parts.length - 1];
37
+ // Coerce booleans: checkboxes send "on", absent means false
38
+ if (typeof existing?.[parts[0]]?.[leaf] === 'boolean' || (parts.length === 2 && typeof (existing?.[parts[0]] ?? {})[leaf] === 'boolean')) {
39
+ target[leaf] = value === 'on' || value === 'true';
40
+ } else if (typeof target[leaf] === 'number') {
41
+ target[leaf] = Number(value);
42
+ } else {
43
+ target[leaf] = value;
44
+ }
45
+ }
46
+ // Uncheck all feature booleans not present in form (unchecked checkboxes are absent)
47
+ if (result.features && typeof result.features === 'object') {
48
+ for (const k of Object.keys(result.features)) {
49
+ if (typeof result.features[k] === 'boolean' && !(`features.${k}` in form)) {
50
+ result.features[k] = false;
51
+ }
52
+ }
53
+ }
54
+ if (result.gates && typeof result.gates === 'object') {
55
+ for (const k of Object.keys(result.gates)) {
56
+ if (typeof result.gates[k] === 'boolean' && !(`gates.${k}` in form)) {
57
+ result.gates[k] = false;
58
+ }
59
+ }
60
+ }
61
+ if (result.safety && typeof result.safety === 'object') {
62
+ for (const k of Object.keys(result.safety)) {
63
+ if (typeof result.safety[k] === 'boolean' && !(`safety.${k}` in form)) {
64
+ result.safety[k] = false;
65
+ }
66
+ }
67
+ }
68
+ return result;
69
+ }
12
70
 
13
71
  const router = Router();
14
72
 
@@ -48,13 +106,26 @@ router.get('/phases/:phaseId', async (req, res) => {
48
106
  }
49
107
 
50
108
  const projectDir = req.app.locals.projectDir;
51
- const phaseData = await getPhaseDetail(projectDir, phaseId);
109
+ const [phaseData, roadmapData] = await Promise.all([
110
+ getPhaseDetail(projectDir, phaseId),
111
+ getRoadmapData(projectDir)
112
+ ]);
113
+
114
+ const phaseIdNum = parseInt(phaseId, 10);
115
+ const allPhases = roadmapData.phases || [];
116
+ const currentIdx = allPhases.findIndex(p => String(p.id) === String(phaseIdNum));
117
+ const prevPhase = currentIdx > 0 ? allPhases[currentIdx - 1] : null;
118
+ const nextPhase = currentIdx >= 0 && currentIdx < allPhases.length - 1
119
+ ? allPhases[currentIdx + 1]
120
+ : null;
52
121
 
53
122
  const templateData = {
54
123
  title: `Phase ${phaseId}: ${phaseData.phaseName}`,
55
124
  activePage: 'phases',
56
125
  currentPath: '/phases/' + phaseId,
57
126
  breadcrumbs: [{ label: 'Phases', url: '/phases' }, { label: 'Phase ' + phaseId }],
127
+ prevPhase,
128
+ nextPhase,
58
129
  ...phaseData
59
130
  };
60
131
 
@@ -462,6 +533,88 @@ router.get('/notes/:slug', async (req, res) => {
462
533
  }
463
534
  });
464
535
 
536
+ router.get('/research', async (req, res) => {
537
+ const projectDir = req.app.locals.projectDir;
538
+ const [researchDocs, codebaseDocs] = await Promise.all([
539
+ listResearchDocs(projectDir),
540
+ listCodebaseDocs(projectDir)
541
+ ]);
542
+
543
+ const templateData = {
544
+ title: 'Research',
545
+ activePage: 'research',
546
+ currentPath: '/research',
547
+ breadcrumbs: [{ label: 'Research' }],
548
+ researchDocs,
549
+ codebaseDocs
550
+ };
551
+
552
+ res.setHeader('Vary', 'HX-Request');
553
+ if (req.get('HX-Request') === 'true') {
554
+ res.render('partials/research-content', templateData);
555
+ } else {
556
+ res.render('research', templateData);
557
+ }
558
+ });
559
+
560
+ router.get('/research/:slug', async (req, res) => {
561
+ const { slug } = req.params;
562
+
563
+ // Validate slug: lowercase alphanumeric, dashes, and dots only
564
+ if (!/^[a-z0-9._-]+$/.test(slug)) {
565
+ const err = new Error('Invalid research document slug format');
566
+ err.status = 404;
567
+ throw err;
568
+ }
569
+
570
+ const projectDir = req.app.locals.projectDir;
571
+ const doc = await getResearchDocBySlug(projectDir, slug);
572
+
573
+ if (!doc) {
574
+ const err = new Error(`Research document "${slug}" not found`);
575
+ err.status = 404;
576
+ throw err;
577
+ }
578
+
579
+ const templateData = {
580
+ title: doc.title,
581
+ activePage: 'research',
582
+ currentPath: '/research/' + slug,
583
+ breadcrumbs: [{ label: 'Research', url: '/research' }, { label: doc.title }],
584
+ ...doc
585
+ };
586
+
587
+ res.setHeader('Vary', 'HX-Request');
588
+ if (req.get('HX-Request') === 'true') {
589
+ res.render('partials/research-detail-content', templateData);
590
+ } else {
591
+ res.render('research-detail', templateData);
592
+ }
593
+ });
594
+
595
+ router.get('/requirements', async (req, res) => {
596
+ const projectDir = req.app.locals.projectDir;
597
+ const { sections, totalCount, coveredCount } = await getRequirementsData(projectDir);
598
+
599
+ const templateData = {
600
+ title: 'Requirements',
601
+ activePage: 'requirements',
602
+ currentPath: '/requirements',
603
+ breadcrumbs: [{ label: 'Requirements' }],
604
+ sections,
605
+ totalCount,
606
+ coveredCount,
607
+ uncoveredCount: totalCount - coveredCount
608
+ };
609
+
610
+ res.setHeader('Vary', 'HX-Request');
611
+ if (req.get('HX-Request') === 'true') {
612
+ res.render('partials/requirements-content', templateData);
613
+ } else {
614
+ res.render('requirements', templateData);
615
+ }
616
+ });
617
+
465
618
  router.get('/roadmap', async (req, res) => {
466
619
  const projectDir = req.app.locals.projectDir;
467
620
  const [roadmapData, stateData] = await Promise.all([
@@ -601,4 +754,100 @@ router.get('/audits/:filename', async (req, res) => {
601
754
  }
602
755
  });
603
756
 
757
+ router.get('/config', async (req, res) => {
758
+ const projectDir = req.app.locals.projectDir;
759
+ const config = await readConfig(projectDir);
760
+
761
+ const templateData = {
762
+ title: 'Config',
763
+ activePage: 'config',
764
+ currentPath: '/config',
765
+ breadcrumbs: [{ label: 'Config' }],
766
+ config: config ?? {}
767
+ };
768
+
769
+ res.setHeader('Vary', 'HX-Request');
770
+
771
+ if (req.get('HX-Request') === 'true') {
772
+ res.render('partials/config-content', templateData);
773
+ } else {
774
+ res.render('config', templateData);
775
+ }
776
+ });
777
+
778
+ router.get('/logs', async (req, res) => {
779
+ const projectDir = req.app.locals.projectDir;
780
+ const { file, page, type, q } = req.query;
781
+
782
+ const logFiles = await listLogFiles(projectDir);
783
+
784
+ // Determine selected file (first in list if not specified)
785
+ const selectedFile = file || (logFiles.length > 0 ? logFiles[0].name : null);
786
+
787
+ let logData = null;
788
+ if (selectedFile) {
789
+ // Validate: no path traversal, must be a .jsonl filename
790
+ if (/^[\w.-]+\.jsonl$/.test(selectedFile)) {
791
+ const { join } = await import('node:path');
792
+ const filePath = join(projectDir, '.planning', 'logs', selectedFile);
793
+ logData = await readLogPage(filePath, {
794
+ page: parseInt(page, 10) || 1,
795
+ pageSize: 100,
796
+ typeFilter: type || '',
797
+ q: q || ''
798
+ });
799
+ }
800
+ }
801
+
802
+ const templateData = {
803
+ title: 'Logs',
804
+ activePage: 'logs',
805
+ currentPath: '/logs',
806
+ breadcrumbs: [{ label: 'Logs' }],
807
+ logFiles,
808
+ selectedFile,
809
+ logData,
810
+ filters: { type: type || '', q: q || '', page: parseInt(page, 10) || 1 }
811
+ };
812
+
813
+ res.setHeader('Vary', 'HX-Request');
814
+ if (req.get('HX-Request') === 'true') {
815
+ // If the request is for a different file/filter, re-render only the entries fragment
816
+ if (req.query.fragment === 'entries') {
817
+ res.render('partials/log-entries-content', templateData);
818
+ } else {
819
+ res.render('partials/logs-content', templateData);
820
+ }
821
+ } else {
822
+ res.render('logs', templateData);
823
+ }
824
+ });
825
+
826
+ router.post('/api/config', async (req, res) => {
827
+ const projectDir = req.app.locals.projectDir;
828
+
829
+ // Accept either JSON body (raw editor) or form-encoded (hybrid form)
830
+ let incoming = req.body;
831
+
832
+ // If the request carries a `rawJson` field, parse it as the full config
833
+ if (typeof incoming.rawJson === 'string') {
834
+ try {
835
+ incoming = JSON.parse(incoming.rawJson);
836
+ } catch {
837
+ return res.status(400).send('<span class="config-feedback config-feedback--error">Invalid JSON</span>');
838
+ }
839
+ } else {
840
+ // Merge form fields into existing config to avoid clobbering unrendered keys
841
+ const existing = await readConfig(projectDir) ?? {};
842
+ incoming = mergeFormIntoConfig(existing, incoming);
843
+ }
844
+
845
+ try {
846
+ await writeConfig(projectDir, incoming);
847
+ res.send('<span class="config-feedback config-feedback--success">Saved</span>');
848
+ } catch (err) {
849
+ res.status(400).send(`<span class="config-feedback config-feedback--error">${err.message}</span>`);
850
+ }
851
+ });
852
+
604
853
  export default router;
@@ -0,0 +1,140 @@
1
+ import { readFile, writeFile, rename } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+
4
+ /** Default config values used when fields are missing. */
5
+ const CONFIG_DEFAULTS = {
6
+ version: '2',
7
+ context_strategy: 'aggressive',
8
+ mode: 'normal',
9
+ depth: 'standard',
10
+ features: {
11
+ structured_planning: true,
12
+ goal_verification: true,
13
+ integration_verification: false,
14
+ context_isolation: true,
15
+ atomic_commits: true,
16
+ session_persistence: true,
17
+ research_phase: true,
18
+ plan_checking: true,
19
+ tdd_mode: false,
20
+ status_line: true,
21
+ auto_continue: false,
22
+ auto_advance: false,
23
+ team_discussions: false,
24
+ },
25
+ models: {
26
+ researcher: 'sonnet',
27
+ planner: 'inherit',
28
+ executor: 'inherit',
29
+ verifier: 'sonnet',
30
+ integration_checker: 'sonnet',
31
+ debugger: 'inherit',
32
+ mapper: 'sonnet',
33
+ synthesizer: 'sonnet',
34
+ },
35
+ parallelization: {
36
+ enabled: true,
37
+ plan_level: true,
38
+ task_level: false,
39
+ max_concurrent_agents: 3,
40
+ min_plans_for_parallel: 2,
41
+ use_teams: false,
42
+ },
43
+ gates: {
44
+ confirm_project: false,
45
+ confirm_roadmap: false,
46
+ confirm_plan: false,
47
+ confirm_execute: false,
48
+ confirm_transition: false,
49
+ issues_review: false,
50
+ },
51
+ };
52
+
53
+ /**
54
+ * Return the default config schema. Useful for UI form generation
55
+ * and for filling in missing fields on existing configs.
56
+ * @returns {object}
57
+ */
58
+ export function getConfigDefaults() {
59
+ return structuredClone(CONFIG_DEFAULTS);
60
+ }
61
+
62
+ /**
63
+ * Deep-merge incoming config with defaults so every expected key exists.
64
+ * Incoming values take precedence; defaults fill gaps.
65
+ * @param {object} incoming
66
+ * @returns {object}
67
+ */
68
+ export function mergeDefaults(incoming) {
69
+ const defaults = getConfigDefaults();
70
+ const merged = { ...defaults, ...incoming };
71
+ for (const section of ['features', 'models', 'parallelization', 'gates']) {
72
+ if (defaults[section] && typeof defaults[section] === 'object') {
73
+ merged[section] = { ...defaults[section], ...(incoming[section] || {}) };
74
+ }
75
+ }
76
+ return merged;
77
+ }
78
+
79
+ /**
80
+ * Read and parse .planning/config.json.
81
+ * @param {string} projectDir
82
+ * @returns {Promise<object|null>}
83
+ */
84
+ export async function readConfig(projectDir) {
85
+ const configPath = join(projectDir, '.planning', 'config.json');
86
+ try {
87
+ const raw = await readFile(configPath, 'utf8');
88
+ return JSON.parse(raw);
89
+ } catch (err) {
90
+ if (err.code === 'ENOENT') return null;
91
+ throw err;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Validate config shape. Throws with a descriptive message on failure.
97
+ * @param {object} config
98
+ */
99
+ export function validateConfig(config) {
100
+ if (!config || typeof config !== 'object') throw new Error('Config must be an object');
101
+ if (typeof config.version !== 'string') throw new Error('config.version must be a string');
102
+ if (config.features != null) {
103
+ for (const [k, v] of Object.entries(config.features)) {
104
+ if (typeof v !== 'boolean') throw new Error(`features.${k} must be a boolean`);
105
+ }
106
+ }
107
+ if (config.models != null) {
108
+ for (const [k, v] of Object.entries(config.models)) {
109
+ if (typeof v !== 'string') throw new Error(`models.${k} must be a string`);
110
+ }
111
+ }
112
+ if (config.parallelization != null) {
113
+ const p = config.parallelization;
114
+ if (p.max_concurrent_agents != null && (typeof p.max_concurrent_agents !== 'number' || p.max_concurrent_agents < 1)) {
115
+ throw new Error('parallelization.max_concurrent_agents must be a positive number');
116
+ }
117
+ if (p.min_plans_for_parallel != null && (typeof p.min_plans_for_parallel !== 'number' || p.min_plans_for_parallel < 1)) {
118
+ throw new Error('parallelization.min_plans_for_parallel must be a positive number');
119
+ }
120
+ }
121
+ if (config.gates != null) {
122
+ for (const [k, v] of Object.entries(config.gates)) {
123
+ if (typeof v !== 'boolean') throw new Error(`gates.${k} must be a boolean`);
124
+ }
125
+ }
126
+ }
127
+
128
+ /**
129
+ * Atomically write config back to .planning/config.json.
130
+ * Validates before writing; throws on validation failure (existing file untouched).
131
+ * @param {string} projectDir
132
+ * @param {object} config
133
+ */
134
+ export async function writeConfig(projectDir, config) {
135
+ validateConfig(config);
136
+ const configPath = join(projectDir, '.planning', 'config.json');
137
+ const tmpPath = configPath + '.tmp';
138
+ await writeFile(tmpPath, JSON.stringify(config, null, 2), 'utf8');
139
+ await rename(tmpPath, configPath);
140
+ }
@@ -1,7 +1,37 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { join } from 'node:path';
3
+ import { execFile as execFileCb } from 'node:child_process';
4
+ import { promisify } from 'node:util';
3
5
  import { stripBOM } from '../utils/strip-bom.js';
4
6
 
7
+ const execFile = promisify(execFileCb);
8
+
9
+ // Plain-object cache for getRecentActivity: keyed by projectDir
10
+ // Each entry: { data: Array, expiresAt: number }
11
+ const _activityCache = new Map();
12
+ const ACTIVITY_CACHE_TTL_MS = 30_000;
13
+
14
+ /** Clear activity cache — exported for testing only */
15
+ export function _clearActivityCache() {
16
+ _activityCache.clear();
17
+ }
18
+
19
+ /**
20
+ * Run a git command in the given directory, returning stdout.
21
+ * Returns empty string on failure.
22
+ */
23
+ async function git(projectDir, args) {
24
+ try {
25
+ const { stdout } = await execFile('git', args, {
26
+ cwd: projectDir,
27
+ maxBuffer: 10 * 1024 * 1024
28
+ });
29
+ return stdout;
30
+ } catch {
31
+ return '';
32
+ }
33
+ }
34
+
5
35
  /**
6
36
  * Parse STATE.md to extract project status information.
7
37
  * Uses regex on raw markdown body text (not YAML frontmatter).
@@ -87,6 +117,19 @@ export async function parseStateFile(projectDir) {
87
117
  ? 'in-progress'
88
118
  : fmStatus || 'unknown';
89
119
 
120
+ // Map STATE.md status to suggested next PBR command
121
+ const nextActionMap = {
122
+ 'planning': '/pbr:plan',
123
+ 'planned': '/pbr:build',
124
+ 'building': '/pbr:build',
125
+ 'built': '/pbr:review',
126
+ 'in-progress': '/pbr:build',
127
+ 'verified': '/pbr:review',
128
+ 'complete': '/pbr:milestone',
129
+ 'discussing': '/pbr:plan',
130
+ };
131
+ const nextAction = nextActionMap[fmStatus] || null;
132
+
90
133
  return {
91
134
  projectName,
92
135
  currentPhase: {
@@ -100,7 +143,8 @@ export async function parseStateFile(projectDir) {
100
143
  date: activityDate,
101
144
  description: activityDescription
102
145
  },
103
- progress
146
+ progress,
147
+ nextAction
104
148
  };
105
149
  } catch (error) {
106
150
  if (error.code === 'ENOENT') {
@@ -240,13 +284,6 @@ export async function parseRoadmapFile(projectDir) {
240
284
  }
241
285
  }
242
286
 
243
- /**
244
- * Get combined dashboard data by parsing both STATE.md and ROADMAP.md.
245
- * Orchestrates both parsers in parallel and derives in-progress status.
246
- *
247
- * @param {string} projectDir - Absolute path to the project root
248
- * @returns {Promise<{projectName: string, currentPhase: object, lastActivity: object, progress: number, phases: Array}>}
249
- */
250
287
  /**
251
288
  * Derive phase statuses by combining roadmap phases with STATE.md context.
252
289
  * Phases before the current phase are marked complete.
@@ -274,10 +311,114 @@ export function derivePhaseStatuses(phases, currentPhase) {
274
311
  });
275
312
  }
276
313
 
314
+ /**
315
+ * Get recent .planning/ file activity from git log.
316
+ * Returns up to 10 deduplicated entries (most recent occurrence per path).
317
+ * Results are cached for 30 seconds.
318
+ *
319
+ * @param {string} projectDir - Absolute path to the project root
320
+ * @returns {Promise<Array<{path: string, timestamp: string, type: string}>>}
321
+ */
322
+ export async function getRecentActivity(projectDir) {
323
+ const cached = _activityCache.get(projectDir);
324
+ if (cached && Date.now() < cached.expiresAt) {
325
+ return cached.data;
326
+ }
327
+
328
+ try {
329
+ const output = await git(projectDir, [
330
+ 'log',
331
+ '--name-only',
332
+ '--format=COMMIT:%ai',
333
+ '-n', '40',
334
+ '--',
335
+ '.planning/'
336
+ ]);
337
+
338
+ if (!output || !output.trim()) {
339
+ return [];
340
+ }
341
+
342
+ // Parse output: lines starting with "COMMIT:" set the current timestamp,
343
+ // non-empty lines that don't start with "COMMIT:" are file paths.
344
+ const seen = new Map(); // path -> { timestamp, type }
345
+ let currentTimestamp = '';
346
+
347
+ for (const line of output.split('\n')) {
348
+ const trimmed = line.trim();
349
+ if (trimmed.startsWith('COMMIT:')) {
350
+ currentTimestamp = trimmed.slice('COMMIT:'.length).trim();
351
+ } else if (trimmed && currentTimestamp) {
352
+ // Only record first occurrence per path (git log is newest-first)
353
+ if (!seen.has(trimmed)) {
354
+ seen.set(trimmed, { timestamp: currentTimestamp, type: 'commit' });
355
+ }
356
+ }
357
+ }
358
+
359
+ const data = [...seen.entries()]
360
+ .slice(0, 10)
361
+ .map(([path, meta]) => ({ path, timestamp: meta.timestamp, type: meta.type }));
362
+
363
+ _activityCache.set(projectDir, { data, expiresAt: Date.now() + ACTIVITY_CACHE_TTL_MS });
364
+ return data;
365
+ } catch {
366
+ return [];
367
+ }
368
+ }
369
+
370
+ /**
371
+ * Derive contextual quick-action buttons based on current phase status.
372
+ * Pure function — no I/O.
373
+ *
374
+ * @param {{ status: string, id: number }} currentPhase
375
+ * @returns {Array<{label: string, href: string, primary: boolean}>}
376
+ */
377
+ export function deriveQuickActions(currentPhase) {
378
+ const id = String(currentPhase.id).padStart(2, '0');
379
+ const status = currentPhase.status || '';
380
+
381
+ switch (status) {
382
+ case 'building':
383
+ case 'in-progress':
384
+ return [
385
+ { label: 'Continue Building', href: `/phases/${id}`, primary: true },
386
+ { label: 'View Roadmap', href: '/roadmap', primary: false }
387
+ ];
388
+
389
+ case 'planning':
390
+ case 'planned':
391
+ return [
392
+ { label: 'View Plans', href: `/phases/${id}`, primary: true },
393
+ { label: 'Roadmap', href: '/roadmap', primary: false }
394
+ ];
395
+
396
+ case 'complete':
397
+ case 'verified':
398
+ return [
399
+ { label: 'View Phase', href: `/phases/${id}`, primary: false },
400
+ { label: 'Roadmap', href: '/roadmap', primary: true }
401
+ ];
402
+
403
+ default:
404
+ return [
405
+ { label: 'Get Started', href: '/roadmap', primary: true }
406
+ ];
407
+ }
408
+ }
409
+
410
+ /**
411
+ * Get combined dashboard data by parsing both STATE.md and ROADMAP.md.
412
+ * Orchestrates both parsers in parallel and derives in-progress status.
413
+ *
414
+ * @param {string} projectDir - Absolute path to the project root
415
+ * @returns {Promise<{projectName: string, currentPhase: object, lastActivity: object, progress: number, phases: Array, recentActivity: Array, quickActions: Array}>}
416
+ */
277
417
  export async function getDashboardData(projectDir) {
278
- const [stateData, roadmapData] = await Promise.all([
418
+ const [stateData, roadmapData, recentActivity] = await Promise.all([
279
419
  parseStateFile(projectDir),
280
- parseRoadmapFile(projectDir)
420
+ parseRoadmapFile(projectDir),
421
+ getRecentActivity(projectDir)
281
422
  ]);
282
423
 
283
424
  const phases = derivePhaseStatuses(roadmapData.phases, stateData.currentPhase);
@@ -288,11 +429,15 @@ export async function getDashboardData(projectDir) {
288
429
  ? Math.ceil((completedPhases / phases.length) * 100)
289
430
  : stateData.progress;
290
431
 
432
+ const quickActions = deriveQuickActions(stateData.currentPhase);
433
+
291
434
  return {
292
435
  projectName: stateData.projectName,
293
436
  currentPhase: stateData.currentPhase,
294
437
  lastActivity: stateData.lastActivity,
295
438
  progress,
296
- phases
439
+ phases,
440
+ recentActivity,
441
+ quickActions
297
442
  };
298
443
  }