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.
- package/lib/dashboard/aidlc/parser.js +581 -0
- package/lib/dashboard/index.js +80 -46
- package/lib/dashboard/simple/parser.js +293 -0
- package/lib/dashboard/tui/app.js +601 -48
- package/lib/dashboard/tui/store.js +11 -0
- package/package.json +1 -1
package/lib/dashboard/index.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
80
|
+
return 'Unsupported flow.';
|
|
26
81
|
}
|
|
27
82
|
|
|
28
|
-
async function
|
|
83
|
+
async function runFlowDashboard(options, flow) {
|
|
29
84
|
const workspacePath = path.resolve(options.path || process.cwd());
|
|
30
|
-
const
|
|
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 () =>
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
165
|
+
runFlowDashboard,
|
|
132
166
|
parseRefreshMs,
|
|
133
|
-
|
|
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
|
+
};
|