@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.
- package/CHANGELOG.md +58 -0
- package/README.md +62 -13
- package/dashboard/package.json +1 -2
- package/dashboard/public/css/layout.css +128 -21
- package/dashboard/public/css/status-colors.css +14 -2
- package/dashboard/public/css/tokens.css +36 -0
- package/dashboard/src/middleware/current-phase.js +2 -1
- package/dashboard/src/routes/events.routes.js +49 -0
- package/dashboard/src/routes/pages.routes.js +250 -1
- package/dashboard/src/services/config.service.js +140 -0
- package/dashboard/src/services/dashboard.service.js +156 -11
- package/dashboard/src/services/log.service.js +105 -0
- package/dashboard/src/services/notes.service.js +16 -0
- package/dashboard/src/services/phase.service.js +58 -9
- package/dashboard/src/services/requirements.service.js +130 -0
- package/dashboard/src/services/research.service.js +137 -0
- package/dashboard/src/services/todo.service.js +30 -0
- package/dashboard/src/views/config.ejs +5 -0
- package/dashboard/src/views/logs.ejs +3 -0
- package/dashboard/src/views/note-detail.ejs +3 -0
- package/dashboard/src/views/partials/activity-feed.ejs +12 -0
- package/dashboard/src/views/partials/config-content.ejs +196 -0
- package/dashboard/src/views/partials/dashboard-content.ejs +71 -46
- package/dashboard/src/views/partials/log-entries-content.ejs +17 -0
- package/dashboard/src/views/partials/logs-content.ejs +131 -0
- package/dashboard/src/views/partials/note-detail-content.ejs +22 -0
- package/dashboard/src/views/partials/notes-content.ejs +7 -1
- package/dashboard/src/views/partials/phase-content.ejs +181 -146
- package/dashboard/src/views/partials/phase-timeline.ejs +16 -0
- package/dashboard/src/views/partials/requirements-content.ejs +44 -0
- package/dashboard/src/views/partials/research-content.ejs +49 -0
- package/dashboard/src/views/partials/research-detail-content.ejs +23 -0
- package/dashboard/src/views/partials/sidebar.ejs +63 -26
- package/dashboard/src/views/partials/todos-done-content.ejs +44 -0
- package/dashboard/src/views/requirements.ejs +3 -0
- package/dashboard/src/views/research-detail.ejs +3 -0
- package/dashboard/src/views/research.ejs +3 -0
- package/dashboard/src/views/todos-done.ejs +3 -0
- package/package.json +1 -1
- package/plugins/copilot-pbr/agents/dev-sync.agent.md +114 -0
- package/plugins/copilot-pbr/hooks/hooks.json +12 -0
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/agents/dev-sync.md +113 -0
- package/plugins/cursor-pbr/hooks/hooks.json +10 -0
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/agents/dev-sync.md +120 -0
- package/plugins/pbr/hooks/hooks.json +10 -0
- package/plugins/pbr/scripts/config-schema.json +4 -1
- package/plugins/pbr/scripts/local-llm/health.js +4 -1
- package/plugins/pbr/scripts/local-llm/operations/classify-commit.js +68 -0
- package/plugins/pbr/scripts/local-llm/operations/classify-file-intent.js +73 -0
- package/plugins/pbr/scripts/local-llm/operations/triage-test-output.js +72 -0
- package/plugins/pbr/scripts/post-bash-triage.js +132 -0
- package/plugins/pbr/scripts/post-write-dispatch.js +44 -0
- package/plugins/pbr/scripts/pre-bash-dispatch.js +17 -11
- package/plugins/pbr/scripts/status-line.js +50 -5
- 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
|
|
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
|
}
|