specsmd 0.1.26 → 0.1.28

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,8 @@
1
1
  const path = require('path');
2
2
  const { detectFlow } = require('./flow-detect');
3
3
  const { parseFireDashboard } = require('./fire/parser');
4
+ const { parseAidlcDashboard } = require('./aidlc/parser');
5
+ const { parseSimpleDashboard } = require('./simple/parser');
4
6
  const { formatDashboardText } = require('./tui/renderer');
5
7
  const { createDashboardApp } = require('./tui/app');
6
8
 
@@ -13,62 +15,99 @@ function parseRefreshMs(raw) {
13
15
  return Math.max(200, Math.min(parsed, 5000));
14
16
  }
15
17
 
16
- function getUnsupportedFlowMessage(flow) {
18
+ const FLOW_CONFIG = {
19
+ fire: {
20
+ markerDir: '.specs-fire',
21
+ parse: parseFireDashboard
22
+ },
23
+ aidlc: {
24
+ markerDir: 'memory-bank',
25
+ parse: parseAidlcDashboard
26
+ },
27
+ simple: {
28
+ markerDir: 'specs',
29
+ parse: parseSimpleDashboard
30
+ }
31
+ };
32
+
33
+ function formatStaticFlowText(flow, snapshot, error) {
34
+ if (flow === 'fire') {
35
+ return formatDashboardText({
36
+ snapshot,
37
+ error,
38
+ flow,
39
+ workspacePath: snapshot?.workspacePath || process.cwd(),
40
+ view: 'runs',
41
+ runFilter: 'all',
42
+ watchEnabled: false,
43
+ watchStatus: 'off',
44
+ showHelp: true,
45
+ lastRefreshAt: new Date().toISOString(),
46
+ width: process.stdout.columns || 120
47
+ });
48
+ }
49
+
50
+ if (error) {
51
+ return `[${error.code || 'ERROR'}] ${error.message || 'Dashboard error'}`;
52
+ }
53
+
17
54
  if (flow === 'aidlc') {
18
- return 'AI-DLC dashboard is coming soon. FIRE dashboard is available now.';
55
+ const stats = snapshot?.stats || {};
56
+ const active = snapshot?.activeBolts?.[0] || null;
57
+ const lines = [
58
+ `specsmd dashboard | AIDLC | ${snapshot?.project?.name || 'unknown project'}`,
59
+ `intents ${stats.completedIntents || 0}/${stats.totalIntents || 0} | stories ${stats.completedStories || 0}/${stats.totalStories || 0} | bolts ${stats.activeBoltsCount || 0} active / ${stats.completedBolts || 0} done`,
60
+ active
61
+ ? `current bolt: ${active.id} (${active.currentStage || 'unknown stage'}) in ${active.intent || 'unknown intent'}`
62
+ : 'current bolt: none'
63
+ ];
64
+ return lines.join('\n');
19
65
  }
20
66
 
21
67
  if (flow === 'simple') {
22
- return 'Simple flow dashboard is coming soon. FIRE dashboard is available now.';
68
+ const stats = snapshot?.stats || {};
69
+ const active = snapshot?.activeSpecs?.[0] || null;
70
+ const lines = [
71
+ `specsmd dashboard | SIMPLE | ${snapshot?.project?.name || 'unknown project'}`,
72
+ `specs ${stats.completedSpecs || 0}/${stats.totalSpecs || 0} complete | tasks ${stats.completedTasks || 0}/${stats.totalTasks || 0} complete`,
73
+ active
74
+ ? `current spec: ${active.name} (${active.state}) ${active.tasksCompleted}/${active.tasksTotal} tasks`
75
+ : 'current spec: none'
76
+ ];
77
+ return lines.join('\n');
23
78
  }
24
79
 
25
- return `Flow \"${flow}\" dashboard is not available yet.`;
80
+ return 'Unsupported flow.';
26
81
  }
27
82
 
28
- async function runFireDashboard(options) {
83
+ async function runFlowDashboard(options, flow) {
29
84
  const workspacePath = path.resolve(options.path || process.cwd());
30
- const rootPath = path.join(workspacePath, '.specs-fire');
85
+ const config = FLOW_CONFIG[flow];
86
+
87
+ if (!config) {
88
+ console.error(`Flow \"${flow}\" dashboard is not available yet.`);
89
+ process.exitCode = 1;
90
+ return;
91
+ }
92
+
93
+ const rootPath = path.join(workspacePath, config.markerDir);
31
94
  const watchEnabled = options.watch !== false;
32
95
  const refreshMs = parseRefreshMs(options.refreshMs);
33
96
 
34
- const parseSnapshot = async () => parseFireDashboard(workspacePath);
97
+ const parseSnapshot = async () => config.parse(workspacePath);
35
98
 
36
99
  const initialResult = await parseSnapshot();
37
100
 
38
101
  if (!watchEnabled) {
102
+ const output = formatStaticFlowText(
103
+ flow,
104
+ initialResult.ok ? initialResult.snapshot : null,
105
+ initialResult.ok ? null : initialResult.error
106
+ );
107
+ console.log(output);
39
108
  if (!initialResult.ok) {
40
- const output = formatDashboardText({
41
- snapshot: null,
42
- error: initialResult.error,
43
- flow: 'fire',
44
- workspacePath,
45
- view: 'runs',
46
- runFilter: 'all',
47
- watchEnabled: false,
48
- watchStatus: 'off',
49
- showHelp: true,
50
- lastRefreshAt: new Date().toISOString(),
51
- width: process.stdout.columns || 120
52
- });
53
- console.log(output);
54
109
  process.exitCode = 1;
55
- return;
56
110
  }
57
-
58
- const output = formatDashboardText({
59
- snapshot: initialResult.snapshot,
60
- error: null,
61
- flow: 'fire',
62
- workspacePath,
63
- view: 'runs',
64
- runFilter: 'all',
65
- watchEnabled: false,
66
- watchStatus: 'off',
67
- showHelp: true,
68
- lastRefreshAt: new Date().toISOString(),
69
- width: process.stdout.columns || 120
70
- });
71
- console.log(output);
72
111
  return;
73
112
  }
74
113
 
@@ -82,7 +121,7 @@ async function runFireDashboard(options) {
82
121
  parseSnapshot,
83
122
  workspacePath,
84
123
  rootPath,
85
- flow: 'fire',
124
+ flow,
86
125
  refreshMs,
87
126
  watchEnabled,
88
127
  initialSnapshot: initialResult.ok ? initialResult.snapshot : null,
@@ -118,17 +157,12 @@ async function run(options = {}) {
118
157
  console.warn(`Warning: ${detection.warning}`);
119
158
  }
120
159
 
121
- if (detection.flow !== 'fire') {
122
- console.log(getUnsupportedFlowMessage(detection.flow));
123
- return;
124
- }
125
-
126
- await runFireDashboard(options);
160
+ await runFlowDashboard(options, detection.flow);
127
161
  }
128
162
 
129
163
  module.exports = {
130
164
  run,
131
- runFireDashboard,
165
+ runFlowDashboard,
132
166
  parseRefreshMs,
133
- getUnsupportedFlowMessage
167
+ formatStaticFlowText
134
168
  };
@@ -0,0 +1,293 @@
1
+ const fs = require('fs');
2
+ const path = require('path');
3
+
4
+ function readFileSafe(filePath) {
5
+ try {
6
+ return fs.readFileSync(filePath, 'utf8');
7
+ } catch {
8
+ return null;
9
+ }
10
+ }
11
+
12
+ function listSubdirectories(dirPath) {
13
+ try {
14
+ return fs.readdirSync(dirPath, { withFileTypes: true })
15
+ .filter((entry) => entry.isDirectory())
16
+ .map((entry) => entry.name)
17
+ .sort((a, b) => a.localeCompare(b));
18
+ } catch {
19
+ return [];
20
+ }
21
+ }
22
+
23
+ function parseTaskChecklist(content) {
24
+ const lines = String(content || '').split(/\r?\n/);
25
+ const tasks = [];
26
+
27
+ lines.forEach((line, index) => {
28
+ const match = line.match(/^\s*[-*]\s*\[( |x|X)\](\*)?\s+(.+)$/);
29
+ if (!match) {
30
+ return;
31
+ }
32
+
33
+ tasks.push({
34
+ line: index + 1,
35
+ done: match[1].toLowerCase() === 'x',
36
+ optional: match[2] === '*',
37
+ text: match[3].trim()
38
+ });
39
+ });
40
+
41
+ return tasks;
42
+ }
43
+
44
+ function getLatestTimestamp(paths) {
45
+ let latest = 0;
46
+
47
+ for (const filePath of paths) {
48
+ try {
49
+ const stats = fs.statSync(filePath);
50
+ if (stats.mtimeMs > latest) {
51
+ latest = stats.mtimeMs;
52
+ }
53
+ } catch {
54
+ // Ignore missing files.
55
+ }
56
+ }
57
+
58
+ return latest > 0 ? new Date(latest).toISOString() : undefined;
59
+ }
60
+
61
+ function deriveSpecState(hasRequirements, hasDesign, hasTasks, taskStats) {
62
+ if (!hasRequirements) {
63
+ return 'requirements_pending';
64
+ }
65
+
66
+ if (!hasDesign) {
67
+ return 'design_pending';
68
+ }
69
+
70
+ if (!hasTasks) {
71
+ return 'tasks_pending';
72
+ }
73
+
74
+ if (taskStats.total === 0) {
75
+ return 'tasks_pending';
76
+ }
77
+
78
+ if (taskStats.completed >= taskStats.total) {
79
+ return 'completed';
80
+ }
81
+
82
+ if (taskStats.completed > 0) {
83
+ return 'in_progress';
84
+ }
85
+
86
+ return 'ready';
87
+ }
88
+
89
+ function statePriority(state) {
90
+ switch (state) {
91
+ case 'in_progress':
92
+ return 0;
93
+ case 'ready':
94
+ return 1;
95
+ case 'tasks_pending':
96
+ return 2;
97
+ case 'design_pending':
98
+ return 3;
99
+ case 'requirements_pending':
100
+ return 4;
101
+ case 'completed':
102
+ return 5;
103
+ default:
104
+ return 6;
105
+ }
106
+ }
107
+
108
+ function phaseForState(state) {
109
+ if (state === 'requirements_pending') {
110
+ return 'requirements';
111
+ }
112
+
113
+ if (state === 'design_pending') {
114
+ return 'design';
115
+ }
116
+
117
+ return 'tasks';
118
+ }
119
+
120
+ function parseSpec(specsPath, specName, warnings) {
121
+ const specPath = path.join(specsPath, specName);
122
+
123
+ const requirementsPath = path.join(specPath, 'requirements.md');
124
+ const designPath = path.join(specPath, 'design.md');
125
+ const tasksPath = path.join(specPath, 'tasks.md');
126
+
127
+ const hasRequirements = fs.existsSync(requirementsPath);
128
+ const hasDesign = fs.existsSync(designPath);
129
+ const hasTasks = fs.existsSync(tasksPath);
130
+
131
+ if (!hasRequirements) {
132
+ warnings.push(`Spec ${specName} is missing requirements.md.`);
133
+ }
134
+
135
+ const tasksContent = hasTasks ? readFileSafe(tasksPath) : null;
136
+ const tasks = tasksContent ? parseTaskChecklist(tasksContent) : [];
137
+
138
+ const taskStats = {
139
+ total: tasks.length,
140
+ completed: tasks.filter((task) => task.done).length,
141
+ optional: tasks.filter((task) => task.optional).length
142
+ };
143
+
144
+ const state = deriveSpecState(hasRequirements, hasDesign, hasTasks, taskStats);
145
+ const updatedAt = getLatestTimestamp([requirementsPath, designPath, tasksPath]);
146
+
147
+ return {
148
+ id: specName,
149
+ name: specName,
150
+ path: specPath,
151
+ state,
152
+ phase: phaseForState(state),
153
+ hasRequirements,
154
+ hasDesign,
155
+ hasTasks,
156
+ tasks,
157
+ tasksTotal: taskStats.total,
158
+ tasksCompleted: taskStats.completed,
159
+ tasksPending: Math.max(taskStats.total - taskStats.completed, 0),
160
+ optionalTasks: taskStats.optional,
161
+ updatedAt
162
+ };
163
+ }
164
+
165
+ function buildProjectMetadata(workspacePath) {
166
+ const packageJsonPath = path.join(workspacePath, 'package.json');
167
+ const fallbackName = path.basename(workspacePath);
168
+
169
+ try {
170
+ const parsed = JSON.parse(readFileSafe(packageJsonPath) || '{}');
171
+ return {
172
+ name: typeof parsed.name === 'string' ? parsed.name : fallbackName,
173
+ description: typeof parsed.description === 'string' ? parsed.description : undefined
174
+ };
175
+ } catch {
176
+ return { name: fallbackName };
177
+ }
178
+ }
179
+
180
+ function buildStats(specs) {
181
+ const totalSpecs = specs.length;
182
+ const completedSpecs = specs.filter((spec) => spec.state === 'completed').length;
183
+ const inProgressSpecs = specs.filter((spec) => spec.state === 'in_progress').length;
184
+ const readySpecs = specs.filter((spec) => spec.state === 'ready').length;
185
+ const designPendingSpecs = specs.filter((spec) => spec.state === 'design_pending').length;
186
+ const tasksPendingSpecs = specs.filter((spec) => spec.state === 'tasks_pending').length;
187
+ const requirementsPendingSpecs = specs.filter((spec) => spec.state === 'requirements_pending').length;
188
+ const pendingSpecs = totalSpecs - completedSpecs;
189
+
190
+ const totalTasks = specs.reduce((sum, spec) => sum + spec.tasksTotal, 0);
191
+ const completedTasks = specs.reduce((sum, spec) => sum + spec.tasksCompleted, 0);
192
+ const optionalTasks = specs.reduce((sum, spec) => sum + spec.optionalTasks, 0);
193
+ const pendingTasks = Math.max(totalTasks - completedTasks, 0);
194
+
195
+ const progressPercent = totalTasks > 0
196
+ ? Math.round((completedTasks / totalTasks) * 100)
197
+ : 0;
198
+
199
+ return {
200
+ totalSpecs,
201
+ completedSpecs,
202
+ inProgressSpecs,
203
+ readySpecs,
204
+ pendingSpecs,
205
+ designPendingSpecs,
206
+ tasksPendingSpecs,
207
+ requirementsPendingSpecs,
208
+ totalTasks,
209
+ completedTasks,
210
+ pendingTasks,
211
+ optionalTasks,
212
+ activeSpecsCount: inProgressSpecs + readySpecs,
213
+ progressPercent
214
+ };
215
+ }
216
+
217
+ function parseSimpleDashboard(workspacePath) {
218
+ const rootPath = path.join(workspacePath, 'specs');
219
+
220
+ if (!fs.existsSync(rootPath) || !fs.statSync(rootPath).isDirectory()) {
221
+ return {
222
+ ok: false,
223
+ error: {
224
+ code: 'SIMPLE_NOT_FOUND',
225
+ message: `No Simple flow workspace found at ${rootPath}`,
226
+ hint: 'Run this command from a workspace containing specs/ or choose --flow fire/aidlc.'
227
+ }
228
+ };
229
+ }
230
+
231
+ const warnings = [];
232
+ const specFolders = listSubdirectories(rootPath);
233
+ const specs = specFolders
234
+ .map((specFolder) => parseSpec(rootPath, specFolder, warnings))
235
+ .sort((a, b) => {
236
+ const priorityDiff = statePriority(a.state) - statePriority(b.state);
237
+ if (priorityDiff !== 0) {
238
+ return priorityDiff;
239
+ }
240
+
241
+ const aTime = a.updatedAt ? Date.parse(a.updatedAt) : 0;
242
+ const bTime = b.updatedAt ? Date.parse(b.updatedAt) : 0;
243
+ if (bTime !== aTime) {
244
+ return bTime - aTime;
245
+ }
246
+
247
+ return a.name.localeCompare(b.name);
248
+ });
249
+
250
+ if (specs.length === 0) {
251
+ warnings.push('No specs found under specs/.');
252
+ }
253
+
254
+ const activeSpecs = specs.filter((spec) => spec.state !== 'completed');
255
+ const completedSpecs = specs
256
+ .filter((spec) => spec.state === 'completed')
257
+ .sort((a, b) => {
258
+ const aTime = a.updatedAt ? Date.parse(a.updatedAt) : 0;
259
+ const bTime = b.updatedAt ? Date.parse(b.updatedAt) : 0;
260
+ if (bTime !== aTime) {
261
+ return bTime - aTime;
262
+ }
263
+ return b.name.localeCompare(a.name);
264
+ });
265
+
266
+ const stats = buildStats(specs);
267
+
268
+ return {
269
+ ok: true,
270
+ snapshot: {
271
+ flow: 'simple',
272
+ isProject: true,
273
+ initialized: true,
274
+ workspacePath,
275
+ rootPath,
276
+ version: '1.0.0',
277
+ project: buildProjectMetadata(workspacePath),
278
+ specs,
279
+ activeSpecs,
280
+ completedSpecs,
281
+ pendingSpecs: activeSpecs,
282
+ standards: [],
283
+ stats,
284
+ warnings,
285
+ generatedAt: new Date().toISOString()
286
+ }
287
+ };
288
+ }
289
+
290
+ module.exports = {
291
+ parseTaskChecklist,
292
+ parseSimpleDashboard
293
+ };