specsmd 0.1.65 → 0.1.68

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.
@@ -0,0 +1,726 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function escapeHtml(value) {
5
+ return String(value ?? '').replace(/[&<>"']/g, (char) => ({
6
+ '&': '&amp;',
7
+ '<': '&lt;',
8
+ '>': '&gt;',
9
+ '"': '&quot;',
10
+ "'": '&#039;'
11
+ }[char]));
12
+ }
13
+
14
+ function mapStatus(status) {
15
+ if (status === 'completed') return 'complete';
16
+ if (status === 'in_progress') return 'active';
17
+ return 'pending';
18
+ }
19
+
20
+ function formatBoltType(type) {
21
+ return String(type || 'bolt')
22
+ .replace(/-bolt$/, '')
23
+ .replace(/-/g, ' ')
24
+ .replace(/\b\w/g, (char) => char.toUpperCase());
25
+ }
26
+
27
+ function relativeTime(value, now = new Date()) {
28
+ if (!value) return 'unknown';
29
+ const date = value instanceof Date ? value : new Date(value);
30
+ if (Number.isNaN(date.getTime())) return 'unknown';
31
+
32
+ const seconds = Math.max(0, Math.floor((now.getTime() - date.getTime()) / 1000));
33
+ if (seconds < 60) return 'just now';
34
+ const minutes = Math.floor(seconds / 60);
35
+ if (minutes < 60) return `${minutes}m ago`;
36
+ const hours = Math.floor(minutes / 60);
37
+ if (hours < 24) return `${hours}h ago`;
38
+ const days = Math.floor(hours / 24);
39
+ if (days < 30) return `${days}d ago`;
40
+ const months = Math.floor(days / 30);
41
+ if (months < 12) return `${months}mo ago`;
42
+ return `${Math.floor(months / 12)}y ago`;
43
+ }
44
+
45
+ function exactTime(value) {
46
+ const date = value instanceof Date ? value : new Date(value);
47
+ if (Number.isNaN(date.getTime())) return '';
48
+ return date.toLocaleString(undefined, {
49
+ weekday: 'short',
50
+ year: 'numeric',
51
+ month: 'short',
52
+ day: 'numeric',
53
+ hour: '2-digit',
54
+ minute: '2-digit',
55
+ second: '2-digit'
56
+ });
57
+ }
58
+
59
+ function classifyArtifactFile(filename) {
60
+ const lower = filename.toLowerCase();
61
+ if (lower.includes('walkthrough') && lower.includes('test')) return 'test-report';
62
+ if (lower.includes('walkthrough')) return 'walkthrough';
63
+ if (lower.includes('test') || lower.includes('report')) return 'test-report';
64
+ if (lower.includes('plan')) return 'plan';
65
+ if (lower.includes('design') || lower.includes('adr')) return 'design';
66
+ return 'other';
67
+ }
68
+
69
+ function scanBoltArtifactFiles(boltPath) {
70
+ try {
71
+ return fs.readdirSync(boltPath)
72
+ .filter((entry) => entry !== 'bolt.md' && entry.endsWith('.md'))
73
+ .map((entry) => ({
74
+ name: entry,
75
+ path: path.join(boltPath, entry),
76
+ type: classifyArtifactFile(entry)
77
+ }));
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function storyPathFor(snapshot, bolt, storyRef) {
84
+ const storyFileName = String(storyRef).endsWith('.md') ? String(storyRef) : `${storyRef}.md`;
85
+ return path.join(
86
+ snapshot.workspacePath,
87
+ 'memory-bank',
88
+ 'intents',
89
+ bolt.intent || '',
90
+ 'units',
91
+ bolt.unit || '',
92
+ 'stories',
93
+ storyFileName
94
+ );
95
+ }
96
+
97
+ function buildStoryIndex(snapshot) {
98
+ const index = new Map();
99
+ for (const story of snapshot.stories || []) {
100
+ index.set(story.id, story);
101
+ index.set(path.basename(story.path, '.md'), story);
102
+ }
103
+ return index;
104
+ }
105
+
106
+ function mapBoltStories(snapshot, bolt, storyIndex) {
107
+ return (bolt.stories || []).map((storyRef) => {
108
+ const story = storyIndex.get(storyRef) || storyIndex.get(path.basename(String(storyRef), '.md'));
109
+ return {
110
+ id: storyRef,
111
+ name: story?.title || storyRef,
112
+ status: mapStatus(story?.status),
113
+ path: story?.path || storyPathFor(snapshot, bolt, storyRef)
114
+ };
115
+ });
116
+ }
117
+
118
+ function activeBoltData(snapshot, bolt, storyIndex) {
119
+ const stories = mapBoltStories(snapshot, bolt, storyIndex);
120
+ return {
121
+ id: bolt.id,
122
+ name: bolt.id,
123
+ type: formatBoltType(bolt.type),
124
+ currentStage: bolt.currentStage,
125
+ stagesComplete: (bolt.stages || []).filter((stage) => stage.status === 'completed').length,
126
+ stagesTotal: (bolt.stages || []).length,
127
+ storiesComplete: stories.filter((story) => story.status === 'complete').length,
128
+ storiesTotal: stories.length,
129
+ stages: (bolt.stages || []).map((stage) => ({
130
+ name: stage.name,
131
+ status: mapStatus(stage.status)
132
+ })),
133
+ stories,
134
+ path: bolt.path,
135
+ files: scanBoltArtifactFiles(bolt.path)
136
+ };
137
+ }
138
+
139
+ function queuedBoltData(snapshot, bolt, storyIndex) {
140
+ const stories = mapBoltStories(snapshot, bolt, storyIndex);
141
+ return {
142
+ id: bolt.id,
143
+ name: bolt.id,
144
+ type: formatBoltType(bolt.type),
145
+ storiesCount: stories.length,
146
+ isBlocked: Boolean(bolt.isBlocked),
147
+ blockedBy: bolt.blockedBy || [],
148
+ unblocksCount: bolt.unblocksCount || 0,
149
+ stages: (bolt.stages || []).map((stage) => ({
150
+ name: stage.name,
151
+ status: mapStatus(stage.status)
152
+ })),
153
+ stories
154
+ };
155
+ }
156
+
157
+ function completedBoltData(snapshot, bolt, now) {
158
+ return {
159
+ id: bolt.id,
160
+ name: bolt.id,
161
+ type: formatBoltType(bolt.type),
162
+ completedAt: bolt.completedAt || '',
163
+ relativeTime: relativeTime(bolt.completedAt, now),
164
+ path: bolt.path,
165
+ files: scanBoltArtifactFiles(bolt.path),
166
+ constructionLogPath: bolt.unit
167
+ ? path.join(snapshot.rootPath, 'intents', bolt.intent || '', 'units', bolt.unit, 'construction-log.md')
168
+ : undefined
169
+ };
170
+ }
171
+
172
+ function buildActivityEvents(snapshot, now) {
173
+ const events = [];
174
+ for (const bolt of snapshot.bolts || []) {
175
+ if (bolt.completedAt) {
176
+ events.push({
177
+ id: `${bolt.id}-complete`,
178
+ type: 'bolt-complete',
179
+ text: 'Completed bolt',
180
+ target: bolt.id,
181
+ tag: 'bolt',
182
+ timestamp: bolt.completedAt,
183
+ path: bolt.filePath
184
+ });
185
+ } else if (bolt.startedAt) {
186
+ events.push({
187
+ id: `${bolt.id}-start`,
188
+ type: 'bolt-start',
189
+ text: 'Started bolt',
190
+ target: bolt.id,
191
+ tag: 'bolt',
192
+ timestamp: bolt.startedAt,
193
+ path: bolt.filePath
194
+ });
195
+ } else if (bolt.createdAt) {
196
+ events.push({
197
+ id: `${bolt.id}-created`,
198
+ type: 'bolt-created',
199
+ text: 'Created bolt',
200
+ target: bolt.id,
201
+ tag: 'bolt',
202
+ timestamp: bolt.createdAt,
203
+ path: bolt.filePath
204
+ });
205
+ }
206
+ }
207
+
208
+ return events
209
+ .sort((a, b) => Date.parse(b.timestamp) - Date.parse(a.timestamp))
210
+ .slice(0, 10)
211
+ .map((event) => ({
212
+ id: event.id,
213
+ type: event.type,
214
+ text: event.text,
215
+ target: event.target,
216
+ tag: event.tag,
217
+ relativeTime: relativeTime(event.timestamp, now),
218
+ exactTime: exactTime(event.timestamp),
219
+ path: event.path
220
+ }));
221
+ }
222
+
223
+ function buildSpecsData(snapshot) {
224
+ const statusSet = new Set();
225
+ const intents = (snapshot.intents || []).map((intent) => {
226
+ const units = (intent.units || []).map((unit) => {
227
+ const status = unit.status === 'completed' ? 'complete' : (unit.status === 'in_progress' ? 'in-progress' : unit.status);
228
+ statusSet.add(status);
229
+ const stories = (unit.stories || []).map((story) => ({
230
+ id: story.id,
231
+ title: story.title,
232
+ path: story.path,
233
+ status: story.status === 'completed' ? 'complete' : (story.status === 'in_progress' ? 'active' : story.status)
234
+ }));
235
+ return {
236
+ name: unit.id || unit.name,
237
+ path: unit.path,
238
+ status,
239
+ storiesComplete: stories.filter((story) => story.status === 'complete').length,
240
+ storiesTotal: stories.length,
241
+ stories
242
+ };
243
+ });
244
+
245
+ return {
246
+ name: intent.name,
247
+ number: intent.number,
248
+ path: intent.path,
249
+ storiesComplete: units.reduce((sum, unit) => sum + unit.storiesComplete, 0),
250
+ storiesTotal: units.reduce((sum, unit) => sum + unit.storiesTotal, 0),
251
+ units
252
+ };
253
+ });
254
+
255
+ return {
256
+ intents,
257
+ availableStatuses: Array.from(statusSet).sort()
258
+ };
259
+ }
260
+
261
+ function buildStats(snapshot) {
262
+ const stats = snapshot.stats || {};
263
+ return {
264
+ active: stats.activeBoltsCount || 0,
265
+ queued: stats.queuedBolts || 0,
266
+ done: stats.completedBolts || 0,
267
+ blocked: stats.blockedBolts || 0
268
+ };
269
+ }
270
+
271
+ function selectCurrentIntent(snapshot) {
272
+ const activeBolt = (snapshot.activeBolts || [])[0];
273
+ const queuedBolt = (snapshot.pendingBolts || []).find((bolt) => !bolt.isBlocked);
274
+ const selectedBolt = activeBolt || queuedBolt;
275
+ if (!selectedBolt) {
276
+ return { currentIntent: null, currentIntentContext: 'none' };
277
+ }
278
+
279
+ const intent = (snapshot.intents || []).find((candidate) =>
280
+ candidate.id === selectedBolt.intent
281
+ || candidate.number === selectedBolt.intent
282
+ || candidate.name === selectedBolt.intent
283
+ );
284
+
285
+ return {
286
+ currentIntent: intent ? { name: intent.name, number: intent.number } : null,
287
+ currentIntentContext: activeBolt ? 'active' : 'queued'
288
+ };
289
+ }
290
+
291
+ function buildNextActions(snapshot) {
292
+ const activeBolt = (snapshot.activeBolts || [])[0];
293
+ if (activeBolt) {
294
+ return [{
295
+ type: 'continue-bolt',
296
+ priority: 1,
297
+ title: `Continue ${activeBolt.id}`,
298
+ description: activeBolt.currentStage ? `Current stage: ${activeBolt.currentStage}` : 'Continue the active bolt',
299
+ targetId: activeBolt.id,
300
+ targetName: activeBolt.id
301
+ }];
302
+ }
303
+
304
+ const queuedBolt = (snapshot.pendingBolts || []).find((bolt) => !bolt.isBlocked);
305
+ if (queuedBolt) {
306
+ return [{
307
+ type: 'start-bolt',
308
+ priority: 1,
309
+ title: `Start ${queuedBolt.id}`,
310
+ description: `${queuedBolt.stories?.length || 0} stories ready`,
311
+ targetId: queuedBolt.id,
312
+ targetName: queuedBolt.id
313
+ }];
314
+ }
315
+
316
+ return [{
317
+ type: 'celebrate',
318
+ priority: 1,
319
+ title: 'All caught up',
320
+ description: 'No active or queued bolts are waiting.'
321
+ }];
322
+ }
323
+
324
+ function normalizeFireStatus(status) {
325
+ if (status === 'complete') return 'completed';
326
+ if (status === 'in-progress') return 'in_progress';
327
+ return status || 'pending';
328
+ }
329
+
330
+ function normalizeFireMode(mode) {
331
+ return mode || 'confirm';
332
+ }
333
+
334
+ function normalizeFireComplexity(complexity) {
335
+ return ['low', 'medium', 'high'].includes(complexity) ? complexity : 'medium';
336
+ }
337
+
338
+ function buildFireWorkItemLookup(snapshot) {
339
+ const lookup = new Map();
340
+ for (const intent of snapshot.intents || []) {
341
+ for (const item of intent.workItems || []) {
342
+ lookup.set(item.id, {
343
+ ...item,
344
+ intentId: intent.id,
345
+ intentTitle: intent.title,
346
+ intentFilePath: intent.filePath
347
+ });
348
+ }
349
+ }
350
+ return lookup;
351
+ }
352
+
353
+ function fireRunFiles(run) {
354
+ const files = [];
355
+ if (run.hasPlan) files.push({ name: 'plan.md', path: path.join(run.folderPath, 'plan.md') });
356
+ if (run.hasWalkthrough) files.push({ name: 'walkthrough.md', path: path.join(run.folderPath, 'walkthrough.md') });
357
+ if (run.hasTestReport) files.push({ name: 'test-report.md', path: path.join(run.folderPath, 'test-report.md') });
358
+ return files;
359
+ }
360
+
361
+ function transformFireRun(run, lookup) {
362
+ return {
363
+ id: run.id,
364
+ scope: run.scope || 'single',
365
+ workItems: (run.workItems || []).map((item) => {
366
+ const details = lookup.get(item.id) || {};
367
+ return {
368
+ id: item.id,
369
+ intentId: item.intentId || details.intentId || '',
370
+ mode: normalizeFireMode(item.mode || details.mode),
371
+ status: normalizeFireStatus(item.status || details.status),
372
+ currentPhase: item.currentPhase,
373
+ checkpointState: item.checkpointState,
374
+ currentCheckpoint: item.currentCheckpoint,
375
+ title: details.title || item.id,
376
+ filePath: details.filePath,
377
+ intentFilePath: details.intentFilePath
378
+ };
379
+ }),
380
+ currentItem: run.currentItem,
381
+ folderPath: run.folderPath,
382
+ startedAt: run.startedAt || '',
383
+ completedAt: run.completedAt,
384
+ hasPlan: Boolean(run.hasPlan),
385
+ hasWalkthrough: Boolean(run.hasWalkthrough),
386
+ hasTestReport: Boolean(run.hasTestReport),
387
+ files: fireRunFiles(run)
388
+ };
389
+ }
390
+
391
+ function buildFireViewData(snapshot) {
392
+ const lookup = buildFireWorkItemLookup(snapshot);
393
+ const pendingItems = (snapshot.pendingItems || []).map((item) => ({
394
+ id: item.id,
395
+ intentId: item.intentId,
396
+ intentTitle: item.intentTitle,
397
+ intentFilePath: (snapshot.intents || []).find((intent) => intent.id === item.intentId)?.filePath,
398
+ title: item.title || item.id,
399
+ status: normalizeFireStatus(item.status),
400
+ mode: normalizeFireMode(item.mode),
401
+ complexity: normalizeFireComplexity(item.complexity),
402
+ filePath: item.filePath,
403
+ dependencies: item.dependencies || []
404
+ }));
405
+
406
+ const completedRuns = (snapshot.completedRuns || []).map((run) => ({
407
+ id: run.id,
408
+ scope: run.scope || 'single',
409
+ itemCount: (run.workItems || []).length,
410
+ completedAt: run.completedAt || '',
411
+ folderPath: run.folderPath,
412
+ files: fireRunFiles(run)
413
+ }));
414
+
415
+ const intents = (snapshot.intents || []).map((intent) => ({
416
+ id: intent.id,
417
+ title: intent.title || intent.id,
418
+ status: normalizeFireStatus(intent.status),
419
+ filePath: intent.filePath,
420
+ description: intent.description,
421
+ workItems: (intent.workItems || []).map((item) => ({
422
+ id: item.id,
423
+ title: item.title || item.id,
424
+ status: normalizeFireStatus(item.status),
425
+ mode: normalizeFireMode(item.mode),
426
+ complexity: normalizeFireComplexity(item.complexity),
427
+ filePath: item.filePath
428
+ }))
429
+ }));
430
+
431
+ return {
432
+ activeTab: 'runs',
433
+ runsData: {
434
+ activeRuns: (snapshot.activeRuns || []).map((run) => transformFireRun(run, lookup)),
435
+ pendingItems,
436
+ completedRuns,
437
+ completedRunsDisplayLimit: 5,
438
+ stats: snapshot.stats || {}
439
+ },
440
+ intentsData: {
441
+ intents,
442
+ expandedIntents: intents.slice(0, 3).map((intent) => intent.id),
443
+ filter: 'all'
444
+ },
445
+ overviewData: {
446
+ project: snapshot.project
447
+ ? {
448
+ name: snapshot.project.name || 'FIRE Project',
449
+ description: snapshot.project.description,
450
+ created: snapshot.project.created || '',
451
+ fireVersion: snapshot.version || snapshot.project.fireVersion || '0.0.0'
452
+ }
453
+ : null,
454
+ workspace: snapshot.workspace || null,
455
+ standards: snapshot.standards || [],
456
+ stats: snapshot.stats || {}
457
+ }
458
+ };
459
+ }
460
+
461
+ function flowDisplayName(flow) {
462
+ if (flow === 'aidlc') return 'AI-DLC';
463
+ if (flow === 'fire') return 'FIRE';
464
+ if (flow === 'simple') return 'Simple';
465
+ return flow || 'SpecsMD';
466
+ }
467
+
468
+ function flowRootFolder(flow) {
469
+ if (flow === 'aidlc') return 'memory-bank';
470
+ if (flow === 'fire') return '.specs-fire';
471
+ if (flow === 'simple') return 'specs';
472
+ return flow || '';
473
+ }
474
+
475
+ function flowIcon(flow) {
476
+ if (flow === 'aidlc') return '📘';
477
+ if (flow === 'fire') return '🔥';
478
+ if (flow === 'simple') return '📄';
479
+ return '📁';
480
+ }
481
+
482
+ function buildWebviewData(snapshot) {
483
+ const now = new Date();
484
+ const storyIndex = buildStoryIndex(snapshot);
485
+ const specs = buildSpecsData(snapshot);
486
+ const current = selectCurrentIntent(snapshot);
487
+
488
+ return {
489
+ ...current,
490
+ stats: buildStats(snapshot),
491
+ activeBolts: (snapshot.activeBolts || []).map((bolt) => activeBoltData(snapshot, bolt, storyIndex)),
492
+ upNextQueue: (snapshot.pendingBolts || []).map((bolt) => queuedBoltData(snapshot, bolt, storyIndex)),
493
+ completedBolts: (snapshot.completedBolts || []).slice(0, 10).map((bolt) => completedBoltData(snapshot, bolt, now)),
494
+ activityEvents: buildActivityEvents(snapshot, now),
495
+ intents: specs.intents,
496
+ standards: (snapshot.standards || []).map((standard) => ({
497
+ name: standard.name,
498
+ path: standard.filePath
499
+ })),
500
+ nextActions: buildNextActions(snapshot),
501
+ focusCardExpanded: true,
502
+ activityFilter: 'all',
503
+ activityHeight: 200,
504
+ specsFilter: 'all',
505
+ availableStatuses: specs.availableStatuses
506
+ };
507
+ }
508
+
509
+ function getSpecsViewHtml(data) {
510
+ const filter = data.specsFilter || 'all';
511
+ const statusOptionsHtml = (data.availableStatuses || [])
512
+ .map((status) => `<option value="${escapeHtml(status)}"${status === filter ? ' selected' : ''}>${escapeHtml(status)}</option>`)
513
+ .join('');
514
+
515
+ const toolbarHtml = `
516
+ <div class="specs-toolbar">
517
+ <span class="specs-toolbar-label">Filter</span>
518
+ <select class="specs-toolbar-select" id="specsFilter">
519
+ <option value="all"${filter === 'all' ? ' selected' : ''}>all</option>
520
+ ${statusOptionsHtml}
521
+ </select>
522
+ </div>`;
523
+
524
+ if (!data.intents.length) {
525
+ return `${toolbarHtml}<div class="specs-content"><div class="empty-state"><div class="empty-state-icon">&#128203;</div><div class="empty-state-text">No intents found</div></div></div>`;
526
+ }
527
+
528
+ return `${toolbarHtml}
529
+ <div class="specs-content">
530
+ ${data.intents.map((intent) => {
531
+ const progress = intent.storiesTotal > 0 ? Math.round((intent.storiesComplete / intent.storiesTotal) * 100) : 0;
532
+ const dashOffset = 69.115 - (69.115 * progress / 100);
533
+ return `
534
+ <div class="intent-item">
535
+ <div class="intent-header" data-intent="${escapeHtml(intent.number)}">
536
+ <span class="intent-expand">&#9660;</span>
537
+ <span class="intent-icon">&#127919;</span>
538
+ <div class="intent-info">
539
+ <div class="intent-name">${escapeHtml(intent.number)}-${escapeHtml(intent.name)} - intent</div>
540
+ <div class="intent-meta">${intent.units.length} units | ${intent.storiesTotal} stories</div>
541
+ </div>
542
+ <button type="button" class="spec-open-btn intent-open-btn" data-path="${escapeHtml(path.join(intent.path, 'requirements.md'))}" title="Open intent requirements">&#128269;</button>
543
+ <div class="intent-progress-ring">
544
+ <svg width="28" height="28" viewBox="0 0 28 28">
545
+ <circle class="ring-bg" cx="14" cy="14" r="11"></circle>
546
+ <circle class="ring-fill" cx="14" cy="14" r="11" style="stroke-dashoffset: ${dashOffset}"></circle>
547
+ </svg>
548
+ <span class="intent-progress-text">${progress}%</span>
549
+ </div>
550
+ </div>
551
+ <div class="intent-content">
552
+ ${intent.units.map((unit) => `
553
+ <div class="unit-item">
554
+ <div class="unit-header" data-unit="${escapeHtml(unit.name)}">
555
+ <span class="unit-expand">&#9660;</span>
556
+ <span class="unit-icon">&#128218;</span>
557
+ <span class="unit-name">${escapeHtml(unit.name)} - unit</span>
558
+ <button type="button" class="spec-open-btn unit-open-btn" data-path="${escapeHtml(path.join(unit.path, 'unit-brief.md'))}" title="Open unit brief">&#128269;</button>
559
+ <span class="unit-progress">${unit.storiesComplete}/${unit.storiesTotal}</span>
560
+ </div>
561
+ <div class="unit-content">
562
+ ${unit.stories.length > 0 ? unit.stories.map((story) => `
563
+ <div class="spec-story-item" data-path="${escapeHtml(story.path)}">
564
+ <span class="spec-story-icon">&#128221;</span>
565
+ <div class="spec-story-status ${escapeHtml(story.status)}">${story.status === 'complete' ? '&#10003;' : story.status === 'active' ? '&#9679;' : ''}</div>
566
+ <span class="spec-story-name ${story.status === 'complete' ? 'complete' : ''}">${escapeHtml(story.id)}-${escapeHtml(story.title)}</span>
567
+ </div>
568
+ `).join('') : '<div class="spec-no-stories">No stories in this unit</div>'}
569
+ </div>
570
+ </div>
571
+ `).join('')}
572
+ </div>
573
+ </div>`;
574
+ }).join('')}
575
+ </div>`;
576
+ }
577
+
578
+ function getOverviewViewHtml(data) {
579
+ const totalStories = data.intents.reduce((sum, intent) => sum + intent.storiesTotal, 0);
580
+ const completedStories = data.intents.reduce((sum, intent) => sum + intent.storiesComplete, 0);
581
+ const progressPercent = totalStories > 0 ? Math.round((completedStories / totalStories) * 100) : 0;
582
+ const totalBolts = data.stats.active + data.stats.queued + data.stats.done + data.stats.blocked;
583
+
584
+ return `<div class="overview-content">
585
+ <div class="overview-section">
586
+ <div class="overview-section-title">Overall Progress</div>
587
+ <div class="overview-progress-bar"><div class="overview-progress-fill" style="width: ${progressPercent}%"></div></div>
588
+ <div class="overview-metrics">
589
+ <div class="overview-metric-card"><div class="overview-metric-value highlight">${progressPercent}%</div><div class="overview-metric-label">Complete</div></div>
590
+ <div class="overview-metric-card"><div class="overview-metric-value success">${completedStories}/${totalStories}</div><div class="overview-metric-label">Stories Done</div></div>
591
+ <div class="overview-metric-card"><div class="overview-metric-value">${data.stats.done}/${totalBolts}</div><div class="overview-metric-label">Bolts Done</div></div>
592
+ <div class="overview-metric-card"><div class="overview-metric-value">${data.intents.length}</div><div class="overview-metric-label">Intents</div></div>
593
+ </div>
594
+ </div>
595
+ <div class="overview-section">
596
+ <div class="overview-section-title">Suggested Actions</div>
597
+ <div class="overview-list">
598
+ ${data.nextActions.slice(0, 3).map((action) => `
599
+ <div class="overview-list-item action-item" data-action-type="${escapeHtml(action.type)}" data-target-id="${escapeHtml(action.targetId || '')}">
600
+ <div class="overview-list-icon action ${escapeHtml(action.type)}">▶</div>
601
+ <div class="overview-list-info">
602
+ <div class="overview-list-name">${escapeHtml(action.title)}</div>
603
+ <div class="overview-list-meta">${escapeHtml(action.description)}</div>
604
+ </div>
605
+ </div>
606
+ `).join('')}
607
+ </div>
608
+ </div>
609
+ <div class="overview-section">
610
+ <div class="overview-section-title">Intents</div>
611
+ <div class="overview-list">
612
+ ${data.intents.map((intent) => {
613
+ const progress = intent.storiesTotal > 0 ? Math.round((intent.storiesComplete / intent.storiesTotal) * 100) : 0;
614
+ return `
615
+ <div class="overview-list-item" data-intent="${escapeHtml(intent.number)}">
616
+ <div class="overview-list-icon intent">&#128203;</div>
617
+ <div class="overview-list-info">
618
+ <div class="overview-list-name">${escapeHtml(intent.number)}-${escapeHtml(intent.name)}</div>
619
+ <div class="overview-list-meta">${intent.units.length} units | ${intent.storiesTotal} stories</div>
620
+ </div>
621
+ <div class="overview-list-progress">${progress}%</div>
622
+ </div>`;
623
+ }).join('')}
624
+ </div>
625
+ </div>
626
+ <div class="overview-section">
627
+ <div class="overview-section-title">Standards</div>
628
+ <div class="overview-list">
629
+ ${data.standards.length > 0 ? data.standards.map((standard) => `
630
+ <div class="overview-list-item" data-path="${escapeHtml(standard.path)}">
631
+ <div class="overview-list-icon intent">&#128220;</div>
632
+ <div class="overview-list-info"><div class="overview-list-name">${escapeHtml(standard.name)}</div></div>
633
+ </div>
634
+ `).join('') : '<div class="empty-state"><div class="empty-state-text">No standards defined</div></div>'}
635
+ </div>
636
+ </div>
637
+ </div>`;
638
+ }
639
+
640
+ function createSetDataMessage(data) {
641
+ if (!data?.ok || !data.snapshot) {
642
+ return {
643
+ type: 'setData',
644
+ activeTab: 'bolts',
645
+ boltsData: {
646
+ currentIntent: null,
647
+ currentIntentContext: 'none',
648
+ stats: { active: 0, queued: 0, done: 0, blocked: 0 },
649
+ activeBolts: [],
650
+ upNextQueue: [],
651
+ completedBolts: [],
652
+ activityEvents: [],
653
+ focusCardExpanded: true,
654
+ activityFilter: 'all',
655
+ activityHeight: 200,
656
+ specsFilter: 'all'
657
+ },
658
+ specsHtml: '',
659
+ overviewHtml: '',
660
+ availableFlows: [],
661
+ activeFlowId: null
662
+ };
663
+ }
664
+
665
+ const flowInfo = {
666
+ id: data.flow,
667
+ displayName: flowDisplayName(data.flow),
668
+ icon: flowIcon(data.flow),
669
+ rootFolder: flowRootFolder(data.flow)
670
+ };
671
+
672
+ if (data.flow === 'fire') {
673
+ return {
674
+ type: 'setData',
675
+ activeTab: 'bolts',
676
+ boltsData: {
677
+ currentIntent: null,
678
+ currentIntentContext: 'none',
679
+ stats: { active: 0, queued: 0, done: 0, blocked: 0 },
680
+ activeBolts: [],
681
+ upNextQueue: [],
682
+ completedBolts: [],
683
+ activityEvents: [],
684
+ focusCardExpanded: true,
685
+ activityFilter: 'all',
686
+ activityHeight: 200,
687
+ specsFilter: 'all'
688
+ },
689
+ specsHtml: '',
690
+ overviewHtml: '',
691
+ fireData: buildFireViewData(data.snapshot),
692
+ availableFlows: [flowInfo],
693
+ activeFlowId: data.flow
694
+ };
695
+ }
696
+
697
+ const webviewData = buildWebviewData(data.snapshot);
698
+ return {
699
+ type: 'setData',
700
+ activeTab: 'bolts',
701
+ boltsData: {
702
+ currentIntent: webviewData.currentIntent,
703
+ currentIntentContext: webviewData.currentIntentContext,
704
+ stats: webviewData.stats,
705
+ activeBolts: webviewData.activeBolts,
706
+ upNextQueue: webviewData.upNextQueue,
707
+ completedBolts: webviewData.completedBolts,
708
+ activityEvents: webviewData.activityEvents,
709
+ focusCardExpanded: webviewData.focusCardExpanded,
710
+ activityFilter: webviewData.activityFilter,
711
+ activityHeight: webviewData.activityHeight,
712
+ specsFilter: webviewData.specsFilter
713
+ },
714
+ specsHtml: getSpecsViewHtml(webviewData),
715
+ overviewHtml: getOverviewViewHtml(webviewData),
716
+ availableFlows: [flowInfo],
717
+ activeFlowId: data.flow
718
+ };
719
+ }
720
+
721
+ module.exports = {
722
+ buildWebviewData,
723
+ createSetDataMessage,
724
+ getOverviewViewHtml,
725
+ getSpecsViewHtml
726
+ };