iikit-dashboard 1.0.0
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 +77 -0
- package/bin/iikit-dashboard.js +68 -0
- package/package.json +45 -0
- package/src/board.js +93 -0
- package/src/integrity.js +63 -0
- package/src/parser.js +768 -0
- package/src/pipeline.js +130 -0
- package/src/planview.js +195 -0
- package/src/public/index.html +3322 -0
- package/src/server.js +302 -0
- package/src/storymap.js +40 -0
package/src/server.js
ADDED
|
@@ -0,0 +1,302 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const express = require('express');
|
|
4
|
+
const http = require('http');
|
|
5
|
+
const { WebSocketServer } = require('ws');
|
|
6
|
+
const path = require('path');
|
|
7
|
+
const fs = require('fs');
|
|
8
|
+
const chokidar = require('chokidar');
|
|
9
|
+
const { parseSpecStories, parseTasks, parseConstitutionPrinciples } = require('./parser');
|
|
10
|
+
const { computeBoardState } = require('./board');
|
|
11
|
+
const { computeAssertionHash, checkIntegrity } = require('./integrity');
|
|
12
|
+
const { computePipelineState } = require('./pipeline');
|
|
13
|
+
const { computeStoryMapState } = require('./storymap');
|
|
14
|
+
const { computePlanViewState } = require('./planview');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* List features from specs/ directory.
|
|
18
|
+
* A feature is a directory under specs/ that contains tasks.md.
|
|
19
|
+
*/
|
|
20
|
+
function listFeatures(projectPath) {
|
|
21
|
+
const specsDir = path.join(projectPath, 'specs');
|
|
22
|
+
if (!fs.existsSync(specsDir)) return [];
|
|
23
|
+
|
|
24
|
+
const entries = fs.readdirSync(specsDir, { withFileTypes: true });
|
|
25
|
+
const features = [];
|
|
26
|
+
|
|
27
|
+
for (const entry of entries) {
|
|
28
|
+
if (!entry.isDirectory()) continue;
|
|
29
|
+
const featureDir = path.join(specsDir, entry.name);
|
|
30
|
+
const tasksPath = path.join(featureDir, 'tasks.md');
|
|
31
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
32
|
+
|
|
33
|
+
// Parse to get summary info
|
|
34
|
+
const specContent = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf-8') : '';
|
|
35
|
+
const tasksContent = fs.existsSync(tasksPath) ? fs.readFileSync(tasksPath, 'utf-8') : '';
|
|
36
|
+
const stories = parseSpecStories(specContent);
|
|
37
|
+
const tasks = parseTasks(tasksContent);
|
|
38
|
+
|
|
39
|
+
const checkedCount = tasks.filter(t => t.checked).length;
|
|
40
|
+
const totalCount = tasks.length;
|
|
41
|
+
|
|
42
|
+
// Convert id to human-readable name: "001-kanban-board" -> "Kanban Board"
|
|
43
|
+
const namePart = entry.name.replace(/^\d+-/, '');
|
|
44
|
+
const name = namePart.split('-').map(w => w.charAt(0).toUpperCase() + w.slice(1)).join(' ');
|
|
45
|
+
|
|
46
|
+
features.push({
|
|
47
|
+
id: entry.name,
|
|
48
|
+
name,
|
|
49
|
+
stories: stories.length,
|
|
50
|
+
progress: `${checkedCount}/${totalCount}`
|
|
51
|
+
});
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return features.reverse();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Get board state for a specific feature.
|
|
59
|
+
*/
|
|
60
|
+
function getBoardState(projectPath, featureId) {
|
|
61
|
+
const featureDir = path.join(projectPath, 'specs', featureId);
|
|
62
|
+
if (!fs.existsSync(featureDir)) return null;
|
|
63
|
+
|
|
64
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
65
|
+
const tasksPath = path.join(featureDir, 'tasks.md');
|
|
66
|
+
const testSpecsPath = path.join(featureDir, 'tests', 'test-specs.md');
|
|
67
|
+
const contextPath = path.join(featureDir, 'context.json');
|
|
68
|
+
|
|
69
|
+
const specContent = fs.existsSync(specPath) ? fs.readFileSync(specPath, 'utf-8') : '';
|
|
70
|
+
const tasksContent = fs.existsSync(tasksPath) ? fs.readFileSync(tasksPath, 'utf-8') : '';
|
|
71
|
+
|
|
72
|
+
const stories = parseSpecStories(specContent);
|
|
73
|
+
const tasks = parseTasks(tasksContent);
|
|
74
|
+
const board = computeBoardState(stories, tasks);
|
|
75
|
+
|
|
76
|
+
// Integrity check
|
|
77
|
+
let integrity = { status: 'missing', currentHash: null, storedHash: null };
|
|
78
|
+
if (fs.existsSync(testSpecsPath)) {
|
|
79
|
+
const testSpecsContent = fs.readFileSync(testSpecsPath, 'utf-8');
|
|
80
|
+
const currentHash = computeAssertionHash(testSpecsContent);
|
|
81
|
+
|
|
82
|
+
let storedHash = null;
|
|
83
|
+
if (fs.existsSync(contextPath)) {
|
|
84
|
+
try {
|
|
85
|
+
const context = JSON.parse(fs.readFileSync(contextPath, 'utf-8'));
|
|
86
|
+
storedHash = context?.testify?.assertion_hash || null;
|
|
87
|
+
} catch {
|
|
88
|
+
// malformed context.json
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
integrity = checkIntegrity(currentHash, storedHash);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
return { ...board, integrity };
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Create and configure the Express server with WebSocket support.
|
|
100
|
+
*
|
|
101
|
+
* @param {Object} options
|
|
102
|
+
* @param {string} options.projectPath - Path to the project directory
|
|
103
|
+
* @param {number} [options.port=3000] - Port to listen on (0 for random)
|
|
104
|
+
* @returns {Promise<{server: http.Server, port: number, wss: WebSocketServer}>}
|
|
105
|
+
*/
|
|
106
|
+
function createServer({ projectPath, port = 3000 }) {
|
|
107
|
+
const app = express();
|
|
108
|
+
|
|
109
|
+
// Serve static files from src/public
|
|
110
|
+
app.use(express.static(path.join(__dirname, 'public')));
|
|
111
|
+
|
|
112
|
+
// API: list features
|
|
113
|
+
app.get('/api/features', (req, res) => {
|
|
114
|
+
try {
|
|
115
|
+
const features = listFeatures(projectPath);
|
|
116
|
+
res.json(features);
|
|
117
|
+
} catch (err) {
|
|
118
|
+
res.status(500).json({ error: err.message });
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
// API: constitution data (project-level, not feature-specific)
|
|
123
|
+
app.get('/api/constitution', (req, res) => {
|
|
124
|
+
try {
|
|
125
|
+
const constitution = parseConstitutionPrinciples(projectPath);
|
|
126
|
+
res.json(constitution);
|
|
127
|
+
} catch (err) {
|
|
128
|
+
res.status(500).json({ error: err.message });
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
// API: pipeline state for a feature
|
|
133
|
+
app.get('/api/pipeline/:feature', (req, res) => {
|
|
134
|
+
try {
|
|
135
|
+
const featureDir = path.join(projectPath, 'specs', req.params.feature);
|
|
136
|
+
if (!fs.existsSync(featureDir)) {
|
|
137
|
+
return res.status(404).json({ error: 'Feature not found' });
|
|
138
|
+
}
|
|
139
|
+
const pipeline = computePipelineState(projectPath, req.params.feature);
|
|
140
|
+
res.json(pipeline);
|
|
141
|
+
} catch (err) {
|
|
142
|
+
res.status(500).json({ error: err.message });
|
|
143
|
+
}
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
// API: story map state for a feature
|
|
147
|
+
app.get('/api/storymap/:feature', (req, res) => {
|
|
148
|
+
try {
|
|
149
|
+
const featureDir = path.join(projectPath, 'specs', req.params.feature);
|
|
150
|
+
if (!fs.existsSync(featureDir)) {
|
|
151
|
+
return res.status(404).json({ error: 'Feature not found' });
|
|
152
|
+
}
|
|
153
|
+
const storymap = computeStoryMapState(projectPath, req.params.feature);
|
|
154
|
+
res.json(storymap);
|
|
155
|
+
} catch (err) {
|
|
156
|
+
res.status(500).json({ error: err.message });
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
// API: plan view state for a feature
|
|
161
|
+
app.get('/api/planview/:feature', async (req, res) => {
|
|
162
|
+
try {
|
|
163
|
+
const planview = await computePlanViewState(projectPath, req.params.feature);
|
|
164
|
+
res.json(planview);
|
|
165
|
+
} catch (err) {
|
|
166
|
+
res.status(500).json({ error: err.message });
|
|
167
|
+
}
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
// API: board state for a feature
|
|
171
|
+
app.get('/api/board/:feature', (req, res) => {
|
|
172
|
+
try {
|
|
173
|
+
const board = getBoardState(projectPath, req.params.feature);
|
|
174
|
+
if (!board) {
|
|
175
|
+
return res.status(404).json({ error: 'Feature not found' });
|
|
176
|
+
}
|
|
177
|
+
res.json(board);
|
|
178
|
+
} catch (err) {
|
|
179
|
+
res.status(500).json({ error: err.message });
|
|
180
|
+
}
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
const server = http.createServer(app);
|
|
184
|
+
const wss = new WebSocketServer({ server });
|
|
185
|
+
|
|
186
|
+
// Track connected clients and their current feature
|
|
187
|
+
wss.on('connection', (ws) => {
|
|
188
|
+
ws.currentFeature = null;
|
|
189
|
+
|
|
190
|
+
ws.on('message', (raw) => {
|
|
191
|
+
try {
|
|
192
|
+
const msg = JSON.parse(raw);
|
|
193
|
+
if (msg.type === 'subscribe' && msg.feature) {
|
|
194
|
+
ws.currentFeature = msg.feature;
|
|
195
|
+
// Send initial board state
|
|
196
|
+
const board = getBoardState(projectPath, msg.feature);
|
|
197
|
+
if (board) {
|
|
198
|
+
ws.send(JSON.stringify({ type: 'board_update', feature: msg.feature, board }));
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
} catch {
|
|
202
|
+
// ignore malformed messages
|
|
203
|
+
}
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// File watcher with 300ms debounce
|
|
208
|
+
let debounceTimer = null;
|
|
209
|
+
const constitutionPath = path.join(projectPath, 'CONSTITUTION.md');
|
|
210
|
+
const watchPaths = [projectPath];
|
|
211
|
+
|
|
212
|
+
let watcher = null;
|
|
213
|
+
if (watchPaths.length > 0) {
|
|
214
|
+
watcher = chokidar.watch(watchPaths, {
|
|
215
|
+
ignoreInitial: true,
|
|
216
|
+
ignored: ['**/node_modules/**', '**/.git/**'],
|
|
217
|
+
awaitWriteFinish: { stabilityThreshold: 200, pollInterval: 50 }
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
watcher.on('all', () => {
|
|
221
|
+
if (debounceTimer) clearTimeout(debounceTimer);
|
|
222
|
+
debounceTimer = setTimeout(async () => {
|
|
223
|
+
// Push updates to all connected clients
|
|
224
|
+
for (const ws of wss.clients) {
|
|
225
|
+
if (ws.readyState === 1 && ws.currentFeature) {
|
|
226
|
+
try {
|
|
227
|
+
const board = getBoardState(projectPath, ws.currentFeature);
|
|
228
|
+
if (board) {
|
|
229
|
+
ws.send(JSON.stringify({
|
|
230
|
+
type: 'board_update',
|
|
231
|
+
feature: ws.currentFeature,
|
|
232
|
+
board
|
|
233
|
+
}));
|
|
234
|
+
}
|
|
235
|
+
const pipeline = computePipelineState(projectPath, ws.currentFeature);
|
|
236
|
+
if (pipeline) {
|
|
237
|
+
ws.send(JSON.stringify({
|
|
238
|
+
type: 'pipeline_update',
|
|
239
|
+
feature: ws.currentFeature,
|
|
240
|
+
pipeline
|
|
241
|
+
}));
|
|
242
|
+
}
|
|
243
|
+
const storymap = computeStoryMapState(projectPath, ws.currentFeature);
|
|
244
|
+
if (storymap) {
|
|
245
|
+
ws.send(JSON.stringify({
|
|
246
|
+
type: 'storymap_update',
|
|
247
|
+
feature: ws.currentFeature,
|
|
248
|
+
storymap
|
|
249
|
+
}));
|
|
250
|
+
}
|
|
251
|
+
const planview = await computePlanViewState(projectPath, ws.currentFeature);
|
|
252
|
+
if (planview) {
|
|
253
|
+
ws.send(JSON.stringify({
|
|
254
|
+
type: 'planview_update',
|
|
255
|
+
feature: ws.currentFeature,
|
|
256
|
+
planview
|
|
257
|
+
}));
|
|
258
|
+
}
|
|
259
|
+
} catch {
|
|
260
|
+
// ignore errors during push
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// Also push constitution_update to ALL clients
|
|
266
|
+
try {
|
|
267
|
+
const constitution = parseConstitutionPrinciples(projectPath);
|
|
268
|
+
const constitutionMsg = JSON.stringify({ type: 'constitution_update', constitution });
|
|
269
|
+
for (const ws of wss.clients) {
|
|
270
|
+
if (ws.readyState === 1) {
|
|
271
|
+
ws.send(constitutionMsg);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
} catch {
|
|
275
|
+
// ignore
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
// Also push features_update
|
|
279
|
+
try {
|
|
280
|
+
const features = listFeatures(projectPath);
|
|
281
|
+
const msg = JSON.stringify({ type: 'features_update', features });
|
|
282
|
+
for (const ws of wss.clients) {
|
|
283
|
+
if (ws.readyState === 1) {
|
|
284
|
+
ws.send(msg);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
} catch {
|
|
288
|
+
// ignore
|
|
289
|
+
}
|
|
290
|
+
}, 300);
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return new Promise((resolve) => {
|
|
295
|
+
server.listen(port, () => {
|
|
296
|
+
const actualPort = server.address().port;
|
|
297
|
+
resolve({ server, port: actualPort, wss, watcher });
|
|
298
|
+
});
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
module.exports = { createServer, listFeatures, getBoardState };
|
package/src/storymap.js
ADDED
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { parseSpecStories, parseRequirements, parseSuccessCriteria, parseClarifications, parseStoryRequirementRefs } = require('./parser');
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Compute story map state for a feature by parsing spec.md.
|
|
9
|
+
*
|
|
10
|
+
* @param {string} projectPath - Path to the project root
|
|
11
|
+
* @param {string} featureId - Feature directory name
|
|
12
|
+
* @returns {{stories: Array, requirements: Array, successCriteria: Array, clarifications: Array, edges: Array}}
|
|
13
|
+
*/
|
|
14
|
+
function computeStoryMapState(projectPath, featureId) {
|
|
15
|
+
const featureDir = path.join(projectPath, 'specs', featureId);
|
|
16
|
+
const specPath = path.join(featureDir, 'spec.md');
|
|
17
|
+
|
|
18
|
+
if (!fs.existsSync(specPath)) {
|
|
19
|
+
return { stories: [], requirements: [], successCriteria: [], clarifications: [], edges: [] };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const content = fs.readFileSync(specPath, 'utf-8');
|
|
23
|
+
|
|
24
|
+
const rawStories = parseSpecStories(content);
|
|
25
|
+
const requirements = parseRequirements(content);
|
|
26
|
+
const successCriteria = parseSuccessCriteria(content);
|
|
27
|
+
const clarifications = parseClarifications(content);
|
|
28
|
+
const edges = parseStoryRequirementRefs(content);
|
|
29
|
+
|
|
30
|
+
// Add clarificationCount to each story (global count per FR-010)
|
|
31
|
+
const clarificationCount = clarifications.length;
|
|
32
|
+
const stories = rawStories.map(s => ({
|
|
33
|
+
...s,
|
|
34
|
+
clarificationCount
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
return { stories, requirements, successCriteria, clarifications, edges };
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
module.exports = { computeStoryMapState };
|