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/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 };
@@ -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 };