specsmd 0.1.66 → 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.
- package/README.md +11 -4
- package/bin/cli.js +14 -1
- package/lib/dashboard/web/extension-adapter.js +726 -0
- package/lib/dashboard/web/public/app.js +9 -0
- package/lib/dashboard/web/public/index.html +14 -0
- package/lib/dashboard/web/public/styles.css +36 -0
- package/lib/dashboard/web/public/webview-bundle.js +7596 -0
- package/lib/dashboard/web/server.js +376 -0
- package/lib/dashboard/web/snapshot.js +299 -0
- package/package.json +5 -2
- package/scripts/check-webview-bundle-sync.cjs +38 -0
- package/scripts/sync-webview-bundle.cjs +19 -0
|
@@ -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
|
+
'&': '&',
|
|
7
|
+
'<': '<',
|
|
8
|
+
'>': '>',
|
|
9
|
+
'"': '"',
|
|
10
|
+
"'": '''
|
|
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">📋</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">▼</span>
|
|
537
|
+
<span class="intent-icon">🎯</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">🔍</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">▼</span>
|
|
556
|
+
<span class="unit-icon">📚</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">🔍</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">📝</span>
|
|
565
|
+
<div class="spec-story-status ${escapeHtml(story.status)}">${story.status === 'complete' ? '✓' : story.status === 'active' ? '●' : ''}</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">📋</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">📜</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
|
+
};
|