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.
@@ -0,0 +1,376 @@
1
+ const fs = require('fs');
2
+ const http = require('http');
3
+ const path = require('path');
4
+ const { URL } = require('url');
5
+ const { spawn } = require('child_process');
6
+ const crypto = require('crypto');
7
+ const { createWatchRuntime } = require('../runtime/watch-runtime');
8
+ const { detectFlow } = require('../flow-detect');
9
+ const { loadWebDashboardData } = require('./snapshot');
10
+
11
+ const PUBLIC_DIR = path.join(__dirname, 'public');
12
+ const TOKEN_COOKIE = 'specsmd_dashboard_token';
13
+
14
+ function parsePort(raw) {
15
+ const parsed = Number.parseInt(String(raw || '0'), 10);
16
+ if (Number.isNaN(parsed) || parsed < 0 || parsed > 65535) {
17
+ return 0;
18
+ }
19
+ return parsed;
20
+ }
21
+
22
+ function sendJson(res, statusCode, payload) {
23
+ const body = JSON.stringify(payload);
24
+ res.writeHead(statusCode, {
25
+ 'content-type': 'application/json; charset=utf-8',
26
+ 'cache-control': 'no-store',
27
+ 'content-length': Buffer.byteLength(body)
28
+ });
29
+ res.end(body);
30
+ }
31
+
32
+ function sendText(res, statusCode, body, contentType = 'text/plain; charset=utf-8') {
33
+ res.writeHead(statusCode, {
34
+ 'content-type': contentType,
35
+ 'cache-control': 'no-store',
36
+ 'content-length': Buffer.byteLength(body)
37
+ });
38
+ res.end(body);
39
+ }
40
+
41
+ function sendHtml(res, body, token) {
42
+ res.writeHead(200, {
43
+ 'content-type': 'text/html; charset=utf-8',
44
+ 'cache-control': 'no-store',
45
+ 'set-cookie': `${TOKEN_COOKIE}=${token}; Path=/; SameSite=Strict`,
46
+ 'content-length': Buffer.byteLength(body)
47
+ });
48
+ res.end(body);
49
+ }
50
+
51
+ function contentTypeFor(filePath) {
52
+ if (filePath.endsWith('.html')) return 'text/html; charset=utf-8';
53
+ if (filePath.endsWith('.js')) return 'text/javascript; charset=utf-8';
54
+ if (filePath.endsWith('.css')) return 'text/css; charset=utf-8';
55
+ if (filePath.endsWith('.svg')) return 'image/svg+xml';
56
+ if (filePath.endsWith('.png')) return 'image/png';
57
+ return 'application/octet-stream';
58
+ }
59
+
60
+ function safeStaticPath(requestPath) {
61
+ const pathname = requestPath === '/' ? '/index.html' : requestPath;
62
+ const normalized = path.normalize(pathname).replace(/^(\.\.[/\\])+/, '');
63
+ const filePath = path.join(PUBLIC_DIR, normalized);
64
+ if (!filePath.startsWith(PUBLIC_DIR)) {
65
+ return null;
66
+ }
67
+ return filePath;
68
+ }
69
+
70
+ function readRequestBody(req) {
71
+ return new Promise((resolve, reject) => {
72
+ let body = '';
73
+ req.setEncoding('utf8');
74
+ req.on('data', (chunk) => {
75
+ body += chunk;
76
+ if (body.length > 1024 * 1024) {
77
+ reject(new Error('Request body too large.'));
78
+ req.destroy();
79
+ }
80
+ });
81
+ req.on('end', () => resolve(body));
82
+ req.on('error', reject);
83
+ });
84
+ }
85
+
86
+ function parseCookies(req) {
87
+ return String(req.headers.cookie || '')
88
+ .split(';')
89
+ .map((cookie) => cookie.trim())
90
+ .filter(Boolean)
91
+ .reduce((cookies, cookie) => {
92
+ const separator = cookie.indexOf('=');
93
+ if (separator === -1) return cookies;
94
+ cookies[cookie.slice(0, separator)] = decodeURIComponent(cookie.slice(separator + 1));
95
+ return cookies;
96
+ }, {});
97
+ }
98
+
99
+ function hasValidCommandToken(req, token) {
100
+ const headerToken = req.headers['x-specsmd-dashboard-token'];
101
+ const cookies = parseCookies(req);
102
+ return headerToken === token || cookies[TOKEN_COOKIE] === token;
103
+ }
104
+
105
+ function isAllowedCommandRequest(req, host, port, token) {
106
+ if (!hasValidCommandToken(req, token)) {
107
+ return false;
108
+ }
109
+
110
+ const origin = req.headers.origin;
111
+ if (!origin) {
112
+ return true;
113
+ }
114
+
115
+ try {
116
+ const parsed = new URL(origin);
117
+ const expectedPort = String(port);
118
+ const requestHost = req.headers.host || `${host}:${expectedPort}`;
119
+ return parsed.protocol === 'http:'
120
+ && parsed.host === requestHost;
121
+ } catch {
122
+ return false;
123
+ }
124
+ }
125
+
126
+ function isSafeWorkspacePath(workspacePath, candidatePath) {
127
+ if (typeof candidatePath !== 'string' || candidatePath.trim() === '') {
128
+ return false;
129
+ }
130
+ try {
131
+ const resolvedWorkspace = fs.realpathSync.native(workspacePath);
132
+ const resolvedCandidate = fs.realpathSync.native(candidatePath);
133
+ const stat = fs.statSync(resolvedCandidate);
134
+ return stat.isFile()
135
+ && (resolvedCandidate === resolvedWorkspace || resolvedCandidate.startsWith(`${resolvedWorkspace}${path.sep}`));
136
+ } catch {
137
+ return false;
138
+ }
139
+ }
140
+
141
+ function openExternal(url) {
142
+ if (typeof url !== 'string' || !/^https?:\/\//i.test(url)) {
143
+ return false;
144
+ }
145
+ const opener = process.platform === 'darwin'
146
+ ? 'open'
147
+ : (process.platform === 'win32' ? 'cmd' : 'xdg-open');
148
+ const args = process.platform === 'win32' ? ['/c', 'start', '', url] : [url];
149
+ const child = spawn(opener, args, { detached: true, stdio: 'ignore' });
150
+ child.unref();
151
+ return true;
152
+ }
153
+
154
+ function openLocalFile(filePath) {
155
+ const opener = process.platform === 'darwin'
156
+ ? 'open'
157
+ : (process.platform === 'win32' ? 'cmd' : 'xdg-open');
158
+ const args = process.platform === 'win32' ? ['/c', 'start', '', filePath] : [filePath];
159
+ const child = spawn(opener, args, { detached: true, stdio: 'ignore' });
160
+ child.unref();
161
+ }
162
+
163
+ function buildWatchRoots(workspacePath, flow) {
164
+ const normalizedFlow = String(flow || '').toLowerCase();
165
+ if (normalizedFlow === 'aidlc') {
166
+ return [path.join(workspacePath, 'memory-bank')];
167
+ }
168
+ if (normalizedFlow === 'simple') {
169
+ return [path.join(workspacePath, 'specs')];
170
+ }
171
+ return [path.join(workspacePath, '.specs-fire')];
172
+ }
173
+
174
+ async function startDashboardWeb(options = {}) {
175
+ const workspacePath = path.resolve(options.path || options.workspacePath || process.cwd());
176
+ const host = options.host || '127.0.0.1';
177
+ const port = parsePort(options.port);
178
+ const commandToken = crypto.randomBytes(24).toString('base64url');
179
+ const clients = new Set();
180
+ let watcher = null;
181
+ let lastData = null;
182
+
183
+ async function loadAndBroadcast() {
184
+ lastData = await loadWebDashboardData({ workspacePath, flow: options.flow });
185
+ const message = lastData.webviewMessage || lastData;
186
+ const payload = `event: message\ndata: ${JSON.stringify(message)}\n\n`;
187
+ for (const client of clients) {
188
+ client.write(payload);
189
+ }
190
+ return lastData;
191
+ }
192
+
193
+ const server = http.createServer(async (req, res) => {
194
+ try {
195
+ const requestUrl = new URL(req.url || '/', `http://${host}`);
196
+
197
+ if (req.method === 'GET' && requestUrl.pathname === '/api/snapshot') {
198
+ const data = await loadAndBroadcast();
199
+ sendJson(res, data.ok ? 200 : 400, data);
200
+ return;
201
+ }
202
+
203
+ if (req.method === 'POST' && requestUrl.pathname === '/api/message') {
204
+ if (!isAllowedCommandRequest(req, host, actualPort, commandToken)) {
205
+ sendJson(res, 403, {
206
+ ok: false,
207
+ error: {
208
+ code: 'FORBIDDEN_ORIGIN',
209
+ message: 'Dashboard commands must originate from the local dashboard page.'
210
+ }
211
+ });
212
+ return;
213
+ }
214
+ const rawBody = await readRequestBody(req);
215
+ const message = rawBody ? JSON.parse(rawBody) : {};
216
+ if (message.type === 'refresh' || message.type === 'ready') {
217
+ const data = await loadAndBroadcast();
218
+ sendJson(res, 200, { ok: true, data });
219
+ return;
220
+ }
221
+ if (message.type === 'openExternal') {
222
+ const opened = openExternal(message.url);
223
+ sendJson(res, opened ? 200 : 400, opened
224
+ ? { ok: true }
225
+ : { ok: false, error: { code: 'INVALID_URL', message: 'Only http(s) URLs can be opened.' } });
226
+ return;
227
+ }
228
+ if (message.type === 'openArtifact' && isSafeWorkspacePath(workspacePath, message.path)) {
229
+ openLocalFile(message.path);
230
+ sendJson(res, 200, { ok: true });
231
+ return;
232
+ }
233
+ sendJson(res, 202, { ok: true, ignored: true });
234
+ return;
235
+ }
236
+
237
+ if (req.method === 'GET' && requestUrl.pathname === '/events') {
238
+ if (!isAllowedCommandRequest(req, host, actualPort, commandToken)) {
239
+ sendJson(res, 403, {
240
+ ok: false,
241
+ error: {
242
+ code: 'FORBIDDEN_ORIGIN',
243
+ message: 'Dashboard event streams must originate from the local dashboard page.'
244
+ }
245
+ });
246
+ return;
247
+ }
248
+ res.writeHead(200, {
249
+ 'content-type': 'text/event-stream; charset=utf-8',
250
+ 'cache-control': 'no-cache, no-transform',
251
+ connection: 'keep-alive'
252
+ });
253
+ res.write('\n');
254
+ clients.add(res);
255
+ if (lastData) {
256
+ const message = lastData.webviewMessage || lastData;
257
+ res.write(`event: message\ndata: ${JSON.stringify(message)}\n\n`);
258
+ }
259
+ req.on('close', () => {
260
+ clients.delete(res);
261
+ });
262
+ return;
263
+ }
264
+
265
+ if (req.method === 'GET') {
266
+ const filePath = safeStaticPath(requestUrl.pathname);
267
+ if (!filePath || !fs.existsSync(filePath) || !fs.statSync(filePath).isFile()) {
268
+ sendText(res, 404, 'Not found');
269
+ return;
270
+ }
271
+ const body = fs.readFileSync(filePath);
272
+ if (filePath.endsWith('.html')) {
273
+ sendHtml(res, body, commandToken);
274
+ return;
275
+ }
276
+ res.writeHead(200, {
277
+ 'content-type': contentTypeFor(filePath),
278
+ 'cache-control': filePath.endsWith('.html') ? 'no-store' : 'public, max-age=60',
279
+ 'content-length': body.length
280
+ });
281
+ res.end(body);
282
+ return;
283
+ }
284
+
285
+ sendText(res, 405, 'Method not allowed');
286
+ } catch (error) {
287
+ sendJson(res, 500, {
288
+ ok: false,
289
+ error: {
290
+ code: 'SERVER_ERROR',
291
+ message: error?.message || String(error)
292
+ }
293
+ });
294
+ }
295
+ });
296
+
297
+ await new Promise((resolve, reject) => {
298
+ server.once('error', reject);
299
+ server.listen(port, host, () => {
300
+ server.off('error', reject);
301
+ resolve();
302
+ });
303
+ });
304
+
305
+ const address = server.address();
306
+ const actualPort = typeof address === 'object' && address ? address.port : port;
307
+ const url = `http://${host}:${actualPort}/`;
308
+
309
+ const initialData = await loadAndBroadcast();
310
+ if (options.watch !== false && initialData.flow) {
311
+ let detection = null;
312
+ try {
313
+ detection = detectFlow(workspacePath, options.flow);
314
+ } catch {
315
+ detection = null;
316
+ }
317
+ const flow = detection?.flow || initialData.flow;
318
+ const roots = buildWatchRoots(workspacePath, flow).filter((root) => fs.existsSync(root));
319
+ if (roots.length > 0) {
320
+ watcher = createWatchRuntime({
321
+ rootPaths: roots,
322
+ onRefresh: () => {
323
+ loadAndBroadcast().catch(() => {});
324
+ }
325
+ });
326
+ watcher.start();
327
+ }
328
+ }
329
+
330
+ async function close() {
331
+ if (watcher) {
332
+ await watcher.close();
333
+ watcher = null;
334
+ }
335
+ for (const client of clients) {
336
+ client.end();
337
+ }
338
+ clients.clear();
339
+ await new Promise((resolve) => server.close(resolve));
340
+ }
341
+
342
+ return {
343
+ server,
344
+ url,
345
+ host,
346
+ port: actualPort,
347
+ workspacePath,
348
+ close,
349
+ getLastData: () => lastData
350
+ };
351
+ }
352
+
353
+ async function run(options = {}) {
354
+ const handle = await startDashboardWeb(options);
355
+ console.log(`specsmd dashboard web: ${handle.url}`);
356
+ console.log(`workspace: ${handle.workspacePath}`);
357
+ console.log('Press Ctrl+C to stop.');
358
+
359
+ if (options.open !== false) {
360
+ openExternal(handle.url);
361
+ }
362
+
363
+ const shutdown = async () => {
364
+ await handle.close();
365
+ process.exit(0);
366
+ };
367
+ process.once('SIGINT', shutdown);
368
+ process.once('SIGTERM', shutdown);
369
+ }
370
+
371
+ module.exports = {
372
+ startDashboardWeb,
373
+ run,
374
+ parsePort,
375
+ buildWatchRoots
376
+ };
@@ -0,0 +1,299 @@
1
+ const path = require('path');
2
+ const { detectFlow } = require('../flow-detect');
3
+ const { parseFireDashboard } = require('../fire/parser');
4
+ const { parseAidlcDashboard } = require('../aidlc/parser');
5
+ const { parseSimpleDashboard } = require('../simple/parser');
6
+ const { listGitChanges } = require('../git/changes');
7
+ const { createSetDataMessage } = require('./extension-adapter');
8
+
9
+ const FLOW_PARSERS = {
10
+ fire: parseFireDashboard,
11
+ aidlc: parseAidlcDashboard,
12
+ simple: parseSimpleDashboard
13
+ };
14
+
15
+ function normalizeFlow(flow) {
16
+ return String(flow || '').trim().toLowerCase();
17
+ }
18
+
19
+ function compactError(error) {
20
+ if (!error) {
21
+ return {
22
+ code: 'UNKNOWN_ERROR',
23
+ message: 'Unknown dashboard error.'
24
+ };
25
+ }
26
+
27
+ return {
28
+ code: error.code || 'DASHBOARD_ERROR',
29
+ message: error.message || String(error),
30
+ hint: error.hint,
31
+ details: error.details,
32
+ path: error.path
33
+ };
34
+ }
35
+
36
+ function createCard(label, value, detail) {
37
+ return {
38
+ label,
39
+ value: String(value ?? 0),
40
+ detail: detail || ''
41
+ };
42
+ }
43
+
44
+ function summarizeAidlc(snapshot) {
45
+ const stats = snapshot?.stats || {};
46
+ const activeBolts = Array.isArray(snapshot?.activeBolts) ? snapshot.activeBolts : [];
47
+ const pendingBolts = Array.isArray(snapshot?.pendingBolts) ? snapshot.pendingBolts : [];
48
+ const completedBolts = Array.isArray(snapshot?.completedBolts) ? snapshot.completedBolts : [];
49
+ const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
50
+
51
+ return {
52
+ cards: [
53
+ createCard('Intents', stats.totalIntents, `${stats.completedIntents || 0} complete`),
54
+ createCard('Stories', stats.totalStories, `${stats.completedStories || 0} complete`),
55
+ createCard('Bolts', stats.totalBolts, `${stats.activeBoltsCount || 0} active`),
56
+ createCard('Progress', `${stats.progressPercent || 0}%`, 'story completion')
57
+ ],
58
+ sections: [
59
+ {
60
+ title: 'Active Bolts',
61
+ empty: 'No active bolts.',
62
+ items: activeBolts.map((bolt) => ({
63
+ title: bolt.id,
64
+ meta: `${bolt.intent || 'unknown intent'} / ${bolt.unit || 'unknown unit'}`,
65
+ status: bolt.currentStage || bolt.status,
66
+ path: bolt.filePath
67
+ }))
68
+ },
69
+ {
70
+ title: 'Queued Bolts',
71
+ empty: 'No queued bolts.',
72
+ items: pendingBolts.slice(0, 12).map((bolt) => ({
73
+ title: bolt.id,
74
+ meta: `${bolt.intent || 'unknown intent'} / ${bolt.unit || 'unknown unit'}`,
75
+ status: bolt.isBlocked ? 'blocked' : bolt.status,
76
+ path: bolt.filePath
77
+ }))
78
+ },
79
+ {
80
+ title: 'Recent Completed Bolts',
81
+ empty: 'No completed bolts.',
82
+ items: completedBolts.slice(0, 12).map((bolt) => ({
83
+ title: bolt.id,
84
+ meta: `${bolt.intent || 'unknown intent'} / ${bolt.unit || 'unknown unit'}`,
85
+ status: bolt.completedAt || 'completed',
86
+ path: bolt.filePath
87
+ }))
88
+ },
89
+ {
90
+ title: 'Intents',
91
+ empty: 'No intents found.',
92
+ items: intents.map((intent) => ({
93
+ title: intent.title || intent.id,
94
+ meta: `${intent.unitCount || 0} units / ${intent.storyCount || 0} stories`,
95
+ status: intent.status,
96
+ path: intent.path
97
+ }))
98
+ }
99
+ ],
100
+ primaryItems: intents.map((intent) => ({
101
+ title: intent.title || intent.id,
102
+ status: intent.status,
103
+ path: intent.path
104
+ }))
105
+ };
106
+ }
107
+
108
+ function summarizeFire(snapshot) {
109
+ const stats = snapshot?.stats || {};
110
+ const activeRuns = Array.isArray(snapshot?.activeRuns) ? snapshot.activeRuns : [];
111
+ const pendingItems = Array.isArray(snapshot?.pendingItems) ? snapshot.pendingItems : [];
112
+ const completedRuns = Array.isArray(snapshot?.completedRuns) ? snapshot.completedRuns : [];
113
+ const intents = Array.isArray(snapshot?.intents) ? snapshot.intents : [];
114
+
115
+ return {
116
+ cards: [
117
+ createCard('Intents', stats.totalIntents, `${stats.completedIntents || 0} complete`),
118
+ createCard('Work Items', stats.totalWorkItems, `${stats.completedWorkItems || 0} complete`),
119
+ createCard('Runs', stats.totalRuns, `${stats.activeRunsCount || 0} active`),
120
+ createCard('Standards', Array.isArray(snapshot?.standards) ? snapshot.standards.length : 0, 'configured')
121
+ ],
122
+ sections: [
123
+ {
124
+ title: 'Active Runs',
125
+ empty: 'No active runs.',
126
+ items: activeRuns.map((run) => ({
127
+ title: run.id,
128
+ meta: `${run.scope || 'single'} / ${run.workItems?.length || 0} work items`,
129
+ status: run.currentItem || 'active',
130
+ path: run.folderPath
131
+ }))
132
+ },
133
+ {
134
+ title: 'Pending Work Items',
135
+ empty: 'No pending work items.',
136
+ items: pendingItems.slice(0, 12).map((item) => ({
137
+ title: item.title || item.id,
138
+ meta: item.intentTitle || item.intentId || '',
139
+ status: `${item.mode || 'confirm'} / ${item.complexity || 'medium'}`,
140
+ path: item.filePath
141
+ }))
142
+ },
143
+ {
144
+ title: 'Completed Runs',
145
+ empty: 'No completed runs.',
146
+ items: completedRuns.slice(0, 12).map((run) => ({
147
+ title: run.id,
148
+ meta: `${run.scope || 'single'} / ${run.workItems?.length || 0} work items`,
149
+ status: run.completedAt || 'completed',
150
+ path: run.folderPath
151
+ }))
152
+ },
153
+ {
154
+ title: 'Intents',
155
+ empty: 'No intents found.',
156
+ items: intents.map((intent) => ({
157
+ title: intent.title || intent.id,
158
+ meta: `${intent.workItems?.length || 0} work items`,
159
+ status: intent.status,
160
+ path: intent.filePath
161
+ }))
162
+ }
163
+ ],
164
+ primaryItems: intents.map((intent) => ({
165
+ title: intent.title || intent.id,
166
+ status: intent.status,
167
+ path: intent.filePath
168
+ }))
169
+ };
170
+ }
171
+
172
+ function summarizeSimple(snapshot) {
173
+ const stats = snapshot?.stats || {};
174
+ const activeSpecs = Array.isArray(snapshot?.activeSpecs) ? snapshot.activeSpecs : [];
175
+ const completedSpecs = Array.isArray(snapshot?.completedSpecs) ? snapshot.completedSpecs : [];
176
+
177
+ return {
178
+ cards: [
179
+ createCard('Specs', stats.totalSpecs, `${stats.completedSpecs || 0} complete`),
180
+ createCard('Tasks', stats.totalTasks, `${stats.completedTasks || 0} complete`),
181
+ createCard('Ready', stats.readySpecs, 'specs'),
182
+ createCard('Progress', `${stats.progressPercent || 0}%`, 'task completion')
183
+ ],
184
+ sections: [
185
+ {
186
+ title: 'Active Specs',
187
+ empty: 'No active specs.',
188
+ items: activeSpecs.map((spec) => ({
189
+ title: spec.name,
190
+ meta: `${spec.tasksCompleted || 0}/${spec.tasksTotal || 0} tasks`,
191
+ status: spec.state,
192
+ path: spec.path
193
+ }))
194
+ },
195
+ {
196
+ title: 'Completed Specs',
197
+ empty: 'No completed specs.',
198
+ items: completedSpecs.slice(0, 12).map((spec) => ({
199
+ title: spec.name,
200
+ meta: `${spec.tasksCompleted || 0}/${spec.tasksTotal || 0} tasks`,
201
+ status: spec.updatedAt || 'completed',
202
+ path: spec.path
203
+ }))
204
+ }
205
+ ],
206
+ primaryItems: activeSpecs.map((spec) => ({
207
+ title: spec.name,
208
+ status: spec.state,
209
+ path: spec.path
210
+ }))
211
+ };
212
+ }
213
+
214
+ function summarizeSnapshot(flow, snapshot) {
215
+ if (flow === 'fire') {
216
+ return summarizeFire(snapshot);
217
+ }
218
+ if (flow === 'simple') {
219
+ return summarizeSimple(snapshot);
220
+ }
221
+ return summarizeAidlc(snapshot);
222
+ }
223
+
224
+ async function loadWebDashboardData(options = {}) {
225
+ const workspacePath = path.resolve(options.workspacePath || options.path || process.cwd());
226
+ let detection;
227
+
228
+ try {
229
+ detection = detectFlow(workspacePath, options.flow);
230
+ } catch (error) {
231
+ return {
232
+ ok: false,
233
+ flow: normalizeFlow(options.flow) || null,
234
+ workspacePath,
235
+ error: compactError(error)
236
+ };
237
+ }
238
+
239
+ if (!detection.flow) {
240
+ return {
241
+ ok: false,
242
+ flow: null,
243
+ workspacePath,
244
+ availableFlows: detection.availableFlows || [],
245
+ error: {
246
+ code: 'NO_SUPPORTED_FLOW',
247
+ message: 'No supported flow detected. Expected one of: .specs-fire, memory-bank, specs'
248
+ }
249
+ };
250
+ }
251
+
252
+ const parser = FLOW_PARSERS[detection.flow];
253
+ if (!parser) {
254
+ return {
255
+ ok: false,
256
+ flow: detection.flow,
257
+ workspacePath,
258
+ availableFlows: detection.availableFlows || [],
259
+ error: {
260
+ code: 'UNSUPPORTED_FLOW',
261
+ message: `Flow "${detection.flow}" is not supported by the web dashboard.`
262
+ }
263
+ };
264
+ }
265
+
266
+ const result = await parser(workspacePath);
267
+ if (!result.ok) {
268
+ return {
269
+ ok: false,
270
+ flow: detection.flow,
271
+ workspacePath,
272
+ availableFlows: detection.availableFlows || [],
273
+ error: compactError(result.error)
274
+ };
275
+ }
276
+
277
+ const snapshot = {
278
+ ...result.snapshot,
279
+ gitChanges: listGitChanges(workspacePath)
280
+ };
281
+
282
+ const data = {
283
+ ok: true,
284
+ flow: detection.flow,
285
+ availableFlows: detection.availableFlows || [detection.flow],
286
+ workspacePath,
287
+ snapshot,
288
+ summary: summarizeSnapshot(detection.flow, snapshot),
289
+ warnings: Array.isArray(snapshot.warnings) ? snapshot.warnings : [],
290
+ generatedAt: snapshot.generatedAt || new Date().toISOString()
291
+ };
292
+ data.webviewMessage = createSetDataMessage(data);
293
+ return data;
294
+ }
295
+
296
+ module.exports = {
297
+ loadWebDashboardData,
298
+ summarizeSnapshot
299
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.66",
3
+ "version": "0.1.68",
4
4
  "description": "Multi-agent orchestration system for AI-native software development. Delivers AI-DLC, Agile, and custom SDLC flows as markdown-based agent systems.",
5
5
  "main": "lib/installer.js",
6
6
  "bin": {
@@ -12,7 +12,10 @@
12
12
  "test:schema": "vitest run __tests__/unit/schema-validation/",
13
13
  "lint:md": "markdownlint 'flows/**/*.md' --config ../.markdownlint.yaml",
14
14
  "lint:md:fix": "markdownlint 'flows/**/*.md' --config ../.markdownlint.yaml --fix",
15
- "validate:all": "npm run test && npm run lint:md"
15
+ "sync:webview-bundle": "node scripts/sync-webview-bundle.cjs",
16
+ "check:webview-bundle": "node scripts/check-webview-bundle-sync.cjs",
17
+ "prepack": "npm run check:webview-bundle",
18
+ "validate:all": "npm run test && npm run lint:md && npm run check:webview-bundle"
16
19
  },
17
20
  "keywords": [
18
21
  "ai-dlc",