@sienklogic/plan-build-run 2.34.0 → 2.38.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 +683 -0
- package/dashboard/public/css/command-center.css +152 -65
- package/dashboard/public/css/explorer.css +22 -41
- package/dashboard/public/css/layout.css +119 -1
- package/dashboard/public/css/tokens.css +13 -0
- package/dashboard/src/components/Layout.tsx +32 -6
- package/dashboard/src/components/explorer/tabs/PhasesTab.tsx +11 -1
- package/dashboard/src/components/explorer/tabs/TodosTab.tsx +18 -2
- package/dashboard/src/components/partials/AttentionPanel.tsx +7 -1
- package/dashboard/src/components/partials/CurrentPhaseCard.tsx +26 -24
- package/dashboard/src/components/partials/QuickActions.tsx +21 -11
- package/dashboard/src/components/partials/StatCardGrid.tsx +67 -0
- package/dashboard/src/components/partials/StatusHeader.tsx +1 -0
- package/dashboard/src/routes/command-center.routes.tsx +8 -7
- package/dashboard/src/routes/index.routes.tsx +32 -29
- package/package.json +2 -2
- package/plugins/copilot-pbr/agents/audit.agent.md +129 -16
- package/plugins/copilot-pbr/agents/codebase-mapper.agent.md +49 -1
- package/plugins/copilot-pbr/agents/debugger.agent.md +50 -1
- package/plugins/copilot-pbr/agents/dev-sync.agent.md +23 -0
- package/plugins/copilot-pbr/agents/executor.agent.md +153 -8
- package/plugins/copilot-pbr/agents/general.agent.md +46 -1
- package/plugins/copilot-pbr/agents/integration-checker.agent.md +55 -2
- package/plugins/copilot-pbr/agents/plan-checker.agent.md +50 -2
- package/plugins/copilot-pbr/agents/planner.agent.md +80 -1
- package/plugins/copilot-pbr/agents/researcher.agent.md +50 -2
- package/plugins/copilot-pbr/agents/synthesizer.agent.md +49 -1
- package/plugins/copilot-pbr/agents/verifier.agent.md +114 -13
- package/plugins/copilot-pbr/commands/test.md +5 -0
- package/plugins/copilot-pbr/hooks/hooks.json +11 -0
- package/plugins/copilot-pbr/plugin.json +1 -1
- package/plugins/copilot-pbr/references/agent-contracts.md +27 -0
- package/plugins/copilot-pbr/references/checkpoints.md +32 -1
- package/plugins/copilot-pbr/references/context-quality-tiers.md +45 -0
- package/plugins/copilot-pbr/references/pbr-tools-cli.md +115 -0
- package/plugins/copilot-pbr/references/questioning.md +21 -1
- package/plugins/copilot-pbr/references/verification-patterns.md +96 -18
- package/plugins/copilot-pbr/skills/audit/SKILL.md +19 -3
- package/plugins/copilot-pbr/skills/begin/SKILL.md +57 -4
- package/plugins/copilot-pbr/skills/build/SKILL.md +39 -2
- package/plugins/copilot-pbr/skills/config/SKILL.md +12 -2
- package/plugins/copilot-pbr/skills/debug/SKILL.md +12 -1
- package/plugins/copilot-pbr/skills/explore/SKILL.md +13 -2
- package/plugins/copilot-pbr/skills/health/SKILL.md +13 -5
- package/plugins/copilot-pbr/skills/import/SKILL.md +26 -1
- package/plugins/copilot-pbr/skills/milestone/SKILL.md +15 -3
- package/plugins/copilot-pbr/skills/plan/SKILL.md +50 -0
- package/plugins/copilot-pbr/skills/quick/SKILL.md +21 -0
- package/plugins/copilot-pbr/skills/review/SKILL.md +45 -0
- package/plugins/copilot-pbr/skills/scan/SKILL.md +20 -0
- package/plugins/copilot-pbr/skills/setup/SKILL.md +9 -1
- package/plugins/copilot-pbr/skills/shared/context-budget.md +10 -0
- package/plugins/copilot-pbr/skills/shared/universal-anti-patterns.md +6 -0
- package/plugins/copilot-pbr/skills/test/SKILL.md +210 -0
- package/plugins/copilot-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
- package/plugins/copilot-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
- package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
- package/plugins/cursor-pbr/agents/audit.md +52 -5
- package/plugins/cursor-pbr/agents/codebase-mapper.md +49 -1
- package/plugins/cursor-pbr/agents/debugger.md +50 -1
- package/plugins/cursor-pbr/agents/dev-sync.md +23 -0
- package/plugins/cursor-pbr/agents/executor.md +153 -8
- package/plugins/cursor-pbr/agents/general.md +46 -1
- package/plugins/cursor-pbr/agents/integration-checker.md +54 -1
- package/plugins/cursor-pbr/agents/plan-checker.md +49 -1
- package/plugins/cursor-pbr/agents/planner.md +80 -1
- package/plugins/cursor-pbr/agents/researcher.md +49 -1
- package/plugins/cursor-pbr/agents/synthesizer.md +49 -1
- package/plugins/cursor-pbr/agents/verifier.md +113 -12
- package/plugins/cursor-pbr/commands/test.md +5 -0
- package/plugins/cursor-pbr/hooks/hooks.json +9 -0
- package/plugins/cursor-pbr/references/agent-contracts.md +27 -0
- package/plugins/cursor-pbr/references/checkpoints.md +32 -1
- package/plugins/cursor-pbr/references/context-quality-tiers.md +45 -0
- package/plugins/cursor-pbr/references/pbr-tools-cli.md +115 -0
- package/plugins/cursor-pbr/references/questioning.md +21 -1
- package/plugins/cursor-pbr/references/verification-patterns.md +96 -18
- package/plugins/cursor-pbr/skills/audit/SKILL.md +19 -3
- package/plugins/cursor-pbr/skills/begin/SKILL.md +57 -4
- package/plugins/cursor-pbr/skills/build/SKILL.md +37 -2
- package/plugins/cursor-pbr/skills/config/SKILL.md +12 -2
- package/plugins/cursor-pbr/skills/debug/SKILL.md +12 -1
- package/plugins/cursor-pbr/skills/explore/SKILL.md +13 -2
- package/plugins/cursor-pbr/skills/health/SKILL.md +14 -5
- package/plugins/cursor-pbr/skills/import/SKILL.md +26 -1
- package/plugins/cursor-pbr/skills/milestone/SKILL.md +15 -3
- package/plugins/cursor-pbr/skills/plan/SKILL.md +50 -0
- package/plugins/cursor-pbr/skills/quick/SKILL.md +21 -0
- package/plugins/cursor-pbr/skills/review/SKILL.md +45 -0
- package/plugins/cursor-pbr/skills/scan/SKILL.md +20 -0
- package/plugins/cursor-pbr/skills/setup/SKILL.md +9 -1
- package/plugins/cursor-pbr/skills/shared/context-budget.md +10 -0
- package/plugins/cursor-pbr/skills/shared/universal-anti-patterns.md +6 -0
- package/plugins/cursor-pbr/skills/test/SKILL.md +211 -0
- package/plugins/cursor-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
- package/plugins/cursor-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
- package/plugins/pbr/.claude-plugin/plugin.json +1 -1
- package/plugins/pbr/agents/audit.md +45 -0
- package/plugins/pbr/agents/codebase-mapper.md +48 -0
- package/plugins/pbr/agents/debugger.md +49 -0
- package/plugins/pbr/agents/dev-sync.md +23 -0
- package/plugins/pbr/agents/executor.md +151 -6
- package/plugins/pbr/agents/general.md +45 -0
- package/plugins/pbr/agents/integration-checker.md +53 -0
- package/plugins/pbr/agents/plan-checker.md +48 -0
- package/plugins/pbr/agents/planner.md +78 -1
- package/plugins/pbr/agents/researcher.md +48 -0
- package/plugins/pbr/agents/synthesizer.md +48 -0
- package/plugins/pbr/agents/verifier.md +112 -11
- package/plugins/pbr/commands/test.md +5 -0
- package/plugins/pbr/hooks/hooks.json +9 -0
- package/plugins/pbr/references/agent-contracts.md +27 -0
- package/plugins/pbr/references/checkpoints.md +32 -0
- package/plugins/pbr/references/context-quality-tiers.md +45 -0
- package/plugins/pbr/references/pbr-tools-cli.md +115 -0
- package/plugins/pbr/references/questioning.md +21 -0
- package/plugins/pbr/references/verification-patterns.md +96 -17
- package/plugins/pbr/scripts/check-plan-format.js +13 -1
- package/plugins/pbr/scripts/check-state-sync.js +26 -7
- package/plugins/pbr/scripts/check-subagent-output.js +30 -2
- package/plugins/pbr/scripts/config-schema.json +11 -1
- package/plugins/pbr/scripts/context-bridge.js +265 -0
- package/plugins/pbr/scripts/lib/config.js +271 -0
- package/plugins/pbr/scripts/lib/core.js +587 -0
- package/plugins/pbr/scripts/lib/history.js +73 -0
- package/plugins/pbr/scripts/lib/init.js +166 -0
- package/plugins/pbr/scripts/lib/migrate.js +169 -0
- package/plugins/pbr/scripts/lib/phase.js +364 -0
- package/plugins/pbr/scripts/lib/roadmap.js +175 -0
- package/plugins/pbr/scripts/lib/state.js +397 -0
- package/plugins/pbr/scripts/lib/todo.js +300 -0
- package/plugins/pbr/scripts/pbr-tools.js +425 -1310
- package/plugins/pbr/scripts/post-write-dispatch.js +5 -4
- package/plugins/pbr/scripts/pre-write-dispatch.js +1 -1
- package/plugins/pbr/scripts/progress-tracker.js +1 -1
- package/plugins/pbr/scripts/suggest-compact.js +1 -1
- package/plugins/pbr/scripts/track-context-budget.js +53 -2
- package/plugins/pbr/scripts/validate-task.js +20 -28
- package/plugins/pbr/skills/audit/SKILL.md +19 -3
- package/plugins/pbr/skills/begin/SKILL.md +48 -2
- package/plugins/pbr/skills/build/SKILL.md +39 -2
- package/plugins/pbr/skills/config/SKILL.md +12 -2
- package/plugins/pbr/skills/debug/SKILL.md +12 -1
- package/plugins/pbr/skills/debug/templates/continuation-prompt.md.tmpl +12 -1
- package/plugins/pbr/skills/debug/templates/initial-investigation-prompt.md.tmpl +12 -5
- package/plugins/pbr/skills/explore/SKILL.md +13 -2
- package/plugins/pbr/skills/health/SKILL.md +14 -3
- package/plugins/pbr/skills/help/SKILL.md +2 -0
- package/plugins/pbr/skills/import/SKILL.md +26 -1
- package/plugins/pbr/skills/milestone/SKILL.md +15 -3
- package/plugins/pbr/skills/plan/SKILL.md +52 -2
- package/plugins/pbr/skills/quick/SKILL.md +21 -0
- package/plugins/pbr/skills/review/SKILL.md +46 -0
- package/plugins/pbr/skills/scan/SKILL.md +20 -0
- package/plugins/pbr/skills/setup/SKILL.md +9 -1
- package/plugins/pbr/skills/shared/context-budget.md +10 -0
- package/plugins/pbr/skills/shared/universal-anti-patterns.md +6 -0
- package/plugins/pbr/skills/test/SKILL.md +212 -0
- package/plugins/pbr/templates/SUMMARY-complex.md.tmpl +95 -0
- package/plugins/pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
|
@@ -0,0 +1,587 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/core.js — Foundation utilities for Plan-Build-Run tools.
|
|
3
|
+
*
|
|
4
|
+
* Pure utility functions with no dependencies on other lib modules.
|
|
5
|
+
* Provides: output/error formatting, YAML frontmatter parsing, status transitions,
|
|
6
|
+
* file operations (atomicWrite, lockedFileUpdate, findFiles, tailLines),
|
|
7
|
+
* and shared constants (KNOWN_AGENTS, VALID_STATUS_TRANSITIONS).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
12
|
+
const path = require('path');
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Canonical list of known PBR agent types.
|
|
16
|
+
* Imported by validate-task.js and check-subagent-output.js to avoid drift.
|
|
17
|
+
*/
|
|
18
|
+
const KNOWN_AGENTS = [
|
|
19
|
+
'researcher',
|
|
20
|
+
'planner',
|
|
21
|
+
'plan-checker',
|
|
22
|
+
'executor',
|
|
23
|
+
'verifier',
|
|
24
|
+
'integration-checker',
|
|
25
|
+
'debugger',
|
|
26
|
+
'codebase-mapper',
|
|
27
|
+
'synthesizer',
|
|
28
|
+
'general',
|
|
29
|
+
'audit',
|
|
30
|
+
'dev-sync'
|
|
31
|
+
];
|
|
32
|
+
|
|
33
|
+
// --- Phase status transition state machine ---
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Valid phase status transitions. Each key is a current status, and its value
|
|
37
|
+
* is an array of statuses that are legal to transition to. This is advisory —
|
|
38
|
+
* invalid transitions produce a stderr warning but are not blocked, to avoid
|
|
39
|
+
* breaking existing workflows.
|
|
40
|
+
*
|
|
41
|
+
* State machine:
|
|
42
|
+
* pending -> planned, skipped
|
|
43
|
+
* planned -> building
|
|
44
|
+
* building -> built, partial, needs_fixes
|
|
45
|
+
* built -> verified, needs_fixes
|
|
46
|
+
* partial -> building, needs_fixes
|
|
47
|
+
* verified -> building (re-execution)
|
|
48
|
+
* needs_fixes -> planned, building
|
|
49
|
+
* skipped -> pending (unskip)
|
|
50
|
+
*/
|
|
51
|
+
const VALID_STATUS_TRANSITIONS = {
|
|
52
|
+
pending: ['planned', 'skipped'],
|
|
53
|
+
planned: ['building'],
|
|
54
|
+
building: ['built', 'partial', 'needs_fixes'],
|
|
55
|
+
built: ['verified', 'needs_fixes'],
|
|
56
|
+
partial: ['building', 'needs_fixes'],
|
|
57
|
+
verified: ['building'],
|
|
58
|
+
needs_fixes: ['planned', 'building'],
|
|
59
|
+
skipped: ['pending']
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Check whether a phase status transition is valid according to the state machine.
|
|
64
|
+
* Returns { valid, warning? } — never blocks, only advises.
|
|
65
|
+
*
|
|
66
|
+
* @param {string} oldStatus - Current phase status
|
|
67
|
+
* @param {string} newStatus - Desired phase status
|
|
68
|
+
* @returns {{ valid: boolean, warning?: string }}
|
|
69
|
+
*/
|
|
70
|
+
function validateStatusTransition(oldStatus, newStatus) {
|
|
71
|
+
const from = (oldStatus || '').trim().toLowerCase();
|
|
72
|
+
const to = (newStatus || '').trim().toLowerCase();
|
|
73
|
+
|
|
74
|
+
// If the status isn't changing, that's always fine
|
|
75
|
+
if (from === to) {
|
|
76
|
+
return { valid: true };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// If the old status is unknown to our map, we can't validate — allow it
|
|
80
|
+
if (!VALID_STATUS_TRANSITIONS[from]) {
|
|
81
|
+
return { valid: true };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const allowed = VALID_STATUS_TRANSITIONS[from];
|
|
85
|
+
if (allowed.includes(to)) {
|
|
86
|
+
return { valid: true };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return {
|
|
90
|
+
valid: false,
|
|
91
|
+
warning: `Suspicious status transition: "${from}" -> "${to}". Expected one of: [${allowed.join(', ')}]. Proceeding anyway (advisory).`
|
|
92
|
+
};
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// --- Output helpers ---
|
|
96
|
+
|
|
97
|
+
function output(data) {
|
|
98
|
+
const json = JSON.stringify(data, null, 2);
|
|
99
|
+
if (json.length > 50000) {
|
|
100
|
+
const tmpPath = path.join(os.tmpdir(), `pbr-${Date.now()}.json`);
|
|
101
|
+
fs.writeFileSync(tmpPath, json, 'utf8');
|
|
102
|
+
process.stdout.write('@file:' + tmpPath + '\n');
|
|
103
|
+
} else {
|
|
104
|
+
process.stdout.write(json + '\n');
|
|
105
|
+
}
|
|
106
|
+
process.exit(0);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function error(msg) {
|
|
110
|
+
process.stdout.write(JSON.stringify({ error: msg }));
|
|
111
|
+
process.exit(1);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
// --- YAML frontmatter parsing ---
|
|
115
|
+
|
|
116
|
+
function parseYamlFrontmatter(content) {
|
|
117
|
+
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
|
|
118
|
+
if (!match) return {};
|
|
119
|
+
|
|
120
|
+
const yaml = match[1];
|
|
121
|
+
const result = {};
|
|
122
|
+
|
|
123
|
+
// Simple YAML parser for flat and basic nested values
|
|
124
|
+
const lines = yaml.split('\n');
|
|
125
|
+
let currentKey = null;
|
|
126
|
+
|
|
127
|
+
for (const line of lines) {
|
|
128
|
+
// Array item
|
|
129
|
+
if (/^\s+-\s+/.test(line) && currentKey) {
|
|
130
|
+
const val = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
|
|
131
|
+
if (!result[currentKey]) result[currentKey] = [];
|
|
132
|
+
if (Array.isArray(result[currentKey])) {
|
|
133
|
+
result[currentKey].push(val);
|
|
134
|
+
}
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Key-value pair
|
|
139
|
+
const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.*)/);
|
|
140
|
+
if (kvMatch) {
|
|
141
|
+
currentKey = kvMatch[1];
|
|
142
|
+
let val = kvMatch[2].trim();
|
|
143
|
+
|
|
144
|
+
if (val === '' || val === '|') {
|
|
145
|
+
// Possible array or block follows
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Handle arrays on same line: [a, b, c]
|
|
150
|
+
if (val.startsWith('[') && val.endsWith(']')) {
|
|
151
|
+
result[currentKey] = val.slice(1, -1).split(',')
|
|
152
|
+
.map(v => v.trim().replace(/^["']|["']$/g, ''))
|
|
153
|
+
.filter(Boolean);
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Clean quotes
|
|
158
|
+
val = val.replace(/^["']|["']$/g, '');
|
|
159
|
+
|
|
160
|
+
// Type coercion
|
|
161
|
+
if (val === 'true') val = true;
|
|
162
|
+
else if (val === 'false') val = false;
|
|
163
|
+
else if (/^\d+$/.test(val)) val = parseInt(val, 10);
|
|
164
|
+
|
|
165
|
+
result[currentKey] = val;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// Handle must_haves as a nested object
|
|
170
|
+
if (yaml.includes('must_haves:')) {
|
|
171
|
+
result.must_haves = parseMustHaves(yaml);
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
return result;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function parseMustHaves(yaml) {
|
|
178
|
+
const result = { truths: [], artifacts: [], key_links: [] };
|
|
179
|
+
let section = null;
|
|
180
|
+
|
|
181
|
+
const inMustHaves = yaml.split('\n');
|
|
182
|
+
let collecting = false;
|
|
183
|
+
|
|
184
|
+
for (const line of inMustHaves) {
|
|
185
|
+
if (/^\s*must_haves:/.test(line)) {
|
|
186
|
+
collecting = true;
|
|
187
|
+
continue;
|
|
188
|
+
}
|
|
189
|
+
if (collecting) {
|
|
190
|
+
if (/^\s{2}truths:/.test(line)) { section = 'truths'; continue; }
|
|
191
|
+
if (/^\s{2}artifacts:/.test(line)) { section = 'artifacts'; continue; }
|
|
192
|
+
if (/^\s{2}key_links:/.test(line)) { section = 'key_links'; continue; }
|
|
193
|
+
if (/^\w/.test(line)) break; // New top-level key, stop
|
|
194
|
+
|
|
195
|
+
if (section && /^\s+-\s+/.test(line)) {
|
|
196
|
+
result[section].push(line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, ''));
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
return result;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// --- File helpers ---
|
|
205
|
+
|
|
206
|
+
function findFiles(dir, pattern) {
|
|
207
|
+
try {
|
|
208
|
+
return fs.readdirSync(dir).filter(f => pattern.test(f)).sort();
|
|
209
|
+
} catch (_) {
|
|
210
|
+
return [];
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/**
|
|
215
|
+
* Read the last N lines from a file efficiently.
|
|
216
|
+
*
|
|
217
|
+
* @param {string} filePath - Absolute path to the file
|
|
218
|
+
* @param {number} n - Number of trailing lines to return
|
|
219
|
+
* @returns {string[]} Array of raw line strings (last n lines)
|
|
220
|
+
*/
|
|
221
|
+
function tailLines(filePath, n) {
|
|
222
|
+
try {
|
|
223
|
+
if (!fs.existsSync(filePath)) return [];
|
|
224
|
+
const content = fs.readFileSync(filePath, 'utf8').trim();
|
|
225
|
+
if (!content) return [];
|
|
226
|
+
const lines = content.split('\n');
|
|
227
|
+
if (lines.length <= n) return lines;
|
|
228
|
+
return lines.slice(lines.length - n);
|
|
229
|
+
} catch (_e) {
|
|
230
|
+
return [];
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
function countMustHaves(mustHaves) {
|
|
235
|
+
if (!mustHaves) return 0;
|
|
236
|
+
return (mustHaves.truths || []).length +
|
|
237
|
+
(mustHaves.artifacts || []).length +
|
|
238
|
+
(mustHaves.key_links || []).length;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
function determinePhaseStatus(planCount, completedCount, summaryCount, hasVerification, phaseDir) {
|
|
242
|
+
if (planCount === 0) {
|
|
243
|
+
// Check for CONTEXT.md (discussed only)
|
|
244
|
+
if (fs.existsSync(path.join(phaseDir, 'CONTEXT.md'))) return 'discussed';
|
|
245
|
+
return 'not_started';
|
|
246
|
+
}
|
|
247
|
+
if (completedCount === 0 && summaryCount === 0) return 'planned';
|
|
248
|
+
if (completedCount < planCount) return 'building';
|
|
249
|
+
if (!hasVerification) return 'built';
|
|
250
|
+
// Check verification status
|
|
251
|
+
try {
|
|
252
|
+
const vContent = fs.readFileSync(path.join(phaseDir, 'VERIFICATION.md'), 'utf8');
|
|
253
|
+
if (/status:\s*["']?passed/i.test(vContent)) return 'verified';
|
|
254
|
+
if (/status:\s*["']?gaps_found/i.test(vContent)) return 'needs_fixes';
|
|
255
|
+
return 'reviewed';
|
|
256
|
+
} catch (_) {
|
|
257
|
+
return 'built';
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function calculateProgress(planningDir) {
|
|
262
|
+
const phasesDir = path.join(planningDir, 'phases');
|
|
263
|
+
if (!fs.existsSync(phasesDir)) {
|
|
264
|
+
return { total: 0, completed: 0, percentage: 0 };
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
let total = 0;
|
|
268
|
+
let completed = 0;
|
|
269
|
+
|
|
270
|
+
const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
|
|
271
|
+
.filter(e => e.isDirectory());
|
|
272
|
+
|
|
273
|
+
for (const entry of entries) {
|
|
274
|
+
const dir = path.join(phasesDir, entry.name);
|
|
275
|
+
const plans = findFiles(dir, /-PLAN\.md$/);
|
|
276
|
+
total += plans.length;
|
|
277
|
+
|
|
278
|
+
const summaries = findFiles(dir, /^SUMMARY-.*\.md$/);
|
|
279
|
+
for (const s of summaries) {
|
|
280
|
+
const content = fs.readFileSync(path.join(dir, s), 'utf8');
|
|
281
|
+
if (/status:\s*["']?complete/i.test(content)) completed++;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
return {
|
|
286
|
+
total,
|
|
287
|
+
completed,
|
|
288
|
+
percentage: total > 0 ? Math.round((completed / total) * 100) : 0
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
// --- Atomic file operations ---
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* Write content to a file atomically: write to .tmp, backup original to .bak,
|
|
296
|
+
* rename .tmp over original. On failure, restore from .bak if available.
|
|
297
|
+
*
|
|
298
|
+
* @param {string} filePath - Target file path
|
|
299
|
+
* @param {string} content - Content to write
|
|
300
|
+
* @returns {{success: boolean, error?: string}} Result
|
|
301
|
+
*/
|
|
302
|
+
function atomicWrite(filePath, content) {
|
|
303
|
+
const tmpPath = filePath + '.tmp';
|
|
304
|
+
const bakPath = filePath + '.bak';
|
|
305
|
+
|
|
306
|
+
try {
|
|
307
|
+
// 1. Write to temp file
|
|
308
|
+
fs.writeFileSync(tmpPath, content, 'utf8');
|
|
309
|
+
|
|
310
|
+
// 2. Backup original if it exists
|
|
311
|
+
if (fs.existsSync(filePath)) {
|
|
312
|
+
try {
|
|
313
|
+
fs.copyFileSync(filePath, bakPath);
|
|
314
|
+
} catch (_e) {
|
|
315
|
+
// Backup failure is non-fatal — proceed with rename
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// 3. Rename temp over original (atomic on most filesystems)
|
|
320
|
+
fs.renameSync(tmpPath, filePath);
|
|
321
|
+
|
|
322
|
+
// 4. Clean up backup file on success
|
|
323
|
+
try {
|
|
324
|
+
if (fs.existsSync(bakPath)) {
|
|
325
|
+
fs.unlinkSync(bakPath);
|
|
326
|
+
}
|
|
327
|
+
} catch (_e) {
|
|
328
|
+
// Cleanup failure is non-fatal
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
return { success: true };
|
|
332
|
+
} catch (e) {
|
|
333
|
+
// Rename failed — try to restore from backup
|
|
334
|
+
try {
|
|
335
|
+
if (fs.existsSync(bakPath)) {
|
|
336
|
+
fs.copyFileSync(bakPath, filePath);
|
|
337
|
+
}
|
|
338
|
+
} catch (_restoreErr) {
|
|
339
|
+
// Restore also failed — nothing more we can do
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// Clean up temp file if it still exists
|
|
343
|
+
try {
|
|
344
|
+
if (fs.existsSync(tmpPath)) {
|
|
345
|
+
fs.unlinkSync(tmpPath);
|
|
346
|
+
}
|
|
347
|
+
} catch (_cleanupErr) {
|
|
348
|
+
// Best-effort cleanup
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
return { success: false, error: e.message };
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Locked file update: read-modify-write with exclusive lockfile.
|
|
357
|
+
* Prevents concurrent writes to STATE.md and ROADMAP.md.
|
|
358
|
+
*
|
|
359
|
+
* @param {string} filePath - Absolute path to the file to update
|
|
360
|
+
* @param {function} updateFn - Receives current content, returns new content
|
|
361
|
+
* @param {object} opts - Options: { retries: 3, retryDelayMs: 100, timeoutMs: 5000 }
|
|
362
|
+
* @returns {object} { success, content?, error? }
|
|
363
|
+
*/
|
|
364
|
+
function lockedFileUpdate(filePath, updateFn, opts = {}) {
|
|
365
|
+
const retries = opts.retries || 3;
|
|
366
|
+
const retryDelayMs = opts.retryDelayMs || 100;
|
|
367
|
+
const timeoutMs = opts.timeoutMs || 5000;
|
|
368
|
+
const lockPath = filePath + '.lock';
|
|
369
|
+
|
|
370
|
+
let lockFd = null;
|
|
371
|
+
let lockAcquired = false;
|
|
372
|
+
|
|
373
|
+
try {
|
|
374
|
+
// Acquire lock with retries
|
|
375
|
+
for (let attempt = 0; attempt < retries; attempt++) {
|
|
376
|
+
try {
|
|
377
|
+
lockFd = fs.openSync(lockPath, 'wx');
|
|
378
|
+
lockAcquired = true;
|
|
379
|
+
break;
|
|
380
|
+
} catch (e) {
|
|
381
|
+
if (e.code === 'EEXIST') {
|
|
382
|
+
// Lock exists — check if stale (older than timeoutMs)
|
|
383
|
+
try {
|
|
384
|
+
const stats = fs.statSync(lockPath);
|
|
385
|
+
if (Date.now() - stats.mtimeMs > timeoutMs) {
|
|
386
|
+
// Stale lock — remove and retry
|
|
387
|
+
fs.unlinkSync(lockPath);
|
|
388
|
+
continue;
|
|
389
|
+
}
|
|
390
|
+
} catch (_statErr) {
|
|
391
|
+
// Lock disappeared between check — retry
|
|
392
|
+
continue;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
if (attempt < retries - 1) {
|
|
396
|
+
// Wait and retry
|
|
397
|
+
const waitMs = retryDelayMs * (attempt + 1);
|
|
398
|
+
const start = Date.now();
|
|
399
|
+
while (Date.now() - start < waitMs) {
|
|
400
|
+
// Busy wait (synchronous context)
|
|
401
|
+
}
|
|
402
|
+
continue;
|
|
403
|
+
}
|
|
404
|
+
return { success: false, error: `Could not acquire lock for ${path.basename(filePath)} after ${retries} attempts` };
|
|
405
|
+
}
|
|
406
|
+
throw e;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!lockAcquired) {
|
|
411
|
+
return { success: false, error: `Could not acquire lock for ${path.basename(filePath)}` };
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Write PID to lock file for debugging
|
|
415
|
+
fs.writeSync(lockFd, `${process.pid}`);
|
|
416
|
+
fs.closeSync(lockFd);
|
|
417
|
+
lockFd = null;
|
|
418
|
+
|
|
419
|
+
// Read current content
|
|
420
|
+
let content = '';
|
|
421
|
+
if (fs.existsSync(filePath)) {
|
|
422
|
+
content = fs.readFileSync(filePath, 'utf8');
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
// Apply update
|
|
426
|
+
const newContent = updateFn(content);
|
|
427
|
+
|
|
428
|
+
// Write back atomically
|
|
429
|
+
const writeResult = atomicWrite(filePath, newContent);
|
|
430
|
+
if (!writeResult.success) {
|
|
431
|
+
return { success: false, error: writeResult.error };
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
return { success: true, content: newContent };
|
|
435
|
+
} catch (e) {
|
|
436
|
+
return { success: false, error: e.message };
|
|
437
|
+
} finally {
|
|
438
|
+
// Close fd if still open
|
|
439
|
+
try {
|
|
440
|
+
if (lockFd !== null) fs.closeSync(lockFd);
|
|
441
|
+
} catch (_e) { /* ignore */ }
|
|
442
|
+
// Only release lock if we acquired it
|
|
443
|
+
if (lockAcquired) {
|
|
444
|
+
try {
|
|
445
|
+
fs.unlinkSync(lockPath);
|
|
446
|
+
} catch (_e) { /* ignore — may already be cleaned up */ }
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/**
|
|
452
|
+
* Write .active-skill with OS-level mutual exclusion.
|
|
453
|
+
*
|
|
454
|
+
* @param {string} planningDir - Path to .planning/ directory
|
|
455
|
+
* @param {string} skillName - Skill name to write
|
|
456
|
+
* @returns {{success: boolean, warning?: string}} Result
|
|
457
|
+
*/
|
|
458
|
+
function writeActiveSkill(planningDir, skillName) {
|
|
459
|
+
const skillFile = path.join(planningDir, '.active-skill');
|
|
460
|
+
const lockFile = skillFile + '.lock';
|
|
461
|
+
const staleThresholdMs = 60 * 60 * 1000; // 60 minutes
|
|
462
|
+
|
|
463
|
+
let lockFd = null;
|
|
464
|
+
try {
|
|
465
|
+
// Try exclusive create of lock file
|
|
466
|
+
lockFd = fs.openSync(lockFile, 'wx');
|
|
467
|
+
fs.writeSync(lockFd, `${process.pid}`);
|
|
468
|
+
fs.closeSync(lockFd);
|
|
469
|
+
lockFd = null;
|
|
470
|
+
|
|
471
|
+
// Check for existing .active-skill from another session
|
|
472
|
+
let warning = null;
|
|
473
|
+
if (fs.existsSync(skillFile)) {
|
|
474
|
+
try {
|
|
475
|
+
const stats = fs.statSync(skillFile);
|
|
476
|
+
const ageMs = Date.now() - stats.mtimeMs;
|
|
477
|
+
if (ageMs < staleThresholdMs) {
|
|
478
|
+
const existing = fs.readFileSync(skillFile, 'utf8').trim();
|
|
479
|
+
warning = `.active-skill already set to "${existing}" (${Math.round(ageMs / 60000)}min ago). Overwriting — possible concurrent session.`;
|
|
480
|
+
}
|
|
481
|
+
} catch (_e) {
|
|
482
|
+
// File disappeared between exists and stat — fine
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
// Write the skill name
|
|
487
|
+
fs.writeFileSync(skillFile, skillName, 'utf8');
|
|
488
|
+
|
|
489
|
+
// Release lock
|
|
490
|
+
try { fs.unlinkSync(lockFile); } catch (_e) { /* best effort */ }
|
|
491
|
+
|
|
492
|
+
return { success: true, warning };
|
|
493
|
+
} catch (e) {
|
|
494
|
+
// Close fd if still open
|
|
495
|
+
try { if (lockFd !== null) fs.closeSync(lockFd); } catch (_e) { /* ignore */ }
|
|
496
|
+
|
|
497
|
+
if (e.code === 'EEXIST') {
|
|
498
|
+
// Lock held by another process — check staleness
|
|
499
|
+
try {
|
|
500
|
+
const lockStats = fs.statSync(lockFile);
|
|
501
|
+
const lockAgeMs = Date.now() - lockStats.mtimeMs;
|
|
502
|
+
if (lockAgeMs > staleThresholdMs) {
|
|
503
|
+
// Stale lock — force remove and retry once
|
|
504
|
+
fs.unlinkSync(lockFile);
|
|
505
|
+
return writeActiveSkill(planningDir, skillName);
|
|
506
|
+
}
|
|
507
|
+
} catch (_statErr) {
|
|
508
|
+
// Lock disappeared — retry once
|
|
509
|
+
return writeActiveSkill(planningDir, skillName);
|
|
510
|
+
}
|
|
511
|
+
return { success: false, warning: `.active-skill.lock held by another process. Another PBR session may be active.` };
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
// Other error — write without lock as fallback
|
|
515
|
+
try {
|
|
516
|
+
fs.writeFileSync(skillFile, skillName, 'utf8');
|
|
517
|
+
return { success: true, warning: `Lock failed (${e.code}), wrote without lock` };
|
|
518
|
+
} catch (writeErr) {
|
|
519
|
+
return { success: false, warning: `Failed to write .active-skill: ${writeErr.message}` };
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
/**
|
|
525
|
+
* Lightweight JSON Schema validator — supports type, enum, properties,
|
|
526
|
+
* additionalProperties, minimum, maximum for the config schema.
|
|
527
|
+
*/
|
|
528
|
+
function validateObject(value, schema, prefix, errors, warnings) {
|
|
529
|
+
if (schema.type) {
|
|
530
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
531
|
+
const actualType = typeof value;
|
|
532
|
+
const typeMatch = types.some(t => {
|
|
533
|
+
if (t === 'integer') return actualType === 'number' && Number.isInteger(value);
|
|
534
|
+
return actualType === t;
|
|
535
|
+
});
|
|
536
|
+
if (!typeMatch) {
|
|
537
|
+
errors.push(`${prefix || 'root'}: expected ${types.join('|')}, got ${actualType}`);
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
if (schema.enum && !schema.enum.includes(value)) {
|
|
543
|
+
errors.push(`${prefix || 'root'}: value "${value}" not in allowed values [${schema.enum.join(', ')}]`);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
|
|
547
|
+
if (schema.minimum !== undefined && value < schema.minimum) {
|
|
548
|
+
errors.push(`${prefix || 'root'}: value ${value} is below minimum ${schema.minimum}`);
|
|
549
|
+
}
|
|
550
|
+
if (schema.maximum !== undefined && value > schema.maximum) {
|
|
551
|
+
errors.push(`${prefix || 'root'}: value ${value} is above maximum ${schema.maximum}`);
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
if (schema.type === 'object' && schema.properties) {
|
|
555
|
+
const knownKeys = new Set(Object.keys(schema.properties));
|
|
556
|
+
|
|
557
|
+
for (const key of Object.keys(value)) {
|
|
558
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
559
|
+
if (!knownKeys.has(key)) {
|
|
560
|
+
if (schema.additionalProperties === false) {
|
|
561
|
+
warnings.push(`${fullKey}: unrecognized key (possible typo?)`);
|
|
562
|
+
}
|
|
563
|
+
continue;
|
|
564
|
+
}
|
|
565
|
+
validateObject(value[key], schema.properties[key], fullKey, errors, warnings);
|
|
566
|
+
}
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
module.exports = {
|
|
571
|
+
KNOWN_AGENTS,
|
|
572
|
+
VALID_STATUS_TRANSITIONS,
|
|
573
|
+
validateStatusTransition,
|
|
574
|
+
output,
|
|
575
|
+
error,
|
|
576
|
+
parseYamlFrontmatter,
|
|
577
|
+
parseMustHaves,
|
|
578
|
+
findFiles,
|
|
579
|
+
tailLines,
|
|
580
|
+
countMustHaves,
|
|
581
|
+
determinePhaseStatus,
|
|
582
|
+
calculateProgress,
|
|
583
|
+
atomicWrite,
|
|
584
|
+
lockedFileUpdate,
|
|
585
|
+
writeActiveSkill,
|
|
586
|
+
validateObject
|
|
587
|
+
};
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* lib/history.js — HISTORY.md operations for Plan-Build-Run tools.
|
|
3
|
+
*
|
|
4
|
+
* Handles appending and loading project history records.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const path = require('path');
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Append a record to HISTORY.md. Creates the file if it doesn't exist.
|
|
12
|
+
* Each entry is a markdown section appended at the end.
|
|
13
|
+
*
|
|
14
|
+
* @param {object} entry - { type: 'milestone'|'phase'|'metric', title: string, body: string }
|
|
15
|
+
* @param {string} [dir] - Path to .planning directory
|
|
16
|
+
* @returns {{success: boolean, error?: string}}
|
|
17
|
+
*/
|
|
18
|
+
function historyAppend(entry, dir) {
|
|
19
|
+
const planningDir = dir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
|
|
20
|
+
const historyPath = path.join(planningDir, 'HISTORY.md');
|
|
21
|
+
const timestamp = new Date().toISOString().slice(0, 10);
|
|
22
|
+
|
|
23
|
+
let header = '';
|
|
24
|
+
if (!fs.existsSync(historyPath)) {
|
|
25
|
+
header = '# Project History\n\nCompleted milestones and phase records. This file is append-only.\n\n';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
const section = `${header}## ${entry.type === 'milestone' ? 'Milestone' : 'Phase'}: ${entry.title}\n_Completed: ${timestamp}_\n\n${entry.body.trim()}\n\n---\n\n`;
|
|
29
|
+
|
|
30
|
+
try {
|
|
31
|
+
fs.appendFileSync(historyPath, section, 'utf8');
|
|
32
|
+
return { success: true };
|
|
33
|
+
} catch (e) {
|
|
34
|
+
return { success: false, error: e.message };
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Load HISTORY.md and parse it into structured records.
|
|
40
|
+
* Returns null if HISTORY.md doesn't exist.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} [dir] - Path to .planning directory
|
|
43
|
+
* @returns {object|null} { records: [{type, title, date, body}], line_count }
|
|
44
|
+
*/
|
|
45
|
+
function historyLoad(dir) {
|
|
46
|
+
const planningDir = dir || path.join(process.env.PBR_PROJECT_ROOT || process.cwd(), '.planning');
|
|
47
|
+
const historyPath = path.join(planningDir, 'HISTORY.md');
|
|
48
|
+
if (!fs.existsSync(historyPath)) return null;
|
|
49
|
+
|
|
50
|
+
const content = fs.readFileSync(historyPath, 'utf8');
|
|
51
|
+
const records = [];
|
|
52
|
+
const sectionRegex = /^## (Milestone|Phase): (.+)\n_Completed: (\d{4}-\d{2}-\d{2})_\n\n([\s\S]*?)(?=\n---|\s*$)/gm;
|
|
53
|
+
|
|
54
|
+
let match;
|
|
55
|
+
while ((match = sectionRegex.exec(content)) !== null) {
|
|
56
|
+
records.push({
|
|
57
|
+
type: match[1].toLowerCase(),
|
|
58
|
+
title: match[2].trim(),
|
|
59
|
+
date: match[3],
|
|
60
|
+
body: match[4].trim()
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
records,
|
|
66
|
+
line_count: content.split('\n').length
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
module.exports = {
|
|
71
|
+
historyAppend,
|
|
72
|
+
historyLoad
|
|
73
|
+
};
|