specsmd 0.1.24 → 0.1.26

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.
@@ -1,6 +1,5 @@
1
1
  const { createWatchRuntime } = require('../runtime/watch-runtime');
2
2
  const { createInitialUIState, cycleView, cycleRunFilter } = require('./store');
3
- const { formatDashboardText } = require('./renderer');
4
3
 
5
4
  function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
6
5
  if (!error) {
@@ -33,6 +32,248 @@ function toDashboardError(error, defaultCode = 'DASHBOARD_ERROR') {
33
32
  };
34
33
  }
35
34
 
35
+ function safeJsonHash(value) {
36
+ try {
37
+ return JSON.stringify(value, (key, nestedValue) => {
38
+ if (key === 'generatedAt') {
39
+ return undefined;
40
+ }
41
+ return nestedValue;
42
+ });
43
+ } catch {
44
+ return String(value);
45
+ }
46
+ }
47
+
48
+ function truncate(value, width) {
49
+ const text = String(value ?? '');
50
+ if (!Number.isFinite(width) || width <= 0 || text.length <= width) {
51
+ return text;
52
+ }
53
+
54
+ if (width <= 3) {
55
+ return text.slice(0, width);
56
+ }
57
+
58
+ return `${text.slice(0, width - 3)}...`;
59
+ }
60
+
61
+ function fitLines(lines, maxLines, width) {
62
+ const safeLines = (Array.isArray(lines) ? lines : []).map((line) => truncate(line, width));
63
+
64
+ if (safeLines.length <= maxLines) {
65
+ return safeLines;
66
+ }
67
+
68
+ const visible = safeLines.slice(0, Math.max(1, maxLines - 1));
69
+ visible.push(truncate(`... +${safeLines.length - visible.length} more`, width));
70
+ return visible;
71
+ }
72
+
73
+ function formatTime(value) {
74
+ if (!value) {
75
+ return 'n/a';
76
+ }
77
+
78
+ const date = new Date(value);
79
+ if (Number.isNaN(date.getTime())) {
80
+ return value;
81
+ }
82
+
83
+ return date.toLocaleTimeString();
84
+ }
85
+
86
+ function buildShortStats(snapshot) {
87
+ if (!snapshot?.initialized) {
88
+ return 'init: waiting for state.yaml';
89
+ }
90
+
91
+ const stats = snapshot.stats;
92
+ return `runs ${stats.activeRunsCount}/${stats.completedRuns} | intents ${stats.completedIntents}/${stats.totalIntents} | work ${stats.completedWorkItems}/${stats.totalWorkItems}`;
93
+ }
94
+
95
+ function buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, view, runFilter, width) {
96
+ const projectName = snapshot?.project?.name || 'Unnamed FIRE project';
97
+ const shortStats = buildShortStats(snapshot);
98
+
99
+ const line = `${flow.toUpperCase()} | ${projectName} | ${shortStats} | watch:${watchEnabled ? watchStatus : 'off'} | ${view}/${runFilter} | ${formatTime(lastRefreshAt)}`;
100
+
101
+ return truncate(line, width);
102
+ }
103
+
104
+ function buildErrorLines(error, width) {
105
+ if (!error) {
106
+ return [];
107
+ }
108
+
109
+ const lines = [`[${error.code || 'ERROR'}] ${error.message || 'Unknown error'}`];
110
+
111
+ if (error.details) {
112
+ lines.push(`details: ${error.details}`);
113
+ }
114
+ if (error.path) {
115
+ lines.push(`path: ${error.path}`);
116
+ }
117
+ if (error.hint) {
118
+ lines.push(`hint: ${error.hint}`);
119
+ }
120
+
121
+ return lines.map((line) => truncate(line, width));
122
+ }
123
+
124
+ function buildActiveRunLines(snapshot, runFilter, width) {
125
+ if (runFilter === 'completed') {
126
+ return [truncate('Hidden by run filter: completed', width)];
127
+ }
128
+
129
+ const activeRuns = snapshot?.activeRuns || [];
130
+ if (activeRuns.length === 0) {
131
+ return [truncate('No active runs', width)];
132
+ }
133
+
134
+ const lines = [];
135
+ for (const run of activeRuns) {
136
+ const currentItem = run.currentItem || 'n/a';
137
+ const workItems = Array.isArray(run.workItems) ? run.workItems : [];
138
+ const completed = workItems.filter((item) => item.status === 'completed').length;
139
+ const inProgress = workItems.filter((item) => item.status === 'in_progress').length;
140
+
141
+ lines.push(`${run.id} [${run.scope}] current: ${currentItem}`);
142
+ lines.push(`progress: ${completed}/${workItems.length} done, ${inProgress} active`);
143
+ }
144
+
145
+ return lines.map((line) => truncate(line, width));
146
+ }
147
+
148
+ function buildPendingLines(snapshot, runFilter, width) {
149
+ if (runFilter === 'completed') {
150
+ return [truncate('Hidden by run filter: completed', width)];
151
+ }
152
+
153
+ const pending = snapshot?.pendingItems || [];
154
+ if (pending.length === 0) {
155
+ return [truncate('No pending work items', width)];
156
+ }
157
+
158
+ return pending.map((item) => {
159
+ const deps = item.dependencies && item.dependencies.length > 0 ? ` deps:${item.dependencies.join(',')}` : '';
160
+ return truncate(`${item.id} (${item.mode}/${item.complexity}) in ${item.intentTitle}${deps}`, width);
161
+ });
162
+ }
163
+
164
+ function buildCompletedLines(snapshot, runFilter, width) {
165
+ if (runFilter === 'active') {
166
+ return [truncate('Hidden by run filter: active', width)];
167
+ }
168
+
169
+ const completedRuns = snapshot?.completedRuns || [];
170
+ if (completedRuns.length === 0) {
171
+ return [truncate('No completed runs yet', width)];
172
+ }
173
+
174
+ return completedRuns.map((run) => {
175
+ const workItems = Array.isArray(run.workItems) ? run.workItems : [];
176
+ const completed = workItems.filter((item) => item.status === 'completed').length;
177
+ return truncate(`${run.id} [${run.scope}] ${completed}/${workItems.length} done at ${run.completedAt || 'unknown'}`, width);
178
+ });
179
+ }
180
+
181
+ function buildStatsLines(snapshot, width) {
182
+ if (!snapshot?.initialized) {
183
+ return [truncate('Waiting for .specs-fire/state.yaml initialization.', width)];
184
+ }
185
+
186
+ const stats = snapshot.stats;
187
+ return [
188
+ `intents: ${stats.completedIntents}/${stats.totalIntents} done | in_progress: ${stats.inProgressIntents} | blocked: ${stats.blockedIntents}`,
189
+ `work items: ${stats.completedWorkItems}/${stats.totalWorkItems} done | in_progress: ${stats.inProgressWorkItems} | pending: ${stats.pendingWorkItems} | blocked: ${stats.blockedWorkItems}`,
190
+ `runs: ${stats.activeRunsCount} active | ${stats.completedRuns} completed | ${stats.totalRuns} total`
191
+ ].map((line) => truncate(line, width));
192
+ }
193
+
194
+ function buildWarningsLines(snapshot, width) {
195
+ const warnings = snapshot?.warnings || [];
196
+ if (warnings.length === 0) {
197
+ return [truncate('No warnings', width)];
198
+ }
199
+
200
+ return warnings.map((warning) => truncate(warning, width));
201
+ }
202
+
203
+ function buildOverviewProjectLines(snapshot, width) {
204
+ if (!snapshot?.initialized) {
205
+ return [
206
+ truncate('FIRE folder detected, but state.yaml is missing.', width),
207
+ truncate('Initialize project context and this view will populate.', width)
208
+ ];
209
+ }
210
+
211
+ const project = snapshot.project || {};
212
+ const workspace = snapshot.workspace || {};
213
+
214
+ return [
215
+ `project: ${project.name || 'unknown'} | fire_version: ${project.fireVersion || snapshot.version || '0.0.0'}`,
216
+ `workspace: ${workspace.type || 'unknown'} / ${workspace.structure || 'unknown'}`,
217
+ `autonomy: ${workspace.autonomyBias || 'unknown'} | run scope pref: ${workspace.runScopePreference || 'unknown'}`
218
+ ].map((line) => truncate(line, width));
219
+ }
220
+
221
+ function buildOverviewIntentLines(snapshot, width) {
222
+ const intents = snapshot?.intents || [];
223
+ if (intents.length === 0) {
224
+ return [truncate('No intents found', width)];
225
+ }
226
+
227
+ return intents.map((intent) => {
228
+ const workItems = Array.isArray(intent.workItems) ? intent.workItems : [];
229
+ const done = workItems.filter((item) => item.status === 'completed').length;
230
+ return truncate(`${intent.id}: ${intent.status} (${done}/${workItems.length} work items)`, width);
231
+ });
232
+ }
233
+
234
+ function buildOverviewStandardsLines(snapshot, width) {
235
+ const expected = ['constitution', 'tech-stack', 'coding-standards', 'testing-standards', 'system-architecture'];
236
+ const actual = new Set((snapshot?.standards || []).map((item) => item.type));
237
+
238
+ return expected.map((name) => {
239
+ const marker = actual.has(name) ? '[x]' : '[ ]';
240
+ return truncate(`${marker} ${name}.md`, width);
241
+ });
242
+ }
243
+
244
+ function allocateSingleColumnPanels(candidates, rowsBudget) {
245
+ const filtered = (candidates || []).filter(Boolean);
246
+ if (filtered.length === 0) {
247
+ return [];
248
+ }
249
+
250
+ const selected = [];
251
+ let remaining = Math.max(4, rowsBudget);
252
+
253
+ for (const panel of filtered) {
254
+ const margin = selected.length > 0 ? 1 : 0;
255
+ const minimumRows = 4 + margin;
256
+
257
+ if (remaining >= minimumRows || selected.length === 0) {
258
+ selected.push({
259
+ ...panel,
260
+ maxLines: 1
261
+ });
262
+ remaining -= minimumRows;
263
+ }
264
+ }
265
+
266
+ let index = 0;
267
+ while (remaining > 0 && selected.length > 0) {
268
+ const panelIndex = index % selected.length;
269
+ selected[panelIndex].maxLines += 1;
270
+ remaining -= 1;
271
+ index += 1;
272
+ }
273
+
274
+ return selected;
275
+ }
276
+
36
277
  function createDashboardApp(deps) {
37
278
  const {
38
279
  React,
@@ -47,33 +288,126 @@ function createDashboardApp(deps) {
47
288
  initialError
48
289
  } = deps;
49
290
 
50
- const { Box, Text, useApp, useInput } = ink;
51
- const { useState, useEffect, useCallback } = React;
291
+ const { Box, Text, useApp, useInput, useStdout } = ink;
292
+ const { useState, useEffect, useCallback, useRef } = React;
293
+
294
+ function SectionPanel(props) {
295
+ const {
296
+ title,
297
+ lines,
298
+ width,
299
+ maxLines,
300
+ borderColor,
301
+ marginBottom
302
+ } = props;
303
+
304
+ const contentWidth = Math.max(18, width - 4);
305
+ const visibleLines = fitLines(lines, maxLines, contentWidth);
306
+
307
+ return React.createElement(
308
+ Box,
309
+ {
310
+ flexDirection: 'column',
311
+ borderStyle: 'round',
312
+ borderColor: borderColor || 'gray',
313
+ paddingX: 1,
314
+ width,
315
+ marginBottom: marginBottom || 0
316
+ },
317
+ React.createElement(Text, { bold: true, color: 'cyan' }, truncate(title, contentWidth)),
318
+ ...visibleLines.map((line, index) => React.createElement(Text, { key: `${title}-${index}` }, line))
319
+ );
320
+ }
321
+
322
+ function TabsBar(props) {
323
+ const { view, width } = props;
324
+ const tabs = [
325
+ { id: 'runs', label: ' 1 RUNS ' },
326
+ { id: 'overview', label: ' 2 OVERVIEW ' },
327
+ { id: 'health', label: ' 3 HEALTH ' }
328
+ ];
329
+
330
+ return React.createElement(
331
+ Box,
332
+ { width, flexWrap: 'nowrap' },
333
+ ...tabs.map((tab) => {
334
+ const isActive = tab.id === view;
335
+ return React.createElement(
336
+ Text,
337
+ {
338
+ key: tab.id,
339
+ bold: isActive,
340
+ color: isActive ? 'black' : 'gray',
341
+ backgroundColor: isActive ? 'cyan' : undefined
342
+ },
343
+ tab.label
344
+ );
345
+ })
346
+ );
347
+ }
52
348
 
53
349
  function DashboardApp() {
54
350
  const { exit } = useApp();
351
+ const { stdout } = useStdout();
352
+
353
+ const initialNormalizedError = initialError ? toDashboardError(initialError) : null;
354
+ const snapshotHashRef = useRef(safeJsonHash(initialSnapshot || null));
355
+ const errorHashRef = useRef(initialNormalizedError ? safeJsonHash(initialNormalizedError) : null);
55
356
 
56
357
  const [snapshot, setSnapshot] = useState(initialSnapshot || null);
57
- const [error, setError] = useState(initialError ? toDashboardError(initialError) : null);
358
+ const [error, setError] = useState(initialNormalizedError);
58
359
  const [ui, setUi] = useState(createInitialUIState());
59
360
  const [lastRefreshAt, setLastRefreshAt] = useState(new Date().toISOString());
60
361
  const [watchStatus, setWatchStatus] = useState(watchEnabled ? 'watching' : 'off');
362
+ const [terminalSize, setTerminalSize] = useState(() => ({
363
+ columns: stdout?.columns || process.stdout.columns || 120,
364
+ rows: stdout?.rows || process.stdout.rows || 40
365
+ }));
61
366
 
62
367
  const refresh = useCallback(async () => {
368
+ const now = new Date().toISOString();
369
+
63
370
  try {
64
371
  const result = await parseSnapshot();
65
372
 
66
373
  if (result?.ok) {
67
- setSnapshot(result.snapshot || null);
68
- setError(null);
69
- setWatchStatus(watchEnabled ? 'watching' : 'off');
374
+ const nextSnapshot = result.snapshot || null;
375
+ const nextSnapshotHash = safeJsonHash(nextSnapshot);
376
+
377
+ if (nextSnapshotHash !== snapshotHashRef.current) {
378
+ snapshotHashRef.current = nextSnapshotHash;
379
+ setSnapshot(nextSnapshot);
380
+ setLastRefreshAt(now);
381
+ }
382
+
383
+ if (errorHashRef.current !== null) {
384
+ errorHashRef.current = null;
385
+ setError(null);
386
+ setLastRefreshAt(now);
387
+ }
388
+
389
+ if (watchEnabled) {
390
+ setWatchStatus((previous) => (previous === 'watching' ? previous : 'watching'));
391
+ }
70
392
  } else {
71
- setError(toDashboardError(result?.error, 'PARSE_ERROR'));
393
+ const nextError = toDashboardError(result?.error, 'PARSE_ERROR');
394
+ const nextErrorHash = safeJsonHash(nextError);
395
+
396
+ if (nextErrorHash !== errorHashRef.current) {
397
+ errorHashRef.current = nextErrorHash;
398
+ setError(nextError);
399
+ setLastRefreshAt(now);
400
+ }
72
401
  }
73
402
  } catch (refreshError) {
74
- setError(toDashboardError(refreshError, 'REFRESH_FAILED'));
75
- } finally {
76
- setLastRefreshAt(new Date().toISOString());
403
+ const nextError = toDashboardError(refreshError, 'REFRESH_FAILED');
404
+ const nextErrorHash = safeJsonHash(nextError);
405
+
406
+ if (nextErrorHash !== errorHashRef.current) {
407
+ errorHashRef.current = nextErrorHash;
408
+ setError(nextError);
409
+ setLastRefreshAt(now);
410
+ }
77
411
  }
78
412
  }, [parseSnapshot, watchEnabled]);
79
413
 
@@ -103,6 +437,11 @@ function createDashboardApp(deps) {
103
437
  return;
104
438
  }
105
439
 
440
+ if (input === '3') {
441
+ setUi((previous) => ({ ...previous, view: 'health' }));
442
+ return;
443
+ }
444
+
106
445
  if (key.tab) {
107
446
  setUi((previous) => ({ ...previous, view: cycleView(previous.view) }));
108
447
  return;
@@ -117,6 +456,34 @@ function createDashboardApp(deps) {
117
456
  void refresh();
118
457
  }, [refresh]);
119
458
 
459
+ useEffect(() => {
460
+ if (!stdout || typeof stdout.on !== 'function') {
461
+ setTerminalSize({
462
+ columns: process.stdout.columns || 120,
463
+ rows: process.stdout.rows || 40
464
+ });
465
+ return undefined;
466
+ }
467
+
468
+ const updateSize = () => {
469
+ setTerminalSize({
470
+ columns: stdout.columns || process.stdout.columns || 120,
471
+ rows: stdout.rows || process.stdout.rows || 40
472
+ });
473
+ };
474
+
475
+ updateSize();
476
+ stdout.on('resize', updateSize);
477
+
478
+ return () => {
479
+ if (typeof stdout.off === 'function') {
480
+ stdout.off('resize', updateSize);
481
+ } else if (typeof stdout.removeListener === 'function') {
482
+ stdout.removeListener('resize', updateSize);
483
+ }
484
+ };
485
+ }, [stdout]);
486
+
120
487
  useEffect(() => {
121
488
  if (!watchEnabled) {
122
489
  return undefined;
@@ -124,20 +491,29 @@ function createDashboardApp(deps) {
124
491
 
125
492
  const runtime = createWatchRuntime({
126
493
  rootPath: rootPath || `${workspacePath}/.specs-fire`,
127
- debounceMs: 250,
494
+ debounceMs: 200,
128
495
  onRefresh: () => {
129
496
  void refresh();
130
497
  },
131
498
  onError: (watchError) => {
132
- setWatchStatus('reconnecting');
133
- setError(toDashboardError(watchError, 'WATCH_ERROR'));
499
+ const now = new Date().toISOString();
500
+ setWatchStatus((previous) => (previous === 'reconnecting' ? previous : 'reconnecting'));
501
+
502
+ const nextError = toDashboardError(watchError, 'WATCH_ERROR');
503
+ const nextErrorHash = safeJsonHash(nextError);
504
+ if (nextErrorHash !== errorHashRef.current) {
505
+ errorHashRef.current = nextErrorHash;
506
+ setError(nextError);
507
+ setLastRefreshAt(now);
508
+ }
134
509
  }
135
510
  });
136
511
 
137
512
  runtime.start();
513
+ const fallbackIntervalMs = Math.max(refreshMs, 5000);
138
514
  const interval = setInterval(() => {
139
515
  void refresh();
140
- }, refreshMs);
516
+ }, fallbackIntervalMs);
141
517
 
142
518
  return () => {
143
519
  clearInterval(interval);
@@ -145,24 +521,127 @@ function createDashboardApp(deps) {
145
521
  };
146
522
  }, [watchEnabled, refreshMs, refresh, rootPath, workspacePath]);
147
523
 
148
- const dashboardOutput = formatDashboardText({
149
- snapshot,
150
- error,
151
- flow,
152
- workspacePath,
153
- view: ui.view,
154
- runFilter: ui.runFilter,
155
- watchEnabled,
156
- watchStatus,
157
- showHelp: ui.showHelp,
158
- lastRefreshAt,
159
- width: process.stdout.columns || 120
160
- });
524
+ const cols = Number.isFinite(terminalSize.columns) ? terminalSize.columns : (process.stdout.columns || 120);
525
+ const rows = Number.isFinite(terminalSize.rows) ? terminalSize.rows : (process.stdout.rows || 40);
526
+
527
+ const fullWidth = Math.max(40, cols - 1);
528
+ const compactWidth = Math.max(18, fullWidth - 4);
529
+
530
+ const showHelpLine = ui.showHelp && rows >= 14;
531
+ const showErrorPanel = Boolean(error) && rows >= 18;
532
+ const showErrorInline = Boolean(error) && !showErrorPanel;
533
+
534
+ const reservedRows = 2 + (showHelpLine ? 1 : 0) + (showErrorPanel ? 5 : 0) + (showErrorInline ? 1 : 0);
535
+ const contentRowsBudget = Math.max(4, rows - reservedRows);
536
+ const ultraCompact = rows <= 14;
537
+
538
+ let panelCandidates;
539
+ if (ui.view === 'overview') {
540
+ panelCandidates = [
541
+ {
542
+ key: 'project',
543
+ title: 'Project + Workspace',
544
+ lines: buildOverviewProjectLines(snapshot, compactWidth),
545
+ borderColor: 'green'
546
+ },
547
+ {
548
+ key: 'intent-status',
549
+ title: 'Intent Status',
550
+ lines: buildOverviewIntentLines(snapshot, compactWidth),
551
+ borderColor: 'yellow'
552
+ },
553
+ {
554
+ key: 'standards',
555
+ title: 'Standards',
556
+ lines: buildOverviewStandardsLines(snapshot, compactWidth),
557
+ borderColor: 'blue'
558
+ }
559
+ ];
560
+ } else if (ui.view === 'health') {
561
+ panelCandidates = [
562
+ {
563
+ key: 'stats',
564
+ title: 'Stats',
565
+ lines: buildStatsLines(snapshot, compactWidth),
566
+ borderColor: 'magenta'
567
+ },
568
+ {
569
+ key: 'warnings',
570
+ title: 'Warnings',
571
+ lines: buildWarningsLines(snapshot, compactWidth),
572
+ borderColor: 'red'
573
+ }
574
+ ];
575
+
576
+ if (error && showErrorPanel) {
577
+ panelCandidates.push({
578
+ key: 'error-details',
579
+ title: 'Error Details',
580
+ lines: buildErrorLines(error, compactWidth),
581
+ borderColor: 'red'
582
+ });
583
+ }
584
+ } else {
585
+ panelCandidates = [
586
+ {
587
+ key: 'active-runs',
588
+ title: 'Active Runs',
589
+ lines: buildActiveRunLines(snapshot, ui.runFilter, compactWidth),
590
+ borderColor: 'green'
591
+ },
592
+ {
593
+ key: 'pending',
594
+ title: 'Pending Queue',
595
+ lines: buildPendingLines(snapshot, ui.runFilter, compactWidth),
596
+ borderColor: 'yellow'
597
+ },
598
+ {
599
+ key: 'completed',
600
+ title: 'Recent Completed Runs',
601
+ lines: buildCompletedLines(snapshot, ui.runFilter, compactWidth),
602
+ borderColor: 'blue'
603
+ }
604
+ ];
605
+ }
606
+
607
+ if (ultraCompact) {
608
+ panelCandidates = [panelCandidates[0]];
609
+ }
610
+
611
+ const panels = allocateSingleColumnPanels(panelCandidates, contentRowsBudget);
612
+
613
+ const helpText = 'q quit | r refresh | h/? help | tab next view | 1 runs | 2 overview | 3 health | f run filter';
161
614
 
162
615
  return React.createElement(
163
616
  Box,
164
- { flexDirection: 'column' },
165
- React.createElement(Text, null, dashboardOutput)
617
+ { flexDirection: 'column', width: fullWidth },
618
+ React.createElement(Text, { color: 'cyan' }, buildHeaderLine(snapshot, flow, watchEnabled, watchStatus, lastRefreshAt, ui.view, ui.runFilter, fullWidth)),
619
+ React.createElement(TabsBar, { view: ui.view, width: fullWidth }),
620
+ showErrorInline
621
+ ? React.createElement(Text, { color: 'red' }, truncate(buildErrorLines(error, fullWidth)[0] || 'Error', fullWidth))
622
+ : null,
623
+ showErrorPanel
624
+ ? React.createElement(SectionPanel, {
625
+ title: 'Errors',
626
+ lines: buildErrorLines(error, compactWidth),
627
+ width: fullWidth,
628
+ maxLines: 2,
629
+ borderColor: 'red',
630
+ marginBottom: 1
631
+ })
632
+ : null,
633
+ ...panels.map((panel, index) => React.createElement(SectionPanel, {
634
+ key: panel.key,
635
+ title: panel.title,
636
+ lines: panel.lines,
637
+ width: fullWidth,
638
+ maxLines: panel.maxLines,
639
+ borderColor: panel.borderColor,
640
+ marginBottom: index === panels.length - 1 ? 0 : 1
641
+ })),
642
+ showHelpLine
643
+ ? React.createElement(Text, { color: 'gray' }, truncate(helpText, fullWidth))
644
+ : null
166
645
  );
167
646
  }
168
647
 
@@ -171,5 +650,9 @@ function createDashboardApp(deps) {
171
650
 
172
651
  module.exports = {
173
652
  createDashboardApp,
174
- toDashboardError
653
+ toDashboardError,
654
+ truncate,
655
+ fitLines,
656
+ safeJsonHash,
657
+ allocateSingleColumnPanels
175
658
  };
@@ -10,6 +10,9 @@ function cycleView(current) {
10
10
  if (current === 'runs') {
11
11
  return 'overview';
12
12
  }
13
+ if (current === 'overview') {
14
+ return 'health';
15
+ }
13
16
  return 'runs';
14
17
  }
15
18
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "specsmd",
3
- "version": "0.1.24",
3
+ "version": "0.1.26",
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": {