@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.
Files changed (160) hide show
  1. package/CHANGELOG.md +683 -0
  2. package/dashboard/public/css/command-center.css +152 -65
  3. package/dashboard/public/css/explorer.css +22 -41
  4. package/dashboard/public/css/layout.css +119 -1
  5. package/dashboard/public/css/tokens.css +13 -0
  6. package/dashboard/src/components/Layout.tsx +32 -6
  7. package/dashboard/src/components/explorer/tabs/PhasesTab.tsx +11 -1
  8. package/dashboard/src/components/explorer/tabs/TodosTab.tsx +18 -2
  9. package/dashboard/src/components/partials/AttentionPanel.tsx +7 -1
  10. package/dashboard/src/components/partials/CurrentPhaseCard.tsx +26 -24
  11. package/dashboard/src/components/partials/QuickActions.tsx +21 -11
  12. package/dashboard/src/components/partials/StatCardGrid.tsx +67 -0
  13. package/dashboard/src/components/partials/StatusHeader.tsx +1 -0
  14. package/dashboard/src/routes/command-center.routes.tsx +8 -7
  15. package/dashboard/src/routes/index.routes.tsx +32 -29
  16. package/package.json +2 -2
  17. package/plugins/copilot-pbr/agents/audit.agent.md +129 -16
  18. package/plugins/copilot-pbr/agents/codebase-mapper.agent.md +49 -1
  19. package/plugins/copilot-pbr/agents/debugger.agent.md +50 -1
  20. package/plugins/copilot-pbr/agents/dev-sync.agent.md +23 -0
  21. package/plugins/copilot-pbr/agents/executor.agent.md +153 -8
  22. package/plugins/copilot-pbr/agents/general.agent.md +46 -1
  23. package/plugins/copilot-pbr/agents/integration-checker.agent.md +55 -2
  24. package/plugins/copilot-pbr/agents/plan-checker.agent.md +50 -2
  25. package/plugins/copilot-pbr/agents/planner.agent.md +80 -1
  26. package/plugins/copilot-pbr/agents/researcher.agent.md +50 -2
  27. package/plugins/copilot-pbr/agents/synthesizer.agent.md +49 -1
  28. package/plugins/copilot-pbr/agents/verifier.agent.md +114 -13
  29. package/plugins/copilot-pbr/commands/test.md +5 -0
  30. package/plugins/copilot-pbr/hooks/hooks.json +11 -0
  31. package/plugins/copilot-pbr/plugin.json +1 -1
  32. package/plugins/copilot-pbr/references/agent-contracts.md +27 -0
  33. package/plugins/copilot-pbr/references/checkpoints.md +32 -1
  34. package/plugins/copilot-pbr/references/context-quality-tiers.md +45 -0
  35. package/plugins/copilot-pbr/references/pbr-tools-cli.md +115 -0
  36. package/plugins/copilot-pbr/references/questioning.md +21 -1
  37. package/plugins/copilot-pbr/references/verification-patterns.md +96 -18
  38. package/plugins/copilot-pbr/skills/audit/SKILL.md +19 -3
  39. package/plugins/copilot-pbr/skills/begin/SKILL.md +57 -4
  40. package/plugins/copilot-pbr/skills/build/SKILL.md +39 -2
  41. package/plugins/copilot-pbr/skills/config/SKILL.md +12 -2
  42. package/plugins/copilot-pbr/skills/debug/SKILL.md +12 -1
  43. package/plugins/copilot-pbr/skills/explore/SKILL.md +13 -2
  44. package/plugins/copilot-pbr/skills/health/SKILL.md +13 -5
  45. package/plugins/copilot-pbr/skills/import/SKILL.md +26 -1
  46. package/plugins/copilot-pbr/skills/milestone/SKILL.md +15 -3
  47. package/plugins/copilot-pbr/skills/plan/SKILL.md +50 -0
  48. package/plugins/copilot-pbr/skills/quick/SKILL.md +21 -0
  49. package/plugins/copilot-pbr/skills/review/SKILL.md +45 -0
  50. package/plugins/copilot-pbr/skills/scan/SKILL.md +20 -0
  51. package/plugins/copilot-pbr/skills/setup/SKILL.md +9 -1
  52. package/plugins/copilot-pbr/skills/shared/context-budget.md +10 -0
  53. package/plugins/copilot-pbr/skills/shared/universal-anti-patterns.md +6 -0
  54. package/plugins/copilot-pbr/skills/test/SKILL.md +210 -0
  55. package/plugins/copilot-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
  56. package/plugins/copilot-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
  57. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +1 -1
  58. package/plugins/cursor-pbr/agents/audit.md +52 -5
  59. package/plugins/cursor-pbr/agents/codebase-mapper.md +49 -1
  60. package/plugins/cursor-pbr/agents/debugger.md +50 -1
  61. package/plugins/cursor-pbr/agents/dev-sync.md +23 -0
  62. package/plugins/cursor-pbr/agents/executor.md +153 -8
  63. package/plugins/cursor-pbr/agents/general.md +46 -1
  64. package/plugins/cursor-pbr/agents/integration-checker.md +54 -1
  65. package/plugins/cursor-pbr/agents/plan-checker.md +49 -1
  66. package/plugins/cursor-pbr/agents/planner.md +80 -1
  67. package/plugins/cursor-pbr/agents/researcher.md +49 -1
  68. package/plugins/cursor-pbr/agents/synthesizer.md +49 -1
  69. package/plugins/cursor-pbr/agents/verifier.md +113 -12
  70. package/plugins/cursor-pbr/commands/test.md +5 -0
  71. package/plugins/cursor-pbr/hooks/hooks.json +9 -0
  72. package/plugins/cursor-pbr/references/agent-contracts.md +27 -0
  73. package/plugins/cursor-pbr/references/checkpoints.md +32 -1
  74. package/plugins/cursor-pbr/references/context-quality-tiers.md +45 -0
  75. package/plugins/cursor-pbr/references/pbr-tools-cli.md +115 -0
  76. package/plugins/cursor-pbr/references/questioning.md +21 -1
  77. package/plugins/cursor-pbr/references/verification-patterns.md +96 -18
  78. package/plugins/cursor-pbr/skills/audit/SKILL.md +19 -3
  79. package/plugins/cursor-pbr/skills/begin/SKILL.md +57 -4
  80. package/plugins/cursor-pbr/skills/build/SKILL.md +37 -2
  81. package/plugins/cursor-pbr/skills/config/SKILL.md +12 -2
  82. package/plugins/cursor-pbr/skills/debug/SKILL.md +12 -1
  83. package/plugins/cursor-pbr/skills/explore/SKILL.md +13 -2
  84. package/plugins/cursor-pbr/skills/health/SKILL.md +14 -5
  85. package/plugins/cursor-pbr/skills/import/SKILL.md +26 -1
  86. package/plugins/cursor-pbr/skills/milestone/SKILL.md +15 -3
  87. package/plugins/cursor-pbr/skills/plan/SKILL.md +50 -0
  88. package/plugins/cursor-pbr/skills/quick/SKILL.md +21 -0
  89. package/plugins/cursor-pbr/skills/review/SKILL.md +45 -0
  90. package/plugins/cursor-pbr/skills/scan/SKILL.md +20 -0
  91. package/plugins/cursor-pbr/skills/setup/SKILL.md +9 -1
  92. package/plugins/cursor-pbr/skills/shared/context-budget.md +10 -0
  93. package/plugins/cursor-pbr/skills/shared/universal-anti-patterns.md +6 -0
  94. package/plugins/cursor-pbr/skills/test/SKILL.md +211 -0
  95. package/plugins/cursor-pbr/templates/SUMMARY-complex.md.tmpl +95 -0
  96. package/plugins/cursor-pbr/templates/SUMMARY-minimal.md.tmpl +48 -0
  97. package/plugins/pbr/.claude-plugin/plugin.json +1 -1
  98. package/plugins/pbr/agents/audit.md +45 -0
  99. package/plugins/pbr/agents/codebase-mapper.md +48 -0
  100. package/plugins/pbr/agents/debugger.md +49 -0
  101. package/plugins/pbr/agents/dev-sync.md +23 -0
  102. package/plugins/pbr/agents/executor.md +151 -6
  103. package/plugins/pbr/agents/general.md +45 -0
  104. package/plugins/pbr/agents/integration-checker.md +53 -0
  105. package/plugins/pbr/agents/plan-checker.md +48 -0
  106. package/plugins/pbr/agents/planner.md +78 -1
  107. package/plugins/pbr/agents/researcher.md +48 -0
  108. package/plugins/pbr/agents/synthesizer.md +48 -0
  109. package/plugins/pbr/agents/verifier.md +112 -11
  110. package/plugins/pbr/commands/test.md +5 -0
  111. package/plugins/pbr/hooks/hooks.json +9 -0
  112. package/plugins/pbr/references/agent-contracts.md +27 -0
  113. package/plugins/pbr/references/checkpoints.md +32 -0
  114. package/plugins/pbr/references/context-quality-tiers.md +45 -0
  115. package/plugins/pbr/references/pbr-tools-cli.md +115 -0
  116. package/plugins/pbr/references/questioning.md +21 -0
  117. package/plugins/pbr/references/verification-patterns.md +96 -17
  118. package/plugins/pbr/scripts/check-plan-format.js +13 -1
  119. package/plugins/pbr/scripts/check-state-sync.js +26 -7
  120. package/plugins/pbr/scripts/check-subagent-output.js +30 -2
  121. package/plugins/pbr/scripts/config-schema.json +11 -1
  122. package/plugins/pbr/scripts/context-bridge.js +265 -0
  123. package/plugins/pbr/scripts/lib/config.js +271 -0
  124. package/plugins/pbr/scripts/lib/core.js +587 -0
  125. package/plugins/pbr/scripts/lib/history.js +73 -0
  126. package/plugins/pbr/scripts/lib/init.js +166 -0
  127. package/plugins/pbr/scripts/lib/migrate.js +169 -0
  128. package/plugins/pbr/scripts/lib/phase.js +364 -0
  129. package/plugins/pbr/scripts/lib/roadmap.js +175 -0
  130. package/plugins/pbr/scripts/lib/state.js +397 -0
  131. package/plugins/pbr/scripts/lib/todo.js +300 -0
  132. package/plugins/pbr/scripts/pbr-tools.js +425 -1310
  133. package/plugins/pbr/scripts/post-write-dispatch.js +5 -4
  134. package/plugins/pbr/scripts/pre-write-dispatch.js +1 -1
  135. package/plugins/pbr/scripts/progress-tracker.js +1 -1
  136. package/plugins/pbr/scripts/suggest-compact.js +1 -1
  137. package/plugins/pbr/scripts/track-context-budget.js +53 -2
  138. package/plugins/pbr/scripts/validate-task.js +20 -28
  139. package/plugins/pbr/skills/audit/SKILL.md +19 -3
  140. package/plugins/pbr/skills/begin/SKILL.md +48 -2
  141. package/plugins/pbr/skills/build/SKILL.md +39 -2
  142. package/plugins/pbr/skills/config/SKILL.md +12 -2
  143. package/plugins/pbr/skills/debug/SKILL.md +12 -1
  144. package/plugins/pbr/skills/debug/templates/continuation-prompt.md.tmpl +12 -1
  145. package/plugins/pbr/skills/debug/templates/initial-investigation-prompt.md.tmpl +12 -5
  146. package/plugins/pbr/skills/explore/SKILL.md +13 -2
  147. package/plugins/pbr/skills/health/SKILL.md +14 -3
  148. package/plugins/pbr/skills/help/SKILL.md +2 -0
  149. package/plugins/pbr/skills/import/SKILL.md +26 -1
  150. package/plugins/pbr/skills/milestone/SKILL.md +15 -3
  151. package/plugins/pbr/skills/plan/SKILL.md +52 -2
  152. package/plugins/pbr/skills/quick/SKILL.md +21 -0
  153. package/plugins/pbr/skills/review/SKILL.md +46 -0
  154. package/plugins/pbr/skills/scan/SKILL.md +20 -0
  155. package/plugins/pbr/skills/setup/SKILL.md +9 -1
  156. package/plugins/pbr/skills/shared/context-budget.md +10 -0
  157. package/plugins/pbr/skills/shared/universal-anti-patterns.md +6 -0
  158. package/plugins/pbr/skills/test/SKILL.md +212 -0
  159. package/plugins/pbr/templates/SUMMARY-complex.md.tmpl +95 -0
  160. 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
+ };