@sienklogic/plan-build-run 2.0.0 → 2.0.2

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 (233) hide show
  1. package/CHANGELOG.md +56 -56
  2. package/CLAUDE.md +149 -149
  3. package/LICENSE +21 -21
  4. package/README.md +247 -247
  5. package/dashboard/bin/cli.js +25 -25
  6. package/dashboard/package.json +34 -34
  7. package/dashboard/public/css/layout.css +406 -406
  8. package/dashboard/public/css/status-colors.css +98 -98
  9. package/dashboard/public/js/htmx-title.js +5 -5
  10. package/dashboard/public/js/sidebar-toggle.js +20 -20
  11. package/dashboard/src/app.js +78 -78
  12. package/dashboard/src/middleware/errorHandler.js +52 -52
  13. package/dashboard/src/middleware/notFoundHandler.js +9 -9
  14. package/dashboard/src/repositories/planning.repository.js +128 -128
  15. package/dashboard/src/routes/events.routes.js +40 -40
  16. package/dashboard/src/routes/index.routes.js +31 -31
  17. package/dashboard/src/routes/pages.routes.js +245 -195
  18. package/dashboard/src/server.js +42 -42
  19. package/dashboard/src/services/dashboard.service.js +222 -222
  20. package/dashboard/src/services/phase.service.js +220 -167
  21. package/dashboard/src/services/project.service.js +57 -57
  22. package/dashboard/src/services/roadmap.service.js +171 -171
  23. package/dashboard/src/services/sse.service.js +58 -58
  24. package/dashboard/src/services/todo.service.js +254 -254
  25. package/dashboard/src/services/watcher.service.js +48 -48
  26. package/dashboard/src/views/coming-soon.ejs +11 -11
  27. package/dashboard/src/views/error.ejs +13 -13
  28. package/dashboard/src/views/index.ejs +5 -5
  29. package/dashboard/src/views/layout.ejs +1 -1
  30. package/dashboard/src/views/partials/dashboard-content.ejs +77 -77
  31. package/dashboard/src/views/partials/footer.ejs +3 -3
  32. package/dashboard/src/views/partials/head.ejs +21 -21
  33. package/dashboard/src/views/partials/header.ejs +12 -12
  34. package/dashboard/src/views/partials/layout-bottom.ejs +15 -15
  35. package/dashboard/src/views/partials/layout-top.ejs +8 -8
  36. package/dashboard/src/views/partials/phase-content.ejs +188 -181
  37. package/dashboard/src/views/partials/phase-doc-content.ejs +38 -0
  38. package/dashboard/src/views/partials/phases-content.ejs +117 -117
  39. package/dashboard/src/views/partials/roadmap-content.ejs +142 -142
  40. package/dashboard/src/views/partials/sidebar.ejs +38 -38
  41. package/dashboard/src/views/partials/todo-create-content.ejs +53 -53
  42. package/dashboard/src/views/partials/todo-detail-content.ejs +38 -38
  43. package/dashboard/src/views/partials/todos-content.ejs +53 -53
  44. package/dashboard/src/views/phase-detail.ejs +5 -5
  45. package/dashboard/src/views/phase-doc.ejs +5 -0
  46. package/dashboard/src/views/phases.ejs +5 -5
  47. package/dashboard/src/views/roadmap.ejs +5 -5
  48. package/dashboard/src/views/todo-create.ejs +5 -5
  49. package/dashboard/src/views/todo-detail.ejs +5 -5
  50. package/dashboard/src/views/todos.ejs +5 -5
  51. package/package.json +57 -57
  52. package/plugins/cursor-pbr/.cursor-plugin/plugin.json +22 -0
  53. package/plugins/cursor-pbr/agents/.gitkeep +0 -0
  54. package/plugins/cursor-pbr/assets/.gitkeep +0 -0
  55. package/plugins/cursor-pbr/hooks/hooks.json +11 -0
  56. package/plugins/cursor-pbr/references/.gitkeep +0 -0
  57. package/plugins/cursor-pbr/rules/.gitkeep +0 -0
  58. package/plugins/cursor-pbr/skills/.gitkeep +0 -0
  59. package/plugins/cursor-pbr/templates/.gitkeep +0 -0
  60. package/plugins/pbr/.claude-plugin/plugin.json +13 -13
  61. package/plugins/pbr/UI-CONSISTENCY-GAPS.md +61 -61
  62. package/plugins/pbr/agents/codebase-mapper.md +279 -271
  63. package/plugins/pbr/agents/debugger.md +281 -281
  64. package/plugins/pbr/agents/executor.md +428 -407
  65. package/plugins/pbr/agents/general.md +164 -164
  66. package/plugins/pbr/agents/integration-checker.md +169 -141
  67. package/plugins/pbr/agents/plan-checker.md +296 -280
  68. package/plugins/pbr/agents/planner.md +358 -358
  69. package/plugins/pbr/agents/researcher.md +363 -363
  70. package/plugins/pbr/agents/synthesizer.md +230 -230
  71. package/plugins/pbr/agents/verifier.md +489 -454
  72. package/plugins/pbr/commands/begin.md +5 -5
  73. package/plugins/pbr/commands/build.md +5 -5
  74. package/plugins/pbr/commands/config.md +5 -5
  75. package/plugins/pbr/commands/continue.md +5 -5
  76. package/plugins/pbr/commands/debug.md +5 -5
  77. package/plugins/pbr/commands/discuss.md +5 -5
  78. package/plugins/pbr/commands/explore.md +5 -5
  79. package/plugins/pbr/commands/health.md +5 -5
  80. package/plugins/pbr/commands/help.md +5 -5
  81. package/plugins/pbr/commands/import.md +5 -5
  82. package/plugins/pbr/commands/milestone.md +5 -5
  83. package/plugins/pbr/commands/note.md +5 -5
  84. package/plugins/pbr/commands/pause.md +5 -5
  85. package/plugins/pbr/commands/plan.md +5 -5
  86. package/plugins/pbr/commands/quick.md +5 -5
  87. package/plugins/pbr/commands/resume.md +5 -5
  88. package/plugins/pbr/commands/review.md +5 -5
  89. package/plugins/pbr/commands/scan.md +5 -5
  90. package/plugins/pbr/commands/setup.md +5 -5
  91. package/plugins/pbr/commands/status.md +5 -5
  92. package/plugins/pbr/commands/todo.md +5 -5
  93. package/plugins/pbr/contexts/dev.md +27 -27
  94. package/plugins/pbr/contexts/research.md +28 -28
  95. package/plugins/pbr/contexts/review.md +36 -36
  96. package/plugins/pbr/hooks/hooks.json +183 -183
  97. package/plugins/pbr/references/agent-anti-patterns.md +24 -24
  98. package/plugins/pbr/references/agent-interactions.md +134 -134
  99. package/plugins/pbr/references/agent-teams.md +54 -54
  100. package/plugins/pbr/references/checkpoints.md +157 -157
  101. package/plugins/pbr/references/common-bug-patterns.md +13 -13
  102. package/plugins/pbr/references/config-reference.md +441 -0
  103. package/plugins/pbr/references/continuation-format.md +212 -212
  104. package/plugins/pbr/references/deviation-rules.md +112 -112
  105. package/plugins/pbr/references/git-integration.md +226 -226
  106. package/plugins/pbr/references/integration-patterns.md +117 -117
  107. package/plugins/pbr/references/model-profiles.md +99 -99
  108. package/plugins/pbr/references/model-selection.md +31 -31
  109. package/plugins/pbr/references/pbr-rules.md +193 -193
  110. package/plugins/pbr/references/plan-authoring.md +181 -181
  111. package/plugins/pbr/references/plan-format.md +287 -283
  112. package/plugins/pbr/references/planning-config.md +213 -213
  113. package/plugins/pbr/references/questioning.md +214 -214
  114. package/plugins/pbr/references/reading-verification.md +127 -127
  115. package/plugins/pbr/references/stub-patterns.md +160 -160
  116. package/plugins/pbr/references/subagent-coordination.md +119 -119
  117. package/plugins/pbr/references/ui-formatting.md +461 -399
  118. package/plugins/pbr/references/verification-patterns.md +198 -198
  119. package/plugins/pbr/references/wave-execution.md +95 -95
  120. package/plugins/pbr/scripts/auto-continue.js +80 -80
  121. package/plugins/pbr/scripts/check-dangerous-commands.js +136 -136
  122. package/plugins/pbr/scripts/check-doc-sprawl.js +102 -102
  123. package/plugins/pbr/scripts/check-phase-boundary.js +196 -196
  124. package/plugins/pbr/scripts/check-plan-format.js +270 -270
  125. package/plugins/pbr/scripts/check-roadmap-sync.js +322 -252
  126. package/plugins/pbr/scripts/check-skill-workflow.js +262 -262
  127. package/plugins/pbr/scripts/check-state-sync.js +476 -476
  128. package/plugins/pbr/scripts/check-subagent-output.js +144 -144
  129. package/plugins/pbr/scripts/config-schema.json +251 -251
  130. package/plugins/pbr/scripts/context-budget-check.js +287 -287
  131. package/plugins/pbr/scripts/event-handler.js +151 -151
  132. package/plugins/pbr/scripts/event-logger.js +92 -92
  133. package/plugins/pbr/scripts/hook-logger.js +80 -76
  134. package/plugins/pbr/scripts/hooks-schema.json +79 -79
  135. package/plugins/pbr/scripts/log-subagent.js +164 -152
  136. package/plugins/pbr/scripts/log-tool-failure.js +88 -88
  137. package/plugins/pbr/scripts/pbr-tools.js +1378 -1301
  138. package/plugins/pbr/scripts/post-write-dispatch.js +66 -66
  139. package/plugins/pbr/scripts/post-write-quality.js +207 -207
  140. package/plugins/pbr/scripts/pre-bash-dispatch.js +86 -56
  141. package/plugins/pbr/scripts/pre-write-dispatch.js +97 -62
  142. package/plugins/pbr/scripts/progress-tracker.js +281 -228
  143. package/plugins/pbr/scripts/run-hook.js +92 -0
  144. package/plugins/pbr/scripts/session-cleanup.js +254 -254
  145. package/plugins/pbr/scripts/status-line.js +288 -285
  146. package/plugins/pbr/scripts/suggest-compact.js +119 -119
  147. package/plugins/pbr/scripts/task-completed.js +45 -45
  148. package/plugins/pbr/scripts/track-context-budget.js +149 -119
  149. package/plugins/pbr/scripts/validate-commit.js +200 -200
  150. package/plugins/pbr/scripts/validate-plugin-structure.js +183 -172
  151. package/plugins/pbr/scripts/validate-task.js +106 -0
  152. package/plugins/pbr/skills/begin/SKILL.md +594 -545
  153. package/plugins/pbr/skills/begin/templates/PROJECT.md.tmpl +33 -33
  154. package/plugins/pbr/skills/begin/templates/REQUIREMENTS.md.tmpl +18 -18
  155. package/plugins/pbr/skills/begin/templates/STATE.md.tmpl +49 -49
  156. package/plugins/pbr/skills/begin/templates/config.json.tmpl +64 -63
  157. package/plugins/pbr/skills/begin/templates/researcher-prompt.md.tmpl +19 -19
  158. package/plugins/pbr/skills/begin/templates/roadmap-prompt.md.tmpl +30 -30
  159. package/plugins/pbr/skills/begin/templates/synthesis-prompt.md.tmpl +16 -16
  160. package/plugins/pbr/skills/build/SKILL.md +943 -962
  161. package/plugins/pbr/skills/config/SKILL.md +256 -241
  162. package/plugins/pbr/skills/continue/SKILL.md +164 -127
  163. package/plugins/pbr/skills/debug/SKILL.md +515 -489
  164. package/plugins/pbr/skills/debug/templates/continuation-prompt.md.tmpl +16 -16
  165. package/plugins/pbr/skills/debug/templates/initial-investigation-prompt.md.tmpl +27 -27
  166. package/plugins/pbr/skills/discuss/SKILL.md +347 -338
  167. package/plugins/pbr/skills/discuss/templates/CONTEXT.md.tmpl +61 -61
  168. package/plugins/pbr/skills/discuss/templates/decision-categories.md +9 -9
  169. package/plugins/pbr/skills/explore/SKILL.md +378 -362
  170. package/plugins/pbr/skills/health/SKILL.md +221 -186
  171. package/plugins/pbr/skills/health/templates/check-pattern.md.tmpl +30 -30
  172. package/plugins/pbr/skills/health/templates/output-format.md.tmpl +63 -63
  173. package/plugins/pbr/skills/help/SKILL.md +155 -140
  174. package/plugins/pbr/skills/import/SKILL.md +504 -490
  175. package/plugins/pbr/skills/milestone/SKILL.md +704 -673
  176. package/plugins/pbr/skills/milestone/templates/audit-report.md.tmpl +48 -48
  177. package/plugins/pbr/skills/milestone/templates/stats-file.md.tmpl +30 -30
  178. package/plugins/pbr/skills/note/SKILL.md +231 -212
  179. package/plugins/pbr/skills/pause/SKILL.md +249 -235
  180. package/plugins/pbr/skills/pause/templates/continue-here.md.tmpl +71 -71
  181. package/plugins/pbr/skills/plan/SKILL.md +685 -628
  182. package/plugins/pbr/skills/plan/decimal-phase-calc.md +98 -98
  183. package/plugins/pbr/skills/plan/templates/checker-prompt.md.tmpl +21 -21
  184. package/plugins/pbr/skills/plan/templates/gap-closure-prompt.md.tmpl +32 -32
  185. package/plugins/pbr/skills/plan/templates/planner-prompt.md.tmpl +38 -38
  186. package/plugins/pbr/skills/plan/templates/researcher-prompt.md.tmpl +19 -19
  187. package/plugins/pbr/skills/plan/templates/revision-prompt.md.tmpl +23 -23
  188. package/plugins/pbr/skills/quick/SKILL.md +354 -335
  189. package/plugins/pbr/skills/resume/SKILL.md +402 -388
  190. package/plugins/pbr/skills/review/SKILL.md +686 -652
  191. package/plugins/pbr/skills/review/templates/debugger-prompt.md.tmpl +60 -60
  192. package/plugins/pbr/skills/review/templates/gap-planner-prompt.md.tmpl +40 -40
  193. package/plugins/pbr/skills/review/templates/verifier-prompt.md.tmpl +115 -115
  194. package/plugins/pbr/skills/scan/SKILL.md +304 -269
  195. package/plugins/pbr/skills/scan/templates/mapper-prompt.md.tmpl +201 -201
  196. package/plugins/pbr/skills/setup/SKILL.md +253 -227
  197. package/plugins/pbr/skills/shared/commit-planning-docs.md +35 -35
  198. package/plugins/pbr/skills/shared/config-loading.md +102 -102
  199. package/plugins/pbr/skills/shared/context-budget.md +40 -40
  200. package/plugins/pbr/skills/shared/context-loader-task.md +86 -86
  201. package/plugins/pbr/skills/shared/digest-select.md +79 -79
  202. package/plugins/pbr/skills/shared/domain-probes.md +125 -125
  203. package/plugins/pbr/skills/shared/error-reporting.md +79 -79
  204. package/plugins/pbr/skills/shared/gate-prompts.md +388 -388
  205. package/plugins/pbr/skills/shared/phase-argument-parsing.md +45 -45
  206. package/plugins/pbr/skills/shared/progress-display.md +53 -53
  207. package/plugins/pbr/skills/shared/revision-loop.md +81 -81
  208. package/plugins/pbr/skills/shared/state-loading.md +62 -62
  209. package/plugins/pbr/skills/shared/state-update.md +161 -161
  210. package/plugins/pbr/skills/shared/universal-anti-patterns.md +33 -33
  211. package/plugins/pbr/skills/status/SKILL.md +367 -353
  212. package/plugins/pbr/skills/todo/SKILL.md +198 -181
  213. package/plugins/pbr/templates/CONTEXT.md.tmpl +52 -52
  214. package/plugins/pbr/templates/INTEGRATION-REPORT.md.tmpl +151 -151
  215. package/plugins/pbr/templates/RESEARCH-SUMMARY.md.tmpl +97 -97
  216. package/plugins/pbr/templates/ROADMAP.md.tmpl +40 -40
  217. package/plugins/pbr/templates/SUMMARY.md.tmpl +81 -81
  218. package/plugins/pbr/templates/VERIFICATION-DETAIL.md.tmpl +116 -116
  219. package/plugins/pbr/templates/codebase/ARCHITECTURE.md.tmpl +98 -98
  220. package/plugins/pbr/templates/codebase/CONCERNS.md.tmpl +93 -93
  221. package/plugins/pbr/templates/codebase/CONVENTIONS.md.tmpl +104 -104
  222. package/plugins/pbr/templates/codebase/INTEGRATIONS.md.tmpl +78 -78
  223. package/plugins/pbr/templates/codebase/STACK.md.tmpl +78 -78
  224. package/plugins/pbr/templates/codebase/STRUCTURE.md.tmpl +80 -80
  225. package/plugins/pbr/templates/codebase/TESTING.md.tmpl +107 -107
  226. package/plugins/pbr/templates/continue-here.md.tmpl +73 -73
  227. package/plugins/pbr/templates/prompt-partials/phase-project-context.md.tmpl +37 -37
  228. package/plugins/pbr/templates/research/ARCHITECTURE.md.tmpl +124 -124
  229. package/plugins/pbr/templates/research/STACK.md.tmpl +71 -71
  230. package/plugins/pbr/templates/research/SUMMARY.md.tmpl +112 -112
  231. package/plugins/pbr/templates/research-outputs/phase-research.md.tmpl +81 -81
  232. package/plugins/pbr/templates/research-outputs/project-research.md.tmpl +99 -99
  233. package/plugins/pbr/templates/research-outputs/synthesis.md.tmpl +36 -36
@@ -1,1301 +1,1378 @@
1
- #!/usr/bin/env node
2
-
3
- /**
4
- * pbr-tools.js — Structured JSON state operations for Plan-Build-Run skills.
5
- *
6
- * Provides read-only commands that return JSON, replacing LLM-based text parsing
7
- * of STATE.md, ROADMAP.md, and config.json. Skills call this via:
8
- * node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js <command> [args]
9
- *
10
- * Commands:
11
- * state load — Full project state as JSON
12
- * state check-progress — Recalculate progress from filesystem
13
- * state update <f> <v> — Atomically update a STATE.md field
14
- * config validate — Validate config.json against schema
15
- * plan-index <phase> — Plan inventory for a phase, grouped by wave
16
- * frontmatter <filepath> — Parse .md file's YAML frontmatter → JSON
17
- * must-haves <phase> — Collect all must-haves from phase plans → JSON
18
- * phase-info <phase> — Comprehensive single-phase status → JSON
19
- * roadmap update-status <phase> <status> — Update phase status in ROADMAP.md
20
- * roadmap update-plans <phase> <complete> <total> — Update phase plans in ROADMAP.md
21
- * history append <type> <title> [body] — Append record to HISTORY.md
22
- * history load — Load all HISTORY.md records as JSON
23
- */
24
-
25
- const fs = require('fs');
26
- const path = require('path');
27
-
28
- const cwd = process.cwd();
29
- const planningDir = path.join(cwd, '.planning');
30
-
31
- // --- Cached config loader ---
32
-
33
- let _configCache = null;
34
- let _configMtime = 0;
35
- let _configPath = null;
36
-
37
- /**
38
- * Load config.json with in-process mtime-based caching.
39
- * Returns the parsed config object, or null if not found / parse error.
40
- * Cache invalidates when file mtime changes or path differs.
41
- *
42
- * @param {string} [dir] - Path to .planning directory (defaults to cwd/.planning)
43
- * @returns {object|null} Parsed config or null
44
- */
45
- function configLoad(dir) {
46
- const configPath = path.join(dir || planningDir, 'config.json');
47
- try {
48
- if (!fs.existsSync(configPath)) return null;
49
- const stat = fs.statSync(configPath);
50
- const mtime = stat.mtimeMs;
51
- if (_configCache && mtime === _configMtime && configPath === _configPath) {
52
- return _configCache;
53
- }
54
- _configCache = JSON.parse(fs.readFileSync(configPath, 'utf8'));
55
- _configMtime = mtime;
56
- _configPath = configPath;
57
- return _configCache;
58
- } catch (_e) {
59
- return null;
60
- }
61
- }
62
-
63
- /**
64
- * Clear the configLoad() in-process cache.
65
- * Useful in tests where multiple temp directories are used in rapid succession.
66
- */
67
- function configClearCache() {
68
- _configCache = null;
69
- _configMtime = 0;
70
- _configPath = null;
71
- }
72
-
73
- /**
74
- * Read the last N lines from a file efficiently.
75
- * Reads the entire file but only parses (JSON.parse) the trailing entries.
76
- * For JSONL files where full parsing is expensive, this avoids parsing
77
- * all lines when you only need recent entries.
78
- *
79
- * @param {string} filePath - Absolute path to the file
80
- * @param {number} n - Number of trailing lines to return
81
- * @returns {string[]} Array of raw line strings (last n lines)
82
- */
83
- function tailLines(filePath, n) {
84
- try {
85
- if (!fs.existsSync(filePath)) return [];
86
- const content = fs.readFileSync(filePath, 'utf8').trim();
87
- if (!content) return [];
88
- const lines = content.split('\n');
89
- if (lines.length <= n) return lines;
90
- return lines.slice(lines.length - n);
91
- } catch (_e) {
92
- return [];
93
- }
94
- }
95
-
96
- /**
97
- * Built-in depth profile defaults. These define the effective settings
98
- * for each depth level. User config.depth_profiles overrides these.
99
- */
100
- const DEPTH_PROFILE_DEFAULTS = {
101
- quick: {
102
- 'features.research_phase': false,
103
- 'features.plan_checking': false,
104
- 'features.goal_verification': false,
105
- 'features.inline_verify': false,
106
- 'scan.mapper_count': 2,
107
- 'scan.mapper_areas': ['tech', 'arch'],
108
- 'debug.max_hypothesis_rounds': 3
109
- },
110
- standard: {
111
- 'features.research_phase': true,
112
- 'features.plan_checking': true,
113
- 'features.goal_verification': true,
114
- 'features.inline_verify': false,
115
- 'scan.mapper_count': 4,
116
- 'scan.mapper_areas': ['tech', 'arch', 'quality', 'concerns'],
117
- 'debug.max_hypothesis_rounds': 5
118
- },
119
- comprehensive: {
120
- 'features.research_phase': true,
121
- 'features.plan_checking': true,
122
- 'features.goal_verification': true,
123
- 'features.inline_verify': true,
124
- 'scan.mapper_count': 4,
125
- 'scan.mapper_areas': ['tech', 'arch', 'quality', 'concerns'],
126
- 'debug.max_hypothesis_rounds': 10
127
- }
128
- };
129
-
130
- /**
131
- * Resolve the effective depth profile for the current config.
132
- * Merges built-in defaults with any user overrides from config.depth_profiles.
133
- *
134
- * @param {object|null} config - Parsed config.json (from configLoad). If null, returns 'standard' defaults.
135
- * @returns {{ depth: string, profile: object }} The resolved depth name and flattened profile settings.
136
- */
137
- function resolveDepthProfile(config) {
138
- const depth = (config && config.depth) || 'standard';
139
- const defaults = DEPTH_PROFILE_DEFAULTS[depth] || DEPTH_PROFILE_DEFAULTS.standard;
140
-
141
- // Merge user overrides if present
142
- const userOverrides = (config && config.depth_profiles && config.depth_profiles[depth]) || {};
143
- const profile = { ...defaults, ...userOverrides };
144
-
145
- return { depth, profile };
146
- }
147
-
148
- function main() {
149
- const args = process.argv.slice(2);
150
- const command = args[0];
151
- const subcommand = args[1];
152
-
153
- try {
154
- if (command === 'state' && subcommand === 'load') {
155
- output(stateLoad());
156
- } else if (command === 'state' && subcommand === 'check-progress') {
157
- output(stateCheckProgress());
158
- } else if (command === 'state' && subcommand === 'update') {
159
- const field = args[2];
160
- const value = args[3];
161
- if (!field || value === undefined) {
162
- error('Usage: pbr-tools.js state update <field> <value>\nFields: current_phase, status, plans_complete, last_activity');
163
- }
164
- output(stateUpdate(field, value));
165
- } else if (command === 'config' && subcommand === 'validate') {
166
- output(configValidate());
167
- } else if (command === 'config' && subcommand === 'resolve-depth') {
168
- const dir = args[2] || undefined;
169
- const config = configLoad(dir);
170
- output(resolveDepthProfile(config));
171
- } else if (command === 'plan-index') {
172
- const phase = args[1];
173
- if (!phase) {
174
- error('Usage: pbr-tools.js plan-index <phase-number>');
175
- }
176
- output(planIndex(phase));
177
- } else if (command === 'frontmatter') {
178
- const filePath = args[1];
179
- if (!filePath) {
180
- error('Usage: pbr-tools.js frontmatter <filepath>');
181
- }
182
- output(frontmatter(filePath));
183
- } else if (command === 'must-haves') {
184
- const phase = args[1];
185
- if (!phase) {
186
- error('Usage: pbr-tools.js must-haves <phase-number>');
187
- }
188
- output(mustHavesCollect(phase));
189
- } else if (command === 'phase-info') {
190
- const phase = args[1];
191
- if (!phase) {
192
- error('Usage: pbr-tools.js phase-info <phase-number>');
193
- }
194
- output(phaseInfo(phase));
195
- } else if (command === 'roadmap' && subcommand === 'update-status') {
196
- const phase = args[2];
197
- const status = args[3];
198
- if (!phase || !status) {
199
- error('Usage: pbr-tools.js roadmap update-status <phase-number> <status>');
200
- }
201
- output(roadmapUpdateStatus(phase, status));
202
- } else if (command === 'roadmap' && subcommand === 'update-plans') {
203
- const phase = args[2];
204
- const complete = args[3];
205
- const total = args[4];
206
- if (!phase || complete === undefined || total === undefined) {
207
- error('Usage: pbr-tools.js roadmap update-plans <phase-number> <complete> <total>');
208
- }
209
- output(roadmapUpdatePlans(phase, complete, total));
210
- } else if (command === 'history' && subcommand === 'append') {
211
- const type = args[2]; // 'milestone' or 'phase'
212
- const title = args[3];
213
- const body = args[4] || '';
214
- if (!type || !title) {
215
- error('Usage: pbr-tools.js history append <milestone|phase> <title> [body]');
216
- }
217
- output(historyAppend({ type, title, body }));
218
- } else if (command === 'history' && subcommand === 'load') {
219
- output(historyLoad());
220
- } else if (command === 'event') {
221
- const category = args[1];
222
- const event = args[2];
223
- let details = {};
224
- if (args[3]) {
225
- try { details = JSON.parse(args[3]); } catch (_e) { details = { raw: args[3] }; }
226
- }
227
- if (!category || !event) {
228
- error('Usage: pbr-tools.js event <category> <event> [JSON-details]');
229
- }
230
- const { logEvent } = require('./event-logger');
231
- logEvent(category, event, details);
232
- output({ logged: true, category, event });
233
- } else {
234
- error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update, config validate, plan-index, frontmatter, must-haves, phase-info, roadmap update-status|update-plans, history append|load, event`);
235
- }
236
- } catch (e) {
237
- error(e.message);
238
- }
239
- }
240
-
241
- // --- Commands ---
242
-
243
- function stateLoad() {
244
- const result = {
245
- exists: false,
246
- config: null,
247
- state: null,
248
- roadmap: null,
249
- phase_count: 0,
250
- current_phase: null,
251
- progress: null
252
- };
253
-
254
- if (!fs.existsSync(planningDir)) {
255
- return result;
256
- }
257
- result.exists = true;
258
-
259
- // Load config.json
260
- const configPath = path.join(planningDir, 'config.json');
261
- if (fs.existsSync(configPath)) {
262
- try {
263
- result.config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
264
- } catch (_) {
265
- result.config = { _error: 'Failed to parse config.json' };
266
- }
267
- }
268
-
269
- // Load STATE.md
270
- const statePath = path.join(planningDir, 'STATE.md');
271
- if (fs.existsSync(statePath)) {
272
- const content = fs.readFileSync(statePath, 'utf8');
273
- result.state = parseStateMd(content);
274
- }
275
-
276
- // Load ROADMAP.md
277
- const roadmapPath = path.join(planningDir, 'ROADMAP.md');
278
- if (fs.existsSync(roadmapPath)) {
279
- const content = fs.readFileSync(roadmapPath, 'utf8');
280
- result.roadmap = parseRoadmapMd(content);
281
- result.phase_count = result.roadmap.phases.length;
282
- }
283
-
284
- // Extract current phase
285
- if (result.state && result.state.current_phase) {
286
- result.current_phase = result.state.current_phase;
287
- }
288
-
289
- // Calculate progress
290
- result.progress = calculateProgress();
291
-
292
- return result;
293
- }
294
-
295
- function stateCheckProgress() {
296
- const phasesDir = path.join(planningDir, 'phases');
297
- if (!fs.existsSync(phasesDir)) {
298
- return { phases: [], total_plans: 0, completed_plans: 0, percentage: 0 };
299
- }
300
-
301
- const phases = [];
302
- let totalPlans = 0;
303
- let completedPlans = 0;
304
-
305
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
306
- .filter(e => e.isDirectory())
307
- .sort((a, b) => a.name.localeCompare(b.name));
308
-
309
- for (const entry of entries) {
310
- const phaseDir = path.join(phasesDir, entry.name);
311
- const plans = findFiles(phaseDir, /-PLAN\.md$/);
312
- const summaries = findFiles(phaseDir, /^SUMMARY-.*\.md$/);
313
- const verification = fs.existsSync(path.join(phaseDir, 'VERIFICATION.md'));
314
-
315
- const completedSummaries = summaries.filter(s => {
316
- const content = fs.readFileSync(path.join(phaseDir, s), 'utf8');
317
- return /status:\s*["']?complete/i.test(content);
318
- });
319
-
320
- const phaseInfo = {
321
- directory: entry.name,
322
- plans: plans.length,
323
- summaries: summaries.length,
324
- completed: completedSummaries.length,
325
- has_verification: verification,
326
- status: determinePhaseStatus(plans.length, completedSummaries.length, summaries.length, verification, phaseDir)
327
- };
328
-
329
- phases.push(phaseInfo);
330
- totalPlans += plans.length;
331
- completedPlans += completedSummaries.length;
332
- }
333
-
334
- return {
335
- phases,
336
- total_plans: totalPlans,
337
- completed_plans: completedPlans,
338
- percentage: totalPlans > 0 ? Math.round((completedPlans / totalPlans) * 100) : 0
339
- };
340
- }
341
-
342
- function planIndex(phaseNum) {
343
- const phasesDir = path.join(planningDir, 'phases');
344
- if (!fs.existsSync(phasesDir)) {
345
- return { error: 'No phases directory found' };
346
- }
347
-
348
- // Find phase directory matching the number
349
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
350
- .filter(e => e.isDirectory());
351
-
352
- const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
353
- if (!phaseDir) {
354
- return { error: `No phase directory found matching phase ${phaseNum}` };
355
- }
356
-
357
- const fullDir = path.join(phasesDir, phaseDir.name);
358
- const planFiles = findFiles(fullDir, /-PLAN\.md$/);
359
-
360
- const plans = [];
361
- const waves = {};
362
-
363
- for (const file of planFiles) {
364
- const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
365
- const frontmatter = parseYamlFrontmatter(content);
366
-
367
- const plan = {
368
- file,
369
- plan_id: frontmatter.plan || file.replace(/-PLAN\.md$/, ''),
370
- wave: parseInt(frontmatter.wave, 10) || 1,
371
- type: frontmatter.type || 'unknown',
372
- autonomous: frontmatter.autonomous !== false,
373
- depends_on: frontmatter.depends_on || [],
374
- gap_closure: frontmatter.gap_closure || false,
375
- has_summary: fs.existsSync(path.join(fullDir, `SUMMARY-${frontmatter.plan || ''}.md`)),
376
- must_haves_count: countMustHaves(frontmatter.must_haves)
377
- };
378
-
379
- plans.push(plan);
380
-
381
- const waveKey = `wave_${plan.wave}`;
382
- if (!waves[waveKey]) waves[waveKey] = [];
383
- waves[waveKey].push(plan.plan_id);
384
- }
385
-
386
- return {
387
- phase: phaseDir.name,
388
- total_plans: plans.length,
389
- plans,
390
- waves
391
- };
392
- }
393
-
394
- function configValidate(preloadedConfig) {
395
- let config;
396
- if (preloadedConfig) {
397
- config = preloadedConfig;
398
- } else {
399
- const configPath = path.join(planningDir, 'config.json');
400
- if (!fs.existsSync(configPath)) {
401
- return { valid: false, errors: ['config.json not found'], warnings: [] };
402
- }
403
-
404
- try {
405
- config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
406
- } catch (e) {
407
- return { valid: false, errors: [`config.json is not valid JSON: ${e.message}`], warnings: [] };
408
- }
409
- }
410
-
411
- const schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'config-schema.json'), 'utf8'));
412
- const warnings = [];
413
- const errors = [];
414
-
415
- validateObject(config, schema, '', errors, warnings);
416
-
417
- // Semantic conflict detection — logical contradictions that pass schema validation
418
- if (config.mode === 'autonomous' && config.gates) {
419
- const activeGates = Object.entries(config.gates || {}).filter(([, v]) => v === true).map(([k]) => k);
420
- if (activeGates.length > 0) {
421
- warnings.push(`mode=autonomous with active gates (${activeGates.join(', ')}): gates are unreachable in autonomous mode`);
422
- }
423
- }
424
- if (config.features && config.features.auto_continue && config.mode === 'interactive') {
425
- warnings.push('features.auto_continue=true with mode=interactive: auto_continue only fires in autonomous mode');
426
- }
427
- if (config.parallelization) {
428
- if (config.parallelization.enabled === false && config.parallelization.plan_level === true) {
429
- warnings.push('parallelization.enabled=false with plan_level=true: plan_level is ignored when parallelization is disabled');
430
- }
431
- if (config.parallelization.max_concurrent_agents === 1 && config.teams && config.teams.coordination) {
432
- warnings.push('parallelization.max_concurrent_agents=1 with teams.coordination set: teams require concurrent agents to be useful');
433
- }
434
- }
435
-
436
- return {
437
- valid: errors.length === 0,
438
- errors,
439
- warnings
440
- };
441
- }
442
-
443
- // --- New read-only commands ---
444
-
445
- /**
446
- * Parse a markdown file's YAML frontmatter and return as JSON.
447
- * Wraps parseYamlFrontmatter() + parseMustHaves().
448
- */
449
- function frontmatter(filePath) {
450
- const resolved = path.resolve(filePath);
451
- if (!fs.existsSync(resolved)) {
452
- return { error: `File not found: ${resolved}` };
453
- }
454
- const content = fs.readFileSync(resolved, 'utf8');
455
- return parseYamlFrontmatter(content);
456
- }
457
-
458
- /**
459
- * Collect all must-haves from all PLAN.md files in a phase.
460
- * Returns per-plan grouping + flat deduplicated list + total count.
461
- */
462
- function mustHavesCollect(phaseNum) {
463
- const phasesDir = path.join(planningDir, 'phases');
464
- if (!fs.existsSync(phasesDir)) {
465
- return { error: 'No phases directory found' };
466
- }
467
-
468
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
469
- .filter(e => e.isDirectory());
470
- const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
471
- if (!phaseDir) {
472
- return { error: `No phase directory found matching phase ${phaseNum}` };
473
- }
474
-
475
- const fullDir = path.join(phasesDir, phaseDir.name);
476
- const planFiles = findFiles(fullDir, /-PLAN\.md$/);
477
-
478
- const perPlan = {};
479
- const allTruths = new Set();
480
- const allArtifacts = new Set();
481
- const allKeyLinks = new Set();
482
-
483
- for (const file of planFiles) {
484
- const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
485
- const fm = parseYamlFrontmatter(content);
486
- const planId = fm.plan || file.replace(/-PLAN\.md$/, '');
487
- const mh = fm.must_haves || { truths: [], artifacts: [], key_links: [] };
488
-
489
- perPlan[planId] = mh;
490
- (mh.truths || []).forEach(t => allTruths.add(t));
491
- (mh.artifacts || []).forEach(a => allArtifacts.add(a));
492
- (mh.key_links || []).forEach(k => allKeyLinks.add(k));
493
- }
494
-
495
- const all = {
496
- truths: [...allTruths],
497
- artifacts: [...allArtifacts],
498
- key_links: [...allKeyLinks]
499
- };
500
-
501
- return {
502
- phase: phaseDir.name,
503
- plans: perPlan,
504
- all,
505
- total: all.truths.length + all.artifacts.length + all.key_links.length
506
- };
507
- }
508
-
509
- /**
510
- * Comprehensive single-phase status combining roadmap, filesystem, and plan data.
511
- */
512
- function phaseInfo(phaseNum) {
513
- const phasesDir = path.join(planningDir, 'phases');
514
- if (!fs.existsSync(phasesDir)) {
515
- return { error: 'No phases directory found' };
516
- }
517
-
518
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
519
- .filter(e => e.isDirectory());
520
- const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
521
- if (!phaseDir) {
522
- return { error: `No phase directory found matching phase ${phaseNum}` };
523
- }
524
-
525
- const fullDir = path.join(phasesDir, phaseDir.name);
526
-
527
- // Get roadmap info
528
- let roadmapInfo = null;
529
- const roadmapPath = path.join(planningDir, 'ROADMAP.md');
530
- if (fs.existsSync(roadmapPath)) {
531
- const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
532
- const roadmap = parseRoadmapMd(roadmapContent);
533
- roadmapInfo = roadmap.phases.find(p => p.number === phaseNum.padStart(2, '0')) || null;
534
- }
535
-
536
- // Get plan index
537
- const plans = planIndex(phaseNum);
538
-
539
- // Check for verification
540
- const verificationPath = path.join(fullDir, 'VERIFICATION.md');
541
- let verification = null;
542
- if (fs.existsSync(verificationPath)) {
543
- const vContent = fs.readFileSync(verificationPath, 'utf8');
544
- verification = parseYamlFrontmatter(vContent);
545
- }
546
-
547
- // Check summaries
548
- const summaryFiles = findFiles(fullDir, /^SUMMARY-.*\.md$/);
549
- const summaries = summaryFiles.map(f => {
550
- const content = fs.readFileSync(path.join(fullDir, f), 'utf8');
551
- const fm = parseYamlFrontmatter(content);
552
- return { file: f, plan: fm.plan || f.replace(/^SUMMARY-|\.md$/g, ''), status: fm.status || 'unknown' };
553
- });
554
-
555
- // Determine filesystem status
556
- const planCount = plans.total_plans || 0;
557
- const completedCount = summaries.filter(s => s.status === 'complete').length;
558
- const hasVerification = fs.existsSync(verificationPath);
559
- const fsStatus = determinePhaseStatus(planCount, completedCount, summaryFiles.length, hasVerification, fullDir);
560
-
561
- return {
562
- phase: phaseDir.name,
563
- name: roadmapInfo ? roadmapInfo.name : phaseDir.name.replace(/^\d+-/, ''),
564
- goal: roadmapInfo ? roadmapInfo.goal : null,
565
- roadmap_status: roadmapInfo ? roadmapInfo.status : null,
566
- filesystem_status: fsStatus,
567
- plans: plans.plans || [],
568
- plan_count: planCount,
569
- summaries,
570
- completed: completedCount,
571
- verification,
572
- has_context: fs.existsSync(path.join(fullDir, 'CONTEXT.md'))
573
- };
574
- }
575
-
576
- // --- Mutation commands ---
577
-
578
- /**
579
- * Atomically update a field in STATE.md using lockedFileUpdate.
580
- * Supports both legacy and frontmatter (v2) formats.
581
- *
582
- * @param {string} field - One of: current_phase, status, plans_complete, last_activity
583
- * @param {string} value - New value (use 'now' for last_activity to auto-timestamp)
584
- */
585
- function stateUpdate(field, value) {
586
- const statePath = path.join(planningDir, 'STATE.md');
587
- if (!fs.existsSync(statePath)) {
588
- return { success: false, error: 'STATE.md not found' };
589
- }
590
-
591
- const validFields = ['current_phase', 'status', 'plans_complete', 'last_activity'];
592
- if (!validFields.includes(field)) {
593
- return { success: false, error: `Invalid field: ${field}. Valid fields: ${validFields.join(', ')}` };
594
- }
595
-
596
- // Auto-timestamp
597
- if (field === 'last_activity' && value === 'now') {
598
- value = new Date().toISOString().slice(0, 19).replace('T', ' ');
599
- }
600
-
601
- const result = lockedFileUpdate(statePath, (content) => {
602
- const fm = parseYamlFrontmatter(content);
603
- if (fm.version === 2 || fm.current_phase !== undefined) {
604
- return updateFrontmatterField(content, field, value);
605
- }
606
- return updateLegacyStateField(content, field, value);
607
- });
608
-
609
- if (result.success) {
610
- return { success: true, field, value };
611
- }
612
- return { success: false, error: result.error };
613
- }
614
-
615
- /**
616
- * Append a record to HISTORY.md. Creates the file if it doesn't exist.
617
- * Each entry is a markdown section appended at the end.
618
- *
619
- * @param {object} entry - { type: 'milestone'|'phase', title: string, body: string }
620
- * @param {string} [dir] - Path to .planning directory (defaults to cwd/.planning)
621
- * @returns {{success: boolean, error?: string}}
622
- */
623
- function historyAppend(entry, dir) {
624
- const historyPath = path.join(dir || planningDir, 'HISTORY.md');
625
- const timestamp = new Date().toISOString().slice(0, 10);
626
-
627
- let header = '';
628
- if (!fs.existsSync(historyPath)) {
629
- header = '# Project History\n\nCompleted milestones and phase records. This file is append-only.\n\n';
630
- }
631
-
632
- const section = `${header}## ${entry.type === 'milestone' ? 'Milestone' : 'Phase'}: ${entry.title}\n_Completed: ${timestamp}_\n\n${entry.body.trim()}\n\n---\n\n`;
633
-
634
- try {
635
- fs.appendFileSync(historyPath, section, 'utf8');
636
- return { success: true };
637
- } catch (e) {
638
- return { success: false, error: e.message };
639
- }
640
- }
641
-
642
- /**
643
- * Load HISTORY.md and parse it into structured records.
644
- * Returns null if HISTORY.md doesn't exist.
645
- *
646
- * @param {string} [dir] - Path to .planning directory
647
- * @returns {object|null} { records: [{type, title, date, body}], line_count }
648
- */
649
- function historyLoad(dir) {
650
- const historyPath = path.join(dir || planningDir, 'HISTORY.md');
651
- if (!fs.existsSync(historyPath)) return null;
652
-
653
- const content = fs.readFileSync(historyPath, 'utf8');
654
- const records = [];
655
- const sectionRegex = /^## (Milestone|Phase): (.+)\n_Completed: (\d{4}-\d{2}-\d{2})_\n\n([\s\S]*?)(?=\n---|\s*$)/gm;
656
-
657
- let match;
658
- while ((match = sectionRegex.exec(content)) !== null) {
659
- records.push({
660
- type: match[1].toLowerCase(),
661
- title: match[2].trim(),
662
- date: match[3],
663
- body: match[4].trim()
664
- });
665
- }
666
-
667
- return {
668
- records,
669
- line_count: content.split('\n').length
670
- };
671
- }
672
-
673
- /**
674
- * Update the Status column for a phase in ROADMAP.md's Phase Overview table.
675
- */
676
- function roadmapUpdateStatus(phaseNum, newStatus) {
677
- const roadmapPath = path.join(planningDir, 'ROADMAP.md');
678
- if (!fs.existsSync(roadmapPath)) {
679
- return { success: false, error: 'ROADMAP.md not found' };
680
- }
681
-
682
- let oldStatus = null;
683
-
684
- const result = lockedFileUpdate(roadmapPath, (content) => {
685
- const lines = content.split('\n');
686
- const rowIdx = findRoadmapRow(lines, phaseNum);
687
- if (rowIdx === -1) {
688
- return content; // No matching row found
689
- }
690
- const parts = lines[rowIdx].split('|');
691
- oldStatus = parts[6] ? parts[6].trim() : 'unknown';
692
- lines[rowIdx] = updateTableRow(lines[rowIdx], 5, newStatus);
693
- return lines.join('\n');
694
- });
695
-
696
- if (!oldStatus) {
697
- return { success: false, error: `Phase ${phaseNum} not found in ROADMAP.md table` };
698
- }
699
-
700
- if (result.success) {
701
- return { success: true, old_status: oldStatus, new_status: newStatus };
702
- }
703
- return { success: false, error: result.error };
704
- }
705
-
706
- /**
707
- * Update the Plans column for a phase in ROADMAP.md's Phase Overview table.
708
- */
709
- function roadmapUpdatePlans(phaseNum, complete, total) {
710
- const roadmapPath = path.join(planningDir, 'ROADMAP.md');
711
- if (!fs.existsSync(roadmapPath)) {
712
- return { success: false, error: 'ROADMAP.md not found' };
713
- }
714
-
715
- let oldPlans = null;
716
- const newPlans = `${complete}/${total}`;
717
-
718
- const result = lockedFileUpdate(roadmapPath, (content) => {
719
- const lines = content.split('\n');
720
- const rowIdx = findRoadmapRow(lines, phaseNum);
721
- if (rowIdx === -1) {
722
- return content;
723
- }
724
- const parts = lines[rowIdx].split('|');
725
- oldPlans = parts[4] ? parts[4].trim() : 'unknown';
726
- lines[rowIdx] = updateTableRow(lines[rowIdx], 3, newPlans);
727
- return lines.join('\n');
728
- });
729
-
730
- if (!oldPlans) {
731
- return { success: false, error: `Phase ${phaseNum} not found in ROADMAP.md table` };
732
- }
733
-
734
- if (result.success) {
735
- return { success: true, old_plans: oldPlans, new_plans: newPlans };
736
- }
737
- return { success: false, error: result.error };
738
- }
739
-
740
- // --- Mutation helpers ---
741
-
742
- /**
743
- * Update a field in legacy (non-frontmatter) STATE.md content.
744
- * Pure function: content in, content out.
745
- */
746
- function updateLegacyStateField(content, field, value) {
747
- const lines = content.split('\n');
748
-
749
- switch (field) {
750
- case 'current_phase': {
751
- const idx = lines.findIndex(l => /Phase:\s*\d+\s+of\s+\d+/.test(l));
752
- if (idx !== -1) {
753
- lines[idx] = lines[idx].replace(/(Phase:\s*)\d+/, `$1${value}`);
754
- }
755
- break;
756
- }
757
- case 'status': {
758
- const idx = lines.findIndex(l => /^Status:/i.test(l));
759
- if (idx !== -1) {
760
- lines[idx] = `Status: ${value}`;
761
- } else {
762
- const phaseIdx = lines.findIndex(l => /Phase:/.test(l));
763
- if (phaseIdx !== -1) {
764
- lines.splice(phaseIdx + 1, 0, `Status: ${value}`);
765
- } else {
766
- lines.push(`Status: ${value}`);
767
- }
768
- }
769
- break;
770
- }
771
- case 'plans_complete': {
772
- const idx = lines.findIndex(l => /Plan:\s*\d+\s+of\s+\d+/.test(l));
773
- if (idx !== -1) {
774
- lines[idx] = lines[idx].replace(/(Plan:\s*)\d+/, `$1${value}`);
775
- }
776
- break;
777
- }
778
- case 'last_activity': {
779
- const idx = lines.findIndex(l => /^Last Activity:/i.test(l));
780
- if (idx !== -1) {
781
- lines[idx] = `Last Activity: ${value}`;
782
- } else {
783
- const statusIdx = lines.findIndex(l => /^Status:/i.test(l));
784
- if (statusIdx !== -1) {
785
- lines.splice(statusIdx + 1, 0, `Last Activity: ${value}`);
786
- } else {
787
- lines.push(`Last Activity: ${value}`);
788
- }
789
- }
790
- break;
791
- }
792
- }
793
-
794
- return lines.join('\n');
795
- }
796
-
797
- /**
798
- * Update a field in YAML frontmatter content.
799
- * Pure function: content in, content out.
800
- */
801
- function updateFrontmatterField(content, field, value) {
802
- const match = content.match(/^(---\s*\n)([\s\S]*?)(\n---)/);
803
- if (!match) return content;
804
-
805
- const before = match[1];
806
- let yaml = match[2];
807
- const after = match[3];
808
- const rest = content.slice(match[0].length);
809
-
810
- // Format value: integers stay bare, strings get quotes
811
- const isNum = /^\d+$/.test(String(value));
812
- const formatted = isNum ? value : `"${value}"`;
813
-
814
- const fieldRegex = new RegExp(`^(${field})\\s*:.*$`, 'm');
815
- if (fieldRegex.test(yaml)) {
816
- yaml = yaml.replace(fieldRegex, `${field}: ${formatted}`);
817
- } else {
818
- yaml = yaml + `\n${field}: ${formatted}`;
819
- }
820
-
821
- return before + yaml + after + rest;
822
- }
823
-
824
- /**
825
- * Find the row index of a phase in a ROADMAP.md table.
826
- * @returns {number} Line index or -1 if not found
827
- */
828
- function findRoadmapRow(lines, phaseNum) {
829
- const paddedPhase = phaseNum.padStart(2, '0');
830
- for (let i = 0; i < lines.length; i++) {
831
- if (!lines[i].includes('|')) continue;
832
- const parts = lines[i].split('|');
833
- if (parts.length < 3) continue;
834
- const phaseCol = parts[1] ? parts[1].trim() : '';
835
- if (phaseCol === paddedPhase) {
836
- return i;
837
- }
838
- }
839
- return -1;
840
- }
841
-
842
- /**
843
- * Update a specific column in a markdown table row.
844
- * @param {string} row - The full table row string (e.g., "| 01 | Setup | ... |")
845
- * @param {number} columnIndex - 0-based column index (Phase=0, Name=1, ..., Status=5)
846
- * @param {string} newValue - New cell value
847
- * @returns {string} Updated row
848
- */
849
- function updateTableRow(row, columnIndex, newValue) {
850
- const parts = row.split('|');
851
- // parts[0] is empty (before first |), data starts at parts[1]
852
- const partIndex = columnIndex + 1;
853
- if (partIndex < parts.length) {
854
- parts[partIndex] = ` ${newValue} `;
855
- }
856
- return parts.join('|');
857
- }
858
-
859
- /**
860
- * Lightweight JSON Schema validator — supports type, enum, properties,
861
- * additionalProperties, minimum, maximum for the config schema.
862
- */
863
- function validateObject(value, schema, prefix, errors, warnings) {
864
- if (schema.type && typeof value !== schema.type) {
865
- if (!(schema.type === 'integer' && typeof value === 'number' && Number.isInteger(value))) {
866
- errors.push(`${prefix || 'root'}: expected ${schema.type}, got ${typeof value}`);
867
- return;
868
- }
869
- }
870
-
871
- if (schema.enum && !schema.enum.includes(value)) {
872
- errors.push(`${prefix || 'root'}: value "${value}" not in allowed values [${schema.enum.join(', ')}]`);
873
- return;
874
- }
875
-
876
- if (schema.minimum !== undefined && value < schema.minimum) {
877
- errors.push(`${prefix || 'root'}: value ${value} is below minimum ${schema.minimum}`);
878
- }
879
- if (schema.maximum !== undefined && value > schema.maximum) {
880
- errors.push(`${prefix || 'root'}: value ${value} is above maximum ${schema.maximum}`);
881
- }
882
-
883
- if (schema.type === 'object' && schema.properties) {
884
- const knownKeys = new Set(Object.keys(schema.properties));
885
-
886
- for (const key of Object.keys(value)) {
887
- const fullKey = prefix ? `${prefix}.${key}` : key;
888
- if (!knownKeys.has(key)) {
889
- if (schema.additionalProperties === false) {
890
- warnings.push(`${fullKey}: unrecognized key (possible typo?)`);
891
- }
892
- continue;
893
- }
894
- validateObject(value[key], schema.properties[key], fullKey, errors, warnings);
895
- }
896
- }
897
- }
898
-
899
- /**
900
- * Locked file update: read-modify-write with exclusive lockfile.
901
- * Prevents concurrent writes to STATE.md and ROADMAP.md.
902
- *
903
- * @param {string} filePath - Absolute path to the file to update
904
- * @param {function} updateFn - Receives current content, returns new content
905
- * @param {object} opts - Options: { retries: 3, retryDelayMs: 100, timeoutMs: 5000 }
906
- * @returns {object} { success, content?, error? }
907
- */
908
- function lockedFileUpdate(filePath, updateFn, opts = {}) {
909
- const retries = opts.retries || 3;
910
- const retryDelayMs = opts.retryDelayMs || 100;
911
- const timeoutMs = opts.timeoutMs || 5000;
912
- const lockPath = filePath + '.lock';
913
-
914
- let lockFd = null;
915
- let lockAcquired = false;
916
-
917
- try {
918
- // Acquire lock with retries
919
- for (let attempt = 0; attempt < retries; attempt++) {
920
- try {
921
- lockFd = fs.openSync(lockPath, 'wx');
922
- lockAcquired = true;
923
- break;
924
- } catch (e) {
925
- if (e.code === 'EEXIST') {
926
- // Lock exists — check if stale (older than timeoutMs)
927
- try {
928
- const stats = fs.statSync(lockPath);
929
- if (Date.now() - stats.mtimeMs > timeoutMs) {
930
- // Stale lock — remove and retry
931
- fs.unlinkSync(lockPath);
932
- continue;
933
- }
934
- } catch (_statErr) {
935
- // Lock disappeared between check — retry
936
- continue;
937
- }
938
-
939
- if (attempt < retries - 1) {
940
- // Wait and retry
941
- const waitMs = retryDelayMs * (attempt + 1);
942
- const start = Date.now();
943
- while (Date.now() - start < waitMs) {
944
- // Busy wait (synchronous context)
945
- }
946
- continue;
947
- }
948
- return { success: false, error: `Could not acquire lock for ${path.basename(filePath)} after ${retries} attempts` };
949
- }
950
- throw e;
951
- }
952
- }
953
-
954
- if (!lockAcquired) {
955
- return { success: false, error: `Could not acquire lock for ${path.basename(filePath)}` };
956
- }
957
-
958
- // Write PID to lock file for debugging
959
- fs.writeSync(lockFd, `${process.pid}`);
960
- fs.closeSync(lockFd);
961
- lockFd = null;
962
-
963
- // Read current content
964
- let content = '';
965
- if (fs.existsSync(filePath)) {
966
- content = fs.readFileSync(filePath, 'utf8');
967
- }
968
-
969
- // Apply update
970
- const newContent = updateFn(content);
971
-
972
- // Write back atomically
973
- const writeResult = atomicWrite(filePath, newContent);
974
- if (!writeResult.success) {
975
- return { success: false, error: writeResult.error };
976
- }
977
-
978
- return { success: true, content: newContent };
979
- } catch (e) {
980
- return { success: false, error: e.message };
981
- } finally {
982
- // Close fd if still open
983
- try {
984
- if (lockFd !== null) fs.closeSync(lockFd);
985
- } catch (_e) { /* ignore */ }
986
- // Only release lock if we acquired it
987
- if (lockAcquired) {
988
- try {
989
- fs.unlinkSync(lockPath);
990
- } catch (_e) { /* ignore — may already be cleaned up */ }
991
- }
992
- }
993
- }
994
-
995
- // --- Parsers ---
996
-
997
- function parseStateMd(content) {
998
- const result = {
999
- current_phase: null,
1000
- phase_name: null,
1001
- progress: null,
1002
- status: null,
1003
- line_count: content.split('\n').length,
1004
- format: 'legacy' // 'legacy' or 'frontmatter'
1005
- };
1006
-
1007
- // Check for YAML frontmatter (version 2 format)
1008
- const frontmatter = parseYamlFrontmatter(content);
1009
- if (frontmatter.version === 2 || frontmatter.current_phase !== undefined) {
1010
- result.format = 'frontmatter';
1011
- result.current_phase = frontmatter.current_phase || null;
1012
- result.total_phases = frontmatter.total_phases || null;
1013
- result.phase_name = frontmatter.phase_slug || frontmatter.phase_name || null;
1014
- result.status = frontmatter.status || null;
1015
- result.progress = frontmatter.progress_percent !== undefined ? frontmatter.progress_percent : null;
1016
- result.plans_total = frontmatter.plans_total || null;
1017
- result.plans_complete = frontmatter.plans_complete || null;
1018
- result.last_activity = frontmatter.last_activity || null;
1019
- result.last_command = frontmatter.last_command || null;
1020
- result.blockers = frontmatter.blockers || [];
1021
- return result;
1022
- }
1023
-
1024
- // Legacy regex-based parsing (version 1 format, no frontmatter)
1025
- // Extract "Phase: N of M"
1026
- const phaseMatch = content.match(/Phase:\s*(\d+)\s+of\s+(\d+)/);
1027
- if (phaseMatch) {
1028
- result.current_phase = parseInt(phaseMatch[1], 10);
1029
- result.total_phases = parseInt(phaseMatch[2], 10);
1030
- }
1031
-
1032
- // Extract phase name (line after "Phase:")
1033
- const nameMatch = content.match(/--\s+(.+?)(?:\n|$)/);
1034
- if (nameMatch) {
1035
- result.phase_name = nameMatch[1].trim();
1036
- }
1037
-
1038
- // Extract progress percentage
1039
- const progressMatch = content.match(/(\d+)%/);
1040
- if (progressMatch) {
1041
- result.progress = parseInt(progressMatch[1], 10);
1042
- }
1043
-
1044
- // Extract plan status
1045
- const statusMatch = content.match(/Status:\s*(.+?)(?:\n|$)/i);
1046
- if (statusMatch) {
1047
- result.status = statusMatch[1].trim();
1048
- }
1049
-
1050
- return result;
1051
- }
1052
-
1053
- function parseRoadmapMd(content) {
1054
- const result = { phases: [], has_progress_table: false };
1055
-
1056
- // Find Phase Overview table
1057
- const overviewMatch = content.match(/## Phase Overview[\s\S]*?\|[\s\S]*?(?=\n##|\s*$)/);
1058
- if (overviewMatch) {
1059
- const rows = overviewMatch[0].split('\n').filter(r => r.includes('|'));
1060
- // Skip header and separator rows
1061
- for (let i = 2; i < rows.length; i++) {
1062
- const cols = rows[i].split('|').map(c => c.trim()).filter(Boolean);
1063
- if (cols.length >= 3) {
1064
- result.phases.push({
1065
- number: cols[0],
1066
- name: cols[1],
1067
- goal: cols[2],
1068
- plans: cols[3] || '',
1069
- wave: cols[4] || '',
1070
- status: cols[5] || 'pending'
1071
- });
1072
- }
1073
- }
1074
- }
1075
-
1076
- // Check for Progress table
1077
- result.has_progress_table = /## Progress/.test(content);
1078
-
1079
- return result;
1080
- }
1081
-
1082
- function parseYamlFrontmatter(content) {
1083
- const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
1084
- if (!match) return {};
1085
-
1086
- const yaml = match[1];
1087
- const result = {};
1088
-
1089
- // Simple YAML parser for flat and basic nested values
1090
- const lines = yaml.split('\n');
1091
- let currentKey = null;
1092
-
1093
- for (const line of lines) {
1094
- // Array item
1095
- if (/^\s+-\s+/.test(line) && currentKey) {
1096
- const val = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
1097
- if (!result[currentKey]) result[currentKey] = [];
1098
- if (Array.isArray(result[currentKey])) {
1099
- result[currentKey].push(val);
1100
- }
1101
- continue;
1102
- }
1103
-
1104
- // Key-value pair
1105
- const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.*)/);
1106
- if (kvMatch) {
1107
- currentKey = kvMatch[1];
1108
- let val = kvMatch[2].trim();
1109
-
1110
- if (val === '' || val === '|') {
1111
- // Possible array or block follows
1112
- continue;
1113
- }
1114
-
1115
- // Handle arrays on same line: [a, b, c]
1116
- if (val.startsWith('[') && val.endsWith(']')) {
1117
- result[currentKey] = val.slice(1, -1).split(',')
1118
- .map(v => v.trim().replace(/^["']|["']$/g, ''))
1119
- .filter(Boolean);
1120
- continue;
1121
- }
1122
-
1123
- // Clean quotes
1124
- val = val.replace(/^["']|["']$/g, '');
1125
-
1126
- // Type coercion
1127
- if (val === 'true') val = true;
1128
- else if (val === 'false') val = false;
1129
- else if (/^\d+$/.test(val)) val = parseInt(val, 10);
1130
-
1131
- result[currentKey] = val;
1132
- }
1133
- }
1134
-
1135
- // Handle must_haves as a nested object
1136
- if (yaml.includes('must_haves:')) {
1137
- result.must_haves = parseMustHaves(yaml);
1138
- }
1139
-
1140
- return result;
1141
- }
1142
-
1143
- function parseMustHaves(yaml) {
1144
- const result = { truths: [], artifacts: [], key_links: [] };
1145
- let section = null;
1146
-
1147
- const inMustHaves = yaml.split('\n');
1148
- let collecting = false;
1149
-
1150
- for (const line of inMustHaves) {
1151
- if (/^\s*must_haves:/.test(line)) {
1152
- collecting = true;
1153
- continue;
1154
- }
1155
- if (collecting) {
1156
- if (/^\s{2}truths:/.test(line)) { section = 'truths'; continue; }
1157
- if (/^\s{2}artifacts:/.test(line)) { section = 'artifacts'; continue; }
1158
- if (/^\s{2}key_links:/.test(line)) { section = 'key_links'; continue; }
1159
- if (/^\w/.test(line)) break; // New top-level key, stop
1160
-
1161
- if (section && /^\s+-\s+/.test(line)) {
1162
- result[section].push(line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, ''));
1163
- }
1164
- }
1165
- }
1166
-
1167
- return result;
1168
- }
1169
-
1170
- // --- Helpers ---
1171
-
1172
- function findFiles(dir, pattern) {
1173
- try {
1174
- return fs.readdirSync(dir).filter(f => pattern.test(f)).sort();
1175
- } catch (_) {
1176
- return [];
1177
- }
1178
- }
1179
-
1180
- function determinePhaseStatus(planCount, completedCount, summaryCount, hasVerification, phaseDir) {
1181
- if (planCount === 0) {
1182
- // Check for CONTEXT.md (discussed only)
1183
- if (fs.existsSync(path.join(phaseDir, 'CONTEXT.md'))) return 'discussed';
1184
- return 'not_started';
1185
- }
1186
- if (completedCount === 0 && summaryCount === 0) return 'planned';
1187
- if (completedCount < planCount) return 'building';
1188
- if (!hasVerification) return 'built';
1189
- // Check verification status
1190
- try {
1191
- const vContent = fs.readFileSync(path.join(phaseDir, 'VERIFICATION.md'), 'utf8');
1192
- if (/status:\s*["']?passed/i.test(vContent)) return 'verified';
1193
- if (/status:\s*["']?gaps_found/i.test(vContent)) return 'needs_fixes';
1194
- return 'reviewed';
1195
- } catch (_) {
1196
- return 'built';
1197
- }
1198
- }
1199
-
1200
- function countMustHaves(mustHaves) {
1201
- if (!mustHaves) return 0;
1202
- return (mustHaves.truths || []).length +
1203
- (mustHaves.artifacts || []).length +
1204
- (mustHaves.key_links || []).length;
1205
- }
1206
-
1207
- function calculateProgress() {
1208
- const phasesDir = path.join(planningDir, 'phases');
1209
- if (!fs.existsSync(phasesDir)) {
1210
- return { total: 0, completed: 0, percentage: 0 };
1211
- }
1212
-
1213
- let total = 0;
1214
- let completed = 0;
1215
-
1216
- const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
1217
- .filter(e => e.isDirectory());
1218
-
1219
- for (const entry of entries) {
1220
- const dir = path.join(phasesDir, entry.name);
1221
- const plans = findFiles(dir, /-PLAN\.md$/);
1222
- total += plans.length;
1223
-
1224
- const summaries = findFiles(dir, /^SUMMARY-.*\.md$/);
1225
- for (const s of summaries) {
1226
- const content = fs.readFileSync(path.join(dir, s), 'utf8');
1227
- if (/status:\s*["']?complete/i.test(content)) completed++;
1228
- }
1229
- }
1230
-
1231
- return {
1232
- total,
1233
- completed,
1234
- percentage: total > 0 ? Math.round((completed / total) * 100) : 0
1235
- };
1236
- }
1237
-
1238
- function output(data) {
1239
- process.stdout.write(JSON.stringify(data, null, 2));
1240
- process.exit(0);
1241
- }
1242
-
1243
- function error(msg) {
1244
- process.stdout.write(JSON.stringify({ error: msg }));
1245
- process.exit(1);
1246
- }
1247
-
1248
- /**
1249
- * Write content to a file atomically: write to .tmp, backup original to .bak,
1250
- * rename .tmp over original. On failure, restore from .bak if available.
1251
- *
1252
- * @param {string} filePath - Target file path
1253
- * @param {string} content - Content to write
1254
- * @returns {{success: boolean, error?: string}} Result
1255
- */
1256
- function atomicWrite(filePath, content) {
1257
- const tmpPath = filePath + '.tmp';
1258
- const bakPath = filePath + '.bak';
1259
-
1260
- try {
1261
- // 1. Write to temp file
1262
- fs.writeFileSync(tmpPath, content, 'utf8');
1263
-
1264
- // 2. Backup original if it exists
1265
- if (fs.existsSync(filePath)) {
1266
- try {
1267
- fs.copyFileSync(filePath, bakPath);
1268
- } catch (_e) {
1269
- // Backup failure is non-fatal — proceed with rename
1270
- }
1271
- }
1272
-
1273
- // 3. Rename temp over original (atomic on most filesystems)
1274
- fs.renameSync(tmpPath, filePath);
1275
-
1276
- return { success: true };
1277
- } catch (e) {
1278
- // Rename failed — try to restore from backup
1279
- try {
1280
- if (fs.existsSync(bakPath)) {
1281
- fs.copyFileSync(bakPath, filePath);
1282
- }
1283
- } catch (_restoreErr) {
1284
- // Restore also failed — nothing more we can do
1285
- }
1286
-
1287
- // Clean up temp file if it still exists
1288
- try {
1289
- if (fs.existsSync(tmpPath)) {
1290
- fs.unlinkSync(tmpPath);
1291
- }
1292
- } catch (_cleanupErr) {
1293
- // Best-effort cleanup
1294
- }
1295
-
1296
- return { success: false, error: e.message };
1297
- }
1298
- }
1299
-
1300
- if (require.main === module) { main(); }
1301
- module.exports = { parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad };
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * pbr-tools.js — Structured JSON state operations for Plan-Build-Run skills.
5
+ *
6
+ * Provides read-only commands that return JSON, replacing LLM-based text parsing
7
+ * of STATE.md, ROADMAP.md, and config.json. Skills call this via:
8
+ * node ${CLAUDE_PLUGIN_ROOT}/scripts/pbr-tools.js <command> [args]
9
+ *
10
+ * Commands:
11
+ * state load — Full project state as JSON
12
+ * state check-progress — Recalculate progress from filesystem
13
+ * state update <f> <v> — Atomically update a STATE.md field
14
+ * config validate — Validate config.json against schema
15
+ * plan-index <phase> — Plan inventory for a phase, grouped by wave
16
+ * frontmatter <filepath> — Parse .md file's YAML frontmatter → JSON
17
+ * must-haves <phase> — Collect all must-haves from phase plans → JSON
18
+ * phase-info <phase> — Comprehensive single-phase status → JSON
19
+ * roadmap update-status <phase> <status> — Update phase status in ROADMAP.md
20
+ * roadmap update-plans <phase> <complete> <total> — Update phase plans in ROADMAP.md
21
+ * history append <type> <title> [body] — Append record to HISTORY.md
22
+ * history load — Load all HISTORY.md records as JSON
23
+ */
24
+
25
+ const fs = require('fs');
26
+ const path = require('path');
27
+
28
+ const cwd = process.cwd();
29
+ const planningDir = path.join(cwd, '.planning');
30
+
31
+ // --- Phase status transition state machine ---
32
+
33
+ /**
34
+ * Valid phase status transitions. Each key is a current status, and its value
35
+ * is an array of statuses that are legal to transition to. This is advisory —
36
+ * invalid transitions produce a stderr warning but are not blocked, to avoid
37
+ * breaking existing workflows.
38
+ *
39
+ * State machine:
40
+ * pending -> planned, skipped
41
+ * planned -> building
42
+ * building -> built, partial, needs_fixes
43
+ * built -> verified, needs_fixes
44
+ * partial -> building, needs_fixes
45
+ * verified -> building (re-execution)
46
+ * needs_fixes -> planned, building
47
+ * skipped -> pending (unskip)
48
+ */
49
+ const VALID_STATUS_TRANSITIONS = {
50
+ pending: ['planned', 'skipped'],
51
+ planned: ['building'],
52
+ building: ['built', 'partial', 'needs_fixes'],
53
+ built: ['verified', 'needs_fixes'],
54
+ partial: ['building', 'needs_fixes'],
55
+ verified: ['building'],
56
+ needs_fixes: ['planned', 'building'],
57
+ skipped: ['pending']
58
+ };
59
+
60
+ /**
61
+ * Check whether a phase status transition is valid according to the state machine.
62
+ * Returns { valid, warning? } — never blocks, only advises.
63
+ *
64
+ * @param {string} oldStatus - Current phase status
65
+ * @param {string} newStatus - Desired phase status
66
+ * @returns {{ valid: boolean, warning?: string }}
67
+ */
68
+ function validateStatusTransition(oldStatus, newStatus) {
69
+ const from = (oldStatus || '').trim().toLowerCase();
70
+ const to = (newStatus || '').trim().toLowerCase();
71
+
72
+ // If the status isn't changing, that's always fine
73
+ if (from === to) {
74
+ return { valid: true };
75
+ }
76
+
77
+ // If the old status is unknown to our map, we can't validate — allow it
78
+ if (!VALID_STATUS_TRANSITIONS[from]) {
79
+ return { valid: true };
80
+ }
81
+
82
+ const allowed = VALID_STATUS_TRANSITIONS[from];
83
+ if (allowed.includes(to)) {
84
+ return { valid: true };
85
+ }
86
+
87
+ return {
88
+ valid: false,
89
+ warning: `Suspicious status transition: "${from}" -> "${to}". Expected one of: [${allowed.join(', ')}]. Proceeding anyway (advisory).`
90
+ };
91
+ }
92
+
93
+ // --- Cached config loader ---
94
+
95
+ let _configCache = null;
96
+ let _configMtime = 0;
97
+ let _configPath = null;
98
+
99
+ /**
100
+ * Load config.json with in-process mtime-based caching.
101
+ * Returns the parsed config object, or null if not found / parse error.
102
+ * Cache invalidates when file mtime changes or path differs.
103
+ *
104
+ * @param {string} [dir] - Path to .planning directory (defaults to cwd/.planning)
105
+ * @returns {object|null} Parsed config or null
106
+ */
107
+ function configLoad(dir) {
108
+ const configPath = path.join(dir || planningDir, 'config.json');
109
+ try {
110
+ if (!fs.existsSync(configPath)) return null;
111
+ const stat = fs.statSync(configPath);
112
+ const mtime = stat.mtimeMs;
113
+ if (_configCache && mtime === _configMtime && configPath === _configPath) {
114
+ return _configCache;
115
+ }
116
+ _configCache = JSON.parse(fs.readFileSync(configPath, 'utf8'));
117
+ _configMtime = mtime;
118
+ _configPath = configPath;
119
+ return _configCache;
120
+ } catch (_e) {
121
+ return null;
122
+ }
123
+ }
124
+
125
+ /**
126
+ * Clear the configLoad() in-process cache.
127
+ * Useful in tests where multiple temp directories are used in rapid succession.
128
+ */
129
+ function configClearCache() {
130
+ _configCache = null;
131
+ _configMtime = 0;
132
+ _configPath = null;
133
+ }
134
+
135
+ /**
136
+ * Read the last N lines from a file efficiently.
137
+ * Reads the entire file but only parses (JSON.parse) the trailing entries.
138
+ * For JSONL files where full parsing is expensive, this avoids parsing
139
+ * all lines when you only need recent entries.
140
+ *
141
+ * @param {string} filePath - Absolute path to the file
142
+ * @param {number} n - Number of trailing lines to return
143
+ * @returns {string[]} Array of raw line strings (last n lines)
144
+ */
145
+ function tailLines(filePath, n) {
146
+ try {
147
+ if (!fs.existsSync(filePath)) return [];
148
+ const content = fs.readFileSync(filePath, 'utf8').trim();
149
+ if (!content) return [];
150
+ const lines = content.split('\n');
151
+ if (lines.length <= n) return lines;
152
+ return lines.slice(lines.length - n);
153
+ } catch (_e) {
154
+ return [];
155
+ }
156
+ }
157
+
158
+ /**
159
+ * Built-in depth profile defaults. These define the effective settings
160
+ * for each depth level. User config.depth_profiles overrides these.
161
+ */
162
+ const DEPTH_PROFILE_DEFAULTS = {
163
+ quick: {
164
+ 'features.research_phase': false,
165
+ 'features.plan_checking': false,
166
+ 'features.goal_verification': false,
167
+ 'features.inline_verify': false,
168
+ 'scan.mapper_count': 2,
169
+ 'scan.mapper_areas': ['tech', 'arch'],
170
+ 'debug.max_hypothesis_rounds': 3
171
+ },
172
+ standard: {
173
+ 'features.research_phase': true,
174
+ 'features.plan_checking': true,
175
+ 'features.goal_verification': true,
176
+ 'features.inline_verify': false,
177
+ 'scan.mapper_count': 4,
178
+ 'scan.mapper_areas': ['tech', 'arch', 'quality', 'concerns'],
179
+ 'debug.max_hypothesis_rounds': 5
180
+ },
181
+ comprehensive: {
182
+ 'features.research_phase': true,
183
+ 'features.plan_checking': true,
184
+ 'features.goal_verification': true,
185
+ 'features.inline_verify': true,
186
+ 'scan.mapper_count': 4,
187
+ 'scan.mapper_areas': ['tech', 'arch', 'quality', 'concerns'],
188
+ 'debug.max_hypothesis_rounds': 10
189
+ }
190
+ };
191
+
192
+ /**
193
+ * Resolve the effective depth profile for the current config.
194
+ * Merges built-in defaults with any user overrides from config.depth_profiles.
195
+ *
196
+ * @param {object|null} config - Parsed config.json (from configLoad). If null, returns 'standard' defaults.
197
+ * @returns {{ depth: string, profile: object }} The resolved depth name and flattened profile settings.
198
+ */
199
+ function resolveDepthProfile(config) {
200
+ const depth = (config && config.depth) || 'standard';
201
+ const defaults = DEPTH_PROFILE_DEFAULTS[depth] || DEPTH_PROFILE_DEFAULTS.standard;
202
+
203
+ // Merge user overrides if present
204
+ const userOverrides = (config && config.depth_profiles && config.depth_profiles[depth]) || {};
205
+ const profile = { ...defaults, ...userOverrides };
206
+
207
+ return { depth, profile };
208
+ }
209
+
210
+ function main() {
211
+ const args = process.argv.slice(2);
212
+ const command = args[0];
213
+ const subcommand = args[1];
214
+
215
+ try {
216
+ if (command === 'state' && subcommand === 'load') {
217
+ output(stateLoad());
218
+ } else if (command === 'state' && subcommand === 'check-progress') {
219
+ output(stateCheckProgress());
220
+ } else if (command === 'state' && subcommand === 'update') {
221
+ const field = args[2];
222
+ const value = args[3];
223
+ if (!field || value === undefined) {
224
+ error('Usage: pbr-tools.js state update <field> <value>\nFields: current_phase, status, plans_complete, last_activity');
225
+ }
226
+ output(stateUpdate(field, value));
227
+ } else if (command === 'config' && subcommand === 'validate') {
228
+ output(configValidate());
229
+ } else if (command === 'config' && subcommand === 'resolve-depth') {
230
+ const dir = args[2] || undefined;
231
+ const config = configLoad(dir);
232
+ output(resolveDepthProfile(config));
233
+ } else if (command === 'plan-index') {
234
+ const phase = args[1];
235
+ if (!phase) {
236
+ error('Usage: pbr-tools.js plan-index <phase-number>');
237
+ }
238
+ output(planIndex(phase));
239
+ } else if (command === 'frontmatter') {
240
+ const filePath = args[1];
241
+ if (!filePath) {
242
+ error('Usage: pbr-tools.js frontmatter <filepath>');
243
+ }
244
+ output(frontmatter(filePath));
245
+ } else if (command === 'must-haves') {
246
+ const phase = args[1];
247
+ if (!phase) {
248
+ error('Usage: pbr-tools.js must-haves <phase-number>');
249
+ }
250
+ output(mustHavesCollect(phase));
251
+ } else if (command === 'phase-info') {
252
+ const phase = args[1];
253
+ if (!phase) {
254
+ error('Usage: pbr-tools.js phase-info <phase-number>');
255
+ }
256
+ output(phaseInfo(phase));
257
+ } else if (command === 'roadmap' && subcommand === 'update-status') {
258
+ const phase = args[2];
259
+ const status = args[3];
260
+ if (!phase || !status) {
261
+ error('Usage: pbr-tools.js roadmap update-status <phase-number> <status>');
262
+ }
263
+ output(roadmapUpdateStatus(phase, status));
264
+ } else if (command === 'roadmap' && subcommand === 'update-plans') {
265
+ const phase = args[2];
266
+ const complete = args[3];
267
+ const total = args[4];
268
+ if (!phase || complete === undefined || total === undefined) {
269
+ error('Usage: pbr-tools.js roadmap update-plans <phase-number> <complete> <total>');
270
+ }
271
+ output(roadmapUpdatePlans(phase, complete, total));
272
+ } else if (command === 'history' && subcommand === 'append') {
273
+ const type = args[2]; // 'milestone' or 'phase'
274
+ const title = args[3];
275
+ const body = args[4] || '';
276
+ if (!type || !title) {
277
+ error('Usage: pbr-tools.js history append <milestone|phase> <title> [body]');
278
+ }
279
+ output(historyAppend({ type, title, body }));
280
+ } else if (command === 'history' && subcommand === 'load') {
281
+ output(historyLoad());
282
+ } else if (command === 'event') {
283
+ const category = args[1];
284
+ const event = args[2];
285
+ let details = {};
286
+ if (args[3]) {
287
+ try { details = JSON.parse(args[3]); } catch (_e) { details = { raw: args[3] }; }
288
+ }
289
+ if (!category || !event) {
290
+ error('Usage: pbr-tools.js event <category> <event> [JSON-details]');
291
+ }
292
+ const { logEvent } = require('./event-logger');
293
+ logEvent(category, event, details);
294
+ output({ logged: true, category, event });
295
+ } else {
296
+ error(`Unknown command: ${args.join(' ')}\nCommands: state load|check-progress|update, config validate, plan-index, frontmatter, must-haves, phase-info, roadmap update-status|update-plans, history append|load, event`);
297
+ }
298
+ } catch (e) {
299
+ error(e.message);
300
+ }
301
+ }
302
+
303
+ // --- Commands ---
304
+
305
+ function stateLoad() {
306
+ const result = {
307
+ exists: false,
308
+ config: null,
309
+ state: null,
310
+ roadmap: null,
311
+ phase_count: 0,
312
+ current_phase: null,
313
+ progress: null
314
+ };
315
+
316
+ if (!fs.existsSync(planningDir)) {
317
+ return result;
318
+ }
319
+ result.exists = true;
320
+
321
+ // Load config.json
322
+ const configPath = path.join(planningDir, 'config.json');
323
+ if (fs.existsSync(configPath)) {
324
+ try {
325
+ result.config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
326
+ } catch (_) {
327
+ result.config = { _error: 'Failed to parse config.json' };
328
+ }
329
+ }
330
+
331
+ // Load STATE.md
332
+ const statePath = path.join(planningDir, 'STATE.md');
333
+ if (fs.existsSync(statePath)) {
334
+ const content = fs.readFileSync(statePath, 'utf8');
335
+ result.state = parseStateMd(content);
336
+ }
337
+
338
+ // Load ROADMAP.md
339
+ const roadmapPath = path.join(planningDir, 'ROADMAP.md');
340
+ if (fs.existsSync(roadmapPath)) {
341
+ const content = fs.readFileSync(roadmapPath, 'utf8');
342
+ result.roadmap = parseRoadmapMd(content);
343
+ result.phase_count = result.roadmap.phases.length;
344
+ }
345
+
346
+ // Extract current phase
347
+ if (result.state && result.state.current_phase) {
348
+ result.current_phase = result.state.current_phase;
349
+ }
350
+
351
+ // Calculate progress
352
+ result.progress = calculateProgress();
353
+
354
+ return result;
355
+ }
356
+
357
+ function stateCheckProgress() {
358
+ const phasesDir = path.join(planningDir, 'phases');
359
+ if (!fs.existsSync(phasesDir)) {
360
+ return { phases: [], total_plans: 0, completed_plans: 0, percentage: 0 };
361
+ }
362
+
363
+ const phases = [];
364
+ let totalPlans = 0;
365
+ let completedPlans = 0;
366
+
367
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
368
+ .filter(e => e.isDirectory())
369
+ .sort((a, b) => a.name.localeCompare(b.name));
370
+
371
+ for (const entry of entries) {
372
+ const phaseDir = path.join(phasesDir, entry.name);
373
+ const plans = findFiles(phaseDir, /-PLAN\.md$/);
374
+ const summaries = findFiles(phaseDir, /^SUMMARY-.*\.md$/);
375
+ const verification = fs.existsSync(path.join(phaseDir, 'VERIFICATION.md'));
376
+
377
+ const completedSummaries = summaries.filter(s => {
378
+ const content = fs.readFileSync(path.join(phaseDir, s), 'utf8');
379
+ return /status:\s*["']?complete/i.test(content);
380
+ });
381
+
382
+ const phaseInfo = {
383
+ directory: entry.name,
384
+ plans: plans.length,
385
+ summaries: summaries.length,
386
+ completed: completedSummaries.length,
387
+ has_verification: verification,
388
+ status: determinePhaseStatus(plans.length, completedSummaries.length, summaries.length, verification, phaseDir)
389
+ };
390
+
391
+ phases.push(phaseInfo);
392
+ totalPlans += plans.length;
393
+ completedPlans += completedSummaries.length;
394
+ }
395
+
396
+ return {
397
+ phases,
398
+ total_plans: totalPlans,
399
+ completed_plans: completedPlans,
400
+ percentage: totalPlans > 0 ? Math.round((completedPlans / totalPlans) * 100) : 0
401
+ };
402
+ }
403
+
404
+ function planIndex(phaseNum) {
405
+ const phasesDir = path.join(planningDir, 'phases');
406
+ if (!fs.existsSync(phasesDir)) {
407
+ return { error: 'No phases directory found' };
408
+ }
409
+
410
+ // Find phase directory matching the number
411
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
412
+ .filter(e => e.isDirectory());
413
+
414
+ const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
415
+ if (!phaseDir) {
416
+ return { error: `No phase directory found matching phase ${phaseNum}` };
417
+ }
418
+
419
+ const fullDir = path.join(phasesDir, phaseDir.name);
420
+ const planFiles = findFiles(fullDir, /-PLAN\.md$/);
421
+
422
+ const plans = [];
423
+ const waves = {};
424
+
425
+ for (const file of planFiles) {
426
+ const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
427
+ const frontmatter = parseYamlFrontmatter(content);
428
+
429
+ const plan = {
430
+ file,
431
+ plan_id: frontmatter.plan || file.replace(/-PLAN\.md$/, ''),
432
+ wave: parseInt(frontmatter.wave, 10) || 1,
433
+ type: frontmatter.type || 'unknown',
434
+ autonomous: frontmatter.autonomous !== false,
435
+ depends_on: frontmatter.depends_on || [],
436
+ gap_closure: frontmatter.gap_closure || false,
437
+ has_summary: fs.existsSync(path.join(fullDir, `SUMMARY-${frontmatter.plan || ''}.md`)),
438
+ must_haves_count: countMustHaves(frontmatter.must_haves)
439
+ };
440
+
441
+ plans.push(plan);
442
+
443
+ const waveKey = `wave_${plan.wave}`;
444
+ if (!waves[waveKey]) waves[waveKey] = [];
445
+ waves[waveKey].push(plan.plan_id);
446
+ }
447
+
448
+ return {
449
+ phase: phaseDir.name,
450
+ total_plans: plans.length,
451
+ plans,
452
+ waves
453
+ };
454
+ }
455
+
456
+ function configValidate(preloadedConfig) {
457
+ let config;
458
+ if (preloadedConfig) {
459
+ config = preloadedConfig;
460
+ } else {
461
+ const configPath = path.join(planningDir, 'config.json');
462
+ if (!fs.existsSync(configPath)) {
463
+ return { valid: false, errors: ['config.json not found'], warnings: [] };
464
+ }
465
+
466
+ try {
467
+ config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
468
+ } catch (e) {
469
+ return { valid: false, errors: [`config.json is not valid JSON: ${e.message}`], warnings: [] };
470
+ }
471
+ }
472
+
473
+ const schema = JSON.parse(fs.readFileSync(path.join(__dirname, 'config-schema.json'), 'utf8'));
474
+ const warnings = [];
475
+ const errors = [];
476
+
477
+ validateObject(config, schema, '', errors, warnings);
478
+
479
+ // Semantic conflict detection — logical contradictions that pass schema validation
480
+ // Clear contradictions errors; ambiguous/preference issues → warnings
481
+ if (config.mode === 'autonomous' && config.gates) {
482
+ const activeGates = Object.entries(config.gates || {}).filter(([, v]) => v === true).map(([k]) => k);
483
+ if (activeGates.length > 0) {
484
+ errors.push(`mode=autonomous with active gates (${activeGates.join(', ')}): gates are unreachable in autonomous mode`);
485
+ }
486
+ }
487
+ if (config.features && config.features.auto_continue && config.mode === 'interactive') {
488
+ warnings.push('features.auto_continue=true with mode=interactive: auto_continue only fires in autonomous mode');
489
+ }
490
+ if (config.parallelization) {
491
+ if (config.parallelization.enabled === false && config.parallelization.plan_level === true) {
492
+ warnings.push('parallelization.enabled=false with plan_level=true: plan_level is ignored when parallelization is disabled');
493
+ }
494
+ if (config.parallelization.max_concurrent_agents === 1 && config.teams && config.teams.coordination) {
495
+ errors.push('parallelization.max_concurrent_agents=1 with teams.coordination set: teams require concurrent agents to be useful');
496
+ }
497
+ }
498
+
499
+ return {
500
+ valid: errors.length === 0,
501
+ errors,
502
+ warnings
503
+ };
504
+ }
505
+
506
+ // --- New read-only commands ---
507
+
508
+ /**
509
+ * Parse a markdown file's YAML frontmatter and return as JSON.
510
+ * Wraps parseYamlFrontmatter() + parseMustHaves().
511
+ */
512
+ function frontmatter(filePath) {
513
+ const resolved = path.resolve(filePath);
514
+ if (!fs.existsSync(resolved)) {
515
+ return { error: `File not found: ${resolved}` };
516
+ }
517
+ const content = fs.readFileSync(resolved, 'utf8');
518
+ return parseYamlFrontmatter(content);
519
+ }
520
+
521
+ /**
522
+ * Collect all must-haves from all PLAN.md files in a phase.
523
+ * Returns per-plan grouping + flat deduplicated list + total count.
524
+ */
525
+ function mustHavesCollect(phaseNum) {
526
+ const phasesDir = path.join(planningDir, 'phases');
527
+ if (!fs.existsSync(phasesDir)) {
528
+ return { error: 'No phases directory found' };
529
+ }
530
+
531
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
532
+ .filter(e => e.isDirectory());
533
+ const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
534
+ if (!phaseDir) {
535
+ return { error: `No phase directory found matching phase ${phaseNum}` };
536
+ }
537
+
538
+ const fullDir = path.join(phasesDir, phaseDir.name);
539
+ const planFiles = findFiles(fullDir, /-PLAN\.md$/);
540
+
541
+ const perPlan = {};
542
+ const allTruths = new Set();
543
+ const allArtifacts = new Set();
544
+ const allKeyLinks = new Set();
545
+
546
+ for (const file of planFiles) {
547
+ const content = fs.readFileSync(path.join(fullDir, file), 'utf8');
548
+ const fm = parseYamlFrontmatter(content);
549
+ const planId = fm.plan || file.replace(/-PLAN\.md$/, '');
550
+ const mh = fm.must_haves || { truths: [], artifacts: [], key_links: [] };
551
+
552
+ perPlan[planId] = mh;
553
+ (mh.truths || []).forEach(t => allTruths.add(t));
554
+ (mh.artifacts || []).forEach(a => allArtifacts.add(a));
555
+ (mh.key_links || []).forEach(k => allKeyLinks.add(k));
556
+ }
557
+
558
+ const all = {
559
+ truths: [...allTruths],
560
+ artifacts: [...allArtifacts],
561
+ key_links: [...allKeyLinks]
562
+ };
563
+
564
+ return {
565
+ phase: phaseDir.name,
566
+ plans: perPlan,
567
+ all,
568
+ total: all.truths.length + all.artifacts.length + all.key_links.length
569
+ };
570
+ }
571
+
572
+ /**
573
+ * Comprehensive single-phase status combining roadmap, filesystem, and plan data.
574
+ */
575
+ function phaseInfo(phaseNum) {
576
+ const phasesDir = path.join(planningDir, 'phases');
577
+ if (!fs.existsSync(phasesDir)) {
578
+ return { error: 'No phases directory found' };
579
+ }
580
+
581
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
582
+ .filter(e => e.isDirectory());
583
+ const phaseDir = entries.find(e => e.name.startsWith(phaseNum.padStart(2, '0') + '-'));
584
+ if (!phaseDir) {
585
+ return { error: `No phase directory found matching phase ${phaseNum}` };
586
+ }
587
+
588
+ const fullDir = path.join(phasesDir, phaseDir.name);
589
+
590
+ // Get roadmap info
591
+ let roadmapInfo = null;
592
+ const roadmapPath = path.join(planningDir, 'ROADMAP.md');
593
+ if (fs.existsSync(roadmapPath)) {
594
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf8');
595
+ const roadmap = parseRoadmapMd(roadmapContent);
596
+ roadmapInfo = roadmap.phases.find(p => p.number === phaseNum.padStart(2, '0')) || null;
597
+ }
598
+
599
+ // Get plan index
600
+ const plans = planIndex(phaseNum);
601
+
602
+ // Check for verification
603
+ const verificationPath = path.join(fullDir, 'VERIFICATION.md');
604
+ let verification = null;
605
+ if (fs.existsSync(verificationPath)) {
606
+ const vContent = fs.readFileSync(verificationPath, 'utf8');
607
+ verification = parseYamlFrontmatter(vContent);
608
+ }
609
+
610
+ // Check summaries
611
+ const summaryFiles = findFiles(fullDir, /^SUMMARY-.*\.md$/);
612
+ const summaries = summaryFiles.map(f => {
613
+ const content = fs.readFileSync(path.join(fullDir, f), 'utf8');
614
+ const fm = parseYamlFrontmatter(content);
615
+ return { file: f, plan: fm.plan || f.replace(/^SUMMARY-|\.md$/g, ''), status: fm.status || 'unknown' };
616
+ });
617
+
618
+ // Determine filesystem status
619
+ const planCount = plans.total_plans || 0;
620
+ const completedCount = summaries.filter(s => s.status === 'complete').length;
621
+ const hasVerification = fs.existsSync(verificationPath);
622
+ const fsStatus = determinePhaseStatus(planCount, completedCount, summaryFiles.length, hasVerification, fullDir);
623
+
624
+ return {
625
+ phase: phaseDir.name,
626
+ name: roadmapInfo ? roadmapInfo.name : phaseDir.name.replace(/^\d+-/, ''),
627
+ goal: roadmapInfo ? roadmapInfo.goal : null,
628
+ roadmap_status: roadmapInfo ? roadmapInfo.status : null,
629
+ filesystem_status: fsStatus,
630
+ plans: plans.plans || [],
631
+ plan_count: planCount,
632
+ summaries,
633
+ completed: completedCount,
634
+ verification,
635
+ has_context: fs.existsSync(path.join(fullDir, 'CONTEXT.md'))
636
+ };
637
+ }
638
+
639
+ // --- Mutation commands ---
640
+
641
+ /**
642
+ * Atomically update a field in STATE.md using lockedFileUpdate.
643
+ * Supports both legacy and frontmatter (v2) formats.
644
+ *
645
+ * @param {string} field - One of: current_phase, status, plans_complete, last_activity
646
+ * @param {string} value - New value (use 'now' for last_activity to auto-timestamp)
647
+ */
648
+ function stateUpdate(field, value) {
649
+ const statePath = path.join(planningDir, 'STATE.md');
650
+ if (!fs.existsSync(statePath)) {
651
+ return { success: false, error: 'STATE.md not found' };
652
+ }
653
+
654
+ const validFields = ['current_phase', 'status', 'plans_complete', 'last_activity'];
655
+ if (!validFields.includes(field)) {
656
+ return { success: false, error: `Invalid field: ${field}. Valid fields: ${validFields.join(', ')}` };
657
+ }
658
+
659
+ // Auto-timestamp
660
+ if (field === 'last_activity' && value === 'now') {
661
+ value = new Date().toISOString().slice(0, 19).replace('T', ' ');
662
+ }
663
+
664
+ const result = lockedFileUpdate(statePath, (content) => {
665
+ const fm = parseYamlFrontmatter(content);
666
+ if (fm.version === 2 || fm.current_phase !== undefined) {
667
+ return updateFrontmatterField(content, field, value);
668
+ }
669
+ return updateLegacyStateField(content, field, value);
670
+ });
671
+
672
+ if (result.success) {
673
+ return { success: true, field, value };
674
+ }
675
+ return { success: false, error: result.error };
676
+ }
677
+
678
+ /**
679
+ * Append a record to HISTORY.md. Creates the file if it doesn't exist.
680
+ * Each entry is a markdown section appended at the end.
681
+ *
682
+ * @param {object} entry - { type: 'milestone'|'phase', title: string, body: string }
683
+ * @param {string} [dir] - Path to .planning directory (defaults to cwd/.planning)
684
+ * @returns {{success: boolean, error?: string}}
685
+ */
686
+ function historyAppend(entry, dir) {
687
+ const historyPath = path.join(dir || planningDir, 'HISTORY.md');
688
+ const timestamp = new Date().toISOString().slice(0, 10);
689
+
690
+ let header = '';
691
+ if (!fs.existsSync(historyPath)) {
692
+ header = '# Project History\n\nCompleted milestones and phase records. This file is append-only.\n\n';
693
+ }
694
+
695
+ const section = `${header}## ${entry.type === 'milestone' ? 'Milestone' : 'Phase'}: ${entry.title}\n_Completed: ${timestamp}_\n\n${entry.body.trim()}\n\n---\n\n`;
696
+
697
+ try {
698
+ fs.appendFileSync(historyPath, section, 'utf8');
699
+ return { success: true };
700
+ } catch (e) {
701
+ return { success: false, error: e.message };
702
+ }
703
+ }
704
+
705
+ /**
706
+ * Load HISTORY.md and parse it into structured records.
707
+ * Returns null if HISTORY.md doesn't exist.
708
+ *
709
+ * @param {string} [dir] - Path to .planning directory
710
+ * @returns {object|null} { records: [{type, title, date, body}], line_count }
711
+ */
712
+ function historyLoad(dir) {
713
+ const historyPath = path.join(dir || planningDir, 'HISTORY.md');
714
+ if (!fs.existsSync(historyPath)) return null;
715
+
716
+ const content = fs.readFileSync(historyPath, 'utf8');
717
+ const records = [];
718
+ const sectionRegex = /^## (Milestone|Phase): (.+)\n_Completed: (\d{4}-\d{2}-\d{2})_\n\n([\s\S]*?)(?=\n---|\s*$)/gm;
719
+
720
+ let match;
721
+ while ((match = sectionRegex.exec(content)) !== null) {
722
+ records.push({
723
+ type: match[1].toLowerCase(),
724
+ title: match[2].trim(),
725
+ date: match[3],
726
+ body: match[4].trim()
727
+ });
728
+ }
729
+
730
+ return {
731
+ records,
732
+ line_count: content.split('\n').length
733
+ };
734
+ }
735
+
736
+ /**
737
+ * Update the Status column for a phase in ROADMAP.md's Phase Overview table.
738
+ */
739
+ function roadmapUpdateStatus(phaseNum, newStatus) {
740
+ const roadmapPath = path.join(planningDir, 'ROADMAP.md');
741
+ if (!fs.existsSync(roadmapPath)) {
742
+ return { success: false, error: 'ROADMAP.md not found' };
743
+ }
744
+
745
+ let oldStatus = null;
746
+
747
+ const result = lockedFileUpdate(roadmapPath, (content) => {
748
+ const lines = content.split('\n');
749
+ const rowIdx = findRoadmapRow(lines, phaseNum);
750
+ if (rowIdx === -1) {
751
+ return content; // No matching row found
752
+ }
753
+ const parts = lines[rowIdx].split('|');
754
+ oldStatus = parts[6] ? parts[6].trim() : 'unknown';
755
+ lines[rowIdx] = updateTableRow(lines[rowIdx], 5, newStatus);
756
+ return lines.join('\n');
757
+ });
758
+
759
+ if (!oldStatus) {
760
+ return { success: false, error: `Phase ${phaseNum} not found in ROADMAP.md table` };
761
+ }
762
+
763
+ // Advisory transition validation — warn on suspicious transitions but don't block
764
+ const transition = validateStatusTransition(oldStatus, newStatus);
765
+ if (!transition.valid && transition.warning) {
766
+ process.stderr.write(`[pbr-tools] WARNING: ${transition.warning}\n`);
767
+ }
768
+
769
+ if (result.success) {
770
+ const response = { success: true, old_status: oldStatus, new_status: newStatus };
771
+ if (!transition.valid) {
772
+ response.transition_warning = transition.warning;
773
+ }
774
+ return response;
775
+ }
776
+ return { success: false, error: result.error };
777
+ }
778
+
779
+ /**
780
+ * Update the Plans column for a phase in ROADMAP.md's Phase Overview table.
781
+ */
782
+ function roadmapUpdatePlans(phaseNum, complete, total) {
783
+ const roadmapPath = path.join(planningDir, 'ROADMAP.md');
784
+ if (!fs.existsSync(roadmapPath)) {
785
+ return { success: false, error: 'ROADMAP.md not found' };
786
+ }
787
+
788
+ let oldPlans = null;
789
+ const newPlans = `${complete}/${total}`;
790
+
791
+ const result = lockedFileUpdate(roadmapPath, (content) => {
792
+ const lines = content.split('\n');
793
+ const rowIdx = findRoadmapRow(lines, phaseNum);
794
+ if (rowIdx === -1) {
795
+ return content;
796
+ }
797
+ const parts = lines[rowIdx].split('|');
798
+ oldPlans = parts[4] ? parts[4].trim() : 'unknown';
799
+ lines[rowIdx] = updateTableRow(lines[rowIdx], 3, newPlans);
800
+ return lines.join('\n');
801
+ });
802
+
803
+ if (!oldPlans) {
804
+ return { success: false, error: `Phase ${phaseNum} not found in ROADMAP.md table` };
805
+ }
806
+
807
+ if (result.success) {
808
+ return { success: true, old_plans: oldPlans, new_plans: newPlans };
809
+ }
810
+ return { success: false, error: result.error };
811
+ }
812
+
813
+ // --- Mutation helpers ---
814
+
815
+ /**
816
+ * Update a field in legacy (non-frontmatter) STATE.md content.
817
+ * Pure function: content in, content out.
818
+ */
819
+ function updateLegacyStateField(content, field, value) {
820
+ const lines = content.split('\n');
821
+
822
+ switch (field) {
823
+ case 'current_phase': {
824
+ const idx = lines.findIndex(l => /Phase:\s*\d+\s+of\s+\d+/.test(l));
825
+ if (idx !== -1) {
826
+ lines[idx] = lines[idx].replace(/(Phase:\s*)\d+/, `$1${value}`);
827
+ }
828
+ break;
829
+ }
830
+ case 'status': {
831
+ const idx = lines.findIndex(l => /^Status:/i.test(l));
832
+ if (idx !== -1) {
833
+ lines[idx] = `Status: ${value}`;
834
+ } else {
835
+ const phaseIdx = lines.findIndex(l => /Phase:/.test(l));
836
+ if (phaseIdx !== -1) {
837
+ lines.splice(phaseIdx + 1, 0, `Status: ${value}`);
838
+ } else {
839
+ lines.push(`Status: ${value}`);
840
+ }
841
+ }
842
+ break;
843
+ }
844
+ case 'plans_complete': {
845
+ const idx = lines.findIndex(l => /Plan:\s*\d+\s+of\s+\d+/.test(l));
846
+ if (idx !== -1) {
847
+ lines[idx] = lines[idx].replace(/(Plan:\s*)\d+/, `$1${value}`);
848
+ }
849
+ break;
850
+ }
851
+ case 'last_activity': {
852
+ const idx = lines.findIndex(l => /^Last Activity:/i.test(l));
853
+ if (idx !== -1) {
854
+ lines[idx] = `Last Activity: ${value}`;
855
+ } else {
856
+ const statusIdx = lines.findIndex(l => /^Status:/i.test(l));
857
+ if (statusIdx !== -1) {
858
+ lines.splice(statusIdx + 1, 0, `Last Activity: ${value}`);
859
+ } else {
860
+ lines.push(`Last Activity: ${value}`);
861
+ }
862
+ }
863
+ break;
864
+ }
865
+ }
866
+
867
+ return lines.join('\n');
868
+ }
869
+
870
+ /**
871
+ * Update a field in YAML frontmatter content.
872
+ * Pure function: content in, content out.
873
+ */
874
+ function updateFrontmatterField(content, field, value) {
875
+ const match = content.match(/^(---\s*\n)([\s\S]*?)(\n---)/);
876
+ if (!match) return content;
877
+
878
+ const before = match[1];
879
+ let yaml = match[2];
880
+ const after = match[3];
881
+ const rest = content.slice(match[0].length);
882
+
883
+ // Format value: integers stay bare, strings get quotes
884
+ const isNum = /^\d+$/.test(String(value));
885
+ const formatted = isNum ? value : `"${value}"`;
886
+
887
+ const fieldRegex = new RegExp(`^(${field})\\s*:.*$`, 'm');
888
+ if (fieldRegex.test(yaml)) {
889
+ yaml = yaml.replace(fieldRegex, `${field}: ${formatted}`);
890
+ } else {
891
+ yaml = yaml + `\n${field}: ${formatted}`;
892
+ }
893
+
894
+ return before + yaml + after + rest;
895
+ }
896
+
897
+ /**
898
+ * Find the row index of a phase in a ROADMAP.md table.
899
+ * @returns {number} Line index or -1 if not found
900
+ */
901
+ function findRoadmapRow(lines, phaseNum) {
902
+ const paddedPhase = phaseNum.padStart(2, '0');
903
+ for (let i = 0; i < lines.length; i++) {
904
+ if (!lines[i].includes('|')) continue;
905
+ const parts = lines[i].split('|');
906
+ if (parts.length < 3) continue;
907
+ const phaseCol = parts[1] ? parts[1].trim() : '';
908
+ if (phaseCol === paddedPhase) {
909
+ return i;
910
+ }
911
+ }
912
+ return -1;
913
+ }
914
+
915
+ /**
916
+ * Update a specific column in a markdown table row.
917
+ * @param {string} row - The full table row string (e.g., "| 01 | Setup | ... |")
918
+ * @param {number} columnIndex - 0-based column index (Phase=0, Name=1, ..., Status=5)
919
+ * @param {string} newValue - New cell value
920
+ * @returns {string} Updated row
921
+ */
922
+ function updateTableRow(row, columnIndex, newValue) {
923
+ const parts = row.split('|');
924
+ // parts[0] is empty (before first |), data starts at parts[1]
925
+ const partIndex = columnIndex + 1;
926
+ if (partIndex < parts.length) {
927
+ parts[partIndex] = ` ${newValue} `;
928
+ }
929
+ return parts.join('|');
930
+ }
931
+
932
+ /**
933
+ * Lightweight JSON Schema validator — supports type, enum, properties,
934
+ * additionalProperties, minimum, maximum for the config schema.
935
+ */
936
+ function validateObject(value, schema, prefix, errors, warnings) {
937
+ if (schema.type && typeof value !== schema.type) {
938
+ if (!(schema.type === 'integer' && typeof value === 'number' && Number.isInteger(value))) {
939
+ errors.push(`${prefix || 'root'}: expected ${schema.type}, got ${typeof value}`);
940
+ return;
941
+ }
942
+ }
943
+
944
+ if (schema.enum && !schema.enum.includes(value)) {
945
+ errors.push(`${prefix || 'root'}: value "${value}" not in allowed values [${schema.enum.join(', ')}]`);
946
+ return;
947
+ }
948
+
949
+ if (schema.minimum !== undefined && value < schema.minimum) {
950
+ errors.push(`${prefix || 'root'}: value ${value} is below minimum ${schema.minimum}`);
951
+ }
952
+ if (schema.maximum !== undefined && value > schema.maximum) {
953
+ errors.push(`${prefix || 'root'}: value ${value} is above maximum ${schema.maximum}`);
954
+ }
955
+
956
+ if (schema.type === 'object' && schema.properties) {
957
+ const knownKeys = new Set(Object.keys(schema.properties));
958
+
959
+ for (const key of Object.keys(value)) {
960
+ const fullKey = prefix ? `${prefix}.${key}` : key;
961
+ if (!knownKeys.has(key)) {
962
+ if (schema.additionalProperties === false) {
963
+ warnings.push(`${fullKey}: unrecognized key (possible typo?)`);
964
+ }
965
+ continue;
966
+ }
967
+ validateObject(value[key], schema.properties[key], fullKey, errors, warnings);
968
+ }
969
+ }
970
+ }
971
+
972
+ /**
973
+ * Locked file update: read-modify-write with exclusive lockfile.
974
+ * Prevents concurrent writes to STATE.md and ROADMAP.md.
975
+ *
976
+ * @param {string} filePath - Absolute path to the file to update
977
+ * @param {function} updateFn - Receives current content, returns new content
978
+ * @param {object} opts - Options: { retries: 3, retryDelayMs: 100, timeoutMs: 5000 }
979
+ * @returns {object} { success, content?, error? }
980
+ */
981
+ function lockedFileUpdate(filePath, updateFn, opts = {}) {
982
+ const retries = opts.retries || 3;
983
+ const retryDelayMs = opts.retryDelayMs || 100;
984
+ const timeoutMs = opts.timeoutMs || 5000;
985
+ const lockPath = filePath + '.lock';
986
+
987
+ let lockFd = null;
988
+ let lockAcquired = false;
989
+
990
+ try {
991
+ // Acquire lock with retries
992
+ for (let attempt = 0; attempt < retries; attempt++) {
993
+ try {
994
+ lockFd = fs.openSync(lockPath, 'wx');
995
+ lockAcquired = true;
996
+ break;
997
+ } catch (e) {
998
+ if (e.code === 'EEXIST') {
999
+ // Lock exists — check if stale (older than timeoutMs)
1000
+ try {
1001
+ const stats = fs.statSync(lockPath);
1002
+ if (Date.now() - stats.mtimeMs > timeoutMs) {
1003
+ // Stale lock — remove and retry
1004
+ fs.unlinkSync(lockPath);
1005
+ continue;
1006
+ }
1007
+ } catch (_statErr) {
1008
+ // Lock disappeared between check — retry
1009
+ continue;
1010
+ }
1011
+
1012
+ if (attempt < retries - 1) {
1013
+ // Wait and retry
1014
+ const waitMs = retryDelayMs * (attempt + 1);
1015
+ const start = Date.now();
1016
+ while (Date.now() - start < waitMs) {
1017
+ // Busy wait (synchronous context)
1018
+ }
1019
+ continue;
1020
+ }
1021
+ return { success: false, error: `Could not acquire lock for ${path.basename(filePath)} after ${retries} attempts` };
1022
+ }
1023
+ throw e;
1024
+ }
1025
+ }
1026
+
1027
+ if (!lockAcquired) {
1028
+ return { success: false, error: `Could not acquire lock for ${path.basename(filePath)}` };
1029
+ }
1030
+
1031
+ // Write PID to lock file for debugging
1032
+ fs.writeSync(lockFd, `${process.pid}`);
1033
+ fs.closeSync(lockFd);
1034
+ lockFd = null;
1035
+
1036
+ // Read current content
1037
+ let content = '';
1038
+ if (fs.existsSync(filePath)) {
1039
+ content = fs.readFileSync(filePath, 'utf8');
1040
+ }
1041
+
1042
+ // Apply update
1043
+ const newContent = updateFn(content);
1044
+
1045
+ // Write back atomically
1046
+ const writeResult = atomicWrite(filePath, newContent);
1047
+ if (!writeResult.success) {
1048
+ return { success: false, error: writeResult.error };
1049
+ }
1050
+
1051
+ return { success: true, content: newContent };
1052
+ } catch (e) {
1053
+ return { success: false, error: e.message };
1054
+ } finally {
1055
+ // Close fd if still open
1056
+ try {
1057
+ if (lockFd !== null) fs.closeSync(lockFd);
1058
+ } catch (_e) { /* ignore */ }
1059
+ // Only release lock if we acquired it
1060
+ if (lockAcquired) {
1061
+ try {
1062
+ fs.unlinkSync(lockPath);
1063
+ } catch (_e) { /* ignore — may already be cleaned up */ }
1064
+ }
1065
+ }
1066
+ }
1067
+
1068
+ // --- Parsers ---
1069
+
1070
+ function parseStateMd(content) {
1071
+ const result = {
1072
+ current_phase: null,
1073
+ phase_name: null,
1074
+ progress: null,
1075
+ status: null,
1076
+ line_count: content.split('\n').length,
1077
+ format: 'legacy' // 'legacy' or 'frontmatter'
1078
+ };
1079
+
1080
+ // Check for YAML frontmatter (version 2 format)
1081
+ const frontmatter = parseYamlFrontmatter(content);
1082
+ if (frontmatter.version === 2 || frontmatter.current_phase !== undefined) {
1083
+ result.format = 'frontmatter';
1084
+ result.current_phase = frontmatter.current_phase || null;
1085
+ result.total_phases = frontmatter.total_phases || null;
1086
+ result.phase_name = frontmatter.phase_slug || frontmatter.phase_name || null;
1087
+ result.status = frontmatter.status || null;
1088
+ result.progress = frontmatter.progress_percent !== undefined ? frontmatter.progress_percent : null;
1089
+ result.plans_total = frontmatter.plans_total || null;
1090
+ result.plans_complete = frontmatter.plans_complete || null;
1091
+ result.last_activity = frontmatter.last_activity || null;
1092
+ result.last_command = frontmatter.last_command || null;
1093
+ result.blockers = frontmatter.blockers || [];
1094
+ return result;
1095
+ }
1096
+
1097
+ // Legacy regex-based parsing (version 1 format, no frontmatter)
1098
+ // DEPRECATED (2026-02): v1 STATE.md format (no YAML frontmatter) is deprecated.
1099
+ // New projects should use v2 (frontmatter) format, generated by /pbr:setup.
1100
+ // v1 support will be removed in a future major version.
1101
+ process.stderr.write('[pbr] WARNING: STATE.md uses legacy v1 format. Run /pbr:setup to migrate to v2 format.\n');
1102
+ // Extract "Phase: N of M"
1103
+ const phaseMatch = content.match(/Phase:\s*(\d+)\s+of\s+(\d+)/);
1104
+ if (phaseMatch) {
1105
+ result.current_phase = parseInt(phaseMatch[1], 10);
1106
+ result.total_phases = parseInt(phaseMatch[2], 10);
1107
+ }
1108
+
1109
+ // Extract phase name (line after "Phase:")
1110
+ const nameMatch = content.match(/--\s+(.+?)(?:\n|$)/);
1111
+ if (nameMatch) {
1112
+ result.phase_name = nameMatch[1].trim();
1113
+ }
1114
+
1115
+ // Extract progress percentage
1116
+ const progressMatch = content.match(/(\d+)%/);
1117
+ if (progressMatch) {
1118
+ result.progress = parseInt(progressMatch[1], 10);
1119
+ }
1120
+
1121
+ // Extract plan status
1122
+ const statusMatch = content.match(/Status:\s*(.+?)(?:\n|$)/i);
1123
+ if (statusMatch) {
1124
+ result.status = statusMatch[1].trim();
1125
+ }
1126
+
1127
+ return result;
1128
+ }
1129
+
1130
+ function parseRoadmapMd(content) {
1131
+ const result = { phases: [], has_progress_table: false };
1132
+
1133
+ // Find Phase Overview table
1134
+ const overviewMatch = content.match(/## Phase Overview[\s\S]*?\|[\s\S]*?(?=\n##|\s*$)/);
1135
+ if (overviewMatch) {
1136
+ const rows = overviewMatch[0].split('\n').filter(r => r.includes('|'));
1137
+ // Skip header and separator rows
1138
+ for (let i = 2; i < rows.length; i++) {
1139
+ const cols = rows[i].split('|').map(c => c.trim()).filter(Boolean);
1140
+ if (cols.length >= 3) {
1141
+ result.phases.push({
1142
+ number: cols[0],
1143
+ name: cols[1],
1144
+ goal: cols[2],
1145
+ plans: cols[3] || '',
1146
+ wave: cols[4] || '',
1147
+ status: cols[5] || 'pending'
1148
+ });
1149
+ }
1150
+ }
1151
+ }
1152
+
1153
+ // Check for Progress table
1154
+ result.has_progress_table = /## Progress/.test(content);
1155
+
1156
+ return result;
1157
+ }
1158
+
1159
+ function parseYamlFrontmatter(content) {
1160
+ const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
1161
+ if (!match) return {};
1162
+
1163
+ const yaml = match[1];
1164
+ const result = {};
1165
+
1166
+ // Simple YAML parser for flat and basic nested values
1167
+ const lines = yaml.split('\n');
1168
+ let currentKey = null;
1169
+
1170
+ for (const line of lines) {
1171
+ // Array item
1172
+ if (/^\s+-\s+/.test(line) && currentKey) {
1173
+ const val = line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, '');
1174
+ if (!result[currentKey]) result[currentKey] = [];
1175
+ if (Array.isArray(result[currentKey])) {
1176
+ result[currentKey].push(val);
1177
+ }
1178
+ continue;
1179
+ }
1180
+
1181
+ // Key-value pair
1182
+ const kvMatch = line.match(/^(\w[\w_]*)\s*:\s*(.*)/);
1183
+ if (kvMatch) {
1184
+ currentKey = kvMatch[1];
1185
+ let val = kvMatch[2].trim();
1186
+
1187
+ if (val === '' || val === '|') {
1188
+ // Possible array or block follows
1189
+ continue;
1190
+ }
1191
+
1192
+ // Handle arrays on same line: [a, b, c]
1193
+ if (val.startsWith('[') && val.endsWith(']')) {
1194
+ result[currentKey] = val.slice(1, -1).split(',')
1195
+ .map(v => v.trim().replace(/^["']|["']$/g, ''))
1196
+ .filter(Boolean);
1197
+ continue;
1198
+ }
1199
+
1200
+ // Clean quotes
1201
+ val = val.replace(/^["']|["']$/g, '');
1202
+
1203
+ // Type coercion
1204
+ if (val === 'true') val = true;
1205
+ else if (val === 'false') val = false;
1206
+ else if (/^\d+$/.test(val)) val = parseInt(val, 10);
1207
+
1208
+ result[currentKey] = val;
1209
+ }
1210
+ }
1211
+
1212
+ // Handle must_haves as a nested object
1213
+ if (yaml.includes('must_haves:')) {
1214
+ result.must_haves = parseMustHaves(yaml);
1215
+ }
1216
+
1217
+ return result;
1218
+ }
1219
+
1220
+ function parseMustHaves(yaml) {
1221
+ const result = { truths: [], artifacts: [], key_links: [] };
1222
+ let section = null;
1223
+
1224
+ const inMustHaves = yaml.split('\n');
1225
+ let collecting = false;
1226
+
1227
+ for (const line of inMustHaves) {
1228
+ if (/^\s*must_haves:/.test(line)) {
1229
+ collecting = true;
1230
+ continue;
1231
+ }
1232
+ if (collecting) {
1233
+ if (/^\s{2}truths:/.test(line)) { section = 'truths'; continue; }
1234
+ if (/^\s{2}artifacts:/.test(line)) { section = 'artifacts'; continue; }
1235
+ if (/^\s{2}key_links:/.test(line)) { section = 'key_links'; continue; }
1236
+ if (/^\w/.test(line)) break; // New top-level key, stop
1237
+
1238
+ if (section && /^\s+-\s+/.test(line)) {
1239
+ result[section].push(line.replace(/^\s+-\s+/, '').trim().replace(/^["']|["']$/g, ''));
1240
+ }
1241
+ }
1242
+ }
1243
+
1244
+ return result;
1245
+ }
1246
+
1247
+ // --- Helpers ---
1248
+
1249
+ function findFiles(dir, pattern) {
1250
+ try {
1251
+ return fs.readdirSync(dir).filter(f => pattern.test(f)).sort();
1252
+ } catch (_) {
1253
+ return [];
1254
+ }
1255
+ }
1256
+
1257
+ function determinePhaseStatus(planCount, completedCount, summaryCount, hasVerification, phaseDir) {
1258
+ if (planCount === 0) {
1259
+ // Check for CONTEXT.md (discussed only)
1260
+ if (fs.existsSync(path.join(phaseDir, 'CONTEXT.md'))) return 'discussed';
1261
+ return 'not_started';
1262
+ }
1263
+ if (completedCount === 0 && summaryCount === 0) return 'planned';
1264
+ if (completedCount < planCount) return 'building';
1265
+ if (!hasVerification) return 'built';
1266
+ // Check verification status
1267
+ try {
1268
+ const vContent = fs.readFileSync(path.join(phaseDir, 'VERIFICATION.md'), 'utf8');
1269
+ if (/status:\s*["']?passed/i.test(vContent)) return 'verified';
1270
+ if (/status:\s*["']?gaps_found/i.test(vContent)) return 'needs_fixes';
1271
+ return 'reviewed';
1272
+ } catch (_) {
1273
+ return 'built';
1274
+ }
1275
+ }
1276
+
1277
+ function countMustHaves(mustHaves) {
1278
+ if (!mustHaves) return 0;
1279
+ return (mustHaves.truths || []).length +
1280
+ (mustHaves.artifacts || []).length +
1281
+ (mustHaves.key_links || []).length;
1282
+ }
1283
+
1284
+ function calculateProgress() {
1285
+ const phasesDir = path.join(planningDir, 'phases');
1286
+ if (!fs.existsSync(phasesDir)) {
1287
+ return { total: 0, completed: 0, percentage: 0 };
1288
+ }
1289
+
1290
+ let total = 0;
1291
+ let completed = 0;
1292
+
1293
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true })
1294
+ .filter(e => e.isDirectory());
1295
+
1296
+ for (const entry of entries) {
1297
+ const dir = path.join(phasesDir, entry.name);
1298
+ const plans = findFiles(dir, /-PLAN\.md$/);
1299
+ total += plans.length;
1300
+
1301
+ const summaries = findFiles(dir, /^SUMMARY-.*\.md$/);
1302
+ for (const s of summaries) {
1303
+ const content = fs.readFileSync(path.join(dir, s), 'utf8');
1304
+ if (/status:\s*["']?complete/i.test(content)) completed++;
1305
+ }
1306
+ }
1307
+
1308
+ return {
1309
+ total,
1310
+ completed,
1311
+ percentage: total > 0 ? Math.round((completed / total) * 100) : 0
1312
+ };
1313
+ }
1314
+
1315
+ function output(data) {
1316
+ process.stdout.write(JSON.stringify(data, null, 2));
1317
+ process.exit(0);
1318
+ }
1319
+
1320
+ function error(msg) {
1321
+ process.stdout.write(JSON.stringify({ error: msg }));
1322
+ process.exit(1);
1323
+ }
1324
+
1325
+ /**
1326
+ * Write content to a file atomically: write to .tmp, backup original to .bak,
1327
+ * rename .tmp over original. On failure, restore from .bak if available.
1328
+ *
1329
+ * @param {string} filePath - Target file path
1330
+ * @param {string} content - Content to write
1331
+ * @returns {{success: boolean, error?: string}} Result
1332
+ */
1333
+ function atomicWrite(filePath, content) {
1334
+ const tmpPath = filePath + '.tmp';
1335
+ const bakPath = filePath + '.bak';
1336
+
1337
+ try {
1338
+ // 1. Write to temp file
1339
+ fs.writeFileSync(tmpPath, content, 'utf8');
1340
+
1341
+ // 2. Backup original if it exists
1342
+ if (fs.existsSync(filePath)) {
1343
+ try {
1344
+ fs.copyFileSync(filePath, bakPath);
1345
+ } catch (_e) {
1346
+ // Backup failure is non-fatal — proceed with rename
1347
+ }
1348
+ }
1349
+
1350
+ // 3. Rename temp over original (atomic on most filesystems)
1351
+ fs.renameSync(tmpPath, filePath);
1352
+
1353
+ return { success: true };
1354
+ } catch (e) {
1355
+ // Rename failed — try to restore from backup
1356
+ try {
1357
+ if (fs.existsSync(bakPath)) {
1358
+ fs.copyFileSync(bakPath, filePath);
1359
+ }
1360
+ } catch (_restoreErr) {
1361
+ // Restore also failed — nothing more we can do
1362
+ }
1363
+
1364
+ // Clean up temp file if it still exists
1365
+ try {
1366
+ if (fs.existsSync(tmpPath)) {
1367
+ fs.unlinkSync(tmpPath);
1368
+ }
1369
+ } catch (_cleanupErr) {
1370
+ // Best-effort cleanup
1371
+ }
1372
+
1373
+ return { success: false, error: e.message };
1374
+ }
1375
+ }
1376
+
1377
+ if (require.main === module) { main(); }
1378
+ module.exports = { parseStateMd, parseRoadmapMd, parseYamlFrontmatter, parseMustHaves, countMustHaves, stateLoad, stateCheckProgress, configLoad, configClearCache, configValidate, lockedFileUpdate, planIndex, determinePhaseStatus, findFiles, atomicWrite, tailLines, frontmatter, mustHavesCollect, phaseInfo, stateUpdate, roadmapUpdateStatus, roadmapUpdatePlans, updateLegacyStateField, updateFrontmatterField, updateTableRow, findRoadmapRow, resolveDepthProfile, DEPTH_PROFILE_DEFAULTS, historyAppend, historyLoad, VALID_STATUS_TRANSITIONS, validateStatusTransition };