@lipter7/blueprint 2.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (125) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +626 -0
  3. package/agents/bp-codebase-mapper.md +761 -0
  4. package/agents/bp-debugger.md +1198 -0
  5. package/agents/bp-executor.md +403 -0
  6. package/agents/bp-integration-checker.md +423 -0
  7. package/agents/bp-phase-researcher.md +469 -0
  8. package/agents/bp-plan-checker.md +622 -0
  9. package/agents/bp-planner.md +1157 -0
  10. package/agents/bp-project-researcher.md +618 -0
  11. package/agents/bp-research-synthesizer.md +236 -0
  12. package/agents/bp-roadmapper.md +605 -0
  13. package/agents/bp-verifier.md +523 -0
  14. package/bin/install.js +1754 -0
  15. package/blueprint/bin/blueprint-tools.js +4597 -0
  16. package/blueprint/bin/blueprint-tools.test.js +2033 -0
  17. package/blueprint/references/checkpoints.md +775 -0
  18. package/blueprint/references/continuation-format.md +249 -0
  19. package/blueprint/references/decimal-phase-calculation.md +65 -0
  20. package/blueprint/references/git-integration.md +248 -0
  21. package/blueprint/references/git-planning-commit.md +38 -0
  22. package/blueprint/references/model-profile-resolution.md +32 -0
  23. package/blueprint/references/model-profiles.md +73 -0
  24. package/blueprint/references/phase-argument-parsing.md +61 -0
  25. package/blueprint/references/planning-config.md +194 -0
  26. package/blueprint/references/questioning.md +141 -0
  27. package/blueprint/references/tdd.md +263 -0
  28. package/blueprint/references/ui-brand.md +160 -0
  29. package/blueprint/references/verification-patterns.md +612 -0
  30. package/blueprint/templates/DEBUG.md +159 -0
  31. package/blueprint/templates/UAT.md +247 -0
  32. package/blueprint/templates/codebase/architecture.md +255 -0
  33. package/blueprint/templates/codebase/concerns.md +310 -0
  34. package/blueprint/templates/codebase/conventions.md +307 -0
  35. package/blueprint/templates/codebase/integrations.md +280 -0
  36. package/blueprint/templates/codebase/stack.md +186 -0
  37. package/blueprint/templates/codebase/structure.md +285 -0
  38. package/blueprint/templates/codebase/testing.md +480 -0
  39. package/blueprint/templates/config.json +35 -0
  40. package/blueprint/templates/context.md +283 -0
  41. package/blueprint/templates/continue-here.md +78 -0
  42. package/blueprint/templates/debug-subagent-prompt.md +91 -0
  43. package/blueprint/templates/discovery.md +146 -0
  44. package/blueprint/templates/milestone-archive.md +123 -0
  45. package/blueprint/templates/milestone.md +115 -0
  46. package/blueprint/templates/phase-prompt.md +567 -0
  47. package/blueprint/templates/planner-subagent-prompt.md +117 -0
  48. package/blueprint/templates/project.md +184 -0
  49. package/blueprint/templates/requirements.md +231 -0
  50. package/blueprint/templates/research-project/ARCHITECTURE.md +204 -0
  51. package/blueprint/templates/research-project/FEATURES.md +147 -0
  52. package/blueprint/templates/research-project/PITFALLS.md +200 -0
  53. package/blueprint/templates/research-project/STACK.md +120 -0
  54. package/blueprint/templates/research-project/SUMMARY.md +170 -0
  55. package/blueprint/templates/research.md +552 -0
  56. package/blueprint/templates/roadmap.md +202 -0
  57. package/blueprint/templates/state.md +176 -0
  58. package/blueprint/templates/summary-complex.md +59 -0
  59. package/blueprint/templates/summary-minimal.md +41 -0
  60. package/blueprint/templates/summary-standard.md +48 -0
  61. package/blueprint/templates/summary.md +246 -0
  62. package/blueprint/templates/user-setup.md +311 -0
  63. package/blueprint/templates/verification-report.md +322 -0
  64. package/blueprint/workflows/add-phase.md +111 -0
  65. package/blueprint/workflows/add-todo.md +157 -0
  66. package/blueprint/workflows/audit-milestone.md +241 -0
  67. package/blueprint/workflows/check-todos.md +176 -0
  68. package/blueprint/workflows/complete-milestone.md +644 -0
  69. package/blueprint/workflows/diagnose-issues.md +219 -0
  70. package/blueprint/workflows/discovery-phase.md +289 -0
  71. package/blueprint/workflows/discuss-phase.md +408 -0
  72. package/blueprint/workflows/execute-phase.md +338 -0
  73. package/blueprint/workflows/execute-plan.md +437 -0
  74. package/blueprint/workflows/help.md +470 -0
  75. package/blueprint/workflows/insert-phase.md +129 -0
  76. package/blueprint/workflows/list-phase-assumptions.md +178 -0
  77. package/blueprint/workflows/map-codebase.md +327 -0
  78. package/blueprint/workflows/new-milestone.md +373 -0
  79. package/blueprint/workflows/new-project.md +958 -0
  80. package/blueprint/workflows/pause-work.md +122 -0
  81. package/blueprint/workflows/plan-milestone-gaps.md +256 -0
  82. package/blueprint/workflows/plan-phase.md +376 -0
  83. package/blueprint/workflows/progress.md +385 -0
  84. package/blueprint/workflows/quick.md +230 -0
  85. package/blueprint/workflows/remove-phase.md +154 -0
  86. package/blueprint/workflows/research-phase.md +74 -0
  87. package/blueprint/workflows/resume-project.md +306 -0
  88. package/blueprint/workflows/set-profile.md +80 -0
  89. package/blueprint/workflows/settings.md +145 -0
  90. package/blueprint/workflows/transition.md +493 -0
  91. package/blueprint/workflows/update.md +212 -0
  92. package/blueprint/workflows/verify-phase.md +226 -0
  93. package/blueprint/workflows/verify-work.md +570 -0
  94. package/commands/bp/add-phase.md +39 -0
  95. package/commands/bp/add-todo.md +42 -0
  96. package/commands/bp/audit-milestone.md +42 -0
  97. package/commands/bp/check-todos.md +41 -0
  98. package/commands/bp/complete-milestone.md +136 -0
  99. package/commands/bp/debug.md +162 -0
  100. package/commands/bp/discuss-phase.md +86 -0
  101. package/commands/bp/execute-phase.md +42 -0
  102. package/commands/bp/help.md +22 -0
  103. package/commands/bp/insert-phase.md +33 -0
  104. package/commands/bp/join-discord.md +18 -0
  105. package/commands/bp/list-phase-assumptions.md +50 -0
  106. package/commands/bp/map-codebase.md +71 -0
  107. package/commands/bp/new-milestone.md +51 -0
  108. package/commands/bp/new-project.md +42 -0
  109. package/commands/bp/pause-work.md +35 -0
  110. package/commands/bp/plan-milestone-gaps.md +40 -0
  111. package/commands/bp/plan-phase.md +44 -0
  112. package/commands/bp/progress.md +24 -0
  113. package/commands/bp/quick.md +38 -0
  114. package/commands/bp/reapply-patches.md +110 -0
  115. package/commands/bp/remove-phase.md +32 -0
  116. package/commands/bp/research-phase.md +187 -0
  117. package/commands/bp/resume-work.md +40 -0
  118. package/commands/bp/set-profile.md +34 -0
  119. package/commands/bp/settings.md +36 -0
  120. package/commands/bp/update.md +37 -0
  121. package/commands/bp/verify-work.md +39 -0
  122. package/hooks/dist/bp-check-update.js +62 -0
  123. package/hooks/dist/bp-statusline.js +91 -0
  124. package/package.json +48 -0
  125. package/scripts/build-hooks.js +42 -0
@@ -0,0 +1,4597 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Blueprint Tools — CLI utility for Blueprint workflow operations
5
+ *
6
+ * Replaces repetitive inline bash patterns across ~50 Blueprint command/workflow/agent files.
7
+ * Centralizes: config parsing, model resolution, phase lookup, git commits, summary verification.
8
+ *
9
+ * Usage: node blueprint-tools.js <command> [args] [--raw]
10
+ *
11
+ * Atomic Commands:
12
+ * state load Load project config + state
13
+ * state update <field> <value> Update a STATE.md field
14
+ * state get [section] Get STATE.md content or section
15
+ * state patch --field val ... Batch update STATE.md fields
16
+ * resolve-model <agent-type> Get model for agent based on profile
17
+ * find-phase <phase> Find phase directory by number
18
+ * commit <message> [--files f1 f2] Commit planning docs
19
+ * verify-summary <path> Verify a SUMMARY.md file
20
+ * generate-slug <text> Convert text to URL-safe slug
21
+ * current-timestamp [format] Get timestamp (full|date|filename)
22
+ * list-todos [area] Count and enumerate pending todos
23
+ * verify-path-exists <path> Check file/directory existence
24
+ * config-ensure-section Initialize .blueprint/config.json
25
+ * history-digest Aggregate all SUMMARY.md data
26
+ * summary-extract <path> [--fields] Extract structured data from SUMMARY.md
27
+ * state-snapshot Structured parse of STATE.md
28
+ * phase-plan-index <phase> Index plans with waves and status
29
+ * websearch <query> Search web via Brave API (if configured)
30
+ * [--limit N] [--freshness day|week|month]
31
+ *
32
+ * Phase Operations:
33
+ * phase next-decimal <phase> Calculate next decimal phase number
34
+ * phase add <description> Append new phase to roadmap + create dir
35
+ * phase insert <after> <description> Insert decimal phase after existing
36
+ * phase remove <phase> [--force] Remove phase, renumber all subsequent
37
+ * phase complete <phase> Mark phase done, update state + roadmap
38
+ *
39
+ * Roadmap Operations:
40
+ * roadmap get-phase <phase> Extract phase section from ROADMAP.md
41
+ * roadmap analyze Full roadmap parse with disk status
42
+ *
43
+ * Milestone Operations:
44
+ * milestone complete <version> Archive milestone, create MILESTONES.md
45
+ * [--name <name>]
46
+ *
47
+ * Validation:
48
+ * validate consistency Check phase numbering, disk/roadmap sync
49
+ *
50
+ * Progress:
51
+ * progress [json|table|bar] Render progress in various formats
52
+ *
53
+ * Todos:
54
+ * todo complete <filename> Move todo from pending to completed
55
+ *
56
+ * Scaffolding:
57
+ * scaffold context --phase <N> Create CONTEXT.md template
58
+ * scaffold uat --phase <N> Create UAT.md template
59
+ * scaffold verification --phase <N> Create VERIFICATION.md template
60
+ * scaffold phase-dir --phase <N> Create phase directory
61
+ * --name <name>
62
+ *
63
+ * Frontmatter CRUD:
64
+ * frontmatter get <file> [--field k] Extract frontmatter as JSON
65
+ * frontmatter set <file> --field k Update single frontmatter field
66
+ * --value jsonVal
67
+ * frontmatter merge <file> Merge JSON into frontmatter
68
+ * --data '{json}'
69
+ * frontmatter validate <file> Validate required fields
70
+ * --schema plan|summary|verification
71
+ *
72
+ * Verification Suite:
73
+ * verify plan-structure <file> Check PLAN.md structure + tasks
74
+ * verify phase-completeness <phase> Check all plans have summaries
75
+ * verify references <file> Check @-refs + paths resolve
76
+ * verify commits <h1> [h2] ... Batch verify commit hashes
77
+ * verify artifacts <plan-file> Check must_haves.artifacts
78
+ * verify key-links <plan-file> Check must_haves.key_links
79
+ *
80
+ * Template Fill:
81
+ * template fill summary --phase N Create pre-filled SUMMARY.md
82
+ * [--plan M] [--name "..."]
83
+ * [--fields '{json}']
84
+ * template fill plan --phase N Create pre-filled PLAN.md
85
+ * [--plan M] [--type execute|tdd]
86
+ * [--wave N] [--fields '{json}']
87
+ * template fill verification Create pre-filled VERIFICATION.md
88
+ * --phase N [--fields '{json}']
89
+ *
90
+ * State Progression:
91
+ * state advance-plan Increment plan counter
92
+ * state record-metric --phase N Record execution metrics
93
+ * --plan M --duration Xmin
94
+ * [--tasks N] [--files N]
95
+ * state update-progress Recalculate progress bar
96
+ * state add-decision --summary "..." Add decision to STATE.md
97
+ * [--phase N] [--rationale "..."]
98
+ * state add-blocker --text "..." Add blocker
99
+ * state resolve-blocker --text "..." Remove blocker
100
+ * state record-session Update session continuity
101
+ * --stopped-at "..."
102
+ * [--resume-file path]
103
+ *
104
+ * Compound Commands (workflow-specific initialization):
105
+ * init execute-phase <phase> All context for execute-phase workflow
106
+ * init plan-phase <phase> All context for plan-phase workflow
107
+ * init new-project All context for new-project workflow
108
+ * init new-milestone All context for new-milestone workflow
109
+ * init quick <description> All context for quick workflow
110
+ * init resume All context for resume-project workflow
111
+ * init verify-work <phase> All context for verify-work workflow
112
+ * init phase-op <phase> Generic phase operation context
113
+ * init todos [area] All context for todo workflows
114
+ * init milestone-op All context for milestone operations
115
+ * init map-codebase All context for map-codebase workflow
116
+ * init progress All context for progress workflow
117
+ */
118
+
119
+ const fs = require('fs');
120
+ const path = require('path');
121
+ const { execSync } = require('child_process');
122
+
123
+ // ─── Model Profile Table ─────────────────────────────────────────────────────
124
+
125
+ const MODEL_PROFILES = {
126
+ 'bp-planner': { quality: 'opus', balanced: 'opus', budget: 'sonnet' },
127
+ 'bp-roadmapper': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
128
+ 'bp-executor': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
129
+ 'bp-phase-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
130
+ 'bp-project-researcher': { quality: 'opus', balanced: 'sonnet', budget: 'haiku' },
131
+ 'bp-research-synthesizer': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
132
+ 'bp-debugger': { quality: 'opus', balanced: 'sonnet', budget: 'sonnet' },
133
+ 'bp-codebase-mapper': { quality: 'sonnet', balanced: 'haiku', budget: 'haiku' },
134
+ 'bp-verifier': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
135
+ 'bp-plan-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
136
+ 'bp-integration-checker': { quality: 'sonnet', balanced: 'sonnet', budget: 'haiku' },
137
+ };
138
+
139
+ // ─── Helpers ──────────────────────────────────────────────────────────────────
140
+
141
+ function parseIncludeFlag(args) {
142
+ const includeIndex = args.indexOf('--include');
143
+ if (includeIndex === -1) return new Set();
144
+ const includeValue = args[includeIndex + 1];
145
+ if (!includeValue) return new Set();
146
+ return new Set(includeValue.split(',').map(s => s.trim()));
147
+ }
148
+
149
+ function safeReadFile(filePath) {
150
+ try {
151
+ return fs.readFileSync(filePath, 'utf-8');
152
+ } catch {
153
+ return null;
154
+ }
155
+ }
156
+
157
+ function loadConfig(cwd) {
158
+ const configPath = path.join(cwd, '.blueprint', 'config.json');
159
+ const defaults = {
160
+ model_profile: 'balanced',
161
+ commit_docs: true,
162
+ search_gitignored: false,
163
+ branching_strategy: 'none',
164
+ phase_branch_template: 'bp/phase-{phase}-{slug}',
165
+ milestone_branch_template: 'bp/{milestone}-{slug}',
166
+ research: true,
167
+ plan_checker: true,
168
+ verifier: true,
169
+ parallelization: true,
170
+ brave_search: false,
171
+ };
172
+
173
+ try {
174
+ const raw = fs.readFileSync(configPath, 'utf-8');
175
+ const parsed = JSON.parse(raw);
176
+
177
+ const get = (key, nested) => {
178
+ if (parsed[key] !== undefined) return parsed[key];
179
+ if (nested && parsed[nested.section] && parsed[nested.section][nested.field] !== undefined) {
180
+ return parsed[nested.section][nested.field];
181
+ }
182
+ return undefined;
183
+ };
184
+
185
+ const parallelization = (() => {
186
+ const val = get('parallelization');
187
+ if (typeof val === 'boolean') return val;
188
+ if (typeof val === 'object' && val !== null && 'enabled' in val) return val.enabled;
189
+ return defaults.parallelization;
190
+ })();
191
+
192
+ return {
193
+ model_profile: get('model_profile') ?? defaults.model_profile,
194
+ commit_docs: get('commit_docs', { section: 'planning', field: 'commit_docs' }) ?? defaults.commit_docs,
195
+ search_gitignored: get('search_gitignored', { section: 'planning', field: 'search_gitignored' }) ?? defaults.search_gitignored,
196
+ branching_strategy: get('branching_strategy', { section: 'git', field: 'branching_strategy' }) ?? defaults.branching_strategy,
197
+ phase_branch_template: get('phase_branch_template', { section: 'git', field: 'phase_branch_template' }) ?? defaults.phase_branch_template,
198
+ milestone_branch_template: get('milestone_branch_template', { section: 'git', field: 'milestone_branch_template' }) ?? defaults.milestone_branch_template,
199
+ research: get('research', { section: 'workflow', field: 'research' }) ?? defaults.research,
200
+ plan_checker: get('plan_checker', { section: 'workflow', field: 'plan_check' }) ?? defaults.plan_checker,
201
+ verifier: get('verifier', { section: 'workflow', field: 'verifier' }) ?? defaults.verifier,
202
+ parallelization,
203
+ brave_search: get('brave_search') ?? defaults.brave_search,
204
+ };
205
+ } catch {
206
+ return defaults;
207
+ }
208
+ }
209
+
210
+ function isGitIgnored(cwd, targetPath) {
211
+ try {
212
+ execSync('git check-ignore -q -- ' + targetPath.replace(/[^a-zA-Z0-9._\-/]/g, ''), {
213
+ cwd,
214
+ stdio: 'pipe',
215
+ });
216
+ return true;
217
+ } catch {
218
+ return false;
219
+ }
220
+ }
221
+
222
+ function execGit(cwd, args) {
223
+ try {
224
+ const escaped = args.map(a => {
225
+ if (/^[a-zA-Z0-9._\-/=:@]+$/.test(a)) return a;
226
+ return "'" + a.replace(/'/g, "'\\''") + "'";
227
+ });
228
+ const stdout = execSync('git ' + escaped.join(' '), {
229
+ cwd,
230
+ stdio: 'pipe',
231
+ encoding: 'utf-8',
232
+ });
233
+ return { exitCode: 0, stdout: stdout.trim(), stderr: '' };
234
+ } catch (err) {
235
+ return {
236
+ exitCode: err.status ?? 1,
237
+ stdout: (err.stdout ?? '').toString().trim(),
238
+ stderr: (err.stderr ?? '').toString().trim(),
239
+ };
240
+ }
241
+ }
242
+
243
+ function normalizePhaseName(phase) {
244
+ const match = phase.match(/^(\d+(?:\.\d+)?)/);
245
+ if (!match) return phase;
246
+ const num = match[1];
247
+ const parts = num.split('.');
248
+ const padded = parts[0].padStart(2, '0');
249
+ return parts.length > 1 ? `${padded}.${parts[1]}` : padded;
250
+ }
251
+
252
+ function extractFrontmatter(content) {
253
+ const frontmatter = {};
254
+ const match = content.match(/^---\n([\s\S]+?)\n---/);
255
+ if (!match) return frontmatter;
256
+
257
+ const yaml = match[1];
258
+ const lines = yaml.split('\n');
259
+
260
+ // Stack to track nested objects: [{obj, key, indent}]
261
+ // obj = object to write to, key = current key collecting array items, indent = indentation level
262
+ let stack = [{ obj: frontmatter, key: null, indent: -1 }];
263
+
264
+ for (const line of lines) {
265
+ // Skip empty lines
266
+ if (line.trim() === '') continue;
267
+
268
+ // Calculate indentation (number of leading spaces)
269
+ const indentMatch = line.match(/^(\s*)/);
270
+ const indent = indentMatch ? indentMatch[1].length : 0;
271
+
272
+ // Pop stack back to appropriate level
273
+ while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
274
+ stack.pop();
275
+ }
276
+
277
+ const current = stack[stack.length - 1];
278
+
279
+ // Check for key: value pattern
280
+ const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
281
+ if (keyMatch) {
282
+ const key = keyMatch[2];
283
+ const value = keyMatch[3].trim();
284
+
285
+ if (value === '' || value === '[') {
286
+ // Key with no value or opening bracket — could be nested object or array
287
+ // We'll determine based on next lines, for now create placeholder
288
+ current.obj[key] = value === '[' ? [] : {};
289
+ current.key = null;
290
+ // Push new context for potential nested content
291
+ stack.push({ obj: current.obj[key], key: null, indent });
292
+ } else if (value.startsWith('[') && value.endsWith(']')) {
293
+ // Inline array: key: [a, b, c]
294
+ current.obj[key] = value.slice(1, -1).split(',').map(s => s.trim().replace(/^["']|["']$/g, '')).filter(Boolean);
295
+ current.key = null;
296
+ } else {
297
+ // Simple key: value
298
+ current.obj[key] = value.replace(/^["']|["']$/g, '');
299
+ current.key = null;
300
+ }
301
+ } else if (line.trim().startsWith('- ')) {
302
+ // Array item
303
+ const itemValue = line.trim().slice(2).replace(/^["']|["']$/g, '');
304
+
305
+ // If current context is an empty object, convert to array
306
+ if (typeof current.obj === 'object' && !Array.isArray(current.obj) && Object.keys(current.obj).length === 0) {
307
+ // Find the key in parent that points to this object and convert it
308
+ const parent = stack.length > 1 ? stack[stack.length - 2] : null;
309
+ if (parent) {
310
+ for (const k of Object.keys(parent.obj)) {
311
+ if (parent.obj[k] === current.obj) {
312
+ parent.obj[k] = [itemValue];
313
+ current.obj = parent.obj[k];
314
+ break;
315
+ }
316
+ }
317
+ }
318
+ } else if (Array.isArray(current.obj)) {
319
+ current.obj.push(itemValue);
320
+ }
321
+ }
322
+ }
323
+
324
+ return frontmatter;
325
+ }
326
+
327
+ function reconstructFrontmatter(obj) {
328
+ const lines = [];
329
+ for (const [key, value] of Object.entries(obj)) {
330
+ if (value === null || value === undefined) continue;
331
+ if (Array.isArray(value)) {
332
+ if (value.length === 0) {
333
+ lines.push(`${key}: []`);
334
+ } else if (value.every(v => typeof v === 'string') && value.length <= 3 && value.join(', ').length < 60) {
335
+ lines.push(`${key}: [${value.join(', ')}]`);
336
+ } else {
337
+ lines.push(`${key}:`);
338
+ for (const item of value) {
339
+ lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
340
+ }
341
+ }
342
+ } else if (typeof value === 'object') {
343
+ lines.push(`${key}:`);
344
+ for (const [subkey, subval] of Object.entries(value)) {
345
+ if (subval === null || subval === undefined) continue;
346
+ if (Array.isArray(subval)) {
347
+ if (subval.length === 0) {
348
+ lines.push(` ${subkey}: []`);
349
+ } else if (subval.every(v => typeof v === 'string') && subval.length <= 3 && subval.join(', ').length < 60) {
350
+ lines.push(` ${subkey}: [${subval.join(', ')}]`);
351
+ } else {
352
+ lines.push(` ${subkey}:`);
353
+ for (const item of subval) {
354
+ lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#')) ? `"${item}"` : item}`);
355
+ }
356
+ }
357
+ } else if (typeof subval === 'object') {
358
+ lines.push(` ${subkey}:`);
359
+ for (const [subsubkey, subsubval] of Object.entries(subval)) {
360
+ if (subsubval === null || subsubval === undefined) continue;
361
+ if (Array.isArray(subsubval)) {
362
+ if (subsubval.length === 0) {
363
+ lines.push(` ${subsubkey}: []`);
364
+ } else {
365
+ lines.push(` ${subsubkey}:`);
366
+ for (const item of subsubval) {
367
+ lines.push(` - ${item}`);
368
+ }
369
+ }
370
+ } else {
371
+ lines.push(` ${subsubkey}: ${subsubval}`);
372
+ }
373
+ }
374
+ } else {
375
+ const sv = String(subval);
376
+ lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') ? `"${sv}"` : sv}`);
377
+ }
378
+ }
379
+ } else {
380
+ const sv = String(value);
381
+ if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{')) {
382
+ lines.push(`${key}: "${sv}"`);
383
+ } else {
384
+ lines.push(`${key}: ${sv}`);
385
+ }
386
+ }
387
+ }
388
+ return lines.join('\n');
389
+ }
390
+
391
+ function spliceFrontmatter(content, newObj) {
392
+ const yamlStr = reconstructFrontmatter(newObj);
393
+ const match = content.match(/^---\n[\s\S]+?\n---/);
394
+ if (match) {
395
+ return `---\n${yamlStr}\n---` + content.slice(match[0].length);
396
+ }
397
+ return `---\n${yamlStr}\n---\n\n` + content;
398
+ }
399
+
400
+ function parseMustHavesBlock(content, blockName) {
401
+ // Extract a specific block from must_haves in raw frontmatter YAML
402
+ // Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
403
+ const fmMatch = content.match(/^---\n([\s\S]+?)\n---/);
404
+ if (!fmMatch) return [];
405
+
406
+ const yaml = fmMatch[1];
407
+ // Find the block (e.g., "truths:", "artifacts:", "key_links:")
408
+ const blockPattern = new RegExp(`^\\s{4}${blockName}:\\s*$`, 'm');
409
+ const blockStart = yaml.search(blockPattern);
410
+ if (blockStart === -1) return [];
411
+
412
+ const afterBlock = yaml.slice(blockStart);
413
+ const blockLines = afterBlock.split('\n').slice(1); // skip the header line
414
+
415
+ const items = [];
416
+ let current = null;
417
+
418
+ for (const line of blockLines) {
419
+ // Stop at same or lower indent level (non-continuation)
420
+ if (line.trim() === '') continue;
421
+ const indent = line.match(/^(\s*)/)[1].length;
422
+ if (indent <= 4 && line.trim() !== '') break; // back to must_haves level or higher
423
+
424
+ if (line.match(/^\s{6}-\s+/)) {
425
+ // New list item at 6-space indent
426
+ if (current) items.push(current);
427
+ current = {};
428
+ // Check if it's a simple string item
429
+ const simpleMatch = line.match(/^\s{6}-\s+"?([^"]+)"?\s*$/);
430
+ if (simpleMatch && !line.includes(':')) {
431
+ current = simpleMatch[1];
432
+ } else {
433
+ // Key-value on same line as dash: "- path: value"
434
+ const kvMatch = line.match(/^\s{6}-\s+(\w+):\s*"?([^"]*)"?\s*$/);
435
+ if (kvMatch) {
436
+ current = {};
437
+ current[kvMatch[1]] = kvMatch[2];
438
+ }
439
+ }
440
+ } else if (current && typeof current === 'object') {
441
+ // Continuation key-value at 8+ space indent
442
+ const kvMatch = line.match(/^\s{8,}(\w+):\s*"?([^"]*)"?\s*$/);
443
+ if (kvMatch) {
444
+ const val = kvMatch[2];
445
+ // Try to parse as number
446
+ current[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
447
+ }
448
+ // Array items under a key
449
+ const arrMatch = line.match(/^\s{10,}-\s+"?([^"]+)"?\s*$/);
450
+ if (arrMatch) {
451
+ // Find the last key added and convert to array
452
+ const keys = Object.keys(current);
453
+ const lastKey = keys[keys.length - 1];
454
+ if (lastKey && !Array.isArray(current[lastKey])) {
455
+ current[lastKey] = current[lastKey] ? [current[lastKey]] : [];
456
+ }
457
+ if (lastKey) current[lastKey].push(arrMatch[1]);
458
+ }
459
+ }
460
+ }
461
+ if (current) items.push(current);
462
+
463
+ return items;
464
+ }
465
+
466
+ function output(result, raw, rawValue) {
467
+ if (raw && rawValue !== undefined) {
468
+ process.stdout.write(String(rawValue));
469
+ } else {
470
+ process.stdout.write(JSON.stringify(result, null, 2));
471
+ }
472
+ process.exit(0);
473
+ }
474
+
475
+ function error(message) {
476
+ process.stderr.write('Error: ' + message + '\n');
477
+ process.exit(1);
478
+ }
479
+
480
+ // ─── Commands ─────────────────────────────────────────────────────────────────
481
+
482
+ function cmdGenerateSlug(text, raw) {
483
+ if (!text) {
484
+ error('text required for slug generation');
485
+ }
486
+
487
+ const slug = text
488
+ .toLowerCase()
489
+ .replace(/[^a-z0-9]+/g, '-')
490
+ .replace(/^-+|-+$/g, '');
491
+
492
+ const result = { slug };
493
+ output(result, raw, slug);
494
+ }
495
+
496
+ function cmdCurrentTimestamp(format, raw) {
497
+ const now = new Date();
498
+ let result;
499
+
500
+ switch (format) {
501
+ case 'date':
502
+ result = now.toISOString().split('T')[0];
503
+ break;
504
+ case 'filename':
505
+ result = now.toISOString().replace(/:/g, '-').replace(/\..+/, '');
506
+ break;
507
+ case 'full':
508
+ default:
509
+ result = now.toISOString();
510
+ break;
511
+ }
512
+
513
+ output({ timestamp: result }, raw, result);
514
+ }
515
+
516
+ function cmdListTodos(cwd, area, raw) {
517
+ const pendingDir = path.join(cwd, '.blueprint', 'todos', 'pending');
518
+
519
+ let count = 0;
520
+ const todos = [];
521
+
522
+ try {
523
+ const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
524
+
525
+ for (const file of files) {
526
+ try {
527
+ const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
528
+ const createdMatch = content.match(/^created:\s*(.+)$/m);
529
+ const titleMatch = content.match(/^title:\s*(.+)$/m);
530
+ const areaMatch = content.match(/^area:\s*(.+)$/m);
531
+
532
+ const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
533
+
534
+ // Apply area filter if specified
535
+ if (area && todoArea !== area) continue;
536
+
537
+ count++;
538
+ todos.push({
539
+ file,
540
+ created: createdMatch ? createdMatch[1].trim() : 'unknown',
541
+ title: titleMatch ? titleMatch[1].trim() : 'Untitled',
542
+ area: todoArea,
543
+ path: path.join('.blueprint', 'todos', 'pending', file),
544
+ });
545
+ } catch {}
546
+ }
547
+ } catch {}
548
+
549
+ const result = { count, todos };
550
+ output(result, raw, count.toString());
551
+ }
552
+
553
+ function cmdVerifyPathExists(cwd, targetPath, raw) {
554
+ if (!targetPath) {
555
+ error('path required for verification');
556
+ }
557
+
558
+ const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
559
+
560
+ try {
561
+ const stats = fs.statSync(fullPath);
562
+ const type = stats.isDirectory() ? 'directory' : stats.isFile() ? 'file' : 'other';
563
+ const result = { exists: true, type };
564
+ output(result, raw, 'true');
565
+ } catch {
566
+ const result = { exists: false, type: null };
567
+ output(result, raw, 'false');
568
+ }
569
+ }
570
+
571
+ function cmdConfigEnsureSection(cwd, raw) {
572
+ const configPath = path.join(cwd, '.blueprint', 'config.json');
573
+ const planningDir = path.join(cwd, '.blueprint');
574
+
575
+ // Ensure .blueprint directory exists
576
+ try {
577
+ if (!fs.existsSync(planningDir)) {
578
+ fs.mkdirSync(planningDir, { recursive: true });
579
+ }
580
+ } catch (err) {
581
+ error('Failed to create .blueprint directory: ' + err.message);
582
+ }
583
+
584
+ // Check if config already exists
585
+ if (fs.existsSync(configPath)) {
586
+ const result = { created: false, reason: 'already_exists' };
587
+ output(result, raw, 'exists');
588
+ return;
589
+ }
590
+
591
+ // Detect Brave Search API key availability
592
+ const homedir = require('os').homedir();
593
+ const braveKeyFile = path.join(homedir, '.blueprint', 'brave_api_key');
594
+ const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile));
595
+
596
+ // Create default config
597
+ const defaults = {
598
+ model_profile: 'balanced',
599
+ commit_docs: true,
600
+ search_gitignored: false,
601
+ branching_strategy: 'none',
602
+ phase_branch_template: 'bp/phase-{phase}-{slug}',
603
+ milestone_branch_template: 'bp/{milestone}-{slug}',
604
+ workflow: {
605
+ research: true,
606
+ plan_check: true,
607
+ verifier: true,
608
+ },
609
+ parallelization: true,
610
+ brave_search: hasBraveSearch,
611
+ };
612
+
613
+ try {
614
+ fs.writeFileSync(configPath, JSON.stringify(defaults, null, 2), 'utf-8');
615
+ const result = { created: true, path: '.blueprint/config.json' };
616
+ output(result, raw, 'created');
617
+ } catch (err) {
618
+ error('Failed to create config.json: ' + err.message);
619
+ }
620
+ }
621
+
622
+ function cmdConfigSet(cwd, keyPath, value, raw) {
623
+ const configPath = path.join(cwd, '.blueprint', 'config.json');
624
+
625
+ if (!keyPath) {
626
+ error('Usage: config-set <key.path> <value>');
627
+ }
628
+
629
+ // Parse value (handle booleans and numbers)
630
+ let parsedValue = value;
631
+ if (value === 'true') parsedValue = true;
632
+ else if (value === 'false') parsedValue = false;
633
+ else if (!isNaN(value) && value !== '') parsedValue = Number(value);
634
+
635
+ // Load existing config or start with empty object
636
+ let config = {};
637
+ try {
638
+ if (fs.existsSync(configPath)) {
639
+ config = JSON.parse(fs.readFileSync(configPath, 'utf-8'));
640
+ }
641
+ } catch (err) {
642
+ error('Failed to read config.json: ' + err.message);
643
+ }
644
+
645
+ // Set nested value using dot notation (e.g., "workflow.research")
646
+ const keys = keyPath.split('.');
647
+ let current = config;
648
+ for (let i = 0; i < keys.length - 1; i++) {
649
+ const key = keys[i];
650
+ if (current[key] === undefined || typeof current[key] !== 'object') {
651
+ current[key] = {};
652
+ }
653
+ current = current[key];
654
+ }
655
+ current[keys[keys.length - 1]] = parsedValue;
656
+
657
+ // Write back
658
+ try {
659
+ fs.writeFileSync(configPath, JSON.stringify(config, null, 2), 'utf-8');
660
+ const result = { updated: true, key: keyPath, value: parsedValue };
661
+ output(result, raw, `${keyPath}=${parsedValue}`);
662
+ } catch (err) {
663
+ error('Failed to write config.json: ' + err.message);
664
+ }
665
+ }
666
+
667
+ function cmdHistoryDigest(cwd, raw) {
668
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
669
+ const digest = { phases: {}, decisions: [], tech_stack: new Set() };
670
+
671
+ if (!fs.existsSync(phasesDir)) {
672
+ digest.tech_stack = [];
673
+ output(digest, raw);
674
+ return;
675
+ }
676
+
677
+ try {
678
+ const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
679
+ .filter(e => e.isDirectory())
680
+ .map(e => e.name)
681
+ .sort();
682
+
683
+ for (const dir of phaseDirs) {
684
+ const dirPath = path.join(phasesDir, dir);
685
+ const summaries = fs.readdirSync(dirPath).filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
686
+
687
+ for (const summary of summaries) {
688
+ try {
689
+ const content = fs.readFileSync(path.join(dirPath, summary), 'utf-8');
690
+ const fm = extractFrontmatter(content);
691
+
692
+ const phaseNum = fm.phase || dir.split('-')[0];
693
+
694
+ if (!digest.phases[phaseNum]) {
695
+ digest.phases[phaseNum] = {
696
+ name: fm.name || dir.split('-').slice(1).join(' ') || 'Unknown',
697
+ provides: new Set(),
698
+ affects: new Set(),
699
+ patterns: new Set(),
700
+ };
701
+ }
702
+
703
+ // Merge provides
704
+ if (fm['dependency-graph'] && fm['dependency-graph'].provides) {
705
+ fm['dependency-graph'].provides.forEach(p => digest.phases[phaseNum].provides.add(p));
706
+ } else if (fm.provides) {
707
+ fm.provides.forEach(p => digest.phases[phaseNum].provides.add(p));
708
+ }
709
+
710
+ // Merge affects
711
+ if (fm['dependency-graph'] && fm['dependency-graph'].affects) {
712
+ fm['dependency-graph'].affects.forEach(a => digest.phases[phaseNum].affects.add(a));
713
+ }
714
+
715
+ // Merge patterns
716
+ if (fm['patterns-established']) {
717
+ fm['patterns-established'].forEach(p => digest.phases[phaseNum].patterns.add(p));
718
+ }
719
+
720
+ // Merge decisions
721
+ if (fm['key-decisions']) {
722
+ fm['key-decisions'].forEach(d => {
723
+ digest.decisions.push({ phase: phaseNum, decision: d });
724
+ });
725
+ }
726
+
727
+ // Merge tech stack
728
+ if (fm['tech-stack'] && fm['tech-stack'].added) {
729
+ fm['tech-stack'].added.forEach(t => digest.tech_stack.add(typeof t === 'string' ? t : t.name));
730
+ }
731
+
732
+ } catch (e) {
733
+ // Skip malformed summaries
734
+ }
735
+ }
736
+ }
737
+
738
+ // Convert Sets to Arrays for JSON output
739
+ Object.keys(digest.phases).forEach(p => {
740
+ digest.phases[p].provides = [...digest.phases[p].provides];
741
+ digest.phases[p].affects = [...digest.phases[p].affects];
742
+ digest.phases[p].patterns = [...digest.phases[p].patterns];
743
+ });
744
+ digest.tech_stack = [...digest.tech_stack];
745
+
746
+ output(digest, raw);
747
+ } catch (e) {
748
+ error('Failed to generate history digest: ' + e.message);
749
+ }
750
+ }
751
+
752
+ function cmdPhasesList(cwd, options, raw) {
753
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
754
+ const { type, phase } = options;
755
+
756
+ // If no phases directory, return empty
757
+ if (!fs.existsSync(phasesDir)) {
758
+ if (type) {
759
+ output({ files: [], count: 0 }, raw, '');
760
+ } else {
761
+ output({ directories: [], count: 0 }, raw, '');
762
+ }
763
+ return;
764
+ }
765
+
766
+ try {
767
+ // Get all phase directories
768
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
769
+ let dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
770
+
771
+ // Sort numerically (handles decimals: 01, 02, 02.1, 02.2, 03)
772
+ dirs.sort((a, b) => {
773
+ const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
774
+ const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
775
+ return aNum - bNum;
776
+ });
777
+
778
+ // If filtering by phase number
779
+ if (phase) {
780
+ const normalized = normalizePhaseName(phase);
781
+ const match = dirs.find(d => d.startsWith(normalized));
782
+ if (!match) {
783
+ output({ files: [], count: 0, phase_dir: null, error: 'Phase not found' }, raw, '');
784
+ return;
785
+ }
786
+ dirs = [match];
787
+ }
788
+
789
+ // If listing files of a specific type
790
+ if (type) {
791
+ const files = [];
792
+ for (const dir of dirs) {
793
+ const dirPath = path.join(phasesDir, dir);
794
+ const dirFiles = fs.readdirSync(dirPath);
795
+
796
+ let filtered;
797
+ if (type === 'plans') {
798
+ filtered = dirFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
799
+ } else if (type === 'summaries') {
800
+ filtered = dirFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
801
+ } else {
802
+ filtered = dirFiles;
803
+ }
804
+
805
+ files.push(...filtered.sort());
806
+ }
807
+
808
+ const result = {
809
+ files,
810
+ count: files.length,
811
+ phase_dir: phase ? dirs[0].replace(/^\d+(?:\.\d+)?-?/, '') : null,
812
+ };
813
+ output(result, raw, files.join('\n'));
814
+ return;
815
+ }
816
+
817
+ // Default: list directories
818
+ output({ directories: dirs, count: dirs.length }, raw, dirs.join('\n'));
819
+ } catch (e) {
820
+ error('Failed to list phases: ' + e.message);
821
+ }
822
+ }
823
+
824
+ function cmdRoadmapGetPhase(cwd, phaseNum, raw) {
825
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
826
+
827
+ if (!fs.existsSync(roadmapPath)) {
828
+ output({ found: false, error: 'ROADMAP.md not found' }, raw, '');
829
+ return;
830
+ }
831
+
832
+ try {
833
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
834
+
835
+ // Escape special regex chars in phase number, handle decimal
836
+ const escapedPhase = phaseNum.replace(/\./g, '\\.');
837
+
838
+ // Match "### Phase X:" or "### Phase X.Y:" with optional name
839
+ const phasePattern = new RegExp(
840
+ `###\\s*Phase\\s+${escapedPhase}:\\s*([^\\n]+)`,
841
+ 'i'
842
+ );
843
+ const headerMatch = content.match(phasePattern);
844
+
845
+ if (!headerMatch) {
846
+ output({ found: false, phase_number: phaseNum }, raw, '');
847
+ return;
848
+ }
849
+
850
+ const phaseName = headerMatch[1].trim();
851
+ const headerIndex = headerMatch.index;
852
+
853
+ // Find the end of this section (next ### or end of file)
854
+ const restOfContent = content.slice(headerIndex);
855
+ const nextHeaderMatch = restOfContent.match(/\n###\s+Phase\s+\d/i);
856
+ const sectionEnd = nextHeaderMatch
857
+ ? headerIndex + nextHeaderMatch.index
858
+ : content.length;
859
+
860
+ const section = content.slice(headerIndex, sectionEnd).trim();
861
+
862
+ // Extract goal if present
863
+ const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
864
+ const goal = goalMatch ? goalMatch[1].trim() : null;
865
+
866
+ output(
867
+ {
868
+ found: true,
869
+ phase_number: phaseNum,
870
+ phase_name: phaseName,
871
+ goal,
872
+ section,
873
+ },
874
+ raw,
875
+ section
876
+ );
877
+ } catch (e) {
878
+ error('Failed to read ROADMAP.md: ' + e.message);
879
+ }
880
+ }
881
+
882
+ function cmdPhaseNextDecimal(cwd, basePhase, raw) {
883
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
884
+ const normalized = normalizePhaseName(basePhase);
885
+
886
+ // Check if phases directory exists
887
+ if (!fs.existsSync(phasesDir)) {
888
+ output(
889
+ {
890
+ found: false,
891
+ base_phase: normalized,
892
+ next: `${normalized}.1`,
893
+ existing: [],
894
+ },
895
+ raw,
896
+ `${normalized}.1`
897
+ );
898
+ return;
899
+ }
900
+
901
+ try {
902
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
903
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
904
+
905
+ // Check if base phase exists
906
+ const baseExists = dirs.some(d => d.startsWith(normalized + '-') || d === normalized);
907
+
908
+ // Find existing decimal phases for this base
909
+ const decimalPattern = new RegExp(`^${normalized}\\.(\\d+)`);
910
+ const existingDecimals = [];
911
+
912
+ for (const dir of dirs) {
913
+ const match = dir.match(decimalPattern);
914
+ if (match) {
915
+ existingDecimals.push(`${normalized}.${match[1]}`);
916
+ }
917
+ }
918
+
919
+ // Sort numerically
920
+ existingDecimals.sort((a, b) => {
921
+ const aNum = parseFloat(a);
922
+ const bNum = parseFloat(b);
923
+ return aNum - bNum;
924
+ });
925
+
926
+ // Calculate next decimal
927
+ let nextDecimal;
928
+ if (existingDecimals.length === 0) {
929
+ nextDecimal = `${normalized}.1`;
930
+ } else {
931
+ const lastDecimal = existingDecimals[existingDecimals.length - 1];
932
+ const lastNum = parseInt(lastDecimal.split('.')[1], 10);
933
+ nextDecimal = `${normalized}.${lastNum + 1}`;
934
+ }
935
+
936
+ output(
937
+ {
938
+ found: baseExists,
939
+ base_phase: normalized,
940
+ next: nextDecimal,
941
+ existing: existingDecimals,
942
+ },
943
+ raw,
944
+ nextDecimal
945
+ );
946
+ } catch (e) {
947
+ error('Failed to calculate next decimal phase: ' + e.message);
948
+ }
949
+ }
950
+
951
+ function cmdStateLoad(cwd, raw) {
952
+ const config = loadConfig(cwd);
953
+ const planningDir = path.join(cwd, '.blueprint');
954
+
955
+ let stateRaw = '';
956
+ try {
957
+ stateRaw = fs.readFileSync(path.join(planningDir, 'STATE.md'), 'utf-8');
958
+ } catch {}
959
+
960
+ const configExists = fs.existsSync(path.join(planningDir, 'config.json'));
961
+ const roadmapExists = fs.existsSync(path.join(planningDir, 'ROADMAP.md'));
962
+ const stateExists = stateRaw.length > 0;
963
+
964
+ const result = {
965
+ config,
966
+ state_raw: stateRaw,
967
+ state_exists: stateExists,
968
+ roadmap_exists: roadmapExists,
969
+ config_exists: configExists,
970
+ };
971
+
972
+ // For --raw, output a condensed key=value format
973
+ if (raw) {
974
+ const c = config;
975
+ const lines = [
976
+ `model_profile=${c.model_profile}`,
977
+ `commit_docs=${c.commit_docs}`,
978
+ `branching_strategy=${c.branching_strategy}`,
979
+ `phase_branch_template=${c.phase_branch_template}`,
980
+ `milestone_branch_template=${c.milestone_branch_template}`,
981
+ `parallelization=${c.parallelization}`,
982
+ `research=${c.research}`,
983
+ `plan_checker=${c.plan_checker}`,
984
+ `verifier=${c.verifier}`,
985
+ `config_exists=${configExists}`,
986
+ `roadmap_exists=${roadmapExists}`,
987
+ `state_exists=${stateExists}`,
988
+ ];
989
+ process.stdout.write(lines.join('\n'));
990
+ process.exit(0);
991
+ }
992
+
993
+ output(result);
994
+ }
995
+
996
+ function cmdStateGet(cwd, section, raw) {
997
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
998
+ try {
999
+ const content = fs.readFileSync(statePath, 'utf-8');
1000
+
1001
+ if (!section) {
1002
+ output({ content }, raw, content);
1003
+ return;
1004
+ }
1005
+
1006
+ // Try to find markdown section or field
1007
+ const fieldEscaped = section.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1008
+
1009
+ // Check for **field:** value
1010
+ const fieldPattern = new RegExp(`\\*\\*${fieldEscaped}:\\*\\*\\s*(.*)`, 'i');
1011
+ const fieldMatch = content.match(fieldPattern);
1012
+ if (fieldMatch) {
1013
+ output({ [section]: fieldMatch[1].trim() }, raw, fieldMatch[1].trim());
1014
+ return;
1015
+ }
1016
+
1017
+ // Check for ## Section
1018
+ const sectionPattern = new RegExp(`##\\s*${fieldEscaped}\\s*\n([\\s\\S]*?)(?=\\n##|$)`, 'i');
1019
+ const sectionMatch = content.match(sectionPattern);
1020
+ if (sectionMatch) {
1021
+ output({ [section]: sectionMatch[1].trim() }, raw, sectionMatch[1].trim());
1022
+ return;
1023
+ }
1024
+
1025
+ output({ error: `Section or field "${section}" not found` }, raw, '');
1026
+ } catch {
1027
+ error('STATE.md not found');
1028
+ }
1029
+ }
1030
+
1031
+ function cmdStatePatch(cwd, patches, raw) {
1032
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1033
+ try {
1034
+ let content = fs.readFileSync(statePath, 'utf-8');
1035
+ const results = { updated: [], failed: [] };
1036
+
1037
+ for (const [field, value] of Object.entries(patches)) {
1038
+ const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1039
+ const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
1040
+
1041
+ if (pattern.test(content)) {
1042
+ content = content.replace(pattern, `$1${value}`);
1043
+ results.updated.push(field);
1044
+ } else {
1045
+ results.failed.push(field);
1046
+ }
1047
+ }
1048
+
1049
+ if (results.updated.length > 0) {
1050
+ fs.writeFileSync(statePath, content, 'utf-8');
1051
+ }
1052
+
1053
+ output(results, raw, results.updated.length > 0 ? 'true' : 'false');
1054
+ } catch {
1055
+ error('STATE.md not found');
1056
+ }
1057
+ }
1058
+
1059
+ function cmdStateUpdate(cwd, field, value) {
1060
+ if (!field || value === undefined) {
1061
+ error('field and value required for state update');
1062
+ }
1063
+
1064
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1065
+ try {
1066
+ let content = fs.readFileSync(statePath, 'utf-8');
1067
+ const fieldEscaped = field.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1068
+ const pattern = new RegExp(`(\\*\\*${fieldEscaped}:\\*\\*\\s*)(.*)`, 'i');
1069
+ if (pattern.test(content)) {
1070
+ content = content.replace(pattern, `$1${value}`);
1071
+ fs.writeFileSync(statePath, content, 'utf-8');
1072
+ output({ updated: true });
1073
+ } else {
1074
+ output({ updated: false, reason: `Field "${field}" not found in STATE.md` });
1075
+ }
1076
+ } catch {
1077
+ output({ updated: false, reason: 'STATE.md not found' });
1078
+ }
1079
+ }
1080
+
1081
+ // ─── State Progression Engine ────────────────────────────────────────────────
1082
+
1083
+ function stateExtractField(content, fieldName) {
1084
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
1085
+ const match = content.match(pattern);
1086
+ return match ? match[1].trim() : null;
1087
+ }
1088
+
1089
+ function stateReplaceField(content, fieldName, newValue) {
1090
+ const escaped = fieldName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
1091
+ const pattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
1092
+ if (pattern.test(content)) {
1093
+ return content.replace(pattern, `$1${newValue}`);
1094
+ }
1095
+ return null;
1096
+ }
1097
+
1098
+ function cmdStateAdvancePlan(cwd, raw) {
1099
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1100
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
1101
+
1102
+ let content = fs.readFileSync(statePath, 'utf-8');
1103
+ const currentPlan = parseInt(stateExtractField(content, 'Current Plan'), 10);
1104
+ const totalPlans = parseInt(stateExtractField(content, 'Total Plans in Phase'), 10);
1105
+ const today = new Date().toISOString().split('T')[0];
1106
+
1107
+ if (isNaN(currentPlan) || isNaN(totalPlans)) {
1108
+ output({ error: 'Cannot parse Current Plan or Total Plans in Phase from STATE.md' }, raw);
1109
+ return;
1110
+ }
1111
+
1112
+ if (currentPlan >= totalPlans) {
1113
+ content = stateReplaceField(content, 'Status', 'Phase complete — ready for verification') || content;
1114
+ content = stateReplaceField(content, 'Last Activity', today) || content;
1115
+ fs.writeFileSync(statePath, content, 'utf-8');
1116
+ output({ advanced: false, reason: 'last_plan', current_plan: currentPlan, total_plans: totalPlans, status: 'ready_for_verification' }, raw, 'false');
1117
+ } else {
1118
+ const newPlan = currentPlan + 1;
1119
+ content = stateReplaceField(content, 'Current Plan', String(newPlan)) || content;
1120
+ content = stateReplaceField(content, 'Status', 'Ready to execute') || content;
1121
+ content = stateReplaceField(content, 'Last Activity', today) || content;
1122
+ fs.writeFileSync(statePath, content, 'utf-8');
1123
+ output({ advanced: true, previous_plan: currentPlan, current_plan: newPlan, total_plans: totalPlans }, raw, 'true');
1124
+ }
1125
+ }
1126
+
1127
+ function cmdStateRecordMetric(cwd, options, raw) {
1128
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1129
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
1130
+
1131
+ let content = fs.readFileSync(statePath, 'utf-8');
1132
+ const { phase, plan, duration, tasks, files } = options;
1133
+
1134
+ if (!phase || !plan || !duration) {
1135
+ output({ error: 'phase, plan, and duration required' }, raw);
1136
+ return;
1137
+ }
1138
+
1139
+ // Find Performance Metrics section and its table
1140
+ const metricsPattern = /(##\s*Performance Metrics[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n)([\s\S]*?)(?=\n##|\n$|$)/i;
1141
+ const metricsMatch = content.match(metricsPattern);
1142
+
1143
+ if (metricsMatch) {
1144
+ const tableHeader = metricsMatch[1];
1145
+ let tableBody = metricsMatch[2].trimEnd();
1146
+ const newRow = `| Phase ${phase} P${plan} | ${duration} | ${tasks || '-'} tasks | ${files || '-'} files |`;
1147
+
1148
+ if (tableBody.trim() === '' || tableBody.includes('None yet')) {
1149
+ tableBody = newRow;
1150
+ } else {
1151
+ tableBody = tableBody + '\n' + newRow;
1152
+ }
1153
+
1154
+ content = content.replace(metricsPattern, `${tableHeader}${tableBody}\n`);
1155
+ fs.writeFileSync(statePath, content, 'utf-8');
1156
+ output({ recorded: true, phase, plan, duration }, raw, 'true');
1157
+ } else {
1158
+ output({ recorded: false, reason: 'Performance Metrics section not found in STATE.md' }, raw, 'false');
1159
+ }
1160
+ }
1161
+
1162
+ function cmdStateUpdateProgress(cwd, raw) {
1163
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1164
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
1165
+
1166
+ let content = fs.readFileSync(statePath, 'utf-8');
1167
+
1168
+ // Count summaries across all phases
1169
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
1170
+ let totalPlans = 0;
1171
+ let totalSummaries = 0;
1172
+
1173
+ if (fs.existsSync(phasesDir)) {
1174
+ const phaseDirs = fs.readdirSync(phasesDir, { withFileTypes: true })
1175
+ .filter(e => e.isDirectory()).map(e => e.name);
1176
+ for (const dir of phaseDirs) {
1177
+ const files = fs.readdirSync(path.join(phasesDir, dir));
1178
+ totalPlans += files.filter(f => f.match(/-PLAN\.md$/i)).length;
1179
+ totalSummaries += files.filter(f => f.match(/-SUMMARY\.md$/i)).length;
1180
+ }
1181
+ }
1182
+
1183
+ const percent = totalPlans > 0 ? Math.round(totalSummaries / totalPlans * 100) : 0;
1184
+ const barWidth = 10;
1185
+ const filled = Math.round(percent / 100 * barWidth);
1186
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
1187
+ const progressStr = `[${bar}] ${percent}%`;
1188
+
1189
+ const progressPattern = /(\*\*Progress:\*\*\s*).*/i;
1190
+ if (progressPattern.test(content)) {
1191
+ content = content.replace(progressPattern, `$1${progressStr}`);
1192
+ fs.writeFileSync(statePath, content, 'utf-8');
1193
+ output({ updated: true, percent, completed: totalSummaries, total: totalPlans, bar: progressStr }, raw, progressStr);
1194
+ } else {
1195
+ output({ updated: false, reason: 'Progress field not found in STATE.md' }, raw, 'false');
1196
+ }
1197
+ }
1198
+
1199
+ function cmdStateAddDecision(cwd, options, raw) {
1200
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1201
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
1202
+
1203
+ const { phase, summary, rationale } = options;
1204
+ if (!summary) { output({ error: 'summary required' }, raw); return; }
1205
+
1206
+ let content = fs.readFileSync(statePath, 'utf-8');
1207
+ const entry = `- [Phase ${phase || '?'}]: ${summary}${rationale ? ` — ${rationale}` : ''}`;
1208
+
1209
+ // Find Decisions section (various heading patterns)
1210
+ const sectionPattern = /(###?\s*(?:Decisions|Decisions Made|Accumulated.*Decisions)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1211
+ const match = content.match(sectionPattern);
1212
+
1213
+ if (match) {
1214
+ let sectionBody = match[2];
1215
+ // Remove placeholders
1216
+ sectionBody = sectionBody.replace(/None yet\.?\s*\n?/gi, '').replace(/No decisions yet\.?\s*\n?/gi, '');
1217
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
1218
+ content = content.replace(sectionPattern, `${match[1]}${sectionBody}`);
1219
+ fs.writeFileSync(statePath, content, 'utf-8');
1220
+ output({ added: true, decision: entry }, raw, 'true');
1221
+ } else {
1222
+ output({ added: false, reason: 'Decisions section not found in STATE.md' }, raw, 'false');
1223
+ }
1224
+ }
1225
+
1226
+ function cmdStateAddBlocker(cwd, text, raw) {
1227
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1228
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
1229
+ if (!text) { output({ error: 'text required' }, raw); return; }
1230
+
1231
+ let content = fs.readFileSync(statePath, 'utf-8');
1232
+ const entry = `- ${text}`;
1233
+
1234
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1235
+ const match = content.match(sectionPattern);
1236
+
1237
+ if (match) {
1238
+ let sectionBody = match[2];
1239
+ sectionBody = sectionBody.replace(/None\.?\s*\n?/gi, '').replace(/None yet\.?\s*\n?/gi, '');
1240
+ sectionBody = sectionBody.trimEnd() + '\n' + entry + '\n';
1241
+ content = content.replace(sectionPattern, `${match[1]}${sectionBody}`);
1242
+ fs.writeFileSync(statePath, content, 'utf-8');
1243
+ output({ added: true, blocker: text }, raw, 'true');
1244
+ } else {
1245
+ output({ added: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
1246
+ }
1247
+ }
1248
+
1249
+ function cmdStateResolveBlocker(cwd, text, raw) {
1250
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1251
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
1252
+ if (!text) { output({ error: 'text required' }, raw); return; }
1253
+
1254
+ let content = fs.readFileSync(statePath, 'utf-8');
1255
+
1256
+ const sectionPattern = /(###?\s*(?:Blockers|Blockers\/Concerns|Concerns)\s*\n)([\s\S]*?)(?=\n###?|\n##[^#]|$)/i;
1257
+ const match = content.match(sectionPattern);
1258
+
1259
+ if (match) {
1260
+ const sectionBody = match[2];
1261
+ const lines = sectionBody.split('\n');
1262
+ const filtered = lines.filter(line => {
1263
+ if (!line.startsWith('- ')) return true;
1264
+ return !line.toLowerCase().includes(text.toLowerCase());
1265
+ });
1266
+
1267
+ let newBody = filtered.join('\n');
1268
+ // If section is now empty, add placeholder
1269
+ if (!newBody.trim() || !newBody.includes('- ')) {
1270
+ newBody = 'None\n';
1271
+ }
1272
+
1273
+ content = content.replace(sectionPattern, `${match[1]}${newBody}`);
1274
+ fs.writeFileSync(statePath, content, 'utf-8');
1275
+ output({ resolved: true, blocker: text }, raw, 'true');
1276
+ } else {
1277
+ output({ resolved: false, reason: 'Blockers section not found in STATE.md' }, raw, 'false');
1278
+ }
1279
+ }
1280
+
1281
+ function cmdStateRecordSession(cwd, options, raw) {
1282
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1283
+ if (!fs.existsSync(statePath)) { output({ error: 'STATE.md not found' }, raw); return; }
1284
+
1285
+ let content = fs.readFileSync(statePath, 'utf-8');
1286
+ const now = new Date().toISOString();
1287
+ const updated = [];
1288
+
1289
+ // Update Last session / Last Date
1290
+ let result = stateReplaceField(content, 'Last session', now);
1291
+ if (result) { content = result; updated.push('Last session'); }
1292
+ result = stateReplaceField(content, 'Last Date', now);
1293
+ if (result) { content = result; updated.push('Last Date'); }
1294
+
1295
+ // Update Stopped at
1296
+ if (options.stopped_at) {
1297
+ result = stateReplaceField(content, 'Stopped At', options.stopped_at);
1298
+ if (!result) result = stateReplaceField(content, 'Stopped at', options.stopped_at);
1299
+ if (result) { content = result; updated.push('Stopped At'); }
1300
+ }
1301
+
1302
+ // Update Resume file
1303
+ const resumeFile = options.resume_file || 'None';
1304
+ result = stateReplaceField(content, 'Resume File', resumeFile);
1305
+ if (!result) result = stateReplaceField(content, 'Resume file', resumeFile);
1306
+ if (result) { content = result; updated.push('Resume File'); }
1307
+
1308
+ if (updated.length > 0) {
1309
+ fs.writeFileSync(statePath, content, 'utf-8');
1310
+ output({ recorded: true, updated }, raw, 'true');
1311
+ } else {
1312
+ output({ recorded: false, reason: 'No session fields found in STATE.md' }, raw, 'false');
1313
+ }
1314
+ }
1315
+
1316
+ function cmdResolveModel(cwd, agentType, raw) {
1317
+ if (!agentType) {
1318
+ error('agent-type required');
1319
+ }
1320
+
1321
+ const config = loadConfig(cwd);
1322
+ const profile = config.model_profile || 'balanced';
1323
+
1324
+ const agentModels = MODEL_PROFILES[agentType];
1325
+ if (!agentModels) {
1326
+ const result = { model: 'sonnet', profile, unknown_agent: true };
1327
+ output(result, raw, 'sonnet');
1328
+ return;
1329
+ }
1330
+
1331
+ const model = agentModels[profile] || agentModels['balanced'] || 'sonnet';
1332
+ const result = { model, profile };
1333
+ output(result, raw, model);
1334
+ }
1335
+
1336
+ function cmdFindPhase(cwd, phase, raw) {
1337
+ if (!phase) {
1338
+ error('phase identifier required');
1339
+ }
1340
+
1341
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
1342
+ const normalized = normalizePhaseName(phase);
1343
+
1344
+ const notFound = { found: false, directory: null, phase_number: null, phase_name: null, plans: [], summaries: [] };
1345
+
1346
+ try {
1347
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1348
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
1349
+
1350
+ const match = dirs.find(d => d.startsWith(normalized));
1351
+ if (!match) {
1352
+ output(notFound, raw, '');
1353
+ return;
1354
+ }
1355
+
1356
+ const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/);
1357
+ const phaseNumber = dirMatch ? dirMatch[1] : normalized;
1358
+ const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
1359
+
1360
+ const phaseDir = path.join(phasesDir, match);
1361
+ const phaseFiles = fs.readdirSync(phaseDir);
1362
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
1363
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
1364
+
1365
+ const result = {
1366
+ found: true,
1367
+ directory: path.join('.blueprint', 'phases', match),
1368
+ phase_number: phaseNumber,
1369
+ phase_name: phaseName,
1370
+ plans,
1371
+ summaries,
1372
+ };
1373
+
1374
+ output(result, raw, result.directory);
1375
+ } catch {
1376
+ output(notFound, raw, '');
1377
+ }
1378
+ }
1379
+
1380
+ function cmdCommit(cwd, message, files, raw, amend) {
1381
+ if (!message && !amend) {
1382
+ error('commit message required');
1383
+ }
1384
+
1385
+ const config = loadConfig(cwd);
1386
+
1387
+ // Check commit_docs config
1388
+ if (!config.commit_docs) {
1389
+ const result = { committed: false, hash: null, reason: 'skipped_commit_docs_false' };
1390
+ output(result, raw, 'skipped');
1391
+ return;
1392
+ }
1393
+
1394
+ // Check if .blueprint is gitignored
1395
+ if (isGitIgnored(cwd, '.blueprint')) {
1396
+ const result = { committed: false, hash: null, reason: 'skipped_gitignored' };
1397
+ output(result, raw, 'skipped');
1398
+ return;
1399
+ }
1400
+
1401
+ // Stage files
1402
+ const filesToStage = files && files.length > 0 ? files : ['.blueprint/'];
1403
+ for (const file of filesToStage) {
1404
+ execGit(cwd, ['add', file]);
1405
+ }
1406
+
1407
+ // Commit
1408
+ const commitArgs = amend ? ['commit', '--amend', '--no-edit'] : ['commit', '-m', message];
1409
+ const commitResult = execGit(cwd, commitArgs);
1410
+ if (commitResult.exitCode !== 0) {
1411
+ if (commitResult.stdout.includes('nothing to commit') || commitResult.stderr.includes('nothing to commit')) {
1412
+ const result = { committed: false, hash: null, reason: 'nothing_to_commit' };
1413
+ output(result, raw, 'nothing');
1414
+ return;
1415
+ }
1416
+ const result = { committed: false, hash: null, reason: 'nothing_to_commit', error: commitResult.stderr };
1417
+ output(result, raw, 'nothing');
1418
+ return;
1419
+ }
1420
+
1421
+ // Get short hash
1422
+ const hashResult = execGit(cwd, ['rev-parse', '--short', 'HEAD']);
1423
+ const hash = hashResult.exitCode === 0 ? hashResult.stdout : null;
1424
+ const result = { committed: true, hash, reason: 'committed' };
1425
+ output(result, raw, hash || 'committed');
1426
+ }
1427
+
1428
+ function cmdVerifySummary(cwd, summaryPath, checkFileCount, raw) {
1429
+ if (!summaryPath) {
1430
+ error('summary-path required');
1431
+ }
1432
+
1433
+ const fullPath = path.join(cwd, summaryPath);
1434
+ const checkCount = checkFileCount || 2;
1435
+
1436
+ // Check 1: Summary exists
1437
+ if (!fs.existsSync(fullPath)) {
1438
+ const result = {
1439
+ passed: false,
1440
+ checks: {
1441
+ summary_exists: false,
1442
+ files_created: { checked: 0, found: 0, missing: [] },
1443
+ commits_exist: false,
1444
+ self_check: 'not_found',
1445
+ },
1446
+ errors: ['SUMMARY.md not found'],
1447
+ };
1448
+ output(result, raw, 'failed');
1449
+ return;
1450
+ }
1451
+
1452
+ const content = fs.readFileSync(fullPath, 'utf-8');
1453
+ const errors = [];
1454
+
1455
+ // Check 2: Spot-check files mentioned in summary
1456
+ const mentionedFiles = new Set();
1457
+ const patterns = [
1458
+ /`([^`]+\.[a-zA-Z]+)`/g,
1459
+ /(?:Created|Modified|Added|Updated|Edited):\s*`?([^\s`]+\.[a-zA-Z]+)`?/gi,
1460
+ ];
1461
+
1462
+ for (const pattern of patterns) {
1463
+ let m;
1464
+ while ((m = pattern.exec(content)) !== null) {
1465
+ const filePath = m[1];
1466
+ if (filePath && !filePath.startsWith('http') && filePath.includes('/')) {
1467
+ mentionedFiles.add(filePath);
1468
+ }
1469
+ }
1470
+ }
1471
+
1472
+ const filesToCheck = Array.from(mentionedFiles).slice(0, checkCount);
1473
+ const missing = [];
1474
+ for (const file of filesToCheck) {
1475
+ if (!fs.existsSync(path.join(cwd, file))) {
1476
+ missing.push(file);
1477
+ }
1478
+ }
1479
+
1480
+ // Check 3: Commits exist
1481
+ const commitHashPattern = /\b[0-9a-f]{7,40}\b/g;
1482
+ const hashes = content.match(commitHashPattern) || [];
1483
+ let commitsExist = false;
1484
+ if (hashes.length > 0) {
1485
+ for (const hash of hashes.slice(0, 3)) {
1486
+ const result = execGit(cwd, ['cat-file', '-t', hash]);
1487
+ if (result.exitCode === 0 && result.stdout === 'commit') {
1488
+ commitsExist = true;
1489
+ break;
1490
+ }
1491
+ }
1492
+ }
1493
+
1494
+ // Check 4: Self-check section
1495
+ let selfCheck = 'not_found';
1496
+ const selfCheckPattern = /##\s*(?:Self[- ]?Check|Verification|Quality Check)/i;
1497
+ if (selfCheckPattern.test(content)) {
1498
+ const passPattern = /(?:all\s+)?(?:pass|✓|✅|complete|succeeded)/i;
1499
+ const failPattern = /(?:fail|✗|❌|incomplete|blocked)/i;
1500
+ const checkSection = content.slice(content.search(selfCheckPattern));
1501
+ if (failPattern.test(checkSection)) {
1502
+ selfCheck = 'failed';
1503
+ } else if (passPattern.test(checkSection)) {
1504
+ selfCheck = 'passed';
1505
+ }
1506
+ }
1507
+
1508
+ if (missing.length > 0) errors.push('Missing files: ' + missing.join(', '));
1509
+ if (!commitsExist && hashes.length > 0) errors.push('Referenced commit hashes not found in git history');
1510
+ if (selfCheck === 'failed') errors.push('Self-check section indicates failure');
1511
+
1512
+ const checks = {
1513
+ summary_exists: true,
1514
+ files_created: { checked: filesToCheck.length, found: filesToCheck.length - missing.length, missing },
1515
+ commits_exist: commitsExist,
1516
+ self_check: selfCheck,
1517
+ };
1518
+
1519
+ const passed = missing.length === 0 && selfCheck !== 'failed';
1520
+ const result = { passed, checks, errors };
1521
+ output(result, raw, passed ? 'passed' : 'failed');
1522
+ }
1523
+
1524
+ function cmdTemplateSelect(cwd, planPath, raw) {
1525
+ if (!planPath) {
1526
+ error('plan-path required');
1527
+ }
1528
+
1529
+ try {
1530
+ const fullPath = path.join(cwd, planPath);
1531
+ const content = fs.readFileSync(fullPath, 'utf-8');
1532
+
1533
+ // Simple heuristics
1534
+ const taskMatch = content.match(/###\s*Task\s*\d+/g) || [];
1535
+ const taskCount = taskMatch.length;
1536
+
1537
+ const decisionMatch = content.match(/decision/gi) || [];
1538
+ const hasDecisions = decisionMatch.length > 0;
1539
+
1540
+ // Count file mentions
1541
+ const fileMentions = new Set();
1542
+ const filePattern = /`([^`]+\.[a-zA-Z]+)`/g;
1543
+ let m;
1544
+ while ((m = filePattern.exec(content)) !== null) {
1545
+ if (m[1].includes('/') && !m[1].startsWith('http')) {
1546
+ fileMentions.add(m[1]);
1547
+ }
1548
+ }
1549
+ const fileCount = fileMentions.size;
1550
+
1551
+ let template = 'templates/summary-standard.md';
1552
+ let type = 'standard';
1553
+
1554
+ if (taskCount <= 2 && fileCount <= 3 && !hasDecisions) {
1555
+ template = 'templates/summary-minimal.md';
1556
+ type = 'minimal';
1557
+ } else if (hasDecisions || fileCount > 6 || taskCount > 5) {
1558
+ template = 'templates/summary-complex.md';
1559
+ type = 'complex';
1560
+ }
1561
+
1562
+ const result = { template, type, taskCount, fileCount, hasDecisions };
1563
+ output(result, raw, template);
1564
+ } catch (e) {
1565
+ // Fallback to standard
1566
+ output({ template: 'templates/summary-standard.md', type: 'standard', error: e.message }, raw, 'templates/summary-standard.md');
1567
+ }
1568
+ }
1569
+
1570
+ function cmdTemplateFill(cwd, templateType, options, raw) {
1571
+ if (!templateType) { error('template type required: summary, plan, or verification'); }
1572
+ if (!options.phase) { error('--phase required'); }
1573
+
1574
+ const phaseInfo = findPhaseInternal(cwd, options.phase);
1575
+ if (!phaseInfo || !phaseInfo.found) { output({ error: 'Phase not found', phase: options.phase }, raw); return; }
1576
+
1577
+ const padded = normalizePhaseName(options.phase);
1578
+ const today = new Date().toISOString().split('T')[0];
1579
+ const phaseName = options.name || phaseInfo.phase_name || 'Unnamed';
1580
+ const phaseSlug = phaseInfo.phase_slug || generateSlugInternal(phaseName);
1581
+ const phaseId = `${padded}-${phaseSlug}`;
1582
+ const planNum = (options.plan || '01').padStart(2, '0');
1583
+ const fields = options.fields || {};
1584
+
1585
+ let frontmatter, body, fileName;
1586
+
1587
+ switch (templateType) {
1588
+ case 'summary': {
1589
+ frontmatter = {
1590
+ phase: phaseId,
1591
+ plan: planNum,
1592
+ subsystem: '[primary category]',
1593
+ tags: [],
1594
+ provides: [],
1595
+ affects: [],
1596
+ 'tech-stack': { added: [], patterns: [] },
1597
+ 'key-files': { created: [], modified: [] },
1598
+ 'key-decisions': [],
1599
+ 'patterns-established': [],
1600
+ duration: '[X]min',
1601
+ completed: today,
1602
+ ...fields,
1603
+ };
1604
+ body = [
1605
+ `# Phase ${options.phase}: ${phaseName} Summary`,
1606
+ '',
1607
+ '**[Substantive one-liner describing outcome]**',
1608
+ '',
1609
+ '## Performance',
1610
+ '- **Duration:** [time]',
1611
+ '- **Tasks:** [count completed]',
1612
+ '- **Files modified:** [count]',
1613
+ '',
1614
+ '## Accomplishments',
1615
+ '- [Key outcome 1]',
1616
+ '- [Key outcome 2]',
1617
+ '',
1618
+ '## Task Commits',
1619
+ '1. **Task 1: [task name]** - `hash`',
1620
+ '',
1621
+ '## Files Created/Modified',
1622
+ '- `path/to/file.ts` - What it does',
1623
+ '',
1624
+ '## Decisions & Deviations',
1625
+ '[Key decisions or "None - followed plan as specified"]',
1626
+ '',
1627
+ '## Next Phase Readiness',
1628
+ '[What\'s ready for next phase]',
1629
+ ].join('\n');
1630
+ fileName = `${padded}-${planNum}-SUMMARY.md`;
1631
+ break;
1632
+ }
1633
+ case 'plan': {
1634
+ const planType = options.type || 'execute';
1635
+ const wave = parseInt(options.wave) || 1;
1636
+ frontmatter = {
1637
+ phase: phaseId,
1638
+ plan: planNum,
1639
+ type: planType,
1640
+ wave,
1641
+ depends_on: [],
1642
+ files_modified: [],
1643
+ autonomous: true,
1644
+ user_setup: [],
1645
+ must_haves: { truths: [], artifacts: [], key_links: [] },
1646
+ ...fields,
1647
+ };
1648
+ body = [
1649
+ `# Phase ${options.phase} Plan ${planNum}: [Title]`,
1650
+ '',
1651
+ '## Objective',
1652
+ '- **What:** [What this plan builds]',
1653
+ '- **Why:** [Why it matters for the phase goal]',
1654
+ '- **Output:** [Concrete deliverable]',
1655
+ '',
1656
+ '## Context',
1657
+ '@.blueprint/PROJECT.md',
1658
+ '@.blueprint/ROADMAP.md',
1659
+ '@.blueprint/STATE.md',
1660
+ '',
1661
+ '## Tasks',
1662
+ '',
1663
+ '<task type="code">',
1664
+ ' <name>[Task name]</name>',
1665
+ ' <files>[file paths]</files>',
1666
+ ' <action>[What to do]</action>',
1667
+ ' <verify>[How to verify]</verify>',
1668
+ ' <done>[Definition of done]</done>',
1669
+ '</task>',
1670
+ '',
1671
+ '## Verification',
1672
+ '[How to verify this plan achieved its objective]',
1673
+ '',
1674
+ '## Success Criteria',
1675
+ '- [ ] [Criterion 1]',
1676
+ '- [ ] [Criterion 2]',
1677
+ ].join('\n');
1678
+ fileName = `${padded}-${planNum}-PLAN.md`;
1679
+ break;
1680
+ }
1681
+ case 'verification': {
1682
+ frontmatter = {
1683
+ phase: phaseId,
1684
+ verified: new Date().toISOString(),
1685
+ status: 'pending',
1686
+ score: '0/0 must-haves verified',
1687
+ ...fields,
1688
+ };
1689
+ body = [
1690
+ `# Phase ${options.phase}: ${phaseName} — Verification`,
1691
+ '',
1692
+ '## Observable Truths',
1693
+ '| # | Truth | Status | Evidence |',
1694
+ '|---|-------|--------|----------|',
1695
+ '| 1 | [Truth] | pending | |',
1696
+ '',
1697
+ '## Required Artifacts',
1698
+ '| Artifact | Expected | Status | Details |',
1699
+ '|----------|----------|--------|---------|',
1700
+ '| [path] | [what] | pending | |',
1701
+ '',
1702
+ '## Key Link Verification',
1703
+ '| From | To | Via | Status | Details |',
1704
+ '|------|----|----|--------|---------|',
1705
+ '| [source] | [target] | [connection] | pending | |',
1706
+ '',
1707
+ '## Requirements Coverage',
1708
+ '| Requirement | Status | Blocking Issue |',
1709
+ '|-------------|--------|----------------|',
1710
+ '| [req] | pending | |',
1711
+ '',
1712
+ '## Result',
1713
+ '[Pending verification]',
1714
+ ].join('\n');
1715
+ fileName = `${padded}-VERIFICATION.md`;
1716
+ break;
1717
+ }
1718
+ default:
1719
+ error(`Unknown template type: ${templateType}. Available: summary, plan, verification`);
1720
+ return;
1721
+ }
1722
+
1723
+ const fullContent = `---\n${reconstructFrontmatter(frontmatter)}\n---\n\n${body}\n`;
1724
+ const outPath = path.join(cwd, phaseInfo.directory, fileName);
1725
+
1726
+ if (fs.existsSync(outPath)) {
1727
+ output({ error: 'File already exists', path: path.relative(cwd, outPath) }, raw);
1728
+ return;
1729
+ }
1730
+
1731
+ fs.writeFileSync(outPath, fullContent, 'utf-8');
1732
+ const relPath = path.relative(cwd, outPath);
1733
+ output({ created: true, path: relPath, template: templateType }, raw, relPath);
1734
+ }
1735
+
1736
+ function cmdPhasePlanIndex(cwd, phase, raw) {
1737
+ if (!phase) {
1738
+ error('phase required for phase-plan-index');
1739
+ }
1740
+
1741
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
1742
+ const normalized = normalizePhaseName(phase);
1743
+
1744
+ // Find phase directory
1745
+ let phaseDir = null;
1746
+ let phaseDirName = null;
1747
+ try {
1748
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
1749
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
1750
+ const match = dirs.find(d => d.startsWith(normalized));
1751
+ if (match) {
1752
+ phaseDir = path.join(phasesDir, match);
1753
+ phaseDirName = match;
1754
+ }
1755
+ } catch {
1756
+ // phases dir doesn't exist
1757
+ }
1758
+
1759
+ if (!phaseDir) {
1760
+ output({ phase: normalized, error: 'Phase not found', plans: [], waves: {}, incomplete: [], has_checkpoints: false }, raw);
1761
+ return;
1762
+ }
1763
+
1764
+ // Get all files in phase directory
1765
+ const phaseFiles = fs.readdirSync(phaseDir);
1766
+ const planFiles = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
1767
+ const summaryFiles = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
1768
+
1769
+ // Build set of plan IDs with summaries
1770
+ const completedPlanIds = new Set(
1771
+ summaryFiles.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
1772
+ );
1773
+
1774
+ const plans = [];
1775
+ const waves = {};
1776
+ const incomplete = [];
1777
+ let hasCheckpoints = false;
1778
+
1779
+ for (const planFile of planFiles) {
1780
+ const planId = planFile.replace('-PLAN.md', '').replace('PLAN.md', '');
1781
+ const planPath = path.join(phaseDir, planFile);
1782
+ const content = fs.readFileSync(planPath, 'utf-8');
1783
+ const fm = extractFrontmatter(content);
1784
+
1785
+ // Count tasks (## Task N patterns)
1786
+ const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
1787
+ const taskCount = taskMatches.length;
1788
+
1789
+ // Parse wave as integer
1790
+ const wave = parseInt(fm.wave, 10) || 1;
1791
+
1792
+ // Parse autonomous (default true if not specified)
1793
+ let autonomous = true;
1794
+ if (fm.autonomous !== undefined) {
1795
+ autonomous = fm.autonomous === 'true' || fm.autonomous === true;
1796
+ }
1797
+
1798
+ if (!autonomous) {
1799
+ hasCheckpoints = true;
1800
+ }
1801
+
1802
+ // Parse files-modified
1803
+ let filesModified = [];
1804
+ if (fm['files-modified']) {
1805
+ filesModified = Array.isArray(fm['files-modified']) ? fm['files-modified'] : [fm['files-modified']];
1806
+ }
1807
+
1808
+ const hasSummary = completedPlanIds.has(planId);
1809
+ if (!hasSummary) {
1810
+ incomplete.push(planId);
1811
+ }
1812
+
1813
+ const plan = {
1814
+ id: planId,
1815
+ wave,
1816
+ autonomous,
1817
+ objective: fm.objective || null,
1818
+ files_modified: filesModified,
1819
+ task_count: taskCount,
1820
+ has_summary: hasSummary,
1821
+ };
1822
+
1823
+ plans.push(plan);
1824
+
1825
+ // Group by wave
1826
+ const waveKey = String(wave);
1827
+ if (!waves[waveKey]) {
1828
+ waves[waveKey] = [];
1829
+ }
1830
+ waves[waveKey].push(planId);
1831
+ }
1832
+
1833
+ const result = {
1834
+ phase: normalized,
1835
+ plans,
1836
+ waves,
1837
+ incomplete,
1838
+ has_checkpoints: hasCheckpoints,
1839
+ };
1840
+
1841
+ output(result, raw);
1842
+ }
1843
+
1844
+ function cmdStateSnapshot(cwd, raw) {
1845
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
1846
+
1847
+ if (!fs.existsSync(statePath)) {
1848
+ output({ error: 'STATE.md not found' }, raw);
1849
+ return;
1850
+ }
1851
+
1852
+ const content = fs.readFileSync(statePath, 'utf-8');
1853
+
1854
+ // Helper to extract **Field:** value patterns
1855
+ const extractField = (fieldName) => {
1856
+ const pattern = new RegExp(`\\*\\*${fieldName}:\\*\\*\\s*(.+)`, 'i');
1857
+ const match = content.match(pattern);
1858
+ return match ? match[1].trim() : null;
1859
+ };
1860
+
1861
+ // Extract basic fields
1862
+ const currentPhase = extractField('Current Phase');
1863
+ const currentPhaseName = extractField('Current Phase Name');
1864
+ const totalPhasesRaw = extractField('Total Phases');
1865
+ const currentPlan = extractField('Current Plan');
1866
+ const totalPlansRaw = extractField('Total Plans in Phase');
1867
+ const status = extractField('Status');
1868
+ const progressRaw = extractField('Progress');
1869
+ const lastActivity = extractField('Last Activity');
1870
+ const lastActivityDesc = extractField('Last Activity Description');
1871
+ const pausedAt = extractField('Paused At');
1872
+
1873
+ // Parse numeric fields
1874
+ const totalPhases = totalPhasesRaw ? parseInt(totalPhasesRaw, 10) : null;
1875
+ const totalPlansInPhase = totalPlansRaw ? parseInt(totalPlansRaw, 10) : null;
1876
+ const progressPercent = progressRaw ? parseInt(progressRaw.replace('%', ''), 10) : null;
1877
+
1878
+ // Extract decisions table
1879
+ const decisions = [];
1880
+ const decisionsMatch = content.match(/##\s*Decisions Made[\s\S]*?\n\|[^\n]+\n\|[-|\s]+\n([\s\S]*?)(?=\n##|\n$|$)/i);
1881
+ if (decisionsMatch) {
1882
+ const tableBody = decisionsMatch[1];
1883
+ const rows = tableBody.trim().split('\n').filter(r => r.includes('|'));
1884
+ for (const row of rows) {
1885
+ const cells = row.split('|').map(c => c.trim()).filter(Boolean);
1886
+ if (cells.length >= 3) {
1887
+ decisions.push({
1888
+ phase: cells[0],
1889
+ summary: cells[1],
1890
+ rationale: cells[2],
1891
+ });
1892
+ }
1893
+ }
1894
+ }
1895
+
1896
+ // Extract blockers list
1897
+ const blockers = [];
1898
+ const blockersMatch = content.match(/##\s*Blockers\s*\n([\s\S]*?)(?=\n##|$)/i);
1899
+ if (blockersMatch) {
1900
+ const blockersSection = blockersMatch[1];
1901
+ const items = blockersSection.match(/^-\s+(.+)$/gm) || [];
1902
+ for (const item of items) {
1903
+ blockers.push(item.replace(/^-\s+/, '').trim());
1904
+ }
1905
+ }
1906
+
1907
+ // Extract session info
1908
+ const session = {
1909
+ last_date: null,
1910
+ stopped_at: null,
1911
+ resume_file: null,
1912
+ };
1913
+
1914
+ const sessionMatch = content.match(/##\s*Session\s*\n([\s\S]*?)(?=\n##|$)/i);
1915
+ if (sessionMatch) {
1916
+ const sessionSection = sessionMatch[1];
1917
+ const lastDateMatch = sessionSection.match(/\*\*Last Date:\*\*\s*(.+)/i);
1918
+ const stoppedAtMatch = sessionSection.match(/\*\*Stopped At:\*\*\s*(.+)/i);
1919
+ const resumeFileMatch = sessionSection.match(/\*\*Resume File:\*\*\s*(.+)/i);
1920
+
1921
+ if (lastDateMatch) session.last_date = lastDateMatch[1].trim();
1922
+ if (stoppedAtMatch) session.stopped_at = stoppedAtMatch[1].trim();
1923
+ if (resumeFileMatch) session.resume_file = resumeFileMatch[1].trim();
1924
+ }
1925
+
1926
+ const result = {
1927
+ current_phase: currentPhase,
1928
+ current_phase_name: currentPhaseName,
1929
+ total_phases: totalPhases,
1930
+ current_plan: currentPlan,
1931
+ total_plans_in_phase: totalPlansInPhase,
1932
+ status,
1933
+ progress_percent: progressPercent,
1934
+ last_activity: lastActivity,
1935
+ last_activity_desc: lastActivityDesc,
1936
+ decisions,
1937
+ blockers,
1938
+ paused_at: pausedAt,
1939
+ session,
1940
+ };
1941
+
1942
+ output(result, raw);
1943
+ }
1944
+
1945
+ function cmdSummaryExtract(cwd, summaryPath, fields, raw) {
1946
+ if (!summaryPath) {
1947
+ error('summary-path required for summary-extract');
1948
+ }
1949
+
1950
+ const fullPath = path.join(cwd, summaryPath);
1951
+
1952
+ if (!fs.existsSync(fullPath)) {
1953
+ output({ error: 'File not found', path: summaryPath }, raw);
1954
+ return;
1955
+ }
1956
+
1957
+ const content = fs.readFileSync(fullPath, 'utf-8');
1958
+ const fm = extractFrontmatter(content);
1959
+
1960
+ // Parse key-decisions into structured format
1961
+ const parseDecisions = (decisionsList) => {
1962
+ if (!decisionsList || !Array.isArray(decisionsList)) return [];
1963
+ return decisionsList.map(d => {
1964
+ const colonIdx = d.indexOf(':');
1965
+ if (colonIdx > 0) {
1966
+ return {
1967
+ summary: d.substring(0, colonIdx).trim(),
1968
+ rationale: d.substring(colonIdx + 1).trim(),
1969
+ };
1970
+ }
1971
+ return { summary: d, rationale: null };
1972
+ });
1973
+ };
1974
+
1975
+ // Build full result
1976
+ const fullResult = {
1977
+ path: summaryPath,
1978
+ one_liner: fm['one-liner'] || null,
1979
+ key_files: fm['key-files'] || [],
1980
+ tech_added: (fm['tech-stack'] && fm['tech-stack'].added) || [],
1981
+ patterns: fm['patterns-established'] || [],
1982
+ decisions: parseDecisions(fm['key-decisions']),
1983
+ };
1984
+
1985
+ // If fields specified, filter to only those fields
1986
+ if (fields && fields.length > 0) {
1987
+ const filtered = { path: summaryPath };
1988
+ for (const field of fields) {
1989
+ if (fullResult[field] !== undefined) {
1990
+ filtered[field] = fullResult[field];
1991
+ }
1992
+ }
1993
+ output(filtered, raw);
1994
+ return;
1995
+ }
1996
+
1997
+ output(fullResult, raw);
1998
+ }
1999
+
2000
+ // ─── Web Search (Brave API) ──────────────────────────────────────────────────
2001
+
2002
+ async function cmdWebsearch(query, options, raw) {
2003
+ const apiKey = process.env.BRAVE_API_KEY;
2004
+
2005
+ if (!apiKey) {
2006
+ // No key = silent skip, agent falls back to built-in WebSearch
2007
+ output({ available: false, reason: 'BRAVE_API_KEY not set' }, raw, '');
2008
+ return;
2009
+ }
2010
+
2011
+ if (!query) {
2012
+ output({ available: false, error: 'Query required' }, raw, '');
2013
+ return;
2014
+ }
2015
+
2016
+ const params = new URLSearchParams({
2017
+ q: query,
2018
+ count: String(options.limit || 10),
2019
+ country: 'us',
2020
+ search_lang: 'en',
2021
+ text_decorations: 'false'
2022
+ });
2023
+
2024
+ if (options.freshness) {
2025
+ params.set('freshness', options.freshness);
2026
+ }
2027
+
2028
+ try {
2029
+ const response = await fetch(
2030
+ `https://api.search.brave.com/res/v1/web/search?${params}`,
2031
+ {
2032
+ headers: {
2033
+ 'Accept': 'application/json',
2034
+ 'X-Subscription-Token': apiKey
2035
+ }
2036
+ }
2037
+ );
2038
+
2039
+ if (!response.ok) {
2040
+ output({ available: false, error: `API error: ${response.status}` }, raw, '');
2041
+ return;
2042
+ }
2043
+
2044
+ const data = await response.json();
2045
+
2046
+ const results = (data.web?.results || []).map(r => ({
2047
+ title: r.title,
2048
+ url: r.url,
2049
+ description: r.description,
2050
+ age: r.age || null
2051
+ }));
2052
+
2053
+ output({
2054
+ available: true,
2055
+ query,
2056
+ count: results.length,
2057
+ results
2058
+ }, raw, results.map(r => `${r.title}\n${r.url}\n${r.description}`).join('\n\n'));
2059
+ } catch (err) {
2060
+ output({ available: false, error: err.message }, raw, '');
2061
+ }
2062
+ }
2063
+
2064
+ // ─── Frontmatter CRUD ────────────────────────────────────────────────────────
2065
+
2066
+ function cmdFrontmatterGet(cwd, filePath, field, raw) {
2067
+ if (!filePath) { error('file path required'); }
2068
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
2069
+ const content = safeReadFile(fullPath);
2070
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
2071
+ const fm = extractFrontmatter(content);
2072
+ if (field) {
2073
+ const value = fm[field];
2074
+ if (value === undefined) { output({ error: 'Field not found', field }, raw); return; }
2075
+ output({ [field]: value }, raw, JSON.stringify(value));
2076
+ } else {
2077
+ output(fm, raw);
2078
+ }
2079
+ }
2080
+
2081
+ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
2082
+ if (!filePath || !field || value === undefined) { error('file, field, and value required'); }
2083
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
2084
+ if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
2085
+ const content = fs.readFileSync(fullPath, 'utf-8');
2086
+ const fm = extractFrontmatter(content);
2087
+ let parsedValue;
2088
+ try { parsedValue = JSON.parse(value); } catch { parsedValue = value; }
2089
+ fm[field] = parsedValue;
2090
+ const newContent = spliceFrontmatter(content, fm);
2091
+ fs.writeFileSync(fullPath, newContent, 'utf-8');
2092
+ output({ updated: true, field, value: parsedValue }, raw, 'true');
2093
+ }
2094
+
2095
+ function cmdFrontmatterMerge(cwd, filePath, data, raw) {
2096
+ if (!filePath || !data) { error('file and data required'); }
2097
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
2098
+ if (!fs.existsSync(fullPath)) { output({ error: 'File not found', path: filePath }, raw); return; }
2099
+ const content = fs.readFileSync(fullPath, 'utf-8');
2100
+ const fm = extractFrontmatter(content);
2101
+ let mergeData;
2102
+ try { mergeData = JSON.parse(data); } catch { error('Invalid JSON for --data'); return; }
2103
+ Object.assign(fm, mergeData);
2104
+ const newContent = spliceFrontmatter(content, fm);
2105
+ fs.writeFileSync(fullPath, newContent, 'utf-8');
2106
+ output({ merged: true, fields: Object.keys(mergeData) }, raw, 'true');
2107
+ }
2108
+
2109
+ const FRONTMATTER_SCHEMAS = {
2110
+ plan: { required: ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'] },
2111
+ summary: { required: ['phase', 'plan', 'subsystem', 'tags', 'duration', 'completed'] },
2112
+ verification: { required: ['phase', 'verified', 'status', 'score'] },
2113
+ };
2114
+
2115
+ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
2116
+ if (!filePath || !schemaName) { error('file and schema required'); }
2117
+ const schema = FRONTMATTER_SCHEMAS[schemaName];
2118
+ if (!schema) { error(`Unknown schema: ${schemaName}. Available: ${Object.keys(FRONTMATTER_SCHEMAS).join(', ')}`); }
2119
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
2120
+ const content = safeReadFile(fullPath);
2121
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
2122
+ const fm = extractFrontmatter(content);
2123
+ const missing = schema.required.filter(f => fm[f] === undefined);
2124
+ const present = schema.required.filter(f => fm[f] !== undefined);
2125
+ output({ valid: missing.length === 0, missing, present, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
2126
+ }
2127
+
2128
+ // ─── Verification Suite ──────────────────────────────────────────────────────
2129
+
2130
+ function cmdVerifyPlanStructure(cwd, filePath, raw) {
2131
+ if (!filePath) { error('file path required'); }
2132
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
2133
+ const content = safeReadFile(fullPath);
2134
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
2135
+
2136
+ const fm = extractFrontmatter(content);
2137
+ const errors = [];
2138
+ const warnings = [];
2139
+
2140
+ // Check required frontmatter fields
2141
+ const required = ['phase', 'plan', 'type', 'wave', 'depends_on', 'files_modified', 'autonomous', 'must_haves'];
2142
+ for (const field of required) {
2143
+ if (fm[field] === undefined) errors.push(`Missing required frontmatter field: ${field}`);
2144
+ }
2145
+
2146
+ // Parse and check task elements
2147
+ const taskPattern = /<task[^>]*>([\s\S]*?)<\/task>/g;
2148
+ const tasks = [];
2149
+ let taskMatch;
2150
+ while ((taskMatch = taskPattern.exec(content)) !== null) {
2151
+ const taskContent = taskMatch[1];
2152
+ const nameMatch = taskContent.match(/<name>([\s\S]*?)<\/name>/);
2153
+ const taskName = nameMatch ? nameMatch[1].trim() : 'unnamed';
2154
+ const hasFiles = /<files>/.test(taskContent);
2155
+ const hasAction = /<action>/.test(taskContent);
2156
+ const hasVerify = /<verify>/.test(taskContent);
2157
+ const hasDone = /<done>/.test(taskContent);
2158
+
2159
+ if (!nameMatch) errors.push('Task missing <name> element');
2160
+ if (!hasAction) errors.push(`Task '${taskName}' missing <action>`);
2161
+ if (!hasVerify) warnings.push(`Task '${taskName}' missing <verify>`);
2162
+ if (!hasDone) warnings.push(`Task '${taskName}' missing <done>`);
2163
+ if (!hasFiles) warnings.push(`Task '${taskName}' missing <files>`);
2164
+
2165
+ tasks.push({ name: taskName, hasFiles, hasAction, hasVerify, hasDone });
2166
+ }
2167
+
2168
+ if (tasks.length === 0) warnings.push('No <task> elements found');
2169
+
2170
+ // Wave/depends_on consistency
2171
+ if (fm.wave && parseInt(fm.wave) > 1 && (!fm.depends_on || (Array.isArray(fm.depends_on) && fm.depends_on.length === 0))) {
2172
+ warnings.push('Wave > 1 but depends_on is empty');
2173
+ }
2174
+
2175
+ // Autonomous/checkpoint consistency
2176
+ const hasCheckpoints = /<task\s+type=["']?checkpoint/.test(content);
2177
+ if (hasCheckpoints && fm.autonomous !== 'false' && fm.autonomous !== false) {
2178
+ errors.push('Has checkpoint tasks but autonomous is not false');
2179
+ }
2180
+
2181
+ output({
2182
+ valid: errors.length === 0,
2183
+ errors,
2184
+ warnings,
2185
+ task_count: tasks.length,
2186
+ tasks,
2187
+ frontmatter_fields: Object.keys(fm),
2188
+ }, raw, errors.length === 0 ? 'valid' : 'invalid');
2189
+ }
2190
+
2191
+ function cmdVerifyPhaseCompleteness(cwd, phase, raw) {
2192
+ if (!phase) { error('phase required'); }
2193
+ const phaseInfo = findPhaseInternal(cwd, phase);
2194
+ if (!phaseInfo || !phaseInfo.found) {
2195
+ output({ error: 'Phase not found', phase }, raw);
2196
+ return;
2197
+ }
2198
+
2199
+ const errors = [];
2200
+ const warnings = [];
2201
+ const phaseDir = path.join(cwd, phaseInfo.directory);
2202
+
2203
+ // List plans and summaries
2204
+ let files;
2205
+ try { files = fs.readdirSync(phaseDir); } catch { output({ error: 'Cannot read phase directory' }, raw); return; }
2206
+
2207
+ const plans = files.filter(f => f.match(/-PLAN\.md$/i));
2208
+ const summaries = files.filter(f => f.match(/-SUMMARY\.md$/i));
2209
+
2210
+ // Extract plan IDs (everything before -PLAN.md)
2211
+ const planIds = new Set(plans.map(p => p.replace(/-PLAN\.md$/i, '')));
2212
+ const summaryIds = new Set(summaries.map(s => s.replace(/-SUMMARY\.md$/i, '')));
2213
+
2214
+ // Plans without summaries
2215
+ const incompletePlans = [...planIds].filter(id => !summaryIds.has(id));
2216
+ if (incompletePlans.length > 0) {
2217
+ errors.push(`Plans without summaries: ${incompletePlans.join(', ')}`);
2218
+ }
2219
+
2220
+ // Summaries without plans (orphans)
2221
+ const orphanSummaries = [...summaryIds].filter(id => !planIds.has(id));
2222
+ if (orphanSummaries.length > 0) {
2223
+ warnings.push(`Summaries without plans: ${orphanSummaries.join(', ')}`);
2224
+ }
2225
+
2226
+ output({
2227
+ complete: errors.length === 0,
2228
+ phase: phaseInfo.phase_number,
2229
+ plan_count: plans.length,
2230
+ summary_count: summaries.length,
2231
+ incomplete_plans: incompletePlans,
2232
+ orphan_summaries: orphanSummaries,
2233
+ errors,
2234
+ warnings,
2235
+ }, raw, errors.length === 0 ? 'complete' : 'incomplete');
2236
+ }
2237
+
2238
+ function cmdVerifyReferences(cwd, filePath, raw) {
2239
+ if (!filePath) { error('file path required'); }
2240
+ const fullPath = path.isAbsolute(filePath) ? filePath : path.join(cwd, filePath);
2241
+ const content = safeReadFile(fullPath);
2242
+ if (!content) { output({ error: 'File not found', path: filePath }, raw); return; }
2243
+
2244
+ const found = [];
2245
+ const missing = [];
2246
+
2247
+ // Find @-references: @path/to/file (must contain / to be a file path)
2248
+ const atRefs = content.match(/@([^\s\n,)]+\/[^\s\n,)]+)/g) || [];
2249
+ for (const ref of atRefs) {
2250
+ const cleanRef = ref.slice(1); // remove @
2251
+ const resolved = cleanRef.startsWith('~/')
2252
+ ? path.join(process.env.HOME || '', cleanRef.slice(2))
2253
+ : path.join(cwd, cleanRef);
2254
+ if (fs.existsSync(resolved)) {
2255
+ found.push(cleanRef);
2256
+ } else {
2257
+ missing.push(cleanRef);
2258
+ }
2259
+ }
2260
+
2261
+ // Find backtick file paths that look like real paths (contain / and have extension)
2262
+ const backtickRefs = content.match(/`([^`]+\/[^`]+\.[a-zA-Z]{1,10})`/g) || [];
2263
+ for (const ref of backtickRefs) {
2264
+ const cleanRef = ref.slice(1, -1); // remove backticks
2265
+ if (cleanRef.startsWith('http') || cleanRef.includes('${') || cleanRef.includes('{{')) continue;
2266
+ if (found.includes(cleanRef) || missing.includes(cleanRef)) continue; // dedup
2267
+ const resolved = path.join(cwd, cleanRef);
2268
+ if (fs.existsSync(resolved)) {
2269
+ found.push(cleanRef);
2270
+ } else {
2271
+ missing.push(cleanRef);
2272
+ }
2273
+ }
2274
+
2275
+ output({
2276
+ valid: missing.length === 0,
2277
+ found: found.length,
2278
+ missing,
2279
+ total: found.length + missing.length,
2280
+ }, raw, missing.length === 0 ? 'valid' : 'invalid');
2281
+ }
2282
+
2283
+ function cmdVerifyCommits(cwd, hashes, raw) {
2284
+ if (!hashes || hashes.length === 0) { error('At least one commit hash required'); }
2285
+
2286
+ const valid = [];
2287
+ const invalid = [];
2288
+ for (const hash of hashes) {
2289
+ const result = execGit(cwd, ['cat-file', '-t', hash]);
2290
+ if (result.exitCode === 0 && result.stdout.trim() === 'commit') {
2291
+ valid.push(hash);
2292
+ } else {
2293
+ invalid.push(hash);
2294
+ }
2295
+ }
2296
+
2297
+ output({
2298
+ all_valid: invalid.length === 0,
2299
+ valid,
2300
+ invalid,
2301
+ total: hashes.length,
2302
+ }, raw, invalid.length === 0 ? 'valid' : 'invalid');
2303
+ }
2304
+
2305
+ function cmdVerifyArtifacts(cwd, planFilePath, raw) {
2306
+ if (!planFilePath) { error('plan file path required'); }
2307
+ const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
2308
+ const content = safeReadFile(fullPath);
2309
+ if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
2310
+
2311
+ const artifacts = parseMustHavesBlock(content, 'artifacts');
2312
+ if (artifacts.length === 0) {
2313
+ output({ error: 'No must_haves.artifacts found in frontmatter', path: planFilePath }, raw);
2314
+ return;
2315
+ }
2316
+
2317
+ const results = [];
2318
+ for (const artifact of artifacts) {
2319
+ if (typeof artifact === 'string') continue; // skip simple string items
2320
+ const artPath = artifact.path;
2321
+ if (!artPath) continue;
2322
+
2323
+ const artFullPath = path.join(cwd, artPath);
2324
+ const exists = fs.existsSync(artFullPath);
2325
+ const check = { path: artPath, exists, issues: [], passed: false };
2326
+
2327
+ if (exists) {
2328
+ const fileContent = safeReadFile(artFullPath) || '';
2329
+ const lineCount = fileContent.split('\n').length;
2330
+
2331
+ if (artifact.min_lines && lineCount < artifact.min_lines) {
2332
+ check.issues.push(`Only ${lineCount} lines, need ${artifact.min_lines}`);
2333
+ }
2334
+ if (artifact.contains && !fileContent.includes(artifact.contains)) {
2335
+ check.issues.push(`Missing pattern: ${artifact.contains}`);
2336
+ }
2337
+ if (artifact.exports) {
2338
+ const exports = Array.isArray(artifact.exports) ? artifact.exports : [artifact.exports];
2339
+ for (const exp of exports) {
2340
+ if (!fileContent.includes(exp)) check.issues.push(`Missing export: ${exp}`);
2341
+ }
2342
+ }
2343
+ check.passed = check.issues.length === 0;
2344
+ } else {
2345
+ check.issues.push('File not found');
2346
+ }
2347
+
2348
+ results.push(check);
2349
+ }
2350
+
2351
+ const passed = results.filter(r => r.passed).length;
2352
+ output({
2353
+ all_passed: passed === results.length,
2354
+ passed,
2355
+ total: results.length,
2356
+ artifacts: results,
2357
+ }, raw, passed === results.length ? 'valid' : 'invalid');
2358
+ }
2359
+
2360
+ function cmdVerifyKeyLinks(cwd, planFilePath, raw) {
2361
+ if (!planFilePath) { error('plan file path required'); }
2362
+ const fullPath = path.isAbsolute(planFilePath) ? planFilePath : path.join(cwd, planFilePath);
2363
+ const content = safeReadFile(fullPath);
2364
+ if (!content) { output({ error: 'File not found', path: planFilePath }, raw); return; }
2365
+
2366
+ const keyLinks = parseMustHavesBlock(content, 'key_links');
2367
+ if (keyLinks.length === 0) {
2368
+ output({ error: 'No must_haves.key_links found in frontmatter', path: planFilePath }, raw);
2369
+ return;
2370
+ }
2371
+
2372
+ const results = [];
2373
+ for (const link of keyLinks) {
2374
+ if (typeof link === 'string') continue;
2375
+ const check = { from: link.from, to: link.to, via: link.via || '', verified: false, detail: '' };
2376
+
2377
+ const sourceContent = safeReadFile(path.join(cwd, link.from || ''));
2378
+ if (!sourceContent) {
2379
+ check.detail = 'Source file not found';
2380
+ } else if (link.pattern) {
2381
+ try {
2382
+ const regex = new RegExp(link.pattern);
2383
+ if (regex.test(sourceContent)) {
2384
+ check.verified = true;
2385
+ check.detail = 'Pattern found in source';
2386
+ } else {
2387
+ const targetContent = safeReadFile(path.join(cwd, link.to || ''));
2388
+ if (targetContent && regex.test(targetContent)) {
2389
+ check.verified = true;
2390
+ check.detail = 'Pattern found in target';
2391
+ } else {
2392
+ check.detail = `Pattern "${link.pattern}" not found in source or target`;
2393
+ }
2394
+ }
2395
+ } catch {
2396
+ check.detail = `Invalid regex pattern: ${link.pattern}`;
2397
+ }
2398
+ } else {
2399
+ // No pattern: just check source references target
2400
+ if (sourceContent.includes(link.to || '')) {
2401
+ check.verified = true;
2402
+ check.detail = 'Target referenced in source';
2403
+ } else {
2404
+ check.detail = 'Target not referenced in source';
2405
+ }
2406
+ }
2407
+
2408
+ results.push(check);
2409
+ }
2410
+
2411
+ const verified = results.filter(r => r.verified).length;
2412
+ output({
2413
+ all_verified: verified === results.length,
2414
+ verified,
2415
+ total: results.length,
2416
+ links: results,
2417
+ }, raw, verified === results.length ? 'valid' : 'invalid');
2418
+ }
2419
+
2420
+ // ─── Roadmap Analysis ─────────────────────────────────────────────────────────
2421
+
2422
+ function cmdRoadmapAnalyze(cwd, raw) {
2423
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
2424
+
2425
+ if (!fs.existsSync(roadmapPath)) {
2426
+ output({ error: 'ROADMAP.md not found', milestones: [], phases: [], current_phase: null }, raw);
2427
+ return;
2428
+ }
2429
+
2430
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
2431
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
2432
+
2433
+ // Extract all phase headings: ### Phase N: Name
2434
+ const phasePattern = /###\s*Phase\s+(\d+(?:\.\d+)?)\s*:\s*([^\n]+)/gi;
2435
+ const phases = [];
2436
+ let match;
2437
+
2438
+ while ((match = phasePattern.exec(content)) !== null) {
2439
+ const phaseNum = match[1];
2440
+ const phaseName = match[2].replace(/\(INSERTED\)/i, '').trim();
2441
+
2442
+ // Extract goal from the section
2443
+ const sectionStart = match.index;
2444
+ const restOfContent = content.slice(sectionStart);
2445
+ const nextHeader = restOfContent.match(/\n###\s+Phase\s+\d/i);
2446
+ const sectionEnd = nextHeader ? sectionStart + nextHeader.index : content.length;
2447
+ const section = content.slice(sectionStart, sectionEnd);
2448
+
2449
+ const goalMatch = section.match(/\*\*Goal:\*\*\s*([^\n]+)/i);
2450
+ const goal = goalMatch ? goalMatch[1].trim() : null;
2451
+
2452
+ const dependsMatch = section.match(/\*\*Depends on:\*\*\s*([^\n]+)/i);
2453
+ const depends_on = dependsMatch ? dependsMatch[1].trim() : null;
2454
+
2455
+ // Check completion on disk
2456
+ const normalized = normalizePhaseName(phaseNum);
2457
+ let diskStatus = 'no_directory';
2458
+ let planCount = 0;
2459
+ let summaryCount = 0;
2460
+ let hasContext = false;
2461
+ let hasResearch = false;
2462
+
2463
+ try {
2464
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
2465
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
2466
+ const dirMatch = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
2467
+
2468
+ if (dirMatch) {
2469
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dirMatch));
2470
+ planCount = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
2471
+ summaryCount = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
2472
+ hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
2473
+ hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
2474
+
2475
+ if (summaryCount >= planCount && planCount > 0) diskStatus = 'complete';
2476
+ else if (summaryCount > 0) diskStatus = 'partial';
2477
+ else if (planCount > 0) diskStatus = 'planned';
2478
+ else if (hasResearch) diskStatus = 'researched';
2479
+ else if (hasContext) diskStatus = 'discussed';
2480
+ else diskStatus = 'empty';
2481
+ }
2482
+ } catch {}
2483
+
2484
+ // Check ROADMAP checkbox status
2485
+ const checkboxPattern = new RegExp(`-\\s*\\[(x| )\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}`, 'i');
2486
+ const checkboxMatch = content.match(checkboxPattern);
2487
+ const roadmapComplete = checkboxMatch ? checkboxMatch[1] === 'x' : false;
2488
+
2489
+ phases.push({
2490
+ number: phaseNum,
2491
+ name: phaseName,
2492
+ goal,
2493
+ depends_on,
2494
+ plan_count: planCount,
2495
+ summary_count: summaryCount,
2496
+ has_context: hasContext,
2497
+ has_research: hasResearch,
2498
+ disk_status: diskStatus,
2499
+ roadmap_complete: roadmapComplete,
2500
+ });
2501
+ }
2502
+
2503
+ // Extract milestone info
2504
+ const milestones = [];
2505
+ const milestonePattern = /##\s*(.*v(\d+\.\d+)[^(\n]*)/gi;
2506
+ let mMatch;
2507
+ while ((mMatch = milestonePattern.exec(content)) !== null) {
2508
+ milestones.push({
2509
+ heading: mMatch[1].trim(),
2510
+ version: 'v' + mMatch[2],
2511
+ });
2512
+ }
2513
+
2514
+ // Find current and next phase
2515
+ const currentPhase = phases.find(p => p.disk_status === 'planned' || p.disk_status === 'partial') || null;
2516
+ const nextPhase = phases.find(p => p.disk_status === 'empty' || p.disk_status === 'no_directory' || p.disk_status === 'discussed' || p.disk_status === 'researched') || null;
2517
+
2518
+ // Aggregated stats
2519
+ const totalPlans = phases.reduce((sum, p) => sum + p.plan_count, 0);
2520
+ const totalSummaries = phases.reduce((sum, p) => sum + p.summary_count, 0);
2521
+ const completedPhases = phases.filter(p => p.disk_status === 'complete').length;
2522
+
2523
+ const result = {
2524
+ milestones,
2525
+ phases,
2526
+ phase_count: phases.length,
2527
+ completed_phases: completedPhases,
2528
+ total_plans: totalPlans,
2529
+ total_summaries: totalSummaries,
2530
+ progress_percent: totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0,
2531
+ current_phase: currentPhase ? currentPhase.number : null,
2532
+ next_phase: nextPhase ? nextPhase.number : null,
2533
+ };
2534
+
2535
+ output(result, raw);
2536
+ }
2537
+
2538
+ // ─── Phase Add ────────────────────────────────────────────────────────────────
2539
+
2540
+ function cmdPhaseAdd(cwd, description, raw) {
2541
+ if (!description) {
2542
+ error('description required for phase add');
2543
+ }
2544
+
2545
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
2546
+ if (!fs.existsSync(roadmapPath)) {
2547
+ error('ROADMAP.md not found');
2548
+ }
2549
+
2550
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
2551
+ const slug = generateSlugInternal(description);
2552
+
2553
+ // Find highest integer phase number
2554
+ const phasePattern = /###\s*Phase\s+(\d+)(?:\.\d+)?:/gi;
2555
+ let maxPhase = 0;
2556
+ let m;
2557
+ while ((m = phasePattern.exec(content)) !== null) {
2558
+ const num = parseInt(m[1], 10);
2559
+ if (num > maxPhase) maxPhase = num;
2560
+ }
2561
+
2562
+ const newPhaseNum = maxPhase + 1;
2563
+ const paddedNum = String(newPhaseNum).padStart(2, '0');
2564
+ const dirName = `${paddedNum}-${slug}`;
2565
+ const dirPath = path.join(cwd, '.blueprint', 'phases', dirName);
2566
+
2567
+ // Create directory
2568
+ fs.mkdirSync(dirPath, { recursive: true });
2569
+
2570
+ // Build phase entry
2571
+ const phaseEntry = `\n### Phase ${newPhaseNum}: ${description}\n\n**Goal:** [To be planned]\n**Depends on:** Phase ${maxPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /bp:plan-phase ${newPhaseNum} to break down)\n`;
2572
+
2573
+ // Find insertion point: before last "---" or at end
2574
+ let updatedContent;
2575
+ const lastSeparator = content.lastIndexOf('\n---');
2576
+ if (lastSeparator > 0) {
2577
+ updatedContent = content.slice(0, lastSeparator) + phaseEntry + content.slice(lastSeparator);
2578
+ } else {
2579
+ updatedContent = content + phaseEntry;
2580
+ }
2581
+
2582
+ fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
2583
+
2584
+ const result = {
2585
+ phase_number: newPhaseNum,
2586
+ padded: paddedNum,
2587
+ name: description,
2588
+ slug,
2589
+ directory: `.blueprint/phases/${dirName}`,
2590
+ };
2591
+
2592
+ output(result, raw, paddedNum);
2593
+ }
2594
+
2595
+ // ─── Phase Insert (Decimal) ──────────────────────────────────────────────────
2596
+
2597
+ function cmdPhaseInsert(cwd, afterPhase, description, raw) {
2598
+ if (!afterPhase || !description) {
2599
+ error('after-phase and description required for phase insert');
2600
+ }
2601
+
2602
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
2603
+ if (!fs.existsSync(roadmapPath)) {
2604
+ error('ROADMAP.md not found');
2605
+ }
2606
+
2607
+ const content = fs.readFileSync(roadmapPath, 'utf-8');
2608
+ const slug = generateSlugInternal(description);
2609
+
2610
+ // Verify target phase exists
2611
+ const afterPhaseEscaped = afterPhase.replace(/\./g, '\\.');
2612
+ const targetPattern = new RegExp(`###\\s*Phase\\s+${afterPhaseEscaped}:`, 'i');
2613
+ if (!targetPattern.test(content)) {
2614
+ error(`Phase ${afterPhase} not found in ROADMAP.md`);
2615
+ }
2616
+
2617
+ // Calculate next decimal using existing logic
2618
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
2619
+ const normalizedBase = normalizePhaseName(afterPhase);
2620
+ let existingDecimals = [];
2621
+
2622
+ try {
2623
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
2624
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
2625
+ const decimalPattern = new RegExp(`^${normalizedBase}\\.(\\d+)`);
2626
+ for (const dir of dirs) {
2627
+ const dm = dir.match(decimalPattern);
2628
+ if (dm) existingDecimals.push(parseInt(dm[1], 10));
2629
+ }
2630
+ } catch {}
2631
+
2632
+ const nextDecimal = existingDecimals.length === 0 ? 1 : Math.max(...existingDecimals) + 1;
2633
+ const decimalPhase = `${normalizedBase}.${nextDecimal}`;
2634
+ const dirName = `${decimalPhase}-${slug}`;
2635
+ const dirPath = path.join(cwd, '.blueprint', 'phases', dirName);
2636
+
2637
+ // Create directory
2638
+ fs.mkdirSync(dirPath, { recursive: true });
2639
+
2640
+ // Build phase entry
2641
+ const phaseEntry = `\n### Phase ${decimalPhase}: ${description} (INSERTED)\n\n**Goal:** [Urgent work - to be planned]\n**Depends on:** Phase ${afterPhase}\n**Plans:** 0 plans\n\nPlans:\n- [ ] TBD (run /bp:plan-phase ${decimalPhase} to break down)\n`;
2642
+
2643
+ // Insert after the target phase section
2644
+ const headerPattern = new RegExp(`(###\\s*Phase\\s+${afterPhaseEscaped}:[^\\n]*\\n)`, 'i');
2645
+ const headerMatch = content.match(headerPattern);
2646
+ if (!headerMatch) {
2647
+ error(`Could not find Phase ${afterPhase} header`);
2648
+ }
2649
+
2650
+ const headerIdx = content.indexOf(headerMatch[0]);
2651
+ const afterHeader = content.slice(headerIdx + headerMatch[0].length);
2652
+ const nextPhaseMatch = afterHeader.match(/\n###\s+Phase\s+\d/i);
2653
+
2654
+ let insertIdx;
2655
+ if (nextPhaseMatch) {
2656
+ insertIdx = headerIdx + headerMatch[0].length + nextPhaseMatch.index;
2657
+ } else {
2658
+ insertIdx = content.length;
2659
+ }
2660
+
2661
+ const updatedContent = content.slice(0, insertIdx) + phaseEntry + content.slice(insertIdx);
2662
+ fs.writeFileSync(roadmapPath, updatedContent, 'utf-8');
2663
+
2664
+ const result = {
2665
+ phase_number: decimalPhase,
2666
+ after_phase: afterPhase,
2667
+ name: description,
2668
+ slug,
2669
+ directory: `.blueprint/phases/${dirName}`,
2670
+ };
2671
+
2672
+ output(result, raw, decimalPhase);
2673
+ }
2674
+
2675
+ // ─── Phase Remove ─────────────────────────────────────────────────────────────
2676
+
2677
+ function cmdPhaseRemove(cwd, targetPhase, options, raw) {
2678
+ if (!targetPhase) {
2679
+ error('phase number required for phase remove');
2680
+ }
2681
+
2682
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
2683
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
2684
+ const force = options.force || false;
2685
+
2686
+ if (!fs.existsSync(roadmapPath)) {
2687
+ error('ROADMAP.md not found');
2688
+ }
2689
+
2690
+ // Normalize the target
2691
+ const normalized = normalizePhaseName(targetPhase);
2692
+ const isDecimal = targetPhase.includes('.');
2693
+
2694
+ // Find and validate target directory
2695
+ let targetDir = null;
2696
+ try {
2697
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
2698
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
2699
+ targetDir = dirs.find(d => d.startsWith(normalized + '-') || d === normalized);
2700
+ } catch {}
2701
+
2702
+ // Check for executed work (SUMMARY.md files)
2703
+ if (targetDir && !force) {
2704
+ const targetPath = path.join(phasesDir, targetDir);
2705
+ const files = fs.readdirSync(targetPath);
2706
+ const summaries = files.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
2707
+ if (summaries.length > 0) {
2708
+ error(`Phase ${targetPhase} has ${summaries.length} executed plan(s). Use --force to remove anyway.`);
2709
+ }
2710
+ }
2711
+
2712
+ // Delete target directory
2713
+ if (targetDir) {
2714
+ fs.rmSync(path.join(phasesDir, targetDir), { recursive: true, force: true });
2715
+ }
2716
+
2717
+ // Renumber subsequent phases
2718
+ const renamedDirs = [];
2719
+ const renamedFiles = [];
2720
+
2721
+ if (isDecimal) {
2722
+ // Decimal removal: renumber sibling decimals (e.g., removing 06.2 → 06.3 becomes 06.2)
2723
+ const baseParts = normalized.split('.');
2724
+ const baseInt = baseParts[0];
2725
+ const removedDecimal = parseInt(baseParts[1], 10);
2726
+
2727
+ try {
2728
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
2729
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
2730
+
2731
+ // Find sibling decimals with higher numbers
2732
+ const decPattern = new RegExp(`^${baseInt}\\.(\\d+)-(.+)$`);
2733
+ const toRename = [];
2734
+ for (const dir of dirs) {
2735
+ const dm = dir.match(decPattern);
2736
+ if (dm && parseInt(dm[1], 10) > removedDecimal) {
2737
+ toRename.push({ dir, oldDecimal: parseInt(dm[1], 10), slug: dm[2] });
2738
+ }
2739
+ }
2740
+
2741
+ // Sort descending to avoid conflicts
2742
+ toRename.sort((a, b) => b.oldDecimal - a.oldDecimal);
2743
+
2744
+ for (const item of toRename) {
2745
+ const newDecimal = item.oldDecimal - 1;
2746
+ const oldPhaseId = `${baseInt}.${item.oldDecimal}`;
2747
+ const newPhaseId = `${baseInt}.${newDecimal}`;
2748
+ const newDirName = `${baseInt}.${newDecimal}-${item.slug}`;
2749
+
2750
+ // Rename directory
2751
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
2752
+ renamedDirs.push({ from: item.dir, to: newDirName });
2753
+
2754
+ // Rename files inside
2755
+ const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
2756
+ for (const f of dirFiles) {
2757
+ // Files may have phase prefix like "06.2-01-PLAN.md"
2758
+ if (f.includes(oldPhaseId)) {
2759
+ const newFileName = f.replace(oldPhaseId, newPhaseId);
2760
+ fs.renameSync(
2761
+ path.join(phasesDir, newDirName, f),
2762
+ path.join(phasesDir, newDirName, newFileName)
2763
+ );
2764
+ renamedFiles.push({ from: f, to: newFileName });
2765
+ }
2766
+ }
2767
+ }
2768
+ } catch {}
2769
+
2770
+ } else {
2771
+ // Integer removal: renumber all subsequent integer phases
2772
+ const removedInt = parseInt(normalized, 10);
2773
+
2774
+ try {
2775
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
2776
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
2777
+
2778
+ // Collect directories that need renumbering (integer phases > removed, and their decimals)
2779
+ const toRename = [];
2780
+ for (const dir of dirs) {
2781
+ const dm = dir.match(/^(\d+)(?:\.(\d+))?-(.+)$/);
2782
+ if (!dm) continue;
2783
+ const dirInt = parseInt(dm[1], 10);
2784
+ if (dirInt > removedInt) {
2785
+ toRename.push({
2786
+ dir,
2787
+ oldInt: dirInt,
2788
+ decimal: dm[2] ? parseInt(dm[2], 10) : null,
2789
+ slug: dm[3],
2790
+ });
2791
+ }
2792
+ }
2793
+
2794
+ // Sort descending to avoid conflicts
2795
+ toRename.sort((a, b) => {
2796
+ if (a.oldInt !== b.oldInt) return b.oldInt - a.oldInt;
2797
+ return (b.decimal || 0) - (a.decimal || 0);
2798
+ });
2799
+
2800
+ for (const item of toRename) {
2801
+ const newInt = item.oldInt - 1;
2802
+ const newPadded = String(newInt).padStart(2, '0');
2803
+ const oldPadded = String(item.oldInt).padStart(2, '0');
2804
+ const decimalSuffix = item.decimal !== null ? `.${item.decimal}` : '';
2805
+ const oldPrefix = `${oldPadded}${decimalSuffix}`;
2806
+ const newPrefix = `${newPadded}${decimalSuffix}`;
2807
+ const newDirName = `${newPrefix}-${item.slug}`;
2808
+
2809
+ // Rename directory
2810
+ fs.renameSync(path.join(phasesDir, item.dir), path.join(phasesDir, newDirName));
2811
+ renamedDirs.push({ from: item.dir, to: newDirName });
2812
+
2813
+ // Rename files inside
2814
+ const dirFiles = fs.readdirSync(path.join(phasesDir, newDirName));
2815
+ for (const f of dirFiles) {
2816
+ if (f.startsWith(oldPrefix)) {
2817
+ const newFileName = newPrefix + f.slice(oldPrefix.length);
2818
+ fs.renameSync(
2819
+ path.join(phasesDir, newDirName, f),
2820
+ path.join(phasesDir, newDirName, newFileName)
2821
+ );
2822
+ renamedFiles.push({ from: f, to: newFileName });
2823
+ }
2824
+ }
2825
+ }
2826
+ } catch {}
2827
+ }
2828
+
2829
+ // Update ROADMAP.md
2830
+ let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
2831
+
2832
+ // Remove the target phase section
2833
+ const targetEscaped = targetPhase.replace(/\./g, '\\.');
2834
+ const sectionPattern = new RegExp(
2835
+ `\\n?###\\s*Phase\\s+${targetEscaped}\\s*:[\\s\\S]*?(?=\\n###\\s+Phase\\s+\\d|$)`,
2836
+ 'i'
2837
+ );
2838
+ roadmapContent = roadmapContent.replace(sectionPattern, '');
2839
+
2840
+ // Remove from phase list (checkbox)
2841
+ const checkboxPattern = new RegExp(`\\n?-\\s*\\[[ x]\\]\\s*.*Phase\\s+${targetEscaped}[:\\s][^\\n]*`, 'gi');
2842
+ roadmapContent = roadmapContent.replace(checkboxPattern, '');
2843
+
2844
+ // Remove from progress table
2845
+ const tableRowPattern = new RegExp(`\\n?\\|\\s*${targetEscaped}\\.?\\s[^|]*\\|[^\\n]*`, 'gi');
2846
+ roadmapContent = roadmapContent.replace(tableRowPattern, '');
2847
+
2848
+ // Renumber references in ROADMAP for subsequent phases
2849
+ if (!isDecimal) {
2850
+ const removedInt = parseInt(normalized, 10);
2851
+
2852
+ // Collect all integer phases > removedInt
2853
+ const maxPhase = 99; // reasonable upper bound
2854
+ for (let oldNum = maxPhase; oldNum > removedInt; oldNum--) {
2855
+ const newNum = oldNum - 1;
2856
+ const oldStr = String(oldNum);
2857
+ const newStr = String(newNum);
2858
+ const oldPad = oldStr.padStart(2, '0');
2859
+ const newPad = newStr.padStart(2, '0');
2860
+
2861
+ // Phase headings: ### Phase 18: → ### Phase 17:
2862
+ roadmapContent = roadmapContent.replace(
2863
+ new RegExp(`(###\\s*Phase\\s+)${oldStr}(\\s*:)`, 'gi'),
2864
+ `$1${newStr}$2`
2865
+ );
2866
+
2867
+ // Checkbox items: - [ ] **Phase 18:** → - [ ] **Phase 17:**
2868
+ roadmapContent = roadmapContent.replace(
2869
+ new RegExp(`(Phase\\s+)${oldStr}([:\\s])`, 'g'),
2870
+ `$1${newStr}$2`
2871
+ );
2872
+
2873
+ // Plan references: 18-01 → 17-01
2874
+ roadmapContent = roadmapContent.replace(
2875
+ new RegExp(`${oldPad}-(\\d{2})`, 'g'),
2876
+ `${newPad}-$1`
2877
+ );
2878
+
2879
+ // Table rows: | 18. → | 17.
2880
+ roadmapContent = roadmapContent.replace(
2881
+ new RegExp(`(\\|\\s*)${oldStr}\\.\\s`, 'g'),
2882
+ `$1${newStr}. `
2883
+ );
2884
+
2885
+ // Depends on references
2886
+ roadmapContent = roadmapContent.replace(
2887
+ new RegExp(`(Depends on:\\*\\*\\s*Phase\\s+)${oldStr}\\b`, 'gi'),
2888
+ `$1${newStr}`
2889
+ );
2890
+ }
2891
+ }
2892
+
2893
+ fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
2894
+
2895
+ // Update STATE.md phase count
2896
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
2897
+ if (fs.existsSync(statePath)) {
2898
+ let stateContent = fs.readFileSync(statePath, 'utf-8');
2899
+ // Update "Total Phases" field
2900
+ const totalPattern = /(\*\*Total Phases:\*\*\s*)(\d+)/;
2901
+ const totalMatch = stateContent.match(totalPattern);
2902
+ if (totalMatch) {
2903
+ const oldTotal = parseInt(totalMatch[2], 10);
2904
+ stateContent = stateContent.replace(totalPattern, `$1${oldTotal - 1}`);
2905
+ }
2906
+ // Update "Phase: X of Y" pattern
2907
+ const ofPattern = /(\bof\s+)(\d+)(\s*(?:\(|phases?))/i;
2908
+ const ofMatch = stateContent.match(ofPattern);
2909
+ if (ofMatch) {
2910
+ const oldTotal = parseInt(ofMatch[2], 10);
2911
+ stateContent = stateContent.replace(ofPattern, `$1${oldTotal - 1}$3`);
2912
+ }
2913
+ fs.writeFileSync(statePath, stateContent, 'utf-8');
2914
+ }
2915
+
2916
+ const result = {
2917
+ removed: targetPhase,
2918
+ directory_deleted: targetDir || null,
2919
+ renamed_directories: renamedDirs,
2920
+ renamed_files: renamedFiles,
2921
+ roadmap_updated: true,
2922
+ state_updated: fs.existsSync(statePath),
2923
+ };
2924
+
2925
+ output(result, raw);
2926
+ }
2927
+
2928
+ // ─── Phase Complete (Transition) ──────────────────────────────────────────────
2929
+
2930
+ function cmdPhaseComplete(cwd, phaseNum, raw) {
2931
+ if (!phaseNum) {
2932
+ error('phase number required for phase complete');
2933
+ }
2934
+
2935
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
2936
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
2937
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
2938
+ const normalized = normalizePhaseName(phaseNum);
2939
+ const today = new Date().toISOString().split('T')[0];
2940
+
2941
+ // Verify phase info
2942
+ const phaseInfo = findPhaseInternal(cwd, phaseNum);
2943
+ if (!phaseInfo) {
2944
+ error(`Phase ${phaseNum} not found`);
2945
+ }
2946
+
2947
+ const planCount = phaseInfo.plans.length;
2948
+ const summaryCount = phaseInfo.summaries.length;
2949
+
2950
+ // Update ROADMAP.md: mark phase complete
2951
+ if (fs.existsSync(roadmapPath)) {
2952
+ let roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
2953
+
2954
+ // Checkbox: - [ ] Phase N: → - [x] Phase N: (...completed DATE)
2955
+ const checkboxPattern = new RegExp(
2956
+ `(-\\s*\\[)[ ](\\]\\s*.*Phase\\s+${phaseNum.replace('.', '\\.')}[:\\s][^\\n]*)`,
2957
+ 'i'
2958
+ );
2959
+ roadmapContent = roadmapContent.replace(checkboxPattern, `$1x$2 (completed ${today})`);
2960
+
2961
+ // Progress table: update Status to Complete, add date
2962
+ const phaseEscaped = phaseNum.replace('.', '\\.');
2963
+ const tablePattern = new RegExp(
2964
+ `(\\|\\s*${phaseEscaped}\\.?\\s[^|]*\\|[^|]*\\|)\\s*[^|]*(\\|)\\s*[^|]*(\\|)`,
2965
+ 'i'
2966
+ );
2967
+ roadmapContent = roadmapContent.replace(
2968
+ tablePattern,
2969
+ `$1 Complete $2 ${today} $3`
2970
+ );
2971
+
2972
+ // Update plan count in phase section
2973
+ const planCountPattern = new RegExp(
2974
+ `(###\\s*Phase\\s+${phaseEscaped}[\\s\\S]*?\\*\\*Plans:\\*\\*\\s*)[^\\n]+`,
2975
+ 'i'
2976
+ );
2977
+ roadmapContent = roadmapContent.replace(
2978
+ planCountPattern,
2979
+ `$1${summaryCount}/${planCount} plans complete`
2980
+ );
2981
+
2982
+ fs.writeFileSync(roadmapPath, roadmapContent, 'utf-8');
2983
+ }
2984
+
2985
+ // Find next phase
2986
+ let nextPhaseNum = null;
2987
+ let nextPhaseName = null;
2988
+ let isLastPhase = true;
2989
+
2990
+ try {
2991
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
2992
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
2993
+ const currentFloat = parseFloat(phaseNum);
2994
+
2995
+ // Find the next phase directory after current
2996
+ for (const dir of dirs) {
2997
+ const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/);
2998
+ if (dm) {
2999
+ const dirFloat = parseFloat(dm[1]);
3000
+ if (dirFloat > currentFloat) {
3001
+ nextPhaseNum = dm[1];
3002
+ nextPhaseName = dm[2] || null;
3003
+ isLastPhase = false;
3004
+ break;
3005
+ }
3006
+ }
3007
+ }
3008
+ } catch {}
3009
+
3010
+ // Update STATE.md
3011
+ if (fs.existsSync(statePath)) {
3012
+ let stateContent = fs.readFileSync(statePath, 'utf-8');
3013
+
3014
+ // Update Current Phase
3015
+ stateContent = stateContent.replace(
3016
+ /(\*\*Current Phase:\*\*\s*).*/,
3017
+ `$1${nextPhaseNum || phaseNum}`
3018
+ );
3019
+
3020
+ // Update Current Phase Name
3021
+ if (nextPhaseName) {
3022
+ stateContent = stateContent.replace(
3023
+ /(\*\*Current Phase Name:\*\*\s*).*/,
3024
+ `$1${nextPhaseName.replace(/-/g, ' ')}`
3025
+ );
3026
+ }
3027
+
3028
+ // Update Status
3029
+ stateContent = stateContent.replace(
3030
+ /(\*\*Status:\*\*\s*).*/,
3031
+ `$1${isLastPhase ? 'Milestone complete' : 'Ready to plan'}`
3032
+ );
3033
+
3034
+ // Update Current Plan
3035
+ stateContent = stateContent.replace(
3036
+ /(\*\*Current Plan:\*\*\s*).*/,
3037
+ `$1Not started`
3038
+ );
3039
+
3040
+ // Update Last Activity
3041
+ stateContent = stateContent.replace(
3042
+ /(\*\*Last Activity:\*\*\s*).*/,
3043
+ `$1${today}`
3044
+ );
3045
+
3046
+ // Update Last Activity Description
3047
+ stateContent = stateContent.replace(
3048
+ /(\*\*Last Activity Description:\*\*\s*).*/,
3049
+ `$1Phase ${phaseNum} complete${nextPhaseNum ? `, transitioned to Phase ${nextPhaseNum}` : ''}`
3050
+ );
3051
+
3052
+ fs.writeFileSync(statePath, stateContent, 'utf-8');
3053
+ }
3054
+
3055
+ const result = {
3056
+ completed_phase: phaseNum,
3057
+ phase_name: phaseInfo.phase_name,
3058
+ plans_executed: `${summaryCount}/${planCount}`,
3059
+ next_phase: nextPhaseNum,
3060
+ next_phase_name: nextPhaseName,
3061
+ is_last_phase: isLastPhase,
3062
+ date: today,
3063
+ roadmap_updated: fs.existsSync(roadmapPath),
3064
+ state_updated: fs.existsSync(statePath),
3065
+ };
3066
+
3067
+ output(result, raw);
3068
+ }
3069
+
3070
+ // ─── Milestone Complete ───────────────────────────────────────────────────────
3071
+
3072
+ function cmdMilestoneComplete(cwd, version, options, raw) {
3073
+ if (!version) {
3074
+ error('version required for milestone complete (e.g., v1.0)');
3075
+ }
3076
+
3077
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
3078
+ const reqPath = path.join(cwd, '.blueprint', 'REQUIREMENTS.md');
3079
+ const statePath = path.join(cwd, '.blueprint', 'STATE.md');
3080
+ const milestonesPath = path.join(cwd, '.blueprint', 'MILESTONES.md');
3081
+ const archiveDir = path.join(cwd, '.blueprint', 'milestones');
3082
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
3083
+ const today = new Date().toISOString().split('T')[0];
3084
+ const milestoneName = options.name || version;
3085
+
3086
+ // Ensure archive directory exists
3087
+ fs.mkdirSync(archiveDir, { recursive: true });
3088
+
3089
+ // Gather stats from phases
3090
+ let phaseCount = 0;
3091
+ let totalPlans = 0;
3092
+ let totalTasks = 0;
3093
+ const accomplishments = [];
3094
+
3095
+ try {
3096
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
3097
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
3098
+
3099
+ for (const dir of dirs) {
3100
+ phaseCount++;
3101
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
3102
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
3103
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
3104
+ totalPlans += plans.length;
3105
+
3106
+ // Extract one-liners from summaries
3107
+ for (const s of summaries) {
3108
+ try {
3109
+ const content = fs.readFileSync(path.join(phasesDir, dir, s), 'utf-8');
3110
+ const fm = extractFrontmatter(content);
3111
+ if (fm['one-liner']) {
3112
+ accomplishments.push(fm['one-liner']);
3113
+ }
3114
+ // Count tasks
3115
+ const taskMatches = content.match(/##\s*Task\s*\d+/gi) || [];
3116
+ totalTasks += taskMatches.length;
3117
+ } catch {}
3118
+ }
3119
+ }
3120
+ } catch {}
3121
+
3122
+ // Archive ROADMAP.md
3123
+ if (fs.existsSync(roadmapPath)) {
3124
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
3125
+ fs.writeFileSync(path.join(archiveDir, `${version}-ROADMAP.md`), roadmapContent, 'utf-8');
3126
+ }
3127
+
3128
+ // Archive REQUIREMENTS.md
3129
+ if (fs.existsSync(reqPath)) {
3130
+ const reqContent = fs.readFileSync(reqPath, 'utf-8');
3131
+ const archiveHeader = `# Requirements Archive: ${version} ${milestoneName}\n\n**Archived:** ${today}\n**Status:** SHIPPED\n\nFor current requirements, see \`.blueprint/REQUIREMENTS.md\`.\n\n---\n\n`;
3132
+ fs.writeFileSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`), archiveHeader + reqContent, 'utf-8');
3133
+ }
3134
+
3135
+ // Archive audit file if exists
3136
+ const auditFile = path.join(cwd, '.blueprint', `${version}-MILESTONE-AUDIT.md`);
3137
+ if (fs.existsSync(auditFile)) {
3138
+ fs.renameSync(auditFile, path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`));
3139
+ }
3140
+
3141
+ // Create/append MILESTONES.md entry
3142
+ const accomplishmentsList = accomplishments.map(a => `- ${a}`).join('\n');
3143
+ const milestoneEntry = `## ${version} ${milestoneName} (Shipped: ${today})\n\n**Phases completed:** ${phaseCount} phases, ${totalPlans} plans, ${totalTasks} tasks\n\n**Key accomplishments:**\n${accomplishmentsList || '- (none recorded)'}\n\n---\n\n`;
3144
+
3145
+ if (fs.existsSync(milestonesPath)) {
3146
+ const existing = fs.readFileSync(milestonesPath, 'utf-8');
3147
+ fs.writeFileSync(milestonesPath, existing + '\n' + milestoneEntry, 'utf-8');
3148
+ } else {
3149
+ fs.writeFileSync(milestonesPath, `# Milestones\n\n${milestoneEntry}`, 'utf-8');
3150
+ }
3151
+
3152
+ // Update STATE.md
3153
+ if (fs.existsSync(statePath)) {
3154
+ let stateContent = fs.readFileSync(statePath, 'utf-8');
3155
+ stateContent = stateContent.replace(
3156
+ /(\*\*Status:\*\*\s*).*/,
3157
+ `$1${version} milestone complete`
3158
+ );
3159
+ stateContent = stateContent.replace(
3160
+ /(\*\*Last Activity:\*\*\s*).*/,
3161
+ `$1${today}`
3162
+ );
3163
+ stateContent = stateContent.replace(
3164
+ /(\*\*Last Activity Description:\*\*\s*).*/,
3165
+ `$1${version} milestone completed and archived`
3166
+ );
3167
+ fs.writeFileSync(statePath, stateContent, 'utf-8');
3168
+ }
3169
+
3170
+ const result = {
3171
+ version,
3172
+ name: milestoneName,
3173
+ date: today,
3174
+ phases: phaseCount,
3175
+ plans: totalPlans,
3176
+ tasks: totalTasks,
3177
+ accomplishments,
3178
+ archived: {
3179
+ roadmap: fs.existsSync(path.join(archiveDir, `${version}-ROADMAP.md`)),
3180
+ requirements: fs.existsSync(path.join(archiveDir, `${version}-REQUIREMENTS.md`)),
3181
+ audit: fs.existsSync(path.join(archiveDir, `${version}-MILESTONE-AUDIT.md`)),
3182
+ },
3183
+ milestones_updated: true,
3184
+ state_updated: fs.existsSync(statePath),
3185
+ };
3186
+
3187
+ output(result, raw);
3188
+ }
3189
+
3190
+ // ─── Validate Consistency ─────────────────────────────────────────────────────
3191
+
3192
+ function cmdValidateConsistency(cwd, raw) {
3193
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
3194
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
3195
+ const errors = [];
3196
+ const warnings = [];
3197
+
3198
+ // Check for ROADMAP
3199
+ if (!fs.existsSync(roadmapPath)) {
3200
+ errors.push('ROADMAP.md not found');
3201
+ output({ passed: false, errors, warnings }, raw, 'failed');
3202
+ return;
3203
+ }
3204
+
3205
+ const roadmapContent = fs.readFileSync(roadmapPath, 'utf-8');
3206
+
3207
+ // Extract phases from ROADMAP
3208
+ const roadmapPhases = new Set();
3209
+ const phasePattern = /###\s*Phase\s+(\d+(?:\.\d+)?)\s*:/gi;
3210
+ let m;
3211
+ while ((m = phasePattern.exec(roadmapContent)) !== null) {
3212
+ roadmapPhases.add(m[1]);
3213
+ }
3214
+
3215
+ // Get phases on disk
3216
+ const diskPhases = new Set();
3217
+ try {
3218
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
3219
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
3220
+ for (const dir of dirs) {
3221
+ const dm = dir.match(/^(\d+(?:\.\d+)?)/);
3222
+ if (dm) diskPhases.add(dm[1]);
3223
+ }
3224
+ } catch {}
3225
+
3226
+ // Check: phases in ROADMAP but not on disk
3227
+ for (const p of roadmapPhases) {
3228
+ if (!diskPhases.has(p) && !diskPhases.has(normalizePhaseName(p))) {
3229
+ warnings.push(`Phase ${p} in ROADMAP.md but no directory on disk`);
3230
+ }
3231
+ }
3232
+
3233
+ // Check: phases on disk but not in ROADMAP
3234
+ for (const p of diskPhases) {
3235
+ const unpadded = String(parseInt(p, 10));
3236
+ if (!roadmapPhases.has(p) && !roadmapPhases.has(unpadded)) {
3237
+ warnings.push(`Phase ${p} exists on disk but not in ROADMAP.md`);
3238
+ }
3239
+ }
3240
+
3241
+ // Check: sequential phase numbers (integers only)
3242
+ const integerPhases = [...diskPhases]
3243
+ .filter(p => !p.includes('.'))
3244
+ .map(p => parseInt(p, 10))
3245
+ .sort((a, b) => a - b);
3246
+
3247
+ for (let i = 1; i < integerPhases.length; i++) {
3248
+ if (integerPhases[i] !== integerPhases[i - 1] + 1) {
3249
+ warnings.push(`Gap in phase numbering: ${integerPhases[i - 1]} → ${integerPhases[i]}`);
3250
+ }
3251
+ }
3252
+
3253
+ // Check: plan numbering within phases
3254
+ try {
3255
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
3256
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
3257
+
3258
+ for (const dir of dirs) {
3259
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
3260
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md')).sort();
3261
+
3262
+ // Extract plan numbers
3263
+ const planNums = plans.map(p => {
3264
+ const pm = p.match(/-(\d{2})-PLAN\.md$/);
3265
+ return pm ? parseInt(pm[1], 10) : null;
3266
+ }).filter(n => n !== null);
3267
+
3268
+ for (let i = 1; i < planNums.length; i++) {
3269
+ if (planNums[i] !== planNums[i - 1] + 1) {
3270
+ warnings.push(`Gap in plan numbering in ${dir}: plan ${planNums[i - 1]} → ${planNums[i]}`);
3271
+ }
3272
+ }
3273
+
3274
+ // Check: plans without summaries (completed plans)
3275
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md'));
3276
+ const planIds = new Set(plans.map(p => p.replace('-PLAN.md', '')));
3277
+ const summaryIds = new Set(summaries.map(s => s.replace('-SUMMARY.md', '')));
3278
+
3279
+ // Summary without matching plan is suspicious
3280
+ for (const sid of summaryIds) {
3281
+ if (!planIds.has(sid)) {
3282
+ warnings.push(`Summary ${sid}-SUMMARY.md in ${dir} has no matching PLAN.md`);
3283
+ }
3284
+ }
3285
+ }
3286
+ } catch {}
3287
+
3288
+ // Check: frontmatter in plans has required fields
3289
+ try {
3290
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
3291
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
3292
+
3293
+ for (const dir of dirs) {
3294
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
3295
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md'));
3296
+
3297
+ for (const plan of plans) {
3298
+ const content = fs.readFileSync(path.join(phasesDir, dir, plan), 'utf-8');
3299
+ const fm = extractFrontmatter(content);
3300
+
3301
+ if (!fm.wave) {
3302
+ warnings.push(`${dir}/${plan}: missing 'wave' in frontmatter`);
3303
+ }
3304
+ }
3305
+ }
3306
+ } catch {}
3307
+
3308
+ const passed = errors.length === 0;
3309
+ output({ passed, errors, warnings, warning_count: warnings.length }, raw, passed ? 'passed' : 'failed');
3310
+ }
3311
+
3312
+ // ─── Progress Render ──────────────────────────────────────────────────────────
3313
+
3314
+ function cmdProgressRender(cwd, format, raw) {
3315
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
3316
+ const roadmapPath = path.join(cwd, '.blueprint', 'ROADMAP.md');
3317
+ const milestone = getMilestoneInfo(cwd);
3318
+
3319
+ const phases = [];
3320
+ let totalPlans = 0;
3321
+ let totalSummaries = 0;
3322
+
3323
+ try {
3324
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
3325
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort((a, b) => {
3326
+ const aNum = parseFloat(a.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
3327
+ const bNum = parseFloat(b.match(/^(\d+(?:\.\d+)?)/)?.[1] || '0');
3328
+ return aNum - bNum;
3329
+ });
3330
+
3331
+ for (const dir of dirs) {
3332
+ const dm = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/);
3333
+ const phaseNum = dm ? dm[1] : dir;
3334
+ const phaseName = dm && dm[2] ? dm[2].replace(/-/g, ' ') : '';
3335
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
3336
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').length;
3337
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').length;
3338
+
3339
+ totalPlans += plans;
3340
+ totalSummaries += summaries;
3341
+
3342
+ let status;
3343
+ if (plans === 0) status = 'Pending';
3344
+ else if (summaries >= plans) status = 'Complete';
3345
+ else if (summaries > 0) status = 'In Progress';
3346
+ else status = 'Planned';
3347
+
3348
+ phases.push({ number: phaseNum, name: phaseName, plans, summaries, status });
3349
+ }
3350
+ } catch {}
3351
+
3352
+ const percent = totalPlans > 0 ? Math.round((totalSummaries / totalPlans) * 100) : 0;
3353
+
3354
+ if (format === 'table') {
3355
+ // Render markdown table
3356
+ const barWidth = 10;
3357
+ const filled = Math.round((percent / 100) * barWidth);
3358
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
3359
+ let out = `# ${milestone.version} ${milestone.name}\n\n`;
3360
+ out += `**Progress:** [${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)\n\n`;
3361
+ out += `| Phase | Name | Plans | Status |\n`;
3362
+ out += `|-------|------|-------|--------|\n`;
3363
+ for (const p of phases) {
3364
+ out += `| ${p.number} | ${p.name} | ${p.summaries}/${p.plans} | ${p.status} |\n`;
3365
+ }
3366
+ output({ rendered: out }, raw, out);
3367
+ } else if (format === 'bar') {
3368
+ const barWidth = 20;
3369
+ const filled = Math.round((percent / 100) * barWidth);
3370
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(barWidth - filled);
3371
+ const text = `[${bar}] ${totalSummaries}/${totalPlans} plans (${percent}%)`;
3372
+ output({ bar: text, percent, completed: totalSummaries, total: totalPlans }, raw, text);
3373
+ } else {
3374
+ // JSON format
3375
+ output({
3376
+ milestone_version: milestone.version,
3377
+ milestone_name: milestone.name,
3378
+ phases,
3379
+ total_plans: totalPlans,
3380
+ total_summaries: totalSummaries,
3381
+ percent,
3382
+ }, raw);
3383
+ }
3384
+ }
3385
+
3386
+ // ─── Todo Complete ────────────────────────────────────────────────────────────
3387
+
3388
+ function cmdTodoComplete(cwd, filename, raw) {
3389
+ if (!filename) {
3390
+ error('filename required for todo complete');
3391
+ }
3392
+
3393
+ const pendingDir = path.join(cwd, '.blueprint', 'todos', 'pending');
3394
+ const completedDir = path.join(cwd, '.blueprint', 'todos', 'completed');
3395
+ const sourcePath = path.join(pendingDir, filename);
3396
+
3397
+ if (!fs.existsSync(sourcePath)) {
3398
+ error(`Todo not found: ${filename}`);
3399
+ }
3400
+
3401
+ // Ensure completed directory exists
3402
+ fs.mkdirSync(completedDir, { recursive: true });
3403
+
3404
+ // Read, add completion timestamp, move
3405
+ let content = fs.readFileSync(sourcePath, 'utf-8');
3406
+ const today = new Date().toISOString().split('T')[0];
3407
+ content = `completed: ${today}\n` + content;
3408
+
3409
+ fs.writeFileSync(path.join(completedDir, filename), content, 'utf-8');
3410
+ fs.unlinkSync(sourcePath);
3411
+
3412
+ output({ completed: true, file: filename, date: today }, raw, 'completed');
3413
+ }
3414
+
3415
+ // ─── Scaffold ─────────────────────────────────────────────────────────────────
3416
+
3417
+ function cmdScaffold(cwd, type, options, raw) {
3418
+ const { phase, name } = options;
3419
+ const padded = phase ? normalizePhaseName(phase) : '00';
3420
+ const today = new Date().toISOString().split('T')[0];
3421
+
3422
+ // Find phase directory
3423
+ const phaseInfo = phase ? findPhaseInternal(cwd, phase) : null;
3424
+ const phaseDir = phaseInfo ? path.join(cwd, phaseInfo.directory) : null;
3425
+
3426
+ if (phase && !phaseDir && type !== 'phase-dir') {
3427
+ error(`Phase ${phase} directory not found`);
3428
+ }
3429
+
3430
+ let filePath, content;
3431
+
3432
+ switch (type) {
3433
+ case 'context': {
3434
+ filePath = path.join(phaseDir, `${padded}-CONTEXT.md`);
3435
+ content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Context\n\n## Decisions\n\n_Decisions will be captured during /bp:discuss-phase ${phase}_\n\n## Discretion Areas\n\n_Areas where the executor can use judgment_\n\n## Deferred Ideas\n\n_Ideas to consider later_\n`;
3436
+ break;
3437
+ }
3438
+ case 'uat': {
3439
+ filePath = path.join(phaseDir, `${padded}-UAT.md`);
3440
+ content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — User Acceptance Testing\n\n## Test Results\n\n| # | Test | Status | Notes |\n|---|------|--------|-------|\n\n## Summary\n\n_Pending UAT_\n`;
3441
+ break;
3442
+ }
3443
+ case 'verification': {
3444
+ filePath = path.join(phaseDir, `${padded}-VERIFICATION.md`);
3445
+ content = `---\nphase: "${padded}"\nname: "${name || phaseInfo?.phase_name || 'Unnamed'}"\ncreated: ${today}\nstatus: pending\n---\n\n# Phase ${phase}: ${name || phaseInfo?.phase_name || 'Unnamed'} — Verification\n\n## Goal-Backward Verification\n\n**Phase Goal:** [From ROADMAP.md]\n\n## Checks\n\n| # | Requirement | Status | Evidence |\n|---|------------|--------|----------|\n\n## Result\n\n_Pending verification_\n`;
3446
+ break;
3447
+ }
3448
+ case 'phase-dir': {
3449
+ if (!phase || !name) {
3450
+ error('phase and name required for phase-dir scaffold');
3451
+ }
3452
+ const slug = generateSlugInternal(name);
3453
+ const dirName = `${padded}-${slug}`;
3454
+ const phasesParent = path.join(cwd, '.blueprint', 'phases');
3455
+ fs.mkdirSync(phasesParent, { recursive: true });
3456
+ const dirPath = path.join(phasesParent, dirName);
3457
+ fs.mkdirSync(dirPath, { recursive: true });
3458
+ output({ created: true, directory: `.blueprint/phases/${dirName}`, path: dirPath }, raw, dirPath);
3459
+ return;
3460
+ }
3461
+ default:
3462
+ error(`Unknown scaffold type: ${type}. Available: context, uat, verification, phase-dir`);
3463
+ }
3464
+
3465
+ if (fs.existsSync(filePath)) {
3466
+ output({ created: false, reason: 'already_exists', path: filePath }, raw, 'exists');
3467
+ return;
3468
+ }
3469
+
3470
+ fs.writeFileSync(filePath, content, 'utf-8');
3471
+ const relPath = path.relative(cwd, filePath);
3472
+ output({ created: true, path: relPath }, raw, relPath);
3473
+ }
3474
+
3475
+ // ─── Compound Commands ────────────────────────────────────────────────────────
3476
+
3477
+ function resolveModelInternal(cwd, agentType) {
3478
+ const config = loadConfig(cwd);
3479
+ const profile = config.model_profile || 'balanced';
3480
+ const agentModels = MODEL_PROFILES[agentType];
3481
+ if (!agentModels) return 'sonnet';
3482
+ return agentModels[profile] || agentModels['balanced'] || 'sonnet';
3483
+ }
3484
+
3485
+ function findPhaseInternal(cwd, phase) {
3486
+ if (!phase) return null;
3487
+
3488
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
3489
+ const normalized = normalizePhaseName(phase);
3490
+
3491
+ try {
3492
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
3493
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
3494
+ const match = dirs.find(d => d.startsWith(normalized));
3495
+ if (!match) return null;
3496
+
3497
+ const dirMatch = match.match(/^(\d+(?:\.\d+)?)-?(.*)/);
3498
+ const phaseNumber = dirMatch ? dirMatch[1] : normalized;
3499
+ const phaseName = dirMatch && dirMatch[2] ? dirMatch[2] : null;
3500
+ const phaseDir = path.join(phasesDir, match);
3501
+ const phaseFiles = fs.readdirSync(phaseDir);
3502
+
3503
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md').sort();
3504
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md').sort();
3505
+ const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
3506
+ const hasContext = phaseFiles.some(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
3507
+ const hasVerification = phaseFiles.some(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
3508
+
3509
+ // Determine incomplete plans (plans without matching summaries)
3510
+ const completedPlanIds = new Set(
3511
+ summaries.map(s => s.replace('-SUMMARY.md', '').replace('SUMMARY.md', ''))
3512
+ );
3513
+ const incompletePlans = plans.filter(p => {
3514
+ const planId = p.replace('-PLAN.md', '').replace('PLAN.md', '');
3515
+ return !completedPlanIds.has(planId);
3516
+ });
3517
+
3518
+ return {
3519
+ found: true,
3520
+ directory: path.join('.blueprint', 'phases', match),
3521
+ phase_number: phaseNumber,
3522
+ phase_name: phaseName,
3523
+ phase_slug: phaseName ? phaseName.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '') : null,
3524
+ plans,
3525
+ summaries,
3526
+ incomplete_plans: incompletePlans,
3527
+ has_research: hasResearch,
3528
+ has_context: hasContext,
3529
+ has_verification: hasVerification,
3530
+ };
3531
+ } catch {
3532
+ return null;
3533
+ }
3534
+ }
3535
+
3536
+ function pathExistsInternal(cwd, targetPath) {
3537
+ const fullPath = path.isAbsolute(targetPath) ? targetPath : path.join(cwd, targetPath);
3538
+ try {
3539
+ fs.statSync(fullPath);
3540
+ return true;
3541
+ } catch {
3542
+ return false;
3543
+ }
3544
+ }
3545
+
3546
+ function generateSlugInternal(text) {
3547
+ if (!text) return null;
3548
+ return text.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-+|-+$/g, '');
3549
+ }
3550
+
3551
+ function getMilestoneInfo(cwd) {
3552
+ try {
3553
+ const roadmap = fs.readFileSync(path.join(cwd, '.blueprint', 'ROADMAP.md'), 'utf-8');
3554
+ const versionMatch = roadmap.match(/v(\d+\.\d+)/);
3555
+ const nameMatch = roadmap.match(/## .*v\d+\.\d+[:\s]+([^\n(]+)/);
3556
+ return {
3557
+ version: versionMatch ? versionMatch[0] : 'v1.0',
3558
+ name: nameMatch ? nameMatch[1].trim() : 'milestone',
3559
+ };
3560
+ } catch {
3561
+ return { version: 'v1.0', name: 'milestone' };
3562
+ }
3563
+ }
3564
+
3565
+ function cmdInitExecutePhase(cwd, phase, includes, raw) {
3566
+ if (!phase) {
3567
+ error('phase required for init execute-phase');
3568
+ }
3569
+
3570
+ const config = loadConfig(cwd);
3571
+ const phaseInfo = findPhaseInternal(cwd, phase);
3572
+ const milestone = getMilestoneInfo(cwd);
3573
+
3574
+ const result = {
3575
+ // Models
3576
+ executor_model: resolveModelInternal(cwd, 'bp-executor'),
3577
+ verifier_model: resolveModelInternal(cwd, 'bp-verifier'),
3578
+
3579
+ // Config flags
3580
+ commit_docs: config.commit_docs,
3581
+ parallelization: config.parallelization,
3582
+ branching_strategy: config.branching_strategy,
3583
+ phase_branch_template: config.phase_branch_template,
3584
+ milestone_branch_template: config.milestone_branch_template,
3585
+ verifier_enabled: config.verifier,
3586
+
3587
+ // Phase info
3588
+ phase_found: !!phaseInfo,
3589
+ phase_dir: phaseInfo?.directory || null,
3590
+ phase_number: phaseInfo?.phase_number || null,
3591
+ phase_name: phaseInfo?.phase_name || null,
3592
+ phase_slug: phaseInfo?.phase_slug || null,
3593
+
3594
+ // Plan inventory
3595
+ plans: phaseInfo?.plans || [],
3596
+ summaries: phaseInfo?.summaries || [],
3597
+ incomplete_plans: phaseInfo?.incomplete_plans || [],
3598
+ plan_count: phaseInfo?.plans?.length || 0,
3599
+ incomplete_count: phaseInfo?.incomplete_plans?.length || 0,
3600
+
3601
+ // Branch name (pre-computed)
3602
+ branch_name: config.branching_strategy === 'phase' && phaseInfo
3603
+ ? config.phase_branch_template
3604
+ .replace('{phase}', phaseInfo.phase_number)
3605
+ .replace('{slug}', phaseInfo.phase_slug || 'phase')
3606
+ : config.branching_strategy === 'milestone'
3607
+ ? config.milestone_branch_template
3608
+ .replace('{milestone}', milestone.version)
3609
+ .replace('{slug}', generateSlugInternal(milestone.name) || 'milestone')
3610
+ : null,
3611
+
3612
+ // Milestone info
3613
+ milestone_version: milestone.version,
3614
+ milestone_name: milestone.name,
3615
+ milestone_slug: generateSlugInternal(milestone.name),
3616
+
3617
+ // File existence
3618
+ state_exists: pathExistsInternal(cwd, '.blueprint/STATE.md'),
3619
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
3620
+ config_exists: pathExistsInternal(cwd, '.blueprint/config.json'),
3621
+ };
3622
+
3623
+ // Include file contents if requested via --include
3624
+ if (includes.has('state')) {
3625
+ result.state_content = safeReadFile(path.join(cwd, '.blueprint', 'STATE.md'));
3626
+ }
3627
+ if (includes.has('config')) {
3628
+ result.config_content = safeReadFile(path.join(cwd, '.blueprint', 'config.json'));
3629
+ }
3630
+ if (includes.has('roadmap')) {
3631
+ result.roadmap_content = safeReadFile(path.join(cwd, '.blueprint', 'ROADMAP.md'));
3632
+ }
3633
+
3634
+ output(result, raw);
3635
+ }
3636
+
3637
+ function cmdInitPlanPhase(cwd, phase, includes, raw) {
3638
+ if (!phase) {
3639
+ error('phase required for init plan-phase');
3640
+ }
3641
+
3642
+ const config = loadConfig(cwd);
3643
+ const phaseInfo = findPhaseInternal(cwd, phase);
3644
+
3645
+ const result = {
3646
+ // Models
3647
+ researcher_model: resolveModelInternal(cwd, 'bp-phase-researcher'),
3648
+ planner_model: resolveModelInternal(cwd, 'bp-planner'),
3649
+ checker_model: resolveModelInternal(cwd, 'bp-plan-checker'),
3650
+
3651
+ // Workflow flags
3652
+ research_enabled: config.research,
3653
+ plan_checker_enabled: config.plan_checker,
3654
+ commit_docs: config.commit_docs,
3655
+
3656
+ // Phase info
3657
+ phase_found: !!phaseInfo,
3658
+ phase_dir: phaseInfo?.directory || null,
3659
+ phase_number: phaseInfo?.phase_number || null,
3660
+ phase_name: phaseInfo?.phase_name || null,
3661
+ phase_slug: phaseInfo?.phase_slug || null,
3662
+ padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
3663
+
3664
+ // Existing artifacts
3665
+ has_research: phaseInfo?.has_research || false,
3666
+ has_context: phaseInfo?.has_context || false,
3667
+ has_plans: (phaseInfo?.plans?.length || 0) > 0,
3668
+ plan_count: phaseInfo?.plans?.length || 0,
3669
+
3670
+ // Environment
3671
+ planning_exists: pathExistsInternal(cwd, '.blueprint'),
3672
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
3673
+ };
3674
+
3675
+ // Include file contents if requested via --include
3676
+ if (includes.has('state')) {
3677
+ result.state_content = safeReadFile(path.join(cwd, '.blueprint', 'STATE.md'));
3678
+ }
3679
+ if (includes.has('roadmap')) {
3680
+ result.roadmap_content = safeReadFile(path.join(cwd, '.blueprint', 'ROADMAP.md'));
3681
+ }
3682
+ if (includes.has('requirements')) {
3683
+ result.requirements_content = safeReadFile(path.join(cwd, '.blueprint', 'REQUIREMENTS.md'));
3684
+ }
3685
+ if (includes.has('context') && phaseInfo?.directory) {
3686
+ // Find *-CONTEXT.md in phase directory
3687
+ const phaseDirFull = path.join(cwd, phaseInfo.directory);
3688
+ try {
3689
+ const files = fs.readdirSync(phaseDirFull);
3690
+ const contextFile = files.find(f => f.endsWith('-CONTEXT.md') || f === 'CONTEXT.md');
3691
+ if (contextFile) {
3692
+ result.context_content = safeReadFile(path.join(phaseDirFull, contextFile));
3693
+ }
3694
+ } catch {}
3695
+ }
3696
+ if (includes.has('research') && phaseInfo?.directory) {
3697
+ // Find *-RESEARCH.md in phase directory
3698
+ const phaseDirFull = path.join(cwd, phaseInfo.directory);
3699
+ try {
3700
+ const files = fs.readdirSync(phaseDirFull);
3701
+ const researchFile = files.find(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
3702
+ if (researchFile) {
3703
+ result.research_content = safeReadFile(path.join(phaseDirFull, researchFile));
3704
+ }
3705
+ } catch {}
3706
+ }
3707
+ if (includes.has('verification') && phaseInfo?.directory) {
3708
+ // Find *-VERIFICATION.md in phase directory
3709
+ const phaseDirFull = path.join(cwd, phaseInfo.directory);
3710
+ try {
3711
+ const files = fs.readdirSync(phaseDirFull);
3712
+ const verificationFile = files.find(f => f.endsWith('-VERIFICATION.md') || f === 'VERIFICATION.md');
3713
+ if (verificationFile) {
3714
+ result.verification_content = safeReadFile(path.join(phaseDirFull, verificationFile));
3715
+ }
3716
+ } catch {}
3717
+ }
3718
+ if (includes.has('uat') && phaseInfo?.directory) {
3719
+ // Find *-UAT.md in phase directory
3720
+ const phaseDirFull = path.join(cwd, phaseInfo.directory);
3721
+ try {
3722
+ const files = fs.readdirSync(phaseDirFull);
3723
+ const uatFile = files.find(f => f.endsWith('-UAT.md') || f === 'UAT.md');
3724
+ if (uatFile) {
3725
+ result.uat_content = safeReadFile(path.join(phaseDirFull, uatFile));
3726
+ }
3727
+ } catch {}
3728
+ }
3729
+
3730
+ output(result, raw);
3731
+ }
3732
+
3733
+ function cmdInitNewProject(cwd, raw) {
3734
+ const config = loadConfig(cwd);
3735
+
3736
+ // Detect Brave Search API key availability
3737
+ const homedir = require('os').homedir();
3738
+ const braveKeyFile = path.join(homedir, '.blueprint', 'brave_api_key');
3739
+ const hasBraveSearch = !!(process.env.BRAVE_API_KEY || fs.existsSync(braveKeyFile));
3740
+
3741
+ // Detect existing code
3742
+ let hasCode = false;
3743
+ let hasPackageFile = false;
3744
+ try {
3745
+ const files = execSync('find . -maxdepth 3 \\( -name "*.ts" -o -name "*.js" -o -name "*.py" -o -name "*.go" -o -name "*.rs" -o -name "*.swift" -o -name "*.java" \\) 2>/dev/null | grep -v node_modules | grep -v .git | head -5', {
3746
+ cwd,
3747
+ encoding: 'utf-8',
3748
+ stdio: ['pipe', 'pipe', 'pipe'],
3749
+ });
3750
+ hasCode = files.trim().length > 0;
3751
+ } catch {}
3752
+
3753
+ hasPackageFile = pathExistsInternal(cwd, 'package.json') ||
3754
+ pathExistsInternal(cwd, 'requirements.txt') ||
3755
+ pathExistsInternal(cwd, 'Cargo.toml') ||
3756
+ pathExistsInternal(cwd, 'go.mod') ||
3757
+ pathExistsInternal(cwd, 'Package.swift');
3758
+
3759
+ const result = {
3760
+ // Models
3761
+ researcher_model: resolveModelInternal(cwd, 'bp-project-researcher'),
3762
+ synthesizer_model: resolveModelInternal(cwd, 'bp-research-synthesizer'),
3763
+ roadmapper_model: resolveModelInternal(cwd, 'bp-roadmapper'),
3764
+
3765
+ // Config
3766
+ commit_docs: config.commit_docs,
3767
+
3768
+ // Existing state
3769
+ project_exists: pathExistsInternal(cwd, '.blueprint/PROJECT.md'),
3770
+ has_codebase_map: pathExistsInternal(cwd, '.blueprint/codebase'),
3771
+ planning_exists: pathExistsInternal(cwd, '.blueprint'),
3772
+
3773
+ // Brownfield detection
3774
+ has_existing_code: hasCode,
3775
+ has_package_file: hasPackageFile,
3776
+ is_brownfield: hasCode || hasPackageFile,
3777
+ needs_codebase_map: (hasCode || hasPackageFile) && !pathExistsInternal(cwd, '.blueprint/codebase'),
3778
+
3779
+ // Git state
3780
+ has_git: pathExistsInternal(cwd, '.git'),
3781
+
3782
+ // Enhanced search
3783
+ brave_search_available: hasBraveSearch,
3784
+ };
3785
+
3786
+ output(result, raw);
3787
+ }
3788
+
3789
+ function cmdInitNewMilestone(cwd, raw) {
3790
+ const config = loadConfig(cwd);
3791
+ const milestone = getMilestoneInfo(cwd);
3792
+
3793
+ const result = {
3794
+ // Models
3795
+ researcher_model: resolveModelInternal(cwd, 'bp-project-researcher'),
3796
+ synthesizer_model: resolveModelInternal(cwd, 'bp-research-synthesizer'),
3797
+ roadmapper_model: resolveModelInternal(cwd, 'bp-roadmapper'),
3798
+
3799
+ // Config
3800
+ commit_docs: config.commit_docs,
3801
+ research_enabled: config.research,
3802
+
3803
+ // Current milestone
3804
+ current_milestone: milestone.version,
3805
+ current_milestone_name: milestone.name,
3806
+
3807
+ // File existence
3808
+ project_exists: pathExistsInternal(cwd, '.blueprint/PROJECT.md'),
3809
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
3810
+ state_exists: pathExistsInternal(cwd, '.blueprint/STATE.md'),
3811
+ };
3812
+
3813
+ output(result, raw);
3814
+ }
3815
+
3816
+ function cmdInitQuick(cwd, description, raw) {
3817
+ const config = loadConfig(cwd);
3818
+ const now = new Date();
3819
+ const slug = description ? generateSlugInternal(description)?.substring(0, 40) : null;
3820
+
3821
+ // Find next quick task number
3822
+ const quickDir = path.join(cwd, '.blueprint', 'quick');
3823
+ let nextNum = 1;
3824
+ try {
3825
+ const existing = fs.readdirSync(quickDir)
3826
+ .filter(f => /^\d+-/.test(f))
3827
+ .map(f => parseInt(f.split('-')[0], 10))
3828
+ .filter(n => !isNaN(n));
3829
+ if (existing.length > 0) {
3830
+ nextNum = Math.max(...existing) + 1;
3831
+ }
3832
+ } catch {}
3833
+
3834
+ const result = {
3835
+ // Models
3836
+ planner_model: resolveModelInternal(cwd, 'bp-planner'),
3837
+ executor_model: resolveModelInternal(cwd, 'bp-executor'),
3838
+
3839
+ // Config
3840
+ commit_docs: config.commit_docs,
3841
+
3842
+ // Quick task info
3843
+ next_num: nextNum,
3844
+ slug: slug,
3845
+ description: description || null,
3846
+
3847
+ // Timestamps
3848
+ date: now.toISOString().split('T')[0],
3849
+ timestamp: now.toISOString(),
3850
+
3851
+ // Paths
3852
+ quick_dir: '.blueprint/quick',
3853
+ task_dir: slug ? `.blueprint/quick/${nextNum}-${slug}` : null,
3854
+
3855
+ // File existence
3856
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
3857
+ planning_exists: pathExistsInternal(cwd, '.blueprint'),
3858
+ };
3859
+
3860
+ output(result, raw);
3861
+ }
3862
+
3863
+ function cmdInitResume(cwd, raw) {
3864
+ const config = loadConfig(cwd);
3865
+
3866
+ // Check for interrupted agent
3867
+ let interruptedAgentId = null;
3868
+ try {
3869
+ interruptedAgentId = fs.readFileSync(path.join(cwd, '.blueprint', 'current-agent-id.txt'), 'utf-8').trim();
3870
+ } catch {}
3871
+
3872
+ const result = {
3873
+ // File existence
3874
+ state_exists: pathExistsInternal(cwd, '.blueprint/STATE.md'),
3875
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
3876
+ project_exists: pathExistsInternal(cwd, '.blueprint/PROJECT.md'),
3877
+ planning_exists: pathExistsInternal(cwd, '.blueprint'),
3878
+
3879
+ // Agent state
3880
+ has_interrupted_agent: !!interruptedAgentId,
3881
+ interrupted_agent_id: interruptedAgentId,
3882
+
3883
+ // Config
3884
+ commit_docs: config.commit_docs,
3885
+ };
3886
+
3887
+ output(result, raw);
3888
+ }
3889
+
3890
+ function cmdInitVerifyWork(cwd, phase, raw) {
3891
+ if (!phase) {
3892
+ error('phase required for init verify-work');
3893
+ }
3894
+
3895
+ const config = loadConfig(cwd);
3896
+ const phaseInfo = findPhaseInternal(cwd, phase);
3897
+
3898
+ const result = {
3899
+ // Models
3900
+ planner_model: resolveModelInternal(cwd, 'bp-planner'),
3901
+ checker_model: resolveModelInternal(cwd, 'bp-plan-checker'),
3902
+
3903
+ // Config
3904
+ commit_docs: config.commit_docs,
3905
+
3906
+ // Phase info
3907
+ phase_found: !!phaseInfo,
3908
+ phase_dir: phaseInfo?.directory || null,
3909
+ phase_number: phaseInfo?.phase_number || null,
3910
+ phase_name: phaseInfo?.phase_name || null,
3911
+
3912
+ // Existing artifacts
3913
+ has_verification: phaseInfo?.has_verification || false,
3914
+ };
3915
+
3916
+ output(result, raw);
3917
+ }
3918
+
3919
+ function cmdInitPhaseOp(cwd, phase, raw) {
3920
+ const config = loadConfig(cwd);
3921
+ const phaseInfo = findPhaseInternal(cwd, phase);
3922
+
3923
+ const result = {
3924
+ // Config
3925
+ commit_docs: config.commit_docs,
3926
+ brave_search: config.brave_search,
3927
+
3928
+ // Phase info
3929
+ phase_found: !!phaseInfo,
3930
+ phase_dir: phaseInfo?.directory || null,
3931
+ phase_number: phaseInfo?.phase_number || null,
3932
+ phase_name: phaseInfo?.phase_name || null,
3933
+ phase_slug: phaseInfo?.phase_slug || null,
3934
+ padded_phase: phaseInfo?.phase_number?.padStart(2, '0') || null,
3935
+
3936
+ // Existing artifacts
3937
+ has_research: phaseInfo?.has_research || false,
3938
+ has_context: phaseInfo?.has_context || false,
3939
+ has_plans: (phaseInfo?.plans?.length || 0) > 0,
3940
+ has_verification: phaseInfo?.has_verification || false,
3941
+ plan_count: phaseInfo?.plans?.length || 0,
3942
+
3943
+ // File existence
3944
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
3945
+ planning_exists: pathExistsInternal(cwd, '.blueprint'),
3946
+ };
3947
+
3948
+ output(result, raw);
3949
+ }
3950
+
3951
+ function cmdInitTodos(cwd, area, raw) {
3952
+ const config = loadConfig(cwd);
3953
+ const now = new Date();
3954
+
3955
+ // List todos (reuse existing logic)
3956
+ const pendingDir = path.join(cwd, '.blueprint', 'todos', 'pending');
3957
+ let count = 0;
3958
+ const todos = [];
3959
+
3960
+ try {
3961
+ const files = fs.readdirSync(pendingDir).filter(f => f.endsWith('.md'));
3962
+ for (const file of files) {
3963
+ try {
3964
+ const content = fs.readFileSync(path.join(pendingDir, file), 'utf-8');
3965
+ const createdMatch = content.match(/^created:\s*(.+)$/m);
3966
+ const titleMatch = content.match(/^title:\s*(.+)$/m);
3967
+ const areaMatch = content.match(/^area:\s*(.+)$/m);
3968
+ const todoArea = areaMatch ? areaMatch[1].trim() : 'general';
3969
+
3970
+ if (area && todoArea !== area) continue;
3971
+
3972
+ count++;
3973
+ todos.push({
3974
+ file,
3975
+ created: createdMatch ? createdMatch[1].trim() : 'unknown',
3976
+ title: titleMatch ? titleMatch[1].trim() : 'Untitled',
3977
+ area: todoArea,
3978
+ path: path.join('.blueprint', 'todos', 'pending', file),
3979
+ });
3980
+ } catch {}
3981
+ }
3982
+ } catch {}
3983
+
3984
+ const result = {
3985
+ // Config
3986
+ commit_docs: config.commit_docs,
3987
+
3988
+ // Timestamps
3989
+ date: now.toISOString().split('T')[0],
3990
+ timestamp: now.toISOString(),
3991
+
3992
+ // Todo inventory
3993
+ todo_count: count,
3994
+ todos,
3995
+ area_filter: area || null,
3996
+
3997
+ // Paths
3998
+ pending_dir: '.blueprint/todos/pending',
3999
+ completed_dir: '.blueprint/todos/completed',
4000
+
4001
+ // File existence
4002
+ planning_exists: pathExistsInternal(cwd, '.blueprint'),
4003
+ todos_dir_exists: pathExistsInternal(cwd, '.blueprint/todos'),
4004
+ pending_dir_exists: pathExistsInternal(cwd, '.blueprint/todos/pending'),
4005
+ };
4006
+
4007
+ output(result, raw);
4008
+ }
4009
+
4010
+ function cmdInitMilestoneOp(cwd, raw) {
4011
+ const config = loadConfig(cwd);
4012
+ const milestone = getMilestoneInfo(cwd);
4013
+
4014
+ // Count phases
4015
+ let phaseCount = 0;
4016
+ let completedPhases = 0;
4017
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
4018
+ try {
4019
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
4020
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name);
4021
+ phaseCount = dirs.length;
4022
+
4023
+ // Count phases with summaries (completed)
4024
+ for (const dir of dirs) {
4025
+ try {
4026
+ const phaseFiles = fs.readdirSync(path.join(phasesDir, dir));
4027
+ const hasSummary = phaseFiles.some(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
4028
+ if (hasSummary) completedPhases++;
4029
+ } catch {}
4030
+ }
4031
+ } catch {}
4032
+
4033
+ // Check archive
4034
+ const archiveDir = path.join(cwd, '.blueprint', 'archive');
4035
+ let archivedMilestones = [];
4036
+ try {
4037
+ archivedMilestones = fs.readdirSync(archiveDir, { withFileTypes: true })
4038
+ .filter(e => e.isDirectory())
4039
+ .map(e => e.name);
4040
+ } catch {}
4041
+
4042
+ const result = {
4043
+ // Config
4044
+ commit_docs: config.commit_docs,
4045
+
4046
+ // Current milestone
4047
+ milestone_version: milestone.version,
4048
+ milestone_name: milestone.name,
4049
+ milestone_slug: generateSlugInternal(milestone.name),
4050
+
4051
+ // Phase counts
4052
+ phase_count: phaseCount,
4053
+ completed_phases: completedPhases,
4054
+ all_phases_complete: phaseCount > 0 && phaseCount === completedPhases,
4055
+
4056
+ // Archive
4057
+ archived_milestones: archivedMilestones,
4058
+ archive_count: archivedMilestones.length,
4059
+
4060
+ // File existence
4061
+ project_exists: pathExistsInternal(cwd, '.blueprint/PROJECT.md'),
4062
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
4063
+ state_exists: pathExistsInternal(cwd, '.blueprint/STATE.md'),
4064
+ archive_exists: pathExistsInternal(cwd, '.blueprint/archive'),
4065
+ phases_dir_exists: pathExistsInternal(cwd, '.blueprint/phases'),
4066
+ };
4067
+
4068
+ output(result, raw);
4069
+ }
4070
+
4071
+ function cmdInitMapCodebase(cwd, raw) {
4072
+ const config = loadConfig(cwd);
4073
+
4074
+ // Check for existing codebase maps
4075
+ const codebaseDir = path.join(cwd, '.blueprint', 'codebase');
4076
+ let existingMaps = [];
4077
+ try {
4078
+ existingMaps = fs.readdirSync(codebaseDir).filter(f => f.endsWith('.md'));
4079
+ } catch {}
4080
+
4081
+ const result = {
4082
+ // Models
4083
+ mapper_model: resolveModelInternal(cwd, 'bp-codebase-mapper'),
4084
+
4085
+ // Config
4086
+ commit_docs: config.commit_docs,
4087
+ search_gitignored: config.search_gitignored,
4088
+ parallelization: config.parallelization,
4089
+
4090
+ // Paths
4091
+ codebase_dir: '.blueprint/codebase',
4092
+
4093
+ // Existing maps
4094
+ existing_maps: existingMaps,
4095
+ has_maps: existingMaps.length > 0,
4096
+
4097
+ // File existence
4098
+ planning_exists: pathExistsInternal(cwd, '.blueprint'),
4099
+ codebase_dir_exists: pathExistsInternal(cwd, '.blueprint/codebase'),
4100
+ };
4101
+
4102
+ output(result, raw);
4103
+ }
4104
+
4105
+ function cmdInitProgress(cwd, includes, raw) {
4106
+ const config = loadConfig(cwd);
4107
+ const milestone = getMilestoneInfo(cwd);
4108
+
4109
+ // Analyze phases
4110
+ const phasesDir = path.join(cwd, '.blueprint', 'phases');
4111
+ const phases = [];
4112
+ let currentPhase = null;
4113
+ let nextPhase = null;
4114
+
4115
+ try {
4116
+ const entries = fs.readdirSync(phasesDir, { withFileTypes: true });
4117
+ const dirs = entries.filter(e => e.isDirectory()).map(e => e.name).sort();
4118
+
4119
+ for (const dir of dirs) {
4120
+ const match = dir.match(/^(\d+(?:\.\d+)?)-?(.*)/);
4121
+ const phaseNumber = match ? match[1] : dir;
4122
+ const phaseName = match && match[2] ? match[2] : null;
4123
+
4124
+ const phasePath = path.join(phasesDir, dir);
4125
+ const phaseFiles = fs.readdirSync(phasePath);
4126
+
4127
+ const plans = phaseFiles.filter(f => f.endsWith('-PLAN.md') || f === 'PLAN.md');
4128
+ const summaries = phaseFiles.filter(f => f.endsWith('-SUMMARY.md') || f === 'SUMMARY.md');
4129
+ const hasResearch = phaseFiles.some(f => f.endsWith('-RESEARCH.md') || f === 'RESEARCH.md');
4130
+
4131
+ const status = summaries.length >= plans.length && plans.length > 0 ? 'complete' :
4132
+ plans.length > 0 ? 'in_progress' :
4133
+ hasResearch ? 'researched' : 'pending';
4134
+
4135
+ const phaseInfo = {
4136
+ number: phaseNumber,
4137
+ name: phaseName,
4138
+ directory: path.join('.blueprint', 'phases', dir),
4139
+ status,
4140
+ plan_count: plans.length,
4141
+ summary_count: summaries.length,
4142
+ has_research: hasResearch,
4143
+ };
4144
+
4145
+ phases.push(phaseInfo);
4146
+
4147
+ // Find current (first incomplete with plans) and next (first pending)
4148
+ if (!currentPhase && (status === 'in_progress' || status === 'researched')) {
4149
+ currentPhase = phaseInfo;
4150
+ }
4151
+ if (!nextPhase && status === 'pending') {
4152
+ nextPhase = phaseInfo;
4153
+ }
4154
+ }
4155
+ } catch {}
4156
+
4157
+ // Check for paused work
4158
+ let pausedAt = null;
4159
+ try {
4160
+ const state = fs.readFileSync(path.join(cwd, '.blueprint', 'STATE.md'), 'utf-8');
4161
+ const pauseMatch = state.match(/\*\*Paused At:\*\*\s*(.+)/);
4162
+ if (pauseMatch) pausedAt = pauseMatch[1].trim();
4163
+ } catch {}
4164
+
4165
+ const result = {
4166
+ // Models
4167
+ executor_model: resolveModelInternal(cwd, 'bp-executor'),
4168
+ planner_model: resolveModelInternal(cwd, 'bp-planner'),
4169
+
4170
+ // Config
4171
+ commit_docs: config.commit_docs,
4172
+
4173
+ // Milestone
4174
+ milestone_version: milestone.version,
4175
+ milestone_name: milestone.name,
4176
+
4177
+ // Phase overview
4178
+ phases,
4179
+ phase_count: phases.length,
4180
+ completed_count: phases.filter(p => p.status === 'complete').length,
4181
+ in_progress_count: phases.filter(p => p.status === 'in_progress').length,
4182
+
4183
+ // Current state
4184
+ current_phase: currentPhase,
4185
+ next_phase: nextPhase,
4186
+ paused_at: pausedAt,
4187
+ has_work_in_progress: !!currentPhase,
4188
+
4189
+ // File existence
4190
+ project_exists: pathExistsInternal(cwd, '.blueprint/PROJECT.md'),
4191
+ roadmap_exists: pathExistsInternal(cwd, '.blueprint/ROADMAP.md'),
4192
+ state_exists: pathExistsInternal(cwd, '.blueprint/STATE.md'),
4193
+ };
4194
+
4195
+ // Include file contents if requested via --include
4196
+ if (includes.has('state')) {
4197
+ result.state_content = safeReadFile(path.join(cwd, '.blueprint', 'STATE.md'));
4198
+ }
4199
+ if (includes.has('roadmap')) {
4200
+ result.roadmap_content = safeReadFile(path.join(cwd, '.blueprint', 'ROADMAP.md'));
4201
+ }
4202
+ if (includes.has('project')) {
4203
+ result.project_content = safeReadFile(path.join(cwd, '.blueprint', 'PROJECT.md'));
4204
+ }
4205
+ if (includes.has('config')) {
4206
+ result.config_content = safeReadFile(path.join(cwd, '.blueprint', 'config.json'));
4207
+ }
4208
+
4209
+ output(result, raw);
4210
+ }
4211
+
4212
+ // ─── CLI Router ───────────────────────────────────────────────────────────────
4213
+
4214
+ async function main() {
4215
+ const args = process.argv.slice(2);
4216
+ const rawIndex = args.indexOf('--raw');
4217
+ const raw = rawIndex !== -1;
4218
+ if (rawIndex !== -1) args.splice(rawIndex, 1);
4219
+
4220
+ const command = args[0];
4221
+ const cwd = process.cwd();
4222
+
4223
+ if (!command) {
4224
+ error('Usage: blueprint-tools <command> [args] [--raw]\nCommands: state, resolve-model, find-phase, commit, verify-summary, verify, frontmatter, template, generate-slug, current-timestamp, list-todos, verify-path-exists, config-ensure-section, init');
4225
+ }
4226
+
4227
+ switch (command) {
4228
+ case 'state': {
4229
+ const subcommand = args[1];
4230
+ if (subcommand === 'update') {
4231
+ cmdStateUpdate(cwd, args[2], args[3]);
4232
+ } else if (subcommand === 'get') {
4233
+ cmdStateGet(cwd, args[2], raw);
4234
+ } else if (subcommand === 'patch') {
4235
+ const patches = {};
4236
+ for (let i = 2; i < args.length; i += 2) {
4237
+ const key = args[i].replace(/^--/, '');
4238
+ const value = args[i + 1];
4239
+ if (key && value !== undefined) {
4240
+ patches[key] = value;
4241
+ }
4242
+ }
4243
+ cmdStatePatch(cwd, patches, raw);
4244
+ } else if (subcommand === 'advance-plan') {
4245
+ cmdStateAdvancePlan(cwd, raw);
4246
+ } else if (subcommand === 'record-metric') {
4247
+ const phaseIdx = args.indexOf('--phase');
4248
+ const planIdx = args.indexOf('--plan');
4249
+ const durationIdx = args.indexOf('--duration');
4250
+ const tasksIdx = args.indexOf('--tasks');
4251
+ const filesIdx = args.indexOf('--files');
4252
+ cmdStateRecordMetric(cwd, {
4253
+ phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null,
4254
+ plan: planIdx !== -1 ? args[planIdx + 1] : null,
4255
+ duration: durationIdx !== -1 ? args[durationIdx + 1] : null,
4256
+ tasks: tasksIdx !== -1 ? args[tasksIdx + 1] : null,
4257
+ files: filesIdx !== -1 ? args[filesIdx + 1] : null,
4258
+ }, raw);
4259
+ } else if (subcommand === 'update-progress') {
4260
+ cmdStateUpdateProgress(cwd, raw);
4261
+ } else if (subcommand === 'add-decision') {
4262
+ const phaseIdx = args.indexOf('--phase');
4263
+ const summaryIdx = args.indexOf('--summary');
4264
+ const rationaleIdx = args.indexOf('--rationale');
4265
+ cmdStateAddDecision(cwd, {
4266
+ phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null,
4267
+ summary: summaryIdx !== -1 ? args[summaryIdx + 1] : null,
4268
+ rationale: rationaleIdx !== -1 ? args[rationaleIdx + 1] : '',
4269
+ }, raw);
4270
+ } else if (subcommand === 'add-blocker') {
4271
+ const textIdx = args.indexOf('--text');
4272
+ cmdStateAddBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw);
4273
+ } else if (subcommand === 'resolve-blocker') {
4274
+ const textIdx = args.indexOf('--text');
4275
+ cmdStateResolveBlocker(cwd, textIdx !== -1 ? args[textIdx + 1] : null, raw);
4276
+ } else if (subcommand === 'record-session') {
4277
+ const stoppedIdx = args.indexOf('--stopped-at');
4278
+ const resumeIdx = args.indexOf('--resume-file');
4279
+ cmdStateRecordSession(cwd, {
4280
+ stopped_at: stoppedIdx !== -1 ? args[stoppedIdx + 1] : null,
4281
+ resume_file: resumeIdx !== -1 ? args[resumeIdx + 1] : 'None',
4282
+ }, raw);
4283
+ } else {
4284
+ cmdStateLoad(cwd, raw);
4285
+ }
4286
+ break;
4287
+ }
4288
+
4289
+ case 'resolve-model': {
4290
+ cmdResolveModel(cwd, args[1], raw);
4291
+ break;
4292
+ }
4293
+
4294
+ case 'find-phase': {
4295
+ cmdFindPhase(cwd, args[1], raw);
4296
+ break;
4297
+ }
4298
+
4299
+ case 'commit': {
4300
+ const amend = args.includes('--amend');
4301
+ const message = args[1];
4302
+ // Parse --files flag (collect args after --files, stopping at other flags)
4303
+ const filesIndex = args.indexOf('--files');
4304
+ const files = filesIndex !== -1 ? args.slice(filesIndex + 1).filter(a => !a.startsWith('--')) : [];
4305
+ cmdCommit(cwd, message, files, raw, amend);
4306
+ break;
4307
+ }
4308
+
4309
+ case 'verify-summary': {
4310
+ const summaryPath = args[1];
4311
+ const countIndex = args.indexOf('--check-count');
4312
+ const checkCount = countIndex !== -1 ? parseInt(args[countIndex + 1], 10) : 2;
4313
+ cmdVerifySummary(cwd, summaryPath, checkCount, raw);
4314
+ break;
4315
+ }
4316
+
4317
+ case 'template': {
4318
+ const subcommand = args[1];
4319
+ if (subcommand === 'select') {
4320
+ cmdTemplateSelect(cwd, args[2], raw);
4321
+ } else if (subcommand === 'fill') {
4322
+ const templateType = args[2];
4323
+ const phaseIdx = args.indexOf('--phase');
4324
+ const planIdx = args.indexOf('--plan');
4325
+ const nameIdx = args.indexOf('--name');
4326
+ const typeIdx = args.indexOf('--type');
4327
+ const waveIdx = args.indexOf('--wave');
4328
+ const fieldsIdx = args.indexOf('--fields');
4329
+ cmdTemplateFill(cwd, templateType, {
4330
+ phase: phaseIdx !== -1 ? args[phaseIdx + 1] : null,
4331
+ plan: planIdx !== -1 ? args[planIdx + 1] : null,
4332
+ name: nameIdx !== -1 ? args[nameIdx + 1] : null,
4333
+ type: typeIdx !== -1 ? args[typeIdx + 1] : 'execute',
4334
+ wave: waveIdx !== -1 ? args[waveIdx + 1] : '1',
4335
+ fields: fieldsIdx !== -1 ? JSON.parse(args[fieldsIdx + 1]) : {},
4336
+ }, raw);
4337
+ } else {
4338
+ error('Unknown template subcommand. Available: select, fill');
4339
+ }
4340
+ break;
4341
+ }
4342
+
4343
+ case 'frontmatter': {
4344
+ const subcommand = args[1];
4345
+ const file = args[2];
4346
+ if (subcommand === 'get') {
4347
+ const fieldIdx = args.indexOf('--field');
4348
+ cmdFrontmatterGet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, raw);
4349
+ } else if (subcommand === 'set') {
4350
+ const fieldIdx = args.indexOf('--field');
4351
+ const valueIdx = args.indexOf('--value');
4352
+ cmdFrontmatterSet(cwd, file, fieldIdx !== -1 ? args[fieldIdx + 1] : null, valueIdx !== -1 ? args[valueIdx + 1] : undefined, raw);
4353
+ } else if (subcommand === 'merge') {
4354
+ const dataIdx = args.indexOf('--data');
4355
+ cmdFrontmatterMerge(cwd, file, dataIdx !== -1 ? args[dataIdx + 1] : null, raw);
4356
+ } else if (subcommand === 'validate') {
4357
+ const schemaIdx = args.indexOf('--schema');
4358
+ cmdFrontmatterValidate(cwd, file, schemaIdx !== -1 ? args[schemaIdx + 1] : null, raw);
4359
+ } else {
4360
+ error('Unknown frontmatter subcommand. Available: get, set, merge, validate');
4361
+ }
4362
+ break;
4363
+ }
4364
+
4365
+ case 'verify': {
4366
+ const subcommand = args[1];
4367
+ if (subcommand === 'plan-structure') {
4368
+ cmdVerifyPlanStructure(cwd, args[2], raw);
4369
+ } else if (subcommand === 'phase-completeness') {
4370
+ cmdVerifyPhaseCompleteness(cwd, args[2], raw);
4371
+ } else if (subcommand === 'references') {
4372
+ cmdVerifyReferences(cwd, args[2], raw);
4373
+ } else if (subcommand === 'commits') {
4374
+ cmdVerifyCommits(cwd, args.slice(2), raw);
4375
+ } else if (subcommand === 'artifacts') {
4376
+ cmdVerifyArtifacts(cwd, args[2], raw);
4377
+ } else if (subcommand === 'key-links') {
4378
+ cmdVerifyKeyLinks(cwd, args[2], raw);
4379
+ } else {
4380
+ error('Unknown verify subcommand. Available: plan-structure, phase-completeness, references, commits, artifacts, key-links');
4381
+ }
4382
+ break;
4383
+ }
4384
+
4385
+ case 'generate-slug': {
4386
+ cmdGenerateSlug(args[1], raw);
4387
+ break;
4388
+ }
4389
+
4390
+ case 'current-timestamp': {
4391
+ cmdCurrentTimestamp(args[1] || 'full', raw);
4392
+ break;
4393
+ }
4394
+
4395
+ case 'list-todos': {
4396
+ cmdListTodos(cwd, args[1], raw);
4397
+ break;
4398
+ }
4399
+
4400
+ case 'verify-path-exists': {
4401
+ cmdVerifyPathExists(cwd, args[1], raw);
4402
+ break;
4403
+ }
4404
+
4405
+ case 'config-ensure-section': {
4406
+ cmdConfigEnsureSection(cwd, raw);
4407
+ break;
4408
+ }
4409
+
4410
+ case 'config-set': {
4411
+ cmdConfigSet(cwd, args[1], args[2], raw);
4412
+ break;
4413
+ }
4414
+
4415
+ case 'history-digest': {
4416
+ cmdHistoryDigest(cwd, raw);
4417
+ break;
4418
+ }
4419
+
4420
+ case 'phases': {
4421
+ const subcommand = args[1];
4422
+ if (subcommand === 'list') {
4423
+ const typeIndex = args.indexOf('--type');
4424
+ const phaseIndex = args.indexOf('--phase');
4425
+ const options = {
4426
+ type: typeIndex !== -1 ? args[typeIndex + 1] : null,
4427
+ phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
4428
+ };
4429
+ cmdPhasesList(cwd, options, raw);
4430
+ } else {
4431
+ error('Unknown phases subcommand. Available: list');
4432
+ }
4433
+ break;
4434
+ }
4435
+
4436
+ case 'roadmap': {
4437
+ const subcommand = args[1];
4438
+ if (subcommand === 'get-phase') {
4439
+ cmdRoadmapGetPhase(cwd, args[2], raw);
4440
+ } else if (subcommand === 'analyze') {
4441
+ cmdRoadmapAnalyze(cwd, raw);
4442
+ } else {
4443
+ error('Unknown roadmap subcommand. Available: get-phase, analyze');
4444
+ }
4445
+ break;
4446
+ }
4447
+
4448
+ case 'phase': {
4449
+ const subcommand = args[1];
4450
+ if (subcommand === 'next-decimal') {
4451
+ cmdPhaseNextDecimal(cwd, args[2], raw);
4452
+ } else if (subcommand === 'add') {
4453
+ cmdPhaseAdd(cwd, args.slice(2).join(' '), raw);
4454
+ } else if (subcommand === 'insert') {
4455
+ cmdPhaseInsert(cwd, args[2], args.slice(3).join(' '), raw);
4456
+ } else if (subcommand === 'remove') {
4457
+ const forceFlag = args.includes('--force');
4458
+ cmdPhaseRemove(cwd, args[2], { force: forceFlag }, raw);
4459
+ } else if (subcommand === 'complete') {
4460
+ cmdPhaseComplete(cwd, args[2], raw);
4461
+ } else {
4462
+ error('Unknown phase subcommand. Available: next-decimal, add, insert, remove, complete');
4463
+ }
4464
+ break;
4465
+ }
4466
+
4467
+ case 'milestone': {
4468
+ const subcommand = args[1];
4469
+ if (subcommand === 'complete') {
4470
+ const nameIndex = args.indexOf('--name');
4471
+ const milestoneName = nameIndex !== -1 ? args.slice(nameIndex + 1).join(' ') : null;
4472
+ cmdMilestoneComplete(cwd, args[2], { name: milestoneName }, raw);
4473
+ } else {
4474
+ error('Unknown milestone subcommand. Available: complete');
4475
+ }
4476
+ break;
4477
+ }
4478
+
4479
+ case 'validate': {
4480
+ const subcommand = args[1];
4481
+ if (subcommand === 'consistency') {
4482
+ cmdValidateConsistency(cwd, raw);
4483
+ } else {
4484
+ error('Unknown validate subcommand. Available: consistency');
4485
+ }
4486
+ break;
4487
+ }
4488
+
4489
+ case 'progress': {
4490
+ const subcommand = args[1] || 'json';
4491
+ cmdProgressRender(cwd, subcommand, raw);
4492
+ break;
4493
+ }
4494
+
4495
+ case 'todo': {
4496
+ const subcommand = args[1];
4497
+ if (subcommand === 'complete') {
4498
+ cmdTodoComplete(cwd, args[2], raw);
4499
+ } else {
4500
+ error('Unknown todo subcommand. Available: complete');
4501
+ }
4502
+ break;
4503
+ }
4504
+
4505
+ case 'scaffold': {
4506
+ const scaffoldType = args[1];
4507
+ const phaseIndex = args.indexOf('--phase');
4508
+ const nameIndex = args.indexOf('--name');
4509
+ const scaffoldOptions = {
4510
+ phase: phaseIndex !== -1 ? args[phaseIndex + 1] : null,
4511
+ name: nameIndex !== -1 ? args.slice(nameIndex + 1).join(' ') : null,
4512
+ };
4513
+ cmdScaffold(cwd, scaffoldType, scaffoldOptions, raw);
4514
+ break;
4515
+ }
4516
+
4517
+ case 'init': {
4518
+ const workflow = args[1];
4519
+ const includes = parseIncludeFlag(args);
4520
+ switch (workflow) {
4521
+ case 'execute-phase':
4522
+ cmdInitExecutePhase(cwd, args[2], includes, raw);
4523
+ break;
4524
+ case 'plan-phase':
4525
+ cmdInitPlanPhase(cwd, args[2], includes, raw);
4526
+ break;
4527
+ case 'new-project':
4528
+ cmdInitNewProject(cwd, raw);
4529
+ break;
4530
+ case 'new-milestone':
4531
+ cmdInitNewMilestone(cwd, raw);
4532
+ break;
4533
+ case 'quick':
4534
+ cmdInitQuick(cwd, args.slice(2).join(' '), raw);
4535
+ break;
4536
+ case 'resume':
4537
+ cmdInitResume(cwd, raw);
4538
+ break;
4539
+ case 'verify-work':
4540
+ cmdInitVerifyWork(cwd, args[2], raw);
4541
+ break;
4542
+ case 'phase-op':
4543
+ cmdInitPhaseOp(cwd, args[2], raw);
4544
+ break;
4545
+ case 'todos':
4546
+ cmdInitTodos(cwd, args[2], raw);
4547
+ break;
4548
+ case 'milestone-op':
4549
+ cmdInitMilestoneOp(cwd, raw);
4550
+ break;
4551
+ case 'map-codebase':
4552
+ cmdInitMapCodebase(cwd, raw);
4553
+ break;
4554
+ case 'progress':
4555
+ cmdInitProgress(cwd, includes, raw);
4556
+ break;
4557
+ default:
4558
+ error(`Unknown init workflow: ${workflow}\nAvailable: execute-phase, plan-phase, new-project, new-milestone, quick, resume, verify-work, phase-op, todos, milestone-op, map-codebase, progress`);
4559
+ }
4560
+ break;
4561
+ }
4562
+
4563
+ case 'phase-plan-index': {
4564
+ cmdPhasePlanIndex(cwd, args[1], raw);
4565
+ break;
4566
+ }
4567
+
4568
+ case 'state-snapshot': {
4569
+ cmdStateSnapshot(cwd, raw);
4570
+ break;
4571
+ }
4572
+
4573
+ case 'summary-extract': {
4574
+ const summaryPath = args[1];
4575
+ const fieldsIndex = args.indexOf('--fields');
4576
+ const fields = fieldsIndex !== -1 ? args[fieldsIndex + 1].split(',') : null;
4577
+ cmdSummaryExtract(cwd, summaryPath, fields, raw);
4578
+ break;
4579
+ }
4580
+
4581
+ case 'websearch': {
4582
+ const query = args[1];
4583
+ const limitIdx = args.indexOf('--limit');
4584
+ const freshnessIdx = args.indexOf('--freshness');
4585
+ await cmdWebsearch(query, {
4586
+ limit: limitIdx !== -1 ? parseInt(args[limitIdx + 1], 10) : 10,
4587
+ freshness: freshnessIdx !== -1 ? args[freshnessIdx + 1] : null,
4588
+ }, raw);
4589
+ break;
4590
+ }
4591
+
4592
+ default:
4593
+ error(`Unknown command: ${command}`);
4594
+ }
4595
+ }
4596
+
4597
+ main();